atomic-ruby 0.12.0 → 0.13.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: 0171c5fec65586e1a6b090b65e266a8c4fa88c5a3fa7c98e6c41c5fd63654f31
4
- data.tar.gz: aa26033345014a3d64ff81faa7ac812fc3de3d4189f9c59d0f559684429d7540
3
+ metadata.gz: 540189a4554f5e046af0b5305063a96be9fa58cfac1740e5e6076d6b1c48cd40
4
+ data.tar.gz: 528fa3c2e94c0d403ec8792076a773d3e7ce800852f1472dbde8e9ce7bbfb6f8
5
5
  SHA512:
6
- metadata.gz: 226c50dd465ec5f255f6b5a2991e64939bcdf77d08ecdda4f9c6aea8ee68de16cdb46ac2be7b6705417b316a40a4c4159cf21b4eef34759f0bc939a544930f50
7
- data.tar.gz: 4e80ac5fc97aed98950779897c5308d5d3f8180424cca09e4af611d980570b67b3305a1bc0557f8d4278a6b618f7c3ee69d2a60935764e5f59f7769f04711967
6
+ metadata.gz: 8ad39592c8615f88aa0da987d1de06d87496c6c82481a9678c907937de65d308f4595e54008b0e357d99605fc660d8b35b986397ad0573e4b81dbf5de8926695
7
+ data.tar.gz: 46794d17727ab6b835210c7fc8e901d8c7daf37ff319c16b0efdb87cdc8bd9c95b6fb2dad171f96ca8d7b52b3fb8bd08cfde002f264cbe2155e6041310e74827
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.13.0] - 2026-06-12
4
+
5
+ - Replace `sleep` with `AtomicConditionVariable` in idle `AtomicThreadPool` workers
6
+ - Add `AtomicConditionVariable`
7
+
3
8
  ## [0.12.0] - 2026-05-18
4
9
 
5
10
  - Wake idle `AtomicThreadPool` workers on enqueue
data/README.md CHANGED
@@ -49,6 +49,27 @@ atom.toggle
49
49
  p atom.false? #=> true
50
50
  ```
51
51
 
52
+ `AtomicConditionVariable`:
53
+
54
+ ```ruby
55
+ require "atomic-ruby"
56
+
57
+ condvar = AtomicConditionVariable.new
58
+ ready = AtomicBoolean.new(false)
59
+ p condvar.waiter_count #=> 0
60
+
61
+ waiter = Thread.new do
62
+ condvar.wait { ready.true? }
63
+ end
64
+ Thread.pass until condvar.waiter_count == 1
65
+ p condvar.waiter_count #=> 1
66
+
67
+ ready.make_true
68
+ p condvar.signal #=> true
69
+ waiter.join
70
+ p condvar.waiter_count #=> 0
71
+ ```
72
+
52
73
  `AtomicThreadPool`:
53
74
 
54
75
  ```ruby
@@ -98,7 +119,7 @@ p latch.count #=> 0
98
119
  ```
99
120
 
100
121
  > [!NOTE]
101
- > `Atom`, `AtomicBoolean`, and `AtomicCountDownLatch` are Ractor-safe in Ruby 4.0+.
122
+ > `Atom`, `AtomicBoolean`, and `AtomicCountDownLatch` are Ractor-safe in Ruby 4.0+. `AtomicConditionVariable` is not, since it parks `Thread` references which cannot be shared across ractors.
102
123
 
103
124
  ## Benchmarks
104
125
 
@@ -208,11 +229,11 @@ puts "Atomic Ruby Atomic Bank Account: #{results[2].real.round(6)} seconds"
208
229
  ```
209
230
 
210
231
  ```
211
- > bundle exec rake compile && bundle exec ruby examples/atom_benchmark.rb
232
+ > bundle exec rake clobber && bundle exec rake compile && bundle exec ruby examples/atom_benchmark.rb
212
233
 
213
- ruby version: ruby 4.0.3 (2026-04-21 revision 85ddef263a) +YJIT +PRISM [arm64-darwin23]
234
+ ruby version: ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [arm64-darwin23]
214
235
  concurrent-ruby version: 1.3.6
215
- atomic-ruby version: 0.11.0
236
+ atomic-ruby version: 0.13.0
216
237
 
217
238
  Balances:
218
239
  Synchronized Bank Account Balance: 975
@@ -220,9 +241,9 @@ Concurrent Ruby Atomic Bank Account Balance: 975
220
241
  Atomic Ruby Atomic Bank Account Balance: 975
221
242
 
222
243
  Benchmark Results:
223
- Synchronized Bank Account: 5.106931 seconds
224
- Concurrent Ruby Atomic Bank Account: 5.106199 seconds
225
- Atomic Ruby Atomic Bank Account: 5.101662 seconds
244
+ Synchronized Bank Account: 5.109724 seconds
245
+ Concurrent Ruby Atomic Bank Account: 5.137749 seconds
246
+ Atomic Ruby Atomic Bank Account: 5.101637 seconds
226
247
  ```
227
248
 
228
249
  </details>
@@ -299,31 +320,105 @@ end
299
320
  ```
300
321
 
301
322
  ```
302
- > bundle exec rake compile && bundle exec ruby examples/atomic_boolean_benchmark.rb
323
+ > bundle exec rake clobber && bundle exec rake compile && bundle exec ruby examples/atomic_boolean_benchmark.rb
303
324
 
304
- ruby version: ruby 4.0.3 (2026-04-21 revision 85ddef263a) +YJIT +PRISM [arm64-darwin23]
325
+ ruby version: ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [arm64-darwin23]
305
326
  concurrent-ruby version: 1.3.6
306
- atomic-ruby version: 0.11.0
327
+ atomic-ruby version: 0.13.0
328
+
329
+ Warming up --------------------------------------
330
+ Synchronized Boolean Toggle 167.000 i/100ms
331
+ Concurrent Ruby Atomic Boolean Toggle 132.000 i/100ms
332
+ Atomic Ruby Atomic Boolean Toggle 117.000 i/100ms
333
+ Calculating -------------------------------------
334
+ Synchronized Boolean Toggle 1.653k (± 2.0%) i/s (605.04 μs/i) - 8.350k in 5.052096s
335
+ Concurrent Ruby Atomic Boolean Toggle 1.313k (± 2.1%) i/s (761.45 μs/i) - 6.600k in 5.025579s
336
+ Atomic Ruby Atomic Boolean Toggle 1.230k (± 4.8%) i/s (812.86 μs/i) - 6.201k in 5.040557s
337
+
338
+ Comparison:
339
+ Synchronized Boolean Toggle: 1652.8 i/s
340
+ Concurrent Ruby Atomic Boolean Toggle: 1313.3 i/s - 1.26x slower
341
+ Atomic Ruby Atomic Boolean Toggle: 1230.2 i/s - 1.34x slower
342
+ ```
343
+
344
+ </details>
345
+
346
+ <details>
347
+
348
+ <summary>AtomicConditionVariable</summary>
349
+
350
+ ```ruby
351
+ # frozen_string_literal: true
352
+
353
+ require "benchmark/ips"
354
+ require_relative "../lib/atomic-ruby"
355
+
356
+ module Benchmark
357
+ module IPS
358
+ class Job
359
+ class StreamReport
360
+ def start_warming
361
+ @out.puts "\n"
362
+ @out.puts "ruby version: #{RUBY_DESCRIPTION}"
363
+ @out.puts "atomic-ruby version: #{AtomicRuby::VERSION}"
364
+ @out.puts "\n"
365
+ @out.puts "Warming up --------------------------------------"
366
+ end
367
+ end
368
+ end
369
+ end
370
+ end
371
+
372
+ Benchmark.ips do |x|
373
+ x.report("Synchronized Condition Variable Wait/Signal") do
374
+ flag = false
375
+ mutex = Mutex.new
376
+ condvar = ConditionVariable.new
377
+
378
+ waiter = Thread.new do
379
+ mutex.synchronize do
380
+ condvar.wait(mutex) until flag
381
+ end
382
+ end
383
+
384
+ mutex.synchronize do
385
+ flag = true
386
+ condvar.signal
387
+ end
388
+ waiter.join
389
+ end
390
+
391
+ x.report("Atomic Ruby Atomic Condition Variable Wait/Signal") do
392
+ flag = AtomicBoolean.new(false)
393
+ condvar = AtomicConditionVariable.new
394
+
395
+ waiter = Thread.new { condvar.wait { flag.true? } }
396
+
397
+ flag.make_true
398
+ condvar.signal
399
+ waiter.join
400
+ end
401
+
402
+ x.compare!
403
+ end
404
+ ```
405
+
406
+ ```
407
+ > bundle exec rake clobber && bundle exec rake compile && bundle exec ruby examples/atomic_condition_variable_benchmark.rb
408
+
409
+ ruby version: ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [arm64-darwin23]
410
+ atomic-ruby version: 0.13.0
307
411
 
308
412
  Warming up --------------------------------------
309
- Synchronized Boolean Toggle
310
- 135.000 i/100ms
311
- Concurrent Ruby Atomic Boolean Toggle
312
- 117.000 i/100ms
313
- Atomic Ruby Atomic Boolean Toggle
314
- 101.000 i/100ms
413
+ Synchronized Condition Variable Wait/Signal 3.847k i/100ms
414
+ Atomic Ruby Atomic Condition Variable Wait/Signal 3.704k i/100ms
315
415
  Calculating -------------------------------------
316
- Synchronized Boolean Toggle
317
- 1.414k1.4%) i/s (707.15 μs/i) - 7.155k in 5.060692s
318
- Concurrent Ruby Atomic Boolean Toggle
319
- 1.149k (± 2.9%) i/s (870.53 μs/i) - 5.850k in 5.097019s
320
- Atomic Ruby Atomic Boolean Toggle
321
- 1.046k (± 2.1%) i/s (955.57 μs/i) - 5.252k in 5.021118s
416
+ Synchronized Condition Variable Wait/Signal 38.661k (± 4.4%) i/s (25.87 μs/i) - 196.197k in 5.074793s
417
+ Atomic Ruby Atomic Condition Variable Wait/Signal 37.895k4.7%) i/s (26.39 μs/i) - 192.608k in 5.082648s
322
418
 
323
419
  Comparison:
324
- Synchronized Boolean Toggle: 1414.1 i/s
325
- Concurrent Ruby Atomic Boolean Toggle: 1148.7 i/s - 1.23x slower
326
- Atomic Ruby Atomic Boolean Toggle: 1046.5 i/s - 1.35x slower
420
+ Synchronized Condition Variable Wait/Signal: 38661.1 i/s
421
+ Atomic Ruby Atomic Condition Variable Wait/Signal: 37895.2 i/s - same-ish: difference falls within error
327
422
  ```
328
423
 
329
424
  </details>
@@ -377,15 +472,15 @@ puts "Atomic Ruby Atomic Thread Pool: #{results[1].real.round(6)} seconds"
377
472
  ```
378
473
 
