沈崴 - 标准线程的协程替换 · 文字版 - PyCon China 2020

这里将会使用 Python,把操作系统中的原生线程完全重写为基于协程的版本。使现有的非异步程序,“无论使用何种语言开发”,都能在无需修改和重新编译的情况下直接变成协程架构下的异步程序,从而在并发处理能力上取得大幅提升。本主题将会在 Python 的扩展与嵌入,以及协程与异步编程等方面进行较为深入地探讨。

不过在开始之前,思虑在三,我觉得还是要先介绍一下协程。现在 Python 增加了关键字 async/await,相信大家对协程已经比较熟悉了 —— 协程,是在系统线程中模拟出来的更小的线程。不管开了多少个协程,本质上还是单线程程序。

我这里用了一张图,五角场中环的大转盘。我经常拿交通上的调度来讲线程和协程的不同。线程是用时间片来调度的,就像红绿灯。时间一到,不管这条路上有没有车,通行权都会交到另一个车道。而协程只有在碰到 I/O 这样的阻塞的时候,才会切换。所以协程的调度次数非常少,系统负担小。所以一言不和就可以随便起几十万上百万个协程,只要你的内存够用。

虽然协程用起来和线程差不多,但协程其实就是一个函数。函数碰到 return 就会从栈里面跳出去,栈就回收掉了。但如果跳出去以后,不把栈回收掉呢?我们就可以再跳回去,回到这个函数里,继续执行。这种从函数里跳出去的方法,可以叫 yield、switch 也可以叫 async/await。这样函数就变成了协程。所以协程和函数一样轻,创建协程和调用函数的开销差不多。

传统上,有两种编程模式。“多线程编程”,比较适合初学者入门,而“异步编程”的好处是高并发 —— 这里大家注意我说的多线程,是容易入门,而不是易用。因为多线程之间的资源是共享的,几个线程一起访问资源是很危险的。所以要写出真的能用的多线程程序,也不容易。除非拿来当玩具。所以很多 Unix 程序员就很抗拒线程。

再说到异步程序,不管是事件循环还是回调地狱,基本上写起来是非常反人类的。但自从有了协程,情况就很不一样了。协程写起来就像线程一样丝滑,本质上又是单线程的程序,没有临界资源争用的问题。然后协程是和异步配合使用的,高并发和易开发的好处就都给他占了。所以我们肯定要多用。

现在进入主题:“标准线程的协程替换实现”。就是使用 Python 将 POSIX Thread 重新实现为基于协程的版本。使现有的非异步程序,无论使用何种语言开发都能在无需修改和重新编译的情况下直接变成协程架构下的异步程序。

如果你看了这句话不知道是什么意思,没有关系。用一句话来说,就是:“在不改动程序的情况下,把任意程序变成异步程序”。

—— 这是什么,这是妥妥的黑科技!那么这是怎么做到的呢?

就是把系统线程库用协程重新实现一遍。再把阻塞式的 I/O 用异步重新实现一遍。然后再把原来的库替掉。

这种直接修改程序运行时的方法叫 DLL Injection —— 动态库注入。

Proxychains 就是一个运行时注入,很好的例子。

上面第三个链接是我开源的堡垒机套件,等一下演示 proxychains 的时候会用到。

Proxychains 可以让程序用上代理。他通过 LD_PRELOAD 这个环境变量,在程序启动时载入自己的 libproxychians 库,然后就把用户的 socket 替掉了。

这里来试一下用 wget 来取一个网页:

在左边我们看到这个 wget 是系统自带的,是没有改过的。右边是代理服务器。现在我们执行 wget,我们来看代理服务器的日志 —— wget 经过了代理服务器。这样我们接下来要替换 libpthread 系统线程库的这个想法应该是可行的。

接下来我们要用到两个技术。一个是 Gevent,是 Python 的协程库,已经比较完善了。

主题的原因我们今天先不讲。

另外我们今天主要是用到的是 Cython,这里我用 Cython 来写 C 语言的库。

大家说不对啊,Cython 是给 Python 写 C 扩展 extension 的啊 —— 其实 Cython 也是可以拿来做 C 语言开发的,Cython 其实可以把 python 程序或者 Cython 扩展语法的程序转成 C 语言的代码。

我这里就把一个 Cython 程序转成了 C 语言。

这段程序是字符串转整型。大家看左边这个 Cython 的程序基本上就是 Python 的语法,增加了静态类型声明。右边是用 Cython 把他转成 C 语言之后的样子,是没有 Python 依赖的纯 C 代码。

