farce 0.2.0 → 0.3.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.
@@ -4,13 +4,13 @@
4
4
 
5
5
  module Farce
6
6
  module Internal # :nodoc: all
7
- class MainThreadScheduler < ThreadScheduler
7
+ class MainThreadExecutor < ThreadExecutor
8
8
  # Enter the main emulated Ractor without starting another thread.
9
9
  def execute(...) = FakeRactor.with_current(Ractor.main) { super }
10
10
  def local?(wait: true) = Ractor.main? # rubocop:disable Lint/UnusedMethodArgument
11
11
  end
12
12
 
13
- MainScheduler = MainThreadScheduler.new do |*args, &block|
13
+ MainScheduler = MainThreadExecutor.new do |*args, &block|
14
14
  Thread.new do
15
15
  ThreadGroup::Default.add(Thread.current)
16
16
  block.call(*args)
@@ -4,6 +4,6 @@
4
4
 
5
5
  module Farce
6
6
  module Internal # :nodoc: all
7
- ParallelScheduler = ThreadScheduler.new(&Ractor.method(:new))
7
+ ParallelScheduler = ThreadExecutor.new(&Ractor.method(:new))
8
8
  end
9
9
  end
@@ -7,7 +7,7 @@ module Farce
7
7
  class ProxyOwner
8
8
  include ProxyOwnerNotifications
9
9
 
10
- class OwnerScheduler < Farce::ThreadScheduler
10
+ class OwnerScheduler < Farce::ThreadExecutor
11
11
  def initialize
12
12
  @threads = Thread::Queue.new
13
13
  super
@@ -17,7 +17,7 @@ module Farce
17
17
  @value = value
18
18
  @compare_by_identity = compare_by_identity
19
19
  @mutex = Mutex.new
20
- @signal = ConditionVariable.new
20
+ @signal = Signal.new
21
21
  @updating = false
22
22
  @updating_fiber = nil
23
23
  @updating_thread = nil
@@ -224,13 +224,14 @@ module Farce
224
224
  end
225
225
 
226
226
  def wait_for_signal(deadline)
227
- return @signal.wait(@mutex) unless deadline
228
-
229
- remaining = deadline - Clock.now
230
- return false unless remaining.positive?
231
-
232
- @signal.wait(@mutex, remaining)
233
- true
227
+ # Snapshot while holding the state lock so a change during registration is not missed.
228
+ generation = @signal.generation
229
+ begin
230
+ @mutex.unlock
231
+ ReservationWaiting.wait(@signal, generation, deadline)
232
+ ensure
233
+ @mutex.lock unless @mutex.owned?
234
+ end
234
235
  end
235
236
 
236
237
  def reject_update_wait!
@@ -13,6 +13,15 @@ module Farce
13
13
  module Internal # :nodoc: all
14
14
  include Autoloads["#{__dir__}/shared"]
15
15
 
16
+ # Keep thread-only scheduler workers and their tasks in their emulated Ractor.
17
+ def with_scheduler_thread_group
18
+ group = ThreadGroup.new
19
+ FakeRactor::GROUPS[group] = Ractor.current
20
+ yield group
21
+ ensure
22
+ FakeRactor::GROUPS.delete(group)
23
+ end
24
+
16
25
  # @api private
17
26
  # The shim's shareable? can delegate to a lazy or weak value and change its state.
18
27
  # Snapshot metadata uses only structural properties on engines without Ractors.
@@ -5,7 +5,7 @@
5
5
  module Farce
6
6
  # @!visibility private
7
7
  module Internal # :nodoc: all
8
- # Coordinates lazy Lease construction without running user code under a storage lock.
8
+ # Coordinates lazy construction without running user code under a storage lock.
9
9
  class LeaseInitialization
10
10
  def initialize
11
11
  @mutex = Mutex.new
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+ # shareable_constant_value: literal
3
+ # warn_indent: true
4
+
5
+ module Farce
6
+ module Internal # :nodoc: all
7
+ # The local scheduling and execution interface for Ractor-local executors.
8
+ module LocalExecutorMethods
9
+ LOCAL_MODES = %i[local smart smart_raise smart_move smart_copy].freeze
10
+ private_constant :LOCAL_MODES
11
+
12
+ # @param wait [Boolean] Accepted for compatibility with {Abstract::Executor#local?}.
13
+ # @return [true] Workers run in the calling Ractor.
14
+ def local?(wait: true) = true # rubocop:disable Lint/UnusedMethodArgument
15
+
16
+ # Run a task directly, preserving its block, arguments, and calling fiber.
17
+ # `mode` and `auto_local` follow the same validation as {#schedule}.
18
+ # @param (see Abstract::Executor#execute)
19
+ # @return [self]
20
+ def execute(*, mode: :smart, auto_local: true, &)
21
+ validate_local_mode(mode, auto_local)
22
+ super(*, mode: :local, auto_local: true, &)
23
+ end
24
+
25
+ private
26
+
27
+ def validate_local_mode(mode, auto_local)
28
+ return if auto_local || LOCAL_MODES.include?(mode)
29
+ raise ArgumentError, "only local or smart modes are supported"
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+ # shareable_constant_value: literal
3
+ # warn_indent: true
4
+
5
+ module Farce
6
+ module Internal # :nodoc: all
7
+ # A Ractor-local task that retains its block and argument references.
8
+ class LocalScheduledTask
9
+ include Noncopyable
10
+ include Farce::Unshareable
11
+
12
+ def initialize(args, block)
13
+ @args = args
14
+ @block = block
15
+ super()
16
+ end
17
+
18
+ def to_proc(completion = nil)
19
+ lambda do
20
+ @block.call(*@args)
21
+ ensure
22
+ completion&.task_finished
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -12,7 +12,7 @@ module Farce
12
12
  MANAGER = ModeManager.new
13
13
 
14
14
  def initialize(args, block, mode)
15
- @block = Ractor.shareable_proc(&block)
15
+ @block = Ractor.shareable?(block) ? block : Ractor.shareable_proc(&block)
16
16
  @unwrap = false
17
17
  @args = args.map! do |arg|
18
18
  wrapped = MANAGER.wrap(arg, mode:)
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+ # shareable_constant_value: literal
3
+ # warn_indent: true
4
+
5
+ module Farce
6
+ module Internal # :nodoc: all
7
+ # A Ractor-local service for delayed pool growth and executor shutdown requests.
8
+ class ThreadPoolSupervisor
9
+ include Farce::Unshareable
10
+
11
+ def self.current = Storage.store_if_absent(self) { new }
12
+
13
+ def initialize
14
+ @queue = TimerQueue.new(mode: :raise)
15
+ @closed = Atom.new(false)
16
+ @worker = Thread.new do
17
+ loop do
18
+ entry = @queue.pop
19
+ break if entry == :stop
20
+ pool, token = entry
21
+ pool = pool.value
22
+ token == :close ? pool.close : pool.scale_if_needed(token)
23
+ end
24
+ end
25
+ super
26
+ end
27
+
28
+ def close_handle(pool) = [@queue, Envelope::Local.new(pool)].freeze
29
+
30
+ def schedule(pool, token, delay)
31
+ raise ::Farce::ClosedQueueError, "pool supervisor is closed" if @closed.value
32
+ @queue.push([Envelope::Local.new(pool), token].freeze, delay:)
33
+ end
34
+
35
+ def close
36
+ return unless @closed.compare_and_set(false, true)
37
+ @queue.push(:stop, clock: 0)
38
+ @worker.join
39
+ @queue.close
40
+ end
41
+ end
42
+
43
+ at_exit { Storage[ThreadPoolSupervisor]&.close }
44
+ end
45
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+ # shareable_constant_value: literal
3
+ # warn_indent: true
4
+
5
+ module Farce
6
+ module Internal # :nodoc: all
7
+ # A worker coordinator pinned to its pool's Ractor.
8
+ class UnsharedPoolWorker < PoolWorker
9
+ include Farce::Unshareable
10
+
11
+ private def publish_shareable = pin_unshareable
12
+ end
13
+ end
14
+ end
@@ -54,6 +54,19 @@ module Farce
54
54
  new_self
55
55
  end
