C# 應用 – 多執行緒 7) 處理同步數據之 Synchronized code regions (同步程式碼區域): Monitor 和 lock

目錄:

  1. System.Threading.Monitor:提供同步訪問對象的機制;
  2. lock 是語法糖,是對 Monitor Enter 和 Exit 方法的一個封裝
  3. lock 案例

1. Monitor

1. 基本方法

  1. public static void Enter(object obj);
    在指定對象上獲取排他鎖。
  2. public static void Exit(object obj);
    釋放指定對象上的排他鎖。

2. 使用例子

// 被 Monitor 保護的隊列
private Queue<T> m_inputQueue = new Queue<T>();

// 給 m_inputQueue 加鎖,並往 m_inputQueue 添加一個元素
public void Enqueue(T qValue)
{
  // 請求獲取鎖,並阻塞其他執行緒獲得該鎖,直到獲得鎖
  Monitor.Enter(m_inputQueue);
  try
  {
     m_inputQueue.Enqueue(qValue);
  }
  finally
  {
     // 釋放鎖
     Monitor.Exit(m_inputQueue);
  }
}

2. lock

lock 是語法糖,是對Monitor的Enter和Exit的一個封裝。

lock (m_inputQueue) {} 等價於

bool __lockWasTaken = false;
try
{
    System.Threading.Monitor.Enter(m_inputQueue, ref __lockWasTaken);
}
finally
{
    if (__lockWasTaken) System.Threading.Monitor.Exit(m_inputQueue);
}
  1. 當同步對共享資源的執行緒訪問時,請鎖定專用對象實例(例如,private readonly object balanceLock = new object();)或另一個不太可能被程式碼無關部分用作 lock 對象的實例。 避免對不同的共享資源使用相同的 lock 對象實例,因為這可能導致死鎖或鎖爭用;
  2. 具體而言,避免將以下對象用作 lock 對象:
    1)this(調用方可能將其用作 lock)
    2)Type 實例(可以通過 typeof 運算符或反射獲取)
    3)字元串實例,包括字元串文本,(這些可能是暫存的)。
    儘可能縮短持有鎖的時間,以減少鎖爭用。
private readonly object balanceLock = new object();
private Queue<T> m_inputQueue = new Queue<T>();

public void Enqueue(T qValue)
{
    lock (balanceLock)
    {
        m_inputQueue.Enqueue(qValue);
    }
}

3. lock 案例

1. 資料庫訪問工廠單例模式

private static object _iBlockPortLockObj = new object();
private static IBlockPort _iBlockPort;

/// <summary>
/// 卡口
/// </summary>
/// <returns></returns>
public static IBlockPort CreateBlockPort()
{            
    if (_iBlockPort == null)
    {
        lock (_iBlockPortLockObj)
        {
            if (_iBlockPort == null)
            {
                string className = AssemblyName + "." + db + "BlockPort";
                _iBlockPort = (IBlockPort)Assembly.Load(AssemblyName).CreateInstance(className);
            }
        }                
    }

    return _iBlockPort;
}

2. 隊列進出

public abstract class AbstractCache<T> where T : ICloneable
{
    protected int queenLength = 30; // 保持隊列的最大長度,主要可能考慮記憶體

    /// <summary>
    /// 過車快取列表
    /// </summary>
    public List<T> listCache { get; set; }

    protected object _lockObj = new object();

    /// <summary>
    /// 初始化或重置快取列表
    /// </summary>
    protected void RefreshListCache()
    {
        lock (_lockObj)
        {
            if (listCache == null)
            {
                listCache = new List<T>();
            }
            else
            {
                listCache.Clear();
            }
        }            
    }
    
    /// <summary>
    /// 添加新的數據進隊列,後續考慮做成環形隊列減少開銷
    /// </summary>
    /// <param name="list"></param>
    protected void AddListToCache(List<T> list)
    {
        lock (_lockObj)
        {
            if (listCache == null) return;

            listCache.InsertRange(0, list);
            if (listCache.Count > queenLength)
            {
                listCache.RemoveRange(queenLength, listCache.Count - queenLength);
            }
        }
    }

    /// <summary>
    /// 移除並返回過車快取隊列的最後一個元素
    /// </summary>
    /// <returns></returns>
    public T DequeueLastCar()
    {
        T res = default;

        lock (_lockObj)
        {
            if (listCache != null && listCache.Count > 0)
            {
                int lastIndex = listCache.Count - 1;
                res = (T)listCache[lastIndex].Clone();
                listCache.RemoveAt(lastIndex);
            }
        }

        return res;
    }
}
  1. 前提:在某項目上,view 的控制項包括一個下拉框(可選idA、idB等)、一個圖片 image;
  2. 數據邏輯設計:執行緒 A 定時根據下拉框的選擇作為條件從第三方的資料庫獲取數據並添加進隊列
    1)執行緒 B 定時從隊列取出一個並展示到 image 控制項
    2)當下拉框切換選擇時,清空隊列 [便於展示跟下拉框關聯的圖片]
  3. 問題:從第三方的資料庫取數據需要 1s 左右,如果剛好出現這樣的操作:執行緒 A 查資料庫獲取 idA 相關的數據(將持續 1s)-> 下拉框 idA 切換到 idB 並觸發執行清空隊列操作 -> 執行緒 A 將 idA 的數據添加到隊列,將會出現下拉框切換 idB 之後依舊展示 idA 相關的數據。
  4. 解決:在執行緒 a 查資料庫時就對隊列加鎖(同時去掉隊列入隊的鎖,避免死鎖),這樣在獲取數據的中途切換下拉框,就能等到獲取完並加入隊列後再清空。
  5. 導致新的問題:在獲取的過程中,因隊列被鎖,導致無法執行緒 B 出隊的操作被阻塞。
  6. 解決:入隊和出隊共用一個鎖,從資料庫獲取數據和清空隊列共用一個鎖。
/// <summary>
/// 添加新的數據進隊列,後續考慮做成環形隊列減少開銷
/// 清空、添加、取出一個數據,都需要加鎖,但是由於添加的數據是從海康那邊拿過來的,可能需要幾秒的時間,        
/// 可能會導致這樣的結果:執行緒 A 查資料庫(持續幾秒)-> 執行緒 B 執行清空隊列操作 -> 執行緒 A 將數據添加到隊列
/// 因此將,鎖直接移動到 lock {執行緒 A 查資料庫、將數據添加到隊列}
/// </summary>
/// <param name="list"></param>
protected void AddListToCache(List<T> list)
{
    if (listCache == null) return;

    listCache.InsertRange(0, list);
    if (listCache.Count > queenLength)
    {
        listCache.RemoveRange(queenLength, listCache.Count - queenLength);
    }
}

CancellationTokenSource source = new CancellationTokenSource();

/// <summary>
/// 定時獲取 xx 數據
/// </summary>
public void GetPassCarInterval()
{
    Task.Factory.StartNew(() =>
    {
        while (!source.IsCancellationRequested)
        {
            if (!string.IsNullOrWhiteSpace(xx))
            {
                lock (_lockObj)
                {
                    // 從資料庫獲取數據
                    var list = GetPassCarInfo.GetLastBlockPortCarRecordBy(xx);
                    
                    AddListToCache(list);
                }                        
            }                    

            AutoReset.WaitOne(Common.GetDataTimespan);
        }
    }, TaskCreationOptions.LongRunning);
}