*宇宙安全声明:本系列创作目的仅为个人(小白)读书记录,由于本人水平实在有限,欢迎大家指摘其中的错误,希望大家不吝赐教,多多包涵。
*顺序:系列将按照书籍目录顺序推进,会有选择的跳过部分内容;同时各篇文章也基本按照书籍章节进行划分,大多会直接借用原书题目。
第一部分 简介
第一章 计算机系统的基础概念
概述:分别从硬件部分和软件部分,回顾计算机系统的一些基本而又重要的概念。
1.2 计算机硬件体系结构与发展历史
1)三个最为关键的部件——中央处理器CPU、内存、I/O控制芯片
在早期的计算机中,由于CPU的核心频率与内存的频率一样,它们可以直接连接在同一个总线(Bus)上;而I/O设备的速度比前两者慢很多,为了协调I/O设备与总线之间的速度且使得CPU能和I/O设备进行通信,每个设备都会有一个相应的I/O控制器。
*)I/O设备指包括显示设备、键盘、软盘和磁盘等用于输入、输出的硬件配件。
后来CPU核心频率提升,内存跟不上CPU的速度,于是产生了与内存频率一致的系统总线,而CPU采用倍频的方式与系统总线进行通信。为了提高部件间数据交换的效率,人们设计出北桥芯片(Northbridge)和南桥芯片(Southbridge)分别处理高速设备和低速设备,同时在低速设备上采用ISA总线,各低速设备数据由南桥汇总后链接到北桥上,进入系统总线PCI,合称PCI/ISA。
再之后,为了满足人们对数据交换速度的需求,又出现了AGP、PCI Express等越来越复杂的硬件结构,然而实际上它还是没能脱离最初的CPU、内存、I/O的基本结构。
2)SMP与多核——处理器
在过去的50年里,CPU的频率从几十KHz到现在的4GHz,整整提高了数十万倍,基本上每18个月频率就会翻倍。但自2004年起,人们在制造CPU的工艺方面已经达到了物理极限,CPU的频率被目前的4GHz的“天花板”所限制,再也没有发生质的提高。
于是人们开始想办法从其他角度提高CPU的速度,比如对称多处理器(SMP,Symmetrical Multi-Processing)和多核处理器(Multi-core Processor):每个CPU在系统中所处的地位和发挥的功能都是一样的,相互对称的。不过虽然能很好的处理大量相互独立的请求,但是面对无法拆解成子问题的一个复杂问题时,并不能派上用场。就好比一个女人可以花10个月生出一个孩子,但是10个女人并不能在一个月就生出一个孩子一样。
1.3 计算机系统软件体系结构
1)软件体系结构
传统意义上一般将用于管理计算机本身的软件称为系统软件,以区别普通的应用程序。
系统软件可分成两类,一类是平台性的,比如操作系统内核、驱动程序、运行库和数以千计的系统工具;另一类是用于程序开发的,比如编译器、汇编器、链接器等开发工具和开发库。
计算机系统软件体系结构采用一种层的结构,有人说过一句名言:“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”
2)接口
每个层次之间需要相互通信,就必须有一个通信的协议,称为接口(Interface)。
接口的下层提供接口,由它定义接口;接口的上层通过接口来实现所需要的功能
1.4.1 CPU资源分配方式与发展历史
1)多道程序(排队式)
不分轻重缓急,当某个程序暂时无需使用CPU时,监控程序就把另外的正在等待CPU资源的程序启动。交互反应慢且易崩溃。
2)分时系统(礼让式)
每个程序运行一段时间以后都主动让出CPU给其他程序,使得一段时间内每个程序都有机会运行一小段时间。对于交互式的任务尤为重要。一个死循环整个电脑崩溃。
3)多任务系统(抢占式)
操作系统接管了所有的硬件资源,并且本身运行在一个受硬件保护的级别。所有的应用程序都以进程(Process)的方式运行在比操作系统权限更低的级别,每个进程都有自己独立的地址空间,使得进程之间的地址空间相互隔离。CPU由操作系统统一进行分配,每个进程根据进程优先级的高低都有机会得到CPU,但是运行时间超出了一定的时间,操作系统会暂停该进程,将CPU资源分配给其他等待运行的进程。这种CPU分配方式即所谓的抢占式(Preemptive),操作系统可以强制剥夺CPU资源并且分配给它认为目前最需要的进程。如果操作系统分配给每个进程的时间都很短,即CPU在多个进程间快速地切换,从而造成了很多进程同时运行的假象。
1.4.2设备驱动
1)硬件驱动(Device Driver)程序
为避免在软件开发时,受到不同硬件千差万别的操作方式和访问方式造成的麻烦,硬件逐渐被抽象成了一系列概念。这些繁琐的硬件细节全都交给了操作系统中的硬件驱动程序来完成。
2)硬盘
硬盘基本存储单位为扇区(Sector),每个扇区512字节。一个硬盘往往有多个盘片,每个盘片分两面,每面按照同心圆划分为若干个磁道,每个磁道划分为若干个扇区。现代的硬盘普遍使用一种叫做LBA(Logical Block Address)的方式,即整个硬盘中的所有扇区从0开始编号,一直到最后一个扇区,这个编号叫做逻辑扇区号。硬盘的电子设备会自动将扇区号转换成实际的盘面、磁道等位置。
1.5 内存
早期的计算机中,程序是直接运行在物理内存上的,也就是说,程序在运行时所访问的地址都是物理地址。然而,将计算机上有限的物理内存分配给多个程序使用时,会造成很多问题:
1)地址空间不隔离:防止不同程序相互影响;保证其中一个任务失败了,至少不会影响其他任务。
2)内存使用效率低:在需要临时运行程序时,可能会导致大量的数据在换入换出,致使效率十分低下。
3)程序运行的地址不确定:程序在编写时,它访问数据和指令跳跃时的目标地址很多都是固定的,而程序装入运行时,分配的空闲区域是不确定的,于是涉及程序的重定位问题。
解决上述问题的思路就是增加中间层,即使用一种间接的地址访问方法:把程序给出的地址看作是一种虚拟地址(Virtual Address),然后通过某些映射的方法,将这个虚拟地址转换成实际的物理地址。这样,只要我们能够妥善地控制这个虚拟地址到物理地址的映射过程,就可以保证任意一个程序所能够访问的物理内存区域跟另一个程序相互不重叠,以达到地址空间隔离的效果。
1)关于隔离
地址空间分两种:虚拟地址空间(Virtual Address Space)和物理地址空间(Physical Address Space)
物理地址空间是实实在在的,且一台计算机只有唯一的一个。物理地址真正有效部分的大小取决于内存大小,其他部分都是无效的。
虚拟地址空间是指虚拟的、想象出来的地址空间,并不真实存在,每个进程都有自己独立的虚拟空间,而且每个进程只能访问自己的地址空间,这样就有效的做到了进程的隔离。
2)分段(Segmentation)
最开始人们使用的是一种叫做分段(Segmentation)的方法,基本思路是把一段与程序所需要的内存空间大小的虚拟空间映射到某个地址空间。
例如:对于程序A所需要的10MB内存,假设有一个地址从0x00000000到0x00A00000的10MB大小的一个虚拟空间,然后我们从实际的物理内存中分配一个相同大小的物理地址,可能是0X00100000开始到0x00B00000结束的一块空间,将两个空间进行一一映射。
映射过程由软件来设置,比如操作系统来设置这个映射函数,实际的地址转换由硬件完成。
分段的方法基本解决了上面提到的三个问题中的第一个和第三个。通过将不同程序映射到不同的物理空间区域,使他们之间没有任何重叠,且一旦某个程序的访问要求超过了虚拟地址的范围,就会被硬件判断为非法的访问,进而拒绝并上报监控程序。此外,对于每个程序来说,他们只按照虚拟空间地址进行操作,不再需要关心物理空间地址的位置,从而不再需要重定位。
3)分页(Paging)
分页的基本方法是把地址空间人为地分成固定大小的页,每一页的大小由硬件决定,或硬件支持多种大小的页,由操作系统选择决定每页的大小。但是在同一时刻只能选择一种大小,所以对整个系统来说,页就是固定大小的。
我们把进程的虚拟地址空间按页分割,把常用的数据和代码装载到内存中,把不常用的代码和数据保存在磁盘里,或暂时处于未使用的状态(放在虚拟空间中),当需要用到的时候再把它从磁盘里取出来即可。在这里,我们把虚拟空间的页叫做虚拟页(VP,Virtual Page),把物理内存中的页叫做物理页(PP,Physical Page),把磁盘中的页叫做磁盘页(DP,Disk Page)
当进程需要用的代码不在内存中时,硬件会捕获这个消息,就是所谓的页错误(Page Fault),然后操作系统接管进程,负责将相应的页从磁盘中读出来并且装入内存。
页映射可以达到保护进程的目的,简单地说就是每个页可以设置权限属性,只有操作系统有权修改这些属性.
几乎所有的硬件都采用一个叫MMU(Memory Management Unit)。在页映射模式下,CPU发出的是Virtual Address,即我们的程序看到的是虚拟地址。经过MMU转换以后就变成了Physical Address。一般MMU都集成在CPU内部了,不会以独立的部件存在。
1.6多线程处理
1)线程概念
线程(Thread),有时又称为轻量级进程(Lightweight Process,LWP),是程序执行流的最小单位。一个标准的线程由线程ID,当前指令指针(PC)、寄存器集合和堆栈组成。
通常意义上,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段、数据段、堆等等)及一些进程级的资源(如打开文件和信号)。
2)多线程的好处
a.某个操作可能会陷入长时间等待,多线程执行可以有效利用等待时间,例如:等待网络响应,这可能要花费数秒甚至数十秒。
b.多线程可以边交互、边计算。
c.有些程序逻辑本身就要求并发操作。
d.单线程程序无法全面地发挥计算机的全部计算能力。
e.多线程在数据共享方面效率要高得多。
3)线程的访问权限
线程的访问非常自由,可以访问进程内存里所有的数据,甚至包括其他线程的堆栈(少数情况);同时,线程也拥有自己的私有储存空间,包括:栈、线程局部存储(Thread Local Storage,TLS)、寄存器(寄存器是执行流的基本数据,因此为线程私有)
4)线程调度与优先级
在单处理器对应多线程的情况下,操作系统会让这些多线程程序轮流来执行,每次仅执行一小段时间(毫秒级),这样每个线程就“看起来”在同时执行。这样的一个不断在处理器上切换不同线程的行为称之为线程调度(Thread Schedule)
线程调度中,线程通常拥有至少三种状态:
运行(Running)、就绪(Ready:可以运行,但CPU正在被占用)、等待(Waiting,等待某一事件发生,还不能执行)
处于运行中的线程拥有一段可以执行的时间,称之为时间片(Time Slice)
不断有新的线程调度方案和算法被提出,主流的调度方式尽管各不相同,但都带有优先级调度和轮换法的痕迹。
轮换法(Round Robin)即:各个线程轮流执行一小段时间,交错执行。
优先级调度即(Priority Schedule):具有更优先级的线程会更早执行,而低优先级的线程常常要等到系统中已经没有高优先级的可执行的线程存在时才能够执行。
Windows中可使用“BOOL WINAPI SetThreadPriority(HANDLE hThread ,int nPriority)”来设置优先级;
系统还会根据不同线程的表现自动调整优先级:通常情况下,频繁进入等待状态的线程(如:处理I/O的线程)比每次都把时间片耗完的线程(如:用于计算的线程)优先级更高,因为进入等待状态后,会放弃之后仍然可以占用的时间份额,导致可能更快结束。前者称为IO密集型线程(IO Bound Thread),后者称为CPU密集型线程(CPU Bound Thread)。
在优先级调度下,存在一种线程被饿死(Starvation)的现象,即该线程的优先级很低,在他前面总有更高优先级的线程需要处理,以至于该线程无法执行;为了避免饿死现象,调度系统常常会逐步提高那些等待了过长时间的得不到执行的线程的优先级。使得一个线程只要等待足够长的时间,其优先级一定会提高到它可以执行的程度。
5)可抢占线程和不可抢占线程
时间片用尽的线程会被强制剥夺继续执行的能力,轮换到其他线程执行,这个过程叫做抢占(Preemption)
不可抢占线程执行时,线程调度的时机是确定的,只会发生在线程主动放弃执行或进入等待状态时。这样就可以避免一些因为抢占式线程里调度时机不确定而产生的问题。尽管如此,非抢占式线程仍然少见。
6)Linux的多线程——任务(略)
Linux内核中并不存在真正意义上的线程概念。Linux将所有的执行实体(无论是进程还是线程)都称为任务(Task),每个任务类似于一个单线程的进程,不同的任务可以选择共享内存空间,因而实际上,共享了同一个内存空间的多个任务构成了一个进程。
创建一个新任务的方法,略。
7)线程安全
多线程程序在并发时数据的一致性非常重要(多个线程可能同时处理全局变量或堆区的同一个数据,导致结果不可预测)
我们把单指令的操作成为原子的(Atomic),无论如何,单条指令的执行是不会被打断的。不足之处是当我们需要保证一个复杂的数据结构更改的原子性,原子操作就力不从心了。
8)同步与锁
为了避免多个线程同时读写同一个数据而产生不可预料的后果,我们要将各个线程对同一个数据的访问同步(Synchronization),即在一个线程访问数据未结束时,其他线程不得对同一个数据进行访问。于是,对数据的访问就被原子化了。
同步的最常见方法是使用锁(Lock)。锁是一种非强制机制,每一个线程在访问数据或资源之前首先试图获取(Acquire)锁,并在结束访问后释放(Release)锁。在锁已被占用的时候试图获取锁,线程会进入等待状态,直到锁被释放后重新可用。
a.信号量(Semaphore)
术语:获取信号量、释放信号量
二元信号量(Binary Semaphore)是最简答的一种锁,只有占用和非占用两种状态,适合只能被唯一一个线程独占访问的资源。
对于允许多个线程并发访问的资源,多元信号量简称信号量是一个很好的选择:一个初始值为N的信号量允许N个线程并发访问,线程访问时,获取一个信号量,访问完后释放信号量,若信号量不足则进入等待状态。
b.互斥量(Mutex)
术语:加锁、解锁
与二元信号量类似,互斥量保证了资源仅允许被一个线程访问,但和信号量不同的是,信号量在整个系统中可以被任意线程获取并释放,即同一个信号量可以被系统中的一个线程获取后由另一个线程释放;而互斥量要求上锁的线程负责释放该锁。
c.临界区(Critical Section)
术语:进入临界区、离开临界区
包含两个操作原语:EnterCriticalSection() 进入临界区、LeaveCriticalSection() 离开临界区。通过EnterCriticalSection()进入临界区的线程,在访问完资源后必须通过LeaveCriticalSection()离开临界区,否则临界区保护的资源永远不会被释放。
d.读写锁(Read-Write Lock)
术语:共享的(Shared)或独占的(Excludive)
对于同一数据,允许多个进程同时读取,但只要有任何一个线程试图对这个数据进行修改,就必须使用同步手段来避免出错。当某个线程企图以独占的方式获取共享状态的锁时,需要等到该锁被所有正在共享访问的线程释放。对于读取频繁而仅仅偶尔写入的情况,更加高效。
e.条件变量(Condition Variable)
术语:等待(wait)、唤醒(signal)和广播(broadcast)
条件变量用于在多线程间进行通信和同步,允许多个线程等待某个特定条件成立。当事件发生时,所有等待该条件变量的线程都被唤醒,一起恢复执行。
9)可重入(Reentrant)与线程安全
一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该程序执行。
包括两种情况:a.多个线程同时执行这个函数 b.函数自身调用自身
一个函数被称为可重入的,表明该函数被重入之后不会产生任何不良后果。其必须具备以下条件:a.不使用(局部)静态或全局的非const变量。
b.不返回(局部)静态或全局的非const变量的指针。
c.仅依赖于调用方提供的参数。
d.不依赖任何单个资源的锁(mutex等)。
e.不调用任何不可重入的函数。
10)过度优化
随着编译器技术快速发展,很多为了提高效率的操作,使得数据并发再次出现问题。
a.问题一:线程一对数据x进行上锁、读取到寄存器中修改再解锁的操作,但由于后续可能仍会用到数据x,所以线程一暂时并未将修改后的x写回。此时,如果线程二也要对数据x进行修改的操作,就可能导致最后数据有误的问题。
此时可以用volatile关键字解决问题一:volatile可以阻止编译器为了提高速度将一个变量缓存到寄存器内而不写回。
b.问题二:编译器在进行优化的时候,可能为了效率而交换毫不相干的两条相邻指令的执行顺序,或是CPU动态调度换序,于是导致数据错误。
通常情况下,可以调用CPU提供的barrier指令(不同体系结构的CPU提供的barrier指令名称各不相同):该指令会阻止CPU将该指令之前的指令交换到barrier之后,反之亦然。
11)三种线程模型
线程包括内核线程(Kernel Thread,与Linux内核里的Kernel_Thread不是一回事)和用户线程(User Thread)。总的来说,内核线程主要用于实现操作系统的核心功能和处理系统级任务,而用户线程则主要用于应用程序级任务处理和提高程序性能。内核线程更加底层和系统级,用户线程更加高层和面向应用。两者在系统中共同协作,共同完成系统中的各种任务。
用户态多线程库的实现方式有三种:一对一、多对一、多对多。
a.一对一模型
一个用户使用的线程对应一个内核使用的线程。
优点:不同线程执行不会互相影响;可以使得多线程程序在多处理器的系统上有更好的表现。
缺点:用户的线程数量受限;内核线程调度时,上下文切换的开销大,使用户线程的执行效率低。
b.多对一模型
多个用户线程映射到一个内核线程上,线程之间的切换由用户态的代码来进行。
优点:高效的上下文切换;几乎无限制的线程数量。
缺点:一个用户线程阻塞,则所有的线程都无法执行。
c.多对多模型
将多个用户线程映射到少数但不止一个内核线程上。
优点:不同线程执行不会互相影响;高效的上下文切换;几乎无限制的线程数量。
缺点:在多处理器系统上,提升的幅度不如一对一模型高。