利用 MemoryBarrier 與 volatile 修飾字處理多執行緒共用變數。
近日在 Code Review 時,工程師表示程式在 IDE 環境下(VS2017)用 Release 模式跑桌面應用程式有時會卡住;而 Debug 不會。棘手的是,Release 模式下也不是每次都會發生。這種無法每次重現的問題最麻煩。在偶然的機會下(本來在看別的程式碼問題),發現了以下這段程式:
while (!_stop)
{
if (Leave)
Break;
}
這個 while 迴圈屬於流程控制的一部份,必須等到 _stop 設定為 true 或 Leave 為 true 才會繼續下一段程式碼。看到這段程式碼後問了問工程師,是不是這段程式會發生卡住的問題。工程師表示只要在 while 迴圈中加上紀錄 log 程式碼就不會卡頓,但不知道為什麼。啊哈!真的被我遇到這個情況,趕快翻出之前記錄的範例程式(LINQPad 真是好東西)。
void Main()
{
bool stop = false;
var t = new Thread(() =>
{
Console.WriteLine("thread begin");
bool toggle = false;
while (!stop)
{
// Thread.MemoryBarrier();
toggle = !toggle;
}
Console.WriteLine("thread end");
});
t.Start();
Thread.Sleep(1000);
stop = true;
Console.WriteLine("stop = true");
Console.WriteLine("waiting...");
t.Join();
}
這個範例來自 StackOverflow,之前只是在研究 MemoryBarrier 在做什麼用;當時有看沒有懂,也不知道實務上究竟有沒有機會碰上,果真這次被我碰到了,在這裡做個紀錄。上述這段程式碼在 Debug 模式下,t 可以正常結束,而 Release 模式下 t 則永遠不會結束(stop 永遠為 false)。為什麼會產生這樣的差異?簡單的來說,不同 cpu 利用了各自的 cache 去儲存執行緒所需要用到的變數,在這個例子裡,stop 在不同執行緒中分別被修改與讀取(看似共享但其實仍被儲存在各自的 cache 中)。既然在各自的 cache,那就會有同步的問題(兩者 cache 的值不一定在同一個時間點相同);而 Release 為了優化程序,會傾向直接利用當下 cache 中的值。在此例中 t 並沒有機會去更新最新的 stop 值,導致執行緒無法正常結束。
要解決這個問題其實很容易,將程式中被註解掉的程式碼解除註解,就可以正確的更新當前的 stop 值。也可以將 stop 變數標記為 volatile(強迫從記憶體中取得值,而非 cache),關於 MemoryBarrier 的作用,Medium 上有一篇由硬體角度切入的文章,寫得淺顯易懂 從硬體觀點了解 memory barrier 的實作和效果。
這個問題若是沒有細問,工程師可能就塞個 log 在裡面就解掉了;這也是多執行緒環境難以底爸葛的原因,常常在不經意的情況下解了爸葛(也可能產生爸葛)。多執行緒是雙面刃,不可不防!