skip to content

In Ruby with concurrent-ruby, how do Concurrent::Promises.future, zip and then run tasks concurrently and combine results, and what does value return on failure?

level: middleimportance: should knowfreq 40%

answer

  1. future starts at once on :io
  2. zip waits for all, yields splatted values
  3. then runs only after fulfilment
  4. value gives nil when rejected
  5. value! raises the reason

basics

~20 s

Concurrent::Promises.future { ... } starts a task immediately on the global :io pool; Promises.zip(a, b) waits for all and then { |x, y| ... } receives the values. On rejection value returns nil silently, while value! raises the error.

solid answer

~40 s

`Concurrent::Promises.future(*args) { |*args| ... }` returns a `Future` and starts the block right away on the default executor, the global `:io` pool (`future_on(:fast, ...)` picks another). `Concurrent::Promises.zip(f1, f2)` (alias of `zip_futures`, also `f1 & f2`) resolves once every input has resolved; it is fulfilled with an Array of values only if all were fulfilled. `then { |a, b| ... }` runs after fulfilment and receives a zipped Array splatted into arguments; if its block raises, the new future is rejected. `rescue { |reason| ... }` handles rejection. The trap is reading results: `value` blocks and returns `nil` when the future was rejected (and on timeout), while `value!` raises the reason, so a failed lookup silently becomes `nil` if you use `value`.

code

ruby · 8 lines
ruby
require "concurrent"

f = Concurrent::Promises.future { raise ArgumentError, "bad token" }

f.value      # => nil (rejected, error hidden)
f.rejected?  # => true
f.reason     # => #<ArgumentError: bad token>
f.value!     # raises ArgumentError: bad token

go deeper

for a junior

Recall that Promises.future starts work in the background and value waits for it; remember that value returns nil when the task failed.

for a middle

Explain zip waiting for all inputs, then receiving splatted values, rescue for rejection, and why value! is the safe way to read a result.

for a senior

Build a fan-out with a bounded executor through future_on, failures surfaced with value! or rescue, and timeouts checked explicitly, since a timed-out value! returns nil.

for a principal

Decide where futures belong in a codebase versus background jobs, and set conventions for executors, timeouts and error propagation.

