5 种避免使用 C# lock 关键字的方法

5 种避免使用 C# lock 关键字的方法

https://zhuanlan.zhihu.com/p/136031306

提起多线程编程,始终离不开线程安全(资源竞争)的问题。如果没有处理好这些问题,往往在会出现开发一时爽,调试火葬场的情况。大都数语言中都会提供一些特定的方法来简化多线程开发,比如 C# 就提供了 lock 关键字来解决这些问题。

如果你在开发的过程中正确的使用了 lock 关键字,将有效的避免许多线程安全的问题。但是任何解决方案都是存在代价的,一味使用 lock 的话也会照成意想不到的性能(逼格)损失。本文就列举了 5 种情况下应避免使用 lock 关键字。

使用 System.Collections.Concurrent 命名空间

比如直接使用 ConcurrentDictionary 替代 Dictionary ,而不是使用 lock:

//使用 lock + Dictionary
lock (dict)
{
    if (dict.ContainsKey(key))
    {
        dict[key] = dict[key] + value;
    }
    else
    {
        dict.Add(key, value);
    }
}
//使用 ConcurrentDictionary
dict.AddOrUpdate(key, value, (k, v) => v + value);

在需要线程安全的情景下应该使用 ConcurrentDictionary<TKey,TValue> 代替 Dictionary<TKey,TValue>,ConcurrentBag<T> 代替 List<T> ,同时还有线程安全的 ConcurrentQueue<T>、ConcurrentStack<T> 等在该命名空间可供使用。值得注意的是 ConcurrentBag 是一个无序的集合同时也并不实现 IList 接口,所以无法使用索引,也无法在需要 IList 的地方代替。

我相信大部分人都知道 System.Collections.Concurrent 这个命名空间,本不想写这一段,但是有点不可思议是我经常见因为不知道 ConcurrentDictionary 的存在而手写了一个 “SynchronizedDictionary ”的人,而且一般手写的“SynchronizedDictionary ”都是有很大问题的,这点可以从 ConcurrentDictionary 选择显示实现 IDictionary 接口上学到不少设计一个线程安全对象 API 的技巧。

使用 Interlocked

使用 Interlocked 而不是使用 lock:

//使用 lock 
lock (counter)
{
    counter.Progress += val;
}
//使用 Interlocked
Interlocked.Add(ref counter.Progress, val);

Interlocked 对于 int,long 这样的基础类型进行线程安全的运算操作时特别方便。线程安全要求保证对共享变量的任何写入或读取访问都是原子的,不然的话,你正在处理数据可能已不可用,或者读取出来的值可能不正确。总之,使用 Interlocked 不仅性能比 lock 更好,而且代码更为清晰简单。

使用 ThreadStaticAttribute 或 ThreadLocal

在处理一些特定场景中需要为每个线程提供单独的变量,比如于多线程下载时,每个线程都需要记录下载的字节数,那么使用 ThreadStaticAttribute 最合适不过了:

//使用 ConcurrentDictionary 下载字节数
ConcurrentDictionary<int, int> ThreadDownloadBytes = new ConcurrentDictionary<int, int>();
void DownloadFileThread()
{
    //... 记录下载字节数
    ThreadDownloadBytes.AddOrUpdate(Thread.CurrentThread.ManagedThreadId, readedByteCount, (k, v) => v + readedByteCount);
}

//使用 ThreadStaticAttribute 来使不同线程有各自独立的存储空间
[ThreadStatic]
static int ThreadDownloadBytes;
void DownloadFileThread()
{
    //... 记录下载字节数
    ThreadDownloadBytes += readedByteCount;
}

上面的场景可能跟实际应用中有出入,只是作为例子说明问题。你可能会说这个场景中并没有使用 lock 啊,那是已经使用 ConcurrentDictionary 来简化代码。

但此处请注意,使用 ThreadStaticAttribute 特性标注的静态变量的初始化并不可靠,因为初始化这个行为只在一个线程发生。如果需要对对象进行初始化或者读取不同线程储存的值可以考虑使用 ThreadLocal<T>

使用 ReaderWriterLockSlim

在需要对资源读写的线程安全中,简单使用 lock 没有太多问题,但是如果这个资源的读取频率高,写入频率相对比较低,则可以使用 ReaderWriterLockSlim 来进一步提升性能,这种情况在一些需要线程安全的缓存场景特别常见。

