传说中不可能的任务:在C语言中实现协程

文/Lych

(本文假设你了解协程的概念、原理、作用与意义)

在标准C中引入协程,确实是不可能的任务,顶多做到Protothreads库的程度(呃……这也已经不是标准C了),其原理可参阅本人另一文章《最轻量级的C协程库:Protothreads》(以下简称《最》),不再赘述,这里要说的只是,在语言基础特性本身所限之下,这个库仍然存在的不足:
    1.协程中所用的变量都不能是局部变量,因为yield/resume操作实质上是该函数的返回和下一次调用,局部变量会失效。
    2.只能在协程入口函数中yield,不能在入口函数所调用的其他函数中yield。
其中1.的解决方法,就是协程用的一切变量都用外部变量。这点是相当的不河蟹,不过别急,解决2.的方法更加不河蟹呢。比方说协程入口函数是A,调用了另一函数B,而现在需要在B中yield,那么,函数A和B都要以协程的规范来打造:即嵌入一堆PT_*宏,不能有局部变量,而且A调用B的地方不能简简单单调用一下就完了,得是这样:循环不停地调用B并检查返回值,若B是yield出来的,则自己也得yield,等待下一次循环回来;若B是执行完毕出来的,自己才能出了循环,只有这样才能真正满足yield的语义。我想如果1.还有圣人可以忍受的话,2.估计是可忍孰不可忍了。

综上所述,要构建复杂一点的应用(比如本人心目中的基于协程和epoll的伪阻塞并发IO框架),Protothreads还是不怎么靠谱的。难道真是再一次证明了C与协程宿命般的有缘无分吗?

本着小强般打不死的精神,咱再来一次深刻的思考。导致1.和2.的本质原因,是协程没有自己的栈。只要有了栈,两个问题都迎刃而解了。标准C语言只有唯一的栈,函数怎么嵌套调用都脱不了其手掌心,而协程的世界里我们偏偏需要打破这个限制,比如协程A华丽地调用了10层嵌套的函数,协程B也是,C也是……而且我们需要在ABC……之间随意切换,而任何一个的10层栈帧都不能被干掉。这是唯一一个栈无论如何也完成不了的。这也是uC/OS-II的一个进程再怎么寒酸也不能不给分配一个栈的原因所在。

OK,现在焦点在于,如何在C中建立多个栈,并彼此切换。若能实现,再加上《最》文中所述的任意跳转功能,则实现靠谱的协程将不再存在任何障碍。显然标准C已经离靠谱太远了,那就干脆不标准到底吧——我们想要的无非是一个功能,就是对栈指针寄存器的任意读写。幸好,这对于一般编译器的扩展来说实在是小菜一碟。

理论障碍不存在了,现在来看这个演示。显然,首先我们需要将一些变量名映射到CPU寄存器:

register void *esp asm(“esp”);
register void *ebp asm(“ebp”);

这是GCC的扩展语法,不用理会太多,总之就是知道定义了esp和ebp两个变量,分别对应同名的寄存器而已。其他编译器如VC同样有此功能,只是语法不同。注意,我们用到的寄存器不止ESP一个,还有EBP。

以下结构体表示一个协程,需要保存ESP和EBP寄存器,并提供一个堆栈。这个堆栈的大小可有讲究,因为这是每个协程都有的开销,而协程往往一下子建立成千上万个。所以既要控制好开销,又不要让栈太小以至于溢出。

typedef struct {
    void *esp;                     // 保存自己的esp内容
    void *ebp;                     // 保存自己的ebp内容
    void *stack[0];               // 协程的栈
} Coro;

或许一点让人意外,协程结构体中居然没有保存当前执行点的指针变量,就是《最》文中那个接收&&label的变量。——这也是trick之一,这次我们要采用和Protothreads完全不同的方式来实现任意跳转,待后述。

每个协程在被创建的时候,都给分配一个对应的Coro结构体。唯一例外是那个没爹没娘、天生石头缝里蹦出来的主协程,我们还得特殊照顾一下,先给它老人家建立一个结构体。由于主协程已经有C语言本身的栈了,我们并不需要给其分配任何栈空间:

Coro *main_coro = (Coro *)malloc(sizeof(Coro));

另外,任意时刻都只有一个协程为当前协程,这需要一种指示:

Coro *cur_coro = main_coro;                            // 最初的当前协程自然是主协程

好了,关键的东西来了,注意,注意看这个函数,这是一切的核心:

void coro_switch(Coro *target) {
    cur_coro->esp = esp;                                  // 将栈相关寄存器保存进当前协程自己的Coro结构体中
    cur_coro->ebp = ebp;
    cur_coro = target;                                      // 使cur_coro指向新协程
    esp = cur_coro->esp;                                  // 将切换目标协程所保存的寄存器值装载到系统寄存器中
    ebp = cur_coro->ebp;
}

就这些,就能完成协程的切换,可能简单得出乎意料,至少,我们没看到任何goto或跳转之类的东西,它怎么就能把执行点换到另一个地方呢?
我们来点山寨风格图示,表示一下各个协程的堆栈,立刻就能弄懂这个问题。为了简化思路,我们暂且假设系统很单纯,只用一个esp来指示栈顶就足够了,且不管ebp的作用,一样能正确演示其原理:

协程1:【函数1的栈帧】【函数2的栈帧】【coro_switch的栈帧】<-赋值前,ESP指向这里
协程2:【函数3的栈帧】【函数4的栈帧】【coro_switch的栈帧】<-赋值后,ESP指向这里
协程3:【函数5的栈帧】【函数6的栈帧】【coro_switch的栈帧】

现象很明显:每个非活动协程的栈的顶端都是一个coro_switch函数调用。为啥一定是?——因为它不调这个函数也不可能发生协程切换,所以只要自己交出运行权的函数,一定会在栈顶留下一个coro_switch的栈帧。而这一个个的coro_switch栈帧,其中所含的返回地址显然是不同的,分别指向函数2、函数4和函数6,因为coro_switch是分别在以上3个地方被调用的。答案显而易见了,coro_switch函数就像一个跳板,因为在内部修改了ESP,就是说如果协程1调用了coro_switch,那返回的时候,根据esp所查到的函数返回值已经是协程2的了,自然会返回到函数4 中。一个随机跳转就这样偷梁换柱地完成于无形之中。coro_switch在执行过程中更换了自己的栈帧,却不会引起混乱,因为这些栈帧都是如假包换的、符合自己格式的栈帧,唯一不同的是局部数据的值变了。具体地说,变的只有两个:返回地址和形参target的值。前者正是我们的阴谋想要的,而后者则需要注意,不能弄混语句的顺序,即篡改寄存器之后不能再使用target形参(准确地说,target是变成了旧target协程上次交权的时候交给的目标 ——绕口ing)。

最后来简单解释下为何还要操作ebp。这纯粹是个底层细节,就是说x86的ebp寄存器是用来在函数的一开始保存下 esp的,这样在函数中间你尽可能发挥想象力构建程序,让esp参与逻辑,怎么折腾都行,最后要返回啦,来,把ebp的值恢复给esp,一切重归于旧,这才能用esp在栈上正确地找到返回地址。另一方面,由于esp可能变化,所以改用恒定不变的ebp来定位局部变量也更加方便。据实测,GCC根据编译优化选项不同,如果函数足够简单没有改变esp(coro_switch正是,但谁能保证以后不给这个函数扩展功能?),则返回时将ebp恢复到esp这一步可能省略。这时只篡改esp就已经完全达到目的了,但如果编译器没省这一步,篡改esp就会被原来的ebp给洗回来,导致还是返回到原来协程的上层函数,而不是新协程的。所以为了应对所有情况,就把ebp也一并篡改了。

至此,协程切换貌似完美解决了,只剩下一个自然会想到的问题,就是上述一切废话的理论前提:非活动协程栈顶一定是个coro_switch栈帧。那刚建立的新协程算不算非活动?如果算,那又没调过coro_switch,栈顶哪来的这个帧?

这个问题确实值得思考,而答案其实也不止一种,比如在Coro结构体中建立一个标志成员来指出一个协程是新建还是挂起,然后在coro_switch中判断,分别处理。这最大的缺点是使coro_switch函数变得复杂,引入了更多的编译器相关的不确定性,最终对可移植性产生不利影响。几经考虑,最终还是用另一个办法:在每个新建的协程的空栈之中,伪造一个coro_switch栈帧,让一个协程一建立,就装成一种“挂起”的样子。做到这点,需要先来分析一下coro_switch的栈帧的结构。我们知道C语言的一个完整的栈帧是这个样子的:

【形参1~n】【返回地址】【保存的上一个EBP】<-EBP指向这里【局部变量1~n】<-ESP指向这里

其中,形参1~n是由主调函数通过压栈产生,其顺序与调用约定有关,cdecl和stdcall是按照源代码书写的参数顺序从右到左,pascal则是从左到右。接下来的返回地址是由call指令产生的。从此处开始,程序执行点就跳到被调函数的第一条指令去了,在被调函数中再进行接下来的栈帧建立工作:首先要用EBP来备份ESP的值以在返回时恢复,但上层函数也同样这么用EBP,所以同时必须先将原来的EBP值压栈保存。备份后,ESP就可以随便折腾了,第一个要折腾的就是给局部变量开辟空间,一般通过给ESP减去(x86的栈向下增长)一个总量来一次性完成,而局部变量的顺序是无关紧要的,根据优化甚至可以重叠。至此,栈帧建立才全部完成。

