第一章 温故而知新
早期的计算机没有很复杂的图形功能,CPU的核心频率也不高,跟内存的频率一致,它们都直接连接在同一个总线上的。由于IO设备如显示设备、键盘、软盘和磁盘等速度与CPU相比还是慢很多,当时也没有复杂的图形设备,显示设备也是只能输出字符的终端,为了协调I/O设备与总线之间的速度,也为了CPU和I/O设备能够进行通信,一般每一个设备都会有一个相应的I/O控制器。
后来CPU核心频率提升,内存跟不上CPU速度,于是产生了内存频率一致的总线,而CPU采用倍频的方式与总线通信,接着由于图形化的操作系统普及,使得图形芯片需要和CPU与内存进行大量的数据交换,于是产生了高速的北桥芯片
。
由于北桥的运算速率非常高,所以如果低速的设备全都连接在北桥,北桥就需要同时处理低速的设备和高速的设备,设计会十分复杂。于是人们由设计了专门处理低速设备的南桥
。芯片、键盘、鼠标、USB等连接在南桥,由南桥汇总连接到北桥。
1.文件的读取
1.1 文件系统
文件系统管理着磁盘中文件的存储方式,比如在Linux系统下有一个文件“/home/user/test.dat”,长度为8000个字节,那么在创建这个文件的时候,Linux的ext3文件系统有可能将这个文件按照这样的方式存储在磁盘中:
前4096个字节存储在1000号扇区到1007号扇区,每个扇区512字节,8个扇区正好4096个字节;
文件的第4097个字节到8000个字节存储在磁盘的2000号扇区到2007号扇区,8个扇区也是4096个字节,只不过只存储了3904个有效的字节,剩下的192个字节无效。硬盘结构介绍
当我们在Linux中,要读取文件的前4096个字节时,会使用一个read的系统调用来实现。文件系统收到read请求后判断出文件前4096个字节在1000~1007个扇区,然后文件系统就向硬盘驱动发出一个读取逻辑扇区为1000号开始的8个扇区的请求,驱动程序收到这个请求之后向硬盘发出硬件命令。
向硬件发送I/O命令的方式有很多种,其中最为常见的一种就是通过读写I/O端口寄存器来实现。在x86平台上共有65536个硬件端口寄存器,不同的硬件被分配到了不同的I/O端口地址。CPU提供了两条专门的指令"in"和"out"实现对硬件端口的读和写。
对IDE接口来说,它有两个通道,分别为IDE0和IDE1,每个通道可以连接两个设备,分别为Master和Slave。一个PC中最多4个IDE设备。
1.2 内存问题
如何将计算机有限的物理内存分配给多个内存使用
有128M内存 三个程序:程序A运行需要10M,程序B运行需要100M,程序C运行需要20M。
如果我们同时运行A和B,比较直接的做法是将前10M分给A,第10到110M分给B。但这样分配问题很多:
-
地址空间不隔离 恶意程序很可能改写其他程序的内存数据
-
内存使用效率低 通常在程序执行时需要将整段程序装入内存执行,如果要运行其他程序内存空间可能不够,就需要将正在运行的程序读回磁盘,等到需要运行的时候再读入内存运行。
-
程序运行的地址不确定 程序每次装入运行的时候都需要给它从内存中分配一块足够大的空闲区域,这个空闲区域的位置是不确定的,但是在程序编写时它访问的数据和指令跳转时的目标地址都是确定的,这涉及到
程序的重定位问题
。-
分段(Segmentation)
将程序所需要的内存空间大小的虚拟空间映射到某个地址空间。
比如说一段程序运行需要10M空间,我们假想有一段10M的虚拟空间,从0x00000000到0x00A00000,然后从实际的内存中分配一个相同大小的物理地址0x00100000到0x00B00000,然后将两地址空间进行一一映射。即虚拟空间中的每个字节对应物理空间的每个字节。 分段的方法解决了‘地址空间不隔离’以及‘程序运行地址不确定’ 的问题。因为程序A和B被映射到了不同的物理空间区域,如果A访问虚拟空间的地址超出了范围,就会被硬件判断为非法的访问,拒绝这个地址请求。再者,对于每个程序来说,无论它们被分配到物理地址的哪一个区域,对于程序来说都是透明的,它们不关心物理地址的变化,只需要按照地址从0x00000000到0x00A00000来编写地址、放置变量,所以程序不再需要重定位。
但是分段美没有解决内存使用效率的问题。分段对内存区域的映射还是按照程序为单位。如果内存不足,被换入换出到磁盘的都是整个程序。事实上,按照程序的局部性原理,当一个程序在运行时,在某个时间段内,它只是频繁地用到了一小段程序,程序地很多数据其实在一个时间段内都是不会被用到地。于是人们很自然地想到了更小粒度地内存分割和映射地方法————分页(Paging).
-
分页(Paging)
分页的基本方法是将地址空间人为地分成固定大小的页。每一页的大小由硬件决定,或硬件支持多种大小的页,由操作系统选择决定页的大小。
我们将进程的虚拟地址空间按页分割,将常用的数据和代码页装在到内存中,不常用的数据和代码保存在磁盘里,当用到的时候在将它从磁盘中取出来。当进程需要的页不在内存中时,如进程需要VP2和VP3两个页,硬件会捕获这两个消息,就是所谓的页错误,然后操作系统接管进程,负责将VP2和VP3从进程中读出来装入内存,然后将内存中的这两个页与VP2和VP3之间建立映射关系。
虚拟空间的页叫虚拟页(VP,Virtual Page),物理内存中的页叫物理页(PP,Physical Page),磁盘中的页叫磁盘页(DP,Disk Page)。虚拟空间中的有些页,或不同进程的页可能回被映射到同一个物理页,这样就可以实现内存共享。
虚拟存储的实现需要硬件的支持,对于不同的CPU来说是不同的。但是几乎所有的硬件都采用一个叫做MMU(Memory Management Unit)的部件来进行页映射。
在页模式下,CPU发出的是Virtual Address,即我们的程序看到的是虚拟地址,经过MMU转换后变成了Physical Address.
一般MMU集成在CPU内部了,不会以独立的部件存在。
-
2.线程
线程,有时被称作轻量级进程(Lightweight Process,LWP),是程序执行流的最小单元。
一个标准的线程由线程ID、当前指令指针PC、寄存器集合、和堆栈组成。通常意义上讲,一个进程由一个到多个线程组成,各个线程之间共享程序的内存空间(包括代码段、数据段、堆等)及一些进程级的资源(如打开文件和信号)。
-
线程的访问非常自由,它可以访问进程内存里的所有数据(如果知道其他线程的堆栈地址,甚至也可以访问),但在实际应用中线程也有自己私有的存储空间。
线程私有 线程之间共享(进程所有) · 局部变量
· 函数的参数
· TLS数据(线程局部存储,Thread Local Storage,TLS)· 全局变量
· 堆上的数据
· 函数里的静态变量
· 程序代码,任何线程都有权利读取并执行任何代码
· 打开的文件,A线程打开的文件可以由B线程读写
2.1 线程调度与优先级
在计算机中,线程总是“并发”执行的,当线程数量小于处理器数量时,不同的线程运行在不同的处理器上,彼此之间毫不相干;但是线程数大于处理器数量时,线程并发会碰到阻碍,这时会有处理器运行多个线程。
在单处理器运行多线程时,并发是模拟出来的一种状态,操作系统会让这些多线程程序轮流执行,每次这个线程只执行一小段时间(通常只有几十到几百毫秒),模拟出线程的并发状态。这样的一个不断在处理器上切换线程的行为称之为线程调度(Thread Schedule).
在线程调度中,线程通常有三种状态:运行(Running)、就绪(Ready)、等待(Waiting).
运行中的线程有一段可以执行的时间,这段时间成为时间片(Time Slice).
时间片用尽,线程进入就绪状态。
时间片用尽之前线程在等待某个事件,线程进入等待状态。
每当一个线程离开运行状态,调度系统就会选择一个其他的就绪线程继续运行。
在具有优先级调度的系统中,线程具有各自的线程优先级。如在Windows和Linux中,系统会根据不同线程的表现自动调整优先级,以使得线程调度更有效率。
IO密集型线程:频繁等待的线程
CPU密集型线程:很少等待的线程
-
Linux的多线程
Windows对进程和线程的实现如同教科书一般标准,Windows内核有明确的线程和进程的概念。在Windows API中,有明确的API来创建进程和线程,并由一系列的API来操纵它们。但对Linux来说,线程并不是一个通用的概念。
Linux内核中并不存在真正意义上的线程概念。在Linux中,所有的执行实体,无论是进程还是线程都被称为任务(Task),相当于一个单线程的进程,具有内存空间,执行实体,文件资源等。不过,Linux下不同的任务之间可以选择共享内存空间,因而在实际意义上,共享了同一个内存空间的多个任务构成了一个进程,这些任务也就构成了进程的线程。
- 写时复制:两个任务可以同时自由地读取内存,但任意一个任务试图对内存进行修改时,内存就会复制一份提供给修改方单独使用,以免影响其他任务使用。
2.2 线程安全
代码中很多语句在编译为汇编代码之后不止一条指令,如自增操作++i
的实现:
(1)读取i到某个寄存器X,
(2)X++,
(3)将X的内容存储回i。
因此,很可能语句++i在执行一半的时候被调度系统打断去执行别的代码。如同时有两个线程对i操作,由于寄存器X的内容在不同的线程中是不一样的,所以可能会导致意想不到的后果。
我们把单指令的操作称为原子的(Atomic),单挑指令的执行是不会被打断的。
-
同步与锁
为了避免多个线程同时读写同一个数据而产生不可预料的后果,我们要将各个线程对同一个数据的访问同步(Synchronization).
同步:一个线程访问数据未结束时,其他线程不得对该数据进行访问,如此,数据的访问被原子化了。同步最常见的作法是使用锁(Lock)。
二元信号量(Binary Semaphore):一种最简单的锁。它只有两种状态:占用/非占用 | 适合只能被唯一一个线程独占访问的资源。占用期间,其他线程无法访问。信号量(Semaphore,或多元信号量),允许多个线程并发访问资源,一个初始值为N的信号量允许N个线程并发访问。信号量在整个系统中可以被任意系统获取并释放,即:同一个信号量可以由系统中的一个线程获取后再由另外一个线程释放
互斥量(Mutex),类似二元信号量,仅同时允许一个线程访问。和信号量不同的是,互斥量要求哪个线程获取了互斥量,哪个线程就要负责释放这个锁。
临界区(Critical Section),比互斥量更加严格的同步手段。互斥量和信号量在系统中的任何进程都是可见的,即:一个进程创建了一个互斥量或信号量,另外一个进程试图获取这个锁是合法的。但是临界区的作用仅限于本进程,其他进程无法获取该锁。
读写锁(Read-Write Lock),使用与数据读取特别频繁,但是写入特别少的情况。 | 读写锁有两种获取方式:共享的/独占的(Shared/Exclusive).如果锁处于自由状态,可以以共享或独占方式获取;如果锁处于共享状态,其他线程只能以共享方式获取锁。
-
多线程内部情况:
大多数操作系统都在内核里提供线程的支持。内核线程(与Linux内核中的kernel_thread不是一回事)由多处理器或调度实现并发,然而实际用户使用的线程并不是内核线程,而是存在于用户态的用户线程。在操作系统内核中,用户线程数量并不一定与内核线程相等。例如某些轻量级的线程库,对于用户来说如果由三个线程在同时执行,对于内核来说可能只有一个线程。
- 一对一模型:一个用户使用的线程唯一对应一个内核使用的线程。(但反过来不一定,一个内核线程在用户态不一定由对应的线程存在)
- 多对一模型:将多个用户线程映射到一个内核线程。
- 多对多模型:将多个用户线程映射到少数但不止一个内核线程。