Skip to content
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

StreamReader sync/async optimizations: ArrayPool, IndexOfAny, ValueStringBuilder #62552

Closed
wants to merge 26 commits into from
Closed
Changes from 16 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
199 changes: 128 additions & 71 deletions src/libraries/System.Private.CoreLib/src/System/IO/StreamReader.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Buffers;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text;
Expand Down Expand Up @@ -413,10 +415,10 @@ public override string ReadToEnd()
CheckAsyncTaskInProgress();

// Call ReadBuffer, then pull data out of charBuffer.
StringBuilder sb = new StringBuilder(_charLen - _charPos);
using ValueStringBuilder sb = new(_charLen - _charPos);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we should be using ValueStringBuilder here. For ReadLine, I think it makes sense because it likely lines are relatively short. But the whole file could be very large, and with ValueStringBuilder and ArrayPool, that will require one large contiguous region, whereas StringBuilder will end up being chunked into essentially a linked list of buffers.

It might make sense to start with some stackalloc'd space and then graduate to a StringBuilder, or something like that. Might be best to revert the changes to ReadToEnd and keep this PR focused on ReadLine{Async}.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've run the benchmarks and it looks better with what we have in this PR (Might be the wrong benchmarks). With stackallock + going to stringbuilder it might be even better -> dotnet/performance#2177

