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内核条件等待实例


1 进程同步问题引入

1.1 概述

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

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

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

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

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

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

Linux 0.11内核分析06:进程同步(部分)_第1张图片

说明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 使用信号解决同步问题

Linux 0.11内核分析06:进程同步(部分)_第2张图片

定义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。这类错误由多个进程并发操作共享数据引起,错误和调度顺序有关,难以发现和调试

Linux 0.11内核分析06:进程同步(部分)_第3张图片

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

Linux 0.11内核分析06:进程同步(部分)_第4张图片

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个进程交替进入临界区,任何时刻只有一个进程有权进入临界区

Linux 0.11内核分析06:进程同步(部分)_第5张图片

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

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

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

2. 算法评价

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

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

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

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

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

3.2.3 标记法

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

Linux 0.11内核分析06:进程同步(部分)_第6张图片

2. 算法评价

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

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

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

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

Linux 0.11内核分析06:进程同步(部分)_第7张图片

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

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

Linux 0.11内核分析06:进程同步(部分)_第8张图片

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的序号就是标记(选号的过程就相当于标记)

Linux 0.11内核分析06:进程同步(部分)_第9张图片

2. 算法评价

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

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

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

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

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

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

3.3 临界区的硬件实现

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

3.3.1 关中断

3.3.1.1 实现方法

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

Linux 0.11内核分析06:进程同步(部分)_第10张图片

说明:如果是通过关中断实现临界区,这里有一个隐含的背景,就是该临界区位于内核态。因为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内核没有用户态信号量机制的一个原因

Linux 0.11内核分析06:进程同步(部分)_第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:关中断之后不应中途退出临界区

Linux 0.11内核分析06:进程同步(部分)_第12张图片

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

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

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

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

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

Linux 0.11内核分析06:进程同步(部分)_第13张图片

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

Linux 0.11内核分析06:进程同步(部分)_第14张图片

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 0.11内核分析06:进程同步(部分)_第15张图片

Linux 0.11内核分析06:进程同步(部分)_第16张图片

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

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

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

Linux 0.11内核分析06:进程同步(部分)_第17张图片

② 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

Linux 0.11内核分析06:进程同步(部分)_第18张图片

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

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

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

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

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

4.2 测试程序

4.2.1 共享缓存区布局

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

Linux 0.11内核分析06:进程同步(部分)_第19张图片

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

4.2.2 程序源码

#include 

#include 

#include 

#include 

#include 

#include 

#include 

#include 

#include 



// 共享缓冲区元素个数

#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个消费者依序消费了共享缓存区中的数据

Linux 0.11内核分析06:进程同步(部分)_第20张图片

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

Linux 0.11内核分析06:进程同步(部分)_第21张图片

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

所需头文件

#include

函数原型

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变量

Linux 0.11内核分析06:进程同步(部分)_第22张图片

Linux 0.11内核分析06:进程同步(部分)_第23张图片

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

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

Linux 0.11内核分析06:进程同步(部分)_第24张图片

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的值,如果已经被其他进程上锁,则当前进程继续睡眠;如果未被其他进程上锁,则当前进程将其上锁

Linux 0.11内核分析06:进程同步(部分)_第25张图片

需要特别注意的是,此处的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文件中

Linux 0.11内核分析06:进程同步(部分)_第26张图片

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

4.3.3.2 信号量定义操作

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

1. 定义同时初始化

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

Linux 0.11内核分析06:进程同步(部分)_第27张图片

2. 定义后初始化

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

4.3.3.3 信号量等待操作

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

Linux 0.11内核分析06:进程同步(部分)_第28张图片

说明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循环用于在进程被唤醒后检查唤醒原因

Linux 0.11内核分析06:进程同步(部分)_第29张图片

说明3:down函数衍生操作

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

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

Linux 0.11内核分析06:进程同步(部分)_第30张图片

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

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

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

Linux 0.11内核分析06:进程同步(部分)_第31张图片

4.3.3.4 信号量唤醒操作

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

