Async/Await 本可以更好






4.85/5 (31投票s)
本文将解释 async/await 对是如何真正工作的,以及如果使用真正的协作式线程,它为什么会更好。
致已阅读本文的读者
如果您已经阅读了本文,只想知道更新内容,请点击此处。第二次更新在此处。
背景
我之前写过一篇文章《Yield Return 本可以更好》,我必须说,如果实现一个栈保存机制来做真正的协作式线程,async/await
本可以更好。我不是说 async/await
是一个不好的东西,但它可以在不改变编译器的情况下添加(使任何 .NET 编译器都能使用它),并且可以通过添加关键字来使其用法更明确。与上次不同的是,我不仅会讨论优点,还会提供一个栈保存器的示例实现并展示其益处。
理解 async/await 对
async/await 原计划用于 .NET 5,但它已在 4.5 CTP 中可用。它的承诺是使异步代码更容易编写,事实也确实如此。
但我的问题是:人们为什么要一开始就使用异步模式?
主要原因是:保持 UI 响应。
我们已经可以使用辅助线程来保持 UI 响应。那么,真正的区别是什么?
嗯,让我们看看这段伪代码
using(var reader = ExecuteReader())
while(reader.ReadRecord())
listbox.Items.Add(reader.Current)
非常简单,创建一个读取器,只要有记录,就将其添加到列表框中。但想象一下,它有 60 条记录,并且每个 ReadRecord
需要一秒钟才能完成。如果您将这段代码放在按钮的 Click 事件中,您的 UI 将冻结整整一分钟。
如果您将这段代码放在辅助线程中,在将项目添加到列表框时会遇到问题,因此您需要使用类似 listbox.Dispatcher.Invoke
的东西来实际更新列表框。
使用新的 await
关键字,您的方法需要标记为 async
,并且您需要更改 while
行,如下所示
while(await reader.ReadRecordAsync())
这样您的 UI 就会有响应。
真是魔法!
您的 UI 仅仅通过调用 await
就变得有响应了?
那 ReadRecordAsync
是什么?
嗯,这就是复杂性真正所在。await
实际上是注册一个延续,然后允许实际方法立即完成(在按钮点击的情况下,线程可以自由地进一步处理 UI 消息)。await
之后的所有内容都将存储在另一个方法中,并且在 await
关键字之前和之后使用的任何数据都将存在于编译器创建的另一个类中,并作为参数传递给该延续。
然后是 ReadRecordAsync
的实现。这可能被认为是部分,因为它可能使用某种真正的异步完成(例如操作系统的 IO 完成端口),或者它仍将使用辅助线程,例如 ThreadPool
线程。
辅助线程
如果它仍然使用辅助线程,您可能会想它如何比普通辅助线程更快。
嗯……它不会更快,它可能会慢一点,因为它默认需要在进程完成时向 UI 线程发送一条消息。但是如果您要更新 UI,您已经需要这样做。
一些速度优势可能在于实际线程可能已经开始做其他事情(而不是什么都不做地等待),以及 Task
通常使用的 ThreadPool
,它会限制过多的并发工作项。也就是说,一些工作项需要结束才能开始新的工作项(包括 Task
)。使用普通线程,我们可能会有太多线程试图同时运行(远超实际处理器数量),这时让一些线程简单地等待启动会更快(而且太多线程会占用过多的操作系统资源)。
注意到显而易见的地方
独立于 ThreadPool
的好处和 async
关键字的易用性,您是否注意到当您在一个方法中放置一个 await
时,实际线程可以自由地做其他工作(例如处理更多的 UI 消息)?
并且在某个时刻,这个 await
将收到一个结果并继续?这样您就可以非常轻松地启动五个不同的作业。每个作业最终都将在同一个线程(可能是 UI)上继续运行。
不难将这些作业视为“轻量”线程。作为一个 Job
,它们启动,它们“阻塞”等待,然后它们继续。实际线程可以在“阻塞”部分做其他事情,但当一个实际线程进入阻塞状态时,CPU 也会发生同样的事情(CPU 在线程阻塞时继续做其他事情)。
这样的 Job 不一定有优先级,它们在其管理器线程中作为一个简单的队列运行,但每次它们完成或进入“等待状态”时,它们都会允许下一个 Job 运行。
所以,它们都将在同一个实际线程中运行,并且一个 Job 必须等待或完成才能允许其他 Job 运行。这就是协作式线程。
本可以更好
我一开始说过它本可以更好,那么,怎么做呢?
嗯,真正的协作式线程会做与 await
关键字相同的事情,但没有 await
关键字,没有返回 Task
,因此使代码更易于未来更改。
您可能认为使用 await
的代码已为未来更改做好准备,但您还记得我的伪代码吗?
using(var reader = ExecuteReader())
while(reader.ReadRecord())
listbox.Items.Add(reader.Current)
想象一下您将其更新为使用 await
关键字。此时,只有 ReadRecord
方法是异步的,所以代码最终会是这样
using(var reader = ExecuteReader())
while(await reader.ReadRecordAsync())
listbox.Items.Add(reader.Current)
但未来,ExecuteReader
方法(今天几乎是瞬时的)可能需要 5 秒才能响应。那我该怎么办?
我应该创建一个 ExecuteReaderAsync
,它将返回一个 Task
,并且应该用 await ExecuteReaderAsync()
替换所有对 ExecuteReader()
的调用。这将是一个巨大的破坏性更改。
如果 ExecuteReader
自己能够说“我要坐下来等待,所以让另一个作业代替我运行”会不会更好?
暂停和恢复 Job
所有问题都集中在这里,这也是 await
关键字存在的原因。嗯,我认为微软的人们对他们可以改变编译器来使用对象和委托管理辅助调用栈(有效地创建延续)感到如此着迷,以至于他们忘记了他们可以创建一个完整的新的调用栈并替换它。
如果您不知道什么是调用栈,您可能已经在调试器窗口中看到过它。它跟踪所有实际正在执行的方法和所有变量。如果方法 A 调用方法 B,然后方法 B 调用方法 C,它将记录在方法 C 中的确切位置,C 返回时将处在的位置,以及 B 返回时将返回到 A 的位置。
延续是这种的困难版本。事实上,简单地继续另一个方法很容易,问题是在方法 A 中创建一个 try/catch
块,并将一个延续放到 B 中,该延续仍然在同一个 try/catch
中。事实上,编译器将在方法 A 和方法 B 中创建完整的 try/catch
,两者都在 catch
中执行相同的代码(可能有一个额外的可被 catch 代码复用的方法)。
如果他们不是在延续中管理“辅助调用栈”,而是创建一个全新的调用栈,并用新调用栈替换线程调用栈,并在等待点恢复原始调用栈,那将简单得多,因为所有使用调用栈的代码都将继续使用它。不需要额外的方法或不同的控制流来处理 try/catch
。
这种替代调用栈就是我在另一篇文章中称之为 StackSaver
的东西,但我最初的想法是误导性的。它不需要保存和恢复调用栈的一部分。它是一个完全独立的调用栈,可以用来替代正常的调用栈(并在等待时或作为其最后操作时恢复原始调用栈)。这将是一个“单指针”更改来完成所有工作(甚至是一个 CPU 寄存器更改)。
理论虽好,但行不通
.NET 团队为了支持“编译器魔术”使 async 工作做了很多改变,我告诉您,如果我们可以简单地创建新的调用栈,我们可以获得相同的好处,而且代码更容易使用和维护,我们所需要做的就是能够在调用栈之间切换。
这看起来太简单了,也许您认为我遗漏了什么,即使您不知道遗漏了什么,所以您认为它行不通。
嗯,这就是我创建 StackSaver
模拟以证明它有效的原因。
我的模拟使用完整的线程来存储调用栈,毕竟目前没有办法在调用栈之间切换。但这只是一个模拟,它将证明我的观点。
即使是完整的线程,我也不是简单地让它们并行运行,因为那样会出现所有与并发相关的问题(并且将是正常的线程)。StackSaver
类与其主线程完全同步,因此一次只有一个运行。
这将给人一种感觉
- 调用
StackSaver.Execute
在“实际线程”中开始执行另一个调用栈; - 当在
StackSaver
中运行的动作结束或调用StackSaver.YieldReturn
时,控制权返回到原始调用栈。
我的 StackSaver
唯一大的不同是,任何使用 Thread
标识的东西(例如 WPF)都会注意到这是另一个线程。所以它不是一个真正的替代品,但它适用于我的模拟目的,并且已经允许在没有任何编译器技巧的情况下创建一个 yield return
替代品。
您没有看错,我没有犯错,默认情况下,StackSaver
允许 yield return
替代,而不是 async/await
替代。
使用 StackSaver 进行 async/await 替换
要将 StackSaver
用作 async/await
的替代品,我们必须有一个处理一个或多个 StackSaver
的线程。我将创建这样一个线程的类称为 CooperativeJobManager
。
它像一个永恒的循环一样运行。如果没有作业,它就等待(真实线程等待,没有作业等待)。如果有一个或多个 Job
,它会从队列中取出 Job
并使其运行。一旦它返回(通过 yield return
或完成)并且原始调用者重新获得执行权,它会检查是否应该将 Job
再次放入队列(作为最后一个)或不放入。
那么唯一的问题就是等待某事。当 Job
请求一个“阻塞”操作时,它必须创建一个 CooperativeWaitEvent
,将设置作业的异步部分如何真正工作(可能使用 ThreadPool
,可能使用 IO 完成端口),将自身标记为等待,然后 yield return
。
主调用栈在看到 Job
正在等待后,将不会再次将其放入执行队列。但是当实际操作结束并“设置”等待事件时,它会重新将作业排入队列。
就这么简单,这是 CooperativeJobManager
的全部代码
using System;
using System.Collections.Generic;
using System.Threading;
namespace Pfz.Threading.Cooperative
{
public sealed class CooperativeJobManager:
IDisposable
{
private readonly HashSet<CooperativeJob> _allTasks = new HashSet<CooperativeJob>();
internal readonly Queue<CooperativeJob> _queuedTasks = new Queue<CooperativeJob>();
internal bool _waiting;
private bool _wasDisposed;
public CooperativeJobManager()
{
// The real implementation uses my UnlimitedThreadPool class.
// I removed such class in this version to give a smaller download
// and make the right classes easier to find.
var thread = new Thread(_RunAll);
thread.Start();
}
public void Dispose()
{
lock(_queuedTasks)
{
_wasDisposed = true;
if (_waiting)
Monitor.Pulse(_queuedTasks);
}
}
public bool WasDisposed
{
get
{
return _wasDisposed;
}
}
private void _RunAll()
{
CooperativeJob task = null;
//try
//{
while(true)
{
lock(_queuedTasks)
{
if (_queuedTasks.Count == 0)
{
if (task == null)
{
do
{
if (_wasDisposed && _allTasks.Count == 0)
return;
_waiting = true;
Monitor.Wait(_queuedTasks);
}
while (_queuedTasks.Count == 0);
}
}
else
{
if (task != null)
_queuedTasks.Enqueue(task);
}
if (_queuedTasks.Count != 0)
{
_waiting = false;
task = _queuedTasks.Dequeue();
}
}
CooperativeJob._current = task;
if (!task._Continue() || task._waiting)
task = null;
}
//} will only work with real stacksavers.
//finally
//{
// CooperativeTask._current = null;
//}
}
public CooperativeJob Run(Action action)
{
if (action == null)
throw new ArgumentNullException("action");
var result = new CooperativeJob(this);
var stackSaver = new StackSaver(() => _Run(result, action));
result._stackSaver = stackSaver;
lock(_queuedTasks)
{
_allTasks.Add(result);
_queuedTasks.Enqueue(result);
if (_waiting)
Monitor.Pulse(_queuedTasks);
}
return result;
}
private void _Run(CooperativeJob task, Action action)
{
try
{
CooperativeJob._current = task;
action();
}
finally
{
CooperativeJob._current = null;
lock(_allTasks)
_allTasks.Remove(task);
}
}
}
}
有了它,您可以调用 Run
并传递一个 Action,该 Action 将作为 CooperativeJob
启动。
如果该动作从不调用 CooperativeJob.YieldReturn
或一些协作式阻塞调用,它将有效地直接执行该动作。如果该动作进行某种 yield 或协作式等待,那么另一个作业可以在其线程中运行。
现在想象一下,在您的旧 Windows Forms 应用程序中。在每个 UI 事件中,您调用 CooperativeJobManager.Run
来执行真实代码。在这些代码中,任何可能阻塞的操作(例如访问数据库、文件,甚至 Sleep)都允许另一个作业运行。就是这样,您拥有完整的异步代码,它没有多线程的复杂性,并且看起来真的像同步代码。
下载的源代码是在 .NET 3.5 中完成的,我确信它甚至可以在 .NET 1.0 下工作(可能需要一些更改)。
真正缺少的是 StackSaver
类,正如我已经告诉您的,在这个实现中它使用了真正的线程,所以它主要用于演示目的。
协作式线程优于编译器实现的 async/await 的优点
- 如果它像这里介绍的类一样,任何 .NET 编译器都可以使用。
- 如果今天不“阻塞”的一个方法未来开始“阻塞”,您将不会导致破坏性更改。
- 您将不会有更简单的延续风格,因为您可以简单地避免它。在任何需要延续的地方,创建一个新的
Job
,它可能会“阻塞”而不会影响您的线程响应能力。 - 调用栈将正常使用,避免了用于存储“状态”引用的 CPU 寄存器和已经用于调用栈的另一个寄存器,这应该会使事情快一点。
- 有了调用栈,调试会更容易。
编译器实现的 async/await 优于协作式线程的优点
我只能看到一个。它是明确的,所以用户不能说他们在编写同步代码时遇到了异步问题。
但这在协作式线程中可以通过标志轻松解决,这些标志将有效地告诉 CooperativeJob
它不能“阻塞”,如果进行了“阻塞”调用,则会引发异常。将一个区域设置为“此处不得运行其他作业”肯定比必须等待 10 次才能进行 10 次不同的读写更容易。
阻塞与“阻塞”
从我的写作中,您可能会注意到“阻塞”调用与阻塞调用不同。
“阻塞”调用会阻塞当前作业,但允许线程自由运行。真正的阻塞调用会阻塞线程,当它返回时,它会继续运行相同的作业。
当然,如果我们的框架充满了阻塞和“阻塞”调用,这可能会有问题。但微软已经通过 Metro 重新发明了一切(甚至 Silverlight 也有一个只支持异步的网络 API)。
那么,为什么不将所有线程阻塞调用替换为作业阻塞调用,并使异步软件编程像普通阻塞软件一样简单呢?
您喜欢这个想法吗?
那么请点击此链接并投票,要求微软通过栈保存器添加真正的协作式线程。
示例
我只做了一个非常简单的示例来展示真实线程阻塞调用与作业阻塞调用之间的区别。
我肯定缺少更好的示例,也许我以后会添加它们。不要让示例的简单性扼杀调用栈“切换”机制的真正潜力,它可以创建更好的异步代码版本、yield return
,并为协作式编程开辟许多新场景,使其更容易编写更隔离的代码,这些代码既可以扩展又可以为未来的改进做好准备,而不会造成破坏性更改。
POLAR - StackSaver 的首次实现
我终于展示了 .NET 本身的 StackSaver
的第一个版本(即使它是一个模拟),但这并不是我第一次展示这个概念的有效版本。我已经在我的 POLAR 语言中展示过它的工作原理。
该语言仍然是编译和解释之间的混合体,但它使用 stacksaver
作为真实的调用栈替换,并且使用 Job
概念而不是 await
关键字来实现异步调用相对容易。我没有确切的日期,因为我同时做很多事情(比如仍然适应一个新国家),但我可以保证它能够处理这样的 Job
,甚至无需知道如何处理辅助线程。
协程和纤程
当我开始写这篇文章时,我真的不知道协程是什么,也不知道纤程是什么。
嗯,此刻我真的在考虑将我的 StackSaver
类重命名为协程,因为这才是它真正提供的。而纤程是操作系统资源,允许保存调用栈并跳到另一个调用栈,是创建协程所需的资源。
我确实尝试过通过 P/Invoke 使用纤程来实现 StackSaver
类,但不幸的是,非托管纤程在 .NET 中无法真正工作。我真的认为这与垃圾回收有关,毕竟在搜索根对象时,.NET 不会看到非托管纤程创建的“替代调用栈”,并会收集仍然存在但不可见的对象。
无论如何,目前我将保留 StackSaver 和“Jobs”的名称,因为这类似于任务,但不会与 Task
类引起麻烦。
更新 - 尝试更好地解释
从评论中我了解到我没有给出最好的解释,人们对我的说法感到困惑。
如果您查看 StackSaver
的源代码,您会看到阻塞的线程。所以不要看 StackSaver
的代码。看它的想法
您使用委托创建一个 StackSaver
。当您调用 stacksaver.Execute
时,它将执行该委托,直到它结束或直到它找到 stacksaver.YieldReturn/StackSaver.StaticYieldReturn
。
当让出时,原始调用者返回其执行,当它再次调用 Execute
时,委托中紧跟在 YieldReturn
之后的语句将继续执行。这将产生与枚举器使用的 yield return
完全相同的效果。
然后 async/await
替换基于一种我称之为 CooperativeJobManager
的“调度器”。该调度器能够在没有安排作业时等待,或者在有安排作业时一个接一个地运行作业。
默认情况下,唯一缺少的是在作业等待时“取消调度”作业,并在异步部分获得结果时再次重新调度它的能力。这是通过将作业标记为等待并“yield returning”来完成的。调度器不会立即重新调度该作业,但当“等待事件”被发出信号时,该作业会再次被调度。
如果调度器使用 ThreadPool
,它将具有与 async/await
相同的能力,即在等待之后,作业可以由另一个线程继续。
如果这仍然不足以理解,我已经在考虑创建一个 C++ 版本的代码,它在 StackSaver
类中不使用 Thread
。但代码的其余部分(使用 StackSaver
的部分)将是相同的……我不确定 C++ 代码是否真的有助于理解这个想法。
关于我的提议方法更易于未来更改的更好示例
我说我的方法更易于未来更改,但例子太抽象了。这可能是造成困惑的原因之一。所以,让我们关注一些更真实的东西。
让我们想象一个获取 ImageSource
的非常简单的接口。该接口有一个 Get
方法,接收一个文件名。非常简单,但让我们看看两个完全不同的实现。一个在启动时加载所有图像,所以当请求图像时,它总是在那里并立即返回。
另一个总是在请求时加载图像。它不尝试进行任何缓存。
现在,让我们想象一下,当我点击一个按钮时,我获取所有图像(假设有数百张),为所有图像生成缩略图并将它们保存到一张图像中。这里就出现了异步代码的问题:如果图像加载是异步的,接口如何返回 ImageSource
?
答案是:接口不能返回 ImageSource
。它应该返回 Task<ImageSource>
。
最终,使用基于 Task
的异步代码,我们将
- 创建 100 个
Task
,即使在使用将所有图像存储在内存中的实现时也是如此。 - 将为生成缩略图的方法创建另一个任务。
- 最后,在保存时,会为文件保存生成一个额外的任务(即使我们不使用它,异步
Write
也会创建它)。 - 事实上,还有一些任务,因为打开和读取是两个不同的异步操作,就像创建和写入文件一样。
如您所见,这里创建了许多任务,即使实现将所有内容都保存在内存中也是如此。
可以将任务本身存储在缓存中(这样可以避免一些异步魔法),但是当从缓存中读取所有内容都在内存中的结果时,我们仍然会有更高的开销。
用我提出的“作业同步/线程异步代码”
- 创建一个作业来执行所有代码。
- 当缓存已加载所有图像时,100 次图像获取不会“阻塞”;或者,当使用另一种实现加载图像时,它们会阻塞
Job
100 次,而不是Thread
。 - 在以“同步”语义获取或加载所有图像后,它将正常执行缩略图生成,然后保存图像,再次“阻塞”
Job
。 - 然后方法结束,作业也随之结束。
总共的作业?1 个。如果使用所有图像都在内存中的实现,我们的代码将更快,因为我们将直接收到 ImageSource
作为结果,而不是需要获取结果的 Task
。
您仍然认为基于 Task 的异步更好吗?
如果您认为基于 Task
的异步会更好,因为它可能在需要时使用辅助线程,那么请再想想,因为基于 Job
的异步也可以。辅助线程(如果有)由真正的异步代码使用(加载或读取文件时,可以使用 IO 完成端口)。异步操作结束后,它应该请求调度延续 Task
(使用 Job
,它将被重新调度)。
如果图像加载本身可能使用硬件加速将字节转换为图像表示形式,因此也返回一个 Task
,那么 Job
也可以启动该硬件异步代码并进入休眠状态,在生成的图像准备好时返回其执行。
基于 Task
的方法的所有优点,我概括为可以稍后继续,无论是在同一线程还是在另一个线程上,都存在。大部分缺点(例如当 [ThreadStatic]
值不再存在时您会迷失)也存在。但是您所有的方法都可以继续返回正确的值类型(而不是 Task
)。
使用我提出的解决方案,如果某些代码可能最终调用同步或异步代码(例如可能直接返回图像或加载图像的接口),您不需要只为了确保代码在异步时能正常工作而生成额外的 Task
。只需让 Job
阻塞并在稍后重新调度即可。
希望现在更有意义了。
更新 2 - 与 Eugene Sadovoi 的讨论
与 Eugene Sadovoi 大量讨论后,我确信我解释得不够清楚。所以,对于那些仍然迷茫的人,我很抱歉。我真的试图省略一些东西,希望能让文章更短、更容易阅读,但显然我做到了相反。
而且,对于那些只想了解更多信息的人,我将尝试现在给出。所以,关于此事的一些新“观点”
任务与作业……或者,我可以说……作业 == 任务
Job 和 Task 这两个词不仅可能具有相同的含义,它们实际上是相同的。在我的整篇文章中,我试图用 Job 来表示一个协作式 Job,而 Task 则表示 .NET 类(Task
和 Task<T>
)。
但要让 Task
成为 Job
,唯一真正需要的是能够在任何时刻“暂停”的可能性。使用 await
关键字,我们只能在当前方法返回 Task
时暂停它。我们不能暂停当前方法的调用者。
如果 await
能够暂停当前的 Task
,无论是此方法返回的 Task
,还是直接调用此方法的 Task
,抑或是调用了未知数量的方法才到达当前方法的 Task
,那么 Task
将是一个 Job,而 await
将真正表示“让当前的 Task/Job 等待,并让当前线程去做其他事情”。
幕后
所以,我的整篇文章实际上是“幕后”。我们如何才能让当前的 Task
在任何时候暂停?
返回 Task
是一个实现细节。用户想要的是使用 await
关键字……而且,当使用它时,他们真正想说的是:在等待这个结果时,允许当前线程做其他事情。
以目前的编译器实现,方法不可能返回 void
并让调用者 Task
等待。它们让当前任务“返回一个延续以便稍后继续”。我认为这太多实现细节了,用户不想要这个。
通过协作式线程,它实际上基于某种栈保存/切换机制,我们确实可以让 Task
在任何时刻等待。它不需要注册一个延续并返回调用栈上的所有方法(如果需要,这些方法也需要注册延续)。它只需说:“立即等待,不管我在调用栈上有多少东西”,然后将延续代码作为下一条指令。这会影响许多其他方法(调用者),而不是当前方法。
最后,对用户有什么改变?
任务不会为任何可能 await
的方法创建。它们只在“关键点”创建。
对于 WPF 或 Windows Forms 应用程序,这意味着每个“UI 事件”都必须创建一个 Task
,这样它才能在任何时候 await
。
只要您不需要并行执行,您只需编写与异步子方法一起工作的同步代码。但是当您真正想要并行执行时,您可以在调用方法(将成为委托)上创建 Task
,并使用诸如 Task.WaitAny
或 Task.WaitAll
之类的东西。
好的……让我们比较一下
维护 - 我的解决方案作为任何阻塞代码工作,如果未来内部方法开始阻塞,则不需要更改。
学习曲线 - 由于您实际上不更改代码,因此易于学习。
速度 - 考虑到 Job 可以是一个“可暂停的任务”,对任务进行的所有优化都可以应用于 Job。
速度 2 - 考虑到状态机(由实际任务实现使用),您总是需要一点“成本”才能返回到方法中的确切位置,而使用栈保存机制,这是一个固定的时间(我甚至不确定是否有优化或特定的 CPU 命令来保存栈/寄存器)。
内存 - 我的方法可能会使用更多的调用栈内存,但它最终可能会分配更少的 Task 对象,因此甚至可能最终使用更少的内存。我在这里将实际实现和我的实现视为等效,没有哪个真正更好。
上下文切换 - 与任何 await 使用一样,只发生在操作系统将当前线程与另一个真实线程重新调度时(这是不可避免的),或者当实际“Task/Job”让出或进入某些等待状态时。
致编译器开发者 - 它不会要求其他编译器进行更改,因为任务将是一个“可暂停”和“可等待”的类。由于没有编译器技巧,因此一个编译器生成比另一个更好的状态机的可能性为零。一个编译器支持而其他编译器不支持的可能性为零。
此外,在完全相同的实现下,用户可能面临的所有错误都将相同,无论使用哪个编译器。使用基于编译器的技巧,可能会出现某些编译器有一种问题,而其他编译器有其他问题。