Method Job Toolchain LineLengthRange Mean Error StdDev Median Min Max Ratio RatioSD Gen 0 Gen 1 Allocated
ReadToEnd Job-UIJYFC /testhost-main/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 0, 0] 5.991 us 0.0616 us 0.0577 us 5.997 us 5.915 us 6.111 us 1.00 0.00 13.4901 0.1408 83 KB
ReadToEnd Job-RITEOG /testhost/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 0, 0] 5.245 us 0.0705 us 0.0660 us 5.252 us 5.147 us 5.350 us 0.88 0.01 5.7341 0.0206 35 KB
ReadToEndAsync Job-UIJYFC /testhost-main/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 0, 0] 7.028 us 0.0850 us 0.0753 us 7.048 us 6.901 us 7.128 us 1.00 0.00 13.4924 0.9478 83 KB
ReadToEndAsync Job-RITEOG /testhost/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 0, 0] 6.026 us 0.1134 us 0.1213 us 5.980 us 5.902 us 6.284 us 0.86 0.02 5.7235 0.7969 35 KB
ReadToEnd Job-UIJYFC /testhost-main/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 0, 1024] 5.992 us 0.0635 us 0.0563 us 5.992 us 5.907 us 6.092 us 1.00 0.00 13.4960 0.1443 83 KB
ReadToEnd Job-RITEOG /testhost/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 0, 1024] 6.219 us 0.1210 us 0.1345 us 6.179 us 6.010 us 6.504 us 1.04 0.03 5.7678 0.0236 35 KB
ReadToEndAsync Job-UIJYFC /testhost-main/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 0, 1024] 7.133 us 0.0568 us 0.0504 us 7.137 us 7.010 us 7.228 us 1.00 0.00 13.5398 1.6853 83 KB
ReadToEndAsync Job-RITEOG /testhost/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 0, 1024] 6.189 us 0.1213 us 0.1192 us 6.181 us 5.994 us 6.405 us 0.87 0.02 5.7769 0.4980 36 KB
ReadToEnd Job-UIJYFC /testhost-main/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 1, 1] 5.973 us 0.0404 us 0.0358 us 5.964 us 5.905 us 6.025 us 1.00 0.00 13.4943 0.1420 83 KB
ReadToEnd Job-RITEOG /testhost/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 1, 1] 5.230 us 0.1164 us 0.1246 us 5.229 us 5.017 us 5.499 us 0.87 0.02 5.7332 0.0209 35 KB
ReadToEndAsync Job-UIJYFC /testhost-main/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 1, 1] 7.006 us 0.0945 us 0.0884 us 6.967 us 6.893 us 7.187 us 1.00 0.00 13.5060 0.9449 83 KB
ReadToEndAsync Job-RITEOG /testhost/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 1, 1] 6.108 us 0.1136 us 0.1062 us 6.091 us 5.936 us 6.324 us 0.87 0.02 5.7265 0.7865 35 KB
ReadToEnd Job-UIJYFC /testhost-main/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 1, 8] 5.971 us 0.0620 us 0.0580 us 5.962 us 5.878 us 6.093 us 1.00 0.00 13.5000 0.1429 83 KB
ReadToEnd Job-RITEOG /testhost/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 1, 8] 5.167 us 0.0824 us 0.0771 us 5.133 us 5.078 us 5.306 us 0.87 0.02 5.7277 0.0202 35 KB
ReadToEndAsync Job-UIJYFC /testhost-main/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 1, 8] 7.018 us 0.0548 us 0.0458 us 7.014 us 6.936 us 7.108 us 1.00 0.00 13.4940 0.9718 83 KB
ReadToEndAsync Job-RITEOG /testhost/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 1, 8] 6.185 us 0.1011 us 0.0844 us 6.178 us 6.033 us 6.330 us 0.88 0.01 5.7269 0.7933 35 KB
ReadToEnd Job-UIJYFC /testhost-main/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 9, 32] 5.992 us 0.1129 us 0.1001 us 5.991 us 5.839 us 6.152 us 1.00 0.00 13.4957 0.1416 83 KB
ReadToEnd Job-RITEOG /testhost/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 9, 32] 6.889 us 0.9402 us 1.0060 us 6.570 us 5.906 us 9.886 us 1.15 0.18 5.7311 0.0236 35 KB
ReadToEndAsync Job-UIJYFC /testhost-main/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 9, 32] 7.005 us 0.0572 us 0.0535 us 6.994 us 6.906 us 7.094 us 1.00 0.00 13.4940 0.9440 83 KB
ReadToEndAsync Job-RITEOG /testhost/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 9, 32] 6.303 us 0.1407 us 0.1620 us 6.343 us 6.008 us 6.611 us 0.91 0.02 5.7468 0.7945 35 KB
ReadToEnd Job-UIJYFC /testhost-main/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 33, 128] 6.013 us 0.0758 us 0.0672 us 5.998 us 5.919 us 6.146 us 1.00 0.00 13.5102 0.1226 83 KB
ReadToEnd Job-RITEOG /testhost/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 33, 128] 6.130 us 0.0803 us 0.0752 us 6.131 us 5.998 us 6.279 us 1.02 0.01 5.7292 0.0237 35 KB
ReadToEndAsync Job-UIJYFC /testhost-main/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 33, 128] 7.141 us 0.1344 us 0.1320 us 7.086 us 7.008 us 7.448 us 1.00 0.00 13.4896 0.9398 83 KB
ReadToEndAsync Job-RITEOG /testhost/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 33, 128] 6.179 us 0.1216 us 0.1137 us 6.188 us 6.003 us 6.386 us 0.87 0.03 5.7411 0.5014 35 KB
ReadToEnd Job-UIJYFC /testhost-main/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 129, 1024] 6.005 us 0.0466 us 0.0389 us 5.993 us 5.945 us 6.089 us 1.00 0.00 13.6508 0.9630 84 KB
ReadToEnd Job-RITEOG /testhost/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 129, 1024] 6.223 us 0.0751 us 0.0703 us 6.216 us 6.139 us 6.389 us 1.04 0.02 5.8710 0.0248 36 KB
ReadToEndAsync Job-UIJYFC /testhost-main/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 129, 1024] 7.281 us 0.0623 us 0.0520 us 7.292 us 7.148 us 7.344 us 1.00 0.00 13.6322 2.0506 84 KB
ReadToEndAsync Job-RITEOG /testhost/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [ 129, 1024] 6.263 us 0.0896 us 0.0794 us 6.244 us 6.158 us 6.437 us 0.86 0.01 5.8634 0.4097 36 KB
ReadToEnd Job-UIJYFC /testhost-main/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [1025, 2048] 6.172 us 0.0548 us 0.0513 us 6.152 us 6.117 us 6.299 us 1.00 0.00 13.7824 1.3758 85 KB
ReadToEnd Job-RITEOG /testhost/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [1025, 2048] 6.186 us 0.1195 us 0.1118 us 6.146 us 6.051 us 6.421 us 1.00 0.02 6.0063 - 37 KB
ReadToEndAsync Job-UIJYFC /testhost-main/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [1025, 2048] 7.448 us 0.0780 us 0.0691 us 7.457 us 7.337 us 7.541 us 1.00 0.00 13.7816 2.1945 85 KB
ReadToEndAsync Job-RITEOG /testhost/net7.0-OSX-Release-x64/shared/Microsoft.NETCore.App/7.0.0/corerun [1025, 2048] 6.435 us 0.0515 us 0.0482 us 6.435 us 6.365 us 6.526 us 0.86 0.01 6.0125 0.8337 37 KB

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Trapov per your comment above -- did you compare stackalloc+StringBuilder vs what you have here? And now you have these great perf tests 😄 .
Instructions for running against local bits here but at this point you are already familiar.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@danmoseley you suggest running the tests again to see if something changed? I know we changed them (the perf tests) a little but it’s not that much. I can run them again but I feel like nothing would change

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran the benchs. It's the last message in the PR.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Trapov sorry if I wasn't clear. I'm asking what the benchmark results are for stackalloc+StringBuilder (what @stephentoub suggested) vs the current code in main.

