OS-进程与线程
学习自《计算机操作系统》第五版
进程Process
为了实现程序并发运行,并且可以对并发执行的程序加以描述和控制,引入了进程。
-
进程是具有独立功能的程序在一个数据集合上运行的过程,
-
它是系统进行资源分配和调度的一个独立单位。
PCB+程序段+数据段=进程
进程的三种基本状态
- 就绪:进程已分配到除CPU外所有必要资源,排成就绪队列
- 执行:进程已获得CPU,其程序正在执行.
- 阻塞:进程的执行受到阻塞,引起进程调度,排成阻塞队列
引入挂起操作的原因,是基于系统和用户的如下需要:
(1) 终端用户的需要。
(2) 父进程请求。
(3) 负荷调节的需要。
(4) 操作系统的需要。
进程控制块PCB
计算机系统中,对于每个资源和进程都设置了一个数据结构,用于表征其实体,称之为资源信息表或进程信息表。操作系统管理的数据结构一般分为四类:内存表、设备表、文件表、进程表,其中进程表即进程控制块,它是一种记录型数据结构。
作用
- 作为独立运行基本单位的标志。
- 能实现间断性运行方式。
- 提供进程管理所需要的信息。
- 提供进程调度所需要的信息。
- 实现与其它进程的同步与通信。
信息
进程控制块中主要包含如下信息:
- 进程标识符:外部标识符描述进程间的关系,方便用户访问进程;内部标识符通常是进程的序号,方便系统使用进程。
- 处理机状态:又称为处理机上下文,主要是由处理机的各种寄存器组成,包括通用寄存器、指令计数器、程序状态字、用户栈指针等。当进程被切换时,正在处理的信息都会从寄存器保存到相应的PCB中,以便之后可以在断点处重新执行。
- 进程调度信息:包括进程状态、进程优先级、事件(阻塞原因)等。
- 进程控制信息:包括程序和数据的地址、进程同步和通信机制、资源清单、链接指针等。
组织方式
在一个系统中,通常可拥有数十个、数百个乃至数千个PCB。为了能对它们加以有效的管理,应该用适当的方式将这些PCB组织起来,目前常用的组织方式有以下三种:
- 线性方式:所有PCB组织在一张线性表中
- 链接方式:具有相同状态进程的PCB分别通过PCB中的链接字链接成一个队列
- 索引方式:系统根据所有进程状态的不同,建立几张索引表
管理包括:创建新的PCB,终止PCB,挑选就绪PCB,改变PCB状态
进程控制
进程控制是进程管理中最基本的功能,一般是由OS的内核中的原语实现的
操作系统内核
通常将一些与硬件紧密相关的模块,各种常用设备的驱动程序以及运行频率较高的模块都安排在紧靠硬件的软件层次,将它们常驻内存,即通常被称为的OS内核。
相对应为了防止OS本身及关键数据遭受应用程序有意或无意的破坏,通常将处理机的执行状态分成
-
系统态/管态/内核态
能执行一切指令,访问所有寄存器和存储区
-
用户态/目态
仅能执行规定的指令,访问指定的寄存器和存储区
大多数OS内核都包含以下两个方面功能
- 支撑功能
- 中断处理:内核最基本的功能
- 时钟管理
- 原语操作
- 原语:由若干条指令组成,用于完成一定功能的一个过程
- 与一般过程的区别在于它是原子操作,即是该操作所有动作要么全做,要么全不做
- 原语在系统态下执行,不允许被中断
- 资源管理功能
- 进程管理
- 存储器管理
- 设备管理
进程的创建
进程的层次结构
在OS中,允许一个进程创建另一个进程,分别对应父进程和子进程,子进程可以继承父进程所拥有的资源。
在UNIX中,进程与子孙进程共同组成一个进程家族,具有层次结构,通常用一棵有向树来表示
而在Windows中则不存在任何进程层次结构的概念,而是获得句柄与否,控制与被控制的简单关系,创建进程时若获得句柄Handle,相当于一个令牌,用于控制被创建的进程
引起创建进程的事件
典型的有四类事件会导致一个进程创建另一个进程:
- 用户登录:在分时系统中,用户登陆成功后系统会为该用户创建一个进程
- 作业调度
- 提供服务:运行中的用户程序提出某个请求后,系统会专门创建一个进程来提供服务
- 应用请求:前三者都是系统为用户创建进程,而这一类是用户进程自己创建新进程
创建原语
每当系统中出现创建进程的请求,系统就会调用进程创建原语Creat
- 为新进程申请获得唯一的数字标识符,申请空白PCB
- 为新进程分配其运行所需的资源
- 初始化PCB
- 将新进程插入就绪队列
进程的终止
引起进程终止的事件
- 正常结束,即进程的任务已完成,准备退出运行
- 异常结束,即进程在运行时发生了某种异常事件,程序无法继续执行,如:越界、非法指令、权限错、运行超时、等待超时、运算错误、I/O故障
- 外界干预,干预多种多样,典型的如:操作系统干预、父进程请求、父进程终止。
终止过程
- 根据被终止进程的标识符,从PCB集合中检索出该进程的PCB,读出进程的状态;
- 若待终止进程处于执行状态,则终止执行
- 终止其子孙进程;
- 将被终止进程拥有的全部资源归还给父进程或操作系统;
- 将终止进程从所在队列移出。
进程的阻塞与唤醒
引起进程阻塞和唤醒的事件
(1) 向系统请求共享资源失败。
(2) 等待某种操作的完成。
(3) 新数据尚未到达。
(4) 等待新任务的到达。
阻塞
阻塞是一种主动行为。正在执行的进程遇到引起阻塞的事件时,便会调用阻塞原语Block将自己阻塞。
唤醒
唤醒是一种被动行为。当被阻塞进程期待的事件发生时,有关进程就会调用唤醒原语wakeup将等待该事件的进程唤醒。
进程的挂起与激活
如之前提到的,操作系统还可以挂起和激活进程,对应挂起原语suspend和激活原语active。
进程同步
为保证多个进程有条不紊地运行,在多道程序系统中,引入了进程同步机制,常见的同步机制有硬件同步机制、信号量机制、管程机制。
进程同步机制的主要任务是对多个相关进程的执行次序进行协调,使并发的进程之间能按照一定的规则共享系统资源,使程序的执行具有可再现性。
制约关系
同处于一个系统的多个进程之间可能存在制约关系
- 间接制约:对于临界资源,进程只能互斥地访问,对于这类资源,必须由系统统一分配;
- 直接制约:直接制约源于进程的相互合作。
临界资源(Critical Resource)
一次仅允许一个进程使用的资源称为临界资源(Critical Resource),许多物理设备都属于临界资源,如输入机、打印机等。
临界区
每个进程中访问临界资源的那段代码称为临界区
while(true)
{
// 进入区代码,检查待访问资源,设置正在访问标志
// 临界区代码,
// 退出区代码,把标志位恢复为未被访问的状态
// 剩余区代码,不属于访问临界资源的范围
}
同步机制规则
- 空闲让进
- 忙则等待
- 有限等待:保证有限时间内能进入临界区,避免死等
- 让权等待:当进程不能进入临界区,立即释放处理机,避免进程陷入忙等状态
硬件同步机制
对临界区进行管理时,可将标志看作一个锁,锁开进入,锁关等待,每个要进入临界区的进程必须先对锁进行测试,测试和关锁操作必须连续,防止多个进程同时测试到锁打开然后一起进去
-
关中断
- 进入锁测试前关闭中断,直到完成锁测试并上锁之后才能打开中断
-
利用Test-and-Set指令
-
利用Swap指令/XCHG指令
- 为每个临界资源设置一个全局的布尔变量lock,初值为false,每个进程再利用一个局部布尔变量key,进入则swap(lock,key)
-
整形信号量
-
除初始化外,仅能通过两个标准的原子操作(Atomic Operation) wait(S)和signal(S)来访问。
-
很长时间以来,这两个操作一直被分别称为P、V操作。
wait(S): while S≤0 do no-op //说明此时有进程占用 S∶=S-1; //标记该临界区由该进程占用 signal(S): S∶=S+1; //标记该临界区无进程占用
-
如何通过屏蔽中断来实现原语操作?
将原语处理成在管态下运行,就可以设置屏蔽中断,这样不会被中断,一直到原语执行完毕。
信号量机制
进程控制中最重要的一部分便是协调好进程的并发,控制进程同步,最具体的体现就是处理临界资源。信号量机制便广泛应用在临界资源处理方面。
信号量的分类与发展
信号量最初的定义是表示资源的数目。
整型信号量
顾名思义,这种信号量就是用一个整型量s表示某临界资源的数目。在初始化时,把s初始化为资源的数目,并限制只能通过原子操作wait(s)和signal(s)来访问,这两个操作通常也被称为P、V操作。可代码表示如下:
wait(s) {
while(s <= 0);
--s;
}
signal(s) {
++s;
}
wait只能用semaphore,并且用其他如:int之类的,不能保证不被中断
note:每个信号量必须注释!
PV操作要成对出现: P()
记录型信号量
可以看到,在整型信号量中,只要申请不到资源,就会不断调试,并不符合“让权等待”原则(即让进程处于“忙等”状态),而记录型信号量就可以解决这个问题。
在记录型信号量中,简单的整型量s被扩充为记录型结构体,里面额外保存了一个进程表指针,用于链接所有等待进程。
Copystruct ProcessControlBlock;
using PCB = ProcessControlBlock;
struct Semaphore {
int value; // 资源数目
PCB* processList;
};
wait(Semaphore *s) {
if(--s->value < 0) {
// 资源已被分配给其他进程,当前进程自我阻塞
// 并把其插入等待队列
block(s->processList);
}
}
signal(Semaphore *s) {
if(++s->value <= 0) {
// 条件为真说明有进程在等待队列中,唤醒队列的第一个进程
wakeup(s->processList);
}
}
可以看到,进程在申请不到资源时会进行自我阻塞,也就符合让权等待的原则。特殊地,在资源初始数量为一时就会转化为互斥信号量。
AND型信号量
进程互斥只是针对多进程共享一个临界资源的情况。在某些情况下,一个进程需要获得不止一个共享资源后才可以执行任务。这种情况下进程很容易发生死锁。如:若两个进程需要的临界资源一致但申请顺序不一致且交替申请,那么他们都得不到所有所需的临界资源,即陷入死锁。
针对这个问题,AND型信号量的解决方案是:将进程在整个运行过程中需要的所有资源一次性全部分配给进程,待进程使用完后再一起释放。
Swait(s1, s2, ..., sn) {
while(true) {
if(s1 >= 1 && s2 >= 1 && ... && sn >= 1) {
--s1; --s2; ...; --sn;
break;
} else {
wait();
}
}
}
signal(s1, s2, ..., sn) {
while(true) {
++s1; ++s2; ...; ++sn;
}
}
信号量集
由于P、V操作只能对信号量进行加一和减一操作,当一次需要n个单位时显然是低效的,而且逐单位分配更容易引起死锁。为了系统安全,有些情况下我们需要管制资源,具体而言即当所申请的资源数d低于对应的下限值t时不予分配。
针对这个问题,信号量集的解决方案是:在进行P、V操作时需要知道进程对资源的需求量以及对应资源的下限值,并由此决定资源的分配。
Swait(s1, t1, d1, ..., sn, tn, dn);
Ssignal(s1, d1, ..., sn, dn);
三种特殊的信号量集:
Swait(s, 0, 0)
:信号量集中只有一个信号量,且下限值和允许申请数相同;Swait(s, 1, 1)
:此时的信号量集退化为记录型信号量;Swait(s, 1, 0)
:当s>0时,允许多个进程进入某个特定区,当s变为0时封锁区域,作用类似于开关。
信号量的应用
实现进程互斥
要实现某个资源的互斥访问,只需要为该资源设置一个互斥信号量mutex并设初值为1,结合P、V操作即可。
Semaphore mutex = 1;
// a进程P资源
Pa() {
while(true) {
wait(mutex);
// 临界区
signal(mutex);
// 剩余区
}
}
实现前趋关系
前趋图是一种描述进程前趋关系的有向无环图。
利用信号量可以描述进程之间的前趋关系。有前趋图如下:
那么可以用如下代码框架描述这个关系:
CopySemaphore a, b, c, d, e, f, g;
p1() { s1; signal(a); signal(b); }
p2() { wait(a); s2; signal(c); signal(d); }
p3() { wait(b); s3; signal(e); }
p4() { wait(c); s4; signal(f); }
p5() { wait(d); s5; signal(g); }
p6() { wait(e); wait(f); wait(g); s6; }
main() {
a.value = b.value = c.value = d.value = 0;
e.value = f.value = g.value = 0;
cobegin
p1(); p2(); p3(); p4(); p5(); p6();
coend
}
经典同步问题
生产者-消费者问题
有一群生产者进程在生产产品,并将产品提供给消费者进程消费。为了使生产者和消费者能并发执行,在两者之间设置了一个具有n个缓冲区的缓冲池。这也就暗含两个约束关系:消费者不能到一个空缓冲区取产品;生产者不能往一个满缓冲区投放产品。
利用记录型信号量即可解决这一问题。这里需要设置两个信号量:empty
和full
表示缓冲区的两个临界状态,用代码描述如下:
Copyint in = 0, out = 0;
Product pool[n]; // 环形缓冲池
Semaphore mutex = 1, empty = n, full = 0;
void Producer() {
while(true) {
Produce(); // 生产产品
wait(empty); // 需要一个空的缓冲区容纳新产品
wait(mutex); // 请求锁
pool[in] = nextp; // 存放新产品
in = (in + 1) % n; // 偏移指针指向下一个可存放区域
signal(mutex); // 释放锁
signal(full); // 增加一个满缓冲区
}
}
void Consumer() {
while(true) {
wait(full); // 需要一个满缓冲区来取其中的产品
wait(mutex); // 请求锁
nextc = pool[out]; // 取出产品
out = (out + 1) % n; // 偏移指针指向下一个可取出区域
signal(mutex); // 释放锁
signal(empty); // 增加一个空缓冲区
Consume(); // 消费取出的产品
}
}
void main() {
cobegin
Producer();
Consumer();
coend
}
需要注意的是,对互斥信号量的申请一定要出现在对资源信号量的申请之后,否则可能引起死锁。显然这个问题也可以用AND型信号量解决。
Copyint in = 0, out = 0;
Product pool[n];
Semaphore mutex = 1, empty = n, full = 0;
void Producer() {
while(true) {
Produce();
Swait(empty, mutex); // 要同时申请到空缓冲区和互斥信号量
pool[in] = nextp;
in = (in + 1) % n;
Ssignal(mutex, full); // 同时释放满缓冲区和互斥信号量
}
}
void Consumer() {
while(true) {
Swait(full, mutex);
nextc = pool[out];
out = (out + 1) % n;
Ssignal(mutex, empty);
Consumer();
}
}
哲学家进餐问题
有五个哲学家共用一张圆桌,桌上放着5个碗和5只筷子,哲学家只做两件事:思考、进餐。一个哲学家总是在思考,饥饿时也只能拿离他最近的两只筷子,拿到了才能进餐。进餐结束才继续思考。
利用记录型信号量可以解决这一问题。
CopySemaphore chopstick[5] = {1, 1, 1, 1, 1};
while(true) {
Think();
wait(chopstick[i]);
wait(chopstick[(i + 1) % 5]);
Eat();
signal(chopstick[i]);
signal(chopstick[(i + 1) % 5]);
}
但上面的做法会有一个问题:如果5位哲学家都处于饥饿状态,那么他们都会先去拿自己左手边的筷子,引起死锁。可以制定一些规则解决死锁问题。而用AND型信号量则不用考虑死锁问题。
CopySemaphore chopstick[5] = {1, 1, 1, 1, 1};
while(true) {
Think();
Swait(chopstick[i], chopstick[(i + 1) % 5]);
Eat();
Ssignal(chopstick[i], chopstick[(i + 1) % 5]);
}
读者-写者问题
一个数据文件可被多个进程共享,只对该文件做读操作的称为Reader进程,其他进程称为Writer进程。多个读进程可以同时读取文件,但当有写进程访问该文件时,其他进程(不管是读进程还是写进程)都不能访问该文件。这个问题常用于测试新的同步原语。
利用记录型信号量可以解决这个问题。
CopySemaphore rMutex = 1, wMutex = 1;
int readerCount = 0;
void Reader() {
while(true) {
wait(rMutex); // 申请读锁
if(readerCount == 0) wait(wRmutex); // 如果当前没有读者,那就需要锁写入
++readerCount;
signal(rMutex); // 由于读取可以同时进行,所以现在就释放读锁
Read();
wait(rMutex);
--readerCount;
if(readerCount == 0) signal(wMutex); // 没有读者才释放写锁
signal(rMutex);
}
}
void Writer() {
while(true) {
wait(wMutex);
Write();
signal(wMutex);
}
}
void main() {
cobegin
Reader();
Writer();
coend
}
如果文件要求最多可以被RN个读进程访问,那么用信号量集解决问题更方便。
Copyint RN;
Semaphore L = RN, mutex = 1;
void Reader() {
while(true) {
Swait(L, 1, 1);
Swait(mutex, 1, 0);
Read();
Ssignal(L, 1);
}
}
void Writer() {
while(true) {
Swait(mutex, 1, 1, L, RN, 0);
Write();
Ssignal(mutex, 1);
}
}
void main() {
cobegin
Reader();
Writer();
coend
}
线程Thread
引入进程目的是使多个程序并发执行,提高资源利用率和系统吞吐量;
引入线程则是为了减少程序在并发执行时(创建进程,撤销进程,进程切换)所付出的时空开销
线程和进程
- 调度的基本单位
- 进程是一个可独立调度和分派的基本单位
- 线程是一个作为调度和分派的基本单位
- 并发性
- 进程间可并发执行
- 一个进程中多个线程之间,不同进程中的线程都能并发执行
- 拥有资源
- 进程可拥有资源
- 线程除了拥有自己少量资源外,还允许多个线程共享该进程所拥有的资源
- 独立性
- 同一进程中的不同线程之间独立性比不同进程之间的低得多
- 因为不同进程彼此防干扰设置更多,而同一进程不同线程往往是为了提高并发性及相互合作而创建的
- 系统开销
- 进程每次调度都需要上下文切换,开销大
- 线程切换时仅需保存和设置少量寄存器内容,切换代价远低于进程
- 支持多处理机系统
- 传统进程,即单线程进程,不管多少处理机,只能运行在一个处理机上
- 多线程进程,允许将一个进程中的多个线程分配到多个处理机上,使其并发执行
线程状态和线程控制块
线程三个状态
线程的三个状态和进程的三个状态非常相似
- 执行状态
- 就绪状态
- 阻塞状态
线程控制块TCB
与PCB类似,操作系统会维护一个数据结构来保存线程的信息,这就是线程控制块(Thread Control Block,TCB)。通常线程控制块包含线程标识符、程序计数器、状态寄存器、通用寄存器、运行状态、存储区、优先级和堆栈指针。
多线程OS中进程的属性
- 进程是一个可拥有资源的基本单位
- 多个线程可并发执行
- 进程已不是可执行的实体,线程才是
线程的实现
线程的分类
-
内核支持线程(Kernel Supported Threads,KST)
内核支持线程是在内核支持下运行的,它的创建、阻塞、撤销、切换都在内核空间实现。
内核支持线程的优点是:
- 内核可以同时调度同一进程的多个线程并发执行;
- 当某一进程中的线程阻塞时,内核可以调度该进程的其他线程或其他进程的线程占有CPU执行;
- 内核支持线程的数据结构和堆栈很小,线程切换快,切换代价小;
- 内核本身也是多线程并发,进一步提供了速度和效率。
缺点是对用户进程的线程切换开销比较大,需要从用户态切换到核心态
-
用户级线程ULT(User Level Threads)
用户级线程是在用户空间中实现的,它的创建、阻塞、撤销、切换都无需内核支持。
用户级线程的优点是:
- 线程切换是用户级操作,不需转换到内核空间;
- 进程调度算法可以是进程专用的,不同进程内根据需要选择不同的线程调度算法;
- 用户级线程的实现与操作系统无关,因为线程管理代码是属于用户程序的一部分;
缺点是
- 当一个线程执行系统调用时,它将被阻塞时,与该线程同属一个进程的线程也会被阻塞
- 内核只会给一个进程分配一个CPU,即同一进程中只能有一个线程能执行,其他线程只能等待
-
组合方式
二者的比较
- 对于设置了用户级线程的系统,其调度仍是以进程为单位进行的。在采用轮换调度算法时,各个进程轮流执行一个时间片,假如进程A包含了一个用户级线程,进程B包含100个用户级线程,则进程A中线程的运行时间是B中各线程运行时间的100倍,相应的,其速度也要快上100倍
- 对于设置了内核支持线程的系统,其调度仍是以线程为单位进行的。在采用轮换调度算法时,各个线程轮流执行一个时间片,假如进程A包含了一个内核支持线程,进程B包含100个内核支持线程,则进程B可以获得的CPU时间是进程A的100倍,且可使100个系统调用并发工作
线程实现
-
内核支持线程的实现
- 系统创建一个新进程时,便为其分配一个任务数据区PTDA(Per Task Data Area)
- PTDA中包含若干个线程控制快TCB空间,每当进程要创建一个新线程,则为其分配一个TCB,并为其分配资源和在相应TCB填入信息
- 系统优化:撤销一个线程时不立即回收该线程的资源和TCB,当以后要创建新线程时,可直接利用已撤销但仍保持有资源的TCB作为新线程的TCB
-
用户级线程的实现
用户级线程都运行在一个中间系统上,有两种方式实现中间系统
-
运行时系统
- 实质上是用于管理和控制线程的函数的集合
- 所有函数都驻留在用户空间,并作为用户级线程与内核之间的接口
-
内核控制线程
-
又称为轻型进程LWP(Light Weight Process)
-
每个进程课拥有多个LWP,当一个用户级线程运行时,只需将它连接到一个LWP上,便具有内核支持线程的所有属性,这种线程实现方式就是组合方式
-
系统优化:为节省系统开销,不能设置太多LWP,而是将这些LWP做成缓冲池,称为“线程池”,任一用户线程都可以连接到线程池的任一LWP上,还可以让多个用户级线程多路复用一个LWP。用户级线程只有通过LWP才能访问内核,而内核看到的只是LWP,从而LWP实现了内核与用户级线程之间的隔离
-
当内核级线程执行操作时发生阻塞,与之连接的多个LWP也将阻塞,连接到LWP上的用户级线程也被阻塞
-
-