我们看 Cython 程序既是 Python 又是 C ,正好是现在排名前两名的语言。所以结合了 Python 和 C 的 Cython 应该是 VIP 中 P 了。

下面我们试试看来实现一个接口:pthread_create —— 线程创建接口。

整个程序特别简单,只有三个文件。

右边是程序的主体,里面用 Cython 写了一个 pthread_create 函数。它只做两件事,打印 PyCon China 2020 ,然后返回一个错误码说线程没有创建成功,然后退出。

大家注意这个关键字“public”,这样这个函数就会被导出到全局的符号表,给 C 语言,还有给所有语言的程序使用。

左下角是用 C 语言做一点的初始化工作,这个初始化函数会被写入编译参数,会在库加载时执行。左上角是一个 Makefile 项目文件。总共是 42 行代码。

编译成功以后,我们得到了两个库,现在我把他写进 LD_PRELOAD。然后我们起一个程序来测试一下,这里我用的是 Python,其实随便哪个程序都是可以的。

创建一个线程之后。我们看到 PyCon China 2020 出来了 —— 测试成功,说明系统原生的 libpthread 已经替掉了。

下面我来改进一下 Makefile。我经常说每个程序员一辈子只需要一个 Makefile,因为每个程序员在写过一段时间程序之后都会搞出一个无所不能的 Makefile。毕竟 —— Emacs 还能煮咖啡,make 必须也可以。

当然还有很多朋友是用 IDE 来管理项目的,对我来说难度太高,这里就不讲了。

现在左边是一个完善后的 Makefile,能够自动发现目录下的 Cython 文件,然后进行编译。

下面在 Makefile 的目录下随便写一个 Cython 文件,在右上角,大家看这里已经完全是 Python 的语法了 —— Cython 在语法上和 Python 是 100% 兼容的,所以我们完全可以用 Cython 把 Python 代码编译之后执行。

右下是一些额外的编译配置,如果需要的话。

下面进行编译,make 会自动找到目录下后缀是“pyx”的 Cython 文件,编译出一个 Python 模块。我们来测试一下刚才编译的“hello”函数。

因为在开发期我们会经常增删,改动文件,有这样一个自动化的 Makefile 会非常的方便。

下面我们回到前面的线程创建接口 pthread_create,这是把 gevent 协程加进去之后的完整版。

全部代码我已经开源了,这里是项目地址。目前包含了标准线程库的协程化的完整实现。

这里是我使用协程重新实现的线程接口,实现这批接口大概需要 2000 行代码 —— Python 向来惜字如金,2000 行已经算是大型项目了。

接下来说一下这个“大型项目”在实现上的一些细节,在开发上用到了哪些技术和关键技巧。

我们在开发协程版的线程库时主要操作的是 gevent 的 greenlet 对象。

这里有两个 greenlet 对象,底层的 greenlet 对象是由 greenlet 这个库提供的。Gevent 的 greenlet 对象继承自 greenlet 库。这里是这两个 greenlet 对象的定义:

左边是底层的 greenlet 库,他提供的 greenlet 对象是一个 Python 对象,他把函数栈,以及指令寄存器都保存在对象结构体中,用来维护协程上下文。

右边是 gevent 库里的实现,可以看到 gevent 也是使用 Cython 开发的。Cython 会把右边的代码转换成 C 代码。就像这样,这也是一个 struct。

我们注意看这个 __pyx_base 成员,他不是一个指向左边这个 PyGreenlet 结构的指针。而是占据了一整个 PyGreenlet 结构体的完整空间。所以右边这个 PyGeventGreenlet struct 和左边的这个 PyGreenlet struct 的头部结构是一致的,一个 PyGreenlet 指针也可以指向 PyGeventGreenlet 对象。而这两个 greenlet 对象的,共同头部,都是一个 PyObject 结构体的申明。

包括 PyDict 在内的 Python 的内建类型都是以这种方式从 PyObject 继承下来的。

内存整整齐齐是 C 语言天大的好处,整齐的内存结构可以做非常非常多的事情,比如说面向对象编程。

实际上 C 语言不仅可以做面向对象编程,而且还特别的简单好用。比如说访问父类,这里就可以从 __pyx_base 中取出父类成员。

另外,具有 继承关系的对象,也就是结构体指针还可以互相做类型转换。