I also agree with him that I suggest you limit this PR to just ReadLine()/ReadLineAsync() and stackalloc+StringBuilder. For lines under ~128 characters the benchmarks should be a net win. if and when that is merged, we could look at the rest separately as it will need more thought and testing.

As you can probably guess, any changes to StreamReader need significant thought and testing of alternatives, as it's a fundamental type and performance sensitive.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I will remove ReadToEnd{Async} changes from this PR.

do
{
sb.Append(_charBuffer, _charPos, _charLen - _charPos);
sb.Append(_charBuffer.AsSpan(_charPos, _charLen - _charPos));
_charPos = _charLen; // Note we consumed these characters
ReadBuffer();
} while (_charLen > 0);
Expand Down Expand Up @@ -786,43 +788,39 @@ private int ReadBuffer(Span<char> userBuffer, out bool readToUserBuffer)
}
}

StringBuilder? sb = null;
using ValueStringBuilder sb = new(stackalloc char[256]);
danmoseley marked this conversation as resolved.
Show resolved Hide resolved
do
{
int i = _charPos;
do
// Note the following common line feed chars:
// \n - UNIX \r\n - DOS \r - Mac
danmoseley marked this conversation as resolved.
Show resolved Hide resolved
int i = _charBuffer.AsSpan(_charPos, _charLen - _charPos).IndexOfAny('\r', '\n');
if (i >= 0)
{
char ch = _charBuffer[i];
// Note the following common line feed chars:
// \n - UNIX \r\n - DOS \r - Mac
if (ch == '\r' || ch == '\n')
string s;
if (sb.Length > 0)
{
string s;
if (sb != null)
{
sb.Append(_charBuffer, _charPos, i - _charPos);
s = sb.ToString();
}
else
{
s = new string(_charBuffer, _charPos, i - _charPos);
}
_charPos = i + 1;
if (ch == '\r' && (_charPos < _charLen || ReadBuffer() > 0))
sb.Append(_charBuffer.AsSpan(_charPos, i));
s = sb.ToString();
}
else
{
s = new string(_charBuffer, _charPos, i);
}
char ch = _charBuffer[_charPos + i];
_charPos += i + 1;
if (ch == '\r' && (_charPos < _charLen || ReadBuffer() > 0))
{
if (_charBuffer[_charPos] == '\n')
{
if (_charBuffer[_charPos] == '\n')
{
_charPos++;
}
_charPos++;
}
return s;
}
i++;
} while (i < _charLen);
return s;
}

i = _charLen - _charPos;
sb ??= new StringBuilder(i + 80);
sb.Append(_charBuffer, _charPos, i);

sb.Append(_charBuffer.AsSpan(_charPos, i));
} while (ReadBuffer() > 0);
return sb.ToString();
}
Expand Down Expand Up @@ -879,63 +877,89 @@ private int ReadBuffer(Span<char> userBuffer, out bool readToUserBuffer)

