Linux 0.11内核分析06:进程同步(部分)

目录

1 进程同步问题引入

1.1 概述

1.2 生产者-消费者同步问题示例

2 从信号到信号量

2.1 使用信号解决同步问题

2.2 将信号扩展为信号量

2.2.1 使用信号的问题

2.2.2 信号量引入

2.2.3 使用信号量解决同步问题

3 通过临界区保护信号量

3.1 临界区问题引入

3.2 临界区的软件实现

3.2.1 临界区代码保护原则

3.2.2 轮换法(值日法)

3.2.3 标记法

3.2.4 Peterson算法(非对称标记法)

3.2.5 Lamport面包店算法

3.3 临界区的硬件实现

3.3.1 关中断

3.3.2 原子指令

4 实验:信号量的使用与实现

4.1 任务目标

4.2 测试程序

4.2.1 共享缓存区布局

4.2.2 程序源码

4.3 实现思路分析

4.3.1 有正有负信号量的实现

4.3.2 只有正数的信号量实现

4.3.3 Linux 2.6内核信号量实例

4.3.4 Linux 2.6内核等待队列实例

4.3.5 Linux 2.6内核完成量实例

4.3.6 Linux 2.6内核条件等待实例

4.4 修改流程

4.4.1 新增系统调用框架

4.4.2 sem_open / sem_unlink系统调用实现

4.4.3 有正有负的sem_wait / sem_post系统调用实现

4.4.4 只有正数的sem_wait / sem_post系统调用实现

5 死锁现象及死锁处理

5.1 死锁现象示例

5.2 死锁必要条件

5.3 死锁处理方法

5.3.1 死锁预防

5.3.2 死锁避免

5.3.3 死锁检测与恢复

5.3.4 死锁忽略


1 进程同步问题引入

1.1 概述

1. 多个进程在操作系统中并发向前推进是操作系统的核心视图之一,多个进程在并发执行过程中,并不一定完全独立,可能会存在相互依赖

这种依赖就是需要在适当的时候查看其他进程的工作情况,然后根据查看结果决定自己是否继续工作

2. 进程同步(process synchronization)的基本结构就是一个进程在需要同步的地方停下来等待依赖进程,当依赖进程完成了和同步对应的工作之后,等待的进程再继续向前运行执行。正是这些等待和唤醒实现了进程之间的相互依赖

3. 进程同步的目的就是实现进程之间合理有序地推进

1.2 生产者-消费者同步问题示例

生产者-消费者问题是最典型的进程同步问题,示例如下,

说明1:生产者进程与消费者进程共享的数据定义如下

// 共享数据元素类型

typedef struct {...} item;



// 共享缓存区数组

#define BUFFER_SIZE 10

item buffer[BUFFER_SIZE];

// 共享缓存区写下标

int in = 0;

// 共享缓存区读下标

int out = 0;

// 共享缓存区元素个数

int counter = 0;

说明2:需要注意的是,共享缓存区必须位于生产者进程和消费者进程都能访问到的共享内存中,而不能是程序中定义的全局变量。因为程序中定义的全局变量在不同进程中会各自的副本,并非在进程间共享

2 从信号到信号量

2.1 使用信号解决同步问题

定义2个信号操作句柄empty和full,并且在这2个信号上进行等待和唤醒,具体语义如下,

1. empty信号

① 生产者进程在缓存区已满时,在empty信号上等待

② 消费者进程在消费一个元素之后,如果缓存区元素个数为BUFFER_SIZE - 1(也就是消费前缓存区已满),则在empty信号上进行唤醒操作

2. full信号

① 消费者进程在缓存区为空时,在full信号上等待

② 生产者进程在生产一个元素之后,如果缓存区元素个数为1(也就是生产前缓存区为空),则在full信号上进行唤醒操作

说明:需要特别注意的是,emptyfull信号是根据counter的计数情况进行唤醒操作,此时并不一定有进程在等待

以empty信号为例,当缓存区已满时,不一定有生产者继续生产从而在empyt信号上等待。但是消费者在消费了一个元素之后,依然会在empty信号上进行唤醒操作

2.2 将信号扩展为信号量

2.2.1 使用信号的问题

信号虽然提供了进程等待和唤醒的机制,但是无法对等待进程进行计数,从而导致进程无法被正确唤醒。假设有如下执行序列,

1. 在缓存区满时,有2个生产者进程执行,

① 生产者进程P0执行,由于(counter == BUFFER_SIZE)条件成立,P0在empty信号上等待

② 生产者进程P1执行,由于(counter == BUFFER_SIZE)条件仍然成立,P1也在empty信号上等待

2. 消费者进程C消费一个元素之后,由于(counter == BUFFER_SIZE - 1)条件成立,C在empty信号上进行唤醒操作,将生产者进程P0唤醒

3. 消费者进程C再次消费一个元素之后,由于(counter == BUFFER_SIZE - 1)条件不成立,C不会在empty信号上进行唤醒操作,从而导致虽然缓存区有空闲,但是生产者P1无法被唤醒

说明:此处有一个假设,就是每次在信号上进行唤醒操作时,只能唤醒一个进程。如果在信号上进行唤醒的语义是唤醒所有等待的进程,则可以避免该问题

2.2.2 信号量引入

1. 信号量(semaphore)是在信号实现等待和唤醒的基础上,又关联了一个变量,使用该变量记录在信号量上等待的进程个数。通过这个变量,就可以决定进程的等待和唤醒时机

2. 信号量数据结构伪代码如下,

struct semaphore {

    // 信号量数值,用来记录资源个数或进程个数

    int value;

    // 等待在信号量上的进程队列

    PCB *queue;

};

3. 在信号量上有两种操作,分别进行加1和减1,伪代码如下,

// P操作为减1操作,即消费资源

P(semaphore s)

{

    s.value--;

   

    // value--之后资源值为复数,说明资源不足,已经欠资源

    // 此时消费者进程在等待队列上等待

    if (s.value < 0)

        sleep_on(s.queue);  

}



// V操作为加1操作,即生产资源

V(semaphore s)

{

    s.value++;

   

    // value++之后资源值非正,说明有进程因资源不足在等待队列上等待

    // 此时生产者进程在等待队列上进行唤醒操作

    // 此处语义是唤醒等待队列上的一个进程

    if (s.value <= 0)

        wake_up(s.queue);

}

2.2.3 使用信号量解决同步问题

1. empty和full信号量用于处理同步

① empty信号量表示缓存区中的空闲位置,因此初始值为BUFFER_SIZE

② full信号量表示缓存区中已生产的资源数量,因此初始值为0

2. mutex信号量用于处理互斥,因此初始值为1,即同一时间只有一个进程可以操作缓存区

说明:为什么有了同步还需要处理互斥?

因为在缓存区非空但是仍有空闲时,生产者进程和消费者进程是都可以继续执行的,因此需要对缓存区的操作进行互斥

3 通过临界区保护信号量

3.1 临界区问题引入

1. 信号量的数值非常重要,只有信号量的数值是正确的,才能正确地使用信号量来决定进程的同步

2. 但是信号量要被多个进程共享操作,有些调度顺序可能导致共享的信号量出现语义错误。如下图所示,希望的结果是将empty的值修改为-3,但是根据图示的调度顺序empty的值将被修改为-2。这类错误由多个进程并发操作共享数据引起,错误和调度顺序有关,难以发现和调试

3. 一种直观的想法,就是将对信号量的修改作为原子操作,也就是每个进程对信号量的修改要么不做,要么全部做完,中途不能被打断。而要作为原子操作被保护的区域,就是临界区(critical section)

4. 在引入了临界区的概念后,实现进程间同步的思路就是,

① 通过临界区保护信号量

② 通过信号量实现进程间的同步

3.2 临界区的软件实现

3.2.1 临界区代码保护原则

3.2.1.1 互斥进入

1. 如果有多个进程要求进入空闲的临界区,一次只允许一个进程进入

2. 在任何时候,一旦已经有进程进入临界区,其他所有试图进入相应临界区的进程都必须等待

3. 互斥进入是临界区临界区的基本原则

3.2.1.2 有空让进

1. 如果没有进程处于临界区,且有进程请求进入临界区,则应该让该进程进入临界区

