Lecture 11: Concurrency 4

回顾:

  1. 使用信号量的并发同步。
  2. 并发编程的生产者/消费者问题和困难。

本章小结:

  • 继续看有界缓冲区问题
  • 用餐哲学家(dining philosophers的问题

哲学家就餐问题:

一个著名的计算机科学问题,用于解释同步、死锁和资源竞争等并发问题。

生产者/消费者问题

单一生产者/消费者和无界缓冲区

竞争条件(不存在的元素=>items= -1)

两个信号量:

  • sync ——二进制信号量(互斥),保护对 items 的读写;

  • delay_consumer ——用来让消费者在没有元素时睡眠,有元素时被唤醒。

内容含义
void * consumer(void *p) / void * producer(void *p)两个线程函数的声明。
1 → 0 → 1表示 items 随时间变化的示例值:最初为 1,消费者拿走一个后变成 0,生产者再生产一个又变成 1。
sem_wait(&delay_consumer);(消费者开头)

若 items == 0,消费者阻塞在 delay_consumer,直到生产者 sem_post 将其唤醒。

0 => -1 表示“计数器减 1”

sem_wait(&sync); / sem_post(&sync);进入/退出临界区,对 items 做互斥保护,防止并发读写。
items-- / items++

消费一个 / 生产一个。

消费者拿走商品 / 生产者放入商品。

0 => 1(写在 items++ 旁)示意:当 items 从 0 变到 1 时,生产者执行 sem_post(&delay_consumer) 唤醒等待中的消费者。
if(items == 1)(生产者)当 items 刚刚由 0 变成 1,说明之前没货,现在需要唤醒消费者。
if(items == 0)(消费者)当 items 变成 0,消费者会再次执行 sem_wait(&delay_consumer) 进入睡眠。
Race condition non-existing element => items = -1错误场景:如果对 items 的读写不加 sync 保护,两个线程并发执行就可能出现 items 变成负数(试图消费一个不存在的元素)。强调了必须加锁

虽然我们同步了对items的访问,但这并不能防止涉及该变量的竞争条件!
我们没有保留items和信号量delay_consumer之间的关系。

死锁

items 已经为 0 时,再次 sem_wait(&delay_consumer)
而此时信号量很可能已经是 0,消费者再次阻塞,生产者却因为锁名错误永远无法唤醒它 → 又一种死锁场景

解决方案

使用临时变量

  • 复制临界区内items的值
  • 递减delay_consumer信号量以使其一致

多生产者,多消费者,有限缓冲

前面的代码(一个消费者,一个生产者)通过存储 items 的值来工作
该问题的另一种变体有n个消费者,m个生产者和一个固定的缓冲区大小N。解决方案基于3个信号量

  • sync:用于对缓冲区强制互斥
  • empty:跟踪空缓冲区的数量,初始化为N
  • full:跟踪已满缓冲区的数量,初始化为0

emptyfull计数信号量,代表资源

例子:多生产者 / 多消费者、固定大小 N = 3 的循环缓冲区


/* 全局 */
int items = 0;              // 当前缓冲区里元素个数
sem_t empty = 3;            // 空位
sem_t full  = 0;            // 数据
sem_t sync  = 1;            // 互斥锁

/* 任一生产者线程 */
void *producer(void *a)
{
    while (1) {
        sem_wait(&empty);   // 等空位
        sem_wait(&sync);    // 进临界区
        items++;            // 放入 1 个
        printf("Producer:%d\n", items);
        sem_post(&sync);    // 出临界区
        sem_post(&full);    // 增加可读计数
    }
}

/* 任一消费者线程 */
void *consumer(void *a)
{
    while (1) {
        sem_wait(&full);    // 等数据
        sem_wait(&sync);    // 进临界区
        items--;            // 取走 1 个
        printf("Consumer:%d\n", items);
        sem_post(&sync);    // 出临界区
        sem_post(&empty);   // 增加空位
    }
}

3个信号量

  • sync:二进制互斥锁,保护对共享变量 items 的读写,初始化为1
  • empty:当前缓冲区还剩余多少个空位可写,初始化为3
  • full:当前缓冲区已有多少个数据可读,初始化为0

哲学家进餐问题

描述

问题定义为:

  • 五位哲学家坐在一张圆桌上
  • 每个人都有一盘意大利面
  • 意大利面太滑了,每个哲学家需要2把叉子才能吃
  • 当饥饿/hungry的时候(在思考/thinking的间隙),哲学家试图获得他们左右两边的叉子

这反映了在多个进程(哲学家)之间共享有限资源(叉子)的一般问题。

方案1:死锁

叉子(Forks信号量表示(初始化为1)

  • 如果叉子可用,则为1:哲学家可以继续
  • 如果叉子不可用,则为0;如果试图获取它,则进入睡眠状态

第一种方法:朴素解法,一个会死锁的版本。每个哲学家拿起一把叉子,等待第二把叉子变得可用(不放下第一把)。

#define N 5
sem_t forks[N];

void * philosopher(void * id) {
  int i = *((int *) id);
  int left = (i + N - 1) % N;
  int right = i % N;
  while(1) {
    printf("%d is thinking\n", i);
    printf("%d is hungry\n", i);
    sem_wait(&forks[left]);
    sem_wait(&forks[right]);
    printf("%d is eating\n", i);
    sem_post(&forks[left]);
    sem_post(&forks[right]);
  }
}

1. 全局定义

#define N 5
sem_t forks[N];
  • 一共 5 位哲学家,编号 0‥4。

  • 每位哲学家左右各有一把叉子,共 5 把。

  • 信号量 forks[i] 的初值为 1,表示第 i 把叉子可用。

2. 哲学家线程函数

void *philosopher(void *id) {
    int i   = *((int *)id);        // 哲学家编号
    int left  = (i + N - 1) % N;   // 左手叉子编号
    int right = i % N;             // 右手叉子编号

3. 无限循环:思考 → 饥饿 → 拿叉 → 吃 → 放叉

    while (1) {
        printf("%d is thinking\n", i);      // 思考
        printf("%d is hungry\n",  i);       // 饥饿

        sem_wait(&forks[left]);             // 拿左叉
        sem_wait(&forks[right]);            // 拿右叉

        printf("%d is eating\n", i);        // 吃面

        sem_post(&forks[left]);             // 放左叉
        sem_post(&forks[right]);            // 放右叉
    }
}

4. 为什么会死锁

如果 5 位哲学家同时先拿左边的叉子,再拿右边的叉子(顺序相同),就会出现:

哲学家 0 拿到叉子 4
哲学家 1 拿到叉子 0
哲学家 2 拿到叉子 1
哲学家 3 拿到叉子 2
哲学家 4 拿到叉子 3

此时每人都持有一把叉子,都在等待另一把,循环等待死锁

可以通过以下方式防止死锁:

  • 把分叉放下,等待一个随机的时间——潜在的其他bug。
  • 在桌子上多放一个叉子——回答一个更简单的问题!
  • 一个由哲学家设定的全局互斥锁,当他们想吃东西的时候,一次只能有一个人吃。——不会导致最大的并行性,因为一次只有一个吃。

方案2:全局互斥信号量

sem_t eating; // 全局互斥信号量,初值为 4(或 1,见下方说明)

void * philosopher(void * id)
{
  int i = (int) id;
  int left = (i + N - 1) % N;
  int right = i % N;
  while(1)
  {
    printf("%d is thinking\n", i);
    printf("%d is hungry\n", i);
    sem_wait(&eating); /**** mutex/semaphore ****/ // ① 开始干饭
    sem_wait(&forks[left]); // ② 拿左叉
    sem_wait(&forks[right]); // ③ 拿右叉
    printf("%d is eating\n", i);
    sem_post(&forks[left]); // ④ 放左叉
    sem_post(&forks[right]); // ⑤ 放右叉
    sem_post(&eating); /**** mutex/semaphore ****/ // ⑥ 停止干饭
  }
}
内容含义
sem_t eating全局互斥信号量,用来限制同时尝试拿叉子的人数
sem_wait(&eating); / sem_post(&eating);进入/退出“临界区域(拿叉→吃→放叉)”。
夹在 eating 之间的三行拿两把叉、吃、再放叉,被全局信号量包住,保证同一时刻最多只有 N-1=4 位哲学家能同时伸手,从而破坏循环等待条件,天然避免死锁。
注释 /**** mutex/semaphore ****/强调这一段用互斥信号量(mutex 或 binary semaphore)保护。

为什么能防死锁?

  • 最多只允许 4 人同时进入“吃饭区”eating 初值设为 4)。

  • 因此至少有一位哲学家无法同时拿叉子,循环等待链被切断 ⇒ 死锁不可能出现

理解

1. 可以将eating信号量的值初始化为2来创建更多的并行性吗?

答:可以把 eating 信号量的初值设为 2,这样最多允许 2 位哲学家同时进入“拿叉-吃饭-放叉”的临界区,确实能提高并发度(相比初值 1)。不过:

  1. 并发度虽提高,但仍低于 N-1(4) 的最大安全值,理论上仍不会死锁。

  2. 实际吞吐量可能反而下降,因为同一时刻只能有 2 人在吃饭,CPU/叉子利用率降低。

  3. 如果目的只是“防死锁”,N-1 是最小且最有效的限制;设成 2 只是折中,不是最优。

可以设成 2,但意义不大;通常直接设为 N-1。

2. 对于单生产者消费者无界缓冲问题,你能找到一个更好的解决方案吗?

答:不需要再维护 empty/full 两个计数信号量,因为缓冲区无限大,永远不会“满”。
• 只需 一把互斥锁(或一条原子链)保护对 in/out 指针的读写;
• 再加 一个条件变量 / 信号量 data_avail(初值 0)告诉“消费者现在至少有一条数据”。
伪代码:避免了 “空/满” 的管理开销。

mutex mtx;
cond  data_avail;
int   in = 0, out = 0;
item  buffer[];

producer() {
    while (true) {
        item = produce();
        lock(&mtx);
        buffer[in] = item;
        in = (in + 1) % INF;   // INF 足够大
        unlock(&mtx);
        signal(&data_avail);
    }
}

consumer() {
    while (true) {
        wait(&data_avail);
        lock(&mtx);
        item = buffer[out];
        out = (out + 1) % INF;
        unlock(&mtx);
        consume(item);
    }
}

3. 2×N 位哲学家围成一圈,最多同时几人吃饭?
答:每把叉子被相邻两人共享;任意两人不能共享同一把叉子
把叉子看作图的边,哲学家看作顶点。最大独立集大小为 N
因此 最多 N 位 哲学家能同时吃饭。

4. 2×N+1 位哲学家围成一圈,最多同时几人吃饭?
答:总人数为奇数,同理每两人之间仍共享一把叉子。
最大独立集大小为 N
因此 最多 N 位 哲学家能同时吃饭。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值