第2章 进程与线程
2.1 进程
2.1.1 进程模型
一个进程就是一个正在执行程序的实例,包括程序计数器、寄存器和变量的当前值。理解进程的另一个角度是,用某种方法把相关的资源集中在一起。进程有存放程序正文和数据以及其它资源的地址空间,包括打开的文件、子进程、报警、信号处理程序、账号信息等。
每个进程都认为它独占CPU,这是通过快速切换进程来实现的,这种切换称作多道程序设计。
2.1.2 创建进程
Unix中只有fork一种系统调用来创建进程,它会创建一个与调用进程相同的副本。fork后,父子进程拥有相同的存储映像、同样的环境变量和同样的打开文件。
子进程可以执行exec系统调用,运行一个新的程序。父进程与子进程有不同的地址空间,其中一个进程对地址空间的修改对另一个进程不可见。
2.1.3 终止进程
进程终止的条件:
1. 自愿退出,包括正常退出和出错退出。Unix中用exit系统调用退出进程。
2. 非自愿退出,包括严重错误或被其他进程杀死。进程引起的错误通常会产生信号导致进程终止。Unix中的kill系统调用用来杀死其他进程。
2.1.4 进程的层次结构
Unix中进程和它的所有后裔共同组成一个进程组。Windows中没有进程层次的概念。
2.1.5 进程的状态
进程有四种状态:
1. 运行态:实际占用CPU。
2. 就绪态:可运行,但因其他进程正在运行而暂停。
3. 阻塞态:逻辑上不能继续运行,等待外部事件。
4. 终止态:不能切换到前3种状态,等待回收。
运行态和就绪态间的切换是由进程调度程序引起的。
2.1.6 进程的实现
操作系统维护一个结构数组,即进程表,每个进程占用一个表项,表项中包含进程的状态:用于进程管理的状态、用于存储管理的状态、用于文件管理的状态,这些是进程由运行态转换到就绪态或阻塞态时必须保存的信息,用来保证进程随后能再次启动。
2.2 线程
2.2.1 线程的使用
为什么要有线程?
1. 通过将应用程序分解成可以准并行运行的多个顺序线程,程序设计模型会变得更简单。
2. 多个线程可以共享同一个地址空间和所有可用数据,这是多进程模型无法表达的。
3. 线程比进程更轻量级,创建、撤消和切换都更快。
4. 如果同时存在大量的计算和大量的I/O处理,多线程允许这些活动重叠进行,性能更好。
5. 多CPU系统中,同时可以有多个线程并行运行,这种系统中多线程能充分利用系统性能。
2.2.2 线程模型
线程中有程序计数器、寄存器、堆栈。进程用于把资源集中到一起,线程则是在CPU上被调度的实体。
同一个进程中并行运行多个线程,是对在同一台计算机上并行运行多个进程的模拟。线程有时也被称为轻量级进程。
所有的线程都有相同的地址空间,还共享打开文件集合、子进程、报警及相关信号。线程可以操纵其他线程的堆栈,线程间是没有保护的,因为:1)不可能;2)没必要,不同的线程都属于同一个用户,彼此间不可能有恶意。
所有线程都是平等的,没有层次关系。但主线程有特殊性:从它的启动函数(main函数)返回会导致进程结束。
2.2.3 Posix线程
所有Pthread都含有一个标识符、一组寄存器和一组存储在结构中的属性,包括堆栈大小、调度参数等。
常用的Pthread函数调用:
1. pthread_create:创建新线程,返回线程标识符。
2. pthread_exit:终止线程并释放栈。
3. pthread_join:等待某线程结束。
4. pthread_yield:主动释放CPU给其他线程。
5. pthread_attr_init:建立关联一个线程的属性结构并初始化为默认值。
6. pthread_attr_destroy:删除一个线程的属性结构,不会影响调用它的线程。
2.2.4 线程的实现
线程包可以在用户空间中或内核中实现。
第一种方式把整个线程包放在用户空间中,内核对线程包一无所知,内核只会参与进程管理。
优点:
1. 用户级线程包可以在不支持线程的操作系统上实现。
2. 用户级线程包需要维护线程表,保存线程状态和调度过程都只是本地过程,整个线程的切换可以在几条指令内完成,要比陷入内核快得多。
3. 允许每个进程有自己定制的调度算法。
缺点:
1. 很难让每个线程阻塞于系统调用时不影响其他线程。
2. 一个线程开始运行后,除非自愿放弃CPU,否则其他线程不能运行。
3. 多线程常用于经常发生线程阻塞的应用中,这种应用会放大上面所说的问题。
如果在内核中实现线程,则由内核维护线程表。所有能够阻塞线程的调用都以系统调用的形式实现。内核的调度单位为线程。
这种实现解决了上面用户级线程包的问题,但在性能上损失很大。
可以将这两种实现混合起来,将线程分为内核线程和用户线程两种,内核只识别并调度内核线程,每个内核线程可以被多个用户线程多路复用。
2.3 进程间通信
进程间通信可以归纳为三个问题:1)信息传递;2)互斥;3)同步。
多个进程读写某些共享数据,最后的结果取决于进程运行的精确时序,称为竞争条件。
一种优先级反转问题:有两个进程,H优先级高,L优先级低,调度规则为H就绪就可以运行。某时刻L处于临界区中,H变到就绪态,此时H开始运行,于是等待L离开临界区,但因为L被H抢占,不会被调度,也就不会离开临界区。
2.3.1 忙等待的互斥
几种实现互斥的方案:
1. 屏蔽中断:最简单的方案,但效果不好,因为把屏蔽中断的权力交给用户进程是不明智的。
2. 锁变量:也不好,对锁变量的修改和测试不是原子操作。
3. 忙等待:非常浪费CPU时间,只有在等待时间非常短时才能用此方案。用于忙等待的锁称为自旋锁。
4. Peterson算法:对忙等待的一种改进。
5. TSL指令:需要硬件支持,锁住内存总线,以禁止其他CPU在本指令结束前访问内存。可用XCHG替代TSL,Intel的X86CPU在低层同步中使用XCHG。
2.3.2 睡眠与唤醒的互斥
信号量、互斥量、条件变量。
有一种高级同步原语,叫做管程,是由过程、变量及数据结构等组成的一个集合。任一时刻管程中只能有一个活跃过程。管程是编程语言的一部分,进入管程时的互斥由编译器负责。
实现了管程的编程语言很少。
2.3.3 消息传递
可以用消息传递来实现通信和同步。消息传递本身需要互斥。它的设计要点:
1. 消息的丢失和确认。
2. 避免重复的消息。
3. 验证消息来源的身份。
并行程序中经常使用消息传递。
2.3.4 屏障
屏障用于为程序划分阶段,除非所有的线程都就绪准备下一阶段,否则任何线程都不能进入下一阶段。
2.4 调度
进程切换的代价是比较高的:用户态切换到内核态——保存进程状态——保存内存映像——选定调度进程——装入新进程的内存映像——新进程开始运行。除此之外,进程切换还会使整个内存高速缓存失效。
2.4.1 进程行为
1. 计算密集型:绝大多数时间花在CPU运算上。
2. I/O密集型:绝大多数时间花在等待外部设备而阻塞上。
随着CPU越来越快,更多的进程倾向为I/O密集型。
2.4.2 何时调度
1. 创建新进程后,父子进程都处于就绪态,可以任意决定哪个先运行。
2. 一个进程退出时,从就绪进程中选择一个。
3. 进程阻塞时,必须选择另一个进程运行。
4. 发生I/O中断时,如果有进程阻塞于此设备,该进程就进入就绪态。
非抢占式调度算法:进程会一直运行到阻塞或自动释放CPU,即在时钟中断时不会进行调度。
抢占式调用算法:进程运行时间达到某值时就被挂起,需要在时钟中断时调度。
如果没有可用的时钟,只能采取非抢占式调度算法。
2.4.3 调度算法的目标
1. 所有系统:
a) 公平:给每个进程公平的CPU份额。
b) 策略强制执行:必须保证能强制执行指定的策略。
c) 平衡:保证系统的所有部分都忙碌。
2. 批处理系统:
a) 吞量:每小时最大作业数。
b) 周转时间:从提交到终止间的最小时间。
c) CPU利用率:保持CPU始终忙碌。
能使吞吐量最大的算法不一定有最小的周转时间。CPU利用率不是一个好的度量参数,真正有价值的是将吞吐量和周转时间结合起来。
3. 交互式系统:
a) 响应时间:快速响应请求。
b) 均衡性:满足用户的期望。
4. 实时系统:
a) 满足截止时间:避免丢失数据。
b) 可预测性:在多媒体系统中避免品质降低。
2.4.4 批处理系统中的调度
1. 先来先服务:非抢占算法。保证了公平性,缺点是性能低
2. 最短作业优先:能保证周转时间最短。但只有在所有作业都可同时运行时才是最优的。
3. 最短剩余时间优先:可以使新的短作业获得良好的服务。
2.4.5 交互式系统中的调度
1. 轮转调度:每个进程分配一个时间片。时间片太短会导致过多的进程切换;太长会导致短的交互请求的响应时间变长。设为20ms~50ms比较合理。
2. 优先级调度:每个进程一个优先级,最高优先级的就绪进程先运行。优先级可以静态赋予或动态赋予。
3. 多级队列:高优先级的进程被调度后优先级降低,同时时间片增长。为计算密集型进程赋予长的时间片会减少切换次数,效率更高。
4. 最短进程优先:需要根据进程过去的行为推测它的执行时间。
5. 保证调度:保证每个进程获得的CPU时间。需要跟踪各进程自创建以来使用的CPU时间。
6. 彩票调度:根据优先级赋予每个进程一定数量的彩票,调度时随机抽取一张彩票,并选择它属于的进程。
7. 公平分享调度:调度不光以进程为单位,还以用户为单位。
2.4.6 实时系统中的调度
实时系统可以分为硬实时和软实时。实时系统中的事件可以分为周期性事件和非周期性事件。能在一个周期内处理完所有事件的实时系统称为是可调度的。
实时系统的调度算法可以是静态或动态的,前者在系统开始运行前作出调度决策,后者在运行时进行决策。
2.4.7 线程调度
用户线程和内核线程的差别在于性能。
从进程A的一个线程切换到进程B的一个线程,其代价高于运行进程A的第2个线程。
2.5 经典的IPC问题
2.5.1 哲学家就餐问题
5个哲学家围成一圈,每个人面前有一碗饭,左右各有一支筷子,因此共有5支筷子。哲学家的生活中只有思考和吃饭两个阶段。当他觉得饿了,需要同时获取到左边和右边的筷子才能吃饭。当他思考时就会放下两支筷子。寻找一种不会产生死锁的方法。
1. 所有人都先拿左边的筷子再拿右边的筷子,会死锁。
2. 拿到左边的筷子后查看右边的筷子,若不可用则回退,过段时间再重来。会死锁。
3. 回退后的等待时间随机化,正常应用中不会产生问题,高可靠性的应用中不行。
4. 每个哲学家一个状态,一个信号量,吃饭时标记自己为饿了,查看左边和右边的筷子,若不可用则阻塞于自己的信号量上;思考时标记自己在思考,检查左边和右边的哲学家,若他们的两个筷子都可用则发信号给他们的信号量。不会产生死锁,且能获得最大的并行度。
2.5.2 读者-写者问题
在一个读者到达,且一个写者在等待时,读者在写者之后被挂起,而不是立即允许进入,能避免死锁。