🔎大家好,我是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_task和await即可,很少需要直接操作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将阻塞操作移出了事件循环所在的主线程,避免了阻塞。这对于集成旧的同步库非常有用。
第四部分:最佳实践与陷阱
-
不要阻塞事件循环 : 这是最重要的原则!永远不要在协程中调用像
time.sleep(),requests.get(),open().read()这样的阻塞函数。使用对应的异步版本(asyncio.sleep(),aiohttp,aiofiles)。 -
使用
asyncio.run(): 这是运行顶层异步程序的唯一正确方式。不要手动创建和管理事件循环,除非你非常清楚自己在做什么。 -
善用
gather和create_task:gather用于并发等待多个协程;create_task用于在后台启动一个任务。 -
处理异常 : 异步代码中的异常也需要被捕获。使用
try...except包裹await语句,或者使用return_exceptions=True。 -
超时 : 总是为网络请求设置超时,防止任务无限期挂起。
pythonasync with asyncio.timeout(5): # Python 3.11+ result = await some_async_function() -
理解并发与并行: 协程是并发(concurrent),不是并行(parallel)。它们在单线程内交替执行,适用于I/O密集型任务。对于CPU密集型任务,仍需使用多进程。
结语
async/await 彻底改变了Python处理高并发I/O的方式。它让你能够用简洁、直观的代码,实现远超多线程的吞吐量。
通过本文的学习,你应该已经掌握了:
async/await的基本语法和工作原理。- 事件循环、任务、协程对象的核心概念。
- 如何使用
aiohttp构建高性能的异步爬虫和API客户端。 - 如何安全地在异步环境中调用同步代码。
