【翻译】探索Fibrers:协同多任务处理与无锁任务执行

6 阅读8分钟

原文链接:www.callstack.com/blog/explor…

作者:Mariusz Pasiński

好的,第三部分终于来了。

阅读第一部分《用Tracy剖析React Native内部机制实现巅峰性能》和第二部分《多线程并非免费午餐:可视化性能陷阱》

若您正在使用 React Native,想必已听闻"纤维"一词。在 React 术语体系中,这是核心团队为 React 16 引入的新协调引擎所起的名称。增量渲染与渲染暂停能力是其最重要的特性。

但"纤维"这个名称并非随意命名。让我们从协程开始这段探索之旅。

什么是协程?

或许你足够资深,还记得"子程序"这个概念;又或许你仍在编写使用subend sub的代码。可以将子程序理解为不返回任何值的函数——它们只是按顺序排列的指令序列(副作用),可通过名称调用。

我们早已习惯当今的计算模型。我们的计算机(包括手机)都是多任务处理机器。但早年间,即便使用单核处理器,我们仍能同时运行多个程序。当然,这只是表象...我们被误导认为这些程序在"同时运行",实际上它们正疯狂地进行"多任务处理"——每秒切换数百甚至数千次任务。

实现这种错觉有两种方式:

  • 操作系统为每个进程分配相同的quanta并切换运行进程;
  • 程序主动放弃自身时间片以让其他任务运行。

前者称为"抢占式多任务处理",后者称为"协作式多任务处理"。

那么这与今天的主题有何关联?协程正是实现此类协作调度的完美工具! 不妨将其视为可随时暂停("yield")并从原点恢复的子程序(或函数)。在Lua或Python等语言中,协程还能通过这种方式传递值。(趣闻:我正是以此方式在Lua中模拟了JavaScript的async/await!)

它有什么特别之处?简而言之,协程的开销要小得多。每次操作系统在任务(进程)之间切换时,都需要进行额外的记录和维护工作,这被称为"上下文切换"。使用线程(或多进程)时,此类上下文切换只能在特权模式(环0)下进行——即操作系统内核运行时。因此程序需要通过系统调用提升至内核层,这同样会消耗时间(增加延迟)。

关键优势在于:协程可在用户模式下完成切换,操作系统甚至可能毫无察觉!我们只需交换部分CPU寄存器(当然要备份原寄存器值)即可完成切换。具体哪些寄存器?这取决于架构,但多数ABI都规定了哪些寄存器是非易失性的,因此几行汇编代码就足够了。

本文讨论的协程属于带栈协程且具有对称性。简单来说,带栈意味着每个协程拥有独立栈空间,因此可在任意位置(包括嵌套栈帧)暂停。对称则表示所有协程地位平等,在"让出"(切换)时需明确将CPU时间分配给哪个协程。

女士们先生们,这正是React中的"Fibers"命名其实相当贴切的原因。

那么,关于Fibers呢?

在此语境下,“Fibers”即为“用户空间线程”。正如单台计算机可运行多个进程,单个进程可运行多个线程,单个线程亦可运行多个纤维。

实际应用中,当我们围绕协程构建调度器并加入同步机制时,便形成了纤维。

我们的目标是同时最小化等待时间和上下文切换开销。上篇文章中,我们看到线程争用导致的工作-等待比极其糟糕。通过解决"全局互斥锁"问题并采用纤维将所有操作"任务化",我们能进一步提升性能。

别急……

没有万能解药。稍有不慎,就可能自食其果!虽然我个人认为这不成问题,但我们的线程绝不能阻塞。这意味着我们不能使用"常规"锁或同步原语,也不能调用阻塞式操作系统函数——这将使I/O等操作失效。除非…… 你愿意接受潜在死锁风险的话。

这引出了关键问题:如何实现纤维同步?如何等待依赖纤维完成?答案是原子计数器!任务完成时,可递减其关联的原子计数器。例如当某个任务处理10项数据(类比map())并聚合结果(类比reduce())时,系统中将存在11个活跃任务。最后一个任务依赖前10项转换结果,可通过等待计数器归零来实现阻塞,对吧?此处我们完全不关心执行顺序。