2. 有空让进是好的临界区的实现原则

3.2.1.3 有限等待

1. 一个进程在提出进入临界区的请求后,最多需要等待临界区被使用有限次之后,该进程就可以进入临界区。即任何一个进程对临界区的等待时间都是有限的,不会出现因等待临界区而造成的饥饿情况

2. 有限等待也是好的临界区的实现原则

3.2.2 轮换法(值日法)

1. 轮转法(值日法)参考现实生活中值日的场景实现,2个进程交替进入临界区,任何时刻只有一个进程有权进入临界区

① turn为0时,轮到生产者P0执行

② turn为1时,轮到生产者P1执行

③ 进程出临界区时反转turn的值,实现交替执行

2. 算法评价

① 轮换法满足互斥进入原则

如果进程P0和P1都进入临界区,则有turn == 0且turn == 1,这是矛盾的

② 轮换法不满足有空让进原则

假设P0先进入临界区,即使当前临界区空闲,也需要P1进入一次临界区之后,P0才可以再次进入临界区

③ 轮换法不满足有空让进原则,自然也就无法满足有限等待原则

3.2.3 标记法

1. 标记法参考现实生活中留便条的场景实现,要进入临界区的进程先留下标记,然后检查其他进程是否留下标记,如果其他进程没有留下标记,则进入临界区执行

2. 算法评价

① 标记法满足互斥进入原则

如果进程P0和P1都进入临界区,则有flag[0] == true且flag[0] == false,同时flag[1] == false且flag[1] == true,这是矛盾的

② 标记法不满足有空让进原则

考虑如下的执行序列,此时2个进程都设置了标记,进而都会在while处自旋,也就是相互锁住,无法进入临界区

3.2.4 Peterson算法(非对称标记法)

1. 标记法的问题在于2个进程的操作是对称的,因此容易造成互锁。而轮换法是不对称的,因此将标记法和轮换法相结合,就是非对称标记法

2. 算法评价

① Peterson算法满足互斥进入原则

如果进程P0和P1都进入临界区,则有turn == 0且turn == 1,这是矛盾的;同时flag[0]和flag[1]也会有类似的矛盾

② Peterson算法满足有空让进原则

只要一个进程不在临界区,就一定满足另一个进程进入临界区的条件。假设P1不在临界区,则flag[1] == false或者turn == 0,此时P0一定可以进入临界区

③ Peterson算法满足有限等待原则

任何请求进入临界区的进程至多等待一次其他进程使用临界区之后,就可以进入临界区

3.2.5 Lamport面包店算法

1. Peterson算法只能处理2个进程的临界区,如果将场景扩展到多进程,就需要使用面包店算法。该算法参考现实生活中排队进入面包店的场景实现,仍然是轮转法和标记法的结合

① 轮转法:每个进程都获得一个序号,并且让序号最小的进程进入临界区

② 标记法:进程离开时将序号置为0,不为0的序号就是标记(选号的过程就相当于标记)

2. 算法评价

① 面包店算法满足互斥进入原则

只有序号最小的进程才能进入临界区

② 面包店算法满足有空让进原则

如果临界区空闲,则申请进入临界区的进程一定可以获取最小的序号(也至于这一个进程获取序号),因此可以进入临界区

③ 面包店算法满足有限等待原则

申请进入临界区的进程都会得到一个序号,因此最多需要等待比当前序号小的所有进程各自使用一次临界区,该进程即可进入临界区

3.3 临界区的硬件实现

面包店算法虽然能处理多进程临界区,但是算法复杂,效率较低;同时还需要处理序号溢出的问题。在计算机系统中,当软件实现变得很复杂时,通常会想到使用硬件来简化操作,提高效率。这也看出操作系统的设计需要软硬件协同

3.3.1 关中断

3.3.1.1 实现方法

使用关中断的方法实现临界区非常简单,就是在进入临界区之前关中断,然后在退出临界区时开中断

说明:如果是通过关中断实现临界区,这里有一个隐含的背景,就是该临界区位于内核态。因为cli和sti指令属于特权指令,在特权级为3的用户态无法执行

3.3.1.2 为什么关中断可以实现临界区?

1. 临界区保护的要义,就是确保只有一个执行流可以进入临界区执行,在前一个执行流没有退出临界区时,其他执行流不能进入临界区。在单核CPU + Linux 0.11内核的场景中,有如下的执行流,

进程(可以在用户态执行,也可以因为系统调用等异常陷入内核态执行)

需要注意的是,在Linux 0.11内核中没有内核线程。内核线程有自己的task_struct结构(但是只有内核地址空间,没有用户地址空间),不严格地说,内核线程就是只在内核态运行的"进程"

中断处理函数(Linux 0.11内核中没有中断顶半部和底半部的概念,这里的中断处理函数相当于顶半部的角色)

2. 如果关中断能实现临界区,就需要能够阻止上述两种执行流进入临界区

① 关中断之后,已进入临界区的进程不会被抢占调度

  • 根据时间片进行的抢占式调度在时钟中断处理函数do_timer中进行,关中断之后,时钟中断也被关闭,因此不会有抢占式调度
  • 此时除非进入临界区的进程主动调用schedule函数,系统不会切换到其他进程执行
  • 结合上文分析,由于临界区处于内核态,而Linux 0.11内核不允许内核态抢占,所以进程只要陷入内核态执行,就不会被切换走。所以即使进程在内核态的临界区不关中断,也不会其他进程抢占,这也是Linux 0.11内核没有用户态信号量机制的一个原因

② 关中断之后,自然也不会有中断处理函数执行

说明1:关中断指令cli无法关闭异常,所以从严格意义上说,关中断无法处理异常处理函数和进程内核态之间的临界区互斥。但是这种场景在Linux 0.11内核中一般是不存在的

说明2:临界区场景分析

结合上文分析,在Linux 0.11内核中,有如下2种临界区场景,

① 进程间内核态之间的临界区

这种临界区由于Linux 0.11内核不支持内核态抢占,所以无需处理

② 进程内核态与中断处理函数之间的临界区

这种临界区通过关中断可以处理

说明3:Linux 0.11内核临界区场景实例

结合上文对Linux 0.11内核临界区场景的理解,我们以对task数组的访问作为实例进行分析

① task数组定义在kernel/sched.c文件中,是一个全局数组,用于管理系统中的所有进程。通过检索内核代码,task数组的访问没有任何互斥处理

② 对task数组的访问有2条路径,

  • 系统调用(e.g. fork系统调用),也就是进程内核态执行流
  • 中断处理函数(e.g. tty_intr函数)

其中需要特别注意的就是schedule函数,该函数会访问task数组,而且schedule函数既可能通过系统调用被调用,也可能在中断处理函数中被调用(而唯一调用schedule函数的中断处理函数,就是时钟中断处理函数do_timer)

③ 对于进程内核态之间,由于Linux 0.11内核不支持内核态抢占,所以无需处理

④ 对于进程内核态与中断处理函数之间,还是由于Linux 0.11内核不支持内核态抢占,当被中断的进程处于内核态时,do_timer函数中不会调用schedule函数,因此也就避免了这种情况下的临界区,从而也就无需处理

结合这2点,Linux 0.11内核对task数组的访问就可以不做任何互斥处理

说明4:有进程间用户态之间的临界区吗?

① 首先,进程之间的用户态是通过内存管理机制相互隔离的

如果进程在用户态可以访问进程间的共享数据,则对该共享数据的访问就是临界区,就需要进行互斥。在Linux 0.11内核的原生代码中没有这种场景,但是在后续引入进程间共享内存的实验后,就有了这种场景,因此需要处理互斥问题

3.3.1.3  讨论1:关中断之后不应中途退出临界区

1. 从概念上说,在进入临界区之后,就不应该中途退出临界区,否则就破坏了临界区原子操作的语义

2. 如果不经过退出区中途退出临界区,可能会导致其他进程无法再进入临界区

当然,如果中途退出临界区之后能够再返回继续执行,则仍可以完成退出区的操作

3.3.1.4 讨论2:为什么Linux 0.11在关中断后退出"临界区"

1. 在Linux 0.11内核中,就存在关闭中断后调用schedule进行进程切换,之后再返回继续执行并打开中断的场景(而且还很常见)。下图为文件系统中等待inode读写操作的场景,