接下来,在深入进行开发的时候我们还需要经常访问 greenlet 对象的属性。但是许多属性是只读,或者是不可访问的。这就需要把这些不可访问的私有属性重新变成可以访问的公开属性。

这里,左边是 gevent 的 greenlet 对象的定义,下面这些带下划线的属性在编译以后,是外部访问不到的私有属性。我们可以通过右边的 Cython 代码来重新申明一次对象,并重新注入回 gevent,让这些私有成员可以被重新访问到。

这样我们就改变了一个二进制模块的行为。

只要能正确地写出对象的内存排列,这个技巧就能用在各种场合,起到各种意想不到的效果。说到这里,我不禁要感叹一下 C、Python 还有 Cython 的博大精深。不愧为排名前二的语言和 VIP 中 P。

通过前面这些内容,我们已经顺利的获得了 greenlet 对象的完整控制权。接下来,我们来说一下具体开发中遇到的其他难点。比如如何强制终止一个协程,也就是实现 pthread_exit 这个接口。

一般来说在多线程程序中,可以通过 signal 来强行中止一个系统的原生线程,但是在协程中并不很适用。因为协程程序其实是一个单线程程序。我们仍然需要通过操作函数栈来实现这个功能。

熟悉协程的朋友应该能想到这样一个方法,切换到上级协程,然后把保存了当前函数栈的 greenlet 对象销毁,然后协程上下文就不能再切换回来,协程也就中止了。这样只需要一行代码就可以了。

这虽然很简单,但事情往往不会向想象中那样发展。因为即使协程对象 greenlet 被销毁,greenlet 库还是会把协程切换回来 —— 这和 greenlet 的内存管理有关。

greenlet 通过将栈转存到堆空间来管理栈内存,这部分内存会随着 greenlet 对象一起被回收。但是用户创建在堆中的内存不会被自动回收,为了留出回收这部分内存的机会,greenlet 里所有的 switch 栈切换,最终都会被强制切换回来。

即使一个协程被设计成完全无法再切换回来,greenlet 也仍然会用一个 GreenletExit 异常来返回,用来通知 Python 来清理对象引用。这显然不符合 pthread_exit 接口不可重入的要求。

下面这段代码可以用来说明这个问题,在 pthread_exit 被调用后,协程在 C 语言这个层面将会继续执行。

确切的解决方法是这样的。在切出函数之前,我们可以通过把 greenlet 对象的 stack_start 成员 hack 成空指针,来阻止 greenlet 抛出 GreenletExit 异常,使自己不能被重入。

这样一旦切换完成就不会再有机会回来清理堆内存,所以我们必须在切换前手工清理全部的对象引用,来避免内存泄漏。包括最后一步 Greenlet_Switch() 操作在内,都不能增加 Python 对象的引用计数。

接下来我们来解决另一个问题,循环依赖。

我们实现的 libpthread 依赖于 Python 运行时 —— libpython。而 libpython 本身又要用到 libpthread。这就产生了循环依赖。

那么 libpython 依赖的是哪些接口呢?

可以发现都是些和线程锁相关的接口。这样我们就知道了,Python 使用线程库是为了来实现 GIL 全局解释锁。

缘分啊,既然又碰到了老朋友 GIL。那么在解决这个问题之前我们先来思考一个灵魂问题。事情是这样的:

  • 因为 GIL,我们知道 python 本质上是单线程程序。
    —— 协程说,巧了巧了,我也是。
  • 那么 python 为什么还要用到线程?python 线程其实主要是用来解决 io 阻塞的。
    —— 协程说,正是在下。

下面问题来了,以 EVE online 为例,Python 协程大规模使用,至少已经有 17 年历史。

那么,Python 伪线程的意义它又在哪里呢?

Python 完全可以取消 GIL,用真协程来代替伪线程。编译一个无 GIL 的 Python 就可以解决所有的问题。

否则我们就只能标记出 GIL,我这里分配给 GIL 的锁 id 是 0,在碰到 GIL 操作线程锁时不做任何操作。

因为协程是一个单线程程序,这个操作是安全的。这样我们的线程库就在客观上禁用了 GIL。

还有一些其他的主题,比如在使用了 DLL Injiection 之后,覆盖了原版接口,那么如何再访问原版的接口?

就像这样,都比较简单,这里就不再赘述了。

今天的代码都可以在这里找到。

我今天的分享就到这里,谢谢大家。

你可能感兴趣的:(沈崴 - 标准线程的协程替换 · 文字版 - PyCon China 2020)