背景

互斥

  • 用户程序的互斥 (futex)
  • 操作系统内核中的互斥 (关中断 + 自旋)
    • 利用自旋锁也可以实现互斥锁

并发数据结构

  • 链表
  • 队列
  • 复合的数据结构 (malloc/free)

是时候面对真正的并发编程了

本次课内容与目标

理解典型的同步问题

  • 生产者-消费者问题
  • 哲学家吃饭问题

理解实现同步的方法

  • 信号量
  • 条件变量

第一个同步问题

生产者-消费者问题

(你们遇到的) 90% 的实际并发问题都是生产者-消费者问题。学会你们就赢了。

生产者 (线程) 生产资源 (一个对象),生产时间不确定

  • 缓冲区里没有空余的空间存放生产的对象时等待

消费者 (线程) 消费资源 (取走一个对象),消费时间也不确定

  • 缓冲区里没有对象可以消费时等待

如何协调它们的生产/消费?

互斥/自旋锁解决生产者-消费者问题

void consume() {
  object_t *obj = NULL;
  while (!obj) { // 队列空时 consumer 在 spin!
    lock(&lk);
    obj = queue_pop();
    unlock(&lk);
  }
  consume(obj);
}
void produce() {
  object_t *obj = produce();
  int succ = false;
  while (!succ) { // 队列满时无法放入,producer 在 spin!
    lock(&lk);
    succ = queue_push(obj);
    unlock(&lk);
  }
}

生产者-消费者问题:分析

(更精简的表达) 有两种线程

void producer() {
  while (1) printf("("); // push
}
void consumer() {
  while (1) printf(")"); // pop
}

