并发与同步:
临界区
new_pid = next_pid++
entry sectioncritical sectionexit section
- 禁用硬件中断:
我们知道,系统调用以及执行流程的切换都是依靠软中断。禁用中断之后,进程(线程)就不会被切换出去,从而保证代码段能执行结束。但坏处也很明显,由于中断被禁用,如果临界区代码一直执行,其他进程就没机会执行了。而且,只能禁止单个CPU的中断。
- 基于软件同步:
即基于代码实现同步互斥,比较有名的是peterson算法,用来解决两个进程对临界区的互斥访问问题。
- 基于原子操作原语的方法:
上述两种方式都比较复杂,我们需要更加高级的武器。支持并发的语言都提供了锁(lock)这个概念,在现实生活中也很好理解,如果只能一个人在屋子里,那么进去之后就锁上,出来的时候再打开锁;没有锁的人只能在外面等着。在编程语言中,大概是这样样子的:
acquire(lock)critical sectionrelease(lock)
Spinlock实现
1 #define LOCKED 12 int TestAndSet(int* lockPtr) {3 int oldValue;4 5 oldValue = *lockPtr;6 *lockPtr = LOCKED;7 8 return oldValue;9 }
void acquire(int *lock){ while(TestAndSet(*lock));}void release(int *lock){ *lock = 0;}
信号量与管程
信号量
Procedure P(Var S:Semaphore);
Begin S:=S-1; If S<0 then w(S) {执行P操作的线程插入等待队列} End;
Procedure V(Var S:Semaphore);
Begin S:=S+1 If S<=0 then R(s) {从阻塞队列中唤醒一个线程} End;
信号量在现实生活中很容易找到对比的例子,比如银行的窗口数量就是S,在窗口办理业务就是P操作,业务办理结束就是V操作。
根据S初始值的不同,semaphore就有不同的作用。如果S初始值为1,那么这个semaphore就是一个mutex semaphore,效果就是临界区的互斥访问。如果S初始值为0,那么就是用来做条件同步,效果就是必须等待某些条件发生。如果S初始值为N(N一般大于1),那么就是用来限制并发数目,也被称之为counting semaphone。
后文会利用具体的例子(生产者消费者问题)来阐述semaphore上面的三种用法。
管程
mutex global_mutex
condition global_cvbool p
另外一个线程acquire(global_mutex)
while(!p): # 如果条件不满足,则需要等待 global_cv.wait(global_mutex) # wait操作将当前线程阻塞在等到队列中,释放对管程的互斥访问// do a lot of thing
release(global_mutex)
acquire(global_mutex)
// do sth make "p" is trueglobal_cv.signal() # 唤醒在管程中等待的线程release(global_mutex)
contidion::wait(mutex& mut){
release(mut) yield current thread # 当前线程释放控制权 acquire(mut)}
可以看到,在T2线程调用x.signal之后,hansen模式会继续执行,所以当重新回到wait线程的时候,可能情况已经发生了变化,所以需要重新判断;而Hoare模式会立刻从T2线程切换到T1线程。Hansen看起来变得复杂,引入了不确定性,但是相比hoare模式,少了一次线程的切换,在真实的操作系统中就是这么实现的,所以我们编码的时候都需要用while循环判断条件是否成立。
信号量 VS 管程
- 信号量本质是可共享的资源的数量; 而管程是一种抽象数据结构用来限制同一时刻只有一个线程进入临界区
- 信号量是可以并发的,并发量取决于S初始值;而管程内部同一时刻最多只能有一个线程执行
- 信号量与管理的资源紧耦合(即信号量S的初始值等同于资源的数目,且通过P V操作修改剩余可用的资源数量);而在管程中需自行判断是否还有可共享的资源。这一点可以参见下面生产者消费者的实现代码
- 信号量的P操作可能阻塞,也可能不阻塞;而管程的wait操作一定会阻塞
- 信号量的V操作如果唤醒了其他线程,当前线程与被唤醒线程并发执行;对于管程的signal操作,要么当前线程继续执行(Hansen),要么被唤醒线程继续执行(Hoare),二者不能并发。
生产者消费者问题
- 对消息队列的操作必须是互斥的,需要加锁(如果是lockfree的数据结构,就不用加锁,如boost::lockfree::queue)
- 消息队列中没有数据时,消费者需要等待生产者产生数据,这就是条件同步
- 消息队列满时,生产者需要等到消费者消费数据,这也是条件同步
信号量实现
从上面分析生产者消费者需要解决的同步问题(互斥与条件同步),都能用信号量来解决。对于互斥,信号量S为1就可以;对于消费者等待的情况,信号量初始值为0即可;对于生产者等待的情况,信号量初始值为消息队列长度即可。linux下提供了信号量的实现,头文件在/usr/include/semaphore.h,代码实现如下(producer_consumer_semaphore.cpp):
1 #include2 #include 3 #include 4 #include 5 #include 6 #include 7 8 #define BUFF_SIZE 3 9 #define PRODUCE_THREAD_SIZE 510 int g_buff[BUFF_SIZE];11 int g_write_index = 0;12 int g_read_index = 0;13 14 sem_t lock;15 sem_t consume_sem, produce_sem;16 17 18 void* produce(void *ptr){19 int idx = *(int*)ptr;20 printf("in produce %d %d %d\n",idx, g_write_index, g_read_index);21 while(1){22 sem_wait(&produce_sem); # 限制了生产者并发的数目23 24 sem_wait(&lock); # 对临界区的访问要加锁25 g_buff[g_write_index] = idx;26 g_write_index = (g_write_index + 1) % BUFF_SIZE;27 sem_post(&lock);28 29 sem_post(&consume_sem);30 }31 return NULL;32 }33 34 void* consume(void *ptr){35 while(1){36 sem_wait(&consume_sem);37 sem_wait(&lock);38 int data = g_buff[g_read_index];39 g_buff[g_read_index] = -1;40 g_read_index = (g_read_index + 1) % BUFF_SIZE;41 printf("consume %d %d %d\n", data, g_read_index, g_write_index);42 sem_post(&lock);43 sem_post(&produce_sem);44 }45 return NULL;46 }47 48 int main(int argc, char * argv[]){49 pthread_t con;50 pthread_t pros[PRODUCE_THREAD_SIZE];51 sem_init(&lock, 0, 1);52 sem_init(&consume_sem,0, 0);53 sem_init(&produce_sem,0, BUFF_SIZE);54 55 pthread_create(&con, NULL, consume, NULL);56 int thread_args[PRODUCE_THREAD_SIZE];57 for(int i = 0; i < PRODUCE_THREAD_SIZE; i++){58 thread_args[i] = i + 1;59 pthread_create(&pros[i], NULL, produce, (thread_args + i));60 }61 62 pthread_join(con,0);63 for(int i = 0; i < PRODUCE_THREAD_SIZE; i++)64 pthread_join(pros[i],0);65 66 sem_destroy(&lock);67 sem_destroy(&consume_sem);68 sem_destroy(&produce_sem);69 70 return 0;71 }
代码中,消息队列的大小为3,produce_sem的初始值一定与消息队列的大小相同。总共有5个生产者线程,多余可并发的数量(produce_sem),因此很大概率会有生产者线程阻塞在produce_sem对应的等待队列。 另外两点需要注意:第一点在produce和consume线程中都是需要加锁(互斥锁lock),因为信号量是可以并发的,需要对临界资源(g_buff,g_read_index,g_write_index)互斥访问。另外,在produce线程,需要先判断能否并发,然后再对临界区加锁,二者的顺序不能反过来,否则会产生死锁。
上面的代码用:g++ -lpthread producer_consumer_semaphore.cpp producer_consumer_semaphore, 然后运行 ./producer_consumer_semaphore 即可
管程实现
1 #include2 #include 3 #include 4 #include 5 #include 6 7 8 #define BUFF_SIZE 3 9 #define PRODUCE_THREAD_SIZE 510 int g_buff[BUFF_SIZE];11 int g_write_index = 0;12 int g_read_index = 0;13 14 pthread_mutex_t lock;15 pthread_cond_t consume_cond, produce_cond;16 17 18 void* produce(void *ptr){19 int idx = *(int*)ptr;20 printf("in produce %d %d %d\n",idx, g_write_index, g_read_index);21 while(1){22 pthread_mutex_lock(&lock);23 while((g_write_index + 1) % BUFF_SIZE == g_read_index)24 pthread_cond_wait(&produce_cond, &lock);25 26 g_buff[g_write_index] = idx;27 g_write_index = (g_write_index + 1) % BUFF_SIZE;28 29 pthread_cond_signal(&consume_cond);30 pthread_mutex_unlock(&lock);31 32 }33 return NULL;34 }35 36 void* consume(void *ptr){37 while(1){38 pthread_mutex_lock(&lock);39 while(g_read_index == g_write_index)40 pthread_cond_wait(&consume_cond, &lock);41 42 int data = g_buff[g_read_index];43 g_buff[g_read_index] = -1;44 g_read_index = (g_read_index + 1) % BUFF_SIZE;45 printf("consume %d\n", data);46 47 pthread_cond_signal(&produce_cond);48 pthread_mutex_unlock(&lock);49 }50 return NULL;51 }52 53 int main(int argc, char * argv[]){54 pthread_t con;55 pthread_t pros[PRODUCE_THREAD_SIZE];56 57 srand((unsigned)time(NULL));58 pthread_mutex_init(&lock, 0);59 pthread_cond_init(&consume_cond,0);60 pthread_cond_init(&produce_cond,0);61 62 pthread_create(&con, NULL, consume, NULL);63 int thread_args[PRODUCE_THREAD_SIZE];64 for(int i = 0; i < PRODUCE_THREAD_SIZE; i++){65 thread_args[i] = i + 1;66 pthread_create(&pros[i], NULL, produce, (thread_args + i));67 }68 69 pthread_join(con,0);70 for(int i = 0; i < PRODUCE_THREAD_SIZE; i++)71 pthread_join(pros[i],0);72 73 pthread_mutex_destroy(&lock);74 pthread_cond_destroy(&consume_cond);75 pthread_cond_destroy(&produce_cond);76 77 return 0;78 }
上面的代码用:g++ -lpthread producer_consumer_monitor.cpp producer_consumer_monitor, 然后运行 ./producer_consumer_monitor 即可
总结:
并发可能带来互斥、饥饿、死锁等问题,操作系统为了解决这些问题提供了很多的支持,从最底层的禁止硬件终端和原子操作指令(test-and-set),到更高级的同步原语:锁(互斥锁、自旋锁)、信号量、管程。管程是一种抽象数据结构,编程中使用互斥锁配合信号量使用。管程有两种不同的模式:hansen vs hoare,区别在于signal操作之后是否立即切换到被唤醒线程,实际的操作系统中,多使用hansen模式。