Protothreads
1 协程介绍
协程是一种轻量级的线程,也被称为用户级线程,它是一种在单个线程内部的并发执行方式,通过在代码中插入特殊的挂起点来实现并发性。
用户级线程:
首先跟具有操作系统概念的线程不一样, 协程是在用户空间利用程序语言的语法语义就能实现逻辑上类似多任务的编程技巧。协程不是被操作系统内核所管理,而完全是用户由程序所控制。实际上协程的概念比线程还要早。可以将协程理解为一次函数调用,只不过协程这种函数调用可以从上次返回点继续执行。
并发性:
协程最主要的作用是在单线程的条件下实现并发的效果,但实际上还是串行的。只不过通过特殊的挂点实现阻塞,让出 CPU 去执行其它的协程,在用户感觉上是并发。
如果有多核CPU的话,多个进程或一个进程内的多个线程是可以并行运行的,但是一个
线程内的多个协程却绝对串行的。
这个特殊的挂点也就是协程库所实现的东西,后面给大家分享实现。
与进程和线程区别:
进程 | 线程 | 协程 | |
---|---|---|---|
调度方式 | 操作系统 | 操作系统 | 用户 |
切换时机 | 操作系统策略 | 操作系统策略 | 用户 |
资源消耗 | 上下文保存于内核栈 | 上下文保存于内核栈 | 用户栈(全局变量) |
通信 | 共享内存… | 信号量… | 共享变量 |
-
调度方式:进程和线程是由操作系统进行调度的,并且在切换时会涉及到上下文切换的开销。而协程的调度是由用户代码控制的,不需要操作系统介入,因此切换开销较小。
也就是说:进程、线程切换过程是:用户态-内核态-用户态;而协程只有用户态。
-
切换时机:进程、线程根据操作系统自己的切换策略,用户不感知,而协程在什么时候切换是由用户(程序)自己决定。
-
资源消耗:进程和线程上下文切换时,会将上下文内容保存于内核栈中;而协程将上下文保存于用户栈中(一个全局变量)。
-
通信方式:进程和线程之间的通信和数据共享较为复杂,需要使用特殊的机制(进程:共享内存、消息队列…;线程:信号量、互斥锁…)。而协程可以通过直接调用函数、共享变量等简单的方式进行通信和数据共享。
-
运行机制区别:线程和进程是同步机制,而协程是异步机制。
总的来说,协程是一种更轻量、更灵活的并发编程方式,适用于需要高效利用计算资源、处理大量并发任务但又不需要真正的并行执行的场景。
协程使用例子:
协程适合I/O 阻塞型。
I/O本身就是阻塞型的(相较于CPU的时间世界而言)。就目前而言,无论I/O的速度多快,也比不上CPU的速度,所以一个I/O相关的程序,当其在进行I/O操作时候,CPU实际上是空闲的。
我们假设这样的场景,如下图:1个线程有5个I/O的事情(子程序)要处理。如果我们绝对的串行化,那么当其中一个I/O阻塞时,其他4个I/O并不能得到执行,因为程序是绝对串行的,5个I/O必须一个一个排队等待处理,当一个I/O阻塞时,其它4个也得等着。
而协程能比较好地处理这个问题,当一个协程(特殊子进程)阻塞时,它可以切换到其他没有阻塞的协程上去继续执行,这样就能得到比较高的效率,如下图所示:
上面举的例子是5个I/O处理,如果每秒500个,5万个或500万个呢?已经达到了“I/O密集型”的程度,而“I/O密集型”确实是协程无法应付的,因为它没有利用多核的能力。这个时候的解决方案就是“多进程+协程”了。
所以说,I/O阻塞时,利用协程来处理确实有优点(切换效率比较高),但是我们也需要看到其不能利用多核的这个缺点,必要的时候,还需要使用综合方案:多线程+协程。
2 Protothreads 介绍
Protothreads 是一种轻量级的无堆栈线程,专为内存严重受限的系统设计,如小型嵌式系统或无线传感器网络节点。Protothreads 为用 C 语言实现的事件驱动系统提供了线性代码执行。在使用或不使用底层操作系统的情况下,Protothreads 都可以提供阻塞事件处理程序。Protothreads 提供顺序的控制流程,不需要复杂的状态机或完整的多线程。
虽然 protothreads 最初是为内存受限的嵌入式系统创建的,但它也可以作为通用库使用。例如被使用在多媒体流服务器软件、网格计算研究软件和互联网电视的 MPEG 解码软件。
主要特点:
-
非常小的内存开销——每个 protothread 只有两个字节,没有额外的堆栈。
-
高度可移植性——protothreads 库是 100% 纯 C 的,没有特定于体系结构的汇编代码。
-
可以被使用在有操作系统或者没有操作系统的软件中。
-
提供阻塞等待而无需完整多线程或堆栈切换。
-
在 BSD 的开源许可协议下免费使用。
注意事项:
-
函数调用:一个 protothread 内部可以调用其它函数,比如库函数或系统调用,但必须保证该例程是非阻塞的,否则所在线程内的所有协程都将被阻塞。毕竟线程才是执行的最小单位。在嵌套函数调用中,阻塞是通过为每个可能阻塞的函数派生单独的 protothread 来实现的。这种方法的优点是阻塞是明确的:程序员确切地知道哪些函数可能阻塞哪些函数不能阻塞。
-
局部变量:由于 protothread 是 stackless 的,不会在阻塞调用时保存栈上下文,所以当 protothread 阻塞时,局部变量也不会被保存,尽量不要使用局部变量,除非该变量对于协程状态是无关紧要的,同理可推,协程所在的代码是不可重入的。
-
switch-case:如果 protothread 使用 switch-case 原语封装的组件,那么禁止在当前 protothread 中使用 switch-case 语句,除非用 GNU C 语法中的标签指针替代。
2.1 Protothreads 和状态机对比
概念提到 Protothreads 为事件驱动系统提供了线性代码执行。
事件驱动模型不用为每个进程都分配一个进程栈,这对内存资源受限的嵌入式系统尤为重要。然而事件驱动模型不支持阻塞等待抽象语句,因此通常用状态机来实现控制流,但这都很复杂。
例子,一个假想的 MAC 层协议:
用状态机实现:
实现上述代码,需要先提炼出准确特定的状态 state,上述代码有三个状态:ON、OFF、WAITING。要提炼出这几个状态并不简单,而且状态机实现后的代码跟系统功能没有相互对应,可阅读性差。
Protothread 机制,来化简这个问题:
Protothread 可以看作是事件驱动和进程的结合,从进程中继承了”阻塞等待“语义,如 Protothread 提供 PT_WAIT_UNTIL
等阻塞语句。 Protothread 从事件驱动中继承了“低内存开销"和“无栈性(所有进程共用一个栈)”。
Protothread 实现:
总结一下优点:…
Protothreads 官网例子代码:
#include "pt.h"
struct pt pt;
struct timer timer;
PT_THREAD(example(struct pt *pt))
{
PT_BEGIN(pt);
while(1) {
if(initiate_io()) {
timer_start(&timer);
PT_WAIT_UNTIL(pt,
io_completed() ||
timer_expired(&timer));
read_data();
}
}
PT_END(pt);
}
这段代码的意思是:
代码定义了一个名为pt
的结构体和一个名为timer
的结构体,这两个结构体用于存储线程和计时器的状态和信息。
之后,代码定义了一个以struct pt *
为参数的函数example
,并使用PT_THREAD
宏标记了该函数。PT_THREAD
宏用于定义 Protothreads 线程函数。
在example
函数中,代码使用PT_BEGIN
宏标记了线程的开始。这表明线程函数内的代码将从此处开始执行。
然后,代码进入一个无限循环(while(1)
),在循环中检查是否已经启动了 I/O 操作(initiate_io()
)。如果是,代码调用timer_start
函数启动一个计时器。接着,使用PT_WAIT_UNTIL
宏将线程的执行暂停,直到I/O操作完成(io_completed()
)或计时器超时(timer_expired(&timer)
)。如果其中任意一个条件为真,线程继续执行并调用read_data()
函数来读取数据。
最后,代码使用PT_END
宏标记了线程函数的结束。
3 实现原理
3.1 调度
用户调用:
protothread 是通过反复调用运行它的函数来驱动的。每次函数被调用时,protothread 都会运行,直到阻塞或退出。因此,protothreads 的调度是由使用protothreads 的应用程序完成的。
3.2 local continuations
protothread 是使用 local continuations 实现的。
**local continuations:**表示程序中特定位置执行的当前状态,但不提供任何调用历史或局部变量。可以在特定函数中设置 local continuations 来捕获函数的状态。在设置了 local continuations 之后,可以恢复该函数在设置 local continuations 时的状态。
local continuations 有多种实现方式:
-
通过使用特定于机器的汇编代码。
-
通过使用标准的C结构。
-
通过使用编译器扩展。
第一种方式的工作原理是保存和恢复处理器状态(栈指针除外),每个协程需要 16 到 32 字节的内存。所需内存的确切数量取决于体系结构。
标准的C语言实现每个 protothread 只需要两个字节的状态,并且以一种不明显的方式利用了 C 语言中的 switch() 语句。然而,这种实现对使用 protothread 的代码有一个轻微的限制,即代码本身不能使用 switch() 语句。
某些编译器有 C 扩展,可以用来实现 protothread。GCC 支持标号指针可以用来实现这个目的。在这种实现方式下,每个 protothreads 需要 4 字节的内存。
3.2.1 Yield 语义
简要理解:yield 功能就是 return 一个值,并且记住这个返回的位置,下次迭代就从这个位置后开始。
这段代码定义了一个生成器函数名为foo
。当使用foo
函数时,它会返回一个生成器对象。生成器对象可以用于迭代,在每次迭代中生成一个值。
def foo(num):
while num < 10:
num = num+1
yield num
在这个生成器函数中,存在一个while
循环。当num
小于10时,执行循环体内的操作。循环体中的操作是将num
加1,并使用yield
语句返回num
的当前值。这意味着每次在循环中执行yield
语句时,将返回num
的当前值,并且生成器的执行状态会暂停,直到下一次调用生成器对象的__next__()
方法。
通过这种方式,当使用生成器对象迭代时,可以逐步生成一系列递增的值,直到num
的值大于等于10为止。
你可以使用以下示例代码来使用这个生成器函数:
my_generator = foo(5)
for value in my_generator:
print(value)
或
my_generator = foo(5)
print(next(my_generator)) # 输出 6
print(next(my_generator)) # 输出 7
print(next(my_generator)) # 输出 8
print(next(my_generator)) # 输出 9
print(next(my_generator)) # 输出 10
需要注意的是,一旦生成器函数的条件不再满足(num
大于等于10),调用生成器对象的__next__()
方法将引发StopIteration
异常,表示生成器已经完成迭代。因此,在使用生成器对象时,需要在迭代过程中适时捕获该异常来终止迭代。
goto 来模拟:
我们可以用 goto 来模拟类似的效果。看下面代码:
int function(void) {
static int i, state = 0;
switch (state) {
case 0: goto LABEL0;
case 1: goto LABEL1;
}
LABEL0: /* start of function */
for (i = 0; i < 10; i++) {
state = 1; /* so we will come back to LABEL1 */
return i;
LABEL1:; /* resume control straight after the return */
}
}
我们在所有需要 yield 的位置都加上标签:起始位置加一个,还有所有 return 语句之后都加一个。每个标签用数字编号,我们在状态变量中保存这个编号,这样就能在我们下次调用时告诉我们应该跳到哪个标签上。每次返回前,更新状态变量,指向到正确的标签;不论调用多少次,针对状态变量的 switch 语句都能找到我们要跳转到的位置。
但是这个代码阅读性很差,最糟糕的部分是所有的标签都需要手工维护,还必须保证函数中的标签和开头 switch 语句中的一致。每次新增一个 return 语句,就必须想一个新的标签名并将其加到 switch 语句中;每次删除 return 语句时,同样也必须删除对应的标签。这使得维护代码的工作量增加了一倍。
仔细想想,其实我们可以不用 switch 语句来决定要跳转到哪里去执行,而是直接利用 switch 语句本身来实现跳转:
int function(void) {
static int i, state = 0;
switch (state) {
case 0: /* start of function */
for (i = 0; i < 10; i++) {
state = 1; /* so we will come back to "case 1" */
return i;
case 1:; /* resume control straight after the return */
}
}
}
类似这种写法其实叫达夫设备(Duff’s device):…
__LINE__
宏优化:
对于上面的代码,我们还可以用 __LINE__
宏使其更加一般化,可以让 case 更具唯一性,减少命名的负担。
int function(void) {
static int i, state = 0;
switch (state) {
case 0: /* start of function */
for (i = 0; i < 10; i++) {
state = __LINE__ + 2; /* so we will come back to "case __LINE__" */
return i;
case __LINE__:; /* resume control straight after the return */
}
}
}
语法优化:使用宏
我们可以用宏提炼出一种范式,封装成组件。
为什么要用宏来将代码进行一个修改?上述代码虽然语法正确,但是语法形式上非常的怪异,尤其是当逻辑代码更多时,非常难以理解。为了突出算法的逻辑,而优化语法的形式是值得的。
#define Begin() static int state=0; switch(state) { case 0:
#define Yield(x) do { state=__LINE__; return x; case __LINE__:; } while (0)
#define End() }
int function(void) {
static int i;
Begin();
for (i = 0; i < 10; i++)
Yield(i);
End();
}
我们利用 switch-case 的分支跳转特性,以及预编译的 __LINE__
宏,实现了一种隐式状态机,最终实现了“yield 语义”。
编程规范的目标就是为了代码清晰。如果将一些重要的东西,像 switch、return 以及 case 语句,隐藏到起“障眼”作用的宏中,从编程规范的角度讲,可以说你扰乱了程序的语法结构,并且违背了代码清晰这一要求。但是我们这样做是为了突出程序的算法结构,而算法结构恰恰是看代码的人更想了解的。
任何编程规范,坚持牺牲算法清晰度来换取语法清晰度的,都应该重写。
3.2 底层工作原理
protothreads 是如何工作的?
C protothreads 库中神奇的宏背后是什么? 为什么是宏? 宏是如何工作的?
在 protothreads 的 C 实现中,所有 protothread 操作都隐藏在 C 宏之后。在 C 宏而不是 C 函数上构建 protothread 库的原因是 protothreads 会改变控制流程。这通常很难用 C 函数完成,因为这样的实现通常需要底层汇编代码才能工作。通过使用宏实现 protothread,它们可以只用标准的 C 结构来改变控制流。
下面解释了 protothreads 的底层工作原理。我们来看看 C 预处理器如何扩展protothread 宏,以及生成的 C 代码是如何执行的。
首先,我们将介绍一个使用 protothreads 编写的简单示例程序。由于这是一个简单的程序,我们可以展示整个程序,包括驱动 protothread 的 main() 函数。下面所示的代码等待计数器达到某个阈值,打印一条消息,并重置计数器。这是在一个 while(1) 中完成的。计数器在 main() 函数中增加。
#include "pt.h"
static int counter;
static struct pt example_pt;
static
PT_THREAD(example(struct pt *pt))
{
PT_BEGIN(pt);
while(1) {
PT_WAIT_UNTIL(pt, counter == 1000);
printf("Threshold reached\n");
counter = 0;
}
PT_END(pt);
}
int main(void)
{
counter = 0;
PT_INIT(&example_pt);
while(1) {
example(&example_pt);
counter++;
}
return 0;
}
在我们让 C 预处理器扩展上述代码之前,我们来看看 protothread 宏是如何定义的。为了让事情更简单,我们使用了一个比 protothreads 源代码中实际定义更简单的定义。(这里使用的定义是 protothread 宏和 C switch 语句实现的 local continuation 宏的组合版本。)这个定义看起来像这样:
struct pt { unsigned short lc; };
#define PT_THREAD(name_args) char name_args
#define PT_BEGIN(pt) switch(pt->lc) { case 0:
#define PT_WAIT_UNTIL(pt, c) pt->lc = __LINE__; case __LINE__: \
if(!(c)) return 0
#define PT_END(pt) } pt->lc = 0; return 2
#define PT_INIT(pt) pt->lc = 0
我们看到结构体 pt 由一个 unsigned short 的 lc (local continuation 的缩写)组成。这个 unsigned short 变量就是 protothread 网页上经常提到的 “2 字节开销” 的来源。此外,我们看到 PT_THREAD
宏只是将一个字符放在其参数之前。此外,我们还注意到 PT_BEGIN
和 PT_END
宏是如何分别打开和关闭 C switch 语句的。但 PT_WAIT_UNTIL
宏是其中最复杂的。它包含一条赋值语句、一条 case 语句、一条 if 语句,甚至还有一条 return 语句!此外,它使用了两次内置的 __LINE__
宏。__LINE__
宏是一个特殊的宏,C预处理器会将其扩展为发出宏的行号。最后,PT_INIT
宏只是将 lc 变量初始化为0。
许多在 protothread 宏中使用的语句在宏中并不常用。PT_WAIT_UNTIL
宏中使用的 return 语句中断了使用该宏的函数的控制流。因此,许多人不喜欢在宏中使用 return 语句。PT_BEGIN
宏打开一个 switch 语句,但不关闭它。PT_END
宏关闭尚未打开的复合语句。如果不从 protothreads 的角度来看,这些东西看起来确实很奇怪。但在protothreads 的上下文中,这些东西对于 protothreads 的正确操作是绝对必要的: 宏必须改变使用宏的C函数中的控制流。这就是 protothreads 的全部意义所在。
好了,对于经验丰富的 C 开发人员来说,protothread 宏看起来有多奇怪,我们已经说得够多了。我们现在来看看上面例子中的 protothread 被C预处理器扩展后是什么样子。为了更容易看到发生了什么,我们将原始版本和扩展版本并排放置。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CCMw2GHT-1691674821769)(Protothreads.assets/image-20230806183755210.png)]
在代码的第一行,我们可以看到如何扩展 PT_THREAD
宏,使 example() protothread变成一个返回字符的普通 C 函数。protothread 函数的返回值可以用来确定 protothread 是被阻塞等待事件发生,还是已经退出或结束。
PT_BEGIN
宏已经扩展为一个带开括号的 PT(switch)
语句。如果我们向下看函数的末尾,可以看到 PT_END
宏的展开部分包含了表示转换的右花括号。在左花括号之后,我们看到 PT_BEGIN
展开包含一个 case 0:
语句。这是为了确保在 protothread 第一次运行时,第一个执行 PT_BEGIN
语句之后的代码。协程再一次执行时将 PT_INIT
将 pt->lc
设置为零。
移过 while(1) 行,我们看到 PT_WAIT_UNTIL
宏已经扩展为包含数字 12 的宏。pt->lc
被设置为 12,在赋值之后紧跟一个 case 12:
语句。之后,检查 counter
变量是否达到了 1000。如果没有,example()
函数现在显式执行 return
!这一切似乎令人惊讶: 数字 12 从何而来,为什么函数会在 while(1)
循环中返回? 要理解这一点,我们需要看一下下一次调用 example()
函数时代码是如何执行的。
下次从 main()
函数调用 example()
函数时,pt->lc
变量将不是零,而是 12,因为它是在 PT_WAIT_UNTIL
宏的扩展中设置的。这使得开关 (pt->lc)
跳转到 case 12:
语句。这条语句位于 if
语句之前, if
语句用来检查 counter
变量是否达到了1000! 因此再次检查 counter
变量。如果还没有达到1000, example()
函数会再次返回。下一次调用该函数时,switch
跳转到情形12:并重新计算 counter == 1000
语句。它会一直这样做,直到 counter
变量达到1000。然后,在 while(1)
循环再次循环之前,执行 printf
语句并将counter变量设置为零。
但是数字 12 是从哪里来的呢? 它是 PT_WAIT_UNTIL
语句的行号(通过在屏幕上计算原始程序中的行数来检查它!)。行号的好处是它们是单调递增的。也就是说,如果在程序的后面添加另一个 PT_WAIT_UNTIL
语句,其行号将与第一个 PT_WAIT_UNTIL
语句不同。因此,开关 (pt->lc)
知道从哪里跳-没有歧义。
你有没有注意到上面的代码有什么奇怪的地方? switch(pt->lc)
语句直接跳到 while(1)
循环中。case 12:
语句在 while(1)
循环中! 这真的有用吗? 是的,它确实有效! C 语言的这个特性最早可能是由 Tom Duff 在他被称为 Duff’s Device 的奇妙编程技巧中发现的。Simon Tatham 也用同样的技巧在 C 语言中实现了协程(一段很棒的代码)。
好了,现在你知道 protothreads 是如何在其怪异的宏中工作的了。
4 代码
4.1 文件
lc-addrlabels.h
(使用一种 “Labels as values” 的方式实现 local continuations)。
lc-switch.h
(基于 switch()
语句实现 local continuations)。
lc.h
(Local continuations, 选择一种实现方式 )。
pt-sem.h
(在 Protothreads 上实现的信号量计数)。
pt.h
(Protothreads 实现)。
4.2 Protothreads 上下文
先来看看实现 protothreads 的数据结构, 实际上它就是协程的上下文结构体,用以保存状态变量,相信你很快就明白为何它的“堆栈”只有 2 个字节:
struct pt {
lc_t lc;
}
里面只有一个 short 类型的变量,实际上它是用来保存上一次出让点的程序计数器。这也映证了协程比线程的灵活之处,就是协程可以是 stackless 的,如果需要实现的功能很单一,比如像生产者-消费者模型那样用来做事件通知,那么实际上协程需要保存的状态变量仅仅是一个程序计数器即可。
4.3 Protothreads的原语和组件
所有的操作都要回到这四个宏上。
有两种实现方法,在 ANSI C 下,就是传统的 switch-case 语句:
#define LC_INIT(s) s = 0;
#define LC_RESUME(s) switch(s) { case 0:
#define LC_SET(s) s = __LINE__; case __LINE__:
#define LC_END(s) }
但这种“原语”有个难以察觉的缺陷:就是你无法在 LC_RESUME 和 LC_END (或者包含它们的组件)之间的代码中使用 switch-case语句,因为这会引起外围的 switch 跳转错误!为此,protothreads 又实现了基于 GNU C 的调度“原语”。在 GNU C 下还有一种语法糖叫做标签指针,就是在一个 label 前面加 &&(不是地址的地址,也不是逻辑与符号,是 GNU 自定义的符号),可以用 void 指针类型保存,然后 goto 跳转:
#define LC_INIT(s) s = NULL
#define LC_RESUME(s) \
do { \
if(s != NULL) { \
goto *s; \
} \
} while(0)
#define LC_CONCAT2(s1, s2) s1##s2
#define LC_CONCAT(s1, s2) LC_CONCAT2(s1, s2)
#define LC_SET(s) \
do { \
LC_CONCAT(LC_LABEL, __LINE__): \
(s) = &&LC_CONCAT(LC_LABEL, __LINE__); \
} while(0)
#define LC_END(s)
4.4 阻塞实现
我们先定义四个退出码作为协程的调度状态机:
#define PT_WAITING 0
#define PT_YIELDED 1
#define PT_EXITED 2
#define PT_ENDED 3
#define PT_WAIT_UNTIL(pt, condition) \
do { \
LC_SET((pt)->lc); \
if(!(condition)) { \
return PT_WAITING; \
} \
} while(0)
4.6 信号量实现
#define PT_SEM_WAIT(pt, s) \
do { \
PT_WAIT_UNTIL(pt, (s)->count > 0); \
--(s)->count; \
} while(0)
#define PT_SEM_SIGNAL(pt, s) ++(s)->count
4.7 例子
4.8 Protothreads 拓展
如果在协程出让时需要保存一些额外的状态量,像迭代生成器,只要数目和大小都是确定并且可控的话,自行扩展协程上下文结构体即可。
模拟通常的抢占式调度。