atomic-ruby 0.14.1 → 0.15.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: 1310b5db37f97fe6e8a8e9da89083554f2bc5cd4500027e853e268d99db15cc6
4
- data.tar.gz: 94039f36f8c248fc2bbc60529deff3c72d933d6c6e638d6f70136f64ad798825
3
+ metadata.gz: 7a3341865540be4c1099997f8a21e35ac9d16427f7ba2cf6898f568aba7d2372
4
+ data.tar.gz: cf4afb42fb8b9680fce99518f5f627827dad0dca8e4725a7ed1b7c409bffd024
5
5
  SHA512:
6
- metadata.gz: 9ad6f1cefcf6a6f39635baa7e2d384e6d6a4e273927c9a442bcb4a63036172dcc1c222fb4fd02d61878205db8163f91f70b4a8a753c48c2de760bc74950987f7
7
- data.tar.gz: 448588150ab485b7a1cb6f7dfe892feba5b15c7b32d6d766570ef4097ec48a3ee0a7fbdc20fa92e9875d7701ee42e31660df9c759d24243f250a7987357d870b
6
+ metadata.gz: d1e237a56143ea169b19593dd7564d21e1f0dfe25bdb34ed9fc32a7960e52f1dcea353b1f594d754aa47361b42ec5b7a1bbd06962896c0a2eeaea4cbb4197cac
7
+ data.tar.gz: b6592601f77c203782a67024bb075a2f018f8ab89b1e931474009c206b7705b295156f8ff7d31fc5fa54c869d5bd013107768806435703979f747a705addcad9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.15.0] - 2026-08-29
4
+
5
+ - Add adaptive scaling to `AtomicThreadPool` with `max_size:`
6
+
3
7
  ## [0.14.1] - 2026-08-20
4
8
 
5
9
  - Fix O(n) in `AtomicThreadPool#length` by tracking the alive count
data/README.md CHANGED
@@ -120,6 +120,18 @@ p results #=> [8, 7, 10, 9, 6, 5, 3, 4, 2, 1]
120
120
  p results.sort #=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
121
121
  ```
122
122
 
123
+ Pass `max_size` to let the pool temporarily add workers when queued work is
124
+ held up by blocking operations. The pool measures time spent blocked outside
125
+ the GVL and waiting for it, so it does not add more threads when GVL contention
126
+ is the bottleneck. It returns to `size` when the queue drains.
127
+
128
+ ```ruby
129
+ pool = AtomicThreadPool.new(size: 4, max_size: 16)
130
+
131
+ # Grow without a limit
132
+ pool = AtomicThreadPool.new(size: 4, max_size: Float::INFINITY)
133
+ ```
134
+
123
135
  `AtomicCountDownLatch`:
124
136
 
125
137
  ```ruby
@@ -256,7 +268,7 @@ puts "Atomic Ruby Atomic Bank Account: #{results[2].real.round(6)} seconds"
256
268
 
257
269
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
258
270
  concurrent-ruby version: 1.3.6
259
- atomic-ruby version: 0.14.1
271
+ atomic-ruby version: 0.15.0
260
272
 
261
273
  Balances:
262
274
  Synchronized Bank Account Balance: 975
@@ -347,7 +359,7 @@ end
347
359
 
348
360
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
349
361
  concurrent-ruby version: 1.3.6
350
- atomic-ruby version: 0.14.1
362
+ atomic-ruby version: 0.15.0
351
363
 
352
364
  Warming up --------------------------------------
353
365
  Synchronized Boolean Toggle 157.000 i/100ms
@@ -430,7 +442,7 @@ end
430
442
  > bundle exec rake clobber && bundle exec rake compile && bundle exec ruby examples/atomic_condition_variable_benchmark.rb
431
443
 
432
444
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
433
- atomic-ruby version: 0.14.1
445
+ atomic-ruby version: 0.15.0
434
446
 
435
447
  Warming up --------------------------------------
436
448
  Synchronized Condition Variable Wait/Signal 3.977k i/100ms
@@ -505,7 +517,7 @@ end
505
517
  > bundle exec rake clobber && bundle exec rake compile && bundle exec ruby examples/atomic_queue_benchmark.rb
506
518
 
507
519
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
508
- atomic-ruby version: 0.14.1
520
+ atomic-ruby version: 0.15.0
509
521
 
510
522
  Warming up --------------------------------------
511
523
  Synchronized Queue Push/Pop 181.000 i/100ms
@@ -575,11 +587,11 @@ puts "Atomic Ruby Atomic Thread Pool: #{results[1].real.round(6)} seconds"
575
587
 
576
588
  ruby version: ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +YJIT +PRISM [arm64-darwin23]
577
589
  concurrent-ruby version: 1.3.8
578
- atomic-ruby version: 0.14.1
590
+ atomic-ruby version: 0.15.0
579
591
 
580
592
  Benchmark Results:
581
- Concurrent Ruby Thread Pool: 5.046895 seconds
582
- Atomic Ruby Atomic Thread Pool: 4.717221 seconds
593
+ Concurrent Ruby Thread Pool: 5.030964 seconds
594
+ Atomic Ruby Atomic Thread Pool: 4.676908 seconds
583
595
  ```
584
596
 
585
597
  </details>
@@ -521,6 +521,178 @@ static VALUE rb_cAtomicConditionVariable_waiter_count(VALUE self) {
521
521
  return UINT2NUM((unsigned int)RUBY_ATOMIC_LOAD(atomic_ruby_condition_variable->count));
522
522
  }
523
523
 
524
+ typedef enum {
525
+ ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE,
526
+ ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING,
527
+ ATOMIC_RUBY_THREAD_POOL_WORKER_WAITING,
528
+ ATOMIC_RUBY_THREAD_POOL_WORKER_BLOCKED
529
+ } atomic_ruby_thread_pool_worker_phase_t;
530
+
531
+ typedef struct {
532
+ _Atomic unsigned int running_count;
533
+ _Atomic unsigned int waiting_count;
534
+ _Atomic unsigned int blocked_count;
535
+ _Atomic unsigned long long running_time;
536
+ _Atomic unsigned long long waiting_time;
537
+ _Atomic unsigned long long blocked_time;
538
+ } atomic_ruby_thread_pool_monitor_t;
539
+
540
+ typedef struct {
541
+ atomic_ruby_thread_pool_monitor_t *monitor;
542
+ atomic_ruby_thread_pool_worker_phase_t phase;
543
+ unsigned long long phase_started_at;
544
+ } atomic_ruby_thread_pool_worker_state_t;
545
+
546
+ static rb_internal_thread_specific_key_t atomic_ruby_thread_pool_worker_key;
547
+
548
+ static void atomic_ruby_thread_pool_monitor_free(void *ptr) {
549
+ xfree(ptr);
550
+ }
551
+
552
+ static size_t atomic_ruby_thread_pool_monitor_memsize(const void *ptr) {
553
+ return sizeof(atomic_ruby_thread_pool_monitor_t);
554
+ }
555
+
556
+ static const rb_data_type_t atomic_ruby_thread_pool_monitor_type = {
557
+ .wrap_struct_name = "AtomicRuby::ThreadPoolMonitor",
558
+ .function = {
559
+ .dfree = atomic_ruby_thread_pool_monitor_free,
560
+ .dsize = atomic_ruby_thread_pool_monitor_memsize
561
+ },
562
+ .flags = RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED
563
+ };
564
+
565
+ static unsigned long long atomic_ruby_monotonic_time(void) {
566
+ struct timespec time;
567
+ if (clock_gettime(CLOCK_MONOTONIC, &time) == -1) return 0;
568
+
569
+ return (unsigned long long)time.tv_sec * 1000000000ULL + (unsigned long long)time.tv_nsec;
570
+ }
571
+
572
+ static void atomic_ruby_thread_pool_worker_leave_phase(atomic_ruby_thread_pool_worker_state_t *state, unsigned long long now) {
573
+ unsigned long long elapsed = now != 0 && state->phase_started_at != 0 && now >= state->phase_started_at ? now - state->phase_started_at : 0;
574
+
575
+ switch (state->phase) {
576
+ case ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING:
577
+ atomic_fetch_sub_explicit(&state->monitor->running_count, 1, memory_order_relaxed);
578
+ atomic_fetch_add_explicit(&state->monitor->running_time, elapsed, memory_order_relaxed);
579
+ break;
580
+ case ATOMIC_RUBY_THREAD_POOL_WORKER_WAITING:
581
+ atomic_fetch_sub_explicit(&state->monitor->waiting_count, 1, memory_order_relaxed);
582
+ atomic_fetch_add_explicit(&state->monitor->waiting_time, elapsed, memory_order_relaxed);
583
+ break;
584
+ case ATOMIC_RUBY_THREAD_POOL_WORKER_BLOCKED:
585
+ atomic_fetch_sub_explicit(&state->monitor->blocked_count, 1, memory_order_relaxed);
586
+ atomic_fetch_add_explicit(&state->monitor->blocked_time, elapsed, memory_order_relaxed);
587
+ break;
588
+ case ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE:
589
+ break;
590
+ }
591
+ }
592
+
593
+ static void atomic_ruby_thread_pool_worker_enter_phase(atomic_ruby_thread_pool_worker_state_t *state, atomic_ruby_thread_pool_worker_phase_t phase, unsigned long long now) {
594
+ state->phase = phase;
595
+ state->phase_started_at = now;
596
+
597
+ switch (phase) {
598
+ case ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING:
599
+ atomic_fetch_add_explicit(&state->monitor->running_count, 1, memory_order_relaxed);
600
+ break;
601
+ case ATOMIC_RUBY_THREAD_POOL_WORKER_WAITING:
602
+ atomic_fetch_add_explicit(&state->monitor->waiting_count, 1, memory_order_relaxed);
603
+ break;
604
+ case ATOMIC_RUBY_THREAD_POOL_WORKER_BLOCKED:
605
+ atomic_fetch_add_explicit(&state->monitor->blocked_count, 1, memory_order_relaxed);
606
+ break;
607
+ case ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE:
608
+ break;
609
+ }
610
+ }
611
+
612
+ static void atomic_ruby_thread_pool_event_callback(rb_event_flag_t event, const rb_internal_thread_event_data_t *event_data, void *user_data) {
613
+ atomic_ruby_thread_pool_worker_state_t *state = rb_internal_thread_specific_get(event_data->thread, atomic_ruby_thread_pool_worker_key);
614
+ if (state == NULL || state->phase == ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE) return;
615
+
616
+ unsigned long long now = atomic_ruby_monotonic_time();
617
+ atomic_ruby_thread_pool_worker_leave_phase(state, now);
618
+
619
+ switch (event) {
620
+ case RUBY_INTERNAL_THREAD_EVENT_READY:
621
+ atomic_ruby_thread_pool_worker_enter_phase(state, ATOMIC_RUBY_THREAD_POOL_WORKER_WAITING, now);
622
+ break;
623
+ case RUBY_INTERNAL_THREAD_EVENT_RESUMED:
624
+ atomic_ruby_thread_pool_worker_enter_phase(state, ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING, now);
625
+ break;
626
+ case RUBY_INTERNAL_THREAD_EVENT_SUSPENDED:
627
+ atomic_ruby_thread_pool_worker_enter_phase(state, ATOMIC_RUBY_THREAD_POOL_WORKER_BLOCKED, now);
628
+ break;
629
+ }
630
+ }
631
+
632
+ static VALUE rb_cThreadPoolMonitor_allocate(VALUE klass) {
633
+ atomic_ruby_thread_pool_monitor_t *monitor;
634
+ VALUE obj = TypedData_Make_Struct(klass, atomic_ruby_thread_pool_monitor_t, &atomic_ruby_thread_pool_monitor_type, monitor);
635
+ atomic_init(&monitor->running_count, 0);
636
+ atomic_init(&monitor->waiting_count, 0);
637
+ atomic_init(&monitor->blocked_count, 0);
638
+ atomic_init(&monitor->running_time, 0);
639
+ atomic_init(&monitor->waiting_time, 0);
640
+ atomic_init(&monitor->blocked_time, 0);
641
+ return obj;
642
+ }
643
+
644
+ static VALUE rb_cThreadPoolMonitor_register_worker(VALUE self) {
645
+ atomic_ruby_thread_pool_monitor_t *monitor;
646
+ TypedData_Get_Struct(self, atomic_ruby_thread_pool_monitor_t, &atomic_ruby_thread_pool_monitor_type, monitor);
647
+
648
+ VALUE thread = rb_thread_current();
649
+ atomic_ruby_thread_pool_worker_state_t *state = ALLOC(atomic_ruby_thread_pool_worker_state_t);
650
+ state->monitor = monitor;
651
+ state->phase = ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE;
652
+ state->phase_started_at = 0;
653
+ rb_internal_thread_specific_set(thread, atomic_ruby_thread_pool_worker_key, state);
654
+ return Qnil;
655
+ }
656
+
657
+ static VALUE rb_cThreadPoolMonitor_unregister_worker(VALUE self) {
658
+ VALUE thread = rb_thread_current();
659
+ atomic_ruby_thread_pool_worker_state_t *state = rb_internal_thread_specific_get(thread, atomic_ruby_thread_pool_worker_key);
660
+ if (state == NULL) return Qnil;
661
+
662
+ atomic_ruby_thread_pool_worker_leave_phase(state, atomic_ruby_monotonic_time());
663
+ rb_internal_thread_specific_set(thread, atomic_ruby_thread_pool_worker_key, NULL);
664
+ xfree(state);
665
+ return Qnil;
666
+ }
667
+
668
+ static VALUE rb_cThreadPoolMonitor_start_work(VALUE self) {
669
+ atomic_ruby_thread_pool_worker_state_t *state = rb_internal_thread_specific_get(rb_thread_current(), atomic_ruby_thread_pool_worker_key);
670
+ atomic_ruby_thread_pool_worker_enter_phase(state, ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING, atomic_ruby_monotonic_time());
671
+ return Qnil;
672
+ }
673
+
674
+ static VALUE rb_cThreadPoolMonitor_stop_work(VALUE self) {
675
+ atomic_ruby_thread_pool_worker_state_t *state = rb_internal_thread_specific_get(rb_thread_current(), atomic_ruby_thread_pool_worker_key);
676
+ atomic_ruby_thread_pool_worker_leave_phase(state, atomic_ruby_monotonic_time());
677
+ state->phase = ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE;
678
+ return Qnil;
679
+ }
680
+
681
+ static VALUE rb_cThreadPoolMonitor_snapshot(VALUE self) {
682
+ atomic_ruby_thread_pool_monitor_t *monitor;
683
+ TypedData_Get_Struct(self, atomic_ruby_thread_pool_monitor_t, &atomic_ruby_thread_pool_monitor_type, monitor);
684
+
685
+ return rb_ary_new_from_args(
686
+ 6,
687
+ UINT2NUM(atomic_load_explicit(&monitor->running_count, memory_order_relaxed)),
688
+ UINT2NUM(atomic_load_explicit(&monitor->waiting_count, memory_order_relaxed)),
689
+ UINT2NUM(atomic_load_explicit(&monitor->blocked_count, memory_order_relaxed)),
690
+ ULL2NUM(atomic_load_explicit(&monitor->running_time, memory_order_relaxed)),
691
+ ULL2NUM(atomic_load_explicit(&monitor->waiting_time, memory_order_relaxed)),
692
+ ULL2NUM(atomic_load_explicit(&monitor->blocked_time, memory_order_relaxed))
693
+ );
694
+ }
695
+
524
696
  RUBY_FUNC_EXPORTED void Init_atomic_ruby(void) {
525
697
  #ifdef ATOMIC_RUBY_RACTOR_SAFE
526
698
  rb_ext_ractor_safe(true);
@@ -563,4 +735,21 @@ RUBY_FUNC_EXPORTED void Init_atomic_ruby(void) {
563
735
  rb_define_private_method(rb_cAtomicConditionVariable, "_shift_thread", rb_cAtomicConditionVariable_shift_thread, 0);
564
736
  rb_define_private_method(rb_cAtomicConditionVariable, "_drain_threads", rb_cAtomicConditionVariable_drain_threads, 0);
565
737
  rb_define_private_method(rb_cAtomicConditionVariable, "_waiter_count", rb_cAtomicConditionVariable_waiter_count, 0);
738
+
739
+ atomic_ruby_thread_pool_worker_key = rb_internal_thread_specific_key_create();
740
+ rb_internal_thread_add_event_hook(
741
+ atomic_ruby_thread_pool_event_callback,
742
+ RUBY_INTERNAL_THREAD_EVENT_READY |
743
+ RUBY_INTERNAL_THREAD_EVENT_RESUMED |
744
+ RUBY_INTERNAL_THREAD_EVENT_SUSPENDED,
745
+ NULL
746
+ );
747
+ VALUE rb_cThreadPoolMonitor = rb_define_class_under(rb_mAtomicRuby, "ThreadPoolMonitor", rb_cObject);
748
+ rb_define_alloc_func(rb_cThreadPoolMonitor, rb_cThreadPoolMonitor_allocate);
749
+ rb_define_method(rb_cThreadPoolMonitor, "register_worker", rb_cThreadPoolMonitor_register_worker, 0);
750
+ rb_define_method(rb_cThreadPoolMonitor, "unregister_worker", rb_cThreadPoolMonitor_unregister_worker, 0);
751
+ rb_define_method(rb_cThreadPoolMonitor, "start_work", rb_cThreadPoolMonitor_start_work, 0);
752
+ rb_define_method(rb_cThreadPoolMonitor, "stop_work", rb_cThreadPoolMonitor_stop_work, 0);
753
+ rb_define_method(rb_cThreadPoolMonitor, "snapshot", rb_cThreadPoolMonitor_snapshot, 0);
754
+ rb_funcall(rb_mAtomicRuby, rb_intern("private_constant"), 1, ID2SYM(rb_intern("ThreadPoolMonitor")));
566
755
  }
@@ -3,8 +3,12 @@
3
3
 
4
4
  #include "ruby.h"
5
5
  #include "ruby/atomic.h"
6
+ #include "ruby/thread.h"
6
7
  #include "ruby/version.h"
7
8
 
9
+ #include <stdatomic.h>
10
+ #include <time.h>
11
+
8
12
  #if RUBY_API_VERSION_CODE >= 40000
9
13
  #define ATOMIC_RUBY_RACTOR_SAFE 1
10
14
  #include "ruby/ractor.h"
@@ -7,11 +7,13 @@ require_relative "atomic_condition_variable"
7
7
  require_relative "atomic_queue"
8
8
 
9
9
  module AtomicRuby
10
- # Provides a fixed-size thread pool using atomic operations for work queuing.
10
+ # Provides a thread pool using atomic operations for work queuing.
11
11
  #
12
- # AtomicThreadPool maintains a fixed number of worker threads that process
13
- # work items from an {AtomicQueue}. Both enqueueing and dequeueing are O(1)
14
- # and lock-free, so concurrent producers and consumers never block one
12
+ # AtomicThreadPool maintains a baseline number of worker threads that process
13
+ # work items from an {AtomicQueue}. When `max_size` is provided, it can
14
+ # temporarily add workers when work remains queued and its workers spend most
15
+ # of their time blocked outside the GVL. Both enqueueing and dequeueing are
16
+ # O(1) and lock-free, so concurrent producers and consumers never block one
15
17
  # another.
16
18
  #
17
19
  # @example Basic usage
@@ -31,6 +33,11 @@ module AtomicRuby
31
33
  # pool.shutdown
32
34
  # puts results.sort #=> [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
33
35
  #
36
+ # @example Scaling for blocking work
37
+ # pool = AtomicThreadPool.new(size: 2, max_size: 8)
38
+ # 20.times { pool << proc { Net::HTTP.get(uri) } }
39
+ # pool.shutdown
40
+ #
34
41
  # @example Monitoring pool state
35
42
  # pool = AtomicThreadPool.new(size: 3)
36
43
  # puts pool.length #=> 3
@@ -44,6 +51,12 @@ module AtomicRuby
44
51
  # @note This class is NOT Ractor-safe as it contains mutable thread state
45
52
  # that cannot be safely shared across ractors.
46
53
  class AtomicThreadPool
54
+ AUTOSCALE_GROWTH_SAMPLES = 3
55
+ AUTOSCALE_IDLE_TIME = 1
56
+ AUTOSCALE_INTERVAL = 0.01
57
+ AUTOSCALE_WINDOW = 0.05
58
+ private_constant :AUTOSCALE_GROWTH_SAMPLES, :AUTOSCALE_IDLE_TIME, :AUTOSCALE_INTERVAL, :AUTOSCALE_WINDOW
59
+
47
60
  class Error < StandardError; end
48
61
 
49
62
  # Error raised when attempting to enqueue work after shutdown.
@@ -52,15 +65,25 @@ module AtomicRuby
52
65
  def message = "cannot queue work after shutdown"
53
66
  end
54
67
 
55
- # Creates a new thread pool with the specified size.
68
+ # Creates a new thread pool with the specified baseline size.
69
+ #
70
+ # When `max_size` is greater than `size`, the pool adds temporary workers
71
+ # if work remains queued while its active workers spend most of their time
72
+ # blocked outside the GVL, but not when they are waiting for the GVL.
73
+ # Temporary workers leave after the queue remains empty, returning the pool
74
+ # to its baseline size. Omitting `max_size` creates a fixed-size pool.
56
75
  #
57
- # @param size [Integer] The number of worker threads to create (must be positive)
76
+ # @param size [Integer] The baseline number of worker threads (must be positive)
77
+ # @param max_size [Integer, Float, nil] Maximum number of worker threads,
78
+ # `Float::INFINITY` for no limit, or nil for a fixed-size pool
58
79
  # @param name [String, nil] Optional name for the thread pool (used in thread names)
59
80
  # @param on_error [Proc, nil] Optional error handler called with the exception when
60
81
  # a work item raises. Receives the exception as its argument. When nil, errors
61
82
  # are printed to stderr
62
83
  #
63
84
  # @raise [ArgumentError] if size is not a positive integer
85
+ # @raise [ArgumentError] if max_size is not an integer greater than or equal
86
+ # to size or `Float::INFINITY`
64
87
  # @raise [ArgumentError] if name is provided but not a string
65
88
  # @raise [ArgumentError] if on_error is provided but not a Proc
66
89
  #
@@ -70,17 +93,23 @@ module AtomicRuby
70
93
  # @example Create a named pool
71
94
  # pool = AtomicThreadPool.new(size: 2, name: "Database Workers")
72
95
  #
96
+ # @example Create an adaptive pool
97
+ # pool = AtomicThreadPool.new(size: 2, max_size: 8)
98
+ #
73
99
  # @example Create a pool with a custom error handler
74
100
  # errors = []
75
101
  # pool = AtomicThreadPool.new(size: 2, on_error: ->(err) { errors << err })
76
102
  #
77
- # @rbs (size: Integer, ?name: String?, ?on_error: Proc?) -> void
78
- def initialize(size:, name: nil, on_error: nil)
103
+ # @rbs (size: Integer, ?max_size: (Integer | Float)?, ?name: String?, ?on_error: Proc?) -> void
104
+ def initialize(size:, max_size: nil, name: nil, on_error: nil)
79
105
  raise ArgumentError, "size must be a positive Integer" unless size.is_a?(Integer) && size > 0
106
+ valid_max_size = max_size.nil? || max_size == Float::INFINITY || (max_size.is_a?(Integer) && max_size >= size)
107
+ raise ArgumentError, "max_size must be an Integer greater than or equal to size or Float::INFINITY" unless valid_max_size
80
108
  raise ArgumentError, "name must be a String" unless name.nil? || name.is_a?(String)
81
109
  raise ArgumentError, "on_error must be a Proc" unless on_error.nil? || on_error.is_a?(Proc)
82
110
 
83
111
  @size = size
112
+ @max_size = max_size || size
84
113
  @name = name
85
114
  @on_error = on_error
86
115
 
@@ -90,6 +119,12 @@ module AtomicRuby
90
119
  @alive_thread_count = Atom.new(0)
91
120
  @active_thread_count = Atom.new(0)
92
121
  @threads = []
122
+ @next_thread_number = 0
123
+ if adaptive?
124
+ @autoscale_available = AtomicConditionVariable.new
125
+ @trim_requested = AtomicBoolean.new(false)
126
+ @thread_pool_monitor = ThreadPoolMonitor.new
127
+ end
93
128
 
94
129
  start
95
130
  end
@@ -120,15 +155,17 @@ module AtomicRuby
120
155
  def <<(work)
121
156
  raise EnqueuedWorkAfterShutdownError if @shutdown.true?
122
157
 
158
+ @trim_requested&.make_false
123
159
  @queue.push(work)
124
160
  @work_available.signal
161
+ @autoscale_available&.signal
125
162
  end
126
163
 
127
164
  # Returns the number of currently alive worker threads.
128
165
  #
129
166
  # This count decreases as the pool shuts down and threads terminate.
130
- # During normal operation, this should equal the size parameter
131
- # passed to the constructor.
167
+ # An adaptive pool may report a value between the `size` and `max_size`
168
+ # parameters passed to the constructor.
132
169
  #
133
170
  # @return [Integer] The number of alive worker threads
134
171
  #
@@ -222,7 +259,9 @@ module AtomicRuby
222
259
  return if @shutdown.true?
223
260
 
224
261
  @shutdown.make_true
262
+ @autoscale_available&.broadcast
225
263
  @work_available.broadcast
264
+ @autoscaler&.join
226
265
  @threads.each(&:join)
227
266
  end
228
267
 
@@ -238,49 +277,186 @@ module AtomicRuby
238
277
  #
239
278
  # @rbs () -> void
240
279
  def start
241
- @size.times do |num|
242
- @threads << Thread.new(num) do |idx|
243
- thread_name = String.new("AtomicThreadPool thread #{idx}")
280
+ @size.times { spawn_worker }
281
+
282
+ if adaptive?
283
+ @autoscaler = Thread.new do
284
+ thread_name = String.new("AtomicThreadPool autoscaler")
244
285
  thread_name << " for #{@name}" if @name
245
286
  Thread.current.name = thread_name
287
+ autoscale
288
+ end
289
+ end
246
290
 
247
- @alive_thread_count.swap { |current_count| current_count + 1 }
291
+ Thread.pass until @alive_thread_count.value == @size
292
+ end
248
293
 
249
- begin
250
- loop do
251
- work = nil
252
- should_shutdown = false
294
+ # Returns whether the pool may grow beyond its baseline size.
295
+ #
296
+ # @return [true, false]
297
+ #
298
+ # @rbs () -> bool
299
+ def adaptive?
300
+ @max_size > @size
301
+ end
302
+
303
+ # Creates a worker thread.
304
+ #
305
+ # Temporary workers leave after the queue remains empty.
306
+ #
307
+ # @param temporary [true, false] whether the worker belongs above the baseline
308
+ # @return [Thread]
309
+ #
310
+ # @rbs (?temporary: bool) -> Thread
311
+ def spawn_worker(temporary: false)
312
+ thread_number = @next_thread_number
313
+ @next_thread_number += 1
314
+
315
+ thread = Thread.new(thread_number) do |idx|
316
+ thread_name = String.new("AtomicThreadPool thread #{idx}")
317
+ thread_name << " for #{@name}" if @name
318
+ Thread.current.name = thread_name
253
319
 
254
- @work_available.wait do
255
- work = @queue.pop
256
- should_shutdown = @shutdown.true? && @queue.empty? unless work
257
- work || should_shutdown
320
+ @thread_pool_monitor&.register_worker
321
+ @alive_thread_count.swap { |current_count| current_count + 1 }
322
+
323
+ begin
324
+ loop do
325
+ work = nil
326
+ should_exit = false
327
+
328
+ @work_available.wait do
329
+ work = @queue.pop
330
+ unless work
331
+ should_exit = @shutdown.true? && @queue.empty?
332
+ should_exit ||= temporary && @trim_requested.true? && @queue.empty?
258
333
  end
334
+ work || should_exit
335
+ end
259
336
 
260
- break if should_shutdown
261
-
262
- @active_thread_count.swap { |current_count| current_count + 1 }
263
- begin
264
- work.call
265
- rescue => err
266
- if @on_error
267
- @on_error.call(err)
268
- else
269
- warn "#{thread_name} rescued:"
270
- warn err.full_message
271
- end
272
- ensure
273
- @active_thread_count.swap { |current_count| current_count - 1 }
337
+ break if should_exit
338
+
339
+ @active_thread_count.swap { |current_count| current_count + 1 }
340
+ begin
341
+ @thread_pool_monitor&.start_work
342
+ work.call
343
+ rescue => err
344
+ if @on_error
345
+ @on_error.call(err)
346
+ else
347
+ warn "#{thread_name} rescued:"
348
+ warn err.full_message
274
349
  end
350
+ ensure
351
+ @thread_pool_monitor&.stop_work
352
+ @active_thread_count.swap { |current_count| current_count - 1 }
275
353
  end
276
- ensure
277
- @alive_thread_count.swap { |current_count| current_count - 1 }
278
354
  end
355
+ ensure
356
+ @alive_thread_count.swap { |current_count| current_count - 1 }
357
+ @thread_pool_monitor&.unregister_worker
279
358
  end
280
359
  end
281
- @threads.freeze
282
360
 
283
- Thread.pass until @alive_thread_count.value == @size
361
+ @threads << thread
362
+ thread
363
+ end
364
+
365
+ # Adds temporary workers while queued work is held up by blocked workers.
366
+ #
367
+ # @return [void]
368
+ #
369
+ # @rbs () -> void
370
+ def autoscale
371
+ previous_snapshot = @thread_pool_monitor.snapshot
372
+ pressure_started_at = nil
373
+ idle_started_at = nil
374
+ growth_samples = 0
375
+ phase_samples = [0, 0, 0]
376
+
377
+ loop do
378
+ @autoscale_available.wait do
379
+ @shutdown.true? || !@queue.empty? || @alive_thread_count.value > @size
380
+ end
381
+ break if @shutdown.true?
382
+
383
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
384
+ snapshot = @thread_pool_monitor.snapshot
385
+
386
+ if @queue.empty?
387
+ pressure_started_at = nil
388
+ previous_snapshot = snapshot
389
+ idle_started_at ||= now
390
+ growth_samples = 0
391
+ phase_samples.fill(0)
392
+
393
+ if now - idle_started_at >= AUTOSCALE_IDLE_TIME
394
+ @trim_requested.make_true
395
+ @work_available.broadcast
396
+ end
397
+ else
398
+ @trim_requested.make_false
399
+ idle_started_at = nil
400
+
401
+ if pressure_started_at.nil?
402
+ previous_snapshot = snapshot
403
+ pressure_started_at = now
404
+ phase_samples.fill(0)
405
+ elsif now - pressure_started_at >= AUTOSCALE_WINDOW
406
+ if should_grow?(previous_snapshot, snapshot, phase_samples)
407
+ growth_samples += 1
408
+ if growth_samples >= AUTOSCALE_GROWTH_SAMPLES
409
+ spawn_worker(temporary: true)
410
+ growth_samples = 0
411
+ end
412
+ else
413
+ growth_samples = 0
414
+ end
415
+ previous_snapshot = snapshot
416
+ pressure_started_at = now
417
+ phase_samples.fill(0)
418
+ else
419
+ 3.times { |index| phase_samples[index] += snapshot[index] }
420
+ end
421
+ end
422
+
423
+ sleep AUTOSCALE_INTERVAL
424
+ end
425
+ end
426
+
427
+ # Returns whether another worker is likely to improve throughput.
428
+ #
429
+ # Requiring workers to spend a majority of their time blocked outside the
430
+ # GVL recognizes blocking operations. The pool only grows when less than
431
+ # two percent of their time was spent waiting for the GVL.
432
+ #
433
+ # @param previous_snapshot [Array<Integer>] previous GVL state snapshot
434
+ # @param snapshot [Array<Integer>] current GVL state snapshot
435
+ # @param phase_samples [Array<Integer>] sampled current GVL states
436
+ # @return [true, false]
437
+ #
438
+ # @rbs (Array[Integer] previous_snapshot, Array[Integer] snapshot, Array[Integer] phase_samples) -> bool
439
+ def should_grow?(previous_snapshot, snapshot, phase_samples)
440
+ @threads.select!(&:alive?)
441
+ workers = @threads
442
+ return false if workers.length >= @max_size
443
+ return false if @active_thread_count.value < workers.length
444
+
445
+ _running_count, _waiting_count, _blocked_count, running_time, waiting_time, blocked_time = snapshot
446
+ running_time -= previous_snapshot[3]
447
+ waiting_time -= previous_snapshot[4]
448
+ blocked_time -= previous_snapshot[5]
449
+ total_time = running_time + waiting_time + blocked_time
450
+
451
+ minimum_measured_time = (AUTOSCALE_WINDOW * 1_000_000_000 * workers.length / 2).to_i
452
+ if total_time < minimum_measured_time
453
+ running_time, waiting_time, blocked_time = phase_samples
454
+ total_time = running_time + waiting_time + blocked_time
455
+ end
456
+
457
+ total_time.positive? &&
458
+ waiting_time * 50 < total_time &&
459
+ blocked_time * 2 > total_time
284
460
  end
285
461
  end
286
462
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AtomicRuby
4
- VERSION = "0.14.1"
4
+ VERSION = "0.15.0"
5
5
  end
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.14.1
4
+ version: 0.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Young