summaryrefslogtreecommitdiff
path: root/test/-ext-/thread
diff options
context:
space:
mode:
Diffstat (limited to 'test/-ext-/thread')
-rw-r--r--test/-ext-/thread/helper.rb51
-rw-r--r--test/-ext-/thread/test_instrumentation_api.rb289
-rw-r--r--test/-ext-/thread/test_lock_native_thread.rb50
3 files changed, 390 insertions, 0 deletions
diff --git a/test/-ext-/thread/helper.rb b/test/-ext-/thread/helper.rb
new file mode 100644
index 0000000000..3ea2057d15
--- /dev/null
+++ b/test/-ext-/thread/helper.rb
@@ -0,0 +1,51 @@
+module ThreadInstrumentation
+ module TestHelper
+ private
+
+ def record
+ Bug::ThreadInstrumentation.register_callback(!ENV["GVL_DEBUG"])
+ yield
+ ensure
+ timeline = Bug::ThreadInstrumentation.unregister_callback
+ if $!
+ raise
+ else
+ return timeline
+ end
+ end
+
+ def timeline_for(thread, timeline)
+ timeline.select { |t, _| t == thread }.map(&:last)
+ end
+
+ def assert_consistent_timeline(events)
+ refute_predicate events, :empty?
+
+ previous_event = nil
+ events.each do |event|
+ refute_equal :exited, previous_event, "`exited` must be the final event: #{events.inspect}"
+ case event
+ when :started
+ assert_nil previous_event, "`started` must be the first event: #{events.inspect}"
+ when :ready
+ unless previous_event.nil?
+ assert %i(started suspended).include?(previous_event), "`ready` must be preceded by `started` or `suspended`: #{events.inspect}"
+ end
+ when :resumed
+ unless previous_event.nil?
+ assert_equal :ready, previous_event, "`resumed` must be preceded by `ready`: #{events.inspect}"
+ end
+ when :suspended
+ unless previous_event.nil?
+ assert_equal :resumed, previous_event, "`suspended` must be preceded by `resumed`: #{events.inspect}"
+ end
+ when :exited
+ unless previous_event.nil?
+ assert %i(resumed suspended).include?(previous_event), "`exited` must be preceded by `resumed` or `suspended`: #{events.inspect}"
+ end
+ end
+ previous_event = event
+ end
+ end
+ end
+end
diff --git a/test/-ext-/thread/test_instrumentation_api.rb b/test/-ext-/thread/test_instrumentation_api.rb
new file mode 100644
index 0000000000..663e41be53
--- /dev/null
+++ b/test/-ext-/thread/test_instrumentation_api.rb
@@ -0,0 +1,289 @@
+# frozen_string_literal: false
+require 'envutil'
+require_relative "helper"
+
+class TestThreadInstrumentation < Test::Unit::TestCase
+ include ThreadInstrumentation::TestHelper
+
+ def setup
+ pend("No windows support") if /mswin|mingw|bccwin/ =~ RUBY_PLATFORM
+
+ require '-test-/thread/instrumentation'
+
+ cleanup_threads
+ end
+
+ def teardown
+ return if /mswin|mingw|bccwin/ =~ RUBY_PLATFORM
+ Bug::ThreadInstrumentation.unregister_callback
+ cleanup_threads
+ end
+
+ THREADS_COUNT = 3
+
+ def test_single_thread_timeline
+ thread = nil
+ full_timeline = record do
+ thread = Thread.new { 1 + 1 }
+ thread.join
+ end
+ assert_equal %i(started ready resumed suspended exited), timeline_for(thread, full_timeline)
+ ensure
+ thread&.kill
+ end
+
+ def test_thread_pass_single_thread
+ full_timeline = record do
+ Thread.pass
+ end
+ assert_equal [], timeline_for(Thread.current, full_timeline)
+ end
+
+ def test_thread_pass_multi_thread
+ thread = Thread.new do
+ cpu_bound_work(0.5)
+ end
+
+ full_timeline = record do
+ Thread.pass
+ end
+
+ assert_equal %i(suspended ready resumed), timeline_for(Thread.current, full_timeline)
+ ensure
+ thread&.kill
+ thread&.join
+ end
+
+ def test_multi_thread_timeline
+ threads = nil
+ full_timeline = record do
+ threads = threaded_cpu_bound_work(1.0)
+ results = threads.map(&:value)
+ results.each do |r|
+ refute_equal false, r
+ end
+ assert_equal [false] * THREADS_COUNT, threads.map(&:status)
+ end
+
+ threads.each do |thread|
+ timeline = timeline_for(thread, full_timeline)
+ assert_consistent_timeline(timeline)
+ assert_operator timeline.count(:suspended), :>=, 1, "Expected threads to yield suspended at least once: #{timeline.inspect}"
+ end
+
+ timeline = timeline_for(Thread.current, full_timeline)
+ assert_consistent_timeline(timeline)
+ ensure
+ threads&.each(&:kill)
+ end
+
+ def test_join_suspends # Bug #18900
+ thread = other_thread = nil
+ full_timeline = record do
+ other_thread = Thread.new { sleep 0.3 }
+ thread = Thread.new { other_thread.join }
+ thread.join
+ end
+
+ timeline = timeline_for(thread, full_timeline)
+ assert_consistent_timeline(timeline)
+ assert_equal %i(started ready resumed suspended ready resumed suspended exited), timeline
+ ensure
+ other_thread&.kill
+ thread&.kill
+ end
+
+ def test_io_release_gvl
+ r, w = IO.pipe
+ thread = nil
+ full_timeline = record do
+ thread = Thread.new do
+ w.write("Hello\n")
+ end
+ thread.join
+ end
+
+ timeline = timeline_for(thread, full_timeline)
+ assert_consistent_timeline(timeline)
+ assert_equal %i(started ready resumed suspended ready resumed suspended exited), timeline
+ ensure
+ r&.close
+ w&.close
+ end
+
+ def test_queue_releases_gvl
+ queue1 = Queue.new
+ queue2 = Queue.new
+
+ thread = nil
+
+ full_timeline = record do
+ thread = Thread.new do
+ queue1 << true
+ queue2.pop
+ end
+
+ queue1.pop
+ queue2 << true
+ thread.join
+ end
+
+ timeline = timeline_for(thread, full_timeline)
+ assert_consistent_timeline(timeline)
+ assert_equal %i(started ready resumed suspended ready resumed suspended exited), timeline
+ end
+
+ def test_blocking_on_ractor
+ assert_ractor(<<-"RUBY", require_relative: "helper", require: "-test-/thread/instrumentation")
+ include ThreadInstrumentation::TestHelper
+
+ ractor = Ractor.new {
+ Ractor.receive # wait until woke
+ Thread.current
+ }
+
+ # Wait for the main thread to block, then wake the ractor
+ Thread.new do
+ while Thread.main.status != "sleep"
+ Thread.pass
+ end
+ ractor.send true
+ end
+
+ full_timeline = record do
+ ractor.take
+ end
+
+ timeline = timeline_for(Thread.current, full_timeline)
+ assert_consistent_timeline(timeline)
+ assert_equal %i(suspended ready resumed), timeline
+ RUBY
+ end
+
+ def test_sleeping_inside_ractor
+ assert_ractor(<<-"RUBY", require_relative: "helper", require: "-test-/thread/instrumentation")
+ include ThreadInstrumentation::TestHelper
+
+ thread = nil
+
+ full_timeline = record do
+ thread = Ractor.new{
+ sleep 0.1
+ Thread.current
+ }.take
+ sleep 0.1
+ end
+
+ timeline = timeline_for(thread, full_timeline)
+ assert_consistent_timeline(timeline)
+ assert_equal %i(started ready resumed suspended ready resumed suspended exited), timeline
+ RUBY
+ end
+
+ def test_thread_blocked_forever_on_mutex
+ mutex = Mutex.new
+ mutex.lock
+ thread = nil
+
+ full_timeline = record do
+ thread = Thread.new do
+ mutex.lock
+ end
+ 10.times { Thread.pass }
+ sleep 0.1
+ end
+
+ mutex.unlock
+ thread.join
+
+ timeline = timeline_for(thread, full_timeline)
+ assert_consistent_timeline(timeline)
+ assert_equal %i(started ready resumed suspended), timeline
+ end
+
+ def test_thread_blocked_temporarily_on_mutex
+ mutex = Mutex.new
+ mutex.lock
+ thread = nil
+
+ full_timeline = record do
+ thread = Thread.new do
+ mutex.lock
+ end
+ 10.times { Thread.pass }
+ sleep 0.1
+ mutex.unlock
+ 10.times { Thread.pass }
+ sleep 0.1
+ end
+
+ thread.join
+
+ timeline = timeline_for(thread, full_timeline)
+ assert_consistent_timeline(timeline)
+ assert_equal %i(started ready resumed suspended ready resumed suspended exited), timeline
+ end
+
+ def test_thread_instrumentation_fork_safe
+ skip "No fork()" unless Process.respond_to?(:fork)
+
+ thread_statuses = full_timeline = nil
+ IO.popen("-") do |read_pipe|
+ if read_pipe
+ thread_statuses = Marshal.load(read_pipe)
+ full_timeline = Marshal.load(read_pipe)
+ else
+ threads = threaded_cpu_bound_work.each(&:join)
+ Marshal.dump(threads.map(&:status), STDOUT)
+ full_timeline = Bug::ThreadInstrumentation.unregister_callback.map { |t, e| [t.to_s, e ] }
+ Marshal.dump(full_timeline, STDOUT)
+ end
+ end
+ assert_predicate $?, :success?
+
+ assert_equal [false] * THREADS_COUNT, thread_statuses
+ thread_names = full_timeline.map(&:first).uniq
+ thread_names.each do |thread_name|
+ assert_consistent_timeline(timeline_for(thread_name, full_timeline))
+ end
+ end
+
+ def test_thread_instrumentation_unregister
+ assert Bug::ThreadInstrumentation::register_and_unregister_callbacks
+ end
+
+ private
+
+ def fib(n = 30)
+ return n if n <= 1
+ fib(n-1) + fib(n-2)
+ end
+
+ def cpu_bound_work(duration)
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + duration
+ i = 0
+ while deadline > Process.clock_gettime(Process::CLOCK_MONOTONIC)
+ fib(25)
+ i += 1
+ end
+ i > 0 ? i : false
+ end
+
+ def threaded_cpu_bound_work(duration = 0.5)
+ THREADS_COUNT.times.map do
+ Thread.new do
+ cpu_bound_work(duration)
+ end
+ end
+ end
+
+ def cleanup_threads
+ Thread.list.each do |thread|
+ if thread != Thread.current
+ thread.kill
+ thread.join rescue nil
+ end
+ end
+ assert_equal [Thread.current], Thread.list
+ end
+end
diff --git a/test/-ext-/thread/test_lock_native_thread.rb b/test/-ext-/thread/test_lock_native_thread.rb
new file mode 100644
index 0000000000..8a5ba78838
--- /dev/null
+++ b/test/-ext-/thread/test_lock_native_thread.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: false
+
+require 'envutil'
+
+mn_supported_p = -> do
+ out, *_ = EnvUtil.invoke_ruby([{'RUBY_MN_THREADS' => '1'}, '-v'], '', true)
+ return /\+MN/ =~ out
+end
+
+if mn_supported_p.call
+ # test only on MN threads
+else
+ return
+end
+
+class TestThreadLockNativeThread < Test::Unit::TestCase
+ def test_lock_native_thread
+ assert_separately([{'RUBY_MN_THREADS' => '1'}], <<-RUBY)
+ require '-test-/thread/lock_native_thread'
+
+ Thread.new{
+ assert_equal true, Thread.current.lock_native_thread
+ }.join
+
+ # main thread already has DNT
+ assert_equal false, Thread.current.lock_native_thread
+ RUBY
+ end
+
+ def test_lock_native_thread_tls
+ assert_separately([{'RUBY_MN_THREADS' => '1'}], <<-RUBY)
+ require '-test-/thread/lock_native_thread'
+ tn = 10
+ ln = 1_000
+
+ ts = tn.times.map{|i|
+ Thread.new(i){|i|
+ Thread.current.set_tls i
+ assert_equal true, Thread.current.lock_native_thread
+
+ ln.times{
+ assert_equal i, Thread.current.get_tls
+ Thread.pass
+ }
+ }
+ }
+ ts.each(&:join)
+ RUBY
+ end
+end