C#线程:排它锁
排他锁结构有三种:lock语句
、Mutex
和SpinLock
。
其中lock是最方便最常用的结构。而其他两种结构多用于处理特定的情形:Mutex可以跨越多个进程(计算机范围锁)。SpinLock可用于实现微优化,可以在高并发场景下减少上下文切换。
lock语句
先看如下代码:
class ThreadUnsafe { static int _val1 = 1, _val2 = 1; static void Go() { if (_val2 != 0) Console.WriteLine(_val1 / _val2); _val2 = 0; } }
以上的类不是线程安全的。如果两个线程同时调用Go方法,则有可能出现除数为0的错误。因为_val2有可能被第一个线程设置为0,而第二个线程正处于if和Console.WriteLine语句之间。下例使用了lock来修正这个错误:
class ThreadSafe { static readonly object _locker = new object(); static int _val1 = 1, _val2 = 1; static void Go() { lock (_locker) { if (_val2 != 0) Console.WriteLine(_val1 / _val2); _val2 = 0; } } }
每一次只能有一个线程锁定同步对象_locker,而其他线程则被阻塞,直至锁释放。如果参与竞争的线程多于一个,则它们需要在准备队列中排队,并以先到先得的方式获得锁。排他锁会强制以所谓序列的方式访问被锁保护的资源,因为线程之间的访问是不能重叠的。因此,本例中的锁保护了Go方法中的访问逻辑,也保护了_val1和_val2字段。
Monitor.Enter方法和Monitor.Exit方法
C#的lock语句是包裹在try/finally语句块中的Monitor.Enter
和Monitor.Exit
语法糖,因此上例Go方法的实际操作为(以下代码对部分逻辑进行了简化):
Monitor.Enter(_locker); try { if (_val2 != 0) Console.WriteLine(_val1 / _val2); _val2 = 0; } finally { Monitor.Exit(_locker); }
如果调用Monitor.Exit之前并没有对同一个对象调用Monitor.Enter,则该方法会抛出异常。
lockTaken重载
上述示例代码中有一个不易发现的漏洞。如果在Monitor.Enter和try语句块之间抛出了(很少见)异常,那么锁的状态是不确定的。但若已经获得了锁,那么这个锁就永远无法释放,因为已经没有机会进入try/finally代码块了。因此这种情况会造成锁泄露。为了防范这种风险,Monitor.Enter进行了如下重载。
Enter方法执行结束后,当且仅当该方法执行时抛出了异常且没有获得锁时,lockTaken为false。
bool lockTaken = false; try { Monitor.Enter(_locker, ref lockTaken); if (_val2 != 0) Console.WriteLine(_val1 / _val2); _val2 = 0; } finally { if(lockTaken) Monitor.Exit(_locker); }
TryEnter
Monitor还提供了TryEnter方法来指定一个超时时间(以毫秒为单位的整数或者一个TimeSpan值)。如果在指定时间内获得了锁,则该方法返回true,如果超时并且没有获得锁,该方法返回false。如果不给TryEnter方法提供任何参数,且当前无法获得锁,则该方法会立即超时。和Enter方法一样,TryEnter方法也进行了重载,并在重载中接受lockTaken参数。
选择同步对象
若一个对象在各个参与线程中都是可见的,那么该对象就可以作为同步对象。但是该对象必须是一个引用类型的对象(这是必须满足的条件)。同步对象通常是私有的(因为这样便于封装锁逻辑),而且一般是实例字段或者静态字段。
同步对象本身也可以是被保护的对象,如下面_list。
List<string> _list = new List<string>(); void Test() { lock(_list){_list.Add("aaa")} ... }
如果一个字段仅作为锁存在(如前一节中的_locker),则可以精确地控制锁的范围和粒度。
除此之外,Lambda表达式或匿名方法中捕获的局部变量也可以作为同步对象进行锁定。
使用锁的时机
使用锁的基本原则是:若需要访问可写的共享字段,则需要在其周围加锁。即便对于最简单的情况(例如对某个字段进行赋值),也必须考虑进行同步。
以下示例中的Increment和Assign方法,不是线程安全的和线程安全的写法:
// 不是线程安全 class TreadUnsafe { static int _x; static void Increment() { _x++; } static void Assign() { _x = 123; } } // 线程安全 class ThreadSafe { static readonly object _locker = new object(); static int _x; static void Increment() { lock(_locker) _x++; } static void Assign() { lock (_locker) _x = 123; } }
锁与原子性
如果使用同一个锁对一组变量的读写操作进行保护,那么可以认为这些变量的读写操作是原子的。
假设我们只在locker锁中对x和y字段进行读写:lock(locker) { if(x!=0) y/=x; }
则可以称x和y是以原子方式访问的。
因为上述代码块是无法分割执行的,也不可能被其他能够更改x和y的值的且破坏其输出结果的线程抢占。因此只要x和y永远在相同的排他锁中进行访问,那么上述代码就永远不会发生除数为零的错误。
嵌套锁
线程可以用嵌套(重入)的方式重复锁住同一个对象:
lock(locker) lock(locker) lock(locker) { ... }
在使用嵌套锁时,只有最外层的lock语句退出时(或者执行相同数目的Monitor.Exit时)对象的锁才会解除。
当锁中的方法调用另一个方法时,嵌套锁很奏效,线程只会阻塞在第一个(最外层的)锁上。
static readonly object _locker = new object(); static void Main() { lock (_locker) { AnotherMethod(); } } static void AnotherMethod() { lock (_locker) { Console.WriteLine("Another method"); } }
死锁
两个线程互相等待对方占用的资源就会使双方都无法继续执行,从而形成死锁。
演示死锁的最简单的方法是使用两个锁:
object locker1 = new object(); object locker2 = new object(); new Thread(() => { lock (locker1) { Thread.Sleep(1000); lock (locker2) ; } }).Start(); lock (locker2) { Thread.Sleep(1000); lock (locker1) ; }
死锁是多线程中最难解决的问题之一,尤其是当其涉及了很多相互关联的对象时。而其中最难的部分是确定调用者持有了哪些锁。
当锁定一个对象的方法调用时,务必警惕该对象是否可能持有当前对象的引用。此外,请确认是否真正有必要在调用其他类的方法时添加锁。
本文来自博客园,作者:一纸年华,转载请注明原文链接:https://www.cnblogs.com/nullcodeworld/p/16639609.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?