您想阅读哪些案例研究主题?参与快速调查。

案例研究

  下载本文的PDF版本 PDF

案例研究

捕获故障:记录与重放调试方法

与 Robert O'Callahan、Kyle Huey、Devon O'Dell 和 Terry Coatta 的讨论

 

当 Mozilla 开始研发名为 rr 的记录与重放调试工具时,目标是生产一种实用、经济高效、资源高效的方法,用于捕获 Firefox 浏览器中低频率的非确定性测试失败。随后的许多工程努力都投入到确保该工具能够以最小的开销兑现这一承诺。

然而,出乎意料的是,rr 将会在 Mozilla 之外得到广泛应用——不仅用于找出难以捉摸的故障,还用于常规调试。

开发者 Robert O'Callahan 和 Kyle Huey 回忆了他们在创建和扩展 rr 时面临的一些更有趣的挑战,同时也推测了为什么它最近的受欢迎程度激增。O'Callahan 是 Mozilla 的杰出工程师,领导了 rr 的开发工作。Huey 在 Mozilla 工作期间也参与了 rr 的开发。他们后来都离开了公司,创立了自己的公司 Pernosco,专注于开发新的调试工具。

帮助引导讨论的还有另外两位值得注意的工程师:Devon O'Dell,谷歌的高级系统工程师,他从不掩饰自己对调试的浓厚兴趣;以及 Terry Coatta,Marine Learning Systems 的首席技术官。

 

DEVON O'DELL 您对其他调试器的哪些挫败感促使您开始研发 rr?

ROBERT O'CALLAHAN 最初的动机是 Mozilla 正在运行大量的测试——就像其他组织一样——其中许多测试都以非确定性的方式失败,这使得这些事情真的很难调试。当您大规模运行测试时——例如,每次提交都进行数千甚至数百万次测试——像这样低的失败率可能会变得非常烦人。

当然,您可以禁用这些测试。但实际上修复它们,特别是考虑到许多测试对应于底层的产品错误,似乎更有吸引力。因此,我们思考了如何做到这一点,并得出结论,如果我们可以构建一个能够记录测试失败,然后根据需要多次重放它的工具——如果我们可以构建它,那将是非常理想的——因为这将真正降低风险。显然,如果一个测试需要运行数千次才能捕获一个故障,您会希望它是自动化的。您也希望能够可靠地调试该故障。这就是我们最初的基本想法:我们如何以尽可能低的开销记录测试,然后拥有根据需要多次重放执行的能力?

TERRY COATTA 当然,这里最简单的解决方案是:“嘿,没问题。我只是记录这个处理器所做的所有事情。” 因此,为了清楚起见,在调试器的世界中,什么是“低开销”?

RO 问得好。从用户的角度来看,这仅仅意味着当您进行记录时,代码的运行速度看起来不会比不记录时慢。显然,要实现这一点,需要做一些额外的工作。我应该补充一点,为了比较,这个领域的许多工具会将程序速度降低 3 倍、5 倍、100 倍——甚至高达 1,000 倍——具体取决于技术。我们的目标是将速度降低到 2 倍以下。

当然,您选择的技术将对开销量产生重大影响。正如您刚才提到的,如果您要进行检测以便查看 CPU 所做的一切,那显然需要大量的工作。

DO 您认为 rr 方法的哪些方面让您实现了您所期望的执行速度?这与其他调试器有何不同?

RO 基本思想是几个系统共享的一个思想:如果您可以假设 CPU 是确定性的,并且您能够记录系统的输入——例如,虚拟机的访客或某些进程的输入——并且您还能够在重放期间完美地重现这些输入,那么 CPU 将执行相同的操作。希望这意味着您可以避免实际监控 CPU 的行为。这就是这个想法,无论如何。而且这也不是什么新想法。

因此,我们只记录跨越进程边界的内容——例如,系统调用和信号。跨越进程边界并不频繁,因为仅仅跨越保护域就会花费您时间。

KYLE HUEY 我们确实记录了非确定性状态的每次注入。因此,任何时候有系统调用或某种异步信号,我们都会记录下来。但是,虽然我们必须记录所有这些东西,但我们不会创建任何我们自己的东西。

RO 所以,事情是这样的:记录与重放往往是全有或全无的。这意味着您基本上需要捕获所有这些不同形式的非确定性。如果您遗漏了某些东西,那么您在重放时观察到的行为很可能会与实际发生的情况不同,这意味着您最终会处于非常不同的状态,因为程序非常混乱。也就是说,您真的需要确保您已经确定了所有这些非确定性来源,并记录了每一个来源。

TC 为了收集所有必要的信息,您对硬件有什么要求?

RO 首先,我们已经讨论过一个重要的假设,即 CPU 是确定性的,这样,每当您从相同的起始状态运行相同的指令序列时,您应该得到相同的结果、相同的控制流以及其他一切。这至关重要,显然,有些指令不符合这些要求——例如,生成随机数的指令。这显然是非确定性的。

这意味着我们需要一种方法来捕获任何非确定性行为或指令,或者至少有一种方法来告诉程序它们应该避免这些东西。例如,对于 X86 架构,您需要注意 CPUID 指令。因此,对于大多数现代 CPU 和内核,我们做了一些工作来确保有一个 API 可供您使用,以说:“嘿,对于此进程中的所有 CPUID 指令,您需要捕获并确保它们不使用 RDRAND。而是做您自己的事情。” 硬件事务内存也是如此,这是现代 Intel CPU 提供的功能。

问题在于,虽然事务本身是没问题的,但有时会由于虚假原因而失败。也就是说,您可能会选择运行一个事务,并且,看,它成功了!它没有中止!但是,如果您再次运行它,则有可能该事务中止,这归因于一些内部缓存状态或您无法看到或控制的环境中的其他因素。这实际上意味着我们需要告诉我们的程序避免使用事务内存。

我们依赖的另一件事——这真的非常重要——是有一种方法来衡量程序中的进度。这样我们就可以在重放期间与记录期间出现异步事件(如信号或上下文切换)完全相同的点交付它们。如果 CPU 是确定性的,并且您在记录时从某个特定状态开始执行一百万条指令,那么您应该在重放中最终处于相同的状态。但是,如果您在重放中执行一百万零三条指令,那么您很可能会最终处于某种不同的状态,这将是一个问题。

这只是想说我们需要一种方法来计算执行的指令数,理想情况下,这样我们就可以在重放期间,一旦执行了那么多指令就停止程序。这恰好是硬件性能计数器允许您做的事情,这意味着我们真的依赖硬件性能计数器来使这一切工作。而且,事实证明,这可能也是使 rr 以低开销工作的关键。硬件性能计数器基本上是免费使用的——特别是如果您只是计数,而不是中断您的程序进行采样。

虽然对我们来说非常幸运的是这些计数器是可用的,但我们也依赖于它们的绝对可靠性——而这才是困难的部分。基本上,您需要确保每次执行特定指令序列时,事件的计数都是相同的。这也意味着,如果您的计数器碰巧是一个计数已退役指令数量的计数器,那么它需要报告与实际执行的指令数量相同的已退役指令数量。这可能是一个很大的挑战,因为许多人使用性能计数器只是为了衡量性能,在这种情况下,此属性并不是真正必要的。如果衡量性能是您的用例,并且计数器偏差仅为几条指令,那没什么大不了的。但是,另一方面,我们实际上很关心这一点。残酷的现实是,很多这些计数器并没有准确地衡量它们所声称的那样。相反,它们会提供虚假的过度计数或虚假的计数不足。

这对我们来说是一个大问题。因此,我们不得不寻找一个可靠的计数器,我们可以将其与 Intel 设备一起使用,这是我们最初的目标。最终,我们找到了一个足够可靠的计数器——实际上是一个条件分支计数器。这就是我们现在用来计算已退役条件分支数量的计数器,我们发现它实际上确实完全按照制造商所说的那样工作。而这才是使 rr 成为可能的真正原因。如果我们发现没有计数器能够以这种方式准确,那么这个项目根本就不可行。

TC Intel 声称其计数器是准确的吗?还是它根本不提供任何保证?

RO 这是一个很难回答的问题。我可以告诉您的是,许多计数器提供略微错误值的情况都在产品数据表中记录为勘误表。因此,我相信 Intel 有人在乎——也许不足以解决问题,但至少足以透露问题。

TC 您是否担心您知道您实际上可以依赖的那个珍贵的性能计数器可能会突然变得非确定性?

RO 当然,我希望在主要的计算机科学出版物上发表文章可能有助于引起人们对这个非常问题的关注。

DO 您之前提到您在多线程程序中执行顺序明显缺乏确定性的情况下,您做出的确定性假设。我的理解是 rr 或多或少在单线程中运行所有内容以应对这种情况,但我认为可能还有更多的内容。

RO 为了清楚起见,我应该指出 rr 在单核上运行所有内容。这非常重要。我们将所有这些线程上下文切换到单个核心上。通过这样做,我们避免了数据竞争,因为线程无法同时访问共享内存。我们以这种方式控制调度,因此能够记录调度决策,并使用性能计数器来衡量进度。这样,上下文切换在重放期间变得确定性,我们不再需要担心数据竞争。

DO 这也有助于您解决诸如内存重排序之类的问题吗?

RO 是的。如果我们谈论的是弱内存模型,您可以通过将所有内容强制到一个核心上来确保一切都将是顺序一致的。因此,有一类内存排序错误在 rr 下是无法观察到的。

TC 您提到您要求硬件能够以某种方式确定性地衡量进度。但听起来您在这里也面临着一大堆约束。例如,听起来您需要能够与调度程序接口,以便理解或将所有线程映射到单个核心。

RO 我们不需要与操作系统调度程序集成。实际上,我们使用 ptrace API 来完成对这里发生的事情的高级控制。这是一个复杂的 API,具有许多不同的功能,但您可以使用它做的一件事是启动和停止单个线程。我们使用它来停止所有线程,然后只启动我们想要运行的线程。之后,每当我们的调度程序决定“嘿,是时候进行上下文切换了”,我们就会根据需要中断正在运行的线程,并启动另一个线程。然后我们将选择另一个线程并使用我们的调度程序运行它。本质上,操作系统调度程序没有任何事情可做。我们控制一切,这很好,因为与操作系统调度程序集成有点麻烦。

同样,至少在某种程度上仍然存在与操作系统调度程序的交互,即每当程序进入系统调用并且系统调用阻塞时,我们需要能够在我们的调度程序中切换线程。这意味着基本上搁置正在运行的线程以等待退出系统调用。同时,我们也可以开始运行另一个线程。我们需要一个操作系统接口,该接口会告诉我们线程何时被阻塞,并且基本上已在内核中取消调度。

幸运的是,存在这样一个接口。Linux 为我们提供了这些调度事件——实际上是性能事件。有一个真正的 Linux 性能计数器 API,它为我们提供了一种方法,可以在我们正在运行的线程阻塞并触发其中一个取消调度时收到通知。我们使用它进行事件通知。

TC 听起来您已经进行了这种超级详细的集成,这使您依赖于处理器的某些非常具体的功能。我想您也依赖于操作系统的某些有趣的部分。正如您已经暗示的那样,这可能会导致一些人们不一定期望的行为被揭示出来。这让我不禁想知道,围绕这些启示是否发生过任何有趣的事件。

RO 由于性能计数器必须完全准确的要求,一些内核更改出乎意料地伤害了我们。在最近的一个例子中,内核中进行了一项更改,最终短暂冻结了性能计数器,直到可以输入内核中断,这导致丢失了一些事件。

我们似乎是唯一真正关心这类事情的人。无论如何,我们最终发现了这个错误,并且,希望我们能在内核发布之前修复它。这类事情往往经常发生在我们身上。

KH 同样,我们在 Xen 虚拟机管理程序中发现了一个错误,该错误与 Intel 芯片中某个错误的解决方法有关,该错误可以追溯到黑暗时代,以至于似乎没有人能够完全确定最初是哪个 CPU 引入了该错误。那里发生的事情是,如果某些东西最终进入了性能监控单元的中断处理程序并被计数为零,它会自动重置为“1”,因为显然,如果您将其保留为零,它只会触发另一个中断。正如您可以想象的那样,这足以使 rr 崩溃,因为它会在计数中添加一个事件。我们花了大约一年的时间才找到它,这被证明是无穷无尽的乐趣。然后他们实际上从未修复它。

RO 另一方面,X86 架构的复杂性令人震惊。指令越多,出错的方式就越多。例如,有许多特殊指令用于访问某些奇怪的寄存器,以便您可以确保它们在记录和重放期间具有正确的值。并且,相信我,这可能被证明是非常重要的。一个寄存器让您知道您的进程是否使用了 1980 年代的 X87 浮点指令之一。对于我们的记录和重放目的,我们需要该寄存器中的值保持不变。如果它也恰好是正确的,那就更好了。有很多像这样的奇怪指令,这可能真的令人大开眼界——并且有点可怕。

