前幾天被一個詭異的 .NET 程式茶包卡住,還在 FB 貼文請大家幫忙測試。

我們都知道 .NET Console 程式執行過程,按下 Ctrl-C 可終止程式執行。若是在 Console.ReadLine() 等待輸入過程按下 Ctrl-C,直覺想法是既然 ReadLine() 會等待 Enter 鍵再繼續,Ctrl-C 非 Enter 鍵,又有終止程式的意義,程式應該會立即停止。

但以下的測試結果就讓人有點匪夷所思了:

Console.WriteLine("Before ReadLine()");
Console.Write("Enter something: ");
var line = Console.ReadLine();
Console.Write("Enter more: ");
var line2 = Console.ReadLine();
Console.WriteLine("After ReadLine()");

前後兩次 ReadLine(),第一次 ReadLine() 時按 Ctrl-C,程式會立刻結束;若第一次 ReadLine() 隨便給值按 Enter,第二次 ReadLine() 才按 Ctrl-C,則會印完 AfterReadLine() 程式才終止。

蒐集我自己及網友的測試結果,發現同樣的程式碼,相同 .NET 版本,有的人可以重現,有的人不行 (Linux/macOS 則沒有重現案例),而 Bill 叔更分享了「問題重現與否跟手速有關」的發現,意思是此現象在第一次 ReadLine() 也會發生。如下圖,我們一律在第一次 ReadLine() 按 Ctrl-C,若時機夠早,程式有可能顯示完 "Enter more:" 才結束(下圖 [1], [3]);若慢一點,程式比較大機率直接結束。(帶有機率性,符合 Race Condition 的特性)

經過一番推敲,我釐清一件事實:判斷按下 Ctrl-C 的邏輯並不是由 ReadLine() 處理的,Ctrl-C 與 Ctrl-Break 對 Console 程式來說是種特殊訊號(SIGINT、SIGBREAK),設有專屬的機制處理,依據官方文件:這些訊號會傳遞到已連結至主控台的所有主控台程序,系統會在每個客戶端進程中建立新的線程來處理事件。
(註:感謝讀者吳承信分享:Linux/macOS 處理 SIGINT 方式不同,會採用中斷 Main Thread 將執行權交給 Signal Handler 的做法,而非另開 Thread 處理,可解釋為何此一現象在 Linux/macOS 不會發生,故以下的討論僅適用 Windows。)

故按下 Ctrl-C 時,Console 程式會啟動一條 Thread 負責結束程式(姑且稱之為自我了結 Thread),而此時 Main Thread 的 ReadLine() 也會因為按了 Ctrl-C 而結束等待,繼續往下執行。若自我了結 Thread 的速度不夠快,在 Main Thread 跑完 Console.Write() 後才把程式停掉,便會出現按了 Ctrl-C 但 ReadLine() 的下一行還是繼續執行的現象。由於自我了結 Thread 與 Main Thread 二者會平行處理,誰快誰慢具有隨機性。想通「結束程式與 ReadLine() 後方邏輯會平行執行」這點,上面遇到的各式不同結果便都有了合理解釋,一切也可歸納為 It's by design, not a bug。

至此本案的最大謎團算是解開了,但我還有個疑問:為什麼 Console.ReadLine() 遇到 Ctrl-C 會等同按了 Enter 繼續往下走?

我決定追進 .NET 原始碼一探究竟。

Console.ReadLine() 背後是呼叫 In.ReadLine(),而 In 是個 TextReader 型別,透過 ConsolePal.GetOrCreateReader() 建立。

public static TextReader In
{
    get
    {
        return Volatile.Read(ref s_in) ?? EnsureInitialized();

        static TextReader EnsureInitialized()
        {
            // Must be placed outside s_syncObject lock. See Out getter.
            ConsolePal.EnsureConsoleInitialized();

            lock (s_syncObject) // Ensures In and InputEncoding are synchronized.
            {
                if (s_in == null)
                {
                    Volatile.Write(ref s_in, ConsolePal.GetOrCreateReader());
                }
                return s_in;
            }
        }
    }
}

追到 ConsolePal.GetOrCreateReader(),得知 TextReader 其實是個 StreamReader。

internal static TextReader GetOrCreateReader()
{
    Stream inputStream = OpenStandardInput();
    return SyncTextReader.GetSynchronizedTextReader(inputStream == Stream.Null ?
        StreamReader.Null :
        new StreamReader(
            stream: inputStream,
            encoding: new ConsoleEncoding(Console.InputEncoding),
            detectEncodingFromByteOrderMarks: false,
            bufferSize: Console.ReadBufferSize,
            leaveOpen: true));
}

而 StreamReader ReadLine() 邏輯如下,等待使用者輸入再繼續的關鍵在 ReadBuffer():(註:可留意有 **** 標示的中文註解處)

