为了账号安全,请及时绑定邮箱和手机立即绑定

C#中锁机制升级:.NET 9引入新锁类型`System.Threading.Lock`

标签:
C# .NET

你有没有考虑过为什么object类型常被用作锁的默认类型,你知道为什么吗?这里说的是那个臭名昭著的代码。

    private static readonly object _lock = new object();  
    ...  
    lock (_lock)  
    {  
      // 关键代码段  
    }

答案很简单:任何引用类型的实例在内存中都是独一无二的,并且具有可以用作锁的特殊特性,那么为什么不使用 .NET 中最简单的类型——object 呢?
此外,还有额外的理由支持这一决定。微软希望保持语言和线程模型的简单性,不让开发人员需要学习额外的专用类型。对象可以代表任何事物,因此开发人员可以利用现有的对象实例,而不需要创建专门的锁实例。

照片由 Pawel Czerwinski 拍摄,来自 Unsplash

但似乎用对象作为默认的Lock类型的时期已经过去。在.NET 9中,我们有了一个新的专门设计用于锁定的类型——System.Threading.Lock。虽然它并不是一个开创性的特性,但它仍然是一项不错的改进。

我们快速来看看锁是如何演变的。如果你对这段历史不感兴趣,可以直接跳过这一节。

关于 .NET 锁定:

lock 语句于 .NET 1.0(2002 年)引入:它是 Monitor 类的语法糖,相当于 Monitor 类的语法糖,以管理线程对临界段的访问。

在 .NET 2.0(2005 年) 中,加入了 ReaderWriterLock 以应对允许多个线程同时读取但仅允许一个线程写入的情况,但该类面临性能问题和死锁的挑战。

.NET 3.5(2007), 通过 ReaderWriterLockSlim 引入了更好的性能和伸缩性。

.NET 4.0 引入了新的并发工具,如 SemaphoreSlimManualResetEventSlimSpinLock,从而使得线程同步更加高效。

在.NET 4.5(2012)这个辉煌的版本中async/await 通过减少了显式锁的需求(至少在某些场景中)改变了并发处理。
这种模式启发了其他多种语言,如 JavaScript、Python、C++ 等,尽管它最初是由F# 异步工作流 (2007)
启发的._

从 .NET Core(2016+) 开始,重点转向了轻量级的锁机制,例如使用 SemaphoreSlim 实现的 AsyncLock 机制,用于处理异步任务。并发集合如 ConcurrentDictionaryConcurrentQueue 更进一步消除了显式锁的必要性。

C# 8.0 引入了异步流处理和 System.Threading.Channels 以支持更高级的并发,以及 ValueTask,这是一种在同步操作时比 Task 更高效的替代方案。

锁到底是怎么工作的?

在简短的历史概述之后,深吸一口气,深入探讨锁定的细节。正如我在博客开头提到的,有一些“用于锁定的特殊功能”。让我们来看看这些特殊功能吧。

在 .NET 中 SyncBlock 和 ThinLock 的对比

.NET中的内部锁定机制使用两个不同的概念:SyncBlockThinLock。这两个术语虽然没有在官方文档中详细说明,但它们指的是CLR用来管理C#中对象级别同步的内部机制。

轻锁

让我们从简单的那个开始。ThinLock 用于锁仅以简单、无竞争的方式使用的场景。无竞争的方式意味着只有一个线程可以进入并离开临界区。

当你有以下这种方法:

    private static readonly object _lock = new object();  

    static void DoSomething(string threadName)  
    {  
      Console.WriteLine($"{threadName} 正试图进入临界区。");  

     // 锁定临界区,防止其他线程进入  
      lock (_lock)  
      {  
        Console.WriteLine($"{threadName} 已进入临界区。");  
        Thread.Sleep(2000); // 模拟临界区内执行的任务  
        Console.WriteLine($"{threadName} 正要离开临界区。");  
      }  

      Console.WriteLine($"{threadName} 已退出临界区。");  
    }

接下来是无竞争锁定(ThinLock)的一个例子:

    // 创建两个线程,它们将尝试进入临界区(Critical Section)。
    var thread1 = new Thread(ThreadMethod);
    var thread2 = new Thread(ThreadMethod);

    // 启动第一个线程
    thread1.Start("Thread 1");
    // 等待第一个线程完成后启动新线程。
    thread1.Join();

    // 启动第二个线程。
    thread2.Start("Thread 2");
    thread2.Join();

