2.3 进程间通信 Interprocess Communication
进程间通信主要是三个问题:
- 一个进程如何传递消息给另一个进程
- 多个进程之间如何不会相会妨碍(竞争、死锁)
- 多个进程之间存在依赖时,需要处理进程的顺序
2.3.1 竞态条件 Race Conditions
当多个进程读写一个共享的数据,并且最终的结果依赖于进程地执行顺序时,这种状态就叫做竞态条件
2.3.2 关键区域 Critical Regions
互斥(mutual exclusion):同一时间只有一个进程可以访问共享数据
临界区域:程序中访问共享数据的部分
避免竞态条件方案应当满足以下四个要求:
- 不会有两个及以上地进程同时处于临界区域
- 不对 CPU 的数量和速度做任何假设
- 在临界区域外的运行的进程不会阻塞任何进程
- 进程不应当永远被阻塞在临界区外
2.3.3 忙等待的互斥 Mutual Exclusion with Busy Wait
当一个处于临界区时,其他进程无法进入临界区。
下面是一些互斥的实现方案:
禁用中断 Disabling interrupts
在单核系统中,当进程进入临界区时,禁用所有的中断,离开临界区时,启用中断。
禁用中断时,不会产生时钟中断,CPU 也就无法切换进程。
缺点:
- 用户态进程能够禁用所有中断是不安全的,可能会导致进程永远无法切换
- 禁用中断只对执行了禁用命令的CPU有效,对其他CPU无效,其他CPU上的进程仍然可以访问共享数据。
但是在内核态中,禁用中断是一个很好用的互斥方式。内核在执行一些命令时,比如更新变量特别是 list 时,可以使用禁用中断来防止竞态条件。
锁变量 Lock Variables
这是一个软件层面的解决方案,通过变量的值来判断是否有进程在临界区中。
int lock = 0;
void foo()
{
// 有其他进程在临界区,等待其他进程离开
while(lock != 0)
// 进入临界区,置位
lock = 1;
// do somethings
// 离开临界区,复位
lock = 0;
}
但是,这个方法有一个很明显的缺点。假设进程 A 先进入临界区,还没来得及对锁变量置位就发生了时钟中断,切换到进程 B,因为 A 还没有对锁变量置位,所以进程 B 也可以进入临界区,形成了竞态条件。
严格交替 Strict Alternation
int turn = 0;
void process_0()
{
while(turn != 0)
cirtical_region();
turn = 1;
noncirtical_region();
}
void process_1()
{
while(turn != 1)
cirtical_region();
turn = 0;
noncirtical_region();
}
像这样不断查看变量直到某个值出现,叫做忙等待(busy wait)
应当避免忙等待状态,因为它会浪费 CPU 资源,除非等待的时间很短
使用忙等待机制的锁叫做自旋锁(spin lock)
假设如下场景:
- 进程 0 离开临界区,此时 turn 为 1
- 进程 1 进入临界区,然后有离开了临界区,此时 turn 为 0
- 进程 0 完成了非临界区的任务,再次进入临界区,很快又离开了临界区,此时 turn 为 1
- 进程 1 仍然在执行非临界区的任务,而进程 0 不得不等待,直到 turn 变为 0
显然,进程 0 被处于非临界区的进程 1 阻塞了(违背了准则 3)
这个方案要求两个进程必须要严格交替执行。
Peterson's Solution
#define N 2
int turn;
// 哪个进程处于临界区中
int interested[N];
void enter_region(int process)
{
// 其他进程
int other = 1 - process;
// 设置当前进程要进入临界区
interested[process] = true;
turn = process;
while(turn == process && interested[other] == true)
}
void leave_region(int process)
{
// 离开临界区,当前进程不再处于临界区,复位
interested[process] = false;
}
相比上一个方案,引入了 interested,用来表示哪个进程还处于临界区中。
考虑之前严格交替的场景,假设进程 1 长时间运行在非临界区。
当进程 0 要进入临界区时,因为 interested[1] 为 false,所以一定可以进入临界区。
思考:turn 是否是必要的?
假设进程 0、进程 1 同时进入临界区,进程 0 先设置 interested[0] = true,然后中断切换到进程 1,执行 interested[1] = true,接下来无论是进程 0 还是进程 1 都无法进入临界区。
The TSL Instruction
TSL: Test and Set Lock
这是一个汇编指令,将 LOCK(一般在内存中)的值复制到 RX 中,然后将 LOCK 设置为一个非 0 值
TSL RX, LOCK
TSL 是原子的:
CPU 在执行 TSL 指令时,会锁住内存总线,防止其他 CPU 访问直到指令完成
如何使用 TSL 实现互斥:
enter_region:
TSL REGISTER, LOCK ; 获取 LOCK 的值,并将 LOCK 置为 1
CMP REGISTER, 0 ; LOCK 是否为 0 ?
JNE enter_region ; 不为 0 则说明已经有进程进入临界区,进行下一轮探测
RET ; 为 0,允许进入临界区
leave_region:
MOV LOCK, 0 ; 离开临界区,LOCK 复位
RET
在 Intel x86 架构中,使用 XCHG 替代 TSL
enter_region:
MOV REGISTER, 1
XCHG REGISTER, LOCK ; 获取 LOCK 的值,并将 LOCK 置为 1
CMP REGISTER, 0 ; LOCK 是否为 0 ?
JNE enter_region ; 不为 0 则说明已经有进程进入临界区,进行下一轮探测
RET ; 为 0,允许进入临界区
leave_region:
MOV LOCK, 0 ; 离开临界区,LOCK 复位
RET
2.3.4 Sleep and Wakeup
之前的方案因为忙等待问题,都存在一个缺点:浪费 CPU 资源
优先级倒置问题(priority inversion problem):
假设两个进程,H 优先级高,L 优先级低,当 H 离开临界区后,L 进入了临界区,但是 L 在临界区中花费了很长时间,甚至一直都不退出临界区,导致 H 无法执行。
生产者消费者问题 The Producer-Consumer Problem
#define N 100
int count = N;
void producer()
{
int item;
while(true)
{
item = produce_item();
if(count == N)
sleep();
inert_item(item);
if(++count == 1)
wakeup(consumer);
}
}
void consumer()
{
int item;
while(true)
{
if(count == 0)
sleep();
item = remove_item();
if(--count == N - 1)
wakeup(producer);
consume_item(item);
}
}
上述代码会产生竞态条件,因为对 count 的访问不受限制:
- 假设 consumer 先读取到 count 为 0,同时 CPU 切换到 producer,产生了一个 item,并且设置 count 为 1,随后调用 wakeup
- 此时 consumer 还没有执行 sleep,wakeup 信号被丢弃
- CPU 切回 consumer,继续调用 sleep,consumer 被挂起,无法唤醒
问题的关键在于,consumer 还没有 sleep,但是 producer 已经发送了 wakeup,导致 wakeup 丢失。
可以使用一个标志位来解决上述问题:
- 当 consumer 处于 awake 状态时,如果收到了 wakeup 信号,标志位置位
- 当 consumer 将要 sleep 时,如果标志位置位,那么将标志位复位,但是进程不进入 sleep 状态
- 每次循环时,都要复位一次标记位
个人理解:
注意这里的标志位应当融入到 wakeup 和 sleep 中,而不是在用户代码中实现。
用户代码如下:
void consumer()
{
while(true)
{
if(count == 0)
{
if(awake == false)
sleep();
}
}
}
- consumer 读取到 awake 为 false
- producer 调用了 wakeup,awake 置位
- consumer 依然执行了 sleep
- producer 不会再调用 wakeup,consumer 一直被挂起,无法唤醒
总之,应当有一种机制确保标志位和sleep、wakeup 是原子,否则就有可能出现竞态条件。
2.3.5 信号量 Semaphores
信号量:一个整型变量,可以简单理解为 wakeup 信号的个数
- 为 0 表示没有 wakeup 信号,或者说没有进程可以被 wakeup
- 任意正整数,表示有一个或多个 wakeup 信号待使用
信号量有两个操作 up 和 down
- down: 信号量的值减一,如果减少后的值大于等于 0,进程继续执行,否则进入阻塞状态
- up: 信号量的值加一,并且唤醒一个因为 down 操作而被阻塞的进程
void down(int* sema)
{
if(--(*sema) < 0)
sleep();
}
void up(int* sema)
{
(*sema)++;
wakeup();
}
注意:up 和 down 都是原子操作,确保对信号量的操作不会被其他进程打断或者影响
- 如果信号量是正值,表示当前允许使用的资源数
- 如果信号量是 0,表示当前无可用资源,且没有进程因为资源而阻塞
- 如果信号量是负值,其绝对值表示因为该资源而阻塞的进程数量
使用信号量解决生产者消费者问题 Solving the Producer-Consumer Problem Using Semaphores
#define N 100
typedef int semaphore;
semaphore mutex = 1;
semaphore empty = N;
semaphore full = 0;
void producer()
{
int item;
while(true)
{
item = produce_item();
down(&empty);
down(&mutex); // 进入临界区
insert_item(item);
up(&mutex); // 离开临界区
up(&full);
}
}
void consumer()
{
int item;
while(true)
{
down(&full);
down(&mutex); // 进入临界区
item = remove_item();
up(&mutex); // 离开临界区
up(&empty);
consume_item(item);
}
}
-
为什么需要 mutex ?
mutex 的初始值为 1,即最多只有一个进程处于 awake 状态,确保 producer(s) 和 consumer(s) 不会同时访问 buffer。
像这种类型的信号量称之为二进制信号量(binary semaphore) -
已经有了 empty(full),为什么还需要 full(empty),是否可以使用 N - empty(full) 代替 full(empty) ?
不可以。假设没有 full,使用 N - empty 代替。考虑如下场景:
- 开始时,buffer 为空。producer 执行完成 down(&empty) 后,还没来得及执行 down(&mutex),CPU 被调度给 consumer。
- consumer 开始执行,因为 producer 执行了 down(&empty),此时 N - empty 的值一定大于 0。
- 接下来,consumer 顺利完成了 down(&mutex),开始从 buffer 中获取 item,但是 producer 还没有执行 insert_item 操作,此时 buffer 为空。
显然,remove_item 一个空 buffer 会导致异常。
-
mutex 和 full(empty) 的顺序是否可以交换?
不可以。假设 mutex 和 full(empty) 交换顺序,producer 先执行 down(&mutex),然后在执行 down(&empty) 时,
如果此时 empty 的值为 0,那么 producer 无法完成 down 操作而被阻塞,而 consumer 因为无法完成 down(&mutex) 而被阻塞。
producer 和 consumer 彼此循环等待,永远都无法被唤醒,陷入死锁。
使用信号量隐藏中断:
- 初始化一个初始值为 0 的信号量,
- 开始 IO 时,进程做一个 down 操作,进入阻塞状态
- 当 IO 完成时,中断处理函数执行 up 操作唤醒进程
互斥信号量:
此类信号的作用是实现互斥,确保同一时间只有一个进程进入临界区
同步信号量:
类似于 full 和 empty 的信号量,确保程序的执行顺序
2.3.6 互斥(锁)Mutex
当不需要信号量的计数能力时,一个更简单的(信号量),称之为 互斥锁(Mutex)。Mutexes 适合实现临界区域的互斥操作,易于实现,并且在用户态下十分有用。
一个 Mutex 有两种状态,unlocked 和 locked。
mutex_lock: 进入临界区
mutex_unlock: 离开临界区
在用户态,mutex 可如下实现:
mutex_lock:
TSL REGISTER, MUTEX
CMP REGISTER, 0
JZE ok
CALL thread_yield
JMP mutex_lock
ok:
RET
mutex_unlock:
MOVE MUTEX, 0
RET
在内核态中,当进程被忙等待阻塞时,因为时钟中断的存在,调度器很快会调度其他进程来运行,一段时间后,当前进程获取到了锁,可以继续运行。
而在用户态线程中并不存在时钟中断,无法停止(切换)长时间运行的线程。那么,当一个线程通过忙等待的方式获取锁时,它就会一直运行下去,因为 CPU 无法调度其他线程,也就没办法释放锁。
mutex_lock 的实现在获取锁失败后,调用 thread_yield 将 CPU 控制权交还给系统(调度其他线程)。所以 mutex_lock 并不存在忙等待。
考虑一个问题,在用户态下,多线程可以访问相同的地址空间,所以可以访问同一个 mutex。但是多进程要如何访问共享内存?
- 共享数据结构。例如将信号量存放在内核中,进程通过系统调用访问和操作。
- 共享地址空间。大多数现代都提供了多进程共享地址空间的方法,可以共享数据结构和缓存,即使以上方式都不支持,也可以共享文件。
如果两个进程共享几乎所有的地址空间,进程和线程的边界逐渐模糊,但还是存在一些无法被共享的东西。
比如打开的不同的文件、定时器、每个进程的属性等等(在一个进程中的线程可以共享这些资源)。
共享地址空间的进程效率不如用户级线程,因为进程由内核来管理。
Futexes
在竞争很少的情况下,自旋锁的性能很高,因为不涉及内核态。但是高竞争场景下,多个进程持续空转会浪费大量的 CPU 资源,因此使用阻塞更加合适。
相反地,在低竞争场景下,使用阻塞锁会进入内核态,内核态切换会降低性能。
futex 由两部分组成:
- 内核服务
提供一个等待队列(wait queue), 在队列中的所有进程都处于阻塞状态等待一个锁,直到内核不再阻塞进程
- 用户库
当进程因为 futex 要被阻塞时,会做一个系统调用,将进程加入等待队列。系统调用会进入内核态,所以应当避免(阻塞)。
在没有竞争的情况下,futex 完全在用户态中工作,通过一个原子操作来完成加锁的动作。
如果锁已经被其他线程持有(存在竞争),那么线程将被阻塞。
注意:本例中 futex 在用户态中不会自旋,仅仅是做一个系统调用将线程加入到内核的等待队列中。
Mutexes in Pthreads
Pthreads 实现的 mutex 在获取不到锁时,会先自旋一段时间,如果还没有获取到锁,就会做系统调用阻塞进程。
Pthreads 提供了额外的同步机制:条件变量(condition variables)
mutex 主要用于保护临界区,防止多个进程同时进入。而条件变量主要是在进程不满足某些条件时,进入阻塞状态。
mutex 和条件变量一般一起使用。mutex 用于保护条件变量(临界区),在不满足某些条件需要阻塞时,调用 pthread_cond_wait,
会自动 unlock mutex,而被唤醒时会自动 lock mutex。
需要注意的是,条件变量和信号量不同,它没有记忆性。当收到一个 wakeup 信号时,如果线程不处于 sleep 状态,这个信号就丢失了。
而信号量本身具有计数的特性,可以将这个信号“存储”起来。
#define MAX_COUNT 1000
// 保护临界区
pthread_mutex_t g_mutex;
// 生产者条件变量
pthread_cond_t g_cond_pro;
// 消费者条件变量
pthread_cond_t g_cond_con;
// 消费队列
int buffer = 0;
void* producer(void* ptr)
{
int i;
for(i = 1; i <= MAX_COUNT; i++)
{
// 进入临界区
pthread_mutex_lock(&g_mutex);
while(buffer != 0)
{
// wait 时自动 unlock mutex
pthread_cond_wait(&g_cond_pro, &g_mutex);
// wake 时自动 lock mutex
}
// produce
buffer = i;
// 唤醒 consumer
pthread_cond_signal(&g_cond_con);
// 离开临界区
pthread_mutex_unlock(&g_mutex);
}
}
void* consumer(void* ptr)
{
int i;
for(i = 1; i <= MAX_COUNT; i++)
{
// 进入临界区
pthread_mutex_lock(&g_mutex);
while(buffer != 0)
{
// wait 时自动 unlock mutex
pthread_cond_wait(&g_cond_con, &g_mutex);
// wake 时自动 lock mutex
}
// consume
buffer = 0;
// 唤醒 producer
pthread_cond_signal(&g_cond_pro);
// 离开临界区
pthread_mutex_unlock(&g_mutex);
}
}
2.3.7 管程 Monitors
关于 monitor,就是对互斥锁、信号量、条件变量的一种语言层面的封装,以实现互斥、阻塞、唤醒等功能,确保同一时间只有一个线程可以访问到 monitor
2.3.8 消息传递 Message Passing
消息传递的两个系统调用
- send(destination, &message)
- receive(source, &message)
Design Issues for Message-Passing Systems
-
如何确认消息发到了对端?
丢包 -> 确认 -> 重传
-
安全(认证)问题
The Producer-Consumer Problem with Message Passing
mailbox: 一个可以缓存一定数量消息的数据结构
当进程向 mailbox 发送消息时,如果此时 mailbox 已满,进程将被挂起直到有一个消息从 mailbox 中被移除
生产者和消费者各自创建一个容量为 N 的 mailbox,生产者向消费者发送实际要消费的数据,
而消费者向生产者发送空的消息。在这一过程中,缓冲区中保存了已发送但是尚未被接收的消息。
另一种方式完全消除了缓冲区,那就是生产者和消费者严格交替发送和接收消息。
void producer()
{
while(true)
{
send(message);
receive(empty);
}
}
void consumer()
{
while(true)
{
receive(message);
send(empty);
}
}
2.3.9 Barries
当需要等待所有进程都完成当前阶段时,才能进入下一阶段,可以通过 barries 实现。
当一个进程遇到 barries 时,它会被阻塞直到所有进程都到达 barries。
2.3.10 避免锁 Avoiding Locks: Read-Copy-Update
确保每一个 reader 都读取到的是一个一致的数据结构,而不是某个状态到某个状态的中间状态。
读操作:无需加锁,直接访问数据(通过指针解引用)。
写操作:先创建数据的副本并修改副本,最后通过原子操作替换原指针(发布新版本)。旧数据的回收需要等待所有可能持有其引用的读操作完成。
RCU 通过以下条件判断何时可以安全回收旧数据:
所有正在执行的读临界区(Read-Side Critical Section)必须完成:读临界区是指线程通过 RCU 保护的指针访问数据的代码段。
CPU调度是协作的关键:在抢占式内核中,线程可能被调度器中断。如果一个线程曾经进入过读临界区,但在退出后被调度出去(发生上下文切换),则它一定不会继续持有旧数据的引用。
为什么线程发生切换后,一定不会继续持有旧数据的引用?
如果线程处于访问数据的 RCU 临界区中,操作系统不会进行上下文切换,直到线程离开 RCU 临界区。

浙公网安备 33010602011771号