[译] Protothread:简化内存受限系统上的事件驱动编程

摘要

  在微嵌入式系统和传感器网络节点中,事件驱动编程是一个受欢迎的模型。尽管事件驱动编程能降低内存开销,但由于它强制使用状态机编程风格,使许多程序难以编写、维护和调试。我们展示了一个新颖的被叫做protothread的编程抽象,使得在每个protothread只消耗两个字节的前提下,可以用类似线程的风格来编写事件驱动程序。我们检查了很多采用事件驱动状态机制实现的程序,若采用protothread机制改写,将大大减小其复杂性。对于这些被检查的大多数程序,其状态机可以完全被移除。而剩下的那部分程序,其状态机数量也能被彻底地减小。通过protothread机制,代码的行数能减小三分之一。基于protothread的程序,其执行时间与一些处理器的周期相当。

介绍

  包括传感器网络在内,在内存受限的嵌入式系统中,最常见的编程模型是事件驱动编程。相比较于多线程系统,事件驱动系统不需要为每个线程栈分配内存空间,这更符合低内存需求。基于这个原因,传感器网络中的许多系统个,包括TinyOS、SOS和Contiki都采用事件驱动模型。根据Hill等人所说的:“在TinyOS中,我们采用事件驱动模型,使得高并发性能在非常小的空间中处理。而基于栈的编程方法需要为每个执行内容保留栈空间。”事件驱动编程也常常被用于那些由于内存受限而不能满足通用目的的嵌入式操作系统中。
  事件驱动模型不支持阻塞等待。因此,这些系统中的程序通常需要使用状态机来实现上层逻辑的控制流。状态机属于系统规范的一部分。与状态机不同,控制流状态机通常没有正式的规范,是由程序员凭空创造的。经验表明,通过确切的状态机来管理控制流将使得事件驱动编程更加复杂。正如Levis等人所说:“这种方法是为响应处理、与硬件通信而设计的,但是由于逻辑阻塞队列必须使用状态机风格实现,使高级操作队列复杂化。”此外,在微嵌入式系统中受欢迎的语言,比如C和nesC,都没有提供任何用于帮助程序员管理状态机的工具。
  在本论文中,我们将研究protothread是如何减小事件驱动程序中状态机的数量的。
  本论文的贡献是向大家展示了通过减小状态机需求,实现简化事件驱动编程的原理。在不需要任何机器架构相关代码的前提下,只用C语言就能很简单地实现protothread机制的原型。在之前的论文中,我们已经体现了protothread的思想。在本论文中,我们将通过改善protothread机制、量化和评估protothread工具,来扩展之前的工作。
  为了评估protothread,我们使用protothread重写、分析了大量的事件驱动程序。我们使用了三个量度来度量protothread的效果:状态机的数量、状态机转换的数量和代码行数。实验证明,我们重写的这些程序,在这三个度量上都有显著效果。对于多数程序,状态机能完全被消除。对于剩下的其它程序,protothread显著地减小了状态的数量。与状态机相比,protothread的内存消耗是单字节的。protothread的内存消耗明显低于传统多线程。protothread的执行时间是几个处理器周期。
  我们不提倡将protothread作为状态机的通用替代品。状态机是设计、模块化和分析嵌入式系统的强有力的工具。状态机提供了一个有根据的形式体系,允许推断系统状态,且在某些情形下提供系统行为的证据。不过,对于很多情形,protothread在不引进任何可感知到的内存消耗的前提下,能大大简化程序。特别的,我们已经看了很多事件驱动系统的程序,它们的状态机在多数情形下只对程序代码可见,难以从代码中提取出来。
  我们最初开发protothread的目的是管理uIP的状态机。uIP是嵌入到TCP/IP协议中的协议。本论文中描述的protothread实现原型被用于Contiki操作系统,也被多个第三方嵌入式开发者使用。比如被用于MPEG解码模块,无线传感器、嵌入式设备等。protothread也被其它人移动到C++和Objective C。
  本论文剩下的部分以如下的形式组织。第2节描述protothread且举一个例子,第3节讨论protothread的内存需求,第4节讲解如何使用protothread替换状态机,第5节描述是如何用C语言实现protothread的,第6节评估protothread,第7节做一个讨论,第8节回顾相关工作,第9节做总结。

protothread

  protothread是一个新颖的编程抽象。它提供一个条件阻塞等待语句PR_WAIT_UNTIL(),以简化内存受限的嵌入式系统的事件驱动编程。该操作需要一个条件语句,并阻塞protothread,直到该条件语句为真。如果条件语句是真,protothread第一次到达PT_WAIT_UNTIL()时不会中断,会直接执行下去。每次protothread被调用时都会评估PT_WAIT_UNTIL()的条件。PT_WAIT_UNTIL()的条件可以是包括布尔表达式在内的任何复杂语句。
  protothread是无栈的:它没有函数调用的历史。正相反,系统中的所有protothread都运行在同一个栈之上,当protothread被阻塞时会回到栈顶。
  protothread被包含在一个函数中。通过重复调用这个函数,就能实现对protothread的驱动。由于protothread是无栈的,它只能在函数顶层阻塞。这意味着,不能通过被protothread调用的常规函数来阻塞被调用函数的内部——只能通过表达式PT_WAIT_UNTIL()来阻塞。这样做的优点是,程序员总是知道哪一条语句可能造成阻塞。虽然如此,也可以使用分层protothread来十年嵌套阻塞(2.5节)。
  PT_BEGIN和PT_END用来声明protothread的开始和结束。protothread表达式,比如PT_WAIT_UNTIL(),必须放在PT_BEGIN和PT_END之间。表达式PT_EXIT用于提前退出线程。PT_BEGIN和PT_END外面的表达式不属于protothread,其行为是未定义的。
  protothread可以看做是事件和线程的结合体。从线程角度,protothread继承了阻塞等待语句;从事件角度,protothread继承了无栈特性和低内存消耗特性。阻塞等待机制允许事件驱动程序中的状态进行线性排序。与传统线程相比,protothread的主要优点是轻量级:它不需要栈。所有的protothread运行在同一个栈空间上,通过栈回溯(stack rewinding)实现内容切换。对于内存受限的系统,这是很大的优点,因为线程的栈将会占据相当大一部分有效内存。比如,运行在MS430F149上的线程其栈大小为200字节,几乎占据了整个RAM的10%。于此形成鲜明对比的是,每个protothread的内存消耗只有两个字节,且不需要额外的栈。

调度

  protothread机制不会指定任何特殊的方法来调用、调度protothread,而是由使用protothread的系统定义的。对于一个运行在事件驱动系统之上的protothread,一旦事件调度器(event scheduler)调用事件处理器(event handler),protothread就会被调度。例如,一个程序运行在uIP TCP/IP栈之上,则在发生TCP/IP事件或者TCP/IP进行周期性轮询时会被调用。
  在Contiki操作系统中,进程(process)是以运行在事件驱动的Contiki内核之上的protothread实现的。当进程接收到事件后,其protothread会被调用。接收到的事件可能是定时器事件、来自其它进程的消息、传感器输入、或者系统中的其它任何事件。进程可以使用protothread条件阻塞表达式来等待一个事件到来。
  protothread不指定如何管理保存protothread状态的内存。当有调度时,系统决定如分配内存。如果系统预先知道要运行多少个protothread,则会提前静态分配内存。否则,将动态分配内存。在Contiki中,进程控制块里保存有进程的protothread相关的内存。典型地,Contiki程序为进程控制块静态分配内存。
  通常,protothread是可重入的。对于多个protothread,只要它们自己有保存状态的内存,就能在相同的代码段上运行。

阻塞事件handler

  protothread可以看做是阻塞事件的handler,可以在不对事件驱动系统做修改的前提下,在该系统上正常运行。protothread使用表达式PT_WAIT_UNIT()进行条件阻塞。事件调度系统不需要知道这个handler是一个protothread还是一个普通的事件handler。
  通常,不需要对事件调度系统做任何修改,就能将一个基于状态机实现的程序替换为基于protothread实现的程序。

