1. 问题背景:一个被GIL锁死的报表服务
我们有个内部订单统计API,接收一组店铺ID,返回对应的销售聚合数据。代码逻辑很简单:每个店铺ID需要做三次独立的计算——订单量、销售额、退款额。最初用Flask + 线程池实现,每个请求内用ThreadPoolExecutor并行处理多个店铺ID。
压测结果惨不忍睹:200并发下,QPS只有120,P99延迟520ms,CPU利用率卡在60%上不去。用py-spy dump看了下线程栈,发现大量线程阻塞在threading.Lock上——典型的GIL竞争。计算逻辑本身是纯Python,循环里全是数字运算,多线程不仅没加速,反而因为锁竞争拖慢了整体吞吐。
2. 环境与版本
改造前后的关键依赖:
Python 3.10.8
Flask 2.2.2
gunicorn 20.1.0 (worker_class=sync)
aiohttp 3.8.3
uvloop 0.17.0
服务器配置:4核8G的云主机,MySQL 8.0。压测工具用wrk,参数wrk -t8 -c200 -d30s --latency。
3. 方案设计:事件循环 + 协程 + 信号量限流
核心思路:把每个店铺ID的计算逻辑改造成异步协程,用asyncio.gather并发执行。整个请求处理流程变成单线程事件循环,彻底避开GIL竞争。
整体架构:
gunicorn (同步worker) → aiohttp独立服务 → asyncio事件循环
为什么不直接在Flask里跑asyncio?Flask是WSGI同步框架,在同步视图里混用asyncio需要手动管理事件循环生命周期,容易出幺蛾子。更干净的方案:拆成两个服务——Flask只做路由和参数校验,真正的计算逻辑丢给aiohttp异步服务。
4. 核心实现:before/after代码对比
Before:线程池方案(有问题)
# before_async.py - 原有多线程实现
from concurrent.futures import ThreadPoolExecutor
from flask import Flask, request, jsonify
app = Flask(__name__)
executor = ThreadPoolExecutor(max_workers=16)
def heavy_compute(shop_id):
"""模拟纯CPU密集计算:处理大量订单记录"""
total = 0
# 模拟读取并聚合5000条订单数据
for i in range(5000):
for j in range(100):
total += (shop_id * i * j) % 9973
return total
@app.route('/api/report', methods=['POST'])
def report():
shop_ids = request.json['shop_ids']
# 每个店铺ID提交到线程池并行计算
futures = [executor.submit(heavy_compute, sid) for sid in shop_ids]
results = [f.result() for f in futures]
return jsonify({'results': results})
After:asyncio协程方案(改造后)
# after_async.py - asyncio改造后
import asyncio
import uvloop
from aiohttp import web
# 启用uvloop替代默认事件循环
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
# 信号量限制最大并发数,防止打满CPU
semaphore = asyncio.Semaphore(64)
async def heavy_compute(shop_id):
"""异步版本计算逻辑,遇到IO自动让出"""
total = 0
# 模拟数据库IO读取(异步)
await asyncio.sleep(0.001)
# 纯计算部分仍然是阻塞的,但通过协程切换避免长时间独占
for i in range(5000):
for j in range(100):
total += (shop_id * i * j) % 9973
# 每1000次迭代让出事件循环,避免饿死其他协程
if i % 10 == 0:
await asyncio.sleep(0)
return total
async def handle_report(request):
data = await request.json()
shop_ids = data['shop_ids']
async with semaphore:
# 并发执行所有店铺的计算
tasks = [heavy_compute(sid) for sid in shop_ids]
results = await asyncio.gather(*tasks)
return web.json_response({'results': results})
app = web.Application()
app.router.add_post('/api/report', handle_report)
if __name__ == '__main__':
web.run_app(app, host='0.0.0.0', port=8080, access_log=None)
注意heavy_compute里的await asyncio.sleep(0)——这是关键。纯计算协程如果不主动让出,事件循环会被阻塞,其他协程全部饿死。这个显式让出机制是异步改造的必修课。
5. 踩坑与优化
坑1:await asyncio.sleep(0) 的粒度问题
一开始每5000次迭代才让出一次,结果P99延迟反而变高了。原因:协程切换有开销,太频繁切换导致上下文切换成本超过收益。实测每1000次迭代让出一次效果最好,QPS提升约15%。
坑2:信号量必须放在gather外面
最初把Semaphore放在gather内部,导致所有协程同时获取信号量,限流形同虚设。正确做法:用async with semaphore包裹gather调用,确保整个批次受控。
坑3:uvloop和aiohttp的兼容性
uvloop 0.17.0搭配aiohttp 3.8.3没问题,但aiohttp 4.0开始移除了对uvloop的隐式支持,需要手动调用loop.run_until_complete。建议锁定版本。
优化:混合计算与IO
纯计算部分可以丢给loop.run_in_executor,让线程池处理CPU密集部分,事件循环只负责调度。实测混合模式(异步IO + 线程计算)在4核机器上比纯协程快20%左右。
6. 效果数据
压测结果(wrk -t8 -c200 -d30s):
| 指标 | Before (线程池) | After (asyncio) | 提升 |
|---|---|---|---|
| QPS | 120 | 850 | 608% |
| 平均延迟 | 380ms | 120ms | 68% |
| P99延迟 | 520ms | 180ms | 65% |
| CPU利用率 | 60% | 95% | +35% |
吞吐量提升了7倍多,延迟降了三分之二。最关键的是CPU利用率从60%升到95%——说明瓶颈真正被解除了。
7. 总结
这次改造最深的体会:异步不等于万能,但用对场景收益巨大。纯计算密集任务在asyncio下如果处理不当(比如不让出事件循环),可能比同步代码更慢。核心要点:
- 协程内必须显式
await asyncio.sleep(0)让出控制权,但粒度要调优 - 用
asyncio.Semaphore做限流,但注意作用域范围 - uvloop能显著提升事件循环性能,但注意版本兼容
- 纯计算和IO混合场景,考虑
run_in_executor混合调度
最后补充一句:这套方案只适合计算密集型的短任务。如果任务本身要跑几秒,协程让出频率要大幅降低,否则CPU空转严重。异步方案需要针对业务场景反复调参,没有银弹。