private async Task<string?> ReadLineAsyncInternal(CancellationToken cancellationToken)
{
if (_charPos == _charLen && (await ReadBufferAsync(cancellationToken).ConfigureAwait(false)) == 0)
static char[] ResizeOrPoolNewArray(char[]? array, int atLeastSpace)
{
return null;
if (array == null) return ArrayPool<char>.Shared.Rent(atLeastSpace);
danmoseley marked this conversation as resolved.
Show resolved Hide resolved

char[] newArr = ArrayPool<char>.Shared.Rent(array.Length + atLeastSpace);
try
{
Array.Copy(array, newArr, array.Length);
}
catch
{
ArrayPool<char>.Shared.Return(newArr);
throw;
}

ArrayPool<char>.Shared.Return(array);
danmoseley marked this conversation as resolved.
Show resolved Hide resolved
return newArr;
}
static void Append(ref char[]? to, int destinationOffset, char[] @from, int offset, int length)
{
if (to == null || (destinationOffset + length) > to.Length)
{
to = ResizeOrPoolNewArray(to, destinationOffset + length + 80);
}

StringBuilder? sb = null;
Array.Copy(@from, offset, to, destinationOffset, length);
}

do
if (_charPos == _charLen && (await ReadBufferAsync(cancellationToken).ConfigureAwait(false)) == 0)
{
char[] tmpCharBuffer = _charBuffer;
int tmpCharLen = _charLen;
int tmpCharPos = _charPos;
int i = tmpCharPos;
return null;
}

char[]? rentedArray = null;
int lastWrittenIndex = 0;
try
{
do
{
char ch = tmpCharBuffer[i];

// Note the following common line feed chars:
// \n - UNIX \r\n - DOS \r - Mac
if (ch == '\r' || ch == '\n')
int i = _charBuffer.AsSpan(_charPos, _charLen - _charPos).IndexOfAny('\r', '\n');
danmoseley marked this conversation as resolved.
Show resolved Hide resolved
if (i >= 0)
{
string s;

if (sb != null)
string? s;
if (rentedArray != null)
{
sb.Append(tmpCharBuffer, tmpCharPos, i - tmpCharPos);
s = sb.ToString();
Append(ref rentedArray, lastWrittenIndex, _charBuffer, _charPos, i);
lastWrittenIndex += i;
s = new string(rentedArray!, 0, lastWrittenIndex);
}
else
{
s = new string(tmpCharBuffer, tmpCharPos, i - tmpCharPos);
s = new string(_charBuffer, _charPos, i);
}

_charPos = tmpCharPos = i + 1;

if (ch == '\r' && (tmpCharPos < tmpCharLen || (await ReadBufferAsync(cancellationToken).ConfigureAwait(false)) > 0))
char ch = _charBuffer[_charPos + i];
_charPos += i + 1;
if (ch == '\r' && (_charPos < _charLen || (await ReadBufferAsync(cancellationToken).ConfigureAwait(false)) > 0))
{
tmpCharPos = _charPos;
if (_charBuffer[tmpCharPos] == '\n')
if (_charBuffer[_charPos] == '\n')
{
_charPos = ++tmpCharPos;
_charPos++;
}
}

return s;
}

i++;
} while (i < tmpCharLen);
i = _charLen - _charPos;
if (i < 0) break; // a hack for System.Globalization.Tests (to check on CI if it fixes the problem)
danmoseley marked this conversation as resolved.
Show resolved Hide resolved

i = tmpCharLen - tmpCharPos;
sb ??= new StringBuilder(i + 80);
sb.Append(tmpCharBuffer, tmpCharPos, i);
} while (await ReadBufferAsync(cancellationToken).ConfigureAwait(false) > 0);

return sb.ToString();
Append(ref rentedArray, lastWrittenIndex, _charBuffer, _charPos, i);
lastWrittenIndex += i;
} while (await ReadBufferAsync(cancellationToken).ConfigureAwait(false) > 0);
return new string(rentedArray!, 0, lastWrittenIndex);
}
finally
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a general rule, I don't believe we generally bother with a try/finally simply to return a pooled array. Hopefully exceptions are exceptional, and in this case failing to return an array is a small issue.
The key rules are to return it in the normal case, to only return it once, and to not use it after returning.

