线程
并发是具体到线程维度的,所以在介绍并发之前,一般都会先介绍下什么是线程。
线程是操作系统提供的抽象,针对的是单个运行的进程。线程间切换也是上下文切换,类比于进程切换把状态保存在进程控制块(Process Control Block,PCB)中,线程切换也会把状态保存在线程控制块(Thread Control Block,TCB),但因为线程之间共享地址空间,能够访问相同的数据,所以线程切换时地址空间保持不变(即不需要切换当前使用的页表)
为什么会有并发问题的原因在于多线程不可控的调度,共享数据是其导致的问题之一。
-
线程之间的交互方式:
-
1.访问共享变量:因为线程不可控的调度所以会带来多线程下共享数据访问不一致的问题,针对这个问题需要思考如何构建对同步原语的支持来支持原子性
-
2. 一个线程在继续之前必须等待另一个线程完成某些操作(在多线程程序中常见的睡眠/唤醒交互的机制)
-
本质上,并发编程就是为了解决这两种交互方式带来的问题
- 术语:
- 竞态条件(race condition) 出现在多个执行线程大致同时进入临界区时,它们都试图更新共享的数据结构,导致了令人惊讶的(也许是不希望的)结果
- 临界区是访问共享变量(或更一般地说,共享资源)的代码片段,一定不能由多个线程同时执行
- 互斥(mutual exclusion)原语: 保证只有一个线程进入临界区,从而避免出现竞态,并产生确定的程序输出
锁
锁的基本思想
锁的本质就是一个变量。这个锁变量(简称锁)保存了锁在某一时刻的状态。它要么是可用的(available,或 unlocked,或 free),表示没有线程持有锁,要么是被占用的(acquired,或 locked,或 held),表示有一个线程持有锁,正处于临界区。
由此可以猜想lock(),unlock()的语义,调用lock()会尝试获取锁,如果没有其他线程持有锁(即它是可用的),该线程会获得锁,进入临界区;调用 unlock(),如果没有其他等待线程(即没有其他线程调用lock()并卡在那里),锁的状态就变成可用了
通过给临界区加锁,可以保证临界区内只有一个线程活跃。锁将原本由操作系统调度的混乱状态变得更为可控。
锁的标准
在实现锁之前,我们应该首先明确目标,意即一个锁应该实现怎样的效果,在这里给出评价锁的标准:
- 互斥性:提供互斥是锁的基本功能,锁要有效就要能阻止多个线程进入临界区
- 公平性:保证每一个竞争线程有公平的机会抢到锁,不会被饿死
- 性能:使用锁之后增加的时间开销
如何实现锁
实现锁想到的第一个方式便是控制中断,因为线程调度不可控的原因之一正是因为处理器的中断。
-
控制中断:在临界区关闭中断来实现互斥,但这种解决方案有如下缺点:
- 处理器资源被独占
- 不支持多处理器
- 关闭中断导致中断丢失
-
因为控制中断存在这些缺点,因此这种方式不是实现锁的理想方式。我们知道锁的本质是一个变量,那是否可以用一个标志变量来标志锁是否被某些线程占用。当一个线程进入临界区,调用 lock(),检查标志是否为 1(这里不是 1),然后设置标志为 1,表明线程持有该锁。结束临界区时,线程调unlock(),清除标志,表示锁未被持有。就相当于把锁放在临界区周围,保证临界区能够像单条原子指令一样执行
- 代码如下:
- 然而这种方式无法保证临界区的互斥,存在多个线程进入临界区的情况,这导致了临界区数据的正确性问题,也存在性能问题。
- 代码如下:
可以看出,不依赖于原子指令实现锁是存在问题的。如果在以上方式实现锁的基础上使用原子指令使得获取和设置锁的值变成一个原子操作,就可以保证了只有一个线程能获取锁,这就实现了一个有效的互斥原语。那么在硬件层面支持了哪些原子指令呢?
原子指令
- 测试并设置指令(test-and-set):
返回 old_ptr 指向的旧值,同时更新为 new 的新值。将测试(test 旧的锁值)和设置(set 新的值)合并为原子操作
- 比较并交换(compare-and-swap)
是检测 ptr 指向的值是否和 expected 相等;如果是,更新 ptr 所 指的值为新值。否则,什么也不做。不论哪种情况,都返回该内存地址的实际值
- 链接的加载和条件式存储指令
- 获取并增加
基于这些硬件提供的原子指令实现的锁简单有效,但却会让获取不到锁的线程自旋等待,而不必要的自旋会浪费 CPU 时间,所以需要思考如何解决这一问题
避免自旋
- 操作系统提供原语 yield(),线程可以调用它主动放弃 CPU, 让其他线程运行
- 存在线程饿死的情况
- 频繁让出资源会导致上下文切换开销大
- 使用队列来保存等待锁的线程
- 使用休眠替代自旋
- 可以决定在锁释放时哪些线程能抢到锁
- 两阶段锁
- 第一阶段线程会先自旋一段时间,希望可以获取锁。
- 第二阶段调用者会睡眠,直到锁可用
基于锁的并发数据结构
- 并发计数器
- 并发链表
- 并发队列
- 并发散列表
条件变量
前面提到线程之间的交互除了通过共享变量之外还会有等待机制,比如线程需要检查某一条件(condition)满足之后才会继续运行,这种线程等待某些条件在多线程程序中是很常见的情况,那么要如何实现这种线程间的等待机制。
1.共享变量
一种方式是用共享变量,线程通过自旋直到共享变量满足条件
2.条件变量
另一种方式是线程可以使用条件变量(condition variable),来等待一个条件变成真。条件变量是一个 显式队列,当某些执行状态(即条件,condition)不满足时,线程可以把自己加入队列,等 待(waiting)该条件。另外某个线程,当它改变了上述状态时,就可以唤醒一个或者多个 等待线程(通过在该条件上发信号),让它们继续执行。
条件变量有两种相关操作:
- wait():释放锁,并让线程休眠
- signal():当线程想唤醒等待在某个条件变量上的睡眠线程时
js
pthread_cond_t c = PTHREAD_COND_INITIALIZER;
pthread_cond_wait(pthread_cond_t *c, pthread_mutex_t *m);
pthread_cond_signal(pthread_cond_t *c);
条件变量往往是配合锁一起使用,在发信号即调用signal()方法和等待即调用wait()方法时最好都加上锁。
生产者/消费者问题
- 问题一:某个消费者被生产者唤醒后,缓冲区的状态已经被改变,比如缓冲区的消息被临时的另一个消费者消费完,此时被唤醒的消费者面临无消息可消费的场景,又会去睡眠,导致了假唤醒的情况。(线程唤醒之后数据状态发生了变化,并不会保证数据在它运行之前状态一直是期望的情况,这种情况常被称为)Mesa 语义
- 解决方式:使用 While 语句替代 If
- 问题二:因为生产者消费者都在同一个条件变量等待睡眠,唤醒时导致会唤醒错误的对象
- 解决方式:使用两个条件变量,empty条件变量和fill条件变量
覆盖条件
用 pthread_cond_broadcast()代替pthread_cond_signal(),唤醒所有的等待线程。但太多线程被唤醒会影响性能
信号量
使用信号量代替锁和条件变量。sem_wait()减一,sem_post()加一。
信号量用作锁
使用值为1的信号量,把临界区用一对 sem_wait()/sem_post()环绕;值为1时线程调用sem_wait()减一进入临界区访问资源;值为0时线程调用sem_wait()减一,在值小于 0 时线程等待(自己睡眠,放弃处理器);
信号量用作条件
信号量解决生产者/消费者问题
如何实现信号量
使用锁和条件变量来实现
js
1 typedef struct _Zem_t {
2 int value;
3 pthread_cond_t cond;
4 pthread_mutex_t lock;
5 } Zem_t;
6
7 // only one thread can call this
8 void Zem_init(Zem_t *s, int value) {
9 s->value = value;
10 Cond_init(&s->cond);
11 Mutex_init(&s->lock);
12 }
13
14 void Zem_wait(Zem_t *s) {
15 Mutex_lock(&s->lock);
16 while (s->value <= 0)
17 Cond_wait(&s->cond, &s->lock);
18 s->value--;
19 Mutex_unlock(&s->lock);
20 }
22 void Zem_post(Zem_t *s) {
23 Mutex_lock(&s->lock);
24 s->value++;
25 Cond_signal(&s->cond);
26 Mutex_unlock(&s->lock);
27 }
常见并发问题
非死锁缺陷
- 违反原子性缺陷
- 解决方式:给共享变量的访问加锁
- 违反顺序缺陷
- 解决方式:锁加条件变量
死锁缺陷
-
产生死锁的条件
- 互斥:线程对于需要的资源进行互斥的访问
- 持有并等待:线程持有了资源,同时又在等待其他资源
- 非抢占:线程获得的资源(例如锁),不能被抢占
- 循环等待:线程之间存在一个环路,环路上每个线程都额外持有一个资源,而这个资源又是下一个线程要申请的
-
如何预防死锁
- 针对循环等待条件:获取锁时提供一个全序
- 针对持有并等待条件: 通过原子地抢锁来避免
- 针对非抢占条件: 使用trylock()函数会尝试抢占锁
- 针对互斥条件:加了锁就会产生互斥,要想避免互斥可以使用无锁化的比较并交换指令
- 通过调度避免死锁
- 检查和恢复:重启
基于事件的并发
等待某个事件发生;当它发生时,检查事件类型,然后做少量的相应工作(可 能是 I/O 请求,或者调度其他事件准备后续处理)。 事件循环的伪代码: 主循环等待某些事件发生(通过 getEvents()调用),然后依次处理这 些发生的事件。处理事件的代码叫作事件处理程序(event handler)。处理程序在 处理一个事件时,它是系统中发生的唯一活动。
问题一:阻塞系统调用
如果某个事件要求发出可能会阻塞的系统调用,该怎么办?
如果一个事件处理程序发出一个阻塞的调用,整个服务器就会这样做:阻塞直到调用完成。 当事件循环阻塞时,系统处于闲置状态,因此是潜在的巨大资源浪费。因此,我们在基于 事件的系统中必须遵守一条规则:不允许阻塞调用。
- 解决方案:异步 I/O
问题二:状态管理
当事件处理程序发出异步 I/O 时,它必须打包一些程序状态,以便下一个事件处理程序在 I/O 最终完成时使用。
- 解决方案:使用一种称为"延续(continuation)"的老编 程语言结构。在某些数据结构中,记录完成处理该事件需要的信息。当事件发生时(即磁盘 I/O 完成时),查找所需信息并处理事件