驾驭高并发:Python协程与 async/await 完全解析

🔎大家好,我是ZTLJQ,希望你看完之后,能对你有所帮助,不足请指正!共同学习交流

📝个人主页-ZTLJQ的主页

🎁欢迎各位→点赞👍 + 收藏⭐️ + 留言📝​📣系列果你对这个系列感兴趣的话

专栏 - ​​​​​​Python从零到企业级应用:短时间成为市场抢手的程序员

✔说明⇢本人讲解主要包括Python爬虫、JS逆向、Python的企业级应用

如果你对这个系列感兴趣的话,可以关注订阅哟👋

当"等待"成为性能瓶颈

想象一下,你正在编写一个程序,需要从10个不同的网站获取数据。如果使用传统的同步方式:

python 复制代码
import requests
import time

def fetch_url(url):
    print(f"开始请求 {url}")
    response = requests.get(url) # 这里会阻塞,等待服务器响应
    print(f"完成请求 {url}")
    return response.text

start_time = time.time()

urls = [
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    "https://httpbin.org/delay/1",
    # ... 假设还有7个
]

# 同步串行执行
for url in urls:
    result = fetch_url(url)
    # 处理结果...

end_time = time.time()
print(f"同步方式总耗时: {end_time - start_time:.2f} 秒") # 输出约 10秒

这个程序的效率极低。大部分时间都花在了等待网络I/O上,而不是执行代码。CPU在这期间几乎是空闲的。

如何解决?多线程或多进程可以,但它们有显著的缺点:

  • 资源开销大: 创建和切换线程/进程成本高。
  • 复杂性高: 需要处理锁、竞态条件等并发问题。
  • GIL限制: CPython的全局解释器锁使得多线程在CPU密集型任务上无法真正并行。

协程(Coroutine) 提供了一种更轻量级、更高效的解决方案。它是一种单线程内的并发模型,通过协作式多任务处理,在遇到I/O阻塞时主动"让出"控制权,去执行其他任务,从而实现高并发。

本篇博客将带你彻底理解async/await语法,掌握asyncio事件循环,并通过真实的网络请求案例展示其强大威力。


第一部分:核心概念与 async/await 语法
1.1 协程是什么?

协程本质上是一个可以被暂停和恢复执行的函数。它不是操作系统层面的线程,而是由程序员和事件循环管理的"微线程"。

  • async def : 用于定义一个协程函数 (coroutine function) 。调用一个协程函数并不会立即执行其内部代码,而是返回一个协程对象 (coroutine object)
  • await : 用于"等待"一个可等待对象 (awaitable) 。最常见的可等待对象就是协程对象。await 表达式会暂停当前协程的执行,直到被等待的对象完成,然后恢复执行。

关键区别:

  • 同步函数: 执行到某一行时,必须等到该行完全执行完毕(比如I/O完成),才会执行下一行。
  • 异步函数 (async def) : 执行到 await 表达式时,如果该表达式代表一个耗时的I/O操作(如网络请求),函数会暂停 ,并将控制权交还给事件循环。事件循环可以立即去执行其他就绪的任务。当I/O操作完成后,事件循环会回来恢复这个协程的执行。
1.2 第一个异步程序

让我们用async/await重写上面的例子:

python 复制代码
import asyncio
import aiohttp # 一个支持异步的HTTP客户端库

async def fetch_url_async(session, url):
    """一个异步的URL获取函数"""
    print(f"开始请求 {url}")
    # 使用 aiohttp 的 session.get 发起异步请求
    # async with 用于异步上下文管理器
    async with session.get(url) as response:
        # await 直到响应返回
        text = await response.text()
        print(f"完成请求 {url}")
        return text

