微软MR技术专家分享:AR/VR多线程处理的八年经验与技巧
关于多线程的经验分享
(映维网 2020年11月17日)多线程(Multithreading)是指从软件或者硬件实现多个线程并发执行的技术。具有多线程能力的计算机因有硬件支持而能够在同一时间执行多于一个线程,进而提升整体处理性能。
微软混合现实技术专家贾里德·拜恩兹(Jared Bienz)是一位著名的软件架构师,有着20多年的从业经验。日前,拜恩兹撰文分享了自己在AR/VR/MR多线程处理方面的八年经验和技巧。下面是映维网的具体整理:
要正确实现多线程并不容易,但它对于资源受限的移动设备流畅运行模拟至关重要。在供职于微软的生涯中,我有机会在四年多的时间里帮助合作伙伴为HoloLens编写高性能的应用程序。我另外有4年多的时间帮助合作伙伴为智能手机和平板电脑编写高性能应用程序。
我早已有意撰写这篇文章。这基本上是我对AR/VR/MR模拟的多线程处理的8年经验分享。尽管本文主要关注Unity和C#,但我希望其中介绍的概念依然能够为所有语言和运行时的模拟开发者带来价值。
1. 什么是线程?
我知道这是一个基础性的问题,但我从它开始写起是有一个重要原因。这个理由会在本章后面变得清晰起来。
维基百科将线程描述为:可以与其它指令并发执行的一系列指令。
我强调并发执行是因为它对这次讨论至关重要。并发运行多个任务的能力使得线程对于模拟至关重要。
2. 关于内核与线程的简要说明
一个CPU可以有多个内核,而有些内核可以运行多个线程。例如,Ryzen Threadripper最多有64个内核,每个内核可以运行2个线程。这意味着,如果你编写的模拟属于高度多线程,你可能会有多达128个不同的任务同时发生。你可以用这些线程来运行NPC的人工智能大脑,或者在物理模拟中制造碰撞。
但请记住,大多数实际场景不会接近128个线程。即使是英特尔的旗舰i9 10900k都只是提供20个并发线程。不过,编写多线程代码意味着提供多个内核的设备可以同时发生多个任务。
3. 线程如何影响应用程序
即使你不依赖先进的人工智能,但几乎所有的MR应用都在某种程度上使用物理。例如,Hand Menu菜单中的按钮会使用物理来检测指尖何时接触按钮的表面。
但远比物理更重要的是渲染。
几乎所有的游戏引擎(包括Unity)都依然依赖于单线程进行渲染。没错,只有一个线程可以在屏幕上绘制。即使是超底层的Directx API都只支持在辅助线程上排队命令。相关命令依然需要发送到渲染线程进行绘制。这是一个特别的线程。
正如你可以想象的那样,从渲染线程获取代码可以释放引擎以绘制内容。你将获得更高的帧速率,看到更少的卡顿和频闪。你的应用程序会感觉更加高响(高响应速度)和稳定。
4. 好吧,所以不要在Render Thread运行代码吗?
这听起来显然像是在逃避,不是吗?但事实证明,Render Thread是所有代码运行的默认位置。不仅如此,在Render Thread运行代码是不可避免的事情。为了说明原因,我们下面来看看一个基本的Unity立方体。
使得立方体成为立方体的主要原因之一是称为网格渲染器(Mesh Renderer)的行为。网格渲染器做什么?当然,它绘制立方体。换句话说,为了使一个Unity立方体成为一个立方体,它必须存在于Render Thread之上。
Unity通常将Render Thread称为主线程、应用线程、以及UI线程。请注意,它们都是同一个意思。
5. coroutine(协程)与线程
当Unity开发者发现coroutine时,大多数人认为他们已经发现了多线程。遗憾的是,事实远非如此。
Unity负责一位coroutine的博士指出:coroutine就像一个函数,它可以暂停执行并将控制权返回给Unity,但然后会在下一个帧中继续执行。
重要的是要意识到coroutine依然是在Render Thread上运行。
想象一下一个简单的Unity应用程序在这样的循环中运行:
如果行为A启动两个coroutine,则循环将简单地更改为:
coroutine和正则函数的唯一区别在于,coroutine的一部分可以在帧之间挂起。挂起时会包含存在关键字yield的任何行。尽管这可能会腾出时间让其他任务运行,但编写糟糕的coroutine依然非常容易给Render Thread造成巨大的负载。
coroutine异常:你知道在coroutine出现异常会发生什么吗?可能不是你想象的那样。异常不会停止应用程序,甚至不会禁用Behavior。唯一发生的事情是,coroutine从更新循环中unscheduled。Behavior不会注意到错误,甚至不知道coroutine已经被unscheduled。
由于coroutine不是并发运行,所以最好把它看作是一个时间切片机制。它们不是真正的多线程。
6. Thread.Start又如何?
我们终于聊到多线程的第一个实际选择。System.Threading.Thread实际上代表一个线程,而调用Thread.Start将导致任务在所述线程并发运行。
但对于Thread类,你需要理解Thread类的实例表示一个能够执行工作的对象,而不是请求完成工作。许多函数可以安排在Thread上运行,而等待Thread完成并不一定意味着函数成功完成。例如,异常可能会发生。
正是由于这些原因,通用Windows Platform(HoloLens运行的平台)甚至不包括System.Threading.Thread。相反,UWP提供了一种名为ThreadPool的元素,其中各个工作项可以进行scheduled。
在本文中,我不打算讨论Thread或ThreadPool,因为我希望重点讨论另一种方法。不过,我还是想简单地讲讲这些问题,因为过去使用Thread的Unity开发者会由于代码无法为HoloLens编译感到困惑或沮丧。Thread类可能会被添加到UWP的未来版本中,但我希望证明即使它可用,我们仍然有更好的模式可以遵循。
7. 回调中的“猫腻”
什么是回调?维基百科将回调定义为:作为参数传递给其他代码的任何可执行代码…这个执行…可能会在稍后的异步回调中发生。
编写“经典”多线程代码的开发者非常熟悉回调,因为一旦你开始并发运行代码,不知何故你需要知道它是于何时完成。
下面是一些关于回调如何工作的伪代码:
但如果代码永远都没有完成呢?如果因为文件被锁定或数据损坏而在第9行引发异常怎么办呢?
回调永远不会被调用。
如果没有额外的编码,应用程序将永远不会知道发生了错误。就应用程序所知,LoadData已成功运行。这是因为异常没有发生在LoadData中,而是发生在LoadData创建的线程中。
对于尝试编写和调试多线程代码的开发者来说,不停止(orphan)的回调一直是痛苦的根源。简而言之,这是因为请求、工作和结果是完全分离的。
回调与事件:请注意,回调模式有时可以作为事件实现。Azure Spatial Anchors在搜索锚点时会执行这一操作。应用程序调用CreateWatcher开始搜索,当找到锚定时,结果将通过AnchorLocated事件传回。这有时会导致意想不到的情况。例如,如果在服务器上撤销了锚定,则AnchorLocated事件将以NotLocateDanchordesNotExist的状态触发。另外,如果发生网络错误,应用程序不会知道,除非它同时订阅了Error事件。我并不是说这是一个糟糕的设计(见下文),但显然,成功地使用基于事件的回调系统需要了解哪些情况会导致哪些事件。
8. 什么是跨线程调度(scheduling)?
让我们再看看之前的伪代码:
你注意到第12行对loadCompleted的调用实际上是在worker线程中执行的吗?如果我们想在数据加载后可视化,这会成为一个问题。请记住,loadCompleted是在worker线程上运行的,但我们只能在Render Thread创建GameObject。这就需要跨线程scheduling。
在Azure Spatial Anchors for Unity示例中,你可以找到一个名为UnityDispatcher的脚本。UnityDispatcher允许在任何线程上运行的代码请求该代码在Render Thread上运行。你甚至可能在没有意识到的情况下看到了这一点。
以下是OnCloudAnchorLocated handler的代码片段:
每当ASA定位到一个锚时,AnchorLocated事件将在worker线程上触发。如果应用程序只需将消息写入日志,则可以接受这个worker线程。事实上这是更好的选择。但这个应用程序需要生成一个GameObject或移动一个现有的GameObject,这两个操作只能在Render Thread上进行。InvokeOnAppThread表示“我知道我已经在一个worker线程,但我需要调用在Render Thread运行的代码”。
9. Unity的跨线程调度
尽管所有多线程系统都有自己的scheduling方式,但Unity的方法有点不寻常。据我所知,Unity没有提供直接的API来调度渲染线程上的工作。他们提供的是一种间接的方式。
UnityDispatcher保留了需要在Render Thread上运行的命令的列表。当一个worker线程调用InvokeOnAppThread时,这只会将代码添加到列表中。当应用程序启动时,UnityDispatcher将自己注册为coroutine。然后在每个帧上,UnityDispatcher检查列表中是否有任何内容。如果是这样,所述代码将作为UnityDispatcher的Update例程的一部分执行。
UnityDispatcher没有绑定到Azure空间锚,因此你可以复制该类并在任何项目中使用它。如果没有ASA,你也可以从GitHub上的ThreadUtils项目中获取这个类的副本。
10. Task-based Programming
针对C++开发者的说明:我将要开始讨论Task-based Programming。我将介绍一个名为Task的C#,但你不会在C++ / WinRT中找到Task。相反,C++开发者使用IasyccAct之类的接口,而当从C#调用时,这些接口会自动转换为Task。更多信息请参阅这里。
如上所述,调试多线程代码非常困难,因为请求、工作和结果都是相互分离的。但我向你承诺过一个更好的方法,我想现在是时候讨论它了。
许多开发者都知道Task-based Programming,但很少有人真正了解它在幕后的工作原理。Task-based Programming统一了我们前面讨论过的概念,大大减少了多线程代码中出错的机会。下面我们来看看Task-based Programming是如何简化线程、回调和跨线程scheduling。
11. Auto Threading
在C#中,每当一个函数被async关键字修饰时,我们告诉编译器的是“这个代码可以在另一个线程上运行”
让我们来看看将数据保存到文件中的一些伪代码:
在本例中,打开文件、写入字节和关闭文件都将在worker线程上执行。
这个神奇的worker线程是什么时候创造出来的呢?它是在使用await操作符时创建的。
如果我们的示例应用程序具有以下代码行:
这相当于:
异步函数中的代码确实在新线程上运行。但你的应用程序不需要知道这些细节,也不需要太多地关注。
更妙的是,正如斯蒂芬·图布(Stephen Toub)常常说的:“等待的一个美妙之处就是它能把你带回原来的地方”。让我们看看这句话在另一个代码示例中的含义吧:
我们知道这段代码是从Render Thread开始的,因为它是对按钮点击的响应。所以在第4行与GameObject交互是有意义的。但我们同时知道,第7行的await关键字启动了一个新线程。所以,如何才能与第10行和第11行的GameObjects交互呢?
答案是一个叫做SynchronizationContext的元素。简而言之,无论何时使用await,编译器都会记住在worker线程启动之前有哪个线程正在运行。编译器同时会在worker线程完成后立即处理返回Starting Thread的操作。是的,await自动处理跨线程scheduling。
重要提示:await永不加塞。看起来像是await妨碍了Starting Thread,但这只是编译器的错觉。await之前的所有内容都是内联运行,而且await之后的所有内容都由调度程序运行。当Task在另一个线程中运行时,Render Thread就是这样保持绘制的。
12. 跟踪工作
正如我在Thread.Start一节指出地那样:线程表示一个能够执行工作的对象,而不是请求完成工作。这是Task-based Programming的另一个亮点。任何Task实例实际上都表示一个要完成的工作的请求。这正是Task类拥有IsCompleted和IsFaulted之类的属性。
13. 数据与异常
我在上面提到回调和事件可以在worker线程完成时返回数据。我同时提到了worker线程上的异常通常意味着回调不运行或事件不触发。Task-based Programming通过将数据作为请求本身的一部分来解决这个问题。
让我们来看看相同的LoadData函数,但我们将其作为Task而不是回调来实现:
让我们假设第7行执行一些数据反序列化。大多数时候,这一切都很好,但偶尔我们的应用程序会打开一个损坏的文件,第7行会出现一个异常。请记住,这个异常是在worker线程上引发的。那么,Starting Thread如何处理这个异常呢?比你想象的要简单:
当我们等待一个Task并且该Task成功时,来自该Task的任何数据都将返回到Starting Thread。但是,如果我们等待一个Task,并且在Task内部发生了异常,该异常将传播回Starting Thread,就像它是内联发生一样。换句话说,在Task中处理异常和在任何普通函数中处理异常都是一样的。
希望大家能够开始明白为什么Task-based Programming会使多线程变得更容易。Task-based Programming提供了一个单一的统一模型。在这个模型中,请求、工作和结果都真正地相互关联。
14. 当撤销(Cancellation)非常重要的时候
在某些情况下,Task可能会运行很长时间。例如,在慢速网络上下载大文件时。在这些场景中,使Task变得可撤销通常会很有帮助。可以通过将CancellationToken传递到异步函数来实现。然后,在运行一会后,所述函数可以在执行更多操作之前检查Token是否已撤销。
下面是所述函数的可能样子:
尽可能频繁地检查CancellationToken非常重要,这样可以快速撤销Task。调用ThrowIfCancellationRequested时,如果Token已被撤销,则整个Task以OperationCanceledException结束。
既然我们已经看到Task可以撤销,下面我们来想象一下使用Task的Azure Spatial Anchors:
我并不是建议ASA应该停止使用事件而开始使用Task。ASA可以同时搜索多个锚,而ASA从不知道何时(甚至是否)定位锚。事件在这种情况下的效果很好,只要你知道什么时候触发了哪些事件。但是,除了事件之外,添加对Task的支持可以帮助简化许多常见的场景。
15. coroutine还是有一席之地的
既然我们已经知道Task的作用,有人可能会问为什么我们要用其他方式编写代码。但请记住,Task在worker线程上运行,而GameObject只存在于Render Thread上。这就是coroutine的意义所在。
coroutine在Render Thread上运行,但可以将时间返回到渲染器。诀窍是在yielding之前确定工作量。太少会需要很长时间,而太多则会导致应用程序没有响应。
让我们想象一个能够接收数据并用GameObject可视化的coroutine吧:
为了保持60 FPS,应用程序需要在大约16毫秒内渲染帧。我们假设我们的应用程序需要4毫秒来渲染。剩下的12毫秒可以用来创建GameObject。
如果第10行需要2毫秒,我们就会剩下6毫秒的容量。不仅如此,我们的应用程序每帧只能创建一个GameObject。
在本例中,更好的实现可能如下所示:
在C#中,%运算符计算余数。所以这里我们说的是“每6个对象之后,把时间还给渲染器。”6个对象x每个对象2毫秒=12毫秒(正好是我们的预算)。
显然,这个数字对于每个应用程序而言都是独一无二,并且会随着时间的推移而变化。应用程序可能会变得更加复杂,需要更长的时间来渲染。或者每个单独的GameObject可能会变得更复杂,需要更长的时间来创建。没有神奇的数字。要达到正确的平衡,你需要花时间分析性能。
16. 将coroutine视作Task
所以coroutine有自己的用武之地,但现在我们有两种不同的方法来处理长时间运行的代码。不仅如此,除非我们实现某种回调,否则应用程序将不知道VisualizeRoutine何时完成(我们已经知道回调中的“猫腻”)。如果我们能把coroutine当作Task来对待,那不是很好吗?
有一个名为TaskCompletionSource的类允许你将任何长时间运行的进程表示为一个Task。具体如下:
- 在一个长时间运行的流程开始时,创建TaskCompletionSource。
- 使用TaskCompletionSource.Task表示长时间运行的过程。
- 完成后,使用TaskCompletionSource.SetResult返回数据。
- 如果进程遇到错误,请使用TaskCompletionSource.SetException来传播异常。
我们可以很容易地修改VisualizationRoutine以接收TaskCompletionSource,并在完成后返回一些数据:
剩下的只是启动coroutine并返回Task的helper函数:
17. coroutine中的异常处理
如果你仔细观察,你可能已经注意到上面的coroutine中有一个非常重要的遗漏。如果在第26行之前产生异常会发生什么事情呢?
遗憾的是,coroutine不能提供与async相同的编译器效果。coroutine中没有自动异常传播,这意味着如果我们不处理异常,我们将以产生一个不停止的Task。任何等待Task的代码将永远不会恢复。如果你认为这听起来很像是一个不停止的回调,你绝对正确。
你说:“没问题。我把所有一切都打包到一个try/catch block中。”
可能看起来像这样:
这正是你要做的事情,除了现在第24行生成了一个CS1626编译器错误。
错误CS1626无法在带有catch clause的try block中生成值。
CS1626出现的原因非常复杂,但你只需知道你不能将try/catch放在任何使用yield的行中。这给我们留下了两个可能的选择:
- 在任何非yield行周围放置多个try/catch block。
- 在IEnumerator周围放置try/catch
选项1最简单,但并非所有情况下都有效。例如,你不能将try/catch放在foreach语句周围,因为foreach语句包含一个yield。
但我们如何实现选项2?通常,IEnumerator直接传递到startRoutine。
遗憾的是,事情变得麻烦起来。IEnumerator接口有一个属性和两个函数。我们必须确保,若任何part-IEnumerator产生异常,我们就将结束Task。
为了帮助解决这个问题,我创建了ExceptionSafeRoutine。你可以在GitHub的AsyncUtils.cs中找到它。ExceptionSafeRoutine接受一个IEnumerator和一个TaskCompletionSource。如果在IEnumerator中引发任何异常,则在TaskCompletionSource设置该异常。还有一个扩展方法可以将任何IEnumerator转换为ExceptionSafeRoutine。
最后,我们更新Visualization Async以确保Task始终完成:
这种方法的酷炫之处在于,任何异常都会被传播。即使协程没有try/catch block。这使得coroutine的工作方式就像async一样。我们唯一要记住的是,在开始一个coroutine时添加.WithExceptionHandling。
18. 总结
如果你看到最后,希望你能够向我分享你的想法。你是否学到什么呢?有什么我需要补充或者遗漏的吗?或者你有什么其他更好的方案吗?