你有没有考虑过为什么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 引入了新的并发工具,如 SemaphoreSlim
、ManualResetEventSlim
和 SpinLock
,从而使得线程同步更加高效。
在.NET 4.5(2012)这个辉煌的版本中,async/await
通过减少了显式锁的需求(至少在某些场景中)改变了并发处理。
这种模式启发了其他多种语言,如 JavaScript、Python、C++ 等,尽管它最初是由F# 异步工作流 (2007)
启发的._
从 .NET Core(2016+) 开始,重点转向了轻量级的锁机制,例如使用 SemaphoreSlim
实现的 AsyncLock
机制,用于处理异步任务。并发集合如 ConcurrentDictionary
和 ConcurrentQueue
更进一步消除了显式锁的必要性。
C# 8.0 引入了异步流处理和 System.Threading.Channels
以支持更高级的并发,以及 ValueTask
,这是一种在同步操作时比 Task 更高效的替代方案。
在简短的历史概述之后,深吸一口气,深入探讨锁定的细节。正如我在博客开头提到的,有一些“用于锁定的特殊功能”。让我们来看看这些特殊功能吧。
在 .NET 中 SyncBlock 和 ThinLock 的对比.NET中的内部锁定机制使用两个不同的概念:SyncBlock 和 ThinLock。这两个术语虽然没有在官方文档中详细说明,但它们指的是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 Pattern' 和 'Lock Pattern' 为英文,因其在中文中通常直接引用。
你有没有注意到这种情况实际上就是Dispose Pattern吗?
private static readonly Lock _lock = new Lock();
//C# 编译器为这两个代码片段生成相同的降级代码:
//1.
使用(_lock.EnterScope())
{
// 关键部分
}
//2.
锁定(_lock)
{
// 关键部分
}
C#团队采用了Dispose
模式,因为lock
和using
关键字之间存在着一些隐藏的相似性。当然,它们之间也存在差异,但这两个模式都控制了某个东西的生命周期(在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。