Linux下提供了多种方式来处理线程同步,最常用的是互斥锁、条件变量、信号量和读写锁。
下面是思维导图:
并发有两大需求,一是互斥,二是等待。互斥是因为线程间存在共享数据,等待则是因为线程间存在依赖,需要同步。从上面可以看出,在Linux内核中有互斥锁、条件变量、读写锁、信号量4种机制用于解决线程间的同步和互斥问题,那这四种机制有何区别?
互斥量有两种状态–解锁和加锁。当一个线程(或进程)需要访问临界区时,它调用互斥锁。如果该互斥量当前是解锁的(即临界区可用),此调用成功,调用线程可以自由进入该临界区。另一方面,如果该互斥量已经加锁,调用线程被阻塞,直到在临界区中的线程完成并调用互斥锁。如果多个线程被阻塞在该互斥量上,将随机选择一个线程并允许它获得锁。
案例:这里有一个打印机p,A和B都要使用,显然只能轮流着使用。这时就需要加一个互斥锁,只有得到了互斥锁lock_s之后才能进行打印
pthread_mutex_lock(lock_s);
print();
pthread_mutex_unlock(lock_s);
与互斥锁不同,条件变量是用来等待而不是用来上锁的。条件变量用来自动阻塞一个线程,直到某特殊情况发生为止。通常条件变量和锁机制同时使用。条件变量,是为了解决等待同步需求,实现生产者消费者队列的一种机制(得益于事件驱动模式——Actor,他也是基于异步AIO出现的,其实现在很多东西都是来自于Actor思想,比如IO多路复用、MapReduce、消息队列中间件(注意不是os中进程间通信的消息队列,这个消息队列中间件算是建立在应用层上的,而非os级的消息队列))。
条件变量使我们可以睡眠等待某种条件出现。条件变量是利用线程间共享的全局变量进行同步的一种机制,主要包括两个动作:一个线程等待"条件变量的条件成立"而挂起;另一个线程使 “条件成立”(给出条件成立信号)。
【原理】:
条件的检测是在互斥锁的保护下进行的。线程在改变条件状态之前必须首先锁住互斥量。如果一个条件为假,一个线程自动阻塞,并释放等待状态改变的互斥锁。如果另一个线程改变了条件,它发信号给关联的条件变量,唤醒一个或多个等待它的线程,重新获得互斥锁,重新评价条件。如果两进程共享可读写的内存,条件变量 可以被用来实现这两进程间的线程同步。
【条件变量的操作流程如下】:
1. 初始化:init()或者pthread_cond_tcond=PTHREAD_COND_INITIALIER;属性置为NULL;
2. 等待条件成立:pthread_wait,pthread_timewait.wait()释放锁,并阻塞等待条件变量为真 timewait()设置等待时间,仍未signal,返回ETIMEOUT(加锁保证只有一个线程wait);
3. 激活条件变量:pthread_cond_signal,pthread_cond_broadcast(激活所有等待线程)
4. 清除条件变量:destroy;无线程等待,否则返回EBUSY清除条件变量:destroy;无线程等待,否则返回EBUSY
案例:生产者P每次只能生产1个配件part,消费者A和B必须要等到有100个配件才能进行装配。
如果我们只用互斥锁的话,那消费者就得互斥地方式不断轮询共享的全局变量part,查看其数量是否大于等于100。如果是队列里有值,就去消费;如果为空,要么是继续查( spin 策略),要么 sleep 一下,让系统过一会再唤醒你,你再次查。可以想到,无论哪种策略,都不通用,要么费 cpu,要么线程 sleep过头了,影响该线程的性能。
//poll轮询
#define assembleMutex
P:
part++
A/B:
while(true){
lock(&assembleMutex)
if(part>=100){
assembly();
part-=100;
}else{
sleep(n_time);
}
unlock(&assembleMutex)
}
那么最好的做法就是设一个闹钟(条件变量),让他不要睡过头了,这样既保证了CPU的充分使用,也保证了线程的及时响应。
具体做法就是,上面的消费者线程,发现队列为空,就告诉操作系统,我要 wait,一会肯定有其他线程发信号来唤醒我的,然后就将消费者加入到wait队列中。当生产者生产了足够多的部件时,就发送一个signal,然后os接收到这个signal后,就会从等待队列中唤醒一个线程。
【为什么条件信号一定要配合互斥锁使用】:
//不加互斥锁
#define cond ————条件变量,100是条件,cond是条件变量(专门在大于100时由os传递给消费者进行通知)
P:
part++;
if(part>=100){
pthread_cond_signal(&cond)
}
A/B:
if(part>=100){
assembly();
part-=100;
}else{
pthread_cond_wait(&cond)
}
上面是一个不加互斥锁的例子,如果A在判断part不满足条件后,此时可能由于时间片轮换造成消费者还没来得及执行pthread_cond_wait()操作,导致wait队列中并不存在此线程,恰巧在此时切换到了某个生产者线程中,并且恰好满足了条件,发出了cond信号,此时os便在wait队列中查找等待中的线程,但是发现并没有等待线程,所以这个cond信号便丢失了,造成后面切换到消费者线程,继续执行到pthread_cond_wait()时,已经错过了这个信号,导致消费者丢失了此次信号,处于阻塞态。
正确做法是当线程切换导致消费者来不及进入wait队列时,其它程序也不可以操作该共享变量(这里就是part),既保证消费者pthread_cond_wait()和条件判断是一个原子操作,而最常见的做法便是在条件判断到pthread_cond_wait()的这一段代码加一个互斥锁进行保护,另外生产者操作共享资源时,必须先获得锁才行。
//条件变量
#define cond
#define condMutex
生产者:
pthread_mutex_lock(&mutex);
part++
if(part>=100){
pthread_cond_signal(&cond)
}
pthread_mutex_unlock(&mutex);
消费者:
pthread_mutex_lock(&condMutex)
while(part<100 // cond is false){
//pthread_cond_wait 执行流程 block-->unlock-->wait() return-->lock
pthread_cond_wait(&cond,&condMutex); //将当前线程放入wait队列,并放开互斥锁,被唤醒后,再次上锁
}
assembly();
part-=100;
pthread_mutex_unlock(&condMutex);
还有个问题,为什么上面要用while循环,是因为这里存在一个虚假唤醒问题。
虚假唤醒:在多核处理器下,pthread_cond_signal可能会激活多于一个线程(阻塞在条件变量上的线程)。结果就是,当一个线程调用pthread_cond_signal()后,多个调用pthread_cond_wait()或pthread_cond_timedwait()的线程返回。这种效应就称为“虚假唤醒”。
由于可能会唤醒了多个线程,如果A在唤醒之后,但是还没有加锁,执行相应的操作,此时切换到另外一个被唤醒的线程B,然后B加锁,执行了相应的操作,然后解锁,最终导致条件已经发生了改变;而等B完成了之后,此时切换到A还继续执行装配的话,就会导致配件数量不够而装配失败。而使用了while则不会产生这种情况,就算被唤醒也还是会检查条件是否满足,如果不满足则不会执行装配任务。
【条件变量的代码模板】:
消费者:
pthread_mutex_lock(&mutex);
while(cond is false)
{
pthread_cond_wait(&cond, &mutex);
}
consume();
alter &cond
pthread_mutex_unlock(&mutex);
生产者:
pthread_mutex_lock(&mutex);
produce();
if(cond is true){
pthread_cond_signal(&cond);
}
pthread_mutex_unlock(&mutex);
参考链接:https://cloud.tencent.com/developer/article/1400153、https://www.zhihu.com/question/68017337
读写锁与互斥量类似,不过读写锁允许更改的并行性,也叫共享互斥锁。互斥量要么是锁住状态,要么就是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁可以有3种状态:读模式下加锁状态、写模式加锁状态、不加锁状态。一次只有一个线程可以占有写模式的读写锁,但是多个线程可以同时占有读模式的读写锁(允许多个线程读但只允许一个线程写)。
可以认为读写锁是两个锁,一个针对读模式下加的锁(防止其它进程的写),一个是针对写模式加的锁(防止其它进程的读和写)。
#include
// 初始化读写锁
int pthread_rwlock_init(pthread_rwlock_t *rwlock, const pthread_rwlockattr_t *attr);
// 申请读锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock );
// 申请写锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock );
// 尝试以非阻塞的方式来在读写锁上获取写锁,
// 如果有任何的读者或写者持有该锁,则立即失败返回。
int pthread_rwlock_trywrlock(pthread_rwlock_t *rwlock);
// 解锁
int pthread_rwlock_unlock (pthread_rwlock_t *rwlock);
// 销毁读写锁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
【读写锁的特点】:
如果有其它线程读数据,则允许其它线程执行读操作,但不允许写操作;
如果有其它线程写数据,则其它线程都不允许读、写操作。
【读写锁的规则】:
如果某线程申请了读锁,其它线程可以再申请读锁,但不能申请写锁;
如果某线程申请了写锁,其它线程不能申请读锁,也不能申请写锁。
读写锁适合于对数据结构的读次数比写次数多得多的情况。
从图中也可以看出信号量和上面讲的3个分在左右两侧,属于不同类,信号量也可以用做互斥,但是更重要得是信号量可以用来解决多资源多生产者多消费者的同步问题。
锁机制使用是有限制的,锁只有两种状态,即加锁和解锁,对于互斥的访问一个全局变量,这样的方式还可以对付,但是要是对于其他的临界资源,比如说多台打印机等,这种方式显然不行了。
信号量机制在操作系统里面学习的比较熟悉了,信号量是一个整数计数器,其数值表示空闲临界资源的数量。
当有进程释放资源时,信号量增加,表示可用资源数增加;当有进程申请到资源时,信号量减少,表示可用资源数减少。这个时候可以把锁机制认为是0-1信号量,也就是说锁是一种特殊的信号量。另外值得一提的是,往往在使用信号量实现并发的时候,少不了0-1信号量的参与(锁),因为在某些状态下需要对信号量实现互斥操作。
#include
// 初始化信号量
int sem_init(sem_t *sem, int pshared, unsigned int value);
// 信号量 P 操作(减 1)
int sem_wait(sem_t *sem);
// 以非阻塞的方式来对信号量进行减 1 操作
int sem_trywait(sem_t *sem);
// 信号量 V 操作(加 1)
int sem_post(sem_t *sem);
// 获取信号量的值
int sem_getvalue(sem_t *sem, int *sval);
// 销毁信号量
int sem_destroy(sem_t *sem);
在不同语言中,这种信号量机制的实现方案有所不同,比如java中或者c语言中,需要0-1信号量来配合实现,而对于go语言,可以通过channel来实现,channel通过通道方式避免了多线程并发对信号量的同时操作。
信号量、互斥锁等算是一种通过共享内存变量实现多线程同步互斥的一种手段
而事件驱动(比如条件变量、go特有的channel(其实质也是消息队列进行通信)等)则是通过程序间发送信号来实现同步互斥的一种手段。
另外值得一提的是事件驱动不光是用在了多线程同步互斥的问题方面,他还可以用来实现高并发的I/O模式(比如I/O多路复用),而高并发的I/O模式在是很多系统的必要支撑(比如netty框架、Redis)等。