
🔥铅笔小新z:个人主页
🎬博客专栏:Linux学习
💫滴水不绝,可穿石;步履不休,能至渊。

本节重点
- 深刻理解线程互斥的原理和操作
- 深刻理解线程同步
- 掌握生产消费模型
- 设计日志和线程池
- 理解线程安全和可重入,掌握锁相关概念
1. 线程互斥
1-1 进程线程间的互斥相关背景概念
共享资源
多个线程都能访问到的资源(比如全局变量)就是共享资源。
临界资源
被保护起来的共享资源,一次只允许一个线程访问,就叫做临界资源。
临界区
每个线程内部,访问临界资源的那一段代码,就叫做临界区。
互斥
任何时刻,保证有且只有一个执行流(线程)进入临界区,访问临界资源,这就是互斥。互斥的本质就是对临界资源起保护作用。
原子性
不会被任何调度机制打断的操作,叫做原子操作。原子操作只有两种状态:要么操作完成了,要么操作没完成------不存在"做了一半"这种中间状态。
📌 知识点总结:什么是临界资源、临界区、互斥和原子性?
想象一个公共卫生间只有一个坑位(临界资源 ),每次只能进去一个人(互斥 )。排队进去、锁门、使用、出来------这一整套动作就是临界区 。如果有人开门到一半突然被叫走,这就不是原子性的(被打断了)。真正的原子操作就像"锁门"这个动作,要么锁上了,要么没锁,没有中间状态。在多线程编程中,我们用互斥来保护临界资源,用原子操作来保证"检查-修改"这种组合操作不被中断。
1-2 互斥量 mutex
大部分情况下,线程使用的局部变量存储在线程栈中,这些变量归单个线程所有,其他线程无法访问。但有时候,我们需要多个线程共享一些变量(比如全局变量),这就是共享变量。

