Skip to content

Reducing EndpointDriver state lock contention: split incoming accept around the endpoint lock - #2633

Open
stablebits wants to merge 14 commits into
quinn-rs:mainfrom
stablebits:endpoint-lock-optimization-accept-split
Open

Reducing EndpointDriver state lock contention: split incoming accept around the endpoint lock#2633
stablebits wants to merge 14 commits into
quinn-rs:mainfrom
stablebits:endpoint-lock-optimization-accept-split

Conversation

@stablebits

@stablebits stablebits commented May 5, 2026

Copy link
Copy Markdown
Contributor

Accept API currently hold the endpoint lock across TLS session setup, Connection::new(), and Connection::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 Accepting connection state. Packets that arrive during the split window are routed to Accepting and 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:

    $ NEW_CONN_RATE=1000 ./bench/run_contention_compare.sh
    throughput: connections=1 streams_per_connection=1 stream_size=1600M stream_runs=1
    new-conn: rate=... workers=16 duration_secs=30 warmup_secs=2

The numbers below are for 500 and 1000 new connections per second.

baseline

    baseline throughput: 384.12 MiB/s (elapsed 4.165s)
    mixed throughput:    209.43 MiB/s (elapsed 7.640s)
    mixed new-conn rate: 500.00 /s
    degradation:         45.48%

    baseline throughput: 377.83 MiB/s (elapsed 4.235s)
    mixed throughput:    43.00 MiB/s (elapsed 37.212s)
    mixed new-conn rate: 1000.00 /s
    degradation:         88.62%

with this change

    baseline throughput: 382.34 MiB/s (elapsed 4.185s)
    mixed throughput:    307.89 MiB/s (elapsed 5.197s)
    mixed new-conn rate: 500.00 /s
    degradation:         19.47%

    baseline throughput: 391.27 MiB/s (elapsed 4.089s)
    mixed throughput:    290.59 MiB/s (elapsed 5.506s)
    mixed new-conn rate: 1000.00 /s
    degradation:         25.73%

@stablebits
stablebits force-pushed the endpoint-lock-optimization-accept-split branch from 486f44a to b5cc1a9 Compare May 5, 2026 13:17
@djc

djc commented May 5, 2026

Copy link
Copy Markdown
Member

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?

@stablebits

Copy link
Copy Markdown
Contributor Author

Also, can you better quantify which (more granular) parts of the acceptance process are taking up time?

The overhead comes from Connection::handle_first_packet() (this, along with Connection::new(), is what runs without holding the lock in finish_without_endpoint() now).

So on the server side (app's accept()), handle_first_packet() (-> process_decrypted_packet() -> process_early_payload() -> (read_crypto() && write_crypto()) is where rustls processes the TLS handshake.

I don't have a full profile at hand. But this is a screenshot that I used some time ago for internal discussions:

Screenshot 2026-03-27 at 13 11 45

@stablebits

stablebits commented May 18, 2026

Copy link
Copy Markdown
Contributor Author

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.

@djc the relevant core changes are basically:
Screenshot 2026-05-18 at 14 34 19

So like ~350 lines of code. Lots of changes are boilerplate code around a new Accepting type. Some changes are due to renaming/refactoring, and in principle could be avoided. Changes in bench/ are to be discarded (as I mentioned). Then there are some new tests. I'll try to reduce the diff more (btw., there are multiple commits introducing changes one by one).

Do you have any specific recommendations?

@stablebits
stablebits force-pushed the endpoint-lock-optimization-accept-split branch from b5cc1a9 to d165bc9 Compare May 18, 2026 14:10
@stablebits

Copy link
Copy Markdown
Contributor Author

Reduced the scope of changes:

  Core code: 7 files, +425 / −115 (net +310 lines)
  Tests: 2 files, +362 / −2 (net +360 lines)

@djc

djc commented May 19, 2026

Copy link
Copy Markdown
Member

So like ~350 lines of code. Lots of changes are boilerplate code around a new Accepting type. Some changes are due to renaming/refactoring, and in principle could be avoided. Changes in bench/ are to be discarded (as I mentioned). Then there are some new tests. I'll try to reduce the diff more (btw., there are multiple commits introducing changes one by one).

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?

@stablebits
stablebits force-pushed the endpoint-lock-optimization-accept-split branch 2 times, most recently from b8c19f3 to a400192 Compare May 21, 2026 14:43
@stablebits

stablebits commented May 21, 2026

Copy link
Copy Markdown
Contributor Author

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?

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

  • AcceptReservation (5 fields, Copy): the persistent identity of an in-flight accept — CIDs, init-CID, buffer-slot index, addresses. Created under the lock in start_accept(); consumed under the lock again in finish_accept() / finish_accept_error() to either register the connection or free the routing state. It bridges the lock-released window so retransmitted Initials and 0-RTT keep landing in the right buffer.
  • Accepting (14 fields): the off-lock work package. Inputs to TLS / Connection::new() / handle_first_packet(). Each field is captured once under the lock and used once after release. Consumed in the middle phase; doesn't reappear under the lock.

The flow:

     start_accept()            finish_without_endpoint()        finish_accept()
     [LOCK HELD]               [NO LOCK]                        [LOCK HELD]
     ─────────────────────     ──────────────────────────────   ─────────────
     build Accepting {         consume Accepting               consume Accepted
       reservation,    ──────► run TLS, Connection::new,  ───► register conn,
       14 other            │   handle_first_packet            remove_accept_reservation(reservation)
       fields             ───►                                 (frees CIDs, buffer slot)
     }                    │   on Ok → Accepted {
                          │     reservation,  ◄── same one
                          │     conn,
                          │   }
                          │   on Err → AcceptingError {
                          │     reservation,  ◄── same one
                          │     ...
                          │   }                              finish_accept_error()
                          │                                  [LOCK HELD]
                          │                                  ─────────────
                          └─────────────────────────────────► consume AcceptingError
                                                              remove_accept_reservation(reservation)

Three phases (start_accept() / finish_without_endpoint() / finish_accept[_error]()) are the lock split.

All internal: methods are doc(hidden), state types only re-export under __internal_split_accept, not semver. Endpoint::accept() is unchanged for direct users.

@stablebits
stablebits force-pushed the endpoint-lock-optimization-accept-split branch from a400192 to 5a8f31c Compare May 21, 2026 15:06
@stablebits
stablebits force-pushed the endpoint-lock-optimization-accept-split branch from 5a8f31c to 80af463 Compare June 2, 2026 13:46
@stablebits

stablebits commented Jun 2, 2026

Copy link
Copy Markdown
Contributor Author

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?

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?

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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 method register_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() into register_connection().
  • Move the self.index.insert_initial() call.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the feedback! Split it into 3 separate commits starting with 885c1fc

@stablebits
stablebits force-pushed the endpoint-lock-optimization-accept-split branch 4 times, most recently from ba6c301 to eaa5363 Compare June 16, 2026 15:39
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.
@stablebits
stablebits force-pushed the endpoint-lock-optimization-accept-split branch from eaa5363 to 3b9adda Compare June 23, 2026 10:44
@stablebits
stablebits requested a review from djc June 29, 2026 08:01

@djc djc left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the delay...


self.index.insert_conn(addresses, loc_cid, ch, side);
let conn_meta = &self.connections[ch];
for cid in conn_meta.loc_cids.values() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! The first part is here #2771

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants