diff --git a/async.gemspec b/async.gemspec index 6976b65..d612a4e 100644 --- a/async.gemspec +++ b/async.gemspec @@ -27,7 +27,7 @@ Gem::Specification.new do |spec| spec.add_dependency "console", "~> 1.29" spec.add_dependency "fiber-annotation" - spec.add_dependency "io-event", "~> 1.9" + spec.add_dependency "io-event", "~> 1.11" spec.add_dependency "traces", "~> 0.15" spec.add_dependency "metrics", "~> 0.12" end diff --git a/gems.rb b/gems.rb index 1f97ada..8a7a7e3 100644 --- a/gems.rb +++ b/gems.rb @@ -9,7 +9,7 @@ gemspec -# gem "io-event", git: "https://github.com/socketry/io-event.git" +gem "io-event", git: "https://github.com/socketry/io-event.git", branch: "worker-pool-bug-fix" # In order to capture both code paths in coverage, we need to optionally load this gem: if ENV["FIBER_PROFILER_CAPTURE"] == "true" diff --git a/lib/async/scheduler.rb b/lib/async/scheduler.rb index f859b1e..fc11eb9 100644 --- a/lib/async/scheduler.rb +++ b/lib/async/scheduler.rb @@ -8,7 +8,6 @@ require_relative "clock" require_relative "task" require_relative "timeout" -require_relative "worker_pool" require "io/event" @@ -45,7 +44,29 @@ def initialize(message = "Scheduler is closed!") def self.supported? true end + + # Used to augment the scheduler to add support for blocking operations. + module BlockingOperationWait + # Wait for the given work to be executed. + # + # @public Since *Async v2.21* and *Ruby v3.4*. + # @asynchronous May be non-blocking. + # + # @parameter work [Proc] The work to execute on a background thread. + # @returns [Object] The result of the work. + def blocking_operation_wait(work) + @worker_pool.call(work) + end + end + + private_constant :BlockingOperationWait + if ::IO::Event.const_defined?(:WorkerPool) + WorkerPool = ::IO::Event::WorkerPool + else + WorkerPool = nil + end + # Create a new scheduler. # # @public Since *Async v1*. @@ -65,14 +86,15 @@ def initialize(parent = nil, selector: nil, profiler: Profiler&.default, worker_ @idle_time = 0.0 @timers = ::IO::Event::Timers.new + if worker_pool == true - @worker_pool = WorkerPool.new + @worker_pool = WorkerPool&.new else @worker_pool = worker_pool end - + if @worker_pool - self.singleton_class.prepend(WorkerPool::BlockingOperationWait) + self.singleton_class.prepend(BlockingOperationWait) end end diff --git a/lib/async/worker_pool.rb b/lib/async/worker_pool.rb deleted file mode 100644 index ca931db..0000000 --- a/lib/async/worker_pool.rb +++ /dev/null @@ -1,182 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2024, by Samuel Williams. - -require "etc" - -module Async - # A simple work pool that offloads work to a background thread. - # - # @private - class WorkerPool - # Used to augment the scheduler to add support for blocking operations. - module BlockingOperationWait - # Wait for the given work to be executed. - # - # @public Since *Async v2.21* and *Ruby v3.4*. - # @asynchronous May be non-blocking. - # - # @parameter work [Proc] The work to execute on a background thread. - # @returns [Object] The result of the work. - def blocking_operation_wait(work) - @worker_pool.call(work) - end - end - - # Execute the given work in a background thread. - class Promise - # Create a new promise. - # - # @parameter work [Proc] The work to be done. - def initialize(work) - @work = work - @state = :pending - @value = nil - @guard = ::Mutex.new - @condition = ::ConditionVariable.new - @thread = nil - end - - # Execute the work and resolve the promise. - def call - work = nil - - @guard.synchronize do - @thread = ::Thread.current - - return unless work = @work - end - - resolve(work.call) - rescue Exception => error - reject(error) - end - - private def resolve(value) - @guard.synchronize do - @work = nil - @thread = nil - @value = value - @state = :resolved - @condition.broadcast - end - end - - private def reject(error) - @guard.synchronize do - @work = nil - @thread = nil - @value = error - @state = :failed - @condition.broadcast - end - end - - # Cancel the work and raise an exception in the background thread. - def cancel - return unless @work - - @guard.synchronize do - @work = nil - @state = :cancelled - @thread&.raise(Interrupt) - end - end - - # Wait for the work to be done. - # - # @returns [Object] The result of the work. - def wait - @guard.synchronize do - while @state == :pending - @condition.wait(@guard) - end - - if @state == :failed - raise @value - else - return @value - end - end - end - end - - # A background worker thread. - class Worker - # Create a new worker. - def initialize - @work = ::Thread::Queue.new - @thread = ::Thread.new(&method(:run)) - end - - # Execute work until the queue is closed. - def run - while work = @work.pop - work.call - end - end - - # Close the worker thread. - def close - if thread = @thread - @thread = nil - thread.kill - end - end - - # Call the work and notify the scheduler when it is done. - def call(work) - promise = Promise.new(work) - - @work.push(promise) - - begin - return promise.wait - ensure - promise.cancel - end - end - end - - # Create a new work pool. - # - # @parameter size [Integer] The number of threads to use. - def initialize(size: Etc.nprocessors) - @ready = ::Thread::Queue.new - - size.times do - @ready.push(Worker.new) - end - end - - # Close the work pool. Kills all outstanding work. - def close - if ready = @ready - @ready = nil - ready.close - - while worker = ready.pop - worker.close - end - end - end - - # Offload work to a thread. - # - # @parameter work [Proc] The work to be done. - def call(work) - if ready = @ready - worker = ready.pop - - begin - worker.call(work) - ensure - ready.push(worker) - end - else - raise RuntimeError, "No worker available!" - end - end - end -end diff --git a/test/async/worker_pool.rb b/test/async/worker_pool.rb deleted file mode 100644 index 2cad9dc..0000000 --- a/test/async/worker_pool.rb +++ /dev/null @@ -1,60 +0,0 @@ -# frozen_string_literal: true - -# Released under the MIT License. -# Copyright, 2022-2024, by Samuel Williams. -# Copyright, 2024, by Patrik Wenger. - -require "async/worker_pool" -require "sus/fixtures/async" - -describe Async::WorkerPool do - let(:worker_pool) {subject.new(size: 1)} - - it "offloads work to a thread" do - result = worker_pool.call(proc do - Thread.current - end) - - expect(result).not.to be == Thread.current - end - - it "gracefully handles errors" do - expect do - worker_pool.call(proc do - raise ArgumentError, "Oops!" - end) - end.to raise_exception(ArgumentError, message: be == "Oops!") - end - - it "can cancel work" do - sleeping = ::Thread::Queue.new - - thread = Thread.new do - Thread.current.report_on_exception = false - - worker_pool.call(proc do - sleeping.push(true) - sleep(1) - end) - end - - # Wait for the worker to start: - sleeping.pop - - thread.raise(Interrupt) - - expect do - thread.join - end.to raise_exception(Interrupt) - end - - with "#close" do - it "can be closed" do - worker_pool.close - - expect do - worker_pool.call(proc{}) - end.to raise_exception(RuntimeError) - end - end -end