## The shape of the API `Concurrent::Promises` is a module of factory methods. You never call `Future.new`; you call: - **`Concurrent::Promises.future(*args) { |*args| ... }`**: creates a `Future` and starts evaluating the block immediately on the default executor, `:io`, which is the gem's global cached thread pool. - **`Concurrent::Promises.future_on(executor, *args) { ... }`**: the same on a chosen executor: `:io`, `:fast`, `:immediate` or your own pool. - **`Concurrent::Promises.zip(*futures)`**: an alias of `zip_futures`; also written `f1 & f2`. A `Future` is **pending**, then **fulfilled** with a value or **rejected** with a reason (usually an exception). ## Fanning out the lookups for a push notification Before sending a notification, a service needs the user's device tokens and their notification preferences, which come from two slow calls: ```ruby require "concurrent" tokens = Concurrent::Promises.future(user_id) { |id| DeviceTokens.for(id) } prefs = Concurrent::Promises.future(user_id) { |id| Preferences.for(id) } delivery = Concurrent::Promises.zip(tokens, prefs).then do |list, pref| pref.muted? ? [] : list.map { |t| PushClient.deliver(t, message) } end delivery.value!(5) ``` Both lookups run concurrently on the `:io` pool. `zip` produces a future that resolves when both inputs have resolved, and `then` receives the zipped values **splatted** into separate block arguments, `list` and `pref`. ## How each combinator behaves | Method | Runs its block when | Result | |---|---|---| | `future.then { \|v\| ... }` | the future is fulfilled | new future with the block's value, or rejected if the block raises | | `future.rescue { \|reason\| ... }` | the future is rejected | new future fulfilled with the block's value | | `Promises.zip(a, b)` | all inputs have resolved | fulfilled with `[va, vb]` only if every input was fulfilled | | `Promises.any(a, b)` | the first input resolves | that input's result | `then` skips its block when the source was rejected, but the new future still resolves, rejected with the same reason, so the failure travels down the chain until a `rescue` or a `value!` meets it. A zipped future is **not** fail-fast: it waits for every input, and if some were rejected its reason is an Array of reasons with `nil` in the fulfilled positions. ## Reading the result: value vs value! This is the part interviewers probe: 1. **`value(timeout = nil, timeout_value = nil)`** blocks until resolved and returns the value if fulfilled, **`nil` if rejected**, and `timeout_value` (default `nil`) if the timeout passes. The exception is not raised. 2. **`value!(timeout = nil, timeout_value = nil)`** does the same but **raises** the rejection reason. For a zip with several failures, it raises `Concurrent::MultipleErrors`. 3. **`reason`** returns the exception of a rejected future and `nil` for a fulfilled one; **`result`** returns the triplet `[fulfilled?, value, reason]`. 4. **`wait`** only blocks; `fulfilled?` and `rejected?` check the state afterwards. So `delivery.value` on a failed lookup returns `nil`, and code that does `delivery.value.each` then raises a confusing `NoMethodError` far from the real cause, or, worse, treats `nil` as "nothing to send". Use `value!` or check `rejected?` and `reason`. ## Choosing the executor Every factory method has an `_on` variant that takes an executor. The three symbols the gem understands: | Symbol | Resolves to | Suited for | |---|---|---| | `:io` (default) | `Concurrent.global_io_executor`, a `CachedThreadPool` | blocking work: HTTP calls, database queries | | `:fast` | `Concurrent.global_fast_executor`, a `FixedThreadPool` of at least 2 threads, sized from the processor count | short CPU-bound callbacks that never block | | `:immediate` | `Concurrent.global_immediate_executor` | runs on the calling thread; meant for tests and debugging | You can also pass any executor object, such as your own `FixedThreadPool`. `then` and `rescue` run on the future's default executor unless you use `then_on` or `rescue_on`. ## Practical rules - Start futures for independent work, then combine with `zip` instead of calling `value` on each in turn. - Keep blocks free of shared mutable state; pass inputs as arguments and return results. - Always read the end of a chain with `value!` or handle it with `rescue`; with a timeout, remember that `value!(5)` returns its `timeout_value` (default `nil`) rather than raising when time runs out, so check `resolved?` afterwards. - Remember that the `:io` pool grows threads on demand; for heavy fan-out, pass your own bounded pool with `future_on`.

  • One of three zipped futures is rejected after 10 ms while the others take 2 seconds. When does the zipped future resolve, and with what?
    `Promises.zip` waits until every input has resolved, so it resolves after about 2 seconds, not 10 ms. Because one input was rejected, the zipped future is rejected; its reason is an Array of reasons with `nil` for the fulfilled inputs, and `value!` raises that single error. Use `Promises.any` or per-future `rescue` if you need to react to the first failure.
  • What is the difference between `then` and `rescue` on a `Concurrent::Promises` future?
    `then` runs its block only when the future is fulfilled, passing the value; `rescue` runs only when it is rejected, passing the reason. Each returns a new future. A `then` on a rejected future skips its block and yields a future rejected with the same reason; a `rescue` on a fulfilled future passes the value through unchanged.
  • Why might you pass your own executor with `future_on` instead of using `future`?
    `future` uses the global `:io` executor, a `CachedThreadPool` that creates a new thread whenever no idle one is available. Fanning out thousands of futures can therefore start thousands of threads. `future_on(pool, ...)` with a bounded `FixedThreadPool` caps the concurrency and keeps unrelated work from competing for the global pool.

saying these in an interview costs you the question

  • Expects future.value to raise the exception of a rejected future
  • Thinks Promises.zip rejects as soon as the first input fails
  • Believes Promises.future waits until value is called to start
  • Receives a zipped Array as one block argument instead of splatted values
  • Wraps a rescue around the future call site to catch errors from its block