很早就听过这本书的介绍,每次想静下心来研读的时候总会被一些琐事打断。这段时间比较空闲,正好把这把咀嚼一下。
这本书详细描述了Windows和Linux操作系统各自的可执行文件、目标文件格式。
- C/C++代码如何被编译成目标文件及程序在目标文件中文件存储。
- 目标文件如何被链接器链接到一起,并且形成可执行文件的。
- 目标文件在链接时符号处理、重定位及地址分配如何进行
- 可执行文件如何被装载并且执行
- .....
- 什么是堆,什么是栈
- ......
下面开始探索之旅吧!
操作系统基础回顾
温故而知新,可以为师
下图为全文的导图
组成部分
计算机三个最为重要的部件:中央处理器CPU、内存、I/O控制芯片。
早起计算机因为核心频率不高所以设备都是连在同一条总线上,并且每个设备都还有一个响应的I/O控制器。
后来核心频率提升,内存跟不上CPU的速度,于是产生了与内存频率一致的系统总线,CPU采用倍频的方式与系统总线通信。再到后来出现了北桥芯片(协调高速芯片),南桥芯片(协调低速设备)
北桥芯片:协调CPU、内存、高速的图形设备,以便高速的交换数据
南桥芯片:协调磁盘、USB、键盘、鼠标等慢速设备
上图可见,位于中间的是连接所有高速芯片的北桥(PCI),他的左边是CPU,右边是内存。
系统软件可以分为两块,一块是平台性的,比如操作系统内核,驱动程序,运行库和数以千计的系统工具;另一块是用于程序开发的,比如编译器、汇编器、链接器、开发库和开发工具。这里主要介绍的是链接器及库相关的内容。
无论是在计算机软件体系还是硬件体系有一句至理名言:计算机科学领域的任何问题都可以通过一个间接的中间层来解决。基本上这菊花概括了所有的设计要点。(想想我们平时所用的什么架构、模式都是这个道理)
可以把这种方式叫做层次体系,相邻的层次通过定义接口实现通讯,一般是下面那层是接口的提供者,上层使用接口(对应到软件开发中也就是面向接口编程,这套在Java中的Spring框架体现得淋漓尽致)。接口都是被精心设计过的,尽量保证稳定不变,这样对上层屏蔽了具体实现,任何一层次能都可以被修改或者被替换。
整体体系结构依赖关系:上次应用层程序——》操作系统应用程序编程接口——》运行库——》系统调用接口(以软件终端的方式)——》硬件接口
- 运行库API:Linux下的GLibc库提供的POSIX的API,Window运行库提供Windows API,比如Win32
- 系统调用接口:系统调用接口在实现中以一般软件中断的方式提供,Linux中以0x80号中断作为系统调用接口,Winodows以0x2E作为系统调用接口
硬件的接口定义决定操作系统内核,确定驱动程序如何操作硬件,如何与硬件通信,这种接口叫做硬件规格,硬件厂商负责提供硬件规格,操作系统及驱动程序的开发者通过阅读硬件规格,文档来编写。
操作系统主要是提供抽象的接口以及管理硬件资源(大学的时候老师讲过)。
CPU进化过程:CPU只能运行一个程序——》多道程序(监控CPU是否空闲)——》分时系统(每个程序都有机会运行一小段时间,任何一个死循环都可能导致死机)——》多任务系统(操作系统管理硬件资源,运行在硬件保护的级别。)
多任务系统中所有应用程序都以进程的方式运作,但是比操作系统的权限更低,每个进程有自己独立的地址空间,进程之前的地址空间相互隔离。CPU由操作系统统一分配,根据进程优先级调度,如果进程占用CPU太久的时间则会被暂停,进而分配给其他等待运行的进程。分配给每个进程的时间都很短,CPU在多个进程间快速切换,造成了多个进程同时运行的假象。
内存、分段、分页(这个很重要!)
早期计算机中,程序是直接运行在物理内存的。比如计算机128M,程序A10M,程序B100M,程序C20M。那么最直接的就是将内存的前10M给A,10M-110M给B。但是这种方式有很多问题:
- 地址空间不隔离(非常危险):直接访问物理地址,恶意程序很容易改写其他程序的内存数据;除此之外如果程序有bug,同样会出现不小心改了其他程序的数据现象。这样就非常不安全,稳定
- 内存使用率低:比如上面例子想要运行C,这个时候内存空间已经不足了,这个时候需要把其中部分数据写到磁盘上,等到要用的时候在读回来。由于程序空间是连续的,将A写到磁盘还是不够用还需要将B程序到磁盘。中间有大量数据的换入换出
- 程序运行地址不确定:程序每次载入运行的时候,需要从内测中分配一块足够大的空间,但是这个无内置是不确定会给编写程序带来麻烦。因为编写程序的时候,访问数据、指令的目标地址很多是固定的,需要重定位。
以上几个问题通过增加了一个中间层解决,也就是间接访问地址。将程序给出的地址看做是虚拟地址,然后通过映射关系,将虚拟地址转换为物理地址。只要能够管理好映射过程就能保证程序之间的内存区域不会重叠,达到地址空间隔离的效果。——虚拟内存
分段用来解决前面提到的地址空间不隔离和程序运行地址不确定。基本思路是把一段与程序所需要的内存空间大小的虚拟空间映射到某个地址空间,比如A程序10M,0X00000000到0X00A00000的10M虚拟空间,然后物理地址分配同样大小的区域,比如0x00100000到0x00B00000。然后将这两块相同大小的地址做空间一一映射,操作系统提供这个映射函数,实际由硬件转换,后面会提到具体是由MMU(内存管理单元)实现。
比如A中访问0x00001000,CPU会将这个二地址转换为实际物理地址的0X00101000.
异常情况处理:虽然分段做到了地址隔离,A和B没有任何重叠,但是如果A访问虚拟地址空间超过了0x00A00000范围,那么硬件就会判断这是一个非法访问,拒绝这地址请求,并将这个请求报告给操作系统或监控程序,让它们进行下一步处理,一般就是产生异常。
分段也做到了程序不需要重定位,对程序而言不需要关系物理地址变化,只需要安装从地址0X00000000到0X00A00000l来编写程序。但是还是没有解决内存使用效率的问题。如果内存不足还是会造成很大的换入换出(以程序为单位,粒度还是太大)
根据程序局部性原理,当程序运行时,某个时间段内,它只会频繁的使用到一小部分数据,也就是说很多数据其实并不会被使用到,于是粒度更小的内存分隔和映射方法孕育而生,那就是分页。
分页是把地址空间人为的分成固定大小的页,页大小范围由硬件决定,其次由操作系统最终确定页大小。
举个例子:
把进程的虚拟空间地址以页分隔,数据和代码也装载到内存,不常用的放到磁盘保存,需要的时候在从磁盘中读取处理。默认情况下虚拟也大小为4k。
- 有两个进程Process1、Process2
- Process1中虚拟页VP0、VP1、VP7映射到物理页PP0、PP2、PP3
- Process2中虚拟页VP0、VP2、VP3、VP7映射到物理页PP5、PP0、PP1、PP3
- 虚拟VP2、VP3页面保存在磁盘页中的DP0、DP1,并不在内存中,当进程需要这两个也的时候,硬件会捕获这个信息,就是所谓的页错误,然后操作系统接管进程,负责将VP2、VP3从磁盘中载入到内存。
- 可以看到到物理页中的PP0、PP3在进程Process1、Process2都有虚拟页映射到其中。这样就实现了内存共享
虚拟内存实现需要依赖硬件支持,不同CPU处理方式不同,但是所有的硬件都采用MMU(内存管理单元)部件来进行页映射管理。MMU将CPU发出的虚拟地址转换为物理地址。
多线程
线程被称为轻量级进程,程序执行流的最小单元。线程由线程ID,当前指令指针,寄存器和堆栈组成。进程由多个线程组成,各个线程之间共享内存空间(代码段、数据段、堆)及进程级别的资源(打开文件和信号)。
进程、线程关系图:
线程访问非常自由,可以访问进程内存中的所有数据,甚至包含其他线程转给你的堆栈(如果知道其他线程的堆栈地址,但是很少见),线程也拥有自己的私有存储空间。
- 栈:虽然不是被其他线程完全无法访问,但是一般可以认为是私有数据
- 线程局部存储(TLS):操作系统为线程单独提供的私有空间,但是很有限
- 寄存器:包括PC,寄存器是执行流的基本数据,为线程私有。
可以总结如下表
线程总是并发
的执行,线程数小于等于处理器数量,线程的并发才是真真的并发(同一时刻,多个进行),不同线程运行在不同的处理器上;当大于处理器数量,此时会出现一个处理器运行多个线程的情况。
单处理器情况下,多线程并发是一种模拟出来的状态,操作系统让多线程轮流执行,每次仅仅执行很小一段时间,这称作线程调度。
线程调度至少有三个状态:
- 运行:线程正在执行。
- 就绪:线程可以立刻运行,但CPU被其他线程占用。
- 等待:线程在等待某一个事件(比如:I/O或者同步)发生,无法执行。
运行状态下的线程执行时间叫做时间片,
- 时间片用尽了就进入就绪状态;——用尽
- 如果时间片用尽之前线程就开始等待某事件,则会就进入等待状态;——事件
- 线程离开运行态,操作系统就会调度其他就绪的线程执行;
- 在等待状态的线程等待的事件发生之后,线程就进入就绪状态;
状态切换如下图:
主流的调度策略虽然不同但是都有优先级调度及轮转法。
- 轮转法:让各个线程轮流执行一小段时间的方法,线程之间交错执行
- 优先级调度:确定线程按照什么顺序轮流执行难,线程都有自己的优先级,具有高优先级的会更早的执行,低优先级的需要等待没有高优先级线程存在的时候才执行。比如Linux中就是通过pthread来实现,iOS中也是
线程可以只定义优先级,系统也会根据线程执行状态改变线程的优先级。频繁进入等待状态的线程(处理I/O)比频繁进行大量计算(每次把时间片用尽的线程)有更多的机会执行,因为频繁等待的线程只占用很少的时间片,CPU喜欢先执行简单的。
如果一个线程一直都得不到执行,这就是饿死现象。优先级较低,总有较高优先级在占用CPU,为了避免这种现象,调度系统会逐步提升等待时间过长却得不到机会执行的线程优先级。
优先级改变触发条件:
- 用户指定优先级
- 根据线程进入等待状态的频率提升或降低优先级
- 长时间得不到执行而被提升优先级
抢占:线程在用尽时间片之后被强制剥夺执行的权利进入就绪状态,这个过程叫做抢占,这样的线程就是抢占线程。目前基本所有的线程都是抢占式的。
不可抢占线程:也就是线程不可被抢占,只有线程主动发出放弃执行的命令,主动进入就绪态,而不靠时间片才会空出当前占用的资源。
不可抢占线程触动放弃情况:
- 线程视图等待某事件
- 线程主动放弃时间片
虽然不可抢占线程可以避免一些因为抢占线程里调度时机不确定而产生的线程安全问题,但是现在非抢占式线程很少。
Linux多线程
Linux内核中并不存在真正的线程概念,Linux将所有的执行实体(线程还是进程都一样)都称为任务,每一个任务都类似于一个单线程的进程,具有内存空间,执行实体,文件资源等。
进程:不同任务之间可以选择共享内存空间,共享同一个内存空间的多任务构成一个进程,这些任务也就是这个进程里面的线程。
Linux创建一个新的任务的方式:
fork函数产生一个和当前进程完全一样的新进程,fork产生的新任务速度非常快,因为不会复制原任务的内存空间,而是共享一个写时复制的内存空间。
写时复制就是指两个任务可以同时自由得读取内存,但任意一个任务要对内存修改,内存就会赋值一份给修改方使用。fork只能产生本任务的镜像,所以需要用exec才能启动新的任务。
线程安全(开发中经常遇到的)
多线程程序中,可访问的全局变量及堆数据随时都可能被其他线程改变,由此产生了的线程安全。
线程安全的根本原因是同时写一个共享数据,每个线程都有自己的寄存器。
计算机中单条指令在执行的时候不会被打断,所以在执行单条指令的时候不会存在线程安全问题,称为具有原子性;常见的自增++操作,因为自增++操作会被汇编为多条指令,所以在执行自增的时候可能被打断,去执行其他代码,不具有原子性,就有可能造成线程安全的问题
一个例子:
其中的i为共享变量。
在计算机中会按照下面的步骤
- 读取i到某个寄存器X(每个线程有自己的寄存器)
- X++
- 将X的内容返回给i(也就是从寄存器取值然后送入内存)
由于1、2线程是并发执行,隐藏坑出现如下的执行序列(每个线程有自己独立寄存器)
如果按照正常逻辑来看i的值应该为1。但是通过上面的执行顺序最后的结果是0,所以出现了问题。实际上可能会是0或者1或者2。i的最终值取决于最终是从哪个线程中赋值的,也就是最终是由哪个线程的寄存器回写到内存中的。
同步与锁
所谓同步就是指在一个线程访问数据未结束的时候其他线程不能对同一数据访问。最常见的同步方式就是使用锁,分为加锁和解锁过程,在加锁之后其他线程需要等待解锁之后才能访问资源。
其次还有二元信号量,也即是占有和非占有两个状态,如果允许对个线程并发访问资源,多远信号量简称信号量。给定一个初始值为N的信号量则允许N个线程并发访问。具体来讲:
- 信号量值减去1
- 如果信号量值小于0则进入等待状态,否则继续执行。访问完资源之后,线程释放信号量
- 将信号量加1
- 如果信号量的值大于1,则唤醒一个等待中的线程
互斥量和二元信号量类似,资源同一时刻只能一个线程访问,和信号量不同的是,信号量在整个系统可以被任意线程获取并释放,而互斥量只能在哪个线程获取的,也就只能在获取的那个线程释放,跨线程释放是无效的。——任意线程
临界区是比互斥量更加严格的同步方式,临界区与信号量大而区别在于,互斥量和幸好量在系统的任何进程里都是可见的,也即是一个进程创建了互斥量或信号量,其他进程视图去获取该锁是合法的,临界区作用范围仅仅限于本进程,其他进程无法获取该锁,除此之外临界区和互斥量具有相同的性质。——任意进程
条件变量同样是一个同步的手段,作用类似于栅栏。线程对条件变量有两种操作方式,一种是条件变量可以被多个线程等待;另一种是线程可以唤醒条件变量,此时所有等待此条件变量的线程都会被唤醒。也就是条件变量可以让许多线程一起等待某个事件的发生。当事件发生时(条件变量被唤醒),线程继续执行——栅栏、多个线程
函数被重入表示这个函数没有执行完成,又进入 了该函数的执行。在多线程同时执行这个函数或者函数自身调用自己就会出现重入现象。如果函数被重入之后不会产生任何不良后果函数被称为具有可重入性。
比如:
类似这样的函数,没有使用任何局部静态或全局的变量、没有依赖调用方提供的参数、不调用任何不可重入的函数,是线程安全的。
在开发中,有时候即使使用了锁也不一定能保证线程安全。这是因为落后的编译技术无法满足增长的并发需求。这样会导致很多看似不合理的情况!
比如:
从代码上来讲有了lock和unlock的包含,x++的是原子性的,那么值似乎必然是2,但是如果编译器为了提高x的访问速度,把x放到某个寄存器里,我们知道不同线程的寄存器是独立的,因此Thread1先获得锁,则程序执行可能会出现如下情况
可见这样并不能达到线程安全,原因就是在R1++之后没有立即回写到内存中,而是缓存在寄存器中,过了一段时间才回写到内存中。**
另一个例子
逻辑上讲r1、r2至少有一个为1,不可能同时为0。但是这种同时为0的情况确实存在。因为CPU有动态调度的特性,在执行程序的时候为了提高效率有可能交换指令的顺序,同样编译器在优化的时候也可能为了效率而交换毫不相干的相邻指令如x=1、r1=y的执行顺序。
上面的代码可能就变成了:
这个时候就可能出现r1=r2=0,关键词volatile(C语言中)可以阻止这种过度优化。它可以实现两件事:
- 阻止编译器为了提高速度将一个变量缓存在寄存器内不回写到内存中。
- 阻止编译器调整操作volatile变量的指令顺序。
CPU动态调度换序
CPU动态调用换序也算是过度优化的范畴。
源代码:
从逻辑上讲是没有问题的,函数返回时,Pinst总是指向一个有效的对象,同时也加了锁。其实有问题,主要是因为CPU乱序执行,C++中的new包含了两个步骤
- 分配内存
- 调用构造函数
那么Pinst = new T其实包含了三个步骤
- 分配内存
- 调用构造函数
- 将内存地址复制给PInst
其中2和3顺序可以颠倒。产生的问题就是这个时候PInst虽然不是NULL,但还没构造完成,这个时候如果另一个线程滴啊用了GetInstance,那么就会直接返回PInst,但是这个地址是并没有构造完全的对象地址。如果后面使用了这个没有被构造完成的毒性地址,那么会发生异常现象
所以需要阻止CPU换序,但是并不存在这样的方法。而是通过一条 指令解决,指令叫做barrier,barrier会阻止CPU将之前的指令交换到barrier之后,相当于一个栅栏。
改进后的代码如下
通过barrier保证了对象构造一定是在barrier之前完成的,那么PInst被复制的时候对象是完整的。
这种情况什么时候会出现,目前我也没遇到过类似的场景。
多线程内部
这里涉及到内核线程与用户线,一些轻量级的线程,对用户而言如果有三个线程同时执行,对内核而言可能就只有一个线程。
一对一的线程模型最为简单,一个用户线程对应一个内核线程(但是内核态的线程不一定在用户太有对应的线程)。这样方式实现了真真的并发,一个线程阻塞不会应影响其他线程。但是也有缺点:
- 许多操作系统限制了内核线程的数量,一次一对一的线程会让用户线程受到限制
- 许多操作系统内核线程调度时,上下文切换开销比较大,导致用户线程的执行效率下降
除了一对一还有多对一的线程模型
多对一将多个用户线程映射到一个内核线程上,线程之间由用户态的代码来惊醒,因此对弈一对一的线程模型而言,多对一在线程切换的时候就快很多。多对一的问题:
- 最大问题就是一旦其中一个用户线程被阻塞了,那么所有的线程都无法继续执行,因此内核里面的线程也被阻塞;最大的好处就是线程的上下文切换和无限制的线程数量
多对多的线程模型结合了一对一,多对一的特点。将多个用户线程映射到不知一个内核线程上。
一个线程阻塞并不会影响其他线程,而且也没有用户线程数量的限制。
小结
第一篇就到这里了,这一节主要是复习操作系统层面的知识。其中比较难(现在也没弄明白)就是CPU的动态调度。不知道有没有大咖懂的。
扩展阅读
下面这几篇都讲得比较深入,比如关于虚拟地址、进程、线程等。可以看一看!
Linux虚拟地址空间布局
Linux 中的各种栈:进程栈 线程栈 内核栈 中断栈
Linux下Fork与Exec使用
Linux虚拟地址空间布局以及进程栈和线程栈总结