Skip to content

Commit 0e6f2fe

Browse files
Use IO_Event_Interrupt and io_uring flags for better wakeup performance.
Replace the NOP-SQE cross-thread wakeup with an async read on an IO_Event_Interrupt (eventfd on Linux, pipe elsewhere): - wakeup() calls IO_Event_Interrupt_signal() — a plain write() that never touches the ring's SQ, making IORING_SETUP_SINGLE_ISSUER safe to use. - Before each blocking wait the owner thread submits an async read on the interrupt descriptor; the read completes when wakeup() fires, consuming the counter atomically with no separate drain step. - IORING_SETUP_SINGLE_ISSUER (kernel 6.0+): only the owner thread submits SQEs, allowing the kernel to skip internal SQ locking. - IORING_SETUP_DEFER_TASKRUN (kernel 6.1+, requires SINGLE_ISSUER): defers io_uring task work to the application thread, reducing cross-CPU signalling overhead across the entire completion path (+~1-2% on HTTP benchmarks). Both flags are guarded by #ifdef and degrade gracefully on older kernels. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 7eb061e commit 0e6f2fe

1 file changed

Lines changed: 96 additions & 24 deletions

File tree

ext/io/event/selector/uring.c

Lines changed: 96 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111
#include <stdint.h>
1212
#include <time.h>
1313

14+
#include "../interrupt.h"
15+
1416
#include "pidfd.c"
1517

1618
#include <linux/version.h>
@@ -33,9 +35,21 @@ struct IO_Event_Selector_URing
3335

3436
// Flag indicating whether the selector is currently blocked in a system call.
3537
// Set to 1 when blocked in io_uring_wait_cqe_timeout() without GVL, 0 otherwise.
36-
// Used by wakeup() to determine if an interrupt signal is needed.
3738
int blocked;
3839

40+
// Interrupt used to wake the selector from another thread without touching the ring's SQ.
41+
// This allows IORING_SETUP_SINGLE_ISSUER: only the owner thread ever submits SQEs.
42+
// Uses eventfd on Linux, pipe fallback elsewhere.
43+
struct IO_Event_Interrupt interrupt;
44+
45+
// Whether an async read on interrupt is currently pending in the ring.
46+
// The read is re-submitted before each blocking wait when not registered.
47+
int wakeup_registered;
48+
49+
// Buffer for the pending async read on the interrupt descriptor.
50+
// Must remain valid for the lifetime of the in-flight SQE.
51+
uint64_t wakeup_value;
52+
3953
struct timespec idle_duration;
4054

4155
struct IO_Event_Array completions;
@@ -101,6 +115,12 @@ void IO_Event_Selector_URing_Type_compact(void *_selector)
101115
static
102116
void close_internal(struct IO_Event_Selector_URing *selector)
103117
{
118+
if (selector->interrupt.descriptor >= 0) {
119+
IO_Event_Interrupt_close(&selector->interrupt);
120+
selector->interrupt.descriptor = -1;
121+
selector->wakeup_registered = 0;
122+
}
123+
104124
if (selector->ring.ring_fd >= 0) {
105125
io_uring_queue_exit(&selector->ring);
106126
selector->ring.ring_fd = -1;
@@ -220,6 +240,8 @@ VALUE IO_Event_Selector_URing_allocate(VALUE self) {
220240

221241
selector->pending = 0;
222242
selector->blocked = 0;
243+
selector->interrupt.descriptor = -1;
244+
selector->wakeup_registered = 0;
223245

224246
IO_Event_List_initialize(&selector->free_list);
225247

@@ -240,14 +262,39 @@ VALUE IO_Event_Selector_URing_initialize(VALUE self, VALUE loop) {
240262
TypedData_Get_Struct(self, struct IO_Event_Selector_URing, &IO_Event_Selector_URing_Type, selector);
241263

242264
IO_Event_Selector_initialize(&selector->backend, self, loop);
243-
int result = io_uring_queue_init(URING_ENTRIES, &selector->ring, 0);
265+
266+
unsigned int flags = 0;
267+
// IORING_SETUP_SINGLE_ISSUER (kernel 6.0+): only the owner thread submits SQEs.
268+
// Safe here because wakeup() uses eventfd (no ring access from other threads).
269+
#ifdef IORING_SETUP_SINGLE_ISSUER
270+
flags |= IORING_SETUP_SINGLE_ISSUER;
271+
#endif
272+
// IORING_SETUP_DEFER_TASKRUN (kernel 6.1+, requires SINGLE_ISSUER): defer io_uring
273+
// task work to the application thread rather than a kernel thread, reducing
274+
// cross-CPU signaling overhead. io_uring_get_events() is called before the
275+
// non-blocking CQ check in select() to flush deferred work into the CQ.
276+
#ifdef IORING_SETUP_DEFER_TASKRUN
277+
flags |= IORING_SETUP_DEFER_TASKRUN;
278+
#endif
279+
280+
int result = io_uring_queue_init(URING_ENTRIES, &selector->ring, flags);
244281

245282
if (result < 0) {
246283
rb_syserr_fail(-result, "IO_Event_Selector_URing_initialize:io_uring_queue_init");
247284
}
248285

249286
rb_update_max_fd(selector->ring.ring_fd);
250287

288+
// Interrupt for cross-thread wakeup: another thread calls signal(); the owner
289+
// thread submits an async read before each blocking wait so the ring wakes up
290+
// without the waking thread ever touching the SQ.
291+
IO_Event_Interrupt_open(&selector->interrupt);
292+
if (selector->interrupt.descriptor < 0) {
293+
io_uring_queue_exit(&selector->ring);
294+
selector->ring.ring_fd = -1;
295+
rb_sys_fail("IO_Event_Selector_URing_initialize:IO_Event_Interrupt_open");
296+
}
297+
251298
return self;
252299
}
253300

@@ -1072,11 +1119,25 @@ void * select_internal(void *_arguments) {
10721119

10731120
static
10741121
int select_internal_without_gvl(struct select_arguments *arguments) {
1075-
io_uring_submit_flush(arguments->selector);
1122+
struct IO_Event_Selector_URing *selector = arguments->selector;
1123+
1124+
// Submit an async read on the wakeup eventfd before releasing the GVL.
1125+
// When wakeup() writes to the fd the read completes, consuming the counter
1126+
// atomically — no separate poll + drain step required.
1127+
// The address of the interrupt struct serves as a unique sentinel in user_data.
1128+
if (!selector->wakeup_registered) {
1129+
struct io_uring_sqe *sqe = io_get_sqe(selector);
1130+
io_uring_prep_read(sqe, IO_Event_Interrupt_descriptor(&selector->interrupt), &selector->wakeup_value, sizeof(selector->wakeup_value), 0);
1131+
io_uring_sqe_set_data(sqe, &selector->interrupt);
1132+
selector->wakeup_registered = 1;
1133+
selector->pending += 1;
1134+
}
1135+
1136+
io_uring_submit_flush(selector);
10761137

1077-
arguments->selector->blocked = 1;
1138+
selector->blocked = 1;
10781139
rb_thread_call_without_gvl(select_internal, (void *)arguments, RUBY_UBF_IO, 0);
1079-
arguments->selector->blocked = 0;
1140+
selector->blocked = 0;
10801141

10811142
if (arguments->result == -ETIME) {
10821143
arguments->result = 0;
@@ -1115,6 +1176,14 @@ unsigned select_process_completions(struct IO_Event_Selector_URing *selector) {
11151176
continue;
11161177
}
11171178

1179+
// Interrupt read completion — the read already consumed the counter.
1180+
// Clear the flag so the next blocking wait re-submits the read.
1181+
if (cqe->user_data == (uint64_t)(uintptr_t)&selector->interrupt) {
1182+
selector->wakeup_registered = 0;
1183+
io_uring_cq_advance(ring, 1);
1184+
continue;
1185+
}
1186+
11181187
struct IO_Event_Selector_URing_Completion *completion = (void*)cqe->user_data;
11191188
struct IO_Event_Selector_URing_Waiting *waiting = completion->waiting;
11201189

@@ -1157,6 +1226,16 @@ VALUE IO_Event_Selector_URing_select(VALUE self, VALUE duration) {
11571226
// Flush any pending events:
11581227
io_uring_submit_flush(selector);
11591228

1229+
#ifdef IORING_SETUP_DEFER_TASKRUN
1230+
// With DEFER_TASKRUN the kernel holds completions as "deferred task work"
1231+
// rather than placing them directly into the CQ. io_uring_get_events() calls
1232+
// io_uring_enter with IORING_ENTER_GETEVENTS (without blocking) to flush that
1233+
// work into the CQ so the non-blocking select_process_completions below sees them.
1234+
if (selector->ring.flags & IORING_SETUP_DEFER_TASKRUN) {
1235+
io_uring_get_events(&selector->ring);
1236+
}
1237+
#endif
1238+
11601239
int ready = IO_Event_Selector_ready_flush(&selector->backend);
11611240

11621241
int result = select_process_completions(selector);
@@ -1200,25 +1279,10 @@ VALUE IO_Event_Selector_URing_wakeup(VALUE self) {
12001279
struct IO_Event_Selector_URing *selector = NULL;
12011280
TypedData_Get_Struct(self, struct IO_Event_Selector_URing, &IO_Event_Selector_URing_Type, selector);
12021281

1203-
// If we are blocking, we can schedule a nop event to wake up the selector:
1282+
// Wake the selector by signalling the interrupt. This is safe from any thread
1283+
// and never touches the ring's SQ, which is required for IORING_SETUP_SINGLE_ISSUER.
12041284
if (selector->blocked) {
1205-
struct io_uring_sqe *sqe = NULL;
1206-
1207-
while (true) {
1208-
sqe = io_uring_get_sqe(&selector->ring);
1209-
if (sqe) break;
1210-
1211-
rb_thread_schedule();
1212-
1213-
// It's possible we became unblocked already, so we can assume the selector has already cycled at least once:
1214-
if (!selector->blocked) return Qfalse;
1215-
}
1216-
1217-
io_uring_prep_nop(sqe);
1218-
// If you don't set this line, the SQE will eventually be recycled and have valid user selector which can cause odd behaviour:
1219-
io_uring_sqe_set_data(sqe, NULL);
1220-
io_uring_submit(&selector->ring);
1221-
1285+
IO_Event_Interrupt_signal(&selector->interrupt);
12221286
return Qtrue;
12231287
}
12241288

@@ -1229,7 +1293,15 @@ VALUE IO_Event_Selector_URing_wakeup(VALUE self) {
12291293

12301294
static int IO_Event_Selector_URing_supported_p(void) {
12311295
struct io_uring ring;
1232-
int result = io_uring_queue_init(32, &ring, 0);
1296+
1297+
unsigned int flags = 0;
1298+
#ifdef IORING_SETUP_SINGLE_ISSUER
1299+
flags |= IORING_SETUP_SINGLE_ISSUER;
1300+
#endif
1301+
#ifdef IORING_SETUP_DEFER_TASKRUN
1302+
flags |= IORING_SETUP_DEFER_TASKRUN;
1303+
#endif
1304+
int result = io_uring_queue_init(32, &ring, flags);
12331305

12341306
if (result < 0) {
12351307
rb_warn("io_uring_queue_init() was available at compile time but failed at run time: %s\n", strerror(-result));

0 commit comments

Comments
 (0)