现代操作系统-学习笔记

1 引论

1.1 计算机硬件介绍

1.1.1 处理器

CPU, 从内存当中取出指令、解码以确定其类型和操作数、最后执行。访问内存耗时,因此常将一些关键变量和零时数据放在CPU中的通用寄存器中。还有很多专用计数器,如程序计数器,用于保存下一条指令的地址;堆栈指针,它指向当前桟顶。为改善效率。
现代CPU一般可以取出多条指令,因此在执行n指令时,可以解析n+1指令,可以取出n+2指令,这成为流水线。但流水线会导致编译器与操作系统的开发过程及其复杂,更高级的做法是超标量CPU,这种CPU会包含多个执行单元,如一个CPU进行整型运算,一个负责bool型,一个复杂浮点型。两个或更多的指令被同时取出解析并放入保持缓冲区中,只要有一个执行单元空闲,就检查保持缓冲区是否为空。
处理器中的多线程和多核芯片:多线程允许CPU保持两个不同的线程状态,多线程不支持并行,一个时刻只有一个线程在运行,但线程间的切换速度是纳米级。多线程很有意义,因为CPU中的每个线程看起来就是一个CPU。

1.2 操作系统概念

1.2.1 进程(简述)

进程本质上是一个正在执行的程序,每个进程都与一个地址空间(一个从最小存储位置到最大存储位置的列表)相关联,进程可以在这个地址空间内进行读写,地址空间内存放有可执行程序、程序数据及程序堆栈。进程还关联了运行改程序需要的所有资源及其他信息。进程基本上是一个容纳运行一个程序所需要所有信息的容器。
当一个进程被挂起时,该进程被再次执行的状态必须与暂停时完全一致,比如当进程正在读取一个文件,与这一操作相关的是指向该文件正在读取位置的指针,挂起时保存该指针以保证重新运行时所执行的读操作能够继续正确的进行。与一个进程有关的所有信息,除了该进程自身的地址空间以外,均存放在操作系统的进程表中,进程表是数组(链表)结构,当前的每一个进程均占用其中一项。所以总结来说,一个进程包括:进程的地址空间以及进程表项,其包含了寄存器与稍后重启该进程所需要的许多其他信息。

2 进程与线程

2.1 进程

什么是进程,举个例子:厨师做菜,菜有对应的菜谱(一个算法,相当于一个未实例化的程序空壳)、对应的食材(输入数据与其他相关信息),厨师相当于CPU。一个进程就是厨师做这道菜的所有总和,因此进程可理解为某种类型的一个活动。
进程运行有多种状态:运行态(进程正在占用CPU)、就续态(可运行,但其他进程的运行导致暂时暂停)、阻塞态(除非某些外部事件发生,如键盘输入,否则进程无法运行)。

2.2 线程

进程模型包含两个独立的概念:资源处理与执行。资源存放在地址空间当中,执行则交给线程完成。线程有一个程序计数器,用来记录接着要执行哪一条指令,线程有寄存器,用以保存当前的工作变量。线程还包含一个堆栈,用以记录执行历史,其中每一帧保存了以调用但还未返回结果的过程的局部变量以及过程结束后的返回地址。进程用于集中资源,线程则是在CPU上被执行调度的实体。线程中包含的内容有:程序计数器、寄存器、堆栈以及状态。一个进程中的多个线程共享进程中的内容包括:地址空间、全局变量、打开文件、子进程、集将发生的报警、信号与信号处理程序以及账户信息。
为何需要多线程?1)许多应用通常有多个活动,其中某些活动随着时间的推移会被阻塞,听过将这些应用程序分解为多个准并行的多个顺序线程能够简化应用程序的设计模型。这些并行实体可以共享同一个地址空间和所有可用数据;2)线程更轻量级,创建或撤销比进程更快更容易;3)如果存在大量的计算和大量的I/O处理,多线程允许这些活动彼此重叠进行,从而加快应用程序速度。

