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 +4 -4
- data/CHANGELOG.md +4 -0
- data/README.md +19 -7
- data/ext/atomic_ruby/atomic_ruby.c +189 -0
- data/ext/atomic_ruby/atomic_ruby.h +4 -0
- data/lib/atomic-ruby/atomic_thread_pool.rb +216 -40
- data/lib/atomic-ruby/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7a3341865540be4c1099997f8a21e35ac9d16427f7ba2cf6898f568aba7d2372
|
|
4
|
+
data.tar.gz: cf4afb42fb8b9680fce99518f5f627827dad0dca8e4725a7ed1b7c409bffd024
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d1e237a56143ea169b19593dd7564d21e1f0dfe25bdb34ed9fc32a7960e52f1dcea353b1f594d754aa47361b42ec5b7a1bbd06962896c0a2eeaea4cbb4197cac
|
|
7
|
+
data.tar.gz: b6592601f77c203782a67024bb075a2f018f8ab89b1e931474009c206b7705b295156f8ff7d31fc5fa54c869d5bd013107768806435703979f747a705addcad9
|
data/CHANGELOG.md
CHANGED
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
590
|
+
atomic-ruby version: 0.15.0
|
|
579
591
|
|
|
580
592
|
Benchmark Results:
|
|
581
|
-
Concurrent Ruby Thread Pool: 5.
|
|
582
|
-
Atomic Ruby Atomic Thread Pool: 4.
|
|
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
|
}
|
|
@@ -7,11 +7,13 @@ require_relative "atomic_condition_variable"
|
|
|
7
7
|
require_relative "atomic_queue"
|
|
8
8
|
|
|
9
9
|
module AtomicRuby
|
|
10
|
-
# Provides a
|
|
10
|
+
# Provides a thread pool using atomic operations for work queuing.
|
|
11
11
|
#
|
|
12
|
-
# AtomicThreadPool maintains a
|
|
13
|
-
# work items from an {AtomicQueue}.
|
|
14
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
291
|
+
Thread.pass until @alive_thread_count.value == @size
|
|
292
|
+
end
|
|
248
293
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
data/lib/atomic-ruby/version.rb
CHANGED