3.2-并发控制:同步

本文介绍了在多处理器环境下协同线程完成任务的同步方法,包括互斥锁、条件变量和信号量的应用。通过生产者-消费者问题和哲学家吃饭问题展示了同步的重要性。强调了正确使用同步机制以避免死锁和资源竞争,提倡使用条件变量和信号量作为通用的解决方案。
摘要由CSDN通过智能技术生成

复习

  • 互斥:自旋锁、互斥锁、futex
  • 是时候面对真正的并发编程了

本次课回答的问题

  • Q: 如何在多处理器上协同多个线程完成任务?

本次课主要内容

  • 典型的同步问题:生产者-消费者;哲学家吃饭
  • 同步的实现方法:信号量、条件变量

一、线程同步

同步 (Synchronization)

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

  • iPhone/iCloud 同步 (手机 vs 电脑 vs 云端)
  • 变速箱同步器 (合并快慢速齿轮)
  • 同步电机 (转子与磁场速度一致)
  • 同步电路 (所有触发器在边沿同时触发)

异步 (Asynchronous) = 不同步

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

并发程序中的同步

并发程序的步调很难保持 “完全一致”

  • 线程同步:在某个时间点共同达到互相已知的状态

再次把线程想象成我们自己

  • NPY:等我洗个头就出门/等我打完这局游戏就来
  • 舍友:等我修好这个 bug 就吃饭
  • 导师:等我出差回来就讨论这个课题
  • jyy:等我成为卷王就躺平
    • “先到先等”

生产者-消费者问题:学废你就赢了

99% 的实际并发问题都可以用生产者-消费者解决。

void Tproduce() { while (1) printf("("); }
void Tconsume() { while (1) printf(")"); }

