操作系统导论——并发
并发
并发介绍
为单个进程提供的抽象:线程。多线程程序有多个执行点(多个程序计数器,每个都用于取指令和执行)。每个线程类似于独立进程,只有一点区别:线程共享地址空间,从而能够访问相同的数据。
线程有一个程序计数器,记录程序从哪里获取指令。每个线程有自己的一组用于计算的寄存器。两个线程发生上下文切换时,类似于进程间的上下文切换,进程保存到PCB,线程保存TCB。但是线程之间的上下文切换:地址空间保持不变。
进程和线程另一个主要区别在于栈。每个线程都有一个栈。
线程创建
eg:运行一个程序,创建了两个线程,每个线程都做了一些独立的工作,在这个例子中,打印“A”和“B”。
主程序创建了两个线程,分别执行mythread()
函数,但是传入不同的参数(字符串类型的A或B)。一旦线程创建,可能会立即运行(取决于调度程序),或者处于就绪状态,等待执行,创建了两个线程后,主程序调用pthread_join()
函数,等待特定线程完成。
#include <stdio.h>
#include <assert.h>
#include <pthread.h>
void *mythread(void *arg){
printf("%s\n", (char *) arg);
return NULL;
}
int main(int argc, char *argv[]){
pthread_t p1, p2;
int rc;
printf("main: begin\n");
rc = pthread_create(&p1, NULL, mythread, "A"); assert(rc == 0);
rc = pthread_create(&p2, NULL, mythread, "B"); assert(rc == 0);
// join waits for the threads to finish
rc = pthead_join(p1, NULL); assert(rc == 0);
rc = pthead_join(p2, NULL); assert(rc == 0);
printf("main: end\n");
return 0;
}
共享数据
两个线程进行相加操作
mov 0x8094a1c, %eax // 从0x8094a1c取出变量放入eax
add $0x1, %eax // 给eax寄存器的值加1
mov %eax,0x8094a1c // eax的值被存回0x8094a1c
假设线程1进入代码区域,取出值 50,然后向寄存器加1,因此eax=51。此时,时钟中断发生,OS将正在运行的线程(包括程序计数器、寄存器,包括eax等)的状态保存到线程TCB。线程2执行,同样也是加1,此时变量的值仍为50,执行完后保存回寄存器,变量为51。然后线程1恢复,准备执行最后一条mov指令。现在eax=51.因此,变量保存到内存中的值为51。
这种情况称为竞态条件:结果取决于代码的时间执行。由于执行的这段代码的多个线程可能导致竞争状态,因此将此段代码称为临界区。临界区是访问共享变量的代码片段,一定不能由多个线程同时执行。真正想要的代码就是所谓的互斥。
线程API
POSIX线程库通过锁来提供互斥进入临界区的那些函数。最基本的一对函数:
int pthread_mutex_lock(pthread_mutex_t *mutex)
int pthread_mutex_unlock(pthread_mutex_t *mutex)
如果你有一段代码是一个临界区,可以通过以下完成:
pthread_mutex_t lock;
pthread_mutex_lock(&lock);
x = x + 1;
pthread_mutex_unlock(&lock);
如果在调用pthread_mutex_lock()
时没有其他线程持有锁,线程将获取该锁并进入临界区。如果另一个线程确实持有该锁,那么尝试获取该锁的线程将不会从该调用返回,直到获得该锁(意味着持有该锁的线程通过解锁调用释放该锁)。
另外两个函数:
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_timedlock(pthread_mutex_t *mutex
struct timespec *abs_timeout);
这两个调用用于获取锁。如果锁已被占用,则trylock版本将失败。获取锁的timedlock会在超时或获取锁后返回,以先发生的为准。因此,具有0超时的timedlock则退化为trylock。
条件变量
当线程之间必须发生某种信号时,如果一个线程在等待另一个线程继续执行某操作,条件变量就很有用。以这种方式进行交互的程序使用两个主要函数。
int pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex);
int pthread_cond_signal(pthread_cond_t *cond);
第一个函数cond_wait使调用函数进入休眠状态,因此等待其他线程发出信号,通常当程序中的某些内容发生变化时,现在正在休眠的线程可能会关心它。
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
Pthread_mutex_lock(&lock);
while (ready == 0)
Pthread_cond_wait(&cond, &lock);
Pthread_mutex_unlock(&lock);
在初始化相关的锁和条件之后,一个线程检查变量ready是否已经被设置为0以外的值。如果没有,那么线程指示简单地调用等待函数以便休眠,直到其他线程唤醒它。
唤醒线程的代码运行在另外某个线程中,像下面这样:
Pthread_mutex_lock(&lock);
ready = 1;
Pthread_cond_signal(&cond);
Pthread_mutex_unlock(&lock);
在发出信号时(以及修改全局变量ready时),我们是始终确保持有锁。这确保我们不会在代码中意外引入竞态条件。等待调用除了使线程进入睡眠状态外,还会让调用者睡眠时释放锁。
锁
希望原子式执行一系列指令。
锁的基本思想
lock_t mutex;
...
lock(&mutex);
balance = balance +1;
unlock(&mutex);
锁就是一个变量,因此需要声明一个某种类型的变量才能使用。这个锁变量保存了锁在某一时刻的状态。也可以保存其他信息,比如持有锁的线程,或请求获取锁的线程队列,但这些信息都会隐藏起来,锁的使用者不会发现。
调用lock()
尝试获取锁,如果没有其他线程持有锁(即它是可用的),该线程会获得锁,进入临界区。这个线程有时被称为锁的持有者(owner)。如果另外一个线程对相同的锁变量(本例中的mutex)调用lock(),因为锁被另一线程持有,该调用不会返回。这样,当持有锁的线程在临界区时,其他线程就无法进入临界区。
锁的持有者一旦调用unlock()
,如果没有其他等待线程(即没有其他线程调用过lock()并卡在那里),锁的状态就变成可用了。如果有等待线程(卡在lock()里),其中一个会(最终)注意到锁状态的变化,获取该锁,进入临界区。
控制中断
最早提供的互斥解决方案时临界区关闭中断。通过在进入临界区之前关闭中断(使用特殊的硬件指令),可用保证临界区的代码不会被中断,从而原子地执行。结束之后,重新打开中断,程序正常执行。
缺点:
1.允许所有线程可以执行特权操作,会造成信任滥用
2.不支持多处理器
3.中断丢失可能造成系统问题。
自旋锁
测试并设置指令:
1 int TestAndSet(int *old_ptr, int new) {
2 int old = *old_ptr; // fetch old value at old_ptr
3 *old_ptr = new; // store 'new' into old_ptr
4 return old; // return the old value
5 }
返回old_ptr指向的旧值,同时更新为new的新值,这些代码是原子地执行。实现自旋锁
1 typedef struct lock_t {
2 int flag;
3 } lock_t;
4
5 void init(lock_t *lock) {
6 // 0 indicates that lock is available, 1 that it is held
7 lock->flag = 0;
8 }
9
10 void lock(lock_t *lock) {
11 while (TestAndSet(&lock->flag, 1) == 1)
12 ; // spin-wait (do nothing)
13 }
14
15 void unlock(lock_t *lock) {
16 lock->flag = 0;
17 c}
首先假设一个线程在运行,调用lock()
,没有其他线程持有锁,所以flag是0。当调用TestAndSet(flag, 1)
方法,返回0,线程会跳出while循环,获取锁。同时也会原子的设置flag为1,标志锁已经被持有。当线程离开临界区,调用unlock()
将flag设置为0,标志没有线程持有锁。
第二种场景:
当有线程持有锁(flag为1),其他线程调用lock(),然后调用testaAndSet,一直返回1,线程会一直自旋。当flag改为0,其他线程调用testAndSet返回0并原子把flag设为1,从而获得锁,进入临界区。
评价自旋锁
自旋锁不提供任何公平性。
在单CPU情况下,性能开销大,自旋一个时间片,浪费CPU周期。
比较并交换
x86是这种硬件原语:
1 int CompareAndSwap(int *ptr, int expected, int new) {
2 int actual = *ptr;
3 if (actual == expected)
4 *ptr = new;
5 return actual;
6 }
基本思路是检测ptr指向的值是否和expected相等;如果是,更新ptr所指向的值为新值。否则,什么也不做。不论哪种情况,都返回该内存地址的实际值,让调用者能够直到执行是否成功。可以用来替换上面lock()
1 void lock(lock_t *lock) {
2 while (CompareAndSwap(&lock->flag, 0, 1) == 1)
3 ; // spin
4 }
使用队列:休眠替代自旋
提供两个调用:park()能够让调用线程休眠,unpark(threadID)则会唤醒 threadID标识的线程。
1 typedef struct lock_t {
2 int flag;
3 int guard;
4 queue_t *q;
5 } lock_t;
6
7 void lock_init(lock_t *m) {
8 m->flag = 0;
9 m->guard = 0;
10 queue_init(m->q);
11 }
12
13 void lock(lock_t *m) {
14 while (TestAndSet(&m->guard, 1) == 1)
15 ; //acquire guard lock by spinning
16 if (m->flag == 0) {
17 m->flag = 1; // lock is acquired
18 m->guard = 0;
19 } else {
20 queue_add(m->q, gettid());
21 m->guard = 0;
22 park();
23 }
24 }
25
26 void unlock(lock_t *m) {
27 while (TestAndSet(&m->guard, 1) == 1)
28 ; //acquire guard lock by spinning
29 if (queue_empty(m->q))
30 m->flag = 0; // let go of lock; no one wants it
31 else
32 unpark(queue_remove(m->q)); // hold lock (for next thread!)
33 m->guard = 0;
34 }
条件变量
等待某些条件是很正常的,简单的方案是自旋直到条件满足,这是极其低效的。那么,线程应该如何等待一个条件?
定义和程序
线程可以使用条件变量,来等待一个条件变成真。条件变量是一个显式队列,当某些执行状态(即条件 condition)不满足时,线程可以把自己加入队列,等待该条件。
#include <stdio.h>
#include <pthread.h>
int done = 0; //状态变量
pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t c = PTHREAD_COND_INITIALIZER; //条件变量初始化
void thr_exit(){
pthread_mutex_lock(&m); //上锁
done = 1;
pthread_cond_signal(&c);
pthread_mutex_unlock(&m);
}
void *child(void *arg){
printf("child\n");
thr_exit();
return NULL;
}
void thr_join(){
pthread_mutex_lock(&m);
while (done == 0)
pthread_cond_wait(&c,&m); //放锁 休眠
pthread_mutex_unlock(&m);
}
int main(int argc, char *argv[]){
printf("parent:begin\n");
pthread_t p;
pthread_create(&p, NULL, child, NULL);
thr_join();
printf("parent:end\n");
return 0;
}
wait()
的职责是释放锁,并让调用线程休眠(原子地)。
生产者消费者问题
也叫有界缓冲区问题。
在使用管道连接不同程序的输出和输入时,也会使用有界缓冲区,例如:
grep foo file.txt | wc -l
该例子并发地执行了两个进程,grep进程从file.txt中查找包括 foo 的行,写到标准输出。管道的另一端是wc进程的标准输入,wc统计完行数后打印出结果。因此,grep是生产者,wc是消费者。
因为有界缓冲区是共享资源,所以必须通过同步机制来访问它,以免产生竞态条件。代码实现:
int buffer;
int count = 0; // 0代表生产 , 1代表消费
void put(int value){
assert(count == 0);
count = 1;
buffer = value;
}
int get(){
assert(count == 1);
count = 0;
return buffer;
}
assert 将通过检查表达式 expression 的值来决定是否需要终止执行程序。
也就是说,如果表达式 expression 的值为假(即为 0),那么它将首先向标准错误流 stderr 打印一条出错信息,然后再通过调用 abort 函数终止程序运行;否则,assert 无任何作用。
以上如果连续生产或者连续消费,则会触发断言。
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);
}
}
使用条件变量和锁的生产消费问题
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);
}
}
如果有两个消费者,1个生产者,c1消费时无数据,去睡眠,开始生产,执行p1 p2 p4 p5(通知c1消费) p6 p1 p2 p3(去睡眠),此时,c2消费者抢先执行 c1 c2 c4 c5(生产者就绪) c6 c1 c2 c3(睡眠),c1消费者执行时会发现没有数据。
仍有问题的方案:用while代替if
cond_t cond;
mutex_t mutex;
void *producer(void *arg){
int i;
for (i = 0; i < loops; i++){
pthread_mutex_lock(&mutex); //p1
while (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
while (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);
}
}
这同样也会导致问题,c1 取完数据,睡眠,c2执行,没有数据,睡眠,此时生产者也在睡眠!!
所以信号需要有指向性,消费者不应该唤醒消费者,而应该只唤醒生产者
单值缓冲区的生产消费问题
cond_t empty, fill;
mutex_t mutex;
void *producer(void *arg){
int i;
for (i = 0; i < loops; i++){
pthread_mutex_lock(&mutex); //p1
while (count == 1) //p2
pthread_cond_wait(&empty, &mutex); //p3
put(i); //p4
pthread_cond_signal(&fill); //p5
pthread_mutex_unlock(&mutex); //p6
}
}
void *consumer(void *arg){
int i;
for (i = 0; i < loops; i++){
pthread_mutex_lock(&mutex); //c1
while (count == 0) //c2
pthread_cond_wait(&fill, &mutex); //c3
int tmp = get(); //c4
pthread_cond_signal(&empty); //c5
pthread_mutex_unlock(&mutex); //c6
printf("%d\n", tmp);
}
}
生产者等待条件变量empty,发信号给变量fill。相应地,消费者线程等待fill,发信号给empty。解决了消费者再也不会唤醒生产者,生产者也不会唤醒消费者。
最终的生产消费方案
put()
和get()
方法
int buffer[MAX];
int fill = 0;
int use = 0;
int count = 0;
void put(int value){
buffer[fill] = value;
fill = (fill + 1) % MAX;
count++;
}
int get(){
int temp = buffer[use];
use = (use + 1) % MAX;
count--;
return tmp;
}
cond_t empty, fill;
mutex_t mutex;
void *producer(void *arg){
int i;
for (i = 0; i < loops; i++){
pthread_mutex_lock(&mutex); //p1
while (count == 1) //p2
pthread_cond_wait(&empty, &mutex); //p3
put(i); //p4
pthread_cond_signal(&fill); //p5
pthread_mutex_unlock(&mutex); //p6
}
}
void *consumer(void *arg){
int i;
for (i = 0; i < loops; i++){
pthread_mutex_lock(&mutex); //c1
while (count == 0) //c2
pthread_cond_wait(&fill, &mutex); //c3
int tmp = get(); //c4
pthread_cond_signal(&empty); //c5
pthread_mutex_unlock(&mutex); //c6
printf("%d\n", tmp);
}
}
信号量 Semaphores
可以使用信号量作为锁和条件变量。如何使用?
信号量的定义
sem_wait
对应 p操作 sem_post
对应v操作,信号量为负数时睡眠
#include <semaphore.h>
sem_t s;
sem_init(&s, 0, 1);
第三个参数将信号量初始化为1,第二个参数表示信号量在同一进程的多个线程共享的。
二值信号量(锁)
使用信号量来实现锁
sem_t m;
sem_init(&m, 0, 1);
sem_wait(&m);
// 临界区
sem_post(&m);
信号量用作条件变量
sem_t s;
void *child(void *arg){
printf("child begin\n");
sem_post(&s);
return NULL;
}
int main(int argc, char *argv[]){
sem_init(&s, 0, 0);
printf("parent begin\n");
pthread_t c;
pthread_create(c, NULL, child, NULL);
sem_wait(&s);
printf("parent end\n");
return 0;
}
生产消费问题
1 sem_t empty;
2 sem_t full;
3
4 void *producer(void *arg) {
5 int i;
6 for (i = 0; i < loops; i++) {
7 sem_wait(&empty); // line P1
8 put(i); // line P2
9 sem_post(&full); // line P3
10 }
11 }
12
13 void *consumer(void *arg) {
14 int i, tmp = 0;
15 while (tmp != -1) {
16 sem_wait(&full); // line C1
17 tmp = get(); // line C2
18 sem_post(&empty); // line C3
19 printf("%d\n", tmp);
20 }
21 }
这个代码会产生两个生产者同时调用put 会被覆盖的情况
下面增加互斥
1 sem_t empty;
2 sem_t full;
3 sem_t mutex;
4
5 void *producer(void *arg) {
6 int i;
7 for (i = 0; i < loops; i++) {
8 sem_wait(&mutex); // line p0 (NEW LINE)
9 sem_wait(&empty); // line p1
10 put(i); // line p2
11 sem_post(&full); // line p3
12 sem_post(&mutex); // line p4 (NEW LINE)
13 }
14 }
15
16 void *consumer(void *arg) {
17 int i;
18 for (i = 0; i < loops; i++) {
19 sem_wait(&mutex); // line c0 (NEW LINE)
20 sem_wait(&full); // line c1
21 int tmp = get(); // line c2
22 sem_post(&empty); // line c3
23 sem_post(&mutex); // line c4 (NEW LINE)
24 printf("%d\n", tmp);
25 }
26 }
27
28 int main(int argc, char *argv[]) {
29 // ...
30 sem_init(&empty, 0, MAX); // MAX buffers are empty to begin with...
31 sem_init(&full, 0, 0); // ... and 0 are full
32 sem_init(&mutex, 0, 1); // mutex=1 because it is a lock (NEW LINE)
33 // ...
34 }
这个代码会产生死锁问题
一个生产和一个消费,假设消费者先运行,池中没有数据,去睡眠,但此时因为c0行消费者持有锁,生产者卡在p0行,因为无法获取锁进入临界区,所以产生死锁
正确版本
1 sem_t empty;
2 sem_t full;
3 sem_t mutex;
4
5 void *producer(void *arg) {
6 int i;
7 for (i = 0; i < loops; i++) {
8 sem_wait(&empty); // line p0 // 改变了互斥量的位置
9 sem_wait(&mutex); // line p1 (NEW LINE)// 改变了互斥量的位置
10 put(i); // line p2
11 sem_post(&mutex); // line p3 (NEW LINE)
12 sem_post(&full); // line p4
13 }
14 }
15
16 void *consumer(void *arg) {
17 int i;
18 for (i = 0; i < loops; i++) {
19 sem_wait(&full); // line c0 // 改变了互斥量的位置
20 sem_wait(&mutex); // line c1 (NEW LINE) // 改变了互斥量的位置
21 int tmp = get(); // line c2
22 sem_post(&mutex); // line c3 (NEW LINE)
23 sem_post(&empty); // line c4
24 printf("%d\n", tmp);
25 }
26 }
27
28 int main(int argc, char *argv[]) {
29 // ...
30 sem_init(&empty, 0, MAX); // MAX buffers are empty to begin with...
31 sem_init(&full, 0, 0); // ... and 0 are full
32 sem_init(&mutex, 0, 1); // mutex=1 because it is a lock (NEW LINE)
33 // ...
34 }
常见并发问题
非死锁缺陷
违反原子性缺性
1 Thread 1::
2 if (thd->proc_info) {
3 ...
4 fputs(thd->proc_info, ...); //打印出值
5 ...
6 }
7
8 Thread 2::
9 thd->proc_info = NULL;
上述例子中,假如线程1先检查proc_info
非空,在打印该值之前,被线程2中断,线程2将proc_info
的值设为NULL,此时如果线程1再打印该值,则会产生空指针异常。
违反顺序缺陷
1 Thread 1::
2 void init() {
3 ...
4 mThread = PR_CreateThread(mMain, ...);
5 ...
6 }
7
8 Thread 2::
9 void mMain(...) {
10 ...
11 mState = mThread->State;
12 ...
13 }
如果线程2在线程1执行之前就执行,mThread未被初始化,则会引用空指针崩溃。
违反顺序的正式定义为:“两个内存访问的预期顺序被打破了(A应该在B执行之前执行)
死锁缺陷
代码库中,组件之间会有复杂的依赖。以及封装隐藏实现细节,导致了死锁。
产生死锁的4个条件
- 互斥:线程对于需要的资源进行互斥的访问(例如一个线程抢到锁)
- 持有并等待:线程持有了资源(已持有锁),同时又在等待其他资源(获得其他锁)
- 非抢占:线程获得的资源(锁)不能被抢占
- 循环等待:线程之间存在一个环路,环路上每个线程都额外持有一个资源,而这个资源优势下一个线程要申请的
预防死锁
循环等待
最直接的方式就是获取锁时提供一个全序,可能很难做到,例如系统有两个锁,每次都先申请L1然后申请L2。
偏序可能是一种有用的方法,安排锁的获取并避免死锁。
通过锁的地址来强制锁的顺序
比如有一个函数:do_something(mutex t *m1, mutex t *m2),如果函数总是先抢 m1,然后 m2,那么当一个线程调用 do_something(L1, L2),而另一个线程调 用 do_something(L2, L1)时,就可能会产生死锁。
可以如下解决:
if (m1 > m2) { // grab locks in high-to-low address order
pthread_mutex_lock(m1);
pthread_mutex_lock(m2);
} else {
pthread_mutex_lock(m2);
pthread_mutex_lock(m1);
} // 假设m1和m2不是同一个锁
持有并等待
可以通过给获取锁的操作上锁来实现原子性
1 lock(prevention);
2 lock(L1);
3 lock(L2);
4 ...
5 unlock(prevention);
先抢到 prevention 这个锁,保证了在抢锁的过程中,不会有不合时宜的线程切换,从而避免了死锁
问题:不适用于封装,不知道需要准确地抢哪些锁,并且提前抢到这些锁。因为要提前抢到所有锁(同时),而不是在真正需要的时候,所以可能降低了并发、
非抢占
trylock()函数会尝试获得锁,或者返回−1,表示锁已经被占有
1 top:
2 lock(L1);
3 if (trylock(L2) == -1) { // 返回-1,表示锁已被占有,进入if
4 unlock(L1); // 释放L1
5 goto top;
6 }
会引来一个新的问题:活锁(livelock)。两个线程有可能一直 重复这一序列,又同时都抢锁失败。(可以在循环结束的时候,随机等待一个时间,然后再重复整个动作)
互斥
最后的预防方法是完全避免互斥。通常来说,代码都会存在临界区,因此很难避免互斥。无等待(wait-free)数据结构的思想,通过硬件指令,可以构造出不需要锁的数据结构。
比较并交换(compare-and-swap)指令,是一种由硬件提 供的原子指令,做下面的事:
1 int CompareAndSwap(int *address, int expected, int new) {
2 if (*address == expected) {
3 *address = new;
4 return 1; // success
5 }
6 return 0; // failure
7 }
假定想原子地给某个值增加特定的数量。可以这样实现:
1 void AtomicIncrement(int *value, int amount) {
2 do {
3 int old = *value;
4 } while (CompareAndSwap(value, old, old + amount) == 0);
5 }
无须获取锁,更新值,然后释放锁这些操作,使用比较并交换指令,反复尝试将值更新到新的值。这种方式没有使用锁,因此不会有死锁(有可能产生活锁)