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 concurrent Async::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.
Problem Statement
Recently I've worked with a team of ~6 developers converting two services from
EventMachine+SynchronytoAsync. That has gone well, but one detail of theAsyncAPI has tripped us all up at first and in some cases, more than once.The confusion arises from
Async { ... }being overloaded:Async { ... }starts up the reactor that must surround the concurrent code.Async { ... }creates a concurrentAsync::Taskwithin 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:
The wrapper code that creates the reactor looks like:
Proposal
An ideal solution would separate the overloaded cases so that the contract can be implicitly enforced by the
Asyncgem 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:With this contract, case 2 could automatically raise an exception on this line if it is run without a surrounding reactor:
Migration
We obviously would not want to introduce this proposal as a breaking change. Instead we could phase it in:
Phase 1
Introduce
Async.runas 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 asAsync.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 theAsync::ReactorNotRunningexception as shown above.