From 63732436ec97c1bbc5cda00a48e830103fae3a47 Mon Sep 17 00:00:00 2001 From: Charles Oliver Nutter Date: Wed, 11 May 2022 01:23:51 +0200 Subject: [PATCH 1/3] Incorporate JRuby implementation This implementation is based on the JDK's ScheduledThreadPoolExecutor class, which provides an efficient way to schedule many tasks in the future without spinning up a thread for each. This logic is ported from a Java implementation of timeout that JRuby has shipped for many years, and passes all but one of the timeout tests. --- lib/timeout.rb | 6 ++++ lib/timeout/jruby.rb | 68 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) create mode 100644 lib/timeout/jruby.rb diff --git a/lib/timeout.rb b/lib/timeout.rb index f50e706..8396db4 100644 --- a/lib/timeout.rb +++ b/lib/timeout.rb @@ -131,3 +131,9 @@ def timeout(sec, klass = nil, message = nil, &block) #:yield: +sec+ module_function :timeout end + +# Load impl-specific logic on top of base implementation +case RUBY_ENGINE +when 'jruby' + require 'timeout/jruby' +end diff --git a/lib/timeout/jruby.rb b/lib/timeout/jruby.rb new file mode 100644 index 0000000..d352e25 --- /dev/null +++ b/lib/timeout/jruby.rb @@ -0,0 +1,68 @@ +require 'jruby' + +module Timeout + # A module to hold the executor for timeout operations + module JRuby + java_import java.util.concurrent.ScheduledThreadPoolExecutor + java_import java.lang.Runtime + java_import org.jruby.threading.DaemonThreadFactory + java_import java.util.concurrent.atomic.AtomicBoolean + java_import org.jruby.RubyTime + java_import java.util.concurrent.TimeUnit + java_import java.util.concurrent.ExecutionException + java_import java.lang.InterruptedException + + EXECUTOR = ScheduledThreadPoolExecutor.new( + Runtime.runtime.available_processors, + DaemonThreadFactory.new) + EXECUTOR.remove_on_cancel_policy = true + end + + # An efficient timeout implementation based on the JDK's ScheduledThreadPoolExecutor + module_function def timeout(sec, klass = nil, message = nil, &block) + #:yield: +sec+ + return yield(sec) if sec == nil or sec.zero? + + message ||= "execution expired".freeze + + if Fiber.respond_to?(:current_scheduler) && (scheduler = Fiber.current_scheduler)&.respond_to?(:timeout_after) + return scheduler.timeout_after(sec, klass || Error, message, &block) + end + + current_thread = Thread.current + latch = JRuby::AtomicBoolean.new(false); + + id = klass.nil? ? Object.new : nil; + + sec_float = JRuby::RubyTime.convert_time_interval ::JRuby.runtime.current_context, sec + + timeout_runnable = -> { + # check latch to see if we have been canceled + if latch.compare_and_set(false, true) + if klass.nil? + timeout_exception = Timeout::Error.new(message) + timeout_exception.instance_variable_set(:@exception_id, id) + current_thread.raise(timeout_exception) + else + current_thread.raise(klass, message); + end + end + } + + timeout_future = JRuby::EXECUTOR.schedule(timeout_runnable, sec_float * 1_000_000, JRuby::TimeUnit::MICROSECONDS) + begin + yield sec + ensure + if latch.compare_and_set(false, true) && timeout_future.cancel(false) + # ok, exception will not fire (also cancel caused task to be removed) + else + # future is not cancellable, wait for it to run and ignore results + begin + timeout_future.get + rescue JRuby::ExecutionException + rescue JRuby::InterruptedException + end + end + end + end +end From 66c7d5b4dc8dd4c9f566c5b7562d2cc033da9a55 Mon Sep 17 00:00:00 2001 From: Charles Oliver Nutter Date: Wed, 11 May 2022 01:26:07 +0200 Subject: [PATCH 2/3] Add JRuby to test matrix --- .github/workflows/test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cc4d46e..d9836a0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,7 +7,7 @@ jobs: name: build (${{ matrix.ruby }} / ${{ matrix.os }}) strategy: matrix: - ruby: [ '3.0', 2.7, 2.6, 2.5, 2.4, head ] + ruby: [ '3.0', 2.7, 2.6, 2.5, 2.4, head, jruby-head ] os: [ ubuntu-latest, macos-latest ] runs-on: ${{ matrix.os }} steps: From 4d5cf5e3f76d3c7a5601c77e415ca2ecf8a9d025 Mon Sep 17 00:00:00 2001 From: Charles Oliver Nutter Date: Wed, 11 May 2022 11:20:05 +0200 Subject: [PATCH 3/3] Refactor and optimize Performance is now roughly the same as the Java version. --- lib/timeout/jruby.rb | 89 +++++++++++++++++++++++++++++--------------- 1 file changed, 58 insertions(+), 31 deletions(-) diff --git a/lib/timeout/jruby.rb b/lib/timeout/jruby.rb index d352e25..e9b85b9 100644 --- a/lib/timeout/jruby.rb +++ b/lib/timeout/jruby.rb @@ -1,66 +1,93 @@ require 'jruby' module Timeout - # A module to hold the executor for timeout operations - module JRuby + module_function def timeout(sec, klass = nil, message = nil, &block) + #:yield: +sec+ + return yield(sec) if sec == nil or sec.zero? + + message ||= "execution expired".freeze + + if Fiber.respond_to?(:current_scheduler) && (scheduler = Fiber.current_scheduler)&.respond_to?(:timeout_after) + return scheduler.timeout_after(sec, klass || Error, message, &block) + end + + JRubyTimeout.timeout(sec, klass, message, &block) + end + + # An efficient timeout implementation based on the JDK's ScheduledThreadPoolExecutor + class JRubyTimeout java_import java.util.concurrent.ScheduledThreadPoolExecutor java_import java.lang.Runtime java_import org.jruby.threading.DaemonThreadFactory java_import java.util.concurrent.atomic.AtomicBoolean java_import org.jruby.RubyTime - java_import java.util.concurrent.TimeUnit java_import java.util.concurrent.ExecutionException java_import java.lang.InterruptedException + java_import java.lang.Runnable + + MICROSECONDS = java.util.concurrent.TimeUnit::MICROSECONDS + # Executor for timeout jobs EXECUTOR = ScheduledThreadPoolExecutor.new( Runtime.runtime.available_processors, DaemonThreadFactory.new) EXECUTOR.remove_on_cancel_policy = true - end - # An efficient timeout implementation based on the JDK's ScheduledThreadPoolExecutor - module_function def timeout(sec, klass = nil, message = nil, &block) - #:yield: +sec+ - return yield(sec) if sec == nil or sec.zero? + # Current JRuby runtime + RUNTIME = JRuby.runtime - message ||= "execution expired".freeze + include Runnable - if Fiber.respond_to?(:current_scheduler) && (scheduler = Fiber.current_scheduler)&.respond_to?(:timeout_after) - return scheduler.timeout_after(sec, klass || Error, message, &block) + def self.timeout(seconds, exception_class, message) + timeout_job = new(exception_class, message) + timeout_job.start(seconds) + begin + yield seconds + ensure + timeout_job.finish + end end - current_thread = Thread.current - latch = JRuby::AtomicBoolean.new(false); - - id = klass.nil? ? Object.new : nil; + def initialize(exception_class, message) + @exception_class = exception_class + @message = message - sec_float = JRuby::RubyTime.convert_time_interval ::JRuby.runtime.current_context, sec + @id = exception_class.nil? ? Object.new : nil + @latch = AtomicBoolean.new + @current_thread = Thread.current + end - timeout_runnable = -> { + def run # check latch to see if we have been canceled - if latch.compare_and_set(false, true) - if klass.nil? - timeout_exception = Timeout::Error.new(message) - timeout_exception.instance_variable_set(:@exception_id, id) - current_thread.raise(timeout_exception) + if @latch.compare_and_set(false, true) + exception_class = @exception_class + if exception_class.nil? + timeout_exception = Timeout::Error.new(@message) + timeout_exception.instance_variable_set(:@exception_id, @id) + @current_thread.raise(timeout_exception) else - current_thread.raise(klass, message); + @current_thread.raise(exception_class, @message); end end - } + end + + def start(seconds) + sec_float = RubyTime.convert_time_interval RUNTIME.current_context, seconds + usec_float = sec_float * 1_000_000 + + @timeout_future = EXECUTOR.schedule(self, usec_float, MICROSECONDS) + end - timeout_future = JRuby::EXECUTOR.schedule(timeout_runnable, sec_float * 1_000_000, JRuby::TimeUnit::MICROSECONDS) - begin - yield sec - ensure - if latch.compare_and_set(false, true) && timeout_future.cancel(false) + def finish + timeout_future = @timeout_future + if @latch.compare_and_set(false, true) && timeout_future.cancel(false) # ok, exception will not fire (also cancel caused task to be removed) else # future is not cancellable, wait for it to run and ignore results begin timeout_future.get - rescue JRuby::ExecutionException - rescue JRuby::InterruptedException + rescue ExecutionException + rescue InterruptedException end end end