-
Notifications
You must be signed in to change notification settings - Fork 4.9k
Move the write side of SslStream to AsyncAwait #23606
Conversation
@@ -1112,7 +1118,8 @@ private static void PartialFrameCallback(AsyncProtocolRequest asyncRequest) | |||
// | |||
private static void ReadFrameCallback(AsyncProtocolRequest asyncRequest) | |||
{ | |||
if (NetEventSource.IsEnabled) NetEventSource.Enter(null); | |||
if (NetEventSource.IsEnabled) | |||
NetEventSource.Enter(null); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There are a bunch of changes to these one line event source calls. Any reason? Across the System.Net libs, we standardized on generally using the single-line syntax, treating the if (NetEventSource.IsEnabled)
as a prefixed guard.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FYI, there is a setting in the repo that causes that to happen if you ctrl-k,d which I just checked. It's a bit of a pain because if you ever do it (or if it's muscle memory :P ) then it breaks all that formatting.
Let me fix that. I didn't touch them but I did ctrl k,d |
if (lockState != LockHandshake) | ||
{ | ||
return Task.CompletedTask; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: blank line before the lock
|
||
_lockWriteState = LockPendingWrite; | ||
TaskCompletionSource<int> completionSource = new TaskCompletionSource<int>(); | ||
_queuedWriteStateRequest = completionSource; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rather than allocating a TaskCompletionSource for each of these, consider using AsyncTaskMethodBuilder
. It's a struct, so won't require an allocation each time. You just need to make sure that you access its Task property before any other thread could try to SetResult/Exception on it, as Task lazily-initializes the task. You also need to be careful about not copying it before accessing its Task property, as it's a mutable struct.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, I just realized _queuedWriteStateRequest is typed as object rather than as TaskCompletionSource<int>
. Nevermind then, since the ATMB
would just get boxed anyway.
default: | ||
// Async write is pending, start it on other thread. | ||
// Consider: we could start it in on this thread but that will delay THIS handshake completion | ||
ThreadPool.QueueUserWorkItem(new WaitCallback(CompleteRequestWaitCallback), obj); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we cache this delegate rather than allocating it on each write that pends?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We could you mean that last delegate? Its the original code move to pattern matching and is super infrequent. But I will change
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Right, I meant the WaitCallback.
return; | ||
case TaskCompletionSource<int> completionSource: | ||
//Async write is pending, should it be started on another thread? | ||
//Or is it fine to do it here |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does this comment mean?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Basically below they do a task.new () wonder if I should do similar?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, you mean do you need to queue the completion of it? That depends: might there be continuations off of this that we don't want to run as part of this call? If we do want to offload it, there are two options: if you only want to sometimes offload it, you can just queue the call to SetResult, as is done below. But if you always want to make sure that continuations run asynchronously from the SetResult call, you can pass TaskCreationOptions.RunContinuationsAsynchronously to the TaskCompletionSource's ctor.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will go with the second one. We don't want the result from the action calling SetResult to be delayed by a write that is pending.
@@ -1449,6 +1420,33 @@ private void FinishHandshake(Exception e, AsyncProtocolRequest asyncRequest) | |||
} | |||
} | |||
|
|||
private void ProcessPendingWrite() | |||
{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we require being called while holding the lock? If so, that'd be good to assert here. Or if we require that the lock isn't held when this is called, that'd be good to assert, too.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added an assert, but it didn't register it in Github as changed for some reason, maybe the line numbers moved too much.
I am happy to do it. Here is my thought process on this. It is only ever hit during a renegotiation and a write is attempted at the same time. The actual lock case is far away from a hotpath so I didn't want to complicate it or risk getting the renegotiation wrong as it's been a source of TLS problems over the years. Infact http/2 does not allow it's use at all. So I am happy to change it but I wonder if simplicity is better in this case? |
throw new InvalidOperationException(SR.Format(SR.net_io_invalidendcall, "EndWrite")); | ||
try | ||
{ | ||
await task; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
.ConfigureAwait(false) on all awaits
{ | ||
// Request a write IO slot. | ||
Task ioSlot = _sslState.CheckEnqueueWriteAsync(); | ||
if (!ioSlot.IsCompleted) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it possible ioSlot could complete as faulted rather than successfully? If so, I'd suggest making this IsCompletedSuccessfully rather than IsCompleted. That won't hurt the fast case, and if there is a chance it could fail, it'll ensure the exception is propagated rather than dropped, as we'll call WaitForWriteIOSlot which will await it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It would only do so if someone was mixing both a sync renegotiation and an async write. Can change but iscompletedsucessfully .net desktop or core only?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
IsCompletedSuccessfully is currently only in .NET Core. But then, so is all of this code, no?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Honestly the interaction between corefx -> core -> AOT -> desktop confuses me, if it's core only that's cool :) I only really code in that these days anyway.
ProtocolToken message = new ProtocolToken(null, status); | ||
ArrayPool<byte>.Shared.Return(rentedBuffer); | ||
throw new IOException(SR.net_io_encrypt, message.GetException()); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: add blank line after closing brace
{ | ||
try | ||
{ | ||
await t; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did you mean writeTask?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes :)
} | ||
} | ||
|
||
internal async Task WriteAsyncChunked(byte[] buffer, int offset, int count, CancellationToken cancellationToken) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: please use an Async suffix on all async methods
|
||
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) | ||
{ | ||
return _sslState.SecureStream.WriteAsync(buffer, offset, count, cancellationToken); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does the wrapped stream's WriteAsync do argument validation, or does that need to be done here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Argument validation is done in sslstreaminternal
Which comment is this in response to? |
It was the TCS but you changed your mind anyway :) |
Right I have made all changes I believe. |
@dotnet-bot Test Outerloop Windows x64 Debug Build |
…and open for future changes
Any progress on that? |
@@ -21,6 +21,7 @@ internal class SslState | |||
private static AsyncProtocolCallback s_partialFrameCallback = new AsyncProtocolCallback(PartialFrameCallback); | |||
private static AsyncProtocolCallback s_readFrameCallback = new AsyncProtocolCallback(ReadFrameCallback); | |||
private static AsyncCallback s_writeCallback = new AsyncCallback(WriteCallback); | |||
private static WaitCallback s_completeWaitCallback = new WaitCallback(CompleteRequestWaitCallback); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: it'd be nice if this delegate and the ones above it were readonly
internal bool CheckEnqueueWrite(AsyncProtocolRequest asyncRequest) | ||
internal Task CheckEnqueueWriteAsync() | ||
{ | ||
//Clear previous request. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: space after //
{ | ||
if (_lockWriteState != LockHandshake) | ||
{ | ||
CheckThrow(true); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: can we name the argument in the call? I don't know what "true" means here.
|
||
lock (this) | ||
{ | ||
if (_lockWriteState != LockHandshake) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a race condition here? I realize this predates your PR, but it looks strange. Outside of the lock the field is being manipulated by an interlocked, and then here there's a check protected by a lock, but that lock and interlocked have nothing to do with each other... could another thread be doing that interlocked operation at the same time we're checking/setting _lockWriteState here?
@@ -1449,6 +1430,34 @@ private void FinishHandshake(Exception e, AsyncProtocolRequest asyncRequest) | |||
} | |||
} | |||
|
|||
private void ProcessPendingWrite() | |||
{ | |||
Debug.Assert(_lockWriteState == LockPendingWrite || _lockWriteState == LockHandshake); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for adding the assert. This is good to have, but I'd actually been asking whether we can/should add an assert like:
Debug.Assert(Monitor.IsEntered(this));
Here's a different approach that might allow us to maintain a single codepath for sync and async. The idea is to hide the sync/async behind a common (async) interface, and await this as usual, but then genericize the implementation methods via a struct that implements this interface. Thoughts?
|
Are we paying for an interface dispatch now in this? Also I am not 100% sure this will solve the issue. The hot path needs to "skip" all the awaiting and just get on with it (the hotpath for async that is). It might work if you then did a GetAwaiter().GetResult() on the result and let the read/write non async flow through the IsComplete path. I would say, this should potentially be a follow up, to reduce this. |
Follow up PR I mean. |
Actually you won't I missed the generic... should work. I like the idea, but would prefer if possible to clear the outstanding issues (and those for the other PR) then I will happily prototype and attempt to crush the sync/async split. Basically my end goals are
Number 2 has the effect of any future code changes or improvements should be a lot easier, and have a lower barrier of entry for others in the community to get involved. With that in mind your idea above is a good one for number 2! |
Don't need the holder for async? |
If you have a pre-existing interface, then you could use that instead of the holder for async. That's not the case here. |
if (count <= _sslState.MaxDataSize) | ||
{ | ||
//Single write | ||
Task t = WriteSingleChunk(buffer, offset, count, cancellationToken); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WriteSingleChunk can throw. Don't we need to deal with that here? (e.g. clear _nestedWrite)
private async Task WaitForWriteIOSlot(Task lockTask, byte[] buffer, int offset, int count, CancellationToken cancellationToken) | ||
{ | ||
await lockTask.ConfigureAwait(false); | ||
await WriteSingleChunk(buffer, offset, count, cancellationToken).ConfigureAwait(false); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Won't this cause us to go do the CheckEnqueueWriteAsync again?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The lock is re-entrant , I will double check the logic, but it keeps the flow simple for the "fastpath". If we actually hit this lock it's a 1 in 100 if that. (Only if there is a renegotiation and we try to write). At that point it's slow anyway as we had to wait for a couple of network round trips so a check of an Interlock it won't really hurt our performance.
The reason it's re-entrant because we set it to "LockWrite" when we want to lock and only check that it isn't "LockHandshake".
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I don't think the perf matters, just trying to understand the logic.
We should add a commenting explaining this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
:) its a tangled best this lot in general, the more eyes and understanding and the simpler we can make it for those that follow the better!
Oh... I see what you are doing there. That's twisted. Did something similar in Kestrel, wasn't proud. Prefer the naming conventions we; shamefully, came up with there: interface IStreamAdapter
{
}
struct StreamSyncAdapter : IStreamAdapter
{
}
struct StreamAsyncAdapter : IStreamAdapter
{
} Though is because you can't handle it via compiler dotnet/roslyn#15822 (e.g. using struct to force templating rather than shared generics) |
Good call though; will use this pattern in other places. |
It has also sparked some ideas for me :) I had prototyped the read side with the flag as discussed previously (IsAsync) but that interface idea is cleaner for sure. |
I stole the basic idea here from the C# "shapes" proposal: dotnet/csharplang#164 The interesting thing here is that "shapes" would basically be a lot of syntactic sugar on top of this basic approach (at least as I understand it). So while the syntax above may be a bit icky at times, the approach seems solid to me. |
Nah... you came up with a unique and novel idea with no outside help at all... :) anyway I like it. I am undecided. Do I try to ram it into this PR or do I fix this PR try to get it merged and submit another "merging both flows" PR? |
If we think we're going to just turn around and change this code again anyway, then I'd prefer to see it all done in one change, personally, instead of making one change and then redoing it significantly. On the other hand, if we want to get the write code in so we're unblocked on other issues, I'm fine with that too. |
And btw, "adapter" seems like a good naming convention to me. |
Okay I will change this to remove the sync path. I also have an easy PR to remove that ref outbuffer stuff which I might slip in first to clean up the code a bit. |
Closing for now while I refactor to remove dual sync/async path. Will be resubmitted soonish |
Sorry @geoffkizer and @stephentoub I didn't know where else I could ask this, but I was wondering, if you pin something that is already pinned do you save anytime? like Pin buffer is that any more efficient than Pin buffer |
No. But certain approaches to pinning are more efficient than others. |
👍 is there going to be the equiv of ArrayPool for buffer out of the box? Last thing, would be interesting if you get rid of the Pin and hold and merge it, what difference it might make to aspnet's benchmarks because pipelines will almost never give sockets the same buffer over and over. (and they are using sockets now ...) |
What are you asking for specifically? Obviously every array that comes out of ArrayPool can be wrapped in a |
Just that there would be an ArrayPool<Memory> just to avoid the allocation of wrapping the Memory class each time you take a pooled array. Nothing to do with pinning. |
|
Well not an ArrayPool more of a MemoryPool.Shared |
really? I thought Memory was a rename of Buffer (which was Memory) and Buffer was always a class. I need to keep up I guess ;) |
|
BUILDS on the removal of pinnable buffers so can't be merged until then..... (#23572)
Changes the write side to async/await. Clears out a bunch of cruft from the APM model.
Will move closer to allowing the SemaphoreSlim unix solution, as well as clarity on the flow that is required.
Is a first step, readside would be next and then the underlying handshake/renegotiation.
Benchmarks and traces to come, but it makes the flow a lot more readable (initial indications is that the aspnet core tests are about 5% faster and with traces I hope to show that is due to lower allocations).
If I had my own way I would convert the sync methods to ReadAsync().GetAwaiter().GetResult() as it is truly async in nature and by not doing that two paths need to be maintained which leads to a higher cognitive load in a security library. That however is a decision that others need to think about.
Cheers
Tim