379
474
  ```
380
- > bundle exec rake compile && bundle exec ruby examples/atomic_thread_pool_benchmark.rb
475
+ > bundle exec rake clobber && bundle exec rake compile && bundle exec ruby examples/atomic_thread_pool_benchmark.rb
381
476
 
382
- ruby version: ruby 4.0.4 (2026-05-12 revision b89eb1bcbf) +YJIT +PRISM [arm64-darwin23]
477
+ ruby version: ruby 4.0.5 (2026-05-20 revision 64336ffd0e) +YJIT +PRISM [arm64-darwin23]
383
478
  concurrent-ruby version: 1.3.6
384
- atomic-ruby version: 0.12.0
479
+ atomic-ruby version: 0.13.0
385
480
 
386
481
  Benchmark Results:
387
- Concurrent Ruby Thread Pool: 5.987538 seconds
388
- Atomic Ruby Atomic Thread Pool: 5.528936 seconds
482
+ Concurrent Ruby Thread Pool: 5.792203 seconds
483
+ Atomic Ruby Atomic Thread Pool: 5.504388 seconds
389
484
  ```
390
485
 
391
486
  </details>
@@ -0,0 +1,171 @@
1
+ # rbs_inline: enabled
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "atom"
5
+
6
+ module AtomicRuby
7
+ # Provides lock-free wait/signal coordination using atomic operations.
8
+ #
9
+ # AtomicConditionVariable lets one or more threads park until another
10
+ # thread signals them, without the paired `Mutex` that Ruby's
11
+ # `ConditionVariable` requires. Coordination is done by publishing the
12
+ # set of parked threads through an {Atom}, so all in-process
13
+ # synchronisation stays on the gem's CAS path. Parking still uses
14
+ # `Thread.stop` and `Thread#wakeup`, which are the standard kernel-level
15
+ # primitives Ruby exposes for sleeping a thread.
16
+ #
17
+ # The lost-wakeup race in a naive `check-then-park` consumer is avoided
18
+ # by the {#wait} contract: a waiter registers itself before re-evaluating
19
+ # the predicate, so any signal that fires after the producer makes the
20
+ # predicate true is guaranteed to see the waiter and wake it. Ruby also
21
+ # remembers pending wakeups across `Thread.stop`, so a wakeup that
22
+ # arrives between the predicate check and the actual park is not lost.
23
+ #
24
+ # @example Basic usage
25
+ # condvar = AtomicConditionVariable.new
26
+ # ready = AtomicBoolean.new(false)
27
+ #
28
+ # waiter = Thread.new do
29
+ # condvar.wait { ready.true? }
30
+ # puts "ready"
31
+ # end
32
+ #
33
+ # ready.make_true
34
+ # condvar.signal
35
+ #
36
+ # @example Worker loop draining an atomic queue
37
+ # condvar.wait do
38
+ # work = nil
39
+ # queue.swap do |q|
40
+ # q.empty? ? q : (work = q.first; q.drop(1).freeze)
41
+ # end
42
+ # work
43
+ # end
44
+ #
45
+ # @note This class is NOT Ractor-safe as it parks `Thread` references,
46
+ # which cannot be shared across ractors.
47
+ class AtomicConditionVariable
48
+ # Creates a new condition variable with no parked threads.
49
+ #
50
+ # @example
51
+ # condvar = AtomicConditionVariable.new
52
+ #
53
+ # @rbs () -> void
54
+ def initialize
55
+ @waiters = Atom.new([].freeze)
56
+ end
57
+
58
+ # Returns the number of currently parked waiters.
59
+ #
60
+ # This operation is atomic and thread-safe. The returned value reflects
61
+ # the state at the time of the call, but may change immediately after
62
+ # in concurrent environments.
63
+ #
64
+ # @return [Integer] The number of currently parked waiters
65
+ #
66
+ # @example
67
+ # condvar = AtomicConditionVariable.new
68
+ # puts condvar.waiter_count #=> 0
69
+ #
70
+ # @rbs () -> Integer
71
+ def waiter_count
72
+ @waiters.value.size
73
+ end
74
+
75
+ # Wakes one parked waiter, or no-ops if none are parked.
76
+ #
77
+ # If a waiter has registered itself but is not yet inside `Thread.stop`,
78
+ # Ruby remembers the wakeup and the next `Thread.stop` returns
79
+ # immediately.
80
+ #
81
+ # @return [true, false] true if a waiter was signalled, false otherwise
82
+ #
83
+ # @example
84
+ # condvar = AtomicConditionVariable.new
85
+ # condvar.signal #=> false
86
+ #
87
+ # @rbs () -> bool
88
+ def signal
89
+ target = nil
90
+ @waiters.swap do |waiters|
91
+ if waiters.empty?
92
+ waiters
93
+ else
94
+ target = waiters.first
95
+ waiters.drop(1).freeze
96
+ end
97
+ end
98
+ return false unless target
99
+
100
+ target.wakeup rescue nil
101
+ true
102
+ end
103
+
104
+ # Wakes every parked waiter.
105
+ #
106
+ # Each woken thread observes the wake the same way as with {#signal}.
107
+ #
108
+ # @return [Integer] The number of waiters signalled
109
+ #
110
+ # @example
111
+ # condvar = AtomicConditionVariable.new
112
+ # condvar.broadcast #=> 0
113
+ #
114
+ # @rbs () -> Integer
115
+ def broadcast
116
+ targets = nil
117
+ @waiters.swap do |waiters|
118
+ targets = waiters
119
+ [].freeze
120
+ end
121
+ targets.each { |thread| thread.wakeup rescue nil }
122
+ targets.size
123
+ end
124
+
125
+ # Blocks until the given block returns a truthy value, then returns that
126
+ # value.
127
+ #
128
+ # The block is evaluated optimistically first. If it returns truthy on
129
+ # that pass, no waiter is registered and the call returns immediately.
130
+ # Otherwise the calling thread registers itself, re-evaluates the
131
+ # block, and parks via `Thread.stop` until a {#signal} or {#broadcast}
132
+ # wakes it. The block may run more than once and may run concurrently
133
+ # with a signalling thread.
134
+ #
135
+ # @yieldreturn [untyped] Truthy to wake, falsy to keep waiting
136
+ # @return [untyped] The first truthy value returned by the block
137
+ #
138
+ # @example Simple wait
139
+ # condvar = AtomicConditionVariable.new
140
+ # ready = AtomicBoolean.new(false)
141
+ #
142
+ # Thread.new do
143
+ # sleep(1)
144
+ # ready.make_true
145
+ # condvar.signal
146
+ # end
147
+ #
148
+ # condvar.wait { ready.true? } #=> true
149
+ #
150
+ # @rbs () { () -> untyped } -> untyped
151
+ def wait
152
+ result = yield
153
+ return result if result
154
+
155
+ self_thread = Thread.current
156
+ loop do
157
+ @waiters.swap { |waiters| (waiters + [self_thread]).freeze }
158
+ result = yield
159
+ if result
160
+ @waiters.swap { |waiters| (waiters - [self_thread]).freeze }
161
+ return result
162
+ end
163
+
164
+ Thread.stop
165
+ @waiters.swap { |waiters| (waiters - [self_thread]).freeze }
166
+ end
167
+ end
168
+ end
169
+ end
170
+
171
+ AtomicConditionVariable = AtomicRuby::AtomicConditionVariable
@@ -2,6 +2,7 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require_relative "atom"
5
+ require_relative "atomic_condition_variable"
5
6
 
6
7
  module AtomicRuby
7
8
  # Provides a fixed-size thread pool using atomic operations for work queuing.
@@ -86,6 +87,7 @@ module AtomicRuby
86
87
  @on_error = on_error
87
88
 
88
89
  @state = Atom.new(in: nil, out: nil, count: 0, shutdown: false)
90
+ @work_available = AtomicConditionVariable.new
89
91
  @started_thread_count = Atom.new(0)
90
92
  @active_thread_count = Atom.new(0)
91
93
  @threads = []
@@ -127,7 +129,7 @@ module AtomicRuby
127
129
  end
128
130
  raise EnqueuedWorkAfterShutdownError if state[:shutdown]
129
131
 
130
- @threads.each(&:wakeup)
132
+ @work_available.signal
131
133
  end
132
134
 
133
135
  # Returns the number of currently alive worker threads.
@@ -236,6 +238,7 @@ module AtomicRuby
236
238
  end
237
239
  return if already_shutdown
238
240
 
241
+ @work_available.broadcast
239
242
  @threads.each(&:join)
240
243
  end
241
244
 
@@ -263,40 +266,39 @@ module AtomicRuby
263
266
  work = nil
264
267
  should_shutdown = false
265
268
 
266
- @state.swap do |current_state|
267
- if current_state[:shutdown] && current_state[:in].nil? && current_state[:out].nil?
268
- should_shutdown = true
269
- current_state
270
- elsif current_state[:out]
271
- work = current_state[:out][:value]
272
- current_state.merge(out: current_state[:out][:next], count: current_state[:count] - 1)
273
- elsif current_state[:in]
274
- new_out = reverse_list(current_state[:in])
275
- work = new_out[:value]
276
- current_state.merge(in: nil, out: new_out[:next], count: current_state[:count] - 1)
277
- else
278
- current_state
269
+ @work_available.wait do
270
+ @state.swap do |current_state|
271
+ if current_state[:shutdown] && current_state[:in].nil? && current_state[:out].nil?
272
+ should_shutdown = true
273
+ current_state
274
+ elsif current_state[:out]
275
+ work = current_state[:out][:value]
276
+ current_state.merge(out: current_state[:out][:next], count: current_state[:count] - 1)
277
+ elsif current_state[:in]
278
+ new_out = reverse_list(current_state[:in])
279
+ work = new_out[:value]
280
+ current_state.merge(in: nil, out: new_out[:next], count: current_state[:count] - 1)
281
+ else
282
+ current_state
283
+ end
279
284
  end
