目录
[1. 不互斥抢票导致数据错误](#1. 不互斥抢票导致数据错误)
[2. 为什么需要互斥?](#2. 为什么需要互斥?)
[3. 互斥锁](#3. 互斥锁)
[4. 进程间互斥](#4. 进程间互斥)
[5. 加锁后的线程切换](#5. 加锁后的线程切换)
[6. RAII 风格的锁(智能指针风格)](#6. RAII 风格的锁(智能指针风格))
[1. 硬件实现](#1. 硬件实现)
[2. 软件实现](#2. 软件实现)
[1. 线程饥饿](#1. 线程饥饿)
[2. 同步要求](#2. 同步要求)
[3. 线程等待与唤醒(条件变量)](#3. 线程等待与唤醒(条件变量))
[4. 条件变量的用处](#4. 条件变量的用处)
[1. 现实类比](#1. 现实类比)
[2. 321 原则](#2. 321 原则)
[3. 好处](#3. 好处)
[1. 阻塞队列](#1. 阻塞队列)
[2. 成员声明](#2. 成员声明)
[3. 伪唤醒](#3. 伪唤醒)
[4. 生产者插入](#4. 生产者插入)
[5. 消费者执行](#5. 消费者执行)
[6. 唤醒与解锁的先后顺序](#6. 唤醒与解锁的先后顺序)
[7. 效率高的原因](#7. 效率高的原因)
[六、POSIX 信号量](#六、POSIX 信号量)
[1. 信号量回顾](#1. 信号量回顾)
[2. 多线程资源场景](#2. 多线程资源场景)
[3. 环形队列](#3. 环形队列)
[4. 数组模拟](#4. 数组模拟)
[1. 信号量封装](#1. 信号量封装)
[2. 生产者消费者操作](#2. 生产者消费者操作)
[3. 与二元信号量对照](#3. 与二元信号量对照)
[1. 输出策略接口(策略模式)](#1. 输出策略接口(策略模式))
[2. 日志器类(管理策略)](#2. 日志器类(管理策略))
[3. 日志信息类(logmsg)](#3. 日志信息类(logmsg))
[4. 使用流程](#4. 使用流程)
[5. 宏简化使用](#5. 宏简化使用)
[1. 成员声明与构造](#1. 成员声明与构造)
[2. 激活线程池](#2. 激活线程池)
[3. 任务处理函数](#3. 任务处理函数)
[4. 插入任务](#4. 插入任务)
[5. 停止线程池](#5. 停止线程池)
[1. 实现方式](#1. 实现方式)
[2. 为线程池应用单例模式](#2. 为线程池应用单例模式)
[1. 可重入与线程安全](#1. 可重入与线程安全)
[2. 死锁的四个必要条件(缺一不可)](#2. 死锁的四个必要条件(缺一不可))
[十二、C++ 标准库的线程安全](#十二、C++ 标准库的线程安全)
一、互斥(Mutex)
1. 不互斥抢票导致数据错误
-
创建多个线程执行抢票逻辑:
cpppthread_t t1, t2, t3, t4; pthread_create(&t1, NULL, route, (void*)"thread 1"); pthread_create(&t2, NULL, route, (void*)"thread 2"); pthread_create(&t3, NULL, route, (void*)"thread 3"); pthread_create(&t4, NULL, route, (void*)"thread 4"); pthread_join(t1, NULL); pthread_join(t2, NULL); pthread_join(t3, NULL); pthread_join(t4, NULL); -
结果 :票数被抢到负数(如 -2 张)。
https://media/image1.png -
使用互斥锁后:
cppvoid* route(void* arg) { char* id = (char*)arg; while (1) { pthread_mutex_lock(&mutex); if (ticket > 0) { usleep(1000); printf("%s sells ticket:%d\n", id, ticket); ticket--; pthread_mutex_unlock(&mutex); } else { pthread_mutex_unlock(&mutex); break; } } return nullptr; } -
结果 :有序进行,数据正确。
https://media/image2.png
2. 为什么需要互斥?
-
ticket--操作非原子-
--操作在汇编层面需要三步:从内存读到寄存器 -> 寄存器减1 -> 写回内存。 -
当线程 A 执行完两步后被切出,线程 B 执行,最后写回内存的数字可能被覆盖,导致数据不一致。
-
-
if (ticket > 0)判断也是运算- 多个线程同时判断为真,都被切出,之后都执行
--操作,可能将票数减为负数。
- 多个线程同时判断为真,都被切出,之后都执行
-
线程切换
-
线程被切走的原因:时间片用完、阻塞 I/O、
sleep等,会陷入内核。 -
线程被切回:从内核态返回到用户态时会进行检查。
-
3. 互斥锁
-
作用:保证同一时刻只有一个线程执行临界区代码。
-
锁本身也是临界资源,因此锁的获取和释放操作必须是原子的。
-
本质 :在执行临界区代码时,让线程执行由并行 改为串行。
4. 进程间互斥
- 在进程管道通信时,可以将管道头部强转为锁对应的类型,将管道变为锁,实现进程间互斥。
5. 加锁后的线程切换
-
加锁后,线程允许切换和调度。
-
但持有锁的线程被切出时,因为没有释放锁,其他线程即使获得 CPU 也无法进入临界区。
-
因此,对于其他线程来说,持有锁的线程要么在运行,要么就绪,它独占临界区,这体现了原子性。
6. RAII 风格的锁(智能指针风格)
cpp
class guard_thread {
public:
guard_thread(my_thread* th)
:_th(th) {
pthread_mutex_lock(_th->_m);
}
~guard_thread() {
pthread_mutex_unlock(_th->_m);
}
private:
my_thread* _th;
};
- 在循环中创建对象时自动加锁,对象销毁时自动解锁,防止忘记解锁。
二、锁的原理
1. 硬件实现
- 可以通过关闭 CPU 中断(时钟中断)来实现。但这样做有风险,只能在操作系统内部使用。
2. 软件实现
-
锁申请流程 :初始化(锁值为1,线程值为0)-> 交换(
exchange)-> 验证。 -
因为
1只有一个,验证时值为1的线程就抢到了锁,可以运行程序;其他线程挂起。 -
这个流程在任意异步时刻被打断也没关系,因为
1的个数没变。 -
交换的本质:获取锁时,交换操作就是将线程的数据(0)交换到 CPU 的寄存器,同时将锁的值(1)取出来。
三、线程同步
1. 线程饥饿
-
高频申请锁的线程可能导致其他线程长期申请不到锁,产生饥饿。
-
因为刚运行结束的线程不需要被唤醒(它还在运行),而没运行的线程需要被唤醒。运行结束的线程在竞争锁时有优势,可能一直由它运行。
2. 同步要求
-
线程不能立即申请第二次锁,申请时要按特定次序进行。
-
这样可以让锁的分配更加公平。
3. 线程等待与唤醒(条件变量)
-
使用
cond(条件变量)相关接口。 -
pthread_cond_init/PTHREAD_COND_INITIALIZER:初始化。 -
pthread_cond_wait(&gcond, &glock):在glock锁定的情况下,阻塞等待gcond信号,并自动释放锁。 -
pthread_cond_signal(&gcond):唤醒一个因gcond阻塞的线程。 -
pthread_cond_broadcast(&gcond):唤醒所有因gcond阻塞的线程。
4. 条件变量的用处
-
当条件不满足时,线程会等待并休眠,直到被唤醒才继续执行。
-
判断条件是否满足需要使用临界区资源,说明线程就在临界区内。因此,让线程休眠时必须让它释放锁,以便其他线程可以进入临界区修改条件。
-
pthread_cond_wait的设计就是为了实现这一点:它原子性地释放锁并进入休眠 ,被唤醒后重新获取锁。
四、生产者消费者模型
1. 现实类比
-
货物经历:工厂 -> 超市 -> 消费者。
-
对应数据:线程 -> 内存相关区域(交易场所) -> 线程。
-
可用于多线程通信。
2. 321 原则
-
3种关系:
-
消费者之间:互斥关系。
-
生产者之间:互斥关系(竞争资源)。
-
消费者与生产者之间:互斥与同步关系(缓冲区满时生产者等,空时消费者等)。
-
-
2个角色:消费者、生产者。
-
1个交易场所:超市 -> 内存空间(如队列)。
3. 好处
-
解耦合:生产者和消费者互相不直接影响。
-
支持忙闲不均:生产者和消费者的速度可以不同。
-
提高效率:并发执行。
五、阻塞队列模型
1. 阻塞队列
-
队列有内容:消费者才能读取,否则阻塞。
-
队列不满:生产者才能写入,否则阻塞。
2. 成员声明
cpp
std::queue<T> _qu;
int _cap;
int _csleep; // 等待的消费者数量
int _psleep; // 等待的生产者数量
pthread_mutex_t _mutex;
pthread_cond_t _full_cond; // 队列满时生产者等待
pthread_cond_t _empty_cond; // 队列空时消费者等待
-
生产者和消费者竞争同一把锁。
-
_full_cond:控制生产者,队列不满时才可生产。 -
_empty_cond:控制消费者,队列不空时才可消费。 -
queue是临界资源,需要加锁保护。
3. 伪唤醒
-
当异常(如被信号中断)导致多唤醒了几个生产者后,这些生产者从
wait返回,但此时队列可能又满了。 -
pthread_cond_wait在被唤醒后,还需要重新获取锁才能继续执行。 -
因此,线程继续执行需要两个条件:收到唤醒信号 + 拿到锁。
-
解决方式 :将判断条件的
if改为while,被唤醒后再次检查条件。
4. 生产者插入
cpp
void enque(const T val) {
pthread_mutex_lock(&_mutex);
while (isfull()) {
_psleep++;
std::cout << "生产者,进入休眠了: " << _psleep << std::endl;
pthread_cond_wait(&_full_cond, &_mutex);
_psleep--;
}
_qu.push(val);
if (_csleep) {
pthread_cond_signal(&_empty_cond);
std::cout << "唤醒消费者" << std::endl;
}
pthread_mutex_unlock(&_mutex);
}
- 当多个生产者被唤醒,其中一个插入
val后队列又满了。此时while循环再次触发,发现满了,其他被唤醒的生产者会再次阻塞。
5. 消费者执行
cpp
T pop() {
pthread_mutex_lock(&_mutex);
while (isempty()) {
_csleep++;
pthread_cond_wait(&_empty_cond, &_mutex);
_csleep--;
}
T ret = _qu.front();
_qu.pop();
if (_psleep) {
pthread_cond_signal(&_full_cond);
std::cout << "唤醒生产者" << std::endl;
}
pthread_mutex_unlock(&_mutex);
return ret;
}
6. 唤醒与解锁的先后顺序
-
先后关系都可以。
-
但先唤醒后解锁,意味着被唤醒的线程一定拿不到锁(因为锁还在当前线程手中),只能再次阻塞等待锁。
7. 效率高的原因
-
生产者和消费者在访问临界区时虽然是串行的,但在现实中,任务可能来自另一个模块,会有等待。
-
这个模型允许生产和消费并发执行:当生产者等待时(队列满),消费者可以执行任务;当消费者等待时(队列空),生产者可以生产。
六、POSIX 信号量
1. 信号量回顾
-
类比电影票,是一种资源的计数器 和预定机制。
-
之前的互斥锁可以看作二元信号量(计数为 1),同一时刻只有一个线程可进入。
2. 多线程资源场景
-
目标资源整体使用:使用互斥锁。
-
目标资源分块使用:使用信号量。
3. 环形队列
-
头指针 (Head):消费者指针。
-
尾指针 (Tail):生产者指针。
-
指针同位置:队列可能为空,也可能为满。
-
满:需要消费者处理。
-
空:需要生产者处理。
-
-
指针不同位置:生产者和消费者可以同时处理。
4. 数组模拟
- 指针位置通过对数组容量取模(
% N)实现。
七、基于环形队列和信号量的生产消费模型
1. 信号量封装
cpp
class sem {
public:
sem(int sem_value = 1) {
sem_init(&_sem, 0, sem_value);
}
void P() { // 申请资源(wait)
sem_wait(&_sem);
}
void V() { // 释放资源(post)
sem_post(&_sem);
}
~sem() {
sem_destroy(&_sem);
}
private:
sem_t _sem;
};
-
P操作:等待资源,资源数减1(原子操作)。 -
V操作:释放资源,资源数加1(原子操作)。 -
信号量将临界资源是否可用、就绪等操作变为原子的。
2. 生产者消费者操作
cpp
void enqueue(const T& val) {
_blank_sem.P(); // 申请空格子资源
LockGuard lg(_c_mutex); // 保护临界区(环形队列)
_rq[_p_step] = val;
_p_step = (_p_step + 1) % _cap;
_full_sem.V(); // 增加数据资源
}
void pop(T* val) {
_full_sem.P(); // 申请数据资源
LockGuard lg(_p_mutex);
*val = _rq[_c_step];
_c_step = (_c_step + 1) % _cap;
_blank_sem.V(); // 增加空格子资源
}
-
申请信号量和加锁的先后顺序:
- 先申请信号量再申请锁:效率更高。类比买票,先买到票再排队;如果先排队再买票,可能排到了却买不到票(白排了)。
3. 与二元信号量对照
-
资源可以拆分(如多个缓冲区单元):使用计数信号量。
-
资源不可拆分(如一个变量):使用互斥锁(二元信号量)。
八、日志库设计
1. 输出策略接口(策略模式)
cpp
class logmodule {
public:
virtual void synclog(const std::string &msg) = 0;
virtual ~logmodule() = default;
};
-
控制台输出策略:
cppclass consolelogstrategy : public logmodule { public: consolelogstrategy() {} void synclog(const std::string &msg) { LockGuard lg(_mu); std::cout << msg << "\r\n"; } ~consolelogstrategy() {} private: Mutex _mu; }; -
文件输出策略:
cppclass filelogstrategy : public logmodule { public: filelogstrategy(const std::string &path = "./log", const std::string &file = "log.log") : _path(path), _file(file) { LockGuard lg(_mu); if (std::filesystem::exists(path)) return; std::filesystem::create_directories(path); } ~filelogstrategy() {} void synclog(const std::string &msg) { LockGuard lg(_mu); std::string &&filename = _path + (_path[_path.size() - 1] == '/' ? "" : "/") + _file; std::ofstream out(filename, std::ios::app); out << msg << "\r\n"; out.close(); } private: Mutex _mu; std::string _path; std::string _file; };
2. 日志器类(管理策略)
cpp
class log {
public:
log() {
enableconsolelogstrateg(); // 默认控制台输出
}
void enableconsolelogstrateg() {
_strategy = std::make_unique<consolelogstrategy>();
}
void enablefilelogstrategy() {
_strategy = std::make_unique<filelogstrategy>();
}
class logmsg; // 前向声明
logmsg operator()(LogLevel level, std::string file_name, int line) {
return logmsg(level, file_name, line, *this);
}
private:
std::unique_ptr<logmodule> _strategy;
};
- 使用智能指针和基类,可以在运行时切换输出策略。
3. 日志信息类(logmsg)
- 目标格式:
[2026-02-22 16:14:42] [DEBUG] [2955193] [main.cpp] [5] - This is a debug message.
cpp
class logmsg {
public:
std::string LevelStr(LogLevel level) {
switch (level) {
case LogLevel::DEBUG: return "DEBUG";
case LogLevel::INFO: return "INFO";
case LogLevel::WARNING: return "WARNING";
case LogLevel::ERROR: return "ERROR";
case LogLevel::FATAL: return "FATAL";
default: return "UNKNOWN";
}
}
logmsg(LogLevel& level, std::string &file_name, int line, log& logger)
: _level(level), _file_name(file_name), _line(line), _logger(logger) {
_pid = getpid();
time_t now = time(nullptr);
struct tm curr_time;
localtime_r(&now, &curr_time);
char time_buffer[128];
snprintf(time_buffer, sizeof(time_buffer), "%4d-%02d-%02d %02d:%02d:%02d",
curr_time.tm_year + 1900, curr_time.tm_mon + 1, curr_time.tm_mday,
curr_time.tm_hour, curr_time.tm_min, curr_time.tm_sec);
_curr_time = time_buffer;
std::stringstream ss;
ss << "[" << _curr_time << "] "
<< "[" << LevelStr(_level) << "] "
<< "[" << _pid << "] "
<< "[" << _file_name << "] "
<< "[" << _line << "] "
<< "- ";
_loginfo = ss.str();
}
template <class T>
logmsg &operator<<(const T &data) {
std::stringstream ss;
ss << data;
_loginfo += ss.str();
return *this;
}
~logmsg() {
_logger._strategy->synclog(_loginfo);
}
private:
std::string _curr_time;
LogLevel _level;
pid_t _pid;
std::string _file_name;
int _line;
std::string _loginfo; // 拼接好的日志信息
log &_logger;
};
- 关键 :析构函数中调用
synclog输出,实现了自动打印。
4. 使用流程
-
定义全局或局部
log对象(程序结束时析构)。 -
log对象默认选择控制台输出模式(可手动切换)。 -
打印日志:
logger(LogLevel::DEBUG, __FILE__, __LINE__) << "This is a debug message."; -
执行过程:
-
调用
operator(),返回一个临时的logmsg对象(调用其构造函数)。 -
调用
operator<<将消息内容追加到_loginfo。 -
临时对象生命周期结束,调用析构函数,将完整的日志信息通过策略输出。
-
5. 宏简化使用
cpp
#define LOG(level) logger(level, __FILE__, __LINE__)
// 使用
LOG(LogLevel::DEBUG) << "This is a debug message.";
九、线程池
1. 成员声明与构造
cpp
std::vector<mythread> _threads;
int _num;
std::queue<T> _tasks;
Mutex _mu;
Cond _co;
bool _isrunning;
int _sleepercnt; // 休眠线程数量
-
_threads:存放线程对象。 -
_tasks:存放任务(可调用对象包装器)。 -
_sleepercnt和_isrunning:用于控制线程池状态。
cpp
threadpool(int num = 5)
: _num(num), _sleepercnt(0), _isrunning(0) {
for (int i = 0; i < num; i++) {
_threads.emplace_back([this]() { HandlerTask(); });
}
}
2. 激活线程池
cpp
void start() {
_isrunning = 1;
for (int i = 0; i < _num; i++) {
_threads[i].start();
}
}
3. 任务处理函数
cpp
void HandlerTask() {
char name[128];
pthread_getname_np(pthread_self(), name, strlen(name));
while (1) {
T t;
{
LockGuard lg(_mu);
// 线程休眠条件:没有任务 且 线程池在运行
while (_tasks.empty() && _isrunning == 1) {
_sleepercnt++;
std::cout << _sleepercnt << std::endl;
_co.Wait(_mu);
_sleepercnt--;
}
// 线程退出条件:线程池停止 且 没有任务
if (_isrunning == 0 && _tasks.empty()) {
LOG(LogLevel::DEBUG) << name << "退出";
break;
}
t = _tasks.front();
_tasks.pop();
}
t(); // 执行任务
}
}
-
唤醒条件:
-
有任务到来。
-
线程池被销毁(广播唤醒)。
-
-
被唤醒后,如果
_isrunning == 1,说明有任务要执行;否则说明线程池要销毁,线程退出。
4. 插入任务
cpp
bool enque(const T &val) {
if (_isrunning == 1) {
LockGuard lg(_mu);
_tasks.push(val);
if (_threads.size() == _sleepercnt) { // 所有线程都在休眠
_co.Signal();
LOG(LogLevel::INFO) << "唤醒一个线程";
return 1;
}
}
return 0;
}
- 当插入任务且所有线程都在休眠时,唤醒一个线程来处理。
5. 停止线程池
cpp
void stop() {
_isrunning = 0;
_co.Broadcast(); // 唤醒所有等待的线程
LOG(LogLevel::INFO) << "唤醒所有线程";
}
- 先设置停止标志,然后广播唤醒所有线程,让它们根据
HandlerTask中的逻辑退出。
十、单例模式
1. 实现方式
-
饿汉模式:
cpptemplate <typename T> class Singleton { static T data; public: static T* GetInstance() { return &data; } };-
静态
T对象,在程序一开始就创建。 -
缺点:可能延长程序启动时间(如果不使用)。
-
-
懒汉模式:
cpptemplate <typename T> class Singleton { static T* inst; public: static T* GetInstance() { if (inst == NULL) { inst = new T(); } return inst; } };-
只有首次调用
GetInstance时才创建对象。 -
页表的原理和它类似。
-
2. 为线程池应用单例模式
-
禁用拷贝构造和赋值重载:
cppthreadpool(const threadpool<T> &tp) = delete; threadpool<T> &operator=(const threadpool<T> &tp) = delete; -
静态指针和静态获取方法:
cppstatic threadpool<T> *_tp; // 在类外初始化为 nullptr static threadpool<T> *getptr() { if (_tp == nullptr) { LockGuard lg(_mutex); if (_tp == nullptr) { LOG(LogLevel::INFO) << "获取单例"; _tp = new threadpool<T>(); _tp->start(); } } return _tp; }- 双检锁保证线程安全。
十一、死锁
1. 可重入与线程安全
-
可重入:多个执行流同时执行不出问题(同一个函数被多次调用,结果正确)。
-
线程安全:多个线程访问共享资源时能正确执行。
-
关系:
-
可重入一定线程安全。
-
线程安全不一定可重入。
-
-
反例:
-
一个线程拿到锁 -> 收到信号(如
Ctrl+C触发的信号处理函数)-> 信号处理函数调用需要同一把锁的函数。 -
该线程持有锁,信号处理函数在同一线程中执行,再次申请锁,导致自己锁住自己(死锁)。
-
但多线程中这个反例不太容易触发,因为执行信号处理函数的是接收到信号的线程(主线程),不是其他线程。
-
-
在一般情况下,可以将可重入和线程安全看作一个硬币的两面。
2. 死锁的四个必要条件(缺一不可)
-
互斥:资源一次只能被一个执行流使用。
-
不剥夺:执行流不能抢夺其他执行流已持有的资源。
-
请求与保持:执行流在等待其他资源时,不释放自己已持有的资源。
-
循环等待:存在一个执行流等待链,如 A 等 B,B 等 A。
-
类比:两个小孩买糖。
-
互斥:两人不能吃同一块糖。
-
不剥夺:不能抢对方的钱。
-
请求与保持:两人都手拿钱,等着对方的糖。
-
循环等待:两人都不让。
-
十二、C++ 标准库的线程安全
-
STL(标准模板库):
- 大多数容器不是线程安全的。因为加锁会影响性能,需要用户自行加锁。
-
智能指针:
-
一般使用不涉及线程安全问题,因为通常只在当前代码作用域内使用。
-
但库设计时考虑到了线程安全,例如
shared_ptr的引用计数操作是原子的。
-