异步 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回到单线程

同步与异步,阻塞与非阻塞

老张爱喝茶,废话不说,煮开水。 出场人物:老张,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。

  1. 老张把水壶放到火上,立等水开。(同步阻塞) 老张觉得自己有点傻
  2. 老张把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有。(同步非阻塞) 老张还是觉得自己有点傻,于是变高端了,买了把会响笛的那种水壶。水开之后,能大声发出嘀的噪音。
  3. 老张把响水壶放到火上,立等水开。(异步阻塞) 老张觉得这样傻等意义不大
  4. 老张把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶。(异步非阻塞) 老张觉得自己聪明了。

所谓同步异步,只是对于水壶而言。 普通水壶,同步;响水壶,异步。 虽然都能干活,但响水壶可以在自己完工之后,提示老张水开了。这是普通水壶所不能及的。 同步只能让调用者去轮询自己(情况2中),造成老张效率的低下。

所谓阻塞非阻塞,仅仅对于老张而言。 立等的老张,阻塞;看电视的老张,非阻塞。 情况1和情况3中老张就是阻塞的,媳妇喊他都不知道。虽然3中响水壶是异步的,可对于立等的老张没有太大的意义。所以一般异步是配合非阻塞使用的,这样才能发挥异步的效用。

这个例子中,老张相当于线程,水壶相当于IO请求,所以就是说同步异步就是指请求是否通知线程(关注的是消息通知机制),阻塞非阻塞就是指线程是否继续执行(关注的是调用者(线程)在等待结果时的状态)

事件循环

事件循环(Event Loop) 是异步编程的核心机制。简单来说,它是单线程语言实现非阻塞异步操作的“调度员”

  1. 核心痛点:为什么需要它?

    单线程意味着同一时间只能做一件事。

    • 如果没有异步: 假如你请求一个网络接口需要 5 秒,如果不把这个任务挂起,整个页面就会“卡死” 5 秒,用户无法点击任何按钮。
    • 解决方案: JavaScript 将任务分为同步任务异步任务。同步任务立即执行,异步任务交给浏览器(或系统)去处理,回头再执行回调。

    事件循环(Event Loop) 就是负责监控代码执行情况,决定“什么时候把异步任务的回调拿回来执行”的机制。


  1. 技术原理:底层架构

    内存空间主要分为这几个部分:

    1. 调用栈 (Call Stack):
      • 存放正在执行的同步代码
      • 代码执行原则是“后进先出”。
      • 只有当调用栈清空了,事件循环才会开始工作。
    2. Web APIs (浏览器) / C++ APIs (Node.js):
      • 这里处理异步操作(如 setTimeoutfetch 请求、DOM 事件)。
      • 当异步操作完成(例如 1秒时间到了,或者数据请求回来了),API 会把回调函数放入任务队列
    3. 任务队列 (Task Queue):
      • 存放准备好要执行的回调函数(即“做好的汤”)。
    4. 事件循环 (Event Loop):
      • 这是一个死循环过程 while(true)
      • 它的工作: 不停地检查 调用栈 是否为空。
      • 如果栈空了,它就从 任务队列 中取出一个任务,推入调用栈执行。

  1. 进阶:宏任务 (MacroTask) 与 微任务 (MicroTask)

    任务队列其实不只一个,由于优先级不同,分为两类:

    • 宏任务 (MacroTask):
      • 普通的异步任务。
      • 包括:script (整体代码)、setTimeoutsetIntervalsetImmediate (Node)、I/O 操作、UI 渲染。
    • 微任务 (MicroTask):
      • VIP 任务,优先级极高,插队执行。
      • 包括:Promise.then/catch/finallyprocess.nextTick (Node)、MutationObserver
  2. 事件循环的详细流程(Loop 过程):

    1. 执行调用栈中的同步代码
    2. 同步代码执行完毕,调用栈清空。
    3. 检查微任务队列
      • 如果有微任务,全部执行完(一个接一个,直到微任务队列清空)。
    4. UI 渲染(如果有需要更新界面的话)。
    5. 执行宏任务队列
      • 取出一个宏任务放入栈中执行。
    6. 回到第 3 步(执行完这个宏任务后,立刻再去检查有没有新的微任务产生)。

asyncio

  • 在函数前加上async可以把函数变成协程
    • 调用协程不会执行函数,只会得到一个协程对象
    • 协程对象的执行需要通过asyncio.create_task()asyncio.gather()
    • 协程跟普通函数最大的区别就是协程是异步的,执行完毕之后可以通知事件循环
  • await
    • await 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_completedgather是一样的,都是按照异步的方式将所有任务执行完毕,但是前者可以执行完一个就进行一次操作,而不需要等待所有任务执行完毕
  • 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)
      • awaitmain函数挂起,意思是等到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)的时候,awaitmain挂起,线程去继续执行chat
    • 线程执行到await asyncio.sleep(1)的时候,会将chat挂起
    • 这是一个嵌套await,嵌套链上的代码全部会挂起,所以相当于串行执行(也就是将这个代码中的所有异步全部取消都没有啥变化)

可以使用multiprocessing库开多个进程,每个进程里面使用asyncio库进行异步IO以最大化利用CPU资源

posted @ 2025-12-19 23:12  最爱丁珰  阅读(38)  评论(0)    收藏  举报