Description
Problem Statement
Recently I've worked with a team of ~6 developers converting two services from EventMachine
+ Synchrony
to Async
. That has gone well, but one detail of the Async
API has tripped us all up at first and in some cases, more than once.
The confusion arises from Async { ... }
being overloaded:
- At the outer edge,
Async { ... }
starts up the reactor that must surround the concurrent code. - Inside the reactor block,
Async { ... }
creates a concurrentAsync::Task
within the reactor.
When writing concurrent code (case 2), it is necessary that it be surrounded by a reactor (case 1). If that is missing, case 2 will be misconstrued as case 1, and it won't be concurrent at all. This is an easy mistake to make, especially when writing tests.
Mitigation
To address this risk, we like to follow Design by Contract, which in this case means that concurrent code (case 2) should ensure that there is a surrounding reactor before it proceeds. Here's how we've done that, crudely:
def process_jobs(jobs)
Async::Task.current? or raise ArgumentError, "must start up reactor first with surrounding 'Async { ... }"
jobs
.map { Async { _1.process! } }
.map(&:wait)
end
The wrapper code that creates the reactor looks like:
Async do
process_jobs(jobs)
end
Proposal
An ideal solution would separate the overloaded cases so that the contract can be implicitly enforced by the Async
gem without clients needing to write manual code each time.
Since case 2 appears much more frequently in code than case 1, let's leave case 2 as-is, but change case 1 to Async.run { ... }
. So the wrapper for the above code would read:
Async.run do
process_jobs(jobs)
end
With this contract, case 2 could automatically raise an exception on this line if it is run without a surrounding reactor:
.map { Async { _1.process! } }
=> Async::ReactorNotRunning: You must start a reactor first with `Async.run { ... }` surrounding this code.
Migration
We obviously would not want to introduce this proposal as a breaking change. Instead we could phase it in:
Phase 1
Introduce Async.run
as a preferred but optional way to create the reactor at the outer level. Update all documentation to show it as the preferred way to create the reactor.
Phase 2
Deprecate Async { ... }
being called without a surrounding reactor. In the deprecation warning, indicate that it should be written as Async.run { ... }
if it is the outer level that is creating the reactor.
Phase 3
Complete the deprecation: If Async { ... }
is called without a surrounding reactor, raise the Async::ReactorNotRunning
exception as shown above.