Skip to content

Commit bca4acd

Browse files
committed
Remove forced serialization of async-over-sync in Stream base methods
The base implementation of Stream.BeginRead/Write queue a work items that invoke the abstract Read/Write methods. When Stream.BeginRead/Write were introduced long ago, for reasons I’m not privy to, someone decided it would be a good idea to add protection to these methods, such that if you try to call either BeginRead or BeginWrite while a previous BeginRead or BeginWrite operation was still in flight, the synchronous call to BeginXx would synchronously block. Yuck. Back in .NET Framework 4.5 when we added Stream.Read/WriteAsync, we had to add the base implementations as wrappers for the BeginRead/Write methods, since Read/WriteAsync should pick up the overrides of those methods if they existed. The idea of propagating that synchronous blocking behavior to Read/WriteAsync was unstomachable, but for compatibility we made it so that Read/WriteAsync would still serialize, just asynchronously (later we added a fast path optimization that would skip BeginRead/Write entirely if they weren’t overridden by the derived type). That serialization, however, even though it was asynchronous, was also misguided. In addition to adding overhead, both in terms of needing a semaphore and somewhere to store it and in terms of using that semaphore for every operation, it prevents the concurrent use of read and write. In general, streams aren’t meant to be used concurrently at all, but some streams are duplex and support up to a single reader and single writer at a time. This serialization ends up blocking such duplex streams from being used (if they don’t override Read/WriteAsync), but worse, it ends up hiding misuse of streams that shouldn’t be used concurrently by masking the misuse and turning it into behavior that might appear to work but is unlikely to actually be the desired behavior. This PR deletes that serialization and then cleans up all the cruft that was built up around it. This is a breaking change, as it’s possible code could have been relying on this (undocumented) protection; the fix for such an app is to stop doing that erroneous concurrent access, which could include applying its own serialization if necessary. BufferedStream was explicitly using the same serialization mechanism; I left that intact. BufferedFileStreamStrategy was also using it, as FileStream kinda sorta somewhat tries to enable concurrent (not parallel) usage when useAsync == true on Windows.
1 parent aea3ac5 commit bca4acd

File tree

7 files changed

+146
-300
lines changed

7 files changed

+146
-300
lines changed

src/libraries/System.IO.FileSystem/tests/FileStream/WriteAsync.cs

Lines changed: 18 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -156,24 +156,15 @@ public async Task WriteAsyncInternalBufferOverflow()
156156

157157
public static IEnumerable<object[]> MemberData_FileStreamAsyncWriting()
158158
{
159-
foreach (bool useAsync in new[] { true, false })
159+
foreach (bool preSize in new[] { true, false })
160160
{
161-
if (useAsync && !OperatingSystem.IsWindows())
161+
foreach (bool cancelable in new[] { true, false })
162162
{
163-
// We don't have a special async I/O implementation in FileStream on Unix.
164-
continue;
165-
}
166-
167-
foreach (bool preSize in new[] { true, false })
168-
{
169-
foreach (bool cancelable in new[] { true, false })
170-
{
171-
yield return new object[] { useAsync, preSize, false, cancelable, 0x1000, 0x100, 100 };
172-
yield return new object[] { useAsync, preSize, false, cancelable, 0x1, 0x1, 1000 };
173-
yield return new object[] { useAsync, preSize, true, cancelable, 0x2, 0x100, 100 };
174-
yield return new object[] { useAsync, preSize, false, cancelable, 0x4000, 0x10, 100 };
175-
yield return new object[] { useAsync, preSize, true, cancelable, 0x1000, 99999, 10 };
176-
}
163+
yield return new object[] { preSize, false, cancelable, 0x1000, 0x100, 100 };
164+
yield return new object[] { preSize, false, cancelable, 0x1, 0x1, 1000 };
165+
yield return new object[] { preSize, true, cancelable, 0x2, 0x100, 100 };
166+
yield return new object[] { preSize, false, cancelable, 0x4000, 0x10, 100 };
167+
yield return new object[] { preSize, true, cancelable, 0x1000, 99999, 10 };
177168
}
178169
}
179170
}
@@ -183,7 +174,6 @@ public Task ManyConcurrentWriteAsyncs()
183174
{
184175
// For inner loop, just test one case
185176
return ManyConcurrentWriteAsyncs_OuterLoop(
186-
useAsync: OperatingSystem.IsWindows(),
187177
presize: false,
188178
exposeHandle: false,
189179
cancelable: true,
@@ -196,15 +186,15 @@ public Task ManyConcurrentWriteAsyncs()
196186
[MemberData(nameof(MemberData_FileStreamAsyncWriting))]
197187
[OuterLoop] // many combinations: we test just one in inner loop and the rest outer
198188
public async Task ManyConcurrentWriteAsyncs_OuterLoop(
199-
bool useAsync, bool presize, bool exposeHandle, bool cancelable, int bufferSize, int writeSize, int numWrites)
189+
bool presize, bool exposeHandle, bool cancelable, int bufferSize, int writeSize, int numWrites)
200190
{
201191
long totalLength = writeSize * numWrites;
202192
var expectedData = new byte[totalLength];
203193
new Random(42).NextBytes(expectedData);
204194
CancellationToken cancellationToken = cancelable ? new CancellationTokenSource().Token : CancellationToken.None;
205195

206196
string path = GetTestFilePath();
207-
using (FileStream fs = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None, bufferSize, useAsync))
197+
using (FileStream fs = new FileStream(path, FileMode.Create, FileAccess.ReadWrite, FileShare.None, bufferSize, useAsync: true))
208198
{
209199
if (presize)
210200
{
@@ -220,17 +210,15 @@ public async Task ManyConcurrentWriteAsyncs_OuterLoop(
220210
{
221211
writes[i] = WriteAsync(fs, expectedData, i * writeSize, writeSize, cancellationToken);
222212
Assert.Null(writes[i].Exception);
223-
if (useAsync)
213+
214+
// To ensure that the buffer of a FileStream opened for async IO is flushed
215+
// by FlushAsync in asynchronous way, we aquire a lock for every buffered WriteAsync.
216+
// The side effect of this is that the Position of FileStream is not updated until
217+
// the lock is released by a previous operation.
218+
// So now all WriteAsync calls should be awaited before starting another async file operation.
219+
if (PlatformDetection.IsNet5CompatFileStreamEnabled)
224220
{
225-
// To ensure that the buffer of a FileStream opened for async IO is flushed
226-
// by FlushAsync in asynchronous way, we aquire a lock for every buffered WriteAsync.
227-
// The side effect of this is that the Position of FileStream is not updated until
228-
// the lock is released by a previous operation.
229-
// So now all WriteAsync calls should be awaited before starting another async file operation.
230-
if (PlatformDetection.IsNet5CompatFileStreamEnabled)
231-
{
232-
Assert.Equal((i + 1) * writeSize, fs.Position);
233-
}
221+
Assert.Equal((i + 1) * writeSize, fs.Position);
234222
}
235223
}
236224

@@ -239,10 +227,7 @@ public async Task ManyConcurrentWriteAsyncs_OuterLoop(
239227

240228
byte[] actualData = File.ReadAllBytes(path);
241229
Assert.Equal(expectedData.Length, actualData.Length);
242-
if (useAsync)
243-
{
244-
Assert.Equal<byte>(expectedData, actualData);
245-
}
230+
AssertExtensions.SequenceEqual(expectedData, actualData);
246231
}
247232

248233
[Theory]

src/libraries/System.Private.CoreLib/src/System/IO/BufferedStream.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Diagnostics;
5+
using System.Diagnostics.CodeAnalysis;
56
using System.Threading;
67
using System.Threading.Tasks;
78

@@ -58,6 +59,7 @@ public sealed class BufferedStream : Stream
5859
// (perf optimization for successive reads of the same size)
5960
// Removing a private default constructor is a breaking change for the DataDebugSerializer.
6061
// Because this ctor was here previously we need to keep it around.
62+
private SemaphoreSlim? _asyncActiveSemaphore; // To serialize async operations.
6163

6264
public BufferedStream(Stream stream)
6365
: this(stream, DefaultBufferSize)
@@ -136,6 +138,16 @@ private void EnsureBufferAllocated()
136138
_buffer = new byte[_bufferSize];
137139
}
138140

141+
[MemberNotNull(nameof(_asyncActiveSemaphore))]
142+
private SemaphoreSlim EnsureAsyncActiveSemaphoreInitialized() =>
143+
// Lazily-initialize _asyncActiveSemaphore. As we're never accessing the SemaphoreSlim's
144+
// WaitHandle, we don't need to worry about Disposing it in the case of a race condition.
145+
#pragma warning disable CS8774 // We lack a NullIffNull annotation for Volatile.Read
146+
Volatile.Read(ref _asyncActiveSemaphore) ??
147+
#pragma warning restore CS8774
148+
Interlocked.CompareExchange(ref _asyncActiveSemaphore, new SemaphoreSlim(1, 1), null) ??
149+
_asyncActiveSemaphore;
150+
139151
public Stream UnderlyingStream
140152
{
141153
get

src/libraries/System.Private.CoreLib/src/System/IO/Strategies/AsyncWindowsFileStreamStrategy.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ public override int Read(byte[] buffer, int offset, int count)
120120
vt.AsTask().GetAwaiter().GetResult();
121121
}
122122

123+
public override IAsyncResult BeginRead(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) =>
124+
TaskToApm.Begin(ReadAsync(buffer, offset, count), callback, state);
125+
126+
public override int EndRead(IAsyncResult asyncResult) =>
127+
TaskToApm.End<int>(asyncResult);
128+
123129
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
124130
=> ReadAsyncInternal(new Memory<byte>(buffer, offset, count), cancellationToken).AsTask();
125131

@@ -207,6 +213,12 @@ private unsafe ValueTask<int> ReadAsyncInternal(Memory<byte> destination, Cancel
207213
public override void Write(byte[] buffer, int offset, int count)
208214
=> WriteAsyncInternal(new ReadOnlyMemory<byte>(buffer, offset, count), CancellationToken.None).AsTask().GetAwaiter().GetResult();
209215

216+
public override IAsyncResult BeginWrite(byte[] buffer, int offset, int count, AsyncCallback? callback, object? state) =>
217+
TaskToApm.Begin(WriteAsync(buffer, offset, count), callback, state);
218+
219+
public override void EndWrite(IAsyncResult asyncResult) =>
220+
TaskToApm.End(asyncResult);
221+
210222
public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
211223
=> WriteAsyncInternal(new ReadOnlyMemory<byte>(buffer, offset, count), cancellationToken).AsTask();
212224

src/libraries/System.Private.CoreLib/src/System/IO/Strategies/BufferedFileStreamStrategy.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ internal sealed class BufferedFileStreamStrategy : FileStreamStrategy
1515
{
1616
private readonly FileStreamStrategy _strategy;
1717
private readonly int _bufferSize;
18+
private SemaphoreSlim? _asyncActiveSemaphore;
1819

1920
private byte[]? _buffer;
2021
private int _writePos;
@@ -46,6 +47,16 @@ internal BufferedFileStreamStrategy(FileStreamStrategy strategy, int bufferSize)
4647
}
4748
}
4849

50+
[MemberNotNull(nameof(_asyncActiveSemaphore))]
51+
private SemaphoreSlim EnsureAsyncActiveSemaphoreInitialized() =>
52+
// Lazily-initialize _asyncActiveSemaphore. As we're never accessing the SemaphoreSlim's
53+
// WaitHandle, we don't need to worry about Disposing it in the case of a race condition.
54+
#pragma warning disable CS8774 // We lack a NullIffNull annotation for Volatile.Read
55+
Volatile.Read(ref _asyncActiveSemaphore) ??
56+
#pragma warning restore CS8774
57+
Interlocked.CompareExchange(ref _asyncActiveSemaphore, new SemaphoreSlim(1, 1), null) ??
58+
_asyncActiveSemaphore;
59+
4960
public override bool CanRead => _strategy.CanRead;
5061

5162
public override bool CanWrite => _strategy.CanWrite;

src/libraries/System.Private.CoreLib/src/System/IO/Strategies/Net5CompatFileStreamStrategy.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ public override Task<int> ReadAsync(byte[] buffer, int offset, int count, Cancel
163163
// Read is invoked asynchronously. But we can do so using the base Stream's internal helper
164164
// that bypasses delegating to BeginRead, since we already know this is FileStream rather
165165
// than something derived from it and what our BeginRead implementation is going to do.
166-
return (Task<int>)base.BeginReadInternal(buffer, offset, count, null, null, serializeAsynchronously: true, apm: false);
166+
return BeginReadInternal(buffer, offset, count, null, null);
167167
}
168168

169169
return ReadAsyncTask(buffer, offset, count, cancellationToken);
@@ -178,7 +178,7 @@ public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken
178178
// internal helper that bypasses delegating to BeginRead, since we already know this is FileStream
179179
// rather than something derived from it and what our BeginRead implementation is going to do.
180180
return MemoryMarshal.TryGetArray(buffer, out ArraySegment<byte> segment) ?
181-
new ValueTask<int>((Task<int>)base.BeginReadInternal(segment.Array!, segment.Offset, segment.Count, null, null, serializeAsynchronously: true, apm: false)) :
181+
new ValueTask<int>(BeginReadInternal(segment.Array!, segment.Offset, segment.Count, null, null)) :
182182
base.ReadAsync(buffer, cancellationToken);
183183
}
184184

@@ -245,7 +245,7 @@ public override Task WriteAsync(byte[] buffer, int offset, int count, Cancellati
245245
// Write is invoked asynchronously. But we can do so using the base Stream's internal helper
246246
// that bypasses delegating to BeginWrite, since we already know this is FileStream rather
247247
// than something derived from it and what our BeginWrite implementation is going to do.
248-
return (Task)base.BeginWriteInternal(buffer, offset, count, null, null, serializeAsynchronously: true, apm: false);
248+
return BeginWriteInternal(buffer, offset, count, null, null);
249249
}
250250

251251
return WriteAsyncInternal(new ReadOnlyMemory<byte>(buffer, offset, count), cancellationToken).AsTask();
@@ -260,7 +260,7 @@ public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationTo
260260
// internal helper that bypasses delegating to BeginWrite, since we already know this is FileStream
261261
// rather than something derived from it and what our BeginWrite implementation is going to do.
262262
return MemoryMarshal.TryGetArray(buffer, out ArraySegment<byte> segment) ?
263-
new ValueTask((Task)base.BeginWriteInternal(segment.Array!, segment.Offset, segment.Count, null, null, serializeAsynchronously: true, apm: false)) :
263+
new ValueTask(BeginWriteInternal(segment.Array!, segment.Offset, segment.Count, null, null)) :
264264
base.WriteAsync(buffer, cancellationToken);
265265
}
266266

src/libraries/System.Private.CoreLib/src/System/IO/Strategies/SyncWindowsFileStreamStrategy.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ public override Task<int> ReadAsync(byte[] buffer, int offset, int count, Cancel
4646
// Read is invoked asynchronously. But we can do so using the base Stream's internal helper
4747
// that bypasses delegating to BeginRead, since we already know this is FileStream rather
4848
// than something derived from it and what our BeginRead implementation is going to do.
49-
return (Task<int>)BeginReadInternal(buffer, offset, count, null, null, serializeAsynchronously: true, apm: false);
49+
return BeginReadInternal(buffer, offset, count, null, null);
5050
}
5151

5252
public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
@@ -56,7 +56,7 @@ public override ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken
5656
// internal helper that bypasses delegating to BeginRead, since we already know this is FileStream
5757
// rather than something derived from it and what our BeginRead implementation is going to do.
5858
return MemoryMarshal.TryGetArray(buffer, out ArraySegment<byte> segment) ?
59-
new ValueTask<int>((Task<int>)BeginReadInternal(segment.Array!, segment.Offset, segment.Count, null, null, serializeAsynchronously: true, apm: false)) :
59+
new ValueTask<int>(BeginReadInternal(segment.Array!, segment.Offset, segment.Count, null, null)) :
6060
base.ReadAsync(buffer, cancellationToken);
6161
}
6262

@@ -79,7 +79,7 @@ public override Task WriteAsync(byte[] buffer, int offset, int count, Cancellati
7979
// Write is invoked asynchronously. But we can do so using the base Stream's internal helper
8080
// that bypasses delegating to BeginWrite, since we already know this is FileStream rather
8181
// than something derived from it and what our BeginWrite implementation is going to do.
82-
return (Task)BeginWriteInternal(buffer, offset, count, null, null, serializeAsynchronously: true, apm: false);
82+
return BeginWriteInternal(buffer, offset, count, null, null);
8383
}
8484

8585
public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationToken cancellationToken = default)
@@ -89,7 +89,7 @@ public override ValueTask WriteAsync(ReadOnlyMemory<byte> buffer, CancellationTo
8989
// internal helper that bypasses delegating to BeginWrite, since we already know this is FileStream
9090
// rather than something derived from it and what our BeginWrite implementation is going to do.
9191
return MemoryMarshal.TryGetArray(buffer, out ArraySegment<byte> segment) ?
92-
new ValueTask((Task)BeginWriteInternal(segment.Array!, segment.Offset, segment.Count, null, null, serializeAsynchronously: true, apm: false)) :
92+
new ValueTask(BeginWriteInternal(segment.Array!, segment.Offset, segment.Count, null, null)) :
9393
base.WriteAsync(buffer, cancellationToken);
9494
}
9595

0 commit comments

Comments
 (0)