atomic-ruby 0.14.1 → 0.15.1
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 +8 -0
- data/README.md +19 -7
- data/ext/atomic_ruby/atomic_ruby.c +208 -0
- data/ext/atomic_ruby/atomic_ruby.h +4 -0
- data/lib/atomic-ruby/atomic_thread_pool.rb +221 -41
- 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: af7a35affcaa2790e6944ee01eb6af8f4d92b5c2479bb1995128742a562fd395
|
|
4
|
+
data.tar.gz: d3908277e6ef09a70f7d46a057a5db7f60c179885d980b4fbbf407bdade6b073
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f5a3525b194636ddb439c0c8e9597e3ed1870a8c0165bc1582cb120a45c49b30a8cf6bd60f393a9e3e0c848abf760b181dce1b9b3026199ff8ffb2ba833dcc1a
|
|
7
|
+
data.tar.gz: e89a1f593774fc1722bfc8a9229385682e33a10df705ba78da3c67f904ab566c770b7aebdebc32069ed7f4dfc2781ad52165f08f10cf3fd4e4d723c41687e02f
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.15.1] - 2026-08-30
|
|
4
|
+
|
|
5
|
+
- Prevent OS scheduling delays from stopping adaptive thread pool growth
|
|
6
|
+
|
|
7
|
+
## [0.15.0] - 2026-08-29
|
|
8
|
+
|
|
9
|
+
- Add adaptive scaling to `AtomicThreadPool` with `max_size:`
|
|
10
|
+
|
|
3
11
|
## [0.14.1] - 2026-08-20
|
|
4
12
|
|
|
5
13
|
- 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 CPU time used by its workers, so it does not add more threads when
|
|
126
|
+
Ruby execution 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.1
|
|
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.1
|
|
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.1
|
|
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.1
|
|
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.1
|
|
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.079561 seconds
|
|
594
|
+
Atomic Ruby Atomic Thread Pool: 4.99168 seconds
|
|
583
595
|
```
|
|
584
596
|
|
|
585
597
|
</details>
|
|
@@ -521,6 +521,197 @@ 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 unsigned long long running_cpu_time;
|
|
539
|
+
} atomic_ruby_thread_pool_monitor_t;
|
|
540
|
+
|
|
541
|
+
typedef struct {
|
|
542
|
+
atomic_ruby_thread_pool_monitor_t *monitor;
|
|
543
|
+
atomic_ruby_thread_pool_worker_phase_t phase;
|
|
544
|
+
unsigned long long phase_started_at;
|
|
545
|
+
unsigned long long running_cpu_started_at;
|
|
546
|
+
} atomic_ruby_thread_pool_worker_state_t;
|
|
547
|
+
|
|
548
|
+
static rb_internal_thread_specific_key_t atomic_ruby_thread_pool_worker_key;
|
|
549
|
+
|
|
550
|
+
static void atomic_ruby_thread_pool_monitor_free(void *ptr) {
|
|
551
|
+
xfree(ptr);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
static size_t atomic_ruby_thread_pool_monitor_memsize(const void *ptr) {
|
|
555
|
+
return sizeof(atomic_ruby_thread_pool_monitor_t);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
static const rb_data_type_t atomic_ruby_thread_pool_monitor_type = {
|
|
559
|
+
.wrap_struct_name = "AtomicRuby::ThreadPoolMonitor",
|
|
560
|
+
.function = {
|
|
561
|
+
.dfree = atomic_ruby_thread_pool_monitor_free,
|
|
562
|
+
.dsize = atomic_ruby_thread_pool_monitor_memsize
|
|
563
|
+
},
|
|
564
|
+
.flags = RUBY_TYPED_FREE_IMMEDIATELY | RUBY_TYPED_WB_PROTECTED
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
static unsigned long long atomic_ruby_monotonic_time(void) {
|
|
568
|
+
struct timespec time;
|
|
569
|
+
if (clock_gettime(CLOCK_MONOTONIC, &time) == -1) return 0;
|
|
570
|
+
|
|
571
|
+
return (unsigned long long)time.tv_sec * 1000000000ULL + (unsigned long long)time.tv_nsec;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
static unsigned long long atomic_ruby_thread_cpu_time(void) {
|
|
575
|
+
struct timespec time;
|
|
576
|
+
if (clock_gettime(CLOCK_THREAD_CPUTIME_ID, &time) == -1) return 0;
|
|
577
|
+
|
|
578
|
+
return (unsigned long long)time.tv_sec * 1000000000ULL + (unsigned long long)time.tv_nsec;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
static void atomic_ruby_thread_pool_worker_leave_phase(atomic_ruby_thread_pool_worker_state_t *state, unsigned long long now) {
|
|
582
|
+
unsigned long long elapsed = now != 0 && state->phase_started_at != 0 && now >= state->phase_started_at ? now - state->phase_started_at : 0;
|
|
583
|
+
|
|
584
|
+
switch (state->phase) {
|
|
585
|
+
case ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING: {
|
|
586
|
+
unsigned long long cpu_now = atomic_ruby_thread_cpu_time();
|
|
587
|
+
unsigned long long cpu_elapsed = cpu_now != 0 && state->running_cpu_started_at != 0 && cpu_now >= state->running_cpu_started_at
|
|
588
|
+
? cpu_now - state->running_cpu_started_at
|
|
589
|
+
: 0;
|
|
590
|
+
atomic_fetch_sub_explicit(&state->monitor->running_count, 1, memory_order_relaxed);
|
|
591
|
+
atomic_fetch_add_explicit(&state->monitor->running_time, elapsed, memory_order_relaxed);
|
|
592
|
+
atomic_fetch_add_explicit(&state->monitor->running_cpu_time, cpu_elapsed, memory_order_relaxed);
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
case ATOMIC_RUBY_THREAD_POOL_WORKER_WAITING:
|
|
596
|
+
atomic_fetch_sub_explicit(&state->monitor->waiting_count, 1, memory_order_relaxed);
|
|
597
|
+
atomic_fetch_add_explicit(&state->monitor->waiting_time, elapsed, memory_order_relaxed);
|
|
598
|
+
break;
|
|
599
|
+
case ATOMIC_RUBY_THREAD_POOL_WORKER_BLOCKED:
|
|
600
|
+
atomic_fetch_sub_explicit(&state->monitor->blocked_count, 1, memory_order_relaxed);
|
|
601
|
+
atomic_fetch_add_explicit(&state->monitor->blocked_time, elapsed, memory_order_relaxed);
|
|
602
|
+
break;
|
|
603
|
+
case ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE:
|
|
604
|
+
break;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
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) {
|
|
609
|
+
state->phase = phase;
|
|
610
|
+
state->phase_started_at = now;
|
|
611
|
+
|
|
612
|
+
switch (phase) {
|
|
613
|
+
case ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING:
|
|
614
|
+
atomic_fetch_add_explicit(&state->monitor->running_count, 1, memory_order_relaxed);
|
|
615
|
+
state->running_cpu_started_at = atomic_ruby_thread_cpu_time();
|
|
616
|
+
break;
|
|
617
|
+
case ATOMIC_RUBY_THREAD_POOL_WORKER_WAITING:
|
|
618
|
+
atomic_fetch_add_explicit(&state->monitor->waiting_count, 1, memory_order_relaxed);
|
|
619
|
+
break;
|
|
620
|
+
case ATOMIC_RUBY_THREAD_POOL_WORKER_BLOCKED:
|
|
621
|
+
atomic_fetch_add_explicit(&state->monitor->blocked_count, 1, memory_order_relaxed);
|
|
622
|
+
break;
|
|
623
|
+
case ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE:
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
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) {
|
|
629
|
+
atomic_ruby_thread_pool_worker_state_t *state = rb_internal_thread_specific_get(event_data->thread, atomic_ruby_thread_pool_worker_key);
|
|
630
|
+
if (state == NULL || state->phase == ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE) return;
|
|
631
|
+
|
|
632
|
+
unsigned long long now = atomic_ruby_monotonic_time();
|
|
633
|
+
atomic_ruby_thread_pool_worker_leave_phase(state, now);
|
|
634
|
+
|
|
635
|
+
switch (event) {
|
|
636
|
+
case RUBY_INTERNAL_THREAD_EVENT_READY:
|
|
637
|
+
atomic_ruby_thread_pool_worker_enter_phase(state, ATOMIC_RUBY_THREAD_POOL_WORKER_WAITING, now);
|
|
638
|
+
break;
|
|
639
|
+
case RUBY_INTERNAL_THREAD_EVENT_RESUMED:
|
|
640
|
+
atomic_ruby_thread_pool_worker_enter_phase(state, ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING, now);
|
|
641
|
+
break;
|
|
642
|
+
case RUBY_INTERNAL_THREAD_EVENT_SUSPENDED:
|
|
643
|
+
atomic_ruby_thread_pool_worker_enter_phase(state, ATOMIC_RUBY_THREAD_POOL_WORKER_BLOCKED, now);
|
|
644
|
+
break;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
static VALUE rb_cThreadPoolMonitor_allocate(VALUE klass) {
|
|
649
|
+
atomic_ruby_thread_pool_monitor_t *monitor;
|
|
650
|
+
VALUE obj = TypedData_Make_Struct(klass, atomic_ruby_thread_pool_monitor_t, &atomic_ruby_thread_pool_monitor_type, monitor);
|
|
651
|
+
atomic_init(&monitor->running_count, 0);
|
|
652
|
+
atomic_init(&monitor->waiting_count, 0);
|
|
653
|
+
atomic_init(&monitor->blocked_count, 0);
|
|
654
|
+
atomic_init(&monitor->running_time, 0);
|
|
655
|
+
atomic_init(&monitor->waiting_time, 0);
|
|
656
|
+
atomic_init(&monitor->blocked_time, 0);
|
|
657
|
+
atomic_init(&monitor->running_cpu_time, 0);
|
|
658
|
+
return obj;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
static VALUE rb_cThreadPoolMonitor_register_worker(VALUE self) {
|
|
662
|
+
atomic_ruby_thread_pool_monitor_t *monitor;
|
|
663
|
+
TypedData_Get_Struct(self, atomic_ruby_thread_pool_monitor_t, &atomic_ruby_thread_pool_monitor_type, monitor);
|
|
664
|
+
|
|
665
|
+
VALUE thread = rb_thread_current();
|
|
666
|
+
atomic_ruby_thread_pool_worker_state_t *state = ALLOC(atomic_ruby_thread_pool_worker_state_t);
|
|
667
|
+
state->monitor = monitor;
|
|
668
|
+
state->phase = ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE;
|
|
669
|
+
state->phase_started_at = 0;
|
|
670
|
+
state->running_cpu_started_at = 0;
|
|
671
|
+
rb_internal_thread_specific_set(thread, atomic_ruby_thread_pool_worker_key, state);
|
|
672
|
+
return Qnil;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
static VALUE rb_cThreadPoolMonitor_unregister_worker(VALUE self) {
|
|
676
|
+
VALUE thread = rb_thread_current();
|
|
677
|
+
atomic_ruby_thread_pool_worker_state_t *state = rb_internal_thread_specific_get(thread, atomic_ruby_thread_pool_worker_key);
|
|
678
|
+
if (state == NULL) return Qnil;
|
|
679
|
+
|
|
680
|
+
atomic_ruby_thread_pool_worker_leave_phase(state, atomic_ruby_monotonic_time());
|
|
681
|
+
rb_internal_thread_specific_set(thread, atomic_ruby_thread_pool_worker_key, NULL);
|
|
682
|
+
xfree(state);
|
|
683
|
+
return Qnil;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
static VALUE rb_cThreadPoolMonitor_start_work(VALUE self) {
|
|
687
|
+
atomic_ruby_thread_pool_worker_state_t *state = rb_internal_thread_specific_get(rb_thread_current(), atomic_ruby_thread_pool_worker_key);
|
|
688
|
+
atomic_ruby_thread_pool_worker_enter_phase(state, ATOMIC_RUBY_THREAD_POOL_WORKER_RUNNING, atomic_ruby_monotonic_time());
|
|
689
|
+
return Qnil;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
static VALUE rb_cThreadPoolMonitor_stop_work(VALUE self) {
|
|
693
|
+
atomic_ruby_thread_pool_worker_state_t *state = rb_internal_thread_specific_get(rb_thread_current(), atomic_ruby_thread_pool_worker_key);
|
|
694
|
+
atomic_ruby_thread_pool_worker_leave_phase(state, atomic_ruby_monotonic_time());
|
|
695
|
+
state->phase = ATOMIC_RUBY_THREAD_POOL_WORKER_INACTIVE;
|
|
696
|
+
return Qnil;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
static VALUE rb_cThreadPoolMonitor_snapshot(VALUE self) {
|
|
700
|
+
atomic_ruby_thread_pool_monitor_t *monitor;
|
|
701
|
+
TypedData_Get_Struct(self, atomic_ruby_thread_pool_monitor_t, &atomic_ruby_thread_pool_monitor_type, monitor);
|
|
702
|
+
|
|
703
|
+
return rb_ary_new_from_args(
|
|
704
|
+
7,
|
|
705
|
+
UINT2NUM(atomic_load_explicit(&monitor->running_count, memory_order_relaxed)),
|
|
706
|
+
UINT2NUM(atomic_load_explicit(&monitor->waiting_count, memory_order_relaxed)),
|
|
707
|
+
UINT2NUM(atomic_load_explicit(&monitor->blocked_count, memory_order_relaxed)),
|
|
708
|
+
ULL2NUM(atomic_load_explicit(&monitor->running_time, memory_order_relaxed)),
|
|
709
|
+
ULL2NUM(atomic_load_explicit(&monitor->waiting_time, memory_order_relaxed)),
|
|
710
|
+
ULL2NUM(atomic_load_explicit(&monitor->blocked_time, memory_order_relaxed)),
|
|
711
|
+
ULL2NUM(atomic_load_explicit(&monitor->running_cpu_time, memory_order_relaxed))
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
524
715
|
RUBY_FUNC_EXPORTED void Init_atomic_ruby(void) {
|
|
525
716
|
#ifdef ATOMIC_RUBY_RACTOR_SAFE
|
|
526
717
|
rb_ext_ractor_safe(true);
|
|
@@ -563,4 +754,21 @@ RUBY_FUNC_EXPORTED void Init_atomic_ruby(void) {
|
|
|
563
754
|
rb_define_private_method(rb_cAtomicConditionVariable, "_shift_thread", rb_cAtomicConditionVariable_shift_thread, 0);
|
|
564
755
|
rb_define_private_method(rb_cAtomicConditionVariable, "_drain_threads", rb_cAtomicConditionVariable_drain_threads, 0);
|
|
565
756
|
rb_define_private_method(rb_cAtomicConditionVariable, "_waiter_count", rb_cAtomicConditionVariable_waiter_count, 0);
|
|
757
|
+
|
|
758
|
+
atomic_ruby_thread_pool_worker_key = rb_internal_thread_specific_key_create();
|
|
759
|
+
rb_internal_thread_add_event_hook(
|
|
760
|
+
atomic_ruby_thread_pool_event_callback,
|
|
761
|
+
RUBY_INTERNAL_THREAD_EVENT_READY |
|
|
762
|
+
RUBY_INTERNAL_THREAD_EVENT_RESUMED |
|
|
763
|
+
RUBY_INTERNAL_THREAD_EVENT_SUSPENDED,
|
|
764
|
+
NULL
|
|
765
|
+
);
|
|
766
|
+
VALUE rb_cThreadPoolMonitor = rb_define_class_under(rb_mAtomicRuby, "ThreadPoolMonitor", rb_cObject);
|
|
767
|
+
rb_define_alloc_func(rb_cThreadPoolMonitor, rb_cThreadPoolMonitor_allocate);
|
|
768
|
+
rb_define_method(rb_cThreadPoolMonitor, "register_worker", rb_cThreadPoolMonitor_register_worker, 0);
|
|
769
|
+
rb_define_method(rb_cThreadPoolMonitor, "unregister_worker", rb_cThreadPoolMonitor_unregister_worker, 0);
|
|
770
|
+
rb_define_method(rb_cThreadPoolMonitor, "start_work", rb_cThreadPoolMonitor_start_work, 0);
|
|
771
|
+
rb_define_method(rb_cThreadPoolMonitor, "stop_work", rb_cThreadPoolMonitor_stop_work, 0);
|
|
772
|
+
rb_define_method(rb_cThreadPoolMonitor, "snapshot", rb_cThreadPoolMonitor_snapshot, 0);
|
|
773
|
+
rb_funcall(rb_mAtomicRuby, rb_intern("private_constant"), 1, ID2SYM(rb_intern("ThreadPoolMonitor")));
|
|
566
774
|
}
|
|
@@ -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,26 @@ 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.
|
|
56
|
-
#
|
|
57
|
-
#
|
|
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 Ruby execution is using the
|
|
73
|
+
# available CPU.
|
|
74
|
+
# Temporary workers leave after the queue remains empty, returning the pool
|
|
75
|
+
# to its baseline size. Omitting `max_size` creates a fixed-size pool.
|
|
76
|
+
#
|
|
77
|
+
# @param size [Integer] The baseline number of worker threads (must be positive)
|
|
78
|
+
# @param max_size [Integer, Float, nil] Maximum number of worker threads,
|
|
79
|
+
# `Float::INFINITY` for no limit, or nil for a fixed-size pool
|
|
58
80
|
# @param name [String, nil] Optional name for the thread pool (used in thread names)
|
|
59
81
|
# @param on_error [Proc, nil] Optional error handler called with the exception when
|
|
60
82
|
# a work item raises. Receives the exception as its argument. When nil, errors
|
|
61
83
|
# are printed to stderr
|
|
62
84
|
#
|
|
63
85
|
# @raise [ArgumentError] if size is not a positive integer
|
|
86
|
+
# @raise [ArgumentError] if max_size is not an integer greater than or equal
|
|
87
|
+
# to size or `Float::INFINITY`
|
|
64
88
|
# @raise [ArgumentError] if name is provided but not a string
|
|
65
89
|
# @raise [ArgumentError] if on_error is provided but not a Proc
|
|
66
90
|
#
|
|
@@ -70,17 +94,23 @@ module AtomicRuby
|
|
|
70
94
|
# @example Create a named pool
|
|
71
95
|
# pool = AtomicThreadPool.new(size: 2, name: "Database Workers")
|
|
72
96
|
#
|
|
97
|
+
# @example Create an adaptive pool
|
|
98
|
+
# pool = AtomicThreadPool.new(size: 2, max_size: 8)
|
|
99
|
+
#
|
|
73
100
|
# @example Create a pool with a custom error handler
|
|
74
101
|
# errors = []
|
|
75
102
|
# pool = AtomicThreadPool.new(size: 2, on_error: ->(err) { errors << err })
|
|
76
103
|
#
|
|
77
|
-
# @rbs (size: Integer, ?name: String?, ?on_error: Proc?) -> void
|
|
78
|
-
def initialize(size:, name: nil, on_error: nil)
|
|
104
|
+
# @rbs (size: Integer, ?max_size: (Integer | Float)?, ?name: String?, ?on_error: Proc?) -> void
|
|
105
|
+
def initialize(size:, max_size: nil, name: nil, on_error: nil)
|
|
79
106
|
raise ArgumentError, "size must be a positive Integer" unless size.is_a?(Integer) && size > 0
|
|
107
|
+
valid_max_size = max_size.nil? || max_size == Float::INFINITY || (max_size.is_a?(Integer) && max_size >= size)
|
|
108
|
+
raise ArgumentError, "max_size must be an Integer greater than or equal to size or Float::INFINITY" unless valid_max_size
|
|
80
109
|
raise ArgumentError, "name must be a String" unless name.nil? || name.is_a?(String)
|
|
81
110
|
raise ArgumentError, "on_error must be a Proc" unless on_error.nil? || on_error.is_a?(Proc)
|
|
82
111
|
|
|
83
112
|
@size = size
|
|
113
|
+
@max_size = max_size || size
|
|
84
114
|
@name = name
|
|
85
115
|
@on_error = on_error
|
|
86
116
|
|
|
@@ -90,6 +120,12 @@ module AtomicRuby
|
|
|
90
120
|
@alive_thread_count = Atom.new(0)
|
|
91
121
|
@active_thread_count = Atom.new(0)
|
|
92
122
|
@threads = []
|
|
123
|
+
@next_thread_number = 0
|
|
124
|
+
if adaptive?
|
|
125
|
+
@autoscale_available = AtomicConditionVariable.new
|
|
126
|
+
@trim_requested = AtomicBoolean.new(false)
|
|
127
|
+
@thread_pool_monitor = ThreadPoolMonitor.new
|
|
128
|
+
end
|
|
93
129
|
|
|
94
130
|
start
|
|
95
131
|
end
|
|
@@ -120,15 +156,17 @@ module AtomicRuby
|
|
|
120
156
|
def <<(work)
|
|
121
157
|
raise EnqueuedWorkAfterShutdownError if @shutdown.true?
|
|
122
158
|
|
|
159
|
+
@trim_requested&.make_false
|
|
123
160
|
@queue.push(work)
|
|
124
161
|
@work_available.signal
|
|
162
|
+
@autoscale_available&.signal
|
|
125
163
|
end
|
|
126
164
|
|
|
127
165
|
# Returns the number of currently alive worker threads.
|
|
128
166
|
#
|
|
129
167
|
# This count decreases as the pool shuts down and threads terminate.
|
|
130
|
-
#
|
|
131
|
-
# passed to the constructor.
|
|
168
|
+
# An adaptive pool may report a value between the `size` and `max_size`
|
|
169
|
+
# parameters passed to the constructor.
|
|
132
170
|
#
|
|
133
171
|
# @return [Integer] The number of alive worker threads
|
|
134
172
|
#
|
|
@@ -222,7 +260,9 @@ module AtomicRuby
|
|
|
222
260
|
return if @shutdown.true?
|
|
223
261
|
|
|
224
262
|
@shutdown.make_true
|
|
263
|
+
@autoscale_available&.broadcast
|
|
225
264
|
@work_available.broadcast
|
|
265
|
+
@autoscaler&.join
|
|
226
266
|
@threads.each(&:join)
|
|
227
267
|
end
|
|
228
268
|
|
|
@@ -238,49 +278,189 @@ module AtomicRuby
|
|
|
238
278
|
#
|
|
239
279
|
# @rbs () -> void
|
|
240
280
|
def start
|
|
241
|
-
@size.times
|
|
242
|
-
|
|
243
|
-
|
|
281
|
+
@size.times { spawn_worker }
|
|
282
|
+
|
|
283
|
+
if adaptive?
|
|
284
|
+
@autoscaler = Thread.new do
|
|
285
|
+
thread_name = String.new("AtomicThreadPool autoscaler")
|
|
244
286
|
thread_name << " for #{@name}" if @name
|
|
245
287
|
Thread.current.name = thread_name
|
|
288
|
+
autoscale
|
|
289
|
+
end
|
|
290
|
+
end
|
|
246
291
|
|
|
247
|
-
|
|
292
|
+
Thread.pass until @alive_thread_count.value == @size
|
|
293
|
+
end
|
|
248
294
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
295
|
+
# Returns whether the pool may grow beyond its baseline size.
|
|
296
|
+
#
|
|
297
|
+
# @return [true, false]
|
|
298
|
+
#
|
|
299
|
+
# @rbs () -> bool
|
|
300
|
+
def adaptive?
|
|
301
|
+
@max_size > @size
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
# Creates a worker thread.
|
|
305
|
+
#
|
|
306
|
+
# Temporary workers leave after the queue remains empty.
|
|
307
|
+
#
|
|
308
|
+
# @param temporary [true, false] whether the worker belongs above the baseline
|
|
309
|
+
# @return [Thread]
|
|
310
|
+
#
|
|
311
|
+
# @rbs (?temporary: bool) -> Thread
|
|
312
|
+
def spawn_worker(temporary: false)
|
|
313
|
+
thread_number = @next_thread_number
|
|
314
|
+
@next_thread_number += 1
|
|
315
|
+
|
|
316
|
+
thread = Thread.new(thread_number) do |idx|
|
|
317
|
+
thread_name = String.new("AtomicThreadPool thread #{idx}")
|
|
318
|
+
thread_name << " for #{@name}" if @name
|
|
319
|
+
Thread.current.name = thread_name
|
|
253
320
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
321
|
+
@thread_pool_monitor&.register_worker
|
|
322
|
+
@alive_thread_count.swap { |current_count| current_count + 1 }
|
|
323
|
+
|
|
324
|
+
begin
|
|
325
|
+
loop do
|
|
326
|
+
work = nil
|
|
327
|
+
should_exit = false
|
|
328
|
+
|
|
329
|
+
@work_available.wait do
|
|
330
|
+
work = @queue.pop
|
|
331
|
+
unless work
|
|
332
|
+
should_exit = @shutdown.true? && @queue.empty?
|
|
333
|
+
should_exit ||= temporary && @trim_requested.true? && @queue.empty?
|
|
258
334
|
end
|
|
335
|
+
work || should_exit
|
|
336
|
+
end
|
|
259
337
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
ensure
|
|
273
|
-
@active_thread_count.swap { |current_count| current_count - 1 }
|
|
338
|
+
break if should_exit
|
|
339
|
+
|
|
340
|
+
@active_thread_count.swap { |current_count| current_count + 1 }
|
|
341
|
+
begin
|
|
342
|
+
@thread_pool_monitor&.start_work
|
|
343
|
+
work.call
|
|
344
|
+
rescue => err
|
|
345
|
+
if @on_error
|
|
346
|
+
@on_error.call(err)
|
|
347
|
+
else
|
|
348
|
+
warn "#{thread_name} rescued:"
|
|
349
|
+
warn err.full_message
|
|
274
350
|
end
|
|
351
|
+
ensure
|
|
352
|
+
@thread_pool_monitor&.stop_work
|
|
353
|
+
@active_thread_count.swap { |current_count| current_count - 1 }
|
|
275
354
|
end
|
|
276
|
-
ensure
|
|
277
|
-
@alive_thread_count.swap { |current_count| current_count - 1 }
|
|
278
355
|
end
|
|
356
|
+
ensure
|
|
357
|
+
@alive_thread_count.swap { |current_count| current_count - 1 }
|
|
358
|
+
@thread_pool_monitor&.unregister_worker
|
|
279
359
|
end
|
|
280
360
|
end
|
|
281
|
-
@threads.freeze
|
|
282
361
|
|
|
283
|
-
|
|
362
|
+
@threads << thread
|
|
363
|
+
thread
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# Adds temporary workers while queued work is held up by blocked workers.
|
|
367
|
+
#
|
|
368
|
+
# @return [void]
|
|
369
|
+
#
|
|
370
|
+
# @rbs () -> void
|
|
371
|
+
def autoscale
|
|
372
|
+
previous_snapshot = @thread_pool_monitor.snapshot
|
|
373
|
+
pressure_started_at = nil
|
|
374
|
+
idle_started_at = nil
|
|
375
|
+
growth_samples = 0
|
|
376
|
+
phase_samples = [0, 0, 0]
|
|
377
|
+
|
|
378
|
+
loop do
|
|
379
|
+
@autoscale_available.wait do
|
|
380
|
+
@shutdown.true? || !@queue.empty? || @alive_thread_count.value > @size
|
|
381
|
+
end
|
|
382
|
+
break if @shutdown.true?
|
|
383
|
+
|
|
384
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
385
|
+
snapshot = @thread_pool_monitor.snapshot
|
|
386
|
+
|
|
387
|
+
if @queue.empty?
|
|
388
|
+
pressure_started_at = nil
|
|
389
|
+
previous_snapshot = snapshot
|
|
390
|
+
idle_started_at ||= now
|
|
391
|
+
growth_samples = 0
|
|
392
|
+
phase_samples.fill(0)
|
|
393
|
+
|
|
394
|
+
if now - idle_started_at >= AUTOSCALE_IDLE_TIME
|
|
395
|
+
@trim_requested.make_true
|
|
396
|
+
@work_available.broadcast
|
|
397
|
+
end
|
|
398
|
+
else
|
|
399
|
+
@trim_requested.make_false
|
|
400
|
+
idle_started_at = nil
|
|
401
|
+
|
|
402
|
+
if pressure_started_at.nil?
|
|
403
|
+
previous_snapshot = snapshot
|
|
404
|
+
pressure_started_at = now
|
|
405
|
+
phase_samples.fill(0)
|
|
406
|
+
elsif now - pressure_started_at >= AUTOSCALE_WINDOW
|
|
407
|
+
if should_grow?(previous_snapshot, snapshot, phase_samples, now - pressure_started_at)
|
|
408
|
+
growth_samples += 1
|
|
409
|
+
if growth_samples >= AUTOSCALE_GROWTH_SAMPLES
|
|
410
|
+
spawn_worker(temporary: true)
|
|
411
|
+
growth_samples = 0
|
|
412
|
+
end
|
|
413
|
+
else
|
|
414
|
+
growth_samples = 0
|
|
415
|
+
end
|
|
416
|
+
previous_snapshot = snapshot
|
|
417
|
+
pressure_started_at = now
|
|
418
|
+
phase_samples.fill(0)
|
|
419
|
+
else
|
|
420
|
+
3.times { |index| phase_samples[index] += snapshot[index] }
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
sleep AUTOSCALE_INTERVAL
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Returns whether another worker is likely to improve throughput.
|
|
429
|
+
#
|
|
430
|
+
# Requiring workers to spend a majority of their time blocked outside the
|
|
431
|
+
# GVL recognizes blocking operations. The pool only grows while its workers
|
|
432
|
+
# use less than half of one CPU, avoiding extra threads when Ruby
|
|
433
|
+
# execution is the bottleneck without mistaking OS scheduling delays for
|
|
434
|
+
# GVL contention.
|
|
435
|
+
#
|
|
436
|
+
# @param previous_snapshot [Array<Integer>] previous GVL state snapshot
|
|
437
|
+
# @param snapshot [Array<Integer>] current GVL state snapshot
|
|
438
|
+
# @param phase_samples [Array<Integer>] sampled current GVL states
|
|
439
|
+
# @param elapsed_time [Float] seconds covered by the snapshots
|
|
440
|
+
# @return [true, false]
|
|
441
|
+
#
|
|
442
|
+
# @rbs (Array[Integer] previous_snapshot, Array[Integer] snapshot, Array[Integer] phase_samples, Float elapsed_time) -> bool
|
|
443
|
+
def should_grow?(previous_snapshot, snapshot, phase_samples, elapsed_time)
|
|
444
|
+
@threads.select!(&:alive?)
|
|
445
|
+
workers = @threads
|
|
446
|
+
return false if workers.length >= @max_size
|
|
447
|
+
return false if @active_thread_count.value < workers.length
|
|
448
|
+
|
|
449
|
+
running_time = snapshot[3] - previous_snapshot[3]
|
|
450
|
+
waiting_time = snapshot[4] - previous_snapshot[4]
|
|
451
|
+
blocked_time = snapshot[5] - previous_snapshot[5]
|
|
452
|
+
running_cpu_time = snapshot[6] - previous_snapshot[6]
|
|
453
|
+
total_time = running_time + waiting_time + blocked_time
|
|
454
|
+
|
|
455
|
+
minimum_measured_time = (AUTOSCALE_WINDOW * 1_000_000_000 * workers.length / 2).to_i
|
|
456
|
+
if total_time < minimum_measured_time
|
|
457
|
+
running_time, waiting_time, blocked_time = phase_samples
|
|
458
|
+
total_time = running_time + waiting_time + blocked_time
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
total_time.positive? &&
|
|
462
|
+
blocked_time * 2 > total_time &&
|
|
463
|
+
running_cpu_time * 2 < elapsed_time * 1_000_000_000
|
|
284
464
|
end
|
|
285
465
|
end
|
|
286
466
|
end
|
data/lib/atomic-ruby/version.rb
CHANGED