文章目录
- 1. 线程互斥
- 1.1 问题引入
- 1.2 线程互斥的相关概念
- 1.3 互斥量mutex
- 1.4 互斥量实现原理
- 1.5 死锁
- 2. 线程安全和可重入函数
- 3. 线程同步
- 3.1 同步概念
- 3.2 条件变量
- 4. 生产消费模型
- 4.1 基于阻塞队列的cp模型
- 4.2 基于环形队列的cp模型
- POSIX信号量
- 5. 线程池
- 5.1 互斥量RAII版本
- 5.2 线程类
- 5.3 线程池的概念
- 6. 线程安全的单例模式
- 线程池_单例模式版本
- 7. 其它问题
- 7.1 STL, 智能指针与线程安全
- 7.2 其它常见的锁
1. 线程互斥
💭上文主要介绍了多线程之间的独立资源,本文将详细介绍多线程之间的共享资源存在的问题和解决方法。
1.1 问题引入
intro
多线程共享进程地址空间,包括创建的全局变量、堆、动态库等。下面是基于全局变量实现的一个多线程抢票的demo。
#include <iostream>
#include <pthread.h>
static const int t_num = 5;//抢票线程数int tickets = 1000;//票数void *threadRoutine(void *args)
{char *name = static_cast<char *>(args);while (true){//票数大于0, 抢票if (tickets > 0){//模拟抢票花费的时间usleep(1000);std::cout << name << " get ticket number: " << tickets-- << std::endl;}//票数小于等于0, 退出else{break;}}return nullptr;
}int main()
{// 创建抢票的线程pthread_t tids[t_num];for (int i = 0; i < t_num; i++){// 给每个线程一个名字char *name = new char[64];snprintf(name, 64, "thread-%d", i + 1);pthread_create(tids + i, nullptr, threadRoutine, name);}// 等待所有线程for (int i = 0; i < t_num; i++){pthread_join(tids[i], nullptr);}return 0;
}
发现错误:线程抢到负数编号的票,为什么呢?
为了解决上面的问题,先要了解线程互斥的概念。
1.2 线程互斥的相关概念
- 临界资源:多线程共享的资源
- 临界区:多线程访问临界资源的代码片段
- 互斥:任何时刻,保证多个线程执行流中只有一个进入临界区,访问临界资源,称为互斥,通常对临界资源起到保护作用
- 原子性:访问临界资源不会被任何调度机制打断,只有完成访问和尚未访问两种状态,没有其它中间状态,称为原子性。
上述demo中,tickets就是临界资源,抢票的代码就是临界区,但访问tickets的动作并不是原子性的。在C/C++中,tickets--
在汇编层面被分为三条语句,如下:
三条汇编语句执行动作是:1.将tickets变量拷贝到eax寄存器中;2.对eax中的值减一;3.将eax中的新值拷贝到tickets的内存空间中。
由于CPU对线程的调度机制,线程在任意时刻都有可能都调度。因此,非原子性的操作,对共享变量来说是不安全的,因为线程可能会在操作途中被调度,而由其它线程访问共享变量,造成变量数据被覆盖、判断条件误入等一系列问题。
💭对于上述demo,当tickets==1
时,可能存在以下情况:线程1判断票数大于0,进入抢票代码区,却在usleep时被调度了,切换为线程2(此时tickets的值依然是1,因为线程1并未修改它),线程2判断票数依然大于0,进入抢票代码区,修改tickets为0,然后调度回线程1,线程1修改tickets为-1,产生错误。这就是为什么会出现线程抢到负数编号的票的情况。当然,在tickets--
的时候调度,也会导致一些错误,如下:
要想解决上面的问题,就必须保证多线程之间是互斥的,也就是保证访问临界资源的原子性,即一个线程在临界区中,不允许其它线程进入临界区。要想完成这些,先要认识一个概念——互斥量(又称互斥锁),以及互斥锁操作的一些接口。
1.3 互斥量mutex
要想实现互斥,我们需要一把锁,进入临界区时申请锁,锁只有一个,申请不到就不得进入临界区,退出临界区时归还锁,任意时刻只能让单个线程持有锁,Linux中提供的这把锁称为互斥量mutex。
💡理解:对于临界资源的保护,实际上是对临界区的进入作限制。
phtread_mutex_t
phtread库中定义的互斥锁类型,本质是一个结构体,内含一个整型变量lock。可以简单理解为,当没有线程持有锁时,lock为1,即可以获得。有线程持有锁时,lock为0,即无法获取。
pthread_mutex_init
初始化互斥锁对象
pthread_mutex_destroy
销毁一个已经初始化的互斥锁对象,并释放相关的资源。在不再需要互斥锁时,应该调用这个函数来避免资源泄漏。
销毁互斥量需要注意:
使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
不要销毁一个已经加锁的互斥量
已经销毁的互斥量,要确保后面不会有线程再尝试加锁
//mutex为phtread_mutex_t类型对象的地址, attr为锁的属性,一般传nullptr即可
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr); int pthread_mutex_destroy(pthread_mutex_t *mutex);//全局的互斥锁可以用宏的方式初始化,程序退出会自动销毁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;RETURN VALUEIf successful, the pthread_mutex_destroy() and pthread_mutex_init() functions shall return zero; otherwise, an error number shall be returned to indicate the error.
pthread_mutex_lock
申请互斥锁,申请失败时,意味着其它线程正在持有锁,当前线程则会阻塞等待(执行流被挂起),等待互斥量解锁。
pthread_mutex_unlock
释放互斥锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);RETURN VALUEIf successful, the pthread_mutex_lock() and pthread_mutex_unlock() functions shall return zero; otherwise, an error number shall be returned to indicate the error.
⭕对抢票问题加入互斥机制
#include <iostream>
#include <unistd.h>
#include <pthread.h>
static const int t_num = 5; // 抢票线程数int tickets = 1000; // 票数pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 定义互斥锁void *threadRoutine(void *args)
{char *name = static_cast<char *>(args);while (true){pthread_mutex_lock(&mutex);// 票数大于0, 抢票if (tickets > 0){// 模拟抢票花费的时间usleep(1000);std::cout << name << " get ticket number: " << tickets-- << std::endl;pthread_mutex_unlock(&mutex);}// 票数小于等于0, 退出else{pthread_mutex_unlock(&mutex);break;}}return nullptr;
}int main()
{// 创建抢票的线程pthread_t tids[t_num];for (int i = 0; i < t_num; i++){// 给每个线程一个名字char *name = new char[64];snprintf(name, 64, "thread-%d", i + 1);pthread_create(tids + i, nullptr, threadRoutine, name);}// 等待所有线程for (int i = 0; i < t_num; i++){pthread_join(tids[i], nullptr);}return 0;
}
注意💨
-
lock和unlock之间的代码称为临界区,要保证临界区是最小的,临界区中尽可能不包含对非临界资源的访问,否则会导致性能降低,因为非临界资源是可以多线程并发访问的。
-
多个线程访问同一份临界资源,要想实现互斥,它们必须使用同一把锁。
现在可以正常抢票了。
1.4 互斥量实现原理
我们说,对临界资源(共享资源)进行互斥保护,需要用到互斥量。但是,多个线程申请同一个互斥量,互斥量不也是共享资源吗?互斥量需要被互斥保护吗?答案是不需要!
🔎为了实现互斥锁操作,大多数体系结构都提供了swap
或exchange
指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。 因此对于互斥量的操作本身就是原子的,无需再被保护。下面看一下lock和unlock的伪代码,帮助理解。
1.5 死锁
死锁(Deadlock)指的是多个线程在竞争有限的资源时,由于彼此之间的互斥和等待条件而陷入无限循环的状态,无法继续执行下去,导致系统停滞不前。
🔒死锁通常涉及以下四个必要条件,只有它们同时成立时,死锁才会发生。
- 互斥条件:多个线程之间是互斥的,即只能有一个执行流访问共享资源。
- 占有与等待条件:线程至少持有一份资源,并且在等待获取其它资源时不释放自身占有的资源。
- 不剥夺条件:线程占有的资源不会被强行剥夺(如被系统回收、被其它线程抢夺),只得由线程释放后才可被其它执行主体获取。
- 循环等待条件:若干个线程形成了头尾相接的循环等待资源的关系,每个线程都在等待另一个线程的资源。
如下面伪代码,就发生了死锁问题。
🔓避免死锁的方法:
- 破坏四个死锁的必要条件
- 确保多个线程按照相同的顺序和规则请求和释放锁
- 保证锁会被释放
- 资源一次性分配
2. 线程安全和可重入函数
-
概念
线程安全: 多个线程访问同一段代码时,不会产生不同的或意料之外的结果。如:多线程对同一个全局变量或静态变量进行操作,不加以锁的保护,就是线程不安全的。
可重入函数: 一个函数被多个线程同时调用(即一个线程还没执行完该函数,另一个线程就进入该函数了)的情况下,运行结果不会产生任何问题,这样的函数称之为可重入函数,反之称为不可重入函数。
-
线程不安全的常见场景
- 不保护共享资源的函数
- 返回指向静态/全局变量指针或引用的函数
- 调用线程不安全函数的函数
-
线程安全的常见场景
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
- 类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
-
常见的不可重入函数
- 调用了malloc/free函数,因为malloc底层是用全局链表管理堆的
- 调用了标准IO库函数,因为标准IO库的很多实现都是以不可重入的方式使用全局数据结构的
- 可重入函数体使用了静态的数据结构
-
常见的可重入函数
- 不使用全局变量或静态变量(包括修改和返回),所有数据由调用者传参提供
- 不调用malloc/free函数
- 不调用不可重入函数
- 使用私有数据,或者通过制作全局数据的本地拷贝来保护全局数据
线程安全 vs 可重入函数
- 线程安全不一定是可重入,但可重入函数一定是线程安全的。线程安全是线程之间的概念,可重入只是函数的一个性质。线程安全的两个线程不一定调用同一个函数,更别谈函数可不可重入了。
- 可重入函数只是线程安全函数的一种,线程安全函数就是保证多线程同时调用时不会出现错误的函数。
- 不可重入函数一定是线程不安全的。
3. 线程同步
3.1 同步概念
场景:如果一个线程是循环式地访问临界资源,那么需要不断申请锁和释放锁。如果临界资源长时间未就绪,线程进入临界区后无法访问临界资源,即申请到锁就要释放了。该线程就这么一直申请锁释放锁,持有锁期间啥也不干,会造成其他线程的饥饿问题(即其他线程长时间无法申请到锁,一直处于等待状态)。为解决这种问题,当一个线程发现临界资源未就绪时,应先让其它线程对临界资源进行修改,使其就绪,再访问。这说明线程对临界资源的访问需按照一定的顺序。
在保证数据安全的前提下,让多线程按照某种顺序访问临界资源,防止出现线程饥饿问题,称为同步。
3.2 条件变量
🔎使用条件变量可以解决上述问题。条件变量维护了一个队列。当线程发现临界资源未准备就绪时,先到条件变量的队列中等待,此时会释放锁,让其他线程申请到锁并访问临界区。后面当临界资源就绪时(由其它线程改变临界资源),可以唤醒条件变量队列中的线程,此时该线程会重新申请锁,然后从休眠处继续向后运行。
pthread_cond_t
是条件变量的类型,操作函数和pthread_mutex_t
的类似,简单介绍。
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr); // 初始化条件变量
int pthread_cond_destroy(pthread_cond_t *cond); // 销毁条件变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; // 静态条件变量的初始化
pthread_cond_wait
让调用线程在cond条件变量上等待,配合mutex互斥锁使用,内部会先释放。代码层面表现为线程阻塞在调用该函数处。
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
下面一对函数,可从cond条件变量上唤醒线程,并让线程去申请刚刚释放的锁。若某线程唤醒后成功申请到锁,则从pthread_cond_wait
处继续往下运行。
int pthread_cond_broadcast(pthread_cond_t *cond); // 唤醒全部
int pthread_cond_signal(pthread_cond_t *cond); // 唤醒队头一个线程
⭕注意
-
条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有另一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足(资源就绪),并且友好的通知等待在条件变量上的线程。
-
条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据
4. 生产消费模型
💭生产消费模型(consumer and productor,简称cp模型)是多线程编程的一种应用场景。由一组线程扮演生产者,负责生产数据,由另外一组线程扮演消费者,负责处理数据。因为生产消费二者的强耦合关系,所有需要一个中间场所用以解耦,提高模型效率,合理协调生产者与消费者的工作步调。通俗地说,就是生产者和消费者不直接通讯,生产者将生产完的数据直接输入中间场所,消费者需要数据时直接从中间场所拿,二者都不关心对方的工作状态,只关心中间场所是否满足自己的操作条件。中间场所充当一个缓冲区的作用。
生产消费模型涉及三个角色:生产者、消费者、中间场所。 可以类比超市的场景,生产者是给超市供货的工厂,消费者就是到超市消费的居民,而中间场所当然就是超市本身。工厂只关心超市还有没有空的货架可以进货,而居民只关心超市有没有自己需要的商品,他们的状态互不影响。
⭕构建生产消费模型,需要关注以下内容:
- 三对关系:生产者与生产者、消费者与消费者、生产者与消费者
- 两个角色:生产者和消费者
- 一个缓冲区:可以是不同的容器
4.1 基于阻塞队列的cp模型
💭阻塞队列(Blocking Queue)是一种常用于实现生产者和消费者模型的数据结构。阻塞队列与普通队列的区别在于:生产者向阻塞队列写入数据时,若阻塞队列为满,则不会再写,阻塞等待消费者读取数据使得队列中有空位可写。同理,消费者从阻塞队列读取数据时,若阻塞队列为空,则不会再读,阻塞等待生产者生产数据使得队列中有数据可读。(与进程间通信中,管道自带的同步机制类似)
该模型中,临界资源自然是blockQueue。生产者向队尾传入数据时,若有多个生产者同时传入,可能会引发线程安全问题,因此生产者之间应该是互斥关系,以保护临界资源。同理,消费者之间也应该是互斥关系。对于生产者和消费者之间的关系,该模型中,将其定义为既有互斥关系又有同步关系:生产者和消费者都视阻塞队列为一个整体,即生产的时候不能消费,消费的时候不能生产,这是互斥关系,而同步关系保证了阻塞队列的与普通队列的区别。也就是说,任意时刻,无论生产者消费者,都只有一个线程在访问阻塞队列。
💬代码实现
//blockQueue.hpp 阻塞队列的实现
#pragma once
#include <iostream>
#include <queue>
#include <pthread.h>const int max_cap = 5;template <class T>
class blockQueue
{
public:blockQueue(int cap = max_cap): _cap(cap){// 初始化互斥量和条件变量pthread_mutex_init(&_mutex, nullptr);pthread_cond_init(&_consumer_cond, nullptr);pthread_cond_init(&_productor_cond, nullptr);}~blockQueue(){// 销毁互斥量和条件变量pthread_mutex_destroy(&_mutex);pthread_cond_destroy(&_consumer_cond);pthread_cond_destroy(&_productor_cond);}bool isFull() { return _q.size() == _cap; }bool isEmpty() { return _q.empty(); }// productor callvoid push(const T &input){pthread_mutex_lock(&_mutex);// 判断资源是否就绪while (isFull()) // 阻塞队列为满, 不push, 等待消费者消费{pthread_cond_wait(&_productor_cond, &_mutex);}// 生产数据_q.push(input);// 此时阻塞队列中至少有一个数据,唤醒消费者pthread_cond_signal(&_consumer_cond);pthread_mutex_unlock(&_mutex);}// consumer callvoid pop(T *output){pthread_mutex_lock(&_mutex);// 判断资源是否就绪while (isEmpty()) // 阻塞队列为空,不pop,等待生产者生产{pthread_cond_wait(&_consumer_cond, &_mutex);}// 消费数据*output = _q.front();_q.pop();// 此时阻塞队列中至少有一个空位, 唤醒生产者pthread_cond_signal(&_productor_cond);pthread_mutex_unlock(&_mutex);}private:std::queue<T> _q; // 底层封装STL队列int _cap; // 阻塞队列的最大容量// 因为生产-生产, 消费-消费, 生产-消费的都是访问同一个缓冲区, 所以用同一把锁pthread_mutex_t _mutex;// 生产者和消费者的等待条件不同, 因此条件变量需各有一个pthread_cond_t _consumer_cond;pthread_cond_t _productor_cond;
};
🔎为什么判断资源是否就绪用while循环判断而不是if判断?以消费者调用pop为例, 生产者调用push同理
在条件变量上wait的线程被唤醒后,并不是直接向下条语句运行,而是还要在wait内部申请锁。假设有多个消费者,开始时阻塞队列为空,消费者们等待。当生产者开始生产时,消费者被唤醒,并申请锁。用if判断可能发生的情况是:多个消费者被唤醒(因为生产者生产了多个数据),开始竞争锁,有一个消费者竞争锁成功了,继续往下运行,把阻塞队列中的数据都pop了,那么下一个竞争到锁的消费者面对的是空队列,它却并不知情(因为if判断只会判断一次),继续向下pop空队列,引发错误。因此要用while循环判断生产者/消费者操作的缓冲区是否满足条件。
//单生产-单消费模型
#include "blockQueue.hpp"
#include <unistd.h>
#include <ctime>
#include <cstdlib>static const int p_num = 5;
static const int c_num = 3;void *productor(void *args)
{blockQueue<int> *bq = static_cast<blockQueue<int> *>(args);srand(time(nullptr) ^ pthread_self());while (true){sleep(1);// 生产数据int n = rand() % 100 + 1;// 输入数据bq->push(n);std::cout << "productor: " << n << std::endl;}return nullptr;
}void *consumer(void *args)
{blockQueue<int> *bq = static_cast<blockQueue<int> *>(args);while (true){// 输出数据int n = 0;bq->pop(&n);// 处理(消费)数据n *= 10;std::cout << "consumer: " << n << std::endl;}return nullptr;
}int main()
{blockQueue<int> bq;pthread_t p, c;pthread_create(&p, nullptr, productor, &bq);pthread_create(&c, nullptr, consumer, &bq);pthread_join(p, nullptr);pthread_join(c, nullptr);return 0;
}
做一个小测试,以便观察阻塞队列cp模型的互斥与同步现象:生产者每次生产前先休眠一秒,休眠时阻塞队列为空,消费者等待生产者生产数据,生产者的步调慢于消费者,生产者生产一个,消费者就拿走并处理一个。
事实上,cp模型更有实际应用价值的是多生产-多消费模型,而不是上面简单的一对一的关系。另外,生产者生产数据,消费者消费数据,这里的“数据”也并非只是一个整数那么简单,更多的情况是某种任务,由生产者派发,消费者处理。
以上两点,凸显了cp模型的一大优点:忙闲不均。多对多的体系中,对于缓冲区的访问是互斥的,因此不免有一些生产者或消费者在阻塞等待访问缓冲区,而生产数据和处理数据的动作对于生产者和消费者来说却是并发的,也就是说,在这个体系中,有的在生产数据,有的在等待缓冲区资源,有的在消费数据,各司其职,忙闲不均,提高整体效率。
处理计算任务的多生产-多消费模型
//main.cc
#include "blockQueue.hpp"
#include "Task.hpp"
#include <unistd.h>
#include <ctime>
#include <cstdlib>static const int p_num = 5;
static const int c_num = 3;void *productor(void *args)
{blockQueue<Task> *bq = static_cast<blockQueue<Task> *>(args);srand(time(nullptr) ^ pthread_self());while (true){sleep(1);// 生产计算任务int x = rand() % 100 + 1;int y = rand() % 100 + 1;char opt = Task::getOperator(x+y);Task t(x,y,opt);// 输入计算任务bq->push(t);std::cout << "productor: " << t.equation() << "?" << std::endl;}return nullptr;
}void *consumer(void *args)
{blockQueue<Task> *bq = static_cast<blockQueue<Task> *>(args);while (true){// 输出任务Task t;bq->pop(&t);// 处理计算t();}return nullptr;
}int main()
{blockQueue<Task> bq;// n-n cppthread_t p[p_num]; // 生产者pthread_t c[c_num]; // 消费者for (int i = 0; i < p_num; i++){pthread_create(p + i, nullptr, productor, &bq);}for (int i = 0; i < c_num; i++){pthread_create(c + i, nullptr, consumer, &bq);}for (int i = 0; i < p_num; i++){pthread_join(p[i], nullptr);}for (int i = 0; i < c_num; i++){pthread_join(c[i], nullptr);}return 0;
}
//Task.hpp
#pragma once
#include <iostream>
#include <string>
#include <cstring>class Task
{
public:Task() = default;Task(int x, int y, char opt) : _x(x), _y(y), _opt(opt), _ret(0), _exit_code(0){}std::string equation(){return std::to_string(_x) + _opt + std::to_string(_y) + '=';}int getResult() const{return _ret;}static char getOperator(int i){return opts[i % strlen(opts)];}void operator()(){switch (_opt){case '+':_ret = _x + _y;break;case '-':_ret = _x - _y;break;case '*':_ret = _x * _y;break;case '/':{if (_y != 0)_ret = _x / _y;else_exit_code = -1;}break;case '%':{if (_y != 0)_ret = _x % _y;else_exit_code = -1;}break;default:break;}}private:int _x;int _y;char _opt;int _ret;int _exit_code;static const char *opts;
};const char *Task::opts = "+-*/%";
4.2 基于环形队列的cp模型
环形队列采用数组模拟,以模运算的方式模拟环状特性
在设计基于阻塞队列(bq)的cp模型时,我们将bq看作一个整体,即在生产者和消费者中,任意时刻都只能有一个线程去访问bq,它们是互斥的,这在可能会影响效率。某些场景下,我们希望生产者和消费者能够在满足某种条件的情况下,并发地访问缓冲区。
⭕在环形队列中,当head和tail指向不同空间时,它们同时访问环形队列是互不影响的(一个读,一个写)。只有指向同一块空间时,由于不能同时读写数据,所以才不能同时访问。什么时候head和tail会指向同个空间呢?当环形队列为空或为满时。
将环形队列运用于cp模型中,自然地,head代表消费者,tail代表生产者。我们可以将整个环形队列分成两份资源:空间资源和数据资源。 生产者只关心空间资源,即队列中是否还有空位;消费者只关心数据资源,即队列中是否有数据可读取。当两份资源都不为0时,生产者和消费者可以并发地访问环形队列。当空间资源为0时,对应环形队列为满,生产者无法写入,直到消费者读取数据,空间资源增多。同理,当数据资源为0时,对应环形队列为空,消费者无法读取,直到生产者写入数据,数据资源增多。这种同步机制我们可以通过POSIX信号量来实现。
POSIX信号量
POSIX信号量是一种用于实现多个进程或多之间的同步和互斥操作。POSIX信号量提供了一种机制,允许进程在共享资源上进行协调,以确保它们不会同时访问关键资源,从而避免竞态条件和数据损坏。POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。
头文件:
#include <semaphore.h>
初始化信号量:
int sem_init(sem_t *sem, int pshared, unsigned int value);
// pshared:0表示线程间共享,非0表示进程间共享
// value: 信号量初始值
// 返回值:成功返回0,失败返回-1
销毁信号量:
int sem_destroy(sem_t *sem)
// 返回值:成功返回0,失败返回-1
等待信号量(P操作):
int sem_wait(sem_t *sem);
// 将sem的值减1, 若sem的值为0则阻塞等待
发布信号量(V操作):
int sem_post(sem_t *sem);
// 将sem的值加1,并唤醒等待该信号量的线程
💬代码实现
实现不同cp模型关键在于维护生产-生产,消费-消费,生产-消费三对关系。在该模型中,生产-生产是互斥关系,消费-消费是互斥关系,生产-消费是互斥同步的,但是在某些情况下可以并发访问临界资源。
#pragma once
#include <iostream>
#include <vector>
#include <semaphore.h>
#include <pthread.h>static const int g_size = 5;template <class T>
class cirQueue
{
public:// size: 环形队列的最大容量cirQueue(int size = g_size) : _cq(size), _comsumer(0), _productor(0){// 初始化信号量// 开始时,环形队列为空,空间资源设为size,数据资源为0sem_init(&_sem_room, 0, size);sem_init(&_sem_data, 0, 0);// 初始化互斥量pthread_mutex_init(&_c_mutex, nullptr);pthread_mutex_init(&_p_mutex, nullptr);}~cirQueue(){// 销毁信号量sem_destroy(&_sem_room);sem_destroy(&_sem_data);// 销毁互斥量pthread_mutex_destroy(&_c_mutex);pthread_mutex_destroy(&_p_mutex);}// productor callvoid push(const T &in){// 申请空间信号量sem_wait(&_sem_room);// 输入数据(占用空间)// 保证生产者——生产者的互斥pthread_mutex_lock(&_p_mutex);_cq[_productor] = in;_productor = (_productor + 1) % _cq.size();pthread_mutex_unlock(&_p_mutex);// 增加数据信号量sem_post(&_sem_data);}// consumer callvoid pop(T *out){// 申请数据信号量sem_wait(&_sem_data);// 输出数据(释放空间)// 保证消费者——消费者的互斥pthread_mutex_lock(&_c_mutex);*out = _cq[_comsumer];_comsumer = (_comsumer + 1) % _cq.size();pthread_mutex_unlock(&_c_mutex);// 增加空间信号量sem_post(&_sem_room);}private:std::vector<T> _cq;int _comsumer;int _productor;pthread_mutex_t _c_mutex; // 消费者互斥量pthread_mutex_t _p_mutex; // 生产者互斥量sem_t _sem_room; // 空间资源信号量sem_t _sem_data; // 数据资源信号量
};
一些细节:
- 若等待信号量成功,也就意味着资源准备就绪,允许访问,无需再判断资源是否就绪。
- 等待信号量成功后,可能会因为线程调度而在访问临界资源前被切走。这没有任何问题,其它线程也需要申请信号量后再访问临界资源。信号量好比一张入场券,拿到之后,即使你没有第一时间入场,你的座位始终为你留着。
5. 线程池
线程池(Thread Pool)是多线程的一大应用场景,可以将上面学到的东西融会贯通起来。实现线程池之前,先简单封装几个小组件。
5.1 互斥量RAII版本
有时候加锁之后可能忘了解锁,我们需要一个与智能指针作用相同的东西,用于自动申请锁和释放锁。
//lockGuard.hpp
#pragma once
#include <pthread.h>class lockGuard
{
public://lockGuard类,构造时lock,析构时unlock,互斥量在类对象的作用域中有效lockGuard(pthread_mutex_t *pmutex):_pmutex(pmutex){pthread_mutex_lock(_pmutex);}~lockGuard(){pthread_mutex_unlock(_pmutex);}private:pthread_mutex_t *_pmutex;
};
5.2 线程类
线程池中有很多个工作线程,要寻求一种更简单的对线程的管理方法,可以将线程封装成一个类
//Thread.hpp
#pragma once
#include <iostream>
#include <cstdio>
#include <pthread.h>
#include <functional>
#include <unistd.h>
#include <cstdlib>using namespace std;class Thread
{
public:typedef void *(*prun)(void *);typedef enum{NEW,RUNNING,EXITED} status; // 线程运行的状态public:Thread() = default;// 创建线程时,传入线程编号、线程函数以及线程函数的参数// 注意:构造函数并没有在内核创建真正的线程,只是在用户层面创建一个线程Thread(int index, prun func, void *arg) : _tid(0), _index(index), _func(func), _arg(arg), _stat(NEW){char name[64] = {0};snprintf(name, sizeof(name), "thread-%d", index);_name = name;}~Thread(){}pthread_t getTid(){return _tid;}string getName(){return _name;}void operator()() // 执行线程函数{_func(_arg);}static void *runHelper(void *args){Thread *tp = static_cast<Thread *>(args);(*tp)();return nullptr;}// 让线程开始运行,真正在内核创建线程void run(){int ret = pthread_create(&_tid, nullptr, runHelper, this);if (ret != 0)exit(1);_stat = RUNNING;}void join(){int ret = pthread_join(_tid, nullptr);if (ret != 0)exit(1);_stat = EXITED;}private:pthread_t _tid;string _name; // 线程名称int _index; // 线程编号status _stat; // 线程状态prun _func; // 线程执行函数的函数指针void *_arg; // 线程执行函数的参数
};
5.3 线程池的概念
💭线程池是多线程的一种使用模式。线程池维护着多个工作线程,等待监督管理者分配可并发执行的任务,避免了频繁创建和销毁线程的开销,同时也限制了并发工作线程的数量,防止资源耗尽和系统过载。
线程池通常包含以下几个核心组件和概念:
-
线程池管理器(Thread Pool Manager):线程池管理器负责创建、初始化和维护线程池。它决定了线程池的大小、任务队列的类型以及其他配置参数。
-
任务队列(Task Queue):任务队列是线程池用于存储待执行任务的数据结构。当一个任务被提交到线程池时,它会被放入任务队列中等待执行。线程池中的空闲线程会从任务队列中取出任务并执行它们。
-
工作线程(Worker Threads):工作线程是线程池中实际执行任务的线程。线程池会预先创建一组工作线程,并在需要时将任务分配给它们执行。
线程池的工作流程如下:
- 应用程序将任务提交给线程池管理器。
- 线程池管理器将任务放入任务队列中。
- 线程池中的工作线程从任务队列中取出任务并执行它们。
- 执行完任务的线程继续在线程池中等待下一个任务。
线程池的应用场景:
- 需要大量的线程来完成任务,且完成任务的时间比较短。 比如WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
- 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
- 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,短时间内产生大量线程可能使内存到达极限出现错误。
💬代码实现
#pragma once
#include <iostream>
#include <queue>
#include <vector>
#include <pthread.h>
#include "Thread.hpp"
#include "Task.hpp"
#include "lockGuard.hpp"static const int max_cap = 10;template <class T>
class threadPool
{
public:threadPool(const int cap = max_cap) : _threads(cap), _cap(cap){// 创建线程, 所有线程处于等待任务的状态for (int i = 0; i < _cap; i++){// 因为threadRoutine是静态成员函数,所以这里要传入this指针,才能使工作线程找到当前的线程池_threads[i] = Thread(i + 1, threadRoutine, this);}pthread_mutex_init(&_mutex, nullptr);pthread_cond_init(&_cond, nullptr);}~threadPool(){// 回收线程for (auto &thr : _threads){thr.join();}pthread_mutex_destroy(&_mutex);pthread_cond_destroy(&_cond);}// 启动线程池, 所有线程开始待命void start(){for (auto &thr : _threads){thr.run();}}void pushTask(const T &in){lockGuard lg(&_mutex);_tasks.push(in);pthread_cond_signal(&_cond);}private:// 工作线程的执行函数,线程池内部私有访问// 线程的执行函数默认接口指针类型为void*(*f)(void*),因此要设为static,除去this指针static void *threadRoutine(void *args){threadPool *tp = static_cast<threadPool *>(args);while (true){// 获取任务, 任务队列如果为空,需要等待T t = tp->popTask();// 处理任务t();}return nullptr;}// 从任务队列获取任务,线程池内部私有访问T popTask(){T out;// 临界区{lockGuard lg(&_mutex);// 任务队列为空时,等待while (_tasks.empty()){pthread_cond_wait(&_cond, &_mutex);}out = _tasks.front();_tasks.pop();}return out;}private:std::queue<T> _tasks; // 任务队列(大小无限制,采用stl中的自动扩容)std::vector<Thread> _threads; // 工作线程// 工作线程访问任务队列的互斥量和条件变量pthread_mutex_t _mutex;pthread_cond_t _cond;int _cap; // 工作线程数量的最大值
};
测试用例:
#include "threadPool.hpp"
#include <ctime>
#include <cstdlib>
#include <unistd.h>int main()
{// 创建线程池threadPool<Task> tp;// 启动线程池tp.start();srand(time(nullptr) ^ getpid());while (true){// 生产计算任务int x = rand() % 100 + 1;int y = rand() % 100 + 1;char opt = Task::getOperator(x + y);Task t(x, y, opt);// 输入计算任务tp.pushTask(t);}return 0;
}
6. 线程安全的单例模式
单例模式(Singleton Pattern)是一种设计模式,确保一个类只有一个实例对象,并提供一个静态的访问接口来获取该实例。 这意味着无论在应用程序中的任何地方调用获取单例实例的方法,都会返回同一个对象实例。单例模式的目标是限制一个类的实例化,并确保该实例可以在整个应用程序中共享和访问。
单例模式可以通过饿汉方式和懒汉方式实现:
- 饿汉方式:程序启动时,单例模式的类实例对象就存在了,这个对象一般是全局属性的,且该进程中只有一个。
- 懒汉方式:第一次获取实例时才会创建该实例对象。
通常使用懒汉方式实现单例模式,这样做可以节省资源,避免提前的空间浪费,只要实例对象要被使用了,才会创建。
线程池_单例模式版本
线程池是一个较为复杂的数据结构,管理难度大,占用资源多,因此可以使用单例模式实现线程池,保证同一进程中只有一个线程池,节省资源,方便管理。同时使用懒汉方式的单例模式来改良线程池,首次使用线程池时再创建其实例对象,避免了空间浪费。
设计单例模式的核心步骤:
- 禁止用户使用类的构造函数、拷贝构造、赋值重载。
- 以类的静态成员变量,存储唯一的实例(对象或指针)
- 提供静态的访问接口get_instance,以获取单例对象
关于
get_instance
函数的两个小细节
- 该函数可能会被多个线程调用,多个线程都想获取同一个单例对象,那么这个单例对象是临界资源,需要被保护,保证线程安全。否则可能会出现创建个单例对象的情况。
- 见下面代码中,
get_instance
函数中的注释。
#pragma once
#include <iostream>
#include <queue>
#include <vector>
#include <pthread.h>
#include "Thread.hpp"
#include "Task.hpp"
#include "lockGuard.hpp"static const int max_cap = 10;template <class T>
class threadPool
{typedef threadPool<T> Self;public:static Self *get_instance(){if (_tp == nullptr){// 只有第一次访问单例时会进入此处// 那么绝大多数情况都是不进入的// 每次都要竞争锁再判断是否进入区块的话, 效率低// 利用双判断, 解决问题lockGuard lg(&_tp_mutex);if (_tp == nullptr){_tp = new Self;_tp->start();}}return _tp;}~threadPool(){// 回收线程for (auto &thr : _threads){thr.join();}pthread_mutex_destroy(&_mutex);pthread_cond_destroy(&_cond);}// 启动线程池, 所有线程开始待命void start(){for (auto &thr : _threads){thr.run();}}void pushTask(const T &in){lockGuard lg(&_mutex);_tasks.push(in);pthread_cond_signal(&_cond);}private:// 构造函数不能删除,设为私有,但仅会被get_instance调用一次threadPool(const int cap = max_cap) : _threads(cap), _cap(cap){// 创建线程, 所有线程处于等待任务的状态for (int i = 0; i < _cap; i++){// 因为threadRoutine是静态成员函数,所以这里要传入this指针,才能使工作线程找到当前的线程池_threads[i] = Thread(i + 1, threadRoutine, this);}pthread_mutex_init(&_mutex, nullptr);pthread_cond_init(&_cond, nullptr);}// 默认拷贝构造和赋值重载声明为delete删除threadPool(const Self &tp) = delete;void operator=(const Self &tp) = delete;// 工作线程的执行函数,线程池内部私有访问// 线程的执行函数默认接口指针类型为void*(*f)(void*),因此要设为static,除去this指针static void *threadRoutine(void *args){threadPool *tp = static_cast<threadPool *>(args);while (true){// 获取任务, 任务队列如果为空,需要等待T t = tp->popTask();// 处理任务t();std::cout << "result: " << t.equation() << " " << t.getResult() << std::endl;}return nullptr;}// 从任务队列获取任务,线程池内部私有访问T popTask(){T out;// 临界区{lockGuard lg(&_mutex);// 任务队列为空时,等待while (_tasks.empty()){pthread_cond_wait(&_cond, &_mutex);}out = _tasks.front();_tasks.pop();}return out;}private:std::queue<T> _tasks; // 任务队列(大小无限制,采用stl中的自动扩容)std::vector<Thread> _threads; // 工作线程// 工作线程访问任务队列的互斥量和条件变量pthread_mutex_t _mutex;pthread_cond_t _cond;int _cap; // 工作线程数量的最大值static Self *_tp; // 指向唯一的实例对象static pthread_mutex_t _tp_mutex; // 保护_tp的互斥量
};template <class T>
threadPool<T> *threadPool<T>::_tp = nullptr;template <class T>
pthread_mutex_t threadPool<T>::_tp_mutex = PTHREAD_MUTEX_INITIALIZER;
🔎双判断可以避免get_instance中多线程不必要的竞争锁,思路如下:
7. 其它问题
7.1 STL, 智能指针与线程安全
-
STL中的容器并不是线程安全的。因为STL的设计初衷就是挖掘效率之极限,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。而且对于不同的容器,加锁方式的不同,,性能可能也不同(例如hash表的锁表和锁桶).因此STL默认不是线程安全。如果需要在多线程环境下使用, 往往需要调用者自行保证线程安全。
-
智能指针,对于 unique_ptr,由于只是在当前代码块范围内生效,因此不涉及线程安全问题。对于 shared_ptr,多个对象需要共用一个引用计数变量,所以会存在线程安全问题。但是标准库实现的时候考虑到了这个问题,基于原子操作(CAS)的方式保证shared_ptr能够高效且原子地操作引用计数。
7.2 其它常见的锁
- 悲观锁: 在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起
- 乐观锁: 每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。(CAS操作:当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。
- 自旋锁: 当一个线程尝试获取自旋锁时,如果锁已经被其他线程占用,该线程不会被阻塞,而是会在一个循环中不断检查锁是否可用。这种自旋等待的行为可以减少线程上下文切换的开销,因为线程不会被挂起和唤醒,但也可能导致CPU资源的浪费。
ENDING…