第四章: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)。

事件循环,说白了就是一个永不停止的调度器。它维护着一个任务队列,不断检查每个任务的状态:

  1. 任务就绪了? → 执行它
  2. 任务在等I/O? → 把它挂起,去执行其他任务
  3. I/O完成了? → 唤醒任务,继续执行

我画了一张图,帮你理解事件循环的工作流程:

事件循环工作原理 事件循环 任务队列 [任务1, 任务2, 任务3...] 就绪队列 [任务A, 任务B...] 等待队列 [任务X(等I/O)...] 取出任务 可执行 执行 遇到I/O I/O完成 循环往复,直到所有任务完成 执行步骤: 1. 事件循环从任务队列取出一个任务 2. 如果任务可执行,放入就绪队列并执行 3. 如果任务遇到I/O等待,放入等待队列 4. 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给我们的这把解药,用好了,性能翻倍不是梦。

可可精品资料库