多个线程并发操作共享变量会带来问题,先看一个经典的售票系统反例:
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100; // 全局共享变量:总共有100张票
// 线程执行函数:每个线程都在这里抢票
void *route(void *arg)
{
char *id = (char*)arg;
while (1) {
if (ticket > 0) { // 判断是否还有余票
usleep(1000); // 模拟卖票过程中的耗时操作
printf("%s sells ticket:%d\n", id, ticket);
ticket--; // 卖出一张票,总数减1
} else {
break; // 没票了就退出
}
}
}
int main(void)
{
pthread_t t1, t2, t3, t4;
// 创建4个线程同时抢票
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);
}
一次执行结果:
thread 4 sells ticket:100
...
thread 4 sells ticket:1
thread 2 sells ticket:0
thread 1 sells ticket:-1
thread 3 sells ticket:-2
票数竟然变成了负数!这在实际生活中是不可能发生的。
为什么会出现问题?
if语句判断条件为真后,代码可能并发切换到其他线程(还没来得及卖票就被切走了)usleep模拟漫长业务过程,在这段时间内可能有很多线程进入该代码段--ticket操作本身不是原子操作
来看 ticket-- 对应的汇编代码:
asm
mov 0x2004e3(%rip),%eax # 第1步:将ticket从内存加载到寄存器(load)
sub $0x1,%eax # 第2步:寄存器中的值减1(update)
mov %eax,0x2004da(%rip) # 第3步:将新值写回内存(store)
ticket-- 对应三条汇编指令:
- load:将共享变量从内存加载到寄存器
- update:更新寄存器中的值(减1)
- store:将新值从寄存器写回内存
如果线程A执行完 load 还没执行 store 时被切走,线程B读到的还是旧值,就会出问题。
解决方案需要做到三点
- 互斥:当代码进入临界区执行时,不允许其他线程进入该临界区
- 轮候:如果多个线程同时要求执行临界区代码,且临界区没有线程在执行,只允许一个线程进入
- 无阻塞:如果线程不在临界区中执行,不能阻止其他线程进入临界区
要做到这三点,本质上就是需要一把锁 。Linux 上提供的这把锁叫 互斥量(mutex)。
📌 知识点总结:为什么多线程操作共享变量会出问题?怎么解决?
多线程操作共享变量出问题的根因是:看似一步的操作(比如
ticket--)在CPU层面其实是多条指令(读→改→写),线程可能在任意两条指令之间被切换。这就导致数据不一致------两个线程同时读到票数是1,都觉得自己能卖,结果卖出了-1张票。解决方案就是加互斥锁(mutex) ,让"判断票数→卖票→减一"这一整个流程变成一个不可分割的原子操作,一个线程在做的时候,其他线程必须在门外等着。
1-3 互斥量的接口
初始化互斥量
有两种方式:
方法一:静态分配
c
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
方法二:动态分配
c
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
// 参数:
// mutex:要初始化的互斥量
// attr:属性,传NULL表示使用默认属性
销毁互斥量
c
int pthread_mutex_destroy(pthread_mutex_t *mutex);
注意:
- 使用
PTHREAD_MUTEX_INITIALIZER初始化的互斥量不需要销毁 - 不要销毁一个已经加锁的互斥量
- 已经销毁的互斥量,要确保后面不会有线程再尝试加锁
互斥量加锁和解锁
c
int pthread_mutex_lock(pthread_mutex_t *mutex); // 加锁,成功返回0
int pthread_mutex_unlock(pthread_mutex_t *mutex); // 解锁,成功返回0
调用 pthread_mutex_lock 时可能遇到的情况:
- 互斥量处于未锁状态 → 锁定互斥量,返回成功
- 其他线程已经锁定互斥量 → 调用线程陷入阻塞(执行流被挂起),等待互斥量解锁
改进后的售票系统
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sched.h>
int ticket = 100; // 共享资源:总票数
pthread_mutex_t mutex; // 定义一把互斥锁
void *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--; // 票数减1
pthread_mutex_unlock(&mutex); // 卖完票后解锁
} else {
pthread_mutex_unlock(&mutex); // 没票了也要解锁
break;
}
}
return nullptr;
}
int main(void)
{
pthread_t t1, t2, t3, t4;
pthread_mutex_init(&mutex, NULL); // 初始化互斥锁
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);
pthread_mutex_destroy(&mutex); // 销毁互斥锁
}
📌 知识点总结:互斥量(mutex)怎么用?
互斥量就是一把"线程锁",用法很简单:在访问共享资源之前调用
pthread_mutex_lock上锁,用完以后调用pthread_mutex_unlock解锁。如果一个线程已经锁了,另一个线程再来lock就会被阻塞住,直到锁被释放。这有点像公共厕所------进去的人把门锁上(lock),外面的人只能排队等着,里面的人出来后再开门(unlock),下一个才能进去。注意,锁用完之后要销毁(destroy),别忘了。
1-4 互斥量实现原理探究
i++ 或 ++i 都不是原子的,它们对应多条汇编指令(load → modify → store),线程切换可能导致数据不一致。
为了实现互斥锁操作,大多数 CPU 都提供了 swap 或 exchange 指令 ,它的作用是把寄存器和内存单元的数据交换 。由于这是一条指令,保证了原子性------即使是多处理器平台,访问内存的总线周期也有先后顺序,一个处理器上的交换指令执行时,另一个处理器的交换指令只能等待总线周期。
简单来说,加锁的底层原理是这样的:
- 内存中有一个 mutex 变量,1 表示"锁空闲",0 表示"锁被占用"
- 加锁时,CPU 执行一条原子交换指令,尝试把寄存器中的 0 和内存中的 mutex 值交换
- 如果交换前 mutex 是 1(空闲),交换后寄存器得到 1,内存变成 0------加锁成功
- 如果交换前 mutex 是 0(已被占用),交换后寄存器得到 0,内存还是 0------加锁失败,线程阻塞
📌 知识点总结:互斥锁的底层原理是怎么实现的?
互斥锁的底层依赖 CPU 提供的原子交换指令(swap/exchange)。这条指令可以一口气完成"把内存里的值和寄存器里的值互换",中间不可被打断。加锁的过程本质上是线程之间"抢"一个标志位------谁通过原子交换把标志位从"空闲"改成"占用",谁就抢到了锁。因为这个交换操作是一条 CPU 指令,所以是原子性的,不会出现"两个线程同时抢到锁"的情况。
1-5 互斥量的封装(RAII 风格)
直接用 pthread_mutex_lock/unlock 容易出问题------万一忘记解锁或者中间抛出异常,锁就永远解不开了。更优雅的方式是使用 RAII(资源获取即初始化) 风格,把锁的生命周期和作用域绑定。
cpp
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
namespace LockModule
{
// 对锁进行封装,可以独立使用
class Mutex
{
public:
// 删除拷贝构造函数和赋值运算符,防止锁被复制
Mutex(const Mutex &) = delete;
const Mutex &operator =(const Mutex &) = delete;
Mutex()
{
int n = pthread_mutex_init(&_mutex, nullptr);
(void)n; // 忽略返回值,生产代码应该检查
}
void Lock()
{
int n = pthread_mutex_lock(&_mutex);
(void)n;
}
void Unlock()
{
int n = pthread_mutex_unlock(&_mutex);
(void)n;
}
// 获取原始互斥量指针,供条件变量等接口使用
pthread_mutex_t *GetMutexOriginal()
{
return &_mutex;
}
~Mutex()
{
int n = pthread_mutex_destroy(&_mutex);
(void)n;
}
private:
pthread_mutex_t _mutex;
};
// 采用RAII风格进行锁管理:构造时加锁,析构时解锁
class LockGuard
{
public:
LockGuard(Mutex &mutex)
: _mutex(mutex)
{
_mutex.Lock(); // 构造时加锁
}
~LockGuard()
{
_mutex.Unlock(); // 析构时解锁
}
private:
Mutex &_mutex; // 持有锁的引用
};
}
使用 RAII 锁改造抢票代码
cpp
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include "Lock.hpp"
using namespace LockModule;
int ticket = 1000; // 全局共享变量:总票数
Mutex mutex; // 全局互斥锁
void *route(void *arg)
{
char *id = (char *)arg;
while (1)
{
LockGuard lockguard(mutex); // 构造时加锁,出作用域自动解锁
if (ticket > 0)
{
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--;
}
else
{
break;
}
}
return nullptr;
}
int main(void)
{
pthread_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);
}
📌 知识点总结:什么是 RAII 风格的锁?为什么用它?
RAII 锁就是"构造时自动加锁,析构时自动解锁"。你只需要在临界区开头创建一个
LockGuard对象,剩下的就不用管了------变量出作用域时会自动调用析构函数释放锁。这样做最大的好处是永远不会忘记解锁 ,即使函数中间 return 或者抛出异常,C++ 的栈展开机制也会确保析构函数被调用。C++11 标准库也提供了类似的东西:std::lock_guard<std::mutex>。
2. 线程同步
2-1 条件变量
当一个线程互斥地访问某个变量时,它可能发现在其他线程改变状态之前,自己什么也做不了。
例如:一个线程访问队列时发现队列是空的,它只能等待,直到其他线程往队列中添加了元素。这种情况下就需要用到条件变量。
条件变量不是锁,它是一种等待通知机制:一个线程可以在某个条件不满足时主动休眠(等待),其他线程在条件满足时把它唤醒。
2-2 同步概念与竞态条件
同步
在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
饥饿问题:一个线程一直在等资源,但总是轮不到它,就像排队买饭时总有人插队。
简单理解互斥和同步的区别:
- 互斥:保证同一时间只有一个人在厕所里(安全)
- 同步:保证大家上厕所的顺序是公平的,不会有人憋死(公平)
竞态条件
因为时序(执行顺序)问题而导致程序异常,叫做竞态条件。比如线程A先执行和线程B先执行,结果完全不同,这就是竞态条件。
📌 知识点总结:线程同步是什么?和互斥有什么区别?
互斥 解决的是"能不能同时用"的问题------保证同一时刻只有一个线程访问共享资源。同步解决的是"按照什么顺序用"的问题------让线程按照某种约定的顺序执行,避免有的线程一直抢不到资源(饥饿)。打个比方:厕所只有一个坑位(互斥保证不会两个人同时用),但如果有人一直占着坑不出来,其他人就会饿死(这就是饥饿),同步机制就是确保大家轮着来,每个人都有机会。
2-3 条件变量函数
初始化
c
int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);
// 参数:
// cond:要初始化的条件变量
// attr:属性,传NULL表示默认
也可以静态初始化:
c
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
销毁
c
int pthread_cond_destroy(pthread_cond_t *cond);
等待条件满足
c
int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);
// 参数:
// cond:在这个条件变量上等待
// mutex:互斥量(后面会详细解释为什么需要)
唤醒等待
c
int pthread_cond_broadcast(pthread_cond_t *cond); // 唤醒所有等待线程
int pthread_cond_signal(pthread_cond_t *cond); // 唤醒一个等待线程
简单示例
cpp
#include <iostream>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
pthread_cond_t cond = PTHREAD_COND_INITIALIZER; // 条件变量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 互斥锁
void *active(void *arg)
{
std::string name = static_cast<const char*>(arg);
while (true) {
pthread_mutex_lock(&mutex);
pthread_cond_wait(&cond, &mutex); // 等待条件满足,同时自动释放锁
std::cout << name << " 活动..." << std::endl;
pthread_mutex_unlock(&mutex);
}
}
int main(void)
{
pthread_t t1, t2;
pthread_create(&t1, NULL, active, (void*)"thread-1");
pthread_create(&t2, NULL, active, (void*)"thread-2");
sleep(3); // 确保两个线程已经在运行并等待
while (true) {
// pthread_cond_signal(&cond); // 每次唤醒一个线程
pthread_cond_broadcast(&cond); // 每次唤醒所有线程
sleep(1);
}
pthread_join(t1, NULL);
pthread_join(t2, NULL);
}
运行结果:
thread-1 活动...
thread-2 活动...
thread-1 活动...
thread-1 活动...
thread-2 活动...
📌 知识点总结:条件变量是什么?怎么用?
条件变量是一种等待-通知 机制。当一个线程发现"条件不满足"(比如队列为空),它可以在条件变量上等待 (休眠),而不是空转浪费 CPU。当其他线程改变了条件(比如往队列里放了数据),就通过
signal或broadcast通知 等待的线程醒来干活。关键点:pthread_cond_wait会自动释放你传入的互斥锁,醒来时又会重新获取锁。这样就可以避免"休眠时还占着锁,别人进不去"的问题。
2-4 生产者消费者模型

