线程池收尾:回调、单例与"可重入 vs 线程安全"(8-22 ~ 8-23)
我的github:(https://github.com/xcx55/ubuntu-linux-project)
感谢各位大佬参观我的github!!
线程池从 8-20 的设计日志起步,这几天收尾:把子线程的执行策略讲透、把"回调"这个设计点破,再用单例模式给线程池定下唯一入口。顺带把线程安全与可重入的关系彻底理清------这两个词以前总混着用,现在是分得清的了。
一、线程池就是一个"带预备队"的生产者消费者模型(8-22)
1. 一句话定性
线程池本身是一个生产者消费者模型!只是多了"提前创建好的一批等待条件变量的线程"这个预备队。
任务队列是"交易场所",主线程/业务线程是生产者,池里的子线程是消费者------只不过消费者是开机就建好、一直在等待队列里睡着的。
2. 子线程 routine 的标准骨架
笔记里写下了子线程的执行结构,逻辑非常清晰:
cpp
while (true) {
pthread_mutex_lock(&mutex, nullptr);
cond.wait(); // ① 先睡着,等条件变量唤醒
if (/* lambda 队列非空 */) {
task(); // ② 取出任务,执行 lambda 回调
}
// ③ 回锁、处理其他分支
pthread_mutex_unlock(&mutex);
}
时序一句话概括:先唤醒、执行、最后执行完 lambda 再休眠------创建出来后立刻进入条件变量休眠,任务来了被唤醒,干完活继续睡。
3. 回调函数:底层调用上层
笔记里标了三个重点号:底层调用上层函数(回调)很重要。
这是线程池作为一个"基础设施"的关键设计:线程池在底层 负责调度和执行,但它不知道要执行什么 ------真正的业务逻辑由上层用 lambda/函数对象注册进来,底层反过来调用上层注册的代码。
我们压入任务,不写函数,写 lambda 表达式 的默认类:
[捕获] (参数) -> 返回类型 { 函数体 }
编译器为每个 lambda 生成一个匿名类的实例,operator() 里装着函数体------所以"压入任务"压的其实是一个可调用对象 。线程池只管 task(),至于这行代码是什么,是上层的事。
二、线程安全与可重入:终于分清了(8-23)
1. 关系一句话
可重入一定线程安全,线程安全不一定可重入!
- 可重入:同一函数被"打断后再次进入"也不会出错------这是更强的要求,所以它自然满足线程安全;
- 线程安全 :多线程并发调用不出错------可以用加锁实现,但加了锁的函数恰恰可能不可重入!
2. 那个致命的死锁场景
笔记里的例子极其经典:
正在执行
as()函数,而这个信号的信号处理函数 也是as()------再调一次就会造成死锁!
推演一下:as() 内部加了锁 → 执行到一半来了信号 → 信号 handler 又是 as() → 再申请同一把锁 → 锁已经被自己(同一个执行流)持有 → 永久阻塞。
所以结论是:
除了信号重入这种情况,函数可重入就是线程安全的;线程安全不一定函数可重入------因为信号重入会导致死锁。
这段推理把之前信号章的"自动屏蔽字"和线程章的"互斥锁"缝在了一起:信号 handler 里绝不能调用会加锁的函数------这就是自动屏蔽字只能挡同号信号、挡不住"handler 调用了别处也用的锁"的原因。
三、单例模式:给线程池一个唯一入口(8-23)
1. 动机
线程池是"全局只有一份"的设施:多个模块都要投递任务,如果各自 new 一个线程池,线程数失控、任务队列互相看不见。所以需要单例模式 ------一个类型只能实例化一个对象。
2. 实现要点
cpp
class ThreadPool {
private:
static ThreadPool* _instance; // 保存自己的地址
ThreadPool() {} // 构造函数私有
ThreadPool(const ThreadPool&) = delete; // 拷贝构造 ban 掉
ThreadPool& operator=(const ThreadPool&) = delete; // 赋值 ban 掉
public:
static ThreadPool* GetInstance(); // 唯一入口
};
笔记里的措辞很直白:直接 ban 掉所有构造函数,要 ban 掉拷贝构造、赋值构造、复制构造;ban 掉之后使用唯一入口函数。
程序根本编译不过去------没有构造函数的类,外部连实例都造不出来,这就是"唯一"的强制保障。
3. 两种创建时机:饿汉 vs 懒汉
- 加载时创建(饿汉):程序启动就 new 好,简单但可能浪费;
- 运行时创建 (懒汉):第一次
GetInstance()才创建------这才叫单例(按需),工业界更常用(需配双检锁保证线程安全)。
4. 单例模式的价值
单例,就是用一个类的实例 ,一直复用它的
GetInstance()。
三个实际好处:
- 避免重复初始化------有些类的构造带 IO 操作,反复"初始化→销毁"代价极高;
- 免去函数间传参 ------main 里初始化好后,任何地方调
GetInstance()就能拿到,比一层层传指针干净得多; - 全局唯一语义------线程池、日志器、配置中心天生就该只有一份。
四、小结
| 主题 | 结论 |
|---|---|
| 线程池本质 | 生产者消费者模型 + 预热好的消费者线程 |
| 子线程 routine | while(true) → lock → cond.wait → 判队列 → task() → unlock |
| 回调 | 底层框架调用上层注册的 lambda(匿名类 + operator()) |
| 可重入 vs 线程安全 | 可重入 ⇒ 线程安全;线程安全 ⇏ 可重入(信号重入 handler 会死锁) |
| 单例 | ban 构造/拷贝/赋值 + 静态指针 + 唯一入口 GetInstance |
| 单例价值 | 避免重复 IO 初始化、免传参、全局唯一 |
到这里,线程章从"轻量级进程"一路走到"线程池 + 单例",工程化的闭环算是合上了。