《程序员的自我修养》读书笔记--第一章
《程序员的自我修养》读书笔记
第一章 温故而知新
1.1 作者提出了一些问题:
- 程序为什么编译之后才可以运行?
- 编译器把C语言变成可执行文件的过程中做了什么?怎么做的?
- 编译出来的可执行文件是什么?
- #include <stdio.h>是什么意思?C语言库是什么?如何实现?
- 不同的操作系统、硬件平台、编译器,最终编译出来的结果相同吗?
- 程序是如何运行起来的?
- 如果没有操作系统,hello world可以运行吗?如何实现?
- printf如何实现的?为什么有不定参数?它为什么能够在终端输出字符串?
- Hello world在运行时,内存是什么样子的?
希望看完这本书之后,能回答这些问题。
1.2 万变不离其宗 -- 计算机的基础
1.2.1 计算机的发展
(1) 计算机关键的三个部件: 中央处理器CPU、内存、I/O控制芯片
(2) 最早期的计算机没有复杂的图形功能,CPU的频率也不高,跟内存频率一样,因此都直接连在同一根总线上。
(3) 随着CPU核心频率的提升,内存跟不上CPU的速度,因此产生了与内存频率一致的系统总线,而CPU采用倍频的方式与系统总线通信。
(4) 随着图形化操作系统的普及,图形芯片需要跟CPU和内存之间频繁通信,慢速的I/O总线不能满足巨大的需求,于是北桥芯片被设计出来,以便更加告诉的交换数据。
(5) 由于北桥计算速度非常高,所有相对低速的设备如果都全部链接在北桥上,它既要处理高速设备,又要处理低俗设备,设计就会十分复杂。因此南桥芯片又被设计出来,键盘鼠标等低速设备都连接在南桥上,汇总之后再连接到北桥上。
1.2.2 CPU的发展
CPU的工艺已经到达了物理极限,短期内不会突破“4GHz”的极限。因此,人们开始想办法增加CPU的数量,这就是多处理器。多处理器在处理大型的数据库、网络服务器的并发请求上能发挥最大的作用,因为这些请求之间往往是相互独立的。
1.3 计算机软件
计算机系统软件采用一种层结构,即每层往上层提供接口来进行通信,定一个通信协议。 大致有这几层: 硬件(硬件规格) à 操作系统内核(系统调用) à 运行时库(操作系统API) à 应用(浏览器、播放器等)
1.4 操作系统做什么
操作系统有两个作用: (1)为应用提供抽象的接口 (2)管理硬件资源
1.4.1 不让CPU打盹 – 充分利用CPU
多道程序 --> 分时系统 --> 多任务系统
1.4.2 设备驱动
(1)操作系统通过硬件驱动将硬件抽象成接口,供程序员调用。驱动程序可以看做是操作系统的一部分,它往往跟操作系统内核一起运行在特权级,但它又与操作系统内核之间有一定的独立性,使得驱动程序有比较好的灵活性。
(2)硬盘的结构:基本存储单位为扇区,每个扇区一般为512字节。一个硬盘往往有多个盘片,每个盘片分为两面,每面按照同心圆划分为若干磁道,每个磁道划分为若干扇区。现代的硬盘普遍使用一种叫做LBA(Logical Block Address)的方式,给我们屏蔽了复杂的盘面、磁道之类的概念,只需要关心逻辑的扇区号。
1.5 内存不够怎么办
如何将计算机有限的物理内存分配给多个程序使用?假如计算机有128M内存,A程序需要10M,B程序需要100M。如果同时运行A和B,最简单的办法就是将计算机的0 – 10M分给A,10 – 110M 分给B。但是这样会产生很多问题:
- l地址空间不隔离。如果越界访问可能会修改别的程序的数据。
- 内存使用效率低。如果再有C程序需要20M内存,就需要把其它程序的数据暂时写到磁盘中,用的时候再读回来。大量数据换入换出,导致效率十分低下。
- 程序运行的地址不确定。每次装入运行都需要从内存中分配一块足够大的空闲区域,位置不确定。
1.5.1 关于隔离
解决上述问题的思路:增加中间层,使用一种间接访问地址的方法。
把程序给出的地址看做是一种虚拟地址,然后通过某种映射,将这个虚拟地址转化到实际的物理地址。这样,只需要控制好映射过程,就能保证程序所能访问的物理内存区域跟别的程序不重叠,达到空间隔离的效果。
每个进程都有自己独立的虚拟空间,而且每个进程只能访问自己的地址空间,这样就做到了进程的隔离。
1.5.2 分段
分段就是:把程序所需要的内存大小的虚拟空间映射到某个地址空间。比如A需要10M,就假设有0x0000 0000 到 0x00A0 0000大小的虚拟空间,然后从物理内存分配一个相同大小的空间,比如是0x0010 0000到0x00B0 0000。操作系统来设置这个映射函数,实际的地址转换由硬件完成。如果越界,硬件就会判断这是一个非法访问,拒绝这个地址请求,并上报操作系统或监控程序。
分段解决了地址隔离的问题,但没有解决内存使用效率的问题。
1.5.3 分页
分页:把地址空间认为的等分为固定大小的页,每一页的大小由硬件决定,或硬件支持多种大小的页,由操作系统决定页的大小。目前几乎所有的PC上都使用4KB大小的页。
虚拟空间的几个页有可能被映射到同一物理内存内存页,这样就可以实现内存共享。
保护也是页映射的目的之一,可以给页设置权限属性,而只有操作系统有权限修改这些属性,那么操作系统就可以保护自己和保护进程。
虚拟存储的实现需要硬件的支持,几乎所有的硬件都使用MMU(Memory Management Unit)来进行页映射。我们的程序看到的是虚拟内存,经过MMU转换后变成物理地址。一般MMU集成在CPU内部,不会以独立的部件存在。
1.6 众人拾柴火焰高
1.6.1 线程基础
线程:有时候被称为轻量级进程,是程序执行流的最小单元。一个标准的线程由线程ID,当前指令指针(PC)、寄存器集合和堆栈组成的。通常一个进程由多个线程组成,各线程共享内存空间及一些进程级的资源。
使用多线程的原因:
- 某个操作可能陷入长时间等待,这时候线程会进入睡眠状态,无法继续执行。多线程可有效利用等待时间。典型的例子是等待网络响应。
- 某个操作会消耗大量的时间,如果只有一个线程,程序和用户之间的交互会中断。多线程可以让一个线程进行交互,另一个线程处理任务。
- 程序本身需要进行并发操作,如多端下载。
- 多CPU或多核计算机,本身就具有同时执行多线程的能力。单线程无法充分利用计算机的全部计算能力。
- 相对于多进程应用,多线程在数据共享方面效率要高很多。
线程的访问权限:
实际运用中线程也拥有自己的存储空间,包括以下几方面:
(1) 栈(并非完全无法被其它线程访问,一般情况下认为是私有数据)
(2) 线程局部存储。操作系统为线程单独提供的私有空间,通常只有很有限的容量。
(3) 寄存器(包括PC寄存器),寄存器是执行流的基本数据,因此为线程私有。
程序在线程中是否私有,如下图所示。
其中,TLS指的是安全传输协议
线程调度与优先级
线程通常拥有至少三种状态:运行、就绪(可立刻运行,但CPU被占用了)、等待(正等待某一事件发生,通常是I/O或同步,无法执行)
处于运行中的线程拥有一段可以执行的时间,成为时间片,时间片用尽之后,线程进入就绪状态,如果在时间片用尽之前就开始等待,则进入等待状态。每当一个线程离开运行状态时,调度系统就会选一个其它的就绪状态的线程来运行,处于等待状态的线程等待结束后,进入就绪状态。
每个线程都自带优先级,用户可以手动设置,操作系统还会根据线程的不同表现调整优先级,使得调度更有效率。
通常情况下,频繁进入等待状态的线程比频繁进入大量计算、以至于每次都将时间片用尽的线程更受欢迎。前者被称为I/O密集型线程,后者称为CPU密集型线程。I/O密集型线程更容易得到优先级提升。
饿死现象:一个线程的优先级较低,每次试图执行时总有优先级较高的线程先执行,导致该线程一直无法被执行。如果CPU密集型线程获得较高优先级时,容易造成线程饿死。为了避免饿死现象,调度系统通常会逐步提高那些等待了过长时间的线程。在这样的手段下,只要一个线程等待足够长的时间,就一定会被执行。
可抢占线程和不可抢占线程
抢占:线程用尽时间片之后会强制剥夺执行权利,进入就绪状态。即别的线程抢占了当前线程。
早期的线程是不可抢占的,线程必须手动发送放弃命令,才能让其它线程得到执行。
1.6.2 线程安全
一、竞争与原子操作
这里有个很典型的问题,线程A执行i= 1;++i;操作,线程B执行--i;操作。在许多体系结构上,++i的实现方法如下:
(1) 将i值读到某个寄存器X上
(2) X++
(3) 将X的值存储会i
加入A和B并发执行,有可能A线程执行完第一步,B线程开始执行,B线程执行完的i值为0,A线程继续执行,最终结果为i=1.(这个问题都很多种可能,每个步骤顺序不同都会产生不同的结果)
很明显,自增操作在多线程情况下出错是因为,它被汇编之后不止一条指令,因此有可能执行一半就被操作系统打断。我们把单指令的操作称为原子的。
原子操作能保证操作不会出问题,但只能适用比较简单的场景,对于复杂的场景,原子操作就力不从心了。这时候,我们就需要用“锁”。
同步与锁
1、 二元信号量:是最简单的一种锁,只有两种状态,占用与非占用。适用于只能被唯一一个线程独占访问的资源。
2、 信号量:允许多个线程并发访问,一个初始值为N的信号量允许N个线程访问。
3、 互斥量:类似于二元信号量,只允许一个线程访问。但不同的是,信号量可以被系统中的任意线程获取并释放(可以A线程获取,B线程释放)。而互斥量要求哪个线程获取了互斥量,哪个线程就要负责释放。
4、 临界区:比互斥量更加严格的同步手段。临界区和互斥量、信号量的区别是:互斥量和信号量在系统的任何进程都是可见的,也就是说,一个进程创建了一个互斥量或信号量,另一个进程试图去获取是合法的。而临界区的作用范围仅限于本进程,其它进程无法获取该锁。
5、 读写锁:读写锁有两种获取方式:共享和独占。当锁是自由的,无论哪种方式获取都能成功。当锁是共享的,可以立刻以共享方式再次获取,但如果是独占请求,则需要等待锁被释放。当锁是独占的,不论以哪种方式再次获取都不成功,要等待当前线程释放。
6、 条件变量:对应条件变量,线程有两种操作:(1)等待条件变量(一个条件变量可以被多个线程等待)。(2)唤醒条件变量。也就是说:使用条件变量可以让多个线程一起等待某件事的发生,当事件发生时(条件变量被唤醒),所以线程一起恢复执行。
可重入与线程安全
一个函数被重入,表明函数没有执行完,由于外部原因或内部调用,再次进入执行。
一个函数被重入,只有两种情况:
(1) 多个线程同时执行该函数。
(2) 函数自身调用自身。
一个函数可重入,表明该函数被重入之后无任何不良后果。例如:
int sqr(int x)
{
return x * x;
}
可重入函数的几个特点:
(1) 不使用任何(局部)静态或全局非const变量。
(2) 不返回任何(局部)静态或全局非const变量。
(3) 仅依赖于调用方法提供的参数。
(4) 不依赖任何单个资源的锁(mutex等)
(5) 不调用任何不可重入的函数。
可重入是并发安全的强力保证,可在多线程环境下放心使用。
过度优化
CPU或系统为提高效率,有可能改变指令顺序,导致我们得不到期待的结果。这时候,我们就可以使用volatile关键字来防止过度优化。它可以做到两件事:
(1) 阻止编译器为提高速度将一个变量缓存到寄存器而不写会。
(2) 阻止编译器调整volatile变量的指令顺序。
但volatile只能阻止编译器调整顺序,而不能阻止CPU动态调整。
阻止CPU换序是必须的,但现在尚无可移植的阻止换序的方法,通常情况下是调用CPU的一条指令barrier。
1.6.3 多线程内部情况
三种线程模型
1、 一对一模型:一个用户使用的线程就唯一对应一个内核使用的线程(反过来不一定,内核线程在用户态不一定有对应的存在)。该模型的优点是:线程之间的并发是真正的并发,一个线程因为某些原因阻塞时,其它线程的执行不会受到影响。一对一模型也可以让多线程程序在多处理器上有更好的表现。
缺点:(1)由于许多操作系统限制了内核线程的数量,因此一对一线程会让用户的线程数量受到限制。(2)许多操作系统内核线程调度时,上下文切换的开销较大,导致用户线程的执行效率下降。
2、 多对一模型:将多个用户态线程映射到一个内核态线程上,线程之间的切换由用户态的代码来进行,因此相对于一对一,该模型切换要快速许多。优势是高效的上下文切换和几乎无限制的线程数量。
多对一模型的一个问题是:如果一个用户线程阻塞,那么所有的线程都无法执行,因为此时内核里的线程也随之阻塞了。
3、 多对多模型:将多个用户线程映射到少数但不止一个内核线程上。在这种模式下,一个用户态线程的阻塞不会使得所有用户态线程阻塞,因为此时还有别的线程被调度执行。另外,用户线程数量也没什么限制,在多处理系统上,多对多模型的线程也能得到一定性能的提升,不过提升的幅度没有一对一多。