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

鑫茂發表於2021-03-11

目錄:

  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);
}

相關文章