Linux 0.11内核分析06:进程同步(部分)_第32张图片

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

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

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

说明2:__up函数分析

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

Linux 0.11内核分析06:进程同步(部分)_第33张图片

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

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

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

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

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

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

4.3.4 Linux 2.6内核等待队列实例

4.3.4.1 等待队列数据结构

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

1. 等待队列头wait_queue_head_t

Linux 0.11内核分析06:进程同步(部分)_第34张图片

2. 等待队列wait_queue_t

Linux 0.11内核分析06:进程同步(部分)_第35张图片

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

4.3.4.2 等待队列定义操作

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

1. 定义同时初始化

Linux 0.11内核分析06:进程同步(部分)_第36张图片

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

Linux 0.11内核分析06:进程同步(部分)_第37张图片

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

Linux 0.11内核分析06:进程同步(部分)_第38张图片

2. 定义后初始化

Linux 0.11内核分析06:进程同步(部分)_第39张图片

说明:在初始化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种加入方式,相应的操作函数如下,

Linux 0.11内核分析06:进程同步(部分)_第40张图片

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

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

Linux 0.11内核分析06:进程同步(部分)_第41张图片

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

Linux 0.11内核分析06:进程同步(部分)_第42张图片

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

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

Linux 0.11内核分析06:进程同步(部分)_第43张图片

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

Linux 0.11内核分析06:进程同步(部分)_第44张图片

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

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

Linux 0.11内核分析06:进程同步(部分)_第45张图片

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

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

4.3.4.4 等待队列唤醒操作

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

Linux 0.11内核分析06:进程同步(部分)_第46张图片

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

Linux 0.11内核分析06:进程同步(部分)_第47张图片

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

① wait_queue_head_t *q

要唤醒的等待队列

② unsigned int mode

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

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

Linux 0.11内核分析06:进程同步(部分)_第48张图片

③ int nr_exclusive

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

④ void *key

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

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

Linux 0.11内核分析06:进程同步(部分)_第49张图片

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

Linux 0.11内核分析06:进程同步(部分)_第50张图片

Linux 0.11内核分析06:进程同步(部分)_第51张图片

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

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

Linux 0.11内核分析06:进程同步(部分)_第52张图片

说明:如果传递的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种

Linux 0.11内核分析06:进程同步(部分)_第53张图片

4.3.5.3 完成量等待操作

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

Linux 0.11内核分析06:进程同步(部分)_第54张图片

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

Linux 0.11内核分析06:进程同步(部分)_第55张图片

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

Linux 0.11内核分析06:进程同步(部分)_第56张图片

Linux 0.11内核分析06:进程同步(部分)_第57张图片

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

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

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

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

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

Linux 0.11内核分析06:进程同步(部分)_第58张图片

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

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

Linux 0.11内核分析06:进程同步(部分)_第59张图片

4.3.4.4 完成量唤醒操作

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

Linux 0.11内核分析06:进程同步(部分)_第60张图片

说明1:complete_all函数分析

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

Linux 0.11内核分析06:进程同步(部分)_第61张图片

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

说明2:completion_done函数分析

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

Linux 0.11内核分析06:进程同步(部分)_第62张图片

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函数分析

Linux 0.11内核分析06:进程同步(部分)_第63张图片

说明1:autoremove_wake_function函数分析

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

Linux 0.11内核分析06:进程同步(部分)_第64张图片

说明2:timeout衍生版本

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

Linux 0.11内核分析06:进程同步(部分)_第65张图片

说明3:interruptible衍生版本

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

Linux 0.11内核分析06:进程同步(部分)_第66张图片

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

Linux 0.11内核分析06:进程同步(部分)_第67张图片

说明4:interruptible_timeout衍生版本

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

Linux 0.11内核分析06:进程同步(部分)_第68张图片

说明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等函数调用

Linux 0.11内核分析06:进程同步(部分)_第69张图片

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

你可能感兴趣的:(Linux内核源码分析,Linux内核)