Skip to content

Provide a dynamically registered phaser #220

Description

@tisonkun

Background

Asyncband currently provides several related coordination primitives, but none covers reusable phases with a participant set that changes over time:

  • Barrier is reusable but fixes the participant count at construction time.
  • Latch is a one-way countdown.
  • WaitGroup tracks a dynamic set of handles, but it represents one completion epoch rather than repeated rounds.

A phaser fills the gap for iterative work whose participants may join or leave between rounds. Java's Phaser is the main prior art: parties register dynamically, arrive separately from waiting, optionally deregister on arrival, and observe a monotonically advancing phase.

This primitive fits Asyncband's runtime-independent scope because progress is driven only by explicit registration, arrival, drop, and polling. It does not need to spawn tasks, obtain an executor, or install timers.

Tracked by #218.

Proposed minimum contract

The first version should provide one non-hierarchical phaser with these capabilities:

  • observe the current phase;
  • register one participant and optionally register several participants at once;
  • record an arrival without waiting;
  • arrive and wait for the phase to advance;
  • arrive and deregister so the participant is excluded from later phases;
  • wait for a particular observed phase to advance without implicitly registering another participant;
  • wake every waiter for a completed phase exactly once.

Registration racing with phase advancement must have one documented linearization point: it joins either the current phase or the next phase, never an ambiguous mixture of both.

The primitive must remain runtime agnostic and use the standard Future/Waker contract.

Design considerations

Participant representation

Java exposes integer counts and treats arriving without registration as a usage error. Rust can make this safer by returning a participant capability from registration. A participant handle could prevent double arrival in one phase and make deregistration explicit in the type system.

The main alternatives are:

  1. counter-oriented methods directly on Phaser, which are compact and match Java but permit more misuse; or
  2. RAII participant handles, which add types but can encode one registration and its lifecycle.

A participant handle is the preferred initial direction, but the public shape should be settled before implementation.

Drop and deregistration

A registered participant that disappears without arriving can prevent a phase from advancing forever. Automatically deregistering on handle drop avoids that leak, but an implicit drop can also advance a phase at a surprising point.

The issue must decide whether drop means:

  • arrive and deregister from the current phase;
  • deregister only from future phases while the current arrival remains required; or
  • a usage error requiring explicit deregistration.

The first option is the most cancellation-resilient, but it needs deterministic tests for races with the last arrival.

Arrival versus waiting cancellation

Arrival and waiting are distinct operations. Once an arrival has been recorded, cancelling the subsequent wait must not retract that arrival. Retrying with the same participant must wait for the already-recorded phase rather than count a second arrival.

This differs from a future whose only effect happens at completion. The cancellation section of the public API must state the exact point at which arrival becomes committed.

Zero participants

Java terminates a phaser by default when deregistration reduces the participant count to zero. Asyncband could instead leave an empty phaser dormant so later registrations can start another phase. Dormancy is simpler and more reusable, while explicit termination can be added later if a concrete need appears.

Phase identity and wraparound

Waiters should compare phase identity rather than only an arrived == registered predicate, otherwise a delayed waiter can confuse two rounds. The public phase type, wraparound behavior, and ordering guarantees need to be documented. A wrapping integer is acceptable if equality, rather than total ordering across the wrap, is the synchronization contract.

Fairness and wake-up behavior

All waiters for the completed phase should become runnable. Registration and arrival operations themselves do not require scheduling fairness, but waiter storage must handle cancellation without retaining stale wakers or losing a phase transition.

Key trade-offs

  • RAII participant safety versus a smaller counter-oriented API.
  • Automatic drop cleanup versus surprising implicit phase advancement.
  • A dormant zero-party state versus Java-style automatic termination.
  • A combined arrive_and_wait future versus an explicit arrival token followed by waiting.
  • A compact shared state versus generation-aware waiter bookkeeping needed for cancellation safety.

Non-goals

