异步 IO 及多线程/进程
进程与线程
- 在我们在终端用
python命令启动一个程序的时候,就是创建了一个python进程 - 进程包含一个或者多个线程,以及其他各种资源。
- 真正执行代码的是线程
- 可以把进程想象成一个公司,线程就是这个公司里面的员工。公司除了员工,当然还有其他很多东西。
- 一个进程至少有一个线程,这个线程叫做主线程
- 如果程序不包含
threading库等,那么这个进程一般只有一个线程,就是主线程 - 当然,这里主线程是Python的GIL和一些简单的操作
- 如果Python代码中涉及到了深度学习库或者Numpy等计算密集型的库的话,这些库是C++写的,底层会自动开启多个线程
- 此时如果再利用“进程数=CPU逻辑核心数”,可能就会造成性能下降
- 如果程序不包含
- 一个CPU核心在同一时间只能执行一个线程
- 超线程也是这样的,超线程只是用了更高级的切换线程的方式
- 一个进程的线程可以在不同的CPU核心中被执行,哪个核心有空就在哪个核心执行
- 所以其实进程数目不是根本因素,线程数目才是根本因素。看CPU是否有空闲/利用率是否高,就看其是否一直在执行线程
- 多进程(每个进程单线程)和多线程(只有单个进程)是不同的,简单来说,由于进程的上下文更多,所以开销会更大,前者会更慢,但是后者一个线程的崩溃会导致进程的崩溃
- 选择策略
- 执行彼此独立,没有数据交换的任务的时候使用多进程
- 执行需要交换大量数据的任务的时候使用多线程
- 最好的策略还是进程和线程混用,进程不要太多线程也不要太多
- Python的特殊性
- Python是一个单线程语言,有GIL,也就是说对于纯粹的Python程序来说,无论开多少个线程,单个Pytohn进程同一时刻永远都只能有一个线程在工作,所以对Python来说,如果是并行处理任务,开多个线程是没有意义的,一般开多个线程可以进行后台监管
- 但是像numpy或者pytorch等库,底层是用C++或者Rust编写的,这些语言就可以利用多个线程同时工作。当Python执行到这些库的时候,首先会释放GIL,然后把主动权给这些库,最后重新申请GIL回到单线程
- 选择策略
同步与异步,阻塞与非阻塞
老张爱喝茶,废话不说,煮开水。 出场人物:老张,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。
- 老张把水壶放到火上,立等水开。(同步阻塞) 老张觉得自己有点傻
- 老张把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有。(同步非阻塞) 老张还是觉得自己有点傻,于是变高端了,买了把会响笛的那种水壶。水开之后,能大声发出嘀的噪音。
- 老张把响水壶放到火上,立等水开。(异步阻塞) 老张觉得这样傻等意义不大
- 老张把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶。(异步非阻塞) 老张觉得自己聪明了。
所谓同步异步,只是对于水壶而言。 普通水壶,同步;响水壶,异步。 虽然都能干活,但响水壶可以在自己完工之后,提示老张水开了。这是普通水壶所不能及的。 同步只能让调用者去轮询自己(情况2中),造成老张效率的低下。
所谓阻塞非阻塞,仅仅对于老张而言。 立等的老张,阻塞;看电视的老张,非阻塞。 情况1和情况3中老张就是阻塞的,媳妇喊他都不知道。虽然3中响水壶是异步的,可对于立等的老张没有太大的意义。所以一般异步是配合非阻塞使用的,这样才能发挥异步的效用。
这个例子中,老张相当于线程,水壶相当于IO请求,所以就是说同步异步就是指请求是否通知线程(关注的是消息通知机制),阻塞非阻塞就是指线程是否继续执行(关注的是调用者(线程)在等待结果时的状态)
事件循环
事件循环(Event Loop) 是异步编程的核心机制。简单来说,它是单线程语言实现非阻塞异步操作的“调度员”。
-
核心痛点:为什么需要它?
单线程意味着同一时间只能做一件事。
- 如果没有异步: 假如你请求一个网络接口需要 5 秒,如果不把这个任务挂起,整个页面就会“卡死” 5 秒,用户无法点击任何按钮。
- 解决方案: JavaScript 将任务分为同步任务和异步任务。同步任务立即执行,异步任务交给浏览器(或系统)去处理,回头再执行回调。
事件循环(Event Loop) 就是负责监控代码执行情况,决定“什么时候把异步任务的回调拿回来执行”的机制。
-
技术原理:底层架构
内存空间主要分为这几个部分:
- 调用栈 (Call Stack):
- 存放正在执行的同步代码。
- 代码执行原则是“后进先出”。
- 只有当调用栈清空了,事件循环才会开始工作。
- Web APIs (浏览器) / C++ APIs (Node.js):
- 这里处理异步操作(如
setTimeout、fetch请求、DOM 事件)。 - 当异步操作完成(例如 1秒时间到了,或者数据请求回来了),API 会把回调函数放入任务队列。
- 这里处理异步操作(如
- 任务队列 (Task Queue):
- 存放准备好要执行的回调函数(即“做好的汤”)。
- 事件循环 (Event Loop):
- 这是一个死循环过程
while(true)。 - 它的工作: 不停地检查 调用栈 是否为空。
- 如果栈空了,它就从 任务队列 中取出一个任务,推入调用栈执行。
- 这是一个死循环过程
- 调用栈 (Call Stack):
-
进阶:宏任务 (MacroTask) 与 微任务 (MicroTask)
任务队列其实不只一个,由于优先级不同,分为两类:
- 宏任务 (MacroTask):
- 普通的异步任务。
- 包括:
script(整体代码)、setTimeout、setInterval、setImmediate(Node)、I/O 操作、UI 渲染。
- 微任务 (MicroTask):
- VIP 任务,优先级极高,插队执行。
- 包括:
Promise.then/catch/finally、process.nextTick(Node)、MutationObserver。
- 宏任务 (MacroTask):
-
事件循环的详细流程(Loop 过程):
- 执行调用栈中的同步代码。
- 同步代码执行完毕,调用栈清空。
- 检查微任务队列:
- 如果有微任务,全部执行完(一个接一个,直到微任务队列清空)。
- UI 渲染(如果有需要更新界面的话)。
- 执行宏任务队列:
- 取出一个宏任务放入栈中执行。
- 回到第 3 步(执行完这个宏任务后,立刻再去检查有没有新的微任务产生)。
asyncio库
- 在函数前加上
async可以把函数变成协程- 调用协程不会执行函数,只会得到一个协程对象
- 协程对象的执行需要通过
asyncio.create_task()和asyncio.gather()等 - 协程跟普通函数最大的区别就是协程是异步的,执行完毕之后可以通知事件循环
awaitawait something的含义是,暂停await所在函数的执行,并且这个函数的执行恢复的必要条件是something执行完毕。当something执行完毕之后,await所在函数也可能不会继续执行,此时这个函数只会被放入到事件循环中的微任务中,当事件循环依次执行微任务的时候,就会继续执行这个函数(事件循环不会去轮询,因为这是异步机制)something一般是协程什么的,这是因为await后面一定要跟一个可以结束的东西(这样所在函数才可以恢复执行),而协程刚好可以通知事件循环自己执行结束了
await的主要作用就是可以让异步代码表现得像同步代码一样- 下面是一些例子:
await my_func(),其中my_func()是一个协程函数,此时会暂停await所在函数然后立即执行my_func(),等到my_func执行完毕之后重新执行await所在函数await task,其中task是一个已经被asyncio.create_task的任务,此时await所在函数暂停执行,但是由于task已经在事件循环的微任务队列中了,所以不会有什么新的反应(task也不一定立即执行),只是等待task执行完毕后,将await所在函数放到事件循环中的微任务队列中
asyncio.run()的作用是:启动异步程序,运行一个顶级协程,并在结束后自动关闭事件循环asyncio.create_task的作用是把协程包装成一个 Task 并放到事件循环中,等到之后CPU让出控制权了再进行执行。此时主程序不等待,继续往下走asyncio.gather()的作用是并发启动多个协程,等它们全部完成,然后把所有返回值按顺序收集成一个列表返回。此时主程序等待,不会继续往下走- 提醒一下,无论是
asyncio.gather()还是其他会把协程放入到事件循环中的函数,我们都不能一下子把大量的协程放入到事件循环中,这会导致内存爆炸 - 所以我们尽量使用生产者-消费者模型
def generate_tasks(): # ... yield 一个协程 running_tasks = set() # 获取生成器 gen = generate_tasks() for task_coro in gen: # 如果当前运行的任务数超过了限制的一个倍数(防止内存无限膨胀),可以稍微等一下 # 但实际上 Semaphore 已经控制了并发,这里主要控制的是“创建任务”的速度 if len(running_tasks) >= semaphore_limit * 2: # 等待至少一个任务完成 done, pending = await asyncio.wait(running_tasks, return_when=asyncio.FIRST_COMPLETED) running_tasks = pending # 处理完成的任务结果(更新进度条等) for t in done: try: t.result() # 触发潜在异常或获取结果 progress_queue.put(1) except Exception as e: print(f"Task exception: {e}") progress_queue.put(1) # 创建新任务 # 注意:这里我们不再在 single_file_handler 内部传 sem, # 而是可以用 sem 包装一下,或者保持原样在内部用 sem。 # 只要 single_file_handler 里有 async with sem,下面直接 create_task 即可。 task = asyncio.create_task(task_coro) running_tasks.add(task) # 循环结束后,等待剩余任务完成 if running_tasks: done, pending = await asyncio.wait(running_tasks) for t in done: progress_queue.put(1)
- 提醒一下,无论是
asyncio.as_completed的作用是,将所有任务放入到事件循环中,同时返回一个迭代器,迭代器中的元素初始是空的,元素数目跟任务相同- 循环可以写成
for f in asyncio.as_completed(tasks):,然后在循环内部有一个await f - 在执行
as_completed的时候,所有任务会被扔到事件循环中,但是没有任务开始执行 - 最开始得到的
f是一个空对象,但是由于await的存在,会让协程挂起,开始执行事件循环中的任务 - 当某个任务最开始执行完的时候,就会放入到
f中 - 于是
await f就结束执行,继续循环 - 所以
as_completed跟gather是一样的,都是按照异步的方式将所有任务执行完毕,但是前者可以执行完一个就进行一次操作,而不需要等待所有任务执行完毕
- 循环可以写成
asyncio库不会创建其他进程或者线程- 创建进程是
multiprocessing库with multiprocessing.Pool(processes=num_processes) as pool:会创建一个进程池,包含num_processes个子进程- 此时主进程会被阻塞,相当于此时一共有
num_processes+1个进程 - 注意子进程会继承主进程的随机种子,如果要在子进程中使用随机函数的话,记得给每个子进程都设置一个自己专有的随机种子
- 创建线程是
threading库- 我们可以用这个库创建一个监听线程来利用
tqdm计数 - 具体来说,先创建一个管理者
with multiprocessing.Manager() as manager:(这也是一个进程,但是如果没有管理者的事的话,这个进程不会占用线程),然后再创建一个队列progress_queue = manager.Queue() - 接着创建一个监听进程
prog_thread = threading.Thread(target=listener_thread, args=(progress_queue, len(data)), daemon=True)(注意设置成守护进程避免死锁),listener_thread中包含q.get(),这句代码的作用类似await,交出CPU的控制权,直到q中有元素了才继续执行 - 在每完成一个任务的时候,就往
progress_queue里面放入一个简单的元素(比如数字1),此时监听线程就会接收到这个数字,然后利用tqdm进行进度条更新即可(注意此时tqdm的进度条是主进程的内容,但是各个输出是子进程的事,所以不要把子进程中的print换成tqdm.write,这是无效的,应该是使用类似with tqdm(total=total_objects, desc="Processing objects", file=progress_file, mininterval=600) as pbar将进度条输入到文件中,然后将最小刷新间隔设大一点,防止文件过大) - 注意此时管理者和监听线程都需要在繁忙的时候才占用CPU核心,所以他们两个的消耗可以忽略不计
- 我们可以用这个库创建一个监听线程来利用
asyncio库仍然是单线程执行,只不过不同的Task轮流执行
- 创建进程是
asyncio.Queue创建一个队列,可以用来实现生产者-消费者模型await queue.put:将数据项放入队列的尾部,如果放不进去了(因为队列已经满了),那么释放CPU控制(就像一般的协程await一样),直到可以放进去,如果放得进去,那么就直接放进去(当然也会释放CPU资源,然后让事件循环去调度,只不过条件立即满足,可以被事件循环立即调度,当然调不调度还是看事件循环);同时维护一个计数器,让计数器加一await queue.get():(释放CPU控制并)获取队头,如果队列为空,那么得等;计数器不会减一queue.task_done():计数器减一await queue.join():等待计数器归零
- 除了要读的文件非常大,而且很多(比如几千个几十GB的文件),要使用第三方库来进行异步文件IO,否则的话文件IO不用考虑异步
- 还有一种解决方案是使用
loop.run_in_executor,这个函数会在后台启动多个线程来执行同步阻塞函数,这个同步阻塞函数当然可以有文件IO操作 - 创建的线程不要太多了,不然操作系统频繁切换速度会更慢;同时不要在每个协程里面创建线程池,而是在每个进程开头,使用
loop = asyncio.get_running_loop()和loop.set_default_executor(ThreadPoolExecutor(max_workers=4))即可
- 还有一种解决方案是使用
下面是一些具体的例子:
-
import asyncio from openai import AsyncOpenAI client = AsyncOpenAI( api_key="sk-1234", base_url="http://0.0.0.0:4000" ) async def chat(message: str) -> str: """单个异步请求""" response = await client.chat.completions.create( model="my-model", messages=[{"role": "user", "content": message}] ) return response.choices[0].message.content async def main(): # 定义多个独立的请求 messages = [ "What LLM are you?", "Could you please tell 1+1 =?", "I want to make friends with you.", "Tell me a joke.", "What is Python?", ] # 使用 asyncio.gather 并发执行所有请求 tasks = [chat(msg) for msg in messages] results = await asyncio.gather(*tasks) for msg, result in zip(messages, results): print(f"Q: {msg}\nA: {result}\n") # 运行 asyncio.run(main())- 线程一直执行到
tasks = [chat(msg) for msg in messages],然后这个语句创建了若干协程对象 results = await asyncio.gather(*tasks)await将main函数挂起,意思是等到asyncio.gather(*tasks)执行结束之后再继续执行下面的代码asyncio.gather(*tasks)会把这些协程对象封装成 Task(如果还没封装的话),并注册到事件循环中- 同一时刻线程只能够执行一个Task
- 当线程执行到某个Task的
await client.chat.completions.create的时候,await会将当前Task挂起,控制权交还给事件循环,事件循环会查看“还有谁准备好了?”,然后让线程去执行下一个 Task - 最后所有协程对象执行都完毕了之后,将所有协程对象的执行结果按序放到
results中
- 线程一直执行到
-
import asyncio async def chat(msg: str) -> str: print(f"开始处理: {msg}") await asyncio.sleep(1) # 模拟网络请求 print(f"完成处理: {msg}") return f"回复: {msg}" async def main(): messages = ["A", "B", "C"] print("=== 串行执行 ===") for msg in messages: result = await chat(msg) print(result) asyncio.run(main())- 线程执行到
result = await chat(msg)的时候,await让main挂起,线程去继续执行chat - 线程执行到
await asyncio.sleep(1)的时候,会将chat挂起 - 这是一个嵌套
await,嵌套链上的代码全部会挂起,所以相当于串行执行(也就是将这个代码中的所有异步全部取消都没有啥变化)
- 线程执行到
可以使用multiprocessing库开多个进程,每个进程里面使用asyncio库进行异步IO以最大化利用CPU资源

浙公网安备 33010602011771号