OK,现在照猫画虎来伪造栈帧。具体到coro_switch函数,形参只有一个,不需要考虑顺序……实际上再想想,需要形参么?其实只要伪造半个栈帧就足够了,从返回地址那里开始。还有,在某种预谋下,这个函数一个局部变量也没有。于是工作简化为这样:

【返回地址】【保存的上一个EBP】<-EBP和ESP都指向这里

貌似简单,可现在问题来了:返回地址怎么填?——也就是说,谁调用了coro_switch?当然,没人调用它,从来没有过,我们这是在凭空无中生有。那让 coro_switch往哪儿返回啊?——我们刚才要怎么来着,把一个新建的协程伪装成一上来就交权挂起的。即然这样,那恢复的时候应该从恢复到哪?毫无疑问是入口函数的开头。ok,我们要的就是这个冒牌coro_switch返回到协程入口函数的开头,即使那里没有对应的call指令,我们也要做得跟真事似的,好像之前有个call在那。那啥也别说了,返回地址就填协程入口函数的入口地址吧(绕口令?),正好在C中获得这个地址一点都不难,那叫做函数指针。——我得承认在邪有暗香盈袖恶的路上是越走越远了,至此,C语言的法律已然面目全非。

#define STACKSIZE 1024

typedef void (*CoroEntry)();                          // 协程入口函数

Coro *coro_spawn(CoroEntry ent) {
    Coro *res = (Coro *)malloc(sizeof(Coro) + STACKSIZE * sizeof(void *));
    res->stack[STACKSIZE - 1] = coro_fini;        // 此行以后另有用途,暂且无视之
    res->stack[STACKSIZE - 2] = ent;
    res->esp = res->stack + STACKSIZE - 3;
    res->ebp = res->esp;
    return res;
}

这是个最简版的创建协程的函数。再简单不过,只是建立了一个向下增长的堆栈,并在其中置入了几个元素。按说完成后,堆栈应该是这个样子的(假设右边为低地址方向):

【coro_fini】【ent】【未知内容】<-ESP和EBP都指向这里

先不管coro_fini为何物,我们现在关注的是:按照阴谋,返回地址由ent填充了,使得再次切换到该协程的时候,就会“返回”进ent函数中。而 ent后边本来应该是保存的EBP来着,为啥是未知内容?——理由也很简单,因为入口函数的执行完成标志着整个协程的结束,不需要返回了,它根本没有上层函数,所以用不着为上层函数维护一个旧EBP。但编译器生成的指令序列,在函数返回之前,仍然会执行从栈中弹出内容到EBP的过程,所以这个空当还是要留出来的。弹出后EBP会变成未知内容,但无所谓。
新建协程后,如果用coro_switch切换过去,则该函数返回之时,就是进入协程入口函数之日。以后的步骤就中规中矩了,入口函数的代码会自己按部就班地再次保存EBP、备份ESP,建立局部变量,执行。即栈是这个样子:

【coro_fini】【保存的EBP,即未知内容】<-EBP指向这里【入口函数的局部变量1~n】<-ESP指向这里

当入口函数执行完毕后,按部就班恢复ESP,弹出未知内容到EBP,然后按照返回地址来返回。慢着!都说了入口函数不能返回,这个返回怎么处理?——协程的结束,意味着切换到另外一个协程,并永远不再切换回来,也就是说协程入口函数必须以coro_switch结尾。可是强求用户自己挨个手写这些调用也很不厚道,于是故伎重施,让入口函数也有“返回到别的函数头部”的本领,让所有结束的协程统一返回到一个函数里,在那里统一coro_switch之。这就是 coro_fini的由来!不用说,这也是个函数指针了。

吧,来看那个迟到的coro_fini函数:

void coro_fini() {
    Coro *target = 从哪个犄角旮旯随便拽出一个挂起的协程来;
    coro_switch(target);
}

啊,气着了吧,那行中文算什么?——不是不想把程序写全,而是这里边太有玄机了,全写出来就是另一个话题了,称为:协程的调度策略。简单地说,那行中文背后的东西要是完整实现,代码还要增加几倍,而且写法也不限一种,可以自由发挥。其要点在于一个协程交权的时候(结束也是一种特殊的交权)决定谁来接棒,可以每次都由交权者显式任意指定(对称协程),可以一些时候可以显式指定,另一些时候必须还给当初交给自己的人(非对称协程/半协程),也可以所有协程顺序轮转,或者优先级抢占,还可以跟协程间I/O挂钩等(跟OS进程调度本是一家),这样Coro结构体为了保存调度相关的信息,也要有相应的扩展,也可以根据具体调度方式编写出不同的协程操作原语:resume几乎等同于coro_switch,yield和coro_fini没有本质区别,协程间I/O、消息队列和带参数的resume/yield可以由coro_switch和提另的公共变量轻易组成。

不过怎样都好,也都是C世界内部中规中矩的事了,底层的冒险到此为止,我们篡改后的C世界已经具备了原生支持协程的能力,本文只作为一种原理演示。

顺便提下,实现C协程的方法也不止这一种。ConcurrentLua/Coco是另一个成品,而且用了多种方法,从嵌入汇编到context相关API到 Windows Fiber API。这都是为了可移植性,每种方法对应一种场合。本文所述的方法也尽量减少对编译器扩展的依赖,比如放弃了GCC特有的&&label扩展,而是使用更加通用的寄存器变量,也同样具备还算可以的移植性。这同时也要求coro_switch函数决不能被显式或隐式的内联,否则后果不堪设想。目前,此方案于跨编译器的限制基本总结于此,或许还会遇到各种各样新的问题,但至少这个本质问题得到了解决,我们可以无庸质疑这一点:C和协程在一起,不都是苦涩,也有快乐。

Posted in 编程与瞎想中 | 49 Comments

随笔:枚举、结构体、数据库与项目的应变性

文/Lych

  枚举
如果问C语言中最令人讨厌的数据类型是什么,我一定会回答:枚举。——广义上也包括bool,原因很简单,我不知道这些该死的数据到底占几个字节。我宁可用#define,不要说什么强类型与C语言纯粹性之类的鬼话,我只知道,#define与其他具体整型类型配合,可以精确控制这些数据的尺寸。——当然,以上观点出自主观,本人的主要工作内容是底层通信。

如果问脚本语言中最令人讨厌数据结构是什么,我也一定会回答:枚举。(当然脚本语言中一般没有显式的枚举类型,往往是由其他普通数据类型充任的,但对最终结果无甚影响。)与前述不同,此次的原因是枚举总是需要事先定义,而脚本语言最大的优势之一就是不需要事先定义,所以枚举破坏了脚本的优雅,与其精神格格不入。

至此我们了解了枚举是个多么讨厌的东西,然而还不到清算的时候,因为其罪状还远没有揭发完全。

开放枚举
如果一个枚举被定义后基本就不再变化了,我们且称之为封闭枚举。比如fseek的起点:SEEK_SET,SEEK_CUR,SEEK_END。封闭枚举虽然具备上述讨厌的特征,却好歹不会继续祸害人了,属于良性范畴。

与之相反的,有的枚举,其内容是随着软件的发展不断变化的,今天加个内容,明天改个名称,后天推倒重来。这种枚举多是跟业务逻辑具有紧密关系的,比如一个服务器可以处理N种请求包,需要用一个枚举来区分包的种类。这称为开放枚举,开放枚举是枚举中的极品,是人间的恶魔,是损害软件工程师身心健康的重要因素。

作为一种普通的、不起眼的数据类型,开放枚举本身貌似没有多大能量。确实,然而其特殊地位决定了,它是一种神奇的指示标志,一种巨大机器的小小面板。动态枚举的小小变化,背后是应用逻辑架构的山崩地裂。

枚举、字符串与符号
脚本语言以一种巧妙的方式来处理枚举。首先,枚举没有一定边界,天生都是开放枚举。其次,为了彻底消灭预先定义,脚本语言毅然决然地抛弃了数值类型,而是用字符串类型来充当枚举元素。这种方法其实是在无奈地模拟另一种古老而优雅的概念,那就是符号。是的,枚举这种事物本身就是对符号的拙劣的模拟。
当然脚本语言支持符号的也不少,支持字符串的更不用说,然而在具体项目中,采用丑陋的预先定义来实现枚举的,仍然比比皆是,相信他们决不都是为了提高性能。

结构体与数据库
结构体与开放枚举有时存在相同的意味。尽管结构体的变化导致的地薄雾浓云愁永昼震威力比开放枚举要差一个量级,但其存在的场合要比开放枚举多好多。它们一起为项目中活跃的地壳运动贡献着宝贵的力量。
结构体的存储介质从内存变为磁盘后,名称也随之改叫数据库记录,在这过程中其蕴含的能量不仅没有减少,而且有增加的势头。数据库结构的变化,往往被放大几倍后反映在程序中,要命的是,这个倍数往往还随着程序规模的增长而增长。
开放枚举和结构体/数据库,一横一纵,从两个维度上肆无忌惮地在应用程序中肆虐,随着程序规模的增长而变本加厉。支离破碎的大地,血红色的天空,天地之间唯有苍老而褴褛的程序员们,发出沙哑而绝望的嘶吼。

