理想的网络程序架构

在写完自己的Raft算法实现xraft之后,对自己欠缺并发和网络程序方面的知识非常有痛感。之后自己找了很多并发程序方面的资料,比如《多核编程艺术》,相对系统性地学习了并发数据结构的知识。其中比较中意STM(Software Transaction Memory)技术,因为实际的程序中对于共享数据的访问模式不会像典型并发数据结构那么简单,这时候,拆分数据,并且用STM来访问的话可以间接解决并发访问的问题。

到此为止,我一直认为自己是并发程序方面有所欠缺。转变发生在今年年初,自己看到别人写的一篇实现Bittorrent Client的文章,自己也想实现一下。个人在很早以前,就对实现Bittorrent Client很感兴趣,但是当时自己能力只够解析Torrent文件。

在实现Bittorrent Client时,自己有意识地把能够并行执行的代码并行化,并且尝试使用Kotlin的coroutine来写。使用Kotlin的coroutine而不是现在貌似很火的Golang的goroutine来写主要还是因为自己不喜欢Golang的运行模式。Golang的runtime决定了goroutine怎么执行,用户无法直接介入。个人认为应用程序需要一种flexible的机制,能够根据工作负载决定使用基于线程的还是基于work-stealing的coroutine的运行模型。Golang帮你做了决定,在错误处理上也帮你做了决定,有强烈的设计者(C语言)的风格。

回到主题,相比之下,Kotlin的coroutine提供了自定义运行模式的方法,我可以强制要求某些coroutine只在某个线程中之行,利用thread confinement避开锁或者其他同步机制。而且Kotlin的coroutine提供了结构化的coroutine,能够简化复杂的异步编程场景。比如说,我先异步访问服务A,结束之后在并行访问多个服务B,全部完成后再访问服务C,Kotlin里面不需要额外的同步类,只需要使用coroutineScope,launch,async/await就可以实现。整体来说,Kotlin的coroutine非常适合复杂的业务场景,用一个类似DSL的方式类型安全地编写异步/并行编程的代码。

不过我在用Kotlin的coroutine编码的时候,发现一个问题,准确来说这和coroutine没有关系,是你的网络程序该用什么架构?习惯了Web后端的线程模型,NodeJS的单线程模型,UI程序的单事件模型之后,对于网络程序,感觉没有一个确定或者通用的模型。coroutine是一个模型么?个人认为coroutine不是一个模型,coroutine准确来说是一种实现,运行模式的一种实现。你可以用基于线程的运行模式,也可以使用coroutine+work stealing的运行模式,这只是两种不同的实现。对于网络程序来说,你的工作负载决定了你适合哪种运行模式。

一般人可能会按照IO多,还是CPU多来分。很明显CPU工作多的程序不希望频繁切换CPU,而IO非常多的则可以在IO等待时运行其他任务避免浪费CPU。现实中的程序,往往是两者兼具的。在定性你的程序之前,另外一个注意的是,运行模式会影响到你的并行程序的写法。程序调度、共享数据访问、线程管理其实是互相关联的,不只是网络程序,非单线程程序都需要考虑这个问题。

之前也提到通过thread confinement可以避免使用锁,这其实就是一个通过程序调度解决共享数据访问的例子。假如一个语言把程序调度和线程管理都帮你做掉了,那么你必须自己解决剩下的共享数据访问问题。Golang提供了锁和间接解决问题的工具channel,Erlang禁止共享数据访问并且提供了Actor方案。

语言提供的解决方案,往往存在可以优化的空间。比如说我有一个只有单个线程会更新的数据,由于其他线程只会读,所以不需要锁,也不需要发送消息。从另一个角度来说,基于通信的方案可以解决更复杂的问题,但是简单的场景可以使用更简单和高效的方案。另外,基于通信的方案对于一次要求多个数据更新的场景支持并不好,有些人会退回到粗粒度锁,但更好的方案是STM等。

总体来说,网络程序也好,一般的多线程程序也好,需要一个灵活的、能够最大化性能,以及解决共享数据访问的整体方案。个人在尝试了多种方案之后,觉得类似Actor,或者说从SEDA(staged event driven architecture)出发的方案比较合适。SEDA把工作负载按照步骤切分,提供了并行执行的方向,间接地解决了共享数据的问题。你可以把SEDA中的Stage等同于Actor,只不过Erlang用coroutine来运行Actor。这里面的重点不是coroutine,而是Actor在收到消息之后,顺序执行的这点。所以Actor是类似Process的东西,而在Process中执行的指令满足process confinement,理论上不会出现多线程访问下的数据竞争问题。其次Actor额外还有父子结构,自我替换,异常时的处理策略等等。其中父子结构模仿了进程的父子进程,可以用于动态子任务生成,与父Actor在不同逻辑线程上执行,提高并行度。

从解决方案的完整度上来看,Actor确实可以作为一个参考架构。为了进一步提高性能,你可以放宽Actor要求,允许Actor收到消息后乱序执行(或者说没有Queue)。还有就是Erlang是一门函数式语言,只有不可变的变量,假如你用其他语言,可以使用语言本身的内存模型来小心地共享部分变量,减少Actor之间的消息。

个人其实也是这么做的,在几次重写之后,用Kotlin写了一个简单的Actor运行时,使用Actor架构的变体来同时解决并发数据访问和运行模式的选择。整体上来说,不用考虑背后是用线程还是coroutine,并发数据访问也很容易,应用层面不需要锁。到现在为止,应该算是我个人认为比较理想的网络程序架构了。