uringmachine 0.20.0 → 0.21.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.
data/grant-2025/tasks.md CHANGED
@@ -1,38 +1,83 @@
1
1
  - [v] io-event
2
-
3
2
  - [v] Make PR to use io_uring_prep_waitid for kernel version >= 6.7
4
3
 
5
- - https://github.com/socketry/io-event/blob/44666dc92ac3e093ca6ce3ab47052b808a58a325/ext/io/event/selector/uring.c#L460
6
- - https://github.com/digital-fabric/uringmachine/blob/d5505d7fd94b800c848d186e17585e03ad9af6f2/ext/um/um.c#L697-L713
7
-
8
- - [ ] UringMachine
9
- - [v] Add support for IO::Buffer in UM API. (How can we detect an IO::Buffer object?)
10
- https://docs.ruby-lang.org/capi/en/master/d8/d36/group__object.html#gab1b70414d07e7de585f47ee50a64a86c
11
-
4
+ - [ ] UringMachine low-level API
5
+ - [v] Add support for IO::Buffer in UM API.
12
6
  - [v] Add `UM::Error` class to be used instead of RuntimeError
13
-
14
- - [ ] Do batch allocation for `struct um_op`, so they'll be adjacent
15
- - [ ] Add optional buffer depth argument to `UM.new` (for example, a the
7
+ - [v] Add optional ring size argument to `UM.new` (for example, a the
16
8
  worker thread for the scheduler `blocking_operation_wait` hook does not need
17
9
  a lot of depth, so you can basically do `UM.new(4)`)
18
-
19
- - [ ] Add support for using IO::Buffer in association with io_uring registered buffers / buffer rings
20
-
21
- - [ ] FiberScheduler implementation
22
- 4
10
+ - [v] Add debugging code suggested by Samuel
11
+ - [v] Add support for SQPOLL
12
+ https://unixism.net/loti/tutorial/sq_poll.html
13
+
14
+ - [ ] Add support for using IO::Buffer in association with io_uring registered
15
+ buffers / buffer rings
16
+ - [ ] Set `IOSQE_CQE_SKIP_SUCCESS` flag for `#close_async` and `#write_async`
17
+ - [ ] In `UM#spin` always start fibers as non-blocking.
18
+ - [ ] Add some way to measure fiber CPU time.
19
+ https://github.com/socketry/async/issues/428
20
+
21
+ - [ ] UringMachine Fiber::Scheduler implementation
23
22
  - [v] Check how scheduler interacts with `fork`.
24
23
  - [v] Implement `process_wait` (with `rb_process_status_new`)
25
- - [ ] Implement timeouts (how do timeouts interact with blocking ops?)
26
- - [ ] Implement address resolution hook
27
- - [ ] Add tests:
28
- - [ ] Sockets
29
- - [ ] Files
30
- - [ ] Mutex / Queue
31
- - [ ] Thread.join
32
- - [ ] Process.wait
33
- - [ ] fork
34
- - [ ] system / exec / etc.
35
- - [ ] popen
24
+ - [v] Implement `fiber_interrupt` hook
25
+ - [v] Add `#address_resolve` hook with same impl as Async:
26
+ https://github.com/socketry/async/blob/ea8b0725042b63667ea781d4d011786ca3658256/lib/async/scheduler.rb#L285-L296
27
+ - [v] Implement other hooks:
28
+ - [v] `#timeout_after`
29
+ https://github.com/socketry/async/blob/ea8b0725042b63667ea781d4d011786ca3658256/lib/async/scheduler.rb#L631-L644
30
+ - [v] `#io_pread`
31
+ - [v] `#io_pwrite`
32
+ - [v] `#io_select`
33
+ - [v] Add timeout handling in different I/O hooks
34
+ - [v] Experiment more with fork:
35
+ - [v] what happens to schedulers on other threads (those that don't make it post-fork)
36
+ - do they get GC'd?
37
+ - do they get closed (`#scheduler_close` called)?
38
+ - are they freed cleanly (at least for UM)?
39
+
40
+ ```ruby
41
+ class S
42
+ def respond_to?(sym) = true
43
+ end
44
+ o = S.new
45
+ ObjectSpace.define_finalizer(o, ->(*){ puts 'scheduler finalized' })
46
+ t1 = Thread.new { Fiber.set_scheduler(o); sleep }
47
+ t2 = Thread.new {
48
+ fork { p(t1:, t2:) }
49
+ GC.start
50
+ }
51
+
52
+ # output:
53
+ # scheduler finalized
54
+ ```
55
+
56
+ So, apparently there's no problem!
57
+ - [v] Implement multi-thread worker pool for `blocking_operation_wait`
58
+ Single thread pool at class level, shared by all schedulers
59
+ With worker count according to CPU count
60
+ - [v] Test working with non-blocking files, it should be fine, and we shouldn't need to reset `O_NONBLOCK`.
61
+ - [v] Implement timeouts (how do timeouts interact with blocking ops?)
62
+ - [ ] Implement `#yield` hook (https://github.com/ruby/ruby/pull/14700)
63
+ - [ ] Finish documentation for the `FiberScheduler` class.
64
+
65
+ - [v] tests:
66
+ - [v] Wrap the scheduler interface such that we can verify that specific
67
+ hooks were called. Add asserts for called hooks for all tests.
68
+ - [v] Sockets (only io_wait)
69
+ - [v] Files
70
+ - [v] Mutex / Queue
71
+ - [v] Thread.join
72
+ - [v] Process.wait
73
+ - [v] fork
74
+ - [v] system / exec / etc.
75
+ - [v] popen
76
+ - [ ] "Integration tests"
77
+ - [ ] queue: multiple concurrent readers / writers
78
+ - [ ] net/http test: ad-hoc HTTP/1.1 server + `Net::HTTP` client
79
+ - [ ] sockets: echo server + many clients
80
+ - [ ] IO - all methods!
36
81
 
37
82
  - [ ] Benchmarks
38
83
  - [ ] UM queue / Ruby queue (threads) / Ruby queue with UM fiber scheduler
@@ -52,15 +97,39 @@
52
97
  - my hunch is we'll be able to show with io_uring real_time is less,
53
98
  while cpu_time is more. But it's just a hunch.
54
99
 
55
-
56
- - https://github.com/ruby/ruby/blob/master/doc/fiber.md
57
- - https://github.com/ruby/ruby/blob/master/test/fiber/scheduler.rb
58
- - https://github.com/socketry/async/blob/main/context/getting-started.md
59
- - https://github.com/socketry/async/blob/main/context/scheduler.md
60
- - https://github.com/socketry/async/blob/main/lib/async/scheduler.rb#L28
100
+ - [ ] Ruby Fiber::Scheduler interface
101
+ - [ ] Make a PR for resetting the scheduler and resetting the fiber non-blocking flag.
102
+ - [ ] Missing hook for close
103
+ - [ ] Missing hooks for send/recv/sendmsg/recvmsg
104
+ - [ ] Writes to a file (including `IO.write`) do not invoke `#io_write` (because writes to files cannot be non-blocking?) Instead, `blocking_operation_wait` is invoked.
61
105
 
62
106
  - [ ] SSL
63
107
  - [ ] openssl gem: custom BIO?
64
108
 
65
109
  - curl: https://github.com/curl/curl/blob/5f4cd4c689c822ce957bb415076f0c78e5f474b5/lib/vtls/openssl.c#L786-L803
66
110
 
111
+ - [ ] UringMachine website
112
+ - [ ] domain: uringmachine.dev
113
+ - [ ] logo: ???
114
+ - [ ] docs (similar to papercraft docs)
115
+
116
+ - [ ] Uma - web server
117
+ - [ ] child process workers
118
+ - [ ] reforking (following https://github.com/Shopify/pitchfork)
119
+ see also: https://byroot.github.io/ruby/performance/2025/03/04/the-pitchfork-story.html
120
+ - Monitor worker memory usage - how much is shared
121
+ - Choose worker with most served request count as "mold" for next generation
122
+ - Perform GC out of band, preferably when there are no active requests
123
+ https://railsatscale.com/2024-10-23-next-generation-oob-gc/
124
+ - When a worker is promoted to "mold", it:
125
+ - Stops `accept`ing requests
126
+ - When finally idle, calls `Process.warmup`
127
+ - Starts replacing sibling workers with forked workers
128
+ see also: https://www.youtube.com/watch?v=kAW5O2dkSU8
129
+ - [ ] Each worker is single-threaded (except for auxiliary threads)
130
+ - [ ] Rack 3.0-compatible
131
+ see: https://github.com/socketry/protocol-rack
132
+ - [ ] Rails integration (Railtie)
133
+ see: https://github.com/socketry/falcon
134
+ - [ ] Benchmarks
135
+ - [ ] Add to the TechEmpower bencchmarks
@@ -1,10 +1,65 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'resolv'
4
+ require 'etc'
5
+
3
6
  class UringMachine
7
+ # Implements a thread pool for running blocking operations.
8
+ class BlockingOperationThreadPool
9
+ def initialize
10
+ @blocking_op_queue = UM::Queue.new
11
+ @pending_count = 0
12
+ @worker_count = 0
13
+ @max_workers = Etc.nprocessors
14
+ @worker_mutex = UM::Mutex.new
15
+ @workers = []
16
+ end
17
+
18
+ def process(machine, job)
19
+ queue = UM::Queue.new
20
+
21
+ if @worker_count == 0 || (@pending_count > 0 && @worker_count < @max_workers)
22
+ start_worker(machine)
23
+ end
24
+ machine.push(@blocking_op_queue, [queue, job])
25
+ machine.shift(queue)
26
+ end
27
+
28
+ private
29
+
30
+ def start_worker(machine)
31
+ machine.synchronize(@worker_mutex) do
32
+ return if @worker_count == @max_workers
33
+
34
+ @workers << Thread.new { run_worker_thread }
35
+ @worker_count += 1
36
+ end
37
+ end
38
+
39
+ def run_worker_thread
40
+ machine = UM.new(4).mark(1)
41
+ loop do
42
+ q, op = machine.shift(@blocking_op_queue)
43
+ @pending_count += 1
44
+ res = begin
45
+ op.()
46
+ rescue Exception => e
47
+ e
48
+ end
49
+ @pending_count -= 1
50
+ machine.push(q, res)
51
+ rescue => e
52
+ UM.debug("worker e: #{e.inspect}")
53
+ end
54
+ end
55
+ end
56
+
4
57
  # UringMachine::FiberScheduler implements the Fiber::Scheduler interface for
5
58
  # creating fiber-based concurrent applications in Ruby, in tight integration
6
59
  # with the standard Ruby I/O and locking APIs.
7
60
  class FiberScheduler
61
+ @@blocking_operation_thread_pool = BlockingOperationThreadPool.new
62
+
8
63
  attr_reader :machine, :fiber_map
9
64
 
10
65
  # Instantiates a scheduler with the given UringMachine instance.
@@ -17,7 +72,6 @@ class UringMachine
17
72
  # @return [void]
18
73
  def initialize(machine = nil)
19
74
  @machine = machine || UM.new
20
- @ios = ObjectSpace::WeakMap.new
21
75
  @fiber_map = ObjectSpace::WeakMap.new
22
76
  end
23
77
 
@@ -29,9 +83,8 @@ class UringMachine
29
83
  # automatically after a fork).
30
84
  #
31
85
  # @return [self]
32
- def post_fork
86
+ def process_fork
33
87
  @machine = UM.new
34
- @ios = ObjectSpace::WeakMap.new
35
88
  @fiber_map = ObjectSpace::WeakMap.new
36
89
  self
37
90
  end
@@ -51,11 +104,6 @@ class UringMachine
51
104
  join()
52
105
  end
53
106
 
54
- # fiber_interrupt hook: to be implemented.
55
- def fiber_interrupt(fiber, exception)
56
- raise NotImplementedError, "Implement me!"
57
- end
58
-
59
107
  # For debugging purposes
60
108
  def p(o) = UM.debug(o.inspect)
61
109
 
@@ -79,11 +127,18 @@ class UringMachine
79
127
  # @param blocking_operation [callable] blocking operation
80
128
  # @return [void]
81
129
  def blocking_operation_wait(blocking_operation)
82
- start_blocking_operation_thread if !@blocking_op_queue
130
+ # naive_blocking_peration_wait(blocking_operation)
131
+ @@blocking_operation_thread_pool.process(@machine, blocking_operation)
132
+ end
83
133
 
84
- queue = UM::Queue.new
85
- @machine.push(@blocking_op_queue, [queue, blocking_operation])
86
- @machine.shift(queue)
134
+ def naive_blocking_peration_wait(blocking_operation)
135
+ res = nil
136
+ Thread.new do
137
+ res = blocking_operation.()
138
+ rescue => e
139
+ res = e
140
+ end.join
141
+ res
87
142
  end
88
143
 
89
144
  # block hook: blocks the current fiber by yielding to the machine. This hook
@@ -92,11 +147,17 @@ class UringMachine
92
147
  #
93
148
  # @param blocker [any] blocker object
94
149
  # @param timeout [Number, nil] optional
95
- # timeout @return [void]
150
+ # timeout @return [bool] was the operation successful
96
151
  def block(blocker, timeout = nil)
97
- raise NotImplementedError, "Implement me!" if timeout
152
+ if timeout
153
+ @machine.timeout(timeout, Timeout::Error) { @machine.yield }
154
+ else
155
+ @machine.yield
156
+ end
98
157
 
99
- @machine.yield
158
+ true
159
+ rescue Timeout::Error
160
+ false
100
161
  end
101
162
 
102
163
  # unblock hook: unblocks the given fiber by scheduling it. This hook is
@@ -108,6 +169,7 @@ class UringMachine
108
169
  # @return [void]
109
170
  def unblock(blocker, fiber)
110
171
  @machine.schedule(fiber, nil)
172
+ @machine.wakeup
111
173
  end
112
174
 
113
175
  # kernel_sleep hook: sleeps for the given duration.
@@ -132,15 +194,28 @@ class UringMachine
132
194
  timeout ||= io.timeout
133
195
  if timeout
134
196
  @machine.timeout(timeout, Timeout::Error) {
135
- @machine.poll(io.fileno, events).tap { p 3 }
197
+ @machine.poll(io.fileno, events)
136
198
  }
137
199
  else
138
- @machine.poll(io.fileno, events).tap { p 6 }
200
+ @machine.poll(io.fileno, events)
201
+ end
202
+ end
203
+
204
+ def io_select(rios, wios, eios, timeout = nil)
205
+ map_r = map_io_fds(rios)
206
+ map_w = map_io_fds(wios)
207
+ map_e = map_io_fds(eios)
139
208
 
209
+ r, w, e = nil
210
+ if timeout
211
+ @machine.timeout(timeout, Timeout::Error) {
212
+ r, w, e = @machine.select(map_r.keys, map_w.keys, map_e.keys)
213
+ }
214
+ else
215
+ r, w, e = @machine.select(map_r.keys, map_w.keys, map_e.keys)
140
216
  end
141
- rescue => e
142
- p e: e
143
- raise
217
+
218
+ [unmap_fds(r, map_r), unmap_fds(w, map_w), unmap_fds(e, map_e)]
144
219
  end
145
220
 
146
221
  # fiber hook: creates a new fiber with the given block. The created fiber is
@@ -156,6 +231,53 @@ class UringMachine
156
231
  fiber
157
232
  end
158
233
 
234
+ # io_read hook: reads from the given IO.
235
+ #
236
+ # @param io [IO] IO object
237
+ # @param buffer [IO::Buffer] read buffer
238
+ # @param length [Integer] read length
239
+ # @param offset [Integer] buffer offset
240
+ # @return [Integer] bytes read
241
+ def io_read(io, buffer, length, offset)
242
+ length = buffer.size if length == 0
243
+
244
+ if (timeout = io.timeout)
245
+ @machine.timeout(timeout, Timeout::Error) do
246
+ @machine.read(io.fileno, buffer, length, offset)
247
+ rescue Errno::EINTR
248
+ retry
249
+ end
250
+ else
251
+ @machine.read(io.fileno, buffer, length, offset)
252
+ end
253
+ rescue Errno::EINTR
254
+ retry
255
+ end
256
+
257
+ # io_pread hook: reads from the given IO at the given offset
258
+ #
259
+ # @param io [IO] IO object
260
+ # @param buffer [IO::Buffer] read buffer
261
+ # @param from [Integer] read offset
262
+ # @param length [Integer] read length
263
+ # @param offset [Integer] buffer offset
264
+ # @return [Integer] bytes read
265
+ def io_pread(io, buffer, from, length, offset)
266
+ length = buffer.size if length == 0
267
+
268
+ if (timeout = io.timeout)
269
+ @machine.timeout(timeout, Timeout::Error) do
270
+ @machine.read(io.fileno, buffer, length, offset, from)
271
+ rescue Errno::EINTR
272
+ retry
273
+ end
274
+ else
275
+ @machine.read(io.fileno, buffer, length, offset, from)
276
+ end
277
+ rescue Errno::EINTR
278
+ retry
279
+ end
280
+
159
281
  # io_write hook: writes to the given IO.
160
282
  #
161
283
  # @param io [IO] IO object
@@ -164,72 +286,77 @@ class UringMachine
164
286
  # @param offset [Integer] write offset
165
287
  # @return [Integer] bytes written
166
288
  def io_write(io, buffer, length, offset)
167
- if offset > 0
168
- raise NotImplementedError, "UringMachine currently does not support writing at an offset"
169
- end
289
+ # p(io_write: io, length:, offset:, timeout: io.timeout)
290
+ length = buffer.size if length == 0
291
+ buffer = buffer.slice(offset) if offset > 0
170
292
 
171
- ensure_nonblock(io)
172
- @machine.write(io.fileno, buffer)
293
+ if (timeout = io.timeout)
294
+ @machine.timeout(timeout, Timeout::Error) do
295
+ @machine.write(io.fileno, buffer, length)
296
+ rescue Errno::EINTR
297
+ retry
298
+ end
299
+ else
300
+ @machine.write(io.fileno, buffer, length)
301
+ end
173
302
  rescue Errno::EINTR
174
303
  retry
175
304
  end
176
305
 
177
- # io_read hook: reads from the given IO.
306
+ # io_pwrite hook: writes to the given IO at the given offset.
178
307
  #
179
308
  # @param io [IO] IO object
180
- # @param buffer [IO::Buffer] read buffer
181
- # @param length [Integer] read length
182
- # @param offset [Integer] read offset
183
- # @return [Integer] bytes read
184
- def io_read(io, buffer, length, offset)
185
- if offset > 0
186
- raise NotImplementedError, "UringMachine currently does not support reading at an offset"
187
- end
188
-
189
- ensure_nonblock(io)
309
+ # @param buffer [IO::Buffer] write buffer
310
+ # @param length [Integer] file offset
311
+ # @param length [Integer] write length
312
+ # @param offset [Integer] buffer offset
313
+ # @return [Integer] bytes written
314
+ def io_pwrite(io, buffer, from, length, offset)
315
+ # p(io_pwrite: io, from:, length:, offset:, timeout: io.timeout)
190
316
  length = buffer.size if length == 0
191
- @machine.read(io.fileno, buffer, length)
317
+ buffer = buffer.slice(offset) if offset > 0
318
+
319
+ if (timeout = io.timeout)
320
+ @machine.timeout(timeout, Timeout::Error) do
321
+ @machine.write(io.fileno, buffer, length, from)
322
+ rescue Errno::EINTR
323
+ retry
324
+ end
325
+ else
326
+ @machine.write(io.fileno, buffer, length, from)
327
+ end
192
328
  rescue Errno::EINTR
193
329
  retry
194
330
  end
195
331
 
196
- if UM.instance_methods.include?(:waitid_status)
332
+ if UM.method_defined?(:waitid_status)
197
333
  def process_wait(pid, flags)
198
334
  flags = UM::WEXITED if flags == 0
199
335
  @machine.waitid_status(UM::P_PID, pid, flags)
200
336
  end
201
337
  end
202
338
 
203
- private
339
+ def fiber_interrupt(fiber, exception)
340
+ @machine.schedule(fiber, exception)
341
+ @machine.wakeup
342
+ end
204
343
 
205
- # Ensures the given IO is in blocking mode.
206
- #
207
- # @param io [IO] IO object
208
- # @return [void]
209
- def ensure_nonblock(io)
210
- return if @ios.key?(io)
344
+ def address_resolve(hostname)
345
+ Resolv.getaddresses(hostname)
346
+ end
211
347
 
212
- @ios[io] = true
213
- UM.io_set_nonblock(io, false)
348
+ def timeout_after(duration, exception, message, &block)
349
+ @machine.timeout(duration, exception, &block)
214
350
  end
215
351
 
216
- # Starts a background thread for running blocking operations.
217
- #
218
- # @return [void]
219
- def start_blocking_operation_thread
220
- @blocking_op_queue = UM::Queue.new
221
- @blocking_op_thread = Thread.new do
222
- m = UM.new
223
- loop do
224
- q, op = m.shift(@blocking_op_queue)
225
- res = begin
226
- op.()
227
- rescue Exception => e
228
- e
229
- end
230
- m.push(q, res)
231
- end
232
- end
352
+ private
353
+
354
+ def map_io_fds(ios)
355
+ ios.each_with_object({}) { |io, h| h[io.fileno] = io }
356
+ end
357
+
358
+ def unmap_fds(fds, map)
359
+ fds.map { map[it] }
233
360
  end
234
361
 
235
362
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class UringMachine
4
- VERSION = '0.20.0'
4
+ VERSION = '0.21.0'
5
5
  end