Copy link
Author

@Trapov Trapov May 10, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If something goes wrong then we wouldn’t be able to return to the shared pool. I don’t know but it seems like an issue to me, that’s why have a try/finally block. What do you suggest?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're correct, but if something goes wrong then we have a bigger problem than wasting an allocation. And so we prefer the simpler code.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really have a problem if a cancellation token triggered on an IO operation (ReadBufferAsync)? That's like a valid case where we wouldn't return to the shared pool (if we omit the finally block) but continue with the normal execution.

I feel like I don't understand the suggestion or what you mean, sorry :(

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The situation we're trying to avoid is something holding on to the buffer after the exception is thrown and returning into the pool prematurely. Lets say for example ReadBufferAsync failed in a way where the buffer was still being used by a background thread (a rogue Task.Run). This finally would run, return to the pool, then another piece of code would rent it and it would still be in used by the rogue Task.Run. To count this out, we don't bother returning to the pool in exceptional cases.

Copy link
Author

@Trapov Trapov May 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now I get it. But still ReadBufferAsync doesn't use our rented array and it's being rented and used only in this place so no rogue task could use it since it doesn't get forwarded to any of the functions down to call-stack. @davidfowl

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My point is that it makes the code cleaner to not have the try/ finally. ArrayPool is all about stats, we’re assuming there will be far far more successes here than exceptions.
Also try/finally are not necessarily free: they may impede some codegen optimizations (eg inlining, although perhaps not relevant here)

{
if (rentedArray != null)
{
ArrayPool<char>.Shared.Return(rentedArray);
}
}
}

public override Task<string> ReadToEndAsync() => ReadToEndAsync(default);
Expand Down Expand Up @@ -985,17 +1009,50 @@ public override Task<string> ReadToEndAsync(CancellationToken cancellationToken)

private async Task<string> ReadToEndAsyncInternal(CancellationToken cancellationToken)
{
// Call ReadBuffer, then pull data out of charBuffer.
StringBuilder sb = new StringBuilder(_charLen - _charPos);
danmoseley marked this conversation as resolved.
Show resolved Hide resolved
do
static char[] Resize(char[] array, int atLeastSpace)
{
int tmpCharPos = _charPos;
sb.Append(_charBuffer, tmpCharPos, _charLen - tmpCharPos);
_charPos = _charLen; // We consumed these characters
await ReadBufferAsync(cancellationToken).ConfigureAwait(false);
} while (_charLen > 0);
char[] newArr = ArrayPool<char>.Shared.Rent(array.Length + atLeastSpace);
try
{
Array.Copy(array, newArr, array.Length);
danmoseley marked this conversation as resolved.
Show resolved Hide resolved
}
catch
{
ArrayPool<char>.Shared.Return(newArr);
throw;
}
ArrayPool<char>.Shared.Return(array);
return newArr;
}
static void Append(ref char[] to, int destinationOffset, char[] @from, int offset, int length)
{
if ((destinationOffset + length) >= to.Length)
{
to = Resize(to, destinationOffset + length + 80);
}

return sb.ToString();
Array.Copy(@from, offset, to, destinationOffset, length);
}

char[] rentedArray = ArrayPool<char>.Shared.Rent(_charLen - _charPos);
int lastWrittenIndex = 0;
try
{
do
{
// int tmpCharPos = _charPos;
Append(ref rentedArray, lastWrittenIndex, _charBuffer, _charPos, _charLen - _charPos);
lastWrittenIndex += _charLen - _charPos;
_charPos = _charLen; // We consumed these characters
await ReadBufferAsync(cancellationToken).ConfigureAwait(false);
} while (_charLen > 0);

return new string(rentedArray, 0, lastWrittenIndex);
}
finally
{
ArrayPool<char>.Shared.Return(rentedArray);
}
}

public override Task<int> ReadAsync(char[] buffer!!, int index, int count)
Expand Down