//使用 lock 方法进行读写的线程安全管理
private object lockObject = new object();
private void Read()
{
    lock (lockObject)
    {
        //具体实现
    }
}
private void Write(string value)
{
    lock (lockObject)
    {
        //具体实现
    }
}
//使用 ReaderWriterLockSlim 进行类似的操作
private ReaderWriterLockSlim LockSlim = new ReaderWriterLockSlim();
private void Read()
{
    LockSlim.EnterReadLock();
    try
    {
        //具体实现
    }
    finally
    {
        LockSlim.ExitReadLock();
    }
}
private void Write(string value)
{
    LockSlim.EnterWriteLock();
    try
    {
        //具体实现
    }
    finally
    {
        LockSlim.ExitWriteLock();
    }
}

看起来好像 ReaderWriterLockSlim 更麻烦一点,不过之所以 ReaderWriterLockSlim 性能更好,是因为它可以允许多个线程进行读取操作,而当进行写入操作时进入独占模式。如果你的场景读取和写入频率不确定,则不应该使用 ReaderWriterLockSlim。ReaderWriterLockSlim 提供了许多方法对资源进行控制,请务必详读 MSDN 里相关内容后再尝试开始使用。

使用 SpinLock 自旋锁

其实我不应该在这里提到 SpinLock,因为如果读这篇文章到这里还没离开的人可能无法正确区分 SpinLock 的适用场景,虽然它的用法跟不加糖的 lock 特别相似。

//lock
private static object lockObject = new object();
private void Update(DateTime d)
{
    lock (lockObject)
    {
        list.Add(d);
    }
}

//SpinLock
private static SpinLock spinLock = new SpinLock();
private void UpdateWithSpinLock(DateTime d)
{
    bool lockTaken = false;
    try
    {
        spinLock.Enter(ref lockTaken);
        list.Add(d);
    }
    finally
    {
        if (lockTaken) spinLock.Exit(false);
    }
}

简单的解释 lock 和 SpinLock 之间区别是,lock 会在资源发生竞争的时候会切换去执行其它代码等待时机,类似于 Thread.Sleep 会把 CPU 时间让出去;而 SpinLock 在发生资源竞争时尝试自旋几个周期再去尝试,类似执行一个 do while 循环,消耗 CPU 时间。而且 SpinLock 是一个 struct 在大量使用的情况下对 GC 友好。所以当你确认锁独占资源的时间非常短,并且也没有使用其它你不知道源码的方法,可以考虑使用 SpinLock 来代替 lock 。

由于我的表达水平有限,实际情况远比上面的说法要复杂,所以关于 SpinLock 适用场景我直接摘抄 MSDN 的内容:[1]

如果共享资源上的锁不会保留太长时间,SpinLock 可能会很有用。在这种情况下,多核计算机上的阻止线程可高效旋转几个周期,直到锁被释放。通过旋转,线程不会受到阻止,这是一个占用大量 CPU 资源的进程。

但是 MSDN 上关于不适于使用 SpinLock 的情况更多:[2]

通常,在持有自旋锁时,应避免使用以下任何操作:
1.堵塞
2.调用自身可能会阻止的任何内容,
3.同时保留多个自旋锁,
4.进行动态调度的调用(interface 和虚方法),
5.对任何代码进行静态调度调用,而不是任何代码,或
6.分配内存。

最后

多线程编程并不容易驾驭,不然也不会出现“一核有难多核围观”的梗,本文目的也只是抛砖引玉,本人水平有限也不想大篇幅的深入底层细节。上面每个话题在 MSDN 上都有大篇幅的内容可供深入阅读,毕竟微软的文档质量不是盖的。最后,写这么个不入流的文章只是希望看到有人大篇幅的使用 lock 关键字的时候,能有篇东西能发给TA看。

参考

  1. ^如何:使用 SpinLock 进行低级别同步 https://docs.microsoft.com/zh-cn/dotnet/standard/threading/how-to-use-spinlock-for-low-level-synchronization?view=netcore-3.1
  2. ^SpinLock 结构 https://docs.microsoft.com/zh-cn/dotnet/api/system.threading.spinlock?view=netcore-3.1

编辑于 2020-05-06

 

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值