321 原则(便于记忆)
3 种关系:
- 生产者 vs 生产者:互斥(不能同时往同一个位置放数据)
- 消费者 vs 消费者:互斥(不能同时从同一个位置取数据)
- 生产者 vs 消费者:互斥 + 同步(不能同时访问缓冲区,且缓冲区满时生产者等,空时消费者等)
2 种角色:生产者 (生产数据)和 消费者(处理数据)
1 个交易场所:缓冲区(如阻塞队列、环形队列)
为什么使用生产者消费者模型
生产者和消费者模式通过一个容器(缓冲区)来解决生产者和消费者的强耦合问题:
- 生产者和消费者彼此之间不直接通讯
- 生产者把数据扔给阻塞队列即可,不用等消费者处理
- 消费者直接从阻塞队列取数据,不用找生产者要
- 阻塞队列就像一个缓冲区,平衡了生产者和消费者的处理能力
生产者消费者模型的优点
- 解耦:生产者和消费者不直接依赖,通过缓冲区间接通信
- 支持并发:生产和消费可以同时进行,提高效率
- 支持忙闲不均:生产者生产快时,数据暂存在缓冲区;消费者消费快时,从缓冲区取
📌 知识点总结:生产者消费者模型的 321 原则是什么?有什么好处?
321原则是:3种关系 (生产-生产互斥、消费-消费互斥、生产-消费互斥+同步)、2种角色 (生产者和消费者)、1个缓冲区 (交易场所)。好处有3点:一是解耦 ------生产者和消费者不需要直接打交道;二是支持并发 ------生产和消费可以同时进行;三是能应对忙闲不均------生产快了数据先存着,消费快了从缓冲区拿。就像一个快递中转站:快递员(生产者)把包裹放进驿站,客户(消费者)自己去取,不用双方约时间见面。
2-5 基于 BlockingQueue 的生产者消费者模型

什么是 BlockingQueue
阻塞队列是多线程编程中常用的数据结构,用于实现生产者消费者模型。与普通队列的区别:
- 队列为空时,尝试取元素的消费者线程会被阻塞,直到有数据放入
- 队列满时,尝试放元素的生产者线程会被阻塞,直到有数据被取出
BlockingQueue 封装代码
cpp
#ifndef __BLOCK_QUEUE_HPP__
#define __BLOCK_QUEUE_HPP__
#include <iostream>
#include <string>
#include <queue>
#include <pthread.h>
// 模板类,支持存放任意类型的数据
template <typename T>
class BlockQueue
{
private:
// 判断队列是否已满
bool IsFull()
{
return _block_queue.size() == _cap;
}
// 判断队列是否为空
bool IsEmpty()
{
return _block_queue.empty();
}
public:
// 构造函数,指定队列容量上限
BlockQueue(int cap) : _cap(cap)
{
_productor_wait_num = 0; // 等待的生产者数量
_consumer_wait_num = 0; // 等待的消费者数量
pthread_mutex_init(&_mutex, nullptr); // 初始化互斥锁
pthread_cond_init(&_product_cond, nullptr); // 生产者条件变量
pthread_cond_init(&_consum_cond, nullptr); // 消费者条件变量
}
// 生产者调用的接口:向队列中放入数据
void Enqueue(T &in)
{
pthread_mutex_lock(&_mutex); // 加锁
while (IsFull()) { // 队列满了就等待
// 注意:pthread_cond_wait 会做三件事:
// a. 让调用线程在条件变量上等待
// b. 自动释放持有的 _mutex 锁
// c. 被唤醒后,重新竞争 _mutex 锁,竞争成功才返回
_productor_wait_num++;
pthread_cond_wait(&_product_cond, &_mutex); // 生产者等待
_productor_wait_num--;
}
// 进行生产
_block_queue.push(in);
// 通知消费者来消费
if (_consumer_wait_num > 0)
pthread_cond_signal(&_consum_cond);
pthread_mutex_unlock(&_mutex); // 解锁
}
// 消费者调用的接口:从队列中取出数据
void Pop(T *out)
{
pthread_mutex_lock(&_mutex); // 加锁
while (IsEmpty()) { // 队列空了就等待
_consumer_wait_num++;
pthread_cond_wait(&_consum_cond, &_mutex); // 消费者等待
_consumer_wait_num--;
}
// 进行消费
*out = _block_queue.front();
_block_queue.pop();
// 通知生产者来生产
if (_productor_wait_num > 0)
pthread_cond_signal(&_product_cond);
pthread_mutex_unlock(&_mutex); // 解锁
}
~BlockQueue()
{
pthread_mutex_destroy(&_mutex);
pthread_cond_destroy(&_product_cond);
pthread_cond_destroy(&_consum_cond);
}
private:
std::queue<T> _block_queue; // 底层容器:标准库队列
int _cap; // 队列容量上限
pthread_mutex_t _mutex; // 保护队列的互斥锁
pthread_cond_t _product_cond; // 生产者条件变量
pthread_cond_t _consum_cond; // 消费者条件变量
int _productor_wait_num; // 等待的生产者数量
int _consumer_wait_num; // 等待的消费者数量
};
#endif
任务类型
cpp
#pragma once
#include <iostream>
#include <string>
#include <functional>
// 任务类型:使用函数对象,可以是任何可调用对象
using Task = std::function<void()>;
注意:这里使用模板,意味着队列中不仅可以存放 int 等内置类型,对象也可以作为任务参与生产消费过程。
📌 知识点总结:阻塞队列(BlockingQueue)在生产消费者模型中是怎么工作的?
阻塞队列就是一个带容量限制的线程安全队列。生产者放数据时,如果队列满了就阻塞等待 (通过条件变量休眠),消费者取数据时,如果队列空了也阻塞等待 。双方各有一个条件变量------生产者等
_product_cond(被通知"有空间了"),消费者等_consum_cond(被通知"有数据了")。这样就实现了"生产者-消费者"之间的同步:满时停生产,空时停消费。同时用互斥锁保护队列本身,保证任何时候只有一个线程在操作队列。
2-6 为什么 pthread_cond_wait 需要互斥量?
核心原因
条件等待是线程间同步的一种手段。如果只有一个线程,条件不满足就只能一直等------永远等不到满足的一天。所以必须要有另一个线程通过某些操作改变共享变量,使条件变得满足,并且通知等待的线程。
条件不会无缘无故突然变得满足,必然会牵扯到共享数据的变化,所以一定要用互斥锁来保护。没有互斥锁,就无法安全地获取和修改共享数据。
错误的设计
c
// 错误的设计:先解锁再等待
pthread_mutex_lock(&mutex);
while (condition_is_false) {
pthread_mutex_unlock(&mutex); // 解锁
// 在解锁之后、等待之前,条件可能已经满足,信号已经发出------但信号被错过了!
pthread_cond_wait(&cond, &mutex);
pthread_mutex_lock(&mutex); // 重新加锁
}
pthread_mutex_unlock(&mutex);
为什么这是错的?因为解锁和等待不是原子操作 。解锁之后、pthread_cond_wait 之前,如果其他线程获取到互斥量,改变了条件并发送了信号,那么 pthread_cond_wait 将错过这个信号,导致线程永远阻塞。
正确的做法
pthread_cond_wait 的设计本身就是原子的:进入该函数后,会原子性地释放互斥量并开始等待。当被唤醒返回时,又会自动重新获取互斥量。这样,解锁和等待就是一个不可分割的操作,不会出现"信号被错过"的情况。
📌 知识点总结 :为什么 pthread_cond_wait 需要传一个互斥量进去?
因为
pthread_cond_wait要保证"释放锁 + 进入休眠 "这两个操作是原子 的。如果不传互斥量,你需要手动先解锁再等待------但解锁和等待之间有空隙,别的线程可能在这期间发送了信号,而你的线程还没开始等,信号就丢失了。pthread_cond_wait内部会原子性地帮你完成:先释放锁让其他人能改条件,然后把自己挂起等待通知;被唤醒后又重新获取锁,保证你在读条件时是安全的。
2-7 条件变量使用规范
等待条件(消费者侧)
c
pthread_mutex_lock(&mutex); // 先加锁
while (条件为假) { // 用 while 而不是 if!
pthread_cond_wait(cond, mutex); // 等待条件满足
}
// 修改条件(如从队列取数据)
pthread_mutex_unlock(&mutex); // 解锁
为什么用 while 而不是 if? 因为可能存在伪唤醒 (spurious wakeup)------线程被唤醒了,但条件并不满足(比如多个消费者同时被唤醒,只有一个抢到了数据)。用 while 可以在唤醒后重新检查条件,不满足就继续等。
发送信号(生产者侧)
c
pthread_mutex_lock(&mutex); // 先加锁
设置条件为真 // 如往队列放数据
pthread_cond_signal(cond); // 通知等待的线程
pthread_mutex_unlock(&mutex); // 解锁
📌 知识点总结:使用条件变量的标准写法是什么?
标准写法分两边:等待方 先加锁,然后用
while循环判断条件(防止伪唤醒),条件不满足就pthread_cond_wait(自动释放锁、休眠、醒来重新加锁),条件满足就执行操作,最后解锁。通知方 先加锁,修改条件(比如往队列放数据),发送信号(signal或broadcast),最后解锁。用while而不是if是因为线程可能被"假唤醒"------明明条件没满足也被叫起来了,while循环能再检查一次。
2-8 条件变量的封装
为了让条件变量更通用,封装时不要在 Cond 类内部引用对应的 Mutex,否则组合时难以初始化(因为 Mutex 和 Cond 基本是一起创建的)。
cpp
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
#include "Lock.hpp"
namespace CondModule
{
using namespace LockModule;
class Cond
{
public:
Cond()
{
int n = pthread_cond_init(&_cond, nullptr);
(void)n; // 生产代码应加错误处理
}
// 等待条件满足,需要传入互斥量
void Wait(Mutex &mutex)
{
int n = pthread_cond_wait(&_cond, mutex.GetMutexOriginal());
(void)n;
}
// 唤醒一个等待线程
void Notify()
{
int n = pthread_cond_signal(&_cond);
(void)n;
}
// 唤醒所有等待线程
void NotifyAll()
{
int n = pthread_cond_broadcast(&_cond);
(void)n;
}
~Cond()
{
int n = pthread_cond_destroy(&_cond);
(void)n;
}
private:
pthread_cond_t _cond;
};
}
📌 知识点总结:封装条件变量时需要注意什么?
封装条件变量时,不要把 Mutex 和 Cond 绑死在同一个类里,因为 Mutex 和 Cond 虽然经常一起用,但它们是不同的概念------一个负责互斥,一个负责同步。把 Cond 设计成独立类,使用时通过参数传入 Mutex,这样更灵活。这就像遥控器和电池------它们需要配合使用,但各自独立生产,用时再组装。
2-9 POSIX 信号量
POSIX 信号量和 SystemV 信号量作用相同,都是用于同步操作,达到无冲突访问共享资源的目的。POSIX 信号量可以用于线程间同步。
初始化
c
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
// 参数:
// sem:信号量对象
// pshared:0表示线程间共享,非0表示进程间共享
// value:信号量的初始值
销毁
c
int sem_destroy(sem_t *sem);
等待信号量(P操作)
c
int sem_wait(sem_t *sem); // 将信号量的值减1,如果值为0则阻塞
发布信号量(V操作)
c
int sem_post(sem_t *sem); // 将信号量的值加1,唤醒等待的线程
简单封装
cpp
#pragma once
#include <iostream>
#include <semaphore.h>
// 随手做一下封装
class Sem
{
public:
Sem(int n)
{
sem_init(&_sem, 0, n); // 初始值设为n,线程间共享
}
void P() // 等待资源(减1),相当于申请资源
{
sem_wait(&_sem);
}
void V() // 释放资源(加1),相当于归还资源
{
sem_post(&_sem);
}
~Sem()
{
sem_destroy(&_sem);
}
private:
sem_t _sem;
};
📌 知识点总结:什么是 POSIX 信号量?P/V 操作代表什么?
信号量本质上就是一个资源计数器 。
P操作(sem_wait)申请一个资源------如果计数器>0就减1然后继续,如果计数器=0就阻塞等待。V操作(sem_post)释放一个资源------计数器加1,如果有线程在等待就唤醒它。可以理解为停车场门口的显示屏:P 操作是"看看有没有空车位,有就进去(数字减1),没有就排队等";V 操作是"车开走了,空出一个车位(数字加1),通知排队的人进来"。
2-10 基于环形队列的生产消费模型
基于固定大小的环形队列(用数组模拟),配合 POSIX 信号量实现生产消费模型。


