Skip to content

Threads are OS threads that take turns under the GVL

Level: 301 · a Python programmer who has used threading and wants to know what Ruby's Thread does differently

One line: Thread.new { } starts a real OS thread that shares every object with the others; join waits and re-raises whatever the thread raised, value hands back its last expression — and the Global VM Lock lets only one thread run Ruby code at a time, just as CPython's GIL does.

A Ruby thread is created with a block, starts at once, and runs the block on an operating-system thread of its own — row 5 below shows that native_thread_id differs between the main thread and a new one. The block closes over the surrounding locals, so threads share memory the way Python threads do (row 6), and Thread.new(1, 2) { |a, b| } hands arguments in without closing over them (row 7). join blocks until the thread ends and returns the thread, so threads.each(&:join) reads naturally; value joins and then returns the block's last expression, which Python's threading.Thread cannot do at all. When a thread raises, the exception ends that thread and is stored: join and value raise it again in the thread that waited (row 8), the thread's status becomes nil rather than false (row 9), and — with Thread.report_on_exception left at its default of true — Ruby also prints a report to stderr the moment the thread dies (row 14). The program sets it to false so the report does not land in the key.

The GVL is the part the output cannot show. Every Ruby thread must hold the Global VM Lock to run Ruby code, so two threads never execute Ruby code at the same time; the lock is released around blocking I/O, sleep and join, and a timer thread forces a switch about every 100 milliseconds so that a busy thread cannot starve the others (Not machine-checked here.). That is why threads make a program that waits on sockets and files faster, and a program that only computes not faster (Not machine-checked here.) — the same shape of promise as Python's GIL. What a thread does get in Ruby is a status string ("run", "sleep", false when finished, nil when killed by an exception), Thread.pass as a hint to the scheduler, and a hard rule for exit: when the main thread ends, every other thread is killed (row 13).

Verified output of threads_and_the_gvl_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. join returns the thread itself                       true
 2. value is the block's last expression                 2
 3. after it ends: alive? / status                       false / false
 4. Thread.current is Thread.main: outside / inside      true / false
 5. a new thread has its own native_thread_id            true
 6. a thread did x += 1 on the outer local; x is now     11
 7. Thread.new(1, 2) { |a, b| a + b }.value              3
 8. join re-raises the thread's exception                ArgumentError: boom
 9. status after an exception (nil, not false)           nil
10. status: a sleeping thread / the current thread       "sleep" / "run"
11. Thread.pass returns                                  nil
12. results through a Queue, sorted after join           [[0, 0], [1, 1], [2, 4]]
13. main ends -> other threads are killed (child stdout) "main done\n"
14. report_on_exception (default) -> child's stderr, line 1 "#<Thread:0x... -e:1 run> terminated with exception (report_on_exception is true):"

Reading the output

Rows 1–3 are the life of one thread: join returns the thread itself, value returns 2, and once the block has ended alive? is false and status is false. Row 4 shows that Thread.current inside the block is a different object from Thread.main, and row 5 that it is a different OS thread as well. Rows 6 and 7 are the two ways data goes in — a shared outer local that the thread assigns, and arguments passed through Thread.new — and row 12 is the safe way data comes out: each worker pushes onto a Queue, the main thread joins them all, then sorts before printing. Nothing about the order in which the workers ran reaches the page.

Rows 8 and 9 are the exception protocol. The thread that raised ArgumentError is dead; join raises the same exception in the waiting thread, and asking for value would raise it again. Its status is nil, which is how you tell a thread that died from one that finished. Row 10 shows the two other status strings, "sleep" for a thread inside sleep (or blocked on I/O or a Queue) and "run" for the one asking. Row 11 is Thread.pass, which returns nil and only suggests that another thread could run now.

Rows 13 and 14 come from two child Rubies whose output the program captured. In the first, the main program prints main done and exits while a worker is still sleeping — the worker's puts "worker done" never runs, because a finishing main thread kills the others. In the second, a thread raises with report_on_exception at its default: the child's stderr begins with #<Thread:0x... -e:1 run> terminated with exception (report_on_exception is true): (the program replaced the address with 0x..., since addresses change between runs), followed by the exception and its backtrace.

