正如我们在前两篇文章中讲述的,枚举器实现的问题是:总有一方需要编写大量复杂的代码,有没有一种两全其美的办法?
接下来,我们将使用纤程作为我们的武器进行回应。在你决定在程序中使用纤程之前,请先阅读下本文末尾的”使用警告”这一章节。我的目标只是向大家展示纤程的一种使用方法,而不是说它可以解决你的任何问题。实际上,和它所解决的问题相比,它可能引发的问题可能还更多。我们稍后会回到”使用警告”这一章节具体来看看。
聪明的你肯定想到了,我们使用纤程解决这一问题的核心思想是:在调用者和枚举器实现者各自的堆栈上来使用纤程。具体我们先看看下面的代码:
这个帮助类对基于纤程的枚举实现进行基本的状态管理。 在构造时,它会保存使用枚举的纤程,同时还会创建将产生枚举的纤程。 在对象析构时,它会清理纤程资源。 这个类的派生类需要实现 FiberProc 方法,并且每隔一段时间调用 Produce 方法。
真正的技巧来自Produce和Next方法,请看下图:
当调用Produce”生产”东西时,我们会先记住”生产代码”,然后将枚举结果预先设置为其默认值 FER_CONTINUE,然后切换到消费者纤程。当消费者纤程返回结果时,我们从 Produce 中返回。
为了得到下一项,我们记住调用纤程的身份标识,然后切换到枚举纤程。 这会运行枚举器,直到它决定 Produce() 某些东西,此时我们获取生产代码并返回它。
这大概就是所有的实现了。m_fef 和 m_fer 成员用于跨纤程边界来回传递参数和结果。
好了,有了这个基础,实现生产者就不那么复杂了。由于我们想让消费者的实现也变得简单,我们在辅助类的帮助下使用了之前设计的接口,请看下图:
正如你所看到的,这个类是前两个类的混合。 与基于消费者的类一样,有关被枚举项的信息是通过调用枚举器对象上的方法获得的。 但与基于回调的版本一样,生成对象本身的循环是一个非常简单的递归函数,调用 Produce 来代替回调。
事实上,它甚至比基于回调的版本更简单,因为我们不必担心 FER_STOP 代码。 如果消费者想要停止枚举,消费者只需停止调用 Next()。
类中的大部分复杂性只是一些状态管理代码,用来允许提前终止枚举的过程。
好的,让我们把”这根光纤”拿出来试一试。 你可以使用与上次相同的 TestWalk 函数,但为了增加通用性,将第一个参数从 DirectoryTreeEnumerator* 更改为 FiberEnumerator*。 (这在下一次会变得很明显。)
不过,需要对主要功能进行一些调整,如下图所示:
由于枚举器要在纤程之间进行切换,我们最好将线程转换为纤程,这样它就有可以切换回来的东西了!
下图是运行此基于纤程的枚举器时发生的情况的示意图:
从纤程的角度观察,另一个与之对应的纤程只是一个子程序而已。
编码精妙之处:为什么每次调用 Next() 方法时都要捕获调用者的纤程? 为什么不在构建 FiberEnumerator 时捕获它?
下一次,我们将看到这个基于纤程的枚举器如何轻松地接受过滤和组合等高级操作。
使用警告
纤程就像炸药。处理不当,你的操作流程就会爆炸。
第一个可怕的警告是,纤程在地址空间方面非常昂贵,因为每个纤程都有自己的堆栈(通常为 1 兆字节)。
而且由于每个纤程都有自己的堆栈,因此它也有自己的异常链。这意味着如果一个纤程抛出异常,只有那个纤程可以捕获它。 (与线程相同。)这是反对使用 STL std::stack 对象来维护我们的状态的有力论据:STL 基于异常抛出模型,但你无法捕获另一个纤程引发的异常。 (你也不能抛出跨越 COM 边界的异常,这严重限制了你可以在 COM 对象中使用 STL 的程度。)
纤程的另一大问题是每个人都必须共同合作。你需要确定一个调用 ConvertThreadToFiber 函数的人,因为光纤/线程转换不计入引用计数。如果两个人在同一个线程上调用 ConvertThreadToFiber,第一个将转换它,第二个也将转换它!这导致同一线程有两条纤程,事情只会从那里开始变得更糟。
你可能会想,”好吧,如果线程没有被转换成纤程,GetCurrentFiber 函数不会返回 NULL 吗?”试试看:它会返回无效数据。 (令人惊讶的是,有多少人提出问题,甚至连最轻微的步骤都没有自己找出答案。你可以试着编写一个测试程序来测试看看。)
但即使 GetCurrentFiber 告诉你线程是否已转换为纤程,这仍然无济于事。假设两个人想在线程上进行纤程活动。第一个转换,第二个注意到线程已经是纤程(不知何故)并跳过转换。现在第一个操作完成并调用 ConvertFiberToThread 函数。哦,太好了,现在第二次转换是在没有纤程的情况下进行纤程活动!
因此,只有在你控制线程并且可以让所有代码就谁控制光纤/线程转换达成一致时,你才能安全地使用纤程。
“in cahoots”规则的一个重要结果是,你必须确保在纤程上使用的所有代码都是“纤程安全的”——甚至超过线程安全的安全级别。 C 运行时库将信息保存在每个线程的状态中:有 errno,当创建线程时会进行各种额外的信息记录,或者调用维护每个线程数据中的状态的各种函数(例如 strerror、_fcvt 和 strtok)。
特别是,C++ 异常处理由运行时管理,运行时在每个线程状态(而不是每个纤程状态)中跟踪此数据。因此,如果从 Fiber 中抛出 C++ 异常,就会发生奇怪的事情。
(注意:最近 C 运行时的情况可能发生了变化;我是根据几年前的版本进行操作的。)
即使你小心地避免使用 C 运行时库,你仍然需要担心使用的任何其他使用每个线程数据的库。它们都不适用于纤程。如果你看到对 TlsAlloc 函数的调用,则很有可能该库不是纤程安全的。 (纤程安全版本是 FlsAlloc 函数。)
另一类不安全的东西是Windows系统本身。 Windows 具有线程亲和性,而不是纤程亲和性。
总结
虽然纤程使用起来有诸多限制,但是它的确是一个非常开脑洞的设计。
但我应该不会在真实工程中用上它。
最后
Raymond Chen的《The Old New Thing》是我非常喜欢的博客之一,里面有很多关于Windows的小知识,对于广大Windows平台开发者来说,确实十分有帮助。
本文来自:《Using fibers to simplify enumerators, part 3: Having it both ways》