线程依次进入临界区。但当两个线程尝试同时访问它时会怎样?

    // 创建两个线程,它们将尝试进入临界区部分  
    var thread1 = new Thread(ThreadMethod);  // (一个方法名称)  
    var thread2 = new Thread(ThreadMethod);  

    // 启动两个线程 - 它们竞争获得锁  
    thread1.Start("Thread 1");  
    thread2.Start("Thread 2");  

    // 等待两个线程完成,  
    thread1.Join();  
    thread2.Join();

这是有争议的经典锁的一个例子。在这种情况下,使用了SyncBlock(同步块),而不是更简单的ThinLock(薄锁)。

既然我们现在明白了ThinLock和SyncBlock的作用,让我们来看看它们是怎么工作的。

简易锁

轻锁(ThinLock)是一款轻便易用的产品。

在CLR分配SyncBlock之前,它尝试使用一种更轻量的机制直接在对象头中管理锁。ThinLock依赖于.NET中的对象头,其中包括一个锁字段,也称为同步块索引。

当对象被锁定时,CLR 首先会将锁信息(如拥有者的线程标识和锁定状态)存储在对象头部内部。

如果没有发生争用,轻量锁(ThinLock)就足够了,不需要同步块锁(SyncBlock)。如果发生争用,CLR会将轻量锁升级为同步块锁。

同步块(胖锁,Fat Lock):

SyncBlock(同步块的简称)是 .NET CLR 内部用于存储对象同步信息的数据结构。它是 CLR 维护的 SyncBlock 表 中的一个条目。

当一个 ThinLock 升级为 SyncBlock 之后,会分配并存储一个外部数据结构。每个 SyncBlock 包含有关锁的详细信息、持有线程、递归计数以及等待线程。

CLR会自动使用ThinLocks来提高性能并减少内存消耗。开发人员无法直接控制这些锁和同步块何时使用,这些锁和同步块包括ThinLocks或SyncBlocks。

我们需要一种新的锁吗?

如你所见,当前的ThinLock和SyncBlock已经经过充分测试,并且运行非常可靠。那么,为什么要引入新的Lock呢?

用东西上锁这种做法一直挺奇怪的。

在我早期的职业生涯中,我发现使用 object 类型进行锁定让我感到困惑。在有这么多专门针对不同场景的类型——比如流处理、线程和全球化——的情况下,没有一个专门用于锁定的类型,这让我觉得奇怪。新的 Lock 类型通过提供一种清晰且语义明确的方式来管理锁,解决了这个困扰。我们来看看如何使用这个 Lock 类型。

    // 旧方法
    private static readonly object _lock = new object();
    lock (_lock)
    {
        // 临界区
    }

    // 新方法
    private static readonly Lock _lock = new Lock();
    lock (_lock)
    {
        // 临界区
    }

就这样。唯一的不同就是用 Lock 类型,而不是 object

正如我之前提到的,当您使用 lock 关键字锁定 object 时,C# 编译器会将代码转换为使用 Monitor 类的机制。

    private static readonly object _lock = new();  
    lock(_lock)  
    {  
      // 临界区(关键部分)
    }

被重新写成(简化:链接)为:

    object @lock = _lock;  
    bool lockTaken = false; // 是否获取到锁
    try  
    {  
      Monitor.Enter(@lock, ref lockTaken);  
    }  
    finally  
    {  
      if (lockTaken)  
      {  
        Monitor.Exit(@lock);  
      }  
    }

新的 Lock 类型的行为有些不一样。下面是如何使用 锁语句 的例子:

    private static readonly Lock _lock = new();  
    lock (_lock)  
    {  
      // 关键代码
    }

变为如下:

    var scope = _lock.EnterScope();  
    try  
    {  
      // 关键区域代码  
    }  
    finally  
    {  
      scope.Dispose();  
    }

不是使用 Monitor 类,Lock 类型使用了一个叫作 Scope 的新类型。该类型的实例负责在其存在的期间持有锁。

  • _lock.EnterScope():此方法获取锁后,表明临界区已得到保护。
  • scope.Dispose():临界区执行完毕后,在 finally 块中调用 Dispose 方法。这确保无论临界区中是否出现异常,锁都会被释放。释放锁后,其他线程可以获取并继续执行。
