dotnet 已知問題 警惕 StreamReader 的 EndOfStream 卡住執行緒

lindexi發表於2024-09-05

在 dotnet 裡面,咱會經常使用 StreamReader 輔助類讀取 Stream 的內容,比如按行讀取等。如果在判斷是否讀取完成時,使用的是 StreamReader 的 EndOfStream 屬性,則可能破壞原本的非同步出讓邏輯,導致執行緒被卡住

對於帶 UI 的應用程式,如 WPF 等應用來說,如果 UI 執行緒被卡住,可能會是一個比較重的坑。在 dotnet 裡面的 StreamReader 類裡面的 EndOfStream 存在一個設計上的問題。訪問 EndOfStream 會導致 StreamReader 執行一次同步讀取 Stream 的過程

假定 Stream 是一個讀取非常慢的物件,如卡頓的網路下的響應內容。此時使用 StreamReader 類進行非同步讀取,自然不會卡住執行緒。假定非同步讀取的是 ReadLineAsync 按行讀取,那開發者可能的需求是知道讀取完成,常見錯誤的寫法如下

var streamReader = new StreamReader(...);

// 這是錯誤的實現,錯誤使用 EndOfStream 作為迴圈判斷條件
while (!streamReader.EndOfStream)
{
    var line = await streamReader.ReadLineAsync();
    // 忽略其他程式碼
}

以上程式碼是錯誤的實現方式,核心原因是在判斷是否已經讀取完成使用了 EndOfStream 屬性而不是 ReadLineAsync 的返回值

正確的實現應該是如下

while (true)
{
    var line = await streamReader.ReadLineAsync();
    if (line is null)
    {
        break;
    }
}

在 ReadLineAsync 或 ReadLine 方法裡面,如果一行裡面是空文字,則會返回 "" 空字串。當讀取完成的時候,則會返回 null

當然了,使用 ReadLine 方法讀取的時候,使用 EndOfStream 屬性是沒有什麼問題的,因為本身就在進行同步讀寫

為什麼在使用 ReadLineAsync 非同步方法時,不能使用 EndOfStream 屬性作為迴圈結束條件?透過讀 dotnet 的實現原始碼可以看到 EndOfStream 屬性是透過讀取一下,看看是不是讀取完了,如果讀取完就返回 true 的值,否則就繼續返回 false 的值

由於 C# 的屬性從語法上就不支援非同步方法,導致 EndOfStream 屬性只能進行同步讀取,從而導致 EndOfStream 屬性可能卡執行緒。從 C# 屬性設計上講,通用的屬性應該都是獲取速度十分快的,然而 EndOfStream 屬性違背了這一點,居然是進行同步讀取 Stream 內容才能判斷,這就導致瞭如果 StreamReader 所讀取的 Stream 是緩慢的,將會導致 EndOfStream 屬性返回緩慢

接下來我將編寫一個簡單的測試程式碼用於告訴大家使用 EndOfStream 屬性在進行非同步讀取時的缺點

如下面程式碼,編寫了一個 FooStream 型別,這個型別在讀取的時候速度非常緩慢

class FooStream : Stream
{
    public FooStream()
    {
        _buffer = "123\r\n"u8.ToArray();
    }

    private readonly byte[] _buffer;

    public override void Flush()
    {
    }

    public override int Read(byte[] buffer, int offset, int count)
    {
        // 模擬卡頓
        Thread.Sleep(10000);

        if (count >= _buffer.Length)
        {
            count = _buffer.Length;

            Array.Copy(_buffer, 0, buffer, offset, count);
        }

        return count;
    }

    public override long Seek(long offset, SeekOrigin origin)
    {
        return offset;
    }

    public override void SetLength(long value)
    {
    }

    public override void Write(byte[] buffer, int offset, int count)
    {
    }

    public override bool CanRead => true;
    public override bool CanSeek => false;
    public override bool CanWrite => false;
    public override long Length => long.MaxValue;
    public override long Position { get; set; }
}

如以下程式碼,使用 StreamReader 進行非同步讀取,且錯誤使用 EndOfStream 屬性作為判斷條件

var fooStream = new FooStream();
var streamReader = new StreamReader(fooStream);

while (!streamReader.EndOfStream)
{
    var line = await streamReader.ReadLineAsync();
    if (line is null)
    {
        break;
    }
}

嘗試跑起來程式碼,可以看到在 EndOfStream 屬性獲取時卡住,在 Visual Studio 裡點選暫停,在堆疊視窗可以看到如下程式碼

> 	System.Private.CoreLib.dll!System.Threading.Thread.Sleep(int millisecondsTimeout)
	HerrigeedaJardarkewel.dll!FooStream.Read(byte[] buffer, int offset, int count)
 	System.Private.CoreLib.dll!System.IO.StreamReader.ReadBuffer()
 	System.Private.CoreLib.dll!System.IO.StreamReader.EndOfStream.get()
 	HerrigeedaJardarkewel.dll!Program.<Main>$(string[] args)
 	HerrigeedaJardarkewel.dll!Program.<Main>(string[] args)

閱讀 dotnet 的原始碼,可以看到 EndOfStream 屬性的實現如下

namespace System.IO
{
    // This class implements a TextReader for reading characters to a Stream.
    // This is designed for character input in a particular Encoding,
    // whereas the Stream class is designed for byte input and output.
    public class StreamReader : TextReader
    {
        public bool EndOfStream
        {
            get
            {
                ThrowIfDisposed();
                CheckAsyncTaskInProgress();

                if (_charPos < _charLen)
                {
                    return false;
                }

                // This may block on pipes!
                int numRead = ReadBuffer();
                return numRead == 0;
            }
        }

        internal virtual int ReadBuffer()
        {
        	 ... // 忽略其他程式碼
            int len = _stream.Read(_byteBuffer, _bytePos, _byteBuffer.Length - _bytePos);
             ... // 忽略其他程式碼
        }

        ... // 忽略其他程式碼
    }
}

從上面程式碼可以看到 EndOfStream 是透過判斷 ReadBuffer 是否能夠讀取到內容從而判斷是否已經讀取完成

在 ReadBuffer 方法裡面將執行 _stream.Read 同步的讀取方法。如果此時 _stream 的讀取緩慢,則會卡住執行緒

本文程式碼放在 githubgitee 上,可以使用如下命令列拉取程式碼。我整個程式碼倉庫比較龐大,使用以下命令列可以進行部分拉取,拉取速度比較快

先建立一個空資料夾,接著使用命令列 cd 命令進入此空資料夾,在命令列裡面輸入以下程式碼,即可獲取到本文的程式碼

git init
git remote add origin https://gitee.com/lindexi/lindexi_gd.git
git pull origin 96a09bc149186f9122f263f887257dcbf209d4e3

以上使用的是國內的 gitee 的源,如果 gitee 不能訪問,請替換為 github 的源。請在命令列繼續輸入以下程式碼,將 gitee 源換成 github 源進行拉取程式碼。如果依然拉取不到程式碼,可以發郵件向我要程式碼

git remote remove origin
git remote add origin https://github.com/lindexi/lindexi_gd.git
git pull origin 96a09bc149186f9122f263f887257dcbf209d4e3

獲取程式碼之後,進入 Workbench/HerrigeedaJardarkewel 資料夾,即可獲取到原始碼

更多技術部落格,請參閱 部落格導航

相關文章