Skip to content

Refactor ManagedWebSocket to avoid forcing Task allocations for ReceiveAsync - #56282

Merged
stephentoub merged 2 commits into
dotnet:mainfrom
stephentoub:websocketreceivealloc
Aug 11, 2021
Merged

Refactor ManagedWebSocket to avoid forcing Task allocations for ReceiveAsync#56282
stephentoub merged 2 commits into
dotnet:mainfrom
stephentoub:websocketreceivealloc

Conversation

@stephentoub

Copy link
Copy Markdown
Member

The ManagedWebSocket implementation today supports CloseAsyncs being issued concurrently with ReceiveAsyncs, even though CloseAsync needs to issue receives (this allowance was carried over from the .NET Framework implementation). Currently the implementation does that by storing the last ReceiveAsync task and awaiting it in CloseAsync if there is one, but that means multiple parties may try to await the same task multiple times (the original caller of ReceiveAsync and CloseAsync), which means we can't just use a ValueTask. So today asynchronously completing ReceiveAsyncs always use AsTask to create a Task from the returned ValueTask. This isn't actually an additional task allocation today, as the async ValueTask builder will create a Task for the asynchronously completing operation, and then AsTask will just return that (and when it completes synchronously, there's extra code to substitute a singleton). But once we switch to using the new pooling builder, that's no longer the case.

This PR uses an async lock as part of the ReceiveAsync implementation, with the existing async method awaiting entering that lock. CloseAsync is then rewritten to be in terms of calling ReceiveAsync in a loop. This also lets us remove the existing Monitor used for synchronously coordinating state between these operations, as the async lock serves that purpose as well. Rather than using a SemaphoreSlim, since we expect zero contention in the common case, we use a simple AsyncMutex that's optimized for the zero contention case, using a single interlocked to acquire and a single interlocked to release the lock.

Closes#50921

MethodToolchainMeanErrorStdDevRatioGen 0Gen 1Gen 2Allocated
PingPong\main\corerun.exe148.7 ms2.92 ms4.00 ms1.0029750.00003000.0000250.0000180,238 KB
PingPong\pr\corerun.exe108.9 ms1.56 ms1.38 ms0.72---249 KB
usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Running;usingSystem.Net.WebSockets;[MemoryDiagnoser]publicclassProgram{publicstaticvoidMain(string[]args)=>BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);privateclassConnection{publicreadonlyWebSocketClient,Server;publicreadonlyMemory<byte>ClientBuffer=newbyte[256];publicreadonlyMemory<byte>ServerBuffer=newbyte[256];publicreadonlyCancellationTokenCancellationToken=default;publicConnection(){(StreamStream1,StreamStream2)streams=ConnectedStreams.CreateBidirectional();Client=WebSocket.CreateFromStream(streams.Stream1,isServer:false,subProtocol:null,Timeout.InfiniteTimeSpan);Server=WebSocket.CreateFromStream(streams.Stream2,isServer:true,subProtocol:null,Timeout.InfiniteTimeSpan);}}privateConnection[]_connections=Enumerable.Range(0,256).Select(_ =>newConnection()).ToArray();privateconstintIters=1_000;[Benchmark]publicTaskPingPong()=>Task.WhenAll(fromcin_connectionsselectTask.WhenAll(Task.Run(async()=>{for(inti=0;i<Iters;i++){awaitc.Server.ReceiveAsync(c.ServerBuffer,c.CancellationToken);awaitc.Server.SendAsync(c.ServerBuffer,WebSocketMessageType.Binary,endOfMessage:true,c.CancellationToken);}}),Task.Run(async()=>{for(inti=0;i<Iters;i++){awaitc.Client.SendAsync(c.ClientBuffer,WebSocketMessageType.Binary,endOfMessage:true,c.CancellationToken);awaitc.Client.ReceiveAsync(c.ClientBuffer,c.CancellationToken);}})));}

@stephentoubstephentoub added this to the 6.0.0 milestone Jul 26, 2021
@ghost

Copy link
Copy Markdown

Tagging subscribers to this area: @dotnet/ncl
See info in area-owners.md if you want to be subscribed.

Issue Details