async def main():
    """主协程函数"""
    # 创建一个 aiohttp 的 ClientSession,用于管理连接
    async with aiohttp.ClientSession() as session:
        urls = [
            "https://httpbin.org/delay/1",
            "https://httpbin.org/delay/1",
            "https://httpbin.org/delay/1",
        ]
        
        # 方法一:创建多个协程任务并并发执行
        tasks = []
        for url in urls:
            # fetch_url_async(...) 返回的是一个协程对象
            # asyncio.create_task() 将其包装成一个Task对象并安排到事件循环中
            task = asyncio.create_task(fetch_url_async(session, url))
            tasks.append(task)
        
        # await asyncio.gather(*tasks) 会并发地等待所有任务完成
        # 并返回所有结果的列表
        results = await asyncio.gather(*tasks)
        print(f"所有请求完成,共获取 {len(results)} 个结果")

# 运行异步程序
if __name__ == "__main__":
    start_time = time.time()
    
    # asyncio.run() 是运行顶层异步程序的推荐方式
    # 它会自动创建事件循环,运行 main() 协程,并在结束后关闭循环
    asyncio.run(main())
    
    end_time = time.time()
    print(f"异步方式总耗时: {end_time - start_time:.2f} 秒") # 输出约 1秒

输出解析:

python 复制代码
开始请求 https://httpbin.org/delay/1
开始请求 https://httpbin.org/delay/1
开始请求 https://httpbin.org/delay/1
完成请求 https://httpbin.org/delay/1
完成请求 https://httpbin.org/delay/1
完成请求 https://httpbin.org/delay/1
所有请求完成,共获取 3 个结果
异步方式总耗时: 1.05 秒

耗时从约3秒降到了约1秒!因为三个请求是同时发起的。

第二部分:asyncio 核心组件详解
2.1 事件循环 (Event Loop)

事件循环是整个异步系统的"心脏"和"调度中心"。你可以把它想象成一个永不结束的while循环,它不断地检查:

  • 哪些I/O操作已经准备就绪(例如,某个网络请求收到了数据)?
  • 哪些任务已经准备好恢复执行?

一旦发现有任务可以执行,事件循环就会调用相应的回调或恢复协程。

  • asyncio.run(coro): 最高级别的入口点,用于运行主协程并管理事件循环的生命周期。
  • loop = asyncio.get_event_loop() : 获取当前线程的事件循环(在asyncio.run内部通常不需要手动获取)。
2.2 任务 (Task)

Task 是对协程的进一步封装,它被调度到事件循环中执行。

  • asyncio.create_task(coro) : 推荐方式。将一个协程包装成Task并立即安排其执行。
  • asyncio.ensure_future(coro_or_future): 较老的方式,功能类似,但语义更宽泛。
python 复制代码
async def my_coro(name):
    print(f"{name} 开始")
    await asyncio.sleep(1) # 模拟异步I/O
    print(f"{name} 结束")

async def main():
    # 创建两个任务,并发执行
    task_a = asyncio.create_task(my_coro("A"))
    task_b = asyncio.create_task(my_coro("B"))
    
    # 主协程等待两个任务完成
    await task_a
    await task_b
    # 或者用 asyncio.gather(task_a, task_b)

# asyncio.run(main())
2.3 Future 对象

Future 是一个低级别的"承诺"(Promise),表示一个尚未完成的计算结果。Task 继承自 Future

  • 当一个I/O操作启动时,会返回一个Future对象。
  • 当操作完成时,事件循环会设置Future的结果,并将其标记为"已完成"。
  • await 一个 Future 就是在等待它完成。

注意 :对于日常开发,直接使用create_taskawait即可,很少需要直接操作Future

第三部分:实战案例
案例一:高性能网页爬虫

这是异步编程最典型的应用场景。

python 复制代码
import asyncio
import aiohttp
import time
from urllib.parse import urljoin, urlparse

async def fetch_page(session, url, timeout=10):
    """获取单个页面内容"""
    try:
        async with session.get(url, timeout=timeout) as response:
            if response.status == 200:
                content = await response.text()
                print(f"成功抓取: {url}")
                return content
            else:
                print(f"抓取失败 ({response.status}): {url}")
                return None
    except Exception as e:
        print(f"抓取异常 ({e}): {url}")
        return None