2. 首先说明在Linux 0.11内核中为什么可以在关中断的情况下调用schedule函数进行进程切换?

① 因为Linux 0.11内核中只有进程,没有内核线程,所以调用schedule函数之后肯定会切换到另一个进程执行。切换到目标进程后,可能是处于内核态也可能是处于用户态,但是最终都是要返回用户态执行。而在用户态肯定是开中断的(0号进程开中断 + 用户态无法关中断),所以可以继续响应中断,也就仍可以进行进程调度

② 当切换回调用sleep_on函数的进程继续执行时,处理器处于关中断状态,之后调用sti函数开中断

③ 调用wait_on_inode函数的进程由于需要等待磁盘操作完成,当前确实无法再继续执行,因此也需要被切换走

说明:在有内核线程的场景中,如果在关中断的情况下切换到内核线程,而该内核线程没有开关中断的操作(比如极端一点儿,假设这个内核线程就是一个while(1)空循环),那么处理器此时就再也无法响应中断了

因此在后续的内核版本中,不允许在中断顶半部调用schedule函数。当然这里还涉及中断处理框架的问题,相关讨论可参考X86汇编语言从实模式到保护模式18:中断和异常的处理与抢占式多任务 chapter 4.2.4

3. 接着再讨论在关中断情况下调用schedule函数的操作是否破坏了原子操作的语义

① 从严格意义上说,如果将cli & sti函数之间的区域作为临界区,这种在中途进行进程切换的操作肯定是破坏了原子操作语义的

② 如果从要保护的资源上说,要保护的资源就是i_lock变量和i_wait等待队列,需要处理进程内核态与中断处理函数之间的互斥。由于wait_on_inode函数在操作等待队列之前已经关中断,所以可以处理互斥问题

③ 由于操作等待队列实际是在sleep_on函数中进行,因此不能交换sleep_on函数和sti函数的顺序,也就是在等待队列操作完成之前不能开中断

4. 最后再讨论在关中断情况下调用schedule函数的操作是否会影响其他进程进入临界区

在切换到其他进程执行后,仍然可以陷入到内核态调用wait_on_inode函数,也就是仍然可以进入临界区

说明:从上面的分析可以看出,分析互斥问题的关键,就是理清要解决哪些执行流之间的互斥问题

3.3.2 原子指令

1. 通过关中断实现临界区只在单核系统中有效,在多核系统中无效

因此cli关中断指令只能关闭当前CPU核的中断,但是对其他CPU核没有影响,因此无法阻止其他CPU核上的执行流进入临界区

2. 多核系统中需要通过原子指令来实现临界区

假设有如下的TestAndSet原子指令,能够将对变量的检查和赋值以原子的方式执行,则可以使用该原子指令实现多核系统中的临界区

说明:Linux内核中的自旋锁就使用类似TestAndSet的思路实现

3. 原子指令一般由处理器体系结构提供

① 在i386体系结构中为lock前缀

② ARMv8体系结构中为ldxr / stxr等内存独占访问指令

4 实验:信号量的使用与实现

4.1 任务目标

1. 在Linux 0.11中添加如下系统调用

/*

* sem_open - 创建一个信号量,或打开一个已经存在的信号量

* name: 信号量的名字,不同进程可以通过同样的name来共享同一个信号量

*       如果该信号不存在,就创建一个名为name的信号量

*       如果该信号量存在,就打开已经存在的名为name的信号量

* value: 信号量初值,仅当创建信号量时,该参数才有效;其余情况则忽略

* 返回值: 成功,则返回信号量地址;失败,则返回(sem_t *)-1,并设置errno

*/

sem_t *sem_open(const char *name, unsigned int value);



/*

* sem_wait: 信号量的P操作

* sem: 要操作的信号量

* 返回值: 成功,则返回0;失败,则返回-1,并设置errno

*/

int sem_wait(sem_t *sem);



/*

* sem_post - 信号量的V操作

* sem: 要操作的信号量

* 返回值: 成功,则返回0;失败,则返回-1,并设置errno

*/

int sem_post(sem_t *sem);



/*

* sem_unlink - 删除名为name的信号量

* name: 信号量的名字,只有当name对应的信号量引用计数为0时,才会实际删除

* 返回值: 成功,则返回0;失败,则返回-1,并设置errno

*/

int sem_unlink(const char *name);

说明:关于sem_open系统调用的返回值

sem_open的系统调用封装例程由_syscall2宏构造,当系统调用的返回值小于0时,系统调用封装例程将返回-1。而sem_open系统调用封装例程的返回值类型为sem_t *,因此该系统调用失败时返回(sem_t *)-1

2. 利用上面实现的系统调用,编写测试程序模拟经典的生产者-消费者模型,其中,

① 共享缓存区只能存放10个数

② 有1个生产者,向共享缓存区写入0 ~ 24共25个数

③ 有5个消费者,每个读取5个数并打印

如果信号量机制实现正常,则无论哪个消费者取出0 ~ 24中的哪个数,最终的结果都应该按序输出0 ~ 24(需要将打印语句也放置在临界区中,从而避免进程调度的影响)

4.2 测试程序

4.2.1 共享缓存区布局

由于Linux 0.11内核中没有实现共享内存机制,因此使用文件来模拟共享缓存区,具体布局如下,

说明:在通过文件模拟共享缓存区的过程中,需要通过lseek函数精确地控制文件读写偏移量

4.2.2 程序源码

#include <unistd.h>

#include <sys/types.h>

#include <stdio.h>

#include <stdlib.h>

#include <fcntl.h>

#include <sys/stat.h>

#include <semaphore.h>

#include <sys/types.h>

#include <sys/wait.h>



// 共享缓冲区元素个数

#define BUFFER_SIZE 10



int main(void)

{

    // 处理同步与互斥的信号量

    sem_t *empty = NULL;

    sem_t *full = NULL;

    sem_t *mutex = NULL;

    // 模拟共享缓存区的文件描述符

    int fd = -1;

    // 共享缓存区读写索引

    int in = 0;

    int out = 0;

    int data = 0;

    pid_t pid = -1;

    int i = 0;

    int j = 0;



    // 设置共享缓存区读写索引初值,初值均为0

    fd = open("buffer.txt", O_CREAT | O_TRUNC | O_RDWR, 0644);

    lseek(fd, BUFFER_SIZE * sizeof(int), SEEK_SET);

    write(fd, &in, sizeof(int));

    // 写入in索引之后,文件偏移量自动变为(BUFFER_SIZE + 1) * sizeof(int)

    write(fd, &out, sizeof(int));

       

    // 验证共享缓存区读写索引初值

    lseek(fd, BUFFER_SIZE * sizeof(int), SEEK_SET);

    read(fd, &in, sizeof(int));

    printf("initial in = %d\n", in);

    lseek(fd, (BUFFER_SIZE + 1) * sizeof(int), SEEK_SET);

    read(fd, &out, sizeof(int));

    printf("initial out = %d\n", out);



    // 打开/创建信号量

    empty = sem_open("empty", O_CREAT, 0666, BUFFER_SIZE);

    full = sem_open("full", O_CREAT, 0666, 0);

    mutex = sem_open("mutex", O_CREAT, 0666, 1);



    // 验证信号量初值

    sem_getvalue(empty, &data);

    printf("initial empty = %d\n", data);

    sem_getvalue(full, &data);

    printf("initial full = %d\n", data);

    sem_getvalue(mutex, &data);

    printf("initial mutex = %d\n", data);



    if (!fork()) {

        // 创建生产者子进程

        // 只有一个生产者子进程

        printf("producer process[%d] start\n", getpid());

       

        // 生产25个数

        // 使用生产者子进程的i变量

        for (i = 0; i < 25; ++i) {

            sem_wait(empty);

            sem_wait(mutex);



            // 读取in索引

            lseek(fd, BUFFER_SIZE * sizeof(int), SEEK_SET);

            read(fd, &in, sizeof(int));



            // 向共享缓存区写入数据

            lseek(fd, in * sizeof(int), SEEK_SET);

            write(fd, &i, sizeof(int));



            // 更新in索引

            in = (in + 1) % BUFFER_SIZE;

            lseek(fd, BUFFER_SIZE * sizeof(int), SEEK_SET);

            write(fd, &in, sizeof(int));



            printf("========producer %d ========\n", i);



            sem_post(mutex);

            sem_post(full);

        }

       

        printf("producer process[%d] end\n", getpid());



        // 终止生产者子进程

        // 此处的终止行为是非常关键的,如不终止,生产者子进程将继续向下执行

        return 0;

    }



    // 创建5个消费者子进程

    // 使用父进程的i变量

    for (i = 0; i < 5; ++i) {

        if (!fork()) {

            printf("consumer process[%d] start\n", getpid());

               

            // every child process read 10 elements

            // 每个子进程消费5个数

            for (j = 0; j < 5; ++j) {

                sem_wait(full);

                sem_wait(mutex);



                // 读取out索引

                lseek(fd, (BUFFER_SIZE + 1) * sizeof(int), SEEK_SET);

                read(fd, &out, sizeof(int));



                // 从共享缓存区读取数据

                lseek(fd, out * sizeof(int), SEEK_SET);

                read(fd, &data, sizeof(int));



                // 更新out索引

                out = (out + 1) % BUFFER_SIZE;

                lseek(fd, (BUFFER_SIZE + 1) * sizeof(int), SEEK_SET);

                write(fd, &out, sizeof(int));



                printf("consumer[%d]: %d\n", getpid(), data);



                sem_post(mutex);

                sem_post(empty);

               

                // 消费者子进程延时100ms

                // 使得多个消费者子进程可以交替执行

                usleep(100 * 1000);

            }



            printf("consumer process[%d] end\n", getpid());



            // 终止消费者子进程

            return 0;

        }

    }



    // 在父进程中等待所有子进程终止

    for (i = 0; i < 6; ++i) {

        wait(NULL);

        printf("wait %d child process\n", i);

    }



    // 在父进程中释放资源

    close(fd);

    sem_unlink("empty");

    sem_unlink("full");

    sem_unlink("mutex");



    return 0;

}

测试程序执行结果如下图所示,可见5个消费者依序消费了共享缓存区中的数据

说明1:编译测试程序时,需要通过-lphtread参数链接pthread动态库,否则无法链接POSIX信号量相关函数

说明2:POSIX版本的sem_open函数比我们要实现的sem_open函数多2个参数,具体如下,

所需头文件

#include <semaphore.h>

函数原型

sem_t *sem_open(const char *name, int oflag, .../* mode_t mode,

                             unsigned int value */);

函数参数

name:信号量的名字

oflag:使用信号量的参数,e.g. O_CREAT

mode:信号量权限

value:信号量初始值

函数返回值

若成功,返回指向信号量的指针;否则,返回SEM_FAILED,也就是(sem_t *)(-1)

说明3:关于sem_open函数的oflag参数

① 当使用一个现有的命名信号量时,只需要传递2个参数:信号量的名字和oflag参数的0值

② 当oflag参数有O_CREAT标志时,如果命名信号量不存在,则创建一个新的;如果他已经存在,则会被使用,但是不会进行额外的初始化

③ 如果指定了O_CREAT标志,则需要提供2个额外的参数,

  • mode参数指定谁可以访问信号量,取值和打开文件的权限位相同
  • value参数指定信号量的初始值

④ 如果oflag参数指定为O_CREAT | O_EXCL,则如果信号量已经存在,会导致sem_open调用失败

说明4:打开和关闭信号量的时机

测试程序是在父进程中同一打开和关闭信号量,但是也可以在各子进程中分别打开和关闭信号量,如下图所示。经过验证,程序也可正常运行

由于写时复制机制,不同子进程操作的是各自的empty / full / mutex变量

说明5:文件模拟的共享缓存区状态验证

测试程序运行完成后,文件模拟的共享缓存区最终状态如下,数据区和索引区状态符合预期

4.3 实现思路分析

4.3.1 有正有负信号量的实现

4.3.1.1 概述

1. 有正有负的信号量属于标准的信号量实现形式

2. sys_sem_wait和sys_sem_post系统调用的核心是对内核中的一个整型变量进行操作,并根据整型变量的数值决定是否要进行进程的睡眠或唤醒

3. 进程的睡眠和唤醒在一个等待队列上进行

4.3.1.2 伪代码

int sys_sem_wait(sem_t *sem)

{

    // 进入临界区

    // 具体的实现方式可以是软件的,也可以是硬件的

    enter_critical();

   

    sem->value--;

    if (sem->value < 0) {

        // 将当前进程阻塞

        current->state = SLEEP;

        // 将当前进程加入等待队列队尾

        enqueue(current, sem->wait_queue);

        // 调用schedule函数切换到其他进程执行

        schedule();

    }

   

    // 退出临界区

    exit_critical();

   

    return 0;

}



int sys_sem_post(sem_t *sem)

{

    task_struct *p = NULL;

   

    // 进入临界区

    enter_critical()

   

    sem->value++;

    if (sem->value <= 0) {

        // 从等待队列队首取出一个进程p

        p = dequeue(sem->wait_queue);

        // 将取出的进程设置为就绪态

        // 如果有就绪队列,则将进程p加入就绪队列

        p->state = READY;

    }

   

    // 退出临界区

    exit_critical()

   

    return 0;

}

说明1:sem->value值的含义

① sem->value >= 0时,表示现在有的资源个数

② sem->value < 0时,表示有多少个进程在该信号量上等待

说明2:进程唤醒逻辑

在上述实现中,进程睡眠从队尾入队,进程唤醒从队首出队。因此,进程的唤醒逻辑是按进程的睡眠先后顺序进行的

4.3.2 只有正数的信号量实现

4.3.2.1 概述

1. 在上一节有正有负信号量的实现中,是按进程睡眠的先后顺序进行唤醒,与进程的优先级无关

2. 如果想在唤醒时考虑等待进程的优先级,可以在将进程加入等待队列时按优先级进行排序,那么此处就需要实现一个复杂的调度算法。但是还有一种简单的做法,就是在释放信号量时唤醒等待队列上的所有进程,然后由schedule调度算法来决定让哪个进程获得信号量

3. 由于每次都是将阻塞队列上的所有进程都唤醒,因此就没有必要记录在信号量上进行等待的进程个数,也就不需要sem->value < 0的情况

4.3.2.2 伪代码

int sys_sem_wait(sem_t *sem)

{

    // 进入临界区

    enter_critical();

   

    // 没有资源则加入等待队列

    while (sem->value == 0) {

        // 将当前进程加入等待队列

        enqueue(current, sem->wait_queue);

        schedule();

    }

   

    // 有资源则消费

    sem->value--;

   

    // 退出临界区

    exit_critical();



    return 0;

}



int sys_sem_post(sem_t *sem)

{

    // 进入临界区

    enter_critical();

   

    sem->value++;

    // 如果等待队列非空,则唤醒等待队列上的所有进程

    if (is_not_empty(sem->wait_queue)) {

        wake_up_all(sem->wait_queue);

    }

   

    // 退出临界区

    exit_critical();



    return 0;

}

说明1:while (sem->value == 0)处理逻辑

由于sys_sem_post函数中是将等待队列上的所有进程唤醒,因此被唤醒的进程需要重新判断信号量条件,以检查自己是否得到了信号量

说明2:Linux 0.11内核中的互斥操作就使用了类似只有正数信号量的逻辑,以lock_inode函数为例,当进程被唤醒后需要重新判断i_lock的值,如果已经被其他进程上锁,则当前进程继续睡眠;如果未被其他进程上锁,则当前进程将其上锁

需要特别注意的是,此处的i_lock变量只有0和1两个值,也就是只能标识上锁还是未上锁,并不具备记录资源数量的功能,因此只能实现互斥,无法实现信号量的语义

说明3:与lock_inode函数对应的解锁操作如下,该函数在将i_lock变量解锁后,会调用wake_up函数将等待队列上的进程全部唤醒

需要注意的是,wake_up函数实现的不是同时唤醒所有进程,而是逐级唤醒所有进程,具体实现可参考Linux 0.11内核分析04:多进程视图 chapter 3.2.2.3

说明4:为什么解锁函数不需要关中断?