The ManagedWebSocket implementation today supports CloseAsyncs being issued concurrently with ReceiveAsyncs, even though CloseAsync needs to issue receives (this allowance was carried over from the .NET Framework implementation). Currently the implementation does that by storing the last ReceiveAsync task and awaiting it in CloseAsync if there is one, but that means multiple parties may try to await the same task multiple times (the original caller of ReceiveAsync and CloseAsync), which means we can't just use a ValueTask. So today asynchronously completing ReceiveAsyncs always use AsTask to create a Task from the returned ValueTask. This isn't actually an additional task allocation today, as the async ValueTask builder will create a Task for the asynchronously completing operation, and then AsTask will just return that (and when it completes synchronously, there's extra code to substitute a singleton). But once we switch to using the new pooling builder, that's no longer the case.

This PR uses an async lock as part of the ReceiveAsync implementation, with the existing async method awaiting entering that lock. CloseAsync is then rewritten to be in terms of calling ReceiveAsync in a loop. This also lets us remove the existing Monitor used for synchronously coordinating state between these operations, as the async lock serves that purpose as well. Rather than using a SemaphoreSlim, since we expect zero contention in the common case, we use a simple AsyncMutex that's optimized for the zero contention case, using a single interlocked to acquire and a single interlocked to release the lock.

Closes #50921

MethodToolchainMeanErrorStdDevRatioGen 0Gen 1Gen 2Allocated
PingPong\main\corerun.exe148.7 ms2.92 ms4.00 ms1.0029750.00003000.0000250.0000180,238 KB
PingPong\pr\corerun.exe108.9 ms1.56 ms1.38 ms0.72---249 KB
usingBenchmarkDotNet.Attributes;usingBenchmarkDotNet.Running;usingSystem.Net.WebSockets;[MemoryDiagnoser]publicclassProgram{publicstaticvoidMain(string[]args)=>BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args);privateclassConnection{publicreadonlyWebSocketClient,Server;publicreadonlyMemory<byte>ClientBuffer=newbyte[256];publicreadonlyMemory<byte>ServerBuffer=newbyte[256];publicreadonlyCancellationTokenCancellationToken=default;publicConnection(){(StreamStream1,StreamStream2)streams=ConnectedStreams.CreateBidirectional();Client=WebSocket.CreateFromStream(streams.Stream1,isServer:false,subProtocol:null,Timeout.InfiniteTimeSpan);Server=WebSocket.CreateFromStream(streams.Stream2,isServer:true,subProtocol:null,Timeout.InfiniteTimeSpan);}}privateConnection[]_connections=Enumerable.Range(0,256).Select(_ =>newConnection()).ToArray();privateconstintIters=1_000;[Benchmark]publicTaskPingPong()=>Task.WhenAll(fromcin_connectionsselectTask.WhenAll(Task.Run(async()=>{for(inti=0;i<Iters;i++){awaitc.Server.ReceiveAsync(c.ServerBuffer,c.CancellationToken);awaitc.Server.SendAsync(c.ServerBuffer,WebSocketMessageType.Binary,endOfMessage:true,c.CancellationToken);}}),Task.Run(async()=>{for(inti=0;i<Iters;i++){awaitc.Client.SendAsync(c.ClientBuffer,WebSocketMessageType.Binary,endOfMessage:true,c.CancellationToken);awaitc.Client.ReceiveAsync(c.ClientBuffer,c.CancellationToken);}})));}
Author:stephentoub
Assignees:-
Labels:

area-System.Net, tenet-performance

Milestone:6.0.0

@stephentoub

Copy link
Copy Markdown
MemberAuthor

@davidfowl, can you help validate this with ASP.NET functional tests and against relevant ASP.NET perf tests? That needs to be done before this can be merged.

Comment threadsrc/libraries/System.Net.WebSockets/src/System/Net/WebSockets/AsyncMutex.cs Outdated

@karelzkarelz 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.

@CarnaViire if you get a chance to take a look as well, that would be good.

@stephentoub
stephentoubforce-pushed the websocketreceivealloc branch from d57be3d to 74a34b8CompareAugust 4, 2021 13:45
@stephentoub

Copy link
Copy Markdown
MemberAuthor

@davidfowl, @adityamandaleeka, any update on validating this change for ASP.NET?

@CarnaViireCarnaViire 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.

LGTM

…veAsync
The ManagedWebSocket implementation today supports CloseAsyncs being issued concurrently with ReceiveAsyncs, even though CloseAsync needs to issue receives (this allowance was carried over from the .NET Framework implementation). Currently the implementation does that by storing the last ReceiveAsync task and awaiting it in CloseAsync if there is one, but that means multiple parties may try to await the same task multiple times (the original caller of ReceiveAsync and CloseAsync), which means we can't just use a ValueTask. So today asynchronously completing ReceiveAsyncs always use AsTask to create a Task from the returned ValueTask. This isn't actually an additional task allocation today, as the async ValueTask builder will create a Task for the asynchronously completing operation, and then AsTask will just return that (and when it completes synchronously, there's extra code to substitute a singleton). But once we switch to using the new pooling builder, that's no longer the case.
This PR uses an async lock as part of the ReceiveAsync implementation, with the existing async method awaiting entering that lock. CloseAsync is then rewritten to be in terms of calling ReceiveAsync in a loop. This also lets us remove the existing Monitor used for synchronously coordinating state between these operations, as the async lock serves that purpose as well. Rather than using a SemaphoreSlim, since we expect zero contention in the common case, we use a simple AsyncMutex that's optimized for the zero contention case, using a single interlocked to acquire and a single interlocked to release the lock.
@stephentoub

Copy link
Copy Markdown
MemberAuthor

Once CI is green, I'll go ahead and merge this. In my own tests, this shows up as neutral to positive both locally in microbenchmarks and on asp-perf-lin and asp-citrine-lin in terms of throughput. We can revert it if it ends up having any negative impact once it makes it to dotnet/aspnetcore.

@davidfowl, @adityamandaleeka, I'd still appreciate extra validation here, but at this point if I don't merge it's not going to make the release. The new websockets benchmark doesn't seem to really stress the system with or without this.

@davidfowl

Copy link
Copy Markdown
Member

Merging and propagating a dependency flow PR is the easiest way to get validation.

@stephentoub
stephentoub merged commit 19b86fb into dotnet:mainAug 11, 2021
@stephentoub
stephentoub deleted the websocketreceivealloc branch August 11, 2021 02:14
@ghostghost locked as resolved and limited conversation to collaborators Sep 10, 2021
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-System.Nettenet-performancePerformance related issue

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Use the new async state machine pooling feature on websockets

4 participants

@stephentoub@davidfowl@CarnaViire@karelz