在第4章中讨论了并行编程的潜在问题,其中之一就是同步开销。当将工作分解为多个工作项并由任务处理时,就需要同步每个线程的结果。线程局部存储和分区局部存储,某种程度上可以解决同步问题。但是,当数据共享时,就需要用到同步原语。
因篇幅所限,本章为第4篇,主要介绍轻量级同步原语、屏障和倒数事件、SpinWait和自旋锁。
本教程对应学习工程:魔术师Dix / HandsOnParallelProgramming · GitCode
.NET Framework 还提供了轻量级的同步原语,其性能比同步原语更好。它们尽可能避免依赖内核对象(如等待句柄),因此它们只在进程内部工作。适合在线程等待时间很短的时候使用。
从微软的介绍来看,在单进程工作的情况下,轻量级同步原语性能会更好,使用上也是相同的。建议作为优先选择。
相当于 ReaderWriterLock 的轻量级实现。其允许多个线程访问受保护资源,而仅允许一个线程写入。
ReaderWriterLockSlim 类 (System.Threading) | Microsoft Learn表示用于管理资源访问的锁定状态,可实现多线程读取或进行独占式写入访问。 https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.readerwriterlockslim?view=netstandard-2.1
虽然 ReaderWriterLockSlim 类似于 ReaderWriterLock,但不同之处在于,前者简化了递归规则以及锁状态的升级和降级规则。 ReaderWriterLockSlim 避免了许多潜在的死锁情况。
另外,ReaderWriterLockSlim 的性能显著优于 ReaderWriterLock。 建议对所有新开发的项目使用 ReaderWriterLockSlim。
轻量级信号灯 SemaphoreSlim 是 Semaphore 的轻量级实现,限制了多线程的访问。SemaphoreSlim 只能是局部信号灯,而 Semaphore 可以创建为全局信号灯。
SemaphoreSlim 类 (System.Threading) | Microsoft Learn对可同时访问资源或资源池的线程数加以限制的 Semaphore 的轻量替代。 https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.semaphoreslim?view=netstandard-2.1
使用区别就是用 Wait 方法替代了 WaitOne 方法。
ManualResetEventSlim 是 ManualResetEvent 的轻量级实现,具有更好的性能。使用时也是,用 Wait 方法替代了 WaitOne 方法。
ManualResetEventSlim 类 (System.Threading) | Microsoft Learn表示线程同步事件,收到信号时,必须手动重置该事件。 此类是 ManualResetEvent 的轻量替代项。 https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.manualreseteventslim?view=netstandard-2.1 从介绍来看,在单进程中,使用 ManualResetEventSlim 大部分情况下都有更好的优势。
.NET Framework 有一些内置的信号原语,可以帮助我们同步多个线程。
在计数为 0 时发出信号的倒数事件:
CountdownEvent 类 (System.Threading) | Microsoft Learn表示在计数变为零时处于有信号状态的同步基元。 https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.countdownevent?view=netstandard-2.1 简单示例代码如下:
public static void RunWithCountDownEvent()
{
CountdownEvent countdownEvent = new CountdownEvent(5);//等待5个任务完成
for (int i = 0; i < 10; i++)//同时执行10个任务
RandomTimtToCountDown(countdownEvent, i);
Task.Run(() =>
{
countdownEvent.Wait();
Debug.LogError("倒数完成 !");
});
}
public static void RandomTimtToCountDown(CountdownEvent countdownEvent, int index)
{
Task.Run(async () =>
{
System.Random random = new System.Random();
int waitTime = random.Next(500, 2000);
//Debug.Log($"开始等待 {waitTime} ms !");
await Task.Delay(waitTime);
Debug.Log($"【{index}】等待 {waitTime} ms 完成");
countdownEvent.Signal();//发出信号
});
}
运行结果如下:
可见,倒数事件能保证5个事件完成时解除阻塞。当我们需要等待多个任务完成一定进度,而不需要在意究竟是哪些任务完成时,就可以使用这个事件。这个我感觉在某些特殊情况很有用,例如我需要同时加载 100 个资源,但是只要加载了其中任意 30 个就可以继续游戏了,就可以使用这个倒数事件。
允许多个线程运行,而无需主线程控制他们,创建了一个屏障直至所有线程到达。
Barrier 类 (System.Threading) | Microsoft Learn使多个任务能够采用并行方式依据某种算法在多个阶段中协同工作。 https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.barrier?view=netstandard-2.1 测试代码如下:
public static void RunWithBarrier()
{
Barrier barrier = new Barrier(0);//设定初始参与者为 0
RandomTimeWaitBarrier(barrier);
RandomTimeWaitBarrier(barrier);
RandomTimeWaitBarrier(barrier);
}
public static void RandomTimeWaitBarrier(Barrier barrier)
{
barrier.AddParticipant();//添加一个参与者
Task.Run(async () =>
{
System.Random random = new System.Random();
int waitTime = random.Next(500, 2000);
await Task.Delay(waitTime);
Debug.Log($"等待 {waitTime} ms 完成");
barrier.SignalAndWait();//标记完成,并等待其他任务完成
Debug.LogError("执行完毕!");
});
}
运行结果如下:
屏障的作用很明显,就是根据参与者的完成情况,然后同时进行剩余步骤。这个的作用就很多了,例如经常遇到的需求,就是一边加载资源一边等待网络消息,但是要两者都完成才能进行下一步。因为我们不知道哪个任务先完成,一般做法是设置很多 Flag ,互相监听等待情况。使用屏障,就能很方便地能实现这个功能。
在 4.2、阻塞与自旋 中提到过,如果阻塞的时间很短,采用自旋技术比阻塞有效得多。因为自旋减少了上下文切换和转换的开销(也就是上下文切换的开销大于阻塞的开销,使用自旋)。
SpinWait 的用法也很简单:
SpinWait spin = new SpinWait();
spin.SpinOnce();
这样就能完成一次极快的阻塞。
也可以使用 SpinWait.SpinUntil ,传入一个方法,当返回会 true 的时候结束阻塞。
SpinWait 结构 (System.Threading) | Microsoft Learn为基于自旋的等待提供支持。 https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.spinwait?view=netstandard-2.1 SpinWait 成员不是线程安全的。 如果多个线程必须旋转,则每个线程都应使用其自己的实例 SpinWait。
如果等待时间极短,则锁和互锁原语可能会大大降低性能。自旋锁 (SpinLock)提供了一种轻量级的低级替代选项。SpinLock 即使还没有获得锁,也会产生线程的时间片。
这里我们又搬出 3.1、重新排序 那一段示例代码,这次我们加上自旋锁:
public static void RunTestAddFunctionWithSpinLock()
{
TestValueA = 0;
TestValueB = 0;
m_IsFinishOnce = false;
Task.Run(() =>
{
SpinLock spinLock = new SpinLock();
Parallel.For(0, 10000, x =>
{
bool lockToken = false;
spinLock.Enter(ref lockToken);
TestValueA = x;
TestValueB = x;
m_IsFinishOnce = TestValueA >= TestValueB;
spinLock.Exit(false);
});
Debug.Log("运行完成");
});
}
这次也达到了其他上锁代码同样的效果,没有再出现取值错误的问题。这里恰好满足自旋锁的使用条件:任务量很小,用自旋锁来避免上下文的切换。
SpinLock | Microsoft Learn详细了解:SpinLockhttps://learn.microsoft.com/zh-cn/dotnet/standard/threading/spinlock
本章小节:
本章详细讲解了 .NET Core 提供的同步原语,如果并行代码要保证正确,使用同步原语非常重要。当然,这会带来额外的性能开销,所以最好只在关键节使用。
另外,尽量使用轻量级同步原语;而且屏障、倒数和自旋也是非常有用的。
本教程对应学习工程:魔术师Dix / HandsOnParallelProgramming · GitCode
相关链接:
【C#】并行编程实战:同步原语(1)_魔术师Dix的博客-CSDN博客
【C#】并行编程实战:同步原语(2)_魔术师Dix的博客-CSDN博客
【C#】并行编程实战:同步原语(3)_魔术师Dix的博客-CSDN博客