首先需要注意的是,这里要保护的主要是对等待队列的保护

① 如果是在中断处理函数中解锁,由于中断处理函数能够执行,说明没有进程在内核态关中断,因此也就没有互斥的问题

② 如果是在进程内核态解锁,出于如下2个原因,也没有互斥的问题

  • 调用lock_inode函数的进程已经完成了对临界资源的实际保护
  • 由于Linux 0.11内核实现的是互斥语义,只有一个进程能在内核态调用unlock_inode函数,而且解锁过程中该进程不会主动放弃CPU

此处其实还依赖一个条件,就是不会在中断处理函数和进程内核态调用unlock_inode函数对同一把锁进行解锁操作,否则还是需要关中断进行保护的

4.3.3 Linux 2.6内核信号量实例

4.3.3.1 信号量数据结构

信号量数据结构定义在include/linux/semaphore.h文件中

说明:semaphore结构体中的count字段在实现中不会出现负数,但是与上文中"只有正数的信号量实现"逻辑不同,详见下文分析

4.3.3.2 信号量定义操作

信号量定义操作在include/linux/semaphore.h文件中实现,有如下2种实现方式

1. 定义同时初始化

使用__SEMAPHORE_INITIALIZER与DECLARE_MUTEX宏可以在定义信号量变量的同时进行初始化,其中DECLARE_MUTEX宏定义的就是初始值为1的信号量

2. 定义后初始化

也可以先定义信号量变量,之后再调用sema_init函数进行初始化

4.3.3.3 信号量等待操作

信号量等待操作由down函数实现,该函数定义在kernel/semaphore.c文件中。可以看出,在操作过程中sem->count的值不会为负数

说明1:为什么要使用可嵌套关中断操作?

① spin_lock_irqsave函数在关中断时,会将当前的中断状态保存在flags变量中,之后在spin_unlock_irqrestore函数中会使用flags变量恢复调用down函数时的中断状态。这样可以确保down函数返回时,调用者的中断开关状态不变

② 因为down函数可能会导致睡眠,所以一般不会在关中断的情况下调用,因此使用可嵌套关中断操作的作用不明显。该操作在up函数中更有用,详见下文分析

说明2:__down函数分析

① __down函数会调用__down_common函数进入睡眠,调用时要传递睡眠时的状态与睡眠超时时间,其中

  • 睡眠状态为不可中断睡眠,也就是不会被信号唤醒,只能被wake_up_process函数指定唤醒
  • 睡眠超时时间为MAX_SCHEDULE_TIMEOUT,在schedule_timeout函数中会将该宏处理为不设置超时时间,也就是死等

② __down_common函数用于统一处理进程的睡眠与唤醒状态判断,其中的for循环用于在进程被唤醒后检查唤醒原因

说明3:down函数衍生操作

除了down函数,对信号量的sem_wait操作还有down_interruptible / down_killable /  down_timeout / down_trylock

其中除了down_trylock函数用于实现非阻塞操作,其他函数的区别就在于调用__down_common函数时传递的等待状态和睡眠时间不同

说明4:关于带超时等待的实现

① 在down_timeout函数的实现中,__down_common函数传递的睡眠状态为TASK_UNINTERRUPTIBLE,因此在超时之前进程不会被信号唤醒

② 在schedule_timeout函数中会根据超时时间启动定时器任务,并且在定时器任务回调函数中唤醒睡眠的进程

4.3.3.4 信号量唤醒操作

信号量唤醒操作由up函数实现,该函数也定义在kernel/semaphore.c文件中

说明1:在up函数中也使用可嵌套的关中断操作

① 因为up函数可能在中断顶半部被调用,也就是在关中断的情况下被调用。该操作可以确保在中断顶半部调用up函数后,中断不会被错误打开

② 需要注意的是,up函数不会导致睡眠

说明2:__up函数分析

在信号量的实现中,是从等待队列的队尾入队,从队首出队,按进程入队的顺序唤醒,没有考虑等待进程的优先级

说明3:信号量的实现有2处特性

① 设置超时时间进行等待时不能接收信号(也就是可设置的等待样式太少)

针对这一特性,本文增加对完成量的分析作为补充

② 等待队列按入队顺序唤醒,没有考虑进程的优先级

针对这一特性,本文增加条件等待的分析作为补充

又由于完成量和条件等待都基于等待队列实现,因此先对等待队列进行分析

4.3.4 Linux 2.6内核等待队列实例

4.3.4.1 等待队列数据结构

等待队列相关的数据结构定义在include/linux/wait.h文件中,主要有如下2个,

1. 等待队列头wait_queue_head_t

2. 等待队列wait_queue_t

说明:wait_queue_head_t用于组织等待队列,wait_queue_t加入wait_queue_head_t中的链表实现等待

4.3.4.2 等待队列定义操作

等待队列定义操作在在include/linux/wait.h文件中实现,有如下2种实现方式

1. 定义同时初始化

说明1:default_wake_function是wait_queue_t的默认唤醒操作函数

说明2:内核代码中一般不直接使用DECLARE_WAIT_QUEUE_HEAD宏,而是使用DECLARE_WAIT_QUEUE_HEAD_ONSTACK宏定义并初始化wait_queue_head_t

2. 定义后初始化

说明:在初始化wait_queue_t结构体的函数中

① init_waitqueue_entry函数主要用于设置等待进程的task_struct结构

② init_waitqueue_func_entry函数用于设置唤醒操作函数,替换默认的default_wake_function函数

4.3.4.3 等待队列加入操作

等待队列加入操作的要点有2个,

1. 加入等待队列的flags标志

是否有WQ_FLAG_EXCLUSIVE标志

2. 加入等待队列的位置

是加入队首,还是加入队尾

根据上述2个要点,可以组合出4种加入方式,相应的操作函数如下,

说明1:上述函数只是实现将wait_queue_t(背后是要进入睡眠的进程)加入wait_queue_head_t等待队列,并没有实现进程睡眠的部分,睡眠操作由使用等待队列机制的各种同步机制完成(e.g. 完成量,条件等待)

内核也提供了一组sleep_on函数,可用于实现在等待队列上的睡眠

上述函数最终调用sleep_on_common函数实现睡眠,只是传递的参数不同。可以看出,通过sleep_on_common的返回值可以得到等待是否超时,但是无法得到等待是否是被信号唤醒

说明2:__开头的等待队列加入函数没有上自旋锁进行保护,处理方式如下,

① 可以调用外层封装函数,例如add_wait_queue函数,这些函数中进行了互斥处理

② 调用者也可以自行处理互斥,便可直接调用以__开头的等待队列加入函数

说明3:不同的flags标志和加入等待队列的位置会影响唤醒操作的效果,详见下文分析

说明4:相应的等待队列退出操作由remove_wait_queue函数完成

① 同样地,以__开头的函数也没有处理互斥,可以调用外层封装的remove_wait_queue函数;也可以由调用者自行处理互斥,然后调用以__开头的函数

② remove_wait_queue函数也只是实现将wait_queue_t(背后是要被唤醒的进程)从wait_queue_head_t等待队列中移出,并没有实现进程唤醒的部分。唤醒操作由使用等待队列机制的各种同步机制完成,例如下面就要分析到的wake_up函数

4.3.4.4 等待队列唤醒操作

1. 根据不同的唤醒方式,有一系列宏可用于等待队列唤醒操作

2. 以最终调用最多的__wake_up函数为例进行分析,不同的等待队列唤醒操作就是向函数传递不同的参数

需要向__wake_up函数传递的参数如下,

① wait_queue_head_t *q

要唤醒的等待队列

② unsigned int mode

  • 要唤醒等待队列中处于哪种状态的进程,其中TASK_NORMAL和TASK_ALL的宏定义如下

  • try_to_wake_up函数中会检查要唤醒进程的状态,如果状态不符,则不会唤醒该进程

③ int nr_exclusive

要唤醒的带有WQ_FLAG_EXCLUSIVE标志的进程个数,唤醒的具体逻辑详见下文对__wake_up_common函数的分析

④ void *key

传递给唤醒操作函数的参数

  • 默认的唤醒操作函数不使用该参数

  • 用户自定义的唤醒操作函数可约定对该参数的使用方法,以child_wait_callback函数为例,使用key传递的就是进程的task_struct结构

  • 与上述示例对应的唤醒函数如下,

