在第4章中讨论了并行编程的潜在问题,其中之一就是同步开销。当将工作分解为多个工作项并由任务处理时,就需要同步每个线程的结果。线程局部存储和分区局部存储,某种程度上可以解决同步问题。但是,当数据共享时,就需要用到同步原语。
因篇幅所限,本章为第3篇。本章主要介绍信号原语。
本教程对应学习工程:魔术师Dix / HandsOnParallelProgramming · GitCode
6、信号原语
并行编程的一个重要方面是任务协调。
在创建任务时,可能会遇到 生产者/消费者(Producer/Consumer)场景,其中一个线程(消费者)在等待另一个线程(生产者)更新共享资源。由于消费者不知道生产之何时更新共享资源,因此它将轮询共享资源,可能导致竞争,且轮询的效率很低。最好使用 .NET Framework 提供的信号原语(Singaling Primitive):消费者线程将暂停,直到从生产者线程接收到信号为止。
6.1、Thread.Join
Thread.Join 方法 (System.Threading) | Microsoft Learn在此实例表示的线程终止前,阻止调用线程。https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.thread.join?view=netstandard-2.1#system-threading-thread-join Thread.Join 是使线程等待另一个线程信号的最简单方法,示例代码如下:
private void RunWithThreadJoin()
{
int result = 0;
Thread th1 = new Thread(() =>
{
Debug.Log("Th1 Start !");
Thread.Sleep(1000);
result = 100;
Debug.Log("Th1 End !");
});
Thread th2 = new Thread(() =>
{
Debug.Log($"Th2 Result Start: {result}");
th1.Join();
Debug.Log($"Th2 Result End: {result}");
});
th1.Start();
th2.Start();
}
此代码运行结果如下:
6.2、AutoResetEvent
AutoResetEvent 是指自动重置的 WaitHandle 类。重置后,允许一个线程通过创建的屏障,一旦线程通过,它们就会再次被设置,从而阻塞线程直到下一个信号。
AutoResetEvent 类 (System.Threading) | Microsoft Learn表示线程同步事件在一个等待线程释放后收到信号时自动重置。 此类不能被继承。https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.autoresetevent?view=netstandard-2.1 首先我们写一段没有线程安全的代码:
private void RunWithAutoResetEvent()
{
int result = 0;
Parallel.For(0, 1001, x =>
{
result += x;
});
Debug.Log($"Result = {result}");
}
显然这段代码是的结果,理论上是 500500,但实际上多运行几次,结果总会有所不同:
这个原因就不赘述了,很显然了。下面用 AutoResetEvent 来进行改造:
private void RunWithAutoResetEvent()
{
AutoResetEvent autoResetEvent = new AutoResetEvent(false);
Task.Run(() =>
{
int result = 0;
autoResetEvent.Set();
Parallel.For(0, 1001, x =>
{
autoResetEvent.WaitOne(100);//会阻塞线程!
result += x;
autoResetEvent.Set();
});
Debug.Log($"Result = {result}");
});
}
最后结果达到预期,没有了线程竞争的问题:
(17次执行结果都是500500)
6.3、ManualResetEvent
ManualResetEvent 类 (System.Threading) | Microsoft Learn表示线程同步事件,收到信号时,必须手动重置该事件。 此类不能被继承。https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.manualresetevent?source=recommendations&view=netstandard-2.1 ManualResetEvent 是指需要手动重置的等待句柄,允许多个线程通过,直到它再次被设置。
private ManualResetEvent manualResetEvent = new ManualResetEvent(false);
private bool IsReset;
private void RunWithManualResetEvent()
{
Task.Run(async () =>
{
for (int i = 0; i < 10; i++)
{
manualResetEvent.WaitOne();
await Task.Delay(1000);
Debug.Log("Task 1 Loop : " + i);
}
});
Task.Run(async () =>
{
for (int i = 0; i < 10; i++)
{
manualResetEvent.WaitOne();
await Task.Delay(1000);
Debug.Log("Task 2 Loop : " + i);
}
});
}
private void SetManualResetEvent()
{
if (IsReset)
manualResetEvent.Reset();
else
manualResetEvent.Set();
IsReset = !IsReset;
}
如上述代码,Task 1 和 Task 2 都可以被暂时挂起等待。而我们设置的 manualResetEvent 可以同时管理这两个线程任务的等待。
6.4、WaitHandle
WaitHandle 是继承 MarshalByRefObject 的抽象类,用于同步应用程序中的线程。调用 WaitHandle 类的任何方法都可以阻塞线程,而释放线程取决于选择的 Signaling 构造的类型。
WaitHandle 类 (System.Threading) | Microsoft Learn封装等待对共享资源进行独占访问的操作系统特定的对象。https://learn.microsoft.com/zh-cn/dotnet/api/system.threading.waithandle?view=netstandard-2.1
其实在之前章节我们已经用过 WaitHandle 了,AutoResetEvent 和 ManualResetEvent 都是继承自 WaitHandle 。通过之前章节的使用,相信大家已经知道这个类的基本用法了。这里单独说一下 WaitHanlde 的两个静态方法:
-
WaitAll:线程等待数组中的所有等待句柄收到信号。
-
WaitAny:线程等待指定一组等待句柄中的任何一个被发出信号。
我们写一段示例代码:
public static void RunWithWaitHandle()
{
AutoResetEvent[] waitHandles =
{
new AutoResetEvent(false),
new AutoResetEvent(false),
};
RandomTimeToSet(waitHandles[0]);
RandomTimeToSet(waitHandles[1]);
WaitHandle.WaitAll(waitHandles);//等待2个任务的信号
//WaitHandle.WaitAny(waitHandles);//等待2个任务中任意一个信号
Debug.Log($"RunWithWaitHandle 执行完成 !");
}
public static void RandomTimeToSet(AutoResetEvent handle)
{
Task.Run(async () =>
{
System.Random random = new System.Random();
int waitTime = random.Next(1000, 10000);
Debug.Log($"开始等待 {waitTime} !");
await Task.Delay(waitTime);
Debug.Log($"等待 {waitTime} 完成");
handle.Set();//发出信号
});
}
这里我们用 WaitAll 来测试,结果如下:
当然,如果用 WaitAny 则只需要等待其中一个任务发出信号即可。
这里值得一提的还有一个 API:
-
SignalAndWait:向一个 WaitHandle 发出信号并等待另一个。
直接看说明比较抽象,这里直接展示代码:
public static void RunWtihWatiHandleSignalAndWait()
{
var autoResetEvent1 = new AutoResetEvent(false);
var autoResetEvent2 = new AutoResetEvent(false);
WaitSingalToDebug(autoResetEvent1);
RandomTimeToSet(autoResetEvent2);
Task.Run(async () =>
{
await Task.Delay(500);
WaitHandle.SignalAndWait(autoResetEvent1, autoResetEvent2);
Debug.Log($"RunWtihWatiHandleSignalAndWait 执行完成 !");
});
}
public static void WaitSingalToDebug(AutoResetEvent handle)
{
Task.Run(() =>
{
Debug.Log("WaitSingalToDebug 开始等待!");
handle.WaitOne();
Debug.Log("WaitSingalToDebug 等待完成!");
});
}
执行结果如下:
可以看到,SignalAndWait 这一行代码直接相当于同时执行2个操作:Set 和 WaitOne 。立即向 autoResetEvent1 发出信号使得 WaitSingalToDebug 这个任务能够正常执行;同时又等待 RandomTimeToSet 任务的信号以继续。
WaitHandle 的用处还是很有用的。有时我们并不想等待其他任务执行完成,又需要其他任务提供一个节点以继续运行,此时使用 WaitHandle 会是不错的选择。
(未完待续)
本教程对应学习工程:魔术师Dix / HandsOnParallelProgramming · GitCode