当前位置: 首页 > news >正文

C#线程同步(二)锁

目录

1.lock

2.Monitor

3.锁的其它要注意的问题

3.1同步对象的选择

3.2什么时候该上锁

3.3锁和原子性

3.4嵌套锁

3.5 死锁

3.6 性能

4.Mutex

5.Semaphore


1.lock

让我们先看一段代码:

class ThreadUnsafe
{static int _val1 = 1, _val2 = 1;static void Go(){if (_val2 != 0) Console.WriteLine (_val1 / _val2);_val2 = 0;}
}

        这段代码在单线程运行时是安全的,但是多线程运行就会出现问题,即有可能在做除法时,出现_val2=0 的情况,这是由于在执行打印时,另一个线程可能会去修改_val2的值。

我们可以通过加锁来解决这个问题:

class ThreadSafe
{static readonly object _locker = new object();static int _val1, _val2;static void Go(){lock (_locker){if (_val2 != 0) Console.WriteLine (_val1 / _val2);_val2 = 0;}}
}

关键字lock保证任何时候,只有一个线程可以访问变量_val1 _val2

2.Monitor

        关键字lock的机制是靠Monitor类实现的。lock可以认为是利用try-finally 结构对Monitor.Enter和Monitor.Exit 函数进行封装。比如上面使用lock关键字的代码用Monitor类实现如下:

Monitor.Enter (_locker);
try
{if (_val2 != 0) Console.WriteLine (_val1 / _val2);_val2 
= 0;
}
finally { Monitor.Exit (_locker); }

(ps:在没有调用Monitor.Enter函数时,调用Monitor.Exit 会抛出异常)

        然而这段代码存在一个微妙的漏洞。试想这样一种(不太可能的)情况:当Monitor.Enter方法内部抛出异常,或是在调用Monitor.Enter之后、进入try代码块之前发生异常(例如线程被强制中止Abort,或是抛出内存耗尽异常OutOfMemoryException)。此时锁可能被获取,也可能未被获取。如果锁已被获取,它将永远不会被释放——因为我们无法进入try/finally代码块,最终导致锁泄漏。 为避免这种风险,CLR 4.0的设计者为Monitor.Enter添加了以下重载方法:

public static void Enter (object obj, ref bool lockTaken);

