2.3 进程间通信 Interprocess Communication

进程间通信主要是三个问题:

  1. 一个进程如何传递消息给另一个进程
  2. 多个进程之间如何不会相会妨碍(竞争、死锁)
  3. 多个进程之间存在依赖时,需要处理进程的顺序

2.3.1 竞态条件 Race Conditions

当多个进程读写一个共享的数据,并且最终的结果依赖于进程地执行顺序时,这种状态就叫做竞态条件

2.3.2 关键区域 Critical Regions

互斥(mutual exclusion):同一时间只有一个进程可以访问共享数据

临界区域:程序中访问共享数据的部分

避免竞态条件方案应当满足以下四个要求:

  1. 不会有两个及以上地进程同时处于临界区域
  2. 不对 CPU 的数量和速度做任何假设
  3. 在临界区域外的运行的进程不会阻塞任何进程
  4. 进程不应当永远被阻塞在临界区外

2.3.3 忙等待的互斥 Mutual Exclusion with Busy Wait

当一个处于临界区时,其他进程无法进入临界区。

下面是一些互斥的实现方案:

禁用中断 Disabling interrupts

在单核系统中,当进程进入临界区时,禁用所有的中断,离开临界区时,启用中断。
禁用中断时,不会产生时钟中断,CPU 也就无法切换进程。

缺点:

  1. 用户态进程能够禁用所有中断是不安全的,可能会导致进程永远无法切换
  2. 禁用中断只对执行了禁用命令的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)

假设如下场景:

  1. 进程 0 离开临界区,此时 turn 为 1
  2. 进程 1 进入临界区,然后有离开了临界区,此时 turn 为 0
  3. 进程 0 完成了非临界区的任务,再次进入临界区,很快又离开了临界区,此时 turn 为 1
  4. 进程 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 的访问不受限制:

  1. 假设 consumer 先读取到 count 为 0,同时 CPU 切换到 producer,产生了一个 item,并且设置 count 为 1,随后调用 wakeup
  2. 此时 consumer 还没有执行 sleep,wakeup 信号被丢弃
  3. CPU 切回 consumer,继续调用 sleep,consumer 被挂起,无法唤醒

问题的关键在于,consumer 还没有 sleep,但是 producer 已经发送了 wakeup,导致 wakeup 丢失。

可以使用一个标志位来解决上述问题:

  1. 当 consumer 处于 awake 状态时,如果收到了 wakeup 信号,标志位置位
  2. 当 consumer 将要 sleep 时,如果标志位置位,那么将标志位复位,但是进程不进入 sleep 状态
  3. 每次循环时,都要复位一次标记位

个人理解
注意这里的标志位应当融入到 wakeup 和 sleep 中,而不是在用户代码中实现。
用户代码如下:

void consumer()
{
    while(true)
    {
        if(count == 0)
        {
            if(awake == false)
                sleep();
        }
    }
}
  1. consumer 读取到 awake 为 false
  2. producer 调用了 wakeup,awake 置位
  3. consumer 依然执行了 sleep
  4. producer 不会再调用 wakeup,consumer 一直被挂起,无法唤醒

总之,应当有一种机制确保标志位和sleep、wakeup 是原子,否则就有可能出现竞态条件。

2.3.5 信号量 Semaphores

信号量:一个整型变量,可以简单理解为 wakeup 信号的个数

  1. 为 0 表示没有 wakeup 信号,或者说没有进程可以被 wakeup
  2. 任意正整数,表示有一个或多个 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);
    }
}
  1. 为什么需要 mutex ?

    mutex 的初始值为 1,即最多只有一个进程处于 awake 状态,确保 producer(s) 和 consumer(s) 不会同时访问 buffer。
    像这种类型的信号量称之为二进制信号量(binary semaphore)

  2. 已经有了 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 会导致异常。

  3. mutex 和 full(empty) 的顺序是否可以交换?

    不可以。假设 mutex 和 full(empty) 交换顺序,producer 先执行 down(&mutex),然后在执行 down(&empty) 时,
    如果此时 empty 的值为 0,那么 producer 无法完成 down 操作而被阻塞,而 consumer 因为无法完成 down(&mutex) 而被阻塞。
    producer 和 consumer 彼此循环等待,永远都无法被唤醒,陷入死锁

使用信号量隐藏中断

  1. 初始化一个初始值为 0 的信号量,
  2. 开始 IO 时,进程做一个 down 操作,进入阻塞状态
  3. 当 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。但是多进程要如何访问共享内存?

  1. 共享数据结构。例如将信号量存放在内核中,进程通过系统调用访问和操作。
  2. 共享地址空间。大多数现代都提供了多进程共享地址空间的方法,可以共享数据结构和缓存,即使以上方式都不支持,也可以共享文件。

如果两个进程共享几乎所有的地址空间,进程和线程的边界逐渐模糊,但还是存在一些无法被共享的东西。
比如打开的不同的文件、定时器、每个进程的属性等等(在一个进程中的线程可以共享这些资源)。
共享地址空间的进程效率不如用户级线程,因为进程由内核来管理。

Futexes

在竞争很少的情况下,自旋锁的性能很高,因为不涉及内核态。但是高竞争场景下,多个进程持续空转会浪费大量的 CPU 资源,因此使用阻塞更加合适。
相反地,在低竞争场景下,使用阻塞锁会进入内核态,内核态切换会降低性能。

futex 由两部分组成:

  1. 内核服务

    提供一个等待队列(wait queue), 在队列中的所有进程都处于阻塞状态等待一个锁,直到内核不再阻塞进程

  2. 用户库

当进程因为 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

消息传递的两个系统调用

  1. send(destination, &message)
  2. receive(source, &message)

Design Issues for Message-Passing Systems

  1. 如何确认消息发到了对端?

    丢包 -> 确认 -> 重传

  2. 安全(认证)问题

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 临界区。

posted @ 2025-04-07 00:43  DantalianLib  阅读(53)  评论(0)    收藏  举报