Reducing EndpointDriver state lock contention: split incoming accept around the endpoint lock - #2633
Conversation
486f44a to
b5cc1a9
Compare
|
Splitting up a lock makes sense to me in principle but it feels like this adds a lot of code to do that, more than I'd intuitively expect. Also, can you better quantify which (more granular) parts of the acceptance process are taking up time? |
@djc the relevant core changes are basically: So like ~350 lines of code. Lots of changes are boilerplate code around a new Do you have any specific recommendations? |
b5cc1a9 to
d165bc9
Compare
|
Reduced the scope of changes: |
Looking specifically at the second commit (quinn-proto: split server-side accept around endpoint lock), it introduces what I consider a substantial amount of new code, a lot of which seems repetitive. Suggest splitting that up into smaller changes? |
b8c19f3 to
a400192
Compare
Thanks for the review. Took another pass at the second commit. Removed/simplified some things. The 2nd commit is now 336+/130−. On splitting the commit further Happy to do this if it helps review, but the smaller commits will probably not stand on their own — each only makes sense because the lock-split commit follows. Still worth pursuing? The two new types
The flow: Three phases ( All internal: methods are doc(hidden), state types only re-export under |
a400192 to
5a8f31c
Compare
5a8f31c to
80af463
Compare
I've split the 2nd commit into smaller ones. The largest (main) one is now 259+/64− (was 336+/130−). Happy to reorder/squash differently if it helps. |
| } | ||
|
|
||
| /// Register endpoint-owned metadata and routes for an active connection. | ||
| fn register_connection( |
There was a problem hiding this comment.
Have reviewed up to this commit, the previous commits looked fine. I want a different commit history for this:
- Extract the logic that previously lived in
add_connection()into a separate methodregister_connection(). The new method should live below the existing one, since it is called by it and we follow top-down ordering. - Move logic from
insert_conn()intoregister_connection(). - Move the
self.index.insert_initial()call.
There was a problem hiding this comment.
Thanks for the feedback! Split it into 3 separate commits starting with 885c1fc
ba6c301 to
eaa5363
Compare
Recent stable rustc no longer fires `clippy::unnecessary_cast` on the `ipi6_ifindex as u32` cast, so the `expect(...)` attribute trips `unfulfilled_lint_expectations` under `-D warnings`. Switch it to `allow(...)`.
`Connection::new` only queried `cid_len()` and `cid_lifetime()` on the generator; it never minted a CID. Take the two values directly.
The locally-created-CID index (`connection_ids`) mapped directly to `ConnectionHandle`, while the initial-DCID index already used `RouteDatagramTo`. Store `RouteDatagramTo` in `connection_ids` too: `new_cid` takes the route to register, and `ConnectionIndex::get` returns it without re-wrapping. No behavior change (every route is still `RouteDatagramTo::Connection`); this is what later lets a locally-created CID point at an in-flight `Incoming`.
Move the `ConnectionMeta` slab-entry construction and the CID registration that lived inline in `add_connection` into a dedicated `register_connection` method. Pure refactor.
Register every CID in `loc_cids` to route to the connection, not just the first. No change here — `new_cid` already registered each — but the split-accept path mints CIDs routing to the in-flight `Incoming`, and this is what re-points them to the finalized connection.
`accept`'s initial CID is the connection's `init_cid`, and only server connections register one, so gating on `side.is_server()` preserves behavior.
Refactor `clean_up_incoming` (now `remove_incoming_state`) and introduce `remove_incoming_buffer`, which returns the removed buffer. The split-accept path will use it to recover a reservation's buffered datagrams under the endpoint lock.
Accept previously held the endpoint lock across TLS session setup, Connection::new, and handle_first_packet. Under a high rate of new connections, that stalls the endpoint driver and cuts throughput for established traffic. Split accept into three phases: - start_accept reserves routing/CID state under the endpoint lock, returning an Accepting handle. - Accepting::finish_without_endpoint runs TLS session creation, Connection::new, and first-packet handling without the lock, returning an Accepted on success. - finish_accept / finish_accept_error finalize or clean up under the lock, replaying any datagrams buffered during the split window. Reservation state (routing/CID/buffer-slot bookkeeping) lives in an AcceptReservation carried inside Accepting/Accepted/AcceptingError, not in the connection slab. Buffered Initials and 0-RTT packets arriving during the split window route as RouteDatagramTo::Incoming into the same buffer slot used before start_accept. The split-accept hooks are internal-only. The methods are doc(hidden) but still compiled because quinn needs to call them. The private __internal_split_accept feature only re-exports the otherwise- unnameable state types (Accepting, Accepted, and AcceptingError) so quinn can store and pass them around. These hooks are not a supported public API and are not covered by semver.
`Accepting::transport_config` was just `server_config.transport.clone()`, and `Accepting` already carries `server_config`. Derive it where it is used, in `finish_without_endpoint`, instead of storing it separately. No behavior change.
Drive `proto::Endpoint::accept` through its three-phase hooks so TLS session creation, `Connection::new`, and first-packet handling run without the endpoint mutex held. `wait_idle` and `EndpointDriver` termination now also wait for `pending_accepts` to drain. The failure path explicitly notifies `shared.idle`; it doesn't go through the drained-connection path that normally wakes idle waiters.
Cover retransmitted Initials buffered during the `Accepting` window and `max_incoming` counting attempts in the `Accepting` phase.
Block inside `ServerConfig::crypto.start_session` to hold a connection in the `Accepting` state. Verify that `open_connections` stays 0 and `wait_idle` does not complete while a pending accept is in flight, and that `Endpoint::close` during a pending accept resolves both sides cleanly.
`start_accept` reserves CIDs, a buffer slot, and a pending-accept count released only by `finish_accept`/`finish_accept_error`. A dropped `Accepting` is covered by the `Incoming` it holds (whose improper-drop warner stays armed), but once `finish_without_endpoint` consumes that `Incoming`, the resulting `Accepted`/`AcceptingError` had no guard — dropping one leaked the reservation silently. Give `Accepted` and `AcceptingError` an `AcceptDropGuard` that warns if dropped and is dismissed on proper completion by `finish_accept` / `finish_accept_error`. Releasing the reservation requires the endpoint, which a `Drop` impl can't reach, so — as with `Incoming` — the guard only warns; it can't clean up.
Cover the `finish_without_endpoint` failure path: when the off-lock handshake fails (here via ALPN mismatch) after `start_accept` has reserved endpoint state, `finish_accept_error` must release the pending-accept slot, the reserved CIDs, and the buffered packets. Assert the endpoint is left with no leaked state.
eaa5363 to
3b9adda
Compare
|
|
||
| self.index.insert_conn(addresses, loc_cid, ch, side); | ||
| let conn_meta = &self.connections[ch]; | ||
| for cid in conn_meta.loc_cids.values() { |
There was a problem hiding this comment.
Okay, I got to "inline insert_conn into register_connection". For these changes, I want to split into (1) a commit that moves the code and (2) a commit that changes the code.
(This is basically the same for all the changes you're submitting -- please self-review.)
In order to make progress here, maybe submit a separate PR with the commits before this one, so we can get it merged? This is pretty hard to digest.


Accept API currently hold the endpoint lock across TLS session setup,
Connection::new(), andConnection::handle_first_packet(). Under a high rate of new connections, that stalls the endpoint driver whenever it encounters a new Initial, which in turn cuts throughput for already-established traffic.This change splits server-side accept into three phases:
First, claim the Incoming and reserve the routing/CID state under the endpoint lock.
Second, run the expensive TLS session creation and first-packet handling without the lock.
Third, reacquire the lock briefly to either activate the connection and replay buffered Initial/0-RTT datagrams, or clean up and send an Initial-close response on failure.
That transient state is made explicit in quinn-proto with an
Acceptingconnection state. Packets that arrive during the split window are routed toAcceptingand buffered until finalization, so retransmitted Initials and 0-RTT packets are preserved.f939eb0 is included for completeness (test results below), but should be dropped.
Is this approach acceptable? If no, what could be a better alternative?
Test results:
The numbers below are for 500 and 1000 new connections per second.
baseline
with this change