-
Notifications
You must be signed in to change notification settings - Fork 10.1k
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
Wrap the PipeWriter used by Kestrel's output writing logic #12081
Conversation
One thing you can do to get some extra coverage for free would be to swap the PipeWriter back to the StreamPipeWriter. It in theory should work with this decorator now. |
src/Servers/Kestrel/Core/src/Internal/Infrastructure/PipeWriterHelpers/ConcurrentPipeWriter.cs
Outdated
Show resolved
Hide resolved
src/Servers/Kestrel/Core/src/Internal/Infrastructure/PipeWriterHelpers/ConcurrentPipeWriter.cs
Outdated
Show resolved
Hide resolved
src/Servers/Kestrel/Core/src/Internal/Infrastructure/PipeWriterHelpers/ConcurrentPipeWriter.cs
Outdated
Show resolved
Hide resolved
274274d
to
d8fb128
Compare
|
||
public override ValueTask DisposeAsync() | ||
protected override void Dispose(bool disposing) |
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.
Remove dispose. DisposeAsync should be enough (in my original debugging, base.DisposeAsync will just call Dispose).
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.
Wouldn't that make DuplexStreamAdapter.Close()/Dispose() no-op while DuplexStreamAdapter.DisposeAsync() would actually cleanup? That seems bad to me.
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.
If anything, I would remove DisposeAsync() since the default implementation of Stream.DisposeAsync() calls Stream.Dispose(). Do you want me to do that instead?
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’re going to make it CompleteAsync eventually though so i rather keep it
@@ -21,15 +21,6 @@ internal class TimingPipeFlusher | |||
private readonly ITimeoutControl _timeoutControl; | |||
private readonly IKestrelTrace _log; | |||
|
|||
private readonly object _flushLock = new object(); |
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.
So much cleaner 😄
src/Servers/Kestrel/Core/src/Internal/Infrastructure/PipeWriterHelpers/ConcurrentPipeWriter.cs
Outdated
Show resolved
Hide resolved
private BufferSegment _tail; | ||
private Memory<byte> _tailMemory; | ||
private int _tailBytesBuffered; | ||
private int _bytesBuffered; |
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.
This should be a long, though in practice I dunno if that will matter, you'd probably OOM first 😄
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.
Technically, you're right. We should probably change it to a long in StreamPipeWriter too.
Can't this lead to a kind of starvation? E.g. one code path like response bodies consistently waits for flushes, where another like responses without bodies consistently appends without flushing. The latter would always get prioritized over the former. Why not let the flush complete normally and have new data wait for the next flush? That might be simpler to implement as well. |
src/Servers/Kestrel/Core/src/Internal/Infrastructure/PipeWriterHelpers/ConcurrentPipeWriter.cs
Show resolved
Hide resolved
src/Servers/Kestrel/Core/src/Internal/Infrastructure/PipeWriterHelpers/ConcurrentPipeWriter.cs
Show resolved
Hide resolved
src/Servers/Kestrel/Core/src/Internal/Infrastructure/PipeWriterHelpers/ConcurrentPipeWriter.cs
Show resolved
Hide resolved
src/Servers/Kestrel/Core/src/Internal/Infrastructure/PipeWriterHelpers/ConcurrentPipeWriter.cs
Outdated
Show resolved
Hide resolved
@@ -9,7 +9,7 @@ | |||
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http; | |||
using Microsoft.Extensions.Logging; | |||
|
|||
namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure | |||
namespace Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure.PipeWriterHelpers |
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: Idk if we should have this in the PipeWriterHelpers namespace.
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 only reason I did this is because Kestrel Core's Infrastructure directory was getting pretty large, particularly with the addition of BufferSegment, and I wanted to namespace to match the directory structure.
I guess TimingPipeFlusher could go back to the Infrastructure directory, but if feels more related to the other stuff in the PipeWriterHelpers folder in my opinion.
@@ -41,7 +41,7 @@ public async Task WriteWindowUpdate_UnsetsReservedBit() | |||
{ | |||
// Arrange | |||
var pipe = new Pipe(new PipeOptions(_dirtyMemoryPool, PipeScheduler.Inline, PipeScheduler.Inline)); | |||
var frameWriter = new Http2FrameWriter(pipe.Writer, null, null, null, null, null, null, new Mock<IKestrelTrace>().Object); | |||
var frameWriter = new Http2FrameWriter(pipe.Writer, null, null, null, null, null, null, _dirtyMemoryPool, new Mock<IKestrelTrace>().Object); |
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: ew.
|
||
var flushTask0 = concurrentPipeWriter.FlushAsync(); | ||
|
||
concurrentPipeWriter.GetMemory(); |
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.
bold move to call GetMemory without looking at the length 😄
What I like about the current implementation is that we don't have to maintain a queue of TaskCompletionSources and keep track of which buffers correspond to which calls to flush. Instead we let a single TaskCompletionSource manage the only queue of every awaiting flusher. If there's a codepath that's consistently appending without flushing, they'll effectively get prioritized no matter what since there's no way for that codepath to observe backpressure unless we block in GetMemory() or something which we decided we're not doing. |
37fb53d
to
1b4cc4e
Compare
src/Servers/Kestrel/Transport.Libuv/test/LibuvOutputConsumerTests.cs
Outdated
Show resolved
Hide resolved
3ffa7ab
to
5b42e1e
Compare
src/Servers/Kestrel/Core/src/Internal/Infrastructure/PipeWriterHelpers/ConcurrentPipeWriter.cs
Outdated
Show resolved
Hide resolved
0a38fc3
to
7151932
Compare
Pipelined Plaintext Physical Servers (master):
Pipelined Plaintext Physical Servers (halter73/concurrent-pipewriter):
BenchmarkDriver command and runtime versions
On average this works out to about a 2% perf regression in the techempower pipelined plaintext benchmark. The pipelined plaintext benchmark is probably one of the most impacted benchmarks since Http1OutputProducer now writes through the ConcurrentPipeWriter which results in two additional uncontested calls to Monitor.Enter/Exit per response. This is because the ConcurrentPipeWriter.GetMemory() and ConcurrentPipeWriter.Advance() each acquire a lock. The lock taken in ConcurrentPipeWriter.FlushAsync() simply replaces the lock that was previously taken by TimingPipeFlusher. If we're concerned about this 2% perf regression, we could consider passing the lock used by Http1/2OutputProducer and Http2FrameWriter to the ConcurrentPipeWriter ctor to use for it's own locking. If we did this, the ConcurrentPipeWriter could assume the correct lock was already taken any time GetMemory(), Advance() or FlushAsync() is called. That would mean the ConcurrentPipeWriter would only need to acquire a lock itself if it need to do a "background" flush which should never happen in the pipelined plaintext benchmark. We could try the lock sharing as a follow up PR too. |
Do you want to approve this @davidfowl? |
If it makes you feel better here are the benchmark results for the ConcurrentPipeWriter w/ the lock sharing.
So I'm confident we can get the perf back. |
Kestrel's Http2FrameWriter expects to be able to append more data to the combined TCP-connection-level PipeWriter even there's currently an ongoing call to PipeWriter.FlushAsync() that's only being observed by another stream or streams. Http1OutputProducer also expects to be able to be able to append during flush in order to provide legacy support for unawaited calls to Response.Body.WriteAsync().
There's currently an issue with DefaultPipeWriter where this usage doesn't always work as intended which has lead to some HTTP/2 output corruption. This is also generally much more difficult to support than simply requiring that FlushAsync() always be awaited before calling GetMemory/Span() again. For this reason, we're likely to change DefaultPipeWriter to always throw when GetMemory/Span() is called before the last call to FlushAsync() completes.
This change adds a PipeWriter decorator called ConcurrentPipeWriter (better names welcome but at least it's internal) meant to exclusively be use by Kestrel's Http2FrameWriter and Http1OutputProducer. For the most part ConcurrentPipeWriter is strictly passthrough, but if a flush is pending and GetMemory/Span() is called, it copies the logic from corefx's StreamPipeWriter to manually build up a linked-list of pending data to be flush. Like DefaultPipeWriter today, anyone currently awaiting a flush will see their flush completion delayed until the data appended during the flush also gets flushed.
The effect of the ConcurrentPipeWriter on the PipeWriter being decorated is that it looks to the inner PipeWriter like it's being called by a regular write loop despite Http2FrameWriter/Http1OutputProducer's admittedly unusual usage of the ConcurrentPipeWriter.
Partially Addresses #11804