2.3 进程间通行

2.3.1 竞争条件

进程间可能彼此共享着一些都能读取的公共存储区,如果多个进程读写共用存储区,而最后的结果取决与进程执行的精确时序,称为竞争条件。举例说明:进程A与B均要打印文件,文件打印列表存放在公共存储区,其中共享变量记录了文件打印列表下一个文件名存放的位置,pos=8。此时进程A开始打印文件,读取pos并存入到自己的堆栈中,此时调度系统判断A运行太久,于是将其暂停并执行了进程B,进程B读取pos=8,将文件名保存在该位置并更新pos值随之退出进程,进程A基于自己堆栈的值pos_A=8将文件名放在该位置并更新pos的值,此时进程B的执行结果被覆盖。可以发现打印序列想要正确,必须保证进程执行的精确时序。

2.3.2 临界区

我们将访问共享内存的程序片段称为临界区。想要避免进程间的竞争条件,要保证两个进程不能同时处于临界区(进程互斥)。

2.3.3 忙等待的互斥

目前有一下几种互斥的方案:
1. 屏蔽中断
某个进程刚进入临界区后立刻执行屏蔽中断,离开时解除屏蔽。屏蔽中断会将当前CPU的时钟中断屏蔽掉,从而导致CPU无法切换继承,该方法的缺陷是:屏蔽中断只能作用于当前CPU,对于多核多处理器系统,无法发挥预期效果。
2. 锁变量
创建一个共享锁变量,初值为0,一个进程进入临界区时锁变量更新为1,等进程结束恢复为0,当另一个进程要进入临界区时先测试这个锁变量如果为1先挂起直到锁变量恢复。该方法也存在缺陷:当进程A读取锁变量的值为0但还未更新锁变量时,此时进程B恰巧需要进入临界区,而当前的锁变量还未0,这意味着两个进程都将进入临界区。
3. 严格轮换法
如果进程不满足进入临界区条件,则通过循环不断检测。整型变量turn用于记录哪个进程进入到临界区。开始时进程A检查turn为0,于是进入临界区,进程B也检查到turn为0,于是在一个等待循环中不停的测试turn,当进程A出了临界区后turn被置为1,进程B开始进入临界区。

while true do -- 进程A
    while turn != 0 do -- 在一个循环中不断测试turn
    end
    critical_region() -- 访问临界区
    turn = 1 
    nocritical_region()
end
while true do
    while turn != 1 do -- 忙等待 自旋锁 在一个循环中不断测试turn
    end
    critical_region() -- 访问临界区
    turn = 0
    nocritical_region()
end

到目前为止一切正常。此时考虑一种情况:当进程B离开临界区时,turn置为0,然后进程A很快执行完了临界区与非临界区,此时turn=1,进程A不能进行一下轮的工作,因为进程B还在缓慢的执行非临界区的代码,如果进程B非临界区一直每执行完成,那么进程A一直将处于悬挂状态。好的解决方案应该满足:非临界区外运行的进程不得阻塞其他进程,很明显,对于该方案,只要两个进程执行过程时间差异较大,就无法满足上述条件。
4. Peterson方法
假设有两个进程0、1。进程0要进入临界区时调用enter_region(),此时如果进程1未被标志则直接进入进程0的临界区,如果此时进程1正在执行临界区代码则进程0将进入循环等待,如果进程1为进入临界区则进程0通过循环并进入临界区,离开临界区时执行 leave_region()函数。

function enter_region(process)
    local other = 1-process
    interested[process] = true -- 标志即将进入临界区的进程
    turn = process
    while turn == process and interested[other] do -- 另一个进程处于临界区 循环等待
    end
end
function leave_region(process) -- 进程离开临界区
    interested[process] = false
end