在不受并发控制的前提下,任意的括号序列都是合法的

  • 生产者-消费者:打印的括号序列必须满足
    • 一定是某个合法括号序列的前缀
    • 括号嵌套的深度不超过 $n$
      • $n=3$, ((())())((( 合法
      • $n=3$, (((()))), (())) 不合法

同步

同步 (Synchronization)

两个或两个以上随时间变化的量在变化过程中保持一定的相对关系

  • iTunes同步 (手机 vs 电脑)
  • 变速箱同步器 (合并快慢速齿轮)
  • 同步电机 (转子与磁场速度一致)
  • 同步电路 (由时钟驱动,一个周期内全部完成更新)

异步 (Asynchronous) = 不同步

  • 上述很多例子都有异步版本 (异步电机、异步电路、异步线程)

并发程序中的同步

在某个时间点共同达到一致的状态

理解并发程序的方法:把线程想象成我们自己。


我们本身是异步的执行流,但需要在某个点汇合

  • NPY:等我洗个头就出门/等我打完这局游戏就来
  • 舍友:等我写完这段代码就吃饭
  • 导师:等我出差回来就讨论这个课题
  • 生产者:等你 (消费者) 空出队列我再继续
  • 消费者:等你 (生产者) 放入队列我再继续

条件变量:一种万能的同步方法

Conditional Variables (条件变量, CV)

任何同步都有汇合的 “条件”

线程 join (threads.h, sum.c)

  • 等所有线程结束后继续执行 exit,否则等待

NPY 的例子

  • 打完游戏且洗完头后继续执行 date(),否则等待

生产者/消费者问题 (括号版本)

  • 左括号:深度 $k < n$ 时 printf,否则等待
  • 右括号:$k > 0$ 时 printf,否则等待

Conditional Variables: 睡眠和唤醒

对于一个 “条件变量” cv 代表了一个同步条件

  • wait(cv) 💤
    • 进入睡眠状态,等待 cv 上的事件发生
  • signal/notify(cv) 💬 私信:走起
    • 报告 cv 上的事件发生
    • 如果有线程正在等待 cv,则唤醒其中一个线程
  • broadcast/notifyAll(cv) 📣 所有人:走起
    • 报告 cv 上的事件发生
    • 唤醒全部正在等待 cv 的线程

条件变量:万能的同步方法

pthread 条件变量:和互斥锁联合使用


需要等待条件满足时

mutex_lock(&mutex);
while (!cond) {
  wait(&cv, &mutex);
}
assert(cond); // 互斥锁必须保证退出循环时 cond 依然成立
mutex_unlock(&mutex);

其他线程条件可能被满足时

broadcast(&cv);

条件变量:生产者-消费者问题

void produce() {
  mutex_lock(&mutex);
  while (!(count < n)) wait(&cv, &mutex);
  printf("("); count++;
  mutex_unlock(&mutex);
  broadcast(&cv);
}

void consume() {
  mutex_lock(&mutex);
  while (!(count > 0)) wait(&cv, &mutex);
  printf(")"); count--;
  mutex_unlock(&mutex);
  broadcast(&cv);
}

mutex 大幅简化了正确性的证明

  • 不要自作主张使用其他写法 (参考 OSTEP)

条件变量:小习题

有三种线程,分别打印 <, >, 和 _

  • 对这些线程进行同步,使得打印出的序列总是 <><_><>_ 组合

使用条件变量,只要回答三个问题:

  • 打印 “<” 的条件?
  • 打印 “>” 的条件?
  • 打印 “_” 的条件?

信号量

复习:MutexLock (futex) 和更衣室管理

管理员 (操作系统代码) 有一个手环

  • 管理员用自旋锁保护原子性、消除数据竞争

任何人想进入更衣室都需要通过管理员

  • 先到的人 (线程)
    • 成功获得手环 → 进入游泳馆 (线程可被调度执行)
  • 后到的人 (线程)
    • 不能进入游泳馆,排队等待 (线程进入队列,不被调度执行)
  • 洗完澡出来的人 (线程)
    • 交还手环给管理员 → 管理员交给排队的人进入游泳馆 (等待的线程被唤醒)

更衣室管理

完全没有必要限制手环的数量——让更多同学可以进入更衣室

  • 管理员可以持有任意数量的手环 (更衣室容量上限)
    • 先进入更衣室的同学先得到
    • 手环用完后则需要等同学出来

v.s.

更衣室管理 (cont'd)

做一点扩展——线程可以任意 “变出” 一个手环

  • 把手环看成是令牌
  • 得到令牌的可以进入执行
  • 可以随时创建令牌

“手环” = “令牌” = “一个资源” = “信号量” (semaphore)

  • P(&sem) - prolaag = try + decrease; wait; down; in
    • 等待一个手环后返回
    • 如果此时管理员手上有空闲的手环,立即返回
  • V(&sem) - verhoog = increase; post; up; out
    • 变出一个手环,送给管理员

信号量 (E. W. Dijkstra)

信号量 = 互斥锁 + 条件变量

  • 互斥锁
    • 仅有一个手环的情况
    • P = lock; V = unlock
  • 条件变量
    • 手环 = 等待条件
    • P = wait; V = signal
      • 因为计数器的存在,不会发生 signal “丢失”

信号量:实现生产者-消费者

信号量设计的重点

  • 考虑 “手环” (每一单位的 “资源”) 是什么,谁创造?谁获取?
void producer() {
  P(&empty);   // P()返回 -> 得到手环
  printf("("); // 假设线程安全
  V(&fill);
}
void consumer() {
  P(&fill);
  printf(")");
  V(&empty);
}
  • 不要被这种简单的假象骗了!信号量好用,但没有那么好用!!

哲学家吃饭问题

哲学家吃饭问题 (E. W. Dijkstra, 1960)

哲学家 (线程) 有时思考,有时吃饭

  • 吃饭需要同时得到左手和右手的叉子
  • 当叉子被其他人占有时,必须等待,如何完成同步?
    • 如何用信号量实现?

忘了信号量,让我们来耍赖吧!

void philosopher_thread(int id) {
  int lhs = (id - 1 + n) % n, rhs = (id + 1) % n;
  mutex_lock(&mutex);
  while (!(has_fork[lhs] && has_fork[rhs])) {
    wait(&cv, &mutex);
  } // 出循环时,循环条件一定为假
  has_fork[lhs] = has_fork[rhs] = false;
  mutex_unlock(&mutex);

  philosopher_eat();

  mutex_lock(&mutex);
  has_fork[lhs] = has_fork[rhs] = true;
  broadcast(&cv); // 对所有等待的人喊:叉子放回去啦,快看看吧!
  mutex_unlock(&mutex);
}

忘了信号量,让一个人集中管理叉子吧!

“Master/Slave”

  • 分布式系统中非常常见的解决思路 (HDFS, ...)
void philosopher_thread(int id) {
  send_request(id, EAT);
  P(allowed[id]); // waiter 会把叉子递给哲学家
  philosopher_eat();
  send_request(id, DONE);
}

void waiter_thread() {
  while (1) {
    (id, status) = receive_request();
    if (status == EAT) { ... }
    if (status == DONE) { ... }
  }
}

忘了那些复杂的同步算法吧

你可能会觉得,管叉子的人是性能瓶颈

  • 一大桌人吃饭,每个人都叫服务员的感觉

抛开 workload 谈优化就是耍流氓

  • eat (实际的 workload) 时间可能远大于请求 (几个内存操作) 时间
  • 如果一个 manager 搞不定,可以分多个

总结

总结

本次课内容与目标

  • 理解同步:在某个时间点达到互相认同的状态
  • 实现同步:条件变量、信号量

Take-away messages

  • 两种同步的 paradigms
    • 条件变量:等待条件达成,并用互斥锁保证条件不被破坏
    • 信号量:用令牌 (手环) 管理准许执行的权限