// Reads a line. A line is defined as a sequence of characters followed by
// a carriage return ('\r'), a line feed ('\n'), or a carriage return
// immediately followed by a line feed. The resulting string does not
// contain the terminating carriage return and/or line feed. The returned
// value is null if the end of the input stream has been reached.
//
public override string? ReadLine()
{
    ThrowIfDisposed();
    CheckAsyncTaskInProgress();

    if (_charPos == _charLen)
    {
        // **** 這裡會等待使用者輸入後再繼續
        if (ReadBuffer() == 0)
        {
            return null;
        }
    }

    var vsb = new ValueStringBuilder(stackalloc char[256]);
    do
    {
        // Look for '\r' or \'n'.
        ReadOnlySpan<char> charBufferSpan = _charBuffer.AsSpan(_charPos, _charLen - _charPos);
        Debug.Assert(!charBufferSpan.IsEmpty, "ReadBuffer returned > 0 but didn't bump _charLen?");

        int idxOfNewline = charBufferSpan.IndexOfAny('\r', '\n');
        if (idxOfNewline >= 0)
        {
            string retVal;
            if (vsb.Length == 0)
            {
                retVal = new string(charBufferSpan.Slice(0, idxOfNewline));
            }
            else
            {
                retVal = string.Concat(vsb.AsSpan(), charBufferSpan.Slice(0, idxOfNewline));
                vsb.Dispose();
            }

            char matchedChar = charBufferSpan[idxOfNewline];
            _charPos += idxOfNewline + 1;

            // If we found '\r', consume any immediately following '\n'.
            if (matchedChar == '\r')
            {
                if (_charPos < _charLen || ReadBuffer() > 0)
                {
                    if (_charBuffer[_charPos] == '\n')
                    {
                        _charPos++;
                    }
                }
            }

            return retVal;
        }

        // We didn't find '\r' or '\n'. Add it to the StringBuilder
        // and loop until we reach a newline or EOF.

        vsb.Append(charBufferSpan);
    } while (ReadBuffer() > 0);

    return vsb.ToString();
}

繼續追進 ReadBuffer(),等待輸入的過程來自 _stream.Read(),而實測:按下 Ctrl-C 或 Ctrl-Z 也會結束 .Read() 等待,但與直接按 Enter 結束的差異在於前者傳回的 _byteLen == 0,而後者 _byteLen == 2,_byteBuffer 內的資料則為 \r 及 \n:

internal virtual int ReadBuffer()
{
    _charLen = 0;
    _charPos = 0;

    if (!_checkPreamble)
    {
        _byteLen = 0;
    }

    bool eofReached = false;

    do
    {
        if (_checkPreamble)
        {
            Debug.Assert(_bytePos <= _encoding.Preamble.Length, "possible bug in _compressPreamble.  Are two threads using this StreamReader at the same time?");
            int len = _stream.Read(_byteBuffer, _bytePos, _byteBuffer.Length - _bytePos);
            Debug.Assert(len >= 0, "Stream.Read returned a negative number!  This is a bug in your stream class.");

            if (len == 0)
            {
                eofReached = true;
                break;
            }

            _byteLen += len;
        }
        else
        {
            Debug.Assert(_bytePos == 0, "bytePos can be non zero only when we are trying to _checkPreamble.  Are two threads using this StreamReader at the same time?");
            // **** 在這裡等待使用者輸入
            _byteLen = _stream.Read(_byteBuffer, 0, _byteBuffer.Length);
            Debug.Assert(_byteLen >= 0, "Stream.Read returned a negative number!  This is a bug in your stream class.");
            // 按下 Ctrl-C 或 Ctrl-Z 時也會到這行,但 _byteLen == 0 (按 Enter 的話 _byteLen == 2, 內容為 byte[] { '\r', '\n' })
            if (_byteLen == 0)
            {
                eofReached = true;
                break;
            }
        }

        // _isBlocked == whether we read fewer bytes than we asked for.
        // Note we must check it here because CompressBuffer or
        // DetectEncoding will change byteLen.
        _isBlocked = (_byteLen < _byteBuffer.Length);

        // Check for preamble before detect encoding. This is not to override the
        // user supplied Encoding for the one we implicitly detect. The user could
        // customize the encoding which we will loose, such as ThrowOnError on UTF8
        if (IsPreamble())
        {
            continue;
        }

        // If we're supposed to detect the encoding and haven't done so yet,
        // do it.  Note this may need to be called more than once.
        if (_detectEncoding && _byteLen >= 2)
        {
            DetectEncoding();
        }

        Debug.Assert(_charPos == 0 && _charLen == 0, "We shouldn't be trying to decode more data if we made progress in an earlier iteration.");
        _charLen = _decoder.GetChars(_byteBuffer, 0, _byteLen, _charBuffer, 0, flush: false);
    } while (_charLen == 0);

    if (eofReached)
    {
        // EOF has been reached - perform final flush.
        // We need to reset _bytePos and _byteLen just in case we hadn't
        // finished processing the preamble before we reached EOF.

        Debug.Assert(_charPos == 0 && _charLen == 0, "We shouldn't be looking for EOF unless we have an empty char buffer.");
        _charLen = _decoder.GetChars(_byteBuffer, 0, _byteLen, _charBuffer, 0, flush: true);
        _bytePos = 0;
        _byteLen = 0;
    }

    return _charLen;
}

追進 _stream.Read() 會來到 System.ConsolePal.WindowsConsoleStream.ReadFileNative(),這段已抵達 Unamanged 世界,再下去是作業系統 kernel32.dll 的 ReadFile() API。

