The single connection-wide `QuicConnection.lock` mutex serialised every
critical path: the read loop's `feedDatagram`, the send loop's
`drainOutbound`, and every public mutator (`openBidiStream`,
`streamById`, `flowControlSnapshot`, ...). The multiplexing testcase
opens hundreds of bidi streams in parallel and was capped at ~25
streams/sec by lock contention against the I/O loops.
Phase 1 of the lock split (see
`quic/plans/2026-05-08-lock-split-design.md`) introduces three
domain-specific mutexes:
- `streamsLock` — streams registry, datagram queues, stream-id
counters, connection-level flow-control bookkeeping, pending-
retransmit maps for control frames
- `LevelState.levelLock` (one per encryption level) — per-level
pnSpace / sentPackets / ackTracker / CRYPTO buffers
- `lifecycleLock` — status transitions, close reason/error code
Acquisition order: `lifecycleLock < streamsLock < levelLock`.
Per-stream `synchronized(this)` blocks inside SendBuffer/ReceiveBuffer
remain at the leaf — never acquire any QuicConnection mutex while
holding a per-stream lock.
The legacy `lock: Mutex` field is preserved as a deprecated alias of
`lifecycleLock` for source-compatibility with external test harnesses;
new code MUST use the appropriate domain lock.
Highlights:
- `feedDatagram` / `drainOutbound` now require the caller to hold
`streamsLock`; the driver wraps each call. Phase 1 keeps the whole
feed/drain inside `streamsLock` for safety; phase 2 (deferred) will
split frame-collection from encrypt + sentPackets-record so app
coroutines can intersperse during the encrypt window.
- `pendingPing`, `peerTransportParameters`, `status`,
`handshakeComplete` are now @Volatile so observers read them
without a lock.
- `markClosedExternally` no longer needs any lock (status is
@Volatile, signals are channel-thread-safe).
- Driver's PTO bookkeeping uses the volatile fields directly — no
lock needed.
- Tests that manually acquired `conn.lock` to call
`getOrCreatePeerStreamLocked` / `onTokensAcked` / `onTokensLost`
now acquire `streamsLock` (the domain those routines mutate).
- New `MultiplexingThroughputTest` locks in the contract: 1000
parallel `openBidiStream` calls must complete in <2 s.
Test plan:
- `:quic:jvmTest` — 294 tests pass (293 prior + 1 new throughput).
- `MultiplexingThroughputTest`: 1000 bidi streams in 52 ms
(~19,000 streams/sec on the in-memory pipe), well above the
250+/sec target.
- `:nestsClient:compileKotlinJvm` — clean, no API breaks.
- `./gradlew :quic:spotlessApply` — clean.
https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
9.6 KiB
QuicConnection Lock Split — Design Note
Date: 2026-05-08
Problem
QuicConnection.lock: Mutex serialises every meaningful operation:
drainOutbound(send loop, holds lock during a full datagram build — iterates every stream, allocates packet numbers, encrypts).feedDatagram(read loop, holds lock during decrypt + frame dispatch- per-stream insert).
openBidiStream/openUniStream(app code, holds lock for stream allocation + map insert).getOrCreatePeerStreamLocked(parser path on the read loop's critical section, but app code can also call it from tests).
Multiplexing test against aioquic measures ~25 streams/sec — every coroutine fights this single mutex.
Goal
Split the mutex into per-domain mutexes so the read loop, send loop, and
app code can mostly progress concurrently. Per-stream synchronized(this)
inside SendBuffer/ReceiveBuffer already handles per-stream
serialisation; we don't touch those.
Domain Map
Domain A — streamsLock: Mutex (the streams registry)
Fields:
streams: MutableMap<Long, QuicStream>streamsList: MutableList<QuicStream>(insertion-ordered list parallel tostreams, used by writer round-robin)nextLocalBidiIndex,nextLocalUniIndexstreamRoundRobinStart— read+written by writer; used in the same critical section it holdsstreamsLockfor the iterationpeerInitiatedUniCount,peerInitiatedBidiCountadvertisedMaxStreamsUni,advertisedMaxStreamsBidi,advertisedMaxDatapendingMaxStreamsUni,pendingMaxStreamsBidi,pendingMaxDatapendingMaxStreamData: MutableMap<Long, Long>pendingNewConnectionId: MutableMap<Long, …>newPeerStreams: ArrayDeque<QuicStream>pendingDatagrams: ArrayDeque<ByteArray>— outbound DATAGRAMsincomingDatagrams: ArrayDeque<ByteArray>— inbound DATAGRAMssendConnectionFlowCredit,sendConnectionFlowConsumedreceiveConnectionFlowLimit
Rationale: the writer needs an atomic snapshot of "all streams + all pending control-frame retransmits + datagram queues + flow-control counters" in one critical section to assemble a packet. The parser needs the same coverage when delivering a STREAM frame (look up or create the stream + queue receive bytes + bump pending* fields). Splitting these into multiple sub-locks would force the writer/parser to acquire several locks per pass — same contention, more deadlock risk.
peerMaxStreamsBidi, peerMaxStreamsUni stay @Volatile (already are):
the writer reads them once at the top of a stream open; the parser
writes once on inbound MAX_STREAMS. Atomic long write is sufficient on
all supported platforms.
Domain B — LevelState.levelLock: Mutex (one per level: initial / handshake / application)
Fields per LevelState:
pnSpace: PacketNumberSpaceStatesentPackets: MutableMap<Long, SentPacket>ackTrackercryptoSend: SendBuffer,cryptoReceive: ReceiveBuffersendProtection: PacketProtection?,receiveProtection: PacketProtection?keysDiscardedlargestAckedPn,largestAckedSentTimeMs
The writer iterates through levels in order (initial → handshake →
application) when building a coalesced datagram. Each level's critical
section is independent, so the lock is held only for the duration of
build at that level (which doesn't touch the streams registry except
to read streamsListLocked() for stream frames inside the application
build — that read transitions through streamsLock).
Domain C — lifecycleLock: Mutex (status + handshake metadata)
Fields:
status: StatuscloseReason: String?,closeErrorCode: LongpeerTransportParameters: TransportParameters?— read-mostly after handshake; using@Volatilereference + write-once-after-handshake is sufficient here. Promoted to@Volatileso writer/parser can snapshot without a lock.handshakeComplete: BooleancloseAllSignals(the channels are themselves thread-safe; lock is only required to serialise the status transition)
Domain D — Atomic / @Volatile (no lock)
Fields:
pendingPing— toggled by driver under PTO; observed by writer. Promote to@Volatile.consecutivePtoCount— already@Volatile. Driver writes it under its own logic; no further protection needed because it's only read inside the same loop iteration that wrote it.destinationConnectionId— already has volatile semantics (internal seton a@Volatile var). Stays as is.udpStatsSupplier— already@Volatile.peerMaxStreamsBidi,peerMaxStreamsUni— already@Volatile.handshakeDoneSignal: CompletableDeferred<Unit>— coroutines primitive, thread-safe.
Domain E — Per-stream (UNCHANGED)
QuicStream already protects its SendBuffer / ReceiveBuffer with
internal synchronized(this) blocks. Nothing changes here.
Lock Acquisition Order
To prevent deadlock, document and enforce:
lifecycleLock < streamsLock < (any LevelState.levelLock)
Per-stream synchronized(...) blocks inside SendBuffer/ReceiveBuffer
remain at the leaf — never acquire any QuicConnection mutex while
holding a per-stream lock.
In practice the only nesting that happens is:
drainOutboundacquiresstreamsLock(for the streams loop + stream-frame creation) but the per-level builds happen outside that block — each level acquires its ownlevelLockseparately. No nestedstreamsLock⊃levelLockchain.- Actually re-checking the design: the writer needs to allocate a
PN at the chosen level while it has decided which streams to
flush. Two options:
(1) acquire streamsLock, snapshot streams + frames, release;
acquire each levelLock to encode + record.
(2) hold streamsLock during level encode for the application
packet (because stream-frame retransmit tokens get recorded
into level.sentPackets in the same operation).
We take option (2) — encode under both locks, with strict order
streamsLock→levelLock. The other levels (initial/handshake) don't touch streamsLock at all, so they only acquirelevelLock.
Public API Compatibility
QuicConnection.lock: Mutex is val-public. External callers exist
(tests + InMemoryQuicPipe-driven harnesses). To avoid breaking those:
- Keep the
lock: Mutexfield as a deprecated forwarder. It now also exists, but it is an alias forlifecycleLock. New code must NOT use it. Existing tests that lock it before mutating state used to cover all domains; we update them in place to use the appropriate lock(s).
Actually simpler: keep lock: Mutex as a no-op lock (still a
Mutex so external code compiles), document that it no longer
guards anything, update the tests that lock it.
After review: tests use conn.lock to serialise their direct calls to
onTokensAcked/onTokensLost/getOrCreatePeerStreamLocked. We update
those tests to acquire streamsLock instead (since those routines
mutate stream-domain state). The lock field is kept as deprecated
for source compatibility but is functionally a leaf no-op.
Migration Plan
- Add
streamsLock,lifecycleLockfields. Keeplockas alias oflifecycleLock. - Add
levelLocktoLevelState. - Convert
getOrCreatePeerStreamLocked→getOrCreatePeerStreamdoing its ownstreamsLockacquisition. Keep the old name as a forwarder for backwards compat. - Update
openBidiStream,openUniStream,streamById,pollIncomingPeerStream,awaitIncomingPeerStream,pollIncomingDatagram,awaitIncomingDatagram,queueDatagram,flowControlSnapshotto acquirestreamsLock. - Update
close,markClosedExternallyto uselifecycleLock. - Update driver's
readLoop/sendLoop:feedDatagramno longer wraps in conn-wide lock. Instead the parser acquiresstreamsLockaround stream-touching code, andlevelLockaround level-touching code.drainOutboundis restructured similarly.
- Update tests that hold
conn.lockto use the relevant new lock.
Risk + Mitigation
- Deadlock: enforce order via code review + (where practical) inline comments at each acquisition site. Keep nesting shallow.
- Missed coverage: enumerate every field in this doc; if a field can be mutated from two domains we either move it to a single domain or annotate it as @Volatile.
- Performance regression: more mutex acquisitions overall; but the critical path (multiplexing test) sees parallel execution instead of serial, which more than compensates.
Implementation Phases
This commit implements phase 1 — separate domain locks but
drainOutbound and feedDatagram still hold streamsLock for the
entire pass. The wins from phase 1 alone:
- App code (
openBidiStream,streamById,flowControlSnapshot) no longer contends withlifecycleLock-only operations. - The PTO timer path stops touching any mutex (volatile fields).
markClosedExternallyno longer needs a lock.close()only takes lifecycleLock — opens the path for in-progress drain to finish without status-write contention.
Phase 2 (deferred follow-up): split buildApplicationPacket into a
"collect frames under streamsLock" stage and an "encrypt + record
under levelLock" stage so app coroutines can intersperse during the
encrypt window. That requires more invasive surgery on the writer's
internals; phase 1 ships first to lock in the safer subset.
Verification
:quic:jvmTest— full suite must pass.MultiplexingThroughputTest(new): 1000 streams in <500 ms on InMemoryQuicPipe.