变化、再变化
程序员采用看上去很酷的技术,常常被管理层误解为某种技术完美主义,仅仅为了卖弄自己的知识,或者某种目光狭窄的执念。实际上,程序员是可怜的,因为他们真正想要的只是少浪费一些自己的生命。他们无非想要提高生产效率,好让本属于自己的私人时间可以用来陪女朋友,而不是加班。好的技术手段对于生产力的提高绝不只是几倍的问题,它关系到规模和变化上到一定程度之后,项目的生死。
枚举和结构体都是表象,其反映的本质是项目需求的无尽的变化。尝试用一个无敌详细的需求定义总结试图把一切都定下来,永远是一种空想。我们需要做的不是努力营造一个不会变的环境,然后在这环境里以不会变的方式来编程;而是放弃幻想,准备打仗,天生就在会变化的环境里,以会变化的方式来构造我们的项目的每一步。每当我们听到“这个地方肯定不会变”的说法,都要定式地先打个寒战,再继续思考。

应变,再应变
最简单有效的应变方式称为“推倒重来”,很多时候这招确实很有效,甚至不能称之为弯路,尤其在采用高效的脚本语言的时候。“都是完备的编程语言,脚本语言能做的,C++就一定也可以做”这句话听过了无数次,然而开发项目的工具仅仅“功能完备”就够了么?换句话说,脚本语言能快速推倒重来无数次还不影响项目进度,C++能做到么?
貌似在推销脚本语言……
可究竟为什么大型编译语言普遍缺乏应对变化的能力呢?因为它们编译吗?否。因为它们缺乏丰富的数据结构吗?否。想想,应用逻辑变化的时候,大型编译语言中究竟哪部分吃不消了?
是了,强类型。枚举和结构体其实都是强类型的体现。强类型的理念,是你每时每刻都知道该出现在每处的数据的类型。这种模型设想,业务逻辑千变万化,变化的也仅仅是数据的值,而其类型不会变化。
然而实际上是这么回事么?
所以说强类型是万恶之源,因为它抵触变化,它拒绝应变。脚本语言之所以被称为适合做应用业务逻辑,其根本在于弱类型。在强类型的体系里,为了达到同等的应变能力,产生了庞大臃肿的Java和C++,令人目眩的设计模式,与简单的弱类型相比,仍难望项背。通观STL和Boost,都在做一件事,就是抹消C++千方百计强调并引以为豪的强类型系统,让它看起来更像弱类型系统。
何苦。

回到数据库
有人告诉我:“数据库里尽量只存原始数据块,由程序解析,这样扩展性好”。扩展性指的是对其数据结构进行修改的时候,不用牵涉到数据库结构。换句话说,违反第一范式换来了程序应变性的提高。想也难怪,因为关系数据库本身就是一种发达的强类型系统。
一个类似玩笑的说法:数据库的每个表不要再千方百计设计字段了,而是用一个键值对的表来保存所有属性,用实体ID和属性名称来查得每个实体的特定属性,这样即使增减属性都用不着alter table。
这或许可以追忆到小时候做学生成绩管理系统,每人一条记录,我很苦恼:每个科目作为一个字段,字段值是分数,但科目也是不断变化调整的呀,难道要不断alter table?后来大人告诉我:要做一个专门的成绩表,用学号和科目号联合做主键。我恍然大悟,自以为得到了真谛。直到有一天忽然想起来,原来学生姓名、性别、班级、是否得过小红花、是否打过架、是否揪过女生辫子……这些属性,都可以看做科目。
在传说中的Web 2.0中,这有一个前卫的名字,叫标签。
标签式的数据库结构是应变的秘密武器吗?
至少有一点可以肯定,你不必担心这种高度琐碎的数据库的运行效率,因为表都简单到这份上了,用Berkeley DB吧。

回到主题
既然是随笔,想想就罢了,有个限度。目前,还是要回到那血色的天地之间,重整精神,继续与开放枚举和结构体拼命厮杀。

Posted in 编程与瞎想中 | Tagged , , , , , | 60 Comments

Lua的小问题:命令行参数

文/Lych

近期在Linker推荐下使用markdownLua版),无意中发掘出了一个小问题。

在Windows下,由于我已经将.lua扩展名的文件直接关联到了解释器lua.exe,所以我这样运行markdown:

markdown.lua test.txt

结果程序并没有解析我的源文件,而是不紧不慢地开始读标准输入。我误以为这个程序有个性,忽略参数,直接读标准输入,于是又这样执行:

markdown.lua < test.txt

于是程序直接崩掉。

我很是不解,在寻找各种解决方案(不堪回首)无效后,哭着去找Linker,结果他这样执行:

lua markdown.lua test.txt

成功之后,像看外星人一样看着我,我成功晕菜。

然后这我就纳闷了:lua markdown.lua和直接markdown.lua难道还有区别?于是我写了这样的测试test.lua:

for k, v in pairs(arg) do
    print(k, v)
end

然后在Windows下执行:lua test.lua aa bb cc
输出:
1 aa
2 bb
3 cc
-1 lua
0 test.lua

再执行:test.lua aa bb cc
输出:
-1 D:\lua-5.1.3\src\lua.exe
0 E:\temp\test.lua

晕了,果然邪门,我抑制住情绪,在Linux下做同样的试验,当然在test.lua第一行要加上#!/bin/lua
执行:lua test.lua aa bb cc
输出:
1 aa
2 bb
3 cc
-1 lua
0 test.lua

再执行:./test.lua aa bb cc
输出:
1 aa
2 bb
3 cc
-1 /bin/lua
0 ./test.lua

怀着悲愤的心情,我宣布发现了Windows下Lua的这个不大不小的问题。我不想去研究Windows文件关联的宇宙超级无敌霹雳本质机理学问之类,也不想就Lua的可移植性发表什么言帘卷西风论,只想发自肺腑地呐喊一声:

Windows这个脑残!

回忆起小明的名言:

要是世上没有Windows,还存在什么软件可移植性问题?

Posted in 编程与瞎想中 | Tagged , , , | 65 Comments

“落后的cout与前卫的printf”

文/Lych

(注:此标题格式借鉴于小明的“落后的return与前卫的finally”,根据小明的“明信片许可证协议”,其内容可以用作任何用途,但再发布时必须寄给他一张明信片,否则将会受到其口头上的鄙视和谴责。现本人经济能力所限无法寄出明信片,故已做好受鄙视和谴责的准备,特此声明。)

解决问题有两种方式,一是用复杂的方式解决简单的问题,二是用简单的方式解决复杂的问题。

前者比如:

cout << "int " << func << "(" << type << " " << param << " = " << hex << defval << ");" << endl;

后者比如:

printf("int %s(%s %s = 0x%x);\n", func, type, param, defval);

计算机技术里有一现象,每当发明很好很强大的新事物,总会最终发现,原来是几十年前就不稀奇的思想。

或许这可以归结为:从前计算机技术不发达,受限于硬件,但计算机科学没有限制,很发达,导致大量科学无法有效地实践,从而束之高阁,而现在技术发达了,硬件的限制放宽了很多,人们意识到可以加入更多科学了,但早已忘了当初的科学,所以倾向于把它们重新研究一遍,并以个小成果洋洋得意,然后忽然发现,这跟伟大的史前文明相比是多么的渺小。

但即使这样,最终能回归到来自史前的伟大思想潮流中去,也是好事。

但,现在的技术比那个时代发达了,生活在蜜罐里的现代人,在“先进”的表象下,究竟会不会迷失了自我,能否企及先哲们的高度?难道技术上也是“文章憎命达”吗?

本来很简单的东西,非要自作聪明地包装一下,笨重无比,曰方便,曰正规,曰架构,不仅我们小混混如此,大师也不能免俗。比如这cout——终于说到正题了。实例摆在那,无须多言。哪个更简明?更易懂?更方便?那个复杂的、有“架构”的,多了什么功能了吗?多了什么方便性了吗?多了什么扩展性了吗?——一句话,多了什么好处了吗?这程序也跟公司一样,一大了就臃肿了,办事效率就低了啊。

究竟为什么,他们当初非要用个东西来取代printf?因为printf无法统一各种I/O?否,fprintf,sprintf,配合fdopen、fmemopen之类,统一得天衣无缝,不妨对比iostreams如何跟socket配合。或是因为printf不具备类型安全?变参太过tricky?

本人愚钝,关于他们想干掉printf的原因,也只能猜测到这。若真如此,只能说,真是历史局限。首先,printf不具备类型安全,这不是printf的错,而是“类型安全”世界的错,是C语言本身的错。C语言不擅长做的事情很多,格式化输出只是其中之一。其他每种语言不擅长做的事情也很多,否则不会不断发明新语言。人们争论哪种语言好争论得累了,最后领悟到了DSL,或面向语言的编程(LOP)。当习惯了在代码中嵌入SQL、正则表达式、Lua乃至Cg,并为其带来的方便而享受的时候,蓦然回首,printf中的格式串,难道不是一种LOP?

OK,现在printf上升到了新的高度:它的格式串,就是一种嵌入的专属领域语言,一种微型的脚本,printf本身就是个微型的引擎!

古老的printf,先哲们朴素而智慧的思想的结晶,那些想干掉它的大大们,做了一件多么愚蠢的、逆历史潮流而动的事!

君不见,“正规”世界里来势汹汹的Java,在提供了眼花缭乱的输出数据方式之后,也最终引入了“落后”的printf。这仅仅是照顾C程序员的习惯吗?这和它一贯蔑视C程序员习惯的作风太不协调了。是的,这是Java对printf的投降,是对简单强大的DSL的投降,是欧洲重骑兵在匈奴轻骑面前无可奈何的败北。

同理,还有Python的“%”格式化运算符。

正是先哲们受限的硬件条件,使得他们必须去思考解决每件事情的最简单高效的方法,不留一点赘物。物质的丰富让我们这一代忘记了应有的智慧,浮躁取代了深思,臃肿取代了优雅。

我还想起了另一个例子。在绘图中,设置线条(笔)的样式,是实线还是虚线还是一点一划还是两点一划还是……,怎么设?我想一般人都习惯于到手册里去找那些诸如PEN_STYLE_SOLID、PEN_STYLE_DASHDOT之类的常量。这种麻烦且忽略不计,若是需要画一种库没有内置的样式,怎么办?或许库有提供添加新样式的方法?……算了,扔掉吧。

让我们来看OpenGL怎么做的,设置线条样式的函数是 glLineStipple(mult, bits)
这两个参数,没有一个是枚举常量。
第二个参数bits是16位整型,化成二进制,比方说是 “0001110101010110”,那线条的样式就是

“   --- - - - -- ”

的不断重复,即:0=无,1=有,每位对应一个像素。——那岂不是无法定义多于16个像素的重复单元?别急,另一个整数参数就是乘倍数,比如上例,如果mult=3,那样式就是

“         ---------   ---   ---   ---   ------   ”

你看这一个函数,让N多PEN_STYLE_*常量相形见绌,要是那库还有脸提供添加内置以外的新样式的功能,赶快得找个地缝钻进去。

在OpenGL中,设置线条样式只是个不起眼的小功能,对其核心业务意义不大。越是这种情况,也使得设计者越是倾向于在它上面不耗费心力,不细作架构,用简明的方法尽量高效地完成功能。这倒好,一不小心做了个经典楷模出来。我们也长了见识,多了个小窍门:简单高效的解决方案哪里去找?就到那些把这方面当成不起眼的小侧面的项目中去找。

Posted in 编程与瞎想中 | Tagged , , , , , , , , , | 134 Comments

干掉for循环的几种方法(二)

罪恶的for循环的第二种形态,是对一个列表中的元素进行累积处理,最终得到一个单值。这时不能应用map了,因为各轮循环已经不再是彼此无关的。这种情况又可细分为两种,依据是否需要保存临时状态。由浅入深,先来看最简单的例子——最简单,莫过于求总和:

def sum(t):
    res = 0
    for i in t:
        res += i
    return res

来,让我们再一次干掉for语句:

def f(a, b):
    return a + b
def sum(t): return reduce(f, t)

reduce函数登场了,这个函数(在有的语言中改叫fold)在FP世界中具有和map不相上下的重要性,用于解决一切“对列表元素进行累积”类型的操作,其对程序解耦的贡献与map类似。——实际上,出色的解耦正是FP整个领域的特色之一。

作为铺垫,我们再来看另一个累积的例子:统计文本中每个字母出现的次数。这次我们要的结果是一个dict,以字符做键,以出现次数做值。

def count(s):
    res = {}
    for i in s:
        if i in res:
            res[i] = res[i] + 1
        else:
            res[i] = 1
    return res

再次干掉for循环:

def f(acc, i):
    if i in acc:
        acc[i] = acc[i] + 1
    else:
        acc[i] = 1
    return acc
def count(s): return reduce(f, s, {})

这次的reduce变了点样,多了一个参数。这个参数是累积的初始值,如果说在前例的加法中这个初始值还有无皆可的话,此处它就是必需的了,而且,函数f的两个参数不再具有交换律。实际上,这才是更通用、更能揭示“累积”本质的格式,f的第一个参数是累积的目的,随着累积过程逐渐增长;而第二个参数则是每次要累加上去的东西。这打开了我们的思路,或许我们应该对“累积”这个看似简单的概念重新审视一番。带着这种想法,我们接下来看更复杂、更刺激的例子:LISP语法解析。

LISP的语法是什么?据民间传说只有一条,就是括号要配对^_^现在我们就按这个规则,来解析一段源程序:

src = '(sqrt (- (* b b) (* 4 a c)))'

所谓解析的结果,就是把这段文本转化为Python的嵌套list表示,就是解析结果应该是这样的一个Python数据结构:

[['sqrt', ['-', ['*', 'b', 'b'], ['*', '4', 'a', 'c']]]]

最外面多套了一层括号,是为了将添加根节点与添加子节点的操作统一,还顺便解决了多个根节点情况的解析。我们假设源代码中没有语法错误,就是说没有不匹配的括号,也没有多余的空格分隔符。照惯例,先由我们的批斗对象,for循环出场:

def parse(s):
    stack = [[]]                        #用来追踪生成的树的堆栈,栈顶为当前操作的节点
    buff = ''                           #文本缓冲区
for i in s: #遍历源代码的每个字符 if i == '(': stack.append([]) #在栈顶压入新的空节点 elif i == ')' or i == ' ': if len(buff) > 0: stack[-1].append(buff) #一个数据的结束,将缓冲区中的内容插入栈顶节点 buff = '' #清空缓冲区 if i == ')': top = stack.pop() #将栈顶节点弹出,次栈顶成为新的当前节点 stack[-1].append(top) #将刚弹出的节点插入当前节点 else: buff += i #普通字符,填充缓冲区 return stack[0] #栈里最后剩下一个元素,就是完整的根节点

尽管越来越复杂,但这也是一个不折不扣、中规中矩的累积操作,所以干掉for循环还是完全可行的,尽管可能要费点脑筋:

def f(acc, i):
    [stack, buff] = acc                    #累积量acc的数据结构变得复杂起来了!
    if i == '(':
        stack.append([])
    elif i == ')' or i == ' ':
        if len(buff) > 0:
            stack[-1].append(buff)
            buff = ''
        if i == ')':
            top = stack.pop()
            stack[-1].append(top)
    else:
        buff += i
    return [stack, buff]                    #将进行了一步累积操作后的acc返回
def parse(s): return reduce(f, s, [[[]], ''])[0][0] #我们最终要的结果只是累积量的一部分

哦,我们发现累积量(累加器?)有变成一个复杂的数据结构的趋向,而且,我们最终需要的结果并不是整个累积量,而是累积量的子集。换句话说,靠一个与最终值相同的数据结构已经没法完成累积了,必须引入一些附加的数据结构,在累积的各步骤之间携带数据。这些数据最终不再需要,但在运算中是必不可少的。在以前的for循环中,这些状态暂存装置一向是散落在循环体各处的,现在我们终于将其集中起来,有了个整体的认识。累积操作,就是一次次地从现有状态和当前输入得到下一个状态的过程,或者说,就是一个状态机。

与map不同,reduce不是那种适合转化成并行的操作,但却是对并行操作结果进行归约的不可缺少的一步。

到目前为止我们讨论的两个函数,或许让你想起一个更华丽的东西:Google的MapReduce技术。所谓的“从FP中获得的灵感”,不用再找,就在这儿。

(待续)

Posted in 编程与瞎想中 | Tagged , , , | 47 Comments

干掉for循环的几种方法(一)

本文有标题党嫌疑,主要想说的,呃……是FP。FP语言经常不提供for循环这类设施,因为这和FP的理念是相悖的,它们用各种其他强大而华丽的设施来取代之。追本溯源,for循环是极端推崇串行(见FPGA计算机杂思)的产物,是滥用串行的罪恶典型之一。这种罪行使程序员习惯于用同样的for循环来应对各种问题,于是渐渐丧失了辨别各种类型的for循环的能力。这使人们远离了计算的基础原理,往小里说,是对FP和并行计算学习曲线的陡化起到了重要的推进作用,往大里说,是阻碍了计算科学历史前进的脚步。

今天,让我们像Neo一样,拨开表象,来接触一下世界的本质。为了简单,我选择Python来演示。

先来简单的问题,数组初始化。下面的例子中,我们生成一个平方表:

def init_array():
    res = range(10)
    for i in xrange(10):
        res[i] = i * i
    return res

罪恶的for循环出现了。让我们看清这个罪犯的第一种形态:每次循环的运算彼此独立,互不相干。这其实是典型的适合并行处理的情况,也是进入并行世界的最简单的门槛之一,实际上OpenMP最基本的看家本领就是这个。FP中一提到循环,通常想到的是用递归来取代,这有时让初学者费解,这么简单个操作都写成递归函数岂不是很找抽?——的确,对于这种简单的情况,可以不麻烦递归出手。

