Java线程池与Python异步事件循环原理精讲
约 6359 字大约 21 分钟
2026-09-24
title: Java 线程池与 Python 异步事件循环原理精讲
createTime: 2026/09/23 17:01:35
permalink: /blog/java-threadpool-asyncio-eventloop/
tags: [Java, 线程池,Python 异步,协程,epoll]
Java 线程池 与 Python asyncio 事件循环:从原理到 epoll 完整链路
开场
学生:老师,我一直没搞懂 Java 线程池和 Python 的 asyncio 到底有什么区别。一个叫线程池,一个叫事件循环,听起来都是 "并发",但是抽象术语太多,我脑子里始终没有一个能跑起来的心智模型。
老师:这个问题问得很好。很多人在这一步卡住,是因为把两者当成了同一个东西。其实它们一个是操作系统线程的复用,一个是用户态协程的调度,底层完全不同。今天我们从原理讲起,一直讲到 epoll 网络通知的完整链路,让你脑子里真正建立起能运转的模型。
第一课:Java 线程池原理
学生:那就从线程池开始吧。ThreadPoolExecutor 到底在干什么?
老师:一句话,线程池的核心目标有三个:复用固定数量的工作线程,避免频繁创建和销毁线程的昂贵系统调用;控制并发数量,防止无限创建线程把机器压垮;解耦任务提交与任务执行。
学生:那它内部由哪些部分组成?
老师:五个核心部分。
核心线程数 corePoolSize,常驻线程,默认空闲也不会被回收。
最大线程数 maximumPoolSize,线程池能开辟的线程上限。
阻塞队列 workQueue,存放来不及被线程处理的任务。
空闲存活时间 keepAliveTime,非核心线程空闲超过这个时间就被回收。
拒绝策略 handler,线程数达到上限、队列也满的时候,对新任务执行拒绝逻辑。
学生:任务的完整调度流程呢?
老师:四步,面试基本必背。
提交任务,如果当前运行线程数小于核心线程数,新建核心线程执行任务。
核心线程已经占满,尝试把任务放进阻塞队列。
队列满了,如果运行线程数小于最大线程数,新建非核心线程执行任务。
线程数到达上限,队列也满了,触发拒绝策略。
学生:那 Worker 是什么?我经常看到这个词。
老师:Worker 是线程池内部封装的工作线程,它继承了 AQS。你可以把 Worker 理解成 "干活的工人",每个 Worker 背后是一个操作系统线程。
它的工作方式是一个死循环:从阻塞队列 getTask 拿任务,队列空的时候这个线程就阻塞等待,不占 CPU;拿到任务就完整执行;取不到任务,非核心线程就退出,被 GC 回收。
学生:所以本质就是线程固定,任务排队,线程循环去队列里抢任务?
老师:对,这句话可以当结论记住。线程池是多线程、抢占式的,用的是操作系统内核线程,属于同步阻塞模型。每个线程是 OS 原生线程,占用独立的栈内存。
学生:那 IO 阻塞的时候会怎样?
老师:线程被操作系统直接挂起,卡在原地,这个线程就干不了别的活了。所以线程池适合 CPU 密集和短任务;IO 密集的场景下大量线程阻塞,资源开销会非常大。
第二课:Python asyncio 事件循环原理
学生:那 asyncio 呢?它跟线程池完全不是一回事吗?
老师:完全不是。asyncio 的核心一句话:在同一个操作系统线程内,切换多个协程;遇到 IO 等待时不阻塞线程,转而去运行其他就绪的协程。它是用户态协程加 IO 多路复用,不是多线程。
学生:先给我讲讲基础概念吧,协程、事件循环、await,我总是一知半解。
老师:逐个来。
协程,用 async def 定义的函数,调用它不会直接执行,返回一个协程对象,需要丢进事件循环运行。
事件循环,整个 asyncio 的调度器,是一个单线程的无限循环。
await,协程的主动让出点。遇到 await 等待 IO,比如网络请求、sleep,当前协程暂停,把控制权交还给事件循环,事件循环去调度其他可运行的协程。
IO 多路复用,底层用 select、epoll、kqueue 监听所有等待的 IO 事件,某个 IO 就绪时通知事件循环唤醒对应的协程。
学生:事件循环的工作流程是怎么样的?
老师:五步。
把多个协程任务注册到事件循环。
循环不断维护两个集合,就绪任务队列和等待 IO 的任务集合。
取出就绪队列的第一个协程执行,直到碰到 await。
碰到 await,协程暂停,注册 IO 监听,事件循环切换去执行别的就绪协程。
IO 完成后,epoll 通知事件循环,把这个协程放回就绪队列,等待下一次调度。
学生:关键点是什么?
老师:全程只有一个操作系统线程。IO 等待的时候线程不会被 OS 阻塞,事件循环利用等待时间跑其他任务。但有个致命约束,一旦协程里面出现同步阻塞代码,比如 time.sleep、同步数据库调用,整个事件循环卡死,所有协程全部停摆。
学生:协程的调度是抢占式的吗?
老师:不是,它是协作式调度。调度只发生在 await 那一点,协程必须主动让出 CPU,系统无法强制打断它。切换只发生在用户态,开销极低,适合 IO 密集场景,不适合 CPU 密集,因为 CPU 密集会占住事件循环,其他任务永远得不到执行。
第三课:两者核心对比
学生:现在把两者摆在一起看,核心差异有哪些?
老师:一张表说清楚。
| 项目 | Java 线程池 ThreadPoolExecutor | Python asyncio 事件循环 |
|---|---|---|
| 执行单元 | OS 内核线程 | 用户态协程,单个 OS 线程 |
| 调度方式 | 操作系统抢占式调度 | 协程协作式调度,await 才让出 |
| 阻塞行为 | IO 阻塞时线程被 OS 挂起,其他线程继续跑 | 同步阻塞代码会卡死整个事件循环;await 异步 IO 不阻塞线程 |
| 切换开销 | 高,内核态切换 | 极低,用户态上下文 |
| 适用场景 | CPU 密集、混合任务 | IO 密集,网络请求为主 |
学生:我用自己的话总结一下,老师帮我看看对不对。Java 线程池是一个同步阻塞任务队列,Worker 容器维护所有核心线程,线程从队列取任务,完整执行,循环往复。
老师:基本正确。补充一点,一个 Worker 一旦开始执行任务,就会从头到尾把这个任务跑完。如果执行过程发生 IO 阻塞,这个 Worker 线程被操作系统挂起,卡在原地,不能去拿别的任务,但其余空闲的 Worker 可以继续消费队列里的其他任务。多个工人,一个被卡住,其他人还能干活。
学生:那协程那边,我原先以为是遇到需要进一步处理的任务就扔给别人处理,自己处理下一个任务?
老师:这个理解不准确。从头到尾只有一个操作系统线程,只有一个工人,不存在 "扔给别人"。协程执行到 await 的时候,是主动交出 CPU 控制权,把自己挂到 IO 等待集合里登记监听事件,然后事件循环在同一个线程里取出就绪队列的下一个协程继续跑。原来的协程暂停了,但工人没有休息,而是接着做下一个就绪任务;等 IO 事件就绪,暂停的协程重新排队,等待工人回来继续做完。
学生:所以是一个工人,手上的任务遇到等待就先放到等待区,工人接着做下一个任务,等事件完成再回头继续。
老师:对,这个心智模型就是对的。
第四课:yield 是怎么实现的
学生:老师,协程的暂停和恢复,我一直觉得非常抽象,能不能从最底层讲起?比如 yield 到底是怎么实现的?
老师:可以。先明确一件事,yield 不是操作系统提供的能力,完全是 Python 虚拟机层面实现的,纯用户态,没有内核参与,没有线程切换。
核心一句话:普通函数是一次性执行;带 yield 的函数被编译成生成器,会保存函数的执行上下文,包括指令位置和局部变量,每次 next 从上次暂停点继续跑。
学生:编译阶段发生了什么?
老师:解释器看到函数体内有 yield 关键字,这个函数就不再是普通函数,被标记为 GENERATOR,编译成字节码。遇到 yield 时执行的是保存状态然后返回值,不是函数退出。
生成器对象内部保存三样东西:程序计数器,记录下一次要执行哪一行字节码;局部变量栈,保存当前函数所有局部变量的值;状态标记,判断是否已经执行完毕。
学生:那 next 做了什么?
老师:next 等价于 g.next。第一步检查生成器状态,如果已经结束,直接抛 StopIteration。第二步恢复保存的上下文,恢复局部变量,把指令指针放到上次暂停的位置。第三步继续执行字节码,直到碰到下一条 yield。第四步碰到 yield,把 yield 后面的值作为返回值,冻结当前上下文,保存指令指针和局部变量,暂停,回到调用方。第五步如果代码执行到函数末尾没有更多 yield,抛 StopIteration,标记生成器结束。
学生:普通函数和生成器的栈有什么区别?
老师:这是最关键的区别。普通函数的栈帧在 C 语言的调用栈上,函数 return 就销毁;生成器的栈帧放到堆内存上,暂停时保留,后面可以恢复。这就是生成器能暂停再恢复的根本原因。
学生:能不能看一下字节码,直观感受一下?
老师:可以。比如这个生成器函数。
def g():
  print("start")
  yield 10
  print("mid")
  yield 20用 dis 模块看字节码:
import dis
dis.dis(g)输出大概是这样的,注意 YIELD_VALUE 指令:
  2 0 LOAD\_GLOBAL 0 (print)
  2 LOAD\_CONST 1 ('start')
  4 CALL\_FUNCTION 1
  6 POP\_TOP
  3 8 LOAD\_CONST 2 (10)
  10 YIELD\_VALUE ← yield指令
  12 POP\_TOP
  4 14 LOAD\_GLOBAL 0 (print)
  16 LOAD\_CONST 3 ('mid')
  18 CALL\_FUNCTION 1
  20 POP\_TOP
  5 22 LOAD\_CONST 4 (20)
  24 YIELD\_VALUE
  26 POP\_TOP
  28 LOAD\_CONST 0 (None)
  30 RETURN\_VALUEYIELD_VALUE 是专门的虚拟机指令,做三件事:把栈顶的值拿出来作为返回值;暂停当前生成器,保存上下文;退出,回到调用者。下次调用 next,从这条指令之后继续执行。
学生:那 send 呢?我听说 await 底层会用到。
老师:send 是 yield 的进阶用法,除了恢复生成器,还能把传入的值作为 yield 表达式的结果。
def gen():
  val = yield "a"
  print(val)
  yield "b"
g = gen()
next(g)
g.send(999)学生:所以生成器不是中断,而是必须主动跑到 yield 才暂停?
老师:对,外部不能强行打断它,这就是协作式调度的来源。而且生成器只是保存函数上下文,不是多线程,同一时刻只有一处代码在运行。
第五课:手写一个简易协程调度器
学生:我还是觉得协程太抽象了,没有一个实际运行的心智模型。老师能帮我实现一个简易的协程调度吗?
老师:当然可以。核心思想:协程本质就是可暂停、可恢复的函数,Python 的生成器 yield 就具备这个能力,可以用它模拟 await。调度器维护两个集合,就绪队列存放已经准备好可以执行的协程,等待字典存放 IO 等待中的协程,标记多久之后恢复。
from collections import deque
import time
class SimpleEventLoop:
  def \_\_init\_\_(self):
  self.ready = deque()
  self.sleeping = dict()
  def add\_task(self, coro):
  self.ready.append(coro)
  def run(self):
  while self.ready or self.sleeping:
  while self.ready:
  coro = self.ready.popleft()
  try:
  delay = next(coro)
  if delay is not None:
  wake\_time = time.time() + delay
  self.sleeping\[wake\_time] = coro
  except StopIteration:
  continue
  now = time.time()
  to\_wake = \[t for t in self.sleeping if t <= now]
  for t in to\_wake:
  coro = self.sleeping.pop(t)
  self.ready.append(coro)
