Conversation
The existing SslStream benchmarks only ever handshake one connection at a time, so there is no coverage of the shape clients actually use against a single host: connection pools, and HTTP/2 and HTTP/3 pool warm-up, fanning out several handshakes at once. SslStreamConcurrencyTests measures per-handshake cost at concurrency levels 1, 8, 64 and 128, with and without session resumption, for TLS 1.2 and TLS 1.3. Unlike the other SslStream benchmarks this one runs over loopback sockets. Measured over in-memory streams, concurrent TLS 1.3 connections resume ~97% of the time at every concurrency level; over sockets the rate falls to ~40% at 128 concurrent connections. An in-memory version would measure the uncontended case whatever Concurrency was set to.
Contributor
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The server-side accept/serve background tasks currently risk masking unexpected failures and producing unobserved task faults during cleanup, which can destabilize benchmark runs.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (1)
What changed in this PR
Adds a new microbenchmark targeting concurrent TLS handshakes with SslStream, intended to better represent real client behavior (connection pools / HTTP/2+ warm-up) and to measure per-handshake cost across multiple concurrency levels and protocol/resumption combinations.
Changes:
- Introduces
SslStreamConcurrencyTests.ConcurrentHandshakemeasuring 128 handshakes per invocation in waves of configurable concurrency. - Adds protocol matrix (TLS 1.2 + optional TLS 1.3) and session-resumption toggle, with a
GlobalSetupassertion that resumption is actually occurring. - Implements a loopback-socket server accept/serve loop to drive concurrent client handshakes.
| File | Description |
|---|---|
| src/benchmarks/micro/libraries/System.Net.Security/SslStreamConcurrencyTests.cs | New benchmark class for concurrent SslStream handshakes over loopback sockets, parameterized by protocol, resumption, and concurrency. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+135
to
+138
| catch | ||
| { | ||
| return; | ||
| } |
wfurt
added a commit
to dotnet/runtime
that referenced
this pull request
Sep 29, 2026
On Linux the client TLS session cache kept exactly one SSL_SESSION per SNI host. For TLS 1.3 that is wrong in two compounding ways. We can get unnecessary tuen when server sends more tickets and we also have problem with concurrency as we cannot use the same ticket twice for TLS 1.3. The cache design was done for TLS 1.2 where using same ticket over and over again is fine. Also we have possible lock ordering problem. | Concurrent connections | TLS 1.2 | TLS 1.3 run 1 | run 2 | run 3 | TLS 1.3 mean | |---:|---:|---:|---:|---:|---:| | 1 | 100% | 100% | 100% | 100% | 100% | | 2 | 100% | 100% | 100% | 100% | 100% | | 4 | 100% | 100% | 100% | 100% | 100% | | 8 | 100% | 100% | 100% | 100% | 100% | | 16 | 100% | 96.9% | 92.2% | 95.3% | **94.8%** | | 32 | 100% | 97.7% | 95.3% | 88.3% | **93.8%** | | 64 | 100% | 84.0% | 93.8% | 91.4% | **89.7%** | Measured with the concurrent handshake benchmark from dotnet/performance#5314 over loopback sockets, TLS 1.3 with resumption: 14% faster at 64 concurrent connections and 12% at 128. TLS 1.2 and the non-resuming cases are unchanged; low concurrency costs 2-3%. Note that this really depends on machine and timing. The problem is nearly invisible when using Memory stream and everything is super fast. But that is not the real world scenario. This PR separates the behavior so we can have multiple (up to 8) tickets so we have better chance of resumption during parallel processing. We would hold eactly one ticket for cases when site is visited once and never again after e.g. crawlers. ### Effect of the fix Measured with `SslStreamConcurrencyTests.ConcurrentHandshake` from dotnet/performance#5314, which performs concurrent TLS handshakes over loopback sockets. Both runtimes were driven by BenchmarkDotNet's CoreRun toolchain in a single run on the same machine, with the unmodified build as the baseline, so the ratios are directly comparable. Linux x64, OpenSSL 3.0.13, RSA-2048 server certificate. Ratio below 1.00 means the fix is faster. **TLS 1.3 with resumption enabled** | Concurrent connections | before | after | ratio | |---:|---:|---:|---:| | 1 | 4345.7 µs | 4495.7 µs | 1.03 ± 0.02 | | 8 | 320.0 µs | 326.1 µs | 1.02 ± 0.01 | | 64 | 318.4 µs | **273.2 µs** | **0.86 ± 0.03** | | 128 | 326.1 µs | **285.6 µs** | **0.88 ± 0.05** | 14% faster at 64 concurrent connections and 12% at 128. **Cases the change should not affect** | Arm | N=1 | N=8 | N=64 | N=128 | |---|---:|---:|---:|---:| | TLS 1.2, resumption enabled | 0.99 | 1.00 | 0.99 | 1.01 | | TLS 1.2, resumption disabled | 0.98 | 0.96 | 0.97 | 1.01 | | TLS 1.3, resumption disabled | 0.98 | 1.00 | 0.98 | 0.97 | **Cost** At low concurrency TLS 1.3 resumption is 2–3% slower (1.03 at N=1, 1.02 at N=8) and allocates about 1% more (12.44 KB vs 12.33 KB at N=64). This is the extra `SSL_SESSION_up_ref` call and the list lookup replacing a dictionary lookup. **Eviction and cache cleanup** OpenSSL continues to enforce its own global cap of `DefaultTlsCacheSizeClient` sessions across all hostnames, unchanged by this PR. When the cache is full `SSL_CTX_add_session` evicts from `ctx->session_cache_tail` until it is back under the limit, and since `SSL_SESSION_list_add` keeps the list ordered by effective expiry rather than by insertion or use, the victim is always the session nearest to expiring — effectively oldest-first, so idle hosts shed entries before active ones. Every removal path invokes `remove_session_cb`, which is how the managed dictionary stays in sync and how its size stays transitively bounded by that same cap: the callback finds the entry by the hostname stashed on the session and by pointer identity, then drops exactly one reference. OpenSSL raises it even when the session was not in its own hash, so the removal is guarded to release once per cached reference. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

The existing
SslStreambenchmarks only ever handshake one connection at a time, so there is no coverage of the shape clients actually use against a single host: connection pools, and HTTP/2 and HTTP/3 pool warm-up, fanning out several handshakes at once.SslStreamConcurrencyTests.ConcurrentHandshakemeasures per-handshake cost at concurrency levels 1, 8, 64 and 128, with and without session resumption, for TLS 1.2 and TLS 1.3. That is 16 test cases, the maximumTooManyTestCasesValidatorallows for a single benchmark.It lives in its own class because
[Params]apply to every benchmark in a class, so adding these toSslStreamTestswould have multiplied its entire matrix.Why loopback sockets rather than in-memory streams
This is the one deliberate departure from the surrounding benchmarks, and it is required rather than incidental. Measuring the steady-state TLS 1.3 resumption rate under identical wave scheduling, varying only the transport:
ConnectedStreamsTLS 1.2 stays at 100% on both transports at every level. Over in-memory streams the concurrent case essentially does not occur, so an in-memory version of this benchmark would measure the uncontended path whatever
Concurrencywas set to.In-memory is also noticeably slower and noisier here — TLS 1.2 at 8 concurrent connections costs 1596 µs/handshake in memory versus 199 µs over sockets, because
StreamBufferoverhead dominates the TLS work.Results (Linux x64, OpenSSL 3.0.13, RSA-2048)
Resumed handshakes, µs each:
For concurrency ≥ 8 every cell reports under 2% error, and the numbers reproduced across repeated runs.
Notes
[GlobalSetup]asserts that resumption actually takes effect before measuring. Without that check theResume=truecases can silently measure full handshakes and still look like plausible results.LingerOption(true, 0)) once it has ingested its session ticket. A graceful close left a peak of ~85,000 sockets inTIME_WAIT, which is 65% of the defaulttcp_max_tw_bucketsand risks exhausting the ephemeral port range; this brings the peak down to ~122. Concurrent file descriptor use is bounded byConcurrency, not by run length.Note
This pull request description was generated with AI assistance.