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
- Handle close / EOF / error while a req is pending —
libuv_io_close /
dispose must resolve the pending req's event with an error / cancellation,
otherwise the coroutine hangs forever.
- Multishot reads — the
req is reused across chunks; its event must be
notified repeatedly and the owner re-armed.
- Fire-and-forget writes (
free_cb path) — no waiter; the event is
initialised but never resolved.
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.
Summary
The async I/O layer (
zend_async_io_t/zend_async_io_req_t) models arequest (
req) as a plain data struct — a result holder only. The onlyawaitable
zend_async_event_tlives 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()-ingto
STDERR, i.e. ordinary logging — each submit their ownreq/uv_write_t, then park on the sharedio->base.event. When one writecompletes,
io_pipe_write_cbdoesNOTIFY(&io->base.event, …)→ wakesall parked writers. A spuriously-woken coroutine in
php_stdiop_writedid not re-check
req->completed, so itdispose()d its ownreqwhilethat req's
uv_writewas still in flight → libuv later wrote into freedmemory → heap corruption / SEGV (
uv__async_iocalling a NULL callback).Repro: 4 coroutines × concurrent
fwrite(STDERR, …)→ 12/12 crashes.Minimal fix already applied in
main/streams/plain_wrapper.c: the callernow loops
do { suspend } while (!req->completed)on both the write and readawait 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
reqan awaitable eventlibuv already gives per-request granularity for writes: each
uv_writehasits own
uv_write_t+ completion callback, andio_pipe_write_cbalreadyrecovers the exact
reqviawrite_req.data. The information is there — itis just discarded in favour of a broadcast.
Make
zend_async_io_req_tcarry its ownzend_async_event_t. Then:resume_when(coroutine, &req->event, …)— uniform, no loops;&req->eventdirectly —point wakeup, no broadcast, no O(N²);
uv_read_start, thereis no
uv_read_t); the handle-levelio_pipe_read_cbresolvesio->active_req->event. The req-event is "emulated" by the handlecallback, 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
libuv_io_close/dispose must resolve the pending req's event with an error / cancellation,
otherwise the coroutine hangs forever.
reqis reused across chunks; its event must benotified repeatedly and the owner re-armed.
free_cbpath) — no waiter; the event isinitialised but never resolved.
disposeracing an in-flight backend callback — the event livesinside
req, soreqmust not be freed while libuv still ownswrite_req/fs_req. Reuse the rendezvous already used byzend_async_udp_req_t(CALLBACK_DONE/DISPOSE_PENDINGflags).Scope
Zend/zend_async_API.h— add azend_async_event_ttozend_async_io_req_t; ABI bump (0.17 → 0.18).ext/async/libuv_reactor.c— initialise the event inlibuv_io_read/libuv_io_write; resolve it inio_pipe_write_cb/io_pipe_writev_cb/io_pipe_read_cband the fileio_file_read_cb/io_file_write_cb;resolve pending events in
libuv_io_close/libuv_io_event_dispose.php_stdiop_read/php_stdiop_writeinmain/streams/plain_wrapper.cpark on&req->event; remove the completionloop.
Acceptance
ext/async/tests/io/083-concurrent_async_write.phptkeepspassing.
ext/async/testsplus thefuzzy-testschaos suite green under boththe FIFO and the random schedulers.