KH 在 X86 领域,水面下肯定隐藏着很多复杂性。

TC 当我听您谈论这些时,我心想,“天哪,这听起来像软件开发人员的地狱。” 您有一些超级复杂的用户软件,它们依赖于一大堆其他超级复杂的水下软件位。这会让您感到困扰吗?您是否必须投入超人的努力进行测试?基本上,您是如何应对如此多的复杂性的?

KH 嗯,我们过去在 Web 浏览器上工作,所以相比之下这似乎很简单。

RO 确实,我们现在正在与之交互的东西有点疯狂且处于底层。另一方面,它变化得并不那么快。英特尔的架构修订版似乎并没有以惊人的速度出现——尤其现在不是。内核在这个时候的演进速度也相当缓慢。

由于 rr 纯粹是一个用户空间工具(这是我们从一开始的设计目标之一),我们依赖于一堆 API 的内核行为,但我们并不真正在意这些 API 是如何实现的,只要它们不破坏东西即可。内核也是开源的,因此我们始终可以确切地看到那里发生了什么。当然,硬件往往更加不透明,但它也更加固定。

尽管如此,我不得不承认,仅仅为了确保事物在架构修订版之间保持一致,就需要付出很多努力。以非常微妙的方式依赖如此多的事物,并且绝对无法控制这些事物,这也很可怕。但我们实际上投入了相当多的精力与 Intel 的人交谈,以引导他们朝正确的方向发展,或者至少阻止他们做肯定会破坏东西的事情。在大多数情况下,他们实际上非常乐于助人。

KH 我想补充一点,我们已经在硬件上进行了一些测试。当然,我们可以做的事情相当有限,因为当我们获得对任何新的 Intel 芯片的访问权限时,我们基本上已经致力于它了。值得庆幸的是,一段时间以来没有发布新的微架构,因此这并不是什么大问题。

我们还定期针对当前的内核版本运行我们的回归测试套件,并且我们最终会定期在那里发现一些东西——大约每季度一次。通常是一些相对良性的东西,但偶尔我们会发现一些更令人不安的东西:也许他们只是以一种对我们来说有问题的方式破坏了性能计数器接口。

 

rr 的一个反复出现的主题是,一种能力似乎会产生另一种能力。例如,一旦 rr 处理非确定性的能力得到明确证明,O'Callahan 和 Huey 就想到,甚至可以使用更多的非确定性来发现奇怪的边缘情况。通过这种方式,rr 不仅被用作调试器,还被用作提高软件可靠性的工具。

也就是说,通过将记录与重放与利用非确定性的能力相结合,很明显,可以通过对程序释放大量的非确定性来测试程序,以查看可能出现的后果。然后可以主动处理任何浮出水面的问题,而不是等待故障在生产环境中出现。

 

TC 您指出创建 rr 的最初动力是捕获在自动化测试期间难以重现的错误。但是现在,它是否也以其他方式使用?

RO 实际上,有趣的是,虽然我们设计 rr 是为了捕获测试和自动化,但它主要现在被用于其他事情——实际上主要是用于普通调试。在记录和重放的基础上,您可以通过在程序向前执行时获取程序的检查点来模拟反向执行,然后回滚到先前的检查点并向前执行到任何所需的状态。当您将其与硬件数据观察点结合使用时,您可以看到程序中的某些状态不正确的位置,然后回滚以查看负责该状态的代码。特别是对于 C 和 C++ 代码,我认为这几乎相当于一种编程超能力。

一旦人们发现了这一点,他们就开始使用它,并发现还有一些其他好处。那时 rr 才真正开始流行起来。在这一点上,Mozilla 和其他地方都有相当多的人开始使用它进行基本上所有的调试。??

rr 和一般的记录与重放更具吸引力的好处之一是,您可以在重现错误的行为和调试错误的行为之间获得清晰的分离。您运行程序,记录它,并且——通过这样做——您重现了错误。然后,您可以继续提供新的输入并像您喜欢的那样频繁地调试所发生的事情。这可能会导致一个持续一天,甚至几周的会话——如果您愿意的话。但是,在每次传递中,您都会更多地了解该测试用例中发生的事情,直到您最终弄清楚错误为止。这意味着您最终会得到一个与传统调试工作流程略有不同的工作流程。我认为人们会接受这一点,因为它很本能。

TC 是否还有另一种模式可以让您完全控制线程调度?作为您的调试工作的一部分,如何使用它?

KH 假设您关心软件的可靠性,一旦您拥有了处理非确定性的有用工具,那么相对明显的一件事就是尽可能多地向其投入非确定性。基本上,这意味着搜索执行空间以查找奇怪的边缘情况,这正是 Rob 试图通过混沌模式完成的事情。

RO 对。我们已经讨论了 rr 如何在单核上运行,以及哪些因素会影响程序在那里的执行。一个问题是,我们很难重现某些类型的错误。事实上,我们有一些 Mozilla Firefox 测试失败,它们只是间歇性地出现——并且,在某些情况下,仅在某些平台上出现。为了弄清真相,我们有时会运行多达 100,000 次测试迭代,但仍然无法重现失败。那时我们开始认为我们可能需要再进行一次传递,我们在其中注入更多的非确定性。最明显的做法是随机化调度程序,或者至少随机化它正在做出的决策。

这使我们能够研究例如需要哪些类型的调度才能重现某些特定错误。它还让我们探索了为什么 rr 没有生成这些调度。通过像这样的多次迭代,我们实际上设法实现了一种改进的混沌模式形式。

虽然我们重视在混沌模式下运行的这些好处,但开销很高。我们试图限制它,但无法消除所有开销,因此我们决定将混沌模式纯粹作为一种选择。尽管如此,通过打开混沌模式来运行测试,您很可能会发现一些更有趣的故障。

有趣的是,很多人报告说他们发现混沌模式对于重现通常很难重现的错误很有用。我们发现的关于使用混沌模式的另一件我认为特别有趣的事情是,许多间歇性失败的 Mozilla 测试实际上最终只在一个平台上失败——例如,Android 或 macOS。然而,当您调试它们时,您会发现它实际上是一个跨平台错误,它仅在一个平台上显示出来,因为它特别慢或具有某种类型的线程调度程序,使其有可能在那里重现该错误。我应该补充一点,rr 混沌模式通常可以使原本仅在 Android 上失败的错误然后在桌面 Linux 上重现。事实证明这相当有用。

TC 为什么不一开始就始终在混沌模式下运行,从而更容易浮现故障?

RO 如果您试图重现间歇性故障,显然建议使用混沌模式。但是将混沌模式与记录与重放工具配对也是明智的。我的意思是,如果您剥离所有记录与重放的东西,您仍然可以使用某种受控调度来重现故障。但是然后您将如何处理它们呢?

 

现在看来,为 rr 本身添加新功能的工作已经结束。但是,随着努力在现有的记录与重放技术之上构建新工具,以提供“新的调试体验”,其中开发人员可以超越一次检查一个程序空间中的一个状态,而是将所有程序空间表示在数据库中,以便然后可以通过重新设计的调试器查询该数据库以获取跨时间的信息,更新和扩展的故事将继续下去。构建这个新调试器的项目已经在进行中。

 

TC 回顾您构建 rr 的努力,您有什么会做不同的事情吗?

RO 是的。但我首先应该说,我认为我们总体上做出了良好的设计决策,因为我们的大多数赌注似乎都得到了很好的回报。这包括我们选择专注于单核方法进行重放,即使该决定受到了批评,因为它基本上将 rr 锁定在了大量且不断增长的高度并行应用程序之外。主要问题是我们仍然不知道如何很好地处理这些应用程序。我不认为在以现有硬件和软件以低开销记录具有数据竞争的高度并行应用程序方面,有人有任何线索。

通过将我们的重点主要放在单核应用程序上,我们设法在这些应用程序上做得相当不错。拥有一个对大量用户来说效果良好的工具很重要,即使它对某些其他用户来说效果不佳。与其做出更广泛的重点可能需要的妥协,不如缩小重点并在某件事上真正擅长。我对这一点毫无遗憾。

我也很满意不做程序代码检测的决定。每次英特尔发布一千条新指令,我们都不必担心,因为我们不必将所有这些指令添加到我们的检测引擎中,我仍然对此表示感谢。

但是既然您问到我们会做哪些不同的事情,我想如果我们今天开始,我们可能会用 Rust 而不是 C++ 编写 rr。但是除此之外,我对我们所做的核心设计决策非常满意。Kyle,您有不同的看法吗?

KH 并非如此。我想要做不同的事情只有在可以找到更理智的硬件和更理智的内核的不同宇宙中才有可能。 ??

TC 您认为随着您前进,rr 会发生什么变化?

KH 我们希望添加对 AMD 和 ARM 架构的支持,但这将需要他们两者的芯片改进。

RO 我们还需要改进对 GPU 的支持,因为当您的会话直接访问 GPU 时,rr 无法工作。为了解决这个问题,我们需要更好地了解 CPU 用户空间和 GPU 硬件之间的交互,以便我们可以弄清楚如何让 rr 记录和重放跨越该边界——特别是当涉及到记录任何 GPU 硬件对 CPU 用户空间的影响时——如果这甚至可能的话。

我认为有空间使用不同的方法构建记录与重放的替代实现,但就 rr 本身而言,它基本上已经到位了。我只是看不到添加大量新功能。相反,我认为未来将涉及在记录与重放技术之上进行构建,这是我们已经在探索的东西。基本思想是,一旦您可以记录和重放执行,您就可以访问所有程序状态。传统的调试器并没有真正利用这一点,因为它们仅限于一次查看一个状态。

未来在于被称为全知调试的新理念,它允许你将所有程序空间表示在一个数据库中,然后重新设计你的调试器,使其能够进行跨时间查询以获取信息。理论上,开发者应该能够使用它来立即获得结果。这就是下一个提升用户体验的前沿领域。

像 rr 这样的调试器实际上是朝着这个方向迈出的重要一步,因为你真正需要的是以低开销记录某些测试失败的能力,然后通过将其分配到多台不同的机器并结合结果来并行分析。 这样做的效果本质上是提供预计算分析,从而为开发者带来更快、更令人满意的调试体验。

TC 全新的调试用户体验?除了更快地交付结果,您具体指的是什么?

RO 使用传统调试器时,您通常会进行单步调试,对吧? 基本上,您想要追踪函数中的控制流。 所以,您一步一步地单步执行。 您会发现自己盯着一个非常狭窄的窗口,让您查看当前状态,然后您可以随着时间的推移对其进行操作,尝试构建控制流的图像。 这是您必须手动聚合数据点才能完成的事情。

相反,我们正在寻找的是能够让我们观察函数执行,然后在假设我们已经记录并存储了整个程序的控制流的情况下,查看一个函数并立即查看哪些行被执行了。 也就是说,您应该能够以直观的方式查看控制流。 当然,这将需要更多的调试器实现工作。

DO 听起来这方面的工作可能已经在进行中了。

RO 是的,我们已经实现了其中很多部分。 虽然尚未发布,但我们正在努力。

DO 关于相关的用户体验 [user experience] 方面,您是否预见到某种方法可以使人们更容易将协议的心智模型映射到代码?

RO 在以更直观的方式向开发者呈现动态程序执行方面,可以做很多事情。 例如,我们在新产品中所做的一件事是使探索动态执行树变得更加容易。 这样,您可以查看函数调用并请求从中动态调用的函数完整列表。 这与人们对程序的心理模型以及程序的工作方式非常吻合。 它也提供了一种探索程序的绝佳方式。

在这方面可以做更多的工作。 如果我们将关于诸如 mallocfree 等函数的信息构建到我们的调试器中,那么这将非常有助于人们准确理解他们的程序正在做什么。 我认为这将非常令人兴奋。

 

相关文章

异步世界中的调试
当您无法保证顺序执行时,可能会出现难以追踪的错误。 正确的工具和技术可以提供帮助。
Michael Donat
https://queue.org.cn/detail.cfm?id=945134

日志分析的进展与挑战
日志包含丰富的系统管理帮助信息。
Adam Oliner、Archana Ganapathi 和 Wei Xu
https://queue.org.cn/detail.cfm?id=2082137

沉浸于约束之中
Google Web Toolkit 是绕过 Web 开发障碍的一种方法。
Bruce Johnson
https://queue.org.cn/detail.cfm?id=1572457

 

© 2020 版权归所有者/作者所有。出版权已授权给 。

acmqueue

最初发表于 Queue 第 18 卷,第 1 期
数字图书馆 中评论这篇文章








© 保留所有权利。

© . All rights reserved.