Thread-identity switcheroo for io_uring

lwn.net · 2026-09-27 · by Long Nguyen ·generic
The io_uring subsystem is all about asynchronous execution; applications count on it to not blo [...]

Thread-identity switcheroo for io_uring

| Welcome to LWN.net The following subscription-only content has been made available to you by an LWN subscriber. Thousands of subscribers depend on LWN for the best news from the Linux and free software communities. If you enjoy this article, please consider subscribing to LWN. Thank you for visiting LWN.net! |
| --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |

By Jonathan Corbet September 17, 2026

The io\_uring subsystem is all about asynchronous execution; applications count on it to not block — unless explicitly requested to. Within io\_uring, maintaining the "never blocks" guarantee has sometimes been a challenge, given that many paths in the kernel were never designed for asynchronous execution. This problem has been worked around, but at a significant cost to performance. Now, io\_uring maintainer Jens Axboe has posted an RFC patch setwith a somewhat radical (and potentially scary) solution to the problem.

A process in user space can submit one or more operations to io\_uring by describing them in submission-queue entries (SQEs) in the submission ring, then calling io\_uring\_enter(). The kernel will initiate processing on all of the entries that were placed into the ring. If possible, the kernel will execute them immediately within the io\_uring\_enter() call. For example, if a read request can be satisfied with data that is already in the page cache and the user-space buffer is entirely resident within RAM, then the requested data can be copied immediately without blocking.

In cases where blocking is required, though, things become more complicated. Many of the I/O paths within the kernel have asynchronous support built into them; they will carry a new request as far as it can go, and finish the job elsewhere within the kernel once the blocking operation has completed. There are other operations, though, that lack this support; these include the io\_uring equivalent of system calls like fdatasync(), statx(), some openat()paths (the O\_NONBLOCK flag notwithstanding), and others. The io\_uring code must take extra care when implementing these operations, lest io\_uring\_enter() block partway through processing a set of submitted operations.

In current kernels, any operation that might cause io\_uring\_enter() to block is handed off to a separate worker thread for execution. That allows the submitting thread to continue; the separate worker can block, if need be, without holding up anything else. This handoff is not free, though; the kernel must wake a waiting worker thread and perform a context switch, among other costs. If the operation does indeed block, those costs may not be significant in the end. In many cases, though, the operation can be completed without blocking. In such cases, the extra overhead becomes a significant part of the overall cost of executing the request. Since developers who turn to io\_uring are usually doing so in search of improved application performance, the cost of avoiding blocking that might not happen anyway hurts.

One solution to the problem would be to rework all of the system-call paths in the kernel to be non-blocking. That has been done, in some cases, over the years, but it is not an easy or quick task. An alternative — the one that Axboe has chosen — is to proceed with a potentially blocking operation then handle cases that actually block without blocking the submitting thread.

Detecting operations that do indeed block requires a change to the scheduler. A new flag (PF\_IO\_HANDOFF) is added to the flags field of the task\_struct structure that represents a thread. If a thread is about to block for any reason and it has that flag set, the scheduler will make a call to a new function called io\_uring\_task\_sleeping(). Hooking into the scheduler in this way allows io\_uring to be informed about blocking that happens anywhere in the kernel, without having to modify the actual code paths involved.

Once io\_uring knows that an operation is going to block, it must do something about the situation. One option, in theory, would be to unwind whatever work had been done up to the blocking point, then to restart the operation in a worker thread. But, since this blocking can happen almost anywhere in the kernel, that is not really an option. As a general rule, once io\_uring has started a requested operation involving kernel code paths that are not designed to avoid blocking, it must see that operation through to the finish.

Axboe's solution is "thread identity handoff". When the scheduler informs io\_uring about a thread that is about to block, io\_uring responds by selecting a worker thread from its thread pool. Rather than hand the ongoing work over to that thread (which is not possible at this point), the code exchanges the identities of the two threads. The worker thread is made to look like the original submitting thread in every way, including its thread ID, signal-handling setup, and more; that thread then continues processing the submission ring before, eventually, returning to user space. The thread that returns from io\_uring\_enter() has a different task\_struct than the one that made the call, but everything else (hopefully) looks the same.

Meanwhile, the original thread, which was about to block executing an operation, takes on the worker thread's identity, then proceeds to block as usual. When it wakes, it will continue the operation through completion, then take its place in the worker-thread pool. The end result is that potentially blocking operations can be executed by the submitting thread and, if they can run without actually blocking, be handled entirely there. The cost of bringing in a worker thread is only paid if the operation really does block.