285
+ work || should_shutdown
280
286
  end
281
287
 
282
- if should_shutdown
283
- break
284
- elsif work
285
- @active_thread_count.swap { |current_count| current_count + 1 }
286
- begin
287
- work.call
288
- rescue => err
289
- if @on_error
290
- @on_error.call(err)
291
- else
292
- warn "#{thread_name} rescued:"
293
- warn err.full_message
294
- end
295
- ensure
296
- @active_thread_count.swap { |current_count| current_count - 1 }
288
+ break if should_shutdown
289
+
290
+ @active_thread_count.swap { |current_count| current_count + 1 }
291
+ begin
292
+ work.call
293
+ rescue => err
294
+ if @on_error
295
+ @on_error.call(err)
296
+ else
297
+ warn "#{thread_name} rescued:"
298
+ warn err.full_message
297
299
  end
298
- else
299
- sleep 0.001
300
+ ensure
301
+ @active_thread_count.swap { |current_count| current_count - 1 }
300
302
  end
301
303
  end
302
304
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AtomicRuby
4
- VERSION = "0.12.0"
4
+ VERSION = "0.13.0"
5
5
  end
data/lib/atomic-ruby.rb CHANGED
@@ -3,5 +3,6 @@
3
3
  require_relative "atomic-ruby/version"
4
4
  require_relative "atomic-ruby/atom"
5
5
  require_relative "atomic-ruby/atomic_boolean"
6
+ require_relative "atomic-ruby/atomic_condition_variable"
6
7
  require_relative "atomic-ruby/atomic_thread_pool"
7
8
  require_relative "atomic-ruby/atomic_count_down_latch"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: atomic-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Young
@@ -27,6 +27,7 @@ files:
27
27
  - lib/atomic-ruby.rb
28
28
  - lib/atomic-ruby/atom.rb
29
29
  - lib/atomic-ruby/atomic_boolean.rb
30
+ - lib/atomic-ruby/atomic_condition_variable.rb
30
31
  - lib/atomic-ruby/atomic_count_down_latch.rb
31
32
  - lib/atomic-ruby/atomic_thread_pool.rb
32
33
  - lib/atomic-ruby/version.rb