在现代软件开发中,性能是一个永恒的话题。特别是在处理网络请求、文件读写等 I/O 密集型任务时,传统的同步编程模型可能会因为等待而浪费大量时间。为了解决这个问题,异步编程应运而生。Python 通过内置的 asyncio
库,为开发者提供了强大而优雅的异步编程能力。 [1][2]
本文将带你从零开始,逐步深入 asyncio
的世界,理解其核心概念,并最终通过实战案例掌握其用法。
想象一下你在厨房做饭,需要同时烧水、切菜和炒菜。
代码的世界也是如此。同步编程就是一次只做一件事,必须等前一件事(比如一次网络请求)完成后才能做下一件。 [1] 而异步编程允许程序在等待一个耗时操作(通常是 I/O 操作)时,切换去执行其他任务,从而提高整体效率。 [1][3]
asyncio
正是 Python 用于实现这种高效工作模式的标准库,它特别适合 I/O 密集型和高层级的网络代码。 [3]
asyncio
的核心基石:async
/await
与协程要使用 asyncio
,首先需要理解几个关键概念。
在 Python 中,使用 async def
关键字定义的函数,我们称之为协程函数。调用它并不会立即执行函数体,而是会返回一个协程对象。 [4] 协程可以被看作是一种可以暂停和恢复执行的特殊函数。 [1]
await
这个关键字只能在 async def
函数内部使用。它的作用是“等待”一个可等待对象 (Awaitable) 执行完成。 [5] 可等待对象包括协程、任务 (Task) 和 Future 对象。 [5] 当程序执行到 await
时,它会告诉事件循环:“这个操作有点耗时,我先在这里暂停,你可以去忙别的,等我好了再回来继续。” [6]
事件循环是 asyncio
的心脏。 [2][6] 你可以把它想象成一个大管家,负责调度和执行所有的异步任务。它会不断检查是否有任务已经准备好可以继续运行,或者是否有新的任务需要开始。 [2]
asyncio.run()
这是启动异步程序的入口。它会创建一个新的事件循环,运行你传入的顶级协程(通常是 main
函数),并在协程执行完毕后关闭事件循环。 [7][8]
asyncio
程序让我们来看一个最简单的例子。
import asyncio
import time
# 使用 async def 定义一个协程函数
async def say_hello(delay, message):
"""一个简单的协程,会延迟指定秒数后打印消息。"""
print(f"[{time.strftime('%X')}] 开始任务: {message}")
# asyncio.sleep 是一个异步的 time.sleep()
# 当遇到 await asyncio.sleep() 时,事件循环会切换到其他任务
await asyncio.sleep(delay)
print(f"[{time.strftime('%X')}] 完成任务: {message}")
# 定义主入口协程
async def main():
print(f"[{time.strftime('%X')}] 程序开始")
# 直接 await 调用协程
await say_hello(2, "你好")
await say_hello(1, "世界")
print(f"[{time.strftime('%X')}] 程序结束")
# 使用 asyncio.run() 启动程序
if __name__ == "__main__":
asyncio.run(main())
运行结果分析:
[13:30:00] 程序开始
[13:30:00] 开始任务: 你好
[13:30:02] 完成任务: 你好
[13:30:02] 开始任务: 世界
[13:30:03] 完成任务: 世界
[13:30:03] 程序结束
你会发现,这段代码虽然是异步的,但执行顺序和同步代码一样,总共耗时 3 秒。这是因为我们依次 await
了两个协程,必须等第一个完成后,第二个才会开始。
那么,如何让它们“同时”运行呢?
asyncio.gather
与 asyncio.create_task
为了真正实现并发,我们需要让多个任务在事件循环中同时被调度。 [3]
asyncio.gather
asyncio.gather()
可以接收一个或多个可等待对象,将它们并发执行,并按输入顺序返回所有结果。 [9]
修改上面的 main
函数:
async def main():
print(f"[{time.strftime('%X')}] 程序开始")
# 使用 asyncio.gather 并发运行两个协程
await asyncio.gather(
say_hello(2, "你好"),
say_hello(1, "世界")
)
print(f"[{time.strftime('%X')}] 程序结束")
# ... 其他代码不变 ...
新的运行结果:
[13:32:10] 程序开始
[13:32:10] 开始任务: 你好
[13:32:10] 开始任务: 世界
[13:32:11] 完成任务: 世界
[13:32:12] 完成任务: 你好
[13:32:12] 程序结束
观察时间戳,两个任务几乎是同时开始的。耗时1秒的任务先结束,耗时2秒的后结束。整个程序的总耗时取决于最长的那个任务,也就是 2 秒,而不是之前的 3 秒。这就是并发带来的效率提升! [3]
asyncio.create_task
asyncio.create_task()
用于将一个协程包装成一个任务 (Task),并提交给事件循环立即开始执行,而不需要马上 await
它。 [6][7] Task
是 Future
的一个子类,专门用于管理协程。 [4]
这就像是“发射后不管”(fire-and-forget),你创建了一个任务让它在后台运行,然后可以继续做其他事情。 [6]
async def main():
print(f"[{time.strftime('%X')}] 程序开始")
# 创建任务,任务会立即开始在事件循环中被调度
task1 = asyncio.create_task(say_hello(2, "你好"))
task2 = asyncio.create_task(say_hello(1, "世界"))
print(f"[{time.strftime('%X')}] 任务已创建")
# 在这里可以做其他事情
await asyncio.sleep(0.5)
print(f"[{time.strftime('%X')}] 主程序做了一些其他工作")
# 等待任务完成
await task1
await task2
print(f"[{time.strftime('%X')}] 程序结束")
运行结果:
[13:35:20] 程序开始
[13:35:20] 任务已创建
[13:35:20] 开始任务: 你好
[13:35:20] 开始任务: 世界
[13:35:20] 主程序做了一些其他工作
[13:35:21] 完成任务: 世界
[13:35:22] 完成任务: 你好
[13:35:22] 程序结束
create_task
和 gather
的区别在于控制的粒度。gather
是一种更高级的抽象,适合一次性并发运行多个任务并收集结果的场景。 [9] create_task
则提供了更灵活的控制,允许你在任务运行期间执行其他逻辑。 [6][9]
aiohttp
并发下载网页理论讲了这么多,让我们来看一个最能体现 asyncio
价值的场景:并发网络请求。我们将使用流行的异步 HTTP 客户端库 aiohttp
。 [10][11]
首先,你需要安装 aiohttp
:
pip install aiohttp
下面的例子将对比同步和异步方式获取多个网页标题所花费的时间。
import asyncio
import time
import aiohttp
import requests # 用于同步对比
urls = [
'https://www.python.org',
'https://github.com',
'https://www.wikipedia.org',
'https://www.youtube.com',
'https://www.amazon.com',
]
def get_title_sync(url):
"""同步获取网页标题"""
try:
resp = requests.get(url, timeout=10)
# 一个简单的解析,实际应用中建议使用 BeautifulSoup
return resp.text.split('' )[1].split('')[0].strip()
except Exception as e:
return f"Error: {e}"
async def get_title_async(session, url):
"""异步获取网页标题"""
try:
# aiohttp 使用 session.get() 发起请求
async with session.get(url, timeout=10) as resp:
# resp.text() 是一个协程,需要 await
html = await resp.text()
return html.split('' )[1].split('')[0].strip()
except Exception as e:
return f"Error: {e}"
async def main_async():
# aiohttp 建议使用一个 ClientSession 来执行所有请求
async with aiohttp.ClientSession() as session:
tasks = [get_title_async(session, url) for url in urls]
# 使用 gather 并发执行所有任务
titles = await asyncio.gather(*tasks)
for url, title in zip(urls, titles):
print(f"{url}: {title}")
if __name__ == "__main__":
# --- 同步版本 ---
print("--- 开始同步请求 ---")
start_time_sync = time.time()
for url in urls:
title = get_title_sync(url)
print(f"{url}: {title}")
end_time_sync = time.time()
print(f"同步请求总耗时: {end_time_sync - start_time_sync:.2f} 秒\n")
# --- 异步版本 ---
print("--- 开始异步请求 ---")
start_time_async = time.time()
asyncio.run(main_async())
end_time_async = time.time()
print(f"异步请求总耗时: {end_time_async - start_time_async:.2f} 秒")
典型的运行结果:
--- 开始同步请求 ---
https://www.python.org: Welcome to Python.org
https://github.com: GitHub: Let’s build from here
https://www.wikipedia.org: Wikipedia
https://www.youtube.com: YouTube
https://www.amazon.com: Amazon.com. Spend less. Smile more.
同步请求总耗时: 4.58 秒
--- 开始异步请求 ---
https://www.python.org: Welcome to Python.org
https://github.com: GitHub: Let’s build from here
https://www.wikipedia.org: Wikipedia
https://www.youtube.com: YouTube
https://www.amazon.com: Amazon.com. Spend less. Smile more.
异步请求总耗时: 0.95 秒
结果一目了然。异步版本的速度比同步版本快了数倍。 [5] 这是因为 asyncio
在等待一个网站响应时,没有闲着,而是立即去请求下一个网站,极大地利用了网络 I/O 的等待时间。 [11]
当我们有多个协程并发运行时,有时它们需要访问同一个资源,或者需要相互传递工作任务。这时,为了避免数据混乱和协调工作流程,就需要用到同步和通信机制。asyncio
提供了与多线程编程中类似的工具,但它们是为协程专门设计的。
asyncio.Lock
在并发环境中,如果多个任务同时尝试修改一个共享资源(例如一个变量或文件),就可能导致竞争条件 (Race Condition),使得最终结果不可预测。
虽然 asyncio
在单线程上运行,不会有真正的并行执行,但一个协程可以在 await
处被挂起,此时事件循环会运行另一个协程。如果这两个协程都在修改同一个数据,问题依然存在。
asyncio.Lock
就是用来解决这个问题的。它保证在任何时候,只有一个协程能够获得锁并执行“临界区”代码。
使用场景:保护对共享资源的访问,确保操作的原子性。
让我们看一个例子:多个协程同时增加一个共享计数器。
import asyncio
# 一个共享的资源
shared_counter = 0
async def unsafe_worker():
"""一个没有锁保护的协程"""
global shared_counter
# 1. 读取当前值
current_value = shared_counter
# 在这里,协程可能会被挂起,切换到另一个 worker
await asyncio.sleep(0.01)
# 2. 基于旧值计算新值
new_value = current_value + 1
# 3. 写入新值
shared_counter = new_value
async def safe_worker(lock):
"""一个有锁保护的协程"""
global shared_counter
# 使用 async with lock 语法可以自动获取和释放锁
async with lock:
current_value = shared_counter
await asyncio.sleep(0.01)
new_value = current_value + 1
shared_counter = new_value
async def main():
global shared_counter
# --- 演示不安全的情况 ---
print("--- 演示不安全的情况 ---")
shared_counter = 0
tasks_unsafe = [unsafe_worker() for _ in range(100)]
await asyncio.gather(*tasks_unsafe)
print(f"没有锁保护,100个任务完成后的计数器值: {shared_counter}") # 结果通常远小于100
# --- 演示安全的情况 ---
print("\n--- 演示安全的情况 ---")
shared_counter = 0
lock = asyncio.Lock()
tasks_safe = [safe_worker(lock) for _ in range(100)]
await asyncio.gather(*tasks_safe)
print(f"使用锁保护,100个任务完成后的计数器值: {shared_counter}") # 结果总是100
if __name__ == "__main__":
asyncio.run(main())
代码解读与结果分析:
unsafe_worker
: 在读取 (current_value = ...
) 和写入 (shared_counter = ...
) 之间有一个 await
。这给了事件循环切换到另一个 unsafe_worker
的机会。多个 worker 可能会基于同一个旧值进行计算,导致一些增加操作丢失。因此,最终结果会小于 100。safe_worker
: 使用了 async with lock:
。当一个协程进入这个代码块时,它会获取锁。如果此时其他协程也想进入,它们必须 await
,直到第一个协程执行完毕并自动释放锁。这确保了“读-改-写”这个操作的完整性,所以最终结果总是正确的 100。asyncio.Queue
asyncio.Queue
是一个为异步编程设计的队列,它非常适合经典的生产者-消费者 (Producer-Consumer) 模型。
队列本身处理了所有的同步逻辑:
get
) 数据,它会自动 await
,直到队列中有新数据。maxsize
)中放入 (put
) 数据,它会自动 await
,直到队列有空位。使用场景:解耦任务的创建和执行,实现任务分发系统,控制并发处理任务的数量。
让我们构建一个简单的爬虫模型:一个生产者负责发现 URL 并放入队列,多个消费者负责从队列中取出 URL 并“下载”。
import asyncio
import random
async def producer(queue, num_urls):
"""生产者:生成一些模拟的URL并放入队列"""
print("生产者启动...")
for i in range(num_urls):
url = f"https://example.com/page/{i}"
# 模拟发现URL需要一些时间
await asyncio.sleep(random.uniform(0.1, 0.5))
# 将URL放入队列
await queue.put(url)
print(f"生产者放入: {url}")
print("生产者完成任务。")
async def consumer(name, queue):
"""消费者:从队列中获取URL并处理"""
print(f"消费者 {name} 启动...")
# 持续从队列中获取任务
while True:
# 从队列中获取URL,如果队列为空,会在此处等待
url = await queue.get()
print(f"消费者 {name} 正在处理: {url}")
# 模拟处理任务需要的时间
await asyncio.sleep(random.uniform(0.5, 1.5))
print(f"消费者 {name} 完成处理: {url}")
# 必须调用 task_done() 来通知队列这个任务已经处理完毕
queue.task_done()
async def main():
# 创建一个不限大小的队列
task_queue = asyncio.Queue()
num_urls_to_produce = 10
num_consumers = 3
# 启动生产者
producer_task = asyncio.create_task(producer(task_queue, num_urls_to_produce))
# 启动多个消费者
consumer_tasks = []
for i in range(num_consumers):
task = asyncio.create_task(consumer(f"C{i+1}", task_queue))
consumer_tasks.append(task)
# 等待生产者完成所有URL的放入
await producer_task
print("所有URL已放入队列,等待消费者处理...")
# 等待队列中的所有任务都被处理完毕
# queue.join() 会阻塞,直到队列中每个项目的 task_done() 都被调用
await task_queue.join()
print("所有任务处理完毕!")
# 所有任务都处理完了,消费者们还在 while True 循环里等待新任务
# 为了让程序能正常退出,我们需要取消这些消费者任务
for task in consumer_tasks:
task.cancel()
if __name__ == "__main__":
asyncio.run(main())
代码解读与关键点:
queue.put(item)
: 生产者使用它来异步地添加项目。queue.get()
: 消费者使用它来异步地获取项目。这是主要的同步点。queue.task_done()
: 这是至关重要的一步!消费者处理完一个项目后,必须调用此方法。它会减少队列的内部计数器。queue.join()
: main
函数用它来等待所有项目都被处理。它会一直阻塞,直到队列的内部计数器归零。这确保了我们在程序结束前,所有工作都已完成。asyncio.run(main())
将永远不会退出。asyncio
为 Python 带来了强大的并发能力,是构建高性能网络应用和服务的利器。
核心要点回顾:
async def
定义协程,await
暂停协程并等待结果。asyncio.run()
是现代 Python 中启动异步程序的标准方式。asyncio.gather()
或 asyncio.create_task()
来并发运行多个任务。asyncio.Lock
保护共享资源,避免竞争条件;使用 asyncio.Queue
构建生产者-消费者模型,高效地分发和处理任务。aiohttp
, aiodns
, asyncpg
等异步库才能发挥最大威力。从 Python 3.4 首次引入 asyncio
至今,它已经变得越来越成熟和易用。虽然异步编程的思维方式需要一些时间来适应,但一旦你掌握了它,它将成为你工具箱中应对高并发挑战的一把“瑞士军刀”。希望这篇博客能为你打开异步编程的大门。
参考文章