Skip to content

Suggested API change #237

Open
Open
@ColinDKelley

Description

@ColinDKelley

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:

  1. At the outer edge, Async { ... } starts up the reactor that must surround the concurrent code.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions