卡?慢?并发低?玩转Python异步编程,手把手教你如何提高你的AI应用性能

398 阅读52分钟

前言

近日,TIOBE官网公布了2025年5月的编程语言排行榜,Python的表现堪称惊艳,以绝对的优势“独霸天下”,Python占比高达25.35%,且本月上升幅度达到了惊人的9.02%,与第二名C++之间的差距首次超过15%。

随着AI越来越火,Python也顺理成章地进入了每一位程序员的视野,无论你是前端还是后端,不安于现状的大伙肯定都或多或少的开始折腾起python了,自己玩起了AI,那么,如何让你的AI应用提高生产部署性能呢?

我们今天来聊一下关于python一个至关重要的特性--异步编程。

本文会带大家从 JavaScript 的视角出发,一步步了解 Python 异步编程的玩法,有前端应用开发基础的小伙伴理解起来是相当Easy的。

长文预警,很细很全,善用目录食用。

为什么我们需要异步?

想象一下,你是东北一家火爆饭店的唯一掌勺师傅。

  • 同步阻塞(Synchronous Blocking) :有位大哥点了一份锅包肉,你得先切肉、腌肉、炸两遍,最后再炒糖醋汁儿。这一套流程下来少说也得七八分钟。你要是死盯着锅里那口油炸肉,啥也不干,后头等着的顾客都快急哭了——“咋还没好啊?我赶时间呢!”
    你倒是想快点儿,可这锅肉没炸完你也不敢走开啊。等一个菜做完才能做下一个,这就是典型的同步阻塞模式,效率低、客人怨气大,你累得够呛还出不了几道菜。
  • 异步非阻塞(Asynchronous Non-blocking) :后来你长心眼了。这回大哥点锅包肉,你先把肉腌上,然后下锅炸第一遍,这时候你知道还得等一小会儿才能复炸。你不傻等了,转身去给另一桌炒个地三鲜,顺手看看炖着的小鸡炖蘑菇是不是该收汤了。
    等锅包肉炸好的时候,“滋啦”一响,你听见了,立马回过头来继续下一步。这样你几乎每时每刻都在干活,没闲着,效率嗖嗖的。

这就叫异步非阻塞 ,在程序里也是一样。尤其是在 I/O 密集型任务中,比如网络请求、文件读写、数据库查询这些操作,咱不能让整个程序卡在这儿干等。趁着它自己慢慢跑的时候,去干点别的活儿,等它好了再来处理。就像厨师一样,边等边干活,效率才高!

JS 和 Python 都意识到了这一点,并提供了各自的异步解决方案。

JS 异步编程核心回顾

在深入 Python 之前,我们快速回顾一下 JS 中的异步功臣:

  1. 回调函数 (Callbacks) :最初的异步实现方式。把一个函数(回调)作为参数传给另一个函数,在异步操作完成后执行这个回调。缺点是容易造成“回调地狱”。

    // 伪代码
    fetchData('/api/user', function(user) {
        fetchData(`/api/posts?userId=${user.id}`, function(posts) {
            console.log(posts);
        }, function(error) {
            console.error('Error fetching posts:', error);
        });
    }, function(error) {
        console.error('Error fetching user:', error);
    });
    
  2. Promise:为了解决回调地狱而生。Promise 是一个代表了异步操作最终完成(或失败)及其结果值的对象。它有三种状态:pending(进行中)、fulfilled(已成功)和 rejected(已失败)。我们可以用 .then() 处理成功,用 .catch() 处理失败。

    // 伪代码
    fetchData('/api/user')
        .then(user => fetchData(`/api/posts?userId=${user.id}`))
        .then(posts => console.log(posts))
        .catch(error => console.error('An error occurred:', error));
    
  3. async/await:ES2017 引入的语法糖,让异步代码看起来更像同步代码,更易读。async 函数会隐式返回一个 Promise,await 关键字则用来等待一个 Promise 完成。

    // 伪代码
    async function main() {
        try {
            const user = await fetchData('/api/user');
            const posts = await fetchData(`/api/posts?userId=${user.id}`);
            console.log(posts);
        } catch (error) {
            console.error('An error occurred:', error);
        }
    }
    main();
    
  4. 事件循环 (Event Loop) :JS 引擎的核心机制。JS 是单线程的,通过事件循环来实现非阻塞的并发。

    • 调用栈 (Call Stack) :同步代码在这里执行。
    • Web APIs / Node APIs (环境提供) :像 setTimeout, Fetch 这样的异步操作,在调用时会交给浏览器或 Node.js 的相应模块处理(这些模块底层可能使用多线程)。
    • 任务队列 (Task Queue / Callback Queue) :也就是宏任务队列,当 Web APIs 完成了它们的工作(比如定时器到时,数据加载完毕),相关的回调函数会被放入任务队列。
    • 微任务队列 (Microtask Queue) :Promise 的 .then, .catch, .finally 回调会进入微任务队列。微任务的优先级高于宏任务(普通任务队列中的任务)。
    • 事件循环:不断检查调用栈是否为空。如果为空,它会先检查微任务队列,清空所有微任务并执行。然后,它会从任务队列中取出一个宏任务来执行。如此往复。

    正是这样的事件循环模型使得 JS 可以在一个主线程上高效地处理 I/O 操作,而不会阻塞主线程,保持了 UI 的响应性(在浏览器中)或服务器的高吞吐量(在 Node.js 中)。

什么是异步IO

异步 I/O(Asynchronous I/O)操作泛指不会阻塞主线程、通过回调、Promise、事件循环等机制处理结果的输入输出操作

叫做 “I/O”(Input/Output,输入/输出)是因为这些操作涉及 程序与外部系统之间的数据交换,而不是仅仅在 CPU 内部运算。

与异步IO相对的概念是同步IO,即 阻塞IO, 如 aiohttp(异步网络请求IO)与 requests(阻塞网络请求IO)。

定义

I/O 操作指的是程序读取或写入外部资源的行为,这些资源可能包括:

  • 文件系统(磁盘)
  • 网络(发送/接收其他服务器或应用上的数据包)
  • 用户输入(键盘、鼠标等)
  • 数据库、外部设备(打印机、摄像头等)

这些资源都位于 CPU/内存以外,所以要通过“输入”或“输出”的方式进行沟通。

举例

操作输入(Input)或输出(Output)类型说明
读取文件内容输入从磁盘读取数据
把数据写入文件输出写入磁盘
发 HTTP 请求获取数据输入网络输入
发送数据到服务器输出网络输出
用户点击按钮触发事件输入用户输入
把结果显示在页面上输出呈现给用户

和 CPU 计算的区别

  • 计算(CPU密集 CPU-bound) :如 100 万次加法、图片压缩。这是 CPU 在工作,几乎不涉及外部资源。
  • I/O(IO密集 IO-bound) :如下载文件、读取数据库。这是等待外部资源返回数据。

总之一句话:

I/O 就是程序“与外界打交道”的过程,比如问外部要数据,或把结果送出去。

Python 的异步世界 —— asyncio 登场

好了,热身完毕!现在我们来看看 Python。Python 的现代异步编程主要依赖于 asyncio 库,它在 Python 3.4 被引入。

这个单词没那么复杂哦,asyncio = async + io,即异步io。

async defawait

Python 的异步语法和 JS 的 async/await 非常相似。

  • 使用 async def 来定义一个协程函数 (coroutine function) 。调用一个协程函数会返回一个协程对象 (coroutine object) 。这有点像 JS 的 async 函数返回一个 Promise。

  • 使用 await 关键字来暂停当前协程的执行,等待另一个协程完成。这和 JS 的 await 等待 Promise 执行一样。

    import asyncio
    
    async def say_hello_after(delay, name):
        print(f"[{name}] Starting, will wait for {delay} seconds...")
        await asyncio.sleep(delay) # 这是一个异步的 sleep,类似于 JS 的 await new Promise(resolve => setTimeout(resolve, delay*1000))
        print(f"[{name}] Hello, {name}!")
        return f"Finished {name}"
    
    async def main():
        print("Starting main function")
        # 调用协程函数,得到协程对象
        coro1 = say_hello_after(2, "小明")
        coro2 = say_hello_after(1, "小红")
    
        # 直接调用协程对象并不会执行它,需要被事件循环调度
        # 用 await 来执行并等待协程完成
        result1 = await coro1 # coro 在python常表示协程
        result2 = await coro2
        # 注意,上面的 await 是串行的。小明 会先完成,然后 小红 才开始。
    
        print(f"Result 1: {result1}")
        print(f"Result 2: {result2}")
        print("Main function finished")
    
    # 运行主协程
    # 在 Python 3.7+ 中,可以用 asyncio.run() 来启动事件循环并运行主协程
    if __name__ == "__main__":
        asyncio.run(main())
    

    输出:

    Starting main function
    [小明] Starting, will wait for 2 seconds...
    [小明] Hello, 小明!
    [小红] Starting, will wait for 1 seconds...
    [小红] Hello, 小红!
    Result 1: Finished 小明
    Result 2: Finished 小红
    Main function finished
    

    await coro1 执行完后,await coro2 才开始。这是因为 await 会暂停当前函数的执行,直到等待的操作完成,和js的Promise是一样的。

单线程并发的魔法 —— asyncio 如何施展

无论Python还是Js,他们都是单线程模型,很多初学者都会困惑的一个问题:“单线程如何实现并发的呢?”

  • 首先Js是由宿主环境来控制单线程执行的

    宿主环境(浏览器 或 Node.js)控制事件循环,JS 引擎负责执行 JS 代码,JS 引擎本身不知道“异步”,异步调度完全由宿主负责。

    • JS 引擎(如 V8):执行 JavaScript 代码,是一个纯计算引擎

    • 宿主环境(如浏览器 / Node.js):

      • 提供事件循环(event loop)机制;
      • 管理任务队列(如微任务、宏任务);
      • 控制什么时候调用 JS 引擎去执行下一段代码。
  • 而Python 解释器(如 CPython)通过 GIL 控制“同时只能一个线程运行字节码”

    Python 运行时可以创建多个线程(threading.Thread),但 GIL(全局解释器锁) 限制了:同一时刻只有一个线程可以执行 Python 字节码。

什么是GIL?

GIL(全局解释器锁)CPython 解释器为了让多个线程安全访问解释器内部状态而设计的一个锁。

GIL 是为了解决 CPython 自身线程不安全的问题,而不是 Python 必须有的东西,其他解释器从底层重新设计了线程模型或使用了线程安全的内存管理机制,从根本上避免了 GIL 的需求,所以并不一定非要用CPython,但是CPython是最常用、兼容性最好、生态最丰富的解释器。

GIL 只锁 Python 字节码,不锁 C 扩展

如果你运行的是 Python 层的代码(如 for 循环、递归、计算),这些都是 Python 字节码 —— 会被 GIL 限制;

如果你运行的是可以释放 GIL 的扩展(如 numpy.dot()time.sleep()requests 的等待),则不受影响。

Python 自动释放 GIL 主要在以下情况:

  • 阻塞I/O 操作time.sleep(),文件读写,网络请求,属于 IO 阻塞型,也会释放 GIL,其所在线程会“休眠”,如果阻塞IO在主线程(事件循环线程)执行,那么事件循环会被阻塞,导致所有协程都“卡住”,所以不要在主线程执行阻塞IO操作。
    • 异步IO不阻塞,自然也就不需要释放GIL
  • C 扩展代码numpypandas 进行矩阵计算,numpy.dot()纯 C 实现,且主动释放 GIL,适用于 CPU 密集型 场景
  • 多进程计算multiprocessing 模块可以完全绕过 GIL)

释放 GIL 的意思是:

当前执行的线程暂停对 GIL 的占用,允许其他线程获取 GIL 以执行 Python 代码。

具体而言:

  • 释放 GIL 不意味着该线程不再执行 Python 代码,它只是允许其他线程获取GIL然后执行代码。
  • 释放 GIL 后,如果当前线程继续执行 Python 代码,它仍然会占用 GIL,直到再次释放它,谁拿到GIL谁执行,轮着来。

释放 GIL 的操作通常不使用Python解释器执行

释放 GIL 的操作一般是 IO 阻塞C 扩展,这些操作本身并不依赖 Python 解释器的执行,而是通过 C 扩展外部系统资源 来处理任务。例如:

  • I/O 操作:在执行 I/O 操作(如 time.sleep()、网络请求requests、文件读取等)时,线程会休眠(阻塞),并释放 GIL 让其他线程执行。IO 操作本身由操作系统处理,不依赖 Python 解释器。
  • C 扩展操作:例如 numpy 使用 C 来执行数值计算。C 代码本身不需要 Python 解释器,因此可以绕过 GIL,实现并行处理。

GIL 的存在让 CPU 密集任务没法跑满多核

举个例子

import threading

def count():
    while True:
        pass  # 模拟 CPU 计算

for _ in range(4):
    threading.Thread(target=count).start()
即使你有 4 核 CPU,4 个线程也不能并行执行

因为:

  • 它们争抢 GIL;
  • 一次只能一个线程运行;
  • CPU 实际使用率约为 1 核。

单线程是为了避免多个线程同时修改内存造成崩溃或数据错误,保障了数据一致性。

GIL 会串行化 Python 字节码的执行,哪怕你启动了多个线程,它们也只能“轮流”运行(所有线程都要轮流获取 GIL),不能真正并行利用多核 CPU,这就是多线程无法并行的根本原因。

如果你要跑 CPU 密集任务,推荐使用:
  • multiprocessing 模块(启动多个进程,每个进程都有自己的 GIL)
  • 或者换成没有 GIL 的解释器(比如 PyPy)。

Ok,搞懂了Python的单线程机制,我们接着说回单线程并发。

和JS一样,单线程并发并不矛盾。

asyncio 的并发模型

asyncio 的并发不是通过创建多个线程来执行 Python 代码 (除非你显式使用 asyncio.to_thread,我们稍后会谈)。它是在单个线程内通过事件循环和协程的协作来实现的。

  • 当一个协程执行到 await 一个异步函数asyncfn()(里面是非阻塞io操作) 时,如果这个 I/O 操作需要时间(比如网络请求),协程会“交出”控制权给事件循环。它告诉事件循环:“我要等这个东西,你先去忙别的吧,好了叫我。”
  • 事件循环此时会去看看有没有其他已经准备好可以运行的协程任务,并执行它们。
  • 当那个 I/O 操作完成后(比如服务器响应回来了),事件循环会得到通知,然后在合适的时机唤醒之前等待的那个协程,让它从 await 的地方继续执行。
所以,单线程并发的实现依赖于:
  • 非阻塞 I/O 操作:这些IO操作(如网络、文件等)不由Python解释器执行,而是由操作系统层面支持非阻塞模式。当 asyncio 发起一个非阻塞 I/O 请求后,它可以立即返回,而不需要等待操作完成。操作系统会在操作完成后通知事件循环。
  • 协程的 await:挂起该协程,告诉事件循环可以去调度其他任务。
与 JS 的相似之处:

JS 的事件循环也是在单线程上做类似的事情。Fetch() API 调用后,实际的网络请求是由浏览器/Node.js 的底层处理的,JS 主线程可以继续执行其他代码。当网络响应回来,Fetch 返回的 Promise 的回调会被放入任务队列,等待事件循环调度执行。

关于 js 的事件循环更详细的传送门,JavaScript 运行机制(EventLoop)详解juejin.cn/post/712338…

什么是进程、线程、协程 ?

进程(Process)—— “找帮工”

什么是进程?

  • 进程是操作系统中运行的一个程序。
  • 每个进程都有自己独立的内存空间和资源。

特点:

特性描述
独立性强每个进程互不干扰,就像两个不同的厨房。
切换开销大启动、切换进程比较慢,因为要复制大量资源。
数据共享困难不同进程之间不能直接访问彼此的数据,需要通过特殊手段通信(比如管道、文件、网络)。

举例:

你在主厨房炒菜,另一个人在副厨房里也能独立炒菜,每个厨房有自己的锅碗瓢盆等工具,两人完全独立操作,互不影响,这就是多进程

线程(Thread)—— “找几个小工一块干”

什么是线程?
  • 线程是进程中的一个执行单元,共享进程的内存和资源,是 CPU 调度的基本单位。
  • 一个进程可有多个线程。
特点:
特性描述
共享资源同一进程下的线程可以访问相同的变量和数据。
切换开销较小线程之间的切换比进程快。
安全风险高因为资源共享,一个线程出错可能影响整个进程。
GIL限制(Python特例)Python 中由于全局解释器锁(GIL),多线程并不能真正并行计算,适合 I/O 密集型任务,不适合 CPU 密集型任务。

举例:

你和几个学徒在一个厨房里炒菜,大家都能看到锅碗瓢盆,谁想用谁拿,但要是不小心把酱油撒了一地,大家都得受影响,这就是多线程

什么是协程?—— “自己切换任务,不等待”

协程是一种用户态的轻量级线程,由程序员自己控制调度什么时候暂停,什么时候执行。

它可以在执行过程中暂停,去执行其他任务,然后再回来继续执行。

注意,协程不是真正的“并行”,而是“协作式”的并发。

并行和并发的区别
概念定义
并发(Concurrency)并发需要切换,同一时间段内管理多个任务的执行,任务之间快速切换,只是看起来“同时进行”。
并行(Parallelism)多个任务在同一时刻真正同时执行,通常依赖多核 CPU。

记住,并发需要切换执行。

特点
特性说明
极轻量协程的创建和切换成本非常低。
用户控制不依赖操作系统调度,由程序自己决定什么时候让出 CPU。
异步友好非常适合处理大量 I/O 操作,比如网络请求、数据库查询等。
单线程内实现即使只有一个线程,也可以利用协程实现并发效果。

举个例子:

你一个人炒两道菜:一道炖鱼,一道锅包肉。你先炸锅包肉,等它沥油的时候去翻炒炖鱼;炖鱼快好了,再回来看看锅包肉是不是该复炸了。你没有请别人帮忙,自己也能高效运转,这就是协程。当然,肯定是比和别人一起干累点,协程就是不让你歇着,把牛马做到极致!

事件循环 (Event Loop) —— Python 的大管家

接下来我们具体说一下Python的事件循环。

和 JS 一样,Python 的 asyncio 也是基于事件循环的。事件循环是 asyncio 应用的核心,它负责:

  • 运行异步任务 (协程)。
  • 处理 I/O 事件。
  • 调度回调。

当你调用 asyncio.run(main()) 时,它会创建一个事件循环,然后将 main() 协程作为第一个任务交给事件循环去运行。当协程遇到 await 并且等待的操作(比如 asyncio.sleep() 或一个真正的 I/O 操作)尚未完成时,该协程会暂停,事件循环就可以去运行其他准备好的任务。一旦等待的操作完成,事件循环会在适当的时候唤醒暂停的协程,让它从 await 的地方继续执行。

asyncio 事件循环的实现和使用

asyncio 事件循环当做是一个勤奋的调度员,管理着一堆待办事项(回调和协程任务)。

事件循环是 asyncio 提供的机制(库级别实现),Python 本身(语言层面)并没有内建事件循环的具体实现,而是:

  • Python 语言在语法层面支持 async/await
  • 而实际的 事件循环机制(如任务调度、回调处理、IO等待)由 asyncio 提供并实现

注意,纯粹同步的python代码不使用事件循环,事件循环是 asyncio 运行的核心引擎asyncio 实现并扩展了事件循环,python本身没有事件循环。 asyncio 是实现事件循环的框架之一,其他还有uvloop等。

可以直接使用(通过 asyncio.run()创建事件循环, asyncio.get_running_loop()获取事件循环 等)。

asyncio 的事件循环运行在主线程之上,事件循环本质上就是一段管理和调度协程执行的 Python 代码。

所以事件循环需要手动调用方法去创建。

# 创建事件循环,运行完自动销毁
asyncio.run() #(仅在主线程使用)

等同于(子线程需手动创建事件循环时使用)

loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
    loop.run_until_complete(main())
finally:
    loop.close()

# 在主线程中获取默认事件循环,如果没有则自动创建
loop = asyncio.get_event_loop() 

   
# 获取当前协程上下文中正在运行的事件循环实例
loop = asyncio.get_running_loop()

注意:

在脚本中多次调用 asyncio.run()(串行)允许每次都会创建/销毁自己的事件循环,不冲突
在已运行的事件循环中调用 asyncio.run()不允许一个线程只能有一个活动的事件循环

Python asyncio 的事件循环流程

  1. 检查并执行立即回调 (Handles - call_soon):

    • 循环开始一次迭代时,首先检查一个内部队列,看是否有通过 loop.call_soon() 安排的“立即”回调函数 (称为 Handles)。
    • 这些是普通的 Python 函数,不是协程。
    • 如果队列中有回调,调度员会按照它们被添加的顺序,依次同步执行这些函数,直到这个队列变空。
  2. 检查并执行到期定时器回调 (Handles - call_later):

    • 接下来,调度员查看所有通过 loop.call_later()loop.call_at() 设置的定时器。
    • 它检查当前时间,找出所有已经到期的定时器。
    • 对于每个到期的定时器,执行其关联的回调函数 (也是 Handles)。如果有多个同时到期,通常按到期时间顺序执行。
  3. 执行 I/O 轮询(等待阶段):

    • 这是事件循环的核心“等待”部分。调度员会调用操作系统的I/O 多路复用功能(如 Linux 的 epoll,macOS 的 kqueue,Windows 的 selectIOCP)。
    • 它告诉操作系统:“请监视这些网络连接、文件等,看看哪些准备好了进行读写操作。同时,请最多等待 T 秒。”
    • 在此期间,事件循环线程基本处于休眠状态,等待操作系统通知。
  4. 处理 I/O 事件并唤醒任务:

    • 当操作系统通知有 I/O 事件发生或者等待超时后,调度员被唤醒。
    • 它查看哪些 I/O 操作已经就绪。
    • 对于每个就绪的 I/O 操作,它找到关联的内部对象(即对应Future)。
    • 将这个 Future 标记为“完成”,并把结果或发生的错误存入 Future
    • 然后,找到那个正在 await 这个 Future协程任务 (Task)
    • 将这个任务标记为“就绪”,并把它放入一个就绪队列 (Ready Queue) ,表示它可以继续运行了。
  5. 执行就绪队列中的任务 (Tasks):

    • 调度员开始处理就绪队列 (Ready Queue) 中的所有协程任务 (Tasks)。
    • 它从队列中取出一个任务恢复这个协程的执行,从它上次 await 暂停的地方继续。
    • 协程代码会一直运行,直到它遇到下一个 await 表达式(主动让出控制权去等待其他操作)、或者协程执行完毕、或者抛出异常。
    • 如果协程 await 了一个尚未完成的 Future(比如发起了一个新的网络请求),这个任务会再次暂停,等待相应的 I/O 事件在未来的循环迭代中将它唤醒(回到第 4 步)。
    • 调度员会继续从就绪队列中取出并执行下一个就绪的任务,直到就绪队列变空。所以在一个循环迭代的这个阶段,可能会执行多个任务的片段。

      就像一个厨师(事件循环调度员)面前有几道菜(就绪的任务)都需要立即处理一下(翻炒、加调料等)。厨师不会只炒第一道菜。他会:

      1. 快速翻炒第一道菜(执行任务 A 片段),直到需要加盖焖煮(await)。
      2. 立刻转到第二道菜,快速加入调料(执行任务 B 片段),直到需要腌制(await)。
      3. 立刻转到第三道菜,检查一下温度正好完成(执行任务 C 片段并结束)。

      在这个“处理就绪菜(可能是一步一步就绪的,例如先切菜、腌制等,每一道菜都有几个阶段)”的阶段,厨师处理了多道菜(多个任务) ,每道菜都只处理了当前需要的一小部分(片段)

  6. 循环:

    • 当就绪队列也处理完毕后,当前事件循环迭代结束。调度员立即开始下一次迭代,回到第 1 步,继续检查 call_soon 回调、定时器、I/O 和就绪任务。这个过程持续进行,驱动整个异步程序的运行。

与 JS 事件循环的主要区别

关键点Python 事件循环 (asyncio)浏览器事件循环 (JavaScript)
核心目的与应用领域主要用于 I/O 密集型任务 的并发处理,如网络请求、文件读写、数据库操作。提升后端服务、网络爬虫、异步工具等的性能和吞吐量。主要为了 保障用户界面 (UI) 的流畅性和响应性,处理用户交互(点击、滚动)、DOM 更新、网络请求、定时器等,并协调各种 Web API 的异步操作。
线程模型与并发运行在单一主线程中。通过协程 (Coroutines) 实现并发,在单线程内进行高效的任务切换。Python 的全局解释器锁 (GIL) 意味着即使使用多线程,CPU 密集型任务也难以真正并行;asyncio 通过避免阻塞 I/O 来最大化单线程效率。JavaScript 代码本身运行在浏览器的单一主线程上。可以通过 Web Workers 将耗时计算任务分配到后台线程执行,实现真正的并行计算,但 Web Worker 有独立的事件循环且不能直接操作 DOM。
任务的表示与管理异步任务由 async def 定义的协程构成。使用 asyncio.create_task()loop.create_task() 将协程包装成 Task 对象进行调度。Future 对象代表异步操作的最终结果。异步操作的结果通过回调函数 (Callbacks)Promises 以及基于 Promise 的 async/await 语法糖来处理和管理。
任务队列机制内部维护一个或多个就绪队列 (ready queue) 来存放可运行的协程/回调。loop.call_soon() 安排回调在下一次迭代尽快执行 (类似微任务的即时性,但不完全等同)。loop.call_later() 安排延迟任务。没有严格公开区分宏任务/微任务队列。明确区分两种任务队列:1. 宏任务队列 (Macrotask Queue / Task Queue): 包括 script (整体代码)、setTimeoutsetInterval、I/O 回调、UI 事件、postMessage 等。2. 微任务队列 (Microtask Queue): 包括 Promise.then/catch/finally 的回调、queueMicrotask()MutationObserver 回调。
调度与执行顺序事件循环从就绪队列中取出任务执行。当一个协程遇到 await(如等待 I/O),它会挂起自身,将控制权交还给事件循环,事件循环转而执行其他就绪任务。I/O 完成后,对应协程重新变为就绪状态。事件循环的每一轮 (tick) 执行以下步骤:<br>1. 从宏任务队列中取出一个宏任务并执行。<br>2. 执行当前宏任务后,立即处理所有微任务队列中的任务,直到微任务队列清空。<br>3. (可选)进行 UI 渲染更新。<br>4. 开始下一轮循环,检查宏任务队列。微任务总是在下一个宏任务之前、以及在UI渲染之前执行完毕。
控制权与启动需要显式获取或创建事件循环实例,并通过 loop.run_until_complete()asyncio.run() (Python 3.7+) 来启动运行事件循环。对事件循环的生命周期有更多控制权。事件循环由浏览器本身提供和管理,对开发者是透明的。开发者编写的 JavaScript 代码自动在浏览器的事件循环中运行,无需手动启动或管理事件循环本身。
I/O 处理方式依赖操作系统提供的非阻塞 I/O 机制。asyncio 封装了这些底层细节,让协程在等待 I/O 时不阻塞整个线程。浏览器将许多 I/O 操作(如网络请求 Fetch、图片加载)委托给浏览器内部的并行线程处理。当这些操作完成时,它们的回调函数会被作为任务添加到相应的事件循环队列中。
错误处理协程中的异常会被封装在 TaskFuture 对象中。可以通过 try...except 块包围 await 表达式来捕获。未捕获的异常会导致 Task 失败,可以通过 loop.set_exception_handler() 设置全局处理器。宏任务中的未捕获异常通常会报告到控制台,并可能终止当前任务,但一般不会终止整个事件循环。Promise 中未处理的拒绝 (rejection) 会触发 unhandledrejection 事件。window.onerrorwindow.onunhandledrejection 可用于全局错误捕获。
可配置性/环境依赖asyncio 事件循环策略在一定程度上可配置。例如使用asyncio.set_event_loop_policy(policy),一般用不到。事件循环是浏览器核心的一部分,其行为由 Web 标准规定,开发者无法配置或替换。强依赖于浏览器环境。

保持事件循环活跃

有一个细节

在 Python 中,必须显式用 await “撑住事件循环”,否则它就结束了
在 JavaScript 中,事件循环一直在后台运行,你丢给它的任务(如 Promise)它会帮你自动调度。

Python vs JavaScript 的事件循环调度上的区别

特性Python (asyncio)JavaScript (浏览器 / Node.js)
事件循环创建需要手动:如 asyncio.run()自动存在:JS 引擎内建事件循环
主程序结束是否关闭事件循环是(默认行为)否,直到所有微任务 / 宏任务执行完
保持事件循环活跃需通过 awaitawait asyncio.sleep()await asyncio.gather() 等显式写法自动调度 microtask/queue,不需要“手动保持”
创建异步任务但不等待结果会被取消 / 不执行(除非保活)自动调度,即使不 await,仍会执行
手动调度下一轮事件是,如通过 await 触发否,由引擎负责安排(比如 Promise.then() 自动下一轮)

Python(不 await,任务不会执行):


async def foo():
    print("foo")

async def main():
    asyncio.create_task(foo())  # 不 await

asyncio.run(main())

asyncio.create_task() 会将协程注册为任务,由事件循环自动调度执行,但必须确保事件循环还在运行,否则任务可能根本来不及执行

输出:什么都没有!,原因create_task仅注册任务到事件循环,需要等下一次事件循环周期时才能被调度,没用awiat就没有下一次了,事件循环直接就结束了,自然就不会执行。

具体解释一下:

首先:事件循环的本质

asyncio.run(main()) 会:

  1. 创建一个事件循环;
  2. 执行 main() 协程;
  3. 一旦 main() 运行完成,事件循环就自动关闭

所以,事件循环的生存时间 = 协程的执行时间

也就是说:

  • 如果 main()(或你传给 asyncio.run() 的协程)马上就执行完了,那整个事件循环就立刻结束
  • 此时,即使你之前用 create_task() 启动了一些任务,它们都没来得及执行完就被终止了

为什么 await ... 能“保活”事件循环?

因为 协程在执行 await 时会挂起,事件循环仍在运行,等待这个协程恢复执行,直到协程结束。

所以,只要有 await,事件循环就会保持“活着”,直到所有 await 的内容完成。

在JavaScript中,即使不 await,任务也会执行


async function foo() {
  console.log('foo');
}

function main() {
  foo(); // 不 await
}

main();

输出:foo

OK,了解事件循环机制之后,接下来详细说一下Python和JS对任务和回调的管理方案。

Future 和 Task —— Python 版的 Promise

1. Future、Task 与 Promise 的区别

概念Python (Future, Task)JavaScript (Promise)
本质对“还没完成”的操作结果的引用同样是“未来值”的占位符
作用跟踪协程或异步操作的执行状态和结果跟踪异步操作是否完成、成功或失败
谁创建它Future: 由底层创建 ;Task: 由开发者包装协程由开发者手动 new Promise(...) 或 API 返回
调度方式依赖 asyncio 事件循环调度内建事件循环自动调度

