一.线程同步
(一).概念
线程同步是一种多线程关系,指的是线程之间按照特定顺序访问临界资源,进而能够避免线程饥饿问题。
所谓线程饥饿指的是某个线程长期“霸占”临界资源,导致其他线程无法访问该资源。而通过线程同步机制能够有效避免饥饿的情况发生。
可以理解为将线程先排成一排就像队列一样,访问临界资源时按照队列顺序一个接着一个的访问。
(二).同步与互斥的关系
之前介绍过线程互斥的概念,链接在此:Linux——什么是互斥与互斥锁
当我们明白同步的概念后,需要梳理一下互斥与同步的关系。
首先,不管是线程互斥还是线程同步,指的都是线程与线程之前的一种联系。
很多文章都在说“同步是一种复杂的互斥,互斥是特殊的同步”,本人解释如下:
互斥的要求是在同一时间只能有一个线程访问临界资源,也就是线程并发执行,同步的条件是线程按次序执行,也就是说同步的条件有两个:有序和并发。同步在互斥的基础上更加强调了线程有序,因此“同步是一种复杂的互斥”。
同时,同步条件之一是有序,而互斥没有顺序要求。也就是说不管有序还是无序,只要满足同一时间只有一个线程访问临界资源就是互斥。换句话说,有序的互斥也就是同步,即“互斥是一种特殊的同步”。
总结一下,不管是互斥还是同步,共有条件是“同一时间只有一个线程访问临界资源”。因此同步与互斥都是用来处理多线程中访问临界资源问题的手段。但同步比互斥更加强调线程顺序的重要性。
(三).条件变量
使用条件变量是达成线程同步的一种手段。顾名思义,条件变量需要线程达成某种条件进而完成同步。
满足条件时线程继续运行,不满足条件时线程排队等待,直到其他线程发送特定信号,解除等待。
linux提供了条件变量的类型和接口:
头文件和互斥锁相同,为<pthread.h>,编译时需要在命令后加-pthread。
g++ xxx.cpp -o xxx -pthread; //gcc同理
pthread_cond_t为条件变量类型,使用条件变量完成线程同步前必须先定义条件变量对象。
pthread_cond_t cond;
①初始化和销毁
使用条件变量必须先进行初始化操作:
linux提供了两种初始化方式,系统接口和系统宏定义。
返回值为0代表成功,非0代表失败,条件变量相关的其他接口同理。
pthread_cond_init(&cond, nullptr);//参数二为条件变量属性,默认为NULL即可
pthread_cond_init cond = PTHREAD_COND_INITALIZER;
当程序不再使用该条件变量时,请销毁它:
pthread_cond_destroy(&cond);
②阻塞等待
根据特定情况,可以使用条件变量使线程阻塞:
pthread_cond_wait(&cond, &mtx);//第二个参数为互斥锁,且必须是已经初始化以及完成加锁。
使用上通常伴随条件判断,如果满足或不满足某种条件时便会触发wait函数,进而使线程阻塞并进入等待队列,如果多个线程都触发了wait函数就会按照顺序依次进入等待队列。
示例如下:
pthread_mutex_lock(&mtx);
while(条件)
{
//达成某种条件时,线程便会阻塞等待并入等待队列
pthread_cond_wait(&cond, &mtx);
}
pthread_mutex_unlock(&mtx);
该接口内部,会将线程放在等待队列上并解除传递的互斥锁,这也就是为什么函数第二个参数是互斥锁mutex,等到结束阻塞的信号后,函数内部会重新争夺互斥锁,待加锁成功后退出wait函数。
图示解析如下:
③解除阻塞
当满足特定条件时,其他线程可以发送信号让等待队列中的线程停止阻塞。
pthread_cond_broadcast(&cond);//该条件变量下的所有阻塞线程继续运行
pthread_cond_signal(&cond);//该条件变量下按照等待顺序让一个阻塞线程继续运行
使用上会配合pthread_cond_wait接口使用,达到线程同步的目的。
伪代码如下:
//线程一
pthread_mutex_lock(&mtx);
while(条件)
{
//尚不构成某种条件,不能获取临界资源
pthread_cond_wait(&cond, &mtx);
}
...//达成条件,处理临界资源
pthread_mutex_unlock(&mtx);
//线程二
...
if(条件)
{
//达成某种条件,解除阻塞,让其他线程访问临界资源
pthread_cond_signal(&cond);
}
尤其需要注意的是,在判断线程是否需要阻塞时,一定要用while循环判断而不是if条件判断。这是因为
线程可能因为意外情况结束阻塞,但是此时条件尚未达成,因此需要在解除阻塞后再次判断是否依旧达成条件。
(四).条件变量实际应用
就使用场景而言,一个经典的案例就是基于阻塞队列的生产消费模型。
相关代码在这篇博客中:Linux——生产消费者模型(阻塞队列形式)
这里主要解释条件变量在其中的应用,而不再具体讨论模型的实现。
当容器为空时消费者线程需要阻塞在临界区外,由于是多线程,可能会有很多线程阻塞在临界区之外。这时这些线程就需要按顺序“排队”,代码而言就是调用pthread_cond_wait接口使线程阻塞。当生产者线程将数据写入容器后,发送信号给阻塞队列,按等待队列次序让消费者线程依次运行处理临界资源。
同理当容器满时,通过另一个条件变量使生产者线程阻塞,当消费者处理数据后,发送信号给生产者使其继续运行。
需要注意的是,生产者与消费者是两个不同的条件变量。这是因为二者阻塞在不同的等待队列中,只有生产者产出数据才能解除消费者的阻塞,同时只有消费者消费数据才能解除生产者的阻塞。但双方并不会发生死锁问题,这是因为二者共用一个互斥锁,生产者与消费者是并发执行的互斥关系。
伪代码示意如下:
//生产者
void* producerFunction(void* arg)
{
pthread_mutex_lock(&mtx);
while(容器已满)
{
pthread_cond_wait(&cond1, &mtx);
}
...//将资源放入容器的过程
pthread_cond_signal(&cond2);//发送信号使消费者消费数据
pthread_mutex_unlock(&mtx);
...
}
//消费者
void* consumerFunction(void* arg)
{
pthread_mutex_lock(&mtx);
while(容器为空)
{
pthread_cond_wait(&cond2, &mtx);
}
...//处理临界资源的过程
pthread_cond_signal(&cond1);//发送信号使生产者生产数据
pthread_mutex_unlock(&mtx);
...
}
二.POSIX信号量
(一).概念
与条件变量相同,信号量也是应用于线程同步的一种技术。本质上,信号量是一种资源预定机制。就好比电影院售票,票数固定,谁能买到票谁就能看电影,没有票就看不到。信号量就是电影票,提前设定好信号量的最大值,象征着有多少线程能访问临界资源,信号量--就是卖出一张票,说明有一个线程拥有了访问资源的权力;信号量++就是退掉了一张票,说明有一个线程访问过临界资源或者取消访问,同理也可以参考智能指针的引用计数。
信号量--的操作称为P操作,即线程预定了资源;信号量++的操作称为V操作,即临界资源访问完毕或取消预订。
需要注意,P操作叫做预定资源,并不是真正获取了临界资源。什么意思呢,就像我们去蜜雪冰城买奶茶,服务员给了我们一张小票,上面写着我们是第几单,前头还有多少单。此时的我们并没有拥有奶茶,而是“预定”了一杯,在未来某时就会获得奶茶。取得信号量的线程(或者叫完成P操作的线程)就是预定成功了,在未来某时就能访问临界资源。
同样的,当我们等待奶茶时,如果临时取消不想买了,把票退给服务员就是V操作中的取消预定;我们兑换小票取得奶茶就是V操作的临界资源访问完毕。V操作的两种情况就本质而言都是信号量++,只不过一个是没有访问临界资源,另一个访问完成。
(二).使用
linux提供了使用信号量的系统接口。定义在头文件<semaphore.h>中。编译时需要在命令后加上-pthread。
g++ xxx.cpp -o xxx -pthread
信号量类型为sem_t类型。
①初始化和销毁
首先需要定义sem_t类型对象并完成对信号量的初始化。
初始化的主要目的就是确定该信号量当前值。
返回值含义与条件变量接口相同,0代表成功,非0代表失败。
sem_t sem;
sem_init(&sem, 0, 5);
//参数pshared:0代表线程间共享,非0代表进程间共享
//参数value即设定的该信号量当前值,这里就代表该信号量当前还能分配给5个线程
当不再使用信号量时请及时销毁,这些和条件变量异曲同工。
sem_destroy(&sem);
②P操作(信号量--)
当线程需要访问临界资源时,请先调用sem_wait接口申请信号量,如果此时有剩余信号量那么申请成功,信号量--;如果此时信号量为0代表没有剩余信号量可以申请,此时线程阻塞在wait接口。sem_wait系统调用本身是原子性的,也就是说P操作本身是线程安全的。
sem_wait(&sem);//申请失败会阻塞
sem_trywait(&sem);//申请失败不会阻塞
③V操作(信号量++)
当线程完成对临界资源的访问后,需要归还信号量便于其他线程获取访问资源的资格。也就是使信号量++。同样V操作也是原子性即线程安全的。
sem_post(&sem);
在实际应用场景中,需要我们先去申请信号量(P操作),申请成功后再加锁访问临界资源,当不再访问时,解锁后归还信号量(V操作)。伪代码流程如下:
//sem、mtx必须是多线程共享都能访问的资源,也就是说这些线程看到的必须是同一份sem、mtx
//一般而言,可以把sem、mtx作为arg参数传给线程函数
void* threadFunction(void* arg)
{
...
sem_wait(&sem);//先申请信号量
pthread_mutex_lock(&mtx);//申请信号量成功,加锁
...//访问临界资源
pthread_mutex_unlock(&mtx);//解锁
sem_post(&sem);//归还信号量
...
}
(三).实际应用:基于循环队列的生产消费模型
在上文中我们提到了基于阻塞队列形式的生产消费模型,那里是使用条件变量来完成生产者与消费者的同步过程。而生产消费模型还可以是循环队列的形式,使用信号量来完成线程同步的过程。
首先简单说一下什么是循环队列,本质就是长度固定的数组,从头开始插入资源,当插入资源位于最后一个位置的下一个时,再从头开始插入资源,也就是把线性的数组“头尾相连”,变成逻辑上的环形结构。不再具体解释,讲解循环队列的文章网上很多。这里重点说明信号量在生产消费模型中是怎么使用的。
图示如下:
首先我们需要知道信号量作为一种预定机制,要信号量清楚在生产消费模型中是在预定什么,这一点非常重要!
对于生产者是预定队列位置,确保有位置能供自己放入资源数据。对于消费者是预定容器(循环队列)资源,确保容器中的现有资源有自己一份。生活中的例子比比皆是,比如蛋糕店生产蛋糕就要确保柜台上还有位置能放蛋糕,而顾客要看柜台上是否还有蛋糕确定能不能买到。
想清楚这一点就不难发现,生产者和消费者是预定了两种不同的资源:空位和容器剩余资源。因此,我们要定义两个信号量来代表这两种资源。并且在初始化时空位数量要为队列长度,剩余资源数量为0。
sem_t placeSem;//生产者预定的空位
sem_t dataSem;//消费者预定的容器剩余资源
sem_init(&placeSem, 0, 队列长度);
sem_init(&dataSem, 0, 0);
在使用时,生产者先预定空位(placeSem进行P操作),然后加锁把数据放入队列再解锁,最后使容器剩余资源数量+1(dataSem进行V操作)。
伪代码如下:
//生产者
void* producerFunction(void* arg)
{
sem_wait(&placeSem);//先预定空位
pthread_mutex_lock(&prod);//生产者与生产者是互斥关系,加生产者间的互斥锁
...//资源放入容器中
pthread_mutex_unlock(&prod);//解锁
sem_post(&dataSem);//容器剩余资源数量+1
...
}
需要说明的是,加锁的过程可以在P操作(预定空位)之前,但是没有必要。因为加锁后的线程一定能访问临界资源,也就是说加锁后的线程肯定是预定了空位的。进行信号量P操作的目的是为了消费者与生产者线程同步,且锁加在P操作之后还能降低粒度。
可能还会有疑问,为什么最后不归还空位使placeSem+1呢,请先看消费者的处理过程,之后会进行说明。
对于消费者而言,先预定容器剩余资源(dataSem进行P操作),然后加锁获取容器数据再解锁,最后使容器空位+1(placeSem进行V操作)。
伪代码如下:
//消费者
void* consumerFunction(void* arg)
{
sem_wait(&dataSem);//预定剩余资源
pthread_mutex_lock(&consum);//消费者之间是互斥关系,加消费者间的互斥锁
...//获取容器资源
pthread_mutex_unlock(&consum);//解锁
sem_post(&placeSem);//容器空位+1
...
}
这时我们会有两个问题:一是为什么生产之后不立即释放预定的空位(placeSem的V操作),二是为什么生产者和消费者的互斥锁不是同一个?
回答第一个问题,首先就逻辑上而言,当完成生产时,我们预定的那个空位已经被生产的数据所填满,此时那个空位已经不再存在。其次,就资源层面来讲,当生产者生产数据放入空位后,如果此时空位数量还是生产前那么多,未免太不合理了吧。因此,只有在消费者消费数据后才能空位+1,也就是在消费者函数中进行placeSem的V操作。同理,只有生产者生产数据后代表剩余资源的dataSem信号量才能进行V操作。
回答第二个问题,首先我们知道在阻塞队列形式的生产消费模型中生产者与消费者的互斥锁是同一个,这是因为生产者与消费者是互斥关系,本质原因是防止生产者与消费者访问同一个资源。但是,在阻塞队列形式中,虽然生产者与消费者共享同一个容器,但是容器内部会定义两个变量,分别代表生产者与消费者各自访问的资源在队列中的下标。而基于信号量的特性,当队列为空时,消费者一定会阻塞在申请剩余资源的P操作那里,只有当生产者生产数据后使剩余资源+1,消费者才能进入临界区获取资源。也就是说,消费者永远在生产者的“屁股后头”,那么双方访问的下标永远不会相同。因此生产者和消费者只需要各自加锁。
从生产消费模型的两种形式能看出,信号量和条件变量都是用来完成线程同步的工具,但是条件变量可以一次性唤醒所有线程,而信号量不行。同样地,信号量能够根据此时的计数值记录状态,而条件变量不行。并且信号量的一大使用特色是作为进程间同步的工具,而条件变量是作为线程间同步的工具。
三.综合应用:线程池
(一).自制线程池
线程池是利用池化技术维护多个线程,当需要处理任务时便调度维护的线程,这样不仅可以保证对内核的充分利用,还可以避免过分调度。主要的应用场景是任务处理过程短且需要大量线程的环境。比如Web服务器完成网页请求就属于这类情况,有大量的网页点击需求,任务小但是需求大,使用线程池能够避免大量的线程创建的等待时间。而会话请求就不太合适,因为会话时间相比于创建线程要长很多,线程池的优点就不太明显。此外,如果是要求迅速响应的任务和瞬间需求大量线程的应用(因为瞬间创建大量线程可能导致内存极限进而出错),线程池技术都比较合适。
概念上,线程池本身也是一种生产消费模型。生产过程就是获取任务到线程池中,消费过程就是调度具体的线程处理任务。因此,我们可以提前创建多个线程作为消费者,使用循环队列作为存储任务的容器。当容器中有任务时,按照次序调度线程处理任务。
源码:threadPool/threadPoolPlus · 纽盖特/linux - 码云 - 开源中国 (gitee.com)
伪代码如下:
/*
优化线程池:
将队列划分为生产消费两个
生产者生产数据后,当数据满足一定数量时交换生产消费队列
同时能使生产与消费互斥关系降到最低,只有在交换队列时才会互斥
*/
struct ThreadData{//线程数据
...//线程名、线程id等数据
void* _arg;//记录该线程的线程池指针,因为线程池_threadFunc函数为静态,无法直接使用threadPool对象资源和成员函数
};
class Thread{//线程类
public:
Thread(.../*其他线程数据*/, tFunc func, void* arg = nullptr)
:_func(func)
{
...//记录其他线程数据
_data._arg = arg;
}
void run()//启动线程,函数传参
{
pthread_create(&_data._tid, nullptr, _func, (void*)&_data);
}
void join()
{
pthread_join(_data._tid, nullptr);
}
private:
ThreadData _data;
tFunc _func;//线程调度的函数
};
#define THREAD_NUM 5//消费者线程数量
#define QUEUE_MAX_SIZE 5//默认容器大小
template<class T>
class ThreadPool{
static void* _threadFunc(void* arg)//线程执行任务的函数
{
//通过参数,获取threadPool对象,因为该函数是静态,没有this指针
ThreadData* data = (ThreadData*)arg;
ThreadPool<T>* pool = (ThreadPool<T>*)data->_arg;
while(true)//某线程循环等待处理队列任务
{
T task;
{
...//获取消费者锁
while(pool->isEmpty())
{
...//当队列空时,阻塞等待生产者交换队列
}
task = pool->getTask();//获取任务
...//解除消费者锁
}
task();//执行资源内容
}
return nullptr;
}
void swapQueue()
{
...//交换生产者消费者队列
}
public:
bool isFull()//判断队列是否已满
{
return _quP->size() == QUEUE_MAX_SIZE;
}
bool isEmpty()//判断队列是否已空
{
return _quC->size() == 0;
}
T getTask()//从阻塞队列中获取任务
{
T task = _quC->front();
_quC->pop();
return task;
}
public:
ThreadPool(size_t num = THREAD_NUM)//参数:定义线程池线程数量
:_num(num)
{
for(size_t i=1; i<=_num; i++)//创建线程
{
...//记录线程名,编号
//传递this作为线程函数参数,是因为_threadFunc为static,无法看见阻塞队列
//传递this指针给线程,调度函数时能获取threadPool对象
_threads[i - 1] = new Thread(线程名, 编号, _threadFunc, this);
}
pthread_mutex_init(&_consum, nullptr);
pthread_mutex_init(&_prod, nullptr);
pthread_cond_init(&_cond, nullptr);
}
~ThreadPool()
{
for(size_t i=0; i<_num; i++)//销毁线程
{
_threads[i]->join();
delete _threads[i];
}
pthread_mutex_destroy(&_consum);
pthread_mutex_destroy(&_prod);
pthread_cond_destroy(&_cond);
}
void pushTask(const T& task)//获取任务至阻塞队列中
{
{
...//加锁,将任务写入生产队列中,解锁
}
while(isFull())
{
...//当任务满时,获取消费锁,交换队列
pthread_cond_signal(&_cond);//向消费者发送信号
..//解锁
}
}
void start()//启动线程
{
for(size_t i = 0; i < _num; i++)
{
_threads[i]->run();//启动线程
}
}
pthread_mutex_t _consum;
pthread_mutex_t _prod;
pthread_cond_t _cond;
private:
queue<T>* _quC = new queue<T>();//消费者队列
queue<T>* _quP = new queue<T>();//生产者队列
size_t _num;//线程数量
Thread* _threads[THREAD_NUM];//线程组
};
(二).拓展学习:thrmgr线程池
参考源码:linux线程池thrmgr源码解析 - 一字千金 - 博客园 (cnblogs.com)
相比于我们自制的线程池,thrmgr提供了主动销毁线程池的函数,并能防止出现某些线程长期得不到调度的情况。当然thrmgr相比于我们自制的线程池肯定还有不少丰富,这里我们重点谈论这两个优势。
首先,自制的线程池采用析构函数销毁线程,也就是RAII技术。但是thrmgr线程池提供了thrmgr_destroy函数用来销毁线程。当然这也是因为thrmgr并不是将调度函数封装在线程池类中,而是像malloc和free一样利用函数接口的形式调用线程池。线程池结构体中记录了当前还有多少线程“存活”,主线程调用thrmgr_destroy函数后,首先向所有线程发送信号让其结束,然后主线程阻塞在当前位置。当最后一个线程结束时会向主线程发送信号停止阻塞,然后完成线程池资源的释放。
thrmgr防止线程长期得不到调度的方式也很简单,当线程没有任务时,采用pthread_cond_timedwait函数阻塞等待,该函数第三个参数为设定的timespec时间类型结构体,当检测超时后自动停止阻塞并返回特定值,根据返回值就能判断线程是否是因为超时而停止阻塞,一旦判断超时,线程跳出等待任务的循环然后结束本线程。
四.其他线程安全问题
(一).单例模式
单例模式分为懒汉和饿汉两种形式。懒汉是在使用单例是才分配空间,饿汉是在程序加载时(main函数启动之前)就分配对空间。当使用饿汉模式时因为是在main函数之前,此时只有一个线程,因此不会存在线程安全问题。但是懒汉模式下,如果是在线程调度的函数中才第一次使用单例,那么就有可能有多个线程给单例分配空间。也就是说分配的空间就是一种临界资源。因此,当使用懒汉模式时,需要在分配空间时加锁。
template <typename T>
class Singleton {
public:
static T* GetInstance() {
if (inst == nullptr) {
lock.lock(); //加锁保护
if (inst == nullptr) {//再次判断是否为空,因为分配空间后其他线程可能取得锁进入临界区
inst = new T();
}
lock.unlock();
}
return inst;
}
private:
...//禁用拷贝构造等操作
volatile static T* inst; // 设置 volatile 关键字防止编译器优化.
static std::mutex lock;//互斥锁,确保分配空间的原子性
};
(二).STL与智能指针
STL并不是线程安全的,因此需要使用者自己维护。
对于智能指针而言,unique_ptr因为只能有一个使用者,因此不用考虑线程安全问题。shared_ptr内部在改变引用计数时标准库将其实现为原子性操作,因此shared_ptr内部也是线程安全的。但是指针引用的空间在使用时并不是线程安全,因此当使用shared_ptr时建议把它按照临界资源考虑,正常上锁。
(三).读写者问题
在实际开发中,可能会有资源需要经常访问但是很少修改的情况,比如网络小说就是会有大量读者频繁,但是作者只有一位且对比阅读来讲修改次数极少。这时如果采用生产消费模型就不太合适了。此时的读者并不会修改数据,也就是说消费者之间并没有互斥关系。因此,不需要在消费者之间加互斥锁,进而支持多线程同时访问临界资源,提高程序效率。这就是读写者模型。同时默认情况下,读者的优先级高于写者,换句话说当读者线程与写者线程同时访问临界资源时,会阻塞写者直到所有读者访问完毕。linux系统提供了读写锁和对应系统调用接口,使用方式如下:
pthread_rwlock_t rwlock;//定义读写锁
pthread_rwlockattr_t attr;//定义读写锁属性(读写者优先级)
//自定义读写优先级,默认读者优先级高
int pthread_rwlockattr_setkind_np(&attr, int pref);
/*
pref 共有 3 种选择:
PTHREAD_RWLOCK_PREFER_READER_NP 默认读者优先,可能会导致写者饥饿
PTHREAD_RWLOCK_PREFER_WRITER_NP 写者优先,目前有可能与默认情况一致的BUG
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 写者优先,但写者不能递归加锁
*/
//初始化
int pthread_rwlock_init(&rwlock, &attr);
//销毁
int pthread_rwlock_destroy(&rwlock);
//加锁和解锁
int pthread_rwlock_rdlock(&rwlock);//读者加锁
int pthread_rwlock_wrlock(&rwlock);//写者加锁
int pthread_rwlock_unlock(&rwlock);//解锁
简单是稳定的前提。— Edsger Dijkstra
如有错误,敬请斧正