第四章:I/O密集型任务瓶颈:阻塞I/O与非阻塞I/O、异步编程入门(asyncio)、事件循环的工作原理
大家好,我是你们的老朋友。今天我们来聊聊I/O密集型任务。说实话,很多人在多线程编程里栽跟头,都是栽在I/O上。CPU计算再快,一旦遇到磁盘读写、网络请求,立马就卡住了。我见过太多项目,线程开了几十个,结果CPU利用率不到10%,全在等I/O。
为什么会这样?说白了,就是阻塞I/O在作怪。今天我就带大家把这块彻底搞明白。
4.1 阻塞I/O vs 非阻塞I/O
先看个最典型的例子。你写个爬虫,用requests库去抓网页。每个请求发出去,线程就停在那等响应。等多久?可能几百毫秒,也可能好几秒。这段时间线程啥也干不了,就干等着。
# 阻塞I/O示例
import requests
import time
def fetch_url(url):
print(f"开始请求: {url}")
response = requests.get(url) # 这里阻塞了!
print(f"完成: {url}, 状态码: {response.status_code}")
return response.text
# 串行执行,每个请求都要等
start = time.time()
fetch_url("https://httpbin.org/delay/2")
fetch_url("https://httpbin.org/delay/2")
print(f"总耗时: {time.time() - start:.2f}秒")
# 输出: 总耗时: 4.02秒
你看,两个请求各等2秒,总共4秒。线程大部分时间都在睡觉。这就是阻塞I/O的典型特征——调用方必须等待I/O操作完成才能继续。
那非阻塞I/O呢?它不等待。你发个请求,系统说「我知道了,你先忙别的,有结果了我通知你」。线程可以继续做其他事情。
核心区别一句话:
- 阻塞I/O:线程等数据,啥也不干
- 非阻塞I/O:线程不等数据,先去干别的
我在项目中遇到过一个问题:一个日志采集系统,要同时从1000台服务器拉取日志。如果用阻塞I/O+多线程,得开1000个线程。线程切换开销巨大,内存也扛不住。后来改成非阻塞I/O+事件驱动,只用几个线程就搞定了。
4.2 异步编程入门:asyncio
Python的asyncio库,就是帮我们做非阻塞I/O的。它用async/await语法,让代码看起来像是同步的,但实际上是异步执行的。
先看个简单的例子:
import asyncio
import time
async def fetch_url_async(url):
print(f"开始请求: {url}")
# 模拟网络延迟
await asyncio.sleep(2) # 非阻塞等待
print(f"完成: {url}")
return f"数据来自{url}"
async def main():
# 创建两个任务,并发执行
task1 = asyncio.create_task(fetch_url_async("https://example.com/1"))
task2 = asyncio.create_task(fetch_url_async("https://example.com/2"))
# 等待所有任务完成
results = await asyncio.gather(task1, task2)
print(results)
start = time.time()
asyncio.run(main())
print(f"总耗时: {time.time() - start:.2f}秒")
# 输出: 总耗时: 2.01秒
看到了吗?两个请求同时发起,总耗时只有2秒,而不是4秒。这就是异步的魅力。
避坑指南:
我曾经犯过一个低级错误——在async函数里调用了同步的time.sleep()。结果整个事件循环都被阻塞了,异步变成了同步。记住:在async函数里,一定要用await asyncio.sleep(),而不是time.sleep()。
4.3 事件循环的工作原理
你可能会问:asyncio是怎么做到「同时」处理多个任务的?答案就是事件循环(Event Loop)。
事件循环,说白了就是一个永不停止的调度器。它维护着一个任务队列,不断检查每个任务的状态:
- 任务就绪了? → 执行它
- 任务在等I/O? → 把它挂起,去执行其他任务
- I/O完成了? → 唤醒任务,继续执行
我画了一张图,帮你理解事件循环的工作流程:
嗯,这里要注意:事件循环是单线程的。它只有一个线程在跑,但通过不断切换任务,实现了「并发」的效果。你想想看,这比开1000个线程高效多了吧?
4.4 实战:用asyncio重写爬虫
光说不练假把式。我们写个真正的异步爬虫看看效果:
import asyncio
import aiohttp
import time
async def fetch(session, url):
async with session.get(url) as response:
# 非阻塞等待响应
data = await response.text()
return len(data)
async def main():
urls = [
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/2",
"https://httpbin.org/delay/3",
"https://httpbin.org/delay/1",
"https://httpbin.org/delay/2",
]
async with aiohttp.ClientSession() as session:
# 创建所有任务
tasks = [fetch(session, url) for url in urls]
# 并发执行
results = await asyncio.gather(*tasks)
for url, size in zip(urls, results):
print(f"{url}: {size} bytes")
start = time.time()
asyncio.run(main())
print(f"总耗时: {time.time() - start:.2f}秒")
# 输出: 总耗时: 3.01秒(最慢的请求是3秒)
5个请求,最慢的要3秒,但总耗时只有3秒。如果是同步方式,得1+2+3+1+2=9秒。性能提升了3倍!
重要提醒:
asyncio不是万能的。它只对I/O密集型任务有效。如果你的任务是CPU密集型的(比如大量计算),用asyncio反而会更慢。因为事件循环是单线程的,CPU计算会阻塞其他任务。这时候应该用多进程(multiprocessing)。
4.5 常见陷阱与最佳实践
我总结了一些实战中容易踩的坑:
| 陷阱 | 错误做法 | 正确做法 |
|---|---|---|
| 在async函数里用同步库 | time.sleep()、requests.get() | asyncio.sleep()、aiohttp.get() |
| 忘记await | task = asyncio.create_task(func()) | result = await task |
| 任务太多不限制并发 | 一次性创建10000个任务 | 使用asyncio.Semaphore限制并发数 |
| 阻塞事件循环 | 在回调里做耗时操作 | 用loop.run_in_executor交给线程池 |
我的个人习惯:
写异步代码时,我通常会先画一个流程图,理清哪些操作是I/O密集的,哪些是CPU密集的。然后只把I/O密集的部分用async/await包装,CPU密集的部分用run_in_executor丢到线程池里。这样既利用了异步的高效,又不耽误计算。
好了,关于I/O密集型任务的优化,今天就聊到这。记住一句话:阻塞I/O是性能杀手,非阻塞I/O+事件循环是解药。asyncio就是Python给我们的这把解药,用好了,性能翻倍不是梦。