3. 等待队列最终的唤醒操作由__wake_up_common函数进行,可见如果唤醒的wait_queue_t带有WQ_FLAG_EXCLUSIVE标志,则最多唤醒nr_exlusive个带有WQ_FLAG_EXCLUSIVE标志的进程

说明:如果传递的nr_exclusive参数为0,由于--nr_exclusive后的值是一个不为零的负数,因此会将带有WQ_FLAG_EXCLUSIVE标志的进程全部唤醒。而不带WQ_FLAG_EXCLUSIVE标志的进程本身就会被唤醒,因此wake_up_all实现的就是将等待队列上的所有处于TASK_NORMAL状态的进程唤醒

4.3.5 Linux 2.6内核完成量实例

4.3.5.1 完成量数据结构

完成量数据结构定义在include/linux/completion.h文件中

说明:与信号量数据结构相比,由于自旋锁的保护在wait_queue_head_t中实现,因此无需在完成量数据结构中定义自旋锁

4.3.5.2 完成量定义操作

完成量定义操作在include/linux/completion.h文件中实现,也是分为定义同时初始化和定义后初始化2种

4.3.5.3 完成量等待操作

1. 完成量等待操作由如下函数完成,可见完成量在设置超时等待的同时也可以接收信号

2. 上述函数通过向wait_for_common函数传递不同的参数实现不同的等待方式,传递的参数为等待超时时间和等待状态

3. wait_for_common函数最终调用do_wait_for_common函数实现等待,这里需要特别注意的是,等待完成量的wait_queue_t结构是带EXCLUSIVE标志且加入队尾

说明:关于do_wait_for_common函数的返回值

① do_wait_for_common函数的返回值需要能够区分如下3种情况,

  • 获取到完成量返回
  • 被信号中断返回,此时会返回-ERESTARTSYS(是一个负数)
  • 超时时间耗尽返回,此时会返回0

那么很自然地,获取到完成量的返回值应该是一个正数,才能正确区分

② 这就涉及对do_wait_for_common函数最后一行的理解,这种三目运算符缺少第2个表达式的用法不常见,我们进行如下实验,可见两种情况下的返回值均为1

由于获取到信号量时的timeout值可能为正数也可能为0,这样就可以确保在获取到完成量后返回1

③ 为了验证上文的分析,我们分析一个内核中对wait_for_completion_interruptible_timeout函数返回值判断的实例

4.3.4.4 完成量唤醒操作

唤醒信号量由complete函数完成,根据传递给__wake_up_common函数的参数,complete函数会唤醒一个处于TASK_NORMAL状态的EXCLUSIVE进程。结合上文对等待操作的分析,完成量也是从等待队列队尾入队,从队首出队,不考虑进程的优先级

说明1:complete_all函数分析

complete_all函数的目的是唤醒完成量等待队列上是所有进程,但是需要特别注意的是,在设置完成量资源值时,并不是根据实际等待的进程数量,而是直接设置为UINT_MAX / 2这么一个非常大的值

也就是说,在调用了complete_all函数后,done的值将不再正确表示资源值,需要调用init_completion函数重新初始化后才能继续使用

说明2:completion_done函数分析

completion_done函数通过done的值判断是否有完成量的等待者

4.3.6 Linux 2.6内核条件等待实例

4.3.6.1 概述

条件等待可以看作是sleep_on系列函数的升级版,主要体现在如下2个方面,

1. 增加条件判断

可以让进程等待到条件满足时才唤醒

2. 提供丰富的等待方式

条件等待提供了wait_event / wait_event_timeout / wait_event_interruptible / wait_event_interruptible_timeout / wait_event_interruptible_exclusive / wait_event_interruptible_locked / wait_event_interruptible_locked_irq / wait_event_interruptible_exclusive_locked_irq / wait_event_killable等一系列等待函数

说明:条件等待通过wake_up系列函数唤醒,因此如果进程不带EXCULSIVE标志被加入等待队列,则会被wake_up函数同时唤醒

4.3.6.2 wait_event函数分析

说明1:autoremove_wake_function函数分析

autoremove_wake_function函数在default_wake_function函数的基础上增加了将wait_queue_t结构移出等待队列的操作

说明2:timeout衍生版本

通过wait_event_timeout的返回值,可以判断等待是否超时

说明3:interruptible衍生版本

① 通过wait_event_interruptible的返回值,可以判断等待是否是被信号唤醒

② 还有一个killable衍生版本,与interruptible衍生版本的差别就是只接收致命信号

说明4:interruptible_timeout衍生版本

通过wait_event_interruptible_timeout的返回值,可以判断等待是否超时,或者是被信号唤醒

说明5:interruptible_exclusive衍生版本

该衍生版本的特征,是wait_queue_t结构加入等待队列时会带EXCLUSIVE标志

4.3.6.3 __wait_event_interruptible_locked函数分析

1. __wait_event_interruptible_locked函数被wait_event_interruptible_locked / wait_event_interruptible_locked_irq / wait_event_interruptible_exclusive_locked/ wait_event_interruptible_exclusive_locked_irq等函数调用

2. 从__wait_event_interruptible_locked函数的实现可知,在调用他之前需要用户先获取相应wait_queue_head_t结构中的自旋锁

4.4 修改流程

4.4.1 新增系统调用框架

说明:关于如何新增系统调用,可参考Linux 0.11内核分析03:系统调用 chapter 5

4.4.1.1 增加系统调用号

修改文件:include/unistd.h

说明:由于编译应用程序时需要使用,根文件系统中的usr/include/unistd.h文件也需要同步增加系统调用号

4.4.1.2 修改系统调用个数上限

修改文件:kernel/system_call.s

4.4.1.3 修改系统调用表

修改文件:include/linux/sys.h

4.4.1.4 增加系统调用实现文件框架

新增文件:kernel/semaphore.c

说明:关于信号量数据类型

// 信号量名字的最大长度,包括'\0'字符

#define SEM_NAME_LEN 20



typedef struct semaphore {

    // 标识当前信号量是否在使用中

    int valid;

    // 信号量名字

    char name[SEM_NAME_LEN];

    // 信号量值

    int value;

    // 信号量引用计数

    int ref_count;

    // 信号量等待队列

    struct task_struct *wait_queue;

} sem_t;

4.4.1.5 修改Makefile

修改文件:kernel/Makefile

4.4.2 sem_open / sem_unlink系统调用实现

// 最多创建20个信号量

#define SEM_TABLE_LEN 20

sem_t sem_table[SEM_TABLE_LEN];



sem_t *sys_sem_open(const char *name, unsigned int value)

{

    char sem_name[SEM_NAME_LEN] = {'\0'};

    int i = 0;

    char c = 0;

    sem_t *sem = NULL;



    if (!name)

        return -EINVAL;



    // 从用户空间获取信号量名字

    for (i = 0; i < SEM_NAME_LEN; ++i) {

        c = get_fs_byte(name++);

        sem_name[i] = c;

        if (!c)

            break;

    }



    // 信号量名字有效字符超限

    if (c)

        return -ENAMETOOLONG;



    // 检查信号量是否已经被创建过

    for (i = 0; i < SEM_TABLE_LEN; ++i) {

        sem = sem_table + i;

        // 按信号量名字进行匹配

        if (sem->valid && !strcmp(sem->name, sem_name))

            break;

    }



    // 如果信号量已经创建过,则增加引用计数

    if (i < SEM_TABLE_LEN) {

        ++sem->ref_count;

        return sem;

    }



    // 如果信号量没有创建过,则创建之

    // 查找空闲sem_t结构

    for (i = 0; i < SEM_TABLE_LEN; ++i) {

        sem = sem_table + i;

        if (!sem->valid)

            break;

    }



    if (i < SEM_TABLE_LEN) {

        // 创建信号量

        strcpy(sem->name, sem_name);

        sem->valid = 1;

        sem->ref_count = 1;

        sem->value = value;

        sem->wait_queue = NULL;

        return sem;

    }

    else

        return -ENOMEM;

}



int sys_sem_unlink(const char *name)

