這個做法在討論跨執行緒共享資料會導致資料完整性錯誤,並介紹 .net 內建的 synchronization primitives 以及使用 lock 的範例。

在多執行緒的任務時常會碰到有同時多於一個執行緒要修改共享的資料,這種操作就會造成資料處於不正確的狀態,所以 .net 提供了 synchronization primitives 用來保護共享資料的完整性。

synchronization primitives 提供了多種類型讓我們能夠以同步的方式存取資料,其中存在最久的就是 Monitor 類別,使用上要注意它是一個 靜態的類別,我們可以使用 Monitor.Enter 取得物件的排他鎖,之後使用 Monitor.Exit 將取得的排他鎖釋放掉,另外常用的還有 Monitor.Wait 會將目前執行緒取得的鎖釋放掉並 block 執行緒重新排隊等待重新獲得物件的鎖,還有與它搭配的 Monitor.Pulse 與 Monitor.PulseAll 能夠將隊伍中的執行緒喚醒並通知 輪到它取得鎖了並工作了,要注意如果沒有呼叫 Monitor.Pulse 與 Monitor.PulseAll 執行緒會一直等待下去。

Monitor.Enter 之所以可以獲得排他鎖是因為它使用了每個物件都有的 sync block index(SBI),當一個物件實例化後它的 SBI 會為初始值 -1, 當我們調用 Monitor.Enter 會在 CLR 中的 sync block array 找到空閒的 sync block 並把它在 array 中的 index 記錄下來對應到 物件的 SBI 上,這樣就能把物件跟 sync block 對應起來了。

當調用 Monitor.Exit 則會檢查有沒有其他執行緒正在排隊等待使用指定物件的 sync block,如果已經沒有人要使用的話就會將物件的 SBI 改回 -1, 這樣之前使用的 sync block 就會回到自由狀態了,等未來可以再次跟其他物件關聯在一起。

下面這段程式碼實例化一個 Transaction 物件並且透過 ThreadPool.QueueUserWorkItem 方法模擬跨執行緒讀取 LastTransaction 屬性, 由於這個屬性讀取時需要獲取物件的排他鎖,我們故意在 ThreadPool.QueueUserWorkItem 方法執行前呼叫 Monitor.Enter 將 Transaction 物件 的排他鎖佔據起來,這樣就會導致執行緒一值阻塞直到運行 Monitor.Exit 將鎖還回去為止。

void Main()
{
	SomeMethod();
}

public static void SomeMethod()
{
	var t = new Transaction();
	Monitor.Enter(t);
	ThreadPool.QueueUserWorkItem(o => Console.WriteLine(t.LastTransaction));
	Monitor.Exit(t);
}

internal sealed class Transaction
{
    private readonly object _lock = new object();
	private DateTime m_timeOfLastTrans;
	public void PerformTransaction()
	{
		Monitor.Enter(this);
		m_timeOfLastTrans = DateTime.Now;
		Monitor.Exit(this);
	}
	public DateTime LastTransaction
	{
		get
		{
			Monitor.Enter(this);
			DateTime temp = m_timeOfLastTrans;
			Monitor.Exit(this);
			return temp;
		}
	}
}

上面這段程式碼會發生這個問題的根本原因是物件的 SBI 就是一個 public 的屬性,也就導致不管在什麼範圍都能夠透過 SBI 取得 sync block, 要解決這個問題也很簡單就是透過一個私有的欄位當作鎖,這樣就能避免鎖與外界一起共用的情況發生了。

添加一個 object 類型的鎖並稍微修改程式碼,現在獲得的就是 _lock 物件的 sync block,就不會跟外界的鎖起衝突了。

internal sealed class Transaction
{
	private readonly object _lock = new object();
	private DateTime m_timeOfLastTrans;
	public void PerformTransaction()
	{
		Monitor.Enter(_lock);
		m_timeOfLastTrans = DateTime.Now;
		Monitor.Exit(_lock);
	}
	public DateTime LastTransaction
	{
		get
		{
			Monitor.Enter(_lock);
			DateTime temp = m_timeOfLastTrans;
			Monitor.Exit(_lock);
			return temp;
		}
	}
}

接下來就可以討論 lock 語句了,它其實就是 Monitor 類別的語法糖,這樣我們就不用每次都要寫 Monitor.Enter 跟 Monitor.Exit 了。 同樣使用 lock 也要注意不要寫成 lock (this) 了,這樣會產生跟 Monitor.Enter(this) 一樣的問題。

internal sealed class Transaction
{
	private readonly object _lock = new object();
	private DateTime m_timeOfLastTrans;
	public void PerformTransaction()
	{
		lock (_lock)
		{
			m_timeOfLastTrans = DateTime.Now;
		}
	}
	public DateTime LastTransaction
	{
		get
		{
			lock (_lock)
			{
				DateTime temp = m_timeOfLastTrans;
				return temp;
			}
		}
	}
}

