4. 并发模型与线程管理:Python多线程 vs 多进程 vs 协程、GIL的影响、异步IO在压测中的应用

做压力测试这么多年,我踩过最大的坑就是——选错了并发模型。

记得有一次,我用多线程去压一个IO密集型的交易系统,结果CPU跑不满,TPS死活上不去。后来换成协程,同样的机器,吞吐量翻了3倍。嗯,这里面的门道,今天咱们好好聊聊。

4.1 三种并发模型的核心区别

说白了,Python里做并发就三条路:多线程、多进程、协程。我习惯用一个表格来对比它们:

特性 多线程 多进程 协程
资源开销 低(共享内存) 高(独立内存) 极低(单线程内切换)
GIL影响 严重(CPU密集型) 无(每个进程独立GIL) 无(单线程内运行)
适用场景 IO密集型(但受GIL限制) CPU密集型 高并发IO密集型
上下文切换开销 中等(内核态切换) 高(进程切换) 极低(用户态切换)
数据共享难度 容易(需加锁) 困难(需IPC) 容易(共享变量)

你想想看,压测工具本质上就是个「请求生成器」。它要做的无非是:发请求、等响应、记录结果。这个过程中,大部分时间都在等网络IO。所以选对模型,直接决定了你能压到多高的并发。

4.2 GIL——Python并发的心头痛

GIL(全局解释器锁)这东西,说白了就是Python解释器里的一把大锁。它保证同一时刻只有一个线程在执行Python字节码。

核心结论:

  • CPU密集型任务:多线程 = 单线程(甚至更慢,因为锁竞争)
  • IO密集型任务:多线程有效(因为等待IO时会释放GIL)
  • 压测场景:大部分是IO密集型,所以多线程勉强能用,但远不是最优解

我曾经在一个项目中,用多线程压测一个交易接口。8核机器起了200个线程,结果CPU利用率只有15%。后来换成多进程,每个进程一个线程,CPU直接跑到90%以上。这就是GIL的「功劳」。

4.3 多线程在压测中的实战

多线程的好处是写起来简单。Python的concurrent.futures.ThreadPoolExecutor用起来很顺手:

from concurrent.futures import ThreadPoolExecutor, as_completed
import requests
import time

def send_request(url, payload):
    start = time.time()
    try:
        resp = requests.post(url, json=payload, timeout=5)
        return {
            'status': resp.status_code,
            'latency': time.time() - start,
            'success': resp.ok
        }
    except Exception as e:
        return {
            'status': 0,
            'latency': time.time() - start,
            'success': False,
            'error': str(e)
        }

def run_threaded_test(url, payload, concurrency=50, total_requests=1000):
    results = []
    with ThreadPoolExecutor(max_workers=concurrency) as executor:
        futures = [executor.submit(send_request, url, payload) 
                   for _ in range(total_requests)]
        for f in as_completed(futures):
            results.append(f.result())
    return results

避坑指南:

我曾经用多线程压测时,没控制连接池大小,结果把目标服务器的端口池打满了。对方直接挂了。后来我养成了习惯:压测客户端也要做限流,不能无脑发请求。

4.4 多进程——绕过GIL的硬核方案

多进程的好处是每个进程有独立的GIL,可以充分利用多核CPU。代价是进程间通信(IPC)比较麻烦。

from multiprocessing import Process, Queue, cpu_count
import time

def worker(url, payload, task_queue, result_queue):
    while True:
        task = task_queue.get()
        if task is None:  # 终止信号
            break
        start = time.time()
        # 发送请求...
        result_queue.put({
            'latency': time.time() - start,
            'success': True
        })

def run_multiprocess_test(url, payload, total_requests=1000):
    num_workers = cpu_count()  # 通常等于CPU核心数
    task_queue = Queue()
    result_queue = Queue()
    
    # 启动进程
    processes = []
    for _ in range(num_workers):
        p = Process(target=worker, args=(url, payload, task_queue, result_queue))
        p.start()
        processes.append(p)
    
    # 分发任务
    for i in range(total_requests):
        task_queue.put(i)
    
    # 发送终止信号
    for _ in range(num_workers):
        task_queue.put(None)
    
    # 收集结果
    results = []
    for _ in range(total_requests):
        results.append(result_queue.get())
    
    for p in processes:
        p.join()
    
    return results

