同步问题是保证数据安全的情况下,让线程访问资源具有一定的顺序性,从而有效避免饥饿问题,叫做同步。
所以怎么才能让线程按照一定的顺序去访问资源呢?也就是同步的解决方案是什么呢?这个解决方案在 Linux 中称为条件变量。
什么叫做条件变量呢?以前我们使用的纯加锁的时候,没有申请到加锁的线程,就直接阻塞挂起了,这个场景为纯互斥场景,也就是线程没有顺序地执行的,谁能申请到锁谁就能得到资源。而条件变量就是可以做到让线程在一个等待队列中按照顺序等待,按照它们到来的先后顺序进入队列等待,前提是这些线程都是申请锁失败的,因为是要保证资源安全的情况下。而且,在资源就绪的时候,也就是有线程释放锁后,这个条件变量还需要提供一种通知机制,唤醒一个或者全部队列中的线程,让队头的线程去访问资源。这就是条件变量。
例如一个线程访问队列时,该队列为共享资源,发现队列为空,它只能等待,直到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。
初始化条件变量的接口:
int pthread_cond_init(pthread_cond_t *restrict cond,
const pthread_condattr_t *restrict attr);
其中第一个参数类型,也是 pthread 库给我们提供的数据类型,我们先要定义一个这样类型的条件变量,也就是要初始化的条件变量。第二个参数是属性,我们设为空即可。
条件变量的初始化和互斥锁的初始化类似,也可以定义一个全局的条件变量,用法也一样,全局或静态的条件变量可以不用初始化和释放。定义全局的条件变量如下:
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
释放条件变量:
int pthread_cond_destroy(pthread_cond_t *cond);
申请锁失败或者等待条件满足时,加入等待队列中:
int pthread_cond_wait(pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex);
第一个参数就是我们初始化的条件变量;第二个参数是一把锁,我们后面再介绍。
唤醒一个线程的接口:
int pthread_cond_signal(pthread_cond_t *cond);
唤醒所有线程:
int pthread_cond_broadcast(pthread_cond_t *cond);
下面我们将上面的接口和互斥接口一起使用起来。我们知道,当多个线程向显示器上打印时,其实就是多个线程访问同一个共享资源,此时如果不加锁,打印出来的信息就是乱的。现在我们就模拟这个场景,对显示器这个共享资源加锁,并添加条件变量实现同步。
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int cnt = 0;
void* func(void* args)
{
pthread_detach(pthread_self());
uint64_t i = (uint64_t)args;
while(1)
{
pthread_mutex_lock(&lock);
// pthread_cond_wait 让线程加入等待队列的时候,会自动释放锁
pthread_cond_wait(&cond, &lock);
cout << "pthread: " << i << ", cnt: " << cnt++ << endl;
pthread_mutex_unlock(&lock);
}
return nullptr;
}
int main()
{
for(uint64_t i = 0; i < 5; i++)
{
pthread_t tid;
pthread_create(&tid, nullptr, func, (void*)i);
}
// 让主线程唤醒等待队列中的队头线程
while(1)
{
sleep(1);
pthread_cond_signal(&cond);
}
return 0;
}
结果如下,我们发现每一个线程都是按照顺序去访问资源的,即使是刚释放锁的线程,也会加入等待队列的队尾重新等待下一轮资源访问:
注意,条件变量的等待需要放在加锁的后面,因为需要保证数据安全的前提,那么加锁后加入等待队列,不会形成死锁吗?不会,因为 pthread_cond_wait(&cond, &lock);
也把锁传进来了,它会帮线程自动释放锁!
所以未来如果我们需要对共享资源加以判断条件判断资源是否就绪,我们就可以把 pthread_cond_wait(&cond, &lock);
放在判断条件里面,如果资源不就绪,就将线程加入等待队列中,再让正在访问资源的线程访问完资源后唤醒线程。
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的,也就是将生产和消费的行为进行解耦!
所以生产者消费者模型会有三种关系,分别是:
除此之外,生产者消费者模型的两中角色分别是生产者和消费者,还有一个特定结构的内存空间。
在多线程编程中阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)。
下面我们使用代码实现这个模型,代码链接:基于 BlockingQueue 的生产者消费者模型.
下面我们对上面的代码中的几个细节分析一下:
_q.size() == _capacity
,然后这批生产线程要去条件变量中等待,被释放锁。此时,一个消费线程消费了一个资源,然后唤醒生产线程,注意 pthread_cond_signal(&c_cond);
也不一定是只唤醒一个线程,有可能会唤醒多个!假设此时生产线程的条件变量中有多个线程被唤醒,此时它们的条件变量就被破环了,它们都要去申请锁,形成了互斥,而资源的空间只有一个,一个生产线程填上这个资源空间后,此时空间就满了,然后唤醒消费线程。如果剩下的被误唤醒的生产线程竞争锁的能力比较强的话,消费线程由于抢不到锁,导致生产线程继续生产数据,但是此时资源空间已经满了,继续生产的话会溢出资源!这就是伪唤醒的情况!所以我们在判断条件时,不能用 if
语句判断,要用 while
循环判断!有人说生产者消费者模型是高效的,为什么高效呢?我们上面列的优点中并没有说高效呀。我们知道,生产和消费的过程是要加锁的,所以整个在访问这个“仓库”的时候,生产和消费本身就是串行的,如果我们单单看到这个过程,确实没有高效的体现。那么如何体现高效呢?如果消费者当前正在消费数据(获取数据),生产者虽然没有办法直接生产数据,但它可以正在获取数据!反过来,如果生产者当前持有锁,往“仓库”里生产数据,而消费者此时可以处理数据!所以我们在生产者消费者模型中,引入生产者生产数据需要时间,消费者处理数据也要花时间,那么就会存在非常大的概率,生产者正在生产数据,消费者不获取数据,而正在处理数据,反过来也一样!也就是说,一个正在访问临界区代码,一个正在访问非临界区代码,这时候生产和消费线程就是在高效并发访问!
我们上面生产者消费者模型的代码中,我们的 queue 是一个共享资源,它被当作一个整体来使用,这个 queue 只有一份,所以需要加锁。但是共享资源也可以被看作多份的!
其实 POSIX 信号量 和我们以前学的 SystemV 信号量 是一样的。它们的作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但 POSIX 可以用于线程间同步。
我们一句话总结以前学的信号量,信号量是一个保证 PV 操作的原子性的一把计数器。那么这把计数器的本质是什么呢?是临界资源的数量!所以,有了 PV 操作,就不需要在临界区之间判断临界资源是否就绪了!因为只要一个线程 P 操作申请成功了,这个线程就一定能访问资源,因为 P 操作就是对资源的预定机制!如果申请不成功的,就要去信号量中去等待了!
初始化信号量:
int sem_init(sem_t *sem, int pshared, unsigned int value);
其中,sem_t 是给我们提供的类型;第一个参数 pshared,表示线程还是进程共享,0 表示线程间共享,非零表示进程间共享;第二个参数 value 表示信号量初始值。
销毁信号量:
int sem_destroy(sem_t *sem);
等待信号量,会将信号量的值减1,即 P() 操作:
int sem_wait(sem_t *sem);
发布信号量,表示资源使用完毕,可以归还资源了,将信号量值加1;即 V() 操作:
int sem_post(sem_t *sem);
环形结构起始状态和结束状态都是一样的,不好判断为空或者为满,也就是空或者满的时候,head 和 tail 都是指向同一个位置。
所以可以通过加计数器或者标记位来判断满或者空。另外也可以预留一个空的位置,作为满的状态。例如使用第二种方法判空还是满:
但是我们现在有信号量这个计数器,就很简单的进行多线程间的同步过程,就不需要进行判空还是判满了。
我们在环形队列中引入生产者和消费者模型,这些多线程就要在一个环形队列中进行生产和消费的动作,所以生产者和生产者,消费者和消费者,必须都是各自互斥,生产和消费也必须有互斥和同步的关系。当生产者线程和消费者线程在访问环形队列的时候,只要生产和消费线程不访问同一个格子资源,那么生产的同时也在消费,消费的同时也在生产!
那么什么情况才会指向同一个位置呢?空或者满!也就是说,在不空和不满的时候,它们一定指向不同的位置,它们可以同时访问!所以为空的时候,只能由生产者线程运行,满的时候,只能由消费者线程运行。而且当消费者不消费的时候,生产者不能超过消费者一圈!反过来,生产者不生产的时候,消费者不能超过生产者!
那么怎么用代码的方式保证上面的方式正常运行呢?下面就需要引入信号量了。信号量的本质是用来描述它所关注的资源的数量的,作为生产者线程,它关注的是环形队列中还有多少剩余空间;作为消费者线程,它关注的是环形队列中还有多少剩余数据。所以我们可以定义两个信号量,一个叫做空间资源信号量:SpaceSem
,初始值也就是环形队列的大小 N;另一个叫做数据资源信号量:DataSem
,因为一开始环形队列没有数据,所以初始值为 0.
接下来生产者和消费者开始并发运行了,生产者想要生产数据,必须先要进行 P(SpaceSem) 操作,再进行生产;对于消费者线程来说,先要申请数据,进行 P(DataSem) 操作,申请到了就可以消费。在一开始队列为空时,一定是生产者先执行 P(SpaceSem) 操作!因为消费者申请不了数据资源,此时为0;所以即便消费者先被调度,也会因为申请失败会被挂起。那么当一个生产者把数据生产完了,那么数据资源就多了一个,此时生产者线程就可以进行 V(DataSem) 操作,此时生产者已经指向下一个位置了,就可以让消费者来消费!那么当消费者把数据消费了,数据资源就必定少了一个,也就是空间资源就多了一个,此时消费者线程就可以进行 V(SpaceSem) 操作!本质就是申请自己的资源,释放对方的资源,进行并发访问!
代码实现的链接:基于环形队列的生产消费模型.
其中代码中的几个细节:
但是如果把加锁放在申请信号量之后,可以在一定程度上,在多线程申请时,在一个线程申请到锁访问期间,其它线程可以申请信号量,让申请信号量和申请锁变成并行的!如下图:
线程池:一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络 sockets 等的数量。
接下来我们简单写一个线程池,示例:创建固定数量线程池,循环从任务队列中获取任务对象;获取到任务对象后,执行任务对象中的任务接口。代码链接:线程池.
自旋锁接口:
在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢? 有,那就是读写锁。