carbon_fiber 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e4912cee9b33c19f921ee9ac02bfb3b7522295d4036a026c4e01756f827268b1
4
- data.tar.gz: a19ab53436bd10394f12ba6f0f0a91e0b62aac470865b86af932ba98922fb0fa
3
+ metadata.gz: 3906713d933ac5b29349ed47160f5e2704060f0a3cdd101474eea9e59cf74dcf
4
+ data.tar.gz: 78da45d46027261788f4c01333ae68cb90ece1992350b7aabb52e59564b32b9b
5
5
  SHA512:
6
- metadata.gz: c59ef2c0819bc66a3ba7f4b19474d1f2b38404ac99e0dc018e1bdf4247008627aea99203b063817f746702be501d2b357ac67048ebd74ef4f059448e67e20d1e
7
- data.tar.gz: a4703832a539939964ed9dcb535ee1813038a23fe94837457c8ddf5a8fea5e808552b40661bad6ae83091d2cbefc8c55f513a69a3d9671ffb8e6dee73bbfa821
6
+ metadata.gz: cb0ae60a2ef8f3463d437b79cc4fd9d36f092060504eb2bcaea31e17b96cf1e137784cd3b01f1f1950d53fd7cb2737f6cb15cbc0c952fb3156578d3ac7b8b31e
7
+ data.tar.gz: df4857f35926d78a04e75f908f03c423d0d275a4a2bb3a4d7fd03eab3f84f46b16feafffc352b6898860aeda02a9f8f91fa51156afbf8d82ef43056899990200
data/CHANGELOG.md CHANGED
@@ -1,3 +1,19 @@
1
+ ## [Unreleased]
2
+
3
+ - Fix the pure-Ruby fallback loop burning CPU while a fiber waits on a background thread; inside a non-main Ractor that spin starved the thread it was waiting for and hung the loop.
4
+
5
+ ## [0.2.0] - 2026-09-05
6
+
7
+ - Work inside Ractors: the native extension is declared Ractor-safe, so each Ractor can install its own `CarbonFiber::Scheduler` (several at once need Ruby 4.0 or later). See the README for the supported messaging pattern.
8
+ - Resolve hostnames in non-main Ractors through the platform resolver on a background thread, since Resolv cannot run there.
9
+ - Fix fibers parked in `Queue#pop`, `Mutex#lock`, or any `block` without a timeout being garbage collected while waiting; once unblocked this raised `TypeError: wrong argument type false (expected fiber)` or hung the loop.
10
+ - Fix a race between garbage collection and I/O completions arriving while the loop waits with the GVL released.
11
+ - Support the Ruby 4.1 buffered I/O scheduler contract: `io_read`/`io_write` take `(io, buffer, offset, length)` and perform a single transfer; Ruby 3.4 through 4.0 keep the legacy behavior. (Thanks to [Samuel Williams](https://github.com/samuel-williams-shopify)).
12
+ - The native extension now loads on Ruby development builds: a C shim compiled against the target headers provides the `ruby_abi_version` their ABI check requires.
13
+ - Match Ruby 4.1's updated `rb_data_type_t` layout when registering the native selector, and verify the layout against the target Ruby's headers when the extension loads.
14
+ - Under the Ruby 4.1 contract, regular-file reads and writes run on the background I/O path instead of risking blocking the scheduler thread.
15
+ - `Scheduler#io_close` and `Async::Selector#io_close` now take a file descriptor (Integer), matching Ruby's Fiber::Scheduler protocol. (Thanks to [Samuel Williams](https://github.com/samuel-williams-shopify)).
16
+
1
17
  ## [0.1.3] - 2026-05-08
2
18
 
3
19
  - Update to Zig 0.16.0; update libxev and zig.rb to the latest versions.
data/README.md CHANGED
@@ -240,6 +240,47 @@ end
240
240
 
241
241
  ---
242
242
 
243
+ ### Inside Ractors
244
+
245
+ A fiber scheduler belongs to a thread, and every Ractor runs its own threads, so each Ractor installs its own `CarbonFiber::Scheduler`. Construct it inside the Ractor that runs it; never share one between Ractors. Running several such Ractors at once needs Ruby 4.0 or later; Ruby 3.4 aborts or hangs under that load, with the pure-Ruby selector as well. On Windows a garbage collection inside a non-main Ractor pauses for several seconds while the main Ractor waits, so Ractor workloads there are slow. Every hook works there as on the main Ractor, including DNS: `address_resolve` uses Resolv on the main Ractor and the platform resolver on a background thread elsewhere, because Resolv keeps state that Ractor isolation forbids.
246
+
247
+ `Ractor.receive` (and `Ractor::Port#receive`) block the calling thread without going through the scheduler, so a worker must not wait for messages on the thread that runs its event loop: every fiber in the Ractor would stall until the next message. Receive on a dedicated thread instead and hand messages to a scheduled fiber through a `Thread::Queue`. A push from the receiving thread wakes the parked fiber through the scheduler's cross-thread `unblock` path, the same path background operations use.
248
+
249
+ ```ruby
250
+ worker = Ractor.new do
251
+ scheduler = CarbonFiber::Scheduler.new
252
+ Fiber.set_scheduler(scheduler)
253
+
254
+ jobs = Thread::Queue.new
255
+ receiver = Thread.new do
256
+ while (message = Ractor.receive) != :stop
257
+ jobs.push(message)
258
+ end
259
+ jobs.close
260
+ end
261
+
262
+ results = []
263
+ Fiber.schedule do
264
+ loop do
265
+ job = jobs.pop or break # a fresh binding per job for the fiber below
266
+ Fiber.schedule { results << handle(job) } # any I/O the scheduler intercepts
267
+ end
268
+ end
269
+
270
+ Fiber.set_scheduler(nil) # runs the loop until the queue closes and every job is done
271
+ receiver.join
272
+ results
273
+ end
274
+
275
+ inputs.each { |job| worker.send(job) }
276
+ worker.send(:stop)
277
+ worker.value # Ractor#take on Ruby 3.4
278
+ ```
279
+
280
+ A pipe works too: the receiving thread writes a byte per message and the dispatching fiber reads it before popping the queue, so the wake-up arrives through `io_wait`. The queue alone is simpler and is what `spec/carbon_fiber/ractor_spec.rb` exercises.
281
+
282
+ ---
283
+
243
284
  ## How It Works
244
285
 
245
286
  The scheduler has two layers:
@@ -2,6 +2,7 @@
2
2
 
3
3
  # Please note that this code is heavily AI-assisted.
4
4
 
5
+ require_relative "io_contract"
5
6
  require_relative "native"
6
7
 
7
8
  module CarbonFiber
@@ -26,8 +27,12 @@ module CarbonFiber
26
27
  attr_reader :loop
27
28
 
28
29
  # @param loop [Fiber] the Async event loop fiber
29
- def initialize(loop)
30
- super
30
+ # @param io_contract_v4 [Boolean] use the Ruby 4.1+ single-transfer
31
+ # contract in the native I/O paths (defaults to what the running
32
+ # Ruby speaks; override only for testing)
33
+ def initialize(loop, io_contract_v4: CarbonFiber.io_contract_v4?)
34
+ super(loop)
35
+ self.io_contract_v4 = io_contract_v4
31
36
  @loop = loop
32
37
  @idle_duration = 0.0
33
38
 
@@ -117,37 +122,84 @@ module CarbonFiber
117
122
  EAGAIN = -Errno::EAGAIN::Errno
118
123
  EWOULDBLOCK = -Errno::EWOULDBLOCK::Errno
119
124
 
125
+ # Legacy contract (Ruby 3.4 through 4.0): length before offset,
126
+ # minimum-progress semantics.
120
127
  # @param fiber [Fiber]
121
128
  # @param io [IO]
122
129
  # @param buffer [IO::Buffer]
123
130
  # @param length [Integer]
124
131
  # @param offset [Integer]
125
132
  # @return [Integer] bytes read, or negative errno
126
- def io_read(fiber, io, buffer, length, offset = 0)
133
+ def io_read_v3(fiber, io, buffer, length, offset = 0)
127
134
  result = native_io_read(io.fileno, buffer, length, offset)
128
135
  return result unless result.nil?
129
136
 
130
137
  ruby_io_read(fiber, io, buffer, length, offset)
131
138
  end
132
139
 
140
+ # Ruby 4.1+ contract: offset before length, single transfer. One
141
+ # nonblocking attempt; short results and -EAGAIN are returned
142
+ # directly, and the caller composes retries via io_wait.
143
+ # @param fiber [Fiber]
144
+ # @param io [IO]
145
+ # @param buffer [IO::Buffer]
146
+ # @param offset [Integer]
147
+ # @param length [Integer] maximum bytes for this transfer
148
+ # @return [Integer] bytes read, or negative errno
149
+ def io_read_v4(fiber, io, buffer, offset, length)
150
+ return 0 if length.zero?
151
+
152
+ result = native_io_read(io.fileno, buffer, length, offset)
153
+ return result unless result.nil?
154
+
155
+ ruby_io_read_v4(io, buffer, offset, length)
156
+ end
157
+
158
+ # Legacy contract (Ruby 3.4 through 4.0): length before offset,
159
+ # minimum-progress semantics.
133
160
  # @param fiber [Fiber]
134
161
  # @param io [IO]
135
162
  # @param buffer [IO::Buffer]
136
163
  # @param length [Integer]
137
164
  # @param offset [Integer]
138
165
  # @return [Integer] bytes written, or negative errno
139
- def io_write(fiber, io, buffer, length, offset = 0)
166
+ def io_write_v3(fiber, io, buffer, length, offset = 0)
140
167
  result = native_io_write(io.fileno, buffer, length, offset)
141
168
  return result unless result.nil?
142
169
 
143
170
  ruby_io_write(fiber, io, buffer, length, offset)
144
171
  end
145
172
 
146
- # Cancel pending waiters and close the descriptor.
173
+ # Ruby 4.1+ contract: offset before length, single transfer.
174
+ # @param fiber [Fiber]
147
175
  # @param io [IO]
148
- def io_close(io)
149
- fd = io.respond_to?(:fileno) ? io.fileno : io.to_i
150
- super(fd, IOError.new("stream closed while waiting"))
176
+ # @param buffer [IO::Buffer]
177
+ # @param offset [Integer]
178
+ # @param length [Integer] maximum bytes for this transfer
179
+ # @return [Integer] bytes written, or negative errno
180
+ def io_write_v4(fiber, io, buffer, offset, length)
181
+ return 0 if length.zero?
182
+
183
+ result = native_io_write(io.fileno, buffer, length, offset)
184
+ return result unless result.nil?
185
+
186
+ ruby_io_write_v4(io, buffer, offset, length)
187
+ end
188
+
189
+ # The public hook names follow the contract of the running Ruby;
190
+ # both generations stay defined for direct testing.
191
+ if CarbonFiber.io_contract_v4?
192
+ alias_method :io_read, :io_read_v4
193
+ alias_method :io_write, :io_write_v4
194
+ else
195
+ alias_method :io_read, :io_read_v3
196
+ alias_method :io_write, :io_write_v3
197
+ end
198
+
199
+ # Cancel pending waiters on the descriptor.
200
+ # @param descriptor [Integer]
201
+ def io_close(descriptor)
202
+ super(descriptor, IOError.new("stream closed while waiting"))
151
203
  end
152
204
 
153
205
  # Wait for a child process on a background thread.
@@ -230,6 +282,22 @@ module CarbonFiber
230
282
  total
231
283
  end
232
284
 
285
+ # Ruby-level single-transfer io_read/io_write for non-socket fds under
286
+ # the Ruby 4.1+ contract: one nonblocking attempt, -EAGAIN and short
287
+ # results returned directly. No io_wait, no minimum-progress loop;
288
+ # Ruby's own read loop composes retries.
289
+ def ruby_io_read_v4(io, buffer, offset, length)
290
+ IO::Event::Selector.nonblock(io) do
291
+ Fiber.blocking { buffer.read(io, offset, length) }
292
+ end
293
+ end
294
+
295
+ def ruby_io_write_v4(io, buffer, offset, length)
296
+ IO::Event::Selector.nonblock(io) do
297
+ Fiber.blocking { buffer.write(io, offset, length) }
298
+ end
299
+ end
300
+
233
301
  def fallback_io_wait(io, events)
234
302
  Thread.new do
235
303
  Thread.current.report_on_exception = false
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CarbonFiber
4
+ # Ruby 4.1 (ruby/ruby#18483) reordered the buffered scheduler hooks to
5
+ # +io_read(io, buffer, offset, length)+ and made them single-transfer:
6
+ # one nonblocking attempt, short results returned directly, -EAGAIN
7
+ # instead of waiting, and zero length returns 0 without I/O.
8
+ # +IO::Buffer::VERSION >= 3+ signals the new contract; Ruby 3.4 through
9
+ # 4.0 define no such constant.
10
+ #
11
+ # @return [Boolean] whether this Ruby uses the v4 scheduler I/O contract
12
+ def self.io_contract_v4?
13
+ defined?(IO::Buffer::VERSION) ? IO::Buffer::VERSION.to_i >= 3 : false
14
+ end
15
+ end
@@ -33,6 +33,10 @@ module CarbonFiber
33
33
  true
34
34
  end
35
35
 
36
+ # Accepted for interface parity with the native selector; the fallback
37
+ # has no native I/O paths, so the contract generation changes nothing.
38
+ attr_writer :io_contract_v4
39
+
36
40
  # @return [Boolean] whether there is pending work
37
41
  def pending?
38
42
  @mutex.synchronize { @ready.any? || @timers.any? || @read_waits.any? }
@@ -100,7 +104,7 @@ module CarbonFiber
100
104
  # Run one event loop iteration.
101
105
  def select(timeout = nil)
102
106
  flush_ready
103
- return 0 unless pending?
107
+ return 0 unless pending? || parked?
104
108
 
105
109
  deadline = next_wait_deadline(timeout)
106
110
 
@@ -124,6 +128,19 @@ module CarbonFiber
124
128
  flush_ready
125
129
  end
126
130
 
131
+ # True while a fiber is parked in block() or do_io_wait. Nothing in the
132
+ # ready list, timers, or read waits refers to it, so pending? is false,
133
+ # yet select must sleep on the condition variable rather than return:
134
+ # the wake-up comes from another thread (a background operation's
135
+ # resume, an unblock, or wakeup), and returning at once makes
136
+ # Scheduler#run spin with the GVL held. On the main Ractor the timer
137
+ # thread preempts that spin; inside a non-main Ractor Ruby's M:N
138
+ # scheduler does not, and the thread that would wake the fiber never
139
+ # runs.
140
+ def parked?
141
+ @mutex.synchronize { @blocked_fibers.any? }
142
+ end
143
+
127
144
  # Mirrors `Selector#kernel_sleep` on the native side so
128
145
  # `Scheduler#kernel_sleep` can delegate to `@selector.kernel_sleep`
129
146
  # in both paths. Branches on the duration: nil parks the fiber on
@@ -2,9 +2,9 @@
2
2
 
3
3
  # Please note that this code is heavily AI-assisted.
4
4
 
5
- require "resolv"
6
5
  require "socket"
7
6
  require "timeout"
7
+ require_relative "io_contract"
8
8
  require_relative "native"
9
9
 
10
10
  # High-performance Ruby Fiber Scheduler backed by Zig and libxev.
@@ -43,10 +43,20 @@ module CarbonFiber
43
43
  class Scheduler
44
44
  # @param root_fiber [Fiber] the event loop fiber (defaults to current)
45
45
  # @param selector [Class] native selector class to instantiate
46
- def initialize(root_fiber = Fiber.current, selector: CarbonFiber::Native::Selector)
46
+ # @param io_contract_v4 [Boolean] use the Ruby 4.1+ single-transfer
47
+ # contract in the native I/O paths (defaults to what the running
48
+ # Ruby speaks; override only for testing)
49
+ def initialize(root_fiber = Fiber.current, selector: CarbonFiber::Native::Selector,
50
+ io_contract_v4: CarbonFiber.io_contract_v4?)
47
51
  @root_fiber = root_fiber
48
52
  @scheduler_thread = Thread.current
53
+ @main_ractor = !defined?(Ractor) || Ractor.current == Ractor.main
54
+ # Resolv keeps state Ractor isolation forbids, and only the main
55
+ # Ractor resolves through it (see #address_resolve), so load it here
56
+ # rather than at require time; a worker Ractor never loads it.
57
+ require "resolv" if @main_ractor
49
58
  @selector = selector.new(root_fiber)
59
+ @selector.io_contract_v4 = io_contract_v4
50
60
  @active_fibers = 0
51
61
  @background_count = 0
52
62
  @closed = false
@@ -189,14 +199,15 @@ module CarbonFiber
189
199
  await_background_operation { io_select_readiness(io, events, timeout) }
190
200
  end
191
201
 
192
- # Read from an IO into a buffer via the native selector.
202
+ # Read from an IO into a buffer via the native selector (legacy contract,
203
+ # Ruby 3.4 through 4.0: length before offset, minimum-progress semantics).
193
204
  # Falls back to a background thread for non-socket descriptors.
194
205
  # @param io [IO]
195
206
  # @param buffer [IO::Buffer]
196
207
  # @param length [Integer]
197
208
  # @param offset [Integer]
198
209
  # @return [Integer] bytes read, or negative errno
199
- def io_read(io, buffer, length, offset = 0)
210
+ def io_read_v3(io, buffer, length, offset = 0)
200
211
  # Native io_read_object extracts the descriptor in Zig, skipping a
201
212
  # `respond_to?(:fileno)` + `io.fileno` method-send pair per call.
202
213
  native_result = @selector.io_read_object(io, buffer, length, offset)
@@ -211,14 +222,39 @@ module CarbonFiber
211
222
  end
212
223
  end
213
224
 
214
- # Write from a buffer to an IO via the native selector.
225
+ # Read from an IO into a buffer via the native selector (Ruby 4.1+
226
+ # contract: offset before length, single transfer). One nonblocking
227
+ # attempt; short results and -EAGAIN are returned directly, and Ruby's
228
+ # own read loop composes retries via io_wait.
229
+ # @param io [IO]
230
+ # @param buffer [IO::Buffer]
231
+ # @param offset [Integer]
232
+ # @param length [Integer] maximum bytes for this transfer
233
+ # @return [Integer] bytes read, or negative errno
234
+ def io_read_v4(io, buffer, offset, length)
235
+ return 0 if length.zero?
236
+
237
+ native_result = @selector.io_read_object(io, buffer, length, offset)
238
+ return native_result unless native_result.nil?
239
+
240
+ await_background_operation do
241
+ Fiber.blocking { buffer.read(io, offset, length) }
242
+ end
243
+ rescue NoMethodError, TypeError
244
+ await_background_operation do
245
+ Fiber.blocking { buffer.read(io, offset, length) }
246
+ end
247
+ end
248
+
249
+ # Write from a buffer to an IO via the native selector (legacy contract,
250
+ # Ruby 3.4 through 4.0: length before offset, minimum-progress semantics).
215
251
  # Falls back to a background thread for non-socket descriptors.
216
252
  # @param io [IO]
217
253
  # @param buffer [IO::Buffer]
218
254
  # @param length [Integer]
219
255
  # @param offset [Integer]
220
256
  # @return [Integer] bytes written, or negative errno
221
- def io_write(io, buffer, length, offset = 0)
257
+ def io_write_v3(io, buffer, length, offset = 0)
222
258
  native_result = @selector.io_write_object(io, buffer, length, offset)
223
259
  return native_result unless native_result.nil?
224
260
 
@@ -231,6 +267,38 @@ module CarbonFiber
231
267
  end
232
268
  end
233
269
 
270
+ # Write from a buffer to an IO via the native selector (Ruby 4.1+
271
+ # contract: offset before length, single transfer).
272
+ # @param io [IO]
273
+ # @param buffer [IO::Buffer]
274
+ # @param offset [Integer]
275
+ # @param length [Integer] maximum bytes for this transfer
276
+ # @return [Integer] bytes written, or negative errno
277
+ def io_write_v4(io, buffer, offset, length)
278
+ return 0 if length.zero?
279
+
280
+ native_result = @selector.io_write_object(io, buffer, length, offset)
281
+ return native_result unless native_result.nil?
282
+
283
+ await_background_operation do
284
+ Fiber.blocking { buffer.write(io, offset, length) }
285
+ end
286
+ rescue NoMethodError, TypeError
287
+ await_background_operation do
288
+ Fiber.blocking { buffer.write(io, offset, length) }
289
+ end
290
+ end
291
+
292
+ # The public hook names follow the contract of the running Ruby;
293
+ # both generations stay defined for direct testing.
294
+ if CarbonFiber.io_contract_v4?
295
+ alias_method :io_read, :io_read_v4
296
+ alias_method :io_write, :io_write_v4
297
+ else
298
+ alias_method :io_read, :io_read_v3
299
+ alias_method :io_write, :io_write_v3
300
+ end
301
+
234
302
  # Blocking IO.select on a background thread.
235
303
  def io_select(...)
236
304
  await_background_operation do
@@ -238,15 +306,14 @@ module CarbonFiber
238
306
  end
239
307
  end
240
308
 
241
- # Cancel pending waiters on an IO and close the descriptor.
242
- # @param io [IO]
243
- def io_close(io)
244
- descriptor = io.respond_to?(:to_i) ? io.to_i : io
309
+ # Cancel pending waiters on a descriptor and close it.
310
+ # @param descriptor [Integer]
311
+ def io_close(descriptor)
245
312
  @selector.io_close(descriptor, IOError.new("stream closed while waiting"))
246
313
 
247
314
  Fiber.blocking do
248
- target = io.is_a?(IO) ? io : IO.for_fd(descriptor.to_i)
249
- target.close unless target.closed?
315
+ io = IO.for_fd(descriptor)
316
+ io.close unless io.closed?
250
317
  end
251
318
 
252
319
  true
@@ -270,14 +337,28 @@ module CarbonFiber
270
337
  end
271
338
  end
272
339
 
273
- # Resolve a hostname to addresses via Resolv.
340
+ # Resolve a hostname to addresses.
341
+ #
342
+ # The main Ractor uses Resolv: it speaks DNS over sockets the scheduler
343
+ # already multiplexes, so lookups never block the loop. Resolv keeps
344
+ # class-level state that Ractor isolation forbids, so a scheduler running
345
+ # in another Ractor asks the platform resolver instead. That call blocks
346
+ # its thread, and from a scheduled fiber it would re-enter this very
347
+ # hook, so it runs on a background thread like process_wait does.
348
+ # Unknown hosts yield an empty list on both paths.
274
349
  # @param hostname [String]
275
350
  # @return [Array<String>]
276
351
  def address_resolve(hostname)
277
352
  if hostname.include?("%")
278
353
  hostname = hostname.split("%", 2).first
279
354
  end
280
- Resolv.getaddresses(hostname)
355
+ return Resolv.getaddresses(hostname) if @main_ractor
356
+
357
+ await_background_operation do
358
+ Addrinfo.getaddrinfo(hostname, nil, nil, :STREAM).map(&:ip_address)
359
+ rescue SocketError
360
+ []
361
+ end
281
362
  end
282
363
 
283
364
  # Run an arbitrary callable on a background thread.
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CarbonFiber
4
4
  # @return [String] the current gem version
5
- VERSION = "0.1.3"
5
+ VERSION = "0.2.0"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: carbon_fiber
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Markin
@@ -121,6 +121,7 @@ files:
121
121
  - README.md
122
122
  - lib/carbon_fiber.rb
123
123
  - lib/carbon_fiber/async.rb
124
+ - lib/carbon_fiber/io_contract.rb
124
125
  - lib/carbon_fiber/native.rb
125
126
  - lib/carbon_fiber/native/fallback.rb
126
127
  - lib/carbon_fiber/scheduler.rb
@@ -148,7 +149,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
148
149
  - !ruby/object:Gem::Version
149
150
  version: '0'
150
151
  requirements: []
151
- rubygems_version: 4.0.6
152
+ rubygems_version: 4.0.16
152
153
  specification_version: 4
153
154
  summary: High-performance Ruby Fiber Scheduler backed by Zig with libxev. Pure Ruby
154
155
  and gem async.