目录
3.2.2 compare-and-swap / compare-and-exchange
3.2.3 load-linked & store-condition
3.2.6 sleeping instead of spinning
一、前言
本文是OSTEP笔记,只是对其中第二部分并发相关章节的提炼,以满足自己对基本原理方面的好奇心,作为并发系列总结后续的理论基础。如果有错误,欢迎指出。
二、基本术语
critical section:临界区,访问共享资源(通常是一个变量或者数据结构)的一个代码段。
race condition:竟态,多个执行线程同时进入临界区并试图同时更新共享资源。
indeterminate program:由于竟态导致程序运行结果随机。
mutual exclusion:互斥,保证同一时间只有一个执行线程进入临界区,避免竞争。
atomicity:原子,all or none,一次做完或者什么也不做。
三、同步
需要进行同步操作有以下两种情况:
- 多执行线程访问共享资源,需要保护临界区,需要加锁。
- 一个线程等待另一个线程完成才能进行下一步的操作,如某线程需要等待I/O操作完成才能进行下一步操作,又如在并行处理中,某核要等待其他核完成相应的计算取得结果之后,才能继续自己的任务。
对于上述第一种情况,可以使用锁来保护临界区,锁的使用方法如下:
acquire the lock
enter critical section
release the unlock
3.1 评估一个锁
构建一个可用的锁需要下面三个方面的考量:
- correctness
- fairness
- performance
可以看到,锁的演进都是在保证正确的前提下,不断提升其公平性和性能的。
3.2 锁的实现
锁的实现需要硬件提供synchronization primitives,下面通过两个例子说明软件无法实现lock的原因:
例一
typedef struct __lock_t {
int flag;
}lock_t;
void init(lock_t *mutex) {
mutex->flag = 0;
}
void lock(lock_t *mutex) {
while (mutex->flag == 1)
;
mutex->flag = 1; // now SET it!
}
void unlock(lock_t *mutex) {
mutex->flag = 0;
}
程序很简单,通过一个flag标识是否能获得锁,获得锁就置1,这样下一个想要获取锁就需要spin,直到锁被释放,即flag置0。这样的锁的实现是不正确的:假设开始锁的unlocked的,这时一个线程获取锁当执行完while (mutex->flag == 1)时,线程被打断,另一个线程获取到锁,进入临界区,原线程切回来继续执行,注意此时while语句已经满足了条件,继续向下执行,也进入了临界区,因此的锁的实现不正确。
例二:
decker‘s alg
通过以上分析,无法通过软件来实现一个锁。因此锁的实现需要通过硬件提供同步原句。
3.2.1 test-and-set
int TestAndSet(int *old_ptr, int new) {
int old = *old_ptr;
*old_ptr = new;
return old;
}
上面是test-and-set的C伪码,注意它代表一个原子操作就可以了。整个执行的作用就是保存new的值,返回old_ptr中的旧值。我们定义锁的两个状态:
#define UNLOCK 0
#define LOCK 1
那么使用test-and-set实现自旋锁的代码如下:
typedef struct __lock_t{
int flag;
}lock_t;
void init(lock_t *lock){
lock->flag = 0;
}
void lock(lock_t *lock){
while (TestAndSet(&lock->flag, 1) == 1)
; // spin-wait (do nothing)
}
void unlock(lock_t *lock){
lock->flag = 0;
}
关键的一句就是 while (TestAndSet(&lock->flag, LOCK) == LOCK) ;
尝试LOCK但是锁已经被LOCK住了(原内存的值已经是LOCK了)就一直循环等待。
3.2.2 compare-and-swap / compare-and-exchange
int CompareAndSwap(int *ptr, int expected, int new) {
int actual = *ptr;
if (actual == expected)
*ptr = new;
return actual;
}
上面是CAS的的C伪码。和test-and-set不同之处在于只有内存中的值和expected值一致时,才更新new,返回内存原值。
void lock(lock_t *lock) {
while (CompareAndSwap(&lock->flag, 0, 1) == 1)
; // spin
}
利用该原句实现锁 while (CompareAndSwap(&lock->flag, UNLOCK, LOCK) == LOCK)
尝试去LOCK,如果没有返回期望的值(UNLOCK)就继续循环等待。
3.2.3 load-linked & store-condition
前面两个原句的原理是test/compare if UNLOCK, set/swap LOCK,本节的指令形式与之不同,具体看:
int LoadLinked(int *ptr) {
return *ptr;
}
int StoreConditional(int *ptr, int value) {
if (no one has updated *ptr since the LoadLinked to this address) {
*ptr = value;
return 1; // success!
} else {
return 0; // failed to update
}
}
上面时load-linked和store-condition的C伪码,可以看到load-linked和正常load指令类似,store-conditon要检测load-linked加载该地址的内容以后没有被更新过或者被中断打断。如果出现了上述情形就返回错误,表明本次store时失败的,需要再次尝试。
void lock(lock_t *lock) {
while (1) {
while (LoadLinked(&lock->flag) == 1)
; // spin until it’s zero
if (StoreConditional(&lock->flag, 1) == 1)
return; // if set-it-to-1 was a success: all done
// otherwise: try it all over again
}
}
使用load-linked和store-condition实现lock方法如上,原理就是使用load-linked指令并保证当前锁是UNLOCK的,此时使用store-condition将锁的状态置为LOCK,由硬件保证其原子性。
3.2.4 fetch-and-add
int FetchAndAdd(int *ptr) {
int old = *ptr;
*ptr = old + 1;
return old;
}
fetch-and-add 指令C伪码,可以看出就是将ptr对应的值加1,然后返回旧值。
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) {
int myturn = FetchAndAdd(&lock->ticket);
while (lock->turn != myturn)
; // spin
}
void unlock(lock_t *lock) {
lock->turn = lock->turn + 1;
}
上图使用fetch-and-add实现了一个ticket lock,锁由ticket和turn组成,每个执行lock的线程,都会获得一个id,当这个id和锁的turn相同时,获的锁,进入临界区。这样获取锁的顺序由turn决定,保证了获取锁的fairness。
上述自旋锁会浪费大量的cpu时间,因此临界区要尽可能小,而且有的临界可能会有context switch,这时候使用自旋锁可能会造成死锁,因此要考虑其他锁的实现方法。
3.2.5 yield
void lock() {
while (TestAndSet(&flag, 1) == 1)
yield(); // give up the CPU
}
获取不到锁就放弃cpu,相当于把进程状态从running切换到ready
使用yield存在的问题是context switch的开销和饥饿问题。考虑100线程同时获取锁的情况,当有一个获取锁以后,其他的99各线程调用yeild让出cpu,当锁被释放以后,下一个获取锁的线程依赖于系统的调度算法,因此,存在某些线程无法被调度执行的情况,造成饥饿。
3.2.6 sleeping instead of spinning
可以考虑排队的方法来避免饿死
typedef struct __lock_t {
int flag;
int guard;
queue_t *q;
}lock_t;
void lock_init(lock_t *m) {
m->flag = 0;
m->guard = 0;
queue_init(m->q);
}
void lock(lock_t *m) {
while (TestAndSet(&m->guard, 1) == 1)
; //acquire guard lock by spinning
if (m->flag == 0) {
m->flag = 1; // lock is acquired
m->guard = 0;
} else {
queue_add(m->q, gettid());
m->guard = 0;
park();
}
}
void unlock(lock_t *m) {
while (TestAndSet(&m->guard, 1) == 1)
; //acquire guard lock by spinning
if (queue_empty(m->q))
m->flag = 0; // let go of lock; no one wants it
else
unpark(queue_remove(m->q)); // hold lock (for next thread!)
m->guard = 0;
}
solaris :
park() 使调用的线程睡眠
unpark() 通过id唤醒特定的线程
guard对应的自旋锁对应小的临界区的保护,flag作为锁的标记
lock:
spin_lock(&m->guard); // (1)
if (m->flag == 0) { // (2)
m->flag = 1; // (3)
spin_unlock(&m->guard); // (4)
} else {
queue_add(m->q, gettid()); // (5)
spin_unlock(&m->guard) // (6)
park(); // (7)}
unlock:
spin_lock(&m->gurad); //(7)
if (queue_empty(m->q)) //(8)
m->flag = 0; //(9)else
unpark(queue_remove(m->q)); // hold lock (for next thread!) (10)spin_unlock(&m->gurad);
TIPS:
1. 为何 (6), (7) 不能互换,考虑一下先park后spin_unlock, 这样会导致死锁,因为guard锁没有释放,后续任何调用lock和unlock的线程都会spin,造成死锁。
某进程运行在(6), (7) 之间,即park()之前,如果被持有锁的进程打断,并释放掉锁,那么前一个进程可能会永远睡眠(?)。
解决的方法是将park()替换为set_park(),set_park()会声明自己即将进入睡眠状态,如果它恰好被中断且另一个进程要在其park()之前执行unpark(),前一个进程的park()操作会立即返回而不是进入睡眠。
A different solution could pass the guard into the kernel. In that case,
the kernel could take precautions to atomically release the lock and dequeue
the running thread.
2. 为何(10)处只有unpark,而没有解锁(m->flag = 0),这样做是必要的,当进程从等待队列移除时,被唤醒的进程从park()后开始执行,这时候需要持有锁,此过程相当于锁从上一个解锁者直接传递给下一个锁的持有者。
TODO: linux futex
TODO: Two-Phase Locks 先尝试一定次数的自旋,如果没有获取到锁,睡眠。
3.3 条件变量(condition variable)
在许多情形下,一个线程要等到某个条件成立才可以继续向下执行,如主线程要检测子线程结束后才继续执行(join动作),这时候不能用一个简单的变量去标识条件,这样做是低效的并且在某些场景下是错误的。这种场景下要使用条件变量。
一个条件变量是一个显式的队列,当线程执行种的某些状态(条件)不满足预期时,线程就把自身置于队列中(wating on the condition )。当其他的线程改变了这些条件,可以唤醒这些等待的线程(signaling on the condition),这样可以允许他们继续执行。
条件变量有两个基本操作wait() 和signal():线程需要睡眠时调用wait,线程更改了某些条件,想要唤醒在该条件上等待的线程时调用signal,在POSIX中:
pthread_cond_wait(pthread_cond_t *c, pthread_mutex_t *m);
pthread_cond_signal(pthread_cond_t *c);
wait通常有一个mutex参数,wait原子的执行解锁和睡眠操作(?),当线程在条件满足下被重新唤醒时,返回之前仍要先获得锁。这种处理上的复杂性源于避免线程睡眠时可能存在的竟态。
一般的,条件变量的使用方式如下:
void thr_exit() {
Pthread_mutex_lock(&m);
done = 1;
Pthread_cond_signal(&c);
Pthread_mutex_unlock(&m);
}
void thr_join() {
Pthread_mutex_lock(&m);
while (done == 0)
Pthread_cond_wait(&c, &m);
Pthread_mutex_unlock(&m);
}
下面展示几种错误的方式增加理解
void thr_exit() {
Pthread_mutex_lock(&m);
Pthread_cond_signal(&c);
Pthread_mutex_unlock(&m);
}
void thr_join() {
Pthread_mutex_lock(&m);
Pthread_cond_wait(&c, &m);
Pthread_mutex_unlock(&m);
}
如果不使用变量done可不可以呢,不可以。因为如果thr_exit先执行,thr_join后执行会无条件睡眠,无法被唤醒。这个例子可以看出状态变量done是十分有用的。
void thr_exit() {
done = 1;
Pthread_cond_signal(&c);
}
void thr_join() {
if (done == 0)
Pthread_cond_wait(&c);
}
如果不使用mutex可不可呢,也不可以,因为如果thr_join先执行,满足条件,当要执行睡眠操作的时候被打断,切换到thr_exit执行,这样当thr_join切换回来睡眠后就无法被唤醒了。这里其实需要mutex保护变量done,避免竟态。
TIPS:
hold the lock when calling signal or wait
保证使用条件变量一定要持有锁,因为变量是共享资源。
生产者消费者问题 (The Producer/Consumer (Bounded Buffer) Problem)
由Dijkstra首次提出,通过这个问题,发明了通用的信号量(semaphore)——可以被用作锁或者条件变量。下面通过对一个简单的生产者消费者程序的完善,说明条件变量的使用方法。
int buffer;
int count = 0; // initially, empty
void put(int value) {
assert(count == 0);
count = 1;
buffer = value;
}
int get() {
assert(count == 1);
count = 0;
return buffer;
}
void *producer(void *arg) {
int i;
int loops = (int) arg;
for (i = 0; i < loops; i++) {
put(i);
}
}
void *consumer(void *arg) {
int i;
while (1) {
int tmp = get();
printf("%d\n", tmp);
}
}
上面是该模型的初始代码,很简单,就是producer和consumer批量向一个只能保存一个int的buffer中存/取数据。我们知道,这个缓冲区是共享资源,对其更新的代码是临界区。我们使用条件变量实现保护:
int loops; // must initialize somewhere...
cond_t cond;
mutex_t mutex;
void *producer(void *arg) {
int i;
for (i = 0; i < loops; i++) {
Pthread_mutex_lock(&mutex); // p1
if (count == 1) // p2
Pthread_cond_wait(&cond, &mutex); // p3
put(i); // p4
Pthread_cond_signal(&cond); // p5
Pthread_mutex_unlock(&mutex); // p6
}
}
void *consumer(void *arg) {
int i;
for (i = 0; i < loops; i++) {
Pthread_mutex_lock(&mutex); // c1
if (count == 0) // c2
Pthread_cond_wait(&cond, &mutex); // c3
int tmp = get(); // c4
Pthread_cond_signal(&cond); // c5
Pthread_mutex_unlock(&mutex); // c6
printf("%d\n", tmp);
}
}
上面的程序有两个问题:
第一,c2,c3处,执行wait 时使用if对状态变量进行判断,这样是有问题的,假设consumer1先运行,wait处睡眠等待,之后producer put,唤醒consumer1,再consumer1重新获取锁之前,consumer2先获得锁并进行了get操作,切换回consumer1依然会再进行一次get操作,这是不正确的。这说明,当wait被唤醒是无法保证原条件是满足的,因为这里有一个锁的释放与重新获取的过程,有一个切换窗口,修改的方法很简单,将if条件判断修改为whille条件判断。
第二,假设有两个consumer,并在一开始就运行,都会进入睡眠状态,这时候producer 进行put操作,调用signal唤醒一个consumer,并再次尝试写入时进入睡眠状态,这时consumer被唤醒,执行get后调用signal,如果此时另一个consumer被唤醒,但此时没有数据重新进入睡眠。这时候三个线程都处于睡眠状态!问题出现的原因在于条件变量使用混乱,consumer不能唤醒其他的consumer,只能唤醒producer。通常,wait/signal处理的条件是成对出现的,在本例中
producer wait on condition full, signal (wake up) empty condition.
consumer wait on condition empty,signal(wake up) full condition .
这里还是总结一下条件变量的使用原则:
- 使用wait和signal时要持有锁。
- wait被唤醒时,不能对此时的状态变量有任何假设,所以需要用while判断。同时能防止spurious wakeups(惊群)问题
- wait/signal的条件要一致——wait on condition not satisfied,signal conditon satisfied。
另一种场景是
int bytesLeft = MAX_HEAP_SIZE;
cond_t c;
mutex_t m;
void * allocate(int size) {
Pthread_mutex_lock(&m);
while (bytesLeft < size)
Pthread_cond_wait(&c, &m);
void *ptr = ...; // get mem from heap
bytesLeft -= size;
Pthread_mutex_unlock(&m);
return ptr;
}
void free(void *ptr, int size) {
Pthread_mutex_lock(&m);
bytesLeft += size;
Pthread_cond_signal(&c); // whom to signal??
Pthread_mutex_unlock(&m);
}
这是一个内存分配系统allocate不满足条件就wait,free时唤醒,这里有个问题时无法知道唤醒哪各线程,加入两个线程分别申请100和10各字节,此时没有额外内存,睡眠。这时候有一个free释放了50个字节,那么此时去唤醒哪个线程呢,唤醒申请100字节的显然唤醒了也没用,这种情况下使用signal函数时不行的,需要使用broadcast,唤醒所有的等待线程,让他们去竞争。
3.4 semphore
目前我们可以使用锁和条件变量去解决两类的并发问题,semphore可以同时作为锁和条件变量使用。
看一下信号量的定义:
int sem_wait(sem_t *s) {
decrement the value of semaphore s by one //将s->value减一
wait if value of semaphore s is negative //如果此时value为负值,wait
}
int sem_post(sem_t *s) {
increment the value of semaphore s by one //将s->value加一
if there are one or more threads waiting, //如果此时有一个或者多个线程等待
wake one , //唤醒一个
}
信号量实现锁(Binary Semaphores)
sem_t m;
sem_init(&m, 0, X); // initialize semaphore to X; what should X be?
sem_wait(&m);
// critical section here
sem_post(&m);
根据定义,将X值初始化为1,可以实现锁。
信号量实现时间排序(event order,类似条件变量)
根据定义,将s->value值初始化为0,可以实现条件变量。
四、参考
【1】 OSTEP
参考: