From 6a7c5aa7c39386a9f26144135af9d42bc029bca2 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 21 Aug 2026 22:15:14 +1200 Subject: [PATCH 01/22] Add io_uring futex support Signed-off-by: Samuel Williams --- .github/workflows/test-futex.yaml | 38 +++++ ext/extconf.rb | 1 + ext/io/event/event.c | 2 +- ext/io/event/selector/uring.c | 253 +++++++++++++++++++++++++++- ext/io/event/selector/uring.h | 2 +- fixtures/io/event/test_scheduler.rb | 5 + lib/io/event.rb | 4 + lib/io/event/debug/selector.rb | 6 + lib/io/event/futex.rb | 30 ++++ releases.md | 5 +- test/io/event/futex.rb | 100 +++++++++++ 11 files changed, 442 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/test-futex.yaml create mode 100644 lib/io/event/futex.rb create mode 100644 test/io/event/futex.rb diff --git a/.github/workflows/test-futex.yaml b/.github/workflows/test-futex.yaml new file mode 100644 index 00000000..7f0baf53 --- /dev/null +++ b/.github/workflows/test-futex.yaml @@ -0,0 +1,38 @@ +name: Test Futex + +on: [push, pull_request] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-24.04 + + steps: + - uses: actions/checkout@v6 + + - name: Install liburing with futex support + run: | + git clone --branch liburing-2.6 --depth 1 https://github.com/axboe/liburing.git /tmp/liburing + make -C /tmp/liburing -j2 + sudo make -C /tmp/liburing install + echo "/usr/lib" | sudo tee /etc/ld.so.conf.d/liburing.conf + sudo ldconfig + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: ruby + bundler-cache: true + + - name: Run futex tests + timeout-minutes: 5 + env: + RUBYLIB: lib:ext + run: | + cd ext + bundle exec ruby extconf.rb + make + cd .. + bundle exec ruby -e 'require "io/event"; abort "IO::Event::Futex is unavailable!" unless defined?(IO::Event::Futex)' + bundle exec sus test/io/event/futex.rb diff --git a/ext/extconf.rb b/ext/extconf.rb index f9c43d80..3a1f4ad0 100755 --- a/ext/extconf.rb +++ b/ext/extconf.rb @@ -34,6 +34,7 @@ if have_library("uring") and have_header("liburing.h") have_func("io_uring_prep_waitid", "liburing.h") + have_func("io_uring_prep_futex_wait", "liburing.h") $srcs << "io/event/selector/uring.c" end diff --git a/ext/io/event/event.c b/ext/io/event/event.c index 59492544..506b9cc8 100644 --- a/ext/io/event/event.c +++ b/ext/io/event/event.c @@ -23,7 +23,7 @@ void Init_IO_Event(void) Init_IO_Event_Selector(IO_Event_Selector); #ifdef IO_EVENT_SELECTOR_URING - Init_IO_Event_Selector_URing(IO_Event_Selector); + Init_IO_Event_Selector_URing(IO_Event, IO_Event_Selector); #endif #ifdef IO_EVENT_SELECTOR_EPOLL diff --git a/ext/io/event/selector/uring.c b/ext/io/event/selector/uring.c index fe664af6..d961fcff 100644 --- a/ext/io/event/selector/uring.c +++ b/ext/io/event/selector/uring.c @@ -17,6 +17,15 @@ #include +#if defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) +#include +#include + +#ifndef FUTEX2_SIZE_U32 +#define FUTEX2_SIZE_U32 2 +#endif +#endif + // `io_uring` support for `IORING_OP_WAITID` was introduced in Linux 6.7. When available, we use it to wait for process exit directly in the ring, instead of polling on a pidfd. #if defined(HAVE_IO_URING_PREP_WAITID) && (LINUX_VERSION_CODE >= KERNEL_VERSION(6,7,0)) #define IO_EVENT_SELECTOR_URING_USE_WAITID @@ -34,6 +43,150 @@ enum { enum {URING_ENTRIES = 64}; +#if defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) + +#pragma mark - Futex Data Type + +struct IO_Event_Futex { + VALUE buffer; + uint32_t *address; +}; + +static const rb_data_type_t IO_Event_Futex_Type; + +static void IO_Event_Futex_mark(void *_futex) { + struct IO_Event_Futex *futex = _futex; + rb_gc_mark_movable(futex->buffer); +} + +static void IO_Event_Futex_compact(void *_futex) { + struct IO_Event_Futex *futex = _futex; + futex->buffer = rb_gc_location(futex->buffer); +} + +static void IO_Event_Futex_free(void *_futex) { + struct IO_Event_Futex *futex = _futex; + if (!NIL_P(futex->buffer)) rb_io_buffer_try_unlock(futex->buffer); + xfree(futex); +} + +static size_t IO_Event_Futex_size(const void *_futex) { + return sizeof(struct IO_Event_Futex); +} + +static const rb_data_type_t IO_Event_Futex_Type = { + .wrap_struct_name = "IO::Event::Futex", + .function = { + .dmark = IO_Event_Futex_mark, + .dcompact = IO_Event_Futex_compact, + .dfree = IO_Event_Futex_free, + .dsize = IO_Event_Futex_size, + }, + .flags = RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED, +}; + +static VALUE IO_Event_Futex_allocate(VALUE klass) { + struct IO_Event_Futex *futex = NULL; + VALUE instance = TypedData_Make_Struct(klass, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + futex->buffer = Qnil; + futex->address = NULL; + return instance; +} + +static ID id_offset; + +static VALUE IO_Event_Futex_initialize(int argc, VALUE *argv, VALUE self) { + VALUE buffer, options; + rb_scan_args(argc, argv, "1:", &buffer, &options); + + VALUE offset_value = Qundef; + if (!NIL_P(options)) { + ID keys[] = {id_offset}; + VALUE values[1]; + rb_get_kwargs(options, keys, 0, 1, values); + offset_value = values[0]; + } + + size_t offset = offset_value == Qundef ? 0 : NUM2SIZET(offset_value); + void *base = NULL; + size_t size = 0; + rb_io_buffer_get_bytes_for_writing(buffer, &base, &size); + + if (offset > size || size - offset < sizeof(uint32_t)) { + rb_raise(rb_eRangeError, "Futex offset exceeds the buffer size!"); + } + + uint32_t *address = (uint32_t *)((char *)base + offset); + if ((uintptr_t)address % sizeof(uint32_t) != 0) { + rb_raise(rb_eArgError, "Futex address must be aligned to 4 bytes!"); + } + + struct IO_Event_Futex *futex = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + if (!NIL_P(futex->buffer)) rb_io_buffer_unlock(futex->buffer); + rb_io_buffer_lock(buffer); + RB_OBJ_WRITE(self, &futex->buffer, buffer); + futex->address = address; + + return self; +} + +static VALUE IO_Event_Futex_value(VALUE self) { + struct IO_Event_Futex *futex = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + return UINT2NUM(__atomic_load_n(futex->address, __ATOMIC_ACQUIRE)); +} + +static VALUE IO_Event_Futex_set_value(VALUE self, VALUE value) { + struct IO_Event_Futex *futex = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + uint32_t converted = NUM2UINT(value); + __atomic_store_n(futex->address, converted, __ATOMIC_RELEASE); + return value; +} + +static VALUE IO_Event_Futex_increment(int argc, VALUE *argv, VALUE self) { + VALUE amount_value; + rb_scan_args(argc, argv, "01", &amount_value); + uint32_t amount = NIL_P(amount_value) ? 1 : NUM2UINT(amount_value); + + struct IO_Event_Futex *futex = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + uint32_t value = __atomic_add_fetch(futex->address, amount, __ATOMIC_ACQ_REL); + return UINT2NUM(value); +} + +static VALUE IO_Event_Futex_wake(int argc, VALUE *argv, VALUE self) { + VALUE count_value; + rb_scan_args(argc, argv, "01", &count_value); + int count = NIL_P(count_value) ? 1 : NUM2INT(count_value); + if (count < 0) rb_raise(rb_eArgError, "Wake count must be non-negative!"); + + struct IO_Event_Futex *futex = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + int result = syscall(SYS_futex, futex->address, FUTEX_WAKE, count, NULL, NULL, 0); + if (result < 0) rb_sys_fail("IO_Event_Futex_wake:futex"); + return INT2NUM(result); +} + +static VALUE IO_Event_Futex_signal(int argc, VALUE *argv, VALUE self) { + VALUE count_value; + rb_scan_args(argc, argv, "01", &count_value); + + VALUE value = IO_Event_Futex_increment(0, NULL, self); + VALUE arguments[] = {NIL_P(count_value) ? INT2NUM(1) : count_value}; + IO_Event_Futex_wake(1, arguments, self); + return value; +} + +static uint32_t *IO_Event_Futex_address(VALUE self) { + struct IO_Event_Futex *futex = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + return futex->address; +} + +#endif + #pragma mark - Data Type struct IO_Event_Selector_URing @@ -804,6 +957,77 @@ VALUE IO_Event_Selector_URing_process_wait(VALUE self, VALUE fiber, VALUE _pid, return rb_ensure(process_wait_transfer, (VALUE)&process_wait_arguments, process_wait_ensure, (VALUE)&process_wait_arguments); } +#if defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) + +#pragma mark - Futex Wait + +struct futex_wait_arguments { + struct IO_Event_Selector_URing *selector; + struct IO_Event_Selector_URing_Waiting *waiting; +}; + +static VALUE futex_wait_ensure(VALUE _arguments) { + struct futex_wait_arguments *arguments = (struct futex_wait_arguments *)_arguments; + + if (arguments->waiting->completion) { + struct io_uring_sqe *sqe = io_get_sqe(arguments->selector); + io_uring_prep_cancel(sqe, (void *)arguments->waiting->completion, 0); + io_uring_sqe_set_data(sqe, NULL); + io_uring_submit_now(arguments->selector); + } + + IO_Event_Selector_URing_Waiting_cancel(arguments->waiting); + return Qnil; +} + +static VALUE futex_wait_transfer(VALUE _arguments) { + struct futex_wait_arguments *arguments = (struct futex_wait_arguments *)_arguments; + IO_Event_Selector_loop_yield(&arguments->selector->backend); + + int32_t result = arguments->waiting->result; + if (result == 0) { + return Qtrue; + } else if (result == -EAGAIN) { + return Qfalse; + } else if (result < 0) { + rb_syserr_fail(-result, "futex_wait_transfer:io_uring_futex_wait"); + } + + return Qfalse; +} + +static VALUE IO_Event_Selector_URing_futex_wait(VALUE self, VALUE fiber, VALUE futex, VALUE expected_value) { + struct IO_Event_Selector_URing *selector = NULL; + TypedData_Get_Struct(self, struct IO_Event_Selector_URing, &IO_Event_Selector_URing_Type, selector); + + struct IO_Event_Selector_URing_Waiting waiting = { + .fiber = fiber, + }; + RB_OBJ_WRITTEN(self, Qundef, fiber); + + struct IO_Event_Selector_URing_Completion *completion = IO_Event_Selector_URing_Completion_acquire(selector, &waiting); + struct futex_wait_arguments arguments = { + .selector = selector, + .waiting = &waiting, + }; + + struct io_uring_sqe *sqe = io_get_sqe(selector); + io_uring_prep_futex_wait( + sqe, + IO_Event_Futex_address(futex), + NUM2UINT(expected_value), + FUTEX_BITSET_MATCH_ANY, + FUTEX2_SIZE_U32, + 0 + ); + io_uring_sqe_set_data(sqe, completion); + io_uring_submit_pending(selector); + + return rb_ensure(futex_wait_transfer, (VALUE)&arguments, futex_wait_ensure, (VALUE)&arguments); +} + +#endif + #pragma mark - IO#wait static inline @@ -1724,6 +1948,8 @@ VALUE IO_Event_Selector_URing_wakeup(VALUE self) { #pragma mark - Native Methods +static int IO_Event_Selector_URing_futex_supported = 0; + static int IO_Event_Selector_URing_supported_p(void) { struct io_uring ring; @@ -1754,13 +1980,21 @@ static int IO_Event_Selector_URing_supported_p(void) { return 0; } + +#if defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) + struct io_uring_probe *probe = io_uring_get_probe_ring(&ring); + if (probe) { + IO_Event_Selector_URing_futex_supported = io_uring_opcode_supported(probe, IORING_OP_FUTEX_WAIT); + io_uring_free_probe(probe); + } +#endif io_uring_queue_exit(&ring); return 1; } -void Init_IO_Event_Selector_URing(VALUE IO_Event_Selector) { +void Init_IO_Event_Selector_URing(VALUE IO_Event, VALUE IO_Event_Selector) { if (!IO_Event_Selector_URing_supported_p()) { return; } @@ -1803,4 +2037,21 @@ void Init_IO_Event_Selector_URing(VALUE IO_Event_Selector) { rb_define_method(IO_Event_Selector_URing, "io_close", IO_Event_Selector_URing_io_close, 1); rb_define_method(IO_Event_Selector_URing, "process_wait", IO_Event_Selector_URing_process_wait, 3); + +#if defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) + if (IO_Event_Selector_URing_futex_supported) { + VALUE IO_Event_Futex = rb_define_class_under(IO_Event, "Futex", rb_cObject); + rb_define_alloc_func(IO_Event_Futex, IO_Event_Futex_allocate); + rb_define_method(IO_Event_Futex, "initialize", IO_Event_Futex_initialize, -1); + rb_define_method(IO_Event_Futex, "value", IO_Event_Futex_value, 0); + rb_define_method(IO_Event_Futex, "value=", IO_Event_Futex_set_value, 1); + rb_define_method(IO_Event_Futex, "increment", IO_Event_Futex_increment, -1); + rb_define_method(IO_Event_Futex, "wake", IO_Event_Futex_wake, -1); + rb_define_method(IO_Event_Futex, "signal", IO_Event_Futex_signal, -1); + + id_offset = rb_intern("offset"); + + rb_define_method(IO_Event_Selector_URing, "futex_wait", IO_Event_Selector_URing_futex_wait, 3); + } +#endif } diff --git a/ext/io/event/selector/uring.h b/ext/io/event/selector/uring.h index f3ad316d..5f8f5f87 100644 --- a/ext/io/event/selector/uring.h +++ b/ext/io/event/selector/uring.h @@ -7,4 +7,4 @@ #define IO_EVENT_SELECTOR_URING -void Init_IO_Event_Selector_URing(VALUE IO_Event_Selector); +void Init_IO_Event_Selector_URing(VALUE IO_Event, VALUE IO_Event_Selector); diff --git a/fixtures/io/event/test_scheduler.rb b/fixtures/io/event/test_scheduler.rb index 6c71315d..b9ea8a40 100644 --- a/fixtures/io/event/test_scheduler.rb +++ b/fixtures/io/event/test_scheduler.rb @@ -37,6 +37,11 @@ module Forwarders def io_close(descriptor) @selector.io_close(descriptor) end + + # Wait while the futex contains the expected value. + def futex_wait(futex, expected) + @selector.futex_wait(Fiber.current, futex, expected) + end end def initialize(selector: nil, worker_pool: nil, maximum_worker_count: nil) diff --git a/lib/io/event.rb b/lib/io/event.rb index 8d953a72..42ee7e3e 100644 --- a/lib/io/event.rb +++ b/lib/io/event.rb @@ -8,3 +8,7 @@ require_relative "event/selector" require_relative "event/timers" require_relative "event/native" + +if defined?(IO::Event::Futex) + require_relative "event/futex" +end diff --git a/lib/io/event/debug/selector.rb b/lib/io/event/debug/selector.rb index a5eca436..1587b8b4 100644 --- a/lib/io/event/debug/selector.rb +++ b/lib/io/event/debug/selector.rb @@ -19,6 +19,12 @@ def io_close(descriptor) log("Closing file descriptor #{descriptor}") @selector.io_close(descriptor) end + + # Wait for a futex value to change, forwarded to the underlying selector. + def futex_wait(fiber, futex, expected) + log("Waiting for futex #{futex.inspect} with value #{expected}") + @selector.futex_wait(fiber, futex, expected) + end end # Wrap the given selector with debugging. diff --git a/lib/io/event/futex.rb b/lib/io/event/futex.rb new file mode 100644 index 00000000..6f057bc9 --- /dev/null +++ b/lib/io/event/futex.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +class IO + module Event + # Represents a Linux futex backed by an aligned 32-bit word in an {IO::Buffer}. + # + # This class is only defined when the native io_uring selector supports + # `IORING_OP_FUTEX_WAIT`. + class Futex + # Wait while the futex contains the expected value. + # + # @asynchronous + # @parameter expected [Integer] The value that must still be present before waiting. + # @returns [Boolean] `true` when woken, or `false` when the value had already changed. + # @raises [NotImplementedError] If the current fiber scheduler does not support futex waits. + def wait(expected = value) + scheduler = Fiber.scheduler + + unless scheduler&.respond_to?(:futex_wait) + raise NotImplementedError, "The current fiber scheduler does not support futex waits!" + end + + scheduler.futex_wait(self, expected) + end + end + end +end diff --git a/releases.md b/releases.md index 5b186595..6e836edc 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Add `IO::Event::Futex` on Linux systems with io_uring futex support, providing an asynchronous shared-memory notification primitive. + ## v1.22.1 - Fix an infinite loop in the ready queue flush when a queued fiber is resumed out of band, e.g. by a stale `unblock` racing a timeout, while another fiber re-queues itself on every iteration. @@ -17,7 +21,6 @@ ## v1.20.0 - Add compatibility with Ruby 4.1's fiber scheduler interface version 4. Buffered IO operations now use `(offset, length)`, perform a single transfer of at most `length` bytes, return short transfers directly, and report `-EAGAIN` without waiting. Earlier Ruby versions retain the existing minimum-progress behavior. - ## v1.19.5 - Preserve the original exception or non-local control flow when `IO::Event::WorkerPool` cancellation interrupts a blocked fiber, while still cancelling and draining the in-flight blocking operation before returning control to Ruby. diff --git a/test/io/event/futex.rb b/test/io/event/futex.rb new file mode 100644 index 00000000..a1d1900a --- /dev/null +++ b/test/io/event/futex.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Samuel Williams. + +require "io/event" + +return unless defined?(IO::Event::Futex) + +describe IO::Event::Futex do + let(:buffer) {IO::Buffer.new(8)} + let(:futex) {subject.new(buffer)} + + with "#value" do + it "stores and loads the value atomically" do + futex.value = 42 + expect(futex.value).to be == 42 + end + end + + with "#increment" do + it "increments the value" do + expect(futex.increment).to be == 1 + expect(futex.increment(2)).to be == 3 + end + end + + with "offset:" do + it "can address independent words in one buffer" do + first = subject.new(buffer, offset: 0) + second = subject.new(buffer, offset: 4) + + first.value = 1 + second.value = 2 + + expect(first.value).to be == 1 + expect(second.value).to be == 2 + end + + it "rejects unaligned offsets" do + expect do + subject.new(buffer, offset: 1) + end.to raise_exception(ArgumentError) + end + + it "rejects offsets outside the buffer" do + expect do + subject.new(buffer, offset: 8) + end.to raise_exception(RangeError) + end + end + + with "#wait" do + it "requires scheduler support" do + expect do + futex.wait(0) + end.to raise_exception(NotImplementedError) + end + + it "waits asynchronously for a signal" do + selector = IO::Event::Selector::URing.new(Fiber.current) + result = nil + + fiber = Fiber.new do + result = selector.futex_wait(Fiber.current, futex, 0) + end + fiber.transfer + + thread = Thread.new do + sleep 0.01 + futex.signal + end + + selector.select(1) + thread.join + + expect(result).to be == true + expect(futex.value).to be == 1 + ensure + selector&.close + thread&.join + end + + it "does not wait when the value has changed" do + selector = IO::Event::Selector::URing.new(Fiber.current) + futex.value = 1 + result = nil + + fiber = Fiber.new do + result = selector.futex_wait(Fiber.current, futex, 0) + end + fiber.transfer + selector.select(1) + + expect(result).to be == false + ensure + selector&.close + end + end +end From 14567c1d44872e75107c9297d02955084a133cc3 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 21 Aug 2026 22:17:22 +1200 Subject: [PATCH 02/22] Allow multiple futexes per buffer Signed-off-by: Samuel Williams --- ext/io/event/selector/uring.c | 6 +----- lib/io/event/futex.rb | 3 +++ 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/ext/io/event/selector/uring.c b/ext/io/event/selector/uring.c index d961fcff..e5e3816b 100644 --- a/ext/io/event/selector/uring.c +++ b/ext/io/event/selector/uring.c @@ -65,9 +65,7 @@ static void IO_Event_Futex_compact(void *_futex) { } static void IO_Event_Futex_free(void *_futex) { - struct IO_Event_Futex *futex = _futex; - if (!NIL_P(futex->buffer)) rb_io_buffer_try_unlock(futex->buffer); - xfree(futex); + xfree(_futex); } static size_t IO_Event_Futex_size(const void *_futex) { @@ -123,8 +121,6 @@ static VALUE IO_Event_Futex_initialize(int argc, VALUE *argv, VALUE self) { struct IO_Event_Futex *futex = NULL; TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); - if (!NIL_P(futex->buffer)) rb_io_buffer_unlock(futex->buffer); - rb_io_buffer_lock(buffer); RB_OBJ_WRITE(self, &futex->buffer, buffer); futex->address = address; diff --git a/lib/io/event/futex.rb b/lib/io/event/futex.rb index 6f057bc9..16565605 100644 --- a/lib/io/event/futex.rb +++ b/lib/io/event/futex.rb @@ -9,6 +9,9 @@ module Event # # This class is only defined when the native io_uring selector supports # `IORING_OP_FUTEX_WAIT`. + # + # The buffer must not be explicitly freed or resized while a futex refers + # to it. The futex retains the buffer so it cannot be garbage collected. class Futex # Wait while the futex contains the expected value. # From 16deb452d03070fbdc5c358a77b32981c08423c7 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 21 Aug 2026 22:33:32 +1200 Subject: [PATCH 03/22] Add vector and blocking futex waits --- .github/workflows/test-futex.yaml | 2 +- ext/extconf.rb | 1 + ext/io/event/selector/uring.c | 230 +++++++++++++++++++++++++++- fixtures/io/event/test_scheduler.rb | 11 ++ lib/io/event/debug/selector.rb | 6 + lib/io/event/futex.rb | 15 -- releases.md | 2 +- test/io/event/futex.rb | 147 +++++++++++++++++- 8 files changed, 388 insertions(+), 26 deletions(-) diff --git a/.github/workflows/test-futex.yaml b/.github/workflows/test-futex.yaml index 7f0baf53..fcc03a68 100644 --- a/.github/workflows/test-futex.yaml +++ b/.github/workflows/test-futex.yaml @@ -34,5 +34,5 @@ jobs: bundle exec ruby extconf.rb make cd .. - bundle exec ruby -e 'require "io/event"; abort "IO::Event::Futex is unavailable!" unless defined?(IO::Event::Futex)' + bundle exec ruby -e 'require "io/event"; abort "IO::Event::Futex is unavailable!" unless defined?(IO::Event::Futex); selector = IO::Event::Selector::URing.new(Fiber.current); abort "futex_waitv is unavailable!" unless selector.respond_to?(:futex_waitv)' bundle exec sus test/io/event/futex.rb diff --git a/ext/extconf.rb b/ext/extconf.rb index 3a1f4ad0..94b79913 100755 --- a/ext/extconf.rb +++ b/ext/extconf.rb @@ -35,6 +35,7 @@ if have_library("uring") and have_header("liburing.h") have_func("io_uring_prep_waitid", "liburing.h") have_func("io_uring_prep_futex_wait", "liburing.h") + have_func("io_uring_prep_futex_waitv", "liburing.h") $srcs << "io/event/selector/uring.c" end diff --git a/ext/io/event/selector/uring.c b/ext/io/event/selector/uring.c index e5e3816b..151eb495 100644 --- a/ext/io/event/selector/uring.c +++ b/ext/io/event/selector/uring.c @@ -17,13 +17,21 @@ #include -#if defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) +#if defined(__linux__) && defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) #include #include #ifndef FUTEX2_SIZE_U32 #define FUTEX2_SIZE_U32 2 #endif + +#ifndef FUTEX_32 +#define FUTEX_32 2 +#endif + +#ifndef FUTEX_WAITV_MAX +#define FUTEX_WAITV_MAX 128 +#endif #endif // `io_uring` support for `IORING_OP_WAITID` was introduced in Linux 6.7. When available, we use it to wait for process exit directly in the ring, instead of polling on a pidfd. @@ -43,7 +51,7 @@ enum { enum {URING_ENTRIES = 64}; -#if defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) +#if defined(__linux__) && defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) #pragma mark - Futex Data Type @@ -181,6 +189,138 @@ static uint32_t *IO_Event_Futex_address(VALUE self) { return futex->address; } +static ID id_futex_wait; +static ID id_futex_waitv; + +struct IO_Event_Futex_BlockingWait { + uint32_t *address; + uint32_t expected; + int result; + int error; +}; + +static void *IO_Event_Futex_blocking_wait_without_gvl(void *_arguments) { + struct IO_Event_Futex_BlockingWait *arguments = _arguments; + arguments->result = syscall(SYS_futex, arguments->address, FUTEX_WAIT, arguments->expected, NULL, NULL, 0); + arguments->error = arguments->result < 0 ? errno : 0; + return NULL; +} + +static VALUE IO_Event_Futex_blocking_wait(VALUE self, VALUE expected_value) { + struct IO_Event_Futex_BlockingWait arguments = { + .address = IO_Event_Futex_address(self), + .expected = NUM2UINT(expected_value), + }; + + rb_thread_call_without_gvl(IO_Event_Futex_blocking_wait_without_gvl, &arguments, RUBY_UBF_IO, 0); + + if (arguments.result == 0) { + return Qtrue; + } else if (arguments.error == EAGAIN) { + return Qfalse; + } else { + rb_syserr_fail(arguments.error, "IO_Event_Futex_blocking_wait:futex"); + } + + return Qfalse; +} + +#ifdef SYS_futex_waitv + +struct IO_Event_Futex_BlockingWaitV { + struct futex_waitv *vector; + size_t count; + int result; + int error; +}; + +static void *IO_Event_Futex_blocking_waitv_without_gvl(void *_arguments) { + struct IO_Event_Futex_BlockingWaitV *arguments = _arguments; + arguments->result = syscall(SYS_futex_waitv, arguments->vector, arguments->count, 0, NULL, CLOCK_MONOTONIC); + arguments->error = arguments->result < 0 ? errno : 0; + return NULL; +} + +static VALUE IO_Event_Futex_blocking_waitv(VALUE klass, VALUE entries) { + (void)klass; + entries = rb_Array(entries); + long count = RARRAY_LEN(entries); + if (count < 1 || count > FUTEX_WAITV_MAX) { + rb_raise(rb_eArgError, "Futex vector must contain between 1 and %d entries!", FUTEX_WAITV_MAX); + } + + struct futex_waitv *vector = ALLOCA_N(struct futex_waitv, count); + for (long index = 0; index < count; index++) { + VALUE entry = rb_Array(RARRAY_AREF(entries, index)); + if (RARRAY_LEN(entry) != 2) { + rb_raise(rb_eArgError, "Each futex vector entry must contain a futex and its expected value!"); + } + + VALUE futex = RARRAY_AREF(entry, 0); + vector[index].val = NUM2UINT(RARRAY_AREF(entry, 1)); + vector[index].uaddr = (uintptr_t)IO_Event_Futex_address(futex); + vector[index].flags = FUTEX_32; + vector[index].__reserved = 0; + } + + struct IO_Event_Futex_BlockingWaitV arguments = { + .vector = vector, + .count = count, + }; + + rb_thread_call_without_gvl(IO_Event_Futex_blocking_waitv_without_gvl, &arguments, RUBY_UBF_IO, 0); + RB_GC_GUARD(entries); + + if (arguments.result >= 0) { + return INT2NUM(arguments.result); + } else if (arguments.error == EAGAIN) { + return Qnil; + } else { + rb_syserr_fail(arguments.error, "IO_Event_Futex_blocking_waitv:futex_waitv"); + } + + return Qnil; +} + +#endif + +static VALUE IO_Event_Futex_wait(int argc, VALUE *argv, VALUE self) { + VALUE expected_value; + rb_scan_args(argc, argv, "01", &expected_value); + + if (argc == 0) { + expected_value = IO_Event_Futex_value(self); + } + + VALUE scheduler = rb_fiber_scheduler_current(); + if (NIL_P(scheduler)) { + return IO_Event_Futex_blocking_wait(self, expected_value); + } + + if (!rb_respond_to(scheduler, id_futex_wait)) { + rb_raise(rb_eNotImpError, "The current fiber scheduler does not support futex waits!"); + } + + return rb_funcall(scheduler, id_futex_wait, 2, self, expected_value); +} + +#ifdef SYS_futex_waitv + +static VALUE IO_Event_Futex_wait_any(VALUE klass, VALUE entries) { + VALUE scheduler = rb_fiber_scheduler_current(); + if (NIL_P(scheduler)) { + return IO_Event_Futex_blocking_waitv(klass, entries); + } + + if (!rb_respond_to(scheduler, id_futex_waitv)) { + rb_raise(rb_eNotImpError, "The current fiber scheduler does not support vector futex waits!"); + } + + return rb_funcall(scheduler, id_futex_waitv, 1, entries); +} + +#endif + #endif #pragma mark - Data Type @@ -953,7 +1093,7 @@ VALUE IO_Event_Selector_URing_process_wait(VALUE self, VALUE fiber, VALUE _pid, return rb_ensure(process_wait_transfer, (VALUE)&process_wait_arguments, process_wait_ensure, (VALUE)&process_wait_arguments); } -#if defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) +#if defined(__linux__) && defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) #pragma mark - Futex Wait @@ -1022,6 +1162,69 @@ static VALUE IO_Event_Selector_URing_futex_wait(VALUE self, VALUE fiber, VALUE f return rb_ensure(futex_wait_transfer, (VALUE)&arguments, futex_wait_ensure, (VALUE)&arguments); } +#ifdef HAVE_IO_URING_PREP_FUTEX_WAITV + +static VALUE futex_waitv_transfer(VALUE _arguments) { + struct futex_wait_arguments *arguments = (struct futex_wait_arguments *)_arguments; + IO_Event_Selector_loop_yield(&arguments->selector->backend); + + int32_t result = arguments->waiting->result; + if (result >= 0) { + return INT2NUM(result); + } else if (result == -EAGAIN) { + return Qnil; + } else { + rb_syserr_fail(-result, "futex_waitv_transfer:io_uring_futex_waitv"); + } + + return Qnil; +} + +static VALUE IO_Event_Selector_URing_futex_waitv(VALUE self, VALUE fiber, VALUE entries) { + struct IO_Event_Selector_URing *selector = NULL; + TypedData_Get_Struct(self, struct IO_Event_Selector_URing, &IO_Event_Selector_URing_Type, selector); + + entries = rb_Array(entries); + long count = RARRAY_LEN(entries); + if (count < 1 || count > FUTEX_WAITV_MAX) { + rb_raise(rb_eArgError, "Futex vector must contain between 1 and %d entries!", FUTEX_WAITV_MAX); + } + + struct futex_waitv *vector = ALLOCA_N(struct futex_waitv, count); + for (long index = 0; index < count; index++) { + VALUE entry = rb_Array(RARRAY_AREF(entries, index)); + if (RARRAY_LEN(entry) != 2) { + rb_raise(rb_eArgError, "Each futex vector entry must contain a futex and its expected value!"); + } + + VALUE futex = RARRAY_AREF(entry, 0); + vector[index].val = NUM2UINT(RARRAY_AREF(entry, 1)); + vector[index].uaddr = (uintptr_t)IO_Event_Futex_address(futex); + vector[index].flags = FUTEX_32; + vector[index].__reserved = 0; + } + + struct IO_Event_Selector_URing_Waiting waiting = { + .fiber = fiber, + }; + RB_OBJ_WRITTEN(self, Qundef, fiber); + + struct IO_Event_Selector_URing_Completion *completion = IO_Event_Selector_URing_Completion_acquire(selector, &waiting); + struct futex_wait_arguments arguments = { + .selector = selector, + .waiting = &waiting, + }; + + struct io_uring_sqe *sqe = io_get_sqe(selector); + io_uring_prep_futex_waitv(sqe, vector, count, 0); + io_uring_sqe_set_data(sqe, completion); + io_uring_submit_pending(selector); + + return rb_ensure(futex_waitv_transfer, (VALUE)&arguments, futex_wait_ensure, (VALUE)&arguments); +} + +#endif + #endif #pragma mark - IO#wait @@ -1945,6 +2148,7 @@ VALUE IO_Event_Selector_URing_wakeup(VALUE self) { #pragma mark - Native Methods static int IO_Event_Selector_URing_futex_supported = 0; +static int IO_Event_Selector_URing_futex_waitv_supported = 0; static int IO_Event_Selector_URing_supported_p(void) { struct io_uring ring; @@ -1977,10 +2181,13 @@ static int IO_Event_Selector_URing_supported_p(void) { return 0; } -#if defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) +#if defined(__linux__) && defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) struct io_uring_probe *probe = io_uring_get_probe_ring(&ring); if (probe) { IO_Event_Selector_URing_futex_supported = io_uring_opcode_supported(probe, IORING_OP_FUTEX_WAIT); +#ifdef HAVE_IO_URING_PREP_FUTEX_WAITV + IO_Event_Selector_URing_futex_waitv_supported = io_uring_opcode_supported(probe, IORING_OP_FUTEX_WAITV); +#endif io_uring_free_probe(probe); } #endif @@ -2034,7 +2241,7 @@ void Init_IO_Event_Selector_URing(VALUE IO_Event, VALUE IO_Event_Selector) { rb_define_method(IO_Event_Selector_URing, "process_wait", IO_Event_Selector_URing_process_wait, 3); -#if defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) +#if defined(__linux__) && defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) if (IO_Event_Selector_URing_futex_supported) { VALUE IO_Event_Futex = rb_define_class_under(IO_Event, "Futex", rb_cObject); rb_define_alloc_func(IO_Event_Futex, IO_Event_Futex_allocate); @@ -2044,10 +2251,23 @@ void Init_IO_Event_Selector_URing(VALUE IO_Event, VALUE IO_Event_Selector) { rb_define_method(IO_Event_Futex, "increment", IO_Event_Futex_increment, -1); rb_define_method(IO_Event_Futex, "wake", IO_Event_Futex_wake, -1); rb_define_method(IO_Event_Futex, "signal", IO_Event_Futex_signal, -1); + rb_define_method(IO_Event_Futex, "wait", IO_Event_Futex_wait, -1); + +#ifdef SYS_futex_waitv + rb_define_singleton_method(IO_Event_Futex, "wait_any", IO_Event_Futex_wait_any, 1); +#endif id_offset = rb_intern("offset"); + id_futex_wait = rb_intern("futex_wait"); + id_futex_waitv = rb_intern("futex_waitv"); rb_define_method(IO_Event_Selector_URing, "futex_wait", IO_Event_Selector_URing_futex_wait, 3); + +#ifdef HAVE_IO_URING_PREP_FUTEX_WAITV + if (IO_Event_Selector_URing_futex_waitv_supported) { + rb_define_method(IO_Event_Selector_URing, "futex_waitv", IO_Event_Selector_URing_futex_waitv, 3); + } +#endif } #endif } diff --git a/fixtures/io/event/test_scheduler.rb b/fixtures/io/event/test_scheduler.rb index b9ea8a40..e372ea80 100644 --- a/fixtures/io/event/test_scheduler.rb +++ b/fixtures/io/event/test_scheduler.rb @@ -40,7 +40,18 @@ def io_close(descriptor) # Wait while the futex contains the expected value. def futex_wait(futex, expected) + @blocked += 1 @selector.futex_wait(Fiber.current, futex, expected) + ensure + @blocked -= 1 + end + + # Wait until any futex value changes. + def futex_waitv(entries) + @blocked += 1 + @selector.futex_waitv(Fiber.current, entries) + ensure + @blocked -= 1 end end diff --git a/lib/io/event/debug/selector.rb b/lib/io/event/debug/selector.rb index 1587b8b4..5bcda250 100644 --- a/lib/io/event/debug/selector.rb +++ b/lib/io/event/debug/selector.rb @@ -25,6 +25,12 @@ def futex_wait(fiber, futex, expected) log("Waiting for futex #{futex.inspect} with value #{expected}") @selector.futex_wait(fiber, futex, expected) end + + # Wait for any futex value to change, forwarded to the underlying selector. + def futex_waitv(fiber, entries) + log("Waiting for futex vector #{entries.inspect}") + @selector.futex_waitv(fiber, entries) + end end # Wrap the given selector with debugging. diff --git a/lib/io/event/futex.rb b/lib/io/event/futex.rb index 16565605..dc091a19 100644 --- a/lib/io/event/futex.rb +++ b/lib/io/event/futex.rb @@ -13,21 +13,6 @@ module Event # The buffer must not be explicitly freed or resized while a futex refers # to it. The futex retains the buffer so it cannot be garbage collected. class Futex - # Wait while the futex contains the expected value. - # - # @asynchronous - # @parameter expected [Integer] The value that must still be present before waiting. - # @returns [Boolean] `true` when woken, or `false` when the value had already changed. - # @raises [NotImplementedError] If the current fiber scheduler does not support futex waits. - def wait(expected = value) - scheduler = Fiber.scheduler - - unless scheduler&.respond_to?(:futex_wait) - raise NotImplementedError, "The current fiber scheduler does not support futex waits!" - end - - scheduler.futex_wait(self, expected) - end end end end diff --git a/releases.md b/releases.md index 6e836edc..9b7c96aa 100644 --- a/releases.md +++ b/releases.md @@ -2,7 +2,7 @@ ## Unreleased - - Add `IO::Event::Futex` on Linux systems with io_uring futex support, providing an asynchronous shared-memory notification primitive. + - Add `IO::Event::Futex` on Linux systems with io_uring futex support, including asynchronous single and vector waits over shared memory. ## v1.22.1 diff --git a/test/io/event/futex.rb b/test/io/event/futex.rb index a1d1900a..5e2ceb06 100644 --- a/test/io/event/futex.rb +++ b/test/io/event/futex.rb @@ -4,6 +4,7 @@ # Copyright, 2026, by Samuel Williams. require "io/event" +require "io/event/test_scheduler" return unless defined?(IO::Event::Futex) @@ -51,10 +52,21 @@ end with "#wait" do - it "requires scheduler support" do - expect do - futex.wait(0) - end.to raise_exception(NotImplementedError) + it "waits without blocking other Ruby threads when no scheduler is installed" do + thread = Thread.new do + sleep 0.01 + futex.signal + end + + expect(futex.wait(0)).to be == true + expect(futex.value).to be == 1 + ensure + thread&.join + end + + it "does not wait without a scheduler when the value has changed" do + futex.value = 1 + expect(futex.wait(0)).to be == false end it "waits asynchronously for a signal" do @@ -81,6 +93,29 @@ thread&.join end + it "uses the current scheduler" do + selector = IO::Event::Selector::URing.new(Fiber.current) + scheduler = IO::Event::TestScheduler.new(selector: selector) + result = nil + + Fiber.set_scheduler(scheduler) + Fiber.schedule do + result = futex.wait(0) + end + + thread = Thread.new do + sleep 0.01 + futex.signal + end + + scheduler.run + + expect(result).to be == true + ensure + Fiber.set_scheduler(nil) + thread&.join + end + it "does not wait when the value has changed" do selector = IO::Event::Selector::URing.new(Fiber.current) futex.value = 1 @@ -97,4 +132,108 @@ selector&.close end end + + with ".wait_any" do + it "waits without blocking other Ruby threads when no scheduler is installed" do + first = subject.new(buffer, offset: 0) + second = subject.new(buffer, offset: 4) + + thread = Thread.new do + sleep 0.01 + second.signal + end + + expect(subject.wait_any([[first, 0], [second, 0]])).to be == 1 + ensure + thread&.join + end + + it "does not wait without a scheduler when a value has changed" do + first = subject.new(buffer, offset: 0) + second = subject.new(buffer, offset: 4) + second.value = 1 + + expect(subject.wait_any([[first, 0], [second, 0]])).to be_nil + end + + it "waits asynchronously for any futex to be signalled" do + selector = IO::Event::Selector::URing.new(Fiber.current) + unless selector.respond_to?(:futex_waitv) + skip "io_uring futex waitv operations are not available" + end + + first = subject.new(buffer, offset: 0) + second = subject.new(buffer, offset: 4) + result = nil + + fiber = Fiber.new do + result = selector.futex_waitv(Fiber.current, [[first, 0], [second, 0]]) + end + fiber.transfer + + thread = Thread.new do + sleep 0.01 + second.signal + end + + selector.select(1) + thread.join + + expect(result).to be == 1 + ensure + selector&.close + thread&.join + end + + it "uses the current scheduler" do + selector = IO::Event::Selector::URing.new(Fiber.current) + unless selector.respond_to?(:futex_waitv) + skip "io_uring futex waitv operations are not available" + end + + scheduler = IO::Event::TestScheduler.new(selector: selector) + first = subject.new(buffer, offset: 0) + second = subject.new(buffer, offset: 4) + result = nil + + Fiber.set_scheduler(scheduler) + Fiber.schedule do + result = subject.wait_any([[first, 0], [second, 0]]) + end + + thread = Thread.new do + sleep 0.01 + second.signal + end + + scheduler.run + + expect(result).to be == 1 + ensure + Fiber.set_scheduler(nil) + thread&.join + end + + it "returns nil when a value has changed" do + selector = IO::Event::Selector::URing.new(Fiber.current) + unless selector.respond_to?(:futex_waitv) + skip "io_uring futex waitv operations are not available" + end + + first = subject.new(buffer, offset: 0) + second = subject.new(buffer, offset: 4) + second.value = 1 + result = :waiting + + fiber = Fiber.new do + result = selector.futex_waitv(Fiber.current, [[first, 0], [second, 0]]) + end + fiber.transfer + selector.select(1) + + expect(result).to be_nil + ensure + selector&.close + end + end end From 55f477dbe23a49d3e7d012b4eb6de7273c5dc56b Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 21 Aug 2026 22:35:26 +1200 Subject: [PATCH 04/22] Fix futex waitv method arity --- ext/io/event/selector/uring.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ext/io/event/selector/uring.c b/ext/io/event/selector/uring.c index 151eb495..3e404899 100644 --- a/ext/io/event/selector/uring.c +++ b/ext/io/event/selector/uring.c @@ -2265,7 +2265,7 @@ void Init_IO_Event_Selector_URing(VALUE IO_Event, VALUE IO_Event_Selector) { #ifdef HAVE_IO_URING_PREP_FUTEX_WAITV if (IO_Event_Selector_URing_futex_waitv_supported) { - rb_define_method(IO_Event_Selector_URing, "futex_waitv", IO_Event_Selector_URing_futex_waitv, 3); + rb_define_method(IO_Event_Selector_URing, "futex_waitv", IO_Event_Selector_URing_futex_waitv, 2); } #endif } From fbb023b80a76c8c06d697cf9539ffb81e522d012 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 21 Aug 2026 23:02:12 +1200 Subject: [PATCH 05/22] Separate the Linux futex implementation --- .github/workflows/test-futex.yaml | 2 +- ext/extconf.rb | 7 +- ext/io/event/event.c | 7 +- ext/io/event/futex.c | 301 ++++++++++++++++++++++++++++ ext/io/event/futex.h | 30 +++ ext/io/event/selector/uring.c | 316 +----------------------------- ext/io/event/selector/uring.h | 2 +- lib/io/event.rb | 4 - lib/io/event/futex.rb | 18 -- releases.md | 2 +- test/io/event/futex.rb | 209 +++++++++++--------- 11 files changed, 468 insertions(+), 430 deletions(-) create mode 100644 ext/io/event/futex.c create mode 100644 ext/io/event/futex.h delete mode 100644 lib/io/event/futex.rb diff --git a/.github/workflows/test-futex.yaml b/.github/workflows/test-futex.yaml index fcc03a68..c72d66ca 100644 --- a/.github/workflows/test-futex.yaml +++ b/.github/workflows/test-futex.yaml @@ -34,5 +34,5 @@ jobs: bundle exec ruby extconf.rb make cd .. - bundle exec ruby -e 'require "io/event"; abort "IO::Event::Futex is unavailable!" unless defined?(IO::Event::Futex); selector = IO::Event::Selector::URing.new(Fiber.current); abort "futex_waitv is unavailable!" unless selector.respond_to?(:futex_waitv)' + bundle exec ruby -e 'require "io/event"; abort "IO::Event::Futex.wait_any is unavailable!" unless defined?(IO::Event::Futex) && IO::Event::Futex.respond_to?(:wait_any); selector = IO::Event::Selector::URing.new(Fiber.current); abort "futex_waitv is unavailable!" unless selector.respond_to?(:futex_waitv)' bundle exec sus test/io/event/futex.rb diff --git a/ext/extconf.rb b/ext/extconf.rb index 94b79913..2a87886b 100755 --- a/ext/extconf.rb +++ b/ext/extconf.rb @@ -31,6 +31,11 @@ have_func("rb_ext_ractor_safe") have_func("&rb_fiber_transfer") +have_io_buffer = have_header("ruby/io/buffer.h") + +if RUBY_PLATFORM.include?("linux") && have_io_buffer && have_header("linux/futex.h") && have_header("sys/syscall.h") + $srcs << "io/event/futex.c" +end if have_library("uring") and have_header("liburing.h") have_func("io_uring_prep_waitid", "liburing.h") @@ -59,8 +64,6 @@ have_func("&rb_fiber_raise") have_func("epoll_pwait2(0, 0, 0, 0, 0)", "sys/epoll.h") if enable_config("epoll_pwait2", true) -have_header("ruby/io/buffer.h") - # Feature detection for blocking operation support if have_func("rb_fiber_scheduler_blocking_operation_extract") # Feature detection for pthread support (needed for WorkerPool) diff --git a/ext/io/event/event.c b/ext/io/event/event.c index 506b9cc8..eb49c059 100644 --- a/ext/io/event/event.c +++ b/ext/io/event/event.c @@ -3,6 +3,7 @@ #include "event.h" #include "fiber.h" +#include "futex.h" #include "selector/selector.h" void Init_IO_Event(void) @@ -15,6 +16,10 @@ void Init_IO_Event(void) Init_IO_Event_Fiber(IO_Event); + #ifdef IO_EVENT_FUTEX + Init_IO_Event_Futex(IO_Event); + #endif + #ifdef HAVE_IO_EVENT_WORKER_POOL Init_IO_Event_WorkerPool(IO_Event); #endif @@ -23,7 +28,7 @@ void Init_IO_Event(void) Init_IO_Event_Selector(IO_Event_Selector); #ifdef IO_EVENT_SELECTOR_URING - Init_IO_Event_Selector_URing(IO_Event, IO_Event_Selector); + Init_IO_Event_Selector_URing(IO_Event_Selector); #endif #ifdef IO_EVENT_SELECTOR_EPOLL diff --git a/ext/io/event/futex.c b/ext/io/event/futex.c new file mode 100644 index 00000000..3201a475 --- /dev/null +++ b/ext/io/event/futex.c @@ -0,0 +1,301 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +#include "futex.h" + +#ifdef IO_EVENT_FUTEX + +#include +#include +#include +#include + +#include +#include +#include + +struct IO_Event_Futex { + VALUE buffer; + uint32_t *address; +}; + +static const rb_data_type_t IO_Event_Futex_Type; + +static void IO_Event_Futex_mark(void *_futex) { + struct IO_Event_Futex *futex = _futex; + rb_gc_mark_movable(futex->buffer); +} + +static void IO_Event_Futex_compact(void *_futex) { + struct IO_Event_Futex *futex = _futex; + futex->buffer = rb_gc_location(futex->buffer); +} + +static void IO_Event_Futex_free(void *_futex) { + xfree(_futex); +} + +static size_t IO_Event_Futex_size(const void *_futex) { + return sizeof(struct IO_Event_Futex); +} + +static const rb_data_type_t IO_Event_Futex_Type = { + .wrap_struct_name = "IO::Event::Futex", + .function = { + .dmark = IO_Event_Futex_mark, + .dcompact = IO_Event_Futex_compact, + .dfree = IO_Event_Futex_free, + .dsize = IO_Event_Futex_size, + }, + .flags = RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED, +}; + +static VALUE IO_Event_Futex_allocate(VALUE klass) { + struct IO_Event_Futex *futex = NULL; + VALUE instance = TypedData_Make_Struct(klass, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + futex->buffer = Qnil; + futex->address = NULL; + return instance; +} + +static ID id_offset; +static ID id_futex_wait; +static ID id_futex_waitv; + +static VALUE IO_Event_Futex_initialize(int argc, VALUE *argv, VALUE self) { + VALUE buffer, options; + rb_scan_args(argc, argv, "1:", &buffer, &options); + + VALUE offset_value = Qundef; + if (!NIL_P(options)) { + ID keys[] = {id_offset}; + VALUE values[1]; + rb_get_kwargs(options, keys, 0, 1, values); + offset_value = values[0]; + } + + size_t offset = offset_value == Qundef ? 0 : NUM2SIZET(offset_value); + void *base = NULL; + size_t size = 0; + rb_io_buffer_get_bytes_for_writing(buffer, &base, &size); + + if (offset > size || size - offset < sizeof(uint32_t)) { + rb_raise(rb_eRangeError, "Futex offset exceeds the buffer size!"); + } + + uint32_t *address = (uint32_t *)((char *)base + offset); + if ((uintptr_t)address % sizeof(uint32_t) != 0) { + rb_raise(rb_eArgError, "Futex address must be aligned to 4 bytes!"); + } + + struct IO_Event_Futex *futex = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + RB_OBJ_WRITE(self, &futex->buffer, buffer); + futex->address = address; + + return self; +} + +static VALUE IO_Event_Futex_value(VALUE self) { + struct IO_Event_Futex *futex = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + return UINT2NUM(__atomic_load_n(futex->address, __ATOMIC_ACQUIRE)); +} + +static VALUE IO_Event_Futex_set_value(VALUE self, VALUE value) { + struct IO_Event_Futex *futex = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + uint32_t converted = NUM2UINT(value); + __atomic_store_n(futex->address, converted, __ATOMIC_RELEASE); + return value; +} + +static VALUE IO_Event_Futex_increment(int argc, VALUE *argv, VALUE self) { + VALUE amount_value; + rb_scan_args(argc, argv, "01", &amount_value); + uint32_t amount = NIL_P(amount_value) ? 1 : NUM2UINT(amount_value); + + struct IO_Event_Futex *futex = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + uint32_t value = __atomic_add_fetch(futex->address, amount, __ATOMIC_ACQ_REL); + return UINT2NUM(value); +} + +static VALUE IO_Event_Futex_wake(int argc, VALUE *argv, VALUE self) { + VALUE count_value; + rb_scan_args(argc, argv, "01", &count_value); + int count = NIL_P(count_value) ? 1 : NUM2INT(count_value); + if (count < 0) rb_raise(rb_eArgError, "Wake count must be non-negative!"); + + int result = syscall(SYS_futex, IO_Event_Futex_address(self), FUTEX_WAKE, count, NULL, NULL, 0); + if (result < 0) rb_sys_fail("IO_Event_Futex_wake:futex"); + return INT2NUM(result); +} + +static VALUE IO_Event_Futex_signal(int argc, VALUE *argv, VALUE self) { + VALUE count_value; + rb_scan_args(argc, argv, "01", &count_value); + + VALUE value = IO_Event_Futex_increment(0, NULL, self); + VALUE arguments[] = {NIL_P(count_value) ? INT2NUM(1) : count_value}; + IO_Event_Futex_wake(1, arguments, self); + return value; +} + +uint32_t *IO_Event_Futex_address(VALUE self) { + struct IO_Event_Futex *futex = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + return futex->address; +} + +struct IO_Event_Futex_BlockingWait { + uint32_t *address; + uint32_t expected; + int result; + int error; +}; + +static void *IO_Event_Futex_blocking_wait_without_gvl(void *_arguments) { + struct IO_Event_Futex_BlockingWait *arguments = _arguments; + arguments->result = syscall(SYS_futex, arguments->address, FUTEX_WAIT, arguments->expected, NULL, NULL, 0); + arguments->error = arguments->result < 0 ? errno : 0; + return NULL; +} + +static VALUE IO_Event_Futex_blocking_wait(VALUE self, VALUE expected_value) { + struct IO_Event_Futex_BlockingWait arguments = { + .address = IO_Event_Futex_address(self), + .expected = NUM2UINT(expected_value), + }; + + rb_thread_call_without_gvl(IO_Event_Futex_blocking_wait_without_gvl, &arguments, RUBY_UBF_IO, 0); + + if (arguments.result == 0) { + return Qtrue; + } else if (arguments.error == EAGAIN) { + return Qfalse; + } else { + rb_syserr_fail(arguments.error, "IO_Event_Futex_blocking_wait:futex"); + } + + return Qfalse; +} + +#ifdef SYS_futex_waitv + +struct IO_Event_Futex_BlockingWaitV { + struct futex_waitv *vector; + size_t count; + int result; + int error; +}; + +static void *IO_Event_Futex_blocking_waitv_without_gvl(void *_arguments) { + struct IO_Event_Futex_BlockingWaitV *arguments = _arguments; + arguments->result = syscall(SYS_futex_waitv, arguments->vector, arguments->count, 0, NULL, CLOCK_MONOTONIC); + arguments->error = arguments->result < 0 ? errno : 0; + return NULL; +} + +static VALUE IO_Event_Futex_blocking_waitv(VALUE entries) { + entries = rb_Array(entries); + long count = RARRAY_LEN(entries); + if (count < 1 || count > FUTEX_WAITV_MAX) { + rb_raise(rb_eArgError, "Futex vector must contain between 1 and %d entries!", FUTEX_WAITV_MAX); + } + + struct futex_waitv *vector = ALLOCA_N(struct futex_waitv, count); + for (long index = 0; index < count; index++) { + VALUE entry = rb_Array(RARRAY_AREF(entries, index)); + if (RARRAY_LEN(entry) != 2) { + rb_raise(rb_eArgError, "Each futex vector entry must contain a futex and its expected value!"); + } + + VALUE futex = RARRAY_AREF(entry, 0); + vector[index].val = NUM2UINT(RARRAY_AREF(entry, 1)); + vector[index].uaddr = (uintptr_t)IO_Event_Futex_address(futex); + vector[index].flags = FUTEX_32; + vector[index].__reserved = 0; + } + + struct IO_Event_Futex_BlockingWaitV arguments = { + .vector = vector, + .count = count, + }; + + rb_thread_call_without_gvl(IO_Event_Futex_blocking_waitv_without_gvl, &arguments, RUBY_UBF_IO, 0); + RB_GC_GUARD(entries); + + if (arguments.result >= 0) { + return INT2NUM(arguments.result); + } else if (arguments.error == EAGAIN) { + return Qnil; + } else { + rb_syserr_fail(arguments.error, "IO_Event_Futex_blocking_waitv:futex_waitv"); + } + + return Qnil; +} + +#endif + +static VALUE IO_Event_Futex_wait(int argc, VALUE *argv, VALUE self) { + VALUE expected_value; + rb_scan_args(argc, argv, "01", &expected_value); + + if (argc == 0) { + expected_value = IO_Event_Futex_value(self); + } + + VALUE scheduler = rb_fiber_scheduler_current(); + if (NIL_P(scheduler)) { + return IO_Event_Futex_blocking_wait(self, expected_value); + } + + if (!rb_respond_to(scheduler, id_futex_wait)) { + rb_raise(rb_eNotImpError, "The current fiber scheduler does not support futex waits!"); + } + + return rb_funcall(scheduler, id_futex_wait, 2, self, expected_value); +} + +#ifdef SYS_futex_waitv + +static VALUE IO_Event_Futex_wait_any(VALUE klass, VALUE entries) { + (void)klass; + VALUE scheduler = rb_fiber_scheduler_current(); + if (NIL_P(scheduler)) { + return IO_Event_Futex_blocking_waitv(entries); + } + + if (!rb_respond_to(scheduler, id_futex_waitv)) { + rb_raise(rb_eNotImpError, "The current fiber scheduler does not support vector futex waits!"); + } + + return rb_funcall(scheduler, id_futex_waitv, 1, entries); +} + +#endif + +void Init_IO_Event_Futex(VALUE IO_Event) { + VALUE IO_Event_Futex = rb_define_class_under(IO_Event, "Futex", rb_cObject); + rb_define_alloc_func(IO_Event_Futex, IO_Event_Futex_allocate); + rb_define_method(IO_Event_Futex, "initialize", IO_Event_Futex_initialize, -1); + rb_define_method(IO_Event_Futex, "value", IO_Event_Futex_value, 0); + rb_define_method(IO_Event_Futex, "value=", IO_Event_Futex_set_value, 1); + rb_define_method(IO_Event_Futex, "increment", IO_Event_Futex_increment, -1); + rb_define_method(IO_Event_Futex, "wake", IO_Event_Futex_wake, -1); + rb_define_method(IO_Event_Futex, "signal", IO_Event_Futex_signal, -1); + rb_define_method(IO_Event_Futex, "wait", IO_Event_Futex_wait, -1); + +#ifdef SYS_futex_waitv + rb_define_const(IO_Event_Futex, "WAITV_LIMIT", INT2NUM(FUTEX_WAITV_MAX)); + rb_define_singleton_method(IO_Event_Futex, "wait_any", IO_Event_Futex_wait_any, 1); +#endif + + id_offset = rb_intern("offset"); + id_futex_wait = rb_intern("futex_wait"); + id_futex_waitv = rb_intern("futex_waitv"); +} + +#endif diff --git a/ext/io/event/futex.h b/ext/io/event/futex.h new file mode 100644 index 00000000..efd43b54 --- /dev/null +++ b/ext/io/event/futex.h @@ -0,0 +1,30 @@ +// Released under the MIT License. +// Copyright, 2026, by Samuel Williams. + +#pragma once + +#include + +#if defined(__linux__) && defined(HAVE_RUBY_IO_BUFFER_H) && defined(HAVE_LINUX_FUTEX_H) && defined(HAVE_SYS_SYSCALL_H) + +#define IO_EVENT_FUTEX + +#include +#include + +#ifndef FUTEX2_SIZE_U32 +#define FUTEX2_SIZE_U32 2 +#endif + +#ifndef FUTEX_32 +#define FUTEX_32 2 +#endif + +#ifndef FUTEX_WAITV_MAX +#define FUTEX_WAITV_MAX 128 +#endif + +uint32_t *IO_Event_Futex_address(VALUE self); +void Init_IO_Event_Futex(VALUE IO_Event); + +#endif diff --git a/ext/io/event/selector/uring.c b/ext/io/event/selector/uring.c index 3e404899..3c0edca5 100644 --- a/ext/io/event/selector/uring.c +++ b/ext/io/event/selector/uring.c @@ -3,6 +3,7 @@ #include "uring.h" #include "selector.h" +#include "../futex.h" #include "../list.h" #include "../array.h" @@ -17,23 +18,6 @@ #include -#if defined(__linux__) && defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) -#include -#include - -#ifndef FUTEX2_SIZE_U32 -#define FUTEX2_SIZE_U32 2 -#endif - -#ifndef FUTEX_32 -#define FUTEX_32 2 -#endif - -#ifndef FUTEX_WAITV_MAX -#define FUTEX_WAITV_MAX 128 -#endif -#endif - // `io_uring` support for `IORING_OP_WAITID` was introduced in Linux 6.7. When available, we use it to wait for process exit directly in the ring, instead of polling on a pidfd. #if defined(HAVE_IO_URING_PREP_WAITID) && (LINUX_VERSION_CODE >= KERNEL_VERSION(6,7,0)) #define IO_EVENT_SELECTOR_URING_USE_WAITID @@ -51,278 +35,6 @@ enum { enum {URING_ENTRIES = 64}; -#if defined(__linux__) && defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) - -#pragma mark - Futex Data Type - -struct IO_Event_Futex { - VALUE buffer; - uint32_t *address; -}; - -static const rb_data_type_t IO_Event_Futex_Type; - -static void IO_Event_Futex_mark(void *_futex) { - struct IO_Event_Futex *futex = _futex; - rb_gc_mark_movable(futex->buffer); -} - -static void IO_Event_Futex_compact(void *_futex) { - struct IO_Event_Futex *futex = _futex; - futex->buffer = rb_gc_location(futex->buffer); -} - -static void IO_Event_Futex_free(void *_futex) { - xfree(_futex); -} - -static size_t IO_Event_Futex_size(const void *_futex) { - return sizeof(struct IO_Event_Futex); -} - -static const rb_data_type_t IO_Event_Futex_Type = { - .wrap_struct_name = "IO::Event::Futex", - .function = { - .dmark = IO_Event_Futex_mark, - .dcompact = IO_Event_Futex_compact, - .dfree = IO_Event_Futex_free, - .dsize = IO_Event_Futex_size, - }, - .flags = RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED, -}; - -static VALUE IO_Event_Futex_allocate(VALUE klass) { - struct IO_Event_Futex *futex = NULL; - VALUE instance = TypedData_Make_Struct(klass, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); - futex->buffer = Qnil; - futex->address = NULL; - return instance; -} - -static ID id_offset; - -static VALUE IO_Event_Futex_initialize(int argc, VALUE *argv, VALUE self) { - VALUE buffer, options; - rb_scan_args(argc, argv, "1:", &buffer, &options); - - VALUE offset_value = Qundef; - if (!NIL_P(options)) { - ID keys[] = {id_offset}; - VALUE values[1]; - rb_get_kwargs(options, keys, 0, 1, values); - offset_value = values[0]; - } - - size_t offset = offset_value == Qundef ? 0 : NUM2SIZET(offset_value); - void *base = NULL; - size_t size = 0; - rb_io_buffer_get_bytes_for_writing(buffer, &base, &size); - - if (offset > size || size - offset < sizeof(uint32_t)) { - rb_raise(rb_eRangeError, "Futex offset exceeds the buffer size!"); - } - - uint32_t *address = (uint32_t *)((char *)base + offset); - if ((uintptr_t)address % sizeof(uint32_t) != 0) { - rb_raise(rb_eArgError, "Futex address must be aligned to 4 bytes!"); - } - - struct IO_Event_Futex *futex = NULL; - TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); - RB_OBJ_WRITE(self, &futex->buffer, buffer); - futex->address = address; - - return self; -} - -static VALUE IO_Event_Futex_value(VALUE self) { - struct IO_Event_Futex *futex = NULL; - TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); - return UINT2NUM(__atomic_load_n(futex->address, __ATOMIC_ACQUIRE)); -} - -static VALUE IO_Event_Futex_set_value(VALUE self, VALUE value) { - struct IO_Event_Futex *futex = NULL; - TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); - uint32_t converted = NUM2UINT(value); - __atomic_store_n(futex->address, converted, __ATOMIC_RELEASE); - return value; -} - -static VALUE IO_Event_Futex_increment(int argc, VALUE *argv, VALUE self) { - VALUE amount_value; - rb_scan_args(argc, argv, "01", &amount_value); - uint32_t amount = NIL_P(amount_value) ? 1 : NUM2UINT(amount_value); - - struct IO_Event_Futex *futex = NULL; - TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); - uint32_t value = __atomic_add_fetch(futex->address, amount, __ATOMIC_ACQ_REL); - return UINT2NUM(value); -} - -static VALUE IO_Event_Futex_wake(int argc, VALUE *argv, VALUE self) { - VALUE count_value; - rb_scan_args(argc, argv, "01", &count_value); - int count = NIL_P(count_value) ? 1 : NUM2INT(count_value); - if (count < 0) rb_raise(rb_eArgError, "Wake count must be non-negative!"); - - struct IO_Event_Futex *futex = NULL; - TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); - int result = syscall(SYS_futex, futex->address, FUTEX_WAKE, count, NULL, NULL, 0); - if (result < 0) rb_sys_fail("IO_Event_Futex_wake:futex"); - return INT2NUM(result); -} - -static VALUE IO_Event_Futex_signal(int argc, VALUE *argv, VALUE self) { - VALUE count_value; - rb_scan_args(argc, argv, "01", &count_value); - - VALUE value = IO_Event_Futex_increment(0, NULL, self); - VALUE arguments[] = {NIL_P(count_value) ? INT2NUM(1) : count_value}; - IO_Event_Futex_wake(1, arguments, self); - return value; -} - -static uint32_t *IO_Event_Futex_address(VALUE self) { - struct IO_Event_Futex *futex = NULL; - TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); - return futex->address; -} - -static ID id_futex_wait; -static ID id_futex_waitv; - -struct IO_Event_Futex_BlockingWait { - uint32_t *address; - uint32_t expected; - int result; - int error; -}; - -static void *IO_Event_Futex_blocking_wait_without_gvl(void *_arguments) { - struct IO_Event_Futex_BlockingWait *arguments = _arguments; - arguments->result = syscall(SYS_futex, arguments->address, FUTEX_WAIT, arguments->expected, NULL, NULL, 0); - arguments->error = arguments->result < 0 ? errno : 0; - return NULL; -} - -static VALUE IO_Event_Futex_blocking_wait(VALUE self, VALUE expected_value) { - struct IO_Event_Futex_BlockingWait arguments = { - .address = IO_Event_Futex_address(self), - .expected = NUM2UINT(expected_value), - }; - - rb_thread_call_without_gvl(IO_Event_Futex_blocking_wait_without_gvl, &arguments, RUBY_UBF_IO, 0); - - if (arguments.result == 0) { - return Qtrue; - } else if (arguments.error == EAGAIN) { - return Qfalse; - } else { - rb_syserr_fail(arguments.error, "IO_Event_Futex_blocking_wait:futex"); - } - - return Qfalse; -} - -#ifdef SYS_futex_waitv - -struct IO_Event_Futex_BlockingWaitV { - struct futex_waitv *vector; - size_t count; - int result; - int error; -}; - -static void *IO_Event_Futex_blocking_waitv_without_gvl(void *_arguments) { - struct IO_Event_Futex_BlockingWaitV *arguments = _arguments; - arguments->result = syscall(SYS_futex_waitv, arguments->vector, arguments->count, 0, NULL, CLOCK_MONOTONIC); - arguments->error = arguments->result < 0 ? errno : 0; - return NULL; -} - -static VALUE IO_Event_Futex_blocking_waitv(VALUE klass, VALUE entries) { - (void)klass; - entries = rb_Array(entries); - long count = RARRAY_LEN(entries); - if (count < 1 || count > FUTEX_WAITV_MAX) { - rb_raise(rb_eArgError, "Futex vector must contain between 1 and %d entries!", FUTEX_WAITV_MAX); - } - - struct futex_waitv *vector = ALLOCA_N(struct futex_waitv, count); - for (long index = 0; index < count; index++) { - VALUE entry = rb_Array(RARRAY_AREF(entries, index)); - if (RARRAY_LEN(entry) != 2) { - rb_raise(rb_eArgError, "Each futex vector entry must contain a futex and its expected value!"); - } - - VALUE futex = RARRAY_AREF(entry, 0); - vector[index].val = NUM2UINT(RARRAY_AREF(entry, 1)); - vector[index].uaddr = (uintptr_t)IO_Event_Futex_address(futex); - vector[index].flags = FUTEX_32; - vector[index].__reserved = 0; - } - - struct IO_Event_Futex_BlockingWaitV arguments = { - .vector = vector, - .count = count, - }; - - rb_thread_call_without_gvl(IO_Event_Futex_blocking_waitv_without_gvl, &arguments, RUBY_UBF_IO, 0); - RB_GC_GUARD(entries); - - if (arguments.result >= 0) { - return INT2NUM(arguments.result); - } else if (arguments.error == EAGAIN) { - return Qnil; - } else { - rb_syserr_fail(arguments.error, "IO_Event_Futex_blocking_waitv:futex_waitv"); - } - - return Qnil; -} - -#endif - -static VALUE IO_Event_Futex_wait(int argc, VALUE *argv, VALUE self) { - VALUE expected_value; - rb_scan_args(argc, argv, "01", &expected_value); - - if (argc == 0) { - expected_value = IO_Event_Futex_value(self); - } - - VALUE scheduler = rb_fiber_scheduler_current(); - if (NIL_P(scheduler)) { - return IO_Event_Futex_blocking_wait(self, expected_value); - } - - if (!rb_respond_to(scheduler, id_futex_wait)) { - rb_raise(rb_eNotImpError, "The current fiber scheduler does not support futex waits!"); - } - - return rb_funcall(scheduler, id_futex_wait, 2, self, expected_value); -} - -#ifdef SYS_futex_waitv - -static VALUE IO_Event_Futex_wait_any(VALUE klass, VALUE entries) { - VALUE scheduler = rb_fiber_scheduler_current(); - if (NIL_P(scheduler)) { - return IO_Event_Futex_blocking_waitv(klass, entries); - } - - if (!rb_respond_to(scheduler, id_futex_waitv)) { - rb_raise(rb_eNotImpError, "The current fiber scheduler does not support vector futex waits!"); - } - - return rb_funcall(scheduler, id_futex_waitv, 1, entries); -} - -#endif - -#endif - #pragma mark - Data Type struct IO_Event_Selector_URing @@ -1093,7 +805,7 @@ VALUE IO_Event_Selector_URing_process_wait(VALUE self, VALUE fiber, VALUE _pid, return rb_ensure(process_wait_transfer, (VALUE)&process_wait_arguments, process_wait_ensure, (VALUE)&process_wait_arguments); } -#if defined(__linux__) && defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) +#if defined(IO_EVENT_FUTEX) && defined(HAVE_IO_URING_PREP_FUTEX_WAIT) #pragma mark - Futex Wait @@ -2181,7 +1893,7 @@ static int IO_Event_Selector_URing_supported_p(void) { return 0; } -#if defined(__linux__) && defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) +#if defined(IO_EVENT_FUTEX) && defined(HAVE_IO_URING_PREP_FUTEX_WAIT) struct io_uring_probe *probe = io_uring_get_probe_ring(&ring); if (probe) { IO_Event_Selector_URing_futex_supported = io_uring_opcode_supported(probe, IORING_OP_FUTEX_WAIT); @@ -2197,7 +1909,7 @@ static int IO_Event_Selector_URing_supported_p(void) { return 1; } -void Init_IO_Event_Selector_URing(VALUE IO_Event, VALUE IO_Event_Selector) { +void Init_IO_Event_Selector_URing(VALUE IO_Event_Selector) { if (!IO_Event_Selector_URing_supported_p()) { return; } @@ -2241,26 +1953,8 @@ void Init_IO_Event_Selector_URing(VALUE IO_Event, VALUE IO_Event_Selector) { rb_define_method(IO_Event_Selector_URing, "process_wait", IO_Event_Selector_URing_process_wait, 3); -#if defined(__linux__) && defined(HAVE_IO_URING_PREP_FUTEX_WAIT) && defined(HAVE_RUBY_IO_BUFFER_H) +#if defined(IO_EVENT_FUTEX) && defined(HAVE_IO_URING_PREP_FUTEX_WAIT) if (IO_Event_Selector_URing_futex_supported) { - VALUE IO_Event_Futex = rb_define_class_under(IO_Event, "Futex", rb_cObject); - rb_define_alloc_func(IO_Event_Futex, IO_Event_Futex_allocate); - rb_define_method(IO_Event_Futex, "initialize", IO_Event_Futex_initialize, -1); - rb_define_method(IO_Event_Futex, "value", IO_Event_Futex_value, 0); - rb_define_method(IO_Event_Futex, "value=", IO_Event_Futex_set_value, 1); - rb_define_method(IO_Event_Futex, "increment", IO_Event_Futex_increment, -1); - rb_define_method(IO_Event_Futex, "wake", IO_Event_Futex_wake, -1); - rb_define_method(IO_Event_Futex, "signal", IO_Event_Futex_signal, -1); - rb_define_method(IO_Event_Futex, "wait", IO_Event_Futex_wait, -1); - -#ifdef SYS_futex_waitv - rb_define_singleton_method(IO_Event_Futex, "wait_any", IO_Event_Futex_wait_any, 1); -#endif - - id_offset = rb_intern("offset"); - id_futex_wait = rb_intern("futex_wait"); - id_futex_waitv = rb_intern("futex_waitv"); - rb_define_method(IO_Event_Selector_URing, "futex_wait", IO_Event_Selector_URing_futex_wait, 3); #ifdef HAVE_IO_URING_PREP_FUTEX_WAITV diff --git a/ext/io/event/selector/uring.h b/ext/io/event/selector/uring.h index 5f8f5f87..f3ad316d 100644 --- a/ext/io/event/selector/uring.h +++ b/ext/io/event/selector/uring.h @@ -7,4 +7,4 @@ #define IO_EVENT_SELECTOR_URING -void Init_IO_Event_Selector_URing(VALUE IO_Event, VALUE IO_Event_Selector); +void Init_IO_Event_Selector_URing(VALUE IO_Event_Selector); diff --git a/lib/io/event.rb b/lib/io/event.rb index 42ee7e3e..8d953a72 100644 --- a/lib/io/event.rb +++ b/lib/io/event.rb @@ -8,7 +8,3 @@ require_relative "event/selector" require_relative "event/timers" require_relative "event/native" - -if defined?(IO::Event::Futex) - require_relative "event/futex" -end diff --git a/lib/io/event/futex.rb b/lib/io/event/futex.rb deleted file mode 100644 index dc091a19..00000000 --- a/lib/io/event/futex.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2026, by Samuel Williams. - -class IO - module Event - # Represents a Linux futex backed by an aligned 32-bit word in an {IO::Buffer}. - # - # This class is only defined when the native io_uring selector supports - # `IORING_OP_FUTEX_WAIT`. - # - # The buffer must not be explicitly freed or resized while a futex refers - # to it. The futex retains the buffer so it cannot be garbage collected. - class Futex - end - end -end diff --git a/releases.md b/releases.md index 9b7c96aa..3b9b7ae5 100644 --- a/releases.md +++ b/releases.md @@ -2,7 +2,7 @@ ## Unreleased - - Add `IO::Event::Futex` on Linux systems with io_uring futex support, including asynchronous single and vector waits over shared memory. + - Add `IO::Event::Futex` on Linux systems, including blocking and scheduler-aware single and vector waits over shared memory. ## v1.22.1 diff --git a/test/io/event/futex.rb b/test/io/event/futex.rb index 5e2ceb06..d6915062 100644 --- a/test/io/event/futex.rb +++ b/test/io/event/futex.rb @@ -11,6 +11,28 @@ describe IO::Event::Futex do let(:buffer) {IO::Buffer.new(8)} let(:futex) {subject.new(buffer)} + let(:uring_selector) do + unless defined?(IO::Event::Selector::URing) + skip "io_uring is not available" + end + + selector = IO::Event::Selector::URing.new(Fiber.current) + unless selector.respond_to?(:futex_wait) + selector.close + skip "io_uring futex operations are not available" + end + + selector + end + let(:waitv_selector) do + selector = uring_selector + unless selector.respond_to?(:futex_waitv) + selector.close + skip "io_uring futex waitv operations are not available" + end + + selector + end with "#value" do it "stores and loads the value atomically" do @@ -70,7 +92,7 @@ end it "waits asynchronously for a signal" do - selector = IO::Event::Selector::URing.new(Fiber.current) + selector = uring_selector result = nil fiber = Fiber.new do @@ -94,7 +116,7 @@ end it "uses the current scheduler" do - selector = IO::Event::Selector::URing.new(Fiber.current) + selector = uring_selector scheduler = IO::Event::TestScheduler.new(selector: selector) result = nil @@ -117,7 +139,7 @@ end it "does not wait when the value has changed" do - selector = IO::Event::Selector::URing.new(Fiber.current) + selector = uring_selector futex.value = 1 result = nil @@ -133,107 +155,112 @@ end end - with ".wait_any" do - it "waits without blocking other Ruby threads when no scheduler is installed" do - first = subject.new(buffer, offset: 0) - second = subject.new(buffer, offset: 4) - - thread = Thread.new do - sleep 0.01 - second.signal + if IO::Event::Futex.respond_to?(:wait_any) + with ".wait_any" do + it "exposes the maximum number of wait entries" do + expect(subject::WAITV_LIMIT).to be == 128 end - expect(subject.wait_any([[first, 0], [second, 0]])).to be == 1 - ensure - thread&.join - end - - it "does not wait without a scheduler when a value has changed" do - first = subject.new(buffer, offset: 0) - second = subject.new(buffer, offset: 4) - second.value = 1 - - expect(subject.wait_any([[first, 0], [second, 0]])).to be_nil - end - - it "waits asynchronously for any futex to be signalled" do - selector = IO::Event::Selector::URing.new(Fiber.current) - unless selector.respond_to?(:futex_waitv) - skip "io_uring futex waitv operations are not available" + it "rejects more than the maximum number of wait entries" do + entries = Array.new(subject::WAITV_LIMIT + 1){[futex, 0]} + + expect do + subject.wait_any(entries) + end.to raise_exception(ArgumentError) end - first = subject.new(buffer, offset: 0) - second = subject.new(buffer, offset: 4) - result = nil - - fiber = Fiber.new do - result = selector.futex_waitv(Fiber.current, [[first, 0], [second, 0]]) + it "waits without blocking other Ruby threads when no scheduler is installed" do + first = subject.new(buffer, offset: 0) + second = subject.new(buffer, offset: 4) + + thread = Thread.new do + sleep 0.01 + second.signal + end + + expect(subject.wait_any([[first, 0], [second, 0]])).to be == 1 + ensure + thread&.join end - fiber.transfer - thread = Thread.new do - sleep 0.01 - second.signal + it "does not wait without a scheduler when a value has changed" do + first = subject.new(buffer, offset: 0) + second = subject.new(buffer, offset: 4) + second.value = 1 + + expect(subject.wait_any([[first, 0], [second, 0]])).to be_nil end - selector.select(1) - thread.join - - expect(result).to be == 1 - ensure - selector&.close - thread&.join - end - - it "uses the current scheduler" do - selector = IO::Event::Selector::URing.new(Fiber.current) - unless selector.respond_to?(:futex_waitv) - skip "io_uring futex waitv operations are not available" - end - - scheduler = IO::Event::TestScheduler.new(selector: selector) - first = subject.new(buffer, offset: 0) - second = subject.new(buffer, offset: 4) - result = nil - - Fiber.set_scheduler(scheduler) - Fiber.schedule do - result = subject.wait_any([[first, 0], [second, 0]]) - end - - thread = Thread.new do - sleep 0.01 - second.signal + it "waits asynchronously for any futex to be signalled" do + selector = waitv_selector + + first = subject.new(buffer, offset: 0) + second = subject.new(buffer, offset: 4) + result = nil + + fiber = Fiber.new do + result = selector.futex_waitv(Fiber.current, [[first, 0], [second, 0]]) + end + fiber.transfer + + thread = Thread.new do + sleep 0.01 + second.signal + end + + selector.select(1) + thread.join + + expect(result).to be == 1 + ensure + selector&.close + thread&.join end - scheduler.run - - expect(result).to be == 1 - ensure - Fiber.set_scheduler(nil) - thread&.join - end - - it "returns nil when a value has changed" do - selector = IO::Event::Selector::URing.new(Fiber.current) - unless selector.respond_to?(:futex_waitv) - skip "io_uring futex waitv operations are not available" + it "uses the current scheduler" do + selector = waitv_selector + + scheduler = IO::Event::TestScheduler.new(selector: selector) + first = subject.new(buffer, offset: 0) + second = subject.new(buffer, offset: 4) + result = nil + + Fiber.set_scheduler(scheduler) + Fiber.schedule do + result = subject.wait_any([[first, 0], [second, 0]]) + end + + thread = Thread.new do + sleep 0.01 + second.signal + end + + scheduler.run + + expect(result).to be == 1 + ensure + Fiber.set_scheduler(nil) + thread&.join end - first = subject.new(buffer, offset: 0) - second = subject.new(buffer, offset: 4) - second.value = 1 - result = :waiting - - fiber = Fiber.new do - result = selector.futex_waitv(Fiber.current, [[first, 0], [second, 0]]) + it "returns nil when a value has changed" do + selector = waitv_selector + + first = subject.new(buffer, offset: 0) + second = subject.new(buffer, offset: 4) + second.value = 1 + result = :waiting + + fiber = Fiber.new do + result = selector.futex_waitv(Fiber.current, [[first, 0], [second, 0]]) + end + fiber.transfer + selector.select(1) + + expect(result).to be_nil + ensure + selector&.close end - fiber.transfer - selector.select(1) - - expect(result).to be_nil - ensure - selector&.close end end end From 92714eb803eabef92f77a87ffa7cb4818492dd48 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 21 Aug 2026 23:14:43 +1200 Subject: [PATCH 06/22] Retain futex values and cover debug forwarding --- ext/io/event/selector/uring.c | 8 ++++++-- test/io/event/debug/selector.rb | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/ext/io/event/selector/uring.c b/ext/io/event/selector/uring.c index 3c0edca5..48091020 100644 --- a/ext/io/event/selector/uring.c +++ b/ext/io/event/selector/uring.c @@ -871,7 +871,9 @@ static VALUE IO_Event_Selector_URing_futex_wait(VALUE self, VALUE fiber, VALUE f io_uring_sqe_set_data(sqe, completion); io_uring_submit_pending(selector); - return rb_ensure(futex_wait_transfer, (VALUE)&arguments, futex_wait_ensure, (VALUE)&arguments); + VALUE result = rb_ensure(futex_wait_transfer, (VALUE)&arguments, futex_wait_ensure, (VALUE)&arguments); + RB_GC_GUARD(futex); + return result; } #ifdef HAVE_IO_URING_PREP_FUTEX_WAITV @@ -932,7 +934,9 @@ static VALUE IO_Event_Selector_URing_futex_waitv(VALUE self, VALUE fiber, VALUE io_uring_sqe_set_data(sqe, completion); io_uring_submit_pending(selector); - return rb_ensure(futex_waitv_transfer, (VALUE)&arguments, futex_wait_ensure, (VALUE)&arguments); + VALUE result = rb_ensure(futex_waitv_transfer, (VALUE)&arguments, futex_wait_ensure, (VALUE)&arguments); + RB_GC_GUARD(entries); + return result; } #endif diff --git a/test/io/event/debug/selector.rb b/test/io/event/debug/selector.rb index 5b30badb..5e11eb4a 100644 --- a/test/io/event/debug/selector.rb +++ b/test/io/event/debug/selector.rb @@ -93,6 +93,16 @@ def io_close(descriptor) :calls_io_close end + def futex_wait(fiber, futex, expected) + @calls << [:futex_wait, fiber, futex, expected] + :calls_futex_wait + end + + def futex_waitv(fiber, entries) + @calls << [:futex_waitv, fiber, entries] + :calls_futex_waitv + end + def select(duration = nil) @calls << [:select, duration] :calls_select @@ -153,6 +163,8 @@ def select(duration = nil) end expect(selector.io_close(input.fileno)).to be == :calls_io_close expect(selector.respond_to?(:io_close)).to be == true + expect(selector.futex_wait(fiber, :futex, 1)).to be == :calls_futex_wait + expect(selector.futex_waitv(fiber, [[:futex, 1]])).to be == :calls_futex_waitv expect(selector.select(0)).to be == :calls_select ensure input&.close From f66ed50ccc569bc97a31212a5c8e04c9d6d911c2 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 21 Aug 2026 23:44:33 +1200 Subject: [PATCH 07/22] Use Ubuntu 26.04 for futex tests --- .github/workflows/test-futex.yaml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/test-futex.yaml b/.github/workflows/test-futex.yaml index c72d66ca..210951ee 100644 --- a/.github/workflows/test-futex.yaml +++ b/.github/workflows/test-futex.yaml @@ -7,18 +7,13 @@ permissions: jobs: test: - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 steps: - uses: actions/checkout@v6 - name: Install liburing with futex support - run: | - git clone --branch liburing-2.6 --depth 1 https://github.com/axboe/liburing.git /tmp/liburing - make -C /tmp/liburing -j2 - sudo make -C /tmp/liburing install - echo "/usr/lib" | sudo tee /etc/ld.so.conf.d/liburing.conf - sudo ldconfig + run: sudo apt-get update && sudo apt-get install -y liburing-dev - uses: ruby/setup-ruby@v1 with: From 1fd0246b008776b5581d97ea88bd06f3b2a9a2f0 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 21 Aug 2026 23:50:51 +1200 Subject: [PATCH 08/22] Add futex atomic operations --- ext/io/event/futex.c | 32 ++++++++++++++++++++++++++++++++ releases.md | 2 +- test/io/event/futex.rb | 25 +++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) diff --git a/ext/io/event/futex.c b/ext/io/event/futex.c index 3201a475..c0de48bd 100644 --- a/ext/io/event/futex.c +++ b/ext/io/event/futex.c @@ -6,6 +6,7 @@ #ifdef IO_EVENT_FUTEX #include +#include #include #include #include @@ -121,6 +122,35 @@ static VALUE IO_Event_Futex_increment(int argc, VALUE *argv, VALUE self) { return UINT2NUM(value); } +static VALUE IO_Event_Futex_decrement(int argc, VALUE *argv, VALUE self) { + VALUE amount_value; + rb_scan_args(argc, argv, "01", &amount_value); + uint32_t amount = NIL_P(amount_value) ? 1 : NUM2UINT(amount_value); + + struct IO_Event_Futex *futex = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + uint32_t value = __atomic_sub_fetch(futex->address, amount, __ATOMIC_ACQ_REL); + return UINT2NUM(value); +} + +static VALUE IO_Event_Futex_compare_exchange(VALUE self, VALUE expected_value, VALUE desired_value) { + uint32_t expected = NUM2UINT(expected_value); + uint32_t desired = NUM2UINT(desired_value); + + struct IO_Event_Futex *futex = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + bool exchanged = __atomic_compare_exchange_n( + futex->address, + &expected, + desired, + false, + __ATOMIC_ACQ_REL, + __ATOMIC_ACQUIRE + ); + + return exchanged ? Qtrue : Qfalse; +} + static VALUE IO_Event_Futex_wake(int argc, VALUE *argv, VALUE self) { VALUE count_value; rb_scan_args(argc, argv, "01", &count_value); @@ -284,6 +314,8 @@ void Init_IO_Event_Futex(VALUE IO_Event) { rb_define_method(IO_Event_Futex, "value", IO_Event_Futex_value, 0); rb_define_method(IO_Event_Futex, "value=", IO_Event_Futex_set_value, 1); rb_define_method(IO_Event_Futex, "increment", IO_Event_Futex_increment, -1); + rb_define_method(IO_Event_Futex, "decrement", IO_Event_Futex_decrement, -1); + rb_define_method(IO_Event_Futex, "compare_exchange", IO_Event_Futex_compare_exchange, 2); rb_define_method(IO_Event_Futex, "wake", IO_Event_Futex_wake, -1); rb_define_method(IO_Event_Futex, "signal", IO_Event_Futex_signal, -1); rb_define_method(IO_Event_Futex, "wait", IO_Event_Futex_wait, -1); diff --git a/releases.md b/releases.md index 3b9b7ae5..3a64f32e 100644 --- a/releases.md +++ b/releases.md @@ -2,7 +2,7 @@ ## Unreleased - - Add `IO::Event::Futex` on Linux systems, including blocking and scheduler-aware single and vector waits over shared memory. + - Add `IO::Event::Futex` on Linux systems, including atomic value operations and blocking and scheduler-aware single and vector waits over shared memory. ## v1.22.1 diff --git a/test/io/event/futex.rb b/test/io/event/futex.rb index d6915062..bc4260ec 100644 --- a/test/io/event/futex.rb +++ b/test/io/event/futex.rb @@ -47,6 +47,31 @@ expect(futex.increment(2)).to be == 3 end end + + with "#decrement" do + it "decrements the value" do + futex.value = 3 + + expect(futex.decrement).to be == 2 + expect(futex.decrement(2)).to be == 0 + end + end + + with "#compare_exchange" do + it "exchanges a matching value" do + futex.value = 2 + + expect(futex.compare_exchange(2, 1)).to be == true + expect(futex.value).to be == 1 + end + + it "does not exchange a different value" do + futex.value = 2 + + expect(futex.compare_exchange(1, 0)).to be == false + expect(futex.value).to be == 2 + end + end with "offset:" do it "can address independent words in one buffer" do From 96f6a30e36f1db82b8e34783bb53c3cb549e4e0c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sat, 22 Aug 2026 11:51:38 +1200 Subject: [PATCH 09/22] Update .github/workflows/test-futex.yaml Co-authored-by: Olle Jonsson --- .github/workflows/test-futex.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test-futex.yaml b/.github/workflows/test-futex.yaml index 210951ee..d8089e93 100644 --- a/.github/workflows/test-futex.yaml +++ b/.github/workflows/test-futex.yaml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-26.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Install liburing with futex support run: sudo apt-get update && sudo apt-get install -y liburing-dev From 577decd5bf91fa69de71762aaca9e5acc5d1d9ec Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Sun, 30 Aug 2026 12:25:10 +1200 Subject: [PATCH 10/22] Fix Futex test formatting Assisted-By: devx/065220da-7d48-4279-a513-aedd3bbfa4da --- test/io/event/futex.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/io/event/futex.rb b/test/io/event/futex.rb index bc4260ec..dfb38330 100644 --- a/test/io/event/futex.rb +++ b/test/io/event/futex.rb @@ -47,27 +47,27 @@ expect(futex.increment(2)).to be == 3 end end - + with "#decrement" do it "decrements the value" do futex.value = 3 - + expect(futex.decrement).to be == 2 expect(futex.decrement(2)).to be == 0 end end - + with "#compare_exchange" do it "exchanges a matching value" do futex.value = 2 - + expect(futex.compare_exchange(2, 1)).to be == true expect(futex.value).to be == 1 end - + it "does not exchange a different value" do futex.value = 2 - + expect(futex.compare_exchange(1, 0)).to be == false expect(futex.value).to be == 2 end From c1917ccf09458e8a2bdd0a59d732626a4d2d07cf Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 24 Sep 2026 12:21:17 +1200 Subject: [PATCH 11/22] Retain counted buffer locks for Futex lifetimes --- .github/workflows/test-futex.yaml | 2 +- ext/io/event/futex.c | 310 ++++++++++++++++++++++-------- ext/io/event/futex.h | 18 +- ext/io/event/selector/uring.c | 180 +++++++++-------- guides/getting-started/readme.md | 26 +++ releases.md | 2 +- test/io/event/futex.rb | 300 +++++++++++++++++++++++++++++ 7 files changed, 663 insertions(+), 175 deletions(-) diff --git a/.github/workflows/test-futex.yaml b/.github/workflows/test-futex.yaml index d8089e93..48bb62ac 100644 --- a/.github/workflows/test-futex.yaml +++ b/.github/workflows/test-futex.yaml @@ -17,7 +17,7 @@ jobs: - uses: ruby/setup-ruby@v1 with: - ruby-version: ruby + ruby-version: head bundler-cache: true - name: Run futex tests diff --git a/ext/io/event/futex.c b/ext/io/event/futex.c index c0de48bd..3e23bdb5 100644 --- a/ext/io/event/futex.c +++ b/ext/io/event/futex.c @@ -15,25 +15,74 @@ #include #include -struct IO_Event_Futex { +// The finalizer owns the allocation lock independently of the Futex. It must +// never retain the Futex or its C struct: either may be reclaimed before the +// finalizer runs. Keeping the buffer here preserves it until it is unlocked. +struct IO_Event_Futex_Finalizer { VALUE buffer; uint32_t *address; + size_t waits; }; -static const rb_data_type_t IO_Event_Futex_Type; +static VALUE IO_Event_Futex_Finalizer_Class; + +static void IO_Event_Futex_Finalizer_mark(void *_finalizer) { + struct IO_Event_Futex_Finalizer *finalizer = _finalizer; + rb_gc_mark_movable(finalizer->buffer); +} + +static void IO_Event_Futex_Finalizer_compact(void *_finalizer) { + struct IO_Event_Futex_Finalizer *finalizer = _finalizer; + finalizer->buffer = rb_gc_location(finalizer->buffer); +} + +static size_t IO_Event_Futex_Finalizer_size(const void *_finalizer) { + return sizeof(struct IO_Event_Futex_Finalizer); +} + +static const rb_data_type_t IO_Event_Futex_Finalizer_Type = { + .wrap_struct_name = "IO::Event::Futex::Finalizer", + .function = { + .dmark = IO_Event_Futex_Finalizer_mark, + .dcompact = IO_Event_Futex_Finalizer_compact, + .dfree = RUBY_TYPED_DEFAULT_FREE, + .dsize = IO_Event_Futex_Finalizer_size, + }, + .flags = RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED, +}; + +static VALUE IO_Event_Futex_Finalizer_release(VALUE self) { + struct IO_Event_Futex_Finalizer *finalizer = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex_Finalizer, &IO_Event_Futex_Finalizer_Type, finalizer); + if (!NIL_P(finalizer->buffer)) { + rb_io_buffer_unlock(finalizer->buffer); + RB_OBJ_WRITE(self, &finalizer->buffer, Qnil); + finalizer->address = NULL; + } + return Qnil; +} + +static VALUE IO_Event_Futex_Finalizer_call(VALUE self, VALUE object_id) { + struct IO_Event_Futex_Finalizer *finalizer = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex_Finalizer, &IO_Event_Futex_Finalizer_Type, finalizer); + // Ruby also invokes finalizers at shutdown, even for reachable objects. + // Never unlock memory still used by an outstanding kernel operation. + if (finalizer->waits) return Qnil; + return IO_Event_Futex_Finalizer_release(self); +} + +struct IO_Event_Futex { + VALUE finalizer; +}; static void IO_Event_Futex_mark(void *_futex) { struct IO_Event_Futex *futex = _futex; - rb_gc_mark_movable(futex->buffer); + rb_gc_mark_movable(futex->finalizer); } static void IO_Event_Futex_compact(void *_futex) { struct IO_Event_Futex *futex = _futex; - futex->buffer = rb_gc_location(futex->buffer); -} - -static void IO_Event_Futex_free(void *_futex) { - xfree(_futex); + futex->finalizer = rb_gc_location(futex->finalizer); } static size_t IO_Event_Futex_size(const void *_futex) { @@ -45,7 +94,7 @@ static const rb_data_type_t IO_Event_Futex_Type = { .function = { .dmark = IO_Event_Futex_mark, .dcompact = IO_Event_Futex_compact, - .dfree = IO_Event_Futex_free, + .dfree = RUBY_TYPED_DEFAULT_FREE, .dsize = IO_Event_Futex_size, }, .flags = RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED, @@ -54,8 +103,7 @@ static const rb_data_type_t IO_Event_Futex_Type = { static VALUE IO_Event_Futex_allocate(VALUE klass) { struct IO_Event_Futex *futex = NULL; VALUE instance = TypedData_Make_Struct(klass, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); - futex->buffer = Qnil; - futex->address = NULL; + futex->finalizer = Qnil; return instance; } @@ -64,6 +112,11 @@ static ID id_futex_wait; static ID id_futex_waitv; static VALUE IO_Event_Futex_initialize(int argc, VALUE *argv, VALUE self) { + rb_check_frozen(self); + struct IO_Event_Futex *futex = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + if (!NIL_P(futex->finalizer)) rb_raise(rb_eRuntimeError, "Futex is already initialized!"); + VALUE buffer, options; rb_scan_args(argc, argv, "1:", &buffer, &options); @@ -76,6 +129,18 @@ static VALUE IO_Event_Futex_initialize(int argc, VALUE *argv, VALUE self) { } size_t offset = offset_value == Qundef ? 0 : NUM2SIZET(offset_value); + // Numeric coercion may have invoked initialize recursively. + if (!NIL_P(futex->finalizer)) rb_raise(rb_eRuntimeError, "Futex is already initialized!"); + + // Register cleanup before acquiring the lock. All allocating operations are + // performed before extracting the pointer; failures leave an inert finalizer. + struct IO_Event_Futex_Finalizer *finalizer = NULL; + VALUE finalizer_value = TypedData_Make_Struct(IO_Event_Futex_Finalizer_Class, struct IO_Event_Futex_Finalizer, &IO_Event_Futex_Finalizer_Type, finalizer); + finalizer->buffer = Qnil; + finalizer->address = NULL; + finalizer->waits = 0; + rb_define_finalizer(self, finalizer_value); + void *base = NULL; size_t size = 0; rb_io_buffer_get_bytes_for_writing(buffer, &base, &size); @@ -89,25 +154,22 @@ static VALUE IO_Event_Futex_initialize(int argc, VALUE *argv, VALUE self) { rb_raise(rb_eArgError, "Futex address must be aligned to 4 bytes!"); } - struct IO_Event_Futex *futex = NULL; - TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); - RB_OBJ_WRITE(self, &futex->buffer, buffer); - futex->address = address; + // Nothing after a successful lock can raise or invoke Ruby code. + rb_io_buffer_lock(buffer); + RB_OBJ_WRITE(finalizer_value, &finalizer->buffer, buffer); + finalizer->address = address; + RB_OBJ_WRITE(self, &futex->finalizer, finalizer_value); return self; } static VALUE IO_Event_Futex_value(VALUE self) { - struct IO_Event_Futex *futex = NULL; - TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); - return UINT2NUM(__atomic_load_n(futex->address, __ATOMIC_ACQUIRE)); + return UINT2NUM(__atomic_load_n(IO_Event_Futex_address(self), __ATOMIC_ACQUIRE)); } static VALUE IO_Event_Futex_set_value(VALUE self, VALUE value) { - struct IO_Event_Futex *futex = NULL; - TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); uint32_t converted = NUM2UINT(value); - __atomic_store_n(futex->address, converted, __ATOMIC_RELEASE); + __atomic_store_n(IO_Event_Futex_address(self), converted, __ATOMIC_RELEASE); return value; } @@ -116,9 +178,7 @@ static VALUE IO_Event_Futex_increment(int argc, VALUE *argv, VALUE self) { rb_scan_args(argc, argv, "01", &amount_value); uint32_t amount = NIL_P(amount_value) ? 1 : NUM2UINT(amount_value); - struct IO_Event_Futex *futex = NULL; - TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); - uint32_t value = __atomic_add_fetch(futex->address, amount, __ATOMIC_ACQ_REL); + uint32_t value = __atomic_add_fetch(IO_Event_Futex_address(self), amount, __ATOMIC_ACQ_REL); return UINT2NUM(value); } @@ -127,9 +187,7 @@ static VALUE IO_Event_Futex_decrement(int argc, VALUE *argv, VALUE self) { rb_scan_args(argc, argv, "01", &amount_value); uint32_t amount = NIL_P(amount_value) ? 1 : NUM2UINT(amount_value); - struct IO_Event_Futex *futex = NULL; - TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); - uint32_t value = __atomic_sub_fetch(futex->address, amount, __ATOMIC_ACQ_REL); + uint32_t value = __atomic_sub_fetch(IO_Event_Futex_address(self), amount, __ATOMIC_ACQ_REL); return UINT2NUM(value); } @@ -137,10 +195,8 @@ static VALUE IO_Event_Futex_compare_exchange(VALUE self, VALUE expected_value, V uint32_t expected = NUM2UINT(expected_value); uint32_t desired = NUM2UINT(desired_value); - struct IO_Event_Futex *futex = NULL; - TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); bool exchanged = __atomic_compare_exchange_n( - futex->address, + IO_Event_Futex_address(self), &expected, desired, false, @@ -166,19 +222,72 @@ static VALUE IO_Event_Futex_signal(int argc, VALUE *argv, VALUE self) { VALUE count_value; rb_scan_args(argc, argv, "01", &count_value); - VALUE value = IO_Event_Futex_increment(0, NULL, self); - VALUE arguments[] = {NIL_P(count_value) ? INT2NUM(1) : count_value}; - IO_Event_Futex_wake(1, arguments, self); - return value; + int count = NIL_P(count_value) ? 1 : NUM2INT(count_value); + if (count < 0) rb_raise(rb_eArgError, "Wake count must be non-negative!"); + uint32_t *address = IO_Event_Futex_address(self); + uint32_t value = __atomic_add_fetch(address, 1, __ATOMIC_ACQ_REL); + int result = syscall(SYS_futex, address, FUTEX_WAKE, count, NULL, NULL, 0); + if (result < 0) rb_sys_fail("IO_Event_Futex_signal:futex"); + return UINT2NUM(value); +} + +static struct IO_Event_Futex_Finalizer *IO_Event_Futex_get(VALUE self) { + struct IO_Event_Futex *futex = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + if (NIL_P(futex->finalizer)) rb_raise(rb_eIOError, "Futex is uninitialized!"); + struct IO_Event_Futex_Finalizer *finalizer = NULL; + TypedData_Get_Struct(futex->finalizer, struct IO_Event_Futex_Finalizer, &IO_Event_Futex_Finalizer_Type, finalizer); + if (!finalizer->address) rb_raise(rb_eIOError, "Futex is closed!"); + return finalizer; } uint32_t *IO_Event_Futex_address(VALUE self) { + return IO_Event_Futex_get(self)->address; +} + +uint32_t *IO_Event_Futex_acquire(VALUE self) { + struct IO_Event_Futex_Finalizer *finalizer = IO_Event_Futex_get(self); + if (finalizer->waits == SIZE_MAX) rb_raise(rb_eRuntimeError, "Too many futex waits!"); + finalizer->waits += 1; + return finalizer->address; +} + +void IO_Event_Futex_release(VALUE self) { + struct IO_Event_Futex_Finalizer *finalizer = IO_Event_Futex_get(self); + if (!finalizer->waits) rb_bug("IO_Event_Futex_release: no pending waits"); + finalizer->waits -= 1; +} + +static VALUE IO_Event_Futex_close(VALUE self) { struct IO_Event_Futex *futex = NULL; TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); - return futex->address; + if (NIL_P(futex->finalizer)) return Qnil; + struct IO_Event_Futex_Finalizer *finalizer = NULL; + TypedData_Get_Struct(futex->finalizer, struct IO_Event_Futex_Finalizer, &IO_Event_Futex_Finalizer_Type, finalizer); + if (finalizer->waits) rb_raise(rb_eIOError, "Cannot close a futex with pending waits!"); + return IO_Event_Futex_Finalizer_release(futex->finalizer); +} + +static VALUE IO_Event_Futex_closed_p(VALUE self) { + struct IO_Event_Futex *futex = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + if (NIL_P(futex->finalizer)) return Qtrue; + struct IO_Event_Futex_Finalizer *finalizer = NULL; + TypedData_Get_Struct(futex->finalizer, struct IO_Event_Futex_Finalizer, &IO_Event_Futex_Finalizer_Type, finalizer); + return finalizer->address ? Qfalse : Qtrue; +} + +static VALUE IO_Event_Futex_initialize_copy(VALUE self, VALUE other) { + struct IO_Event_Futex *futex = NULL; + TypedData_Get_Struct(self, struct IO_Event_Futex, &IO_Event_Futex_Type, futex); + // Ruby copies finalizers before invoking initialize_copy. The rejected copy + // must not release the original's allocation lock when it is collected. + if (NIL_P(futex->finalizer)) rb_undefine_finalizer(self); + rb_raise(rb_eTypeError, "Cannot copy a futex!"); } struct IO_Event_Futex_BlockingWait { + VALUE futex; uint32_t *address; uint32_t expected; int result; @@ -192,81 +301,120 @@ static void *IO_Event_Futex_blocking_wait_without_gvl(void *_arguments) { return NULL; } -static VALUE IO_Event_Futex_blocking_wait(VALUE self, VALUE expected_value) { - struct IO_Event_Futex_BlockingWait arguments = { - .address = IO_Event_Futex_address(self), - .expected = NUM2UINT(expected_value), - }; - - rb_thread_call_without_gvl(IO_Event_Futex_blocking_wait_without_gvl, &arguments, RUBY_UBF_IO, 0); +static VALUE IO_Event_Futex_blocking_wait_body(VALUE _arguments) { + struct IO_Event_Futex_BlockingWait *arguments = (void *)_arguments; + arguments->address = IO_Event_Futex_acquire(arguments->futex); + rb_thread_call_without_gvl(IO_Event_Futex_blocking_wait_without_gvl, arguments, RUBY_UBF_IO, 0); - if (arguments.result == 0) { + if (arguments->result == 0) { return Qtrue; - } else if (arguments.error == EAGAIN) { + } else if (arguments->error == EAGAIN) { return Qfalse; } else { - rb_syserr_fail(arguments.error, "IO_Event_Futex_blocking_wait:futex"); + rb_syserr_fail(arguments->error, "IO_Event_Futex_blocking_wait:futex"); } return Qfalse; } -#ifdef SYS_futex_waitv - -struct IO_Event_Futex_BlockingWaitV { - struct futex_waitv *vector; - size_t count; - int result; - int error; -}; +static VALUE IO_Event_Futex_blocking_wait_ensure(VALUE _arguments) { + struct IO_Event_Futex_BlockingWait *arguments = (void *)_arguments; + if (arguments->address) IO_Event_Futex_release(arguments->futex); + return Qnil; +} -static void *IO_Event_Futex_blocking_waitv_without_gvl(void *_arguments) { - struct IO_Event_Futex_BlockingWaitV *arguments = _arguments; - arguments->result = syscall(SYS_futex_waitv, arguments->vector, arguments->count, 0, NULL, CLOCK_MONOTONIC); - arguments->error = arguments->result < 0 ? errno : 0; - return NULL; +static VALUE IO_Event_Futex_blocking_wait(VALUE self, VALUE expected_value) { + struct IO_Event_Futex_BlockingWait arguments = { + .futex = self, + .expected = NUM2UINT(expected_value), + }; + VALUE result = rb_ensure(IO_Event_Futex_blocking_wait_body, (VALUE)&arguments, IO_Event_Futex_blocking_wait_ensure, (VALUE)&arguments); + RB_GC_GUARD(self); + return result; } -static VALUE IO_Event_Futex_blocking_waitv(VALUE entries) { - entries = rb_Array(entries); +#ifdef IO_EVENT_FUTEX_WAITV + +// Coerce all arguments before retaining native addresses. The returned array +// owns a private snapshot of the futex references: mutating the caller's entries +// while a wait is pending must not make its futexes collectible. +VALUE IO_Event_Futex_prepare_waitv(VALUE entries, struct futex_waitv *vector) { + entries = rb_ary_dup(rb_Array(entries)); long count = RARRAY_LEN(entries); if (count < 1 || count > FUTEX_WAITV_MAX) { rb_raise(rb_eArgError, "Futex vector must contain between 1 and %d entries!", FUTEX_WAITV_MAX); } - - struct futex_waitv *vector = ALLOCA_N(struct futex_waitv, count); + VALUE futexes = rb_ary_new_capa(count); for (long index = 0; index < count; index++) { VALUE entry = rb_Array(RARRAY_AREF(entries, index)); if (RARRAY_LEN(entry) != 2) { rb_raise(rb_eArgError, "Each futex vector entry must contain a futex and its expected value!"); } - - VALUE futex = RARRAY_AREF(entry, 0); + rb_ary_push(futexes, RARRAY_AREF(entry, 0)); vector[index].val = NUM2UINT(RARRAY_AREF(entry, 1)); - vector[index].uaddr = (uintptr_t)IO_Event_Futex_address(futex); vector[index].flags = FUTEX_32; vector[index].__reserved = 0; } + return futexes; +} - struct IO_Event_Futex_BlockingWaitV arguments = { - .vector = vector, - .count = count, - }; +struct IO_Event_Futex_BlockingWaitV { + VALUE futexes; + struct futex_waitv *vector; + size_t count; + size_t acquired; + int result; + int error; +}; - rb_thread_call_without_gvl(IO_Event_Futex_blocking_waitv_without_gvl, &arguments, RUBY_UBF_IO, 0); - RB_GC_GUARD(entries); +static void *IO_Event_Futex_blocking_waitv_without_gvl(void *_arguments) { + struct IO_Event_Futex_BlockingWaitV *arguments = _arguments; + arguments->result = syscall(SYS_futex_waitv, arguments->vector, arguments->count, 0, NULL, CLOCK_MONOTONIC); + arguments->error = arguments->result < 0 ? errno : 0; + return NULL; +} + +static VALUE IO_Event_Futex_blocking_waitv_body(VALUE _arguments) { + struct IO_Event_Futex_BlockingWaitV *arguments = (void *)_arguments; + while (arguments->acquired < arguments->count) { + size_t index = arguments->acquired; + arguments->vector[index].uaddr = (uintptr_t)IO_Event_Futex_acquire(RARRAY_AREF(arguments->futexes, index)); + arguments->acquired += 1; + } + rb_thread_call_without_gvl(IO_Event_Futex_blocking_waitv_without_gvl, arguments, RUBY_UBF_IO, 0); - if (arguments.result >= 0) { - return INT2NUM(arguments.result); - } else if (arguments.error == EAGAIN) { + if (arguments->result >= 0) { + return INT2NUM(arguments->result); + } else if (arguments->error == EAGAIN) { return Qnil; } else { - rb_syserr_fail(arguments.error, "IO_Event_Futex_blocking_waitv:futex_waitv"); + rb_syserr_fail(arguments->error, "IO_Event_Futex_blocking_waitv:futex_waitv"); } return Qnil; } +static VALUE IO_Event_Futex_blocking_waitv_ensure(VALUE _arguments) { + struct IO_Event_Futex_BlockingWaitV *arguments = (void *)_arguments; + while (arguments->acquired) { + IO_Event_Futex_release(RARRAY_AREF(arguments->futexes, --arguments->acquired)); + } + return Qnil; +} + +static VALUE IO_Event_Futex_blocking_waitv(VALUE entries) { + struct futex_waitv vector[FUTEX_WAITV_MAX]; + VALUE futexes = IO_Event_Futex_prepare_waitv(entries, vector); + struct IO_Event_Futex_BlockingWaitV arguments = { + .futexes = futexes, + .vector = vector, + .count = RARRAY_LEN(futexes), + }; + VALUE result = rb_ensure(IO_Event_Futex_blocking_waitv_body, (VALUE)&arguments, IO_Event_Futex_blocking_waitv_ensure, (VALUE)&arguments); + RB_GC_GUARD(futexes); + return result; +} + #endif static VALUE IO_Event_Futex_wait(int argc, VALUE *argv, VALUE self) { @@ -289,7 +437,7 @@ static VALUE IO_Event_Futex_wait(int argc, VALUE *argv, VALUE self) { return rb_funcall(scheduler, id_futex_wait, 2, self, expected_value); } -#ifdef SYS_futex_waitv +#ifdef IO_EVENT_FUTEX_WAITV static VALUE IO_Event_Futex_wait_any(VALUE klass, VALUE entries) { (void)klass; @@ -308,9 +456,17 @@ static VALUE IO_Event_Futex_wait_any(VALUE klass, VALUE entries) { #endif void Init_IO_Event_Futex(VALUE IO_Event) { + IO_Event_Futex_Finalizer_Class = rb_class_new(rb_cObject); + rb_gc_register_mark_object(IO_Event_Futex_Finalizer_Class); + rb_undef_alloc_func(IO_Event_Futex_Finalizer_Class); + rb_define_method(IO_Event_Futex_Finalizer_Class, "call", IO_Event_Futex_Finalizer_call, 1); + VALUE IO_Event_Futex = rb_define_class_under(IO_Event, "Futex", rb_cObject); rb_define_alloc_func(IO_Event_Futex, IO_Event_Futex_allocate); rb_define_method(IO_Event_Futex, "initialize", IO_Event_Futex_initialize, -1); + rb_define_method(IO_Event_Futex, "initialize_copy", IO_Event_Futex_initialize_copy, 1); + rb_define_method(IO_Event_Futex, "close", IO_Event_Futex_close, 0); + rb_define_method(IO_Event_Futex, "closed?", IO_Event_Futex_closed_p, 0); rb_define_method(IO_Event_Futex, "value", IO_Event_Futex_value, 0); rb_define_method(IO_Event_Futex, "value=", IO_Event_Futex_set_value, 1); rb_define_method(IO_Event_Futex, "increment", IO_Event_Futex_increment, -1); @@ -320,7 +476,7 @@ void Init_IO_Event_Futex(VALUE IO_Event) { rb_define_method(IO_Event_Futex, "signal", IO_Event_Futex_signal, -1); rb_define_method(IO_Event_Futex, "wait", IO_Event_Futex_wait, -1); -#ifdef SYS_futex_waitv +#ifdef IO_EVENT_FUTEX_WAITV rb_define_const(IO_Event_Futex, "WAITV_LIMIT", INT2NUM(FUTEX_WAITV_MAX)); rb_define_singleton_method(IO_Event_Futex, "wait_any", IO_Event_Futex_wait_any, 1); #endif diff --git a/ext/io/event/futex.h b/ext/io/event/futex.h index efd43b54..54b12053 100644 --- a/ext/io/event/futex.h +++ b/ext/io/event/futex.h @@ -5,12 +5,19 @@ #include -#if defined(__linux__) && defined(HAVE_RUBY_IO_BUFFER_H) && defined(HAVE_LINUX_FUTEX_H) && defined(HAVE_SYS_SYSCALL_H) +#ifdef HAVE_RUBY_IO_BUFFER_H +#include +#endif + +// Version 3 (Ruby 4.1) provides counted allocation locks, allowing independent +// futex words to retain the same buffer without unlocking each other. +#if defined(__linux__) && RUBY_IO_BUFFER_VERSION >= 3 && defined(HAVE_LINUX_FUTEX_H) && defined(HAVE_SYS_SYSCALL_H) #define IO_EVENT_FUTEX #include #include +#include #ifndef FUTEX2_SIZE_U32 #define FUTEX2_SIZE_U32 2 @@ -20,11 +27,16 @@ #define FUTEX_32 2 #endif -#ifndef FUTEX_WAITV_MAX -#define FUTEX_WAITV_MAX 128 +#if defined(FUTEX_WAITV_MAX) && defined(SYS_futex_waitv) +#define IO_EVENT_FUTEX_WAITV #endif uint32_t *IO_Event_Futex_address(VALUE self); +uint32_t *IO_Event_Futex_acquire(VALUE self); +void IO_Event_Futex_release(VALUE self); +#ifdef IO_EVENT_FUTEX_WAITV +VALUE IO_Event_Futex_prepare_waitv(VALUE entries, struct futex_waitv *vector); +#endif void Init_IO_Event_Futex(VALUE IO_Event); #endif diff --git a/ext/io/event/selector/uring.c b/ext/io/event/selector/uring.c index 48091020..52bcc123 100644 --- a/ext/io/event/selector/uring.c +++ b/ext/io/event/selector/uring.c @@ -811,131 +811,125 @@ VALUE IO_Event_Selector_URing_process_wait(VALUE self, VALUE fiber, VALUE _pid, struct futex_wait_arguments { struct IO_Event_Selector_URing *selector; - struct IO_Event_Selector_URing_Waiting *waiting; + struct IO_Event_Selector_URing_Waiting waiting; + VALUE futex; + VALUE futexes; + struct futex_waitv *vector; + uint32_t expected; + long count; + long acquired; + bool submitted; }; -static VALUE futex_wait_ensure(VALUE _arguments) { +static VALUE futex_wait_cancel(VALUE _arguments) { struct futex_wait_arguments *arguments = (struct futex_wait_arguments *)_arguments; - - if (arguments->waiting->completion) { - struct io_uring_sqe *sqe = io_get_sqe(arguments->selector); - io_uring_prep_cancel(sqe, (void *)arguments->waiting->completion, 0); - io_uring_sqe_set_data(sqe, NULL); - io_uring_submit_now(arguments->selector); + if (arguments->submitted) { + // Drain the original operation before releasing its futex references or + // stack-backed wait vector. The shared helper also tracks cancellation + // CQEs so the completion record cannot be reused prematurely. + IO_Event_Selector_URing_Waiting_cancel_and_wait(arguments->selector, &arguments->waiting); + } else if (arguments->waiting.completion) { + // Setup failed before an SQE referred to this completion. + IO_Event_Selector_URing_Completion_complete(arguments->selector, arguments->waiting.completion); + } + return Qnil; +} + +static VALUE futex_wait_release(VALUE _arguments) { + struct futex_wait_arguments *arguments = (struct futex_wait_arguments *)_arguments; + while (arguments->acquired) { + long index = --arguments->acquired; + VALUE futex = arguments->vector ? RARRAY_AREF(arguments->futexes, index) : arguments->futex; + IO_Event_Futex_release(futex); } - - IO_Event_Selector_URing_Waiting_cancel(arguments->waiting); return Qnil; } +static VALUE futex_wait_ensure(VALUE _arguments) { + return rb_ensure(futex_wait_cancel, _arguments, futex_wait_release, _arguments); +} + static VALUE futex_wait_transfer(VALUE _arguments) { struct futex_wait_arguments *arguments = (struct futex_wait_arguments *)_arguments; + uint32_t *address = NULL; + while (arguments->acquired < arguments->count) { + long index = arguments->acquired; + VALUE futex = arguments->vector ? RARRAY_AREF(arguments->futexes, index) : arguments->futex; + address = IO_Event_Futex_acquire(futex); + arguments->acquired += 1; +#ifdef IO_EVENT_FUTEX_WAITV + if (arguments->vector) arguments->vector[index].uaddr = (uintptr_t)address; +#endif + } + + // Arguments have been coerced and each futex is now held open. From here + // on, the enclosing ensure owns all completion and cancellation cleanup. + struct IO_Event_Selector_URing_Completion *completion = IO_Event_Selector_URing_Completion_acquire(arguments->selector, &arguments->waiting); + struct io_uring_sqe *sqe = io_get_sqe(arguments->selector); +#if defined(IO_EVENT_FUTEX_WAITV) && defined(HAVE_IO_URING_PREP_FUTEX_WAITV) + if (arguments->vector) { + io_uring_prep_futex_waitv(sqe, arguments->vector, arguments->count, 0); + } else +#endif + { + io_uring_prep_futex_wait(sqe, address, arguments->expected, FUTEX_BITSET_MATCH_ANY, FUTEX2_SIZE_U32, 0); + } + io_uring_sqe_set_data(sqe, completion); + arguments->submitted = true; + io_uring_submit_pending(arguments->selector); + IO_Event_Selector_loop_yield(&arguments->selector->backend); - - int32_t result = arguments->waiting->result; - if (result == 0) { - return Qtrue; - } else if (result == -EAGAIN) { - return Qfalse; - } else if (result < 0) { + if (arguments->waiting.completion) { + // An out-of-band resume is not a successful futex notification. + IO_Event_Selector_URing_Waiting_cancel_and_wait(arguments->selector, &arguments->waiting); + } + + int32_t result = arguments->waiting.result; + if (result >= 0) { + return arguments->vector ? INT2NUM(result) : Qtrue; + } else if (result == -EAGAIN || result == -ECANCELED) { + return arguments->vector ? Qnil : Qfalse; + } else { rb_syserr_fail(-result, "futex_wait_transfer:io_uring_futex_wait"); } - return Qfalse; + return Qnil; } static VALUE IO_Event_Selector_URing_futex_wait(VALUE self, VALUE fiber, VALUE futex, VALUE expected_value) { struct IO_Event_Selector_URing *selector = NULL; TypedData_Get_Struct(self, struct IO_Event_Selector_URing, &IO_Event_Selector_URing_Type, selector); - - struct IO_Event_Selector_URing_Waiting waiting = { - .fiber = fiber, - }; - RB_OBJ_WRITTEN(self, Qundef, fiber); - - struct IO_Event_Selector_URing_Completion *completion = IO_Event_Selector_URing_Completion_acquire(selector, &waiting); struct futex_wait_arguments arguments = { .selector = selector, - .waiting = &waiting, + .waiting = {.fiber = fiber}, + .futex = futex, + .expected = NUM2UINT(expected_value), + .count = 1, }; - - struct io_uring_sqe *sqe = io_get_sqe(selector); - io_uring_prep_futex_wait( - sqe, - IO_Event_Futex_address(futex), - NUM2UINT(expected_value), - FUTEX_BITSET_MATCH_ANY, - FUTEX2_SIZE_U32, - 0 - ); - io_uring_sqe_set_data(sqe, completion); - io_uring_submit_pending(selector); - + RB_OBJ_WRITTEN(self, Qundef, fiber); VALUE result = rb_ensure(futex_wait_transfer, (VALUE)&arguments, futex_wait_ensure, (VALUE)&arguments); RB_GC_GUARD(futex); return result; } -#ifdef HAVE_IO_URING_PREP_FUTEX_WAITV - -static VALUE futex_waitv_transfer(VALUE _arguments) { - struct futex_wait_arguments *arguments = (struct futex_wait_arguments *)_arguments; - IO_Event_Selector_loop_yield(&arguments->selector->backend); - - int32_t result = arguments->waiting->result; - if (result >= 0) { - return INT2NUM(result); - } else if (result == -EAGAIN) { - return Qnil; - } else { - rb_syserr_fail(-result, "futex_waitv_transfer:io_uring_futex_waitv"); - } - - return Qnil; -} +#if defined(IO_EVENT_FUTEX_WAITV) && defined(HAVE_IO_URING_PREP_FUTEX_WAITV) static VALUE IO_Event_Selector_URing_futex_waitv(VALUE self, VALUE fiber, VALUE entries) { struct IO_Event_Selector_URing *selector = NULL; TypedData_Get_Struct(self, struct IO_Event_Selector_URing, &IO_Event_Selector_URing_Type, selector); - entries = rb_Array(entries); - long count = RARRAY_LEN(entries); - if (count < 1 || count > FUTEX_WAITV_MAX) { - rb_raise(rb_eArgError, "Futex vector must contain between 1 and %d entries!", FUTEX_WAITV_MAX); - } - - struct futex_waitv *vector = ALLOCA_N(struct futex_waitv, count); - for (long index = 0; index < count; index++) { - VALUE entry = rb_Array(RARRAY_AREF(entries, index)); - if (RARRAY_LEN(entry) != 2) { - rb_raise(rb_eArgError, "Each futex vector entry must contain a futex and its expected value!"); - } - - VALUE futex = RARRAY_AREF(entry, 0); - vector[index].val = NUM2UINT(RARRAY_AREF(entry, 1)); - vector[index].uaddr = (uintptr_t)IO_Event_Futex_address(futex); - vector[index].flags = FUTEX_32; - vector[index].__reserved = 0; - } - - struct IO_Event_Selector_URing_Waiting waiting = { - .fiber = fiber, - }; - RB_OBJ_WRITTEN(self, Qundef, fiber); - - struct IO_Event_Selector_URing_Completion *completion = IO_Event_Selector_URing_Completion_acquire(selector, &waiting); + struct futex_waitv vector[FUTEX_WAITV_MAX]; + VALUE futexes = IO_Event_Futex_prepare_waitv(entries, vector); struct futex_wait_arguments arguments = { .selector = selector, - .waiting = &waiting, + .waiting = {.fiber = fiber}, + .futexes = futexes, + .vector = vector, + .count = RARRAY_LEN(futexes), }; - - struct io_uring_sqe *sqe = io_get_sqe(selector); - io_uring_prep_futex_waitv(sqe, vector, count, 0); - io_uring_sqe_set_data(sqe, completion); - io_uring_submit_pending(selector); - - VALUE result = rb_ensure(futex_waitv_transfer, (VALUE)&arguments, futex_wait_ensure, (VALUE)&arguments); - RB_GC_GUARD(entries); + RB_OBJ_WRITTEN(self, Qundef, fiber); + VALUE result = rb_ensure(futex_wait_transfer, (VALUE)&arguments, futex_wait_ensure, (VALUE)&arguments); + RB_GC_GUARD(futexes); return result; } @@ -1901,7 +1895,7 @@ static int IO_Event_Selector_URing_supported_p(void) { struct io_uring_probe *probe = io_uring_get_probe_ring(&ring); if (probe) { IO_Event_Selector_URing_futex_supported = io_uring_opcode_supported(probe, IORING_OP_FUTEX_WAIT); -#ifdef HAVE_IO_URING_PREP_FUTEX_WAITV +#if defined(IO_EVENT_FUTEX_WAITV) && defined(HAVE_IO_URING_PREP_FUTEX_WAITV) IO_Event_Selector_URing_futex_waitv_supported = io_uring_opcode_supported(probe, IORING_OP_FUTEX_WAITV); #endif io_uring_free_probe(probe); @@ -1961,7 +1955,7 @@ void Init_IO_Event_Selector_URing(VALUE IO_Event_Selector) { if (IO_Event_Selector_URing_futex_supported) { rb_define_method(IO_Event_Selector_URing, "futex_wait", IO_Event_Selector_URing_futex_wait, 3); -#ifdef HAVE_IO_URING_PREP_FUTEX_WAITV +#if defined(IO_EVENT_FUTEX_WAITV) && defined(HAVE_IO_URING_PREP_FUTEX_WAITV) if (IO_Event_Selector_URing_futex_waitv_supported) { rb_define_method(IO_Event_Selector_URing, "futex_waitv", IO_Event_Selector_URing_futex_waitv, 2); } diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 31d54ef8..8fb2dcfb 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -76,6 +76,32 @@ puts "[main] Done" # [main] Done ``` +## Shared-Memory Notifications + +On Linux with Ruby 4.1 or later, `IO::Event::Futex` provides atomic operations and notifications on an aligned 32-bit word in an `IO::Buffer`: + +```ruby +buffer = IO::Buffer.new(8) +first = IO::Event::Futex.new(buffer) +second = IO::Event::Futex.new(buffer, offset: 4) + +first.signal # Increment the word and wake one waiter. +first.close +# The allocation remains locked while second still refers to it: +second.close +buffer.free +``` + +Each futex owns one counted allocation lock, preventing the backing buffer from being freed, resized, or transferred while its address is retained. Slices lock their root allocation. `close` is idempotent and `closed?` reports whether the futex has been released. Operations on closed or uninitialized futexes raise `IOError`; futexes cannot be copied or reinitialized. + +A C-backed finalizer releases the lock if the futex is collected without being closed. Prefer explicit `close` for deterministic release. For externally managed memory, the external owner must also keep the storage alive for the entire binding. A finalizer must not indirectly retain its futex through application references attached to the buffer. + +`wait(expected)` waits while the word equals `expected`. `IO::Event::Futex.wait_any([[first, expected], ...])` waits on up to `IO::Event::Futex::WAITV_LIMIT` words. Wake-ups are notifications, not ownership of a permit: always recheck application state in a loop. Changing a word alone does not wake waiters; use `signal` or `wake`. + +Without a fiber scheduler, waits release the GVL and block the calling thread. With a scheduler, it must implement the optional `futex_wait` or `futex_waitv` hook. The URing selector exposes these methods only when liburing and the running kernel support the corresponding operations; check `respond_to?` before selecting a notification mechanism. An unsupported scheduler raises `NotImplementedError` rather than blocking its event loop. + +Closing a futex with pending waits raises `IOError`. Cancel and finish those waits first. Asynchronous cancellation drains the original operation before releasing its references, and vector waits retain a private snapshot of the supplied futexes. + ## Debugging The {ruby IO::Event::Debug::Selector} class adds extra validations and checks at the expense of performance. It can also log all operations. You can use this by setting the following environment variables: diff --git a/releases.md b/releases.md index 3a64f32e..1c1790ab 100644 --- a/releases.md +++ b/releases.md @@ -2,7 +2,7 @@ ## Unreleased - - Add `IO::Event::Futex` on Linux systems, including atomic value operations and blocking and scheduler-aware single and vector waits over shared memory. + - Add `IO::Event::Futex` on Linux with Ruby 4.1+, including atomic value operations and blocking and scheduler-aware single and vector waits over shared memory. Futexes retain a counted allocation lock until explicitly closed or finalized; pending waits prevent closing until completion or cancellation has finished. ## v1.22.1 diff --git a/test/io/event/futex.rb b/test/io/event/futex.rb index dfb38330..bd20925b 100644 --- a/test/io/event/futex.rb +++ b/test/io/event/futex.rb @@ -6,6 +6,14 @@ require "io/event" require "io/event/test_scheduler" +unless RUBY_PLATFORM.include?("linux") && Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("4.1") + describe IO::Event do + it "does not expose Futex without Linux and counted buffer locks" do + expect(subject).not.to be(:const_defined?, :Futex, false) + end + end +end + return unless defined?(IO::Event::Futex) describe IO::Event::Futex do @@ -34,6 +42,125 @@ selector end + with "buffer lifetime" do + it "locks the allocation until closed" do + instance = subject.new(buffer) + expect(buffer).to be(:locked?) + expect{buffer.free}.to raise_exception(IO::Buffer::LockedError) + expect{buffer.resize(16)}.to raise_exception(IO::Buffer::LockedError) + instance.close + expect(instance).to be(:closed?) + expect(buffer).not.to be(:locked?) + buffer.free + end + + it "owns one independent lock per futex" do + first = subject.new(buffer) + second = subject.new(buffer, offset: 4) + first.close + first.close + expect(buffer).to be(:locked?) + expect(second.increment).to be == 1 + second.close + expect(buffer).not.to be(:locked?) + end + + it "locks the root allocation when bound to a slice" do + instance = subject.new(buffer.slice(4, 4)) + expect{buffer.free}.to raise_exception(IO::Buffer::LockedError) + instance.close + expect(buffer).not.to be(:locked?) + end + + it "does not leak a lock when initialization fails" do + expect{subject.new(buffer, offset: 1)}.to raise_exception(ArgumentError) + expect{subject.new(buffer, offset: 8)}.to raise_exception(RangeError) + expect(buffer).not.to be(:locked?) + end + + it "does not allow reinitialization or copying" do + instance = subject.new(buffer) + expect{instance.send(:initialize, buffer)}.to raise_exception(RuntimeError) + expect{instance.dup}.to raise_exception(TypeError) + expect{instance.clone}.to raise_exception(TypeError) + instance.close + expect(buffer).not.to be(:locked?) + end + + it "does not unlock the original when rejected copies are collected" do + instance = subject.new(buffer) + Thread.new do + expect{instance.dup}.to raise_exception(TypeError) + expect{instance.clone}.to raise_exception(TypeError) + end.join + 3.times{GC.start(full_mark: true, immediate_sweep: true)} + expect(buffer).to be(:locked?) + expect(instance.increment).to be == 1 + instance.close + end + + it "releases locks when futexes are collected" do + # A separate thread removes conservative C-stack references before GC: + Thread.new{subject.new(buffer); nil}.join + 10.times do + GC.start(full_mark: true, immediate_sweep: true) + break unless buffer.locked? + end + expect(buffer).not.to be(:locked?) + end + + it "does not unlock another futex when a closed instance is collected" do + instance = subject.new(buffer) + Thread.new{subject.new(buffer, offset: 4).close}.join + 3.times{GC.start(full_mark: true, immediate_sweep: true)} + expect(buffer).to be(:locked?) + expect(instance.increment).to be == 1 + instance.close + expect(buffer).not.to be(:locked?) + end + + it "retains the buffer across compaction" do + instance = subject.new(buffer) + GC.verify_compaction_references(double_heap: true, toward: :empty) + expect(instance.increment).to be == 1 + instance.close + expect(buffer).not.to be(:locked?) + end + end + + with "closed or uninitialized futexes" do + [ + [:value], [:value=, 1], [:increment], [:decrement], + [:compare_exchange, 0, 1], [:wake], [:signal], [:wait, 0] + ].each do |arguments| + it "rejects #{arguments.first} after close", unique: "closed #{arguments.first}" do + futex.close + expect{futex.public_send(*arguments)}.to raise_exception(IOError) + end + + it "rejects #{arguments.first} before initialization", unique: "uninitialized #{arguments.first}" do + expect{subject.allocate.public_send(*arguments)}.to raise_exception(IOError) + end + end + end + + with "argument coercion" do + it "rechecks the futex after numeric conversion closes it" do + instance = futex + value = Object.new + value.define_singleton_method(:to_int) do + instance.close + 1 + end + expect{instance.value = value}.to raise_exception(IOError) + end + + it "validates the wake count before changing the value" do + expect{futex.signal(-1)}.to raise_exception(ArgumentError) + expect(futex.value).to be == 0 + end + end + with "#value" do it "stores and loads the value atomically" do futex.value = 42 @@ -99,6 +226,111 @@ end with "#wait" do + it "cannot be closed during a blocking wait" do + instance = futex + thread = Thread.new{instance.wait(0)} + Thread.pass while thread.status == "run" + expect{instance.close}.to raise_exception(IOError) + instance.signal + thread.join + instance.close + expect(buffer).not.to be(:locked?) + ensure + thread&.kill&.join + end + + it "releases a blocking wait when its thread is interrupted" do + instance = futex + thread = Thread.new{instance.wait(0)} + Thread.pass while thread.status == "run" + thread.kill.join + instance.close + expect(buffer).not.to be(:locked?) + ensure + thread&.kill&.join + end + + it "cannot be closed while an asynchronous wait is pending" do + selector = uring_selector + fiber = Fiber.new{selector.futex_wait(Fiber.current, futex, 0)} + fiber.transfer + expect{futex.close}.to raise_exception(IOError) + futex.signal + 10.times do + selector.select(0.1) + break unless fiber.alive? + end + expect(fiber).not.to be(:alive?) + futex.close + expect(buffer).not.to be(:locked?) + ensure + selector&.close + end + + it "drains cancellation before allowing close" do + selector = uring_selector + error = RuntimeError.new("cancel futex") + caught = nil + fiber = Fiber.new do + selector.futex_wait(Fiber.current, futex, 0) + rescue RuntimeError => exception + caught = exception + end + fiber.transfer + selector.select(0) + fiber.raise(error) + expect{futex.close}.to raise_exception(IOError) if fiber.alive? + 10.times do + selector.select(0.1) + break unless fiber.alive? + end + expect(caught).to be_equal(error) + expect(fiber).not.to be(:alive?) + futex.close + expect(buffer).not.to be(:locked?) + # Drain the cancellation CQE as well as the original operation: + selector.select(0) + ensure + selector&.close + end + + it "does not report an out-of-band resume as a notification" do + selector = uring_selector + result = :pending + fiber = Fiber.new{result = selector.futex_wait(Fiber.current, futex, 0)} + fiber.transfer + selector.select(0) + fiber.transfer + 10.times do + selector.select(0.1) + break unless fiber.alive? + end + expect(result).to be == false + futex.close + ensure + selector&.close + end + + it "keeps the selector usable after invalid arguments" do + selector = uring_selector + fiber = Fiber.new do + expect{selector.futex_wait(Fiber.current, futex, Object.new)}.to raise_exception(TypeError) + expect{selector.futex_wait(Fiber.current, Object.new, 0)}.to raise_exception(TypeError) + GC.start + futex.value = 1 + expect(selector.futex_wait(Fiber.current, futex, 0)).to be == false + end + fiber.transfer + 10.times do + selector.select(0.1) + break unless fiber.alive? + end + expect(fiber).not.to be(:alive?) + futex.close + ensure + selector&.close + end + it "waits without blocking other Ruby threads when no scheduler is installed" do thread = Thread.new do sleep 0.01 @@ -182,6 +414,74 @@ if IO::Event::Futex.respond_to?(:wait_any) with ".wait_any" do + it "releases earlier entries when a later entry is invalid" do + expect{subject.wait_any([[futex, 0], [Object.new, 0]])}.to raise_exception(TypeError) + futex.close + expect(buffer).not.to be(:locked?) + end + + it "protects every futex during a blocking vector wait" do + first = subject.new(buffer) + second = subject.new(buffer, offset: 4) + thread = Thread.new{subject.wait_any([[first, 0], [second, 0]])} + Thread.pass while thread.status == "run" + expect{first.close}.to raise_exception(IOError) + expect{second.close}.to raise_exception(IOError) + thread.kill.join + first.close + second.close + expect(buffer).not.to be(:locked?) + ensure + thread&.kill&.join + end + + it "retains its own entries until vector cancellation completes" do + selector = waitv_selector + first = subject.new(buffer) + second = subject.new(buffer, offset: 4) + entries = [[first, 0], [second, 0]] + caught = nil + error = RuntimeError.new("cancel vector") + fiber = Fiber.new do + selector.futex_waitv(Fiber.current, entries) + rescue RuntimeError => exception + caught = exception + end + fiber.transfer + entries.clear + GC.verify_compaction_references(double_heap: true, toward: :empty) + expect{first.close}.to raise_exception(IOError) + expect{second.close}.to raise_exception(IOError) + selector.select(0) + fiber.raise(error) + 10.times do + selector.select(0.1) + break unless fiber.alive? + end + expect(caught).to be_equal(error) + expect(fiber).not.to be(:alive?) + first.close + second.close + expect(buffer).not.to be(:locked?) + selector.select(0) + ensure + selector&.close + end + + it "keeps the selector usable after partial vector setup fails" do + selector = waitv_selector + fiber = Fiber.new do + expect{selector.futex_waitv(Fiber.current, [[futex, 0], [Object.new, 0]])}.to raise_exception(TypeError) + futex.close + end + fiber.transfer + GC.start + selector.select(0) + expect(buffer).not.to be(:locked?) + ensure + selector&.close + end + it "exposes the maximum number of wait entries" do expect(subject::WAITV_LIMIT).to be == 128 end From 14c1cf60888f3d00ae0929ebd76775082970965b Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 24 Sep 2026 14:45:52 +1200 Subject: [PATCH 12/22] Add a dedicated Futex notifications guide --- guides/futex/readme.md | 193 +++++++++++++++++++++++++++++++ guides/getting-started/readme.md | 24 +--- guides/links.yaml | 2 + readme.md | 1 + 4 files changed, 198 insertions(+), 22 deletions(-) create mode 100644 guides/futex/readme.md diff --git a/guides/futex/readme.md b/guides/futex/readme.md new file mode 100644 index 00000000..33dbcc7c --- /dev/null +++ b/guides/futex/readme.md @@ -0,0 +1,193 @@ +# Futex Notifications + +This guide explains how to use `IO::Event::Futex` to notify threads or processes when shared state changes, without continuously polling it. + +Shared state describes what changed; the futex tells you to check it. A wake-up does not transfer a message, reserve a worker, or grant ownership of a permit. + +## Availability + +`IO::Event::Futex` is available on Linux with Ruby 4.1 or later and the required Linux build headers. It uses Ruby's counted buffer locks to retain an aligned, writable 32-bit word. The class itself does not require `io_uring`. + +There are two ways to wait: + +- Without an active fiber scheduler for the calling fiber, waits release the GVL and block the calling thread using Linux futex syscalls. +- With a scheduler, waits invoke its optional `futex_wait` or `futex_waitv` hook. A missing hook raises `NotImplementedError`; it does not silently block the event loop. + +Only the URing selector provides asynchronous futex waits. EPoll, KQueue, and Select do not. URing exposes each method only when the build's liburing and the running kernel support the corresponding operation. Check the selected backend before choosing an IPC mechanism: + +```ruby +require "io/event" + +selector = IO::Event::Selector.new(Fiber.current) +begin + futex_available = IO::Event.const_defined?(:Futex, false) + puts "Single waits: #{futex_available && selector.respond_to?(:futex_wait)}" + puts "Vector waits: #{futex_available && selector.respond_to?(:futex_waitv)}" +ensure + selector.close +end +``` + +Creating a selector does not install a fiber scheduler. A scheduler integration must forward the following hooks to its own selector, and should only expose hooks that selector supports: + +| Scheduler hook | Selector call | +| --- | --- | +| `futex_wait(futex, expected)` | `selector.futex_wait(Fiber.current, futex, expected)` | +| `futex_waitv(entries)` | `selector.futex_waitv(Fiber.current, entries)` | + +For blocking vector waits, `IO::Event::Futex.respond_to?(:wait_any)` indicates build support, not running-kernel support; the syscall can still raise `Errno::ENOSYS`. Deployment restrictions can also prevent kernel operations. Negotiate capabilities before peers begin waiting, and choose a socket, pipe, or an IPC protocol such as `async-bus` when asynchronous futex waits are unavailable. There is no automatic IPC fallback in `Futex`. + +## Binding Shared Memory + +Each futex refers to four bytes at a byte offset within an `IO::Buffer`. The address must be aligned to four bytes, and the buffer must have at least four bytes remaining at that offset. The default offset is zero. Constructing a futex preserves the word's existing value. + +```ruby +require "io/event" + +buffer = IO::Buffer.new(8) +first = IO::Event::Futex.new(buffer) +second = IO::Event::Futex.new(buffer, offset: 4) + +begin + first.value = 0 + second.value = 0 + first.signal + puts first.value # => 1 +ensure + first.close + second.close + buffer.free +end +``` + +An ordinary `IO::Buffer.new` allocation is suitable for threads in one process. Forking does not make that allocation shared between processes. For IPC, map shared storage instead: for example, each process can use `IO::Buffer.map(file, size)` on the same pre-sized file opened for reading and writing, without `IO::Buffer::PRIVATE`. Bind futexes at matching offsets in that mapping; virtual addresses need not match between processes. + +Initialize the shared words once, before peers attach. Attaching peers must not reset live state. Futexes do not discover peers or transport Ruby objects; the application supplies the shared-state layout and synchronization protocol. + +## Atomic Operations and Notifications + +| Operation | Effect and return value | +| --- | --- | +| `value` | Atomically reads the word. | +| `value = n` | Atomically stores `n`. | +| `increment(amount = 1)` | Adds `amount` and returns the new value. | +| `decrement(amount = 1)` | Subtracts `amount` and returns the new value. | +| `compare_exchange(expected, desired)` | Stores `desired` only if the word equals `expected`; returns whether it succeeded. | +| `wake(count = 1)` | Wakes at most `count` waiters without changing the word; returns the number woken. | +| `signal(count = 1)` | Increments the word by one, then wakes at most `count` waiters; returns the new word value. | + +Loads use acquire ordering, stores use release ordering, and read-modify-write operations use acquire-release ordering. A failed compare-and-exchange uses acquire ordering. Arithmetic wraps modulo `2**32`; a notification counter is not an indefinitely increasing event history. + +Only `wake` and `signal` notify sleeping waiters. Assignment, increment, decrement, and compare-and-exchange do not wake them. `signal` performs an atomic increment followed by a wake syscall, not one indivisible increment-and-wake operation. Its argument is the waiter count, not the increment amount. Wake counts must be nonnegative integers that fit in a C `int`. + +Publish application state before notifying consumers. A futex does not make other memory accesses atomic or provide a queue's synchronization. Use atomic operations or another suitable synchronization protocol for that state, and do not mix concurrent non-atomic buffer accesses with atomic accesses to the futex word. + +## Waiting Without Missing Notifications + +`futex.wait(expected)` asks the kernel to wait only if the word still equals `expected`. Comparing the word and beginning the wait is atomic with respect to futex operations. If the value has already changed, the call returns `false` without sleeping; a successful wake returns `true`. + +Always recheck application state after a wait. Wake-ups can be spurious, another consumer may have taken the available work, and waking a waiter does not guarantee FIFO ordering or ownership. System errors raise exceptions. Neither wait API currently accepts a timeout argument. + +When the word is a notification counter for separate application state, the consumer must: + +1. Read the notification counter. +2. Check or attempt to consume the synchronized application state. +3. If no work is available, wait using the saved counter value. +4. Repeat after the wait returns. + +The producer publishes work before incrementing the counter and waking consumers. If a producer publishes between steps 2 and 3, the changed counter prevents the consumer from sleeping. Reading the counter *after* checking for work would lose this protection. Likewise, `wait` without an argument snapshots the word at the time of the call, so it is not a substitute for passing the earlier value. + +### Producer and Consumer + +This example uses a thread-safe Ruby `Queue` for application state and a futex for notifications. `Queue` already supports blocking `pop`; the explicit wait here demonstrates the protocol you would use with your own shared state. This particular queue is local to one process, not an IPC queue. + +```ruby +require "io/event" + +buffer = IO::Buffer.new(4) +notification = IO::Event::Futex.new(buffer) +notification.value = 0 +queue = Thread::Queue.new + +producer = Thread.new do + ["first", "second", "third", nil].each do |message| + queue << message + notification.signal + end +end + +begin + loop do + # Snapshot before checking application state: + expected = notification.value + + begin + message = queue.pop(true) + rescue ThreadError + notification.wait(expected) + next + end + + # A nil message marks the end of this example: + break if message.nil? + puts message + end +ensure + producer.join + notification.close + buffer.free +end +``` + +The queue retains work even when a notification wakes nobody. Notifications may be coalesced; consumers must inspect the state rather than count wake-ups. A 32-bit counter can wrap back to a saved value, so protocols must account for wraparound if a consumer could miss an entire counter cycle between its snapshot and wait. + +## Waiting on Several Words + +When supported, `IO::Event::Futex.wait_any(entries)` waits on a vector of `[futex, expected]` pairs. Supply between one and `IO::Event::Futex::WAITV_LIMIT` entries. It returns a zero-based index when woken, or `nil` if any word does not match its expected value. The index is a notification hint, not an exhaustive list of changes or a claim on the associated resource. + +For example, wait until either of two initially-zero words is nonzero: + +```ruby +require "io/event" + +buffer = IO::Buffer.new(8) +first = IO::Event::Futex.new(buffer) +second = IO::Event::Futex.new(buffer, offset: 4) +first.value = second.value = 0 + +producer = Thread.new{second.signal} + +begin + while first.value == 0 && second.value == 0 + IO::Event::Futex.wait_any([[first, 0], [second, 0]]) + end + puts "At least one word changed." +ensure + producer.join + first.close + second.close + buffer.free +end +``` + +For notification counters alongside separate state, snapshot all counters before checking that state, just as with a single wait. Vector waits retain a private snapshot of the supplied futex references; mutating the original entries does not change an already-pending wait. + +## Lifetime and Cancellation + +Each futex owns one counted allocation lock from construction until `close` or finalization. Slices lock their root allocation, and multiple words can independently retain the same allocation. The lock prevents freeing, resizing, or transferring that allocation; it does not prevent writing shared state. + +`close` is idempotent, and `closed?` reports whether the futex has been released. Word operations and waits on closed or uninitialized instances raise `IOError`. Futexes cannot be copied or reinitialized. A finalizer releases the lock if a futex is collected, but explicit `close` makes release deterministic. Avoid application references from the retained buffer back to its futex, which would keep it reachable through the finalizer. + +A futex with pending waits cannot be closed: `close` raises `IOError` rather than cancelling or waking those waits. For orderly shutdown: + +1. Publish shutdown state and notify the necessary waiters, or cancel them through the scheduler. +2. Let the waits finish, including cancellation cleanup. +3. Close all futexes, then free or unmap the buffer. + +URing cancellation drains the original kernel operation before releasing its retained references or wait vector. Keep the event loop running until that cleanup completes. Exceptions still propagate; if a wait is resumed out of band without an exception, it can instead return `false` for a single wait or `nil` for a vector wait. These returns still require rechecking application state. + +Allocation locks cannot protect against an external owner releasing memory or another process truncating a mapped file. The application must preserve the underlying storage for every process that can still access it. + +## Further Reading + +The Linux [futex overview](https://man7.org/linux/man-pages/man2/futex.2.html), [FUTEX_WAIT](https://man7.org/linux/man-pages/man2/FUTEX_WAIT.2const.html), and [FUTEX_WAKE](https://man7.org/linux/man-pages/man2/FUTEX_WAKE.2const.html) documentation describe the underlying shared-memory and notification semantics. diff --git a/guides/getting-started/readme.md b/guides/getting-started/readme.md index 8fb2dcfb..f8b65852 100644 --- a/guides/getting-started/readme.md +++ b/guides/getting-started/readme.md @@ -78,29 +78,9 @@ puts "[main] Done" ## Shared-Memory Notifications -On Linux with Ruby 4.1 or later, `IO::Event::Futex` provides atomic operations and notifications on an aligned 32-bit word in an `IO::Buffer`: +On Linux with Ruby 4.1 or later, `IO::Event::Futex` provides atomic operations and notifications on an aligned 32-bit word in an `IO::Buffer`. It supports blocking thread waits and optional asynchronous waits through the URing selector. -```ruby -buffer = IO::Buffer.new(8) -first = IO::Event::Futex.new(buffer) -second = IO::Event::Futex.new(buffer, offset: 4) - -first.signal # Increment the word and wake one waiter. -first.close -# The allocation remains locked while second still refers to it: -second.close -buffer.free -``` - -Each futex owns one counted allocation lock, preventing the backing buffer from being freed, resized, or transferred while its address is retained. Slices lock their root allocation. `close` is idempotent and `closed?` reports whether the futex has been released. Operations on closed or uninitialized futexes raise `IOError`; futexes cannot be copied or reinitialized. - -A C-backed finalizer releases the lock if the futex is collected without being closed. Prefer explicit `close` for deterministic release. For externally managed memory, the external owner must also keep the storage alive for the entire binding. A finalizer must not indirectly retain its futex through application references attached to the buffer. - -`wait(expected)` waits while the word equals `expected`. `IO::Event::Futex.wait_any([[first, expected], ...])` waits on up to `IO::Event::Futex::WAITV_LIMIT` words. Wake-ups are notifications, not ownership of a permit: always recheck application state in a loop. Changing a word alone does not wake waiters; use `signal` or `wake`. - -Without a fiber scheduler, waits release the GVL and block the calling thread. With a scheduler, it must implement the optional `futex_wait` or `futex_waitv` hook. The URing selector exposes these methods only when liburing and the running kernel support the corresponding operations; check `respond_to?` before selecting a notification mechanism. An unsupported scheduler raises `NotImplementedError` rather than blocking its event loop. - -Closing a futex with pending waits raises `IOError`. Cancel and finish those waits first. Asynchronous cancellation drains the original operation before releasing its references, and vector waits retain a private snapshot of the supplied futexes. +See the [Futex Notifications guide](../futex/index) for capability detection, shared-memory setup, wait/recheck examples, vector waits, and buffer lifetime management. ## Debugging diff --git a/guides/links.yaml b/guides/links.yaml index 7f527b02..4a26f798 100644 --- a/guides/links.yaml +++ b/guides/links.yaml @@ -1,2 +1,4 @@ getting-started: order: 1 +futex: + order: 2 diff --git a/readme.md b/readme.md index 84cbeb3b..c8a39d78 100644 --- a/readme.md +++ b/readme.md @@ -13,6 +13,7 @@ The initial proof-of-concept [Async](https://github.com/socketry/async) was buil Please see the [project documentation](https://socketry.github.io/io-event/) for more details. - [Getting Started](https://socketry.github.io/io-event/guides/getting-started/index) - This guide explains how to use `io-event` for non-blocking IO. + - [Futex Notifications](https://socketry.github.io/io-event/guides/futex/index) - This guide explains shared-memory notifications, capability detection, and safe waiting patterns. ## Releases From c2bdc4435a257acd7981dfe61e9c6f79827a6922 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 24 Sep 2026 17:15:51 +1200 Subject: [PATCH 13/22] Require an explicit expected value for Futex waits --- ext/io/event/futex.c | 11 ++--------- guides/futex/readme.md | 17 +++++++++++++++-- test/io/event/futex.rb | 11 ++++++++--- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/ext/io/event/futex.c b/ext/io/event/futex.c index 3e23bdb5..195f2d93 100644 --- a/ext/io/event/futex.c +++ b/ext/io/event/futex.c @@ -417,14 +417,7 @@ static VALUE IO_Event_Futex_blocking_waitv(VALUE entries) { #endif -static VALUE IO_Event_Futex_wait(int argc, VALUE *argv, VALUE self) { - VALUE expected_value; - rb_scan_args(argc, argv, "01", &expected_value); - - if (argc == 0) { - expected_value = IO_Event_Futex_value(self); - } - +static VALUE IO_Event_Futex_wait(VALUE self, VALUE expected_value) { VALUE scheduler = rb_fiber_scheduler_current(); if (NIL_P(scheduler)) { return IO_Event_Futex_blocking_wait(self, expected_value); @@ -474,7 +467,7 @@ void Init_IO_Event_Futex(VALUE IO_Event) { rb_define_method(IO_Event_Futex, "compare_exchange", IO_Event_Futex_compare_exchange, 2); rb_define_method(IO_Event_Futex, "wake", IO_Event_Futex_wake, -1); rb_define_method(IO_Event_Futex, "signal", IO_Event_Futex_signal, -1); - rb_define_method(IO_Event_Futex, "wait", IO_Event_Futex_wait, -1); + rb_define_method(IO_Event_Futex, "wait", IO_Event_Futex_wait, 1); #ifdef IO_EVENT_FUTEX_WAITV rb_define_const(IO_Event_Futex, "WAITV_LIMIT", INT2NUM(FUTEX_WAITV_MAX)); diff --git a/guides/futex/readme.md b/guides/futex/readme.md index 33dbcc7c..2b096f4b 100644 --- a/guides/futex/readme.md +++ b/guides/futex/readme.md @@ -84,7 +84,7 @@ Publish application state before notifying consumers. A futex does not make othe ## Waiting Without Missing Notifications -`futex.wait(expected)` asks the kernel to wait only if the word still equals `expected`. Comparing the word and beginning the wait is atomic with respect to futex operations. If the value has already changed, the call returns `false` without sleeping; a successful wake returns `true`. +`futex.wait(expected)` requires an explicit expected value and asks the kernel to wait only if the word still equals it. Comparing the word and beginning the wait is atomic with respect to futex operations. If the value has already changed, the call returns `false` without sleeping; a successful wake returns `true`. Omitting `expected` raises `ArgumentError`. Always recheck application state after a wait. Wake-ups can be spurious, another consumer may have taken the available work, and waking a waiter does not guarantee FIFO ordering or ownership. System errors raise exceptions. Neither wait API currently accepts a timeout argument. @@ -95,7 +95,20 @@ When the word is a notification counter for separate application state, the cons 3. If no work is available, wait using the saved counter value. 4. Repeat after the wait returns. -The producer publishes work before incrementing the counter and waking consumers. If a producer publishes between steps 2 and 3, the changed counter prevents the consumer from sleeping. Reading the counter *after* checking for work would lose this protection. Likewise, `wait` without an argument snapshots the word at the time of the call, so it is not a substitute for passing the earlier value. +The producer publishes work before incrementing the counter and waking consumers. If a producer publishes between steps 2 and 3, the changed counter prevents the consumer from sleeping. + +### Why the Expected Value Is Required + +Reading the counter *after* checking for work can miss a notification: + +1. The consumer finds the queue empty. The notification counter is `0`. +2. The producer adds work and signals, changing the counter to `1`. Nobody is waiting yet. +3. The consumer reads the counter and gets `1`. +4. The consumer calls `wait(1)`. Since the counter is still `1`, it sleeps despite available work. Without another notification, it can remain asleep indefinitely. + +An argument-free `wait` would hide step 3 inside the method. Requiring `expected` makes the snapshot explicit: the consumer should capture `0` before checking the queue, then call `wait(0)`. In the same sequence, the kernel sees that the counter is now `1` and returns immediately. If the consumer starts waiting before the producer signals, the signal wakes it instead. + +The required argument does not enforce the ordering by itself. Calling `wait(futex.value)` after checking for work has the same race. The intended sequence is **snapshot, check state, then wait using that snapshot**, followed by another state check after the wait returns. ### Producer and Consumer diff --git a/test/io/event/futex.rb b/test/io/event/futex.rb index bd20925b..200717bc 100644 --- a/test/io/event/futex.rb +++ b/test/io/event/futex.rb @@ -226,6 +226,10 @@ end with "#wait" do + it "requires an explicit expected value" do + expect{futex.wait}.to raise_exception(ArgumentError) + end + it "cannot be closed during a blocking wait" do instance = futex thread = Thread.new{instance.wait(0)} @@ -343,9 +347,10 @@ thread&.join end - it "does not wait without a scheduler when the value has changed" do - futex.value = 1 - expect(futex.wait(0)).to be == false + it "does not wait for a notification published after the snapshot" do + expected = futex.value + futex.signal + expect(futex.wait(expected)).to be == false end it "waits asynchronously for a signal" do From 0b662f63f56d11851bffa042b94fafb48e127b04 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 24 Sep 2026 18:29:44 +1200 Subject: [PATCH 14/22] Restore spacing between release notes --- releases.md | 1 + 1 file changed, 1 insertion(+) diff --git a/releases.md b/releases.md index 1c1790ab..b3a99aec 100644 --- a/releases.md +++ b/releases.md @@ -21,6 +21,7 @@ ## v1.20.0 - Add compatibility with Ruby 4.1's fiber scheduler interface version 4. Buffered IO operations now use `(offset, length)`, perform a single transfer of at most `length` bytes, return short transfers directly, and report `-EAGAIN` without waiting. Earlier Ruby versions retain the existing minimum-progress behavior. + ## v1.19.5 - Preserve the original exception or non-local control flow when `IO::Event::WorkerPool` cancellation interrupts a blocked fiber, while still cancelling and draining the in-flight blocking operation before returning control to Ruby. From 67f72a66a4b7284f59c0cf8f91415b2f1bf90ec3 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 24 Sep 2026 18:46:04 +1200 Subject: [PATCH 15/22] Remove redundant Futex platform absence test --- test/io/event/futex.rb | 8 -------- 1 file changed, 8 deletions(-) diff --git a/test/io/event/futex.rb b/test/io/event/futex.rb index 200717bc..51c10f26 100644 --- a/test/io/event/futex.rb +++ b/test/io/event/futex.rb @@ -6,14 +6,6 @@ require "io/event" require "io/event/test_scheduler" -unless RUBY_PLATFORM.include?("linux") && Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("4.1") - describe IO::Event do - it "does not expose Futex without Linux and counted buffer locks" do - expect(subject).not.to be(:const_defined?, :Futex, false) - end - end -end - return unless defined?(IO::Event::Futex) describe IO::Event::Futex do From cc118e862c5e6469a03bc13cf45fb37e2713ab2d Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 24 Sep 2026 18:51:14 +1200 Subject: [PATCH 16/22] Run Futex CI through a local Bake task --- .github/workflows/test-futex.yaml | 10 +--------- bake.rb | 24 ++++++++++++++++++++++++ guides/futex/readme.md | 10 ++++++++++ 3 files changed, 35 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test-futex.yaml b/.github/workflows/test-futex.yaml index 48bb62ac..40c820ad 100644 --- a/.github/workflows/test-futex.yaml +++ b/.github/workflows/test-futex.yaml @@ -22,12 +22,4 @@ jobs: - name: Run futex tests timeout-minutes: 5 - env: - RUBYLIB: lib:ext - run: | - cd ext - bundle exec ruby extconf.rb - make - cd .. - bundle exec ruby -e 'require "io/event"; abort "IO::Event::Futex.wait_any is unavailable!" unless defined?(IO::Event::Futex) && IO::Event::Futex.respond_to?(:wait_any); selector = IO::Event::Selector::URing.new(Fiber.current); abort "futex_waitv is unavailable!" unless selector.respond_to?(:futex_waitv)' - bundle exec sus test/io/event/futex.rb + run: bundle exec bake test_futex diff --git a/bake.rb b/bake.rb index 91b0b3fe..2f76961d 100644 --- a/bake.rb +++ b/bake.rb @@ -25,6 +25,30 @@ def before_test self.build end +# Build and run the Futex tests, requiring native and io_uring vector-wait support. +def test_futex + self.build + + require_relative "config/environment" + require_relative "lib/io/event" + + unless defined?(IO::Event::Futex) && IO::Event::Futex.respond_to?(:wait_any) + raise "IO::Event::Futex.wait_any is unavailable!" + end + + unless defined?(IO::Event::Selector::URing) + raise "IO::Event::Selector::URing is unavailable!" + end + + [:futex_wait, :futex_waitv].each do |name| + unless IO::Event::Selector::URing.method_defined?(name) + raise "IO::Event::Selector::URing##{name} is unavailable!" + end + end + + system("bundle", "exec", "sus", "test/io/event/futex.rb", exception: true) +end + # Update the project documentation with the new version number. # # @parameter version [String] The new version number. diff --git a/guides/futex/readme.md b/guides/futex/readme.md index 2b096f4b..c9acf523 100644 --- a/guides/futex/readme.md +++ b/guides/futex/readme.md @@ -201,6 +201,16 @@ URing cancellation drains the original kernel operation before releasing its ret Allocation locks cannot protect against an external owner releasing memory or another process truncating a mapped file. The application must preserve the underlying storage for every process that can still access it. +## Running the Tests + +From a project checkout, run the same task used by the dedicated Linux CI job: + +```shell +bundle exec bake test_futex +``` + +This builds the extension, requires native vector waits and both URing futex-wait methods, and runs the Futex tests. Missing support is an error rather than a skipped test suite. The ordinary test suite remains usable on platforms without Futex support. + ## Further Reading The Linux [futex overview](https://man7.org/linux/man-pages/man2/futex.2.html), [FUTEX_WAIT](https://man7.org/linux/man-pages/man2/FUTEX_WAIT.2const.html), and [FUTEX_WAKE](https://man7.org/linux/man-pages/man2/FUTEX_WAKE.2const.html) documentation describe the underlying shared-memory and notification semantics. From b07d8dfa15e0f0904c090cc37c76dc73c43a9e6f Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 24 Sep 2026 19:45:45 +1200 Subject: [PATCH 17/22] Run Futex checks in the main test workflow --- .github/workflows/test-futex.yaml | 25 ------------------------- .github/workflows/test.yaml | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 25 deletions(-) delete mode 100644 .github/workflows/test-futex.yaml diff --git a/.github/workflows/test-futex.yaml b/.github/workflows/test-futex.yaml deleted file mode 100644 index 40c820ad..00000000 --- a/.github/workflows/test-futex.yaml +++ /dev/null @@ -1,25 +0,0 @@ -name: Test Futex - -on: [push, pull_request] - -permissions: - contents: read - -jobs: - test: - runs-on: ubuntu-26.04 - - steps: - - uses: actions/checkout@v7 - - - name: Install liburing with futex support - run: sudo apt-get update && sudo apt-get install -y liburing-dev - - - uses: ruby/setup-ruby@v1 - with: - ruby-version: head - bundler-cache: true - - - name: Run futex tests - timeout-minutes: 5 - run: bundle exec bake test_futex diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 03ea1c90..08976c64 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -50,3 +50,22 @@ jobs: - name: Run tests timeout-minutes: 10 run: bundle exec bake test + + futex: + name: Futex on Linux (head) + runs-on: ubuntu-26.04 + + steps: + - uses: actions/checkout@v6 + + - name: Install liburing with futex support + run: sudo apt-get update && sudo apt-get install -y liburing-dev + + - uses: ruby/setup-ruby@v1 + with: + ruby-version: head + bundler-cache: true + + - name: Run futex tests + timeout-minutes: 5 + run: bundle exec bake test_futex From 141c4b531f1c5fec0a01170a40e8c448bf23aa4c Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 24 Sep 2026 20:16:00 +1200 Subject: [PATCH 18/22] Exercise Futex in the regular Linux head test matrix --- .github/workflows/test.yaml | 37 +++++++++---------------------------- bake.rb | 24 ------------------------ guides/futex/readme.md | 6 +++--- 3 files changed, 12 insertions(+), 55 deletions(-) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 08976c64..1b7b4ecf 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -8,15 +8,15 @@ permissions: jobs: test: name: ${{matrix.ruby}} on ${{matrix.os}} - runs-on: ${{matrix.os}}-latest + runs-on: ${{matrix.os}} continue-on-error: ${{matrix.experimental}} strategy: matrix: os: - - ubuntu - - macos - - windows + - ubuntu-latest + - macos-latest + - windows-latest ruby: - "3.3" @@ -26,13 +26,13 @@ jobs: experimental: [false] include: - - os: ubuntu + - os: ubuntu-latest ruby: truffleruby experimental: true - - os: ubuntu + - os: ubuntu-latest ruby: jruby experimental: true - - os: ubuntu + - os: ubuntu-26.04 ruby: head experimental: true @@ -44,28 +44,9 @@ jobs: bundler-cache: true - name: Install packages (Ubuntu) - if: matrix.os == 'ubuntu' - run: sudo apt-get install -y liburing-dev + if: startsWith(matrix.os, 'ubuntu-') + run: sudo apt-get update && sudo apt-get install -y liburing-dev - name: Run tests timeout-minutes: 10 run: bundle exec bake test - - futex: - name: Futex on Linux (head) - runs-on: ubuntu-26.04 - - steps: - - uses: actions/checkout@v6 - - - name: Install liburing with futex support - run: sudo apt-get update && sudo apt-get install -y liburing-dev - - - uses: ruby/setup-ruby@v1 - with: - ruby-version: head - bundler-cache: true - - - name: Run futex tests - timeout-minutes: 5 - run: bundle exec bake test_futex diff --git a/bake.rb b/bake.rb index 2f76961d..91b0b3fe 100644 --- a/bake.rb +++ b/bake.rb @@ -25,30 +25,6 @@ def before_test self.build end -# Build and run the Futex tests, requiring native and io_uring vector-wait support. -def test_futex - self.build - - require_relative "config/environment" - require_relative "lib/io/event" - - unless defined?(IO::Event::Futex) && IO::Event::Futex.respond_to?(:wait_any) - raise "IO::Event::Futex.wait_any is unavailable!" - end - - unless defined?(IO::Event::Selector::URing) - raise "IO::Event::Selector::URing is unavailable!" - end - - [:futex_wait, :futex_waitv].each do |name| - unless IO::Event::Selector::URing.method_defined?(name) - raise "IO::Event::Selector::URing##{name} is unavailable!" - end - end - - system("bundle", "exec", "sus", "test/io/event/futex.rb", exception: true) -end - # Update the project documentation with the new version number. # # @parameter version [String] The new version number. diff --git a/guides/futex/readme.md b/guides/futex/readme.md index c9acf523..9824b7a4 100644 --- a/guides/futex/readme.md +++ b/guides/futex/readme.md @@ -203,13 +203,13 @@ Allocation locks cannot protect against an external owner releasing memory or an ## Running the Tests -From a project checkout, run the same task used by the dedicated Linux CI job: +The Futex tests are included in the ordinary test suite. From a project checkout: ```shell -bundle exec bake test_futex +bundle exec bake test ``` -This builds the extension, requires native vector waits and both URing futex-wait methods, and runs the Futex tests. Missing support is an error rather than a skipped test suite. The ordinary test suite remains usable on platforms without Futex support. +This builds the extension and runs all tests. The Linux/Ruby-head CI matrix entry uses Ubuntu 26.04 and its packaged `liburing-dev` to exercise Futex support. Futex tests are conditional on the available APIs, so the suite also runs on older Ruby versions and platforms without Futex support. ## Further Reading From 8aea891fd2131eb401f2dd2972ac3bf80725a5a5 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 24 Sep 2026 20:23:03 +1200 Subject: [PATCH 19/22] Document temporary Ubuntu 26.04 pin for Futex coverage --- .github/workflows/test.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 1b7b4ecf..96c749b7 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -32,6 +32,8 @@ jobs: - os: ubuntu-latest ruby: jruby experimental: true + # Use newer packaged liburing for Futex coverage on Ruby head. + # Switch back to ubuntu-latest after its 26.04 rollout (2026-11-19). - os: ubuntu-26.04 ruby: head experimental: true From 54d253478ba85a0ff9bc851e506a8961fa0a80c8 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 24 Sep 2026 20:23:19 +1200 Subject: [PATCH 20/22] Keep the Futex guide focused on usage --- guides/futex/readme.md | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/guides/futex/readme.md b/guides/futex/readme.md index 9824b7a4..2b096f4b 100644 --- a/guides/futex/readme.md +++ b/guides/futex/readme.md @@ -201,16 +201,6 @@ URing cancellation drains the original kernel operation before releasing its ret Allocation locks cannot protect against an external owner releasing memory or another process truncating a mapped file. The application must preserve the underlying storage for every process that can still access it. -## Running the Tests - -The Futex tests are included in the ordinary test suite. From a project checkout: - -```shell -bundle exec bake test -``` - -This builds the extension and runs all tests. The Linux/Ruby-head CI matrix entry uses Ubuntu 26.04 and its packaged `liburing-dev` to exercise Futex support. Futex tests are conditional on the available APIs, so the suite also runs on older Ruby versions and platforms without Futex support. - ## Further Reading The Linux [futex overview](https://man7.org/linux/man-pages/man2/futex.2.html), [FUTEX_WAIT](https://man7.org/linux/man-pages/man2/FUTEX_WAIT.2const.html), and [FUTEX_WAKE](https://man7.org/linux/man-pages/man2/FUTEX_WAKE.2const.html) documentation describe the underlying shared-memory and notification semantics. From 9944c6d3028484da85ba9fcb91a71c60fe5ea368 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Thu, 24 Sep 2026 20:55:41 +1200 Subject: [PATCH 21/22] Separate single and vector Futex wait state --- ext/io/event/selector/uring.c | 122 ++++++++++++++++++++++++---------- test/io/event/futex.rb | 42 ++++++++++++ 2 files changed, 129 insertions(+), 35 deletions(-) diff --git a/ext/io/event/selector/uring.c b/ext/io/event/selector/uring.c index 52bcc123..44a0d70f 100644 --- a/ext/io/event/selector/uring.c +++ b/ext/io/event/selector/uring.c @@ -813,20 +813,16 @@ struct futex_wait_arguments { struct IO_Event_Selector_URing *selector; struct IO_Event_Selector_URing_Waiting waiting; VALUE futex; - VALUE futexes; - struct futex_waitv *vector; uint32_t expected; - long count; - long acquired; + bool acquired; bool submitted; }; static VALUE futex_wait_cancel(VALUE _arguments) { struct futex_wait_arguments *arguments = (struct futex_wait_arguments *)_arguments; if (arguments->submitted) { - // Drain the original operation before releasing its futex references or - // stack-backed wait vector. The shared helper also tracks cancellation - // CQEs so the completion record cannot be reused prematurely. + // Drain the original operation before releasing its futex. The shared + // helper tracks cancellation CQEs to prevent premature completion reuse. IO_Event_Selector_URing_Waiting_cancel_and_wait(arguments->selector, &arguments->waiting); } else if (arguments->waiting.completion) { // Setup failed before an SQE referred to this completion. @@ -837,10 +833,9 @@ static VALUE futex_wait_cancel(VALUE _arguments) { static VALUE futex_wait_release(VALUE _arguments) { struct futex_wait_arguments *arguments = (struct futex_wait_arguments *)_arguments; - while (arguments->acquired) { - long index = --arguments->acquired; - VALUE futex = arguments->vector ? RARRAY_AREF(arguments->futexes, index) : arguments->futex; - IO_Event_Futex_release(futex); + if (arguments->acquired) { + IO_Event_Futex_release(arguments->futex); + arguments->acquired = false; } return Qnil; } @@ -851,29 +846,14 @@ static VALUE futex_wait_ensure(VALUE _arguments) { static VALUE futex_wait_transfer(VALUE _arguments) { struct futex_wait_arguments *arguments = (struct futex_wait_arguments *)_arguments; - uint32_t *address = NULL; - while (arguments->acquired < arguments->count) { - long index = arguments->acquired; - VALUE futex = arguments->vector ? RARRAY_AREF(arguments->futexes, index) : arguments->futex; - address = IO_Event_Futex_acquire(futex); - arguments->acquired += 1; -#ifdef IO_EVENT_FUTEX_WAITV - if (arguments->vector) arguments->vector[index].uaddr = (uintptr_t)address; -#endif - } + uint32_t *address = IO_Event_Futex_acquire(arguments->futex); + arguments->acquired = true; - // Arguments have been coerced and each futex is now held open. From here + // Arguments have been coerced and the futex is now held open. From here // on, the enclosing ensure owns all completion and cancellation cleanup. struct IO_Event_Selector_URing_Completion *completion = IO_Event_Selector_URing_Completion_acquire(arguments->selector, &arguments->waiting); struct io_uring_sqe *sqe = io_get_sqe(arguments->selector); -#if defined(IO_EVENT_FUTEX_WAITV) && defined(HAVE_IO_URING_PREP_FUTEX_WAITV) - if (arguments->vector) { - io_uring_prep_futex_waitv(sqe, arguments->vector, arguments->count, 0); - } else -#endif - { - io_uring_prep_futex_wait(sqe, address, arguments->expected, FUTEX_BITSET_MATCH_ANY, FUTEX2_SIZE_U32, 0); - } + io_uring_prep_futex_wait(sqe, address, arguments->expected, FUTEX_BITSET_MATCH_ANY, FUTEX2_SIZE_U32, 0); io_uring_sqe_set_data(sqe, completion); arguments->submitted = true; io_uring_submit_pending(arguments->selector); @@ -886,9 +866,9 @@ static VALUE futex_wait_transfer(VALUE _arguments) { int32_t result = arguments->waiting.result; if (result >= 0) { - return arguments->vector ? INT2NUM(result) : Qtrue; + return Qtrue; } else if (result == -EAGAIN || result == -ECANCELED) { - return arguments->vector ? Qnil : Qfalse; + return Qfalse; } else { rb_syserr_fail(-result, "futex_wait_transfer:io_uring_futex_wait"); } @@ -904,7 +884,6 @@ static VALUE IO_Event_Selector_URing_futex_wait(VALUE self, VALUE fiber, VALUE f .waiting = {.fiber = fiber}, .futex = futex, .expected = NUM2UINT(expected_value), - .count = 1, }; RB_OBJ_WRITTEN(self, Qundef, fiber); VALUE result = rb_ensure(futex_wait_transfer, (VALUE)&arguments, futex_wait_ensure, (VALUE)&arguments); @@ -914,13 +893,86 @@ static VALUE IO_Event_Selector_URing_futex_wait(VALUE self, VALUE fiber, VALUE f #if defined(IO_EVENT_FUTEX_WAITV) && defined(HAVE_IO_URING_PREP_FUTEX_WAITV) +#pragma mark - Futex Vector Wait + +struct futex_waitv_arguments { + struct IO_Event_Selector_URing *selector; + struct IO_Event_Selector_URing_Waiting waiting; + VALUE futexes; + struct futex_waitv *vector; + long count; + long acquired; + bool submitted; +}; + +static VALUE futex_waitv_cancel(VALUE _arguments) { + struct futex_waitv_arguments *arguments = (struct futex_waitv_arguments *)_arguments; + if (arguments->submitted) { + // Drain the original operation before releasing its futex references or + // stack-backed wait vector. The shared helper also tracks cancellation + // CQEs so the completion record cannot be reused prematurely. + IO_Event_Selector_URing_Waiting_cancel_and_wait(arguments->selector, &arguments->waiting); + } else if (arguments->waiting.completion) { + // Setup failed before an SQE referred to this completion. + IO_Event_Selector_URing_Completion_complete(arguments->selector, arguments->waiting.completion); + } + return Qnil; +} + +static VALUE futex_waitv_release(VALUE _arguments) { + struct futex_waitv_arguments *arguments = (struct futex_waitv_arguments *)_arguments; + while (arguments->acquired) { + IO_Event_Futex_release(RARRAY_AREF(arguments->futexes, --arguments->acquired)); + } + return Qnil; +} + +static VALUE futex_waitv_ensure(VALUE _arguments) { + return rb_ensure(futex_waitv_cancel, _arguments, futex_waitv_release, _arguments); +} + +static VALUE futex_waitv_transfer(VALUE _arguments) { + struct futex_waitv_arguments *arguments = (struct futex_waitv_arguments *)_arguments; + while (arguments->acquired < arguments->count) { + long index = arguments->acquired; + arguments->vector[index].uaddr = (uintptr_t)IO_Event_Futex_acquire(RARRAY_AREF(arguments->futexes, index)); + arguments->acquired += 1; + } + + // Arguments have been coerced and every futex is now held open. The + // enclosing ensure also releases entries if acquisition fails partway. + struct IO_Event_Selector_URing_Completion *completion = IO_Event_Selector_URing_Completion_acquire(arguments->selector, &arguments->waiting); + struct io_uring_sqe *sqe = io_get_sqe(arguments->selector); + io_uring_prep_futex_waitv(sqe, arguments->vector, arguments->count, 0); + io_uring_sqe_set_data(sqe, completion); + arguments->submitted = true; + io_uring_submit_pending(arguments->selector); + + IO_Event_Selector_loop_yield(&arguments->selector->backend); + if (arguments->waiting.completion) { + // An out-of-band resume is not a successful futex notification. + IO_Event_Selector_URing_Waiting_cancel_and_wait(arguments->selector, &arguments->waiting); + } + + int32_t result = arguments->waiting.result; + if (result >= 0) { + return INT2NUM(result); + } else if (result == -EAGAIN || result == -ECANCELED) { + return Qnil; + } else { + rb_syserr_fail(-result, "futex_waitv_transfer:io_uring_futex_waitv"); + } + + return Qnil; +} + static VALUE IO_Event_Selector_URing_futex_waitv(VALUE self, VALUE fiber, VALUE entries) { struct IO_Event_Selector_URing *selector = NULL; TypedData_Get_Struct(self, struct IO_Event_Selector_URing, &IO_Event_Selector_URing_Type, selector); struct futex_waitv vector[FUTEX_WAITV_MAX]; VALUE futexes = IO_Event_Futex_prepare_waitv(entries, vector); - struct futex_wait_arguments arguments = { + struct futex_waitv_arguments arguments = { .selector = selector, .waiting = {.fiber = fiber}, .futexes = futexes, @@ -928,7 +980,7 @@ static VALUE IO_Event_Selector_URing_futex_waitv(VALUE self, VALUE fiber, VALUE .count = RARRAY_LEN(futexes), }; RB_OBJ_WRITTEN(self, Qundef, fiber); - VALUE result = rb_ensure(futex_wait_transfer, (VALUE)&arguments, futex_wait_ensure, (VALUE)&arguments); + VALUE result = rb_ensure(futex_waitv_transfer, (VALUE)&arguments, futex_waitv_ensure, (VALUE)&arguments); RB_GC_GUARD(futexes); return result; } diff --git a/test/io/event/futex.rb b/test/io/event/futex.rb index 51c10f26..7c18306a 100644 --- a/test/io/event/futex.rb +++ b/test/io/event/futex.rb @@ -479,6 +479,48 @@ selector&.close end + it "returns nil for an out-of-band vector resume" do + selector = waitv_selector + first = subject.new(buffer) + second = subject.new(buffer, offset: 4) + result = :pending + fiber = Fiber.new{result = selector.futex_waitv(Fiber.current, [[first, 0], [second, 0]])} + fiber.transfer + selector.select(0) + fiber.transfer + 10.times do + selector.select(0.1) + break unless fiber.alive? + end + expect(result).to be_nil + expect(fiber).not.to be(:alive?) + first.close + second.close + expect(buffer).not.to be(:locked?) + selector.select(0) + ensure + selector&.close + end + + it "returns index zero for a single-entry vector notification" do + selector = waitv_selector + result = nil + fiber = Fiber.new{result = selector.futex_waitv(Fiber.current, [[futex, 0]])} + fiber.transfer + thread = Thread.new do + sleep 0.01 + futex.signal + end + selector.select(1) + thread.join + expect(result).to be == 0 + futex.close + expect(buffer).not.to be(:locked?) + ensure + selector&.close + thread&.join + end + it "exposes the maximum number of wait entries" do expect(subject::WAITV_LIMIT).to be == 128 end From ea695604f94b7c119a73a6c1cac0bb4daa412bc7 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 25 Sep 2026 08:22:53 +1200 Subject: [PATCH 22/22] Make zero-count Futex wake and signal no-ops --- ext/io/event/futex.c | 9 ++++++++- guides/futex/readme.md | 6 ++++-- test/io/event/futex.rb | 22 +++++++++++++++++++--- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/ext/io/event/futex.c b/ext/io/event/futex.c index 195f2d93..e52b3ecb 100644 --- a/ext/io/event/futex.c +++ b/ext/io/event/futex.c @@ -213,7 +213,11 @@ static VALUE IO_Event_Futex_wake(int argc, VALUE *argv, VALUE self) { int count = NIL_P(count_value) ? 1 : NUM2INT(count_value); if (count < 0) rb_raise(rb_eArgError, "Wake count must be non-negative!"); - int result = syscall(SYS_futex, IO_Event_Futex_address(self), FUTEX_WAKE, count, NULL, NULL, 0); + uint32_t *address = IO_Event_Futex_address(self); + // Legacy FUTEX_WAKE can wake one waiter even when count is zero. + if (count == 0) return INT2NUM(0); + + int result = syscall(SYS_futex, address, FUTEX_WAKE, count, NULL, NULL, 0); if (result < 0) rb_sys_fail("IO_Event_Futex_wake:futex"); return INT2NUM(result); } @@ -225,6 +229,9 @@ static VALUE IO_Event_Futex_signal(int argc, VALUE *argv, VALUE self) { int count = NIL_P(count_value) ? 1 : NUM2INT(count_value); if (count < 0) rb_raise(rb_eArgError, "Wake count must be non-negative!"); uint32_t *address = IO_Event_Futex_address(self); + // A zero-count signal neither changes the word nor wakes any waiters. + if (count == 0) return UINT2NUM(__atomic_load_n(address, __ATOMIC_ACQUIRE)); + uint32_t value = __atomic_add_fetch(address, 1, __ATOMIC_ACQ_REL); int result = syscall(SYS_futex, address, FUTEX_WAKE, count, NULL, NULL, 0); if (result < 0) rb_sys_fail("IO_Event_Futex_signal:futex"); diff --git a/guides/futex/readme.md b/guides/futex/readme.md index 2b096f4b..d2a9cd54 100644 --- a/guides/futex/readme.md +++ b/guides/futex/readme.md @@ -74,11 +74,13 @@ Initialize the shared words once, before peers attach. Attaching peers must not | `decrement(amount = 1)` | Subtracts `amount` and returns the new value. | | `compare_exchange(expected, desired)` | Stores `desired` only if the word equals `expected`; returns whether it succeeded. | | `wake(count = 1)` | Wakes at most `count` waiters without changing the word; returns the number woken. | -| `signal(count = 1)` | Increments the word by one, then wakes at most `count` waiters; returns the new word value. | +| `signal(count = 1)` | For a positive count, increments the word by one, then wakes at most `count` waiters; returns the word value. | Loads use acquire ordering, stores use release ordering, and read-modify-write operations use acquire-release ordering. A failed compare-and-exchange uses acquire ordering. Arithmetic wraps modulo `2**32`; a notification counter is not an indefinitely increasing event history. -Only `wake` and `signal` notify sleeping waiters. Assignment, increment, decrement, and compare-and-exchange do not wake them. `signal` performs an atomic increment followed by a wake syscall, not one indivisible increment-and-wake operation. Its argument is the waiter count, not the increment amount. Wake counts must be nonnegative integers that fit in a C `int`. +Only `wake` and `signal` notify sleeping waiters. Assignment, increment, decrement, and compare-and-exchange do not wake them. For a positive count, `signal` performs an atomic increment followed by a wake syscall, not one indivisible increment-and-wake operation. Its argument is the waiter count, not the increment amount. Wake counts must be nonnegative integers that fit in a C `int`. + +Both `wake(0)` and `signal(0)` are no-ops: they leave the word and waiters unchanged. `wake(0)` returns `0`; `signal(0)` returns the current word value. Closed or uninitialized futexes still raise `IOError`. Publish application state before notifying consumers. A futex does not make other memory accesses atomic or provide a queue's synchronization. Use atomic operations or another suitable synchronization protocol for that state, and do not mix concurrent non-atomic buffer accesses with atomic accesses to the futex word. diff --git a/test/io/event/futex.rb b/test/io/event/futex.rb index 7c18306a..3e59b4d3 100644 --- a/test/io/event/futex.rb +++ b/test/io/event/futex.rb @@ -123,14 +123,14 @@ with "closed or uninitialized futexes" do [ [:value], [:value=, 1], [:increment], [:decrement], - [:compare_exchange, 0, 1], [:wake], [:signal], [:wait, 0] + [:compare_exchange, 0, 1], [:wake], [:wake, 0], [:signal], [:signal, 0], [:wait, 0] ].each do |arguments| - it "rejects #{arguments.first} after close", unique: "closed #{arguments.first}" do + it "rejects #{arguments.inspect} after close", unique: "closed #{arguments.inspect}" do futex.close expect{futex.public_send(*arguments)}.to raise_exception(IOError) end - it "rejects #{arguments.first} before initialization", unique: "uninitialized #{arguments.first}" do + it "rejects #{arguments.inspect} before initialization", unique: "uninitialized #{arguments.inspect}" do expect{subject.allocate.public_send(*arguments)}.to raise_exception(IOError) end end @@ -160,6 +160,22 @@ end end + with "zero-count notifications" do + [:wake, :signal].each do |operation| + it "leaves the word and waiters unchanged for #{operation}(0)", unique: operation.to_s do + futex.value = 7 + thread = Thread.new{futex.wait(7)} + Thread.pass while thread.status == "run" + + expect(futex.public_send(operation, 0)).to be == (operation == :wake ? 0 : 7) + expect(futex.value).to be == 7 + expect(thread.join(0.02)).to be_nil + ensure + thread&.kill&.join + end + end + end + with "#increment" do it "increments the value" do expect(futex.increment).to be == 1