52·模块十一:并发与并行高级

异步编程:asyncio 基础与 async/await

asyncioasynchronouscoroutines

第52课:异步编程:asyncio基础与async/await

学习目标

  • 理解协程(Coroutines)的基本概念,以及它与普通函数的区别。
  • 掌握使用 asyncawait 关键字定义和调用协程的语法。
  • 学会使用 asyncio.run() 启动并运行一个简单的协程程序。
  • 能够区分同步(阻塞)和异步(非阻塞)代码,并编写基本的异步代码。
  • 理解事件循环(Event Loop)在 asyncio 中的基本作用。

核心概念

1. 为什么需要异步编程?

想象你在煮一壶咖啡。同步的做法是:你站在水壶旁,盯着水烧开(可能需要5分钟),什么都不做。水开了之后,你再去磨豆子、冲泡。 异步的做法是:你按下水壶的开关后,转身去磨豆子、准备杯子。水壶发出"呜呜"的提示音("我完成了!")时,你再转身来处理热水。这期间,你并没有一直空闲等待。

在编程中,I/O操作(如网络请求、读写文件、查询数据库)就像"等待水烧开"的过程。传统的同步代码会让程序在等待时"卡住"(阻塞),无法执行其他任务。而异步编程允许程序在等待I/O时,转而去执行其他任务,从而提高程序的效率,特别是在I/O密集型应用(如Web服务器、爬虫)中。

2. 什么是协程?

协程是 asyncio 中实现异步的核心。你可以把它理解为一个特殊的函数,它能够在执行到某个点(如等待I/O)时暂停,并将控制权交还给事件循环,让程序去处理其他协程。当之前的I/O操作完成时,它会从暂停的地方恢复执行。

  • 定义:使用 async def 关键字定义。
  • 调用:调用一个协程函数不会立即执行它的代码,而是返回一个协程对象
  • 运行:必须通过 asyncio.run()await 来实际执行它。

3. asyncawait 关键字

  • async def:用于定义一个协程函数。
  • await:用于在协程中暂停当前协程的执行,去等待另一个可等待对象(如另一个协程、Future)的完成。只有在协程函数内部才能使用 await

代码示例

示例1:从同步到异步

import asyncio
import time

# === 同步版本 ===
def make_coffee_sync():
    print("开始烧水...")
    time.sleep(2)  # 模拟耗时2秒的阻塞I/O操作(如水在加热)
    print("水烧开了。")
    print("开始磨豆子...")
    time.sleep(1)  # 模拟耗时1秒的阻塞操作
    print("咖啡豆磨好了。")
    print("冲泡咖啡...")
    time.sleep(1)
    print("咖啡准备完成!")

# === 异步版本 ===
async def make_coffee_async():
    print("开始烧水...")
    await asyncio.sleep(2)  # 使用asyncio.sleep模拟非阻塞的I/O等待
    print("水烧开了。")
    print("开始磨豆子...")
    await asyncio.sleep(1)
    print("咖啡豆磨好了。")
    print("冲泡咖啡...")
    await asyncio.sleep(1)
    print("咖啡准备完成!")

if __name__ == "__main__":
    # 运行同步版本
    print("--- 运行同步程序 ---")
    start = time.time()
    make_coffee_sync()
    end = time.time()
    print(f"同步总耗时: {end - start:.2f}秒\n")

    # 运行异步版本
    print("--- 运行异步程序 ---")
    start = time.time()
    asyncio.run(make_coffee_async())  # 启动事件循环,运行协程
    end = time.time()
    print(f"异步总耗时: {end - start:.2f}秒")

运行结果分析: 同步版本的总耗时是各步骤之和(2+1+1=4秒)。虽然这个简单的异步示例看起来耗时相同,但关键在于 await asyncio.sleep() 在等待时不会阻塞整个程序。如果在 await 期间有其他协程需要运行,事件循环可以去执行它们。

示例2:并发运行多个协程

import asyncio

async def fetch_data(url, delay):
    """模拟一个耗时的数据请求"""
    print(f"开始请求 {url}...")
    await asyncio.sleep(delay)  # 模拟网络延迟
    print(f"完成请求 {url},耗时 {delay} 秒")
    return f"{url} 的数据"

async def main():
    # 创建协程对象(注意:此时还没有执行)
    task1 = fetch_data("网站A", 2)
    task2 = fetch_data("网站B", 1)
    task3 = fetch_data("网站C", 3)

    # 使用 asyncio.gather() 并发运行多个协程,并收集它们的结果
    results = await asyncio.gather(task1, task2, task3)
    print(f"\n所有结果: {results}")

if __name__ == "__main__":
    asyncio.run(main())

输出示例

开始请求 网站A...
开始请求 网站B...
开始请求 网站C...
完成请求 网站B,耗时 1 秒
完成请求 网站A,耗时 2 秒
完成请求 网站C,耗时 3 秒

所有结果: ['网站A 的数据', '网站B 的数据', '网站C 的数据']

关键点

  1. await asyncio.gather(task1, task2, task3) 会并发(同时)启动这三个协程。
  2. 事件循环在某个协程 await 时,会去执行其他已就绪的协程。
  3. 整个程序的总耗时取决于最慢的协程(这里是3秒),而不是所有协程时间的总和(2+1+3=6秒)。这就是异步并发带来的效率提升。

实践练习

练习1:基础协程定义

编写一个协程函数 countdown(n),它从 n 倒数到1,每秒打印一次数字(使用 await asyncio.sleep(1)),最后打印"发射!"。

练习2:顺序与并发对比

编写两个协程:

  • task_a(): 模拟一个耗时2秒的任务,完成后返回 'A完成'
  • task_b(): 模拟一个耗时1秒的任务,完成后返回 'B完成'。 编写主协程 main(),分别用以下两种方式运行它们:
  1. 顺序执行(先 await task_a(),再 await task_b())。
  2. 并发执行(使用 asyncio.gather())。 观察并打印两种方式的总耗时和结果顺序。

练习3:模拟并发下载

编写一个协程函数 download(file_name, duration),模拟下载一个文件,duration 秒后打印下载完成。 创建一个包含3个不同文件名和时长(例如2秒,3秒,1秒)的协程列表,使用 asyncio.gather() 并发下载它们,并打印所有任务完成后的结束信息。

常见错误

  1. 忘记 await

    async def main():
        asyncio.sleep(1)  # 错误!只是创建了一个协程对象,没有等待它执行。
        await asyncio.sleep(1) # 正确
    

    结果:程序可能会立即结束,看起来什么都没做,或者抛出"coroutine ... was never awaited"的警告。

  2. 在普通函数中使用 await

    def normal_func():
        await asyncio.sleep(1)  # 语法错误!await只能用在async def函数内部。
    
  3. 混淆协程函数和协程对象

    async def my_coroutine():
        await asyncio.sleep(1)
    
    # 错误示例:直接在非async函数中调用
    # my_coroutine()  # 这会返回一个协程对象,但不会执行,且可能丢失。
    
    # 正确做法:在async函数中await,或在顶层用asyncio.run()
    asyncio.run(my_coroutine())
    
  4. 阻塞事件循环:在协程中直接调用耗时的同步阻塞代码(如 time.sleep(), 大型计算循环),这会阻塞整个事件循环,失去异步的意义。应使用对应的异步版本(asyncio.sleep())或使用 loop.run_in_executor() 将阻塞操作放到线程/进程池中。

小结

  • 协程是使用 async def 定义的函数,调用后返回协程对象。
  • await 关键字用于暂停当前协程,等待一个可等待对象完成,期间事件循环可以执行其他任务。
  • asyncio.run() 是启动异步程序的主要入口点,它会创建并管理一个事件循环。
  • 异步编程的核心优势在于处理I/O等待时能实现并发,提高程序整体效率。
  • 简单的并发执行可以通过 asyncio.gather() 来实现。
  • 要避免在协程中使用阻塞的同步代码,否则会拖累整个程序的性能。

理解了这些 asyncio 的基础,你就迈入了异步编程的大门。下一课,我们将深入学习**任务(Tasks)**的概念,它是对协程的进一步封装,提供了更多的控制能力,如取消、状态查询等。

练习编辑器

python
Loading...

继续学习

完成本课后,建议继续学习下一课「异步编程:任务(Tasks)与事件循环」 以巩固所学知识。