// P/Invoke wrappers for writing to and from a file, nearly identical
// to the ones on FileStream.  These are duplicated to save startup/hello
// world working set and to avoid requiring a reference to the
// System.IO.FileSystem contract.

private static unsafe int ReadFileNative(IntPtr hFile, Span<byte> buffer, bool isPipe, out int bytesRead, bool useFileAPIs)
{
    if (buffer.IsEmpty)
    {
        bytesRead = 0;
        return Interop.Errors.ERROR_SUCCESS;
    }

    bool readSuccess;
    fixed (byte* p = buffer)
    {
        if (useFileAPIs)
        {
            // **** 靠這段讀取使用者輸入,背後是 kernel32.dll 的 ReadFile API,
            readSuccess = (0 != Interop.Kernel32.ReadFile(hFile, p, buffer.Length, out bytesRead, IntPtr.Zero));
        }
        else
        {
            // If the code page could be Unicode, we should use ReadConsole instead, e.g.
            int charsRead;
            readSuccess = Interop.Kernel32.ReadConsole(hFile, p, buffer.Length / BytesPerWChar, out charsRead, IntPtr.Zero);
            bytesRead = charsRead * BytesPerWChar;
        }
    }
    // **** 實測按 Ctrl-C 時,readSuccess == true
    if (readSuccess)
        return Interop.Errors.ERROR_SUCCESS;

    // For pipes that are closing or broken, just stop.
    // (E.g. ERROR_NO_DATA ("pipe is being closed") is returned when we write to a console that is closing;
    // ERROR_BROKEN_PIPE ("pipe was closed") is returned when stdin was closed, which is not an error, but EOF.)
    int errorCode = Marshal.GetLastPInvokeError();
    if (errorCode == Interop.Errors.ERROR_NO_DATA || errorCode == Interop.Errors.ERROR_BROKEN_PIPE)
        return Interop.Errors.ERROR_SUCCESS;
    return errorCode;
}
            
internal static unsafe partial class Interop
{
    internal static unsafe partial class Kernel32
    {
        [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "8.0.10.26715")]
        internal static unsafe partial int ReadFile(nint handle, byte* bytes, int numBytesToRead, out int numBytesRead, nint mustBeZero)
        {
            int __lastError;
            global::System.Runtime.CompilerServices.Unsafe.SkipInit(out numBytesRead);
            int __retVal;
            // Pin - Pin data in preparation for calling the P/Invoke.
            fixed (int* __numBytesRead_native = &numBytesRead)
            {
                global::System.Runtime.InteropServices.Marshal.SetLastSystemError(0);
                __retVal = __PInvoke(handle, bytes, numBytesToRead, __numBytesRead_native, mustBeZero);
                __lastError = global::System.Runtime.InteropServices.Marshal.GetLastSystemError();
            }

            global::System.Runtime.InteropServices.Marshal.SetLastPInvokeError(__lastError);
            return __retVal;
            // Local P/Invoke
            [global::System.Runtime.InteropServices.DllImportAttribute("kernel32.dll", EntryPoint = "ReadFile", ExactSpelling = true)]
            static extern unsafe int __PInvoke(nint __handle_native, byte* __bytes_native, int __numBytesToRead_native, int* __numBytesRead_native, nint __mustBeZero_native);
        }
    }
}            

至此可獲得結論:ReadLine() 是用作業系統 ReadFile() API 概念讀取使用者從鍵盤輸入的內容,當按下 Ctrl-C 或 Ctrl-Z 時,等同於讀取到檔案結尾;用 StreamReader.ReadLine() 讀取實體檔案時,讀到檔案結尾會傳回 null,可用於判斷已無資料。同理,若我們不希望 ReadLine() 在按 Ctrl-C 後繼續執行,在偵測結果為 null 時結束作業即可。

修改程式如下,顯示 ReadLine() 讀取到的內容:

using System.Text;

Console.WriteLine("Before ReadLine()");
Console.Write("Enter something: ");
var line = Console.ReadLine();
Console.WriteLine($"line = {(line == null ? "null" : 
    line == string.Empty ? "string.Empty" :
    BitConverter.ToString(Encoding.UTF8.GetBytes(line)))}");
if (line == null) return;
Console.WriteLine("After ReadLine()");

測試 1 - 3 為按下 Ctrl-C 的反應,4 為 Ctrl-Z,5 為直接按 Enter。如此,就可以成功避免 ReadLine() 後方的 Console.WriteLine() 在按 Ctrl-C 後執行。

至此,終於可以宣告全案偵破,收工。

This blog post explores a peculiar behavior in .NET Console applications when pressing Ctrl-C during Console.ReadLine(). I delves into the .NET source code to explain why ReadLine() continues execution after Ctrl-C and provides a workaround to properly handle this scenario.


Comments

# by yoyo

跟graceful shutdown有關嗎?

# by yoyo

捕捉 SIGQUIT 訊號 (Ctrl-C) https://blog.miniasp.com/post/2020/07/22/How-to-handle-graceful-shutdown-in-NET-Core

Post a comment