在第4章中讨论了并行编程的潜在问题,其中之一就是同步开销。当将工作分解为多个工作项并由任务处理时,就需要同步每个线程的结果。线程局部存储和分区局部存储,某种程度上可以解决同步问题。但是,当数据共享时,就需要用到同步原语。
因篇幅所限,本章为第2篇。主要介绍锁、互斥锁和信号灯。
本教程对应学习工程:魔术师Dix / HandsOnParallelProgramming · GitCode
5、锁、互斥锁和信号灯
锁(Lock)和互斥锁(Mutex)是仅允许一个线程访问受保护资源的锁结构。信号灯(Semaphore)也是一种锁结构,它允许指定数量的线程访问受保护的资源。
同步原语 | 分配的线程数 | 跨进程 |
锁(Lock) | 1 | × |
互斥锁(Mutex) | 1 | √ |
信号灯(Semaphore) | 多 | √ |
轻量信号灯(SemaphoreSlim) | 多 | × |
由于锁会限制其他线程访问共享资源,所以一定不要锁住会造成阻塞的代码,否则性能会大幅下降。一般来讲,锁只用于关键节。
关键节(Critical Section):
线程执行路径的一部分,必须对其进行保护以防止并发访问,进而维护某些不变性(Invariant)。关键节本身不是同步原语,但是它依赖于同步原语。
5.1、锁
在之前的代码,我们做一下改造:
private static object lockObj = new object();
public static void RunTestAddFunctionWithLock()
{
TestValueA = 0;
TestValueB = 0;
m_IsFinishOnce = false;
Task.Run(() =>
{
Parallel.For(0, 10000, x =>
{
lock (lockObj)
{
TestValueA = x;
TestValueB = x;
m_IsFinishOnce = TestValueA >= TestValueB;
}
});
Debug.Log("运行完成");
});
这个其实是非常常见的锁用法了,如上示例代码,就永远不会出现异常值的情况 m_IsFinishOnce 的值永远为 true。与 Lock 语句类似,还有一种类似写法:
Monitor.Enter(lockObj);
TestValueA = x;
TestValueB = x;
m_IsFinishOnce = TestValueA >= TestValueB;
Monitor.Exit(lockObj);
Monitor 类 (System.Threading) | Microsoft Learn提供同步访问对象的机制。 https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.monitor?view=netstandard-2.1 简单来说,Lock 是 Monitor 的快捷方式,Lock 能实现的 Monitor 也都能实现。想要详细了解这两者可以扩展阅读以下连接:
Lock VS Monitor - 知乎介绍 介绍 对开发人员来说,处理关键代码部分的多线程应用程序是非常重要的。 Monitor和lock是c#语言中多线程应用程序中提供线程安全的方法(lock关键字的本质就是对Monitor的封装)。两者都提供了一种机制来确保只…https://zhuanlan.zhihu.com/p/553789674
5.2、互斥锁
Lock 或 Mutex 只能锁定单进程,毕竟我们锁定的类只是在单进程创建的。如果出现多个进程竞争同一个资源(例如都在对同一个文档进行写入),就会报错。此时我们需要 Mutex ,创建内核级别的应用锁。
Mutex 类 (System.Threading) | Microsoft Learn还可用于进程间同步的同步基元。 https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.mutex?view=netstandard-2.1 用法也很简单;
private static Mutex mutex = new Mutex(false, "TestMutex");
……
mutex.WaitOne();
TestValueA = x;
TestValueB = x;
m_IsFinishOnce = TestValueA >= TestValueB;
mutex.ReleaseMutex();
不过我感觉,在一般的游戏开发都是单进程的逻辑,很少会需要用到互斥锁。可能在工程流水线开发的过程中,用到这个的情况多一些。
Lock 和 Mutex 只能从获得它们的线程释放。
5.3、信号灯
(书上称之为信号量,但微软文档上称之为信号灯,这里采用微软的说法,以下通称为信号灯)
Lock、Monitor、Mutex 仅允许一个线程访问受保护的资源。但是有时我们也需要多个进行能够访问共享资源。例如资源池(Resource Pooling)和节流(Throttling)的应用场景都需要让多个线程能同时访问到共享资源。
与 Lock 或 Mutex 不同,信号灯(Semaphore)是线程不可知的,这意味着任何线程都可以调用 Semaphore 的释放。信号灯也可以跨进程工作。
Semaphore 类 (System.Threading) | Microsoft Learn限制可同时访问某一资源或资源池的线程数。 https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.semaphore?view=netstandard-2.1 示例代码如下:
private static Semaphore semaphore = new Semaphore(1, 3);//初始资源量,资源总量
public static void RunWithSemaphore()
{
Task.Run(() =>
{
Parallel.For(0, 10, async x =>
{
Debug.Log($"【{x} 】进入运行!");
semaphore.WaitOne();
await Task.Delay(1000);
semaphore.Release();
Debug.LogError($"【{x}】 完成运行!");
});
Debug.Log("全部运行完成");
});
}
Semaphore 传入的2个参数,一个是初始资源量,一个是资源总量。像如上的代码,其实就是一个等一个,每隔 1s 执行完成一个任务:
如果将 initialCount 设置为 2 ,这就是一次执行两个任务。这个原理其实就是 PV操作 的意思,通过信号灯来动态控制线程的阻塞与执行。
全局信号灯(Global Semaphore):
对于操作系统是全局的,应用了内核级别的锁原语。使用名称创建(创建时进行了命名)的任何信号灯都将创建为全局信号灯,否则则为局部信号灯。
(未完待续)
本教程对应学习工程:魔术师Dix / HandsOnParallelProgramming · GitCode