Dispose 模式和 Lock 模式?

注:此处保留技术术语 'Dispose Pattern' 和 'Lock Pattern' 为英文,因其在中文中通常直接引用。

你有没有注意到这种情况实际上就是Dispose Pattern吗?

    private static readonly Lock _lock = new Lock();  

    //C# 编译器为这两个代码片段生成相同的降级代码:  

    //1.  
    使用(_lock.EnterScope())  
    {  
      // 关键部分  
    }  

    //2.  
    锁定(_lock)  
    {  
      // 关键部分  
    }  

C#团队采用了Dispose模式,因为lockusing关键字之间存在着一些隐藏的相似性。当然,它们之间也存在差异,但这两个模式都控制了某个东西的生命周期(在using中是一个资源,在lock中是一个临界段),并且都确保了清理或释放,无论代码块是正常结束还是抛出异常。

我们应该用什么?是锁还是使用?

从语义上来说,使用锁更好。暴露范围让人感觉像渗漏抽象。使用 lock 关键字的另一个好理由是模式匹配的好处。未来有一个提案提议支持多种类型的锁定。

    private static readonly SpinLock _spinLock = new();  
    在_spinLock 的锁定下 // 这个目前不起作用,但未来可能会有用。  
    {  

    }

然而,如果你需要更精细地控制何时释放锁,结合使用“使用和作用域”的组合可能会更有帮助。

    var scope = _lock.EnterScope();
    try
    {
        // 做一些工作

        // 提前释放锁
        scope.Dispose();

        // 继续做一些工作
    }
    finally
    {
        scope.Dispose();
    }
模式匹配

既然我们在讨论模式匹配,当然也有一些特殊情况需要考虑。正如我们所见,C# 编译器会根据对象的类型把 lock 关键字转换成两种不同的形式。请看下面的代码片段,试着猜猜看编译器会选择生成传统的监视器锁还是新的范围锁。

// 编译时类型是 object,运行时类型是 Mutex  
object l = new System.Threading.Mutex();   
using (var l = new System.Threading.Mutex(false, "")) {  
}

在这种情形下,编译器使用旧的方法——Monitor.Enter。模式匹配功能依赖于编译时类型而不是运行时值。我理解这可能会让人感到有些困惑,但 C# 编译器会在这类情况下发出警告:

    当类型为'System.Threading.Lock'的值被转换为其他类型时,  
    在'lock'语句里可能会无意地使用基于监视器的锁。

我也知道我之前写的代码:

object l = new System.Threading.Lock(); // 创建一个新的System.Threading.Lock对象

可能看起来有点刻意。然而,这种警告在以下情况中特别有用,特别是当你用 object 替代 Lock 类型不够明确时。

    private static readonly Lock _lock = new(); // 新的 Lock 类型,新的实例()
    private static void Run()
    {
      // lockParam 会悄无声息地转换为 object 类型。
      ThreadPool.QueueUserWorkItem(lockParam =>
      {
        lock (lockParam) // 使用了旧的方式(Monitor)。
        {

        }
      }, _lock); // 编译器会在这一行显示警告。
    }
新锁类型的性能表现。

新的 lock 类型旨在提高性能。正如你所见,最简单的解决方案是尽可能避免升级到 SyncBlock。但如果仅仅支持 Thinlock 就不太有用。这意味着 System.Threading.Lock 可以升级为 SyncBlock,但它的实现(至少根据微软的说法)比传统的 Monitor 更轻量。.NET 运行时尽量避免与 SyncBlock 相关的成本,仅在竞争程度足够高时才会升级。

还没有异步支持

不幸的是,由于同步和异步锁定机制存在根本差异,异步锁定支持不足。C# 编译器将 lock 关键字转换为 Dispose 模式的能力令人鼓舞。也许将来我们可以写出类似下面这样的代码:

    // 在标准的 .NET 中,没有 LockAsync 这样的类型
    private static readonly LockAsync _lock = new();   
    await using(_lock.EnterScope()) // 目前无法使用
    {  
    }  

    // 或者
    private static readonly LockAsync _lock = new();  
    await lock(_lock) // 目前无法使用
    {  
    }

在这之前,我们可以使用支持异步锁定的库,比如Stephen Cleary的AsyncEx。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消