async def crawl_website(base_url, max_concurrent=10):
    """
    爬取一个网站的所有页面。
    Args:
        base_url: 起始URL
        max_concurrent: 最大并发请求数
    """
    # 使用信号量(Semaphore)来限制并发数,避免对目标服务器造成过大压力
    semaphore = asyncio.Semaphore(max_concurrent)
    
    # 存储已访问和待访问的URL
    visited_urls = set()
    to_visit = {base_url}
    
    # 创建一个共享的ClientSession
    async with aiohttp.ClientSession() as session:
        while to_visit:
            # 获取一批待访问的URL
            current_batch = list(to_visit)
            to_visit.clear()
            
            # 为当前批次的每个URL创建一个任务
            tasks = []
            for url in current_batch:
                if url not in visited_urls:
                    visited_urls.add(url)
                    
                    # 包装任务,使用信号量控制并发
                    async def bounded_fetch(url):
                        async with semaphore: # 进入信号量,获取许可
                            return await fetch_page(session, url)
                    
                    task = asyncio.create_task(bounded_fetch(url))
                    tasks.append(task)
            
            # 并发执行当前批次的所有任务
            if tasks:
                # gather 会等待所有任务完成,并返回结果列表
                results = await asyncio.gather(*tasks, return_exceptions=True)
                
                # 分析结果,提取新的链接
                for url, result in zip(current_batch, results):
                    if isinstance(result, str): # 成功获取内容
                        # 这里应该解析HTML,提取所有链接
                        # 为了简化,我们只模拟添加一些新链接
                        # new_links = parse_html_for_links(result)
                        new_links = [
                            urljoin(base_url, f"page{i}.html") for i in range(1, 4)
                        ]
                        to_visit.update(new_links)
        
        print(f"爬取完成,共访问 {len(visited_urls)} 个页面。")

# 运行爬虫
if __name__ == "__main__":
    start_time = time.time()
    # 注意:这里只是一个演示,实际的httpbin不会这样链接
    asyncio.run(crawl_website("https://httpbin.org/", max_concurrent=5))
    print(f"总耗时: {time.time() - start_time:.2f} 秒")

解析:

  • 使用 aiohttp.ClientSession 复用TCP连接,提高效率。
  • 使用 asyncio.Semaphore 限制最大并发请求数,这是一种良好的网络公民行为。
  • asyncio.gather(*tasks, return_exceptions=True) 可以确保即使某个任务失败,其他任务也能继续执行,并且错误会作为结果的一部分返回,而不是中断整个程序。
案例二:并发调用多个API
python 复制代码
import asyncio
import aiohttp
import json

async def fetch_user_data(session, user_id):
    """从用户API获取数据"""
    url = f"https://jsonplaceholder.typicode.com/users/{user_id}"
    async with session.get(url) as response:
        data = await response.json()
        return data['name']

async def fetch_post_count(session, user_id):
    """从帖子API获取某个用户的帖子数量"""
    url = f"https://jsonplaceholder.typicode.com/posts?userId={user_id}"
    async with session.get(url) as response:
        posts = await response.json()
        return len(posts)

async def get_user_info(user_id):
    """聚合一个用户的信息"""
    async with aiohttp.ClientSession() as session:
        # 并发获取用户姓名和帖子数量
        name_task = asyncio.create_task(fetch_user_data(session, user_id))
        post_count_task = asyncio.create_task(fetch_post_count(session, user_id))
        
        # 等待两者都完成
        name = await name_task
        post_count = await post_count_task
        
        return {"id": user_id, "name": name, "post_count": post_count}

async def main():
    user_ids = [1, 2, 3, 4, 5]
    
    # 为每个用户创建一个任务
    tasks = [get_user_info(uid) for uid in user_ids]
    
    # 并发执行所有任务
    results = await asyncio.gather(*tasks)
    
    for info in results:
        print(f"用户 {info['id']}: {info['name']} 发表了 {info['post_count']} 篇帖子")

# asyncio.run(main())

