In Ruby, how do Thread::Queue and Thread::SizedQueue coordinate a bounded download queue, and how do you shut its workers down?
answer
- pop blocks while empty
- SizedQueue push blocks while full
- close: pop returns nil when drained
- push after close: ClosedQueueError
- pop(timeout:) returns nil, Ruby 3.2
basics
~20 sThread::Queue is a thread-safe FIFO whose pop blocks until an item arrives; Thread::SizedQueue also blocks push when full, bounding memory. Calling close lets workers drain the queue, after which pop returns nil and the workers exit.
solid answer
~40 s`Thread::Queue` (also `Queue`) is a thread-safe FIFO: `push`/`<<` adds an item, and `pop` removes one, **blocking** while the queue is empty, so workers need no mutex or condition variable of their own. `Thread::SizedQueue.new(20)` adds a capacity: `push` blocks while 20 items are waiting, which stops a fast producer from queueing every URL in memory ahead of slow downloads. For shutdown, the producer calls `queue.close`: items already queued are still delivered, then `pop` returns `nil` instead of blocking, so `while (url = queue.pop)` loops end; any later `push` raises `ClosedQueueError`. Since Ruby 3.2, `pop(timeout: 5)` returns `nil` after five seconds, and `SizedQueue#push(item, timeout: 5)` returns `nil` if no space frees up.
code
ruby · 10 linesq = Thread::SizedQueue.new(2)
q << :a << :b
q.push(:c, true) # ThreadError: queue full
q.push(:c, timeout: 0.1) # => nil (no room within 0.1 s)
q.close
q.pop # => :a (queued items still delivered)
q.pop # => :b
q.pop # => nil (closed and empty)
q << :d # ClosedQueueError: queue closedgo deeper
Recall that Queue#pop waits for an item and Queue is safe to share between threads without a mutex.
Explain SizedQueue back-pressure, close semantics (drain, then nil; ClosedQueueError on push) and the Ruby 3.2 timeout: keyword.
Show a worker pool that bounds memory, shuts down without sentinels, handles a failing worker, and cannot deadlock on a forgotten close.
Decide where queues belong in a service: in-process queues versus an external job system, and what is lost on a crash.
## Queue: a thread-safe FIFO that blocks `Thread::Queue` is part of core Ruby; the class is also bound to the top-level constant `Queue`. It hands objects from one thread to another in first-in, first-out order and does its own locking. - `push(obj)`, with aliases `<<` and `enq`, appends and returns the queue. - `pop`, with aliases `shift` and `deq`, removes the oldest item. If the queue is empty the calling thread **sleeps** until an item arrives. - `pop(true)` does not wait: on an empty queue it raises `ThreadError` ("queue empty"). - `pop(timeout: 2)` waits at most two seconds and then returns `nil` (Ruby 3.2+). - `size`/`length`, `empty?` and `num_waiting` report state; treat them as hints, because they can change the moment after you read them. A plain `Queue` is **unbounded**: `push` never waits. ## SizedQueue: adding back-pressure `Thread::SizedQueue.new(max)` is a subclass with a capacity: - `push` blocks while `size == max`, until a consumer pops; - `push(obj, true)` raises `ThreadError` ("queue full") instead of waiting; - `push(obj, timeout: 2)` returns `nil` if no slot frees within two seconds (Ruby 3.2+); - `max` and `max=` read and change the capacity. ## The bounded download queue ```ruby require "net/http" jobs = Thread::SizedQueue.new(20) workers = 4.times.map do Thread.new do while (url = jobs.pop) File.write(File.basename(url.path), Net::HTTP.get(url)) end end end urls.each { |u| jobs << u } # waits whenever 20 are pending jobs.close # no more work workers.each(&:join) ``` Four workers run at most four downloads at once. The producer can read URLs from a huge file without holding them all in memory, because `<<` waits whenever twenty are pending. ## Shutting down with close After `queue.close`: | Operation | Behaviour | |---|---| | `closed?` | `true`; a closed queue cannot be reopened | | `push` / `<<` | raises `ClosedQueueError` | | `pop` with items left | returns the next item as usual | | `pop` on an empty closed queue | returns `nil` immediately, waking all waiting consumers | | `pop(true)` on an empty closed queue | raises `ThreadError` | | producers waiting in `SizedQueue#push` | woken with `ClosedQueueError` | `ClosedQueueError` is a subclass of `StopIteration`, so a producer inside `loop do ... end` stops quietly when the queue is closed under it. One caveat: `nil` from `pop` means "closed and drained" (or a timeout), so never push `nil` as a real work item. Before `close` existed, workers were stopped by pushing one sentinel per worker; `close` replaces that bookkeeping. ## Handling a failing worker An exception that escapes a worker's block kills that worker only. The others keep popping, so the queue still drains, just more slowly, and the failure appears when `join` re-raises it. For a download pool that should survive a bad URL, rescue per job inside the loop: ```ruby while (url = jobs.pop) begin download(url) rescue StandardError => e failures << [url, e] # failures is another Thread::Queue end end ``` Collecting failures through a second queue keeps the error list thread-safe without an extra mutex, and the main thread can read it after `join`. ## Deadlock detection If every thread in the process is blocked in `pop` on queues nobody will ever push to, including the main thread, CRuby notices and raises a `fatal` error in the main thread: `No live threads left. Deadlock?`. A forgotten `close` followed by `workers.each(&:join)` with no producer left is the classic way to meet it, although other live threads, such as a server's, can hide the problem and leave the process hanging. ## Other details - `Queue.new([1, 2, 3])` starts pre-filled from any enumerable. - `Queue#freeze` raises `TypeError` (since 3.3); a queue is meant to be mutated. - `clear` discards pending items; it does not close the queue.
- Why is pushing nil as a work item into a Ruby Queue a bad idea?`pop` also returns `nil` when the queue is closed and empty, or when `pop(timeout:)` expires, and the idiomatic worker loop is `while (job = queue.pop)`. A real `nil` item would end a worker early and be indistinguishable from shutdown. Wrap such values or use a distinct object.
- What happens to producers blocked in SizedQueue#push when the queue is closed?They are woken and `push` raises `ClosedQueueError`. Because that class inherits from `StopIteration`, a producer running inside `loop do ... end` simply leaves the loop; elsewhere, rescue it where the producer decides to stop.
saying these in an interview costs you the question
- Queue#pop returns nil immediately when the queue is empty.
- Closing a queue discards the items still in it.
- Thread::Queue needs a Mutex around push and pop.
- A plain Queue blocks push once it holds many items.
- Pushing to a closed queue is silently ignored.