C#线程同步(二)锁

目录

1.lock

2.Monitor

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

3.1同步对象的选择

3.2什么时候该上锁

3.3锁和原子性

3.4嵌套锁

[3.5 死锁](#3.5 死锁)

[3.6 性能](#3.6 性能)

4.Mutex

5.Semaphore


1.lock

让我们先看一段代码:

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

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

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

cs 复制代码
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类实现如下:

cs 复制代码
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添加了以下重载方法:

cs 复制代码
public static void Enter (object obj, ref bool lockTaken);

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

cs 复制代码
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 ;如果因超时未能获取锁,则返回 false 。**TryEnter**也可以不带参数调用,此时它会立即"测试"锁的状态,如果无法立即获取锁,则立刻超时返回。

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

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

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

3.1同步对象的选择

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

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

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

cs 复制代码
lock (this) { ... }
cs 复制代码
lock (typeof (Widget)) { ... }    // For protecting access to statics

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

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

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

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

3.2什么时候该上锁

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

cs 复制代码
class ThreadUnsafe
{
  static int _x;
  static void Increment() { _x++; }
  static void Assign()    { _x = 123; }
}

其线程安全的标准应该为:

cs 复制代码
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 对象加锁的情况下进行读写:

cs 复制代码
lock (locker) { if (x != 0) y /= x; }

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

3.4嵌套锁

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

cs 复制代码
lock (locker)
  lock (locker)
    lock (locker)
    {
       // Do something...
    }

或者改用Monitor类:

cs 复制代码
Monitor.Enter (locker); Monitor.Enter (locker);  Monitor.Enter (locker); 
// Do something...
Monitor
.Exit (locker);  Monitor.Exit (locker);   Monitor.Exit (locker);

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

cs 复制代码
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 死锁

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

cs 复制代码
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 的一个常见用途是确保同一时间只能运行一个程序实例。具体实现如下:

cs 复制代码
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();
  }
}

当然也可以这样实现:

cs 复制代码
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只需要前者的四分之一时间。 )

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

cs 复制代码
class TheClub      // No door lists!
{
  static SemaphoreSlim _sem = new SemaphoreSlim (3);    // Capacity of 3
 
  static 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 threads
    Thread.Sleep (1000 * (int) id);               // can be here at
    Console.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)一样实现跨进程同步


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

相关推荐
新手小新11 分钟前
C++游戏开发(2)
开发语言·前端·c++
你的电影很有趣41 分钟前
lesson30:Python迭代三剑客:可迭代对象、迭代器与生成器深度解析
开发语言·python
程序员编程指南2 小时前
Qt 嵌入式界面优化技术
c语言·开发语言·c++·qt
二川bro3 小时前
第二篇:Three.js核心三要素:场景、相机、渲染器
开发语言·javascript·数码相机
云泽8083 小时前
数据结构前篇 - 深入解析数据结构之复杂度
c语言·开发语言·数据结构
卷卷的小趴菜学编程3 小时前
Qt-----初识
开发语言·c++·qt·sdk·qt介绍
天天进步20153 小时前
Python游戏开发引擎设计与实现
开发语言·python·pygame
Vic101014 小时前
Hutool 的完整 JSON 工具类示例
开发语言·json
蹦蹦跳跳真可爱5894 小时前
Python----MCP(MCP 简介、uv工具、创建MCP流程、MCP客户端接入Qwen、MCP客户端接入vLLM)
开发语言·人工智能·python·语言模型