Skip to content

Tokio Gives Progress, Not Ordering: Scheduling 1M Tasks

Technology

This is a prequel to my previous article, Your Rust Service Isn't Leaking — It Could Be the Allocator.

In that post, I wrote about how a few memory allocators behaved differently under our workload. Before we figured out that the memory behavior was tied to allocators, we first tried to reduce the memory usage from the application side.

In our workload, each event had a maximum of 1000 user tokens. For every user token, we had to make an outbound call, wait on I/O and collect all responses belonging to the event.

Here is a simplified version of the code where we fan-out tokio tasks for user token, fan-in the responses, and generate a response event.

Our primary focus here was throughput. While the fan-out above is unbounded, I assumed it practically wouldn't be a problem. These Tokio tasks are short-lived. They finish and drop once they receive responses, usually within a few milliseconds. So I assumed tasks spawned early would also finish early. Though individual outbound calls could finish out of order, I expected the earlier events overall to finish first, even while newer events were still coming in.

Our requirement was just to make each outbound call as fast as possible so that the burst could finish within the expected time.

Here's how our logs looked during a burst of 1000 events, each containing ~1000 user tokens, spawning ~1M tasks in total:

While the burst finished within the expected time, the logs showed that the token tasks from earlier events were being started much later.

I didn't expect strict ordering between tasks. They could complete in any order, and that was fine. What surprised me was how far some of the earlier tasks drifted from their submission order before receiving their first poll.

Tokio’s multi-threaded runtime has a fixed number of worker threads, a local queue per worker and a global queue shared across workers. Each worker has a local queue with a capacity of 256 tasks and on overflow it moves half of its tasks to the global queue. Workers prefer pulling from their own local queue first, check the global queue occasionally, and steal from other workers when they are idle.

When the tasks become independently schedulable, Tokio no longer knows which event created them. Now they are just runnable tasks which competing to be polled.

A simplified picture looks like this:

With this architecture, once our event fans out into 1000 user token tasks, those tasks get mixed with the token tasks from other events, parent event tasks waiting on JoinSet, and tasks waking from I/O readiness.

With so many tasks being submitted at the same time, Tokio does not guarantee that earlier tasks get polled first. Tasks from different events compete for the available worker queues, and scheduling decisions like queue overflow and work stealing can change the order in which tasks are picked up. As a result, some tasks from earlier events received their first poll much later.

While Tokio continues making progress on every task, memory is affected by how many tasks are live at the same time.

Every Tokio task carries some state and though that state might not be huge individually, when thousands of these tasks are live at the same time, it adds up to the peak memory usage.

And since a few tasks from earlier events in our workload stayed alive till the end of the burst, their parent event tasks also remained alive, keeping the event state in memory, which increased our peak memory usage.

Tokio guarantees fair scheduling under a bounded number of tasks and assuming no task blocks a worker thread.

Our code had no bound on task creation initially:

Tokio takes as many tasks as you give it and keeps making progress on them. The boundedness assumed by Tokio for its fairness guarantee has to come from the application.

These bounds vary based on workload. In our case, what we wanted was event-level fairness. We wanted all the token tasks belonging to a single event to be polled and finish closer to their submission time.

To achieve this, we used a Semaphore to bound the number of events that can be processed at a time.

The maximum number of tasks Tokio sees at a time, which affects how fair it can be, also depends on how long each task takes to return on poll. We did some trial-and-error to find the Semaphore count which worked for us.

An important detail here is that even with this bounded structure, the burst finished within the expected time while reducing the peak memory significantly. In general, it might seem that adding a Semaphore would impact the throughput of the service, but that wasn't the case for us.

This wasn't a Tokio problem. Even with 1M live tasks, Tokio kept making progress until our burst finished on time. The difference was between my assumption (the event as a whole should finish early if started early) and what the Tokio runtime sees (a task is a task, whichever event it came from).

This was an easy miss for me because there was no correctness issue. There were no tasks which dropped midway or noticeable delays in the service, and our throughput requirements were also met. It just showed up as a higher-than-expected memory spike for reasons that didn't appear anywhere in the application logic.

Spawned early doesn't mean first-polled early. First-polled early doesn't mean completed early.

If there is an application-level unit of fairness you expect Tokio to honor, like the event in our case, the runtime doesn't know it exists. The bounds should be added by the application.

And before spawning a Tokio task, ask what the maximum number of live tasks at a time could be and see how that can impact memory. In some cases, it could also impact other resources which your tasks hold while they are alive.


Source: Hacker News — This article was automatically imported from the source. Read full article at original source →

HA
Originally published by Hacker News pranitha.dev
Visit original article

Gram Slattery

Leave a Comment