标签:线程池 允许 标识 说明 协议 结果 ++ sem 次数
为了保证多道程序设计系统中程序能够正确地运行,引入进程概念用于更好地控制和管理程序的执行。进程包括程序但不只是程序本身,它还包括程序运行过程中的一些状态数据信息以及描述特定进程的数据结构PCB(Process Control Block)。操作系统通过PCB来感知进程的存在,并根据PCB来控制管理进程的运行。
进程是程序的一次动态执行过程,不同时刻进程可能处于不同的状态。这些状态通常包含以下几种:
新的(创建):当系统有新的作业到达,在系统资源满足的条件下操作系统为作业创建相应的进程,分配资源并创 建PCB。新的进程将被放入就绪队列等待CPU调度。
运行:进程获取所需的所有资源包括CPU,开始在CPU上执行程序指令。
等待:当进程需要等待某个耗时事件发生时(如I/O请求等),为了提高CPU的利用率,操作系统会让进程让出 CPU并转到相应的等待事件队列中。
就绪:进程等待CPU调度的状态,此时的进程已经获取到所需的除处理机外的所有资源。
终止:进程执行完成,操作系统会释放进程占用的所有资源,并清除进程的PCB。
进程控制块(PCB)记录了进程的相关信息,是进程存在的唯一标志,操作系统通过PCB感知和控制进程。PCB中通常包含以下信息:
线程可以看作是对进程更细粒度的划分,在支持线程的系统中,线程是CPU调度的基本单位。同一个进程中可以只包含一个线程也可能同时包含多个线程,多个线程之间可以并发执行。同一进程中的多个线程共享进程的资源,线程本身只拥有少数的堆栈数据。
在单处理器系统中,同一时刻只能有一个进程执行,当进程让出CPU后需要从就绪队列中选择一个进程到CPU上执行,进程调度就是依据某种原则从就绪队列中选择进程到CPU上执行的过程。
进程被创建后会被加入到就绪队列中,调度程序从就绪队列中选择就绪的进程执行。就绪队列通常由含头节点的链表实现:其头节点有两个指针,用于指向链表的第一个和最后一个PCB块,每个PCB块包含一个指向就绪队列中下一个PCB块的指针。
在多道程序设计系统中,通常情况下多个进程会并发执行,在系统更换运行的进程之前需要将当前进程的运行环境保存到PCB中以便下次运行时恢复,使进程能够继续运行。进程的环境信息通常包括CPU寄存器的值、进程状态和内存管理信息等。
切换CPU到另一个进程需要保存当前进程状态并恢复另一个进程的状态,这个任务称为上下文切换。
采用共享内存的通信方式,需要通信进程建立共享内存区域,该共享内存区域驻留在创建共享内存段的进程地址空间内。其它希望使用这个共享内存段进行通信的进程需要将其附加到自己的地址空间。
消息传递通过在通信进程间创建通信链路来实现通信,消息传递工具需要提供至少两种操作:
send(message)
receive(message)
通信链路的逻辑实现有有以下几种方法:
直接或间接地通信
自动或显式的缓冲
直接通信
采用直接通信的方式,每个进程必须明确指定通信的接收者和发送者。那么原语send()和receive()需定义如下:
// 寻址方式具有对称性,通信双方必须直接指定对方
send(P, message); // 向进程P发送message
receive(Q, message); // 从进程Q接收message
// 非对称寻址方式,直需要发送者指定接收者,接收者不需要指定发送者
send(P, message); // 向进程P发送message
receive(id, message); // 从任何进程接收message,这里id设置为与之通信的进程的名称
这种通信链路的属性:
直接通信缺点:在发送和接收原语中直接绑定了特定的进程,修改进程的标识符需要分析所有进程,并修改所有对旧标识符的引用。
间接通信
进程间通过邮箱或端口交换消息,每个邮箱都有唯一的标识符,进程可以向邮箱放入消息也可以从中删除消息。通信的两个进程需要共享同一个邮箱来实现通信。那么原语send()和receive()需定义如下:
send(A, message); // 向邮箱A发送message
receive(A, message); // 从邮箱A接收message
这种通信链路的属性:
邮箱的拥有者:
邮箱为进程拥有:此时邮箱是进程地址空间的一部分,使用时需要区分邮箱的所有者(只能从邮箱接收消息)和使用者(只能向邮箱发送消息),邮箱会随着进程的终止而消失。
邮箱由操作系统维护:创建邮箱的进程默认为邮箱的所有者,操作系统提供系统调用可以更改邮箱的拥有权和接收特权。此时操作系统需要提供满足进程如下操作的机制:
同步
进程间通过调用原语send()和receive()通信,这些原语有不同的设计方案。消息传递可以是阻塞或非阻塞,也称为同步或异步:
并发性是指多个进程或线程可以在同一个CPU上轮流执行,但任意时刻都只能有一个进程或线程执行。而并行则是指多个进程或线程同时运行。
用户线程位于内核之上,它的管理由程序控制,无需内核的支持。
内核线程是由操作系统直接支持和管理的。
线程库为程序员提供创建和管理线程的API,实现线程库的主要方法有以下两种:
一旦父进程创建了一个子线程后,父线程就恢复自身的执行,父线程与子线程会并发执行。
父线程创建一个或多个子线程之后,在恢复之前需要等待所有子线程的终止,通常是由于父线程需要使用子线程返回的数据。
在多道程序设计系统中,一旦CPU处于空闲状态就需要从就绪队列中选择一个进程执行,从而提高CPU的利用率。进程的选择采用短期调度程序(CPU调度程序),调度程序从内存中选择一个能够执行的进程,并为其分配CPU。
需要进行CPU调度的情况:
如果调度只能发生在第一种和第4种情况下,则此类调度方案称为非抢占的;否则,调度方案称为抢占的。在非抢占调度下,一旦某个进程分配到CPU,进程就会一直使用CPU,直到终止或者切换到等待状态。而在抢占调度下,系统会根据相应的调度策略(优先级或短作业优先等)在上述四种情况下进行调度,不需要等到运行进程终止或者进入等待状态,调度具有强制性。
不同的调度准则具有不同的偏向性,CPU调度算法的设计可以依据不同的准则,那么在不同的场景中就可以选择不同的CPU调度算法以适应场景需求。常见的准则包括:
先到先服务调度算法是非抢占的,系统调度顺序按照进程进入内存的时间顺序。一旦进程获得CPU,该进程会一直使用CPU直到进程终止或进入等待状态,进程从等待状态变为就绪状态时会被放到就绪队列的最后。
最短作业优先调度算法可以是抢占的也可以是非抢占的,当采用可抢占策略时,每当有新的作业进入就绪队列,系统都需要将其与正在运行的进程的剩余运行时间进行对比,并选择所需时间较小的进程运行。对于非抢占策略则继续运行正在执行的进程。
与SJF调度算法类似,既有抢占式又有非抢占式的。调度的原则是依据进入就绪队列的进程的优先级,SJF调度算法中的作业运行时间就可以看作是优先级的一种延伸。
该调度算法通过将处理机时间进行分片,每个进程轮流运行一个时间片。这种调度算法能够尽快的使所有进程都产生响应。
将系统中的进程划分到不同类型的进程队列中,不同的进程队列有不同的调度需求。进行调度时根据实际需求设定多个调度队列的顺序,系统完成前面队列中的进程后依次向后调度。
系统中维护多个进程队列,系统允许进程在多个队列之间迁移。
共享数据的并发访问可能导致数据的不一致,为了保证进程的正确执行,操作系统需要提供一定的机制以便确保共享同一逻辑地址空间的协作进程的有序执行,从而维护数据的一致性。
处理器在每完成一个机器指令后会检查是否有中断请求,一个进程在它的指令流上的任何一点都可能会被中断,并且处理器可能会用于执行其他进程的指令。在这个过程中如果进程使用的共享资源被其它进程修改,那么进程再次运行时会得到不确定的结果,因此操作系统需要对使用同一资源的多个进程的运行加以控制。
竞争条件:多个进程并发访问和操作同一数据并且执行结果与特定访问顺序有关,称为竞争条件。
每个进程中用于操作或修改共享资源的一段代码称为临界区,任何时候都只允许一个进程进入临界区内执行。在进程进入临界区前需要请求许可,实现这一请求的代码区段称为进入区。临界区之后可以有退出区,其余代码称为剩余区。
该方案是一个经典的基于软件的临界区问题的解决方案,Peterson方案要求两个进程共享两个数据项:
int turn; // 表示轮到哪个进程可以进入临界区
boolean flag[2]; // 表示进程是否准备好进入临界区
进程P~i~代码结构
do {
flag[i] = true; // 准备进入临界区
turn = j; // 将进入的机会设置为其它进程,由于turn为共享变量,该语句执行后turn的值可能为j也可能为i
while(flag[j] && turn == j); // 如果没有进程准备好,那么当前进程可直接进入临界区;如果有进程准 备好,那么就需要看turn的值来决定哪个进入临界区
临界区;
flag[i] = false; // 退出临界区时,关闭当前进程的准备状态,给进程P_j进入临界区的机会;如果P_j没 有抓住这个机会,由于循环flag[i]会重新变为true,不过此时P_i会设置turn=j, 进程P_j同样能够进入临界区
剩余区;
} while(true);
对于单处理器环境,在修改共享变量时只要禁止中断出现,就能确保当前指令流可以有序执行,且不会被抢占。由于不可能执行其它指令,所以共享变量不会被意外修改。
现代系统中提供了特殊硬件指令,用于检测和修改字的内容,或者用于原子地交换两个字。使用这些指令可以解决临界区问题。这些指令的抽象定义如下:
test_and_set()指令
// 检测传入的目标的值,并返回目标值
boolean test_and_set(boolean *target) {
boolean rv = *target; // 保存传入的目标值并返回
*target = true; // 无论目标值是什么都将目标值置为true,那么除了最先修改目标值的进程得到的返回结果 为false意外,其余进程得到的结果均为true
return rv;
}
采用test_and_set()实现互斥进入临界区
do {
while(test_and_set(&lock)); // lock初始为false,一旦进程修改lock后,其余进程的检测结果均为 true,进程会进入自旋状态
临界区;
lock = false; // 退出临界区时释放lock
剩余区;
}while(true);
compare_and_swap()指令
// 该指令通过比较期望值与真实值来修改真实值,如果与预期一致表示共享变量没有被修改,那么此时可以交换值
int compare_and_swap(int *value, int expected, int new_value) {
int temp = *value;
if(*value == expected)
*value = new_value;
return temp;
}
采用compare_and_swap()指令实现互斥进入临界区
do {
while(compare_and_swap(&lock, 0, 1) != 0); // lock初始为0,如果lock未被修改,则修改lock为 1,并返回0,进程进入临界区
临界区;
lock = 0; // 退出临界区时释放lock
剩余区;
}while(true);
信号量是一个整型变量,在信号量机制中,除了初始化外只能通过两个标准的原子操作修改信号量的值:wait()和signal()。操作wait()称为P操作,一般用于增加信号量的值;操作signal()称为操作,一般用于减少信号量的值。信号量分为计数信号量和二进制信号量。
计数信号量的值不确定,设置初始值时通常用来表示系统中可用资源的数量。随着系统的运行,每有一个进程申请该资源时,计数信号量就减1,当信号量为负数时其绝对值表示等待资源的进程数量。
二进制信号量的值只能为0或1,因此二进制信号量可当作互斥锁使用,每次只能有一个进程获取到该信号量,其余进程则必须等待。
当进程执行P操作时,如果发现信号量不为正数时,进程需要等待。此时如果让进程处于自旋状态会浪费处理机资源,因此应该让进程阻塞自己并进入到与信号量相关的等待队列中,当有其它进程调用V操作释放信号量时在从相应的等待队列中唤醒一个进程获取信号量并进入就绪状态等待运行。PV操作分别定义如下:
wait(semaphore *S) {
S->value--; // 消耗一个信号量
if(S->value < 0) { // 小于0则说明信号量已经用完,那么进程就应该进入相应的等待队列中并调用阻塞原语 阻塞自己
add this process to S->list;
block();
}
}
wait(semaphore *S) {
S->value++; // 释放一个信号量
if(S->value <= 0) { // 释放一个信号量后仍小于或等于0则说明系统中有等待该信号量的进程,那么就应该 从该信号量的等待队列中唤醒一个进程
remove a process P from S->list;
wakeup(P);
}
}
在读者-写者问题中,只对共享数据进行读取的进程为读者进程,修改共享数据的进程称为写者进程。多个读者可同时读取共享数据而不会导致出现错误,但是任何时刻多个写者进程不能同时修改数据,写者进程和读者进程也不能同时访问共享数据。
实现读者-写者同步,需要用到以下共享变量:
semaphore rw_mutex = 1; // 读者与写者互斥访问共享数据的互斥信号量
semaphore mutex = 1; // 多个读者进程互斥修改当前读者进程数量的信号量
int read_count = 0; // 系统当前读者进程数量
写者进程结构
do {
wait(rw_mutex);
...
/* 修改共享数据 */
...
signal(rw_mutex);
}while(true);
读者进程结构
do {
wait(mutex); // 获取修改读者进程数量的互斥信号量,该操作在请求rw_mutex之前,防止出现死锁
read_count++;
if(read_count == 1) // 判断当前是否为第一个读者进程
wait(rw_mutex); // 如果是就需要请求访问共享数据的互斥信号量
signal(mutex); // read_count修改后释放信号量
...
/* 读取数据 */
...
wait(mutex); // 获取修改读者进程数量的互斥信号量
read_count--;
if(read_count == 0) // 判断当前进程是否为最后一个读者进程
signal(rw_mutex); // 如果是则释放共享数据的互斥信号量,以允许写者进程操作共享数据
signal(mutex);
}while(true);
哲学家进餐问题是在多进程之间分配多个资源,并保证不会出现死锁和饥饿现象的例子,共享数据为
semaphore chopstick[5]; // 五支筷子的互斥信号量
哲学家i的结构
do {
wait(chpostick[i]); // 拿起左边的筷子
wait(chpostick[(i+1) % 5]); // 拿起右边的筷子
/*进餐*/
signal(chopstick[i]);
signal(chopstick[(i+1) % 5]);
/*思考*/
}while(true);
上述进程结构可能会导致死锁,5个哲学家可能同时拿起左边的筷子,为了解决死锁问题可以考虑以下措施:
如果进程所申请的资源被其它等待进程占有,那么该进程有可能再也无法改变状态,这种情况称为死锁。出现死锁时至少涉及两个进程,在无外力作用下,死锁状态将无法解除。
通过破坏死锁发生的四个必要条件之一就可以预防死锁的发生。
通常不能通过破会互斥条件唉预防死锁,互斥是操作系统的重要特性需要加以保护。
进程在申请资源时一次性申请所有资源,那么就不会出现占有并等待资源的情况,但是这样会降低资源的利用率。
当资源变为可抢占时,进程会从其它进程占有的资源中直接剥夺,从而保证进程的执行。
通过对系统中的所有资源进行编号,进程在申请资源时必须按照顺序获取。如果没有获取到前面的资源系统就不会分配后面的资源给其它进程,从而保证至少有一个进程可以执行。
如果系统能够按照一定的顺序为每个进程分配资源,仍然避免死锁,那么系统的状态就是安全的。也就是说如果系统中存在一个进程执行的顺序使得所有进程都能执行完成,那么这个序列称为安全序列,存在安全序列的系统就处于安全状态。
安全状态不是死锁状态,死锁状态是非安全状态,不是所有的非安全状态都能导致死锁状态,只是非安全状态可能导致死锁。
银行家算法用于判断系统内是否存在安全序列,算法中包括:
该算法思路是通过不断迭代,搜索当前系统中的剩余资源能够满足的进程,并进行分配。进程执行完成会释放资源,算法进行新一轮的搜索,知道所有进程都执行完成,那么就能够找到一个安全序列。
死锁检测算法可通过化简系统资源分配图,对于系统能够满足运行条件的进程,删去其在资源分配图中对应的资源申请和资源分配边。如果所有的边都能够消除,则表明系统中没有死锁,否则系统中存在死锁。
进程终止:(1) 终止所有死锁进程;(2) 一次终止一个进程直到消除死锁为止;
资源抢占:不断地抢占一些进程地资源以便给其它进程使用,直到死锁循环被打破为止;
标签:线程池 允许 标识 说明 协议 结果 ++ sem 次数
原文地址:https://www.cnblogs.com/sasworld/p/12500110.html