【Linux C/C++ 学习笔记:线程池与日志系统】
Linux C/C++ 学习笔记:线程池与日志系统
这部分内容是从“线程同步 → 生产者消费者 → 线程池 → 单例模式 → RAII → 日志系统 → C++ 面向对象设计”逐步串起来的。
阅读这份笔记时不要把它当成知识点清单,而应该把它理解成:为什么需要这些东西,以及它们是怎么一步一步组合起来的。
一、从生产者消费者模型理解线程池
前面学习线程同步时,我们已经知道:
多个线程同时访问共享资源,如果不进行同步,就可能出现数据竞争。
线程池其实就是把这个问题进一步工程化。
假设程序不断产生任务:
任务1
任务2
任务3
任务4
...
最简单的想法是:
来一个任务
↓
创建一个线程
↓
执行任务
↓
销毁线程
但是如果任务非常多,就会频繁创建和销毁线程,成本比较高。
于是我们可以反过来:
程序启动
↓
提前创建多个线程
↓
线程一直存在
↓
等待任务
↓
拿到任务就执行
↓
执行完继续等待
这就是线程池。
而“任务从哪里来”呢?
我们准备一个共享任务队列:
生产者
│
│ Enqueue(task)
↓
┌─────────────┐
│ 任务队列 │
│ std::queue │
└──────┬──────┘
│
┌────────┼────────┐
↓ ↓ ↓
线程1 线程2 线程3
│ │ │
↓ ↓ ↓
执行任务 执行任务 执行任务
所以:
线程池的本质,就是一个多线程的生产者消费者模型。
生产者负责把任务放进任务队列,消费者(工作线程)负责从任务队列取任务并执行。
二、为什么线程池需要互斥锁?
任务队列是所有线程共享的:
std::queue<T> _taskq;
生产者可能同时:
_taskq.push(task);
消费者可能同时:
_taskq.front();
_taskq.pop();
如果多个线程同时修改 std::queue,就可能发生数据竞争。
因此:
任务队列
│
└── 共享资源
↓
互斥锁保护
也就是说,访问任务队列的临界区必须加锁。
但问题又来了:
如果没有任务,消费者难道要一直拿着锁检查队列吗?
当然不能。
于是就引出了条件变量。
三、条件变量:让没有任务的线程睡眠
假设线程池暂时没有任务。
如果线程这样写:
while (_taskq.empty())
{
// 不断检查
}
这叫忙等(busy waiting)。
线程虽然没有真正执行任务,但是一直占用 CPU。
更合理的方式是:
任务队列为空
↓
线程进入等待
↓
释放互斥锁
↓
线程睡眠
↓
生产者加入任务
↓
唤醒一个消费者
↓
消费者重新获得锁
↓
检查任务队列
这就是条件变量的作用。
线程池中的核心代码:
while (_taskq.empty() && _isrunning)
{
_sleepernum++;
_cond.Wait(_mutex);
_sleepernum--;
}
这里有两个条件:
_taskq.empty()
表示:
当前没有任务。
以及:
_isrunning
表示:
线程池还没有要求退出。
只有:
没有任务
+
线程池还在运行
两个条件同时成立,线程才应该安心睡眠。
四、为什么 wait() 会和互斥锁一起使用?
这是条件变量最容易被问到的地方。
假设线程已经拿到了 _mutex:
LockGuard lockguard(_mutex);
然后发现:
_taskq.empty()
如果线程直接睡眠,却没有释放 _mutex,生产者就无法获得这把锁,也就无法向任务队列中加入任务。
因此 wait() 必须完成一个很关键的操作:
线程持有 mutex
↓
调用 wait()
↓
释放 mutex
↓
进入睡眠
↓
被唤醒
↓
重新竞争 mutex
↓
获得 mutex
↓
wait() 返回
所以可以记住:
条件变量的 wait 会在等待期间释放互斥锁,被唤醒后重新竞争这把锁。
这也是为什么条件变量通常和互斥锁成对出现。
五、为什么这里必须使用 while,不能简单使用 if?
线程池里经常看到:
while (_taskq.empty() && _isrunning)
{
_cond.Wait(_mutex);
}
而不是:
if (_taskq.empty() && _isrunning)
{
_cond.Wait(_mutex);
}
这是非常典型的面试题。
原因主要有两个。
1. 虚假唤醒
条件变量允许线程在没有真正获得目标条件的情况下被唤醒。
所以:
线程被唤醒 ≠ 条件一定满足。
醒来以后必须重新检查。
2. 多个线程竞争同一个任务
例如任务队列只有一个任务:
任务队列:[Task1]
线程A:等待
线程B:等待
线程C:等待
如果某次唤醒导致 A、B 都重新竞争:
A 获得锁
↓
取走 Task1
↓
队列为空
↓
释放锁
B 获得锁
↓
发现队列已经空了
因此 B 不能认为:
“我刚才被唤醒,所以肯定有任务。”
必须重新检查:
while (_taskq.empty())
{
_cond.Wait(_mutex);
}
所以面试可以直接回答:
条件变量被唤醒后,条件不一定满足,因此需要使用 while 循环重新检查条件。
六、生产者和消费者到底分别做什么?
生产者:Enqueue
生产者把任务放进队列:
Enqueue(task);
逻辑是:
判断线程池是否运行
↓
加锁
↓
任务进入队列
↓
如果存在休眠线程
↓
唤醒一个消费者
↓
解锁
消费者:HandlerTask
工作线程不断:
加锁
↓
检查任务队列
↓
没有任务 → wait
↓
有任务 → 取出任务
↓
解锁
↓
执行任务
↓
再次循环
于是整个线程池就形成了完整的生产者消费者模型。
七、为什么取出任务以后必须尽快释放锁?
这是线程池设计中非常重要的一点。
假设:
LockGuard lockguard(_mutex);
t = _taskq.front();
_taskq.pop();
t();
看起来没有问题,但实际上有一个很严重的问题:
执行任务的时候还持有互斥锁。
如果 t() 执行 10 秒,那么:
线程1:
拿锁
↓
取任务
↓
执行10秒
↓
释放锁
这10秒期间,其他线程无法访问任务队列。
线程池虽然有多个线程,但实际上又被这把锁限制住了。
正确思路是:
{
LockGuard lockguard(_mutex);
t = _taskq.front();
_taskq.pop();
}
t();
也就是:
共享资源操作
↓
加锁
↓
取出任务
↓
立刻解锁
↓
执行任务
为什么这样可以?
因为:
_taskq
是共享资源,而:
t
在任务被取出来以后,就属于当前工作线程自己的局部变量。
所以:
只需要保护任务队列,不需要在执行任务的整个过程中持有队列锁。
这就是所谓:
缩小临界区 / 缩小锁的粒度。
八、signal 和 broadcast
任务来了,通常不需要把所有线程都叫醒。
例如:
线程1:睡眠
线程2:睡眠
线程3:睡眠
线程4:睡眠
线程5:睡眠
来了1个任务
只需要:
_cond.Signal();
唤醒一个消费者即可。
如果来了很多任务,可以不断唤醒线程。
而 Broadcast() 是:
把所有正在等待的线程都唤醒。
它在线程池停止的时候尤其重要。
九、为什么 Stop 的时候必须唤醒所有线程?
这是线程池最经典的面试题之一。
假设:
线程1:Wait()
线程2:Wait()
线程3:Wait()
线程4:Wait()
线程5:Wait()
此时任务队列为空。
主线程执行:
_isrunning = false;
如果什么都不做,那么这些线程仍然睡在:
_cond.Wait(_mutex);
它们根本没有机会重新检查:
_isrunning
于是后面:
thread.Join();
就可能一直等。
所以停止线程池必须:
_isrunning = false;
WakeUpAllThread();
完整过程:
Stop()
↓
_isrunning = false
↓
Broadcast()
↓
所有休眠线程被唤醒
↓
重新获得 mutex
↓
检查:
!_isrunning && _taskq.empty()
↓
退出线程
↓
Join()
因此:
Stop 负责发出退出通知并唤醒阻塞线程,Join 负责等待线程真正退出。
十、线程池的退出为什么还要检查任务队列?
线程池停止时不能简单:
if (!_isrunning)
break;
因为可能出现:
_isrunning = false
_taskq 里面还有任务
如果此时直接退出:
队列里的任务就丢失了。
所以代码通常使用:
if (!_isrunning && _taskq.empty())
{
break;
}
这意味着:
线程池已经停止运行,并且任务队列已经处理完了,线程才真正退出。
这是一种比较合理的优雅退出思路。
十一、线程池中的 _sleepernum
线程池会记录:
int _sleepernum;
表示:
当前正在等待条件变量的线程数量。
例如:
5个工作线程
线程1:工作
线程2:sleep
线程3:sleep
线程4:sleep
线程5:sleep
_sleepernum = 4
任务到来时:
if (_sleepernum > 0)
{
_cond.Signal();
}
就可以只唤醒一个休眠线程。
这里体现的是一种优化:
有任务就尽量唤醒一个能够处理任务的线程,而不是每次都广播所有线程。
十二、线程池生命周期
线程池完整生命周期可以理解成:
构造 ThreadPool
↓
创建 Thread 对象
↓
Start()
↓
工作线程开始执行 HandlerTask()
↓
等待任务
↓
获取任务并执行
↓
...
↓
Stop()
↓
Broadcast()
↓
休眠线程被唤醒
↓
线程退出
↓
Join()
↓
线程池结束
这里一定要区分:
Start
让线程真正开始运行。
Stop
告诉线程池:“不要再继续运行了”。
Join
主线程等待工作线程真正结束。
十三、为什么线程池经常使用单例?
如果程序到处都创建线程池:
ThreadPool A → 5个线程
ThreadPool B → 5个线程
ThreadPool C → 5个线程
线程数量会不断增加。
通常一个程序只需要一个统一的任务调度中心,所以可以设计为单例:
ThreadPool<T>::GetInstance();
这样:
整个程序
↓
唯一线程池
↓
统一任务队列
↓
统一工作线程
十四、懒汉单例和双重检查
线程池采用的是懒汉思想:
第一次使用的时候才创建对象。
例如:
static ThreadPool<T>* GetInstance()
{
if (inc == nullptr)
{
LockGuard lockguard(_lock);
if (inc == nullptr)
{
inc = new ThreadPool<T>();
inc->Start();
}
}
return inc;
}
这里有两次:
if (inc == nullptr)
这叫双重检查。
为什么?
假设两个线程同时第一次调用:
线程A ─┐
├── 都发现 inc == nullptr
线程B ─┘
如果没有锁:
A:创建对象
B:也创建对象
单例就被破坏了。
加入锁:
A → 加锁 → 创建 → 解锁
B → 等待 → 获得锁
↓
再检查一次
↓
已经存在
↓
不再创建
第一次检查的意义:
对象已经存在时,不需要每次都加锁。
第二次检查的意义:
防止多个线程第一次同时发现对象不存在而重复创建。
所以:
双重检查兼顾了线程安全和运行效率。
十五、为什么单例要禁止拷贝?
如果允许:
ThreadPool a;
ThreadPool b = a;
那么就产生了第二个线程池。
这与“单例”矛盾。
所以:
ThreadPool(const ThreadPool<T>&) = delete;
ThreadPool<T>& operator=(const ThreadPool<T>&) = delete;
禁止:
- 拷贝构造
- 赋值
这是单例模式常见写法。
十六、完整线程池代码
#pragma once
#include <iostream>
#include <vector>
#include <queue>
#include <pthread.h>
#include "Log.hpp"
#include "Thread.hpp"
#include "Cond.hpp"
#include "Mutex.hpp"
namespace ThreadPoolModule
{
using namespace ThreadModule;
using namespace LogModule;
using namespace CondModule;
using namespace MutexModule;
static const int gnum = 5;
template <typename T>
class ThreadPool
{
private:
ThreadPool(int num = gnum)
: _num(num)
, _isrunning(false)
, _sleepernum(0)
{
for (int i = 0; i < num; ++i)
{
_threads.emplace_back(
[this]()
{
HandlerTask();
}
);
LOG(LogLevel::INFO)
<< "create new thread success";
}
}
// 禁止拷贝
ThreadPool(const ThreadPool<T>&) = delete;
ThreadPool<T>& operator=(const ThreadPool<T>&) = delete;
// 唤醒所有休眠线程
void WakeUpAllThread()
{
LockGuard lockguard(_mutex);
if (_sleepernum > 0)
{
_cond.Broadcast();
}
LOG(LogLevel::INFO)
<< "唤醒所有休眠线程";
}
// 唤醒一个休眠线程
void WakeUpOne()
{
if (_sleepernum > 0)
{
_cond.Signal();
LOG(LogLevel::INFO)
<< "唤醒一个休眠线程";
}
}
// 工作线程真正执行的函数
void HandlerTask()
{
char name[1024] = {0};
pthread_getname_np(
pthread_self(),
name,
sizeof(name)
);
while (true)
{
T task;
{
LockGuard lockguard(_mutex);
// 没有任务,并且线程池还在运行
// 工作线程进入等待状态
while (_taskq.empty() && _isrunning)
{
++_sleepernum;
_cond.Wait(_mutex);
--_sleepernum;
}
// 线程池已经停止,并且任务队列为空
// 当前线程可以退出
if (!_isrunning && _taskq.empty())
{
LOG(LogLevel::INFO)
<< name
<< "退出:线程池停止且任务队列为空";
break;
}
// 从共享任务队列取出任务
task = _taskq.front();
_taskq.pop();
}
// 锁已经释放
// task 已经属于当前线程,不需要继续持锁
task();
}
}
public:
static ThreadPool<T>* GetInstance()
{
// 第一次检查
if (_instance == nullptr)
{
LockGuard lockguard(_lock);
LOG(LogLevel::DEBUG)
<< "获取线程池单例";
// 第二次检查
if (_instance == nullptr)
{
LOG(LogLevel::DEBUG)
<< "首次使用线程池,创建单例";
_instance = new ThreadPool<T>();
_instance->Start();
}
}
return _instance;
}
// 启动线程池
void Start()
{
if (_isrunning)
{
return;
}
_isrunning = true;
for (auto& thread : _threads)
{
thread.Start();
LOG(LogLevel::INFO)
<< "start new thread success: "
<< thread.Name();
}
}
// 停止线程池
void Stop()
{
if (!_isrunning)
{
return;
}
// 通知线程池停止
_isrunning = false;
// 唤醒可能正在 Wait 的工作线程
WakeUpAllThread();
}
// 等待所有工作线程退出
void Join()
{
for (auto& thread : _threads)
{
thread.Join();
}
}
// 添加任务
bool Enqueue(const T& task)
{
if (!_isrunning)
{
return false;
}
{
LockGuard lockguard(_mutex);
_taskq.push(task);
}
// 有任务就唤醒一个休眠线程
WakeUpOne();
return true;
}
~ThreadPool() = default;
private:
std::vector<Thread> _threads;
int _num;
// 共享任务队列
std::queue<T> _taskq;
// 条件变量
Cond _cond;
// 保护任务队列
Mutex _mutex;
// 线程池是否运行
bool _isrunning;
// 当前休眠线程数量
int _sleepernum;
// 单例对象
static ThreadPool<T>* _instance;
// 保护单例创建过程
static Mutex _lock;
};
template <typename T>
ThreadPool<T>* ThreadPool<T>::_instance = nullptr;
template <typename T>
Mutex ThreadPool<T>::_lock;
}
十七、从线程池自然过渡到日志系统
线程池解决的是:
多个线程如何高效地执行任务。
但是线程一多,就会产生另一个问题:
线程1 → LOG()
线程2 → LOG()
线程3 → LOG()
线程4 → LOG()
如果多个线程同时输出日志:
线程1:任务开始
线程2:任务开始
线程1:任务结束
线程3:任务开始
多个线程可能同时访问同一个输出资源。
所以日志系统也必须考虑:
线程安全。
这就是为什么在日志代码里又看到了:
Mutex
LockGuard
你会发现:
前面学的线程同步知识,在日志系统里又真正用起来了。
十八、日志系统到底想解决什么问题?
如果每次打印日志都这样:
std::cout << ...
那么调用者需要自己负责:
- 时间
- 日志等级
- 文件名
- 行号
- 进程 ID
- 输出位置
- 加锁
- 文件打开
- 文件关闭
调用起来非常麻烦。
所以日志系统希望做到:
LOG(LogLevel::DEBUG)
<< "server start, port="
<< 8080;
调用者只关心:
我要记录什么。
至于:
记录到哪里、怎么格式化、怎么加锁
全部交给日志系统。
十九、Logger、LogMessage、LogStrategy 为什么要分开?
日志系统可以拆成三个角色:
Logger
│
│ 管理日志流程
↓
LogMessage
│
│ 拼接日志
↓
LogStrategy
/ \
/ \
Console File
三个类职责不同。
Logger
负责:
管理当前使用哪一种日志策略。
LogMessage
负责:
形成一条完整的日志消息。
LogStrategy
负责:
具体把日志输出到哪里。
这其实体现了面向对象设计里的一个重要原则:
一个类尽量只负责一类事情。
二十、策略模式
为什么不直接让 Logger 负责:
if (console)
{
cout...
}
else if (file)
{
ofstream...
}
因为以后可能还需要:
控制台
文件
网络
数据库
远程日志服务器
Logger 会越来越臃肿。
所以定义抽象接口:
class LogStrategy
{
public:
virtual ~LogStrategy() = default;
virtual void SyncLog(
const std::string& message
) = 0;
};
然后:
LogStrategy
│
├── ConsoleLogStrategy
│
└── FileLogStrategy
Logger 只保存:
std::unique_ptr<LogStrategy>
这样就可以在运行过程中切换:
EnableConsoleLogStrategy();
EnableFileLogStrategy();
这就是:
策略模式:把可以变化的行为封装成独立策略。
二十一、多态在日志系统里是怎么发生的?
例如:
std::unique_ptr<LogStrategy> _fflush_strategy;
实际可能指向:
ConsoleLogStrategy
也可能指向:
FileLogStrategy
当执行:
_fflush_strategy->SyncLog(message);
到底调用哪个 SyncLog()?
由实际对象类型决定。
这就是:
运行时多态。
所以这里其实把你之前学的 C++ 面向对象知识真正用起来了:
抽象基类
↓
纯虚函数
↓
派生类
↓
virtual
↓
基类指针/智能指针
↓
运行时多态
二十二、为什么基类析构函数要是虚函数?
日志策略是多态基类:
class LogStrategy
{
public:
virtual ~LogStrategy() = default;
};
为什么?
因为可能存在:
LogStrategy* p = new FileLogStrategy;
delete p;
如果基类析构函数不是虚函数,就可能无法正确调用派生类析构函数。
因此:
作为多态基类时,析构函数通常应该声明为 virtual。
这是 C++ 面试高频题。
二十三、unique_ptr 在日志系统中的作用
Logger 中:
std::unique_ptr<LogStrategy> _fflush_strategy;
它表达了一个非常明确的所有权关系:
Logger
│
└── 独占拥有
↓
LogStrategy
切换策略时:
_fflush_strategy =
std::make_unique<FileLogStrategy>();
旧策略对象会自动释放。
因此不需要:
delete
这就是智能指针和 RAII 的结合。
二十四、RAII:为什么 C++ 特别喜欢它?
RAII 可以简单理解成:
让对象的生命周期管理资源。
例如锁:
{
LockGuard guard(_mutex);
// 临界区
}
构造:
LockGuard
↓
加锁
析构:
LockGuard
↓
解锁
智能指针也是一样:
std::unique_ptr<LogStrategy>
对象离开作用域:
unique_ptr 析构
↓
自动释放对象
所以 RAII 不只是一个“锁的技巧”,而是 C++ 非常重要的资源管理思想。
二十五、日志等级为什么使用 enum class?
日志等级:
enum class LogLevel
{
DEBUG,
INFO,
WARNING,
ERROR,
FATAL
};
它表示:
DEBUG
INFO
WARNING
ERROR
FATAL
使用时:
LogLevel::DEBUG
相比普通 enum:
enum LogLevel
{
DEBUG,
INFO
};
enum class 是强类型枚举,不容易和其他枚举值发生名字冲突。
所以现代 C++ 中通常更推荐:
enum class
二十六、日志的一条消息是怎么形成的?
调用:
LOG(LogLevel::DEBUG)
<< "value="
<< 100
<< ", name="
<< "Julian";
实际上不是直接输出。
而是:
LOG()
↓
创建 LogMessage 临时对象
↓
构造日志前缀
↓
operator<<("value=")
↓
operator<<(100)
↓
operator<<(", name=")
↓
operator<<("Julian")
↓
完整日志形成
↓
临时对象生命周期结束
↓
LogMessage 析构
↓
调用 Logger 的日志策略
↓
真正输出
这个设计非常值得理解。
二十七、为什么 operator<< 返回引用?
例如:
LogMessage& operator<<(const T& info)
{
std::stringstream ss;
ss << info;
_loginfo += ss.str();
return *this;
}
关键:
return *this;
返回当前对象的引用。
所以:
log << "hello" << 123 << 3.14;
可以不断作用在同一个对象上。
这就是链式调用。
二十八、为什么 LogMessage 要保存 Logger 的引用?
LogMessage 的职责:
拼接日志。
Logger 的职责:
管理输出策略。
因此 LogMessage 最后需要:
_logger._fflush_strategy->SyncLog(_loginfo);
所以 LogMessage 保存:
Logger& _logger;
这里使用引用意味着:
LogMessage 不拥有 Logger,只是使用 Logger。
它们的关系可以理解成:
Logger
↑
│ 引用
│
LogMessage
而不是:
LogMessage
└── new Logger
否则就会产生错误的对象所有权关系。
二十九、为什么日志刷新放到析构函数?
这是整套日志系统里非常有 C++ 味道的一部分。
例如:
LOG(LogLevel::DEBUG)
<< "hello"
<< 123
<< 3.14;
这里创建的是一个临时 LogMessage。
表达式执行过程中:
hello
↓
123
↓
3.14
不断拼接。
当整个表达式结束:
临时对象生命周期结束
↓
调用析构函数
↓
SyncLog()
所以析构时日志已经完整。
这相当于利用:
C++ 临时对象生命周期 + RAII
实现了“自动刷新”。
三十、日志为什么需要加锁?
多线程环境:
线程1 ──┐
线程2 ──┤
线程3 ──┤ → 同一个日志文件
线程4 ──┘
如果不加锁,可能导致日志内容互相交错。
所以文件输出策略中:
LockGuard lockguard(_mutex);
保护:
ofstream
相关的写入过程。
控制台输出同样需要考虑多个线程同时写的问题。
因此:
线程池让并发出现了,日志系统就必须考虑并发日志输出。
这就是两个模块之间的实际联系。
三十一、文件日志为什么使用 ios::app?
std::ofstream out(
filename,
std::ios::app
);
app:
append,追加。
意思是:
已有日志:
A
B
C
新的日志:
D
最终:
A
B
C
D
不会因为程序重新启动而直接覆盖之前的日志。
三十二、filesystem 的作用
日志文件需要先确认目录是否存在:
std::filesystem::exists(_path)
如果不存在:
std::filesystem::create_directories(_path)
创建目录。
这是 C++17 filesystem 提供的文件系统操作能力。
三十三、__FILE__ 和 __LINE__
日志通常需要记录:
[时间]
[等级]
[进程ID]
[源文件]
[行号]
[日志内容]
因此:
#define LOG(level) logger(level, __FILE__, __LINE__)
其中:
__FILE__
表示当前源文件。
__LINE__
表示当前代码所在行号。
例如:
LOG(LogLevel::DEBUG) << "hello";
最终可以得到类似:
[2026-08-25 19:30:00]
[DEBUG]
[12345]
[main.cpp]
[20]
-hello
这对排查问题非常有帮助。
三十四、完整日志系统代码
改代码依赖由C++封装的 Mutex.hpp 和 LockGuard 。
#pragma once
#include <iostream>
#include <string>
#include <filesystem>
#include <fstream>
#include <sstream>
#include <memory>
#include <ctime>
#include <cstdio>
#include <unistd.h>
#include "Mutex.hpp"
namespace LogModule
{
// ============================================================
// 日志策略基类
// ============================================================
class LogStrategy
{
public:
virtual ~LogStrategy() = default;
// 纯虚函数:由具体日志策略实现
virtual void SyncLog(
const std::string& message
) = 0;
};
// ============================================================
// 控制台日志策略
// ============================================================
class ConsoleLogStrategy : public LogStrategy
{
public:
ConsoleLogStrategy() = default;
void SyncLog(
const std::string& message
) override
{
LockGuard lockguard(_mutex);
std::cout << message << "\r\n";
}
~ConsoleLogStrategy() override = default;
private:
Mutex _mutex;
};
// ============================================================
// 文件日志策略
// ============================================================
const std::string defaultpath = "./log";
const std::string defaultfile = "my.log";
const std::string gsep = "\r\n";
class FileLogStrategy : public LogStrategy
{
public:
FileLogStrategy(
const std::string& path = defaultpath,
const std::string& file = defaultfile
)
: _path(path)
, _file(file)
{
if (std::filesystem::exists(_path))
{
return;
}
try
{
std::filesystem::create_directories(_path);
}
catch (const std::filesystem::filesystem_error& e)
{
std::cerr << e.what() << '\n';
}
}
void SyncLog(
const std::string& message
) override
{
LockGuard lockguard(_mutex);
std::string filename =
_path +
(_path.back() == '/' ? "" : "/") +
_file;
// 追加方式打开文件
std::ofstream out(
filename,
std::ios::app
);
if (!out.is_open())
{
return;
}
out << message << gsep;
}
~FileLogStrategy() override = default;
private:
std::string _path;
std::string _file;
Mutex _mutex;
};
// ============================================================
// 日志等级
// ============================================================
enum class LogLevel
{
DEBUG,
INFO,
WARNING,
ERROR,
FATAL
};
std::string Level2Str(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 GetTimeStamp()
{
time_t curr = time(nullptr);
struct tm curr_tm;
localtime_r(
&curr,
&curr_tm
);
char timebuffer[128];
snprintf(
timebuffer,
sizeof(timebuffer),
"%d-%d-%d %d:%d:%d",
curr_tm.tm_year + 1900,
curr_tm.tm_mon + 1,
curr_tm.tm_mday,
curr_tm.tm_hour,
curr_tm.tm_min,
curr_tm.tm_sec
);
return timebuffer;
}
// ============================================================
// Logger
// ============================================================
class Logger
{
public:
Logger()
{
EnableConsoleLogStrategy();
}
// 切换为文件日志策略
void EnableFileLogStrategy()
{
_fflush_strategy =
std::make_unique<FileLogStrategy>();
}
// 切换为控制台日志策略
void EnableConsoleLogStrategy()
{
_fflush_strategy =
std::make_unique<ConsoleLogStrategy>();
}
// ========================================================
// LogMessage
//
// 负责形成一条完整日志
// ========================================================
class LogMessage
{
public:
LogMessage(
LogLevel level,
const std::string& src_name,
int line_number,
Logger& logger
)
: _curr_time(GetTimeStamp())
, _level(level)
, _pid(getpid())
, _src_name(src_name)
, _line_number(line_number)
, _logger(logger)
{
std::stringstream ss;
ss << "["
<< _curr_time
<< "]"
<< "["
<< Level2Str(_level)
<< "]"
<< "["
<< _pid
<< "]"
<< "["
<< _src_name
<< "]"
<< "["
<< _line_number
<< "]"
<< "-";
_loginfo = ss.str();
}
// ====================================================
// operator<<
//
// 支持:
//
// LOG(...) << "hello" << 123 << 3.14;
// ====================================================
template <typename T>
LogMessage& operator<<(
const T& info
)
{
std::stringstream ss;
ss << info;
_loginfo += ss.str();
// 返回当前对象引用
// 支持链式调用
return *this;
}
// ====================================================
// 析构时刷新日志
// ====================================================
~LogMessage()
{
if (_logger._fflush_strategy)
{
_logger._fflush_strategy
->SyncLog(_loginfo);
}
}
private:
std::string _curr_time;
LogLevel _level;
pid_t _pid;
std::string _src_name;
int _line_number;
// 一条完整日志
std::string _loginfo;
// 引用 Logger
Logger& _logger;
};
// ========================================================
// operator()
//
// 允许:
//
// logger(level, file, line)
// ========================================================
LogMessage operator()(
LogLevel level,
const std::string& name,
int line
)
{
return LogMessage(
level,
name,
line,
*this
);
}
~Logger() = default;
private:
// Logger 独占当前日志策略
std::unique_ptr<LogStrategy>
_fflush_strategy;
};
// ============================================================
// 全局 Logger
// ============================================================
Logger logger;
// ============================================================
// 日志宏
// ============================================================
#define LOG(level) \
logger(level, __FILE__, __LINE__)
#define Enable_Console_Log_Strategy() \
logger.EnableConsoleLogStrategy()
#define Enable_File_Log_Strategy() \
logger.EnableFileLogStrategy()
}
三十五、日志系统一次调用到底发生了什么?
看到:
LOG(LogLevel::DEBUG)
<< "Hello, log"
<< 3.14;
不要只记“这是日志”。
应该能在脑子里走完整流程:
LOG(LogLevel::DEBUG)
↓
logger(
LogLevel::DEBUG,
__FILE__,
__LINE__
)
↓
Logger::operator()
↓
创建临时 LogMessage
↓
LogMessage 构造函数
↓
生成:
[时间][DEBUG][PID][文件][行号]-
↓
operator<<("Hello, log")
↓
_loginfo += "Hello, log"
↓
operator<<(3.14)
↓
_loginfo += "3.14"
↓
整条表达式结束
↓
临时 LogMessage 析构
↓
_logger._fflush_strategy
↓
SyncLog(_loginfo)
↓
Console / File
这才是这套日志代码真正值得学习的地方。
三十六、线程安全和可重入到底是什么关系?
这是另一个容易混淆的知识点。
线程安全
关注的是:
多个线程同时执行。
例如:
线程A → 函数
线程B → 函数
线程C → 函数
如果仍然能保证数据正确,就是线程安全。
可重入
关注的是:
同一个执行上下文中,函数能否被再次进入。
例如函数执行过程中再次调用自己,或者被其他执行路径重新进入。
两者不是完全等价。
常见结论:
可重入函数一定是线程安全的,但线程安全函数不一定是可重入的。
因为线程安全可以通过锁实现:
mutex.lock();
...
mutex.unlock();
但如果同一个线程在持有锁的时候再次进入这个函数,又尝试获取同一把非递归锁,就可能出现问题。
三十七、死锁
线程池和日志系统都涉及锁,所以必须理解死锁。
最典型的例子:
线程A:
拿到锁1
等待锁2
线程B:
拿到锁2
等待锁1
于是:
A → 等 B
B → 等 A
谁也无法继续。
经典死锁条件包括:
- 互斥条件
- 请求并保持
- 不可剥夺
- 循环等待
四个条件同时满足,就可能发生死锁。
三十八、怎么避免死锁?
方法一:固定加锁顺序
所有线程都:
锁1 → 锁2
不要出现:
线程A:锁1 → 锁2
线程B:锁2 → 锁1
方法二:缩小临界区
只锁真正需要保护的共享资源。
例如线程池:
取任务
↓
解锁
↓
执行任务
方法三:使用 RAII
LockGuard guard(mutex);
让对象生命周期自动管理锁。
方法四:不要在持锁状态下做耗时操作
例如不要:
加锁
↓
网络请求
↓
磁盘 IO
↓
耗时计算
↓
解锁
三十九、把这两个模块真正串起来
现在重新看线程池和日志系统,就会发现它们并不是两个孤立的知识点。
线程池:
多个线程
↓
共享任务队列
↓
Mutex
↓
Cond
↓
生产者消费者
日志:
多个线程
↓
共享输出资源
↓
Mutex
↓
Logger
↓
Strategy
它们共同依赖:
线程
↓
共享资源
↓
同步
↓
锁
↓
RAII
同时,日志系统又把前面学过的 C++ 面向对象知识串了起来:
抽象类
↓
纯虚函数
↓
继承
↓
多态
↓
策略模式
↓
unique_ptr
↓
RAII
线程池则把 Linux 线程知识串起来:
线程
↓
互斥量
↓
条件变量
↓
生产者消费者
↓
线程池
↓
单例
所以这部分真正的学习目标不是:
“记住 ThreadPool 有哪些成员变量。”
而是:
理解一个多线程程序是如何把线程同步、任务调度、资源管理和面向对象设计组合起来的。
四十、面试时最值得重点掌握的内容
如果时间有限,优先掌握下面这些。
第一优先级:线程池核心原理
一定要能回答:
1. 什么是线程池?
预先创建一组线程,通过任务队列复用线程执行任务,减少线程频繁创建和销毁的开销。
2. 为什么线程池是生产者消费者模型?
生产者向任务队列添加任务,消费者线程从任务队列取任务执行,任务队列是二者之间的共享缓冲区。
3. 为什么使用条件变量?
没有任务时让消费者阻塞睡眠,避免忙等浪费 CPU;有任务时唤醒消费者。
4. 为什么 wait() 要配合 while?
防止虚假唤醒,并处理多个线程竞争同一个任务的情况。唤醒后必须重新检查条件。
5. 为什么 Stop 要 Broadcast?
因为工作线程可能阻塞在 wait 上,如果不唤醒,它们无法检查退出条件,可能导致 Join 一直等待。
6. 为什么取任务以后立即释放锁?
因为任务本身已经属于当前线程,执行任务不需要访问共享任务队列。尽快释放锁可以缩小临界区,提高并发效率。
四十一、第二优先级:C++设计思想
重点掌握:
RAII
unique_ptr
虚函数
纯虚函数
多态
策略模式
单例模式
尤其是:
为什么这么设计?
而不是只会说定义。
例如面试官问:
为什么日志系统要设计 LogStrategy?
不要只回答:
“因为这是策略模式。”
更好的回答是:
“日志输出方式是容易变化的部分,可能输出到控制台,也可能输出到文件。把这部分抽象成 LogStrategy,可以让 Logger 依赖抽象接口,通过多态替换具体输出策略,从而降低模块之间的耦合。”
这才是真正理解了。
四十二、最终知识地图
Linux / C++
│
┌───────────┴───────────┐
│ │
线程 C++面向对象
│ │
互斥量 继承
│ │
条件变量 多态
│ │
生产者消费者 纯虚函数
│ │
线程池 抽象基类
│ │
单例模式 策略模式
│ │
└───────────┬───────────┘
│
RAII
│
智能指针 / LockGuard
│
↓
日志系统
│
┌──────────┴──────────┐
↓ ↓
Console File
│ │
└──────────┬──────────┘
↓
多线程安全
四十三、这一阶段应该达到什么程度?
不要求你现在把整个线程池代码默写出来。
真正应该达到的是:
看到代码,能够解释每一块为什么存在。
例如看到:
std::queue<T> _taskq;
你应该知道:
这是生产者消费者之间共享的任务缓冲区。
看到:
Mutex _mutex;
应该想到:
任务队列是共享资源,需要互斥保护。
看到:
Cond _cond;
应该想到:
没有任务时消费者不能忙等,需要睡眠等待;任务到来后由生产者唤醒。
看到:
while (_taskq.empty() && _isrunning)
应该想到:
“没有任务”和“线程池仍然运行”共同决定线程是否应该等待。
看到:
if (!_isrunning && _taskq.empty())
应该想到:
线程池停止后仍然要处理完队列中的任务,然后退出。
看到:
std::unique_ptr<LogStrategy>
应该想到:
Logger 依赖抽象策略,通过智能指针管理具体日志策略,实现运行时多态。
看到:
return *this;
应该想到:
为了让
operator<<支持链式调用。
看到:
~LogMessage()
应该想到:
利用临时对象生命周期,在完整日志拼接完成后自动刷新。
四十四、一句话总结
线程池解决的是“如何复用和调度线程”,生产者消费者模型解决的是“如何在线程之间安全传递任务”,互斥锁和条件变量负责同步,Stop/Join 负责线程生命周期;单例保证线程池实例唯一;日志系统则进一步利用 RAII、智能指针、继承、多态和策略模式,把“日志生成”和“日志输出”解耦。
这部分真正需要掌握的不是几十个 API,而是下面这条逻辑:
共享资源
↓
需要同步
↓
Mutex
↓
没有任务时不能忙等
↓
Cond
↓
生产者消费者
↓
把消费者提前创建并复用
↓
线程池
↓
线程池需要统一管理
↓
单例
↓
多线程程序需要记录运行状态
↓
日志系统
↓
日志输出方式可能变化
↓
策略模式 + 多态
↓
资源自动管理
↓
RAII + unique_ptr
这才是这一部分知识真正的主线。
更多推荐



所有评论(0)