Skip to content

async I/O: per-request completion events instead of broadcasting on the handle event #130

@EdmondDantes

Description

@EdmondDantes

Summary

The async I/O layer (zend_async_io_t / zend_async_io_req_t) models a
request (req) as a plain data struct — a result holder only. The only
awaitable zend_async_event_t lives on the handle (io->base.event).
Every coroutine doing I/O on a descriptor parks on that one shared event, so
any single operation's completion broadcasts a wakeup to all parked
coroutines.

The bug this caused (found during #129, I/O chaos)

Concurrent writers on one descriptor — e.g. several coroutines fwrite()-ing
to STDERR, i.e. ordinary logging — each submit their own req /
uv_write_t, then park on the shared io->base.event. When one write
completes, io_pipe_write_cb does NOTIFY(&io->base.event, …) → wakes
all parked writers. A spuriously-woken coroutine in php_stdiop_write
did not re-check req->completed, so it dispose()d its own req while
that req's uv_write was still in flight → libuv later wrote into freed
memory → heap corruption / SEGV (uv__async_io calling a NULL callback).

Repro: 4 coroutines × concurrent fwrite(STDERR, …) → 12/12 crashes.

Minimal fix already applied in main/streams/plain_wrapper.c: the caller
now loops do { suspend } while (!req->completed) on both the write and read
await paths. Correct, but it costs O(N²) wakeups for N concurrent writers
and papers over a structural issue. Regression test:
ext/async/tests/io/083-concurrent_async_write.phpt.

Proposed proper solution: make req an awaitable event

libuv already gives per-request granularity for writes: each uv_write has
its own uv_write_t + completion callback, and io_pipe_write_cb already
recovers the exact req via write_req.data. The information is there — it
is just discarded in favour of a broadcast.

Make zend_async_io_req_t carry its own zend_async_event_t. Then:

  • callers always resume_when(coroutine, &req->event, …) — uniform, no loops;
  • write: the per-req libuv callback resolves &req->event directly —
    point wakeup, no broadcast, no O(N²);
  • read: stream reads are per-handle in libuv (uv_read_start, there
    is no uv_read_t); the handle-level io_pipe_read_cb resolves
    io->active_req->event. The req-event is "emulated" by the handle
    callback, but the consumer-facing model stays uniform ("await your req").
    There is only ever one active read req per handle anyway — a libuv
    constraint, unchanged.

This also fully fixes the read path's spurious-wakeup case (a writer
completing on the same fd no longer wakes a reader), so the minimal loop can
be removed everywhere.

Sharp edges to handle

  1. Handle close / EOF / error while a req is pendinglibuv_io_close /
    dispose must resolve the pending req's event with an error / cancellation,
    otherwise the coroutine hangs forever.
  2. Multishot reads — the req is reused across chunks; its event must be
    notified repeatedly and the owner re-armed.
  3. Fire-and-forget writes (free_cb path) — no waiter; the event is
    initialised but never resolved.
  4. dispose racing an in-flight backend callback — the event lives
    inside req, so req must not be freed while libuv still owns
    write_req / fs_req. Reuse the rendezvous already used by
    zend_async_udp_req_t (CALLBACK_DONE / DISPOSE_PENDING flags).

Scope

  • Zend/zend_async_API.h — add a zend_async_event_t to
    zend_async_io_req_t; ABI bump (0.17 → 0.18).
  • ext/async/libuv_reactor.c — initialise the event in libuv_io_read /
    libuv_io_write; resolve it in io_pipe_write_cb / io_pipe_writev_cb /
    io_pipe_read_cb and the file io_file_read_cb / io_file_write_cb;
    resolve pending events in libuv_io_close / libuv_io_event_dispose.
  • Consumers — php_stdiop_read / php_stdiop_write in
    main/streams/plain_wrapper.c park on &req->event; remove the completion
    loop.

Acceptance

  • Regression test ext/async/tests/io/083-concurrent_async_write.phpt keeps
    passing.
  • No O(N²) wakeups under N concurrent writers.
  • Full ext/async/tests plus the fuzzy-tests chaos suite green under both
    the FIFO and the random schedulers.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions