浅谈 C# 中的代码协同 (Coroutine) 执行支持
2024-07-21 02:19:11
供稿:网友
几个月前我曾大致分析过 c# 2.0 中 iterator block 机制的实现原理,《c# 2.0 中iterators的改进与实现原理浅析》,文中简要介绍了 c# 2.0 是如何在不修改 clr 的前提下由编译器,通过有限状态机来实现 iterator block 中 yield 关键字。
实际上,这一机制的最终目的是提供一个代码协同执行的支持机制。
以下内容为程序代码:
using system.collections.generic;
public class tokens : ienumerable<string>
{
public ienumerator<string> getenumerator()
{
for(int i = 0; i<elements.length; i++)
yield elements[i];
}
...
}
foreach (string item in new tokens())
{
console.writeline(item);
}
在这段代码执行过程中,foreach 的循环体和 getenumerator 函数体实际上是在同一个线程中交替执行的。这是一种介于线程和顺序执行之间的协同执行模式,之所以称之为协同(coroutine),是因为同时执行的多个代码块之间的调度是由逻辑隐式协同完成的。顺序执行无所谓并行性,而线程往往是由系统调度程序强制性抢先切换,相对来说win3.x 中的独占式多任务倒是与协同模型比较类似。
就协同执行而言,从功能上可以分为行为、控制两部分,控制又可进一步细分为控制逻辑和控制状态。行为对应着如何处理目标对象,如上述代码中:行为就是将目标对象打印到控制台;控制则是如何遍历这个 elements 数组,可进一步细分为控制逻辑(顺序遍历)和控制状态(当前遍历到哪个元素)。下面将按照这个逻辑介绍不同语言中如何实现和模拟这些逻辑。
spark gray 在其 blog 上有一个系列文章介绍了协同执行的一些概念。
iterators in ruby (part - 1)
warming up to using iterators (part 2)
文章第 1, 2 部分以 ruby 语言(语法类似 python)介绍了 iterator 机制是如何简化遍历操作的代码。实际上中心思想就是将行为与控制分离,由语言层面的支持来降低控制代码的薄记工作。
以下内容为程序代码:
def textfiles(dir)
dir.chdir(dir)
dir["*"].each do |entry|
yield dir+"/"+entry if /^.*.txt$/ =~ entry
if filetest.directory?(entry)
textfiles(entry){|file| yield dir+"/"+file}
end
end
dir.chdir(".."[img]/images/wink.gif[/img]
end
textfiles(“c:/”){|file|
puts file
}
例如上面这段 ruby 的递归目录处理代码中,就采用了与 c# 2.0 中完全类似的语法实现协同执行支持。
对 c# 1.0 和 c++ 这类不支持协同执行的语言,协同执行过程中的状态迁移或者说执行绪的调度工作,需要由库和使用者自行实现,例如 stl 中的迭代器 (iterator) 自身必须保存了与遍历容器相关的位置信息。例如在 stl 中实现协同执行:
以下内容为程序代码:
#include <vector>
#include <algorithm>
#include <iostream>
// the function object multiplies an element by a factor
template <class type>
class multvalue
{
private:
type factor; // the value to multiply by
public:
// constructor initializes the value to multiply by
multvalue ( const type& _val [img]/images/wink.gif[/img] : factor ( _val [img]/images/wink.gif[/img] {
}
// the function call for the element to be multiplied
void operator ( [img]/images/wink.gif[/img] ( type& elem [img]/images/wink.gif[/img] const
{
elem *= factor;
}
};
int main( [img]/images/wink.gif[/img]
{
using namespace std;
vector <int> v1;
//...
// using for_each to multiply each element by a factor
for_each ( v1.begin ( [img]/images/wink.gif[/img] , v1.end ( [img]/images/wink.gif[/img] , multvalue<int> ( -2 [img]/images/wink.gif[/img] [img]/images/wink.gif[/img];
}
虽然 stl 较为成功的通过迭代器、算法和谓词,将此协同执行逻辑中的行为和控制分离,谓词表现行为(multvalue<int>、迭代器(v1.being(), v1.end())表现控制状态、算法表现控制逻辑(for_each),但仍然存在编写复杂,使用麻烦,并且语义不连冠的问题。
一个缓解的方法是将谓词的定义与控制部分合并到一起,就是类似 boost::lambda 的实现思路:
以下内容为程序代码:
for_each(v.begin(), v.end(), _1 = 1);
for_each(vp.begin(), vp.end(), cout << *_1 << ' ');
通过神奇的模板和宏,可以一定程度降低编写独立谓词来定义行为的复杂度。但控制部分的状态和逻辑还是需要单独实现。
而 c# 1.0 中就干脆没有自带支持,必须通过《c# 2.0 中iterators的改进与实现原理浅析》一文中所举例子那样笨拙的方式完成。
以下内容为程序代码:
public class tokens : ienumerable
{
public string[] elements;
tokens(string source, char[] delimiters)
{
// parse the string into tokens:
elements = source.split(delimiters);
}
public ienumerator getenumerator()
{
return new tokenenumerator(this);
}
// inner class implements ienumerator interface:
private class tokenenumerator : ienumerator
{
private int position = -1;
private tokens t;
public tokenenumerator(tokens t)
{
this.t = t;
}
// declare the movenext method required by ienumerator:
public bool movenext()
{
if (position < t.elements.length - 1)
{
position++;
return true;
}
else
{
return false;
}
}
// declare the reset method required by ienumerator:
public void reset()
{
position = -1;
}
// declare the current property required by ienumerator:
public object current
{
get // get_current函数
{
return t.elements[position];
}
}
}
...
}
这种笨拙的 ienumerable 接口实现方法,实际上是将 stl 中提供控制状态的 iterator 完全自行实现,而且控制逻辑还限定于编写 ienumerable 接口实现时的定义。就算可以通过策略 (strategy) 模式提供一定程度的定制,但其代码逻辑过于分散,要理解一个简单调用必须查看四五处分散的代码。
好在牛人总是不缺的,呵呵。
ajai shankar 在 msdn 上一篇非常出色的文章,coroutines implementing coroutines for .net by wrapping the unmanaged fiber api,里面通过 win32 api 的纤程 (fiber) 支持和 clr 几个底层 api 的支持,完整的实现了一套可用的协同执行支持机制。
spark gray 的第 4 篇文章中就详细讨论了这种实现方式的利弊:
sicp, fiber api and iterators !(part 4)
纤程 fiber 是 win32 子系统为了移植 unix 下伪线程环境下的程序方便,而提供的一套轻量级并行执行机制,由程序代码自行控制调度流程。
其使用方法很简单,在某个线程中调用 convertthreadtofiber(ex) 初始化纤程支持,然后调用 createfiber(ex) 建立多个不同纤程,对新建的纤程和转换时当前线程缺省纤程,都可以通过 switchtofiber 显式进行调度。
以下内容为程序代码:
static int array[3] = { 0, 1, 2 };
static int cur = 0;
void callback fiberproc(pvoid lpparameter)
{
for(int i=0; i<sizeof(array)/sizeof(array[0]); i++)
{
cur = array[i];
switchtofiber(lpparameter);
}
}
lpvoid fibermain = convertthreadtofiber(null);
lpvoid fiberfor = createfiber(0, fiberproc, fibermain);
while(cur >= 0)
{
std::cout << cur << std::endl;
switchtofiber(fiberfor);
}
deletefiber(fiberfor);
上述伪代码是纤程使用的一个大概流程,可以看出实际上纤程跟上面 ruby 和 c# 2.0 中的协同执行所需功能是非常符合的。而在实现上,纤程实际上是通过在同一线程堆栈中构造出不同的区域(convertthreadtofiber/createfiber),在 switchtofiber 函数中切换到指定区域,以此区域(纤程)的代码和寄存器等环境执行,有点类似于 c 代码库中 longjmp 的概念。netscape 提供的状态线程库 state threads library 就是通过 longjmp 等机制模拟的类似功能。
而在 .net 1.0/1.1 中要使用纤程,则还需要考虑对每个纤程的 managed 环境构造,以及切换调度时的状态管理等等。有兴趣的朋友可以仔细阅读上述两篇精彩文章。
以下内容为程序代码:
class coriter : fiber {
protected override void run() {
object[] array = new object[] {1, 2, 3, 4};
for(int ndx = 0; true; ++ndx)
yield(arr[ndx]);
}
}
coroutine next = new coriter();
object o = next();
可以看到这个代码已经非常类似 c# 2.0 中的语法了,只是要受到一些细节上的限制。
而 c# 2.0 中,大概是为了保障移植性,使用了将控制逻辑编译成状态机的方式实现,并由状态机自动管理控制状态。其原理我在《c# 2.0 中iterators的改进与实现原理浅析》一文中已经大概分析过了,有兴趣的朋友可以进一步阅读 spark gray 的第 5 篇文章中的详细分析。
implementation of iterators in c# 2.0 (part 5)
以及 matt pietrek 的关于 iterator 状态机的分析文章
fun with iterators and state machines
而为了将行为与控制更紧密地绑定到一起,c# 2.0 也提供了类似 c++ 中 boost::lambda 机制的匿名方法支持。简要的分析可以参考我以前的一篇文章《clr 中匿名函数的实现原理浅析》,或者spark gray 的第 6 篇文章。
implementation of closures (anonymous methods) in c# 2.0 (part 6)
中国最大的web开发资源网站及技术社区,