printf 前后增加代码,使得打印的括号序列满足

  • 一定是某个合法括号序列的前缀
  • 括号嵌套的深度不超过
    • n=3, ((())())((( 合法
    • n=3, (((()))), (())) 不合法
  • 同步
    • 等到有空位再打印左括号
    • 等到能配对时再打印右括号

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

为什么叫 “生产者-消费者” 而不是 “括号问题”?

  • 左括号:生产资源 (任务)、放入队列
  • 右括号:从队列取出资源 (任务) 执行

能否用互斥锁实现括号问题?

  • 左括号:嵌套深度 (队列) 不足 n 时才能打印
  • 右括号:嵌套深度 (队列) >1时才能打印
    • 当然是等到满足条件时再打印了:pc.c
      • 用互斥锁保持条件成立
    • 压力测试的检查当然不能少:pc-check.py
    • Model checker 当然也不能少 (留作习题)
#include "thread.h"
#include "thread-sync.h"

int n, count = 0;
mutex_t lk = MUTEX_INIT();

void Tproduce() {
  while (1) {
retry:
    mutex_lock(&lk);
    if (count == n) {
      mutex_unlock(&lk);
      goto retry;
    }
    count++;
    printf("(");
    mutex_unlock(&lk);
  }
}

void Tconsume() {
  while (1) {
retry:
    mutex_lock(&lk);
    if (count == 0) {
      mutex_unlock(&lk);
      goto retry;
    }
    count--;
    printf(")");
    mutex_unlock(&lk);
  }
}

int main(int argc, char *argv[]) {
  assert(argc == 2);
  n = atoi(argv[1]);
  setbuf(stdout, NULL);
  for (int i = 0; i < 8; i++) {
    create(Tproduce);
    create(Tconsume);
  }
}

编译运行

gcc pc.c -lpthread
./a.out 1

./a.out 2

./a.out 3

./a.out 1 | python pc-check.py 1
# 100000 Ok.
# 100000 Ok.
# 100000 Ok.

./a.out 3 | python pc-check.py 3

二、条件变量:万能同步方法

同步问题:分析

任何同步问题都有先来先等待的条件。

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

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

NPY 的例子

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

生产者/消费者问题

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

Conditional Variables (条件变量, CV)

pc.c 中的自旋变成睡眠

  • 在完成操作时唤醒

条件变量 API

  • wait(cv, mutex) 💤
    • 调用时必须保证已经获得 mutex
    • 释放 mutex、进入睡眠状态
  • signal/notify(cv) 💬 私信:走起
    • 如果有线程正在等待 cv,则唤醒其中一个线程
  • broadcast/notifyAll(cv) 📣 所有人:走起
    • 唤醒全部正在等待 cv 的线程

条件变量:实现生产者-消费者

压力测试:pc-cv.c;模型检验:pc-cv.py

  • (Small scope hypothesis)
#include "thread.h"
#include "thread-sync.h"

int n, count = 0;
mutex_t lk = MUTEX_INIT();
cond_t cv = COND_INIT();

void Tproduce() {
  while (1) {
    mutex_lock(&lk);
    if (count == n) {
      cond_wait(&cv, &lk);
    }
    printf("("); count++;
    cond_signal(&cv);
    mutex_unlock(&lk);
  }
}

void Tconsume() {
  while (1) {
    mutex_lock(&lk);
    if (count == 0) {
      pthread_cond_wait(&cv, &lk);
    }
    printf(")"); count--;
    cond_signal(&cv);
    mutex_unlock(&lk);
  }
}

int main(int argc, char *argv[]) {
  assert(argc == 2);
  n = atoi(argv[1]);
  setbuf(stdout, NULL);
  for (int i = 0; i < 8; i++) {
    create(Tproduce);
    create(Tconsume);
  }
}

编译运行

gcc pc-cv.c -lpthread
./a.out 1
class ProducerConsumer:
    locked, count, log, waits = '', 0, '', ''

    def tryacquire(self):
        self.locked, seen = '🔒', self.locked
        return seen == ''

    def release(self):
        self.locked = ''

    @thread
    def tp(self):
        for _ in range(2):
            while not self.tryacquire(): pass # mutex_lock()

            if self.count == 1:
                # cond_wait
                _, self.waits = self.release(), self.waits + '1'
                while '1' in self.waits: pass
                while not self.tryacquire(): pass

            self.log, self.count = self.log + '(', self.count + 1
            self.waits = self.waits[1:] # cond_signal
            self.release() # mutex_unlock()

    @thread
    def tc1(self):
        while not self.tryacquire(): pass

        if self.count == 0:
            _, self.waits = self.release(), self.waits + '2'
            while '2' in self.waits: pass
            while not self.tryacquire(): pass

        self.log, self.count = self.log + ')', self.count - 1

        self.waits = self.waits[1:]
        self.release()

    @thread
    def tc2(self):
        while not self.tryacquire(): pass

        if self.count == 0:
            _, self.waits = self.release(), self.waits + '3'
            while '3' in self.waits: pass
            while not self.tryacquire(): pass

        self.log, self.count = self.log + ')', self.count - 1

        self.waits = self.waits[1:]
        self.release()

    @marker
    def mark_negative(self, state):
        count = 0
        for ch in self.log:
            if ch == '(': count += 1
            if ch == ')': count -= 1
            if count < 0: return 'red'

编译运行

time python model-checker.py pc-cv.py > a.txt
# 13.13s user 0.64s system 98% cpu 13.936 total

条件变量:正确的打开方式

需要等待条件满足时

mutex_lock(&mutex);
while (!cond) {
  wait(&cv, &mutex);
}
assert(cond);
// ...
// 互斥锁保证了在此期间条件 cond 总是成立
// ...
mutex_unlock(&mutex);

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

broadcast(&cv);

条件变量:实现并行计算

struct job {
  void (*run)(void *arg);
  void *arg;
}

while (1) {
  struct job *job;

  mutex_lock(&mutex);
  while (! (job = get_job()) ) {
    wait(&cv, &mutex);
  }
  mutex_unlock(&mutex);

  job->run(job->arg); // 不需要持有锁
                      // 可以生成新的 job
                      // 注意回收分配的资源
}

条件变量:更古怪的习题/面试题

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

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

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

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

三、信号量

复习:互斥锁和更衣室管理

操作系统 = 更衣室管理员

  • 先到的人 (线程)
    • 成功获得手环,进入游泳馆
    • *lk = 🔒,系统调用直接返回
  • 后到的人 (线程)
    • 不能进入游泳馆,排队等待
    • 线程放入等待队列,执行线程切换 (yield)
  • 洗完澡出来的人 (线程)
    • 交还手环给管理员;管理员把手环再交给排队的人
    • 如果等待队列不空,从等待队列中取出一个线程允许执行
    • 如果等待队列为空,*lk = ✅
  • 管理员 (OS) 使用自旋锁确保自己处理手环的过程是原子的

更衣室管理

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

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

更衣室管理 (by E.W. Dijkstra)

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

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

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

  • P(&sem) - prolaag = try + decrease; wait; down; in
    • 等待一个手环后返回
    • 如果此时管理员手上有空闲的手环,立即返回
  • V(&sem) - verhoog = increase; post; up; out
    • 变出一个手环,送给管理员
  • 信号量的行为建模: sem.py
class Semaphore:
    token, waits = 1, ''

    def P(self, tid):
        if self.token > 0:
            self.token -= 1
            return True
        else:
            self.waits = self.waits + tid
            return False

    def V(self):
        if self.waits:
            self.waits = self.waits[1:]
        else:
            self.token += 1

    @thread
    def t1(self):
        self.P('1')
        while '1' in self.waits: pass
        cs = True
        del cs
        self.V()

    @thread
    def t2(self):
        self.P('2')
        while '2' in self.waits: pass
        cs = True
        del cs
        self.V()

    @marker
    def mark_t1(self, state):
        if localvar(state, 't1', 'cs'): return 'blue'

    @marker
    def mark_t2(self, state):
        if localvar(state, 't2', 'cs'): return 'green'

    @marker
    def mark_both(self, state):
        if localvar(state, 't1', 'cs') and localvar(state, 't2', 'cs'):
            return 'red'

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

信号量设计的重点

  • 考虑 “手环” (每一单位的 “资源”) 是什么,谁创造?谁获取?
void producer() {
  P(&empty);   // P()返回 -> 得到手环
  printf("("); // 假设线程安全
  V(&fill);
}
void consumer() {
  P(&fill);
  printf(")");
  V(&empty);
}
  • 在 “一单位资源” 明确的问题上更好用
#include "thread.h"
#include "thread-sync.h"

sem_t fill, empty;

void producer() {
  while (1) {
    P(&empty);
    printf("(");
    V(&fill);
  }
}

void consumer() {
  while (1) {
    P(&fill);
    printf(")");
    V(&empty);
  }
}

int main(int argc, char *argv[]) {
  assert(argc == 2);
  SEM_INIT(&fill, 0);
  SEM_INIT(&empty, atoi(argv[1]));
  for (int i = 0; i < 8; i++) {
    create(producer);
    create(consumer);
  }
}
gcc pc-sem.c -lpthread && ./a.out 3

四、哲学家吃饭问题

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

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

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

失败与成功的尝试

失败的尝试


成功的尝试 (万能的方法)

mutex_lock(&mutex);
while (!(avail[lhs] && avail[rhs])) {
  wait(&cv, &mutex);
}
avail[lhs] = avail[rhs] = false;
mutex_unlock(&mutex);

mutex_lock(&mutex);
avail[lhs] = avail[rhs] = true;
broadcast(&cv);
mutex_unlock(&mutex);

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

“Leader/follower” - 生产者/消费者

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

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

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

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

  • 一大桌人吃饭,每个人都叫服务员的感觉
  • Premature optimization is the root of all evil (D. E. Knuth)

抛开 workload 谈优化就是耍流氓

  • 吃饭的时间通常远远大于请求服务员的时间
  • 如果一个 manager 搞不定,可以分多个 (fast/slow path)

总结

本次课回答的问题

  • Q: 如何在多处理器上协同多个线程完成任务?

Take-away message

  • 实现同步的方法
    • 条件变量、信号量;生产者-消费者问题
    • Job queue 可以实现几乎任何并行算法
  • 不要 “自作聪明” 设计算法,小心求证
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

绿洲213

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值