纤程与协程
纤程

纤程Fiber)是 Windows 操作系统提供的概念。什么是纤程呢?

当我们需要异步执行一些任务时,常用的一种做法就是开启一个工作线程,在工作线程中执行我们的任务。但是这样存在两个问题:

  • 由于线程的调度是操作系统内核控制的,我们没法准确地确定操作系统何时运行、暂且该线程的执行;
  • 对于一些轻量级的任务,创建一个新的线程去做,消耗比较大,我们不希望有这种消耗。

那么有没有一种机制,既能起到新建线程执行任务,又没有新建线程消耗那么大呢?有,这就是纤程

在 Windows 中一个线程中可以有多个纤程,用户可以根据需要自由在各个纤程之间切换。如果要在某个线程中使用纤程,必须先将该线程切换成纤程模式,可以调用如下 API 函数:

LPVOID ConvertThreadToFiber(LPVOID lpParameter);

这个函数不仅将当前线程切换成纤程模式,同时也得到线程中第一个纤程,我们可以通过这个函数的返回值来引用和操作纤程,这个纤程是线程中的“主纤程”,但是这个“主纤程”由于没法指定“纤程”函数,所以什么也做不了。可以通过参数lpParameter给主纤程传递数据,获取当前纤程的数据使用 API 函数:

PVOID GetFiberData();

当在不同纤程之间切换时,也会涉及到纤程上下文的切换,包括 CPU 寄存器数据的切换,在默认情况下,x86 系统的 CPU 浮点状态信息不属于 CPU 寄存器的一部分,不会为每个纤程都维护一份,因此如果你的纤程中需要执行浮点操作,将会导致数据被破坏。为了禁用这种行为,我们需要ConvertThreadToFiber函数:

LPVOID ConvertThreadToFiberEx(LPVOID lpParameter, DWORD dwFlags);

将第二个参数dwFlags设置为FIBER_FLAG_FLOAT_SWITCH即可。

将线程从纤程模式切回至默认的线程模式,使用 API 函数:

BOOL ConvertFiberToThread();

上文我们说了默认的主纤程什么都做不了,所以我们在需要的时候要创建新的纤程,使用 API 函数:

LPVOID CreateFiber(SIZE_T dwStackSize, LPFIBER_START_ROUTINE lpStartAddress, LPVOID lpParameter);

和创建线程的函数很类似,参数dwStackSize指定纤程栈大小,如果使用默认的大小,将该值设置为 0 即可。我们可以通过CreateFiber函数返回值作为操作纤程的“句柄”。

纤程函数签名如下:

VOID WINAPI FIBER_START_ROUTINE(LPVOID lpFiberParameter);

当不需要使用纤程时,记得调用DeleteFiber删除纤程对象:

void DeleteFiber(LPVOID lpFiber);

在不同纤程之间切换,使用 API 函数:

void SwitchToFiber(LPVOID lpFiber);

参数lpFiber即上文说的纤程句柄。

和线程存在线程局部存储一样,纤程也可以有自己的局部存储——纤程局部存储,获取和设置纤程局部存储数据使用 API 函数:

DWORD WINAPI FlsAlloc(PFLS_CALLBACK_FUNCTION lpCallback); BOOL WINAPI FlsFree(DWORD dwFlsIndex); BOOL WINAPI FlsSetValue(DWORD dwFlsIndex, PVOID lpFlsData); PVOID WINAPI FlsGetValue(DWORD dwFlsIndex);

这个四个函数和介绍线程局部存储章节介绍的对应的四个函数用法一样,这里就不再赘述了。

Windows 还提供了一个获取当前执行纤程的 API 函数:

PVOID GetCurrentFiber();

返回值也是纤程“句柄”。

我们来看一个具体的例子:

#include <Windows.h> #include <string> char g_szTime[64] = { "time not set..." }; LPVOID mainWorkerFiber = NULL; LPVOID pWorkerFiber = NULL; void WINAPI workerFiberProc(LPVOID lpFiberParameter) { while (true) { //假设这是一项很耗时的操作 SYSTEMTIME st; GetLocalTime(&st); wsprintfA(g_szTime, "%04d-%02d-%02d %02d:%02d:%02d", st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond); printf("%s\n", g_szTime); //切换回主纤程 //SwitchToFiber(mainWorkerFiber); } } int main() { mainWorkerFiber = ConvertThreadToFiber(NULL); int index = 0; while (index < 100) { ++index; pWorkerFiber = CreateFiber(0, workerFiberProc, NULL); if (pWorkerFiber == NULL) return -1; //切换至新的纤程 SwitchToFiber(pWorkerFiber); memset(g_szTime, 0, sizeof(g_szTime)); strncpy(g_szTime, "time not set...", strlen("time not set...")); printf("%s\n", g_szTime); Sleep(1000); } DeleteFiber(pWorkerFiber); //切换回线程模式 ConvertFiberToThread(); return 0; }

上述代码只有一个主线程,主线程在36行切换至新建的纤程pWorkerFiber中,由于新建的纤程函数中是一个while无限循环,这样 main 函数中38行及以后的代码永远不会执行。输出结果如下:

我们将纤程函数workerFiberProc19行注释放开,这样 main 函数的38就有机会执行了,输出结果如下:

上述代码跳跃步骤是先从 main 函数36行跳到workerFiberProc函数执行,在workerFiberProc19行跳回 main 函数38行执行,接着周而复始进行下一轮循环,直到 main 函数 while 条件不满足,退出程序。

纤程从本质上来说就是所谓的**协程(coroutine)**思想,Windows 纤程技术让单个线程能按用户的意愿像线程一样做自由切换,且没有线程切换那样的开销和不可控性。

Windows 最早引入纤程是为了方便将 Unix 单线程程序迁移到 Windows 上来。当然也有人提出,调试时当程序执行点在纤程函数内部时,调用堆栈对用户是割裂的,对于习惯看连续性上下文堆栈的用户来说可能不太友好。

协程

如果你理解了上面介绍的纤程概念,那么协程coroutine)应该也很好理解了。

我们知道,线程是操作系统的内核对象,多线程编程时,如果线程数过多,就会导致频繁的上下文切换,这些对性能是是一种额外的损耗。例如,在一些高并发的网络服务器编程中,使用一个线程服务一个 socket 连接是很不明智的,因此现在的主流做法是利用操作系统提供了基于事件模式的异步编程模型,用少量的线程来服务大量的网络连接和 IO。但是采用异步和基于事件的编程模型,让程序代码变得复杂,非常容易出错,也提高排查错误的难度。

协程,是在应用层模拟的线程,它避免了上下文切换的额外损耗,同时又兼顾了多线程的优点,简化了高并发程序的复杂度。还是以上面的高并发的网络服务器为例,可以为每一个 socket 连接使用一个协程来处理,在兼顾性能的情况下,代码也清晰。

协程是在1963 年由 Melvin E. Conway USAF, Bedford, MA 等人提出的一个概念,且协程的概念是早于线程提出的。但是由于协程是非抢占式的调度,无法实现公平的任务调用,也无法直接利用多核 CPU 的优势,因此,我们不能武断地说协程是比线程更高级的技术。尽管协程的概念早于线程提出,但是目前主流的操作系统原生 API 并不支持协程技术,新兴的一些高级编程语言如golang都是在语言的运行时环境中自己利用线程技术模拟了一套协程。

这些语言协程的内部实现上都是是基于线程的,思路是维护了一组数据结构和 n 个线程,真正的执行还是线程,协程执行的代码被扔进一个待执行队列中,由这 n 个线程从队列中拉出来执行。这就解决了协程的执行问题。那么协程是怎么切换的呢?以 golang 为例,golang 对操作系统的各种 IO 函数(如 Linux 的 epoll、select, windows 的 IOCP 等)进行了封装,这些封装的函数提供给应用程序使用,而其内部调用了操作系统的异步 IO 函数,当这些异步函数返回 busy 或 blocking 时,golang 利用这个时机将现有的执行序列压栈,让线程去拉另外一个协程的代码来执行。由于 golang 是从编译器和语言基础库多个层面对协程做了实现,所以,golang 的协程是目前各类存在协程概念的语言中实现的最完整和成熟的,十万个协程同时运行也毫无压力。带来的优势就是,程序员可以在编写 golang 代码的时候,可以更多的关注业务逻辑的实现,更少的在这些关键的基础构件上耗费太多精力。

现今之所以协程技术这么流行是因为大多数设计喜欢使用异步编程以追求程序的性能,这就强行的将线性的程序逻辑打乱,程序逻辑变得非常的混乱与复杂。对程序状态的管理也变得异常困难,例如NodeJS那恶心的层层Callback。在我们疯狂被NodeJS的层层回调恶心到的时候,golang 作为名门之后开始进入广大开发者的视野,并且迅速的在 Web 后端攻城略地。例如以 Docker 以及围绕 Docker 展开的整个容器生态圈为代表,其最大的卖点就是协程技术,至此协程技术开始真正的流行与被讨论起来。

腾讯公司开源了一套 C/C++ 版本的协程库 libco:https://github.com/Tencent/libco,有兴趣的读者可以研究一下其实现原理。

所以,协程技术从来不是什么新东西,只是人们为了从重复复杂的底层技术中解脱出来,能够快速专注业务开发而带来的产物。万变不离其宗,只要我们掌握了多线程编程的技术的核心原理,我们也能快速的学习协程技术。

专栏总结

本章介绍了介绍了目前主流的 C/C++ 开发环境下的两个操作系统 Windows 和 Linux 系统层面上的线程原理和多线程资源同步技术的方方面面,同时基于这些基础知识延伸出了更高级的线程池技术和队列系统,也介绍了目前前沿的协程技术。

无论某种编程语言和其运行时环境(如 Java、Python)对操作系统的线程功能增加了多少中间层、封装了多少功能的,操作线程提供的线程相关 API 和同步接口是最基础的,且由于这些接口是操作系统提供的,它们在相当长的时间内都会基本保持不变,一旦你理解并熟练使用它们,你不仅可以灵活地学习和开发出强大的多线程程序来,同时也能快速地理解其他语言中的各种线程同步概念和技术。

多线程编程技术是怎么强调也不过份基本功,希望读者能够非常熟练地掌握它们,这种熟练掌握不仅是理解其原理,一定是熟悉到具体的 API 层面来上。