Python 异步编程入门:从回调地狱到 async/await 优雅实践
在日常开发中,我们经常会遇到处理 I/O 密集型任务的场景,比如爬取网页、读写文件、调用网络 API 等等。传统的同步编程方式会在等待 I/O 的过程中阻塞当前线程,导致大量时间被浪费。而异步编程则可以让程序在等待 I/O 的时候去处理其他任务,充分利用 CPU 资源,提升程序的整体性能。
Python 对异步编程的支持经历了漫长的演进过程,从早期的回调模式,到基于生成器的实现,最终在 Python 3.5 引入了 async/await 语法,让异步代码变得更加简洁易读。本文将从最基础的概念讲起,带你一步步理解 Python 异步编程的核心思想,并通过实际的代码示例掌握如何编写优雅的异步程序。
同步 vs 异步:到底有什么区别?
在开始学习异步编程之前,我们首先要弄清楚同步和异步的区别。我们先来看一个简单的同步编程示例:
import time def download_file(url): print(f"开始下载 {url}") # 模拟网络 I/O 等待,耗时 2 秒 time.sleep(2) print(f"下载完成 {url}") return f"文件内容来自 {url}" def main(): start_time = time.time() # 依次下载三个文件 download_file("https://example.com/file1") download_file("https://example.com/file2") download_file("https://example.com/file3") cost = time.time() - start_time print(f"总共耗时: {cost:.2f} 秒") if __name__ == "__main__": main() 运行这段代码,你会看到输出结果类似这样:
开始下载 https://example.com/file1 下载完成 https://example.com/file1 开始下载 https://example.com/file2 下载完成 https://example.com/file2 开始下载 https://example.com/file3 下载完成 https://example.com/file3 总共耗时: 6.01 秒
很明显,同步方式每次只能等待一个下载完成,才能开始下一个,总耗时是三个文件下载时间的总和。如果使用异步方式,我们可以同时发起三个下载请求,然后一起等待结果,总耗时只需要大约 2 秒。这就是异步编程最大的优势——在处理 I/O 密集型任务时,可以大幅缩短整体执行时间。
那么异步编程到底是如何工作的呢?其实核心思想就是非阻塞等待:当一个任务在等待 I/O 完成时,程序会把 CPU 让给其他任务继续执行,等到 I/O 完成后再回来继续处理这个任务。这样一来,CPU 几乎不会空闲,资源利用率得到了极大提升。
从回调地狱说起:早期的异步实现
在介绍 async/await 之前,我们先来看一下早期异步编程常用的回调模式,理解它为什么会让代码变得难以维护。
回调模式的基本思想是:你发起一个异步操作,然后传递一个函数作为参数,当异步操作完成后,系统会调用你传递的这个回调函数处理结果。我们用 Python 来模拟一下:
import time from threading import Timer def async_download(url, callback): print(f"开始下载 {url}") # 使用定时器模拟异步 I/O def on_complete(): result = f"文件内容来自 {url}" print(f"下载完成 {url}") callback(result) Timer(2, on_complete).start() def main(): start_time = time.time() def after_download1(result1): print(f"处理结果1: {result1}") async_download("https://example.com/file2", lambda result2: print(f"处理结果2: {result2}") async_download("https://example.com/file3", lambda result3: print(f"处理结果3: {result3}") cost = time.time() - start_time print(f"总共耗时: {cost:.2f} 秒") ) ) async_download("https://example.com/file1", after_download1) if __name__ == "__main__": main() # 保持主线程不退出 time.sleep(7) 你发现问题了吗?如果我们需要按顺序执行多个异步操作,回调函数就会一层套一层,代码缩进会越来越深,阅读和维护都非常困难,这就是大家常说的回调地狱。
为了解决回调嵌套的问题,Python 社区开始探索新的异步编程方式,这就引出了基于生成器的异步实现,后来又发展出了 async/await 语法。
async/await:优雅的异步编程语法
Python 3.5 正式引入了 async 和 await 关键字,让我们可以写出既高效又简洁的异步代码。我们先来重写上面的下载示例,感受一下 async/await 的魅力:
import asyncio import time async def download_file(url): print(f"开始下载 {url}") # 异步等待 I/O 完成,不会阻塞事件循环 await asyncio.sleep(2) print(f"下载完成 {url}") return f"文件内容来自 {url}" async def main(): start_time = time.time() # 方式一:依次等待三个任务 # await download_file("https://example.com/file1") # await download_file("https://example.com/file2") # await download_file("https://example.com/file3") # 方式二:并发执行三个任务 task1 = asyncio.create_task(download_file("https://example.com/file1")) task2 = asyncio.create_task(download_file("https://example.com/file2")) task3 = asyncio.create_task(download_file("https://example.com/file3")) await task1 await task2 await task3 cost = time.time() - start_time print(f"总共耗时: {cost:.2f} 秒") if __name__ == "__main__": asyncio.run(main()) 运行这段代码,你会看到输出结果:
开始下载 https://example.com/file1 开始下载 https://example.com/file2 开始下载 https://example.com/file3 下载完成 https://example.com/file1 下载完成 https://example.com/file2 下载完成 https://example.com/file3 总共耗时: 2.01 秒
看看!代码结构和同步版本几乎一模一样,但是却实现了并发执行,总耗时从 6 秒缩短到了 2 秒,代码也比回调版本清晰太多了。这就是 async/await 的魔力。
理解核心概念:事件循环、协程、任务
要理解 Python 异步编程,我们需要掌握三个核心概念:事件循环、协程和任务。
事件循环(Event Loop)是异步程序的核心调度器,它负责管理所有待执行的任务,当一个任务等待 I/O 完成时,事件循环会把它暂停,然后去执行其他已经准备好的任务,等到 I/O 完成后再恢复执行这个任务。这样不断循环往复,直到所有任务执行完成。
协程(Coroutine)是 async def 定义的函数,调用它会返回一个协程对象,并不会立即执行。协程可以在等待的时候暂停执行,把控制权交还给事件循环,等条件满足后再继续执行。await 关键字就是用来暂停协程,等待某个操作完成后再继续执行。
任务(Task)是对协程的封装,把协程包装成一个可被事件循环调度的任务。使用 asyncio.create_task() 可以创建任务,让协程开始并发执行,而不是等待它完成。
我们再通过一个示例来理解任务的调度过程:
import asyncio async def say_after(delay, message): await asyncio.sleep(delay) print(message) async def main(): print("开始") # 直接调用协程,只会创建协程对象,不会执行 coro = say_after(1, "你好") print(f"协程对象: {coro}") # 把协程包装成任务,开始调度执行 task = asyncio.create_task(coro) print(f"任务对象: {task}") # 等待任务完成 await task print(f"任务完成,结果: {task.result()}") asyncio.run(main()) 理解了这三个概念,你就掌握了 Python 异步编程的基本框架。接下来我们来看一些更实用的例子。
实战:并发抓取网页示例
异步编程最常见的应用场景就是并发网络请求,我们来实现一个简单的并发网页抓取示例,对比一下同步和异步的性能差异。
import asyncio import aiohttp import time async def fetch_page(session, url): async with session.get(url) as response: content = await response.text() return f"{url}: 抓取到 {len(content)} 字节内容" async def main(): urls = [ "https://www.example.com", "https://www.python.org", "https://www.github.com", "https://www.baidu.com", ] start_time = time.time() # 创建一个异步 HTTP 会话 async with aiohttp.ClientSession() as session: # 为每个 URL 创建一个抓取任务 tasks = [asyncio.create_task(fetch_page(session, url)) for url in urls] # 等待所有任务完成 results = await asyncio.gather(*tasks) # 输出结果 for result in results: print(result) total_time = time.time() - start_time print(f"
全部抓取完成,总耗时: {total_time:.2f} 秒") if __name__ == "__main__": asyncio.run(main()) 这个例子使用了 aiohttp 库来进行异步 HTTP 请求,如果换成同步的 requests 库一个个抓取,总耗时会是所有请求的总和,而使用异步并发抓取,总耗时只略大于最慢的那个请求,大大提升了抓取效率。
需要注意的是,并发抓取并不是无限制的,如果你同时创建上千个任务,可能会对服务器造成过大压力,也可能导致网络阻塞。实际应用中,我们通常会使用信号量来控制并发数量:
async def bounded_fetch(session, url, semaphore): # 使用信号量限制并发数量 async with semaphore: return await fetch_page(session, url) async def main(): urls = [...] # 大量 URL 列表 semaphore = asyncio.Semaphore(10) # 最多允许 10 个并发请求 async with aiohttp.ClientSession() as session: tasks = [bounded_fetch(session, url, semaphore) for url in urls] results = await asyncio.gather(*tasks)
异步编程常见坑点与解决方法
虽然 async/await 语法让异步代码变得更加清晰,但是异步编程仍然有一些容易踩坑的地方,我们来总结几个常见的问题:
1. 忘记给 I/O 操作使用异步版本
最常见的错误就是在异步代码中使用了同步的 I/O 操作,比如在协程中调用 time.sleep() 而不是 asyncio.sleep(),或者使用 requests 库而不是 aiohttp。这样会阻塞整个事件循环,导致所有其他任务都无法执行,异步就变成了同步。
解决方法:在异步代码中,所有可能会阻塞的 I/O 操作都必须使用异步版本。
2. 没有使用 create_task 导致没有并发
很多初学者写出这样的代码:
await download_file("file1") await download_file("file2") 这种写法虽然用了 async/await,但是并没有实现并发,总耗时还是累加的。如果需要并发执行,一定要把协程包装成任务再 await。
解决方法:需要并发执行时,先用 asyncio.create_task() 创建任务,然后再 await 所有任务。
3. 异常处理
异步代码的异常处理和同步代码类似,使用 try/except 即可:
async def main(): try: result = await some_async_task() except SomeError as e: print(f"处理错误: {e}") 当使用 asyncio.gather() 时,如果其中一个任务抛出异常,gather 会默认抛出这个异常,其他未完成的任务仍然会继续执行,但是不会返回结果。如果你希望不管是否出错都返回所有任务的结果,可以设置 return_exceptions=True 参数:
results = await asyncio.gather(*tasks, return_exceptions=True)
异步编程适合什么场景?
最后我们来谈谈异步编程的适用场景。异步编程天生适合I/O 密集型任务,比如:
- 网络爬虫、API 调用
- 数据库异步读写
- WebSocket 服务
- 大量文件 IO 操作
对于CPU 密集型任务,异步编程并没有优势,因为 CPU 密集型任务会一直占着 CPU,无法靠暂停让出资源。这种情况下,使用多进程处理会更加合适。当然,你也可以结合使用:多进程利用多核 CPU,每个进程内部使用异步处理 I/O。
总结
本文从同步编程的痛点讲起,介绍了 Python 异步编程的发展历程,讲解了事件循环、协程、任务等核心概念,通过多个原创示例演示了如何使用 async/await 编写高效的异步代码,还总结了常见坑点与解决方法,最后分析了异步编程的适用场景。
异步编程并不是银弹,但是掌握它可以让你在处理 I/O 密集型任务时多一件强大的武器,写出性能更高、资源利用率更好的程序。希望本文能帮助你入门 Python 异步编程,开启高效编程之旅。
Python,异步编程,asyncio,async/await,并发编程