Future + Task ≈ Promise

2. Task 与 Future 的关系

TaskFuture 的子类。

一个 Task 对象 就是 一个 Future 对象。 它继承了 Future 的所有属性和方法。

TaskFuture 的基础上,增加了专门用于包装和运行协程 (coroutine) 的功能。

asyncio.Future (基础/父类)

Future 与 Promise 类似,但是它不会自动运行,需要配合Task去运行,所以 Future + Task ≈ Promise

  • 定义Future 对象代表一个异步操作的最终结果。它是一个占位符,表示某个值或异常将在未来的某个时刻可用。

  • 角色

    • 作为异步操作结果的容器。
    • 提供查询状态(例如,是否完成 done(), 是否被取消 cancelled())的方法。
    • 提供获取结果 (result()) 或异常 (exception()) 的方法。
    • 提供设置结果 (set_result()) 或设置异常 (set_exception()) 的方法。
    • 可以被 awaitawait一个 Future 会暂停当前协程,直到该 Future 完成。
    • 可以添加完成回调 (add_done_callback())。
  • 用途Future 是一个相对底层的接口。 asyncio 库的很多部分会使用它。例如:

    • loop.run_in_executor() 会返回一个 Future,因为它代表在线程池中执行的函数的结果。
Future 作为未来的占位符使用

Python 中如何实现类似 js 中使用 Promise 作为未来值的占位符,可以在任意地方resolve这个promise,从而灵活控制执行回调的时机呢?

我们可以使用asyncio.Future 像JS 的 Promise 那样,在任意时刻手动 set_result(类似 resolve):

import asyncio

# 创建一个全局 Future 实例
future: asyncio.Future

async def waiter():
    print("Waiting for future to be resolved...")
    result = await future
    print(f"Future resolved with: {result}")

async def resolver():
    await asyncio.sleep(2)  # 模拟某个异步事件之后才 resolve
    print("Resolving future now...")
    future.set_result("hello from future")

async def main():
    global future
    future = asyncio.Future()
    
    # 启动两个任务:一个等待 future,一个在将来某时设置它的结果,一旦 `set_result()` 被调用,等待它的协程就会继续执行
    await asyncio.gather(waiter(), resolver())

asyncio.run(main())
asyncio.Task (特定的 Future/子类)
  • 定义Task 对象专门用于在事件循环中调度、执行和管理一个协程

  • Future 的关系

    1. 继承性:由于 TaskFuture 的子类,它拥有 Future 的所有特性。因此,一个 Task 也是其所包装协程最终结果的占位符(因为Future是占位符)。可以 await 一个 Task,获取其结果或异常,就像使用一个普通的 Future

    2. 封装协程Task 的核心附加功能是它封装了一个协程。当你通过 asyncio.create_task(coro)loop.create_task(coro) 创建一个 Task 时,这个协程就会被安排在事件循环上运行(注册到就绪队列中,等待下一轮事件循环被执行)。

    3. 驱动执行Task 负责与事件循环交互,驱动其内部协程的执行。当协程 await 其他可等待对象时,Task 会确保协程被正确挂起和恢复。

      示例:

      import asyncio
      
      async def task1():
          print("任务 1 开始")
          await asyncio.sleep(2)  # 任务 1 挂起,让出执行权
          print("任务 1 继续执行")
      
      async def task2():
          print("任务 2 开始")
          await asyncio.sleep(1)  # 任务 2 挂起,让出执行权
          print("任务 2 继续执行")
      
      async def main():
          t1 = asyncio.create_task(task1())  # 创建 Task,交给事件循环调度
          t2 = asyncio.create_task(task2())  # 创建 Task,交给事件循环调度
          await asyncio.sleep(3)  # 等待一段时间,观察调度过程
      
      asyncio.run(main())
      
      输出顺序:
      
      
      任务 1 开始
      任务 2 开始
      任务 2 继续执行 (1秒后,任务 2 先恢复)
      任务 1 继续执行 (2秒后,任务 1 也恢复)
      
      
    4. 自动设置结果:当被 Task 包装的协程执行完毕并返回一个值时,Task 会自动调用其(从 Future 继承来的)set_result() 方法来存储这个值。如果是异常,Task 会自动调用 set_exception() 方法。

    5. 取消处理Task 对取消 (cancel()) 有特定的处理:它会向其包装的协程内部注入一个 asyncio.CancelledError 异常,将协程进行清理。

总结一下它们的关系
  • Future 是一个通用的“未来凭证” :它承诺在未来会有一个结果或错误。

  • Task 是一个“正在执行的异步工作(特指协程)” :它不仅是一个“未来凭证”(因为它是一个 Future),它还主动管理着一个协程的生命周期,并负责执行这个协程。

  • Future 就像你提前准备好的一张空白“成绩单”,你知道以后某个时间会有结果写上去;

  • Task 就是那个真正去参加考试的学生,他考试完后,会把成绩写到那张成绩单(Future)上

Future = 成绩单(空的,等着有人填结果)

Task = 一个“正在考试的人”,考试过程就是执行协程的过程

TaskFuture 的子类:它不仅是“空白成绩单”,还会自己动手去“答题”并写结果

Future + Task ≈ Promise

3. 状态比较

状态Python Future/TaskJS Promise
Pending尚未完成尚未完成
Done成功完成Fulfilled(成功)
Exception抛出异常(异常信息可取)Rejected(失败)

Python 用 .done(), .result(), .exception()
JS 用 .then(), .catch()

4. 创建方式对比

JavaScript:


const p = new Promise((resolve, reject) => {
  setTimeout(() => resolve("ok"), 1000);
});

p.then(console.log).catch(console.error);

Python:


import asyncio

async def foo():
    await asyncio.sleep(1)
    return "ok"

task = asyncio.create_task(foo())  # 把协程交给事件循环调度
result = await task
  • Task 是把 foo() 的协程变成一个“可以被事件循环调度的任务”。
  • 类似于 Promise 中你触发了异步逻辑,注册了回调,但是在js中这些是自动的,Python中需要手动。

5. 回调注册方式对比

比较项Python Task/FutureJS Promise
注册回调future.add_done_callback(fn).then(fn).catch(fn)
等待方式await task(阻塞当前协程)await promise.then()

6. 异常处理方式对比

JavaScript:


promise.then(handle).catch(errorHandler)

Python:


try:
    result = await task
except Exception as e:
    print("Caught:", e)

二者都是在“await”时抛出异常、由调用者处理。

7. 链式调用 vs 调度队列 区别

  • JS Promise 支持链式 .then().catch().finally()
  • Python Task/Future 没有链式风格,使用 await + async def 管理控制流,可读性更高

Task 使用场景

asyncio.create_task() 的使用场景,核心在于它可以让你并发执行多个协程,即:同时启动多个“子任务”在后台执行,而不阻塞当前协程的流程。

1. 并发运行多个异步任务(如多个网络请求)


async def fetch_data(url):
    await asyncio.sleep(1)
    return f"data from {url}"

async def main():
    urls = ['a.com', 'b.com', 'c.com']
    tasks = [asyncio.create_task(fetch_data(url)) for url in urls]
    results = await asyncio.gather(*tasks) # `asyncio.gather` 类似 JS 中 `Promise.all` 的能力
    print(results)

# 启动所有任务并发执行,提高效率
asyncio.run(main())
import asyncio

async def say_hello(delay, who):
    await asyncio.sleep(delay)
    print(f"Hello, {who}!")

async def main():
    task1 = asyncio.create_task(say_hello(2, "小明"))
    task2 = asyncio.create_task(say_hello(2, "小红"))
    
    print("Tasks started")
    await task1
    await task2

asyncio.run(main())

create_task() 的作用是开始执行,而 await 的方式决定了如何等待

await asyncio.gather(tasks)并发启动,并发等待同时等待多个任务完成,这种方式更高效,因为它会并行收集所有结果。

