前言
操作系统中被问的最多的一个问题,这个问题起源于多道程序设计。多道程序设计技术是操作系统最早引入的技术,它的设计思想是允多个程序同时进入内存并运行,其目的是为了CPU的利用率,进而提高系统效率。
本文主要阐述进程和线程的相关理论,粗略概括一二,更详细的解释可以参考操作系统的基本经典书籍。
Q&A
Q:进程与线程有什么区别?
A:
- 进程是对运行时程序的封装,是系统进行资源分配的最小单位,实现了操作系统的并发;
线程是进程的子任务,是CPU调度的最小单位,实现了进程内部的并发。 - 进程有独立的系统资源,而同一进程内的线程共享大部分的系统资源(比如堆、代码段、数据段等)。
- 一个进程崩溃不会对其他进程造成影响,但是一个线程崩溃会导致进程内的其他线程崩溃。
- 进程在创建、切换和销毁时的开销比较大,需要分配/释放系统资源;而线程只需保存和设置少量寄存器的内容,开销较小。
- 进程间的通信较为复杂,而线程由于由于共享代码段和数据段,通信较容易。
Q:进程间通信和线程间通信各有哪些方式?
A:
进程间的通信方式:
- 管道(pipe):在内存中开辟的大小固定的缓存区
- 信号(signal):
- 消息队列(message queue):
- 共享内存(shared memory):允许两个或多个进程共享一个给定的存储区
- 信号量(semaphore):
- 套接字(socket):
线程间的通信方式:
- 临界区:通过多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问;
- 互斥量Synchronized/Lock:采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问
- 信号量Semphare:为控制具有有限数量的用户资源而设计的,它允许多个线程在同一时刻去访问同一个资源,但一般需要限制同一时刻访问此资源的最大线程数目。
- 事件(信号),Wait/Notify:通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作
Q: 有了线程为什么还需要进程?
A: 进程可以使多个程序并发执行,但是进程(程序)自身在一个时间只能做一件事情,比如while循环等待的时候无法做别的事情,因此操作系用引入粒度更小的线程作为调度的基本单位。
此外,线程的通信机制比进程方便很多,线程的切换效率也远远高于进程,因此引入线程可以减少系统开销,提高操作系统的工作效率。
进程
进程的定义:一个运行中的程序
程序段、数据段、PCB三部分组成了进程实体(进程映像),一般把进程实体就简称为进程。严格来说,进程是进程实体的运行过程,不过一般不对二者进行区分。
PCB是进程存在的唯一标志
进程的状态与转换
三种基本状态
- 运行态Running——占有CPU,并在CPU上运行
- 就绪态Ready——已经具备运行条件,但是没有空闲CPU,暂时不能运行(完事具备,只欠CPU)
- 阻塞态/等待态Blocked/Waiting——因等待某一时间而不能运行。
另外两种状态
- 创建态——进程正在被创建
- 终止态——进程正在被撤销
进程状态的转换
引起进程状态转换的具体原因如下:
- NULL→新建态:执行一个程序,创建一个子进程。
- 新建态→就绪态:当操作系统完成了进程创建的必要操作,并且当前系统的性能和虚拟内存的容量均允许。
- 运行态→终止态:当一个进程到达了自然结束点,或是出现了无法克服的错误,或是被操作系统所终结,或是被其他有终止权的进程所终结。
- 运行态→就绪态:运行时间片到;出现有更高优先权进程。
- 运行态→等待态:等待使用资源;如等待外设传输;等待人工干预。
- 就绪态→终止态:未在状态转换图中显示,但某些操作系统允许父进程终结子进程。
- 等待态→终止态:未在状态转换图中显示,但某些操作系统允许父进程终结子进程。
- 终止态→NULL:完成善后操作。
进程控制
简单理解:进程控制就是实现进程的状态转换
进程控制的实现: 原语——不能被中断的指令,通过关/开中断实现,运行在核心态
- 创建原语
- 撤销原语
- 阻塞原语
- 唤醒原语
- 切换原语
进程通信
- 共享存储
- 共享存储允许两个或多个进程共享一个给定的存储区。
- 对存储区的访问要互斥进行
- 消息传递
- 使用“发送消息/接收消息”两个原语来交换格式化的消息
- 管道通信
管道——在内存中开辟的大小固定的缓存区
- 管道只能实现半双工通信。
- 各进程要互斥地访问管道
进程同步&进程互斥
进程同步
进程具有异步性的特征,各并发的进程以独立的、不可预知的速度向前推进。
进程同步解决的是这种异步关系,即协调各个进程间的工作次序。
进程互斥
系统的某些资源同一个时间段只允许一个进程访问该资源。当某一个进程访问该资源的时候,另一个进程要想访问资源则必须等待。
进程互斥的原则:
- 空闲让进
- 忙则等待
- 有限等待
- 让权等待
线程
在早期的操作系统中并没有线程的概念,进程是拥有资源和独立运行的最小单位,也是程序执行的最小单位。任务调度采用的是时间片轮转的抢占式调度方式,而进程是任务调度的最小单位,每个进程有各自独立的内存空间,使得各个进程之间内存地址相互隔离。
后来,随着计算机行业的发展,程序的功能设计越来越复杂,我们的应用中同时发生着多种活动,其中某些活动随着时间的推移会被阻塞,比如网络请求、读写文件(也就是IO操作),我们自然而然地想着能不能把这些应用程序分解成更细粒度、能 准并行运行 多个顺序执行实体,并且这些细粒度的执行实体可以共享进程的地址空间,也就是可以共享程序代码、数据、内存空间等,这样程序设计模型会变得更加简单。
其实很多计算机世界里的技术演变,都是模拟现实世界。比如我们把一个进程当成一个项目,当项目任务变得复杂时,自然想着能不能将项目按照业务、产品、工作方向等分成一个个任务模块,分派给不同人员各自并行完成,再按照某种方式组织起各自的任务成果,最终完成项目。
需要多线程还有一个重要的理由就是:每个进程都有独立的代码和数据空间(程序上下文),程序之间的切换会有较大的开销;线程可以看做轻量级的进程,同一类线程共享代码和数据空间,每个线程都有自己独立的运行栈和程序计数器,线程之间切换的开销小。所以线程的创建、销毁、调度性能远远优于进程。
在引入多线程模型后,进程和线程在程序执行过程中的分工就相当明确了,进程负责分配和管理系统资源,线程负责CPU调度运算,也是CPU切换时间片的最小单位。对于任何一个进程来讲,即便我们没有主动去创建线程,进程也是默认有一个主线程的。
信号量机制
什么是信号量?
信号量是Dijkstra在1965年提出的一种方法,它使用一个整型变量来累计唤醒次数。用户可以通过操作系统提供的一对原语来对信号量进行操作:wait/P和signal/V。
具体方法:
用一个整数型的变量作为信号量,用来表示系统中某种资源的数量。
Eg:某计算机系统中有一台打印机:1
2
3
4
5
6
7
8
9
10int S = 1;
void wait(int S){ //wait/P原语
while(S <= 0); //如果资源不够,就一直循环等待
S = S - 1; //如果资源足够,就占用一个资源
}
void signal(int S){ //signal/V原语
S = S + 1; //使用完资源后,释放资源
}
1 | //进程P0: 进程P1: 进程P2: |
使用信号量机制实现同步、互斥
信号量实现进程互斥1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18/*信号量实现进程互斥*/
semphore mutex = 1;
P1(){
···
P(mutex);
//临界区代码段
V(mutex);
···
}
P2(){
···
P(mutex);
//临界区代码段
V(mutex);
···
}
实现步骤:
- 分析并发进程的关键活动,划分临界区
- 设置互斥信号量mutex
- 在临界区前执行P(mutex)
- 在临界区后执行V(mutex)
信号量实现进程同步1
2
3
4
5
6
7
8
9
10
11P1(){
code 1;
code 2;
code 3;
}
P2(){
code 4;
code 5;
code 6;
}
上述代码P1和P2并发执行,存在异步性,因此二者交替推进的次序是不确定的。
若想使P2的code 4
在P1的code 1
和code 2
之后,那么就要保证code 4
是在code 2
之后才会执行。
1 | /*用信号量机制实现进程同步*/ |
实现步骤:
- 分析什么地方需要同步关系
- 设置同步信号量S,初始值为0
- 在“前操作”之后执行V操作
- 在“后操作”之前执行P操作
PS:这里将code 1和code2看作是执行code 4需要的资源,执行完code 1和code 2之后资源量+1,执行code 4之前资源量-1