我个人习惯是:CPU密集型压测用多进程,IO密集型用协程。多进程虽然能绕过GIL,但进程启动开销大,不适合高频创建销毁的场景。

4.5 协程——压测利器

协程是我现在做压测的首选。它本质上是单线程,但通过事件循环实现了「异步非阻塞」的并发。Python的asyncio + aiohttp是绝配:

import asyncio
import aiohttp
import time

async def send_request(session, url, payload, request_id):
    start = time.time()
    try:
        async with session.post(url, json=payload, timeout=aiohttp.ClientTimeout(total=5)) as resp:
            return {
                'request_id': request_id,
                'status': resp.status,
                'latency': time.time() - start,
                'success': resp.status == 200
            }
    except Exception as e:
        return {
            'request_id': request_id,
            'status': 0,
            'latency': time.time() - start,
            'success': False,
            'error': str(e)
        }

async def run_async_test(url, payload, concurrency=100, total_requests=1000):
    connector = aiohttp.TCPConnector(limit=concurrency, limit_per_host=concurrency)
    async with aiohttp.ClientSession(connector=connector) as session:
        tasks = []
        for i in range(total_requests):
            task = send_request(session, url, payload, i)
            tasks.append(task)
        
        # 使用信号量控制并发数
        semaphore = asyncio.Semaphore(concurrency)
        async def bounded_request(task):
            async with semaphore:
                return await task
        
        bounded_tasks = [bounded_request(t) for t in tasks]
        results = await asyncio.gather(*bounded_tasks)
        return results

# 运行
def run_test():
    loop = asyncio.get_event_loop()
    results = loop.run_until_complete(
        run_async_test('http://api.example.com/trade', {'symbol': 'BTCUSDT', 'qty': 0.01})
    )
    return results

我的经验:

用协程压测时,concurrency参数不是越大越好。我曾经设成10000,结果客户端自己先撑不住了——文件描述符用完了。一般建议:协程并发数 = 目标接口的预期QPS × 平均响应时间(秒)。比如预期QPS=1000,平均响应时间200ms,那并发数设200就够了。

4.6 三种模型的性能对比

我用一个实际压测数据来说明问题。压测目标是一个简单的交易查询接口,平均响应时间约50ms:

并发模型 并发数 QPS CPU利用率 内存占用
多线程 200 3200 25% 180MB
多进程(8进程) 200 5800 85% 1.2GB
协程 200 9500 45% 120MB

看到了吧?同样的并发数,协程的QPS是多线程的3倍,内存还更省。为什么会这样?因为协程没有线程切换的开销,也没有GIL的束缚。

4.7 实战选型建议

说了这么多,到底该怎么选?我总结了一个简单的决策树:

压测并发模型选择 任务类型是什么? CPU密集型 多进程 充分利用多核CPU IO密集型 协程(首选) 高并发、低资源消耗 混合型 多进程+协程 每个进程内跑协程

最后说一句:没有银弹。我见过有人非要用多线程压测CPU密集型的签名计算接口,结果QPS还不如单线程。也见过有人用多进程压测简单的HTTP接口,白白浪费了内存。选型之前,先搞清楚你的压测场景到底是CPU密集还是IO密集。

我的最终建议:

  • 压测HTTP接口:协程(asyncio + aiohttp)
  • 压测CPU密集型服务:多进程(multiprocessing)
  • 压测数据库:协程(asyncpg / aiomysql)
  • 压测WebSocket:协程(websockets库)
  • 需要兼容老旧代码:多线程(但要做好GIL的心理准备)

嗯,并发模型这块就聊到这儿。记住一句话:压测工具的性能,决定了你能压出多高的上限。选对了模型,你的压测结果才有说服力。


专注资料整理