-
Notifications
You must be signed in to change notification settings - Fork 5.5k
Implement MsQuicConfiguration cache #99371
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 9 commits
8b81795
6f94f38
34c314d
6825c3a
b5689a5
40c4bc2
82965d2
0578632
8e26582
c1ddffb
ebab536
3e82d24
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,242 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| using System.Diagnostics; | ||
| using System.Collections.Generic; | ||
| using System.Collections.Concurrent; | ||
| using System.Collections.ObjectModel; | ||
| using System.Security.Authentication; | ||
| using System.Net.Security; | ||
| using System.Security.Cryptography.X509Certificates; | ||
| using System.Threading; | ||
| using Microsoft.Quic; | ||
|
|
||
| namespace System.Net.Quic; | ||
|
|
||
| internal static partial class MsQuicConfiguration | ||
| { | ||
| private const int CheckExpiredModulo = 32; | ||
|
|
||
| private const string DisableCacheEnvironmentVariable = "DOTNET_SYSTEM_NET_QUIC_DISABLE_CONFIGURATION_CACHE"; | ||
| private const string DisableCacheCtxSwitch = "System.Net.Quic.DisableConfigurationCache"; | ||
|
|
||
| private static volatile int s_configurationCacheEnabled = -1; | ||
| internal static bool ConfigurationCacheEnabled | ||
| { | ||
| get | ||
| { | ||
| int enabled = s_configurationCacheEnabled; | ||
| if (enabled != -1) | ||
| { | ||
| return enabled != 0; | ||
| } | ||
|
|
||
| // AppContext switch takes precedence | ||
| if (AppContext.TryGetSwitch(DisableCacheCtxSwitch, out bool value)) | ||
| { | ||
| s_configurationCacheEnabled = value ? 0 : 1; | ||
| } | ||
| else | ||
| { | ||
| // check environment variable | ||
| s_configurationCacheEnabled = Environment.GetEnvironmentVariable(DisableCacheEnvironmentVariable) is string envVar && (envVar == "1" || envVar.Equals("true", StringComparison.OrdinalIgnoreCase)) ? 0 : 1; | ||
| } | ||
|
|
||
| return s_configurationCacheEnabled != 0; | ||
| } | ||
| } | ||
|
|
||
| private static readonly ConcurrentDictionary<CacheKey, MsQuicConfigurationSafeHandle> s_configurationCache = new(); | ||
|
|
||
| private readonly struct CacheKey : IEquatable<CacheKey> | ||
| { | ||
| public readonly List<byte[]> CertificateThumbprints; | ||
| public readonly QUIC_CREDENTIAL_FLAGS Flags; | ||
| public readonly QUIC_SETTINGS Settings; | ||
| public readonly List<SslApplicationProtocol> ApplicationProtocols; | ||
| public readonly QUIC_ALLOWED_CIPHER_SUITE_FLAGS AllowedCipherSuites; | ||
|
|
||
| public CacheKey(QUIC_SETTINGS settings, QUIC_CREDENTIAL_FLAGS flags, X509Certificate? certificate, ReadOnlyCollection<X509Certificate2>? intermediates, List<SslApplicationProtocol> alpnProtocols, QUIC_ALLOWED_CIPHER_SUITE_FLAGS allowedCipherSuites) | ||
| { | ||
| CertificateThumbprints = certificate == null ? new List<byte[]>() : new List<byte[]> { certificate.GetCertHash() }; | ||
|
|
||
| if (intermediates != null) | ||
| { | ||
| foreach (X509Certificate2 intermediate in intermediates) | ||
| { | ||
| CertificateThumbprints.Add(intermediate.GetCertHash()); | ||
| } | ||
| } | ||
|
|
||
| Flags = flags; | ||
| Settings = settings; | ||
| // make defensive copy to prevent modification (the list comes from user code) | ||
| ApplicationProtocols = new List<SslApplicationProtocol>(alpnProtocols); | ||
| AllowedCipherSuites = allowedCipherSuites; | ||
| } | ||
|
|
||
| public override bool Equals(object? obj) => obj is CacheKey key && Equals(key); | ||
|
|
||
| public bool Equals(CacheKey other) | ||
| { | ||
| if (CertificateThumbprints.Count != other.CertificateThumbprints.Count) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| for (int i = 0; i < CertificateThumbprints.Count; i++) | ||
| { | ||
| if (!CertificateThumbprints[i].AsSpan().SequenceEqual(other.CertificateThumbprints[i])) | ||
| { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| if (ApplicationProtocols.Count != other.ApplicationProtocols.Count) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| for (int i = 0; i < ApplicationProtocols.Count; i++) | ||
| { | ||
| if (ApplicationProtocols[i] != other.ApplicationProtocols[i]) | ||
| { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| return | ||
| Flags == other.Flags && | ||
| Settings.Equals(other.Settings) && | ||
| AllowedCipherSuites == other.AllowedCipherSuites; | ||
| } | ||
|
|
||
| public override int GetHashCode() | ||
| { | ||
| HashCode hash = default; | ||
|
|
||
| foreach (var thumbprint in CertificateThumbprints) | ||
| { | ||
| hash.AddBytes(thumbprint); | ||
| } | ||
|
|
||
| hash.Add(Flags); | ||
| hash.Add(Settings); | ||
|
|
||
| foreach (var protocol in ApplicationProtocols) | ||
| { | ||
| hash.AddBytes(protocol.Protocol.Span); | ||
| } | ||
|
|
||
| hash.Add(AllowedCipherSuites); | ||
|
|
||
| return hash.ToHashCode(); | ||
| } | ||
| } | ||
|
|
||
| private static MsQuicConfigurationSafeHandle GetCachedCredentialOrCreate(QUIC_SETTINGS settings, QUIC_CREDENTIAL_FLAGS flags, X509Certificate? certificate, ReadOnlyCollection<X509Certificate2>? intermediates, List<SslApplicationProtocol> alpnProtocols, QUIC_ALLOWED_CIPHER_SUITE_FLAGS allowedCipherSuites) | ||
| { | ||
| CacheKey key = new CacheKey(settings, flags, certificate, intermediates, alpnProtocols, allowedCipherSuites); | ||
|
|
||
| MsQuicConfigurationSafeHandle? handle; | ||
|
|
||
| if (s_configurationCache.TryGetValue(key, out handle) && handle.TryAddRentCount()) | ||
| { | ||
| if (NetEventSource.Log.IsEnabled()) | ||
| { | ||
| NetEventSource.Info(null, $"Found cached MsQuicConfiguration: {handle}."); | ||
| } | ||
| return handle; | ||
| } | ||
|
|
||
| // if we get here, the handle is either not in the cache, or we lost the race between | ||
| // TryAddRentCount on this thread and MarkForDispose on another thread doing cache cleanup. | ||
| // In either case, we need to create a new handle. | ||
|
|
||
| if (NetEventSource.Log.IsEnabled()) | ||
| { | ||
| NetEventSource.Info(null, $"MsQuicConfiguration not found in cache, creating new."); | ||
| } | ||
|
|
||
| handle = CreateInternal(settings, flags, certificate, intermediates, alpnProtocols, allowedCipherSuites); | ||
| handle.TryAddRentCount(); // we are the first renter | ||
|
ManickaP marked this conversation as resolved.
|
||
|
|
||
| MsQuicConfigurationSafeHandle cached; | ||
| do | ||
| { | ||
| cached = s_configurationCache.GetOrAdd(key, handle); | ||
| } | ||
| // If we get the same handle back, we successfully added it to the cache and we are done. | ||
| // If we get a different handle back, we need to increase the rent count. | ||
| // If we fail to add the rent count, then the existing/cached handle is in process of | ||
| // being removed from the cache and we can try again, eventually either succeeding to add our | ||
| // new handle or getting a fresh handle inserted by another thread meanwhile. | ||
| while (cached != handle && !cached.TryAddRentCount()); | ||
|
|
||
| if (cached != handle) | ||
| { | ||
| // we lost a race with another thread to insert new handle into the cache | ||
| if (NetEventSource.Log.IsEnabled()) | ||
| { | ||
| NetEventSource.Info(null, $"Discarding MsQuicConfiguration {handle} (preferring cached {cached})."); | ||
| } | ||
|
|
||
| // First dispose decrements the rent count we added before attempting the cache insertion | ||
| // and second closes the handle | ||
| handle.Dispose(); | ||
| handle.Dispose(); | ||
| Debug.Assert(handle.IsClosed); | ||
|
|
||
| return cached; | ||
| } | ||
|
|
||
| // we added a new handle, check if we need to cleanup | ||
| if (s_configurationCache.Count % CheckExpiredModulo == 0) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This may lead to frequently throwing out and recreating entries once you have > 32 certs in use. It may be worth adding slightly more logic here (e.g. skip throwing away entries that were used in the last second / use a timer instead of checking
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The strategy has been copied from the one we do in SslStream for Windows, I am not aware of any reported issues of too frequent cleanup (but maybe it just was not used for this sort of thing, cc @wfurt) and the code over there is ancient. How common is such a scenario? My thoughts on this is to keep it simple, we are not trying to prevent all unnecessary allocations, only the frequent ones, if, say, app rotates 40 different configurations but does outbound connection once every second (i.e. 40s to rotate through all of them), then I don't think creating a fresh configuration makes a measurable dent in CPU usage. If the app makes very frequent connections to multiple hosts and we should care about not doing extra work, then most of the configurations would still be in use and once there is a burst of connections and we stay above 32 connections, then there is no attempt for cleanup until the cache goes to 64 items, and so on. I am not opposed to adding additional conditions for the cleanup, but adding additional conditions like "only 1 cleanup per X seconds" feels arbitrary without a more concrete evidence that it will help. Another option would be making the cache cleanup size configurable via envvar so that we have a way out if some customer hits the issue.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. IMHO, this seems sufficient atm and if this proves problematic, we can always revisit the logic here. |
||
| { | ||
| // let only one thread perform cleanup at a time | ||
| lock (s_configurationCache) | ||
| { | ||
| if (s_configurationCache.Count % CheckExpiredModulo == 0) | ||
|
MihaZupan marked this conversation as resolved.
Outdated
|
||
| { | ||
| CleanupCache(); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return handle; | ||
| } | ||
|
|
||
| private static void CleanupCache() | ||
| { | ||
| KeyValuePair<CacheKey, MsQuicConfigurationSafeHandle>[] toRemoveAttempt = s_configurationCache.ToArray(); | ||
|
|
||
| if (NetEventSource.Log.IsEnabled()) | ||
| { | ||
| NetEventSource.Info(null, $"Cleaning up MsQuicConfiguration cache, current size: {toRemoveAttempt.Length}."); | ||
| } | ||
|
|
||
| foreach ((CacheKey key, MsQuicConfigurationSafeHandle handle) in toRemoveAttempt) | ||
| { | ||
| if (!handle.TryMarkForDispose()) | ||
| { | ||
| // handle in use | ||
| continue; | ||
| } | ||
|
|
||
| // the handle is not in use and has been marked such that no new rents can be added. | ||
| if (NetEventSource.Log.IsEnabled()) | ||
| { | ||
| NetEventSource.Info(null, $"Removing cached MsQuicConfiguration {handle}."); | ||
| } | ||
| bool removed = s_configurationCache.TryRemove(key, out _); | ||
| Debug.Assert(removed); | ||
| handle.Dispose(); | ||
| Debug.Assert(handle.IsClosed); | ||
| } | ||
|
|
||
| if (NetEventSource.Log.IsEnabled()) | ||
| { | ||
| NetEventSource.Info(null, $"Cleaning up MsQuicConfiguration cache, new size: {s_configurationCache.Count}."); | ||
| } | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.