Compared with Python

Python's threading.Thread is the same OS-thread-behind-a-lock design, and rows 4–7 and 12 print the same answers: a new thread is not the main thread, it has its own native id, it shares the outer locals (nonlocal where Ruby needs nothing), it takes args=, and results come back through a queue.Queue and are sorted after join(). The differences are in what the thread object offers. join() returns None and there is no value — row 2 uses a ThreadPoolExecutor Future, whose result() is the nearest thing. There is no status string, only is_alive(). And an exception in a thread is not re-raised by join(): row 8 shows join() returning None normally while the exception went to threading.excepthook, which by default prints Exception in thread Thread-1 (boom): and a traceback to stderr (row 14) — the equivalent of Ruby's report_on_exception, except that Ruby also re-raises on join. Row 11 prints CPython's switch interval, 0.005 seconds — the GIL's counterpart of Ruby's 100-millisecond timer.

The largest difference is row 13. A Ruby program ends when its main thread ends and kills the other threads; a Python program waits for every non-daemon thread, so the child prints main done and then, a second later, worker done. Only a thread started with daemon=True behaves like a Ruby thread and is dropped at exit. CPython 3.13 added a build without the GIL (python3.13t), where threads run Python code in parallel; the standard builds that CI uses here still have it (Not machine-checked here.).

Verified output of threads_and_the_gvl_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. join returns                                         None
 2. no Thread.value: a Future's result()                 2
 3. after it ends: is_alive() / status                   False / (no status attribute)
 4. current_thread() is main_thread(): outside / inside  True / False
 5. a new thread has its own get_native_id()             True
 6. a thread did x += 1 on the outer local; x is now     11
 7. Thread(target=add, args=(1, 2)) -> result            3
 8. join returns normally; excepthook got                ValueError: boom (join returned None)
 9. is_alive() after an exception                        False
10. a waiting thread / the current thread                is_alive() True / (no status attribute)
11. time.sleep(0) is the hint; sys.getswitchinterval()   0.005
12. results through a Queue, sorted after join           [(0, 0), (1, 1), (2, 4)]
13. main ends -> child stdout: non-daemon / daemon=True  'main done\nworker done\n' / 'main done\n'
14. default excepthook -> child's stderr, line 1         'Exception in thread Thread-1 (boom):'

The Concurrency library asks the cross-language version of these questions: Getting a result back ↗ for where a thread's answer and failure go, and Global interpreter lock ↗ for the lock itself.

Try it

  1. Start five threads that each cube their index and report through a Queue. Print the results in index order, then print each thread's status. Add a sixth thread that raises, and show what join raises, what status says afterwards, and what value does when asked a second time.
  2. Start three threads that sleep 0.3, 0.2 and 0.1 seconds and push their index onto a Queue as they wake, then print the queue's contents in arrival order. Explain why this library would still refuse to record that order as an answer key, and rewrite the program so that its output is stable.
Solution to 1

threads_and_the_gvl_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: five threads cube their index and report through a Queue; the results
# are printed in index order, then a failing thread shows what join re-raises
# and how its status differs from a finished thread's.
Thread.report_on_exception = false

q = Queue.new
threads = 5.times.map { |i| Thread.new(i) { |n| q << [n, n**3] } }
threads.each(&:join)

Array.new(q.size) { q.pop }.sort.each do |n, cube|
  puts "thread #{n}: #{n}**3 = #{cube}"
end
puts "statuses after join: #{threads.map(&:status).inspect}"

failing = Thread.new { raise "thread 5 failed" }
begin
  failing.join
rescue => e
  puts "join raised #{e.class}: #{e.message}"
end
puts "status of the failed thread: #{failing.status.inspect}"
puts "value of the failed thread: " + (failing.value rescue "raises #{$!.class} again")

Verified output of threads_and_the_gvl_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

thread 0: 0**3 = 0
thread 1: 1**3 = 1
thread 2: 2**3 = 8
thread 3: 3**3 = 27
thread 4: 4**3 = 64
statuses after join: [false, false, false, false, false]
join raised RuntimeError: thread 5 failed
status of the failed thread: nil
value of the failed thread: raises RuntimeError again

See also