def my\_sleep(delay):
  yield delay
def task(name):
  print(f"{name} 开始执行")
  yield 2
  print(f"{name} 结束执行")
loop = SimpleEventLoop()
loop.add\_task(task("A"))
loop.add\_task(task("B"))
loop.run()学生:这里我一直搞不懂 next 和 yield 到底是什么关系。
老师:用最简单的方式记:yield 等于在这里暂停,把一个值交给调度器;next 等于恢复这个暂停的函数,一直跑到下一个 yield。
拿上面代码逐行拆解。调用 task ("A"),因为函数里有 yield,所以不会执行函数体,只是返回生成器对象,放进就绪队列。loop.run 进入循环,取出任务 A 的生成器,执行 next,启动任务,打印 A 开始执行,执行到 yield 2,函数暂停,所有局部变量保存,把数字 2 返回给调度器,表示需要等待 2 秒,调度器把这个生成器放进 sleeping 字典记录唤醒时间。就绪队列还有任务 B,同样处理。就绪队列空了,循环不断检查时间。2 秒后 A 和 B 的唤醒时间到了,放回就绪队列。再次执行 next,从上次暂停的 yield 后面继续跑,打印结束,函数走到末尾抛 StopIteration,调度器知道这个任务完成。
学生:对照一下心智模型。
老师:next 是调度器喊一句,你继续跑,直到你想停下来。yield 是协程回复,我现在要暂停,需要等待 2 秒,等时间到了你再 next 我继续跑。StopIteration 是协程告诉调度器,我全部做完了,不用再调度我了。
学生:这就是 await 的底层原型?
老师:对。await something 本质就是执行到某个点,主动交出控制权,告诉调度器我在等事件,你先去跑别的任务,事件就绪后再来恢复我。Java 线程池里没有这种暂停后原地恢复函数的能力,线程一旦被 OS 挂起,整个线程卡住,函数栈保存在操作系统内核,不由用户态调度器管理。
第六课:asyncio.run 内部流程
学生:Python 里主动调用协程是 asyncio.run,它调用后是直接进入循环吗?
老师:先纠正一个概念,asyncio.run 接收的是协程对象,不是普通函数。
import asyncio
async def main():
  print("hello")
  await asyncio.sleep(1)
  print("world")