设计思路
- 环形队列用数组模拟,通过取模运算实现环状特性
- 用两个信号量分别管理"剩余空间"和"已有数据"
_room_sem:剩余空间数量(生产者关心)_data_sem:已有数据数量(消费者关心)
多生产多消费的 "321" 分析
- 3种关系:生产-生产(互斥)、消费-消费(互斥)、生产-消费(互斥+同步)
- 2种角色:生产者和消费者
- 1个交易场所:环形队列
解决方案:
- 需要 2 把锁:一把给生产者互斥,一把给消费者互斥
- 信号量本身是原子的,所以 P/V 操作不需要额外保护
完整实现
cpp
#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <semaphore.h>
#include <pthread.h>
// 单生产单消费 / 多生产多消费 都适用
template<typename T>
class RingQueue
{
private:
// 加锁和解锁的辅助方法
void Lock(pthread_mutex_t &mutex)
{
pthread_mutex_lock(&mutex);
}
void Unlock(pthread_mutex_t &mutex)
{
pthread_mutex_unlock(&mutex);
}
public:
// 构造函数,指定队列容量
RingQueue(int cap)
: _ring_queue(cap),
_cap(cap),
_room_sem(cap), // 初始有cap个空位
_data_sem(0), // 初始有0个数据
_productor_step(0),
_consumer_step(0)
{
pthread_mutex_init(&_productor_mutex, nullptr); // 生产者互斥锁
pthread_mutex_init(&_consumer_mutex, nullptr); // 消费者互斥锁
}
// 生产者:向队列中放入数据
void Enqueue(const T &in)
{
// 生产行为
_room_sem.P(); // 申请一个空位(没有空位就等待)
Lock(_productor_mutex); // 多个生产者互斥
_ring_queue[_productor_step++] = in; // 把数据放入当前位置
_productor_step %= _cap; // 下标移动到下一个位置(环形)
Unlock(_productor_mutex);
_data_sem.V(); // 数据数量加1,通知消费者
}
// 消费者:从队列中取出数据
void Pop(T *out)
{
// 消费行为
_data_sem.P(); // 申请一个数据(没有数据就等待)
Lock(_consumer_mutex); // 多个消费者互斥
*out = _ring_queue[_consumer_step++]; // 取出当前位置的数据
_consumer_step %= _cap; // 下标移动到下一个位置(环形)
Unlock(_consumer_mutex);
_room_sem.V(); // 空位数量加1,通知生产者
}
~RingQueue()
{
pthread_mutex_destroy(&_productor_mutex);
pthread_mutex_destroy(&_consumer_mutex);
}
private:
// 1. 环形队列的存储
std::vector<T> _ring_queue; // 底层数组
int _cap; // 容量上限
// 2. 生产和消费的下标
int _productor_step; // 生产者当前写入位置
int _consumer_step; // 消费者当前读取位置
// 3. 信号量
Sem _room_sem; // 剩余空间(生产者关心)
Sem _data_sem; // 已有数据(消费者关心)
// 4. 互斥锁(多生产多消费时需要)
pthread_mutex_t _productor_mutex; // 生产者之间的互斥锁
pthread_mutex_t _consumer_mutex; // 消费者之间的互斥锁
};
📌 知识点总结:基于环形队列的生产消费模型怎么设计?
环形队列 + 信号量是一个经典组合。用两个信号量:
_room_sem表示剩余空间(初始值=容量),_data_sem表示已有数据(初始值=0)。生产者先 P(room) 申请空位,然后放数据,最后 V(data) 增加数据量;消费者先 P(data) 申请数据,然后取数据,最后 V(room) 释放空位。信号量本身的 P/V 是原子的,所以单生产单消费不需要加锁。多生产多消费场景下,需要额外加两把锁:一把锁住生产者之间的竞争,一把锁住消费者之间的竞争。因为生产者和消费者操作的是不同的下标,不需要同一把锁。
3. 线程池
3-1 日志与策略模式
什么是设计模式
设计模式是前辈们针对一些经典、常见的场景,总结出来的通用解决方案。它不是代码,而是一种编程思想。
日志认识
日志是记录系统和软件运行中发生事件的文件,主要作用是监控运行状态、记录异常信息,帮助快速定位问题。
日志格式中以下几个指标是必须有的:
- 时间戳
- 日志等级
- 日志内容
以下指标是可选的:
- 文件名和行号
- 进程ID、线程ID等
设计目标格式:
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [16] - hello world
[2024-08-04 12:27:03] [WARNING] [202938] [main.cc] [23] - hello world
策略模式日志实现
这里采用策略模式来设计日志系统,策略模式的核心思想是:定义一系列算法(策略),把它们封装起来,使它们可以互相替换。
cpp
#pragma once
#include <iostream>
#include <string>
#include <fstream>
#include <memory>
#include <ctime>
#include <sstream>
#include <filesystem> // C++17,需要高版本编译器 -std=c++17
#include <unistd.h>
#include "Lock.hpp"
namespace LogModule
{
using namespace LockModule;
// 默认路径和日志名称
const std::string defaultpath = "./log/";
const std::string defaultname = "log.txt";
// 日志等级枚举
enum class LogLevel
{
DEBUG,
INFO,
WARNING,
ERROR,
FATAL
};
// 将日志等级转换为字符串
std::string LogLevelToString(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";
}
}
// 获取可读性较强的时间字符串
std::string GetCurrTime()
{
time_t tm = time(nullptr);
struct tm curr;
localtime_r(&tm, &curr);
char timebuffer[64];
snprintf(timebuffer, sizeof(timebuffer), "%4d-%02d-%02d %02d:%02d:%02d",
curr.tm_year + 1900,
curr.tm_mon,
curr.tm_mday,
curr.tm_hour,
curr.tm_min,
curr.tm_sec);
return timebuffer;
}
// 策略模式:策略接口(抽象基类)
class LogStrategy
{
public:
virtual ~LogStrategy() = default;
virtual void SyncLog(const std::string &message) = 0; // 不同模式核心是刷新方式不同
};
// 具体策略1:控制台日志(打印到屏幕,方便调试)
class ConsoleLogStrategy : public LogStrategy
{
public:
void SyncLog(const std::string &message) override
{
LockGuard LockGuard(_mutex); // 显示器也是临界资源
std::cerr << message << std::endl;
}
private:
Mutex _mutex; // 保证输出线程安全
};
// 具体策略2:文件日志(写入到文件)
class FileLogStrategy : public LogStrategy
{
public:
FileLogStrategy(const std::string logpath = defaultpath,
std::string logfilename = defaultname)
: _logpath(logpath), _logfilename(logfilename)
{
LockGuard lockguard(_mutex);
if (std::filesystem::exists(_logpath)) // 如果目录已存在
return;
try
{
std::filesystem::create_directories(_logpath); // 创建目录
}
catch (const std::filesystem::filesystem_error &e)
{
std::cerr << e.what() << '\n';
}
}
void SyncLog(const std::string &message) override
{
LockGuard lockguard(_mutex);
std::string log = _logpath + _logfilename;
std::ofstream out(log.c_str(), std::ios::app); // 追加方式写入
if (!out.is_open())
return;
out << message << "\n";
out.close();
}
private:
std::string _logpath;
std::string _logfilename;
Mutex _mutex; // 保证文件写入线程安全
};
// 日志类本身
class Logger
{
public:
Logger()
{
UseConsoleStrategy(); // 默认使用控制台策略
}
~Logger() {}
// 切换策略:控制台输出
void UseConsoleStrategy()
{
_strategy = std::make_unique<ConsoleLogStrategy>();
}
// 切换策略:文件输出
void UseFileStrategy()
{
_strategy = std::make_unique<FileLogStrategy>();
}
// 内部类:表示一条完整的日志(RAII风格)
class LogMessage
{
public:
// 构造时格式化好日志头部信息
LogMessage(LogLevel type, std::string &filename, int line, Logger &logger)
: _type(type),
_curr_time(GetCurrTime()),
_pid(getpid()),
_filename(filename),
_line(line),
_logger(logger)
{
std::stringstream ssbuffer;
ssbuffer << "[" << _curr_time << "] "
<< "[" << LogLevelToString(type) << "] "
<< "[" << _pid << "] "
<< "[" << _filename << "] "
<< "[" << _line << "]"
<< " - ";
_loginfo = ssbuffer.str();
}
// 支持 C++ 风格的 << 连续输入
template <typename T>
LogMessage &operator<<(const T &info)
{
std::stringstream ssbuffer;
ssbuffer << info;
_loginfo += ssbuffer.str();
return *this; // 返回自身,支持链式调用
}
// 析构时(RAII)将完整的日志信息持久化
~LogMessage()
{
if (_logger._strategy)
{
_logger._strategy->SyncLog(_loginfo);
}
}
private:
LogLevel _type; // 日志等级
std::string _curr_time; // 日志时间
pid_t _pid; // 进程ID
std::string _filename; // 对应的文件名
int _line; // 对应的文件行号
Logger &_logger; // 引用外部Logger,方便使用策略刷新
std::string _loginfo; // 完整的日志信息
};
// 返回临时对象,形成 RAII 管理
LogMessage operator()(LogLevel type, std::string filename, int line)
{
return LogMessage(type, filename, line, *this);
}
private:
std::unique_ptr<LogStrategy> _strategy; // 日志写入策略
};
// 全局 Logger 对象
Logger logger;
// 宏:方便获取文件名和行号
#define LOG(type) logger(type, __FILE__, __LINE__)
// 宏:切换策略
#define ENABLE_CONSOLE_LOG_STRATEGY() logger.UseConsoleStrategy()
#define ENABLE_FILE_LOG_STRATEGY() logger.UseFileStrategy()
}
使用示例
cpp
#include <iostream>
#include "Log.hpp"
using namespace LogModule;
void fun()
{
int a = 10;
LOG(LogLevel::FATAL) << "hello world" << 1234 << ", 3.14" << 'c' << a;
}
int main()
{
LOG(LogLevel::DEBUG) << "hello world";
LOG(LogLevel::DEBUG) << "hello world";
LOG(LogLevel::WARNING) << "hello world";
fun();
return 0;
}
📌 知识点总结:策略模式在日志系统中是怎么体现的?
策略模式定义一系列算法,把它们封装成可互换的类。在日志系统中,我们定义了一个
LogStrategy接口(纯虚基类),然后派生出两个具体策略:ConsoleLogStrategy(打印到控制台)和FileLogStrategy(写入文件)。Logger类持有策略对象的指针,运行时可以随时切换策略(比如调试时用控制台,部署后用文件)。这样,日志格式化和日志输出就解耦了------以后想加网络日志、数据库日志,只需再写一个策略子类,完全不用改现有代码。
3-2 线程池设计
什么是线程池
线程过多会带来调度开销,影响性能。线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这就避免了在处理短时间任务时,频繁创建和销毁线程的代价。
适用场景
- 大量短小任务:比如 Web 服务器处理网页请求,任务小但数量巨大
- 性能要求苛刻:要求服务器迅速响应客户请求
- 突发性大量请求:短时间内产生大量请求,如果没有线程池,可能因为创建大量线程导致内存溢出
线程池的种类
- 固定数量线程池:创建固定数量的线程,循环从任务队列取任务执行
- 浮动线程池:线程数量可以动态调整
此处我们选择固定线程个数的线程池。
线程池完整实现
cpp
#pragma once
#include <iostream>
#include <vector>
#include <queue>
#include <memory>
#include <pthread.h>
#include "Log.hpp" // 引入日志
#include "Thread.hpp" // 引入线程封装
#include "Lock.hpp" // 引入锁
#include "Cond.hpp" // 引入条件变量
using namespace ThreadModule;
using namespace CondModule;
using namespace LockModule;
using namespace LogModule;
const static int gdefaultthreadnum = 10; // 默认线程数
template <typename T>
class ThreadPool
{
private:
// 线程要执行的任务处理函数
void HandlerTask()
{
std::string name = GetThreadNameFromNptl();
LOG(LogLevel::INFO) << name << " is running...";
while (true)
{
// 1. 加锁保护任务队列
_mutex.Lock();
// 2. 队列为空且线程池在运行,则等待
while (_task_queue.empty() && _isrunning)
{
_waitnum++; // 等待的线程数加1
_cond.Wait(_mutex); // 等待条件变量(有任务来)
_waitnum--; // 等待的线程数减1
}
// 2.1 如果线程池已退出 && 任务队列为空
if (_task_queue.empty() && !_isrunning)
{
_mutex.Unlock();
break; // 退出循环,线程结束
}
// 2.2 线程池在运行 && 队列有任务:正常执行
// 2.3 线程池已退出 && 队列还有任务:处理完再退出
// 3. 一定有任务,取出任务
T t = _task_queue.front();
_task_queue.pop();
_mutex.Unlock(); // 取完任务立即解锁
LOG(LogLevel::DEBUG) << name << " get a task";
// 4. 处理任务(任务在线程中独占执行)
t();
}
}
public:
// 构造函数(私有,后面会改成单例)
ThreadPool(int threadnum = gdefaultthreadnum)
: _threadnum(threadnum), _waitnum(0), _isrunning(false)
{
LOG(LogLevel::INFO) << "ThreadPool Construct()";
}
// 初始化:创建所有线程对象(不启动)
void InitThreadPool()
{
for (int num = 0; num < _threadnum; num++)
{
_threads.emplace_back(
std::bind(&ThreadPool::HandlerTask, this)); // 绑定成员函数
LOG(LogLevel::INFO) << "init thread "
<< _threads.back().Name() << " done";
}
}
// 启动所有线程
void Start()
{
_isrunning = true;
for (auto &thread : _threads)
{
thread.Start();
LOG(LogLevel::INFO) << "start thread " << thread.Name() << "done";
}
}
// 停止线程池(发出退出信号)
void Stop()
{
_mutex.Lock();
_isrunning = false; // 标记线程池退出
_cond.NotifyAll(); // 唤醒所有等待的线程
_mutex.Unlock();
LOG(LogLevel::DEBUG) << "线程池退出中...";
}
// 等待所有线程退出
void Wait()
{
for (auto &thread : _threads)
{
thread.Join();
LOG(LogLevel::INFO) << thread.Name() << " 退出...";
}
}
// 向任务队列中添加任务
bool Enqueue(const T &t)
{
bool ret = false;
_mutex.Lock();
if (_isrunning)
{
_task_queue.push(t);
if (_waitnum > 0)
{
_cond.Notify(); // 有线程在等任务,唤醒一个
}
LOG(LogLevel::DEBUG) << "任务入队列成功";
ret = true;
}
_mutex.Unlock();
return ret;
}
~ThreadPool() {}
private:
int _threadnum; // 线程数量
std::vector<Thread> _threads; // 线程对象容器
std::queue<T> _task_queue; // 任务队列
Mutex _mutex; // 保护任务队列的锁
Cond _cond; // 条件变量(等待/通知任务)
int _waitnum; // 当前等待任务的线程数
bool _isrunning; // 线程池是否在运行
};
编译命令:
bash
g++ main.cc -std=c++17 -lpthread
运行日志示例:
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [62] - ThreadPool Construct()
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [70] - init thread Thread-0 done
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [79] - start thread Thread-0done
[2024-08-04 15:09:29] [INFO] [206342] [ThreadPool.hpp] [28] - Thread-0 is running...
[2024-08-04 15:09:29] [DEBUG] [206342] [ThreadPool.hpp] [109] - 任务入队列成功
[2024-08-04 15:09:29] [DEBUG] [206342] [ThreadPool.hpp] [52] - Thread-0 get a task
this is a task
[2024-08-04 15:09:39] [DEBUG] [206342] [ThreadPool.hpp] [88] - 线程池退出中...
[2024-08-04 15:09:44] [INFO] [206342] [ThreadPool.hpp] [95] - Thread-0 退出...
📌 知识点总结:线程池是怎么设计的?为什么要用线程池?
线程池的核心思想是:预先创建一批线程,它们不干活时就阻塞在条件变量上等待任务;当有任务进来时,唤醒一个线程去执行,执行完再回来继续等。这样避免了频繁创建/销毁线程的开销。设计要点:用一个互斥锁保护任务队列,一个条件变量让线程在没任务时休眠,用
_isrunning标志控制线程池的启停。退出时先把标志设为 false,然后广播唤醒所有线程,让它们自己检查到"要退出了"就主动结束。
3-3 线程安全的单例模式
什么是单例模式
某些类只应该有一个对象(实例),这就叫单例。比如一个男人只能有一个媳妇。在很多服务器开发场景中,经常需要让服务器加载大量数据到内存中,此时往往用一个单例类来管理这些数据。
饿汉方式和懒汉方式
用一个洗碗的例子来理解:
- 饿汉:吃完饭立刻洗碗。下一顿吃的时候可以立刻拿碗吃饭(提前准备好)
- 懒汉:吃完饭把碗放下,下一顿用到碗了再洗(用到时才创建)
懒汉方式最核心的思想是延时加载,可以优化服务器的启动速度。
饿汉方式实现
cpp
template <typename T>
class Singleton {
static T data; // 程序启动时就创建好
public:
static T* GetInstance() {
return &data;
}
};
优点:实现简单,线程安全(初始化发生在 main 之前)
缺点:不管用不用,都会创建对象,影响启动速度
懒汉方式实现(线程不安全版)
cpp
template <typename T>
class Singleton {
static T* inst; // 初始为nullptr
public:
static T* GetInstance() {
if (inst == NULL) {
inst = new T(); // 第一次调用时才创建
}
return inst;
}
};
问题:线程不安全。第一次调用时,如果两个线程同时进入,可能会创建两份对象。
懒汉方式实现(线程安全版)
cpp
// 懒汉模式,线程安全
template <typename T>
class Singleton {
volatile static T* inst; // volatile 防止编译器过度优化
static std::mutex lock;
public:
static T* GetInstance() {
if (inst == NULL) { // 第一层判断:避免不必要的锁竞争
lock.lock(); // 加锁保证线程安全
if (inst == NULL) { // 第二层判断:防止重复创建
inst = new T();
}
lock.unlock();
}
return inst;
}
};
关键点:
- 双重判定(Double-Check):外层 if 避免每次调用都加锁,内层 if 保证只创建一次
- 加锁:保证多线程下只调用一次 new
- volatile:防止编译器过度优化导致读取到过期值
📌 知识点总结:懒汉单例和饿汉单例有什么区别?
饿汉 是程序启动时就创建好对象,好处是实现简单、天然线程安全(初始化在 main 之前),坏处是拖慢启动速度,且如果一直不用就浪费了资源。懒汉是用到时才创建,好处是启动快、不浪费资源,坏处是多线程环境下要考虑线程安全。线程安全的懒汉单例使用"双重判定 + 加锁"的方案:外层 if 判断是否为 nullptr,避免每次调用都拿锁(性能优化);内层 if 在锁保护下再判断一次,确保只创建一次。volatile 关键字防止编译器把变量优化到寄存器导致判断出错。
3-4 单例式线程池
将上面的线程池改造为单例模式,保证整个进程只有一个线程池实例。
cpp
#pragma once
#include <iostream>
#include <vector>
#include <queue>
#include <memory>
#include <pthread.h>
#include "Log.hpp"
#include "Thread.hpp"
#include "Lock.hpp"
#include "Cond.hpp"
using namespace ThreadModule;
using namespace CondModule;
using namespace LockModule;
using namespace LogModule;
const static int gdefaultthreadnum = 10;
template <typename T>
class ThreadPool
{
private:
// 构造函数私有化,外部不能直接创建
ThreadPool(int threadnum = gdefaultthreadnum)
: _threadnum(threadnum), _waitnum(0), _isrunning(false)
{
LOG(LogLevel::INFO) << "ThreadPool Construct()";
}
void InitThreadPool()
{
for (int num = 0; num < _threadnum; num++)
{
_threads.emplace_back(
std::bind(&ThreadPool::HandlerTask, this));
LOG(LogLevel::INFO) << "init thread "
<< _threads.back().Name() << " done";
}
}
void Start()
{
_isrunning = true;
for (auto &thread : _threads)
{
thread.Start();
LOG(LogLevel::INFO) << "start thread " << thread.Name() << "done";
}
}
void HandlerTask()
{
std::string name = GetThreadNameFromNptl();
LOG(LogLevel::INFO) << name << " is running...";
while (true)
{
_mutex.Lock();
while (_task_queue.empty() && _isrunning)
{
_waitnum++;
_cond.Wait(_mutex);
_waitnum--;
}
if (_task_queue.empty() && !_isrunning)
{
_mutex.Unlock();
break;
}
T t = _task_queue.front();
_task_queue.pop();
_mutex.Unlock();
LOG(LogLevel::DEBUG) << name << " get a task";
t();
}
}
// 禁止拷贝和赋值
ThreadPool<T> &operator=(const ThreadPool<T> &) = delete;
ThreadPool(const ThreadPool<T> &) = delete;
public:
// 获取单例对象的静态方法
static ThreadPool<T> *GetInstance()
{
// 双判断方式,有效减少加锁成本
if (nullptr == _instance) // 第一次之后,不需加锁直接返回
{
LockGuard lockguard(_lock);
if (nullptr == _instance) // 真正创建对象
{
_instance = new ThreadPool<T>();
_instance->InitThreadPool();
_instance->Start();
LOG(LogLevel::DEBUG) << "创建线程池单例";
return _instance;
}
}
LOG(LogLevel::DEBUG) << "获取线程池单例";
return _instance;
}
void Stop()
{
_mutex.Lock();
_isrunning = false;
_cond.NotifyAll();
_mutex.Unlock();
LOG(LogLevel::DEBUG) << "线程池退出中...";
}
void Wait()
{
for (auto &thread : _threads)
{
thread.Join();
LOG(LogLevel::INFO) << thread.Name() << " 退出...";
}
}
bool Enqueue(const T &t)
{
bool ret = false;
_mutex.Lock();
if (_isrunning)
{
_task_queue.push(t);
if (_waitnum > 0)
{
_cond.Notify();
}
LOG(LogLevel::DEBUG) << "任务入队列成功";
ret = true;
}
_mutex.Unlock();
return ret;
}
~ThreadPool() {}
private:
int _threadnum;
std::vector<Thread> _threads;
std::queue<T> _task_queue;
Mutex _mutex;
Cond _cond;
int _waitnum;
bool _isrunning;
// 单例相关静态成员
static ThreadPool<T> *_instance;
static Mutex _lock;
};
// 静态成员初始化
template <typename T>
ThreadPool<T> *ThreadPool<T>::_instance = nullptr;
template <typename T>
Mutex ThreadPool<T>::_lock;
测试用例
cpp
#include <iostream>
#include <functional>
#include <unistd.h>
#include "ThreadPool.hpp"
using task_t = std::function<void()>;
void DownLoad()
{
std::cout << "this is a task" << std::endl;
}
int main()
{
ENABLE_CONSOLE_LOG_STRATEGY();
int cnt = 10;
while (cnt)
{
// 通过单例获取线程池对象,添加任务
ThreadPool<task_t>::GetInstance()->Enqueue(DownLoad);
sleep(1);
cnt--;
}
ThreadPool<task_t>::GetInstance()->Stop();
sleep(5);
ThreadPool<task_t>::GetInstance()->Wait();
return 0;
}
📌 知识点总结:单例式线程池有什么好处?怎么保证线程安全?
单例式线程池保证整个程序只有一个 线程池实例,避免重复创建线程池浪费资源。通过双重判定 + 加锁实现线程安全的懒汉单例:外层 if 判断是否已创建(避免每次请求都加锁),内层 if 在锁保护下真正创建对象(保证只创建一次)。构造函数私有化,外部只能通过
GetInstance()静态方法获取线程池对象。在测试代码中,多次调用GetInstance()返回的都是同一个对象,添加任务时直接Enqueue即可。
4. 线程安全和重入问题
基本概念
线程安全
多个线程在访问共享资源时,能够正确地执行,不会相互干扰或破坏彼此的执行结果。多个线程并发执行同一段只有局部变量的代码时,不会出现不同的结果。但操作全局变量或静态变量且没有锁保护时,就容易出现线程安全问题。
重入
同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他执行流再次进入,叫做重入 。一个函数在重入的情况下,运行结果不会出现任何不同或问题,则该函数叫做可重入函数 ,否则叫做不可重入函数。
重入分为两种情况:
- 多线程重入函数:多个线程同时执行同一个函数
- 信号导致一个执行流重复进入函数:信号处理函数中又调用了当前正在执行的函数
常见的线程不安全情况
- 不保护共享变量的函数
- 函数状态随着调用而发生变化的函数
- 返回指向静态变量指针的函数
- 调用了线程不安全函数的函数
常见的不可重入情况
- 调用了
malloc/free函数(用全局链表管理堆) - 调用了标准 I/O 库函数(很多实现以不可重入方式使用全局数据结构)
- 函数体内使用了静态的数据结构
常见的线程安全情况
- 每个线程对全局变量或静态变量只有读取权限,没有写入权限
- 类或接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致执行结果有二义性
常见的可重入情况
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据由调用者提供
- 使用本地数据,或通过制作全局数据的本地拷贝来保护全局数据
可重入与线程安全的联系
- 函数是可重入的,那就是线程安全的
- 函数是不可重入的,就不能由多个线程使用,可能引发线程安全问题
- 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的
可重入与线程安全的区别
- 可重入函数是线程安全函数的一种
- 线程安全不一定是可重入的,而可重入函数一定是线程安全的
- 如果对临界资源的访问加了锁,这个函数是线程安全的,但如果重入时锁还未释放,就会产生死锁,因此是不可重入的
- 线程安全侧重描述线程访问公共资源的安全情况,表现的是并发线程的特点
- 可重入描述一个函数是否能被重复进入,表现的是函数本身的特点
📌 知识点总结:线程安全和可重入有什么区别和联系?
联系 :可重入的函数一定是线程安全的(因为可重入要求更严格,它甚至不能依赖锁)。区别:线程安全侧重"多个线程同时用不会出问题"(可以通过加锁实现),可重入侧重"一个执行流还没执行完,另一个执行流再次进入也不会出问题"(不能依赖锁,因为锁可能已经被自己持有了)。比如一个加锁的保护共享变量的函数,它是线程安全的,但如果它在持有锁时被信号处理函数再次调用,就会死锁------所以它不是可重入的。
5. 常见锁概念
5-1 死锁
什么是死锁

