Limit ENOBUFS retries to avoid head-of-line blocking - #278
Merged
Conversation
Owner
Author
nirs
force-pushed
the
drop-on-enobufs
branch
4 times, most recently
from
July 31, 2026 21:20
548aa9e to
6e2512d
Compare
When the kernel's vmnet buffer is full, the helper retries
sendmsg_x() with a 50 microsecond sleep between attempts. Previously
the retries were unbounded, causing head-of-line blocking: a
high-priority small packet (e.g. an HTTP/2 PING or TCP ACK) queued
behind large data packets is delayed without bound while the helper
waits for buffer space. If the delay exceeds the protocol's timeout,
the connection breaks even though the network is otherwise healthy.
Limit retries to 10 attempts (500µs maximum wait) shared across the
entire batch. The fast path retries sendmsg_x() within this budget,
then falls back to the slow path which writes remaining packets one
at a time, also retrying within the same budget. Packets are dropped
only after the budget is exhausted or on non-ENOBUFS errors.
Before (ENOBUFS in fast path):
sendmsg_x(batch) → ENOBUFS
→ sleep 50µs → retry → ENOBUFS
→ sleep 50µs → retry → ...
(unbounded retries, blocks all packets in the batch)
After (ENOBUFS in fast path):
sendmsg_x(batch) → ENOBUFS
→ sleep 50µs → retry (up to 10 times, shared budget)
→ fall back to slow path
→ write(packet₁) → ENOBUFS → retry (same budget)
→ write(packet₁) → success
→ write(packet₂) → ENOBUFS → budget exhausted → drop
→ write(packet₃) → success
(bounded wait across both paths, then drop)
The retry limit is per batch, not per sendmsg_x call, ensuring the
total wait is bounded regardless of partial successes between
retries.
Dropping packets is correct because the helper implements an ethernet
link between the VM and the host. Like a physical network link under
congestion, packets may be lost. TCP retransmits lost segments, and
UDP applications are expected to tolerate loss.
Bounded retries are preferable to dropping immediately because TCP
retransmission is far more expensive than a 50µs sleep. Dropping
causes TCP to halve its congestion window and wait for retransmit,
taking milliseconds to seconds to recover. In testing, dropping
immediately on ENOBUFS caused throughput to collapse from 26 Gbps to
20 Gbps with 4 parallel streams as TCP repeatedly backed off.
ENOBUFS occurs when the VM cannot drain the vmnet buffer fast enough.
With a fast VMM (krunkit) and --busy-poll, the VM processes packets
fast enough that ENOBUFS never occurs even at 36 Gbps:
krunkit, --busy-poll, 4 CPUs, 8 MiB buffer:
TX (Gbps) drops fast calls/sec
before (retry) 36.4 0 14.5k
after (drop) 36.4 0 14.5k
With a slower VMM (vfkit), the VM cannot keep up and ENOBUFS occurs.
The bounded retry mechanism absorbs most ENOBUFS pressure, but some
packets are dropped. The previous unbounded retry behavior achieved
slightly higher throughput for vfkit by naturally throttling the
sending rate:
vfkit, --busy-poll, 4 CPUs, 4 MiB buffer:
TX (Gbps) drops fast calls/sec
before (retry) 9.8 0 15-28k
after (drop) 9.4 780 ~28k
The slight throughput regression for slow VMMs is acceptable because
head-of-line blocking is a correctness issue, not a performance
issue. Without bounded retries, a single blocked batch can stall all
traffic for an unbounded duration.
Based on #266 with the following changes:
- Tighter retry budget: 10 retries (500µs) vs 100 (5ms). Benchmarks
show retries rarely exceed a few attempts.
- Change error logging in sendmsg_x() and write() from ERROR to DEBUG
for ENOBUFS (normal backpressure signal) and WARN for other errors
(unexpected but recoverable).
- Drops tracked via stats counters instead of a separate rate-limited
warning log.
Thanks: Leo Lännenmäki <leo.lannenmaki@avrea.com>
Fixes: #267
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.




When the kernel's vmnet buffer is full, the helper retries sendmsg_x() with a 50 microsecond sleep between attempts. Previously the retries were unbounded, causing head-of-line blocking: a high-priority small packet (e.g. an HTTP/2 PING or TCP ACK) queued behind large data packets is delayed without bound while the helper waits for buffer space. If the delay exceeds the protocol's timeout, the connection breaks even though the network is otherwise healthy.
Limit retries to 10 attempts (500µs maximum wait) shared across the entire batch. The fast path retries sendmsg_x() within this budget, then falls back to the slow path which writes remaining packets one at a time, also retrying within the same budget. Packets are dropped only after the budget is exhausted or on non-ENOBUFS errors.
Before (ENOBUFS in fast path):
After (ENOBUFS in fast path):
The retry limit is per batch, not per sendmsg_x call, ensuring the total wait is bounded regardless of partial successes between retries.
Dropping packets is correct because the helper implements an ethernet link between the VM and the host. Like a physical network link under congestion, packets may be lost. TCP retransmits lost segments, and UDP applications are expected to tolerate loss.
Bounded retries are preferable to dropping immediately because TCP retransmission is far more expensive than a 50µs sleep. Dropping causes TCP to halve its congestion window and wait for retransmit, taking milliseconds to seconds to recover. In testing, dropping immediately on ENOBUFS caused throughput to collapse from 26 Gbps to 20 Gbps with 4 parallel streams as TCP repeatedly backed off.
ENOBUFS occurs when the VM cannot drain the vmnet buffer fast enough. With a fast VMM (krunkit) and --busy-poll, the VM processes packets fast enough that ENOBUFS never occurs even at 36 Gbps:
With a slower VMM (vfkit), the VM cannot keep up and ENOBUFS occurs. The bounded retry mechanism absorbs most ENOBUFS pressure, but some packets are dropped. The previous unbounded retry behavior achieved slightly higher throughput for vfkit by naturally throttling the sending rate:
The slight throughput regression for slow VMMs is acceptable because head-of-line blocking is a correctness issue, not a performance issue. Without bounded retries, a single blocked batch can stall all traffic for an unbounded duration.
Based on #266 with the following changes:
Thanks: Leo Lännenmäki leo.lannenmaki@avrea.com
Fixes: #267