楼层: 首页/ 软件技术/ Python 基础/ 并发与异步
10

并发与异步

threading / multiprocessing / asyncio

为什么你的程序在等网络、等文件的时候傻站着?因为它在"串行干活"。并发让它能同时干几件事。但 Python 有个著名的 GIL,选错工具白忙活。这章给你一张"什么时候用什么"的决策表。

论GIL:全局解释器锁

CPython(官方 Python 解释器)有一把全局锁:同一时刻,一个进程里只有一个线程在跑 Python 字节码。这意味着多线程并不能加速 CPU 密集型任务——多个线程抢同一把锁,等于排队。但 IO 密集型(等网络、等磁盘)不受影响,因为等的时候锁会释放,别的线程能用。

三种并发武器对照表

武器适合不适合
多线程 threadingIO 密集型(爬虫、读文件、调 API)CPU 密集型(GIL 挡着)。
多进程 multiprocessingCPU 密集型(图像计算、数值计算)IO 密集型(进程创建贵)。
异步 asyncio超高并发 IO(成千上万网络请求)CPU 密集;且必须用异步库。

concurrent.futures:最省心的并发

import time from concurrent.futures import ThreadPoolExecutor def fetch(url): time.sleep(0.5) # 假装在等网络 return f"{url} 完成" urls = ["a.com", "b.com", "c.com", "d.com"] # 串行:要 2 秒 start = time.time() for u in urls: fetch(u) print("串行:", time.time() - start) # 线程池:4 个一起跑,0.5 秒搞定 start = time.time() with ThreadPoolExecutor(max_workers=4) as pool: results = list(pool.map(fetch, urls)) print("并发:", time.time() - start) print(results)

async / await 异步

import asyncio # async def 定义协程函数,调用它不会立刻执行,返回一个协程对象 async def fetch(name, delay): print(f"{name} 开始等") await asyncio.sleep(delay) # 异步等,不占线程 print(f"{name} 完成") return name async def main(): # 同时跑三个协程 results = await asyncio.gather( fetch("A", 1), fetch("B", 2), fetch("C", 1.5), ) return results # Python 3.7+:asyncio.run() 启动事件循环 out = asyncio.run(main()) print(out) # ['A', 'B', 'C'] # 总耗时约 2 秒(最慢的那个),不是 4.5 秒 # 异步 HTTP 用 aiohttp(要 pip install aiohttp)

异步并发量控制:Semaphore

同时发 1000 个请求会把对方服务器打挂(也会把你自己封 IP)。用信号量限制"同时最多 10 个":

async def fetch_limited(name, sem): async with sem: # 同时只有 10 个能进来 await fetch(name, 1) async def main(): sem = asyncio.Semaphore(10) await asyncio.gather(*[fetch_limited(f"req{i}", sem) for i in range(100)])

完整案例:aiohttp 异步爬虫(同步 vs 异步速度对比)

光说不练假把式。下面用 aiohttp 并发抓 10 个网页,对比同步 requests 和异步 aiohttp 的耗时差。这是异步最能打的场景——爬虫等网络的时间被压缩到几乎为零。

先装库:pip install aiohttp requests

import asyncio import time import requests import aiohttp URLS = [ "https://httpbin.org/delay/1", # 故意让服务器延迟 1 秒返回 "https://httpbin.org/delay/1", "https://httpbin.org/delay/1", "https://httpbin.org/delay/1", "https://httpbin.org/delay/1", ] * 2 # 一共 10 个请求 # ===== 同步版:requests,一个一个等 ===== def fetch_sync(url): resp = requests.get(url, timeout=10) return resp.status_code start = time.time() sync_results = [fetch_sync(u) for u in URLS] sync_cost = time.time() - start print(f"同步 requests:{sync_cost:.2f} 秒,结果 {len(sync_results)} 条") # ===== 异步版:aiohttp + Semaphore 限流 ===== async def fetch_async(session, url, sem): async with sem: # 同时最多 5 个请求,别把服务器打挂 async with session.get(url, timeout=10) as resp: return resp.status async def main_async(): sem = asyncio.Semaphore(5) async with aiohttp.ClientSession() as session: tasks = [fetch_async(session, u, sem) for u in URLS] return await asyncio.gather(*tasks) start = time.time() async_results = asyncio.run(main_async()) async_cost = time.time() - start print(f"异步 aiohttp:{async_cost:.2f} 秒,结果 {len(async_results)} 条") print(f"提速 {sync_cost / async_cost:.1f} 倍")

运行输出(你机器上数字略有不同,但比例惊人)

# 同步 requests:10.23 秒,结果 10 条 # 异步 aiohttp:2.14 秒,结果 10 条 # 提速 4.8 倍 # 解析:同步版每个请求等 1 秒,10 个就是 10 秒; # 异步版 5 个一批同时等,两批就完事,约 2 秒。 # 请求越多、延迟越高,异步优势越夸张——几百个请求能从 10 分钟压到 10 秒。
异步里别写阻塞代码

在 async def 里调用 requests.get()、time.sleep() 这种阻塞函数,会卡住整个事件循环,并发直接废掉。规则:异步代码里一切都要用异步版本——等网络用 aiohttp,等时间用 asyncio.sleep(),读文件用 aiofiles,连数据库用异步驱动(async SQLAlchemy)。偶尔必须调同步库?丢到 asyncio.to_thread() 里跑。

concurrent.futures 完整案例:map / submit / as_completed

ThreadPoolExecutor(线程池,IO 密集)和 ProcessPoolExecutor(进程池,CPU 密集)是 concurrent.futures 的两大门将。三种提交方式:map(最简单,按顺序返回)、submit(单个提交)+ as_completed(谁先完成先处理谁)。

import time from concurrent.futures import ( ThreadPoolExecutor, ProcessPoolExecutor, as_completed ) def work(n): time.sleep(0.5 + n * 0.1) # 模拟每个任务耗时不同 return n * n nums = [1, 2, 3, 4, 5] # ① map:按输入顺序返回结果(哪怕 5 号先完成,也得等 1 号排到) with ThreadPoolExecutor(max_workers=3) as pool: results = list(pool.map(work, nums)) print(f"map 顺序结果:{results}") # map 顺序结果:[1, 4, 9, 16, 25] 始终按 nums 的顺序 # ② submit + as_completed:谁先算完先用谁,不等慢的 with ThreadPoolExecutor(max_workers=3) as pool: future_to_n = {pool.submit(work, n): n for n in nums} for fut in as_completed(future_to_n): n = future_to_n[fut] print(f"任务 {n} 完成,结果 {fut.result()}") # 任务 1 完成,结果 1 # 任务 2 完成,结果 4 # 任务 3 完成,结果 9 # 任务 4 完成,结果 16 # 任务 5 完成,结果 25 (顺序取决于谁先跑完,这里恰好从小到大) # ③ ProcessPoolExecutor:CPU 密集型用这个(绕开 GIL) def cpu_heavy(n): s = 0 for i in range(1_0000_000): s += i * n return s with ProcessPoolExecutor(max_workers=4) as pool: total = sum(pool.map(cpu_heavy, [1, 2, 3, 4])) print(f"CPU 密集多进程结果:{total}")

论map 和 as_completed 怎么选

map:结果顺序和输入一致,代码最干净,适合"我只要全部结果,不关心谁先完成"。submit + as_completed:谁先干完就先处理谁(比如先渲染先下载的图片),适合流式展示、超时取消。CPU 密集型把 ThreadPoolExecutor 换成 ProcessPoolExecutor 即可,接口一模一样——这就是 concurrent.futures 的优雅之处。

本章面试题 · 并发与异步

1.(概念题)什么是 GIL?它对多线程和多进程分别有什么影响?

查看答案

答案:GIL(全局解释器锁)是 CPython 保证同一时刻只有一个线程执行 Python 字节码的机制。多线程在 CPU 密集任务上无法真正并行(互相抢锁),但 IO 密集任务上有效(等 IO 时释放锁);多进程每个进程有独立 GIL,能真正利用多核,适合 CPU 密集,但进程创建开销大、内存不共享。

2.(概念题)asyncio 和多线程有什么区别?为什么异步爬虫比多线程还快?

查看答案

答案:多线程靠操作系统切换线程,有内核态开销;asyncio 在单个线程里由事件循环调度协程,切换是用户态的,极轻量。爬虫等网络时两者都在"等",但 asyncio 一个线程就能挂起几千个协程,多线程开几千个线程上下文切换成本很高。且 asyncio 没有 GIL 抢锁问题。

3.(代码题)下面这段异步代码有什么问题?

async def main():
    results = [await fetch(u) for u in URLs]
asyncio.run(main())

查看答案

答案:这是串行异步——列表推导里 await 一个接一个,等于没用上并发。正确做法:先创建所有协程/任务,再 gather:tasks = [fetch(u) for u in URLs]; await asyncio.gather(*tasks)。记住:await 是"等这一个",gather 才是"一起跑"。

4.(概念题)ThreadPoolExecutor 的 map 和 as_completed 有什么区别?

查看答案

答案:map 返回结果顺序和输入一致,哪怕第 5 个任务先完成,也要等前面的排到;submit + as_completed 按完成先后迭代,谁先返回先处理谁。要保顺序用 map,要流式处理用 as_completed。

5.(思考题)为什么在 async def 里直接调 requests.get() 会"毁掉"异步性能?怎么补救?

查看答案

答案:requests.get() 是阻塞调用,一旦它在等网络,整个事件循环都被它卡住,其他协程全部停摆,并发形同虚设。补救:① 换成异步库 aiohttp;② 实在要用同步库,丢进 await asyncio.to_thread(requests.get, url),让它在线程里跑,不阻塞事件循环。