例子:假想的MAC协议

  为了描述如何使用protothread替换状态机,我们考虑一个假想的节能传感器网络的MAC协议。该协议的其中一个任务是允许关闭无线radio,以达到减小设备总体能量消耗的目的。因此,许多MAC协议是当无线radio完全关闭后周期地进入睡眠。
  这里的假想MAC协议与T-MAC协议相似,在调度期间切换无线radio的开关。该机制 如图3所示,且其流程为:   

1
2
3
4
5
6
1.打开无线
2.等待,直到 t = t0 + tawake
3.当所有的通信完成后,关闭无线
4.如果通信未完成,等到完成或者直到 t = t0 +tawake +twait_max
5.关闭无线。等待,直到 t = t0 +tawake +tsleep
6.重复步骤1

  为了在事件驱动模型中实现这个协议,我们首先需要明确一系列的状态,以便设计状态机。对于这个协议,我们可以很快地明确由三个状态:开——无线是打开的,等待——正在等待完成剩下的通信,关——无线是关闭的。图4展示了状态机和状态传输的结果。
  为了实现这个状态机,我们使用一个变量枚举state(其值为ON,WAITING,OFF)。工具变量state的值,我们使用if语句执行不同的动作。代码放在事件handler函数中,当事件产生时会被调用。在这种情形下,事件可能是定时器到期或者通信完成。为了简化代码,我们用了两个独立的定时器timer、wait_timer去跟踪时间。图1是其对于的伪代码。
  我们注意到,这样一个简单的机制需要相当多的代码。控制状态机转换的代码超过了总代码的三分之一。当我们使用protothread实现周期睡眠时,我们只需要使用表达式PT_WAIT_UNTIL()来等待定时器到期。图2是对应的伪代码。可以看到,其代码比图1的代码短,且逻辑更清晰。

protothread退出

  在将状态机重写为protothread的过程中,我们发现了无条件阻塞等待的重要性。表达式PT_YIELF()用于进行无条件阻塞等待,它将阻塞该线程,直到线程下一次被调用。下一次被调用时,protothread将从表达式PT_YIELD()处继续执行。
  通过添加PT_YIELD()操作,protothread就像一个栈的变形。

protothread分层

  尽管很多程序可以很容易以单线程实现,但是更多的复杂的程序需要以分层的形式实现。protothread中的表达式PT_SPAWN()支持这种操作。PT_SPAWN()初始化一个子线程,该子线程使用PT_END结束或者使用PT_EXIT退出。子线程被父线程调度。当系统每次调用父线程的时候,都能通过表达式OPT_SPAWN调用子线程。父线程结构体中的一个局部变量保存子线程的状态。
  为了理解protothread分层工作原理,我们举一个例子,考虑一个假想的数据采集协议。该协议分为两步:第一步,通过网络传送感兴趣的数据;第二步,继续传送数据消息返回到消息传来的地方。消息一一种可靠的方式传播:如果没有收到确认消息,将重传该消息。
   这里写图片描述
  图5. 假想数据采集协议的伪代码实现
  图5用伪代码的形式实现了这假想协议。程序由主线程和线程data_collection_protocal组成。data_collocetion_protocal是一个子线程,采用可靠传输方式试实现数据传输。

本地延续(local continuations)

  LC(local continuations,其实就我们平时所说的保留现场)是用于支持protothread的底层机制。当protothread发生阻塞时,其线程状态保存在LC中。本地延续LC与平常所说的延续基本相似,有一点不同的是LC不保存程序栈空间,只保存一个函数内部的执行状态。很熟的状态由程序当前正在运行的函数的延续点以及函数局部变量的值共同决定。protothread机制只需要这些实际被用于存储阻塞等待的变量。基于C语言的LC实现原型从这里面抽取了出来,不存储任何局部变量。