申请一把锁是原子的,但是申请两把就不一定了。

造成的结果是:

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程占用的不会释放的资源,而处于的一种永久等待状态。
例如:线程 A 持有锁 1,线程 B 持有锁 2,A 想申请锁 2,B 想申请锁 1,双方都不释放自己手里的锁------于是就"死"在那了。
5-2 死锁的四个必要条件
-
互斥条件:一个资源每次只能被一个执行流使用
-
请求与保持条件 :一个执行流因请求资源而阻塞时,对已获得的资源保持不放

-
不剥夺条件 :一个执行流已获得的资源,在未使用完之前,不能强行剥夺

-
循环等待条件 :若干执行流之间形成一种头尾相接的循环等待资源的关系

5-3 避免死锁
破坏死锁的四个必要条件
核心思路:破坏循环等待条件。常见方法:
- 资源一次性分配:要么一次拿到所有需要的锁,要么一个都不拿
- 使用超时机制:申请锁时设置超时时间,超时了就放弃已持有的锁
- 加锁顺序一致:所有线程按照相同的顺序加锁(比如总是先锁 mtx1 再锁 mtx2)
示例:不加锁顺序一致会导致的问题
cpp
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
#include <unistd.h>
// 两个共享资源和两把锁
int shared_resource1 = 0;
int shared_resource2 = 0;
std::mutex mtx1, mtx2;
// 同时访问两个共享资源的函数
void access_shared_resources()
{
int cnt = 10000;
while (cnt)
{
++shared_resource1;
++shared_resource2;
cnt--;
}
}
// 模拟多线程并发访问
void simulate_concurrent_access()
{
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i)
{
threads.emplace_back(access_shared_resources);
}
for (auto &thread : threads)
{
thread.join();
}
std::cout << "Shared Resource 1: " << shared_resource1 << std::endl;
std::cout << "Shared Resource 2: " << shared_resource2 << std::endl;
}
int main()
{
simulate_concurrent_access();
return 0;
}
如果不按顺序加锁,结果可能不正确(资源1=94416,资源2=94536,不是期望的100000)。
使用 std::lock 一次性锁定多个互斥量,或者使用 std::unique_lock 配合 std::defer_lock 延迟加锁,可以避免死锁。
cpp
// 正确做法:同时锁定两个互斥量(原子操作)
std::unique_lock<std::mutex> lock1(mtx1, std::defer_lock);
std::unique_lock<std::mutex> lock2(mtx2, std::defer_lock);
std::lock(lock1, lock2); // 一次性锁定两个锁
5-4 避免死锁算法(了解)
- 死锁检测算法:允许死锁发生,但检测到时进行恢复
- 银行家算法:资源分配前判断是否会导致不安全状态
📌 知识点总结:什么是死锁?怎么避免?
死锁就是两个或多个线程互相等待对方释放资源,结果谁也没法继续执行。产生死锁需要同时满足四个条件:互斥、请求与保持、不剥夺、循环等待 。只要破坏其中一个就能避免死锁。最实用的办法是让所有线程按相同的顺序加锁 (比如都先锁 A 再锁 B),或者使用
std::lock一次性申请所有锁。就像两个人要互相交换礼物------最安全的做法是双方同时松手再同时接住,而不是你先拿着自己的礼物等对方先给。
6. STL、智能指针和线程安全
6-1 STL 容器是否是线程安全的?
不是。
原因是 STL 的设计初衷是将性能挖掘到极致,而加锁保证线程安全会对性能造成巨大影响。不同容器的加锁方式也不同(比如哈希表有"锁表"和"锁桶"之分),所以 STL 默认不提供线程安全保证。在多线程环境下使用 STL 容器,需要调用者自行加锁保护。
6-2 智能指针是否是线程安全的?
unique_ptr:只在当前代码块范围内生效,不存在线程安全问题(它是独占所有权的,不共享)shared_ptr:多个对象共用一个引用计数变量,存在线程安全问题。但标准库实现时已经考虑到了这一点,基于**原子操作(CAS)**的方式保证shared_ptr能够高效、原子地操作引用计数
📌 知识点总结:STL 容器和智能指针是线程安全的吗?
STL 容器不是 线程安全的------这是设计上的取舍,为了性能不引入锁的开销。你自己用 STL 容器时需要手动加锁。
unique_ptr是独占的,不存在线程安全问题。shared_ptr的引用计数的修改是原子 的(通过 CAS 实现),所以引用计数本身是线程安全的,但shared_ptr指向的数据并不是自动保护的------多个线程通过shared_ptr读写指向的对象时仍然需要加锁。
7. 其他常见的锁
悲观锁
在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁、写锁、行锁等)。其他线程想要访问数据时会被阻塞挂起。互斥锁就是一种典型的悲观锁。
乐观锁
每次取数据时,总是乐观地认为数据不会被其他线程修改,因此不上锁。但在更新数据前,会判断数据在更新前有没有被其他线程修改过。主要采用两种方式:
- 版本号机制:每次修改版本号加1,更新时对比版本号
- CAS(Compare-And-Swap)操作
CAS 操作
当需要更新数据时,判断当前内存值和之前取到的值是否相等:
- 如果相等,说明数据没有被修改过,用新值更新
- 如果不相等,说明数据已经被别人修改了,更新失败
- 失败后重试,一般是一个自旋的过程(不断重试)
CAS 通常对应 CPU 的一条原子指令(如 cmpxchg),所以它是原子的。
📌 知识点总结:悲观锁和乐观锁有什么区别?
悲观锁 认为一定会有人抢,所以每次操作前先加锁------就像上车前先抢座,把座位占住,别人不能坐。乐观锁认为不太会有人抢,所以先不上锁,但在提交修改时检查一下------就像在座位上留个纸条写了自己的名字,更新时看看名字有没有被改过。如果改了说明有人坐了,那就重试(重新找座位)。CAS 是实现乐观锁的经典方式,它利用 CPU 提供的原子比较-交换指令来判断"值是否被改过"。悲观锁适合写多的场景,乐观锁适合读多的场景。
总结:线程同步与互斥全景图
本讲内容可以归纳为以下几个层次:
| 层次 | 核心概念 | 关键工具 |
|---|---|---|
| 互斥 | 保护共享资源,防止数据竞争 | 互斥量(mutex) |
| 同步 | 控制线程执行顺序,避免饥饿 | 条件变量、信号量 |
| 模型 | 生产-消费模式 | BlockingQueue、环形队列 |
| 架构 | 线程管理 | 线程池、单例模式 |
| 安全 | 全面理解并发安全 | 可重入、死锁、CAS |
