Class Http2FrameWriter
WriteIntents). Its entire job is serializing
concurrent access to one connection's socket write side as cheaply as physically possible,
because under multiplexing every stream on a connection shares that one socket.
The design, three layers
Layer 1 — serialize outside the lock. By the time write(dev.relism.flash.http2.frame.WriteIntent) is called, the caller
has already built its complete frame into a buffer it owns (see WriteIntent). This writer
never serializes anything; it only ever issues one bulk sink.write(buffer, offset,
length) call while holding the lock — never many small writes, which would turn "hold the lock"
into "hold the lock across a serialization pass."
Layer 2 — ReentrantLock, never synchronized. On Java 21, a virtual
thread blocking inside synchronized pins its carrier platform thread; blocking on a
ReentrantLock unmounts it instead (JEP 491, which removes the synchronized
ReentrantLock is also load-bearing here for a second reason synchronized cannot offer:
ReentrantLock.tryLock().
Layer 3 — tryLock() fast path, intrusive MPSC fallback. The overwhelmingly
common case, even on a genuinely multiplexed connection, is exactly one stream wanting to write
at a given instant. tryLock() on an uncontended lock is one successful CAS; the calling
thread writes inline and releases — no handoff, no queue touched, no allocation, no context
switch. Only when tryLock() fails (genuine contention) does the intent get published
through IntrusiveMpscQueue (one more CAS, still zero allocation — the intent itself is
the queue node) for the current lock holder to drain.
Lost-wakeup avoidance
The classic hazard: a producer offers its intent to the queue at the exact moment the current holder has just found the queue empty and is about to unlock — the item would be stranded with nobody left to drain it. This is closed by two cooperating checks, and the correctness argument for why together they are sufficient is a happens-before chain through the queue's
AtomicReference and the lock's own acquire/release ordering (recorded in full in
WRITER.md, since it is exactly the kind of reasoning a future reader must be able to re-derive,
not just trust):
write(intent):
if tryLock() succeeds: // 1 CAS, the fast path
drive(intent) // write intent directly, then drain the queue, then unlock
else:
queue.offer(intent) // 1 CAS, zero allocation
if tryLock() succeeds: // the producer's own second chance
drive(null) // drain whatever is queued, including our own intent
drive(firstIntentOrNull):
write firstIntentOrNull if present, then poll-and-write until the queue is empty
unlock()
while queue.hasWork(): // the re-check-after-unlock that closes the race
if !tryLock(): break // someone else is now responsible; their own recheck covers us
poll-and-write until empty
unlock()
A frame's bytes are never interleaved with another frame's bytes: every write of one intent is a
single sink.write call issued while holding the lock, and the lock is not released
between a WriteIntent's bytes.
Write timeout
A blocking write is unavoidable when the kernel send buffer is full and the peer is not reading — whoever holds the lock is blocked in the syscall, holding up every other stream on the connection. This is bounded byHttp2Limits.WRITE_TIMEOUT_MS, enforced by a shared
background reaper (Http2FrameWriter.WriteTimeoutReaper) that interrupts the blocked thread past the
deadline — Socket#setSoTimeout bounds reads, not writes, so it cannot be used here.
connection setup), so arming/disarming the deadline for each individual write is two
volatile field writes, not an allocation.-
Nested Class Summary
Nested ClassesModifier and TypeClassDescriptionstatic interfaceWhat a frame's serialized bytes are ultimately written to. -
Constructor Summary
Constructors -
Method Summary
Modifier and TypeMethodDescriptionvoidclose()Deregisters this writer from the write-timeout reaper.voiddrain()Flushes any queued intents.voidwrite(WriteIntent intent) Serializes and writes one frame.voidwritePriority(WriteIntent intent) Writes a connection-control frame ahead of queued stream data.
-
Constructor Details
-
Http2FrameWriter
-
Http2FrameWriter
-
-
Method Details
-
write
Serializes and writes one frame. Returns when the bytes are in the socket buffer or safely queued behind another writer. Never blocks on another stream's I/O while holding the lock for longer than that stream's own single bulk write.Why the fast path is gated on
!queue.hasWork(), not justtryLock()Writingintentimmediately, before anything already queued, is only safe when nothing is already queued. Without thehasWork()check, this sequence is possible — and violates same-producer ordering, which the stress test asserts: a producer'swrite(a)thenwrite(b)contends and both get queued (fire-and-forget); the current holder is about to drain them but has not yet; that producer's very next call,write(c), finds the lock free (the holder released it between the producer's calls) and would otherwise writecdirectly — landing on the wire beforeaandb, which are still sitting in the queue. CheckinghasWork()first means "bypass the queue" only happens when the queue is observed genuinely empty, i.e. everything previously offered — by any producer — has already been written; seeWRITER.mdfor the full argument.- Throws:
IOException
-
writePriority
Writes a connection-control frame ahead of queued stream data. An already executing socket write is never interrupted, but once it completes the priority queue is drained before the ordinary queue. This is used for PING acknowledgements, SETTINGS acknowledgements, GOAWAY and RST_STREAM.- Throws:
IOException
-
drain
Flushes any queued intents. Called by the demux loop when it has nothing left to read — a no-op on the (overwhelmingly common) fast path where nothing is queued.- Throws:
IOException
-
close
public void close()Deregisters this writer from the write-timeout reaper. Call once, when the connection closes.
-