|
|
Log in / Subscribe / Register

Thread-identity switcheroo for io_uring

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 set with 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 thinks that 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
Kernelio_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] (2 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] (1 responses)

>> 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.

Is non-blocking a thing?

Posted Sep 30, 2026 11:37 UTC (Wed) by rwmj (subscriber, #5474) [Link]

I wonder if the basic assumption, that some operations are non-blocking, is even true any longer. In modern hardware, even reading from memory could miss the cache and take hundreds of cycles to complete, in other words, blocking (in a sense) at the hardware level. It seems as if it's better just to assume eveything is blocking and use a separate thread to handle all io_uring requests.

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 :)


Copyright © 2026, Eklektix, Inc.
This article may be redistributed under the terms of the Creative Commons CC BY-SA 4.0 license
Comments and public postings are copyrighted by their creators.
Linux is a registered trademark of Linus Torvalds