{

    char sem_name[SEM_NAME_LEN] = {'\0'};

    int i = 0;

    char c = 0;

    sem_t *sem = NULL;



    if (!name)

        return -EINVAL;



    // 从用户空间获取信号量名字

    for (i = 0; i < SEM_NAME_LEN; ++i) {

        c = get_fs_byte(name++);

        sem_name[i] = c;

        if (!c)

            break;

    }



    // 信号量名字有效字符超限

    if (c)

        return -ENAMETOOLONG;



    // 检查信号量是否已经被创建过

    for (i = 0; i < SEM_TABLE_LEN; ++i) {

        sem = sem_table + i;

        if (sem->valid && !strcmp(sem->name, sem_name))

        break;

    }



    // 如果信号量已被创建,则递减引用计数

    // 当引用计数递减到0时,删除信号量

    if (i < SEM_TABLE_LEN) {

        --sem->ref_count;

        if (!sem->ref_count)

            sem->valid = 0;

               

        return 0;

    } else

        return -EINVAL;

}

说明1:使用如下代码对sem_open / sem_unlink系统调用进行基础验证

#include <stdio.h>

#define __LIBRARY__

#include <unistd.h>

#include <errno.h>



// 结构体信息隐藏

struct semaphore;

typedef struct semaphore sem_t;



#define SEM_FAILED ((sem_t *)-1)



// 声明系统调用

_syscall2(sem_t *, sem_open, const char *, name, unsigned int, value)

_syscall1(int, sem_unlink, const char *, name)



int main(int argc, char *argv[])

{

    sem_t *sem = NULL;

    int ret = 0;



    // 信号量名字为NULL

    sem = sem_open(NULL, 10);

    if (sem == SEM_FAILED)

        perror("name is null");



    // 信号量名字长度超限

    sem = sem_open("01234567890123456789", 10);

    if (sem == SEM_FAILED)

        perror("name too long");



    sem = sem_open("empty", 0);

    if (sem == SEM_FAILED)

        perror("first open empty failed");

    else

        printf("first open empty success: sem = 0x%x\n", sem);



    // 再次打开同一信号量

    sem = sem_open("empty", 0);

    if (sem == SEM_FAILED)

        perror("second open empty failed");

    else

        printf("second open empty success: sem = 0x%x\n", sem);



    // 关闭不存在的信号量

    ret = sem_unlink("full");

    if (ret)

        perror("unlink invalid sem");



    ret = sem_unlink("empty");

    if (ret)

        perror("first unlink empty failed");

    else

        printf("first unlink empty success\n");



    ret = sem_unlink("empty");

    if (ret)

        perror("second unlink empty failed");

    else

        printf("second unlin empty success\n");



    // 由于empty信号量只被打开2次,因此第3次关闭时该信号量已无效

    ret = sem_unlink("empty");

    if (ret)

        perror("third unlink empty failed");



    return 0;

}

实际验证效果符合预期,

说明2:关于结构体信息隐藏

① 在我们的测试代码中,使用如下方式实现结构体信息隐藏。这是因为后续只使用到sem_t类型的指针,而不同类型结构体的指针大小都是相同的

② glibc中则是使用定义占位变量的形式实现结构体信息隐藏

/usr/include/i386-linux-gnu/bits/semaphore.c

说明3:在sem_open和sem_unlink系统调用实现中,也会访问全局资源。但是因为Linux 0.11内核不支持内核抢占,并且也没有中断处理程序会访问相关全局资源,因此没有关中断或加锁

4.4.3 有正有负的sem_wait / sem_post系统调用实现

// 将task进程加入wait_queue队尾

static void enqueue(struct task_struct *task, struct task_struct **wait_queue)

{

    if (!task || !wait_queue)

        return;



    if (!*wait_queue) {

        // 如果wait_queue为空,则需要修改头指针指向

        task->next = *wait_queue; // task->next = NULL;

        *wait_queue = task;

    } else {

        struct task_struct *p = *wait_queue;

       

        // 搜索wait_queue尾节点

        // 尾节点的特征为p->next = NULL       

        while (p->next)

            p = p->next;

               

        task->next = p->next;

        p->next = task;

    }

}



int sys_sem_wait(sem_t *sem)

{

    if (!sem || !sem->valid)

        return -EINVAL;



    // 进入临界区

    cli();



    // 递减信号量值

    sem->value--;

   

    // 如果递减后信号量值 < 0,则将当前进程加入wait_queue

    if (sem->value < 0) {

        current->state = TASK_UNINTERRUPTIBLE;

        enqueue(current, &sem->wait_queue);

        schedule();

    }



    // 退出临界区

    sti();



    return 0;

}



// 取出wait_queue队首进程

static struct task_struct *dequeue(struct task_struct **wait_queue)

{

    struct task_struct *task = NULL;



    if (!wait_queue || !*wait_queue)

        return NULL;



    // 取出队首进程

    task = *wait_queue;

    *wait_queue = task->next;



    return task;

}



int sys_sem_post(sem_t *sem)

{

    struct task_struct *task = NULL;



    if (!sem || !sem->valid)

        return -EINVAL;



    // 进入临界段

    cli();



    // 递增信号量值

    sem->value++;

   

    // 如果递增后信号量值 <= 0,说明有进程在wait_queue上等待

    // 因此唤醒队首进程

    if (sem->value <= 0) {

        task = dequeue(&sem->wait_queue);



        if (task)

            task->state = TASK_RUNNING;

    }



    // 退出临界段

    sti();



    return 0;

}

说明1:在task_struct结构中增加next字段,用于构成单向链表

修改文件:include/linux/sched.h

之所以要构成task_struct结构体链表,是因为Linux 0.11内核提供的sleep_on & wake_up机制是将等待队列中的进程全部唤醒。但是在有正有负信号量的实现实现中,信号量值为负数时,表示有多少个进程在等待队列上等待,因此需要按信号量的值逐个唤醒进程

说明2:使用如下代码对sem_wait / sem_post系统调用进行基础验证

#include <stdio.h>

#define __LIBRARY__

#include <unistd.h>

#include <errno.h>



// 结构体信息因此

struct semaphore;

typedef struct semaphore sem_t;



#define SEM_FAILED ((sem_t *)-1)



// 声明系统调用

_syscall2(sem_t *, sem_open, const char *, name, unsigned int, value)

_syscall1(int, sem_unlink, const char *, name)

_syscall1(int, sem_wait, sem_t *, name)

_syscall1(int, sem_post, sem_t *, name)



int main(int argc, char *argv[])

{

    pid_t pid = 0;

    sem_t *sem = NULL;



    // 创建信号量mutex,初始值为0,当作互斥量使用

    sem = sem_open("mutex", 0);

    if (sem == SEM_FAILED) {

        perror("open mutex failed");

        return -1;

    }

       

    pid = fork();

    if (pid > 0) {

        printf("father process start\n");

        // 父进程等待信号量

        sem_wait(sem);

        printf("father process end\n");

       

        return 0;

    } else if (!pid) {

        printf("child process start\n");

        // 子进程释放信号量

        sem_post(sem);

        printf("child process end\n");



        return 0;

    } else {

        perror("fork failed");

        return -1;

    }

}

实际验证效果符合预期,

说明3:对本章测试程序进行修改,可以在我们实现的信号量上运行

修改主要在如下2点,

① 使用我们实现的信号量接口替换POSIX信号量接口

② 因为打印太多会导致虚拟机故障,减少子进程与资源数量,其中

  • 有1个生产者子进程,向共享缓存区写入0 ~ 14共15个数
  • 有3个消费者子进程,每个读取5个数并打印

4.4.4 只有正数的sem_wait / sem_post系统调用实现

int sys_sem_wait(sem_t *sem)

{

    if (!sem || !sem->valid)

        return -EINVAL;



    // 进入临界段

    cli();



    // 如果没有资源,则在循环中等待,直到有资源为止

    while (sem->value == 0) {

        sleep_on(&sem->wait_queue);

        schedule();

    }



    // 占用资源

    sem->value--;



    // 退出临界段

    sti();



    return 0;

}



int sys_sem_post(sem_t *sem)

{

    if (!sem || !sem->valid)

        return -EINVAL;



    // 进入临界段

    cli();



    // 释放资源

    sem->value++;

    // 唤醒wait_queue上的所有进程

    wake_up(&sem->wait_queue);



    // 退出临界段

    sti();



    return 0;

}

