skip to content

In concurrent-ruby, how do Concurrent::ThreadPoolExecutor and FixedThreadPool differ, and how would you configure one for sending push notifications safely?

level: seniorimportance: should knowfreq 32%

answer

  1. FixedThreadPool: min equals max
  2. max_queue 0 means unbounded
  3. fallback_policy :abort, :discard, :caller_runs
  4. task errors are only logged
  5. shutdown then wait_for_termination

basics

~20 s

FixedThreadPool.new(n) is a ThreadPoolExecutor with min_threads and max_threads both n and an unbounded queue. For push notifications, bound the queue with max_queue, choose a fallback_policy, handle errors inside each task, and shutdown plus wait_for_termination before exit.

solid answer

~40 s

`Concurrent::ThreadPoolExecutor.new(min_threads:, max_threads:, max_queue:, idletime:, fallback_policy:)` is the configurable pool; its defaults are 0 min threads, an effectively unlimited max, `max_queue: 0` (unbounded) and `fallback_policy: :abort`. `Concurrent::FixedThreadPool.new(n)` is the same class with `min_threads` and `max_threads` forced to `n`. For notification delivery I would use a fixed number of threads and a bounded `max_queue`, so a burst cannot grow memory without limit, and pick what happens when the queue is full: `:abort` raises `Concurrent::RejectedExecutionError`, `:discard` makes `post` return false, `:caller_runs` runs the task on the posting thread as back-pressure. A `StandardError` raised inside a posted block is only logged at debug level, so each task must rescue and report its own failures. On shutdown, call `shutdown` and then `wait_for_termination(timeout)` so queued deliveries finish.

go deeper

for a junior

Recall that FixedThreadPool.new(n) runs tasks posted with post on n reusable threads, and that you shut it down at the end.

for a middle

Explain the ThreadPoolExecutor options, why max_queue 0 means unbounded, and what each fallback policy does when the queue fills.

for a senior

Configure a pool for real traffic: bounded queue, deliberate fallback policy, errors rescued inside tasks, orderly shutdown on deploy.

for a principal

Decide when in-process pools are acceptable at all versus a durable job queue, given restarts, lost work and provider rate limits.

## Two classes, one engine `Concurrent::ThreadPoolExecutor` is the general pool. `Concurrent::FixedThreadPool` is a subclass that forces a fixed size; `CachedThreadPool` is another subclass that grows without a limit. | Option | `ThreadPoolExecutor` default | `FixedThreadPool.new(n)` | |---|---|---| | `min_threads` | 0 | `n` (forced) | | `max_threads` | 2,147,483,647 | `n` (forced) | | `max_queue` | 0, meaning unbounded | 0 unless you pass one | | `idletime` | 60 seconds | 60 seconds | | `fallback_policy` | `:abort` | `:abort` | On CRuby the pool first grows to `min_threads`; after that it hands a new task to an idle thread if one exists, otherwise starts a new thread as long as it is below `max_threads`, and only when it can do neither does the task wait in the queue. With the default `max_threads`, a plain `ThreadPoolExecutor.new` therefore keeps creating threads under load. `FixedThreadPool` avoids that surprise by capping the thread count. ## When the queue is full With `max_queue` greater than 0, a task that finds every thread busy and the queue full is handled by the **fallback policy**. The same policy applies to a `post` after `shutdown`: 1. **`:abort`** (default): `post` raises `Concurrent::RejectedExecutionError` and the task is dropped. 2. **`:discard`**: the task is dropped and `post` returns `false`. 3. **`:caller_runs`**: the posting thread runs the task itself, which slows the producer down and acts as back-pressure. Without `max_queue`, none of this ever triggers: a burst of 500,000 notifications just becomes 500,000 queued blocks in memory. ## A pool for push notifications ```ruby require "concurrent" PUSH_POOL = Concurrent::FixedThreadPool.new( 10, max_queue: 5_000, fallback_policy: :caller_runs, name: "push" ) def enqueue_push(token, message) PUSH_POOL.post(token, message) do |t, m| PushClient.deliver(t, m) rescue => e ErrorReporter.notify(e, token: t) end end at_exit do PUSH_POOL.shutdown PUSH_POOL.kill unless PUSH_POOL.wait_for_termination(30) end ``` Choices worth defending in an interview: - **Fixed size** keeps the number of open connections to the push provider predictable. - **Bounded queue plus `:caller_runs`** turns an overload into slower producers instead of unbounded memory or lost messages; choose `:discard` only if dropping a notification is acceptable, and `:abort` if the caller should decide. - **`rescue` inside the task**: when a posted block raises a `StandardError`, the worker catches it and logs it at debug level through the gem's logger, then carries on. Without your own `rescue`, failed deliveries disappear. - **`name:`** labels the pool and its threads, which helps when reading thread dumps. ## Watching a pool in production A pool that silently backs up is a common incident. The executor exposes counters you can export as metrics: - **`queue_length`**: tasks waiting for a thread; a steadily growing value means producers outpace delivery. - **`remaining_capacity`**: slots left before `max_queue` triggers the fallback policy; `-1` means the queue is unbounded. - **`length`** and **`largest_length`**: threads now, and the most ever created. - **`active_count`**: threads currently running a task. - **`scheduled_task_count`** and **`completed_task_count`**: totals since the pool was created. A task that raised is not counted as completed, so a widening gap between the two hints at failures you are not reporting. ## Shutting down `post` also accepts arguments and returns `true` when the task was accepted; `<<` posts a block and returns the pool. At the end of the process: - **`shutdown`** stops accepting new tasks and lets queued and running ones finish. - **`wait_for_termination(timeout)`** blocks until that has happened and returns `true`, or `false` if the timeout passed. - **`kill`** stops threads immediately; the gem's documentation warns its effect on in-flight work is unpredictable, so use it only after a failed wait. The pool's threads are marked as daemon threads by default (`auto_terminate: true`), so a process that simply exits does not wait for them, and anything still queued is lost.

  • Why can `Concurrent::ThreadPoolExecutor.new` with default options create thousands of threads under a burst?
    On CRuby the pool prefers an idle thread, then creates a new thread while it is below `max_threads`, and queues only when it cannot. The default `max_threads` is 2,147,483,647, so a burst of slow tasks keeps adding threads. Setting `max_threads` explicitly, or using `FixedThreadPool`, caps it.
  • A deploy restarts the process and some notifications queued in the pool were never sent. What happened, and how do you fix it?
    The pool's threads are daemon threads by default, so the process exited without waiting, and queued tasks were dropped. Call `shutdown` followed by `wait_for_termination(timeout)` from the shutdown path, and use `kill` only if the wait times out. Anything that must survive a restart belongs in a persistent job queue, not an in-memory pool.

A fixed pool is a post office with ten counters and a waiting room of fixed size: when the room is full, :caller_runs makes the next customer serve themselves, :discard turns them away quietly, and :abort turns them away with a complaint.

saying these in an interview costs you the question

  • Believes a FixedThreadPool rejects tasks once all threads are busy
  • Thinks the default max_queue of 0 means no queueing at all
  • Expects an exception in a posted block to reach the caller of post
  • Calls kill on shutdown instead of shutdown plus wait_for_termination
  • Assumes ThreadPoolExecutor.new defaults to a small, fixed thread count