打破砂鍋:Ctrl-C 擋不住 Console.ReadLine() 繼續執行?
2 |
前幾天被一個詭異的 .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