解析:get_user_info 内部,我们并发地调用了两个独立的API,这比串行调用快得多。

案例三:异步与同步代码的混合

有时你需要在异步函数中调用一个没有异步版本的阻塞(同步)函数,比如复杂的科学计算或文件I/O。直接await它会阻塞整个事件循环!

解决方案:使用 loop.run_in_executor() 将其放到一个线程池或进程池中执行。

python 复制代码
import asyncio
import time
from concurrent.futures import ThreadPoolExecutor

# 一个模拟的耗时同步函数
def blocking_io_operation(filename):
    print(f"开始执行耗时的IO操作: {filename}")
    time.sleep(2) # 模拟耗时
    print(f"完成IO操作: {filename}")
    return f"处理后的{filename}"

async def async_wrapper(filename):
    """将同步IO操作包装成异步调用"""
    # 获取当前事件循环
    loop = asyncio.get_running_loop()
    
    # 使用默认的线程池执行器
    # run_in_executor 返回一个 Future
    result = await loop.run_in_executor(None, blocking_io_operation, filename)
    return result

async def main():
    # 并发执行多个耗时的IO操作
    filenames = ["file1.txt", "file2.txt", "file3.txt"]
    tasks = [async_wrapper(fname) for fname in filenames]
    
    results = await asyncio.gather(*tasks)
    print("所有IO操作完成:", results)

# asyncio.run(main())

解析: run_in_executor 将阻塞操作移出了事件循环所在的主线程,避免了阻塞。这对于集成旧的同步库非常有用。

第四部分:最佳实践与陷阱
  1. 不要阻塞事件循环 : 这是最重要的原则!永远不要在协程中调用像 time.sleep(), requests.get(), open().read() 这样的阻塞函数。使用对应的异步版本(asyncio.sleep(), aiohttp, aiofiles)。

  2. 使用 asyncio.run(): 这是运行顶层异步程序的唯一正确方式。不要手动创建和管理事件循环,除非你非常清楚自己在做什么。

  3. 善用 gathercreate_task : gather 用于并发等待多个协程;create_task 用于在后台启动一个任务。

  4. 处理异常 : 异步代码中的异常也需要被捕获。使用 try...except 包裹 await 语句,或者使用 return_exceptions=True

  5. 超时 : 总是为网络请求设置超时,防止任务无限期挂起。

    python 复制代码
    async with asyncio.timeout(5): # Python 3.11+
        result = await some_async_function()
  6. 理解并发与并行: 协程是并发(concurrent),不是并行(parallel)。它们在单线程内交替执行,适用于I/O密集型任务。对于CPU密集型任务,仍需使用多进程。

结语

async/await 彻底改变了Python处理高并发I/O的方式。它让你能够用简洁、直观的代码,实现远超多线程的吞吐量。

通过本文的学习,你应该已经掌握了:

  • async/await 的基本语法和工作原理。
  • 事件循环、任务、协程对象的核心概念。
  • 如何使用 aiohttp 构建高性能的异步爬虫和API客户端。
  • 如何安全地在异步环境中调用同步代码。
相关推荐
百年੭ ᐕ)੭*⁾⁾2 小时前
DataFrame存入mysql以及读取操作
数据库·mysql·numpy·pandas·ipython
²º²²এ松2 小时前
vs code连接ubuntu esp项目
linux·数据库·ubuntu
一勺菠萝丶2 小时前
芋道框架 - API 前缀区分机制
java·linux·python
kcuwu.2 小时前
Python判断及循环
android·java·python
Maverick062 小时前
02-SQL执行计划与优化器:Oracle是怎么决定“该怎么查“的
数据库·sql·oracle·ffmpeg
前进的李工2 小时前
LangChain使用之Model IO(提示词模版之ChatPromptTemplate)
java·前端·人工智能·python·langchain·大模型
浪游东戴河2 小时前
网线简介及分类
运维·服务器·网络
不知名。。。。。。。。2 小时前
仿muduo库实现高并发---请求HttpRequest模块 响应HttpResponse模块
服务器·c++