await task1 await task2并发启动,串行等待。先等 task1 完成,再等 task2

场景:爬虫、多接口调用、并发 I/O 操作(而非串行)。

2. 后台任务(启动后继续执行,不等它)


async def log_writer():
    while True:
        await asyncio.sleep(5) # 类似 JS 中 Promise + setTimeOut
        print("log flushed.")

async def main():
    asyncio.create_task(log_writer())  # 不 await,后台运行
    print("doing other things...")
    await asyncio.sleep(10) # 保持事件循环活跃,挂起后立即去任务队列里取出log_writer执行

asyncio.run(main())

场景:后台监控、日志刷新、非核心功能的异步初始化等。

3. 超时控制或手动取消任务


async def slow_task():
    await asyncio.sleep(10)
    return "done"

async def main():
    task = asyncio.create_task(slow_task())
    await asyncio.sleep(3)
    task.cancel()  # 主动取消任务
    try:
        await task
    except asyncio.CancelledError:
        print("task was cancelled.")

asyncio.run(main())

场景:防止异步任务卡死或超时。

4. 等待多个任务中“任意一个完成”


task1 = asyncio.create_task(do_1())
task2 = asyncio.create_task(do_2())
done, pending = await asyncio.wait([task1, task2], return_when=asyncio.FIRST_COMPLETED)

类似js版本的 Promise.race

function do_1() {
    return new Promise(resolve => setTimeout(() => {
        console.log("任务 1 完成");
        resolve("任务 1");
    }, 2000));
}

function do_2() {
    return new Promise(resolve => setTimeout(() => {
        console.log("任务 2 完成");
        resolve("任务 2");
    }, 1000));
}

async function main() {
    const [done] = await Promise.race([do_1(), do_2()]);  // 等待**第一个**完成的任务
    console.log("最早完成的任务:", done);
}

main();

场景:多个候选接口,只取最快返回的。

为什么不能直接用协程对象?

你可能会问:

# fetch_data 是一个async fn
tasks = [fetch_data(url) for url in urls]  # 不 create_task 行不行?
asyncio.gather(*tasks)

asyncio.gather(*tasks) 内部会帮你自动转换成任务并放入事件循环,所以有些场景下不手动用 create_task() 也可以。

但如果你需要更精细控制:如后台运行、取消、限时、单独 await 某个子任务,就一定要手动 create_task()

你还可能会问:

# fetch_data 是一个async fn
fetch_data(url1)
fetch_data(url2)
fetch_data(url3)

连续调用不 create_task 行不行?

因为js中是可以并行执行的,如:

const task1 = fetch("a.com");  // fetch 返回的是一个已开始的 Promise
const task2 = fetch("b.com");
fetch() 马上就发请求了

task1 和 task2 同时在跑 → 并发,它们会在 JS 的事件循环中注册 .then() 回调等待完成。

但是,在Python中的结论是不行,因为Python中的协程不会自动执行,如:

async def fetch(url):
    await asyncio.sleep(1)
    return f"data from {url}"

tasks = [fetch("a.com"), fetch("b.com")]  # async def 只是创建返回了协程对象,还没执行!
results = await asyncio.gather(*tasks)    # gather 内部才把它们调度起来执行
fetch("a.com") 创建了一个“挂起函数”的执行体(coroutine object),它什么都没做。

只有:

  • 被 await,

  • 或者被包进 create_task(),

  • 或者被 asyncio.gather() 等“调度器”调度,它才真正跑起来。

Task 注意事项

create_task 中的协程不是立即执行的,它只是告诉事件循环将来要执行


async def foo():
    print("in foo")

task1 = asyncio.create_task(foo())  # 注册 foo 的执行,但不会马上运行 foo 内部代码
task2 = asyncio.create_task(boo()) 

这个 create_task(foo()) 做了两件事:

  1. 注册一个异步任务,不会立即执行,实际上被放到了事件循环的就绪队列里;
  2. 任务会在 事件循环的下一次调度周期 中被执行,会从就绪队列里被取出执行;
  3. 返回一个 Task 对象,可以 await 它的结果。

事件循环稍后调度 task1 和task2 执行,task1 和task2 将来被执行的顺序也不能保证和注册时顺序一致(但是你可以通过awiat控制等待的顺序)。

真正的调度(即协程中代码开始执行)要等事件循环的下一次调度周期(I/O 循环轮询)到来。而这个调度是由 事件循环根据任务的状态和优先级进行安排的,不严格保证按注册顺序调度

  • JavaScript 中怎么实现类似行为?

    async function foo() {
      console.log("in foo");
    }
    
    Promise.resolve().then(() => foo());
    

    这段 JS 代码可以模拟出 类似的效果

    • Promise.resolve().then(...):把任务推入微任务队列
    • foo() 是一个 async function,返回的是一个 Promise,执行会被推入 JS 的异步调用栈;
    • 这个组合就像 Python 中 create_task() 一样:不会立即执行 foo() 的内容,而是稍后执行
  • 为什么不类比成 setTimeout(fn, 0)

    setTimeout(foo, 0); // 是宏任务,调度时机会比 microtask 更晚
    
    • setTimeout(fn, 0) 也“稍后执行”,但它是在 下一轮宏任务 中;
    • create_task() 的任务一般在 事件循环中尽早被执行(更像微任务);
    • 因此 Promise.resolve().then(() => foo()) 是更贴近 Python create_task() 的调度行为。

总结:何时用 create_task()

目的是否需要 create_task()
并发执行多个异步任务推荐使用
后台运行,不等待结果必须使用
控制取消/超时/状态管理必须使用
简单的 await可直接 await 协程或用 gather()

当同步代码闯入异步世界 —— asyncio.to_thread()

async/await 非常适合 I/O 密集型任务。但如果一个 async def 函数里包含了一段长时间运行的、CPU 密集的、并且是同步阻塞的代码,会发生什么?

asyncio 的事件循环运行在主线程之上,事件循环与其他代码一样本质上就是一段管理和调度协程执行的 Python 代码。

因为 asyncio 的并发模型依赖于任务的快速切换。如果主线程(也就是事件循环正在运行的那个线程)被一个长时间的同步操作阻塞了,那么事件循环就无法进行任务切换,整个异步模型的优势就丧失了。

import asyncio
import time

async def cpu_bound_task_in_async():
    print("Async func: Starting CPU-bound work...")
    # 这是一个同步的、阻塞的 sleep,它会阻塞整个事件循环
    time.sleep(3) # 模拟耗时的同步计算
    print("Async func: CPU-bound work finished.")

async def another_task():
    print("Another task: strart...")
    await asyncio.sleep(0.1)
    print("Another task: Finished.")

async def main_with_blocking_call():
    task1 = asyncio.create_task(cpu_bound_task_in_async())
    task2 = asyncio.create_task(another_task())

    await task1
    await task2

# if __name__ == "__main__":
#     asyncio.run(main_with_blocking_call())

如果你运行上面的代码,会发现 cpu_bound_task_in_async 中的 time.sleep(3) 会冻结整个事件循环3秒钟。another_task 即使被创建了,也得等那3秒结束后才有机会真正运行。这是因为 time.sleep() 是一个阻塞调用,它不像 asyncio.sleep() 那样会把控制权交还给事件循环,事件循环就没有办法去调度做其他的事。

JS 中也有类似问题:如果你的 Promise 中或 .then() 回调里有一个长时间的同步循环,它同样会阻塞主线程,因为无论是否异步,这些代码最终都会在主线程上执行,只是早晚的事。

Python 的解决方案:asyncio.to_thread() (Python 3.9+)

为了解决在 asyncio 程序中运行阻塞 I/O 或 CPU 密集型同步函数的问题,Python 提供了 asyncio.to_thread()。它会在一个单独的线程中运行阻塞函数,并返回一个可以 await 的对象。这样,主线程的事件循环就不会被阻塞。

asyncio.to_thread() 是 Python asyncio 库中的一个方法,可以在 asyncio 事件循环中将同步代码运行在 线程池 中,而不阻塞主线程。

asyncio.to_thread()基于线程池:使用 Python 的 concurrent.futures.ThreadPoolExecutor 在后台线程执行阻塞性任务。


import asyncio
import time

def cpu_bound_task():
    print("Async func: Starting CPU-bound work...")
    time.sleep(3)  # 运行在独立线程,不会阻塞事件循环
    print("Async func: CPU-bound work finished.")

async def another_task():
    print("Another task: start...")
    await asyncio.sleep(0.1)  # 事件循环可以继续运行
    print("Another task: Finished.")

async def main():
    task1 = asyncio.to_thread(cpu_bound_task)  # 在独立线程执行
    task2 = asyncio.create_task(another_task())  # 事件循环任务

    await asyncio.gather(task1, task2)  # 让两个任务并发执行

asyncio.run(main())


cpu_bound_task() 不会阻塞事件循环,因为它运行在一个独立线程里。 another_task() 可以正常执行,不会被 CPU 任务卡住。 事件循环仍然可以调度其他任务,保证程序的响应性。

与 JS 的对比 —— Web Workers

  • JS 中,如果有 CPU 密集的同步代码,我们一般会考虑使用 Web Workers。Web Workers 可以在后台线程中运行脚本,而不会阻塞主线程。这和 Python 的 asyncio.to_thread() + 内部线程池的概念是类似的,都是把阻塞工作扔给其他线程。
  • Python 还有更传统的 threading 模块用于手动创建和管理线程,以及 multiprocessing 模块用于利用多核并行处理 CPU 密集型任务(它通过创建新进程来绕过 GIL 的限制)。

关于线程数量:

asyncio.to_thread() 内部使用一个线程池 (ThreadPoolExecutor)。可以无限调用它,但并发执行的阻塞任务数量受限于线程池的大小。线程本身是系统资源,不可能无限创建。Python 的 threading 模块也是如此。

JS 中,Web Worker 的数量也有限制。Promise 本身不直接创建线程,是运行环境(浏览器/Node)为某些原生异步API(如 Fetch,文件I/O)可能使用内部线程或线程池。并不“手动”为Promise创建线程。

注意

因为 Python 的线程共享全局解释器锁(GIL),to_thread()开辟的线程仍然在 Python 进程的内存空间内运行,并不会像 Web Workers 那样创建真正的独立线程。

to_thread()创建的线程 与 GIL 的关系

回顾几个核心点:

  1. GIL(全局解释器锁)是同时刻只允许一个线程运行 Python 字节码的锁。
  2. GIL 是动态流转的:哪个线程要执行 Python 代码,哪个就要先“拿到” GIL。
  3. GIL 的持有者是可以变化的,并不是谁拿了就永远拿着。
  4. 线程是会“抢占”GIL 的,解释器会定时强制释放当前线程的 GIL,让别的线程有机会运行(CPython 中默认每 5ms 检查一次),注意只是有机会,实际情况是不能确保雨露均沾的,有的抢得多,有的抢的少。

GIL 切换的时机:

Python(CPython)会每隔一小段时间(默认约 5ms)强制当前持有 GIL 的线程“让一下”,让其他线程尝试抢占 GIL。

这就意味着:

后台线程执行 CPU 密集任务时,即使它想一直持有 GIL,Python 解释器也会周期性地暂停它,释放 GIL,给主线程一个机会继续执行。

这叫做 “cooperative preemptive switching” —— 合作式地强制切换。

所以:

即使 GIL 在新线程被占用,主线程也能“插空”运行!

to_thread 的局限

线程间的任务会穿插轮流执行,这导致即使使用 to_thread 也不能缩短那个 CPU 密集型任务本身的时间,甚至由于线程创建和上下文切换(包括 GIL 的竞争和切换)的开销,这个 CPU 密集型任务的执行时间还可能会略微增加。

既然如此,使用 to_thread 的意义是什么?

to_thread 的价值不在于缩短那个 CPU 密集型任务本身的时间,而在于缩短整个应用程序的“总响应时间”或“完成一组混合任务的总时间”。

例如,你有一个 CPU 密集型任务A (耗时5秒) 和几个 I/O 密集型异步任务B、C、D (分别需要等待1秒网络延迟,但自身处理很快)。

  • 不用 to_thread 如果 A 直接阻塞事件循环,B、C、D 必须等 A 完成后才能开始处理网络延迟。总耗时可能是 5 + 1 + 1 + 1 + 处理时间。
  • 使用 to_thread 运行 A: A 在后台线程运行。事件循环没有被阻塞,它争取到了GIL,立刻就可以开始处理 B、C、D 的异步网络请求。B、C、D 的等待可以与 A 的计算并发进行(A 在自己的线程,BCD 在事件循环线程通过 await 让出控制权等待 I/O)。这时,整个应用完成所有任务的总时间最多可能也只用了5s + 处理时间。

所以,Python中不推荐在异步函数中调用阻塞的同步函数(包括阻塞型IO任务和cpu密集型python任务),遇到这种情况你都应该思考是否有必要使用to_thread,不使用会不会卡死事件循环?

但是对于真正重型的 CPU 任务,应该:

  • 不用线程(受 GIL 限制)
  • 用进程 multiprocessing(每个进程一个 GIL,互不干扰)
  • 用 C 或 Rust 等编写的扩展

asyncio.to_thread(func, *args)是 Python 3.9+ 的简写形式,实际上是:

loop.run_in_executor(None, func, *args)

默认使用 ThreadPoolExecutor

常见的并发API

可以使用以下方式来切换线程或进程。

名称类型是否跨进程是否受 GIL 限制适合场景属于什么模块
threading.Thread线程✅ 是I/O 密集型threading
multiprocessing.Process进程✅ 是❌ 否CPU 密集型multiprocessing
ThreadPoolExecutor线程池✅ 是I/O 密集型concurrent.futures
ProcessPoolExecutor进程池✅ 是❌ 否CPU 密集型concurrent.futures
asyncio.to_thread()协程调度线程✅ 是异步中执行同步代码asyncio
loop.run_in_executor()协程调度线程/进程取决于 executor✅/❌I/O 或 CPU 密集asyncio + concurrent.futures

ThreadPoolExecutorthreading.Thread 的关系

concurrent.futures.ThreadPoolExecutor 是对 threading.Thread高级封装,它用线程池的方式管理线程,提供更简单的任务提交、回收、异常处理等机制。

特性threading.ThreadThreadPoolExecutor
所在模块threadingconcurrent.futures
抽象层次低级:面向线程对象高级:面向任务函数
启动方式手动创建 & 启动自动复用线程池
线程复用无复用有线程池复用机制
任务提交无需返回值(或借助 Queue)支持 submit() 返回 Future
结果获取需自己管理同步/通信内建 Future.result()
错误处理手动 try/except自动传递到 Future.exception()

ProcessPoolExecutor 与 multiprocessing的关系

concurrent.futures.ProcessPoolExecutor 是更现代、更高级的 多进程 API 封装,底层就是基于 multiprocessing 实现的。

特性ProcessPoolExecutormultiprocessing
模块位置concurrent.futuresmultiprocessing
抽象层高级封装(面向任务)低级控制(面向进程)
编码方式类似线程池:submit(fn)自建进程、队列、通信
进程管理自动管理手动创建与管理
错误处理简单异常传播需手动捕获、传递
可与 asyncio 结合是(loop.run_in_executor 可用)否(需手动整合)
后端实现基于 multiprocessing.Pool自己管理 Process, Queue, Pipe

哪些方式是单线程协程调度?

这些方式不会创建新线程,全部在当前线程的事件循环上调度:

