編譯器優化導致共用變數無法取得最新的值

利用 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 在裡面就解掉了;這也是多執行緒環境難以底爸葛的原因,常常在不經意的情況下解了爸葛(也可能產生爸葛)。多執行緒是雙面刃,不可不防!

參考資料:
Why we need Thread.MemoryBarrier()?