心跳模式






4.46/5 (11投票s)
如何避免多线程软件范式,并恢复任务关键型软件中急需的执行确定性。
引言
多线程,由于CPU时钟频率停止增长,常常成为必要的性能优化手段,但也成为了最大的软件设计灾难之一。
多线程应用程序非常难以调试。它们不像单线程应用程序那样具有确定性,可预测性较差,也更复杂。这里的确定性意味着,在出现线程同步错误的情况下,应用程序不仅可能做错事情,而且会随着线程调度程序的行为,时而做不同的错事。这使得软件测试效率不高。就像未初始化变量的问题一样,你不会希望程序或函数理论上依赖于RAM的先前状态。小心谨慎也无济于事,因为bug最终会进入代码——而由于其测试免疫性,这类bug是最不希望出现的。本文中,我不会提倡极端确定性,例如函数式编程。然而,程序输入,例如影响程序行为的内容,必须始终清晰。
多线程对许多程序员来说非常有吸引力。一开始可能觉得将现有算法转换为多线程并与其他内容并行运行是一个很小的改动。但通常会惊讶地发现,程序的不同部分之间有多少对象被重用,现在需要正确地了解线程策略和不那么直观的线程安全特性。
此外,由于相对执行时间确定性的丧失,对象的线程安全几乎无法进行单元测试。
本文描述了一种旨在减少应用程序设计中线程数量的设计模式,并提出了在各种设计场景中应有多少线程的观点。
何时使用多线程
使用多线程有两个正当理由
- CPU密集型应用程序的极端性能优化。当你想利用多个可用的CPU核心时。我们将争论应用程序的CPU密集型部分必须被识别并进行局部优化。
- 你被迫使用不支持适当异步API的第三方组件。例如,.NET的BeginConnect()会接受一个回调参数,该参数将在任意线程中调用(可能是函数返回前调用者的线程,也可能是不同的线程——取决于IP地址)。这种第三方API设计让你别无选择。
另一个例子是计算时间很长的第三方API。你不能从UI线程调用它,因为那样你的用户就无法在不杀死应用程序的情况下取消,你又被迫创建一个单独的线程。如果实现由你控制,我们将演示如何避免这种软件接口。
何时不使用多线程
- 当应用程序是IO密集型,并且将CPU并行拆分作为性能优化没有意义时。
- 当你正在开发必须针对各种可能的输入组合进行测试的任务关键型应用程序时。
- 当你有第三方组件可供选择时
长时间执行(例如算法)、IO、通信都必须有适当的异步API支持。适当的异步API可能看起来像这样
ProperAPI.Initialize(); ProperAPI.DoSomethingSmall(); ProperAPI.DoSomethingSmallAgain(); ...
因此,被调用的代码将负责维护状态,并能够将任务分解成小块。控制(指令指针寄存器)就像一个火球。你捕获它,必须迅速将其返回给调用者。算法绝不能长时间阻塞(窃取)它。它是一种共享资源!
另一种良好的异步API可能是轮询(具有轮询设计的所有缺点)
DoSomething(); if(AreYouFinished())... // ? // or, perhaps: DoSomethingAndSetThisFlagWhenDone(event);
这样的API意味着用户将有一个主循环,等待事件列表中的一个事件触发。在此等待期间,进程将无法访问CPU。
“心跳”模式
既然我们相信协作式多任务处理很有用,那么我们如何实现它呢?有一个中央循环,称为心跳循环。对于每个执行长时间任务的对象,该对象的心跳函数会在这个循环中被调用。通过这种方式,每个参与的对象都有机会完成一小部分工作,并将控制权返回。
一些伪代码
class IHeartBeat { bool HeartBeat() = 0; // Do whatever you need, and get back. Returns true if there’s more to do just now, before any additional input is received or time passes };
something::Run() { vector<IHeartBeat *> modules; // create modules, etc modules.push_back(&newModule); // .......... while(true) { bool didSomething = false; for (Module &module: modules) { StopWatch stopWatch; stopwatch.Start(); didSomething ||= module->HeartBeat(); stopwatch.Stop(); // do some statistics and report abusing modules. If someone stolen control, it must be logged for debug. if(stopWanted) // requested stop by user, e.g. request to stop the service return; // or quit in some other way // Do housekeeping stuff (e.g. create new modules, kill dead ones if they are created dynamically) .... ProcessSomeUI(); // e.g. winapi PeekMessage()/DispatchMessage() couple } Sleep(didSomething ? 0 : 1); // surrender the busy loop, or // GiveUpControlTillInterrupt(); // if there is a good way to know that no module needs control. See examples below } }
如何分解操作并将循环颠倒过来
当你将一个连续的长时间操作分解成多个部分时,你不可避免地需要创建一个状态机。
例如
DoStuff() { DoThing1(); DoThing2(); DoThing3(); }
变成
StateManagingAlgorithm::DoOneMoreThing() {
switch(_currentState) {
case 1: DoThing1(); _currentState++; break;
case x: DoThingX(); _currentState++; break;
}
}
并且
for(int i=0; i<n; i++) for(int j=0; j<n; j++) for(int k=0; k<n; k++) c[i][k]=a[i][j]*b[j][k];
变成
bool DoNextThing() { if(k>=n) { j++; k=0; } if(j>=n) { i+=; j=0; } if(i>=n) return false; c[i][k]=a[i][j]*b[j][k]; return true; }
其中 i、j、k 是最初初始化为零的状态变量。
心跳循环在哪里运行?
嗯,这取决于应用程序现有的线程模型或其构建所基于的框架。以下是一些示例。
- 专用工作线程:有时这是无法避免的。你创建一个特殊线程并在其中执行主应用程序循环。通过改变一些共享状态变量来示意它停止。
- Windows API 应用程序:你被赋予一个UI线程,并由你来处理消息循环。这是最方便的。消息循环等待函数被替换为峰值函数。这被称为PeekMessage循环。生活很美好——你可以只用一个线程来处理所有事情。例如,对于MFC来说,这不实用,因为它自己实现了Windows消息循环。
- Winforms.NET GUI 应用程序:你没有被赋予一个线程。如果你的模块不需要频繁访问控件 [D1],你可以使用 Winforms 定时器事件来执行循环的一个步骤,否则你将不得不实际创建一个工作(非 UI)线程。
在所有情况下,一个体面的UI框架都不会强制你创建一个独立于UI的线程。
TODO 评论给自己:扩展到其他平台。重叠IO、现有API。
理想情况下,应该有一个“等待中断”机制,所有重叠IO都将使用它来指示完成。例如,在Windows上,写入套接字、文件、串口和几乎所有其他操作都使用相同的API,该API支持四种类型的信号完成(APC、事件、完成端口等),而UI事件使用不同的机制(窗口消息),因为它是由不同的团队开发的,并且还有一个使用一个额外同步对象和API的独立套接字API。 窗口消息和内核同步对象可以通过一个函数MsgWaitForMultipleObjects()进行等待。
高性能应用
当使用Sleep(1)或事件等待函数放弃剩余的调度器周期时,可能会限制总的心跳速率。
可以检查 heartbeat() 的返回值,如果为 true,则在将控制权交还给系统之前,循环将再次运行。
需要做出的决定
-
[D1] 我需要频繁访问控制器吗?
-
如果你每隔一小段时间就有事情要做,你将不得不使用 Sleep(1)(主动放弃调度器时间片)而不是等待事件。
-
或者,心跳可以每隔一段时间由定时器调用。如果你能通过异步方式(见下文)触发通信事件来调用心跳,这可以是一个较长的时间段。
-
-
[D2] 如果应用程序有UI,我们的UI框架是否允许所有操作都在一个线程中完成?
-
一些 UI 框架,例如 Winforms 或 MFC,会自己实现 UI 消息循环,因此“一个 CPU 一个线程”可能无法实现。
-
幸运的是,Winforms 为单线程编程提供了一个很好的功能,称为 Control.Invoke()。它的意思是,在控件的 UI 线程上调用一个回调函数。
_listener.BeginAccept(new AsyncCallback((IAsyncResult ar) => { Invoke(processNewConnectionDelegate, ar); }), null);
生活很美好。尽管 BeginAccept() 不好,并且会在随机线程上触发完成回调,但这种构造会安全地将其路由到主消息循环。
Winforms 也有 Winforms.Timer(),它可以在 UI 线程中触发定时器事件。如果不是 D1,这是一个运行心跳的方便位置。
-
-
[D3] 我们能否在没有事情发生(中断)之前完全放弃控制,如果可以,如何放弃?这可能是一个不频繁的中断。
-
在这种情况下,您将在任何中断发生时对所有模块进行心跳。如果无事可做,它们应该快速返回。
-
- [D4] 我们是否被迫使用任何同步(阻塞)API?我们将不得不将它们封装到单独的线程中。同步意味着可能长时间不返回的API。被迫意味着我们100%确定这些API没有异步(重叠)替代方案。在这种情况下,重叠通常意味着第三方框架将在其自己的线程中或通过其他一些魔术来执行操作。或者,异步API将提供它自己的心跳函数,即一个需要不时调用的函数。