线程池
github地址:https://github.com/belife73/threadPool.git
1.理论基础
1.🧐 线程池是什么?
线程池是一种管理和复用线程的机制。在软件开发中,尤其是在服务器端编程或并发处理任务时,如果每当有一个新任务到来就创建一个新线程,任务执行完毕后再销毁线程,会带来显著的性能开销:
- 创建/销毁开销: 线程的创建和销毁需要调用操作系统内核的资源,这是一个相对耗时的过程。
- 资源消耗: 无限制地创建线程会耗尽系统内存和 CPU 资源,可能导致系统过载(OOM,Out of Memory)或频繁的上下文切换,反而降低效率。
线程池就是为了解决这些问题而设计的。
核心概念
线程池通常由以下三个主要部分组成:
- 工作线程集合 (Worker Threads):
- 这是线程池中预先创建并处于等待状态的线程。
- 它们是真正执行任务的执行者。
- 任务队列 (Task Queue / Work Queue):
- 用于存储等待执行的任务(例如实现了
Runnable或Callable接口的对象)。 - 当新任务到来时,如果工作线程都在忙碌中,任务就会被放入队列中等待。
- 用于存储等待执行的任务(例如实现了
- 线程池管理器 (Thread Pool Manager):
- 负责线程池的创建、销毁、任务提交和线程调度等管理工作。
- 它根据配置(如核心线程数、最大线程数)来决定是创建新线程、复用空闲线程还是将任务放入队列。
线程池的工作流程
- 当应用程序提交一个任务给线程池时。
- 线程池管理器首先检查是否有空闲的工作线程。
- 如果有,立即分配一个空闲线程来执行该任务。
- 如果没有空闲线程,管理器会检查:
- 是否可以创建新的线程? 如果当前线程数小于最大线程数,可以创建新的工作线程来执行任务。
- 是否应该将任务放入队列? 如果已经达到核心线程数但未达到最大线程数,通常会先将任务放入任务队列。
- 工作线程完成任务后,并不会被立即销毁,而是返回池中继续等待下一个任务。
- 如果任务队列满了,并且线程数已经达到最大值,线程池会根据预设的拒绝策略来处理这个新任务(例如抛出异常、丢弃任务等)。
2.为什么使用线程池
1. 降低资源消耗(省资源)
- 问题: 线程的创建和销毁不是免费的。操作系统需要为线程分配内存(栈空间)、注册内核对象,这需要昂贵的系统调用。如果任务执行时间很短(例如 1ms),但创建线程要 2ms,那大部分时间都浪费在了“招人”和“裁员”上,而不是“干活”上。
- 线程池解法: 复用。线程池里的线程是“长工”,干完一个任务不销毁,继续干下一个。这样就把创建和销毁的开销分摊到了无数个任务上,几乎忽略不计。
2. 保护系统稳定性(稳系统)
- 问题: 假如你运营一个网站,如果不加限制,来一个请求就创建一个线程。双十一来了,瞬间涌入 10 万个请求,系统就会试图创建 10 万个线程。这会瞬间耗尽内存(OOM),或者导致 CPU 即使 100% 运转也全在做上下文切换,根本没法处理业务,最终导致系统死机。
- 线程池解法: 管控。线程池有一个“最大线程数”的限制(比如设置最多 200 个)。无论外面请求多疯狂,干活的永远只有这 200 人,多出来的请求会在队列里排队。这相当于给系统加了一个“限流阀”,保证系统永远在能力范围内运行,不会被压垮。
3. 提高响应速度(快响应)
- 问题: 当一个请求到来时,如果还要现去向操作系统申请创建线程,用户就需要多等待这段“启动时间”。
- 线程池解法: 预热。线程池里通常有处于等待状态的核心线程。任务一来,马上就能领走执行,实现了“零延迟”启动。
4. 统一管理(好管理)
- 问题: 散落在代码各个角落的
new Thread()就像散兵游勇,你无法知道系统里到底跑了多少线程,也无法统一停止它们。 - 线程池解法: 组织。线程池是一个统一的组件,你可以监控它的排队数量、执行状态,也可以方便地进行调优、关闭或执行定时任务。
3.🔒 线程池并发控制的核心机制
1. 任务生命周期管理是在哪里?
- 回答: 任务的生命周期管理主要在线程池内部(ThreadPool Manager)和任务队列(Task Queue)中完成。
| 组件 | 管理的阶段 | 具体职责 |
|---|---|---|
| 线程池管理器 (Manager) | 初始化、执行、终结 | 决定任务是否应该被接受、创建多少线程来执行,并在任务完成后处理线程的复用或销毁。 |
| 任务队列 (Task Queue) | 等待 | 存储任务,管理任务的排队顺序。任务从提交到开始执行的过程都在队列中。 |
| 工作线程 (Worker Thread) | 执行中 | 实际执行任务的 run() 方法。任务的生命周期结束于工作线程完成执行。 |
2. 锁到底在哪个结构?为什么放在队列中?
锁(Mutex)主要放置在任务队列这个数据结构上。
- 结构位置: 锁是作为任务队列的成员变量存在的,用于保护队列内部的数据结构(例如,
std::deque或std::list)。 - 为什么放在队列中?
- 保证线程安全: 任务队列是线程池中唯一的共享数据结构,它同时被生产者(任务提交线程)和消费者(工作线程)访问。
- 生产者: 正在向队列中添加任务 (
push/put)。 - 消费者: 正在从队列中移除任务 (
pop/take)。 - 如果不对队列的操作加锁,就会发生数据竞争(Data Race),导致数据损坏或程序崩溃。
- 生产者: 正在向队列中添加任务 (
- 实现阻塞功能: 锁(
Mutex)和条件变量(Condition Variable)是实现阻塞队列(Blocking Queue)的基础。它们一起工作,实现了任务队列的等待和唤醒机制。
- 保证线程安全: 任务队列是线程池中唯一的共享数据结构,它同时被生产者(任务提交线程)和消费者(工作线程)访问。
3. 怎么根据队列的状态管理消费者线程?使用什么类型的锁?
这是线程池最精妙的部分,它利用阻塞队列的特性,实现了零 CPU 消耗的线程协作。
A. 状态管理机制 (使用条件变量)
管理消费者线程状态(休眠/唤醒)的核心机制是条件变量(Condition Variable)。
- 队列状态:队列从“无”到“有” (唤醒)
- 工作线程(消费者): 调用
queue.take()时,如果队列为空,线程会调用条件变量的wait()方法,进入休眠状态(Blocking)。 - 提交线程(生产者): 成功将任务放入队列后,它会调用条件变量的
notify()或notify_all()方法,将一个或所有等待中的消费者线程唤醒(Wake up),使其重新竞争锁并取出任务。
- 工作线程(消费者): 调用
- 队列状态:队列从“有”到“无” (休眠)
- 工作线程(消费者): 持续从队列中取出任务执行,直到队列为空,它再次调用
wait()进入休眠。
- 工作线程(消费者): 持续从队列中取出任务执行,直到队列为空,它再次调用
B. 使用的锁类型
线程池通常使用互斥锁(Mutex)与条件变量(Condition Variable)配合使用。
- 互斥锁(Mutex):
- 作用: 确保在任何时刻,只有一个线程可以对队列进行修改(添加或移除),保证队列数据结构的原子性。
- 条件变量(Condition Variable):
- 作用: 负责线程之间的通信和同步。它必须与互斥锁配合使用。线程在等待条件时释放互斥锁,在被唤醒后重新获取互斥锁。
为什么不用自旋锁 (Spinlock)?
自旋锁适用于临界区极短的场景。线程池中的任务队列可能长期为空(尤其在低负载时)。如果使用自旋锁,线程会一直空转(Busy Waiting),浪费 CPU 资源。使用互斥锁 + 条件变量可以让线程在没有任务时进入休眠,将 CPU 让给其他进程,更加高效。
🚀 线程池的主要优点
| 优点 | 描述 |
|---|---|
| 降低资源消耗 | 通过重用已存在的线程,避免了线程创建和销毁带来的性能开销。 |
| 提高响应速度 | 任务无需等待线程创建即可立即执行(如果池中有空闲线程)。 |
| 提高线程的可管理性 | 线程是稀缺资源,线程池可以统一分配、调优和监控,防止无限制地创建线程导致系统资源耗尽。 |
| 提供更多功能 | 线程池框架通常会提供如定时执行、定期执行、线程分组等高级功能。 |
2.核心架构
1. 核心组件架构图解
我们可以将线程池拆解为四个关键的架构组件:
A. 任务队列 (Blocking Queue) —— "缓冲区"
这是线程池的中枢神经。它负责解耦“提交任务的线程(生产者)”和“执行任务的线程(消费者)”。
- 作用: 存储待处理的任务。
- 特性: 必须是线程安全的。
- 实现方式: 通常是一个阻塞队列(Blocking Queue)。
- 当队列为空时,工作线程会阻塞(挂起),而不是空转(Busy Waiting),以节省 CPU。
- 当新任务加入时,通过信号量 or 条件变量唤醒工作线程。
B. 工作线程 (Worker Threads) —— "消费者"
这不仅仅是普通的线程对象,它们通常封装了一个死循环。
-
生命周期: 它们启动后不会立即结束,而是进入一个循环体。
-
核心逻辑(伪代码):
while (true) { task = taskQueue.take(); // 获取任务,如果队列为空则阻塞等待 task.run(); // 执行任务业务逻辑 }
C. 管理组件 (ThreadPool Manager) —— "调度者"
负责管理线程池的整体状态。
- 状态维护: 记录当前线程池是运行中(RUNNING)、关闭中(SHUTDOWN)还是已停止(STOP)。
- 扩容与缩容:
- Core Pool Size (核心线程数): 即使空闲也保持存活的线程数。
- Max Pool Size (最大线程数): 当队列满了,可以临时创建的额外线程。
- Keep Alive Time (存活时间): 临时线程空闲多久后被销毁。
D. 拒绝策略处理器 (Reject Handler) —— "保险丝"
当任务队列已满,且线程数已达到最大值时,必须有一个策略来处理新提交的任务,防止系统崩溃。
2. 核心交互流程(Step-by-Step)
为了更清晰地展示架构运作,我们来看一下当一个任务进入系统时,各个组件是如何交互的:
- 提交 (Submit): 外部线程调用
pool.submit(task)。 - 核心判断 (Core Check):
- 如果
当前工作线程数 < 核心线程数-> 立即创建新线程执行该任务。 - 如果
当前工作线程数 >= 核心线程数-> 进入下一步。
- 如果
- 入队 (Enqueue):
- 尝试将任务放入 任务队列。
- 如果队列未满 -> 任务入队成功,等待被消费者取走。
- 如果队列已满 -> 进入下一步。
- 激进扩容 (Scale Up):
- 如果
当前线程数 < 最大线程数-> 创建非核心线程来立刻处理这个新任务(通常是处理新任务,而不是去取队列里的旧任务,这取决于具体实现)。 - 如果
当前线程数 >= 最大线程数-> 无法扩容,进入下一步。
- 如果
- 拒绝 (Reject):
- 调用拒绝策略(如抛出异常、丢弃任务、或由调用者线程自己执行)。
3. 关键同步技术 (The "Glue")
架构得以稳定运行,依赖于底层的同步原语。这在 C++ 或 Java 的底层实现中非常关键:
- 互斥锁 (Mutex / ReentrantLock): 保证同一时刻只有一个线程能向队列添加或移除任务,防止数据竞争。
- 条件变量 (Condition Variable):
notEmpty:当队列为空时,工作线程在notEmpty上等待;当生产者放入任务后,发送signal唤醒工作线程。notFull:当队列满时,生产者在notFull上等待。
总结
线程池的架构之美在于解耦与复用:
- 解耦: 提交任务的人不需要知道谁在执行,也不需要等待执行结束。
- 复用: 昂贵的线程资源被循环利用,只需要付出极小的同步代价(锁竞争)。
3.队列的锁管理
1. 原子操作 (Atomic Operations) —— “不可分割的瞬时动作”
原子操作是同步机制的基石。在 C++ 中对应 std::atomic。
- 本质: 它是一条CPU指令(或极少几条组合),在执行过程中绝对不会被中断。对于 CPU 来说,这步操作要么没做,要么做完了,不存在“做了一半”的状态。
- 底层原理:
- 总线锁 (Bus Lock) / 缓存锁 (Cache Lock): CPU 通过
LOCK#信号或缓存一致性协议(如 MESI),锁定内存地址,确保在同一时钟周期内只有一个核心能修改该数据。 - CAS (Compare-And-Swap): 这是无锁编程的核心。指令逻辑是:“如果内存值等于预期值 A,则将其更新为 B;否则失败”。
- 总线锁 (Bus Lock) / 缓存锁 (Cache Lock): CPU 通过
- 线程状态: 不阻塞,不等待。线程一直在全速运行指令。
- 适用场景:
- 简单的计数器(
i++)。 - 标志位(Flag)的设置。
- 实现锁本身(自旋锁和互斥锁的底层都依赖原子操作来争抢状态)。
- 简单的计数器(
2. 自旋锁 (Spinlock) —— “原地打转,死等”
自旋锁是一种基于原子操作的锁机制。
-
本质: 当线程尝试获取锁失败时,它不会让出 CPU,而是进入一个
while循环(自旋),不断地检查锁是否被释放。 -
伪代码逻辑:
// 只有一条原子指令 while (std::atomic_flag_test_and_set(&lock_flag)) { // 锁被占用,像陀螺一样原地旋转 (Spin) // do nothing } // 获取到锁,进入临界区 -
线程状态: 忙等待 (Busy Waiting)。线程处于运行状态(Running),也就是“占着茅坑不拉屎”,白白消耗 CPU 时间片。
-
优点: * 没有上下文切换(Context Switch)的开销。
- 如果临界区极短(短于切换线程的时间),性能极高。
-
缺点: * 如果持有锁的线程长时间不释放,等待的线程会把 CPU 跑满,导致浪费。
- 不可递归(通常),容易死锁。
-
适用场景:
- 操作系统内核(中断处理程序中不能休眠,必须用自旋锁)。
- 高频、短耗时的任务(如简单的链表节点插入)。
- 你关注的 DPDK 就是大量使用自旋锁来避免上下文切换,追求极致的 I/O 吞吐。
3. 互斥锁 (Mutex) —— “拿不到就睡,等待唤醒”
互斥锁是操作系统层面的锁。在 C++ 中对应 std::mutex。
- 本质: 当线程获取锁失败时,它会主动让出 CPU,请求操作系统将自己挂起(Block),放入等待队列。
- 底层流程:
- 用户态尝试通过原子操作获取锁(Fast path)。
- 如果失败,发起系统调用(System Call),陷入内核态。
- 内核将线程状态改为“睡眠/阻塞”,将其 TCB(线程控制块)移出运行队列。
- 发生上下文切换,CPU 去执行别的线程。
- 当持有锁的线程释放锁时,内核唤醒等待队列中的线程。
- 线程状态: 阻塞 (Blocked/Sleeping)。不消耗 CPU 时间。
- 优点: * 适合长临界区,不会浪费 CPU 资源。
- 缺点: * 上下文切换开销大:保存寄存器、刷新 TLB/Cache 等操作通常需要几微秒甚至更久。如果你的任务只需要 0.1 微秒,用互斥锁就是杀鸡用牛刀。
- 适用场景:
- 业务逻辑复杂、包含 I/O 操作、或者无法预估执行时间的代码块。
4.spinlock不需要错误检测
核心原因:实现机制与错误返回的价值
pthread_mutex_init 和 pthread_cond_init 是 POSIX 线程库提供的系统级同步原语,它们可能需要向操作系统申请资源,因此需要返回错误码。而 spinlock_init 如果是自定义实现,通常仅在用户态完成,极少有失败的可能。
1. pthread_mutex_init 为什么需要错误检测?
pthread_mutex_init 和 pthread_cond_init 可能会失败,原因包括:
- 资源限制: 尽管在 Linux/Unix 上通常是轻量级的(主要在用户态),但在某些嵌入式系统或资源受限的环境中,初始化它们可能需要分配内核对象或特定的系统资源。如果系统内存或资源不足,会返回错误(例如
ENOMEM)。 - 属性错误: 如果您传递了非法的互斥锁属性(
attr,此处为NULL),初始化也可能失败。
因此,按照 POSIX 规范,这些函数必须返回一个表示成功或失败的整数错误码。
2. spinlock_init 为什么通常不需要错误检测?
自旋锁(Spinlock)的实现通常极其简单和底层,因此极少有失败的可能性,或者说,失败了也无力回天。
- 用户态实现: 自旋锁在用户态最常见的实现是基于一个原子变量(Atomic Variable),例如 C++ 中的
std::atomic_flag或 C 中的一个整数类型配合汇编指令(如XCHG或CAS)。spinlock_init的工作就是将这个原子变量设置为初始值(例如 0 或false)。
- 无资源分配: 自旋锁初始化不需要调用操作系统进行资源分配。它只操作
task_queue_t结构体内部已经通过malloc分配好的那一块内存。 - 失败即系统级灾难:
- 如果
spinlock_init失败,唯一的可能是系统内存已彻底损坏、指令集不可用(极不可能)或malloc失败。 - 在
malloc已经成功的前提下,如果连一个简单的原子变量初始化都会失败,那么操作系统或硬件已经处于不可挽回的状态,此时继续执行任何代码(包括错误处理代码)都没有意义。
- 如果
3. 代码分析和最佳实践
在您提供的代码中:
// ...
if (ret == 0) { // 成功初始化互斥锁
ret = pthread_cond_init(&queue->cond, NULL);
if (ret == 0) { // 成功初始化条件变量
spinlock_init(&queue->lock); // 为什么这里没有 ret 检查?
// ...
- 隐式假设:
spinlock_init函数被假设为必定成功(即它的返回值是void或者被调用者选择忽略其返回值)。 - 设计原则: 如果自旋锁是自定义的,通常设计者会让它成为一个
void函数,或者返回一个仅用于调试的布尔值。如果它返回错误码,那只会徒增代码的复杂度,因为在实际运行环境中,你几乎永远不会遇到这个错误。
结论:
pthread_mutex_init 是 POSIX 规范的一部分,必须处理系统资源分配失败的可能。而 spinlock_init 是一个轻量级的底层用户态操作,基于效率和对底层机制的信任,通常省略了失败检测。
⚔️ 三者对比总结
| 特性 | 原子操作 (Atomic) | 自旋锁 (Spinlock) | 互斥锁 (Mutex) |
|---|---|---|---|
| 保护粒度 | 单个变量 | 代码块 (临界区) | 代码块 (临界区) |
| CPU 消耗 | 极低 (单指令) | 高 (空转消耗) | 低 (睡眠不消耗) |
| 切换开销 | 无 | 无 | 高 (Context Switch) |
| 等待模式 | 不等待 (立即返回成功/失败) | 忙等待 (Busy Wait) | 休眠 (Sleep/Block) |
| OS 介入 | 不需要 | 不需要 (纯用户态) | 需要 (内核态调度) |
| 典型用途 | 计数器、标志位、实现锁 | 内核开发、极短任务 | 常规业务逻辑、文件读写 |
🧠 进阶思考:现代互斥锁的优化 (Futex)
在现代 Linux 和 C++ std::mutex 的实现中,互斥锁其实是混合体。
为了避免频繁陷入内核,现代 Mutex 通常使用了 Futex (Fast Userspace muTEX) 机制:
- 先尝试自旋: 线程会先尝试自旋一小会儿(用户态原子操作),因为绝大多数锁很快就会释放。
- 再进入睡眠: 只有当自旋了一定次数(或时间)还没拿到锁,才会真正发起系统调用挂起自己。
这结合了自旋锁的“快”和互斥锁的“省”,是目前应用层开发的主流选择。
4.固定多少线程
1. CPU 密集型 (CPU-Intensive)
-
图示公式:
CPU 核心数 -
常见变体:
CPU 核心数 + 1 -
典型场景: 视频解码、加密/解密、复杂算法计算、科学计算。
-
核心原理: “占满即止”。
-
为什么不多开?
对于 CPU 密集型任务,线程主要在进行逻辑运算,几乎不等待。如果你的机器有 4 个核,4 个线程就已经能让 CPU 100% 满负荷运转了。
如果此时你开了 8 个线程,操作系统就必须强行打断正在计算的线程,把 CPU 让给另一个线程(上下文切换)。正如之前所说,上下文切换是有开销的(保存寄存器、缓存失效等)。多开线程不仅不能加快计算,反而因为“切换”浪费了算力,导致整体效率下降。
-
为什么有时候推荐 +1?
工程实践中通常建议配置为 N + 1。这个额外的 “1” 是个“替补队员”。
- 防止意外暂停: 即使是计算型任务,也可能发生缺页中断(Page Fault)或其他系统级暂停。当一个核心上的线程意外暂停时,这个“替补”可以立刻顶上,保证 CPU 不空闲。
-
2. IO 密集型 (IO-Intensive)
-
图示公式:
2 * CPU 核心数 + 2 -
典型场景: Web 服务器(Tomcat)、数据库查询、读写文件、RPC 调用。
-
核心原理: “掩盖等待时间”。
-
为什么需要多倍线程?
IO 密集型任务的特点是:CPU 也就是“跑跑腿”,大部分时间都在“等”(等网络包返回、等磁盘寻道)。
假设一个任务执行耗时 100ms,其中 1ms 在用 CPU 处理数据,99ms 在等数据库返回。
- 如果你只开 1 个线程:CPU 利用率只有 1%。
- 为了让 CPU 忙起来,你需要在那个线程“等待”的 99ms 里,安排其他线程上来使用 CPU。
-
公式推导:
其实有一个更通用的理论公式:$$N_{threads} = N_{cpu} \times (1 + \frac{W}{C})$$
- $W$:等待时间 (Wait time)
- $C$:计算时间 (Compute time)
如果一个任务 50% 时间在算,50% 时间在等($W/C = 1$),那么你需要 $N \times (1+1) = 2N$ 的线程数。
2 * CPU 核心数 实际上是假设了“任务大约有一半(或更多)的时间在等待 IO”,这是一个针对通用 Web 业务(如查询数据库)的保守估算。
-
关于最后的 + 2:
这通常是一个经验值(Buffer)。在某些极端情况下(比如系统监控线程、死锁检测等),多留出 1-2 个线程作为“冗余”,可以防止线程池被完全耗尽导致系统假死。
-
总结
教你如何压榨 CPU 的价值:
- CPU 密集型: CPU 已经很累了,别再折腾它切换了,保持 1:1 的关系最好。
- IO 密集型: CPU 经常在摸鱼(等待),多招点人(线程),保证 CPU 永远有活干。
5.线程池的生产消费模型
1. 核心工作机制:生产者-消费者模型
线程池的工作流程可以概括为以下三个角色的协作:
- 生产者 (Producer):
- 负责提交任务的线程(例如处理网络请求的主线程)。
- 关键点: 生产者不一定是一个,可能有多个线程同时向池子扔任务。
- 动作: 当遇到耗时任务时,生产者不自己做,而是将任务丢给“队列”,然后继续处理其他请求(实现异步优化)。
- 任务队列 (Queue):
- 作用: “组织先后到达的任务”。它是连接生产者和消费者的缓冲区。
- 核心逻辑(调度中枢): 笔记中强调了 “队列的状态 决定 消费者线程的状态”。
- 唤醒 (Wake up): 当队列从“无”变为“有”任务时,唤醒沉睡的消费者线程起来干活。
- 休眠 (Sleep): 当队列从“有”变为“无”任务(空了)时,消费者线程进入休眠状态,释放 CPU 资源,而不是销毁。
- 消费者 (Consumer/Thread Pool):
- 角色: 线程池中的工作线程。
- 职责: 负责从队列中取出任务并执行。它们由线程池管理器进行“管理和维持”。
2. 为什么要这样工作?(解决阻塞问题)
- 问题背景: 如果没有线程池,当“某类任务特别耗时”时,会“严重影响该线程(生产者线程)处理其他任务”。
- 比如在 单 Reactor 模式(如 Redis)中,所有事情都在一个线程做,如果有一个计算很慢,整个服务就卡住了。
- 线程池解法: 通过将耗时任务剥离到线程池中,主线程(EventLoop)可以保持高效响应。
- 笔记中对比了 Redis(单线程/单Reactor)和 Nginx/Memcached(多线程/多Reactor)的模式,后者正是利用多线程来处理高并发下的复杂任务。
3.同步 I/O和异步 I/O
🐢 同步 I/O (Synchronous I/O) —— "排队等"
- 场景: 你去银行取钱。
- 过程: 你把身份证给柜员,然后站在窗口前死等。柜员点钞、核对的这 5 分钟里,你不能去玩手机,也不能去上厕所,必须看着他办完,拿到钱,你才能离开窗口去做别的事。
- 关键: 你(应用程序线程)亲自参与了等待和取钱的全过程。
⚡ 异步 I/O (Asynchronous I/O) —— "叫号走人"
- 场景: 你去高端 VIP 银行取钱。
- 过程: 你把身份证和单子交给大堂经理,大堂经理说:“好的先生,您先去旁边沙发喝咖啡玩手机,钱准备好了我给您送过来。”
- 关键: 你提交请求后立刻离开去干别的事(处理其他请求)。什么时候办完、钱怎么拿,你都不用管,系统办好了会主动通知你。
1. 技术层面的核心区别
在计算机系统中,I/O 操作(如读取网络数据)分为两个阶段:
- 等待数据准备好 (Wait for data):数据从网卡拷贝到内核缓冲区。
- 将数据从内核拷贝到用户空间 (Copy data from kernel to user):数据从内核缓冲区拷贝到你的程序内存。
| 特性 | 同步 I/O (Synchronous) | 异步 I/O (Asynchronous) |
|---|---|---|
| 典型代表 | read, write, select, epoll |
Windows IOCP, Linux io_uring, POSIX AIO |
| 谁在等待数据? | 应用程序线程 (或者多路复用器) | 操作系统内核 |
| 谁负责拷贝数据? | 应用程序线程 (CPU 亲自搬运) | 操作系统 (通常利用 DMA 搬运) |
| 线程状态 | 线程会被阻塞,或者需要轮询(Check) | 线程完全不阻塞,继续处理其他逻辑 |
| 通知方式 | "数据来了,你自己来读吧" | "数据我已经帮你读到内存里了,你直接用" |
2."异步IO IOCP"
-
Linux 的现状 (Reactor 模式):
Linux 上最常用的 epoll 实际上是 同步非阻塞 I/O(多路复用)。
epoll只是告诉你“有数据到了”,但读数据(Copy) 这个动作,还是得你的线程亲自去调用read()把它搬到内存里。在这个搬运期间,线程是忙碌的。
-
Windows 的强项 (Proactor 模式 / IOCP):
Windows 的 IOCP (Input/Output Completion Port) 是真正的异步 I/O。
- 你告诉操作系统:“我要读这个 Socket,读好了放在这个内存地址,然后告诉我。”
- 操作系统在后台默默干完所有脏活累活(等待+拷贝)。
- 等操作系统通知你的时候,数据已经在你的 buffer 里了,你的线程全程没为了 I/O 停顿过一微秒。
4. 总结
- 同步 I/O: 就像自助餐厅。菜做好了(数据到了),服务员喊你(通知),你需要自己走过去把菜端回桌子(数据拷贝)。
- 异步 I/O: 就像包间服务。你点完菜继续聊天,服务员直接把做好的菜端到你面前(操作系统完成数据拷贝),然后轻轻告诉你“请慢用”。
对于高性能服务器开发:
虽然异步 I/O (IOCP) 理论上性能更优(CPU 参与度最低),但由于 Linux 服务器占据主流,而 Linux 的真·异步 I/O (io_uring) 也是近年才成熟,所以目前业界主流(如 Nginx, Redis, Netty)依然是使用 同步非阻塞 I/O (epoll) + 线程池 来模拟并发处理。
总结
线程池的工作本质就是:生产者把任务丢进队列 -> 队列状态变化触发信号 -> 唤醒沉睡的工作线程 -> 线程干完活后再次检查队列(空了就继续睡,有就继续干)。 这种机制既避免了频繁创建销毁线程的开销,又实现了业务处理的异步解耦。
6.具体实现代码
1.thread_pool.h
接口: 提供有限的接口给用户
数据结构: 隐藏实现细节
#ifndef _THREAD_POOL_H
#define _THREAD_POOL_H
typedef struct thrdpool_s thrdpool_t;
// 任务执行的规范 ctx 上下文
typedef void (*handler_pt)(void * /* ctx */);
#ifdef __cplusplus
extern "C"
{
#endif
// 对称处理
thrdpool_t *thrdpool_create(int thrd_count);
void thrdpool_terminate(thrdpool_t * pool);
int thrdpool_post(thrdpool_t *pool, handler_pt func, void *arg);
void thrdpool_waitdone(thrdpool_t *pool);
#ifdef __cplusplus
}
#endif
#endif
#ifdef __cplusplus
extern "C"
{
#endif
🧐 extern "C" 的核心作用
1. 消除“名称重整”(Name Mangling)
这是 extern "C" 最核心的作用。
- C++ 的问题: C++ 为了支持函数重载(Function Overloading,即多个同名函数但参数不同),编译器在编译时会对函数名进行特殊的编码,将参数类型信息附加到函数名中。这个过程被称为名称重整 (Name Mangling)。
- 例如: C++ 中的函数
int add(int a, int b)编译后在目标文件中可能变成_Z3addii这样的符号。
- 例如: C++ 中的函数
- C 语言的特点: C 语言不支持函数重载,它直接使用原始函数名作为链接符号。
int add(int a, int b)编译后可能就是_add。 - 混合编译的冲突: 当 C++ 代码想要调用一个 C 语言编写的库函数时,C++ 编译器会按照自己的规则(名称重整)去查找那个 C 语言的符号,但 C 语言的目标文件里根本没有这个重整后的符号,因此在链接阶段会发生 “未定义的引用”(Undefined Reference) 错误。
extern "C" 的作用就是告诉 C++ 编译器:“请对大括号内的所有函数,不要进行名称重整,用 C 语言的规则来生成链接符号。”
2. 宏定义 #ifdef __cplusplus
这是为了确保这段代码可以在 C 和 C++ 两种编译器下都能正确编译:
__cplusplus: 这是一个预定义的宏,只有当文件被 C++ 编译器(如 g++)编译时,这个宏才会生效(被定义)。- 流程:
- 当 C++ 编译器编译时:
#ifdef __cplusplus为真,编译器会看到并处理extern "C" {...}。 - 当 C 编译器编译时: C 编译器不知道
__cplusplus宏(或者说它没有被定义),#ifdef条件为假,编译器会直接跳过extern "C" {...}部分。
- 当 C++ 编译器编译时:
这样就实现了兼容性:在 C 语言环境中,它就是一段普通的函数声明;在 C++ 环境中,它获得了特殊的链接指示。
2.thread_pool.c
typedef struct task_s {
void *next;
handler_pt func;
void *arg;
} task_t;
// void *arg要在堆上分配
typedef struct task_queue_s {
void *head;
void **tail;
int block;
spinlock_t lock;
pthread_mutex_t mutex;
pthread_cond_t cond;
} task_queue_t;
1. 为什么用二级指针 void **tail?
在 C 语言中,管理一个单向链表的尾部(tail)通常需要区分两种情况:空链表和非空链表。
A. 传统尾插法(需要条件判断)
如果 tail 是一个一级指针(例如 task_t *tail):
-
空链表: 当链表为空时 (
head == NULL),插入新节点时必须同时更新head和tail。C
if (head == NULL) { head = newNode; tail = newNode; } else { tail->next = newNode; tail = newNode; } -
非空链表: 只需更新
tail->next和tail。
这种方法需要在每次插入时都进行一次 if/else 条件判断。
B. 使用二级指针 void **tail 的巧妙之处(消除条件判断)
当 tail 被定义为一个指向尾部节点 next 指针的指针时,可以完美地统一这两种情况。
void \**tail指向哪里?- 它要么指向
task_queue_t.head这个变量的地址(链表为空时)。 - 要么指向当前尾部节点的
next字段的地址(链表非空时)。
- 它要么指向
统一的插入逻辑: 无论链表是否为空,*tail 永远是需要被设置为新节点地址的那块内存。
- 设置新节点:
*tail = newNode;- 如果链表为空:
*tail就是head,所以head = newNode。 - 如果链表非空:
*tail就是oldTail->next,所以oldTail->next = newNode。
- 如果链表为空:
- 更新尾指针:
tail = &(newNode->next);(假设新节点结构体内有void *next;)- 将
tail更新为指向新节点的next字段的地址,为下一次插入做准备。
- 将
关键优势: 这种设计使得插入逻辑无需 if/else 判断,简化了代码,并且在高性能/内核级代码中更常见,因为它避免了分支预测错误带来的性能损失。
2. 为什么是 void * 而不是具体的结构体类型?
void *在 C 语言中被称为通用指针。- 目的: 增强代码的通用性和解耦。
task_queue_t的设计者希望这个队列可以存储任何类型的任务节点(例如struct task_A或struct task_B)。- 通过使用
void *,队列结构体本身不需要知道它存储的具体任务节点的定义,只要知道节点内部有一个void *next字段(或其他指针类型)用于链接即可。
总结
void **tail 的设计是 C 语言编程中的一种高级技巧,旨在通过二级指针的特性,将链表空和非空这两种边界情况合并为一种统一的逻辑处理,从而写出更简洁、更高效、更不易出错的链表操作代码。
static task_queue_t *
__taskqueue_create() {
int ret;
task_queue_t *queue = (task_queue_t *)malloc(sizeof(task_queue_t));
if (queue) {
ret = pthread_mutex_init(&queue->mutex, NULL);
if (ret == 0) {
ret = pthread_cond_init(&queue->cond, NULL);
if (ret == 0) {
spinlock_init(&queue->lock);
queue->head = NULL;
queue->tail = &queue->head;
queue->block = 1;
return queue;
}
pthread_mutex_destroy(&queue->mutex);
}
free(queue);
}
return NULL;
}
// static 仅仅thread_pool.c文件可见,用户不可见
// malloc 返回void* 所以要强转(大家都知道)
// sizeof() 编译期运行 在 64 位系统上,它通常是 unsigned long long 类型。
//资源创建 回滚逻辑
//业务逻辑 防御式编程
//资源创建 回滚逻辑
//防止报错后资源未回收
static task_queue_t *
__taskqueue_create() {
int ret;
task_queue_t *queue = (task_queue_t *)malloc(sizeof(task_queue_t));
if (queue) {
free(queue);
}
return NULL;
}
static task_queue_t *
__taskqueue_create() {
int ret;
task_queue_t *queue = (task_queue_t *)malloc(sizeof(task_queue_t));
if (queue) {
ret = pthread_mutex_init(&queue->mutex, NULL);
if (ret == 0) {
pthread_mutex_destroy(&queue->mutex);
}
free(queue);
}
return NULL;
}
--------------------------------------------------------------------------------------
//当调用 __nonblock 时,意味着线程池正在进入关闭阶段,我们需要确保所有处于休眠状态的工作线程都能被唤醒并退出它们的无限循环。
static void
__nonblock(task_queue_t* queue)
{
pthread_mutex_lock(&queue->mutex);
queue->block = 0;
pthread_mutex_unlock(&queue->mutex);
pthread_cond_broadcast(&queue->cond);
}
----------------------------------------------------------------------------------------
static inline void
__add_task(task_queue_t *queue, void *task) {
// 不限定任务类型,只要该任务的结构起始内存是一个用于链接下一个节点的指针
void **link = (void**)task;
*link = NULL;
spinlock_lock(&queue->lock);
*queue->tail /* 等价于 queue->tail->next */ = link;
queue->tail = link;
spinlock_unlock(&queue->lock);
pthread_cond_signal(&queue->cond);
}
----------------------------------------------------------------------------------------------
static inline void *
__get_task(task_queue_t *queue) {
task_t *task;
// 虚假唤醒
while ((task = __pop_task(queue)) == NULL) {
pthread_mutex_lock(&queue->mutex);
if (queue->block == 0) {
pthread_mutex_unlock(&queue->mutex);
return NULL;
}
// 1. 先 unlock(&mtx)
// 2. 在 cond 休眠
// --- __add_task 时唤醒
// 3. 在 cond 唤醒
// 4. 加上 lock(&mtx);
pthread_cond_wait(&queue->cond, &queue->mutex);
pthread_mutex_unlock(&queue->mutex);
}
return task;
}
gcc thrd_pool.c -o libthrdpool.so -I./ -lthread -fPIC -shared
浙公网安备 33010602011771号