在有了进程和线程的模型之后,一个很大的问题就摆在眼前:进程和线程的执行顺序是不可预知的,那么,如何使得两个进程按照我们想要的顺序执行,从而得出正确的结果呢?
竞争条件:两个或者多个进程读写某些共享数据,最后的结果依赖于进程运行的精确时序。
临界区:把对共享内存进行访问的程序片段称作临界区。如果能使两个进程不可能同时处于临界区内,就能够避免竞争。
先引入一个经典的进程同步问题:生产者-消费者问题。
生产者-消费者问题:有一个缓冲区,一个(或多个)进程在生产某种产品,它生产的东西会放入缓冲区内;一个(或多个)进程在消费产品,它会从缓冲区内取走产品。当缓冲区满时,生产者应当暂时停止生产;当缓冲区为空时,消费者应当暂时停止消费。
很显然,这个问题用简单的判断缓冲区是否为0或N是无法解决的。如果在消费者判断缓冲区为0时,恰好遇到了进程切换,生产者进程开始运行,此时应当唤醒消费者,然而这个信号丢失了,因为切换到消费者才进行了睡眠。这时,生产者会不断运行,直到缓冲区满,两个进程全部睡眠,造成了死锁。代码如下:
#define N 1000 int count=0; void producer(void) { int item; while(TRUE) { item=produce_item(); if(count==N) sleep();//一段时间后,缓冲区满,生产者进程也睡眠了 insert_item(item); count=count+1; if(count==1) wakeup(consumer);//设想判断条件成立时,切换了进程,再次切回时,唤醒消费者进程,然而消费者进程此时没有睡眠,信号丢失 } } void consumer(void) { int item; while(TRUE) { if(count==0) sleep();//第一次count=1,消费者进程不会睡眠;第二次确实睡眠了 item=remove_item(); count=count-1;//此时缓冲区确实为空了 if(count==N-1) wakeup(producer); consume_item(item); } }
一、信号量
信号量是一种数据结构,可以理解为一个用来计数的整数和一个队列。整数用来记录唤醒次数,而队列被用来记录因为该信号量而阻塞的进程。
信号量只支持两种操作:P/V操作。
P操作,可以理解为测试并减一。P(signal1),如果signal1大于0,那么把它减一,进程继续执行;如果signal为0,那么执行P操作的进程将会被阻塞,从而变为阻塞态,添加到因为signal1信号而阻塞的进程队列中。
V操作,可以理解为+1并唤醒。V(signal1)后,如果signal1本来就大于0,那么执行+1;如果有进程在该信号量上被阻塞,那么从队列中根据某种策略选择一个进程唤醒。如果多个进程在该信号量上阻塞,那么V操作后,signal1仍然可能为负数。
需要注意的是,P/V操作均应当是原子操作,即作为一个整体执行而不会被打断。
有了信号量,我们再来看生产者-消费者问题:
#define N 1000 typedef int semaphore; semaphore mutex=1;//控制对临界区的访问,其实就是互斥量 semaphore empty=N;//表示空槽的数量 semaphore full=0;//填满的槽的数量 int count=0; void producer(void) { int item; while(TRUE) { item=produce_item(); down(&empty); down(&mutex);//要改变共享区(缓冲区),加锁 insert_item(item); up(&mutex);//解锁 up(&full); } } void consumer(void) { int item; while(TRUE) { down(&full); down(&mutex); item=remove_item(); up(&mutex); up(&empty); consume_item(item); } }
有了信号量,这个问题就好解决多了:用信号量full、empty来表示已用和未用的数量,这样不管是满了还是空了,都不会造成死锁的问题。mutex的操作就是我们接下来要介绍的互斥锁。
二、互斥锁
互斥量其实可以理解为一个简化的信号量,它只有两种状态:0和1。互斥锁是用来解决进程(线程)互斥问题的。所谓进程互斥,就是两个进程实际上是一种互斥的关系,两者不能同时访问共享资源。
互斥量和信号量原理比较类似,一旦一个线程获得了锁,那么其它线程就无法访问共享资源,从而被阻塞,直到该线程交还出了锁的所有权,另外一个线程才能获得锁。
互斥锁的例子就不再给出,上面程序中已经有了,下面的程序中也会出现。
三、条件变量
条件变量是另外一种同步机制,可以用于线程和管程中的进程互斥。通常与互斥量一起使用。
条件变量允许线程由于一些暂时没有达到的条件而阻塞。通常,等待另一个线程完成该线程所需要的条件。条件达到时,另外一个线程发送一个信号,唤醒该线程。
条件变量对应的一组操作是pthread_cond_wait和pthread_cond_signal。
条件变量与互斥量一起使用,一般情况是:一个线程锁住一个互斥量,然后当它不能获得它期待的结果时,等待一个条件变量;最后另外一个线程向它发送信号,使得它可以继续执行。
需要注意的是,pthread_cond_wait会暂时解开持有的互斥锁。
四、读写锁
读写锁相对上面的问题会复杂一些,它被用来解决一个经典的问题:读者-写者问题。
读写锁与互斥量类似,不过读写锁允许更高的并行性。互斥量要么是锁住状态要么是不加锁状态,而且一次只有一个线程可以对其加锁。
下面的代码考虑的是读者优先的读者-写者问题,对于共享区域的读写规则如下:
1.只要有一个读者在读,后来的读者可以进入共享区直接读。
2.只要有一个读者在读,写者就必须阻塞,直到最后一个读者离开。
3.不考虑抢占式,写者在写时,即使有读者到达,也会在就绪态等待。
typedef int semaphore; semaphore mutex=1; //互斥锁,控制对rc的访问 semaphore db=1; //控制对数据库的访问 int rc=0; //当前读者计数 void reader(void) { while(TRUE) { down(&mutex);//加锁 rc=rc+1; if(rc==1) down(&db);//第一个读者,加锁 up(&mutex); read_data_base(); down(&mutex); rc=rc-1; if(rc==0) up(&db);//最后一个读者离开,解锁 up(&mutex); use_data_read(); } } void writer(void) { while(TRUE) { think_up_data(); down(&db);//获取数据库访问的锁 write_data_base(); up(&db); } }
这里,我们其实用了两个互斥锁来实现了读写锁。一个互斥锁用来保护共享区,另外一个互斥锁用来保护读者计数器。
读写锁可以由三种状态:读模式下加锁状态、写模式下加锁状态、不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。
在读写锁是写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞。当读写锁在读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是如果线程希望以写模式对此锁进行加锁,它必须阻塞直到所有的线程释放读锁。虽然读写锁的实现各不相同,但当读写锁处于读模式锁住状态时,如果有另外的线程试图以写模式加锁,读写锁通常会阻塞随后的读模式锁请求。这样可以避免读模式锁长期占用,而等待的写模式锁请求一直得不到满足。
读写锁非常适合于对数据结构读的次数远大于写的情况。当读写锁在写模式下时,它所保护的数据结构就可以被安全地修改,因为当前只有一个线程可以在写模式下拥有这个锁。当读写锁在读状态下时,只要线程获取了读模式下的读写锁,该锁所保护的数据结构可以被多个获得读模式锁的线程读取。
读写锁也叫做共享-独占锁,当读写锁以读模式锁住时,它是以共享模式锁住的;当他以写模式锁住时,它是以独占模式锁住的。
五、总结
这里,主要是简单总结一下这几种同步量的用法。
1、互斥锁只用在同一个线程中,用来给一个需要对临界区进行读写的操作加锁。
2、信号量与互斥量不同的地方在于,信号量一般用在多个进程或者线程中,分别执行P/V操作。
3、条件变量一般和互斥锁同时使用,或者用在管程中。
4、互斥锁,条件变量都只用于同一个进程的各线程间,而信号量(有名信号量)可用于不同进程间的同步。当信号量用于进程间同步时,要求信号量建立在共享内存区。
5、互斥锁是为上锁而优化的;条件变量是为等待而优化的; 信号量既可用于上锁,也可用于等待,因此会有更多的开销和更高的复杂性。
参考书籍:《现代操作系统》