skip to content

In Ruby, what do Thread.new, Thread#join and Thread#value return, and what happens to threads the main program never joins?

level: juniorimportance: must knowfreq 70%

answer

  1. Thread.new needs a block
  2. join returns the thread
  3. join(limit) returns nil on timeout
  4. value returns the block's result
  5. main thread exit kills the rest

basics

~20 s

Thread.new starts a block in a new thread and returns the Thread. join waits and returns that thread, or nil when its limit expires; value returns the block's result or re-raises its exception. Unjoined threads die when main exits.

solid answer

~40 s

`Thread.new(*args) { |*args| ... }` starts the block concurrently and immediately returns a `Thread` object; without a block it raises `ThreadError`. `thr.join` blocks the caller until the thread finishes and returns `thr`; `thr.join(5)` waits at most five seconds and returns `nil` if the thread is still running, which leaves it running. `thr.value` calls `join` and then returns the block's last expression. If the thread died from an exception, both `join` and `value` re-raise that exception in the caller. When the main thread finishes, Ruby kills every other thread, so a thread you never join may never complete its work. The usual pattern for 50 page fetches is `threads = urls.map { |u| Thread.new(u) { |x| fetch(x) } }` followed by `threads.map(&:value)`.

code

ruby · 16 lines
ruby
slow = Thread.new { sleep 2; :done }

slow.join(0.1)   # => nil  (still running)
slow.alive?      # => true
slow.join        # => slow (the Thread itself)
slow.value       # => :done

boom = Thread.new do
  Thread.current.report_on_exception = false
  raise KeyError, "EUR missing"
end
begin
  boom.value
rescue KeyError => e
  e.message      # => "EUR missing"
end

go deeper

for a junior

Recall that Thread.new returns immediately, join waits and returns the thread, and value returns the block's result.

for a middle

Explain join(limit) returning nil while the thread keeps running, value re-raising the thread's exception, and why unjoined threads die at exit.

for a senior

Show the fan-out and fan-in pattern with ordered results, decide how one failed fetch should affect the batch, and bound thread counts for large inputs.

for a principal

Weigh ad-hoc threads against a shared worker pool or an executor library for a codebase, considering error handling, shutdown and observability.

## Starting a thread `Thread.new` takes a block and any arguments, starts a new thread that runs the block, and returns the `Thread` object at once. The caller does not wait. - Arguments passed to `Thread.new` are yielded to the block: `Thread.new(url) { |u| ... }`. - Calling `Thread.new` without a block raises `ThreadError` ("must be called with a block"). - `Thread.start` and `Thread.fork` are the same as `Thread.new` except that they do not call a subclass's `initialize`. ## Waiting: join `Thread#join` suspends the calling thread until the target finishes. 1. `thr.join` with no argument waits for as long as it takes and returns `thr` itself. 2. `thr.join(limit)` waits at most `limit` seconds. If the thread has finished, it returns `thr`; if not, it returns **`nil`** and the thread **keeps running**. A `nil` from `join` is therefore not a result, only "not yet". 3. Joining the current thread, or the main thread from another thread, raises `ThreadError`. `join` returns the thread, not what the block computed, so it is the tool for "wait until done" and for chaining (`threads.each(&:join)`). ## Getting results: value `Thread#value` joins the thread and then returns the value of the block's last expression: ```ruby rate = Thread.new { 1.0842 } rate.value # => 1.0842 ``` Calling `value` twice is fine; the second call returns the same object without waiting. ## Exceptions surface on join and value An exception that escapes a thread's block ends that thread, not the program. The exception is stored with the thread and **re-raised in whichever thread calls `join` or `value`**: ```ruby t = Thread.new { Integer("n/a") } t.value # raises ArgumentError in the caller ``` That is why collecting results with `value` is safer than reading a shared variable: a failure cannot pass silently as a missing result. | Call | Returns when the block succeeds | When the block raised | When a limit expires | |---|---|---|---| | `Thread.new { ... }` | the new `Thread`, immediately | (not applicable) | (not applicable) | | `thr.join` | `thr` | re-raises the exception | (no limit) | | `thr.join(2)` | `thr` | re-raises the exception | `nil`, thread still running | | `thr.value` | the block's value | re-raises the exception | (no limit; waits) | ## When the main thread exits The main thread is special. When it reaches the end of the script, or calls `exit`, Ruby **kills every other thread** before the process ends. A thread started with `Thread.new { sleep 1; save_rates }` and never joined will usually be killed before `save_rates` runs. Always join, or call `value` on, threads whose work must finish. ## Collecting 50 page fetches The idiomatic fan-out and fan-in: ```ruby threads = urls.map { |url| Thread.new(url) { |u| Net::HTTP.get(u) } } pages = threads.map(&:value) ``` - Passing `url` as an argument makes each thread's input explicit. Blocks passed to `each` or `map` already get a fresh block parameter per iteration, but a `for` loop does not create a new variable scope, so a thread started inside `for url in urls` that reads `url` later can see a later value. - `threads.map(&:value)` returns the results in the same order as `urls`, regardless of which thread finished first. - If one fetch raised, `map(&:value)` re-raises at that element; handle errors inside the block (returning `nil` or an error object) when partial results are acceptable. For unbounded lists, starting one thread per item is wasteful; a fixed set of workers pulling from a queue is the usual fix. ## Common mistakes - **Reading `join`'s return value as the result.** `threads.map(&:join)` gives you an array of `Thread` objects, not page bodies; use `value`. - **Treating `nil` from `join(limit)` as failure handling.** The thread is still running and still holds whatever it opened; the caller has merely stopped waiting. - **Writing results into a shared Array from each thread** and then reading it without joining. The array may be incomplete, and the order depends on scheduling. Returning the result from the block and collecting it with `value` avoids both problems. - **Starting threads at the bottom of a script** and letting the script end. The main thread's exit kills them mid-work, often before any output appears. - **Joining inside the loop that starts the threads** (`urls.each { |u| Thread.new { fetch(u) }.join }`). Each thread finishes before the next starts, so the program is serial again with extra overhead.

  • Why can threads started inside a Ruby for loop all see the same loop variable?
    A `for` loop does not create a new variable scope, so every block created in its body closes over the one `url` variable, which the loop keeps reassigning. A thread that reads it after the next iteration sees the newer value. `each` gives each iteration a fresh block parameter; passing the value as `Thread.new(url) { |u| ... }` makes it explicit either way.
  • If thr.join(5) returns nil, is the thread stopped?
    No. `nil` only means the limit expired; the thread keeps running. To stop it you must signal it through shared state it checks, or use `Thread#kill`/`Thread#raise`, which interrupt it at an arbitrary point and are risky for code holding resources.

saying these in an interview costs you the question

  • Thread#join returns the value the thread's block computed.
  • join(limit) raises an error when the thread does not finish in time.
  • A nil from join(limit) means the thread was stopped.
  • An exception in a thread is lost; join and value never see it.
  • Ruby waits for all running threads to finish before the program exits.