计算机硬件结构
站在软件开发者的角度看,一个计算机多如牛毛的硬件设备中,有三个部分最关键,分别是中央处理器CPU,内存和I/O控制芯片。
早期的计算机没有很复杂的图形功能,CPU核心的频率也不高,和内存的频率一样,它们都是直接链接在同一个总线(Bus)上的,为了协调I/O设备与总线之间的速度,也为了能够让CPU能够和I/O设备进行通信,一般每个设备都会有一个相应的I/O控制器。
后来CPU的核心频率提升,导致内存跟不上CPU的速度,于是产生了与内存频率一致的系统总线,而CPU采用倍频的方式与系统总线进行通信。随着3D游戏和多媒体的发展,使得图形芯片需要跟CPU和内存之间大量交换数据,慢速的I/O总线已经无法满足图形设备的巨大需求。为了协调CPU,内存和高速的图形设备,人们专门设计了一个高速的北桥芯片,以便它们能够高速的交换数据。
北桥的运行速度非常高,所有相对低速的设备如果都直接链接在北桥上,北桥又要处理高速设备,又要处理低速设备,设计就会十分复杂。于是人们又设计了专门处理低速设备的南桥芯片,磁盘,USB,键盘,鼠标等设备都链接在南桥上,由南桥将它们汇总后链接到北桥上。
虽然硬件结构看似越来越复杂,但实际上它还是没有脱离最初的CPU,内存以及I/O设备的基本结构。
SMP与多核
过去50年里,CPU的频率从几十kHz提高了数十万倍,但是近期的CPU频率再也没有发生质的飞跃,这是因为人们在制造CPU工艺方面已经达到了物理极限。
于是人们开始增加CPU的数量,其中最常见的就是多对称处理器(SMP),简单的说就是每个CPU在系统中所处的地位和所发挥的功能都是一样的,是相互对称的。
很多情况下,多处理器是非常有用的,最常见的情况就是在大型的数据库,网络服务器上,它们需要同时处理大量的请求,而这些请求之间往往也是相互独立的,所以多处理器就可以发挥很大威力。
现在的计算机都是将多个处理器“合并在一起打包”,这些被打包的处理器之间共享比较昂贵的缓存部件,只保留多个核心。
软件系统
系统软件可以分成两块,一块是平台的,比如操作系统内核,驱动程序,运行库以及数以万计的系统工具;另一块是用于程序开发的,比如编译器,汇编器,连接器等开发工具和开发库。
计算机系统软件体系都是按照层次的结构组织和设计的。每个层次之间都需要相互通信,既然要通信就需要有一个通信的协议,一般将其称为接口。除了硬件层和应用程序,其他都是所谓的中间层。正是这种中间层的存在使得硬件和应用程序之间保持相互的独立。
虚拟机技术就是在硬件和操作系统之间增加了一层虚拟层,使得一个计算机上可以运行多个操作系统。
在我们的软件体系中位于最上层的是应用程序,比如我们平时用到的浏览器,Email客户端,多媒体播放器等。从整体的结构层次上来看,开发工具与应用程序是属于同一个层次的,因为它们都使用一个接口,那就是操作系统应用程序编程接口API,应用程序接口的提供者是运行库。
操作系统内核层对于硬件层来说是硬件接口的使用者,而硬件是接口的定义者,硬件的接口定义决定了操作系统内核,具体来说就是驱动程序如何操作硬件,如何与硬件进行通信。
操作系统做什么
操作系统的一个功能是提供抽象的接口,另外一个主要功能是管理硬件资源。
多道程序
计算机发展早期,CPU资源十分昂贵,如果一个CPU只能运行一个程序,那么当程序读写磁盘时,CPU就空闲下来了,人们便写了一个监控程序,当某个程序暂时无须使用CPU时,监控程序就把另外的正在等待CPU资源的程序启动,使得CPU能够充分的利用起来。这种被称为多道程序。
这种方式很原始,但是它的确大大提高了CPU的利用率,它的缺点也很明显,就是调度策略太粗糙,程序之间不分轻重缓急,如果有些程序急需使用CPU来完成一些任务,那么有可能很长时间后才有机会分配到CPU,有可能造成的后果就是点一下鼠标十分钟之后才有反应。
分时系统
经过不断改进,程序的运行模式变成了一种协作的模式,每个程序运行一段时间之后都会主动让出CPU给其他程序,使得一段时间内,每个程序都有机会运行一小段时间,这对于交互式的任务尤为重要,比如点击一下鼠标,或者点击一下键盘,程序所要做的任务可能并不多,但是它需要尽快被处理,这种程序的模式叫做分时系统。Mac OS X之前的Mac OS版本都是采用这种分时系统的方式来调度程序的。
但是如果一个程序在进行一个很耗时的计算,一直霸占CPU不放,那么操作系统也没办法,其他程序也只能等着,整个系统看上去就好像死机了一样。这种分时系统只能勉强应付一下当时的交互式环境。
多任务系统
在多任务系统中,操作系统接管了所有的硬件资源,并且本身运行在一个受硬件保护的级别。所有的应用程序都以进程的方式运行在比操作系统权限更低的级别,每个进程都有自己独立的地址空间,使得进程之间的地址空间相互隔离。CPU由操作系统统一进行分配,每个进程根据进程的优先级的高低都有机会得到CPU,如果运行时间超出了一定时间,操作系统会暂停该进程,将CPU资源分配给其他等待运行的进程。这种CPU的分配方式即为抢占式,操作系统可以强制剥夺CPU资源并且分配给它认为最需要的进程,如果操作系统给每个进程分配的时间都很短,即CPU在每个进程之间快速的切换,从而造成了很多进程都在同时运行的假象。目前所有现代的操作系统都在采用这种方式。
设备驱动
操作系统作为硬件的上层,它是对硬件的管理和抽象,对于操作系统上面运行的应用程序来说,它们希望看到一个统一的硬件访问模式。作为应用的开发者,我们不希望在开发应用程序的时候直接读写硬件端口,处理硬件中断这些事情。
成熟的操作系统出现以后,硬件逐渐被抽象成了一种概念,在UNIX中,硬件设备的访问形式和访问普通的文件形式一样。在Windows中,图形硬件被抽象成了GDI,声音和多媒体设备被抽象成了DirectX对象,磁盘被抽象成了普通文件系统。
这些繁琐的硬件细节全部都交给了操作系统,具体的说是操作系统中的硬件驱动程序来完成,驱动程序可以看作是操作系统的一部分,它往往跟操作系统内核一起运行在特权级。
硬盘
硬盘的基本存储单位为扇区(sector),每个扇区一般为512字节,一个硬盘往往有多个盘片,每个盘片分两面,每面按照同心圆划分为若干个磁道,每个磁道划分为若干个扇区。
我们读取文件或者写入文件时,操作系统的文件系统就会向硬盘驱动发出一个请求,比如向第1000号扇区开始的连续8个扇区的请求,磁盘驱动收到这个请求以后就向硬盘发出硬件命令。最常见的就是通过读写I/O端口寄存器来实现。
硬盘收到命令以后就会将数据读取到事先设置好的内存地址中。
内存不够怎么办?
应用程序都是以进程的方式运行在内存中,进程的总体目标是希望每个进程从逻辑上看来都可以独占计算机的资源。操作系统的多任务功能使得CPU能够在多个进程之间很好的共享,从进程的角度看好像是它独占了CPU而不用考虑与其他进程分享CPU。
唯一剩下的问题就是内存的分配问题了,在早期的计算机中,程序是直接运行在物理内存上的,也就是说程序在运行时所访问的地址都是物理地址。如果一个计算机同时只能运行一个程序这么做显然是没问题的,但是为了更有效的利用硬件资源,我们必须同时运行多个程序。那么如何将有限的物理内存分配给多个程序使用。
直接使用物理内存的弊端:
1.地址空间不隔离,所有程序都直接访问物理地址,程序所用的内存空间不是相互隔离的。恶意的程序甚至可以改写其他程序的内存数据。
2.内存使用效率低,没有有效的内存管理机制,要运行一个程序时,监控程序就会将整个程序装入内存中然后执行,如果这时我们需要突然运行其他程序,但是内存空间不足,就只能将其他程序暂时写入到磁盘中,等到用的时候再读回来,这个过程涉及大量的数据换入换出,效率十分低下。
3.程序运行的地址不确定,程序每次需要装入运行时,我们都需要给它从内存中分配一块足够大的空闲区域,这个空闲区域的位置是不确定的。这给程序的编写造成了困难,因为程序在编写时,它访问数据和指令跳转时的目标地址很多都是固定的,这涉及程序重定位问题。
解决以上问题的思路就是增加一个中间层,使用一种间接的地址访问。我们把程序给出的地址看作是一种虚拟地址,然后通过某些映射方法,将虚拟地址转换成实际的物理地址。只要我们能够妥善的控制这个虚拟地址到物理地址的映射过程,就可以保证任意一个程序所访问的物理内存和另一个程序不重叠达到地址空间隔离的目的。
关于隔离
一个普通的程序,它所需要的是一个简单的运行环境,有一个单一的地址空间,有自己的CPU,好像整个程序占有整个计算机一样,无须关心其他程序。
所谓的地址空间是个抽象的概念,可以把它想象为一个很大的数组,每个元素是一个字节,而这个数组的大小由地址空间的长度决定,32位操作系统的地址空间大小为2^32字节,也就是4GB。地址空间分为两种:虚拟地址空间和物理地址空间。
物理地址是真正存在的,我们可以把它想象为物理内存,32位机器下物理空间就是4GB,但是如果我们的计算机只装有512MB的内存,那么其实真正有效的物理地址也就只有0x00000000~0x1FFFFFFF,其他部分都是无效的。
虚拟地址是虚拟的,人为想象出来的,它并不存在,每个进程都有自己独立的虚拟空间,而且每个进程只能访问自己的地址空间,这样就有效的做到了进程的隔离。
分段(segmentation)
最开始人们使用了一种分段的方式,把一段与程序所需要的内存空间大小的虚拟空间映射到某个地址空间。假设有一段10MB大小的虚拟空间,然后我们再从实际的物理内存中分配一段10MB的空间,将这两块相同大小的地址空间一一映射。虚拟空间中的每个字节对应于物理空间中的每个字节,这个映射过程由软件来控制,实际的地址转换由硬件完成,我们访问虚拟地址时,CPU会将这个地址转换为实际的物理地址。
这种方式解决了直接访问物理地址的1,3弊端,但是还没有解决第二点。分段对内存的映射还是按照程序为单位,如果内存不足,还是要对程序进行换入换出,造成大量的磁盘访问操作。
其实一个程序在运行时,在某个时间内,它只是频繁的用到了一小部分数据,也就是说程序的很多数据其实在一个时间段内都是不会被用到的。从而有了更小粒度的内存分割和映射的方法。这种方法就是分页。
分页
分页的基本方法是把地址空间人为的等分成固定大小的页,每一页的大小由硬件决定。
当我们把进程的虚拟地址空间按页分割时,把常用的数据和代码页装载到内存中,把不常用的代码和数据保存在磁盘里,需要用到的时候再从磁盘中读出即可。
如果进程需要使用的页不在内存中,硬件会捕获到这个消息,也就是所谓的“页错误”,然后操作系统接管进程,将所需的页从磁盘中读出并且装入内存。
有时我们甚至可以将不同的虚拟页映射到同一个物理页上,这样便可以实现资源共享,比如一些多进程的程序,如PC端的QQ,浏览器等。
虚拟存储的实现需要依靠硬件的支持,几乎所有的硬件都采用一个叫MMU的部件进行页的映射。
在页映射的模式下,CPU发出的是虚拟地址,即我们程序看到的都是虚拟地址,经过MMU转换,变成了物理地址,MMU内置在CPU中。
多线程
多线程作为软件并发执行的一个重要方法,占据着很重要的地位。
线程是程序执行的最小单元,一个标准的线程由线程ID,当前指令指针,寄存器集合和堆栈组成。通常意义上一个进程由一个或者多个线程组成,各个线程之间共享程序的内存空间(包括代码段,数据段,堆)以及一些进程级的资源(如打开文件和信号)。
大部分软件中,多个线程可以互不干扰的并发的执行,并共享进程的全局变量和堆的数据。
线程总是“并发”执行的,当线程数量小于处理器数量时,线程的并发是真正的并发,不同的线程运行在不同的处理器上,彼此互不相干。但是如果线程的数量如果大于处理器数量,线程的并发会收到一定的阻碍,至少会有一个处理器运行多个线程。
在单处理器对应多线程的情况下,并发是一种模拟出来的状态。操作系统会让这些多线程轮流执行,每次仅执行一小段时间(通常是几十到几百毫秒),这样每个线程就“看起来”在同时执行。这样的一个不断在处理器上切换不同线程的行为称之为线程调度。
线程通常拥有三种状态:运行,就绪,等待。
处于运行中线程拥有一段可以执行的时间,这段时间称之为时间片。当时间片用尽的时候,该进程就进入就绪状态。如果在时间片用尽之间就开始等待某事件(I/O或同步)发生,无法执行,那么它将进入等待状态,等待的事件发生后,该线程将进入就绪状态。
线程调度方案不断的在更新,但都带有优先级调度和轮转法的痕迹。所谓轮转法就是让各个线程轮流执行一小段时间但方法。这决定了线程之间交错执行但特点。而优先级调度则决定了线程按照什么顺序轮流执行。
在具有优先级调度但系统中,线程都拥有各自但优先级,具有高优先级但线程会更早的执行,第优先级的线程通常要等到系统中已经没有高优先级的可执行的线程存在时才能执行。
线程的优先级不仅可以由用户手动设置,系统还会根据不同线程的表现自动调整优先级,通常情况下频繁进入等待的线程比频繁进入大量计算,以至于每次都要把时间片全部用尽的线程要受欢迎的多。道理很简单,频繁等待的线程通常只占用很少的时间。
频繁等待的线程称之为IO密集型线程,很少等待的线程称为CPU密集型线程。当一个CPU密集型线程获得较高的优先级时,许多低优先级的线程就有可能饿死。为了避免饿死现象的发生,调度系统常常会逐步提升那些等待时间过长得不到执行的线程的优先级。
线程的抢占
线程的调度有一个特点,就是线程的时间片用尽之后会被强制剥夺继续执行的权利,而进入就绪状态,这个过程叫做抢占。在一些早期系统中,线程是不可抢占的,线程必须手动发出一个放弃执行的命令,才能让其他的线程得到执行。如果线程始终拒绝进入就绪状态,并且也不进行任何等待操作,那么其他的线程将永远无法执行。
不可抢占的线程还是有一些优点,就是线程必须主动放弃某事件,这样可以避免一些因为抢占式线程里调度时机不确定而产生的问题(线程安全)。尽管如此,非抢占线程在现在也已经非常少见。
线程安全
多线程程序处在一个多变的环境中,可访问的全局变量和堆数据随时都可能被其他的线程改变。因此多线程程序在并发时数据的一致性变得非常重要。
多个线程同时访问一个共享数据,可能造成恶劣的后果。
在我们的高级语言中比如一些赋值语句可能就只有一句
i = 1;
++i;
看起来这个语句执行完毕之后无论如何都会等于2,但是如果有其他线程在执行--i,那么结果就完全不可控。
++i这一句代码在被编译为汇编代码之后不止一条指令,因此在执行的时候可能执行了一半就被调度系统打断,如执行别的代码。我们可以设置其为原子的(atomic),这样其指令的执行就不会被打断。
但是设置为原子性只适用于比较简单特定的场合。在复杂的场合下,要保证一个复杂数据结构的一致性,就需要使用锁。
同步与锁
为了避免多个线程同时读写同一个数据而产生不可预料的后果,我们要将各个线程对同一个数据的访问同步。所谓同步就是在一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问。
同步的最常见方法是使用锁。锁是一种非强制机制,每一个线程在访问数据或资源之前首先试图获取锁,并在访问结束之后释放锁。在锁已经被占用的时候试图获取锁时,线程会等待,直到锁重新可用。
二元信号量(binary semaphore)
最简单的一种锁,它只有两种状态,占用与非占用,它适合只能被唯一一个线程独占访问的资源。当它处于非占用状态时,第一个试图获取二元信号量的线程会获得该锁,并将二元信号量重置为占用状态,此后其他的所有线程试图获取二元信号量的线程将会等待,直到该锁被释放。
互斥量(Mutex)
资源仅仅同时允许一个线程访问,它和信号量不同的是,信号量在整个系统可以被任意线程获取并且释放,而mutex则要求哪个线程获取了,哪个线程就要负责释放。
多线程内部情况
线程的并发执行是由多处理器或操作系统调度来实现的。但实际情况要更为复杂,大多数操作系统都在内核里提供线程的支持,由多处理器或调度实现并发。
然而用户实际使用的线程并不是内核线程,而是存在于用户态的用户线程。用户态线程并不一定在操作系统内核里对应同等数量的内核线程,比如对用户来说可能有三个线程在同时执行,对内核来说只有一个线程。
一对一模型
一个用户使用的线程就唯一对应一个内核使用的线程(但反过来不一定)。线程之间的并发是真正的并发。
多对一模型
将多个用户线程映射到一个内核线程上,线程之间的切换由用户代码控制,而不是系统,切换起来快速许多,但是如果一个用户线程阻塞那么所有的线程就无法执行。
多对多模型
将多个用户线程映射到少数但不止一个内核线程上。不会因为一个线程的阻塞而影响所有,用户线程创建的数量也不受限制。