一、先搞懂:为什么性能测试里要用到协程?

很多人做性能测试时,最头疼的就是怎么模拟真实的大流量——比如双11时100万人同时抢商品、或者直播时10万人同时发弹幕的场景。以前大家要么用多线程,要么用多进程来模拟用户,但这俩方式有个大问题:太耗资源了。多线程每个线程都要占内存、抢CPU,模拟1万个用户可能电脑直接卡成PPT;多进程更狠,每个进程都是独立的,资源浪费更严重。

这时候协程就出来救场了。协程本质上是“轻量级的线程”,它不需要操作系统单独分配资源,完全在用户自己的代码里调度,切换速度比线程快几十倍,还特别省内存。比如用多线程模拟1万用户可能要占几个G内存,用协程可能只需要几百M。

Locust作为现在很火的性能测试工具,核心就是用协程来模拟用户。它把每个测试用户都做成一个独立的协程,1万协程跑起来都很顺畅。但很多人用的时候,会踩协程的坑,不仅测不准结果,甚至测到一半程序崩了。接下来就说最常见的几个坑。

二、常见误区1:把协程当线程用,不注意资源隔离

很多刚用Locust的人,会习惯把多线程的写法套到协程上,最典型的就是用全局变量来存用户数据。比如模拟用户登录后,把用户的Token存在全局变量里,觉得每个协程拿到的是自己的Token,结果跑起来发现Token串了,A用户的操作居然用了B用户的Token。

2.1 错误示例(技术栈:Python 3.10 + Locust 2.15.0)

# 错误代码:用全局变量存用户Token,协程之间互相污染
from locust import HttpUser, task, between
import time

# 全局变量:所有协程共享的Token池
global_tokens = []

class WebUser(HttpUser):
    wait_time = between(1, 3)

    @task
    def login(self):
        # 模拟登录接口,返回用户的Token
        response = self.client.post("/api/login", json={"username": f"user_{time.time()}"})
        # 把返回的Token存到全局变量里
        global_tokens.append(response.json()["token"])

    @task
    def view_goods(self):
        # 随便拿一个全局变量里的Token,根本不知道是哪个用户的
        token = global_tokens[0]
        self.client.get("/api/goods", headers={"Authorization": f"Bearer {token}"})

这个代码跑起来会有什么问题?因为global_tokens是所有协程共享的,每个协程登录后都往里面加Token,然后view_goods任务随便拿第一个Token,相当于所有用户都用同一个Token操作,完全模拟不了真实的多用户场景,测出来的接口性能也是假的。

2.2 正确的资源隔离方式

Locust的HttpUser类里有个很重要的属性:self.environment,或者更简单的,每个用户实例(也就是每个协程)自己的属性是完全隔离的。比如我们可以把Token存在self.token里,每个协程的self.token互不干扰。

正确示例:

# 正确代码:用用户实例的属性存Token,协程之间完全隔离
from locust import HttpUser, task, between
import time

class WebUser(HttpUser):
    wait_time = between(1, 3)

    @task
    def login(self):
        # 模拟登录接口,返回用户的Token
        response = self.client.post("/api/login", json={"username": f"user_{time.time()}"})
        # 把Token存在当前用户实例的属性里,只有这个协程能访问
        self.token = response.json()["token"]

    @task
    def view_goods(self):
        # 只有当前协程能拿到自己的Token,不会串
        self.client.get("/api/goods", headers={"Authorization": f"Bearer {self.token}"})

除了用户属性,Locust还提供了一个更安全的方式:用self.environment.runner.user_classes里的用户实例?不,更准确的是,每个用户实例的生命周期是独立的,从创建到销毁,它的属性只属于自己。另外,如果你需要在用户之间传递数据(比如统计某个操作的总次数),应该用Locust的stats模块,而不是自己写全局变量。

2.3 误区规避总结

  1. 绝对不要用全局变量存单个用户的私有数据(比如Token、用户ID、购物车内容);
  2. 每个用户的私有数据,必须存在用户实例的属性里(也就是self开头的属性);
  3. 如果需要统计全局数据,用Locust官方提供的stats工具,不要自己写全局变量。

三、常见误区2:协程里写“同步阻塞”代码,浪费协程优势

协程的核心优势是“非阻塞”,也就是说,当一个协程在等待接口返回的时候,不会占着CPU不放,而是把CPU让给其他协程。但很多人会在协程里写同步阻塞的代码,导致协程的优势完全发挥不出来,甚至比线程还慢。

什么是同步阻塞代码?比如你在协程里写了一个死循环、或者用time.sleep()之外的方式(比如requests库)发HTTP请求,因为requests库是同步的,发请求的时候会一直等响应,占着CPU不放。

3.1 错误示例(技术栈:Python 3.10 + Locust 2.15.0)

# 错误代码:用同步的requests库发请求,阻塞协程
from locust import HttpUser, task, between
import requests  # 同步的HTTP库,会阻塞协程

class WebUser(HttpUser):
    wait_time = between(1, 3)

    @task
    def view_goods(self):
        # 用requests发请求,会一直等响应,占着CPU
        response = requests.get("https://test.example.com/api/goods")
        # 只有响应返回了,才会执行后面的代码
        print(response.status_code)

这个代码跑起来,协程的优势完全没了。因为requests是同步的,每个协程发请求的时候,CPU就只能干等,没法切换到其他协程。比如你开1000个协程,其实和开1000个线程差不多,资源浪费严重。

3.2 正确的非阻塞代码写法

Locust的HttpUser类里自带了self.client,这个client是基于aiohttp(异步HTTP库)写的,是非阻塞的。也就是说,用self.client发请求的时候,协程会主动让出CPU,等响应返回了再回来继续执行。

正确示例:

# 正确代码:用Locust自带的非阻塞client发请求
from locust import HttpUser, task, between

class WebUser(HttpUser):
    wait_time = between(1, 3)

    @task
    def view_goods(self):
        # 用Locust自带的client发请求,非阻塞,让出CPU
        response = self.client.get("/api/goods")
        # 只有响应返回了,才会执行后面的代码,期间CPU可以跑其他协程
        print(response.status_code)

除了HTTP请求,还有很多其他操作也可能导致协程阻塞,比如文件读写、数据库查询。如果是文件读写,应该用aiofiles(异步文件库);如果是数据库查询,应该用异步数据库驱动(比如aiomysql代替pymysql)。

3.3 误区规避总结

  1. 协程里绝对不能用同步的HTTP库(比如requests),必须用Locust自带的self.client;
  2. 所有可能导致等待的操作(文件读写、数据库、外部服务调用),都要用异步版本的库;
  3. 不要在协程里写死循环、或者长时间占CPU的计算代码,会导致整个协程池卡住。

四、常见误区3:协程数量随便设,不考虑实际场景

很多人跑性能测试的时候,会随便设协程数量,比如“我要测1万用户,就开1万协程”,结果要么测出来的结果不准,要么程序直接崩了。协程数量的设置,是有讲究的,不能随便来。

4.1 错误的协程数量设置

比如你要测的是一个电商网站的商品列表接口,这个接口的平均响应时间是1秒,每个用户看商品列表的间隔是3秒(也就是用户看一次列表,3秒后再看一次)。如果你开1万协程,每个协程每秒发一次请求,那每秒的请求量就是1万次,完全不符合真实场景——真实场景下,1万用户的请求量是1万/(1+3)=2500次每秒,差了4倍,测出来的结果完全没用。

4.2 正确的协程数量设置方法

协程数量的设置,核心是要符合真实的“用户行为”。真实的用户行为有两个关键参数:

  1. 活跃用户数:同一时间在线的用户数量;
  2. 用户思考时间:用户操作之间的间隔(比如看商品列表后,过3秒再刷新);
  3. 接口响应时间:接口返回的时间。

公式:每秒请求量 = 活跃用户数 / (思考时间 + 接口响应时间)

举个例子:

  • 活跃用户数:1万;
  • 思考时间:3秒;
  • 接口响应时间:1秒;
  • 每秒请求量 = 1万 / (3+1) = 2500次。

这时候你要开多少协程?其实协程数量可以比活跃用户数少,因为协程是可以复用的。比如你开2500个协程,每个协程每秒发一次请求,刚好达到2500次每秒的请求量。

另外,Locust里有个参数叫“ramp-up”,意思是每秒新增的用户数。比如你要模拟1万活跃用户,不能一下子开1万协程,应该慢慢加,比如每秒加100个,100秒后加到1万,这样更符合真实场景。

4.3 正确的Locust启动命令示例(技术栈:Python 3.10 + Locust 2.15.0)

# 正确的启动命令:协程数量2500,每秒新增100个,模拟1万活跃用户的场景
locust -f locustfile.py --headless -u 2500 -r 100 --run-time 300s

参数解释:

  • -u 2500:协程数量2500;
  • -r 100:每秒新增100个用户;
  • --run-time 300s:测试持续5分钟。

4.4 误区规避总结

  1. 协程数量不能随便设,要结合真实的活跃用户数、思考时间、接口响应时间计算;
  2. 协程数量可以比活跃用户数少,因为协程可以复用;
  3. 必须设置ramp-up参数,慢慢增加协程数量,模拟真实的用户增长;
  4. 测试时间不能太短,至少要让协程数量稳定后再测一段时间(比如协程数量稳定后,再测3-5分钟)。

五、常见误区4:协程里不处理异常,导致测试中断

很多人写Locust脚本的时候,不处理协程里的异常,比如接口返回500错误、网络超时,结果协程直接崩溃,整个测试提前结束。比如你模拟1万用户,突然有1000个协程因为异常崩溃,那剩下的9000个协程的测试结果就不准了。

5.1 错误示例(技术栈:Python 3.10 + Locust 2.15.0)

# 错误代码:不处理异常,协程崩溃导致测试中断
from locust import HttpUser, task, between

class WebUser(HttpUser):
    wait_time = between(1, 3)

    @task
    def view_goods(self):
        # 接口可能返回500错误、网络超时,不处理的话协程直接崩溃
        response = self.client.get("/api/goods")
        # 如果接口返回500,会抛出异常,协程直接结束
        response.raise_for_status()

这个代码跑起来,如果有一个协程发请求时接口返回500,response.raise_for_status()会抛出异常,这个协程就直接崩溃了,Locust会把这个用户标记为失败,测试结果里的成功数会减少,甚至如果崩溃的协程太多,测试会提前结束。

5.2 正确的异常处理方式

协程里的异常处理,要做到“单个协程的异常不影响其他协程,也不影响整个测试”。Locust里有个很方便的装饰器:@task,你可以用try-except块包裹代码,捕获异常,然后用Locust的stats来记录异常,而不是让协程崩溃。

正确示例:

# 正确代码:处理异常,协程崩溃不影响测试
from locust import HttpUser, task, between
import locust.stats  # 导入Locust的统计模块

class WebUser(HttpUser):
    wait_time = between(1, 3)

    @task
    def view_goods(self):
        try:
            # 可能抛出异常的代码
            response = self.client.get("/api/goods")
            response.raise_for_status()
        except Exception as e:
            # 捕获异常,用Locust的stats记录异常,协程不会崩溃
            locust.stats.stats_requests("view_goods", "failure", 0, 0)
            # 可以打印异常信息,方便调试
            print(f"协程异常:{e}")

除了try-except块,Locust还提供了一个更高级的装饰器:@retry,用来自动重试失败的请求。比如接口超时了,自动重试一次,不用协程崩溃。

5.3 误区规避总结

  1. 协程里的所有可能抛出异常的代码,都要用try-except块包裹;
  2. 捕获异常后,要用Locust的stats模块记录异常,不要让协程崩溃;
  3. 可以用@retry装饰器自动重试失败的请求,提高测试的稳定性;
  4. 测试过程中要监控协程的数量,如果协程数量突然减少,说明有大量协程崩溃,要及时检查脚本。

六、常见误区5:协程里滥用锁,降低性能

很多人怕协程之间互相干扰,会在协程里加锁,比如用threading.Lock来保护全局变量,结果反而降低了性能。因为协程是在同一个线程里调度的,加锁不仅没用,还会导致协程切换变慢,甚至死锁。

6.1 错误示例(技术栈:Python 3.10 + Locust 2.15.0)

# 错误代码:协程里加锁,降低性能
from locust import HttpUser, task, between
import threading  # 导入线程锁

# 全局变量:所有协程共享的计数器
global_counter = 0
# 加锁保护计数器
lock = threading.Lock()

class WebUser(HttpUser):
    wait_time = between(1, 3)

    @task
    def increment_counter(self):
        global global_counter
        # 加锁,防止协程同时修改计数器
        with lock:
            global_counter += 1

这个代码里的锁是线程锁,协程在同一个线程里,加锁完全没用,反而会导致协程切换变慢。比如两个协程同时要修改计数器,加锁后一个协程要等另一个协程释放锁,而协程本来是可以切换的,现在反而卡住了。

6.2 正确的全局变量处理方式

协程里的全局变量,不需要加锁,因为协程是在同一个线程里调度的,不会同时执行。也就是说,协程的执行是顺序的,不会有两个协程同时修改同一个全局变量的情况。

正确示例:

# 正确代码:协程里的全局变量不需要加锁
from locust import HttpUser, task, between

# 全局变量:所有协程共享的计数器,不需要加锁
global_counter = 0

class WebUser(HttpUser):
    wait_time = between(1, 3)

    @task
    def increment_counter(self):
        global global_counter
        # 协程在同一个线程里,不会同时执行,所以不需要加锁
        global_counter += 1

如果你的全局变量需要在协程之间传递,或者需要更安全的处理,可以用Locust的stats模块,或者用asyncio的Queue来传递数据,而不是加锁。

6.3 误区规避总结

  1. 协程里绝对不要加线程锁(threading.Lock),完全没用还会降低性能;
  2. 协程的执行是顺序的,不会同时执行,所以全局变量不需要加锁;
  3. 如果需要在协程之间传递数据,用asyncio.Queue,而不是加锁;
  4. 尽量不要用全局变量,用Locust的stats模块来统计数据,更安全更方便。

七、应用场景、优缺点、注意事项总结

7.1 应用场景

协程在Locust里的应用场景主要有:

  1. 模拟大流量的性能测试:比如模拟1万、10万甚至100万用户的场景,协程比线程、进程省资源;
  2. 模拟真实的用户行为:比如模拟用户登录、浏览、下单的完整流程,协程可以很好地隔离每个用户的状态;
  3. 高并发接口的性能测试:比如测试秒杀接口、直播弹幕接口等高并发场景,协程可以快速模拟大量请求;
  4. 分布式性能测试:Locust支持分布式测试,协程可以在多个节点上运行,模拟更大的流量。

7.2 协程的优缺点

优点:

  1. 资源占用少:协程不需要操作系统分配资源,切换速度快,省内存;
  2. 非阻塞:协程在等待的时候会让出CPU,提高CPU利用率;
  3. 状态隔离:每个协程的状态是独立的,适合模拟多个用户的场景;
  4. 开发简单:协程的写法和同步代码差不多,容易理解和维护。

缺点:

  1. 依赖异步库:协程里的所有操作都要用异步库,比如aiohttp、aiofiles,开发成本比同步代码高;
  2. 调试难:协程的执行顺序是不确定的,调试的时候很难追踪问题;
  3. 不适合CPU密集型任务:协程在同一个线程里运行,CPU密集型任务会占着CPU不放,导致其他协程没法执行;
  4. 容易踩坑:协程的概念和线程、进程不一样,很多人会把多线程的写法套到协程上,导致各种问题。

7.3 注意事项

  1. 协程的数量设置要合理,不能随便来,要结合真实场景;
  2. 协程里的所有操作都要用异步库,不能用同步库;
  3. 协程里的异常要处理好,不能让单个协程崩溃影响整个测试;
  4. 尽量不要用全局变量,用Locust的stats模块来统计数据;
  5. 协程里不要加锁,完全没用还会降低性能;
  6. 测试过程中要监控协程的数量、CPU、内存的使用情况,及时调整参数。

八、文章总结

Locust用协程来模拟用户,是性能测试的一大进步,它可以用很少的资源模拟很大的流量。但协程的概念和线程、进程不一样,很多人用的时候会踩坑,比如资源隔离不好、写同步阻塞代码、协程数量随便设、不处理异常、滥用锁等。

要避免这些坑,核心是要理解协程的本质:协程是轻量级的、在同一个线程里调度的、非阻塞的、状态独立的。写Locust脚本的时候,要时刻记住:每个协程就是一个独立的用户,它的状态要自己存,它的操作要非阻塞,它的异常要自己处理。

只要掌握了协程的特点,避开这些常见的坑,Locust就可以帮你很好地完成性能测试,模拟真实的大流量场景,测出接口的真实性能。