def init_array():
    return map(lambda i: i * i, range(10))

或者,为了提高灵活性顺便提高可读性,写成这种形式:

def f(i):
    return i * i
def init_array(): return map(f, range(10))

map函数是所有FP语言中的重要内容,它使个体运算与群体处理过程解耦。比如,你想将平方表改成立方表,可以只修改f函数,而不用到init_array函数里面去找应该修改哪一行。

曾有人将记不住map的参数的顺序(究竟函数在前还是表在前?)当作FP学习曲线陡的因素之一,这里小声说一小窍门:函数式编程嘛,函数是第一公民!当然在第一个位置^_^

当然,在Python里面,使用map并不能让你的程序变成并发执行的,但有朝一日你会惊喜地发现,原来并行普及了之后,我的代码风格可以不用改。

相信我,一旦习惯了,你会一天不用map都难受。for循环?去死吧。

让我们以一个相对实用的例子来结束今天的话题:读取一个文件中所有的行,并去除行两端的空字符。本例的简单性还要感谢Python的潜规则,即一切可以列举的东西都是表。

def get_stripped_lines(filename):
    f = open(filename)
    res = map(str.strip, f)
    f.close()
    return res

(待续)

Posted in 编程与瞎想中 | Tagged , , , , | 17 Comments

最轻量级的C协程库:Protothreads

文/Lych

