Timeout.timeout interrupts a thread from outside¶
Level: 301 · anyone who wrapped a slow call in Timeout.timeout and wants to know what actually happens to it
One line: Timeout.timeout(0.2) { sleep 5 } raises Timeout::Error after a fifth of a second because a watcher thread calls Thread#raise on yours — which is why the interrupt can land in the middle of anything, why ensure blocks still run, and why the same abilities exist on Thread itself (kill, raise, wakeup, run, join(limit)); Python has none of them, and a worker there stops only when its wait times out or it checks a flag.
Timeout.timeout(seconds) { } runs the block in the current thread and, if it has not returned in time, raises Timeout::Error with the message execution expired (row 2). The mechanism is visible in the thread list: before the first call there is only the main thread, afterwards a thread named Timeout stdlib thread (rows 1 and 3), which sleeps until a deadline passes and then calls Thread#raise on the thread that asked. The exception arrives inside the block as a private Timeout::ExitException, unwinds it — running its ensure blocks — and is turned into Timeout::Error at the Timeout.timeout boundary (row 7). Because it is delivered by Thread#raise, it can land anywhere: between two statements that had to happen together, inside an ensure, in the middle of a library that was not written to be interrupted (Not machine-checked here.). That is why the safe timeouts are the ones built into a wait — Queue#pop(timeout:) (row 15), join(limit) (row 11), ConditionVariable#wait(mutex, timeout), IO#timeout= — and why Timeout.timeout is the tool of last resort for code you do not control. A block that finishes in time returns its value (row 4), nil or 0 means no limit (row 5), and a class and message of your own can replace Timeout::Error (row 6), which is otherwise a RuntimeError — so a bare rescue catches it (row 8). A busy loop with no sleep is interrupted too (row 9): the watcher does not need the block to block.
The Thread methods underneath are the rest of the page. Thread#kill ends a thread from outside, and its ensure blocks run on the way out (row 10). join(0.2) on a thread that needs five seconds returns nil instead of the thread, and the thread is still alive (row 11) — the caller chooses what to do next. Thread#wakeup and Thread#run end a sleep early, whether it had a limit or not (rows 12 and 13). Thread#raise is the primitive that Timeout is built on: the target thread's sleep ends with a RuntimeError it can rescue (row 14). Every limit in the program is 0.2 seconds against a 5-second sleep, so a slow machine cannot change a row, and every thread is killed or joined before the next row prints.
Verified output of timeouts_and_killing_threads_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
1. threads before any Timeout.timeout call (names) [nil]
2. Timeout.timeout(0.2) { sleep 5 } Timeout::Error: execution expired
3. after: the watcher thread exists (names) [nil, "Timeout stdlib thread"]
4. a block that finishes in time returns its value :fast
5. timeout(nil) / timeout(0) mean no limit :a / :b
6. your own class and message ArgumentError: too slow
7. what the block sees / what the caller sees ["inside rescued Timeout::ExitException", "inside ensure ran", "outside rescued Timeout::Error"]
8. Timeout::Error's ancestors [Timeout::Error, RuntimeError, StandardError]
9. a busy loop (no sleep) is interrupted too Timeout::Error
10. Thread#kill: ensure runs; status / alive? "ensure ran"; false / false
11. join(0.2) on a 5 s thread / alive? nil / true
12. Thread#wakeup ends a sleep with no limit :woke
13. Thread#run ends a timed sleep early :sleep_cut_short
14. Thread#raise delivers an exception into a thread "rescued RuntimeError: wake up"
15. Queue#pop(timeout: 0.2) on an empty queue nil
Reading the output¶
Rows 1 and 3 bracket the first Timeout.timeout call: [nil] — the main thread has no name — and then [nil, "Timeout stdlib thread"]. Row 2 is the error, Timeout::Error: execution expired. Row 4 prints :fast, row 5 :a / :b for the two spellings of "no limit", and row 6 ArgumentError: too slow from the optional class and message arguments. Row 7 is the sequence inside and out: the block rescued Timeout::ExitException (the program rescues Exception there only to show it, then re-raises), its ensure ran, and the caller rescued Timeout::Error. Row 8 is [Timeout::Error, RuntimeError, StandardError] and row 9 confirms that loop { n += 1 } was interrupted.
Row 10 reads "ensure ran"; false / false: the killed thread ran its ensure, its status is false and it is no longer alive. Row 11 is nil / true — join gave up, the thread did not. Rows 12 and 13 print the values the woken threads returned, :woke from a sleep with no limit and :sleep_cut_short from a five-second one that run ended at once. Row 14 shows the raised RuntimeError: wake up rescued inside the target thread, and row 15 that pop(timeout: 0.2) on an empty queue returns nil.
Compared with Python¶
Python cannot interrupt a thread. There is no Timeout.timeout, no Thread.kill (row 10) and no Thread.raise (row 14) — the only way to inject an exception into a running thread is the C-level PyThreadState_SetAsyncExc, which is not a supported idiom (Not machine-checked here.). What Python has is a timeout on every wait: Future.result(timeout=0.2) raises TimeoutError (row 2), Thread.join(0.2) returns None with the thread still alive (row 11), Event.wait(0.2) returns False (row 12) and queue.get(timeout=0.2) raises queue.Empty (row 15). In every case the caller moves on and the worker keeps running: row 2 prints running() True after the timeout, and row 7 shows the task's finally running only after its own wait ended, never because the caller gave up. Rows 1 and 3 list MainThread and the pool's worker; no watcher thread appears, because nothing watches. TimeoutError is a builtin OSError (row 8), the same object as concurrent.futures.TimeoutError (row 6), and there is no custom class to substitute.
A busy loop that never waits cannot be stopped at all unless it checks a flag (row 9), and a time.sleep(5) cannot be cut short — the twin uses Event.wait(5), which a set() from another thread ends at once (row 13). signal.alarm is the one true interrupt Python offers, and it is a process-wide signal delivered to the main thread only — the Python library's Ctrl-C is a signal ↗ shows how a signal handler runs (Not machine-checked here.). socket.settimeout and asyncio.wait_for are the other two: a timeout built into the wait, which is the design this page recommends in Ruby as well.
Verified output of timeouts_and_killing_threads_py.py — regenerated by tools/run_examples.py, never hand-typed.
1. threads before any timeout (names) ['MainThread']
2. future.result(timeout=0.2) on a 5 s wait TimeoutError; the task is still running: True
3. after: no watcher, only the pool's worker (names) ['MainThread', 'ThreadPoolExecutor-0_0']
4. a task that finishes in time returns its value 'fast'
5. result(timeout=None) waits / timeout=0 gives up at once True / TimeoutError
6. your own class and message? no: always TimeoutError (futures.TimeoutError is TimeoutError: True)
7. what the task sees / what the caller sees ['outside: TimeoutError, task still running', 'inside: the wait ended normally', 'inside: finally ran']
8. TimeoutError's bases ['TimeoutError', 'OSError', 'Exception']
9. a busy loop cannot be interrupted; it checks a flag stopped when it saw the flag
10. Thread.kill exists? / the alternative False / daemon=True threads die with the process
11. join(0.2) on a 5 s thread / is_alive() None / True
12. Event.wait(0.2) before set / after set False / True
13. no Thread.run: time.sleep(5) cannot be cut short; Event.wait(5) can returned True once set
14. Thread.raise exists? False (PyThreadState_SetAsyncExc is the C-level hack)
15. queue.get(timeout=0.2) on an empty queue Empty
Try it¶
- Run a slow job under
Timeout.timeouttwice: once with a limit it cannot meet and once with one it can. RescueTimeout::Errorand print its message, make anensureprint a cleanup line both times, and print the job's value the second time. - Write a
with_deadline(seconds) { |deadline| ... }helper that does not useTimeout: it hands the block aQueue-based deadline object whoseexpired?the block checks between steps. Show a three-step job stopping after the step during which the deadline passed, and say which of this page's dangers your helper avoids.
Solution to 1
timeouts_and_killing_threads_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.
# Kata: a slow job under Timeout.timeout, twice -- once with a limit it
# cannot meet, once with one it can. The rescue prints the error, the ensure
# proves the cleanup ran both times, and the value comes back the second time.
require "timeout"
def slow_job(seconds)
sleep seconds
"finished a #{seconds} s job"
end
[[0.2, 5], [2, 0.1]].each do |limit, work|
result = Timeout.timeout(limit) { slow_job(work) }
puts "limit #{limit} s, job #{work} s: #{result}"
rescue Timeout::Error => e
puts "limit #{limit} s, job #{work} s: #{e.class} (#{e.message})"
ensure
puts " cleanup ran"
end
Verified output of timeouts_and_killing_threads_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.
See also¶
- Threads are OS threads that take turns under the GVL —
join,statusandvalueon the threads killed here - Mutex guards, Queue hands over, ConditionVariable waits —
pop(timeout:)andwait(mutex, timeout), the safe timeouts forkcopies the process;waitcollects its status — killing a process instead of a thread- rescue, ensure, else and retry — why
ensureruns when a thread is killed or interrupted - StandardError is the default —
Timeout::Erroris aRuntimeError, so a barerescuecatches it - exit, at_exit and SystemExit —
InterruptandSignalException, the interrupts that come from outside the process - Ctrl-C is a signal ↗ — the Python library on
signal, the one interrupt Python has - What does a wait return when the time runs out? ↗ — the same question across six languages in the Concurrency library
- A
SizedQueuethat refuses is the 503 behind "queue full" — Passenger answers a Rails app's requests from a fixed pool of processes, 6 by default, through a request…