方法类型会切换线程?说明
asyncio.create_task()协程❌ 否将协程注册进事件循环,单线程异步调度
await 协程协程❌ 否协程挂起并让出控制权,等待事件后恢复执行
asyncio.gather() / asyncio.wait()协程集合❌ 否并发运行多个协程任务,仍在单线程中
async with / async for协程控制结构❌ 否语法糖,本质还是协程

总结口诀(简记):

凡是带 Thread, Process, Executor 的,都有可能切线程/进程;其余 asyncio 原生函数都跑在当前线程。

总之,Python 最推荐的现代并发方式是:

用协程做调度,用线程/进程池处理阻塞任务。

答疑

Q1: 只有某些系统定制的io操作如网络请求有单独的线程,其他js代码始终都在主线程执行是么?

是的。

  • JS 代码在主线程上执行:你编写的绝大部分 JavaScript 代码(包括 Promise内以及其.then/.catch 回调、async/await 函数体内的代码,事件监听器回调等)都运行在主线程上(除非你使用了 Web Workers)。
  • 系统级 I/O 操作:像 Fetch() (网络请求)、setTimeout() (计时器)、Node.js 中的文件系统操作 (fs.readFile)、Python的aiohttp 等,当你调用它们时,实际的耗时工作是由浏览器或 Node.js/Python 环境的底层(可能是 C++ 实现)来处理的。这些底层实现可能会使用它们自己的线程(如网络线程、定时器线程等)或线程池来执行这些操作,以避免阻塞主线程。
  • 事件循环调度:当这些底层操作完成后(数据到达、计时器到期),环境会将一个通知(和相关的回调函数)放入相应的任务队列(宏任务队列或微任务队列)。事件循环在主线程空闲时,会从这些队列中取出任务并在主线程上执行其 JS 回调代码。

Q2: python、js、nodejs 不适合cpu密集型应用是么,而适合io密集型?

总体上是正确的,但有细微差别和解决方案。

  • JS (Node.js 和浏览器主线程) :由于其单线程事件循环模型,非常适合 I/O 密集型应用。它可以高效处理大量并发 I/O 连接。对于 CPU 密集型任务,长时间运行的 同步耗时 计算会阻塞主线程,导致 UI 无响应(浏览器)或无法处理新请求(Node.js)。解决方案是使用 Web Workers (浏览器) 或 Node.js 的 worker_threads 模块,或者将 CPU 密集部分用 C 原生模块实现,例如Node的C++模块sharp(三方模块)、fs、crypto(官方模块),另外Nodejs还可以使用pm2 或官方的cluster 模块来开启多进程提高性能。

  • Python (CPython 与 asyncio) :

    • asyncio 本身也是为 I/O 密集型设计的,它在单线程内通过协程高效处理并发 I/O。

    • 对于 CPU 密集型任务,由于 GIL 的存在,Python 的多线程(threading模块)并不能很好地利用多核 CPU 来并行执行 Python 字节码。asyncio 运行在单线程,所以纯 Python 的 CPU 密集代码在协程里跑也会阻塞事件循环。

    • Python 处理 CPU 密集型任务的常用方法是:

      1. multiprocessing 模块:通过创建多个进程来绕过 GIL,实现真正的并行计算。每个进程有自己的 Python 解释器和内存空间。
      2. asyncio.to_thread() :将阻塞的 CPU 密集代码扔到线程池,不阻塞事件循环,但受 GIL 影响,单个 Python 进程内的这些线程可能无法真正并行执行 Python 代码。
      3. 使用 C/C++/Rust 等编写扩展模块:对于性能瓶颈部分,用这些语言重写,它们可以释放 GIL,实现真正的多线程并行。NumPy, Pandas 等库就是这么做的。
      4. Granian: Granian 是一个用 Rust 编写的 HTTP 服务器,比 Python 服务器更快,类似于 Node.js 的 cluster 模式。

所以,虽然它们的核心模型更偏向 I/O 密集型,但都有处理 CPU 密集型任务的策略。

Q3: 列举所有python中不在主线程执行的任务以及js中不在主线程中执行的任务有哪些?

  • Python 中不在主线程(事件循环线程)执行的任务

    1. 原生异步I/O,如 aiohttp 进行网络请求,aiofiles 进行文件操作
    2. asyncio.sleep 可以让当前协程暂停一段时间,但不会阻塞或占用主线程
    3. 使用 threading.Thread 创建并启动的线程中的任务。
    4. 通过 asyncio.to_thread() 提交的函数,这些函数会在 asyncio 内部管理的线程池中执行。
    5. 使用 multiprocessing.Process 创建的子进程中的任务(这是在不同进程,自然不在主线程)。
    6. 某些 C 扩展在执行特定操作时会释放 GIL 并在其他线程中执行工作。
  • JS 中不在主线程执行的任务

    1. Web Workers (浏览器环境) / worker_threads (Node.js):在这些独立线程中运行的 JS 代码。

    2. Service Workers (浏览器环境):在后台独立于页面的线程中运行。

    3. 浏览器/Node.js 环境的内部操作

      • 实际的网络 I/O (e.g., Fetch 的数据收发部分)。
      • 文件系统 I/O (Node.js fs 模块的异步版本)。
      • setTimeout/setInterval 的计时器管理(计时本身,回调仍在主线程)。
      • 某些复杂的图像解码、音视频处理等可能由浏览器在内部线程完成。 这些是环境层面的,我们不直接控制这些线程。

除此之外,几乎所有代码都是在主线程执行的,包括new Promise(executor) 中的 executor 函数体内的代码,.then() 的回调或是python async def fn 中的代码等等,所以不要认为某些同步代码用异步方案包裹了一下就能够真正不阻塞了,这些代码并没有消失,早晚都会被主线程执行,除非它本身就属于其他线程。

再补充一下浏览器的多线程架构

浏览器内部采用了多线程设计,以优化性能提升用户体验。包括:

  • 主线程 (Main Thread): 负责解析 HTML、CSS、执行 JavaScript、处理用户交互等。
  • 工作线程 (Worker Threads): 如 Web Workers、Service Workers,可用于执行较长时间的计算任务,但仍然受限于 JavaScript 运行环境。
  • 后台线程 (Background Threads): 由浏览器内核管理,用于执行诸如图像解码音视频编解码网络请求等任务,不直接暴露给 Web 开发者。

总结 —— 提升Python应用并发/并行能力方法

我们再总结一下提升并发/并行能力的几种方法。

1. 使用协程处理 I/O 密集型任务

  • 使用 asyncio / aiohttp / aiomysql 等异步IO库,因为同步(阻塞)IO会阻塞线程,等待期间会使所在线程休眠。
  • 使用 asyncio.create_taskasyncio.waitasyncio.gather 提高并发能力

2. 使用线程处理阻塞型任务

  • concurrent.futures.ThreadPoolExecutor或其在asyncio下的简化版 asyncio.to_thread()
  • 注意 Python 的 GIL,线程无法提高 CPU 密集型任务性能,只是让事件循环不阻塞,让它有机会执行其他任务

3. 使用多进程处理复杂 CPU 密集型任务

  • multiprocessing
  • concurrent.futures.ProcessPoolExecutor
  • 完全绕过 GIL,适合图像处理、加密、压缩等

4. C扩展或外部语言替代部分模块

  • 有高性能需求的场景可以重写或使用现有 Rust/C++ 模块

结语

从 JavaScript 的异步到 Python 的 asyncio 领域。你会发现,虽然语法和具体实现有所不同(比如 Python 对事件循环的更显式控制,Future/TaskPromise 的细微差异,以及 asyncio.to_thread 这样的工具),但核心思想 —— 通过事件循环和协作式并发多任务来高效处理I/O密集型操作——是高度一致的。

async/await 只是工具,真正理解其背后的事件循环、协程/Promise、以及它们如何实现单线程并发,才是掌握异步编程的关键。

当我们再看到 Python 中的 async defawaitasyncio.gather() 时,不妨回想一下 JS 中的老朋友 async functionawaitPromise.all(),会发现技术的世界,很多时候都是殊途同归的!