With .NET 5 released in November, it’s a good time to talk about some of the many improvements in the networking stack. This includes improvements around HTTP, Sockets, networking-related security, and other networking primitives. In this post, I will highlight some of the more impactful and interesting changes in the release.
HTTP
Better Error Handling
In the year since .NET Core 3.1 was released, many improvements and fixes have been made in the HTTP space. One of the most requested was to add the ability to distinguish timeouts from cancellations when using HttpClient
(#21965). Originally, a custom CancellationToken
had to be used to distinguish a timeout from a cancellation:
class Program
{
private static readonly HttpClient _client = new HttpClient()
{
Timeout = TimeSpan.FromSeconds(10)
};
static async Task Main()
{
var cts = new CancellationTokenSource();
try
{
// Pass in the token.
using var response = await _client.GetAsync("http://localhost:5001/sleepFor?seconds=100", cts.Token);
}
// If the token has been canceled, it is not a timeout.
catch (TaskCanceledException ex) when (cts.IsCancellationRequested)
{
// Handle cancellation.
Console.WriteLine("Canceled: " + ex.Message);
}
catch (TaskCanceledException ex)
{
// Handle timeout.
Console.WriteLine("Timed out: "+ ex.Message);
}
}
}
With this change, the client still throws TaskCanceledException
(for backward compatibility), but the inner exception is a TimeoutException
in case of a timeout:
class Program
{
private static readonly HttpClient _client = new HttpClient()
{
Timeout = TimeSpan.FromSeconds(10)
};
static async Task Main()
{
try
{
using var response = await _client.GetAsync("http://localhost:5001/sleepFor?seconds=100");
}
// Filter by InnerException.
catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
// Handle timeout.
Console.WriteLine("Timed out: "+ ex.Message);
}
catch (TaskCanceledException ex)
{
// Handle cancellation.
Console.WriteLine("Canceled: " + ex.Message);
}
}
}
Another improvement was to add HttpStatusCode
to HttpRequestException
(#911). The new StatusCode
property is nullable set when EnsureSuccessStatusCode
is called on the response. Then it can be used in exception filters:
class Program
{
private static readonly HttpClient _client = new HttpClient();
static async Task Main()
{
try
{
using var response = await _client.GetAsync("https://localhost:5001/doesNotExists");
// The following line will throw HttpRequestException with StatusCode set if it wasn't 2xx.
response.EnsureSuccessStatusCode();
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
// Handle 404
Console.WriteLine("Not found: " + ex.Message);
}
}
}
Since HttpClient
helper methods: GetStringAsync
, GetByteArrayAsync
and GetStreamAsync
do not return HttpResponseMessage
, they call EnsureSuccessStatusCode
themselves. The exception filtering for those calls then looks like this:
class Program
{
private static readonly HttpClient _client = new HttpClient();
static async Task Main()
{
try
{
// The helper method will throw HttpRequestException with StatusCode set if it wasn't 2xx.
using var stream = await _client.GetStreamAsync("https://localhost:5001/doesNotExists");
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
// Handle 404
Console.WriteLine("Not found: " + ex.Message);
}
}
}
And thanks to the new constructor being public, HttpRequestException
with a status code can be created manually:
class Program
{
private static readonly HttpClient _client = new HttpClient();
static async Task Main()
{
try
{
using var response = await _client.GetAsync("https://localhost:5001/doesNotExists");
// Throw for anything higher than 400.
if (response.StatusCode >= HttpStatusCode.BadRequest)
{
throw new HttpRequestException("Something went wrong", inner: null, response.StatusCode);
}
}
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound)
{
// Handle 404
Console.WriteLine("Not found: " + ex.Message);
}
}
}
We would also like to point out that the credit for this change goes to the community. It was implemented by @yaakov-h in #32455.
Consistent Cross-Platform Implementation
Originally, the HTTP stack in .NET Core relied on platform dependent handlers:
WinHttpHandler
based on WinHTTP for Windows.CurlHandler
based on libcurl for Linux and Mac.
It was almost impossible to achieve consistency across platforms due to the differences between the two libraries. So in .NET Core 2.1 we introduced a managed HTTP implementation called SocketsHttpHandler
. We shifted most of our efforts to SocketsHttpHandler
and, as we became confident in its reliability and feature set, we decided to remove the platform specific handlers from System.Net.Http.dll
completely. In .NET 5, it is no longer possible to switch back to them with the "System.Net.Http.UseSocketsHttpHandler"
AppContext
switch as it used to be. However, WinHttpHandler
is still available as a standalone NuGet package. Any code that made use of it via the switch needs to be changed to reference System.Net.Http.WinHttpHandler
NuGet package:
dotnet add package System.Net.Http.WinHttpHandler
and explicitly pass a WinHttpHandler
instance to the HttpClient
constructor:
class Program
{
private static readonly HttpClient _client = new HttpClient(new WinHttpHandler());
static async Task Main()
{
using var response = await _client.GetAsync("http://localhost:5001/");
}
}
SocketsHttpHandler Extension Points
HttpClient
is a high-level API that is convenient to use but lacks the flexibility in some cases. In more advanced scenarios, finer control is necessary. We have tried to bridge some of these gaps and introduced two extension points to SocketsHttpHandler
– ConnectCallback
(#41949) and PlaintextStreamFilter
(#42557).
ConnectCallback
enables customizing new connection creation. It is called every time a new TCP connection is opened. The callback can be used to establish in-process transport, to control DNS resolution, to control general or platform-specific options of the underlying socket, or just to be notified whenever a new connection is opened. The following remarks apply to the callback:
- It is passed
DnsEndPoint
determining the remote endpoint andHttpRequestMessage
which initiated the connection creation. - Since
SocketsHttpHandler
provides connection pooling, the created connection might be used to handle multiple subsequent requests, not just the initial one. - A new
Stream
is expected to be returned. - The callback should not try to establish a TLS session. This is handled afterwards by
SocketsHttpHandler
.
The default implementation when no callback is provided is equivalent of the following minimal, socket-based callback:
private static async ValueTask<Stream> DefaultConnectAsync(SocketsHttpConnectionContext context, CancellationToken cancellationToken)
{
// The following socket constructor will create a dual-mode socket on systems where IPV6 is available.
Socket socket = new Socket(SocketType.Stream, ProtocolType.Tcp);
// Turn off Nagle's algorithm since it degrades performance in most HttpClient scenarios.
socket.NoDelay = true;
try
{
await socket.ConnectAsync(context.DnsEndPoint, cancellationToken).ConfigureAwait(false);
// The stream should take the ownership of the underlying socket,
// closing it when it's disposed.
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
The other extension point, PlaintextStreamFilter
, enables to plug-in a custom layer above a newly opened connection. This callback is invoked after the connection is fully established, including TLS handshake for secure connections, but before any HTTP requests have been sent. As a result, it is possible to use it to listen to plain-text data sent over a secure connection. The general guidelines for this callback are:
- It is passed a stream, the negotiated HTTP version (it might differ from the request version, see ALPN) and an initiating HTTP request.
- The same stream will be used by subsequent requests as well.
- A stream is expected as the returned value. It can be the one passed in without any changes or a custom stream encapsulating it.
- How to implement a custom stream can be found in the
Stream
documentation. - The custom stream should eventually delegate the reads and writes to the provided one but it can intercept the exchanged data.
- How to implement a custom stream can be found in the
A very minimal example of PlaintextStreamFilter
without a custom stream could look like:
socketsHandler.PlaintextStreamFilter = (context, token) =>
{
Console.WriteLine($"Request {context.InitialRequestMessage} --> negotiated version {context.NegotiatedHttpVersion}");
return ValueTask.FromResult(context.PlaintextStream);
};
The timeline of connection creation with the new extension points is:
ConnectCallback
is called to open TCP connection.SocketsHttpHandler
internally establishes TLS if necessary.PlaintextStreamFilter
is called with the stream from previous step. If no callback has been registered, nothing is called here.
Both of these callbacks are intended for advanced control over connections in SocketsHttpHandler
. They should be implemented and tested with utmost care since they might unintentionally cause performance and stability issues.
Sync API for HttpClient.Send
While we recommend using asynchronous networking APIs for better performance and scalability, we recognize that there are occasions when using a synchronous API is necessary, and synchronously blocking waiting for HttpClient.SendAsync
often has scalability problems due to requiring multiple threads to complete a single operation. Other pitfalls of this approach, including the infamous deadlock of the UI thread, can be found in this article.
To enable the synchronous scenarios and avoid these issues, we have added a synchronous version of HttpClient.Send
, but the implementation has some caveats:
- Only HTTP/1.1 is supported. HTTP/2 uses request multiplexing over a shared connection, thus interleaving requests could block or be blocked by the synchronous operation.
- It cannot be used together with the aforementioned
ConnectCallback
. - If a handler other than the default
SocketsHttpHandler
is used, it must implementHttpMessageHandler.Send
. Otherwise, the default implementation of synchronousHttpMessageHandler.Send
will throw. - A similar restriction applies to a custom
HttpContent
implementation, it must overrideHttpContent.SerializeToStream
in order to be able to be used as a request content in a synchronous call.
We strongly recommend to keep using the async APIs where possible.
HTTP/2
Version Selection
This feature evolved from a request to support HTTP/2 over clear-text (h2c). Clear-text communication is not just practical for local debugging or testing environments, there can be HTTP/2-based services, living behind a firewall or reverse proxy, that do not use TLS. For example, gRPC services use HTTP/2 as a transport and some have chosen to forego encryption.
Up until .NET 5, an unsupported application-wide switch must have been turned on to enable clear-text HTTP/2 communication, which could be problematic since it does not enable per request control. Without the switch, every clear-text HTTP/2 request was automatically downgraded to HTTP/1.1. This is because the TLS extension, ALPN, is used to negotiate the final HTTP version with the server. Without TLS and thus without ALPN, the client cannot be sure that the server will be able to handle HTTP/2. Thus the client avoids the risk and prefers HTTP/1.1, which is universally supported. However, in the case of previously mentioned back-end services and gRPC, it may be known beforehand that all participants can handle h2c and so the automatic downgrade is undesirable.
When we designed the version selection (#987), we tried to generalize the original problem and make the API reasonably “future proof”. As a result, we decided to give the user control over how the version downgrades and upgrades are handled. We have introduced HttpVersionPolicy
, a new enum saying whether downgrades, upgrades, or only the exact version are accepted. For requests created manually and sent by HttpClient.SendAsync
, the policy can be set directly to the request, via HttpRequestMessage.VersionPolicy
. For calls like GetAsync
, PostAsync
, DeleteAsync
, etc., that create the requests internally, an HttpClient
instance property HttpClient.DefaultVersionPolicy
is used to control the policy.
For example, to enable h2c scenarios we could do this:
class Program
{
private static readonly HttpClient _client = new HttpClient()
{
// Allow only HTTP/2, no downgrades or upgrades.
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact,
DefaultRequestVersion = HttpVersion.Version20
};
static async Task Main()
{
try
{
// Request clear-text http, no https.
// The call will internally create a new request corresponding to:
// new HttpRequestMessage(HttpMethod.Get, "http://localhost:5001/h2c")
// {
// Version = HttpVersion.Version20,
// VersionPolicy = HttpVersionPolicy.RequestVersionExact
// }
using var response = await _client.GetAsync("http://localhost:5001/h2c");
}
catch (HttpRequestException ex)
{
// Handle errors, including when h2c connection cannot be established.
Console.WriteLine("Error: " + ex.Message);
}
}
}
Multiple Connections with HTTP/2
HTTP/2 enables multiple simultaneous requests to be multiplexed over a single TCP connection. According to the HTTP/2 specification, only a single TCP connection should be opened to the server. This recommendation works really well for browsers and addresses the problems of HTTP/1 opening multiple connections per origin. However, this reduces the maximum number of concurrent requests to what is set in the SETTINGS frame, which often can be set as low as 100. For service-to-service communication, where one client is sending a very high number of requests to a small number of servers and/or can keep multiple long-lived requests, this limit can significantly affect throughput and performance. To overcome this constraint, we have introduced the ability to open multiple HTTP/2 connections to a single endpoint (#35088).
By default, multiple HTTP/2 connections are disabled. To enable them, SocketsHttpHandler.EnableMultipleHttp2Connections
can be set to true
. An example with multiple concurrent requests can look like:
class Program
{
private static readonly HttpClient _client = new HttpClient(new SocketsHttpHandler()
{
// Enable multiple HTTP/2 connections.
EnableMultipleHttp2Connections = true,
// Log each newly created connection and create the connection the same way as it would be without the callback.
ConnectCallback = async (context, token) =>
{
Console.WriteLine(
$"New connection to {context.DnsEndPoint} with request:{Environment.NewLine}{context.InitialRequestMessage}");
var socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
await socket.ConnectAsync(context.DnsEndPoint, token).ConfigureAwait(false);
return new NetworkStream(socket, ownsSocket: true);
},
})
{
// Allow only HTTP/2, no downgrades or upgrades.
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact,
DefaultRequestVersion = HttpVersion.Version20
};
static async Task Main()
{
// Burst send 2000 requests in parallel.
var tasks = new Task[2000];
for (int i = 0; i < tasks.Length; ++i)
{
tasks[i] = _client.GetAsync("http://localhost:5001/");
}
await Task.WhenAll(tasks);
}
}
The console will show multiple messages from ConnectCallback
about a new connection being created. If EnableMultipleHttp2Connections
is commented out, the console will show only a single message.
Configurable Ping
The HTTP/2 specification defines PING frames, a mechanism to ensure that idle connections are kept alive. This feature is useful for long running idle connections that would otherwise be dropped. Such connections can be found in gRPC scenarios like streaming and long-lived remote procedure calls. Up until now, we were only replying to PING requests but never sending them.
For .NET 5, we have implemented sending of PING frames (#31198) with configurable interval, timeout, and whether to send them always or only with active requests. The full configuration with default values is:
public class SocketsHttpHandler
{
...
// The client will send PING frames to the server if it hasn't receive any frame on the connection for this period of time.
public TimeSpan KeepAlivePingDelay { get; set; } = Timeout.InfiniteTimeSpan;
// The client will close the connection if it doesn't receive PING ACK frame within the timeout.
public TimeSpan KeepAlivePingTimeout { get; set; } = TimeSpan.FromSeconds(20);
// Whether the client will send PING frames only if there are any active streams on the connection or even if it's idle.
public HttpKeepAlivePingPolicy KeepAlivePingPolicy { get; set; } = HttpKeepAlivePingPolicy.Always;
...
}
public enum HttpKeepAlivePingPolicy
{
// PING frames are sent only if there are active streams on the connection.
WithActiveRequests,
// PING frames are sent regardless if there are any active streams or not.
Always
}
The default value of KeepAlivePingDelay
(Timeout.InfiniteTimeSpan
) means that this feature is normally turned off and PING frames will not be automatically sent to the server. The client will still reply to received PING frames, that cannot be turned off. To enable automatic pings the KeepAlivePingDelay
must be changed, for example to 1 minute:
class Program
{
private static readonly HttpClient _client = new HttpClient(new SocketsHttpHandler()
{
KeepAlivePingDelay = TimeSpan.FromSeconds(60)
});
}
PING frames are sent only if there is no active communication with the server. Every incoming frame from the server resets the delay and only after no frame has been received for KeepAlivePingDelay
, a PING frame is sent. Then, the server is given KeepAlivePingTimeout
interval to reply. In case it doesn’t, the connection is considered lost and torn down. The algorithm is checking the delays and timeouts in regular intervals but at most every second. Setting either KeepAlivePingDelay
or KeepAlivePingTimeout
to a smaller value will lead to an exception.
For example, settings as follows:
new SocketsHttpHandler()
{
KeepAlivePingDelay = TimeSpan.FromSeconds(15),
KeepAlivePingTimeout = TimeSpan.FromSeconds(7.5)
};
will lead to 1.875 seconds interval because it is 1/4 of the smaller value of the two, i.e. min(KeepAlivePingDelay, KeepAlivePingTimeout)/4
. In this case, the timeout might happen somewhere between 7.5 to 9.5 seconds after the PING frame has been sent. Note that the calculation of the checking interval is an implementation detail and might change in the future.
HTTP/3
HTTP/3 and its underlying transport layer QUIC are in the final stages of being standardized. QUIC is a new UDP-based transport that offers some benefits over TCP based connections: – Faster handshakes for establishing secure connections with TLS. – More reliable multiplexing of multiple requests over a single connection, removing the head of line blocking problem when packets are dropped. – Connection migration enabling smoother transitions between networks for mobile clients, e.g. Wi-Fi to LTE and back again.
.NET 5 introduces experimental support for HTTP/3 – this feature is not yet recommended to be used in production. Under the hood, we are using MsQuic library, which is an open-sourced, cross-platform implementation of the QUIC protocol. Detailed instructions how to enable HTTP/3 with QUIC can be found in System.Net.Experimental.MsQuic repository:
- Currently, it is available only on Insider build of Windows which has the Schannel support required for QUIC.
- The package containing the MsQuic library, which is currently available only via an experimental feed, needs to be referenced.
HttpClient
QUIC support must be enabled via anAppContext
switch orDOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_HTTP3DRAFTSUPPORT
environment variable, for example like this:
AppContext.SetSwitch("System.Net.SocketsHttpHandler.Http3DraftSupport", true);
- The request must have
Version
andVersionPolicy
properties set as follows:
new HttpRequestMessage
{
// Request HTTP/3 version.
Version = new Version(3, 0),
// Only HTTP/3 is allowed, no version downgrades should happen.
VersionPolicy = HttpVersionPolicy.RequestVersionExact
}
This settings tells HttpClient
that we have a priori knowledge about the server supporting HTTP/3 and requests it. If HTTP/3 is not supported, an exception will be thrown.
The other option is to have the server advertise HTTP/3 via Alt-Svc header. Then the client can use it for subsequent requests. For this scenario, a request should not demand RequestVersionExact
since it needs to process the first request with a lower protocol version.
Better Cancellation Support
Task-based asynchronous methods are nowadays the preferred pattern for asynchronous programming. They are especially valuable in networking where a lot of I/O operations happen. The Task
pattern makes the code much more approachable than the original Begin/End
“APM” pattern. And part of the Task-based async pattern is using CancellationToken
for cancellations and timeouts. We have been continuously working on adding cancellation tokens and properly plumbing them all the way down everywhere. We still have gaps in missing overloads (#33418) but we have filled many in .NET 5.
For sockets, we added overloads to SocketTaskExtensions
where the async methods reside for now – we would like to fold them into the Socket
class itself in .NET 6 (#43901). The new overloads with CancellationToken
are:
public static class SocketTaskExtensions
{
public static ValueTask ConnectAsync(this Socket socket, EndPoint remoteEP, CancellationToken cancellationToken);
public static ValueTask ConnectAsync(this Socket socket, IPAddress address, int port, CancellationToken cancellationToken);
public static ValueTask ConnectAsync(this Socket socket, IPAddress[] addresses, int port, CancellationToken cancellationToken);
public static ValueTask ConnectAsync(this Socket socket, string host, int port, CancellationToken cancellationToken);
}
These overloads have been used in HttpClient
as well as in TcpClient
, leading to a new overloads in the latter:
public class TcpClient
{
public ValueTask ConnectAsync(IPAddress address, int port, CancellationToken cancellationToken);
public ValueTask ConnectAsync(IPAddress[] addresses, int port, CancellationToken cancellationToken);
public ValueTask ConnectAsync(string host, int port, CancellationToken cancellationToken);
}
In HTTP space, we added overloads for HttpClient
(#916) and HttpContent
(#576). HttpClient
was extended with Get(ByteArray|Stream|String)Async
overloads:
class HttpClient
{
public Task<byte[]> GetByteArrayAsync(string requestUri, CancellationToken cancellationToken);
public Task<byte[]> GetByteArrayAsync(Uri requestUri, CancellationToken cancellationToken);
public Task<Stream> GetStreamAsync(string requestUri, CancellationToken cancellationToken);
public Task<Stream> GetStreamAsync(Uri requestUri, CancellationToken cancellationToken);
public Task<string> GetStringAsync(string requestUri, CancellationToken cancellationToken);
public Task<string> GetStringAsync(Uri requestUri, CancellationToken cancellationToken);
}
And HttpContent
was given overloads for serialization and reading:
class HttpContent
{
public Task<byte[]> ReadAsByteArrayAsync(CancellationToken cancellationToken);
public Task<Stream> ReadAsStreamAsync(CancellationToken cancellationToken);
public Task<string> ReadAsStringAsync(CancellationToken cancellationToken);
protected virtual Task<Stream> CreateContentReadStreamAsync(CancellationToken cancellationToken);
protected virtual Task SerializeToStreamAsync(Stream stream, TransportContext context, CancellationToken cancellationToken);
public Task CopyToAsync(Stream stream, CancellationToken cancellationToken);
public Task CopyToAsync(Stream stream, TransportContext context, CancellationToken cancellationToken);
}
If we were to design HttpContent
now, with all the overloads, we would make CreateContentReadStreamAsync
and SerializeToStreamAsync
abstract instead of virtual. The problem is that we try not to break existing code by changing the contract of public classes. What ran under .NET Core 3.1 should keep running under .NET 5 without a change. Adding abstract methods breaks this promise so we had to resort to the virtual ones. Custom HttpContent
implementations should override them both despite being just virtual. All the in-box HttpContent
implementations, like ByteArrayContent
, MultipartContent
and StreamContent
, already do so.
Telemetry
We had been aware that the user story around monitoring the networking internals of .NET Core applications has not been great. Up until now, only very verbose and inconsistent logging messages were possible to collect, and listening for them could have a measurable impact on performance. For .NET 5, we have designed and implemented a new set of telemetry events and counters (#37428). These events and counters were made with continuous monitoring in mind, so they are not as resource heavy as the internal logging. Nevertheless, they are not completely free and listening will consume some, albeit small, amount of CPU cycles. The complete list of them is in this comment of the issue.
We are making these new telemetry events and counters publicly known and they are intended to be consumed by the .NET users. We plan to support them in the future and any change to them will be considered a breaking change.
The telemetry events as well as the counters are based on EventSource
. They can be consumed either in-process via EventListener
or out-of-proc through EventPipe
with the dotnet-trace
and dotnet-counters
command line tools. More information about these tools can be found in our diagnostics repository.
Consuming Telemetry Events
One way to consume the telemetry events is programmatically in the process via EventListener
:
class Program
{
private static readonly HttpClient _client = new HttpClient();
static async Task Main()
{
// Instantiate the listener which subscribes to the events.
using var listener = new HttpEventListener();
// Send an HTTP request.
using var response = await client.GetAsync("https://github.com/runtime");
}
}
internal sealed class HttpEventListener : EventListener
{
// Constant necessary for attaching ActivityId to the events.
public const EventKeywords TasksFlowActivityIds = (EventKeywords)0x80;
protected override void OnEventSourceCreated(EventSource eventSource)
{
// List of event source names provided by networking in .NET 5.
if (eventSource.Name == "System.Net.Http" ||
eventSource.Name == "System.Net.Sockets" ||
eventSource.Name == "System.Net.Security" ||
eventSource.Name == "System.Net.NameResolution")
{
EnableEvents(eventSource, EventLevel.LogAlways);
}
// Turn on ActivityId.
else if (eventSource.Name == "System.Threading.Tasks.TplEventSource")
{
// Attach ActivityId to the events.
EnableEvents(eventSource, EventLevel.LogAlways, TasksFlowActivityIds);
}
}
protected override void OnEventWritten(EventWrittenEventArgs eventData)
{
var sb = new StringBuilder().Append($"{eventData.TimeStamp:HH:mm:ss.fffffff} {eventData.ActivityId}.{eventData.RelatedActivityId} {eventData.EventSource.Name}.{eventData.EventName}(");
for (int i = 0; i < eventData.Payload?.Count; i++)
{
sb.Append(eventData.PayloadNames?[i]).Append(": ").Append(eventData.Payload[i]);
if (i < eventData.Payload?.Count - 1)
{
sb.Append(", ");
}
}
sb.Append(")");
Console.WriteLine(sb.ToString());
}
}
This small program will produce a console log like this:
19:16:42.5983845 00000011-0000-0000-0000-00005d729c59.00000000-0000-0000-0000-000000000000 System.Net.Http.RequestStart(scheme: https, host: github.com, port: 443, pathAndQuery: /runtime, versionMajor: 1, versionMinor: 1, versionPolicy: 0)
19:16:42.6247315 00001011-0000-0000-0000-00005d429c59.00000011-0000-0000-0000-00005d729c59 System.Net.NameResolution.ResolutionStart(hostNameOrAddress: github.com)
19:16:42.6797116 00001011-0000-0000-0000-00005d429c59.00000000-0000-0000-0000-000000000000 System.Net.NameResolution.ResolutionStop()
19:16:42.6806290 00002011-0000-0000-0000-00005d529c59.00000011-0000-0000-0000-00005d729c59 System.Net.Sockets.ConnectStart(address: InterNetworkV6:28:{1,187,0,0,0,0,0,0,0,0,0,0,0,0,0,0,255,255,140,82,121,3,0,0,0,0})
19:16:42.7115980 00002011-0000-0000-0000-00005d529c59.00000000-0000-0000-0000-000000000000 System.Net.Sockets.ConnectStop()
19:16:42.7157362 00003011-0000-0000-0000-00005d229c59.00000011-0000-0000-0000-00005d729c59 System.Net.Security.HandshakeStart(isServer: False, targetHost: github.com)
19:16:42.8606049 00003011-0000-0000-0000-00005d229c59.00000000-0000-0000-0000-000000000000 System.Net.Security.HandshakeStop(protocol: 12288)
19:16:42.8624541 00000011-0000-0000-0000-00005d729c59.00000000-0000-0000-0000-000000000000 System.Net.Http.ConnectionEstablished(versionMajor: 1, versionMinor: 1)
19:16:42.8651762 00004011-0000-0000-0000-00005d329c59.00000011-0000-0000-0000-00005d729c59 System.Net.Http.RequestHeadersStart()
19:16:42.8658442 00004011-0000-0000-0000-00005d329c59.00000000-0000-0000-0000-000000000000 System.Net.Http.RequestHeadersStop()
19:16:42.8979467 00005011-0000-0000-0000-00005d029c59.00000011-0000-0000-0000-00005d729c59 System.Net.Http.ResponseHeadersStart()
19:16:42.9037560 00005011-0000-0000-0000-00005d029c59.00000000-0000-0000-0000-000000000000 System.Net.Http.ResponseHeadersStop()
19:16:42.9090497 00006011-0000-0000-0000-00005d129c59.00000011-0000-0000-0000-00005d729c59 System.Net.Http.ResponseContentStart()
19:16:43.1092912 00006011-0000-0000-0000-00005d129c59.00000000-0000-0000-0000-000000000000 System.Net.Http.ResponseContentStop()
19:16:43.1093326 00000011-0000-0000-0000-00005d729c59.00000000-0000-0000-0000-000000000000 System.Net.Http.RequestStop()
19:16:43.1109221 00000000-0000-0000-0000-000000000000.00000000-0000-0000-0000-000000000000 System.Net.Http.ConnectionClosed(versionMajor: 1, versionMinor: 1)
The events named *Start
and *Stop
are emitted with the same ActivityId
. Such events have special meaning and automatically correlated. It enables monitoring tools like PerfView to count the time consumed by the operation or link other events to the parent one.
Another way is outside of the process via dotnet-trace
:
class Program
{
private static readonly HttpClient _client = new HttpClient();
static async Task Main()
{
// No listener needed but print the process ID and wait for a key press to start the request.
Console.WriteLine(Environment.ProcessId);
Console.ReadKey();
// Send an HTTP request.
using var response = await client.GetAsync("https://github.com/runtime");
}
}
# Name the source events from previous example as providers (--providers).
# Set the output file name (-o).
# Set the process ID (-p).
dotnet trace collect --providers System.Net.Http,System.Net.Sockets,System.Net.Security,System.Net.NameResolution -o networking.nettrace -p 1234
The dotnet-trace
will produce a nettrace
file that can be opened and read in PerfView:
Consuming Counters
Counters can either be consumed programmatically in the process via EventListener
:
class Program
{
private static readonly HttpClient _client = new HttpClient();
static async Task Main()
{
// Instantiate the listener which subscribes to the events.
using var listener = new HttpEventListener();
// Send an HTTP request.
using var response = await client.GetAsync("https://github.com/runtime");
}
}
internal sealed class HttpEventListener : EventListener
{
protected override void OnEventSourceCreated(EventSource eventSource)
{
// List of event source names provided by networking in .NET 5.
if (eventSource.Name == "System.Net.Http" ||
eventSource.Name == "System.Net.Sockets" ||
eventSource.Name == "System.Net.Security" ||
eventSource.Name == "System.Net.NameResolution")
{
EnableEvents(eventSource, EventLevel.LogAlways, EventKeywords.All, new Dictionary<string, string>()
{
// These additional arguments will turn on counters monitoring with a reporting interval set to a half of a second.
["EventCounterIntervalSec"] = TimeSpan.FromSeconds(0.5).TotalSeconds.ToString()
});
}
}
protected override void OnEventWritten(EventWrittenEventArgs eventData)
{
// It's a counter, parse the data properly.
if (eventData.EventId == -1)
{
var sb = new StringBuilder().Append($"{eventData.TimeStamp:HH:mm:ss.fffffff} {eventData.EventSource.Name} ");
var counterPayload = (IDictionary<string, object>)(eventData.Payload[0]);
bool appendSeparator = false;
foreach (var counterData in counterPayload)
{
if (appendSeparator)
{
sb.Append(", ");
}
sb.Append(counterData.Key).Append(": ").Append(counterData.Value);
appendSeparator = true;
}
Console.WriteLine(sb.ToString());
}
}
}
With a console log looking like this:
19:38:55.9452792 System.Net.Http Name: requests-started, DisplayName: Requests Started, Mean: 0, StandardDeviation: 0, Count: 1, Min: 0, Max: 0, IntervalSec: 0.0004773, Series: Interval=500, CounterType: Mean, Metadata: , DisplayUnits:
19:38:55.9487031 System.Net.Http Name: requests-started-rate, DisplayName: Requests Started Rate, DisplayRateTimeScale: 00:00:01, Increment: 1, IntervalSec: 0.0004773, Metadata: , Series: Interval=500, CounterType: Sum, DisplayUnits:
19:38:55.9487610 System.Net.Http Name: requests-failed, DisplayName: Requests Failed, Mean: 0, StandardDeviation: 0, Count: 1, Min: 0, Max: 0, IntervalSec: 0.0004773, Series: Interval=500, CounterType: Mean, Metadata: , DisplayUnits:
19:38:55.9487888 System.Net.Http Name: requests-failed-rate, DisplayName: Requests Failed Rate, DisplayRateTimeScale: 00:00:01, Increment: 0, IntervalSec: 0.0004773, Metadata: , Series: Interval=500, CounterType: Sum, DisplayUnits:
19:38:55.9488052 System.Net.Http Name: current-requests, DisplayName: Current Requests, Mean: 1, StandardDeviation: 0, Count: 1, Min: 1, Max: 1, IntervalSec: 0.0004773, Series: Interval=500, CounterType: Mean, Metadata: , DisplayUnits:
19:38:55.9488201 System.Net.Http Name: http11-connections-current-total, DisplayName: Current Http 1.1 Connections, Mean: 0, StandardDeviation: 0, Count: 1, Min: 0, Max: 0, IntervalSec: 0.0004773, Series: Interval=500, CounterType: Mean, Metadata: , DisplayUnits:
19:38:55.9488524 System.Net.Http Name: http20-connections-current-total, DisplayName: Current Http 2.0 Connections, Mean: 0, StandardDeviation: 0, Count: 1, Min: 0, Max: 0, IntervalSec: 0.0004773, Series: Interval=500, CounterType: Mean, Metadata: , DisplayUnits:
19:38:55.9490235 System.Net.Http Name: http11-requests-queue-duration, DisplayName: HTTP 1.1 Requests Queue Duration, Mean: 0, StandardDeviation: 0, Count: 0, Min: ∞, Max: -∞, IntervalSec: 0.0004773, Series: Interval=500, CounterType: Mean, Metadata: , DisplayUnits: ms
19:38:55.9494528 System.Net.Http Name: http20-requests-queue-duration, DisplayName: HTTP 2.0 Requests Queue Duration, Mean: 0, StandardDeviation: 0, Count: 0, Min: ∞, Max: -∞, IntervalSec: 0.0004773, Series: Interval=500, CounterType: Mean, Metadata: , DisplayUnits: ms
19:38:55.9643081 System.Net.Sockets Name: outgoing-connections-established, DisplayName: Outgoing Connections Established, Mean: 0, StandardDeviation: 0, Count: 1, Min: 0, Max: 0, IntervalSec: 7.64E-05, Series: Interval=500, CounterType: Mean, Metadata: , DisplayUnits:
19:38:55.9643725 System.Net.Sockets Name: incoming-connections-established, DisplayName: Incoming Connections Established, Mean: 0, StandardDeviation: 0, Count: 1, Min: 0, Max: 0, IntervalSec: 7.64E-05, Series: Interval=500, CounterType: Mean, Metadata: , DisplayUnits:
19:38:55.9644278 System.Net.Sockets Name: bytes-received, DisplayName: Bytes Received, Mean: 0, StandardDeviation: 0, Count: 1, Min: 0, Max: 0, IntervalSec: 7.64E-05, Series: Interval=500, CounterType: Mean, Metadata: , DisplayUnits:
19:38:55.9644685 System.Net.Sockets Name: bytes-sent, DisplayName: Bytes Sent, Mean: 0, StandardDeviation: 0, Count: 1, Min: 0, Max: 0, IntervalSec: 7.64E-05, Series: Interval=500, CounterType: Mean, Metadata: , DisplayUnits:
19:38:55.9644858 System.Net.Sockets Name: datagrams-received, DisplayName: Datagrams Received, Mean: 0, StandardDeviation: 0, Count: 1, Min: 0, Max: 0, IntervalSec: 7.64E-05, Series: Interval=500, CounterType: Mean, Metadata: , DisplayUnits:
19:38:55.9645243 System.Net.Sockets Name: datagrams-sent, DisplayName: Datagrams Sent, Mean: 0, StandardDeviation: 0, Count: 1, Min: 0, Max: 0, IntervalSec: 7.64E-05, Series: Interval=500, CounterType: Mean, Metadata: , DisplayUnits:
19:38:55.9662685 System.Net.NameResolution Name: dns-lookups-requested, DisplayName: DNS Lookups Requested, Mean: 0, StandardDeviation: 0, Count: 1, Min: 0, Max: 0, IntervalSec: 1.6E-05, Series: Interval=500, CounterType: Mean, Metadata: , DisplayUnits:
19:38:56.0093961 System.Net.Security Name: tls-handshake-rate, DisplayName: TLS handshakes completed, DisplayRateTimeScale: 00:00:01, Increment: 0, IntervalSec: 1.99E-05, Metadata: , Series: Interval=500, CounterType: Sum, DisplayUnits:
Or outside of the process with dotnet-counters
:
# Name the source events from previous example as providers (--providers).
# Set the process ID (-p).
dotnet counters monitor System.Net.Http System.Net.Sockets System.Net.Security System.Net.NameResolution -p 1234
This will start the counter monitoring, overwriting the terminal window with the actual values, looking similar to:
[System.Net.Http]
Current Http 1.1 Connections 1
Current Http 2.0 Connections 0
Current Requests 1
HTTP 1.1 Requests Queue Duration (ms) 0
HTTP 2.0 Requests Queue Duration (ms) 0
Requests Failed 0
Requests Failed Rate (Count / 1 sec) 0
Requests Started 8
Requests Started Rate (Count / 1 sec) 1
[System.Net.Sockets]
Bytes Received 1,220,260
Bytes Sent 3,819
Datagrams Received 0
Datagrams Sent 0
Incoming Connections Established 0
Outgoing Connections Established 1
[System.Net.NameResolution]
Average DNS Lookup Duration (ms) 0
DNS Lookups Requested 1
[System.Net.Security]
All TLS Sessions Active 1
Current TLS handshakes 0
TLS 1.0 Handshake Duration (ms) 0
TLS 1.0 Sessions Active 0
TLS 1.1 Handshake Duration (ms) 0
TLS 1.1 Sessions Active 0
TLS 1.2 Handshake Duration (ms) 0
TLS 1.2 Sessions Active 0
TLS 1.3 Handshake Duration (ms) 0
TLS 1.3 Sessions Active 1
TLS Handshake Duration (ms) 0
TLS handshakes completed (Count / 1 sec) 0
Total TLS handshakes completed 1
Total TLS handshakes failed 0
Security
The security layer in .NET is dependent on the underlying OS and its capabilities. – For Linux based systems, we use OpenSSL which supports TLS 1.3 since its 1.1.1 version. – For Windows 10, TLS 1.3 is available since version 1903 but only for testing purposes and not for production. Furthermore, it is opt-in and has to be enabled in the registry. As a result, TLS 1.3 did not work on Windows in previous .NET Core versions (#1720).
This changed in Insider Preview build 20170 where TLS 1.3 is turned on by default and can be used via a new Schannel API. We have adjusted the SslStream
implementation on Windows for the new Schannel APIs, and have tested it with the Windows Insider Preview build for .NET 5.
We have also pursued getting a grade A in SSL test for .NET 5 (#30767). To achieve that, we had to introduce a breaking change to SslStream
on Linux where we now set an opinionated list of default cipher suites which are considered strong:
TLS 1.3 cipher suites
TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384
TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256
TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384
TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256
However, these can be overridden multiple ways:
- In code, by manually setting
CipherSuitesPolicy
either directly when calling one of theSslStream.AuthenticateAs...
methods. For example:
var sslStream = new SslStream(networkStream);
await sslStream.AuthenticateAsClientAsync(new SslClientAuthenticationOptions()
{
CipherSuitesPolicy = new CipherSuitesPolicy(new[]
{
TlsCipherSuite.TLS_AES_256_GCM_SHA384,
TlsCipherSuite.TLS_CHACHA20_POLY1305_SHA256,
TlsCipherSuite.TLS_AES_128_GCM_SHA256,
TlsCipherSuite.TLS_AES_128_CCM_8_SHA256,
TlsCipherSuite.TLS_AES_128_CCM_SHA256
}),
});
- Or indirectly for
HttpClient
viaSocketsHttpHandler.SslOptions
:
class Program
{
private static readonly HttpClient _client = new HttpClient(new SocketsHttpHandler()
{
SslOptions = new SslClientAuthenticationOptions()
{
CipherSuitesPolicy = new CipherSuitesPolicy(new []
{
TlsCipherSuite.TLS_AES_256_GCM_SHA384,
TlsCipherSuite.TLS_CHACHA20_POLY1305_SHA256,
TlsCipherSuite.TLS_AES_128_GCM_SHA256,
TlsCipherSuite.TLS_AES_128_CCM_8_SHA256,
TlsCipherSuite.TLS_AES_128_CCM_SHA256
})
}
};
...
}
- By system-wide configuration for OpenSSL (the location of the configuration file can be found with
openssl version -d
). The following configuration should be merged into the existing one instead of rewriting the whole file:
[default_conf]
ssl_conf = ssl_sect
[ssl_sect]
system_default = system_default_sect
[system_default_sect]
CipherString = ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA256
Final Notes
This article is not an exhaustive list of all the changes we did. For example we did not include any performance improvements since they are already covered by Stephen’s huge article about performance. And if you find any errors in the networking stack, do not hesitate to reach out, you can find us under dotnet/ncl
alias.
How do you suggest adding SocketsHttpHandler.SslOptions with typed HttpClient (in a HttpClientFactory scenario) because the AddHttpMessageHandler does not allow adding a SocketsHttpHandler
HttpClientFactory can work with SocketsHttpHandler. You need to set it up as a primary handler, e.g. like that:
Thats good to know, but what would be way to go if there is a need to use a HttlClientHandler to add client certificates in the ConfigurePrimaryHttpMessageHandler(), then am wondering how to add the SocketsHttpHandler?
“there are occasions when using a synchronous API is necessary”
Out of curiosity, what occasions are those? I literally can’t think of any. My guess is that the number of people using the sync API because they don’t understand async programming will far outweigh the number using it out of some necessity. I think it’s going to invite a lot of folks to write a lot of sub-optimal code.
If your code is already synchronous and you cannot change that, for example because it’s a part of a public API.
Whats the purpose of including “using” keyword in all of your examples? Does it really put out a call to dispose() in HttpClient, as the object itself is created as a class scoped public variable?
This does not dispose the client but the response object. It’s the same as:
Fantastic stuff,
Great work guys, We will be able to use a lot of these features in a number of different systems.
Any improvements around port exhaustion when using a httpClient per request?
using (var _http = new httpClient())
{
_http.sendAsync();
}
No, you should never use HttpClient per request, always cache it.
Amazing work!
Super excited to hear you guys are knocking out http3!
Minor Question: as for it’s implementation using that C lib under the hood, just curious: will that be long term, or a stop gap until a full C# implementation?
One puny typo:
“Get(ByteArray|Stream|Sync)Async“
Meant
“Get(ByteArray|Stream|String)Async“
as for it’s implementation using that C lib under the hood, just curious: will that be long term, or a stop gap until a full C# implementation?
That's actually being decided at the moment. For 6.0 we're leaning towards staying with msquic, but that might change in the future. If you are interested in managed implementation, there already is a prototype https://github.com/dotnet/runtimelab/pull/293 made by Radek Zikmund. Also this issue https://github.com/dotnet/runtime/issues/43546 tracks the main work for...
In the year since .NET 3.1 was released,
Should be read as .NET Core 3.1 ?
Note: Also, the previous 3 comments smell like spam..
Should be read as .NET Core 3.1 ?
I don’t think so, but I’m not a native speaker.
Yes it should read .Net Core 3.1.
It’s only since the version 5 of .Net Core that the Core suffix has been dropped. For the classic .Net it went from 3.0 to 3.5, there was never a 3.1 version.
You’re right, fixed. Thanks!
xuexi
very cool
Impressive!
I wish to be one of the people that making these improvements one day.