多线程开发之线程基础(实现线程池必备知识)
前言
基础知识
我们在用C++进行多线程编程的时候,可以使用内核的同步原语进行自己的封装,也可以使用C++11已经封装好的,因为我觉得有必要了解一些底层的东西,所以这两个内容我都会讲到。
《Linux多线程编程》中提到的线程同步四项原则:
- 首要原则是尽量最低限度的共享原则,减少同步的场合。一个对象能不暴露给别的线程就不要暴露;如果要暴露,优先考虑
immutable
对象,实在不行才暴露可修改的对象,并且用同步措施来充分保护它。- 其次是使用高级的并发编程控件,比如
TaskQueue
,Producer-Consumer Queue
,CountDownLatch
等等。- 最后不得已必须使用底层同步原语时,只用非递归的互斥量和条件变量,慎用读写锁,不要用信号量。
- 除了使用atomic整数之外,不要自己编写
lock-free
的代码,也不要用内核级同步原语。
内核-同步原语
同步原语包括互斥量、条件变量、读写锁、信号量、文件互斥,但是在这里我只介绍互斥量和条件变量。
1. 互斥量
互斥量保护了临界区,任何一个时刻最多只能有一个线程在此使用临界资源,这样就做到了保护临界区。
在使用互斥锁的时候,需要注意一些事项(均出自《Linux多线程编程》)
- 使用RAII手法封装mutex的创建、销毁、加锁、解锁的四个操作。
- 不手工的调用
lock和unlock
函数,一切交给栈上的Guard对象的构造和析构函数负责,Guard对象的生命期正好等于临界区。避免在一个函数里面加锁,在另一个函数里面解锁,也避免在不同的分支加锁和解锁。- 在每次构造Guard对象的时候,思考已经持有的锁,防止因为加锁的顺序的不同而导致死锁。
创建和销毁
// 初始化
int pthread_mutex_init (pthread_mutex_t *__mutex, __const pthread_mutexattr_t *__mutexattr);
// 销毁
int pthread_mutex_destroy (pthread_mutex_t *__mutex);
在初始化中的第二个参数是设置线程的属性,如果默认则设置为NULL即可。
设置属性
// 初始化互斥量属性对象
int pthread_mutexattr_init (pthread_mutexattr_t *__attr);
// 销毁互斥量属性对象
int pthread_mutexattr_destroy (pthread_mutexattr_t *__attr);
可以发现属性和互斥量的创建和销毁是类似的。
使用
// 阻塞到该互斥量解锁为止
int pthread_mutex_lock(pthread_mutex_t *mutex);
// 不会阻塞,互斥量被占用则返回EBUSY的错误
int pthread_mutex_trylock(pthread_mutex_t *mutex);
// 解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
封装
class TMutex {
public:
TMutex();
~TMutex();
void lock();
void unlock();
inline pthread_mutex_t* getMutext() { return &_mutex; }
private:
pthread_mutex_t _mutex; // 不可以直接操作
};
TMutex::TMutex() {
pthread_mutex_init(&this->_mutex, NULL);
}
TMutex::~TMutex() {
pthread_mutex_destroy(&this->_mutex);
}
void TMutex::lock() {
pthread_mutex_lock(&this->_mutex);
}
void TMutex::unlock() {
pthread_mutex_unlock(&this->_mutex);
}
class TMutexGuard {
public:
explicit TMutexGuard(const TMutex& mutex) : _mutex(mutex) {
_mutex.lock();
}
~TMutexGuard() {
_mutex.unlock();
};
private:
TMutex _mutex;
};
在使用的时候要通过Guard去操作mutex。类似于这样:
TMutexGuard gaurd(this->_mutex);
注意
死锁通常发生在多个锁相互依赖的时候,比如说,有两个锁A和B,锁A占用了资源A使用共享资源,企图使用共享资源B,此时锁B占用了资源B并且等待使用资源A,如果双方都不想让,最后的结果就是谁也访问不了资源而高高挂起。
预防这种问题的关键在于,应该按照一定的顺序来申请资源,释放资源,不能同时的去对访问资源进行加锁,这样谁都访问不了,还有另外一种方法,就是使用非阻塞的模式,不使用lock,而是使用try_lock,避免一直阻塞在原地,而是返回EBUSY(锁尚未解除)
或者EINVAL(锁变量不可用)
,将自己拿到的锁进行释放,过段时间再试试看。
所以在设计代码的时候,应当尽量减少同一个临界区的锁的数量,因为锁一多,就会出现各种各样的周边问题,还有在命名锁的时候可以加上序号,这样可以很清楚的知道谁先谁后。
2. 条件变量
互斥锁是加锁原语,如果需要等待某一个条件成立的时候,则应该使用条件变量;条件变量必须配合互斥锁一起使用。比如说我们在便利店买东西的时候,我们要在拿好东西以后去告诉店员我们要付帐,在付完帐后店员就可以继续做自己的事情,可以把我们买东西等这个看作是一个任务,店员看做是一个线程,这个线程要去帮我们处理各种各样的任务,如果有任务来的时候,通知线程处理,当任务很多很多的时候,任务就会进入到队列中(就像排队,谁先来处理谁),挨个处理,如果没有任务,线程就等待,这就是线程池的原理。
创建和销毁
// 初始化
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
// 销毁
int pthread_cond_destroy(pthread_cond_t *cond);
同样类似于mutex
,也可以对条件变量设置属性。
使用
// 通知一个线程
int pthread_cond_signal(pthread_cond_t *cond);
// 通知所有线程
int pthread_cond_broadcast(pthread_cond_t *cond);
// 阻塞该线程直到被唤醒
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
// 在前者的基础上,加上了时间的限制
int pthread_cond_timedwait(
pthread_cond_t *restrict cond,
pthread_mutex_t *restrict mutex,
const struct timespec *restrict abstime);
在阻塞的时候,需要传递互斥锁,用来保护条件,以防止多个线程同时请求的竞争;在调用waiting函数之前,必须要申请互斥锁,在进入到waiting的时候,才释放互斥锁,直到条件收到信号,当调用返回的时候,互斥对象再次被锁定。
waiting函数应当放在while循环中,因为需要考虑到可能会被意外唤醒,却不满足条件的时候。这就是所谓的虚假唤醒。
一般的代码编写格式为:
-
wait端
-
signal端或者brocast端
看到有些博客中提到的
wait morphing
-先通知后解锁,因为先通知,wait端被唤醒以后发现想要占用锁,但是发现还没有解锁,所以又进入到了等待,在signal端解锁以后wait端才能占用锁来处理。这里涉及到一个顺序的问题,好像两种方法的结果都是差不多的,但是个中差别如果有人能给我说说更好了😄。
封装
class TConditon {
public:
TConditon(TMutex mutex);
~TConditon();
void wait(); // 阻塞直到有notify通知
void wait_for(double time); // 可以设置时间
void notify_one(); // 唤醒某个线程
void notify_all(); // 唤醒全部线程
private:
pthread_cond_t _cond;
TMutex _mutex;
};
//------------------------------------------------------//
TConditon::TConditon(TMutex mutex) : _mutex(mutex) {
pthread_cond_init(&this->_cond, nullptr);
}
TConditon::~TConditon() {
this->_mutex.unlock();
pthread_cond_destroy(&this->_cond);
}
void TConditon::wait() {
pthread_cond_wait(&this->_cond, this->_mutex.getMutext());
}
void TConditon::wait_for(double time) {
this->_mutex.lock();
struct timespec spec;
spec.tv_sec = static_cast<time_t >(time/1000); // 秒
spec.tv_nsec = 0; // 毫秒
pthread_cond_timedwait(&this->_cond, this->_mutex.getMutext(), &spec);
}
void TConditon::notify_one() {
pthread_cond_signal(&this->_cond);
}
void TConditon::notify_all() {
pthread_cond_broadcast(&this->_cond);
}
注意
条件变量通常用来实现高层的阻塞队列(线程池中的实现就是)或者倒时器;
倒时器主要有两种用途:
- 主线程发起多个子线程,并且在等待全部子线程完成一定的任务后,主线程才继续执行,通常可以用在主线程等待多个子线程完成初始化;
- 主线程同样发起多个子线程,并且在等待主线程完成一定的任务以后,多个子线程才继续执行,通常可以用于多个子线程等待主线程发出起跑的命令。
读写锁和信号量
在陈硕的书中提到,不建议使用读写锁和信号量,因为一般情况下普通mutex和条件变量已经足够,所以这个打算用到再说。
线程
前面讲的都需要基于线程的基础,下面将介绍在Unix下,线程的使用方法,其头文件为#include <pthread.h>
。
线程状态转换图
这是JAVA中的线程转换图,可以发现,线程一共有四种状态,分别是就绪状态,阻塞状态,运行状态,终止状态。
-
就绪状态:线程可以运行,此时等待系统调用,也就是上图中的可运行;
-
运行状态:因为系统会为每个线程分配一个时间段,如果在这个时间片还没有执行完毕,那么系统会将这个线程状态保存下来,同时让下一个线程来执行,也就是这个线程被抢占了,则会到可运行的状态;那么如果没有足够的线程执行任务对象的时候,则会让任务进入等待队列,等待分配一个线程去处理;
-
阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。
阻塞的情况分三种:1. 等待阻塞 -- 通过调用线程的wait()方法,让线程等待某工作的完成。(wait方法会释放占用资源)2. 同步阻塞 -- 线程在获取synchronized同步锁失败(因为锁被其它线程所占用),它会进入同步阻塞状态。3. 其他阻塞 -- 通过调用线程的sleep()或join()或发出了I/O请求时,线程会进入到阻塞状态。当sleep()状态超时、join()等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。
创建和终止
// 创建
int pthread_create(pthread_t *thread_id, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
// 终止(主动的行为)
void pthread_exit(void *retval);
// 终止(在同一进程内的线程可以指定另一个线程退出)
int pthread_cancel(pthread_t thread);
其中thread_id为线程的标识符,attr为线程的属性,start_routine表示线程一旦建立就会执行的函数,arg为传递给函数start_routine的参数。我们可以在函数中调用pthread_self
获取调用这个函数的线程的标识符。
连接和分离
// 连接
int pthread_join(pthread_t thread, void **retval);
// 分离
int pthread_detach(pthread_t thread);
在我们创建线程的时候,有一个属性用来指定是连接的还是分离的,只有定义为非分离的才可以连接,否则会报错,通常我们都会设置为NULL,默认就是非分离的。
线程之间是共享数据段的,因此通常在线程退出以后,退出线程所占用的资源并不会随着线程的退出而被释放,所以这个时候,可以使用pthread_join
来同步释放资源,调用该函数的线程将会挂起等待,直到终止。
两者的差别在于,join会等待所有的线程都处理资源完毕,才会将这个没有任何线程使用的资源给释放掉,也就是说,一个线程执行join以后,其它的线程可以使用它的资源,因为它的资源还没有被系统释放掉;但是detach却不一样,调用该函数的线程终止以后系统立马收回其资源。注意,这两个函数不能够同时使用。
主线程和普通线程
在C语言的程序中,main就是一个主线程,主线程发散多个子线程,而这些子线程就是普通线程。
主线程和普通线程的区别在于:
- 主线程返回或者运行结束时(执行return,exit等),所有的线程不管有没有执行完都要退出,但是普通线程不会。所以我们如果想要主线程等待其它线程结束以后才退出,通常使用的方法是
pthread_join
,这时调用的主线程会被阻塞,直到其它被join
的线程执行结束以后才会往下执行。 - 一般主线程的栈的大小比普通现成的大很多。主线程使用的是进程的栈,所以会比较大。
- 主线程的main函数是被程序在对进程进行初始化后调用,而普通函数则是通过
start
函数调用。
封装
.h文件
#ifndef TICKLE_TTHREAD_H
#define TICKLE_TTHREAD_H
#include <iostream>
#include <pthread.h>
#include <functional>
#include <string.h>
#include <unistd.h>
#include <exception>
#include <memory>
namespace Tickle {
typedef std::function<void()> TThreadFunc;
struct TThreadData {
TThreadFunc _func;
std::weak_ptr<pid_t> _pid;
std::string _name;
TThreadData(const TThreadFunc& func, const std::shared_ptr<pid_t>& pid, const std::string& name) : _func(func), _pid(pid), _name(name) {}
void runInThread() {
try {
if (_func == nullptr) {
std::cout << "function is null" << std::endl;
}
else {
_func();
}
}
catch (std::exception &e) {
std::cout << "Thread error info:" << e.what() << std::endl;
}
}
};
class TThread {
public:
TThread(const TThreadFunc& func, const std::string& name = std::string());
TThread(const TThreadData& data);
~TThread();
void start(); // 创建线程
void join();
inline const std::string& name() const { return _name; }
private:
std::string _name;
pthread_t _thd;
std::shared_ptr<pid_t> _pid;
TThreadFunc _func;
};
}
#endif //TICKLE_TTHREAD_H
.cpp文件
#include "TThread.h"
namespace Tickle {
void* startInThread(void* param) {
TThreadData* data = static_cast<TThreadData*>(param);
data->runInThread();
delete(data);
return nullptr;
}
TThread::TThread(const TThreadFunc& func, const std::string& name) : _func(func), _name(name), _thd(), _pid(new pid_t(0))
{
}
TThread::TThread(const TThreadData &data) : _func(data._func), _name(data._name), _thd(0), _pid(new pid_t(0))
{
}
TThread::~TThread() {
pthread_detach(this->_thd);
}
void TThread::start() {
TThreadData * data = new TThreadData(this->_func, this->_pid, this->_name);
if (0 != pthread_create(&this->_thd, nullptr, startInThread, data)) {
std::cout << "create the thread failed" << std::endl;
return ;
}
}
void TThread::join() {
if (0 != pthread_join(this->_thd, nullptr)) {
std::cout << "join the thread failed" << std::endl;
}
}
}
内存池的实现
内存池包括以下重点:
- 先申请一定数量的线程;
- 添加任务进入队列,并且通知线程来处理;
- 线程在从队列中选择任务的时候,按照队列先入先出的顺序来选择,如果队列中没有任何任务,条件变量则等待。
最后
我这里就不把我实现的给贴出来,大家可以自己实现😄,还有什么错误请指出来,转载请注明出处,谢谢。

关注公众号:数据结构与算法那些事儿,每天一篇数据结构与算法
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列:基于图像分类模型对图像进行分类
· go语言实现终端里的倒计时
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 25岁的心里话
· 闲置电脑爆改个人服务器(超详细) #公网映射 #Vmware虚拟网络编辑器
· 基于 Docker 搭建 FRP 内网穿透开源项目(很简单哒)
· 零经验选手,Compose 一天开发一款小游戏!
· 一起来玩mcp_server_sqlite,让AI帮你做增删改查!!