统一并发 I - 简介
基于通用线程和 Async/Await 两种接口之间的共享模式,为 .NET 和 .NET Core 实现的跨平台面向对象同步原语方法。
系列文章
- 统一并发 I - 简介
- 统一并发 II - 基准测试方法学
- 统一并发 III - 跨基准测试
- 统一并发 IV - 实现跨平台 (.NET Core 2.1 / .NET Standard 2.0)
引言
统一并发框架旨在提供易于使用的同步原语,这些原语在设计时考虑了 OOP 原则,并采用了面向接口和模式的方法,适用于通用线程和 async/await 线程场景。实现的同步原语包括通用线程锁定、自旋锁定、票据自旋锁定、异步锁定、异步自旋锁定和异步票据自旋锁定。统一并发框架的开发方式允许通过有限的代码更改(通常是一行)来灵活地更改同步原语。由于共享模式,还可以轻松地在通用线程和 async/await 风格之间切换。实现的同步原语通常比 C# 的 lock
具有更好的性能,提供 .NET 中未实现的たり、或以最小的开销包装现有的同步原语。
统一并发框架旨在提供一个敏捷的工具,能够通过最少的代码更改轻松地更改同步原语。毕竟,今天的代码是明天的石化的业务遗留代码,没有人敢触摸。必须慎重选择,并为将来的更改敞开大门。
这篇介绍性文章是第一篇。我打算在这里列出实现的同步原语,并说明它们的属性、性能评估和代码示例,而不涉及性能测量/分析的复杂性。下一篇文章将深入探讨复杂问题、测量场景、方法、详细分析和已实现的同步原语的并发单元测试。
统一并发框架实现在 GitHub 上的开源 GreenSuperGreen 库,并可在 Nuget 上找到(3 个包,lib、单元测试、基准测试)。示例将以 Visual Studio 解决方案的形式提供,其中一个项目将针对实际用例中必需的 Nuget 包。
- 下载示例 .NET 4.6: UnifiedConcurrency.zip
- 下载跨平台示例: unified_concurrency.zip
- http://github.com/ipavlu/GreenSuperGreen
NET 4.6
NetStandard 2.0
- https://nuget.net.cn/packages/GreenSuperGreen.NetStandard/
- https://nuget.net.cn/packages/GreenSuperGreen.NetStandard.Test/
- https://nuget.net.cn/packages/GreenSuperGreen.Benchmarking.NetStandard/
NetCore 2.1
Net 4.7.2
接口和模式驱动
所有通用同步原语都必须实现 ILockUC
接口
public interface ILockUC
{
SyncPrimitiveCapabilityUC Capability { get; }
EntryBlockUC Enter();
EntryBlockUC TryEnter();
EntryBlockUC TryEnter(int milliseconds);
}
所有 async/await 风格的同步原语都必须实现 IAsyncLockUC
接口
public interface IAsyncLockUC
{
SyncPrimitiveCapabilityUC Capability { get; }
AsyncEntryBlockUC Enter();
AsyncEntryBlockUC TryEnter();
AsyncEntryBlockUC TryEnter(int milliseconds);
}
EntryBlockUC
是一个 struct
,实现了 IDisposable
接口,可用于 C# 语言的 using
语句。实现的 Dispose()
方法只能使用一次,并且应允许 using
结构在执行完块后调用该方法,这与 using 模式的常见规则一致。
using
语句期望一个实现了 Dispose()
方法的对象,并在 using
语句块的末尾调用它。
即使 EntryBlockUC
是一个 struct
,C# 中的 using
语句也不会装箱 EntryBlockUC
来访问 Dispose()
方法。
但是,如果我们将其存储到类型为 object 或 IDisposable
的变量中,则会发生装箱。这就是为什么所有示例要么不指定返回类型(如果不需要),要么指定返回类型为 EntryBlockUC
。
AsyncEntryBlockUC
是一个 async
/await
风格的 awaiter,它必须被 await,并提供 EntryBlockUC
类型的结果。
EntryBlockUC
通过 HasEntry
属性告知我们是否成功获取了入口。
这仅在 TryEnter
和 TryEnter(timeout)
方法中才有用。对于 Enter()
方法,它总是成功的。
合理的限制
统一并发框架为了统一多种同步原语,必须在某些能力上有所限制。这些限制实际上非常合理。
- 统一并发下的同步原语不支持递归访问自身。对于 async/await 场景,没有语言支持可以高效地实现这一点,而且并非所有同步原语都能提供递归访问。
- 统一并发下的同步原语不是线程亲和的,因此可以从不相关的线程进入和退出。
这些限制实际上使得可以在统一并发同步原语创建的任何锁定块内进行 await。
访问公平性
每个同步原语的定义性特征之一是能够保证访问公平性。有些同步原语根本不保证这一点。例如,通用 SpinLocks 不保证,因为它们使用原子指令,硬件决定谁先获得访问权,而决定受缓存状态的影响。不公平访问会导致线程饥饿,很容易证明,在高度争用时,SpinLocks 可能会使某些线程饥饿数分钟,如果进入受保护块的时间超过某个临界时间(取决于线程数、核心数和硬件)。唯一值得注意的是,随着新硬件架构和更多核心的出现,这个临界时间越来越短,因此这个问题将逐年变得更加重要。
另一方面,一些同步原语可以保证访问公平性,这些将在下面指出。
有趣的是,实时操作系统倾向于保证访问公平性,因为它带来了非常重要的质量,即确保负载平衡。
公平性,或者更确切地说,缺乏公平性并非只有一种情况,它还有多种发生方式,而且公平/不公平的含义也可以更广泛。
访问公平性:KernelRoughlyFIFO
Joe Duffy 的《Concurrent Programming on Windows》一书中很好地描述了一种不公平性:“由于监视器内部使用内核对象,它们表现出与操作系统同步机制相同的近似 FIFO 行为(在前一章中描述)。监视器是不公平的,因此,如果另一个线程在唤醒的等待线程尝试获取锁之前尝试获取锁,那么“偷偷摸摸”的线程将被允许获得锁。被唤醒但未能获得其被唤醒资源的线程将不得不再次等待并稍后重试。”
访问公平性:AtomicInstructionsUnfairness
另一种不公平性发生在使用原子指令的同步机制中,例如在特定场景下的 SpinLocks。当一个线程进入 SpinLock 并持有访问权一段时间,而其他线程开始争用 SpinLock 时,持有访问权的线程离开 SpinLock,但立即再次尝试进入 SpinLock。上次拥有 SpinLock 访问权的线程比其他人具有某种优势,它已经将所需的一切都放在了缓存中,因此它不必浪费时间,并且可以轻松进入。如果有许多线程争用 SpinLock,那么我们可以观察到一些线程被长时间饿死,而线程则以不公平的方式以无序的方式获得访问权。
观察或证明这种不公平性的发生并不容易。
访问公平性:EnhancedAtomicInstructionsUnfairness
前一种不公平性(AtomicInstructionsUnfairness
)通常会得到增强,如果 SpinLock 被增强以通过燃烧指令周期来浪费更少的 CPU 资源。增强形式包括自旋等待、线程让步、线程休眠以及上述技术的组合。虽然可以节省一些 CPU 资源,尤其是在访问时间较长的情况下(例如数百微秒甚至毫秒),但它也会导致不公平性,因为现在一个线程可以非常容易地退出 SpinLock 并重新进入,并以无序的方式获得访问权。这一切都取决于纯粹的偶然和错误的时机,线程可能会等待甚至数秒钟。创建这种饥饿很容易,并且将在第三篇文章中特别提及。
已实现的同步原语
MonitorLockUC
- 实现为 C# lock,Monitor
的一个薄封装,仅在框架内部实现,用于比较和基准测试。C#lock
在多核 CPU 上存在显著的性能和副作用问题,它不保证访问公平性。LockUC
- C# lock,Monitor.Enter()
的替代品,其性能通常优于 C#lock
,它保证访问公平性。SpinLockUC
- .NETSpinLock
struct
的薄封装,它不保证访问公平性。TicketSpinLockUC
- 一个简单的票据自旋锁,它保证访问公平性,但不实现TryEnter
方法。SemaphoreLockUC
- 基于 Semaphore WaitOne/Release 的锁,取决于操作系统,在 Windows 上大致为 FIFO,**公平性不保证**。SemaphoreSlimLockUC
- 基于 SemaphoreSlim Wait/Release 的锁,采用了一种混合方法,结合了原子指令,这不利于 FIFO 风格,**不公平访问可能导致线程停滞**!MutexLockUC
- 此同步原语不可访问,仅限于预定义的基准测试项目,因为它要求在进入和退出调用时进行线程亲和,这在统一并发中是设计上不支持的,但对于基准测试来说,它是可维护且有趣的数据收集工具。AsyncLockUC
- **async/await 风格的同步原语,是统一并发框架中实现的最具性能的同步原语,它保证访问公平性。**AsyncSpinLockUC
- async/await 风格的自旋锁,它不保证访问公平性。AsyncTicketSpinLockUC
- async/await 风格的票据自旋锁,它保证访问公平性。AsyncSemaphoreSlimLockUC
- 基于 SemaphoreSlimWaitAsync
/Release 的锁,**似乎符合 FIFO 风格,公平访问**。性能与AsyncLockUC
相似。
MonitorLockUC
MonitorLockUC
是统一并发框架内部实现的用于基准测试和测试目的的薄封装。它基于 .NET 的 Monitor
,并由 C# 的 lock
使用。这两个引用将互换使用。它不被访问的原因首先是因为存在其他性能更好的同步原语,其次是 C# 的 lock
在许多意想不到的性能问题和副作用,这些问题在高度争用时才会出现。在复杂的代码中,很难人为地创建相同的效果,但它们会在生产环境中发生。这使得 C# 的 lock
在测试和分析问题原因时变得有问题。
在历史上,C# 的 lock
是一个干净的线程挂起/恢复同步原语。它的行为更加可预测,副作用更少。随着多核 CPU 的出现,这种情况很快就改变了。Monitor
现在是一个混合同步原语,线程在调用操作系统之前实际上会在应用程序级别旋转一段时间,这使得获得锁的速度更快。不幸的是,这种模式存在基于核心数量的可伸缩性问题。曾经是 4 个核心,现在是 24 或 40 个核心。如果访问锁的线程数量如此之多,我们就会开始看到限制。核心旋转的时间越来越长,CPU 资源的浪费增长速度快于实际完成的工作,同步原语的吞吐量周期非常短,但 CPU 资源却可能被完全浪费。这种行为可以解释为 CPU 交通堵塞。
需要提及的另一个问题是,Monitor
/ C# lock
无法保证访问公平性。这可能是由于其混合特性造成的,但线程挂起/恢复的 FIFO 行为在某些情况下也可能在操作系统级别被覆盖。实际上晚到的线程可能会更早地获得访问权。
通过 C# lock 进行的高度争用分析其性能问题和副作用,充分说明了软件的设计。显然,这是将过多的并行性应用于同步问题,而这并不健康。新的设计应该考虑到这些问题,并在所有地方避免它们。
//it is internal class, not accessible in general
ILockUC Lock { get; } = new MonitorLockUC();
using (Lock.Enter())
{
//locked area
}
//immediate access or no entry at all
using (EntryBlockUC entry = Lock.TryEnter())
{
if (entry.HasEntry)
{
//locked area
}
}
//immediate access or access within timeout milliseconds or no entry at all
using (EntryBlockUC entry = Lock.TryEnter(msTimeout: 25))
{
if (entry.HasEntry)
{
//locked area
}
}
LockUC
LockUC
同步原语似乎比 Monitor
性能更好,CPU 浪费增长速度要慢得多。在高度争用的极端情况下,并且在锁定块中的时间非常短时,它会输给 Monitor
,但这些情况远远超出了 LockUC
和 Monitor
/ C# lock
的任何合理可用性,它们进入了自旋锁定或票据自旋锁定效果最好的领域。LockUC 不会试图通过启发式方法来避免线程挂起,而是完全相反,如果访问已被其他线程占用,它会尽可能快地挂起。
基于线程的挂起/恢复适用于我们预期需要数百微秒、毫秒或更长时间才能获得锁定区域访问权的情况。它也适用于我们不期望高度争用或我们不特别担心性能的情况。但是,我们应该时刻警惕,防止我们代码库中性能较低的部分不必要地消耗 CPU 资源。对于这些情况,LockUC
是通用线程中的最佳选择,也是 C# lock
的良好替代品。
C# lock
/ Monitor
的主要问题在于试图涵盖从锁定区域的长处理时间到极短时间,从高争用到完全没有争用的整个范围。
ILockUC Lock { get; } = new LockUC();
using (Lock.Enter())
{
//locked area
}
//immediate access or no entry at all
using (EntryBlockUC entry = Lock.TryEnter())
{
if (entry.HasEntry)
{
//locked area
}
}
//immediate access or access within timeout milliseconds or no entry at all
using (EntryBlockUC entry = Lock.TryEnter(msTimeout: 25))
{
if (entry.HasEntry)
{
//locked area
}
}
SpinLockUC
ILockUC Lock { get; } = new SpinLockUC();
using (Lock.Enter())
{
//locked area
}
//immediate access or no entry at all
using (EntryBlockUC entry = Lock.TryEnter())
{
if (entry.HasEntry)
{
//locked area
}
}
//immediate access or access within timeout milliseconds or no entry at all
using (EntryBlockUC entry = Lock.TryEnter(msTimeout: 25))
{
if (entry.HasEntry)
{
//locked area
}
}
TicketSpinLockUC
TicketSpinLockUC
同步原语是 .NET 中缺失的经典票据自旋锁的实现。主要目标是提供类似 SpinLock 的无锁访问,但要确保访问公平性。它的性能略低于 SpinLockUC
,因为吞吐量周期明显较差,但避免了线程饥饿并确保了负载平衡。
不幸的是,Interlocked
类没有实现所有必要的原子操作来轻松实现 TryEnter
方法。因此,这些方法在 TicketSpinLockUC
中未实现,如果使用则会抛出适当的异常。
ILockUC Lock { get; } = new TicketSpinLockUC();
using (Lock.Enter())
{
//locked area
}
//not implemented yet
//using (EntryBlockUC entry = Lock.TryEnter())
//not implemented yet
//using (EntryBlockUC entry = Lock.TryEnter(msTimeout: 25))
SemaphoreLockUC
基于 Semaphore WaitOne/Release 的锁,取决于操作系统,在 Windows 上大致为 FIFO,**公平性不保证**。
ILockUC Lock { get; } = new SemaphoreLockUC();
using (Lock.Enter())
{
//locked area
}
//immediate access or no entry at all
using (EntryBlockUC entry = Lock.TryEnter())
{
if (entry.HasEntry)
{
//locked area
}
}
//immediate access or access within timeout milliseconds or no entry at all
using (EntryBlockUC entry = Lock.TryEnter(msTimeout: 25))
{
if (entry.HasEntry)
{
//locked area
}
}
SemaphoreSlimLockUC
基于 SemaphoreSlim Wait/Release 的锁,采用了一种混合方法,结合了原子指令,这不利于 FIFO 风格,**不公平访问可能导致线程停滞**!
ILockUC Lock { get; } = new SemaphoreSlimLockUC();
using (Lock.Enter())
{
//locked area
}
//immediate access or no entry at all
using (EntryBlockUC entry = Lock.TryEnter())
{
if (entry.HasEntry)
{
//locked area
}
}
//immediate access or access within timeout milliseconds or no entry at all
using (EntryBlockUC entry = Lock.TryEnter(msTimeout: 25))
{
if (entry.HasEntry)
{
//locked area
}
}
MutexLockUC
此同步原语不可访问,仅限于预定义的基准测试项目,因为它要求在进入和退出调用时进行线程亲和,这在统一并发中是设计上不支持的,但对于基准测试来说,它是可维护且有趣的数据收集工具。
ILockUC Lock { get; } = new MutexLockUC();
using (Lock.Enter())
{
//locked area
}
//immediate access or no entry at all
using (EntryBlockUC entry = Lock.TryEnter())
{
if (entry.HasEntry)
{
//locked area
}
}
//immediate access or access within timeout milliseconds or no entry at all
using (EntryBlockUC entry = Lock.TryEnter(msTimeout: 25))
{
if (entry.HasEntry)
{
//locked area
}
}
AsyncLockUC
AsyncLockUC
是一个 async/await 风格的同步原语,它保证访问公平性,并且是对于在锁定区域内执行时间超过一百微秒的任何操作来说,最具性能的同步原语。它是统一并发框架中唯一已知可以防止高度争用下的 CPU 交通堵塞的同步原语。CPU 交通堵塞可以解释为同步原语在高度争用下花费大部分 CPU 资源用于同步本身,而几乎没有或最少用于任何有用的工作。AsyncLockUC
能够避免 CPU 交通堵塞的原因是没有等待访问的线程挂起/恢复。如果没有人拥有访问权,则传入线程会立即同步执行锁定区域。如果传入线程无法获得访问权(因为它已被占用),则会创建一个 TaskCompletionSource
并将其排入队列。每个线程在返回访问权之前,如果存在任何 TaskCompletionSource
,则会将其出队并开始完成,从而有效地将延续调度到 ThreadPool
。
IAsyncLockUC Lock { get; } = new AsyncLockUC();
using (await Lock.Enter())
{
//locked area
}
//immediate access or no entry at all
using (EntryBlockUC entry = await Lock.TryEnter())
{
if (entry.HasEntry)
{
//locked area
}
}
//immediate access or access within timeout milliseconds or no entry at all
using (EntryBlockUC entry = await Lock.TryEnter(msTimeout: 25))
{
if (entry.HasEntry)
{
//locked area
}
}
AsyncSpinLockUC
AsyncSpinLockUC
是一个 async/await 风格的同步原语,是 .NET SpinLock
的封装。awaiting 通常是部分同步的,直到第一个尚未完成的 await。从那时起,调用返回,其余部分必须异步 await。因此,对 AsyncSpinLockUC
的 Enter()
调用实际上是自旋等待以获得访问权,并且总是返回一个已完成的 awaiter,因此 awaiting 会同步向前进行。这里的 awaiters 是 struct
,因此不涉及分配。其余部分与 SpinLocUC
相同,访问公平性不保证,需要处理。
IAsyncLockUC Lock { get; } = new AsyncSpinLockUC();
using (await Lock.Enter())
{
//locked area
}
//immediate access or no entry at all
using (EntryBlockUC entry = await Lock.TryEnter())
{
if (entry.HasEntry)
{
//locked area
}
}
//immediate access or access within timeout milliseconds or no entry at all
using (EntryBlockUC entry = await Lock.TryEnter(msTimeout: 25))
{
if (entry.HasEntry)
{
//locked area
}
}
AsyncTicketSpinLockUC
AsyncTicketSpinLockUC
是一个 async/await 风格的同步原语,类似于 TicketSpinLockUC
。awaiting 通常是部分同步的,直到第一个尚未完成的 await。从那时起,调用返回,其余部分必须异步 await。因此,对 AsyncTicketSpinLockUC
的 Enter()
调用实际上是票据自旋等待以获得访问权,并且总是返回一个已完成的 awaiter,因此 awaiting 会同步向前进行。这里的 awaiters 是 struct
,因此不涉及分配。其余部分与 TicketSpinLocUC
相同,访问公平性得到保证。TryEnter
方法未实现。
IAsyncLockUC Lock { get; } = new AsyncTicketSpinLockUC();
using (await Lock.Enter())
{
//locked area
}
//not implemented yet
//using (EntryBlockUC entry = await Lock.TryEnter())
//not implemented yet
//using (EntryBlockUC entry = await Lock.TryEnter(msTimeout: 25))
AsyncSemaphoreSlimLockUC
基于 SemaphoreSlim WaitAsync
/Release 的锁,**似乎符合 FIFO 风格,公平访问**。
性能方面与 AsyncLockUC
相似。
IAsyncLockUC Lock { get; } = new AsyncSemaphoreSlimLockUC();
using (await Lock.Enter())
{
//locked area
}
//immediate access or no entry at all
using (EntryBlockUC entry = await Lock.TryEnter())
{
if (entry.HasEntry)
{
//locked area
}
}
//immediate access or access within timeout milliseconds or no entry at all
using (EntryBlockUC entry = await Lock.TryEnter(msTimeout: 25))
{
if (entry.HasEntry)
{
//locked area
}
}
摘要
本文的主要目的是介绍统一并发框架中实现的同步原语列表,并展示它们的特性和示例。
修订历史
- 2019 年 3 月 24 日
- 扩展文章系列
- 第四篇文章中实现的最新同步原语
- 下载内容包括第四篇文章的最新示例和跨平台 nuget 目标