当(且仅当)Enter方法抛出异常且未成功获取锁时,该方法执行后lockTaken参数值为false。以下是正确的使用模式(这也正是C# 4.0编译lock语句时生成的代码逻辑):   

bool lockTaken = false;
try
{Monitor
.Enter (_locker, ref lockTaken);// Do your stuff...
}
finally { if (lockTaken) Monitor.Exit (_locker); }

这样一来,即使在Enter函数出现异常,也不会去执行Monitor.Exit函数了。

Monitor 类还提供了 TryEnter 方法,允许指定超时时间(以毫秒或 TimeSpan 形式)。如果成功获取锁,该方法返回 true;如果因超时未能获取锁,则返回 falseTryEnter 也可以不带参数调用,此时它会立即“测试”锁的状态,如果无法立即获取锁,则立刻超时返回。

与 Enter 方法类似,在 CLR 4.0 中,TryEnter 也提供了接受 lockTaken 参数的重载版本。

这里就不过多介绍了,感兴趣的可以去看官方链接

3.锁的其它要注意的问题

3.1同步对象的选择

        任何对参与线程可见的对象都可以作为同步对象,但必须遵循一个硬性规则:同步对象必须是引用类型。同步对象通常声明为 private(这有助于封装锁逻辑),并且通常是实例字段或静态字段。同步对象可以同时作为被保护对象本身,如下例中的 _list 字段所示:

class ThreadSafe
{List 
<string> _list = new List <string>();void Test(){lock (_list){_list
.Add ("Item 1");...

专门用于加锁的字段(如前例中的 _locker)能够精确控制锁的作用范围和粒度。此外,包含对象本身(this)或其类型(typeof(ClassName))也可作为同步对象使用:

lock (this) { ... }
lock (typeof (Widget)) { ... }    // For protecting access to statics

虽然上面两个锁对象都是合理的,却是不建议的:

使用this作为锁对象会造成:

  • 外部代码可能锁定你的对象实例,导致死锁。
  • 破坏了面向对象的封装原则。

使用类类型作为锁对象则更糟糕:typeof(ClassName) 返回的是类的 Type 对象,该对象在 AppDomain 范围内是唯一的。所有线程中任何使用 lock(typeof(ClassName)) 的代码都会竞争同一个锁,导致: ◦  性能瓶颈:无关代码因共享同一个锁而阻塞。  ◦  死锁风险:第三方库或框架若恰好也锁定了该类型,可能引发不可预料的死锁

3.2什么时候该上锁

        首先,如果你确定你的程序是单线程的,那任何时候都不需要上锁。否则,上锁基本原则是:任何对可写共享字段的访问都需要加锁即使是最简单的单字段赋值操作,也必须考虑同步问题。例如以下类中,无论是Increment还是Assign方法都不是线程安全的:

class ThreadUnsafe
{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; }
}

3.3锁和原子性

        如果一组变量总是在同一个锁内进行读写,那么可以认为这些变量的读写操作是原子性的。假设字段 x 和 y 始终在对 locker 对象加锁的情况下进行读写:

lock (locker) { if (x != 0) y /= x; }

那么我们可以说 x 和 y 的访问是原子性的,因为这段代码块不会被其他线程的操作分割或抢占,从而避免 x 或 y 被意外修改而导致结果失效。只要 x 和 y 始终在同一个独占锁内访问,就永远不会发生除零错误。

3.4嵌套锁

        一个线程可以反复的对一个对象添加锁:

lock (locker)lock (locker)lock (locker){// Do something...}

或者改用Monitor类:

Monitor.Enter (locker); Monitor.Enter (locker);  Monitor.Enter (locker); 
// Do something...
Monitor
.Exit (locker);  Monitor.Exit (locker);   Monitor.Exit (locker);

在这种情况下,只有当最外层的 lock 语句执行完毕退出时 - 或者执行了对应数量的 Monitor.Exit 语句后 - 对象才会被解锁。 嵌套锁在方法内部调用另一个加锁方法时特别有用:

static readonly object _locker = new object();static void Main()
{lock (_locker){AnotherMethod();// We still have the lock - because locks are reentrant.}
}static void AnotherMethod()
{lock (_locker) { Console.WriteLine ("Another method"); }
}

3.5 死锁

        死锁在多线程编程是比较常见的。下面这个代码就会触发死锁:

object locker1 = new object();
object locker2 = new object();new Thread (() => {lock (locker1){Thread.Sleep (1000);lock (locker2);      // Deadlock}}).Start();
lock (locker2)
{Thread.Sleep (1000);lock (locker1);                          // Deadlock
}

        在多线程编程中,死锁是最棘手的难题之一——尤其是当存在大量相互关联的对象时。究其根本,难点在于你永远无法确定调用方已经获取了哪些锁。 设想这样一个场景:你可能在类X中无意识地锁定了私有字段a,却不知道调用方(或调用方的调用方)已经在类Y中锁定了字段b。与此同时,另一个线程正以相反的顺序执行锁定——这就形成了死锁。

        颇具讽刺意味的是,这种问题反而会因(良好的)面向对象设计模式而加剧,因为这些模式创建的调用链直到运行时才能确定。 虽然"按固定顺序锁定对象以避免死锁"的建议在我们最初的示例中很有帮助,但很难适用于上述场景。

        更明智的策略是:当持有锁的情况下调用可能反向引用自身对象的方法时要格外谨慎。同时,需要审慎评估是否真的有必要在调用其他类的方法时保持锁定(虽然很多时候确实需要——我们稍后会讨论——但有时存在其他选择)。更多地依赖声明式编程、数据并行、不可变类型以及非阻塞同步结构,可以减少对锁定的依赖。 

        这个问题还可以换个角度理解:当持有锁时调用外部代码,锁的封装性就会在无形中被破坏。这不是CLR或.NET框架的缺陷,而是锁机制与生俱来的局限性。目前包括软件事务内存(Software Transactional Memory)在内的多个研究项目正在尝试解决锁机制带来的各种问题。

         另一个典型的死锁场景发生在WPF应用程序调用Dispatcher.Invoke或Windows Forms应用程序调用Control.Invoke时——如果此时恰好持有锁,而UI线程正在执行另一个等待同一锁的方法,就会立即引发死锁。通常只需改用BeginInvoke而非Invoke即可解决。当然,也可以在调用Invoke前释放锁,不过如果锁是由调用方获取的,这个方法就不适用了。我们将在"富客户端应用与线程关联性"章节详细解释Invoke和BeginInvoke的机制。

3.6 性能

        加锁操作本身非常高效:在2010年代的计算机上,如果锁未被争用,获取和释放一个锁最快仅需20纳秒。但当锁出现争用时,随之而来的上下文切换会使开销激增至微秒级别——如果线程需要重新调度,等待时间可能更长。对于极短时间的锁定,使用SpinLock类可以避免上下文切换的开销。 需要注意的是,如果锁持有时间过长,不仅会降低并发性能,还会显著增加死锁风险。锁的争用会引发线程阻塞,当多个线程相互等待对方释放锁时,系统吞吐量将急剧下降。因此,开发者需要在保证线程安全的前提下,尽量缩小临界区范围,并考虑使用读写锁(ReaderWriterLockSlim)等更细粒度的同步机制来提升并发性。对于高并发场景,无锁编程(lock-free programming)或不可变数据结构往往是更好的选择。

4.Mutex

        互斥锁(Mutex)类似于 C# 的 lock 语句,但它的作用范围可以跨越多个进程。也就是说,Mutex 既可以是应用程序级别的,也可以是计算机全局范围的。( 获取和释放一个无竞争的 Mutex 需要几微秒时间——这比 lock 语句慢了约 50 倍。)

        使用 Mutex 类时,你需要调用 WaitOne 方法来加锁,调用 ReleaseMutex 方法来解锁。关闭或释放 Mutex 会自动解除锁定。与 lock 语句一样,Mutex 只能由获取它的同一个线程来释放。 跨进程 Mutex 的一个常见用途是确保同一时间只能运行一个程序实例。具体实现如下:

class OneAtATimePlease
{static void Main(){// Naming a Mutex makes it available computer-wide. Use a name that's// unique to your company and application (e.g., include your URL).using (var mutex = new Mutex (false, "oreilly.com OneAtATimeDemo")){// Wait a few seconds if contended, in case another instance// of the program is still in the process of shutting down.if (!mutex.WaitOne (TimeSpan.FromSeconds (3), false)){Console.WriteLine ("Another app instance is running. Bye!");return;}RunProgram();}}static void RunProgram(){Console.WriteLine ("Running. Press Enter to exit");Console.ReadLine();}
}

当然也可以这样实现:

static void Main()
{using var mutex = new Mutex(true, "Global\\MyApp", out bool createdNew);if (!createdNew){Console.WriteLine("程序已在运行中!");return;}// 主程序逻辑Console.WriteLine("程序启动...");Console.ReadLine();
}

一般而言,mutex在多线程编程中使用的不多,lock是更常见的选择。但涉及到跨进程时,lock可能就无能为力了,这是可以考虑mutex.

5.Semaphore

        信号量(Semaphore)就像一家夜总会:它有一定的容量限制,由门口的保安严格执行。一旦满员,其他人就无法进入,只能在门外排队等候。每当有一个人离开,队首的一个人就能进入。它的构造函数至少需要两个参数:当前夜总会内的空位数,以及夜总会的总容量

容量为1的信号量与互斥锁(Mutex)或lock类似,但关键区别在于信号量没有"所有者"——它对线程是透明的。任何线程都可以调用信号量的Release方法,而Mutex和lock只能由获取锁的线程来释放

        (这个类有两个功能相似的版本:Semaphore和SemaphoreSlim。后者是在.NET Framework 4.0中引入的,针对并行编程的低延迟需求进行了优化。它在传统多线程编程中也很有用,因为它允许在等待时指定取消令牌。不过,它不能用于进程间通信。 Semaphore执行WaitOne或Release大约需要1微秒;而SemaphoreSlim只需要前者的四分之一时间。 )

信号量在限制并发度方面非常有用——可以防止过多线程同时执行某段代码。在下面的例子中,五个线程试图进入一家同时只允许三个线程进入的"夜总会":

        

class TheClub      // No door lists!
{static SemaphoreSlim _sem = new SemaphoreSlim (3);    // Capacity of 3static void Main(){for (int i = 1; i <= 5; i++) new Thread (Enter).Start (i);}static void Enter (object id){Console.WriteLine (id + " wants to enter");_sem.Wait();Console.WriteLine (id + " is in!");           // Only three threadsThread.Sleep (1000 * (int) id);               // can be here atConsole.WriteLine (id + " is leaving");       // a time._sem.Release();}
}

执行结果如下:

1 wants to enter
1 is in!
2 wants to enter
2 is in!
3 wants to enter
3 is in!
4 wants to enter
5 wants to enter
1 is leaving
4 is in!
2 is leaving
5 is in!

如果将 Sleep 语句替换为密集的磁盘 I/O 操作,信号量(Semaphore)通过限制过多的并发硬盘访问,反而能够提升整体性能。 如果给信号量命名,它就能像互斥锁(Mutex)一样实现跨进程同步


本小节就介绍到这里,下面一节将介绍线程安全的一些实现准则。

http://www.xdnf.cn/news/1225531.html

相关文章:

  • 国产开源大模型崛起:使用Kimi K2/Qwen2/GLM-4.5搭建编程助手
  • Go语言中的盲点:竞态检测和互斥锁的错觉
  • ctfshow_web签到题
  • 从内部保护你的网络
  • 江协科技STM32 12-2 BKP备份寄存器RTC实时时钟
  • TwinCAT3编程入门2
  • 从 0 到 1 认识 Spring MVC:核心思想与基本用法(下)
  • 自动化框架pytest
  • 【Kubernetes 指南】基础入门——Kubernetes 集群(二)
  • 雷达微多普勒特征代表运动中“事物”的运动部件。
  • Ubuntu 开启wifi 5G 热点
  • p5.js 3D模型(model)入门指南
  • ubuntu 镜像克隆
  • hadoop.yarn 带时间的LRU 延迟删除
  • Ubuntu-Server-24.04-LTS版本操作系统如何关闭自动更新,并移除不必要的内核
  • C#常见的转义字符
  • Vue3 setup、ref和reactive函数
  • Vue 详情模块 1
  • C++对象访问有访问权限是不是在ide里有效
  • 解决MySQL不能编译存储过程的问题
  • 《Java 程序设计》核心知识点梳理与深入探究
  • SpringMVC全局异常处理+拦截器使用+参数校验
  • 2025 腾讯广告算法大赛 Baseline 项目解析
  • 为什么MCP协议是AI集成的未来API
  • 向华为学习——IPD流程体系之IPD术语
  • 京东云轻量云服务器与腾讯云域名结合配置网站及申请SSL证书流程详解
  • 使用 whisper, 音频分割, 初步尝试,切割为小块,效果还不错 1
  • 服务器地域选择指南:深度分析北京/上海/广州节点对网站速度的影响
  • 宝塔服务器挂载数据盘
  • OPENGLPG第九版学习 - 纹理与帧缓存 part2