How Linux::Event::Async Became Faster

The largest improvement did not come from changing the public async/await API. It came from changing how native Stream delivery interacts with Future::AsyncAwait.

The original bottleneck

Previously, every framed message followed this path:

native framer -> wake coroutine -> consume one message -> suspend again

For 100,000 messages, that caused approximately:
100,000 coroutine suspensions
100,000 continuation callbacks
200,000 AWAIT_IS_READY checks

The underlying native consumer was already fast. Repeatedly crossing the native-to-Perl await boundary for every individual message was the expensive part.

The optimizations

Added an end-of-native-drain flush hook

The generic Linux::Event consumer ABI gained an optional flush operation. A consumer can now receive all messages produced during one native socket drain and request a single wakeup when that drain finishes.
The ABI remains compatible with older consumers through its existing struct_size negotiation.

Added bounded native prefetching

Linux::Event::Async::Stream now keeps a small native receive ring containing up to:
64 additional messages
approximately 256 KiB of payload
The boundary may overshoot by one complete frame so a message is never split merely to satisfy the limit.

Batched coroutine wakeups

Messages produced during one native drain are placed into the prefetch ring. The coroutine is resumed once at the drain boundary instead of once for every message.
After resuming, repeated calls to:

await $stream->recv

usually complete immediately from the native queue without suspending the coroutine again.

Preserved backpressure and correctness

The queue is deliberately bounded. When the consumer stops draining it, normal Stream pausing and kernel backpressure still apply.

The implementation also preserves:

  • message ordering
  • cancellation behavior
  • receive-generation safety
  • queued messages when a consumer temporarily stops
  • correct EOF delivery after prefetched messages
  • Effect on the await path


For 100,000 messages at the 2.5 KB payload size, coroutine suspensions fell from approximately: 100,000 -> 1,910

That means about 98.1% of receives completed immediately instead of paying a full suspend/resume cycle.

Why it matters

The public programming model remains straightforward:

while (my $message = await $stream->recv) {
process($message);
}

Internally, however, Linux::Event now amortizes the Future/async/await machinery across a batch of messages produced by the same native drain. This is the main reason it reached nearly 8x IO::Async throughput at 2.5 KB, while still retaining an advantage at much larger payload sizes.

Blog

This section provides an overview of the blog, showcasing a variety of articles, insights, and resources to inform and inspire readers.


Leave a Reply