The first version should not include:

  • parent/child or tiered phasers;
  • executor, task, or thread integration;
  • timeout APIs;
  • user-defined barrier actions;
  • forced termination or recovery policies;
  • priority scheduling.

These can be evaluated independently after the core contract is proven.

Acceptance criteria

  • The public contract explains registration, arrival, deregistration, phase advancement, drop, and cancellation behavior.
  • Dynamic join and leave behavior is covered across multiple phases.
  • A participant cannot accidentally contribute two arrivals to one phase.
  • Cancelling a waiter neither retracts a committed arrival nor leaks its waker.
  • Races between registration, the final arrival, deregistration, and waiter cancellation have deterministic regression tests.
  • No executor, timer, or runtime-specific dependency is introduced.
  • Repository build, test, lint, and formatting workflows pass through cargo x.

Activity

  1. ButterBright commented on Aug 30, 2026

    @ButterBright
    Member

    Hi, I'd like to contribute. Here is my proposed design.

    Public API

    Use a phase newtype instead of raw u64 so the public contract can say phases are identity tokens, not ordered timestamps across wraparound.

    pub struct Phaser;
    
    #[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
    pub struct Phase(u64);
    
    impl Phase {
        pub const fn get(self) -> u64;
    }
    
    impl Phaser {
        pub const fn new() -> Self;
        pub fn phase(&self) -> Phase;
        pub fn registered_parties(&self) -> u32;
        pub fn arrived_parties(&self) -> u32;
        pub fn unarrived_parties(&self) -> u32;
        pub fn register(self: &std::sync::Arc<Self>) -> PhaserParticipant;
        pub fn register_many(self: &std::sync::Arc<Self>, parties: u32) -> Vec<PhaserParticipant>;
        pub async fn wait_for_advance(&self, phase: Phase) -> Phase;
    }
    
    pub struct PhaserParticipant;
    
    impl PhaserParticipant {
        pub fn arrive(&mut self) -> Phase;
        pub async fn arrive_and_wait(&mut self) -> Phase;
        pub fn arrive_and_deregister(self) -> Phase;
    }

    State Model

    Represent internal state as one lock-protected record: phase: Phase, registered: u32, unarrived: u32, and waiters: WaitSet.

    Registering n parties under the state lock increments both registered and unarrived, records the observed phase on each returned participant, and panics on u32 overflow.

    Arriving a participant is idempotent per tracked phase: if it has not arrived in the tracked phase, decrement unarrived once and mark the participant arrived; if already arrived, do not change counts.

    Completing a phase happens while holding the state lock when registered > 0 && unarrived == 0; reset unarrived = registered, advance phase = phase.wrapping_add(1), clear each continuing participant's arrived flag when it next observes the new phase, take all waiters, release the lock, then call wake_all.

    Arrive-and-deregister under the lock removes the participant from registered; if the participant had not arrived for the tracked phase, also remove its outstanding arrival requirement by decrementing unarrived; if it had arrived, only decrement registered.

    Dropping a still-registered participant is exactly arrive-and-deregister for its current tracked phase, including possible phase advancement if it was the last outstanding party.

    Zero registered parties are dormant and reusable: reaching zero does not self-advance repeatedly, but a wait on the just-completed phase can still be woken by the deregistration that reached zero, and later registration joins the current dormant phase.

    Waiting for an observed phase never registers, never arrives, and on cancellation only unregisters its WakerToken.

    Arrival in arrive_and_wait commits on first poll. The participant stores a separate pending-wait phase until the wait completes; cancelling after arrival does not retract it, and retrying with the same participant waits for that stored phase even if the phaser has already advanced, rather than arriving in the next phase.

  2. tisonkun commented on Aug 30, 2026

    @tisonkun
    MemberAuthor

    @ButterBright Thanks for your contribution! If possible, you can directly open a pull request where inline comments become easier.

  3. tisonkun commented on Sep 21, 2026

    @tisonkun
    MemberAuthor

    This should be resolved by #250 while some follow-ups can be continously applied for improvements.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions