CRuby 3.2 – 4.0 · Linux · native extension + web viewer · MIT

See what CRuby's scheduler actually did.

A recorder that subscribes to CRuby's own thread instrumentation, so every wants the GVL, acquired, released, started and exited in a trace is the scheduler's transition, timestamped from inside the VM. A viewer that turns it into lanes, a GVL owner track and a replay. Nothing is simulated.

examples/cpu_threads.rb · Ruby 3.4.8 · two CPU-bound threads with a sleep in the middle
0 ms 100 ms 200 ms 300 ms 400 ms 500 ms 600 ms 700 ms 800 ms 900 ms 1000 ms 1100 ms main main RUNNING 0.000 ms – 0.109 ms (observed) main SUSPENDED 0.109 ms – 0.130 ms (observed) main WANTS_GVL 0.130 ms – 0.130 ms (observed) main RUNNING 0.130 ms – 0.272 ms (observed) main SUSPENDED 0.272 ms – 0.275 ms (observed) main WANTS_GVL 0.275 ms – 0.275 ms (observed) main RUNNING 0.275 ms – 0.607 ms (observed) main SUSPENDED 0.607 ms – 1117.514 ms (observed) SUSPENDED main WANTS_GVL 1117.514 ms – 1181.646 ms (observed) main RUNNING 1181.646 ms – 1181.680 ms (observed) Thread #2 Thread #2 STARTED 0.383 ms – 0.531 ms (observed) Thread #2 WANTS_GVL 0.531 ms – 0.733 ms (observed) Thread #2 RUNNING 0.733 ms – 102.454 ms (observed) RUNNING Thread #2 SUSPENDED 102.454 ms – 102.459 ms (observed) Thread #2 WANTS_GVL 102.459 ms – 204.387 ms (observed) WANTS_GVL Thread #2 RUNNING 204.387 ms – 305.926 ms (observed) RUNNING Thread #2 SUSPENDED 305.926 ms – 305.932 ms (observed) Thread #2 WANTS_GVL 305.932 ms – 407.671 ms (observed) WANTS_GVL Thread #2 RUNNING 407.671 ms – 451.045 ms (observed) Thread #2 SLEEPING 451.045 ms – 651.331 ms (derived) SLEEPING Thread #2 WANTS_GVL 651.331 ms – 651.335 ms (observed) Thread #2 RUNNING 651.335 ms – 805.052 ms (observed) RUNNING Thread #2 SUSPENDED 805.052 ms – 805.054 ms (observed) Thread #2 WANTS_GVL 805.054 ms – 906.691 ms (observed) WANTS_GVL Thread #2 RUNNING 906.691 ms – 1008.149 ms (observed) RUNNING Thread #2 SUSPENDED 1008.149 ms – 1008.152 ms (observed) Thread #2 WANTS_GVL 1008.152 ms – 1109.723 ms (observed) WANTS_GVL Thread #2 RUNNING 1109.723 ms – 1117.519 ms (observed) Thread #2 SUSPENDED 1117.519 ms – 1117.521 ms (observed) Thread #3 Thread #3 STARTED 0.539 ms – 0.604 ms (observed) Thread #3 WANTS_GVL 0.604 ms – 102.610 ms (observed) WANTS_GVL Thread #3 RUNNING 102.610 ms – 204.247 ms (observed) RUNNING Thread #3 SUSPENDED 204.247 ms – 204.254 ms (observed) Thread #3 WANTS_GVL 204.254 ms – 306.300 ms (observed) WANTS_GVL Thread #3 RUNNING 306.300 ms – 407.564 ms (observed) RUNNING Thread #3 SUSPENDED 407.564 ms – 407.567 ms (observed) Thread #3 WANTS_GVL 407.567 ms – 451.160 ms (observed) Thread #3 RUNNING 451.160 ms – 503.250 ms (observed) Thread #3 SLEEPING 503.250 ms – 703.432 ms (derived) SLEEPING Thread #3 WANTS_GVL 703.432 ms – 805.170 ms (observed) WANTS_GVL Thread #3 RUNNING 805.170 ms – 906.565 ms (observed) RUNNING Thread #3 SUSPENDED 906.565 ms – 906.568 ms (observed) Thread #3 WANTS_GVL 906.568 ms – 1008.266 ms (observed) WANTS_GVL Thread #3 RUNNING 1008.266 ms – 1109.669 ms (observed) RUNNING Thread #3 SUSPENDED 1109.669 ms – 1109.671 ms (observed) Thread #3 WANTS_GVL 1109.671 ms – 1117.650 ms (observed) Thread #3 RUNNING 1117.650 ms – 1181.592 ms (observed) Thread #3 SUSPENDED 1181.592 ms – 1181.595 ms (observed) GVL GVL main 0.000 – 0.109 ms GVL idle 0.109 – 0.130 ms GVL main 0.130 – 0.272 ms GVL idle 0.272 – 0.275 ms GVL main 0.275 – 0.607 ms GVL idle 0.607 – 0.733 ms GVL Thread #2 0.733 – 102.454 ms Thread #2 GVL idle 102.454 – 102.610 ms GVL Thread #3 102.610 – 204.247 ms Thread #3 GVL idle 204.247 – 204.387 ms GVL Thread #2 204.387 – 305.926 ms Thread #2 GVL idle 305.926 – 306.300 ms GVL Thread #3 306.300 – 407.564 ms Thread #3 GVL idle 407.564 – 407.671 ms GVL Thread #2 407.671 – 451.045 ms GVL idle 451.045 – 451.160 ms GVL Thread #3 451.160 – 503.250 ms GVL idle 503.250 – 651.335 ms idle GVL Thread #2 651.335 – 805.052 ms Thread #2 GVL idle 805.052 – 805.170 ms GVL Thread #3 805.170 – 906.565 ms Thread #3 GVL idle 906.565 – 906.691 ms GVL Thread #2 906.691 – 1008.149 ms Thread #2 GVL idle 1008.149 – 1008.266 ms GVL Thread #3 1008.266 – 1109.669 ms Thread #3 GVL idle 1109.669 – 1109.723 ms GVL Thread #2 1109.723 – 1117.519 ms GVL idle 1117.519 – 1117.650 ms GVL Thread #3 1117.650 – 1181.592 ms GVL idle 1181.592 – 1181.646 ms GVL main 1181.646 – 1181.680 ms
Drawn from the trace file itself: every block is a scheduler event pair. Hover a block for its timestamps. Dashed blocks are derived (SLEEPING = a release that fell inside a Kernel#sleep probe).
RUNNING (owns the GVL) WANTS_GVL SUSPENDED (released, reason unknown) SLEEPING (derived) PREEMPTED (derived, 3.2) WAITING_MUTEX (derived)
runtime-visualizer inspect cpu_threads.rvtrace
$ runtime-visualizer trace examples/cpu_threads.rb
$ runtime-visualizer inspect cpu_threads.rvtrace

time         ruby_thread    native    event            cruby event
    0.000 ms main           304051    tracing_started  -

    0.383 ms Thread #2      304051    thread_started   RUBY_INTERNAL_THREAD_EVENT_STARTED   ← ran on main's native thread
    0.531 ms Thread #2      304051    wants_gvl        RUBY_INTERNAL_THREAD_EVENT_READY

    0.607 ms main           304051    gvl_released     RUBY_INTERNAL_THREAD_EVENT_SUSPENDED
    0.733 ms Thread #2      304135    gvl_acquired     RUBY_INTERNAL_THREAD_EVENT_RESUMED    ← its own native thread
  102.454 ms Thread #2      304135    gvl_released     RUBY_INTERNAL_THREAD_EVENT_SUSPENDED  ← 100 ms timeslice
  102.459 ms Thread #2      304135    wants_gvl        RUBY_INTERNAL_THREAD_EVENT_READY
  102.610 ms Thread #3      304136    gvl_acquired     RUBY_INTERNAL_THREAD_EVENT_RESUMED

  451.038 ms Thread #2      304135    sleep_enter      Kernel#sleep                          ← probe, not the scheduler
  451.045 ms Thread #2      304135    gvl_released     RUBY_INTERNAL_THREAD_EVENT_SUSPENDED
  651.331 ms Thread #2      304135    wants_gvl        RUBY_INTERNAL_THREAD_EVENT_READY
  651.335 ms Thread #2      304135    gvl_acquired     RUBY_INTERNAL_THREAD_EVENT_RESUMED
… rows elided; the source column is dropped for width. The file is spec/fixtures/cpu_threads.rvtrace.
threads:
  main           id=1   native=304051   running=0.618 ms
  Thread #2      id=2   native=304135   running=509.605 ms
  Thread #3      id=3   native=304136   running=521.731 ms

Thread#status says "run". This says who held the lock, when, and how you know.

Ruby exposes very little about its scheduler from Ruby. CRuby does expose it from C: since 3.2, rb_internal_thread_add_event_hook calls you at every transition of the thread scheduler. The recorder lives there.

What you get from Ruby
Thread.list.map(&:status)
# => ["run", "run", "sleep"]

# "run" means runnable. Two threads
# say "run"; only one owns the GVL.
# Which one, since when, and why the
# other is waiting: not available.
What the recorder gets from the VM
102.454 ms  T2  SUSPENDED   released the lock
102.459 ms  T2  READY       wants it back
102.610 ms  T3  RESUMED     sched->running == T3

# One event per scheduler transition,
# CLOCK_MONOTONIC nanoseconds, the
# native thread that ran the hook.

Real scheduler events

READY, RESUMED, SUSPENDED, STARTED, EXITED straight from thread_pthread.c. GC enter and exit from the internal GC tracepoint. No polling, no guessing from timestamps.

Ruby thread ≠ native thread

A Ruby thread is a serial; the native thread is an attribute of each event. Under RUBY_MN_THREADS=1 several Ruby threads share one pthread, and the native view shows it.

Provenance on every event

Each record names its source channel, the CRuby constant behind it and whether the fact was observed, derived or inferred. The viewer shows it, the docs explain it.

Reasons, when they are known

The scheduler never says why a thread released the lock. Two probes running with the GVL (Kernel#sleep, Mutex#lock) let the model label SLEEPING and WAITING_MUTEX, marked as derived.

Never silently lossy

Hooks write into a lock-free ring and never block the scheduler. If the ring fills, the trace carries an events_dropped record with the count, and stats shows the high-water mark.

Replay, zoom, inspect

Play the trace at 0.1x–10x, step event by event, zoom from seconds to microseconds, click any block for the technical card, and see source lines per thread when recorded with --lines.

Every fact says how it was obtained.

Technical honesty is the feature. A block on the timeline is either something the VM reported, something computed from those reports by a documented rule, or a guess about the gap before the first event. The three are labelled and drawn differently.

FactComes fromPrecision
A thread wants, got or released the GVLRUBY_INTERNAL_THREAD_EVENT_READY / RESUMED / SUSPENDEDobserved
A thread started or exitedRUBY_INTERNAL_THREAD_EVENT_STARTED / EXITEDobserved
GC started or finishedRUBY_INTERNAL_EVENT_GC_ENTER / GC_EXITobserved
A thread called sleepprobe around Kernel#sleep, with the GVLobserved (the call)
A thread is SLEEPINGa SUSPENDED interval enclosed by the sleep probederived
A thread is WAITING_MUTEXa SUSPENDED interval enclosed by the mutex probederived
A thread was PREEMPTEDanother thread's RESUMED while this one had not released (Ruby 3.2)derived
Who owns the GVL between two eventsthe last RESUMED not yet followed by SUSPENDEDderived
The ractor of a threadnot in the event; 1 when the process has a single ractorderived / null
A thread's state before its first eventnothinginferred
What CRuby does not tell anyone. Why a thread released the lock (IO, sleep, Thread.pass, a mutex, a queue, a timeslice, exit all look the same), which ractor it belongs to, and on 3.3+ which native thread a READY was delivered on. The trace carries those gaps as null and unknown instead of filling them in.

The same program, three schedulers.

The hooks were read in thread_pthread.c for 3.2.5, 3.3.0, 3.4.8 and 4.0.0 and then checked by recording the same two-thread program on each. The differences are real and the model handles them without hiding them.

3.2

No SUSPENDED on a timeslice

When the 100 ms slice expires, the preempted thread reports READY straight from RUNNING while the next one reports RESUMED. The model closes the owner at the other thread's RESUMED and labels the gap PREEMPTED.

3.2

Two SUSPENDED for one sleep

thread_sched_to_waiting and native_sleep both fire the hook. Both are recorded; a repeated transition simply does not open a new block.

3.3+

Callbacks on other threads

STARTED runs on the thread that called Thread.new; READY runs on whoever woke the thread up. Only RESUMED, SUSPENDED and EXITED are on the thread's own native thread, so only those decide the native view.

M:N

One pthread, many Ruby threads

With RUBY_MN_THREADS=1 the recorder sees T2 and T3 taking turns on native thread 285098. A native thread id is an attribute of an event, never the identity of a Ruby thread.

RUBY_MN_THREADS=1 ruby 3.4.8 · raw hook events, same program
     0.217 ms  T2  tid=285098  RESUMED
   101.861 ms  T2  tid=285098  SUSPENDED
   101.863 ms  T2  tid=285098  READY
   101.865 ms  T3  tid=285098  RESUMED      ← T3 runs on T2's native thread
   203.674 ms  T3  tid=285098  SUSPENDED
   203.675 ms  T2  tid=285098  RESUMED

A timeline that reads like a profiler, with a card that reads like the source.

One lane per Ruby thread, a GVL owner lane with idle drawn explicitly, a native threads view, an event list that follows the cursor, and an inspector that names the CRuby event behind whatever you clicked.

The viewer showing the mutex example: main, Thread #2 and Thread #3 lanes, the GVL lane, and the inspector card for a RUBY_INTERNAL_THREAD_EVENT_SUSPENDED event
TRACE MODE. Program scheduling was not intentionally controlled by Runtime Visualizer. Instrumentation overhead still exists. A debugger-like controlled mode (pause, step) is a different feature with different guarantees; the design for it, and why it must not be built on the scheduler hooks, is in docs/controlled-execution.md.

From clone to timeline.

Build the extension

$ git clone https://github.com/carlosdanielpohlod/ruby-runtime-visualizer
$ cd ruby-runtime-visualizer && bundle install && bundle exec rake compile

CRuby 3.2 or newer on Linux. JRuby, TruffleRuby and Windows have no thread hooks to subscribe to.

Record a script, or a block

$ bundle exec exe/runtime-visualizer trace your_script.rb
$ bundle exec exe/runtime-visualizer trace --lines your_script.rb   # + source lines, high overhead

# or from Ruby
require "runtime_visualizer"
RuntimeVisualizer.trace("out.rvtrace") do
  2.times.map { Thread.new { work } }.each(&:join)
end

Read it

$ bundle exec exe/runtime-visualizer inspect out.rvtrace                    # table + per-thread summary
$ bundle exec exe/runtime-visualizer stats out.rvtrace                      # dropped events, buffer high water
$ bundle exec exe/runtime-visualizer export --perfetto out.rvtrace -o out.json   # ui.perfetto.dev
$ cd web && npm install && npm run dev                                       # then drop out.rvtrace on the page

Instrumentation changes the thing it measures. Here is by how much.

Median of five rounds on a four-thread workload, Ruby 3.4.8 and 3.2.5, from benchmarks/overhead.rb. The default channels record one event per scheduler transition; the line channel records every Ruby line.

1.02x – 1.07xscheduler hooks + GC + probes, the default
76x – 164xwith --lines, and it changes when timeslices expire
0events lost without a record saying so
Inside the hook, the work is bounded: a vDSO clock read, a serial lookup, one compare-and-swap and a 32-byte store, while CRuby already holds the scheduler lock. No Ruby calls, no allocation, no I/O. The full method and the 3.2 numbers are in docs/tracing-overhead.md.

Written to be read, including the C.

The extension explains, at each callback, which lock the caller holds and why it cannot call into Ruby. The docs record what was verified and how.