背後編譯器會產生類似下方的程式碼,可以看到基本上與我們剛剛自己寫的差不多,有多了 try/finally 確保一定能釋放掉排他鎖,還有 lockTaken 他是用來確保某些特殊情況能夠運作正常,當執行到 Monitor.Enter 會將 lockTaken 修改成 true,這樣就一定能在 finally 區塊釋放掉鎖, 但是也有可能在執行 Monitor.Enter 之前程式就停止並進入到 finally,這種情況直接呼叫 Monitor.Exit 反而會造成報錯,所以才多加了 lockTaken 確保沒有真的取得到鎖最後就不必釋放鎖了。

internal sealed class Transaction
{
    [Nullable(1)]
    private readonly object _lock = new object();

    private DateTime m_timeOfLastTrans;

    public DateTime LastTransaction
    {
        get
        {
            object @lock = _lock;
            bool lockTaken = false;
            try
            {
                Monitor.Enter(@lock, ref lockTaken);
                return m_timeOfLastTrans;
            }
            finally
            {
                if (lockTaken)
                {
                    Monitor.Exit(@lock);
                }
            }
        }
    }

    public void PerformTransaction()
    {
        object @lock = _lock;
        bool lockTaken = false;
        try
        {
            Monitor.Enter(@lock, ref lockTaken);
            m_timeOfLastTrans = DateTime.Now;
        }
        finally
        {
            if (lockTaken)
            {
                Monitor.Exit(@lock);
            }
        }
    }
}

但使用 lock 只能鎖定一個區域範圍的操作並不能跨方法或是透過 lambda 表達式在其他地方釋放掉鎖,也沒有辦法限制超時時間只能不斷等待, 這兩種情況可以改用 Monitor 類別,設計起來會比較靈活。

另外要注意取得鎖的物件必須是參考型別的,如果是值型別首先會導致裝箱並取得鎖,這個時候同時又有另一個工作要取得鎖,這時候值型別 又會在一次裝箱,這樣就造成兩個執行緒拿到了兩個不同的鎖,導致不會將流程轉換成同步處理,所以跟沒有鎖一樣。

例如下面這段程式碼運行後會拋出 SynchronizationLockException 因為 Monitor.Exit(_lock) 這邊的 _lock 物件裝箱後跟之前 取得的鎖完全沒有關係,所以就變成釋放掉一個沒有取得過的鎖導致拋出這個錯誤。

internal sealed class Transaction
{
	private readonly int _lock = new int();
	private DateTime m_timeOfLastTrans;
	public void PerformTransaction()
	{
		try
		{
			Monitor.Enter(_lock);
			m_timeOfLastTrans = DateTime.Now;
		}
		finally
		{
			Monitor.Exit(_lock);
		}

	}
	public DateTime LastTransaction
	{
		get
		{
			try
			{
				Monitor.Enter(_lock);
				DateTime temp = m_timeOfLastTrans;
				return temp;

			}
			finally
			{
				Monitor.Exit(_lock);
			}
		}
	}
}

下面這個寫法雖然不會拋出錯誤,但問題也是一樣,每次拿到的都是新 box 的鎖。

internal sealed class Transaction
{
	private readonly object _lock = new int();
	private DateTime m_timeOfLastTrans;
	public void PerformTransaction()
	{
		try
		{
			Monitor.Enter(_lock);
			m_timeOfLastTrans = DateTime.Now;
		}
		finally
		{
			Monitor.Exit(_lock);
		}

	}
	public DateTime LastTransaction
	{
		get
		{
			try
			{
				Monitor.Enter(_lock);
				DateTime temp = m_timeOfLastTrans;
				return temp;

			}
			finally
			{
				Monitor.Exit(_lock);
			}
		}
	}
}

某些情況可以改用 Interlocked 來同步化操作,他能提供原子性的操作,無需使用到完整的鎖定邏輯。 例如數值增減可以使用 Interlocked.Increment()、Interlocked.Decrement(),數值交換 Interlocked.Exchange(),還有經常使用的 CAS Interlocked.CompareExchange()。

下面就是透過 CAS 來比較與替換會員的金額,執行的效率會比使用 lock 還要好。

async Task Main()
{
	var tasks = new List<Task>();
	var user = new User();

	for (int i = 0; i < 10000; i++)
	{
		tasks.Add(Task.Run(() =>
		{
			user.UpdateBalance();
		}));
	}

	for (int i = 0; i < 10000; i++)
	{
		tasks.Add(Task.Run(() =>
		{
			user.UpdateBalance();
		}));
	}

	Console.WriteLine(tasks.Count());
	Task.WaitAll(tasks);
	Console.WriteLine(user);
}

public class User
{
	public int Balance;
	public void UpdateBalance()
	{
		int val = Balance;
		do
		{
			val = Balance;
		} while (val != Interlocked.CompareExchange(ref Balance, Balance + 10, val));
	}
}

Summary

這個做法講解了 Monitor 類別、lock 語句和 Interlocked 類別,都有他們自己的使用場景,我們在設計的時候應該首先考慮使用 Interlocked 類別 ,如果不符合需求在使用 lock。