现在考虑一种情形:两个进程几乎同时调用enter_region(),进程0执行turn=0后进程1随之执行turn=1,此时进程0执行循环时也将陷入循环等待只等到进程1离开临界区为止。

2.3.4 睡眠与唤醒

Peterson方法解法时正确的,但有一个很明显的缺陷:忙等待。因为该方法的本质是:当一个进程想进入临界区时,检查是否满足条件,如果不满足则一直等待直到能进入。这类方法不仅十分浪费CPU还存在一系列风险,比如:对于两个进程A与B,进程A优先级高,进程B优先级低,调度规则规定只要进程A处于就绪态就可以运行。进程B处于临界区中,进程A进入就绪态准备运行(如一个I/O操作结束)。此时进程A尝试进入运行态,但由于进程B已进入临界区,因此进程A随之进入了忙等待状态,而此时由于进程A处于就绪态所以不会调度进程B,因此进程A永远处于了忙等待状态。
现在来考察几条进程通信间原语,当进程无法进入临界区时不是进入忙等待,而是通过调用sleep将其阻塞,等待其他进程主动wakeup。生产者-消费者就是基于这样的机制。生产者消费者共同读写一个固定大小的公共缓冲区,缓冲区有一个最大容量,当其达到最大容量时将生产者阻塞,如果未达到最大则将当前生产的数据放入缓冲区并检测是否需要唤醒消费者,消费者工作方式一致。

-- 生产者
N=100  -- 缓冲区最大数据量
count = 0
function Producer() -- 生产者进程
    while true do
        if count == N then sleep() end -- 容器满 生产者阻塞
        item = pro_item() -- 产生下一个数据项
        count = count + 1
        insert_item(item)
        if count == 1 then -- 说明容器从0变为1, 以此认定消费者处于阻塞
            wakeup(Consumer) -- 唤醒消费者
        end
    end
end
function Consumer() -- 消费者进程
    while true do
        if count == 0 then sleep() end -- 缓冲区空 消费者阻塞
        item = remove_item()
        count = count - 1
        if count == N - 1 then -- 表明缓冲区数量从N变为N-1,以此认定生产者当前被阻塞 重新唤起
            wakeup(Producer)
        end
    end
end

生产者-消费者同样存在竞争条件,原因是count访问未受限值。如当消费者读取缓冲区时发现count为0,此时调度程序决定停止消费者并启动生产者,生产者生产数据,并检测到N=1,于是发送唤醒消费者指令,但此时消费者并未睡眠,于是该指令作废,此时消费者继续执行之前操作,开始测试之前读取的count,发现为0,于是睡眠,后续生产者达到缓冲区容量上限,也睡眠。之后,没有外界干预,两个进程都将无法唤醒。

2.3.5 信号量

我们以生产者-消费者来解释信号量的使用。这里需要建立三个信号量:记录充满的缓冲槽总数full,记录空的缓冲槽总数empty,用来确保生产者与消费者不会同时访问缓冲区的信号量mutex。full的初值为0,empty初值为缓冲槽总数,mutex初值为1,保证一个时刻只有一个进程进入临界区,这称为二元信号量。如果每个进程在进入临界区时执行down操作,在刚刚退出时执行up操作,就能实现互斥。
信号量中有个重要的概念是:原子操作。如对一信号量执行down操作,如果信号量大于0则减1,如果为0则进程休眠,此时down操作并未结束。检查变量值、修改变量值以及可能发生的睡眠操作均作为不可分割的原子操作完成。保证一旦一个信号量操作开始,则在该操作结束或挂起之前,其他进程均不可操作该信号量。如对信号量执行up操作,如果有一个或多个进程在该信号量上面休眠则由系统随机选择其中的一个进程并允许该进程完成其down操作。通常down|up操作由操作系统层面控制以保证其原子性。实现示例代码如下:

function Producer() -- 生产者进程
    while true do
        item = produce_item() -- 产生放在缓冲区里的数据
        down(&empty) -- 空槽数目减1
        down(&mutex) -- 进入临界区
        insert_item(item) -- 将新数据放入缓冲区
        up(&mutex) -- 离开临界区
        up(&full) -- 满槽数目加1
    end
end
function Consumer() -- 消费者进程
    while true do
        down(&full)
        down(&mutex)
        item = remove_item()
        up(&mutex)
        up(&empty)
        consume_item(item)
    end
end

上述代码通过两种方式使用信号量:1)一种是同步,full与empty用来保证事件的顺序发生或不发生,如当full为0时消费者进程挂起,大于0时消费者进程向下执行。这里同步的意义在于:当full为0时消费者进程挂起,当生产者进程完成数据生产并执行up(&full)操作后在full上睡眠的消费者进程将被唤醒并执行down后面的过程,可以发现,借助于full信号量将生产者的行为同步至了消费者中; 2)一种是互斥,mutex用于保证任意时刻只有一个进程读写缓冲区及相关变量。

2.3.6 互斥量

互斥量是信号量的简化版本,它仅适用于管理共享资源以及一小段代码,由于其实现即简单又有效,因此常在用户线程中使用。
互斥量有两个状态:加锁1与解锁0,其使用时一般有两个过程:当需要进入临界区时调用mutex_lock,如果临界区解锁则顺利进入临界区,否则将被阻塞只等到临界区中的线程调用mutex_unlock。如果多个线程在此互斥量上阻塞则系统随机选择一个线程并允许它获得锁。

mutex_lock:
    TSL REGISTER , MUTEX -- 将互斥信号量放入寄存器并将该信号量重置为1
    CMP REGISTER , #0 -- 信号量是0吗
    JZE ok -- 信号量为0 直接进入临界区 因此返回
    CALL thread_yield -- 信号量忙 调度其他线程
    JMP mutex_lock -- 稍后再次尝试mutex_lock
ok: RET -- 返回调用者 进入临界区

mutex_lock与enter_region处理的逻辑相似,但也存在一个关键性差异:当无法进入临界区时后者处于忙等待状态,这在进程使用时没有问题,因为考虑到时钟超时作用,会调度其他进程运行,这样迟早拥有锁的进程会运行并解开临界区锁。但是线程上没有时钟停止机制,当线程进入循环忙等待时,它不会让其他线程运行,因此拥有锁的线程将永远没办法完成自己的工作并释放锁了。因此在线程在获取锁失败时通过调用thread_yield将CPU交给其他线程,当下次运行时再次测试mutex_lock,这样就消除了忙等待。由于thread_yield只是用户空间对线程调用程序的一次调用,不涉及内核操作,因此运行十分快捷。

2.3.7 管程

有了信号量和互斥量,进程间通信仿佛已经足够了,实际上并非如此。针对上文信号量描述部分,考虑一种情形:对于生产者进程,假设此时缓冲槽数量已满,如果将mutex与empty的down操作互调,那么mutex变为0且执行至empty=0时down操作被阻塞。此时执行消费者进程,对mutex=0操作时进程被阻塞,现在两个进程将无效期的阻塞下去。这种状态称为死锁。为更容易编写正确的程序,管程的概念被提出。管程有一个很重要的特性,即同一时刻管程中仅有一个活跃进程,当有其他进程调用管程时将被挂起。管程实现进程互斥的常用方法是互斥量或二元信号量。管程中的互斥是有编译器完成的,因此出错的可能性要小得多。
虽然管程提供了一种实现互斥的简便方案,但这还不够,我们还需要
一种办法告知管程进程在未通过互斥检查时如何被阻塞。如将生产者中的full empty测试放入管程中完成,当发生缓冲槽满的时候要如何阻塞呢?

monitor ProduceConsume
    condition full,empty -- 条件变量
    function insert(item) -- 生产者调用(消费者基本一致)
        if count == N then
            wait(full)
        end
        insert_item(item)
        count = count + 1
        if count == 1 then
            singal(empty)
        end
    end
