Locust’s async support isn’t just about making your tests faster; it fundamentally changes how you think about concurrency in load testing by treating I/O-bound tasks as first-class citizens.
Let’s see Locust’s async capabilities in action. Imagine you’re testing a web service that heavily relies on external API calls, which are inherently I/O-bound. Instead of spawning a new greenlet for each request (which can become resource-intensive), you can leverage asyncio to manage hundreds or thousands of concurrent requests within a single process.
Here’s a simplified example of an async Locust user:
from locust import HttpUser, task, between
import asyncio
import httpx
class AsyncUser(HttpUser):
wait_time = between(1, 2)
host = "http://localhost:8080" # Replace with your target host
async def fetch_data(self, url):
async with httpx.AsyncClient() as client:
try:
response = await client.get(url)
response.raise_for_status()
return response.json()
except httpx.HTTPStatusError as e:
print(f"HTTP error occurred: {e}")
return None
except httpx.RequestError as e:
print(f"An error occurred while requesting {e.request.url!r}: {e}")
return None
@task
async def get_user_data(self):
user_id = 1
data = await self.fetch_data(f"/users/{user_id}")
if data:
self.environment.events.request.fire(
request_type="GET",
name=f"/users/{user_id}",
response_time=0, # httpx doesn't directly expose this per request easily for reporting, will be handled by Locust's internal timing
response_length=len(str(data))
)
@task
async def get_posts_data(self):
post_id = 10
data = await self.fetch_data(f"/posts/{post_id}")
if data:
self.environment.events.request.fire(
request_type="GET",
name=f"/posts/{post_id}",
response_time=0,
response_length=len(str(data))
)
async def on_start(self):
print("Starting async user...")
# You can perform async setup here if needed
In this AsyncUser, fetch_data uses httpx.AsyncClient to make non-blocking HTTP requests. The @task decorated methods are now async functions. Locust, when running async users, will manage an asyncio event loop for each user process. This means that while fetch_data is waiting for a response from an external API, the event loop isn’t blocked; it can switch to other tasks, like initiating another fetch_data call or processing incoming responses.
The primary problem asyncio support in Locust solves is the inefficient scaling of traditional, synchronous load testing when dealing with high concurrency and I/O-bound operations. In a synchronous model, each concurrent request often requires a separate thread or greenlet. As the number of concurrent requests grows, this can lead to significant overhead in terms of memory consumption and context switching, limiting the actual number of concurrent requests a single Locust worker can simulate. asyncio allows a single thread to manage thousands of concurrent I/O operations efficiently by using an event loop and cooperative multitasking. When a task encounters an I/O operation (like waiting for a network response), it yields control back to the event loop, which can then execute other ready tasks. This dramatically reduces resource usage and increases the scalability of your load tests.
Internally, Locust’s async support integrates with Python’s asyncio event loop. When you define an HttpUser subclass with async tasks, Locust sets up an asyncio event loop for the user’s process. It then uses a library like httpx (which has excellent asyncio support) or aiohttp to perform the HTTP requests. The await keyword in your async tasks tells the event loop to pause the current task and allow other tasks to run while waiting for the I/O operation to complete. Locust’s reporting mechanism is designed to capture these events, even though the response_time might need careful handling if you’re wrapping multiple async operations. For direct httpx calls within an async task, Locust will measure the time from when the await client.get() is called until it returns.
The response_time in the self.environment.events.request.fire call within the async task example is set to 0. This is a common pattern when using libraries like httpx directly within an async task, as httpx doesn’t immediately expose the elapsed time for the specific request in a way that Locust’s request.fire can directly consume without additional instrumentation. Locust’s HttpUser base class, when used with synchronous requests, automatically measures the time taken for the client.get (or similar) call. For async tasks, you’d typically rely on Locust’s overall measurement of the task’s execution duration, or if you need precise timing for individual async HTTP calls, you might wrap the await client.get() in a timing mechanism yourself and pass that value. However, for most practical purposes, Locust’s automatic timing of the task’s execution, which includes the await operations, is sufficient.
When you use async with httpx.AsyncClient() as client:, the client instance is designed to be reused for multiple requests within the same context. This is a performance optimization, as establishing new client connections can be costly. Locust’s AsyncUser can effectively leverage this by making multiple sequential await client.get() calls within a single task, or by having multiple tasks concurrently use the same client instance (though care must be taken with shared state in concurrent async code). The underlying asyncio event loop ensures that while one request is waiting, others can proceed, making efficient use of the single client connection pool.
You might notice that the response_time is passed as 0 in the self.environment.events.request.fire calls in the example. This is because httpx itself doesn’t directly provide the elapsed time of an await client.get() call in a way that seamlessly integrates with Locust’s request.fire for reporting. Locust’s HttpUser automatically times synchronous requests. For async tasks, you’d typically rely on Locust measuring the total time spent within the task, or you’d manually instrument the await client.get() calls with timing logic if you need very granular reporting on each individual HTTP request. However, for most use cases, the overall task duration measured by Locust is sufficient to understand performance.
The next step after mastering async HTTP requests is to explore how asyncio can be used for more complex asynchronous operations within your load tests, such as coordinating multiple concurrent async operations or implementing custom asynchronous logic.