waitForCounter(counter, 0);

这里发生了什么?如果条件未满足,我们将当前的"Fibers"移至等待队列,从纤维池中提取一个空闲Fibers并切换至其上,直到该计数器达到预期值。简直美妙!

若需简化,可轻松隐藏部分细节(如计数器),仅暴露单一函数——该函数能"查看"计数器本身:

waitForTask(task); /* reference or an opaque handle */

任务调度

任务是我们能够调度的最小工作单元。它本质上就是一个待执行的函数和用户提供的数据参数。

上例清晰地表明任务之间可能存在依赖关系,因此可以组织成某种(依赖)图或树结构。原子计数器会"通知我们"子任务何时完成,这同样非常便利。

WorkItem tasks[11];
/* populate tasks... */

Counter *counter = NULL;
addTasks(tasks, 11, &counter); /* kick start the jobs */

再次强调,您可自由修改表面API。若您更倾向于采用过程式风格构建任务,则可采用如下方案:

Task parentTask = scheduler->beginTask();
/* ... */
scheduler->addChild(parentTask, childTask);
/* ... add other children... */
scheduler->endAndKickTask();

随你喜欢。这种情况下,只需记得创建一个带有额外递增计数器的任务,该计数器将在 endAndKickTask() 中递减至正确值。你总不希望在声明完任务及其所有依赖项之前就让任务完成吧?

你需要的核心功能是:

  • 一个添加任务的函数;
  • 一个等待指定任务完成的函数。

理论上甚至无需等待所有任务的函数——只需创建一个包含所有任务的根任务,并等待其完成即可。

但没有I/O该如何生存?

这正是引入IO单子的缘由。好吧...不再讲Haskell笑话了😅

最简单的方案是为所有阻塞函数创建专用I/O线程。顺便一提,这正是libuv(Node.js底层库)在Windows上的实现方式——将它们视为中断处理程序,执行完必要操作(如读取文件/套接字)后立即生成新任务/递减计数器。

将线程固定到物理核心

每个核心运行一个线程能让我们充分利用CPU的计算能力。若超出这个界限(如前文所述),就会导致超额订阅,操作系统不得不切换线程——这可不好玩。

但遗憾的是,现实世界并不完美。其他进程甚至操作系统本身都可能中断我们的线程。偶尔某个线程会被驱逐,从而引发系统级连锁反应。缓解此问题的简易方案是将线程绑定至物理核心。多数操作系统(POSIXWinAPIOSX)均支持"CPU亲和性"功能实现此操作。遗憾的是,iOS似乎无法加入这个行列😔

太棒了,但性能分析部分呢?

我们的朋友Tracy确实支持纤维!我们只需在项目层面定义TRACY_FIBERS,并插入两个宏:TracyFiberEnter(name);(可连续多次调用)和TracyFiberLeave;。既然我们从头编写了调度器,这(以及维护名称指针)应该轻而易举。

我是在Windows API和Orbis SDK之间实现的纤维模型,但你不必效仿。虽然POSIX ucontext API已弃用,但若不愿编写汇编代码,随时可选用Boost.Context

具体实现中,我仅需在mtbFiberSwitch()mtbFiberConvertFromThread()函数中添加TracyFiberEnter()调用,并在mtbFiberConvertToThread()函数开头添加TracyFiberLeave;即可。

若所有操作正确无误,无论处理多少任务,我们的相对加速效果都将趋于平稳。

使用 Tracy,您还可以分析内存使用情况,理论上还能检测堆碎片问题。不过这些属于非常专业的领域,本文不再赘述。欢迎查阅官方文档,其中详尽介绍了 Tracy 的全部功能!

致谢

最后,我要特别感谢几位引领我深入理解多线程与无锁编程理念的导师:

以及众多未能尽述的贡献者。若非他们的研究,我至今仍停留在大学所授的知识层面。

若您想自行实现Fibers调度器,建议查阅上述最后三个链接。克里斯蒂安的演讲堪称该领域最佳入门指南。同时推荐观看Dennis Gustafsson在BSC 2025大会关于物理求解器并行化的演讲——利用高级状态(通过精妙的Scope封装)的方案着实令人着迷!