end monitor

解决的方法是使用条件变量及配套的wait与signal操作。可以发现管程中的wait、signal操作与之前的sleep、wakeup操作十分类似,但后者会引起严重的竞争条件,因为当进程想要sleep时(满足sleep条件但还未执行sleep)另一个进程却尝试唤醒它。管程不存在这一问题,因为其自动互斥特性保证了:当执行wait时,其他进程无法进入管程,即使wait执行完了,在当前活跃线程还未从管程中离开之前,其他进程都无法在管程里操作。实际上管程可以理解为:对进程操作的监管保护。

2.3.8 消息传递

管程很不错,但是除了少数几种编程语言之外无法使用。我们需要操作系统层面能够提供的方法。消息传递使用两条原语send和receive,它们像信号量而不是管程,是系统调用而不是语言成分。send向一个给定的目标发送一个消息,receive从一个指定的源接受消息。如果没有消息可用,receive会被阻塞直到一条信息到达,或者带着错误码立刻返回。
消息传递系统有很多信号量和管程不涉及的问题,特别是网络上位于不同机器上的进程间通信的情况。如消息有可能被网络丢失,发送方与接收方可达成以下协议:一旦接收到消息接收方向发送方会动一条特殊确认消息,如果发送方一段时间内未接受到确认信息,则重新发送。下面我们来看如何使用消息传递机制而不是共享内存实现生产者-消费者模式。

N n -- 缓冲区总槽数
message m
function producer()
    while true do
        item = produce_item()
        receive(consumer,&m) -- 等待消费者发送空缓冲区信息 如未收到则挂起
        bulid_message(&m,item) -- 建立一个待发送的消息
        send(consumer,&m)
    end
end
function consumer()
    for i=1,n do -- 先给生产者提供所有空槽数
        send(producer,&m)
    end
    while true do
        receive(producer,&m) -- 接收生产者产生的数据消息
        item = extract_message(&m) -- 从数据消息中提取数据项
        send(producer,&m) -- 提取完毕 将空槽信息发送给生产者
        consumer_item(item) -- 消费者根据自己的逻辑处理数据项
    end
end

如果生产者处理速度快,当所有槽都被充满时将被挂起等待消费者发送空缓冲区信息,反之如果消费者处理速度快,则当处理完所有槽的信息后将被挂起并等待生产者发送被填满的缓冲区信息。
进行消息传递,首先需要对消息进行编址,一种编址方法是为每个进程分配一个唯一地址,让消息按照进程地址进行编址;另一种方法是引入一种新的数据结构:信箱。信箱用来对一定数量的消息进行缓冲,当使用信箱时,send和receive调用的地址就是信箱中的地址。当一个进程试图向一个满的信箱发送消息时将被挂起,直到信箱中有信息被取走一腾出新的空间。对于生产者-消费者,均需要创建足够容纳N条消息的信箱。生产者向消费者信箱发送实际数据的信息,消费者向生产者信箱发送空的消息。当使用信箱时,缓冲机制的作用是:目标信箱容纳那些已被发送但还未被目标进程接受的消息。

2.3.9 屏障

屏障机制应用与进程组而非用于类似于生产者-消费者的双进程。有些应用规定,除非所有进程都就绪准备进入下一阶段,否则任何进程都无法进入下一阶段。可通过在每个阶段结尾处设立屏障来实现这种行为。举一个例子:进行矩阵运算时,由于矩阵非常巨大(几百万*几百万),需要使用多进程计算。各进程工作在矩阵的不同部分,除非所有进程都已完成当前子矩阵的运算,否则所有进程都不能进入下一级的矩阵运算。实现这一目标的方法是:当每个进程完成当前过程时执行barrier操作。

猜你喜欢

转载自blog.csdn.net/XIANG__jiangsu/article/details/79462262