Introduced in Goose 0.7, Async enables the simulation of considerably more "users" by giving up the CPU when a task is blocked. For example, when a Goose "user" requests a web page, it's blocked until the server responds. Without Async, Goose blocks an entire CPU core while waiting for that response. With Async, Goose makes the request, then gives up the core (ie, sleeps) allowing another "user" to make a request with that same core, and again gives up the core (sleeps). When a response comes back, the appropriate "user" gets woken to complete the next task. In our testing, this creates a 2x boost in performance.

Goose is inspired by Locust, which in part attempted to solve the issue of multiple-user testing by making it easier to define multiple or many-user test scenarios (History of Locust). Goose also improves on other load testing tools, specifically a point noted by Locust:

"JMeter is thread-bound. This means for every user you want to simulate, you need a separate thread. Needless to say, benchmarking thousands of users on a single machine just isn’t feasible."

Asynchronous support means that Goose no longer waits for a response before moving on to its next task, and does not require a dedicated thread for every user simulated.

For an example, view this code in Goose. Each call to “.await" surrenders the core to the async realtime and allows another request to be made in parallel. At this point in the code, multiple users can be simulated in a single thread, issuing a request for the recipe page, and as the response starts coming back, requests start going out for static assets.

In this chart, all three clients request data at the same time. Once Client 1 gets a response, it sends the next request. The server responds to these requests, regardless of the requests of the other clients. On each end, communication is asynchronous. Client 1 makes the request, and waits for a response. The recipe server sends a response to Client 1, while Client 2 and Client 3 make requests, which return responses as process free up to handle them.

Multiple clients request information from the server, with asynchronous responses and additional requests.

Adding asynchronous support

Goose uses Reqwest to make asynchronous HTTP requests. Previously, it used the blocking Reqwest client.

Asynchronous support is complex, and adding it to Goose was no exception. One of the first changes was to switch from storing "function pointers" to storing async function pointers, which required putting them into a Box because async functions can not be used as function pointers. Some of the history is here, where a community user of Goose started the effort:

These two commits converted Goose to using asynchronous task functions, but it made writing load tests a bit more complex. To minimize the complexity, a task! macro was introduced to automatically pin the asynchronous task function in a box. Rust uses multiple types of macros; in this case, Goose uses a function-like macro.

This was followed with an enhancement to the existing task! macro, and the introduction of the taskset! macro. While task! includes a little "async magic", taskset! was introduced primarily to add consistency to the load test writing process:.

At this point, asynchronous Goose load tests are written with two macros allowing simple ergonomics as follows:


	GooseAttack::initialize()?
        	.register_taskset(taskset!("WebsiteUser")
                	.register_task(task!(website_index))
    	)
        	.execute()?
        	.print();

It remains possible to write load tests without the macros, but as you can see with the following snippet it's far less ergonomic to not use macros. Both the earlier snippet and the following snippet are the identical load test:


	GooseAttack::initialize()?
        	.register_taskset(GooseTaskSet::new("WebsiteUser")
                	.register_task(GooseTask::new(std::sync::Arc::new(move |s| {
                    	std::boxed::Box::pin(website_index(s))
                	})))
        	)
        	.execute()?
        	.print();

The next cleanup involved making the Goose client object immutable. By default, all objects in Rust are immutable, improving safety and performance. The first attempt involved moving the mutable fields out of GooseClient into a global:

The use of globals caused problems with automated testing, and generally didn't pass the "sniff test". The fields were moved back into the GooseClient object, this time wrapped in Arc and a Mutex where appropriate, allowing specific fields within the object to be mutable where necessary. For example, the Reqwest client which is contained in the GooseClient object has to be mutable to maintain each user's session during the load test:

We also explored managing these dynamic fields in an unsafe block, allowing us to completely remove all locks in the fast codepath. This is possible because we control the order tasks run, and simulated users are run individually with no danger of the same memory being accessed or modified by multiple threads at the same time. In spite of removing the locks, performance was actually reduced as Rust's compiler wasn't able to make as many compile-time optimizations. The pull request was not merged, and Goose does not use any unsafe code.

Finally, we added support for Closures in addition to asynchronous functions:

Prior to this change, it was only possible to define functions in your load test's source code, and pass them into the GooseAttack object to generate a load test. With support for closures, it's possible to programmatically construct asynchronous functions on the fly, improving Goose's ability to be used as a load testing library that is integrated with other programs. For an example of how a Closure works, see this sample code.

Closures have the huge advantage that they can be generated dynamically, for example, from a spreadsheet or from a dynamic list of paths. This gives tremendous power for dynamically generating load tests. It also makes tests simpler, because less code needs to be duplicated.

Async in use

To see asynchronous code in action, check out the Goose documentation on creating a simple load test. For example:

Load test functions must be async. Each load test function must accept a reference to a GooseUser object and return a GooseTaskResult.

For example:


    use goose::prelude::*;

    async fn loadtest_foo(user: &GooseUser) -> GooseTaskResult {
      let _goose = user.get("/path/to/foo").await?;

      Ok(())
    }  

In this example, there are two async elements:

  1. The word async before fn indicates this is an asynchronous function.

  2. The call to user.get() ends with .await which means the get request is made asynchronously and this particular function sleeps in the background while waiting for the server to reply.

For another, similar version, see the Getting Started section of the Goose README.txt.

In summary

What began as a trial of adding asynchronous functions added a great deal of power and processing speed to Goose and its testing ability. These improvements continue to remove bottlenecks, and enable critical testing to complete more quickly. This can directly lead to easier troubleshooting and quicker fixes to website issues.

For more posts and talks Goose, see our index of Goose content.


Photo by Einar Jónsson on Unsplash