文章目录
C3 锁
对于并发编程的一个最基本的问题:程序员希望原子式执行一系列指令,但由于单处理器上的中断(或多线程在多处理器上并发执行),这变得不可实现,为了解决这个问题,在源代码中加锁,放在临界区周围,保证临界区能像单条原子指令一样执行
3.1 锁的基本思想
lock_t mutex;
lock(&mutex);
a = a + 1;
unlock(&mutex);
锁就是一个变量,因此像第一行代码那样需要先声明一个某种类型的锁才能使用,这个锁保存了锁在某一时刻的状态,它要么是可用的,表示没有线程持有锁,要么是被占用的,表示有一个线程持有锁,正处于临界区
(1) 线程获得锁和开锁的作用
lock()的作用为:某线程调用lock()尝试获取锁,如果没有其它线程持有锁,该线程会获得锁,进入临界区,这个线程被称为锁的持有者,当锁的持有者在临界区内时,其它线程无法进入临界区
unlock()的作用为:锁的持有者调用unlock(),该锁就变成可用的了,如果有等待线程(锁的阻塞队列里),其中一个会获取锁,进入临界区执行代码
(2) 锁的意义
锁为程序员提供了最小程度的调度控制,线程被操作系统调度,但通过给临界区加锁,可以保证临界区内只有一个线程活跃,锁将原本操作系统调度的混乱状态变得更为可控
(3) 用更多的锁增加并发
POSIX库将锁称为互斥量 mutex,因为它被用来提供线程之间的互斥,即当一个线程在临界区内时,锁能阻止其它线程进入直到本线程离开临界区
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(&lock);
a = a + 1;
pthread_mutex_unlock(&lock);
POSIX的lock()和unlock()会传入一个变量,因为可能用不同的锁保护不同的临界区,这样可以增加并发,用不同的锁保护不同的数据和结构,从而允许更多的线程进入临界区
接下来试着去实现一个锁
3.2 评价一个锁的几个标准
在实现一个锁之前,应该有一套对这个锁的效果的评价标准
1,该锁能否提供互斥,该锁是否有效,能阻止多个线程进入临界区
2,公平性,当锁可用时,是否每个竞争线程都有公平的机会获得锁,是否会有竞争锁的线程饿死
3,性能,即使用锁之后增加的时间开销
3.3 控制中断
最早提供的互斥解决方法之一就是在临界区内关闭中断,这个解决方案是为了单处理器系统开发的
void lock() {
DisableInterrupts(); //关闭中断
}
void unlock() {
EnableInterrupts(); //打开中断
}
假设在一个单处理器上,通过进入临界区之前关闭中断,可以保证临界区的代码不会被中断,从而原子地执行,结束之后,打开中断,程序正常运行
这个方案的主要优点就是简单,没有中断,线程可以确信它的代码会继续执行下去,不会被其它线程干扰
但是缺点很多:
1,这种方法要求所有的线程有打开或关闭中断的特权操作,如果一个贪婪的程序在一开始就调用lock(),关闭中断就独占了处理器,更糟的是,恶意程序调用lock()后一直死循环,就只能重启
2,这种方法不适合多处理器,如果多个线程运行在不同的CPU上,每个线程都尝试进入同一个临界区,关闭中断也没有用,线程可以运行在其它处理器上,也能进入临界区
3,关闭中断导致中断丢失,假如磁盘完成了I/O,但是CPU无法得到这个中断信息,那么操作系统就不知道何时去唤醒发起I/O的程序
4,效率低,与正常执行的指令相比,现代CPU对于关闭和打开中断的代码执行的较慢
基于以上原因,只在很有限的情况下用关闭中断来实现互斥原语,如某些情况下操作系统本身会采用屏蔽中断的方式,保证访问自己数据结构的原子性
3.4 测试并设置指令/原子交换
因为关闭中断的方法无法工作在多处理器上,所以系统设计者开始让硬件支持锁,最简单的硬件支持是测试并设置指令(test-and-set),也称原子交换
其工作方式很简单:用一个变量作为锁表示临界区是否被某些线程占用,当第一线程进入临界区,调用lock(),检查标志是否为1,如果不是1,则设置为1表明线程持有该锁,进入临界区,从临界区退出时,调用unlock(),清除标志,表示锁未被持有
void init() {
//初始化将锁的标志设置为0
mutex->flag = 0;
}
void lock(lock_t *mutex) {
while(mutex->flag == 1) {
; //调用lock时发现标志为1,说明该锁已被持有,陷入循环中等待
}
mutex->flag = 1; //设置标志为1,持有锁
}
void unlock(lock_t *mutex) {
mutex->flag = 0; //调用unlock时将标志置为0
}
当第一个线程正处于临界区时,如果有一个线程调用lock(),它会在while循环中自旋等待,直到第一个线程退出临界区并调用unlock(),等待的线程才会退出while,争抢着设置标志为1,持有锁并执行临界区代码
但是这种方式实现锁,也有缺陷:
1,这种交替执行时,通过适时的中断,可以让多个线程都将标志设置为1,都能进入临界区,这就无法满足互斥
2,性能问题,当线程在等待已经被持有的锁时,采用了自旋等待的技术,即不停的检查标志的值,自旋等待的线程在等待其它线程释放锁时会浪费时间
3.5 自旋锁
(1) 实现一个可用的自旋锁
对于使用测试并设置指令实现一个锁,必须得有硬件的支持,测试并设置指令的内容必须原子执行
测试并设置指令它返回old ptr指向的旧值,同时更新new的新值,这些代码原子地执行,通过这段代码就可以简单实现一个自旋锁
int TestAndSet(int *old_ptr, int new) {
int old = *old_ptr;
*old_ptr = new;
return old;
}
typedef struct lock_t {
int flag;
}lock_t;
void init(lock_t *lock) {
lock->flag = 0; //初始化时设置标志为0
}
void lock(lock_t *lock) {
//调用lock时调用测试并设置指令检查当前flag的值
//如果flag为1则表明锁已被持有,调用lock的线程进入自旋等待
while(TestAndSet(&lock->flag, 1) == 1) {
;
}
}
void unlock(lock_t *lock) {
//调用unlock时,持有锁的线程将flag置为0,让锁等待竞争
lock->flag = 0;
}
将测试和设置合并成一个原子操作后,就可以保证只有一个线程能获取锁,这就实现了以恶有效的互斥原语
这种锁被称为自旋锁的原因,就是让等待的线程一直自旋,利用CPU周期,直到锁可用,在单处理器上,需要抢占式的调度器(通过时钟中断一个线程,运行其它线程),否则自旋锁在单CPU上无法使用,因为一个自旋的线程永远不会放弃CPU
(2) 评价自旋锁
对于自旋锁根据上面设定的几个标准进行评价
1,正确性:自旋锁一次只允许一个线程进入临界区,因此它是正确的锁
2,公平性:自旋锁不提供任何公平性保证,自旋的线程在竞争条件下可能会永远自旋,自旋锁没有公平性,可能会导致线程饿死
3,性能:
在单CPU上,自旋锁的开销相当大,假设一个线程已经持有了锁,线程调度器可能会运行其它的所有线程,每个线程在得到锁之前,还要自旋一次(进行一次flag的判断),这会浪费CPU周期
在多CPU上,自旋锁性能尚可,假设线程A在CPU1,线程B在CPU2,它们竞争同一个锁,线程A持有锁时,线程B就会在CPU2上自旋,并没有浪费很多CPU周期
3.6 几种实现锁的硬件原语
(1) 比较并交换
比较并交换指令的C伪代码:
int CompareAndSwap(int *ptr, int expected, int new) {
int actual = *ptr;
if(actual == excepted) {
*ptr = new;
}
return actual;
}
其基本思路就是检测ptr指向的值是否和expected相等,如果是,则更新ptr所指的值为新值,否则什么也不做,不论哪种情况,都返回该内存地址的实际值,让调用者知道执行是否成功
比较与交换指令实现的锁:
void lock(lock_t *lock) {
//每次调用lock时,调用CompareAndSwap指令
//传递的是flag和期望值及更新值
//如果发现当前flag的值与比较值相同为0,则设置flag为1,持有锁
//如果当前值是1与期望值0不同则线程进入自旋
while(CompareAndSwap(&lock->flag, 0, 1) == 1) {
;
}
}
(2) 链接的加载和条件式存储指令
一些平台提供了实现临界区的一对指令,如链接的加载指令和条件式存储指令,可以用来配合使用,实现其它并发结构
int LoadLinked(int *ptr) {
return *ptr;
}
int StoreConditional(int *ptr, int value) {
//如果ptr指向的值在加载指令执行前没有被更改,则返回1
if(no one has updated *ptr since the LoadLinked to this address) {
*ptr = value;
return 1;
}
return 0;
}
链接的加载指令和典型加载指令类似,都是从内存中取出值存入一个寄存器,关键区别来自条件存储指令,只有上一次加载的地址在期间都没更新时,才会成功,成功时条件存储返回1,并将ptr指向的值更新为value,失败时,返回0,并且不会更新值
锁的实现:
void lock(lock_t *lock) {
while(1) {
while(LoadLinked(&lock->flag) == 1) {
;
}
if(StoreConditional(&lock->flag, 1) == 1) {
return;
}
}
}
(3) 获取并增加指令
获取并增加指令能原子地返回特定地址地旧值,并且让该值自增1
不是用一个值flag来做为标记,这个方案采用了ticket和turn变量来构建锁,如果线程希望获取锁,首先对一个ticket值执行一个获取并增加指令,这个值作为该线程的turn(顺位),根据全局共享的lock->turn,当某一个线程(myturn == turn)时,则轮到这个线程进入临界区,unlcok是增加turn,从而让下一个等待的线程进入临界区
int FetchAndAdd(int *ptr) {
int old = *ptr;
*ptr = old + 1;
return old;
}
typedef struct lock_t {
int ticket;
int turn;
} lock_t;
void lock_init(lock_t *lock) {
lock->ticket = 0;
lock->turn = 0;
}
void lock(lock_t *lock) {
//调用lock的线程先对它的ticket值加一作为myturn
//直到myturn等于它的顺位才能持有锁
int myturn = FetchAndAdd(&lock->ticket);
while(lock->turn != myturn) {
;
}
}
void unlock(lock_t *lock) {
//对当前线程的turn增加1,一个线程的turn越大,它的顺位myturn越大
//他自旋的时间越长,顺位越低
FetchAndAdd(&lock->turn);
}
不同于之前的方法,本方法能保证所有的线程都能得到锁,只要一个线程获得了ticket值,它最终都会被调度
3.7 自旋过多的解决方法
(1) 自旋过多引起的问题
基于硬件的锁简单且有效,但是在某些场景下,这些解决方案效率并不高
单处理器上运行两个线程,当线程A持有锁时,线程B尝试获取锁时,线程B会陷入自旋,当时钟中断产生时,CPU调度线程B,此时线程A释放锁,线程B获取锁,在类似情况下,一个线程会一直检查一个不会改变的值,浪费掉整个时间片,如果有N个线程在单处理器上,情况会更糟,会浪费N-1个时间片,它们只是自旋并等待持有锁的线程释放锁
只有硬件支持是不够的,还需要操作系统的支持
(2) 简单方法:让出CPU
通过ticket锁,可以实现有效,公平的锁,但问题依然存在,如果临界区的线程发生上下文切换,其它线程只能一直自旋,等待持有锁的线程重新运行,直到它释放锁,这对性能是很大的影响
最为简单的方法是在要自旋的时候,放弃CPU
void init() {
flag = 0;
}
void lock() {
while(TestAndSet(&flag ,1) == 1) {
yield(); //放弃CPU
}
}
void unlock() {
flag = 0;
}
可以假定操作系统提供yield()原语,线程可以调用它主动放弃CPU,yield()让处于运行态的线程变为就绪态,从而允许其它线程运行,因此,让出CPU本质是取消调度自己
现在考虑100个线程竞争一把锁的情况,当一个线程持有锁时,其它99个线程调用lock()时,发现锁已经被持有,然后它们退出CPU,假定采用某种轮转调度程序,这99个线程会一直处于运行-让出CPU这种模式,直到持有锁的线程被调度,才可能有机会得到锁。虽然比原来的浪费99个时间片的自旋方案要好,但这种方式成本依然很高,上下文切换的成本是不可避免的
更糟糕的是,没有考虑到线程饿死的情况,一个线程可能一直处于让出CPU的循环,其它线程反复进出临界区,因此需要更好的方法
(3) 使用队列:休眠代替自旋
调度程序决定如何调度,如果调度不合理,线程或者一直自旋,或者让出CPU,这两种方法,都可能造成浪费,也不能防止饿死
因此需要更多的控制,决定释放锁时,谁能抢到锁,为了做到这一点,需要操作系统的更多支持,并需要一个队列来保存等待锁的线程
通过队列来控制谁会获得锁,避免饿死
(4) Linux的两阶段锁
Linux采用的两阶段锁,两阶段锁意识到自旋可能会有用,尤其是很快要释放锁的场景,因此两阶段锁的第一阶段会先自旋一段时间,希望它可以获取锁,但是如果第一个自旋阶段没有获得锁,第二阶段调用者会休眠,直到锁可用
两阶段锁又是一个杂合方案的例子,当然硬件环境,线程数,其它负载这些因素,都会影响锁的效果
(5) 小结
上述这些方案展示了真实的锁是如何是实现的:一些硬件支持和一些操作系统支持