目录
文章系列
介绍
接口和基于模式
合理限制
访问公平
访问公平性:内核大致FIFO
访问公平性:原子指令不公平
访问公平性:增强的原子指令不公平
实现的同步原语
MonitorLockUC
LockUC
SpinLockUC
TicketSpinLockUC
SemaphoreLockUC
SemaphoreSlimLockUC
MutexLockUC
AsyncLockUC
AsyncSpinLockUC
AsyncTicketSpinLockUC
AsyncSemaphoreSlimLockUC
概括
在这篇介绍性文章中,我列出了已实现的同步原语列表,并说明了它们的属性、性能评估和代码示例,而没有性能测量/分析的复杂性。
- 统一并发 I - 简介
- 统一并发 II - 基准测试方法
- 统一并发 III - 跨基准测试
- 统一并发 IV - 跨平台(.NET Core 2.1 / .NET Standard 2.0)
统一并发框架旨在提供简单易用的同步原语,设计时考虑到OOP原则,并为一般和async/await线程场景提供接口和基于模式的方法。实现的同步原语包括通用线程锁定、自旋锁定、票据自旋锁定、异步锁定、异步自旋锁定和异步票据自旋锁定。统一并发框架以这样的方式开发,以允许在源代码的有限更改(通常为一行)的情况下灵活更改同步原语。由于共享模式,从通用线程切换到async/await风格并返回也更容易。实现的同步原语通常提供比C# lock更好的性能,提供未在.NET中实现的功能或以最小的开销包装现有的同步原语。
统一并发框架旨在提供一种敏捷工具,能够通过最少的代码更改轻松更改同步原语。毕竟,今天的代码是明天石化的业务线遗留代码,没人敢动。人们必须做好选择,并为以后的变化敞开大门。
这篇介绍性文章排在第一。我的目的是在此处展示已实现的同步原语列表,并说明它们的属性、性能评估和代码示例,而无需复杂的性能测量/分析。下一篇文章的工作是深入研究已实现的同步原语的复杂问题、测量场景、方法、详细分析和并发单元测试。
统一并发框架是在GitHub和NuGet上可用的开源GreenSuperGreen库中实现的(3个包,LIB,单元测试基准)。示例将以Visual Studio解决方案的形式提供,其中包含一个针对实际用例中必要的Nuget包的项目。
- 下载示例 .NET 4.6:UnifiedConcurrency.zip
- 下载跨平台示例:unified_concurrency.zip
- http://github.com/ipavlu/GreenSuperGreen
NET 4.6
- http://www.nuget.org/packages/GreenSuperGreen/
- http://www.nuget.org/packages/GreenSuperGreen.Test/
NetStandard 2.0
- http://www.nuget.org/packages/GreenSuperGreen.NetStandard/
- http://www.nuget.org/packages/GreenSuperGreen.NetStandard.Test/
- http://www.nuget.org/packages/GreenSuperGreen.Benchmarking.NetStandard/
NetCore 2.1
- http://www.nuget.org/packages/GreenSuperGreen.Benchmarking.Launcher.NetCore/
Net 4.7.2
- http://www.nuget.org/packages/GreenSuperGreen.Benchmarking.Launcher.Net/
每个通用同步原语都必须实现接口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是一个为C# 语言中的using语句准备好的已实现IDisposable接口的struct。实现的Dispose()方法只能使用一次,并且应该让using构造在执行块结束时调用该方法,这是使用模式的常见规则。
该using语句需要一个具有已实现方法的对象:Dispose() 它将在using语句块的末尾调用它。
即使EntryBlockUC是struct, C#中的using语句也不会为了获得对方法Dispose()的访问而装箱EntryBlockUC。
但是,如果我们将EntryBlockUC存储为object或IDisposable类型的变量,则会发生装箱。这就是为什么所有示例要么在Enter()方法不需要返回类型时不指定返回类型,要么将返回类型指定为EntryBlockUC。
该AsyncEntryBlockUC是async/await风格awaiter,必须等待,并会提供EntryBlockUC类型的结果。
该EntryBlockUC是给我们的信息,我们是否得到了进入或没有属性,bool HasEntry。
它仅在TryEnterTryEnter(timeout)方法的情况下有用。在Enter()方法的情况下:它总是成功的进入。
合理限制统一并发框架为了统一多个同步原语必须在某些能力上受到限制。这些限制实际上是非常合理的。
- 统一并发下的同步原语不支持对自身的递归访问。没有语言支持可以有效地实现async/await场景,并且并非所有同步原语都能够提供递归访问。
- 统一并发下的同步原语不是线程仿射的,因此访问入口和访问出口可能发生在不相关的线程上。
这些限制实际上使得在由统一并发同步原语创建的任何锁定块内等待成为可能。
访问公平每个同步原语的定义特性之一是保证访问公平性的能力。一些同步原语根本不保证这种能力。例如,一般的SpinLock不会,因为它们使用原子指令,硬件是决定谁先获得访问权,而决定是由缓存状态驱动的。不公平的访问会导致线程匮乏,这很容易证明,如果在受保护块内花费的时间超过了一些关键时间,即线程数、内核数和硬件数,则在重度争用期间SpinLocks喜欢让某些线程饿死几分钟依赖。唯一有趣的一点是,随着新硬件架构和更多内核的出现,这个关键时间变得越来越小,因此这个问题会随着时间的推移而发挥更大的作用。
另一方面,一些同步原语可以保证访问公平性,这里会注意到这些。
有趣的是,实时操作系统倾向于保证访问公平性,因为它带来了非常重要的质量,确保了负载平衡。
公平,或者更确切地说,缺乏公平不是一种,但它可以发生的方式更多,而且公平/不公平可以具有更广泛的含义。
访问公平性:内核大致FIFOJoe Duffy在Windows上的并发编程中很好地描述了一种可能发生不公平的方式:“因为监视器在内部使用内核对象,所以它们表现出与操作系统同步机制也表现出的大致相同的FIFO行为。监视器是不公平,所以如果另一个线程在被唤醒的等待线程尝试获取锁之前尝试获取锁,则允许偷偷摸摸的线程获取锁。 无法获取已被唤醒的资源的被唤醒线程将不得不重新等待并在某个时候再做一次。 ”
访问公平性:原子指令不公平另一种可能发生不公平的方式是在特定场景中使用基于原子指令的同步机制作为SpinLock,其中一个线程进入SpinLock并保持访问一段时间,而其他线程开始竞争SpinLock,然后拥有访问权限的线程离开SpinLock,但立即试图再次进入SpinLock。上次拥有SpinLock访问权限的线程比其他线程有一些预先打开的门,它已经在缓存中拥有所需的一切,因此它不必浪费时间并准备好轻松进入。如果有许多线程争用SpinLock,那么我们可以观察到一些线程被饿死的时间更长,并且线程不公平地无序地获得访问权限。观察或证明这种不公平的发生并不容易。
访问公平性:增强的原子指令不公平前一种类型的不公平,AtomicInstructionsUnfairness,通常甚至会被增强,如果SpinLock通过燃烧指令周期来浪费更少的CPU资源。增强以SpinWaiting、线程屈服、线程休眠和先前技术的组合的形式出现。虽然可以节省一些CPU资源,特别是如果访问保持更长的时间,比如数百微秒甚至毫秒,但它也带来了不公平,因为现在一个线程可以从SpinLock退出并进入并且可以很容易地无序访问。 这只是一个纯粹的机会和错误的时间问题,线程可能会坐下来饿死几秒钟。创建这种饥饿是很容易的,它会在第三篇文章中专门提到。
实现的同步原语- MonitorLockUC——作为C#锁的瘦包装器实现,仅在框架内部实现Monitor,为了进行比较和基准测试,C#lock在多核CPU上具有显着的性能和副作用问题,它不保证访问公平性,
- LockUC——替代C#锁,Monitor.Enter()总体上比C#lock具有更好的性能,它保证了访问公平性,
- SpinLockUC——.NET SpinLock struct的薄包装,它不保证访问公平性,
- TicketSpinLockUC——一个简单的Ticket Spin Lock,保证访问公平性,不实现TryEnter方法,
- SemaphoreLockUC——基于信号量WaitOne/Release的锁,依赖于操作系统,在windows上大致为FIFO,不保证公平性。
- SemaphoreSlimLockUC——基于SemaphoreSlim Wait/Release的锁结合了一种混合方法和原子指令,这种方法在FIFO风格中表现不佳,不公平的访问会导致线程停顿!
- MutexLockUC——此同步原语不可访问,仅适用于预定义的基准测试项目,因为它在进入和退出调用时需要线程关联,统一并发的设计不支持这一点,但对于基准测试而言,它是可维护的,并且对于收集数据很有趣。
- AsyncLockUC——async/await风格的同步原语是统一并发框架中实现的性能最高的同步原语,它保证了访问公平性,
- AsyncSpinLockUC ——async/await风格的自旋锁,不保证访问公平性,
- AsyncTicketSpinLockUC ——async/await风格的Ticket Spin Lock,保证了访问的公平性。
- AsyncSemaphoreSlimLockUC——基于SemaphoreSlim /Release的WaitAsync锁,似乎在FIFO风格、公平访问中表现良好。性能方面类似于AsyncLockUC.
MonitorLockUC是一个在统一并发框架中内部实现的瘦包装器,用于基准测试和测试目的。它基于.NET Monitor并由C# lock使用。两个引用将互换使用以相互引用。它不可访问的原因是,首先,还有其他更高性能的同步原语,其次,C# lock有一些无法预料的性能问题和副作用,直到实现了严重的争用才会出现。在复杂的代码中,很难人为地创造一个必要条件来引起同样的效果,但它们却在生产中发生。这使得C# lock难以测试和分析问题的原因。
在历史的某个时刻,C# lock是干净的线程挂起/恢复同步原语。它使行为更可预测,副作用更少。随着多核CPU的发明,这种情况迅速改变。Monitor现在是一个混合同步原语,线程实际上在执行对操作系统的调用之前在应用程序级别旋转一段时间,这允许更快地获得锁。不幸的是,此架构存在基于多个内核的扩展问题。以前有4个内核,现在有24或40个内核。如果访问锁的线程数量如此之大,我们就会开始看到限制。内核旋转的时间更长且频率更高,内核上CPU周期的浪费比实际完成的工作增长得更快,同步原语吞吐周期极短,但CPU资源可能会被完全浪费。这种行为可以解释为CPU僵局。
还有一个问题要提到,Monitor/C# lock不能保证访问公平性。这可能是由混合性质引起的,但在操作系统级别的某些情况下,线程挂起/恢复FIFO行为也可以被覆盖。实际出现的线程可以更快地访问。
在分析C#锁的性能问题和副作用时,通过C#锁进行的大量争用充分说明了软件设计。显然,这是在同步问题上抛出过多并行性而不是健康的情况。新设计应该考虑到这些问题并在任何地方避免它们。
//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同步原语似乎比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
}
}
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同步原语是.NET中缺失的经典Ticket自旋锁的实现。主要目标是提供类似于SpinLock的无锁访问,但确保访问公平性。然后它的性能比SpinLockUC as吞吐量周期明显差一点,但避免了线程饥饿并确保了负载平衡。
不幸的是,该类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))
基于信号量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
}
}
基于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
}
}
此同步原语不可访问,仅适用于预定义的基准测试项目,因为它在进入和退出调用时需要线程关联,统一并发的设计不支持这一点,但对于基准测试而言,它是可维护的,并且对于收集数据很有趣。
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是一个async/await风格的同步原语,它保证了访问公平性,并且对于在锁定区域内花费超过100微秒的任何事情来说,它是性能最高的同步原语。它是统一并发框架中唯一的同步原语,众所周知,它可以防止严重争用下的CPU拥堵。CPU僵局可以解释为在激烈争用下的同步原语将大部分CPU资源用于同步本身而几乎没有或几乎没有用于任何有用的工作的时刻。原因是AsyncLockUC能够避免CPU僵局的原因是没有挂起/恢复等待访问的线程。如果没有人可以访问,则传入线程立即同步执行锁定区域。如果传入线程无法访问,因为它已被占用,则TaskCompletionSource创建并入队。每个线程在返回访问之前,都会在离开之前将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是一个围绕.NET SpinLock的async/await风格同步原语包装器。通常,等待与尚未完成的第一个等待是部分同步的。从那时起,调用返回,其余部分必须异步等待。因此,对AsyncSpinLockUC 的Enter()调用实际上是自旋等待获得访问权并始终返回已完成的等待者,因此等待是同步向前进行的。这里的等待者是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是一个async/await风格的同步原语类似与TicketSpinLockUC。通常,等待与尚未完成的第一个等待是部分同步的。从那时起,调用返回,其余部分必须异步等待。因此,对AsyncTicketSpinLockUC的Enter()调用实际上是票证旋转等待以获得访问权并始终返回已完成的等待者,因此等待是同步向前进行的。这里的等待者是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))
基于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
}
}
本文的主要目的是展示在统一并发框架中实现的同步原语列表,并展示它们的能力和示例。
https://www.codeproject.com/Articles/1236238/Unified-Concurrency-I-introduction