56
56
 
57
+ # @api private
58
+ # @return [Abstract::Executor, Fiber::Scheduler, nil]
59
+ def local_scheduler(create: false)
60
+ if Fiber.respond_to?(:scheduler) && fiber_scheduler = Fiber.scheduler
61
+ return fiber_scheduler
62
+ end
63
+ return Internal::MainScheduler if Ractor.main?
64
+ return Internal::Storage[:local_scheduler] unless create
65
+ Internal::Storage.store_if_absent(:local_scheduler) do
66
+ Internal.native_ractors? ? Scheduler.create(Thread) : ThreadExecutor.new
67
+ end
68
+ end
69
+
57
70
  def timeout_deadline(timeout)
58
71
  return if timeout.nil?
59
72
 
@@ -101,6 +114,7 @@ module Farce
101
114
  def prepare_mutable_numeric(_) = nil
102
115
  def prepare_map_access(_klass, _kind) = nil
103
116
  def finalize_engine = nil
117
+ def with_scheduler_thread_group = yield(ThreadGroup.new)
104
118
 
105
119
  def delegate(from, to, *methods)
106
120
  methods.each do |method|
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+ # shareable_constant_value: literal
3
+ # warn_indent: true
4
+
5
+ module Farce
6
+ module Local
7
+ # A shareable handle with an independent thread pool in each scope.
8
+ #
9
+ # Each pool uses the scaling settings and fiber scheduler configured at
10
+ # construction. Tasks always keep their block and argument references. Only
11
+ # local and smart modes are accepted when `auto_local` is false. A scheduler
12
+ # constructor must be convertible to a shareable proc for use in each Ractor.
13
+ # {#close} drains only the current scope's pool. {#close_all} drains every pool
14
+ # across all scopes and Ractors, and prevents new pools from starting.
15
+ #
16
+ # @example Keep task state within each Ractor
17
+ # pool = Farce::Local::Pool.new(max_size: 2)
18
+ # results = Farce::Unshared::Queue.new
19
+ # pool.schedule { results.push(:done) }
20
+ # results.pop # => :done
21
+ # pool.close_all
22
+ class Pool < Abstract::Pool
23
+ include Shareable::Unfreezable
24
+ include Scoped
25
+ include Internal::LocalExecutorMethods
26
+
27
+ # (see Farce::Abstract::Pool#initialize)
28
+ # @!macro scopes
29
+ # @param scope [Symbol] The scope of the pool. Defaults to `:ractor`.
30
+ def initialize(min_size: 0, max_size: 4, max_inflight: 64,
31
+ grow_after: 0.005, shrink_after: 30, capacity: 1024, scope: :ractor,
32
+ backend: Internal::FROZEN_CONFIG.io_backend, &constructor)
33
+ initialize_configuration(min_size:, max_size:, max_inflight:, grow_after:, shrink_after:, backend:)
34
+ @constructor = prepare_constructor(constructor || Internal::FROZEN_CONFIG.fiber_scheduler_constructor)
35
+ @pools = Strict::Map.new
36
+ @closed_all = Internal::Flag.new(false)
37
+ @initialization_key = Object.new.freeze
38
+ super(scope:, min_size: @min_size, max_size: @max_size, max_inflight: @max_inflight,
39
+ grow_after: @grow_after, shrink_after: @shrink_after, capacity:, backend:)
40
+ end
41
+
42
+ # Stop accepting work in this scope and drain its queued tasks.
43
+ # @return [self]
44
+ def close
45
+ pool = @closed_all.value ? Internal::Storage[self, scope:] : scoped_value
46
+ pool&.close
47
+ self
48
+ end
49
+
50
+ # Stop accepting work in every scope and Ractor and request draining.
51
+ # Future scopes also reject submissions. Does not wait for running tasks.
52
+ # @return [self]
53
+ def close_all
54
+ return self unless @closed_all.compare_and_set(false, true)
55
+ @pools.each_pair do |pool, queue|
56
+ queue.push([pool, :close].freeze, clock: 0)
57
+ rescue ClosedQueueError
58
+ # The owning Ractor may already have shut down its supervisor.
59
+ nil
60
+ end
61
+ close
62
+ end
63
+
64
+ # @return [Integer] Running and starting workers in this scope.
65
+ def size = current_pool&.size || 0
66
+
67
+ # @return [Symbol] The current scope's pool state.
68
+ def state = current_pool&.state || :closed
69
+
70
+ # @return [Exception, nil] The first worker error in this scope.
71
+ def error = current_pool&.error
72
+
73
+ # @return [Boolean] Whether this scope rejects new tasks.
74
+ def closed? = @closed_all.value || super
75
+
76
+ Internal.delegate(self, :scoped_value, :drained?, :admission_generation, :wait_for_admission,
77
+ :admission_changed, :task_taken, :retire_worker, :worker_stopped, :scale_if_needed)
78
+
79
+ private
80
+
81
+ def enqueue_task(non_block, ...)
82
+ raise PoolClosedError, "cannot schedule task on a closed pool" if @closed_all.value
83
+ pool = scoped_value
84
+ submitted = non_block ? pool.try_schedule(...) : pool.schedule(...)
85
+ submitted && self
86
+ end
87
+
88
+ def current_pool
89
+ @closed_all.value ? Internal::Storage[self, scope:] : scoped_value
90
+ end
91
+
92
+ # Worker setup can yield. Initialize once when fibers share a scope.
93
+ def scoped_value
94
+ storage = Internal::Storage.scope(scope)
95
+ pool = storage[self]
96
+ return pool if pool
97
+
98
+ initializers = Internal::Storage.store_if_absent(@initialization_key) { Internal::Storage::ThreadSafe.new }
99
+ initializers.store_if_absent(storage) { Internal::LeaseInitialization.new }.fetch { super }
100
+ end
101
+
102
+ def new_scoped_value(**)
103
+ pool = Unshared::Pool.new(**, &@constructor)
104
+ queue, reference = Internal::ThreadPoolSupervisor.current.close_handle(pool)
105
+ @pools[reference] = queue
106
+ pool.close if @closed_all.value
107
+ pool
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,219 @@
1
+ # frozen_string_literal: true
2
+ # shareable_constant_value: literal
3
+ # warn_indent: true
4
+
5
+ module Farce
6
+ module Local
7
+ # A shareable handle with an independent task scheduler in each scope.
8
+ #
9
+ # {.new} leaves each scheduler unstarted. Launch it with {#launch_thread}, or
10
+ # install it with `Fiber.set_scheduler`. {.create} starts a worker thread in
11
+ # each scope when that scope is first used. The default scope is `:ractor`.
12
+ #
13
+ # Tasks retain their blocks and argument references. {#execute} runs directly
14
+ # in the calling fiber. A fiber scheduler
15
+ # constructor must be convertible to a shareable proc for use in each Ractor.
16
+ # {#close} closes the current scope. {#close_all} closes all scopes and prevents
17
+ # new schedulers from starting. {.current} returns an {Unshared::Scheduler}.
18
+ #
19
+ # @example Start one worker in each Ractor
20
+ # scheduler = Farce::Local::Scheduler.create
21
+ # results = Farce::Unshared::Queue.new
22
+ # scheduler.schedule { results.push(:done) }
23
+ # results.pop # => :done
24
+ # scheduler.close_all
25
+ class Scheduler < Abstract::Scheduler
26
+ include Shareable::Unfreezable
27
+ include Scoped
28
+ include Internal::LocalExecutorMethods
29
+
30
+ # Returns the current thread's unshared scheduler, or nil when none is installed.
31
+ # @param (see Abstract::Scheduler.current)
32
+ # @return [Unshared::Scheduler, nil]
33
+ def self.current(**) = Unshared::Scheduler.current(**)
34
+
35
+ # Starts a worker in this scope and retains the launch settings for other scopes.
36
+ # Additional options and the block are forwarded to {#initialize}.
37
+ # @param executor [Class, nil] Thread or a custom executor that stays in each Ractor.
38
+ # @param name [String, nil] Optional worker name.
39
+ # @param priority [Integer, nil] Optional worker thread priority.
40
+ # @yieldreturn [Object] Fiber scheduler to install in each worker.
41
+ # @return [Scheduler]
42
+ def self.create(executor = Thread, name: nil, priority: nil, **, &)
43
+ raise ArgumentError, "Local schedulers cannot launch Ractor workers" if executor && executor <= Ractor
44
+ worker = [executor || Thread, name && -String(name), priority && Integer(priority)].freeze
45
+ new(**, worker:, &)
46
+ end
47
+
48
+ # Creates a handle whose scoped schedulers remain unstarted.
49
+ # The constructor block builds the fiber scheduler when a scope is launched or installed.
50
+ # @!macro scopes
51
+ # @overload initialize(capacity: 1024, backend: Farce.config.io_backend, scope: :ractor, &constructor)
52
+ # @param capacity [Integer, nil] Pending-task capacity, or nil for an unbounded queue in each scope.
53
+ # @param backend [Symbol] IO backend for the built-in scheduler.
54
+ # @param scope [Symbol] The scheduler scope. Defaults to :ractor.
55
+ # @yieldreturn [Object] Fiber scheduler to install in each scope.
56
+ def initialize(capacity: 1024, backend: Internal::FROZEN_CONFIG.io_backend, scope: :ractor,
57
+ worker: nil, &constructor)
58
+ @constructor = prepare_constructor(constructor || Internal::FROZEN_CONFIG.fiber_scheduler_constructor)
59
+ @backend = backend
60
+ @worker = worker
61
+ @schedulers = Strict::Map.new
62
+ @closed_all = Internal::Flag.new(false)
63
+ @initialization_key = Object.new.freeze
64
+ @registration_key = Object.new.freeze
65
+ super(scope:, capacity: capacity && Integer(capacity), backend:)
66
+ end
67
+
68
+ # Enqueue a task in the current scope, preserving its block and arguments.
69
+ # `mode` is ignored when `auto_local` is true. Otherwise, only local and smart modes are accepted.
70
+ # @overload schedule(*args, mode: :smart, auto_local: true)
71
+ # @param (see Farce::Abstract::Executor#schedule)
72
+ # @return [self]
73
+ def schedule(...) = enqueue_task(false, ...)
74
+
75
+ # (see Abstract::Scheduler#try_schedule)
76
+ def try_schedule(...) = enqueue_task(true, ...)
77
+
78
+ # Starts a worker for the current scope. Other scopes retain their creation behavior.
79
+ # @param executor [Class, nil] Thread or a custom executor that stays in this Ractor.
80
+ # @param name [String, nil] Optional worker name.
81
+ # @param priority [Integer, nil] Optional worker thread priority.
82
+ # @return [Thread, Object] The worker.
83
+ # @raise [ArgumentError] If a Ractor executor is supplied.
84
+ def launch(executor = Thread, name: nil, priority: nil)
85
+ raise ArgumentError, "Local schedulers cannot launch Ractor workers" if executor && executor <= Ractor
86
+ raise "Scheduler may not be reused" if @closed_all.value
87
+ active_scoped_value.launch(executor, name:, priority:)
88
+ end
89
+
90
+ # Closes the current scope and rejects new submissions there.
91
+ # @return [self]
92
+ def close
93
+ current_scheduler&.close
94
+ self
95
+ end
96
+
97
+ # Requests shutdown in every scope and Ractor and rejects future scopes.
98
+ # Does not wait for queued or running tasks to finish.
99
+ # @return [self]
100
+ def close_all
101
+ return self unless @closed_all.compare_and_set(false, true)
102
+ @schedulers.each_pair do |scheduler, queue|
103
+ queue.push([scheduler, :close].freeze, clock: 0)
104
+ rescue ClosedQueueError
105
+ # The owning Ractor may already have shut down its supervisor.
106
+ nil
107
+ end
108
+ close
109
+ end
110
+
111
+ # @return [Symbol] The current scope's scheduler state.
112
+ def state = current_scheduler&.state || :closed
113
+
114
+ # @return [Exception, nil] The current scope's scheduler error.
115
+ def error = current_scheduler&.error
116
+
117
+ # @return [Boolean] Whether the current scope rejects new tasks.
118
+ def closed? = @closed_all.value || scoped_value.closed?
119
+
120
+ # @return [Boolean] Whether the current scope is dispatching tasks.
121
+ def alive? = current_scheduler&.alive? || false
122
+
123
+ # @return [Ractor] The current scope's owning Ractor.
124
+ def owner = Ractor.current
125
+
126
+ # @param value [Ractor] The current Ractor.
127
+ # @raise [ArgumentError] If another owner is supplied.
128
+ def owner=(value)
129
+ raise ArgumentError, "owner must be the current Ractor" unless value == Ractor.current
130
+ end
131
+
132
+ # @return [Boolean] Whether a fiber scheduler constructor was supplied.
133
+ def wraps_external? = !@constructor.nil?
134
+
135
+ # @return [Object, nil] The fiber scheduler in the current scope, when supplied by a constructor.
136
+ def external_scheduler = current_scheduler&.external_scheduler
137
+
138
+ # @api private
139
+ def inspect_with(inspector)
140
+ inspector.object_group(self) do
141
+ inspector.attribute(:scope, scope)
142
+ inspector.attribute(:state, state)
143
+ inspector.attribute(:backend, @backend)
144
+ yield if block_given?
145
+ end
146
+ end
147
+
148
+ private
149
+
150
+ def enqueue_task(non_block, *, mode: :smart, auto_local: true, &block)
151
+ raise SchedulerClosedError, "cannot schedule task on a closed scheduler" if @closed_all.value
152
+ scheduler = scoped_value
153
+ raise SchedulerClosedError, "cannot schedule task on a closed scheduler" if scheduler.closed?
154
+ raise LocalJumpError, "Cannot schedule without a block" unless block
155
+
156
+ validate_local_mode(mode, auto_local)
157
+ register_scheduler(scheduler)
158
+
159
+ submitted =
160
+ if non_block
161
+ scheduler.try_schedule(*, mode:, auto_local:, &block)
162
+ else
163
+ scheduler.schedule(*, mode:, auto_local:, &block)
164
+ end
165
+
166
+ submitted && self
167
+ end
168
+
169
+ def current_scheduler
170
+ return scoped_value unless @closed_all.value
171
+ scheduler = Internal::Storage[self, scope:]
172
+ scheduler&.close
173
+ scheduler
174
+ end
175
+
176
+ # Worker setup can yield. Initialize once when fibers share a scope.
177
+ def scoped_value
178
+ storage = Internal::Storage.scope(scope)
179
+ scheduler = storage[self]
180
+ return scheduler if scheduler
181
+
182
+ initializers = Internal::Storage.store_if_absent(@initialization_key) { Internal::Storage::ThreadSafe.new }
183
+ initializers.store_if_absent(storage) { Internal::LeaseInitialization.new }.fetch { super }
184
+ end
185
+
186
+ def active_scoped_value
187
+ scheduler = scoped_value
188
+ register_scheduler(scheduler)
189
+ scheduler
190
+ end
191
+
192
+ def register_scheduler(scheduler)
193
+ registrations = Internal::Storage.store_if_absent(@registration_key) { Internal::Storage::ThreadSafe.new }
194
+ registrations.store_if_absent(scheduler) do
195
+ queue, reference = Internal::ThreadPoolSupervisor.current.close_handle(scheduler)
196
+ @schedulers[reference] = queue
197
+ true
198
+ end
199
+ scheduler.close if @closed_all.value
200
+ end
201
+
202
+ def new_scoped_value(**)
203
+ scheduler = Unshared::Scheduler.new(**, &@constructor)
204
+ if @worker && !@closed_all.value
205
+ register_scheduler(scheduler)
206
+ executor, name, priority = @worker
207
+ scheduler.launch(executor, name:, priority:) unless @closed_all.value
208
+ end
209
+ scheduler.close if @closed_all.value
210
+ scheduler
211
+ end
212
+
213
+ def set_scheduler!(...)
214
+ raise "Scheduler may not be reused" if @closed_all.value
215
+ active_scoped_value.set_scheduler!(...)
216
+ end
217
+ end
218
+ end
219
+ end