It sounds simple enough, but this kind of identity exchange is fraught with potential land mines. Before the two threads involved can exchange their task\_struct structures, the kernel must make absolutely sure that nothing else in the kernel holds references to those structures. Otherwise, something will eventually be done with a reference to the wrong task\_struct, an outcome that will do nothing to reduce the strain on all of the people trying to keep up with the stream of kernel CVEs. That is a result that is deemed to be worth avoiding.

Preventing it means being sure, before starting an operation that might block, that the submitting thread will be able to hand off its identity if the need arises. There is a long list (found in the definitions of thread\_handoff\_allowed() and thread\_handoff\_compatible() in this patch) of conditions that would prevent a handoff and require the operation to be executed in the old way. For example, if the thread is being traced with ptrace(), then the tracer holds a reference to its task\_struct. By the same logic, if the thread in question is tracing any other tasks, those tasks hold references, so the thread cannot perform a handoff. Other conditions that will prevent a handoff include using perf events, having futex ownership tracked in the kernel, running under a realtime scheduler, holding a core scheduling cookie, performing a vfork(), and several others. This determination seems like the scariest, most fragile part of this series. The task\_struct is widely available, so it is hard to know that all of the possibilities for possible references in the kernel have been covered — before one even begins to worry about the addition of new references in the future by developers who are not thinking about io\_uring at all.

The potential payoff is large, though. The cover letter includes a number of benchmark results. For some quick, non-blocking operations, the improvements can be huge; an fsync() benchmark run on a tmpfs filesystem showed a nearly 700% improvement. Other improvements are more modest, and some of the tests that always block show regressions. The worst regressions tended to be with a higher queue depth — when there is a longer list of operations all being submitted at once. Immediately pushing each of those operations into a worker thread allows them to be worked on in parallel, while processing them up to the blocking point in the submitting thread serializes that work, slowing it down. Axboe said that he has ideas for addressing that problem, but he is unsure whether they are worth pursuing because, he said, applications that perform these operations tend not to have high queue depths to begin with.

Axboe clearly does not expect to merge this series in the near future; he is more concerned with determining whether the overall approach has any chance of being viable. Comments have been limited so far. Peter Zijlstra pointed out that one of the other conditions blocking identity handoff — if the thread involved is running with a shadow stack — will prevent the use of the feature on most deployed systems; Axboe thinksthat the shadow stack can be moved with the rest of the thread's identity. Beyond that, it seems that developers are still mostly digesting this series that, as Gabriel Krisman Bertazi said, is "really cool and seems like very dangerous thing". If this series can convince developers that the "dangerous" part has been dealt with, it may eventually lead to significantly better io\_uring performance for a number of workloads.

| Index entries for this article | | | -------------------------------------- | ---------------------------------------------------- | | Kernel | io\_uring |

| | | |

---

to post comments

Go

Posted Sep 17, 2026 16:21 UTC (Thu) by quotemstr (subscriber, #45331) \[Link\]

Reminds me how the Go runtime rotates logical threads among physical threads as they're about to make system calls

Green threads FTW!

Posted Sep 17, 2026 17:18 UTC (Thu) by Cyberax (✭ supporter ✭, #52523) \[Link\]

So now we'll have a full-blown green threading runtime in the kernel!

Forget kernel Rust, kernel Go is the future.

Maybe with a dedicated io\_uring executor thread pool?

Posted Sep 19, 2026 0:46 UTC (Sat) by himi (subscriber, #340) \[Link\]

This sounds like a really cool idea, but it also sounds \really\ dangerous in the context of a kernel that wasn't designed with this execution model in mind - if task\_struct was a black box accessible only through a clearly defined API then \maybe\, but it's a big grab-bag of stuff used for so many things that dealing with all the potential issues doesn't sound like it'd be practical.

That said, if you set up a pool of dedicated user-space threads (presumably as part of io\_uring\_setup(2)) which were managed by the io\_uring subsystem I expect it would be possible to make it work. Set up the dedicated threads with constraints applied that prevent anything that could cause issues, and them make it so that they're the \only\ threads able to call io\_uring\_enter() - essentially trading some functionality (at least in the code you can run in the dedicated threads) for the ability to use this execution model. If there are significant performance gains I'm sure you'd find people happy to make that trade . . .

run away

Posted Sep 19, 2026 6:17 UTC (Sat) by roc (subscriber, #30627) \[Link\] (1 responses)

\> It sounds simple enough

No. No, it really doesn't. It sounds like a maintenance nightmare.

run away

Posted Sep 19, 2026 9:13 UTC (Sat) by k3ninho (subscriber, #50375) \[Link\]

\>> It sounds simple enough

\>No. No, it really doesn't. It sounds like a maintenance nightmare.

I read it with the dry sarcasm of 'your grumpy editor' and believe there's deep skepticism here. I, too, am running away to the safety of Nope and possibly spending winters in Hard No.

(Maybe a process that initiates io\_uring work is okay with having its task\_struct wibbled like this and we can be explicit about the maintenance of a shadow task\_strict\_ring\_io, creating a thread pool with evergreen runners for these processes and moving them over from task\_struct to task\_struct\_ring\_io when setting up -- and back when tearing down. You might even define task\_struct\_ring\_io as a macro or codegen from task\_struct to get clear debugging and isolate other uses of task\_struct data from these unpredictable changes during runtime. I prefer explicit over implicit, you see.)

K3n.

Copying good ideas from fibrils or syslets?

Posted Sep 19, 2026 10:19 UTC (Sat) by mokki (subscriber, #33200) \[Link\]

Jens mentioned past attempts 20 years ago. These two from 2007 immediately popped back to my mind: Fibrils: <https://lwn.net/Articles/220897/> Syslets: <https://lwn.net/Articles/221913/>

Asynchronous operations

Posted Sep 19, 2026 16:06 UTC (Sat) by magfr (subscriber, #16052) \[Link\] (4 responses)

How common are blocking operations in the kernel context? Would it be (more) reasonable to stop allowing new blockers in and start working towards a fully asynchronous kernel, similarly to how the BKL was removed?

Asynchronous operations

Posted Sep 20, 2026 23:47 UTC (Sun) by willy (subscriber, #9762) \[Link\] (3 responses)

Blocking operations are incredibly common. Every mutex or semaphore (unless you trylock). Memory allocation will block. I/O submissions can be synchronous (depends how you set it up). We want to block the task if it's dirtied too much memory so the system has time to do write back.

If anything, the RT kernel takes us further from being non-blocking. Spinlocks are turned into mutexes so there are more blocking points than there used to be.

Asynchronous operations

Posted Sep 23, 2026 18:24 UTC (Wed) by alkbyby (subscriber, #61687) \[Link\] (2 responses)

My understanding is yes those potentially blocking locations are very common, but actual blocking on them is not. Thus the massive benefit of this approach.

BTW I am curious why this is not doing slightly different, but seemingly safer approach. Instead of switching at the blocking site, do a very efficient coroutine-like switch at the io\_uring\_enter (including current task\_struct). The kind Google fibers stuff was supposed to be, just already inside kernel-space. I.e. fiber\_switchto "atomically" puts the caller to sleep while switching to running the switchto target, bypassing as much scheduling logic as possible.

Then if this worker thread is finished quickly, it switches back. And it it blocks, then the scheduler "magic" wakes up the original thread. Yes, a little more overhead. Because some degree of context switching happens every time. But such a switch is ideally just jumping stacks and saving/restoring general purpose registers. So just a few tens of nanoseconds. And could be (more) immune to "is it safe to swap identities" questions.

Why not a synchronous switchto?

Posted Sep 24, 2026 11:14 UTC (Thu) by ebiederm (subscriber, #35028) \[Link\]

Please read the patch discussion.

I suggested that and he was quite receptive to the idea.

Asynchronous operations

Posted Sep 25, 2026 20:32 UTC (Fri) by alison (subscriber, #63752) \[Link\]

A recent article in the not-paywalled ACCU journal Overload by Lucian Teodorescu (https://accu.org/journals/overload/32/181/teodorescu) discussed the related concept of "thread hopping":

what can the original thread do in the meantime? Well, this may be a bit counterintuitive to the reader, but an adequate option is to go to the thread pool and continue the work there. That is, we essentially switch the threads. The original thread will continue to execute whatever the worker thread has, while the worker thread will continue to execute everything on the main flow after the await point. We also call this behaviour thread hopping. A simplified view of a thread is that it consists of a set of registers (most importantly an instruction pointer, IP, and a stack pointer, SP) plus a stack memory region associated with it. During the lifetime of the thread, the stack pointer register keeps changing within the stack region. Thread hopping essentially swaps important registers between threads, allowing a thread to point to the stack region created by another thread. A good technique to implement thread hopping is to use stackful coroutines

Wow!:)

Posted Sep 24, 2026 4:51 UTC (Thu) by maniax (subscriber, #4509) \[Link\]

I haven't read about something as fun as this in a long while :) It's simple, elegant, and if the task\_struct referencing issue can be worked around, it'd be one of the coolest hacks that have happened in the Linux kernel in the last few years, I think :)

lwn.net · 2 images · 44 links

Comments (0)

No comments yet