Skip to content

Add concurrent TLS handshake benchmark for SslStream - #5314

Open
wfurt wants to merge 1 commit into
dotnet:mainfrom
wfurt:tls-concurrent-handshake-benchmark
Open

wfurt wants to merge 1 commit into
dotnet:mainfrom
wfurt:tls-concurrent-handshake-benchmark

Conversation

@wfurt

@wfurt wfurt commented Sep 19, 2026

Copy link
Copy Markdown
Member

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.ConcurrentHandshake 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. That is 16 test cases, the maximum TooManyTestCasesValidator allows for a single benchmark.

It lives in its own class because [Params] apply to every benchmark in a class, so adding these to SslStreamTests would 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:

Concurrency in-memory ConnectedStreams loopback socket
1 100.0% 100.0%
8 100.0% 100.0%
64 96.6% 51.6%
128 96.4% 37.2%

TLS 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 Concurrency was 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 StreamBuffer overhead dominates the TLS work.

Results (Linux x64, OpenSSL 3.0.13, RSA-2048)

Resumed handshakes, µs each:

Concurrency TLS 1.2 TLS 1.3
1 2831.5 4118.2
8 234.1 319.5
64 172.2 317.5
128 173.1 326.9

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 the Resume=true cases can silently measure full handshakes and still look like plausible results.
  • The client closes abortively (LingerOption(true, 0)) once it has ingested its session ticket. A graceful close left a peak of ~85,000 sockets in TIME_WAIT, which is 65% of the default tcp_max_tw_buckets and risks exhausting the ephemeral port range; this brings the peak down to ~122. Concurrent file descriptor use is bounded by Concurrency, not by run length.
  • Wall time is about 195 s for all 16 cases on the default job.

Note

This pull request description was generated with AI assistance.

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.
Copilot AI lite review requested due to automatic review settings September 19, 2026 05:12

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 Medium severity

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.ConcurrentHandshake measuring 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 GlobalSetup assertion 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>
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