协程的好处不用再多说,作为与函数调用/返回相对的概念,它使我们思考问题的方式经历一场变革。现在我们关注的是C,由于C本身的特质,将协程引入其中将会是一 个挑战。无数先驱已经为这个目标抛了头颅洒了热血,于是我们有了libtask之类。而这里提到的,是一个堪称最轻量级的协程实现:Protothreads(主页:http://www.sics.se/~adam/pt/)。所谓最轻量级,就是说,功能已经不能再精简了,几乎就是原语级别的。——确实,这种最简带来了一些使用上的繁琐不便,但在打退堂鼓之前,先来看看它的优点吧:

  1. 不依赖任何库(包括C标准库和OS,是的,可以在bootloader里使用它),甚至本身都算不上个“库”,事实上整个实现都只有.h文件。
  2. 充上一条,.h文件共也只有5个而已,总共的有效行数也就100数量级(版本1.4)。
  3. 接着补充,那些行中大部分也都是宏定义,所以使用该库导致程序的膨胀基本可以忽略不计。
  4. 每个协程的内存开销只有一个指针那么大。

说实话,这种形式的所谓“库”的最佳使用方式,是去参考其源代码然后直接借鉴到自己的程序中。这么点代码就能实现协程的功能,其原理也就一层窗户纸。事实上Protothreads使用了两种方式来实现协程,你可以选择其中一种方式:

  1. 用switch语句来实现。
  2. 用GCC扩展语法来实现。

前者通用性好但低效,使用起来也有更多不便,后者相反。默认是前者,本人倾向于后者(后者MinGW也支持的),这归咎于用惯了GCC,而且后者从思想上确实更加简明,没有trick的意味。这里的原理叙述也以后者为主。

这个如洪水猛兽般的“扩展语法”,其实就是:可以把label地址保存到变量。label就是goto的那个label,就是那个人人喊打的goto。如下:

begin:
    printf("This is a message\n");
    /* goto begin; -- 我们本来应该这么用 */
    void *p = &&begin;
    goto *p;

&&不是取地址又取地址^_^而是扩展语法,这个运算符用于label,表示取其在代码段中的地址,就是说获得一个指针。指向代码段的指针,第一反应是函数指针,但这个不是,因为它并不指向一个函数的入口,而是指向其腹部。这种指针类型C中是不存在的,GCC也不想把事情搞大,整出个新数据类型来,于是用void *通吃了。这样这个值就可以当普通数据一样摆弄来摆弄去,最后靠goto *p,来从其他任何地方跳到这个地址来执行。

或许还记得,C的goto是不能跨越函数边界的,从理论角度这叫确保了单入单出的结构化编程,从底层实现角度,则保证了栈帧不混乱,即:如果goto到另一个函数的代码段中,但另一个函数的栈帧并没有准备好,栈顶还是当前函数的栈帧,那么目的函数在访问局部数据时候就会发生混乱。这种原来不可能发生的混乱,在这种扩展语法的支持下成为了可能。这是需要注意的一点,在使用扩展的goto语句的时候也要注意不要越过函数边界(当然,如果你BT到了解栈帧协议并试图手工建立栈帧的话,就当我没说^_^)。

Protothreads库对协程的实现,说来也简单,且看一个协程函数的示意:

int foo(struct pt *p) {
    PT_BEGIN(p);
    ……        /* 代码段1 */
    PT_YIELD(p);
    ……        /* 代码段2 */
    PT_END(p);
}

这个函数,在每次重入这个协程的时候都要被调用,靠这些PT_开头的宏,函数可以确定每次被调用时应该执行函数体的哪一部分。比如调用两次foo的话,第一次会执行代码段1,第二次则执行代码段2。原理如下:

结构体struct pt其实只有一个void *型成员,就是传说中那“一个指针的开销”,每个协程都有个对应的此物。该指针在初始化的时候被置NULL(由另一个宏PT_INIT在别处完成),在foo函数中,PT_BEGIN会检查这个指针,若是NULL,则表明是第一次启动该协程,什么也不做。接下来遇到了PT_YIELD,即协程挂起原语。此宏内部定义一个label,并立即将该label保存进pt结构体中。这样,此处可能有多种方式进入,一是顺序执行到此,二是从别处goto过来。这所谓别处,其实就在PT_BEGIN。如果它检查到pt不为空,则立即goto过去。现在PT_YIELD根据到达此处的方式做进一步判断,如果是自然执行到此,该挂起了,则立即reeturn出函数。否则,则是刚刚重入回来,继续执行下边的代码段2。这个判断是如何进行的?——靠一个标志位,PT_BEGIN每次被调用都首先置一个标志,而PT_YIELD则在label之前清除这个标志。这样,在label之后,PT_YIELD就可以据此判断,若标志没了,则是自然执行到此,若标志存在,则是从PT_BEGIN处goto过来的。——说穿了,就是setjmp的一个超轻量级版。

至于PT_END,其作用除了清除pt指针以外,主要是为了返回协程的状态。实际上PT_YIELD中的return也是带值的,之所以foo函数要声明为int,就是为了每次调用foo都能得到该协程当前的状态,是挂起了、结束了,还是中途退出了等等。
应该注意到了一点,就是既然每次重入协程都要重新调用foo函数,则说明foo函数中留不下任何状态,如果定义局部变量,则其内容都会丢失。嗯……这就是我指的“繁琐与不便”的主要所在吧,你需要让一切协程状态都以外部变量的形式存在,典型做法是封装成一个结构体,作为该函数的第二个参数。嗯,毕竟,C是接近底层的语言,让它自动背着你创建好多变量的副本,或者好多个协程局部的堆栈,还是不如你自己精确掌控对每块内存的使用,不是吗?毕竟不能用脚本语言的眼光来看C ^_^

现在,用这种方式创建了好多协程,那么接下来用一个简单的方式让它们运转起来,这个轮转调度简单得难以置信:

while (1) {
    foo1(p1);
    foo2(p2);
    ...
    foon(pn);
}

这就是调度器的主循环,只需要往复依次调用每个协程的入口函数即可。

以上叙述了Protothreads库的核心内容,实际上该库还包含了动态协程建立、协程间通信等设施,对于一个如此单薄的库来说,还是相当令人惊喜的。最后为了再次强调其单薄,在此列举一下其所有的头文件:

        lc-addrlabels.h        用GCC语法扩展实现的协程基础
        lc-switch.h            用switch语句实现的协程基础
        lc.h                  该文件存在的意义仅仅为了选择以上两者之一
        pt.h                 基于lc.h的协程设施的真正实现
        pt-sem.h               协程间通信(信号量)的实现
Posted in 编程与瞎想中 | Tagged , , | 8 Comments

家乡杂记:抚顺的“城铁”

最后一次坐抚顺的电铁是在大学时候,暑假末尾。这时有些同学已经为了各式各样的目的回到了学校,包括我。那时寝室里只有我和小黑两人,整个学校寂静安详,树木葱郁,阳光明亮,空气新鲜。来享受这个幽雅的环境是我提前回来的目的之一。小黑来自新宾,是和我关系最好的哥们之一,在平时开学的喧嚣中,多少算个沉默寡言的人,也只有在这一阵才有些机会好好聊聊。那几天我们在望花区里闲逛,作为本地人,我考虑适合带他去玩的好地方的时候,忽然记起了记忆中尘封的,抚顺的电铁。

北京地铁5号线开通那一阵,大家都在讨论着地铁,偶尔提起中国都有哪些地方有地铁或城铁的问题。当大城市们被一一提起的时候,我脑中浮现第一个清晰的印象,却是抚顺那古老的电车,在闹市之外,在密草之间,寂静,悠闲。那朴陋的车厢,古旧的站台,及其顶棚挂着的指示板上那发着幽蓝的光的字迹。与地铁所象征的快节奏的都市生活相比,这里完全是另一个世界。

同龄的抚顺人中,对于电铁列车,一般来说早已没有什么记住的理由了,而我却是特殊的一个,因为我从小就在有电车的环境中长大,家人的职业都和抚顺矿务局有关,外祖父的职业甚至就在电铁。在我不懂事的年纪里,电车的运输是繁忙的,车厢挤的满满的,而我无论是去妈妈的雕刻厂去玩,去姥姥家,还是仅仅去最喜欢的劳动公园再玩一天,都要坐电铁列车。那时票价还是4角,车票是白纸印成的,下端撕下来是报销凭证,俗称“票根”。这种样式到今天也没有改变,只是票价变成了8角。不管去哪里,在这样的站台上等车,看着这种很像火车的列车进站出站,以及坐在车窗边看着街景飞驰而过,都是最让小孩子兴奋的事情。

电铁列车的车厢有好几种类型,和国铁的火车相似,但又完全不同。有的车头也是个车厢,有的车头是各种奇形怪状,典型的像个坦克,较长的鼻子和尾巴夹着一个凸出的驾驶室。在大线路上支起车顶的电弓取电,在小的线路上则将一个小横杆状的电弓转开90度以接触铁轨一侧的电线。这种车头启动和停车一般都要发出一声排气的尖啸,而那种和车厢成为一体的车头就不用。开车的指示哨声、排气啸声和鸣笛声这定式的三响,意味着新的出发,让孩子们热血澎湃。

电车的内部布置,和火车的硬座车大有不同,而是有的像公交车的横排座,只是宽敞不少;还有的像地铁,贴着车厢两边两溜长椅。这和车头甚至还有对应规律,前者一般是一体化车头,而后者多是坦克式车头的拖车。规律还体现在车厢外观颜色上,前者多是类似国铁的那种绿皮车,后者则一般以白色为基调,加上橙色或蓝色的横条。不管哪种车厢,门都是很宽敞的,类似地铁,而不是火车那种局促的小门。与地铁不同的是,这门是全手动的,开关、上闩都要自助,也基本没有列车员管辖。所以你要体会飞一般的感觉,大可在开车后仍不关门,站在门口凭风吹过。只要不是寒风刺骨的冬天并且门闩没有失灵,也没人会反对你。我受到的绝大多数反对则来自母亲,怕我掉下去。

那时姥爷还没有过世,姥姥家在千台山。千台山据说是开挖西露天矿大坑所出的土所堆成的。西露天矿大坑,这亚洲第一大坑着实是很壮观的,只是这电车的路线就是绕着它转,一路想不看都难,日日相见,也觉得稀松平常了。观看这个大坑,本来古城子有个小小的参观台,就在母亲的单位旁边,所以我算是没事就来旅游的,还获赠了一打西露天矿主题明信片。然而要说参观这个大坑,又何须只在这小小的一台,只要坐上电车,好多时候比这看得清楚,这地方的意义也就在于有个官方接待之处。然而这地方确实太小,于是千台山顶开建了另一处参观台,三层建筑,美轮美奂,周围院落景观一应俱全,公路也一直通到了千台山主峰。后来大概因为这个建筑不在最高的位置,还要在最高处再建一个圆塔状“晚望台”,只是直到这整一片景观的没落,也没有建成。姥姥家就在主峰脚下的千台山住宅小区,这里的楼房都只有三层高,还绝大多数时间停水。这种地势普通的自来水上来是很有难度的,平时用水要从若干大缸里舀,这些水来自短暂给水期间的猛蓄,或者姥爷用扁担和两个大白铁皮水桶,从供水点挑回来。

我没有找到任何一处记载,那时的电车是通到千台山小区的。抚顺电铁本来就是地方铁路,而古城子到千台东这一段则是地方铁路的地方铁路。这条路只有一列车,绿皮,两头都是车头,是兼载客那种,连车头算上共四节车厢。当时我是在姥姥家的阳台上,亲眼看着铺路机将这一段路轨铺起来的。这趟车的站名我还背得出来:千台东,千台,缓坡口,矿大楼,古城子。每一站连一分钟都还不到。这车被附近居民称为“小电车”,一度车帮上挂过一个横幅,上书:“事故为零,重奖重罚”。我是问的姥爷才认得了这些字。小电车在缓坡口站和矿大楼站之间其实还有一个停站,不为载客,只为变换一下方向。上下千台山的坡是很陡的,跟詹天佑当年做的是同一件事。那时我们坐在车厢里看司机穿过整车走向另一端,就知道车要再开了。因为姥爷的内部关系,我有一次坐进了小电车的驾驶室,看见司机是用一把钳子似的工具扳动一个机关来启动列车,行车期间一直握着钳柄,停车时则扳向相反的方向。到调头的地方,司机停下车走出驾驶室,到另一端去了,我则在这斗室中,懵懵懂懂听姥爷给我讲电车的知识。那里有很特殊的电流表和电压表,表盘刻度是竖直一串,指针是水平的,上下垂直滑动,而不是普通的呈扇面型扫动。那个鸣笛是用脚踏的,我平生唯一的一次给列车鸣了一下笛。

很久的后来,我最后一次来到千台山。那次和大怪一起,我们骑车来到破败的千台山小区,最后看了一眼早已卖掉的、当年的姥姥家的房子。之后我们扛着自行车,踩着一百五十三级的石阶攀上了千台山主峰。山上早已空无一人,附属建筑已是风化累累,参观台门窗紧闭或破碎,里面漆黑,最接近的比喻事物就是鬼城堡。在那里我最后一次看了西露天矿大坑,一如往常,烟雾缭绕,看不见底。唯一还能使用的设施就是那一直修到山脚下的公路,在千台山背面盘绕。我们骑车从那路上滑行而下,一边是千台山的密林陡坡,一边是矿坑的万丈深渊。那里出奇的僻静,静得让人心慌。我的心最后宁静下来,因为我想到,据说姥爷去世后,灵魂就留在这座有灵气的山上。现在姥爷一定在看着我,保护我。姥爷,我这算是来看你了吗?

和参观台以及千台小区的生气一起消失的还有那小电车,生锈的铁轨杂草丛生,偶尔远方古城子方向传来电车的汽笛声,像前世的梦,提醒着我最后去记住什么。

忽然想起要带小黑去坐电铁的时候,对往事的记忆稍微泛上来,却又模模糊糊。那时整个车站显得古朴而破旧,而且一上来,就像坐上了时间机器,到达了另一时空,不再是我所熟悉的、现在的抚顺市,而是另一个地方,一个封闭的空间,仅存于记忆中的地方,熟悉而又陌生。列车已经换了新的,是以前没见过的车型,然而很短,大概只有当年小电车那么长。

电铁车票已经八角了,还会再涨么?我不由希望它再涨一些。不过或许我大可不必担心,因为抚顺的电铁不会消失,因为它本身的作用是运煤,只要抚顺还有一天产煤,那些运煤的货车就不会消失,所以就不愁维持电铁的成本。客运只是其附属功能,别说现在已经只能作为少数矿区职工通勤的用途了,就算将来真的没有任何人坐,一切客车消失,这轨道货运系统也不会消失。或许有一天,由于可预见或不可预见的发展,其客运能力会又重新回到抚顺人的视线之内,也不得而知,但那就是另一个故事了。

而我所熟悉的那个故事,终将淹没在历史的尘埃中。

但不管怎样,听到“电车”这个词,北京人会想起无轨电车,大连人还会想起那种轻轨;听到“城铁”这个词,北京人会想到13号线,别的城市的人会想到其他的现代的东西,而我,则始终在想起这个故事,这个在别的地方或可当作奇闻来听的故事。

抚顺的电铁,心中不可磨灭的印迹。

Posted in 一路走过 | 20 Comments

FPGA计算机(可重构计算)杂思

注:本文原来发帖在21ic上,结果21ic的反应一如既往地让我失望。现连同回复,全部转帖在此,望与懂行者交流。

 

==============无奈的分割线===============

 

mercell 发表于 2007-12-3 14:14

楼主: FPGA计算机,应该是什么样子?

作为典型的非冯·诺伊曼计算架构之一,用FPGA构成的计算系统无疑是很有吸引力的。现有的此类解决方案,其实都没有离开冯·诺伊曼机的框架,比如用 FPGA作为传统CPU的可重构协处理器,或者在FPGA内做出软核CPU(冯·诺伊曼虚拟机)。何时FPGA计算平台能摆脱这种局限,真正用自身的特质来构成未来的先进计算平台,是值得期待的。

基于FPGA的计算平台应该具备如下特征:

1.动态局部重配置。这个局部可以是一个FPGA芯片内的部分逻辑,也可以是一个FPGA芯片阵列中的部分芯片。系统根据需求,时刻分配新的逻辑资源,构成新的逻辑部件,来完成新的计算任务,并释放已经完成的任务所占用的逻辑资源。这种切换的最佳时间粒度还有待确定,和传统CPU的多任务切换将会有所区别。未来我们将会用new/delete来分配整块逻辑电路,new的过程就是从RAM的指定地址(代码段?)加载配置数据到FPGA配置存储器的指定地址,并将该块配置存储器标记为已经使用,delete则清除这种标记。随着抽象的提高,引入垃圾收集似将成为一种必然。

2.新结构的主存和辅存。主存一如既往由大量DRAM充当,但其结构细节和使用方式与传统将会有所改变。典型之一是数据口的宽度,由于FPGA不再具备传统CPU的次序读写特征,而是以大块地读写配置数据为主,粒度在KB~MB级别,而计算过程中的小规模、频度高的数据存储任务,则大量由FPGA内部的 SRAM单元担当。这种使用方式使DRAM倾向于具备更宽的数据端口,和更粗粒度的编址方式。比如主存可能按页(4KB)编址,给出页面号之后,即以高度优化的DMA方式一次完成整页数据的读写。而主存的高速缓存也会做出类似改变。系统的辅存和传统一样,以硬盘和闪存为主,只是可执行文件中保存的不再是指令序列,而是配置数据集合。当我们双击可执行文件的图标,系统会加载代码段和数据段到主存,并根据进程调度,在合适的时刻将进程映像加载到FPGA中。时间片到之后,映像会被写回主存,等待下次被调度。鉴于进程调度牵涉到的数据传输量远大于传统系统,一种有效的新的进程切换机制将会被引入。

3.最少的周边I/O设备。各种I/O控制器的数量将会大幅减少,因为一切都可以在FPGA内部做出来,并具备远远更高的灵活性。需要外围电路的唯一理由,将会是对模拟信号的牵涉。各种数字信号接口可以简单地通过电平转换直接连到FPGA的I/O引脚,甚至键盘的防抖动处理都不需要专门的芯片。FPGA 内部与这些引脚直接连接的逻辑部分被称为硬件驱动,相关配置数据即是驱动软件。普通应用编写者不需要对接口细节感兴趣,只需要与这些驱动软件交互。

4.完善、分层次的新结构的软件系统。由逻辑信号构成的API将会形成标准的文档,应用软件编写者可以以简单的方式直接调用这些API而不用考虑硬件时序等低层次信息。程序设计的主导思想可以是状态机,也可以是更高层的抽象。高度并行是不变的主题,传统的顺序执行的编程模型将成为一种高度抽象,或称为“面向顺序的编程(SOP)”。

以上是目前的思考结果,与传统系统的兼容性暂不考虑。望与大家讨论,结识同道。

-------------------------------------------------

HWM 发表于 2007-12-3 15:56

2楼: 呵呵,如果语言结构不改变,这玩意儿都是空谈。

从Turing,到哈佛,再到冯·诺伊曼,都是建立在现有的顺序控制程序语言结构基础之上的。虽然现已建立了一些并行处理的语言雏形但其基础还是顺序控制加同步。如果不能在根本上突破顺序控制的范畴,却在硬件层面上奢谈什么非冯·诺伊曼计算架构,没有意义。

-------------------------------------------------

mercell 发表于 2007-12-3 17:17

3楼: 楼上提的好,那么我来接着补充

的确,缺乏系统理论的指导,仅从硬件上讨论,是一种空谈。这个问题我也是从一开始就注意到的。

其实要说理论基础,FPGA或者说逻辑电路系统,当然是具备的,就是状态机。状态机和图灵机模型本身很相似,或者说其实就是同一种东西。从这个意义上,FPGA是具备图灵机的能力的,否则也没法在其中做出CPU来。

接着说冯·诺伊曼架构,这种架构并不等同于图灵机,而非冯·诺伊曼架构也不是要连图灵机都一起否定了。所谓顺序控制,或者说时序,在数字逻辑中当然是广泛存在的,只要有状态的系统必然有时序。冯·诺伊曼系统是一种对时序的极端推崇,导致本不是必需用时序来解决的问题,也被套上了时序的框子,比如给一个数组所有元素赋值。这给编程带来了简便性,但也给性能带来了很大的损失。为了弥补这个损失,人们在冯·诺伊曼体系下添加了内容,创造了一些并行体系,也就是楼上所说的“雏形”。这些体系大致分两大类:共享资源模型和消息传递模型,前者典型如线程+锁(同步就是从这来的),后者典型如MPI和Erlang(不需要同步)。无论哪种,都不可避免打上了冯·诺伊曼模型的烙印,可以从一定程度上改善性能,却无法带来质的飞跃,否则人们就不会再去研究更好的并行模型,比如SIMD。但连SIMD都算在内,这些模型都已经大大增加了冯·诺伊曼机的复杂度,抵消了其原有的优势。

实际上,或许对并行的研究走入了一个误区(不敢妄言),不该研究在冯·诺伊曼系统上加点什么,而应该是减去些东西,回到原始的图灵机之上。退回这一步,获得的是比各种复杂的衍生架构更直接且更优雅的架构,不是别的,正是普通的基于状态机的数字逻辑系统。不再盲目推崇时序,仅此而已。在这里,状态机的并行和信号处理的并行成为自然的事情,再这面前,资源共享和消息传递成了拙劣的模仿,SIMD则成了水和空气而无处不在。

既然这样,为什么以前人们没有注意到朴素的数字系统所具有的能力,还研究各种冯·诺伊曼衍生来着?很简单,从前没有如此灵活的数字系统,或许可以构造高效的专用处理单元,但构建通用的计算平台是困难的。这个问题持续着,一直到超大规模PLD诞生为止。

现在很幸运我们有了FPGA,那么当初没有发展出来的道路,或许应该继续下去,在这里,突破的不是顺序控制本身,而是对顺序控制的迷信。顺序控制是可以解决一切问题,但不是每一件都解决的那么优雅,或者FPGA计算平台的根本目的,就是要把本不该用顺序控制解决的那部分问题,真正用该用的方式来解决了。

以上观点共参考!

-------------------------------------------------

HWM 发表于 2007-12-3 18:26

4楼: 硬件只是实现“语言”的平台,

脱开语言空谈“并行处理”完全没有价值。

现在的“并行处理”技术在某种程度上是成熟的,并没有跳出冯·诺伊曼的基本构架。但虽然如此其硬件规模已是相当的庞大,非一个或数个FPGA所能及的。

谈到Turing机,它是最为简单的顺序控制结构。至于状态机可以理解成输入串的顺序处理机。

就仿生学而言,曾经有神经网络计算机的说法,但只是“说法”而已。

-------------------------------------------------

mercell 发表于 2007-12-3 19:42

5楼: 好,那么来说“语言”

FPGA计算平台的编程语言应该是什么样子,不难想象,应该跟现有的硬件描述手段(状态机描述手段)类似。我想,编程语言的体系应该包括如下几个层次:

1.机器语言:就是平台相关的FPGA配置数据,1和0等等,不具备可读性和可移植性。
2.汇编语言:使用FPGA内部提供的逻辑部件(比如LUT4、寄存器、SRAM、乘法器、DLL),及其构建规则作为原语,用人类可读的方式搭建的系统,可以用语言描述,也可以用原理图表示。这同样不具备可移植性。
3.跨平台低级语言:使用平台无关的标准逻辑部件和构建规则作为原语搭建的系统,特征除2的部分之外,还具备跨平台性,可以映射到各种FPGA系统,类似于EDIF。
4.中级语言:使用行为方式或RTL方式建立的系统模型,抽象于底层硬件,易于开发,类似于Verilog/VHDL等,是描述状态机的主要手段。

注意,以上四点与现有EDA流程中的对应物并不是完全相同,后者中只有单纯的器件配置数据,往往只将一个器件配置成所需功能,外围系统是一概不管的。而前者,则是以整个系统为基础,协调操作,知道什么时候进行动态部分重配置,什么时候读写内存和外存。一个程序可能由很多份配置数据(整体的和局部的)和相关的其他数据组成。

那么,以上语言中如何实现的并行处理?不言自明,1、2、3太底层,或许不算是语言,描述的是一种结构,并行是隐含在结构中的。而4所描述的并行,以 RTL为例,明显包含在了状态机的组合逻辑中,以及多个状态机之间的并行之中。我们用它们编程的时候,不需要建立线程,不需要控制同步,不需要发送消息,但的确是在并行!一个组合逻辑的N个输入,我们没有指定任何求值顺序,实际上也没有顺序,因为在FPGA内确实是同时计算的。N个组合逻辑之间更是如此。这时候,我们只在组合逻辑不够用的情况下,不得已将一个运算拆成多个步骤,用流水线来实现,这叫“并行不足串行再补”,而不是一上来就都串行。必须引入串行还有一种情况,就是递归。

如果楼上用过Verilog等,自然会知道,一种语言是怎么摆脱了顺序控制的范畴的。各种HDL天生没有冯·诺伊曼的限制,也都实现的很好,尽管有时也会暂时披上顺序控制的表象(begin...end),但本质上还是高度并行的。

最后,上面的列表,最后一个我也只叫做“中级语言”,因为我相信还有更高级的语言,让编程者可以以更抽象、更方便的方式来描述逻辑,而不是一定要把自己套到状态机的框里来。也许SystemVerilog之类是一种类似物,尽管我相信最终的形式会比SystemVerilog更高层。

接下来,关于规模。现在的“并行处理”技术当然没有跳出冯·诺伊曼框架,上次回复我已经说过的,这是其背上的历史包袱,这种包袱导致了复杂,因为经典冯· 诺伊曼架构与并行计算是性质相异的,这并不说明并行计算本身复杂。用FPGA(数字逻辑)实现并行计算将比冯·诺伊曼衍生方案来的简单高效,就是说实现同样的功能和性能,需要的FPGA系统的复杂度(硬件+软件)一定是低于传统CPU系统的。退一步,就算硬拼集成度、复杂度,今天的FPGA也完全可以胜任构成传统的CPU、芯片组以及一切外设。实际上,那些东西在做成ASIC之前都是在FPGA上先进行试验的,不妨参考龙芯的设计过程,而龙芯盒子所用的北桥干脆直接就是片Cyclone II(EP2C20,比较低端的型号)。

最后澄清一次,FPGA计算决不是不要顺序控制,而是最大限度地制止滥用顺序控制。和传统CPU无以复加的顺序控制相比,这对性能将是无比的提升。

最后关于神经网络计算机,感觉跟FPGA计算平台没什么关系,FPGA计算本质上还是图灵机,而不是别的。

* - 本贴最后修改时间:2007-12-3 19:43:48 修改者:mercell

-------------------------------------------------

HWM 发表于 2007-12-3 20:27

6楼: 楼主看来是有点“练功”练得走火入魔了,呵呵。

Verilog/VHDL仅是硬件描述语言,其“动态”性存在着很大局限。

我提到的“神经网络计算机”,从某种意义上说才真正是突破冯·诺伊曼架构的唯一渠道。但这具有相当的难度。

图灵机(状态机也一样,因为他们是等价的)是计算理论的基础,处人脑外谁都跳不出它的范畴。所以在此引用它们没有实际价值,况且就单个而言它们就是最简单的顺序处理机的范例。

现在人们一提到“软核”概念就似乎觉得什么都可以用软核来加以实现。但就技术而言,实现只是一个方面,而效率则又是另一不可忽略的更为重要的方面。当初CISC(或微程序结构)结构和现代RISC结构的对比就是一个很好的例子。

-------------------------------------------------

平常人 发表于 2007-12-3 21:44

7楼: 高手过招,袖手旁观

无言

签名:
  不当雷锋,愿做水工;
  闲来无聊,网上兜风。
----------------------
做个平常人,好好享乐吧

有空抱着地球玩玩

-------------------------------------------------

xwj 发表于 2007-12-3 22:37 创意无限 ←返回版面 按此察看该网友的资料 按此把文章加入收藏夹 按此编辑本帖

8楼: 呵呵,强贴留名,慢慢看!

签名:

-欢迎访问xwj的Blog-

-------------------------------------------------

computer00 发表于 2007-12-3 23:26

9楼: 哈哈~~~俺在一旁等,等你们做好了,俺拿来用~~~

签名:
***********************声明*********************
本人所有发言均为个人观点。由此帖带来的后果,   
本人一般不予负责。在您相信本帖之前,请慎重考虑!
                                      Computer00
访问电脑圈圈的USB专区

访问电脑圈圈的博客

-------------------------------------------------

mercell 发表于 2007-12-4 11:12

10楼: 感谢楼上几位捧场先~呵呵~继续

HVM 兄说到Verilog/VHDL的动态性存在很大局限,完全同意。究其原因,无非也是一种历史局限。它们是为了设计硬件而产生的,而不是设计系统,所以它们可能擅长于描述一个逻辑单元,一个部件,但对描述一个复杂的系统却缺乏能力。随着实用中牵涉到的系统的日益复杂,业界也是注意到了这种局限的,并且对更加强大的描述工具的渴求也日益增强,并开始了多种探索尝试。SystemVerilog就是一个例子,尽管还有很多不尽人意之处,但比原始的 Verilog之类也有了明显进步。上次回复我也提到,若有FPGA计算机,则其编程语言会和现有HDL类似(比如状态机的描述等),但也仅仅是类似,肯定不会完全相同。这种不同(也就是改进)主要体现在哪些方面,现在也是说不好的,我猜想应该至少包括如下几个方面:

1.数据的高度抽象。不像现有HDL一味用逻辑信号来描述,而是有像C一样丰富的数据类型(这点SystemVerilog已经有了)。
2.普遍通用的模块化方案。这个模块不是指module和entity这种原始的模块,而是依照一套完善而易用的规则(调用约定),将一个模块的接口组织成一套风格统一的API,让使用者不需要关心底层硬件细节,比如哪个信号必须在时钟边沿前几个ns准备好,而只去关心应用逻辑。就好比,C的函数调用者不必考虑调用中断之前哪些寄存器该放哪些内容、哪些寄存器所指向的内存该准备好数据之类,只需要调用函数就OK,连函数的参数入栈次序和谁清栈的问题都不用考虑。
3.动态资源配置。这是动态FPGA重配置的基础,目前即使SystemVerilog也没有很好地支持这项。这其实只是内存动态分配的思路的一种延伸,带来的却是划时代的改变。与2结合,动态分配具有标准接口风格的功能模块并调用,会像Java中new一个对象那么简单。

再次关于神经网络计算机,说老实话我不是很了解其原理,但目前感觉,对我构思FPGA计算机并不构成障碍,应该说FPGA计算机的架构仍然不属于神经网络那一种。另外,突破冯·诺伊曼的架构的渠道,这也不是唯一的一种。我举两个例子:

1.DNA计算。DNA的切片、复制等一整套“算法”,被证明是具备图灵完整性的。虽然我也不了解其原理细节,但可以肯定不是冯·诺伊曼架构。
2.lambda演算。这是和图灵机齐名的运算系统,与图灵机具备等价性,是今天各种函数式编程语言的理论基础。虽然函数式编程语言被编译后仍然是冯·诺伊曼结构的机器码,但这只是为了与现有系统适应,或者说实际上是用冯·诺伊曼系统来模拟lambda演算系统,并不说明lambda演算本身与冯·诺伊曼系统有什么必然联系。如果出现非冯·诺伊曼的体系的底层硬件,一样可以把函数式语言编译上去的(当然把传统命令式语言编译上去也不是不可能的,这不是“是否能”的问题,而是“是否优雅、方便、高效”的问题)。

引用图灵机只是为了说明FPGA计算的可行性和实施指导,当然这也早已是共识了吧,呵呵,那是不用再引用了。顺便说下,单个图灵机也不是不能并行的,很简单,SIMD。如果把多个数据的组合看成一个数据,那还是串行的,但实际上已经是完美的并行效果了。朴素数字电路计算的指导思想,就是最大化这种SIMD 的并行度,而减少图灵机磁头读写和移动的次数。

关于软核,单纯看效率,肯定没有硬核高是真的,但这里重点不在比较这种效率。比如同样实现龙芯,达到同样性能的前提下,FPGA肯定比ASIC要消耗更多的硬件资源。但换个角度,同样达到龙芯的计算能力,而不要求一定要和龙芯完全相同的架构,这么比,究竟谁更有优势?软核的优势不在于模拟硬核,而在于灵活可可变性,根据不同的任务,改变自身以达到最佳适应状态。比如做zip解压就加载zip的配置,做图像处理就加载图像处理的配置,这种配置就是软件,和硬件CPU加载不同的指令序列相比,灵活了不止一个量级,效率反倒更胜一筹呢。的确,目前所谓的软核,稍微复杂了就上处理器,这其实是对FPGA资源的一种简单而浪费的用法,不去用FPGA的native的运算方法,而去做虚拟机来模拟冯·诺伊曼。这不是为了性能,只是为了与传统兼容,降低学习曲线,提高开发速度。FPGA厂商没有闲心来发展FPGA计算机,他们可能不愿做如此长线的投资,也可能只是觉得时机没到,还要静待FPGA技术在传统平台中渗透更长的时间(以协处理器、板卡等形式),让公众慢慢接受这种事物。的确,我要是厂商也会做出这种决定。但从现在开始研究纯FPGA构成的运算体系,以图在未来立于不败之地,是有远见的做法。随着FPGA近年来逐渐快速的发展,这个未来不会太远。

* - 本贴最后修改时间:2007-12-4 11:19:58 修改者:mercell

-------------------------------------------------

McuPlayer 发表于 2007-12-6 11:25

11楼: Verilog和VHDL是硬件描述语言不是硬件设计语言

-------------------------------------------------

Posted in FPGA与可重构计算 | 17 Comments

“可重构计算”QQ群开张!欢迎高手的加入!

作为技术的研究者,我们总体上是寂寞的,我们需要有个方式来交流彼此的思想。开这个群是一种尝试,也是一种期望。我们的主题是FPGA可重构计算技术,如果你是领域内的高手,抑或和我一样的业余爱好者;如果你爱好它,喜欢它,愿意和大家交流,那么,本群需要你的加盟,本群期待你的加盟!

 

群号码:31751264

群名称:可重构计算

Posted in FPGA与可重构计算 | 18 Comments