说明1:经过验证,修改信号量实现机制后,上一节的两个测试用例仍可运行

说明2:本节的信号量实现方式会反复进出内核态,开销很大。因此后续的Linux内核中引入了futex机制,这是一种用户态和内核态混合的同步机制

关于futex机制的简介,可参考futex机制介绍

5 死锁现象及死锁处理

5.1 死锁现象示例

调换本章生产者-消费者示例程序中P(empty) / P(full) / P(mutex)的调用顺序,

1. 从单独一个程序的角度来看,调换前后的程序含义仍然是正确的

2. 但是在实际调度中,却可能导致如下这种死锁的场景

① 生产者进程持有mutex,但是阻塞在empty上,等待消费者进程继续执行产生出空闲缓冲区

② 消费者进程阻塞在mutex上,等待生产者进程继续执行释放mutex

由于2个进程互相等待对方继续执行,或者说互相等待对方持有的资源,从而导致谁也无法继续执行

5.2 死锁必要条件

死锁发生有4个必要条件,

1. 互斥使用(mutual exclusion)

资源不能被共享,一个资源每次只能被一个进程使用

2. 不可抢占(no preemption)

进程已获得的资源,在未使用完之前不能强行剥夺,只能由进程资源释放

3. 请求与保持(hold and wait)

进程必须在已占有某些资源的情况下,再去申请其他资源

4. 循环等待(circular wait)

若干进程之间形成一种头尾相接的循环资源等待关系,即在资源分配图中存在一个环路

说明:关于资源分配图

资源分配图(resource allocation graph)用来表示资源等待关系,下面给出一个资源分配图实例。其中,

① R1 ~ R4表示资源,C1 ~ C4表示进程

② 从资源出发到进程的边,表示进程占有了该资源

③ 从进程出发到资源的边,表示进程要请求该资源

在上述资源分配图中就出现了环路,因此可能出现死锁

5.3 死锁处理方法

5.3.1 死锁预防

死锁预防的基本思想就是破坏上述4个死锁必要条件中的某个条件,下面就讨论各个必要条件是否可被破坏

1. 互斥使用

① 互斥是很多资源自身的基本条件,很多时候无法改变

② 例如临界区、共享缓存区等临界资源,一次只能让一个进程访问

2. 不可抢占

① 不可抢占是由程序本身决定的,很多资源如果不是程序主动释放,是不能被强行剥夺的

② 例如生产者进程持有的mutex信号量,只有当共享缓存区使用完毕后才能释放该信号量,在此之前不能强行剥夺,否则就有多个进程同时操作共享缓存区

说明:一般而言,互斥使用和不可抢占这两个条件很难被破坏

3. 请求与保持

① 破坏请求与保持的方法就是要么请求时不保持,要么保持时不请求

② 因此自然的实现方法就是一次性申请进程所需的所有资源

4. 循环等待

① 破坏循环等待的方法就是不让资源等待形成环路,由于资源等待是由资源申请引起的,所以需要对进程的资源申请进行控制

② 如果所有进程按序申请资源,就可以不让资源等待关系中出现环路

③ 仍以上节的资源分配图为例,如果对所有资源进行编号,然后所有进程按序号从小到大(或从大到小)申请资源,则不会出现环路

示例中出现环路,正是因为进程C4破坏了按序申请,在占有R4的情况下申请R1。如果C4按序先申请R1,由于R1目前被C1占有,所以C4会被阻塞在对R1的请求上,从而C3可以顺利通过,不会造成环路

说明1:死锁预防的缺点

① 需要预先计算程序要请求的资源,编程困难

对于存在诸多分支语句的程序而言,准确计算程序所需资源几乎是不可行的

② 许多资源分配后很长时间才使用,因此资源利用率低

说明2:按序申请信号量是编程时的一般通用规则

5.3.2 死锁避免

5.3.2.1 基本思想

1. 由于死锁预防需要破坏死锁的基础条件,因此需要比较强力的手段,但是强力的手段自然也会引入一些不合理的因素

2. 死锁避免则是适当降低手段的强度,以求得安全性与性能的平衡。具体做法就是允许进程随便申请资源,但是操作系统要判断这次申请是否存在死锁危险,如果存在就拒绝此次申请

说明:这种降低手段强度以求得安全性和性能平衡的处理方式,在计算机原理中很常见

① 例如用于维护多核间Cache一致性的MESI协议,虽然可以保证安全,但是严格遵守(强缓存一致性)的话会降低性能

② 因此在CPU实现中会降低保护强度,例如store buffer和invalid queue,从而提升性能

③ 但是这种降低保护强度的做法又会导致衍生的一致性问题(弱缓存一致性),因此又引入了内存屏障机制进行弥补

5.3.2.2 银行家算法

在死锁避免的处理策略中,给出一个判断是否存在死锁危险(或者反过来说判断系统是否安全)的算法是关键所在,银行家算法(banker's algorithm)就是用来完成这一判断的著名算法

5.3.2.2.1 银行家算法思想

1. 银行家算法的核心就是要确定系统是否安全

如果对所有进程的资源请求都存在一种调度方案令其满足,从而所有进程都能顺利执行完成,就说明系统是安全的。而这个能让所有进程都顺利完成的进程调度序列,就被称作安全序列

2. 银行家算法的核心就是要找到这个安全序列

在遇到一个资源请求时,首先假设允许此次资源请求,如果发现在允许该请求后操作系统上仍能找到安全序列,说明此次资源请求是安全的,操作系统就可以真实分配资源了

如果找不到安全序列,虽然不能说明系统一定会发生死锁,但至少存在死锁风险,此次资源请求就会被拒绝

说明:银行家算法是一个死锁的充分性算法,对资源请求的访问控制会比较保守。这种保守就体现在假设进程是最后一次性释放获取的所有资源,而实际的程序可能是随着资源的使用完毕及时释放,这也是说找不到安全序列只是系统只是存在死锁风险的原因

5.3.2.2.2 银行家算法实例

在实例中,Allocation是已经分配给进程的资源,Max是进程需要的最大资源量,Available是系统当前还剩下的可用资源

1. 此时P3申请资源(1 0 1),首先假设允许该请求,此时Available会变为(4 3 3 - 1 0 1 = 3 3 2),而P3的Allocation会变为(2 0 1 + 1 0 1 = 3 0 2)

2. 之后银行家算法开始工作,寻找一个让所有进程都能获得其全部所需资源的调度序列

目前Available为(3 3 2),可以分配给P2以满足其资源请求,因为(2 0 0 + 3 3 2 = 5 3 2)>(3 2 2)。一旦P2执行完成,分配给P2的资源就可以被回收,此时Available就会变成(3 3 2 + 2 0 0 = 5 3 2)

3. 目前Available为(5 3 2),由可以满足P4的资源请求,待P4执行完成,Available就会变成(5 3 2 + 2 1 1 = 7 4 3)

之后再按此算法继续查找,就可以得到一个安全序列(P2,P4,P3,P5,P1),因此对P2的资源申请可以被实际执行

5.3.2.2.3 银行家算法实现

说明:根据上述说明,银行家算法的时间复杂度为O(mn^2)

5.3.3 死锁检测与恢复

1. 死锁避免需要在每次资源申请时调用时间复杂度为O(mn^2)的银行家算法,因此效率较低。为了提升性能,就需要降低调用银行家算法的频率

一种可行的方法,就是定时调用或者在发现资源利用率低时调用银行家算法,以判断系统中是否已发生死锁

2. 由于此时已经发生死锁,就需要选择一些进程进行回滚,使其让出资源,但是这种操作是困难的

① 选择回滚进程的标准不确定(e.g. 按进程优先级、按占用资源的多少)

② 实现进程回滚非常困难

5.3.4 死锁忽略

1. 由于死锁的出现是不确定的,又可以通过重启来恢复,因此可以忽略死锁的情况,不做任何处理

2. 一般的通用操作系统(e.g. Linux,Windows,UNIX)都采用死锁忽略处理方法

猜你喜欢

转载自blog.csdn.net/chenchengwudi/article/details/127112463
今日推荐