asyncio.run(main())asyncio.run 的内部执行步骤,Python 3.7 以后:第一步收到协程对象 main;第二步创建一个全新的事件循环实例;第三步把 main 这个主协程包装成任务,提交到这个循环;第四步调用 loop.run_until_complete 正式进入事件循环,这一步会阻塞当前主线程,直到主任务完成;第五步主协程执行完毕后,事件循环清理剩余未完成的任务,关闭循环;第六步循环关闭后 run 返回,主线程继续往下执行后续代码。
学生:所以它不是一进循环就把所有协程立刻跑完?
老师:不是,它是调度式的。事件循环启动后,只运行当前就绪的协程,遇到 await 就挂起当前协程,切换其他就绪协程。run 帮你管理循环的完整生命周期,创建、启动、跑完、关闭,整个过程只有一个操作系统线程,主线程用来跑事件循环。
学生:容易踩的坑是什么?
老师:不能在已经正在运行的事件循环里面再次调用 asyncio.run,会报错。而且每次调用都会新建独立循环,执行结束就关闭,不能复用。对比之前手写的调度器,loop.run 等价于底层的 run_until_complete,asyncio.run 是外层包装,负责创建循环、提交主任务、执行循环、关闭循环。
第七课:epoll 是怎么和协程对接的
学生:老师,我现在理解了协程对象包含字节码指针、协程栈和状态,但还是不知道 epoll 是怎么和协程对接上的。平时用 epoll 是怎么实现通知的?从网络请求到通知唤醒,整个链路是什么?
老师:这个问题问到点子上了。先说一个最重要的前提:epoll 是操作系统内核的 IO 事件监视器,它完全不知道协程、Python、事件循环这些概念。内核只认文件描述符。
对接的逻辑是:协程 await 网络 IO 时,把 socket 注册进 epoll,然后协程挂起;操作系统内核等待 socket 就绪,就绪后 epoll 返回事件给用户态的事件循环;事件循环找到对应的挂起协程,恢复执行。
学生:完整链路能一步步拆开吗?就拿 await 发起 TCP 连接举例。
老师:可以,分八步。
第一步,协程执行到 await,准备发起非阻塞 socket。创建 socket 并设置为非阻塞模式,这是核心前提,如果是阻塞 socket,系统调用会直接卡住线程。发起 connect,非阻塞 connect 会立刻返回,不阻塞主线程,但连接还在后台内核里握手。
第二步,事件循环把这个 socket 的文件描述符注册到 epoll 实例,告诉 epoll,这个 fd 可读或可写的时候通知我。
第三步,把当前协程挂起,保存它的字节码指针和局部变量栈,把这个协程和 fd 绑定在一起。
第四步,事件循环现在没有就绪协程了,调用 epoll_wait。这是阻塞的系统调用,主线程停在这里交给操作系统,主线程休眠,CPU 交给别的进程。注意这一层不是 Python 层面的协程暂停,是 OS 层面线程休眠。
第五步,操作系统内核完成 TCP 三次握手,socket 变成可写状态,内核标记这个 fd 就绪,epoll 记住这个事件。
第六步,epoll_wait 返回,唤醒用户态的 Python 事件循环,返回就绪 fd 列表。
第七步,事件循环遍历就绪 fd,找到之前绑定的被挂起的协程,把这个协程放回就绪任务队列。
第八步,事件循环取出就绪队列的协程,恢复上下文,恢复字节码指针和局部变量,继续执行 await 后面的代码。
学生:所以这里其实有两层暂停?
老师:对,这个区分非常关键。第一层是协程层面暂停,遇到 await,Python 虚拟机保存协程上下文,交出控制权给事件循环,这是用户态,很轻量。第二层是线程层面阻塞,epoll_wait 没有事件的时候,OS 把整个线程挂起休眠,这是内核态,比较重,但一次等待可以监听成千上万个 fd。
学生:epoll 是怎么通知用户态的?
老师:epoll 有三种工作模式,常用水平触发 LT。用户态调用 epoll_ctl 往内核实例添加 fd,设置监听事件,比如 EPOLLIN 可读、EPOLLOUT 可写。用户态调用 epoll_wait 进入内核,阻塞等待事件。内核监测 socket 状态变化,socket 收到数据或者连接建立成功时,在 epoll 的就绪链表打上标记。epoll_wait 发现就绪链表不为空,把就绪事件拷贝回用户态内存,系统调用返回,唤醒用户态线程。
学生:所以 epoll 会主动回调用户函数吗?
老师:不会。epoll 只是把就绪 fd 列表交给用户程序。所有唤醒协程的逻辑都是事件循环在用户态自己做的,不是操作系统自动唤醒协程。操作系统只知道 fd 就绪,完全不知道协程是什么。
学生:epoll 不能唤醒协程?
老师:对,这是最容易混淆的误区。操作系统只能唤醒线程,协程是 Python 虚拟机的概念,内核一无所知。epoll_wait 阻塞的是 OS 线程,不是协程,协程早就挂起了,等待 epoll 事件的是事件循环。还有,非阻塞 socket 是前提,如果 socket 是阻塞的,connect 或 recv 会直接卡住 OS 线程,epoll 就没用了。
老师:把这八步收成一张图。实线箭头是同一层内的控制流,虚线箭头是用户态与内核态之间的跨层切换。
第八课:Java NIO 和 asyncio 对比
学生:Java 那边的 NIO 不也用 epoll 吗?它们的差别到底在哪?
老师:Java NIO 的 Selector 底层封装的就是 epoll,这一点没错。但底层同一个 epoll_wait,同一个 OS 线程被内核唤醒,差异不在操作系统,而在唤醒之后程序准备怎么处理这个就绪 fd。
Java NIO 原生写法:线程被 epoll 唤醒,拿到就绪 fd,就在当前这个 OS 线程里面直接跑这个 fd 对应的业务逻辑。业务代码是普通 Java 代码,没有可暂停的执行体,一旦业务代码执行中阻塞,这个线程就卡死。Netty 的方案是 IO 线程负责拿到事件,把任务丢进任务队列,交给业务线程池执行,IO 线程只做事件分发,不跑重业务。
Python asyncio:线程被 epoll 唤醒,拿到就绪 fd,查一张映射表,fd 对应挂起的协程对象,然后把这个协程丢进就绪队列,事件循环继续调度,恢复这个协程对象的上下文,接着上次暂停的位置继续跑。协程是虚拟机层面的可暂停执行体,不是线程,OS 完全不知道协程存在。
学生:一句话浓缩一下。
老师:epoll 只负责告诉用户态哪个 fd 就绪了。Java NIO 拿到 fd,当前线程直接执行该连接的业务代码,执行单元是线程栈;asyncio 拿到 fd,找到绑定的协程放到就绪队列,由事件循环恢复协程继续跑,执行单元是协程对象,包含字节码指针和协程栈。
那张 fd 到等待协程的映射表,是 asyncio 事件循环自己维护的,内核 epoll 里面没有存协程,只注册了 fd。
学生:我终于明白了。本质都是一个线程调用操作系统的底层监听能力,只是 Java 停留在线程这个执行体上,而 Python 则把每个 socket fd 都绑定到对应的协程,拿到有事件的 fd 后再调度对应的协程对象即可。
老师:总结得非常好。再补一个关键点:同一个 OS 线程既能跑事件循环调度,又能执行协程。epoll_wait 阻塞休眠的是这个唯一的 OS 线程,一旦内核唤醒线程,回到 Python 用户态才开始协程调度。
学生:所以 IO 多路复用和协程其实是两层独立的东西?
老师:对。IO 多路复用只是内核的事件通知机制,协程是上层虚拟机的执行体管理,二者是两层独立的东西,靠事件循环把它们粘合在一起。
总结
学生:老师,最后帮我用一张图把整个心智模型收一下。
老师:事件循环持有 epoll,完整链路是这样的。
协程 await 网络IO
  ↓
socket 设为非阻塞,注册 fd 进 epoll,协程挂起
  ↓
没有就绪协程 → 调用 epoll\_wait,主线程休眠
  ↓
内核发现 socket 就绪 → epoll\_wait 返回
  ↓
事件循环拿到 fd,找到绑定的协程,放回就绪队列
  ↓
事件循环恢复协程,继续执行 await 后面的代码学生:那 Java 线程池和 asyncio 各一句话收尾。
老师:Java 线程池是多个独立工人各自干活,阻塞就原地休息,别人继续干,执行单元是操作系统线程。协程调度器是单个工人,一个任务遇到等待先放一边,去处理下一个任务,等事件完成再回头继续,执行单元是包含字节码指针和协程栈的协程对象。底层 epoll 只是事件通知,协程调度是虚拟机执行体管理,事件循环把这两层粘在一起。