从今年 3 月份看到有人打算用 Rust 重写 ZeroMQ、我开始认真学习 Rust 语言,到后来 6 月份开始着手实现,再到现在 0.1 版即将达成,前后也有小半年了。今天,我打算在这里把当前的设计总结一下,也顺便试图招募志愿者一起来做开发。
项目地址:https://github.com/zeromq/zmq.rs
没错木哈哈,被收编成了 ZeroMQ 官方项目了,所以一定来一起做哦。
关于本文:8 月份的草稿啊!这都年底了,真是醉了。下个月(2015 年 1 月)北京有个 Rust 的聚会,打算分享这个项目,所以现在果断删掉未完成章节,把能发的先发出来。
Rust 语言
忽然意识到之前几篇《Rust 语言学习笔记》一直没有介绍过 Rust 语言,这里一并补齐。
Rust 语言是几种下一代编程语言中较为优秀的一款系统级编程语言,最显著的特点就是运行时几乎不崩溃、高并发无数据竞争,还有就是跑的贼拉快。
相较于应用级编程语言如 Java 或 Python,Rust 作为一款系统级编程语言,天生被设计用来开发系统软件诸如操作系统、设备驱动或编译器等,与 D 语言、Go 语言齐名(大家对 Go 语言是否是系统级编程语言有争议),是 C++ 继位者的候选人。因此,Rust 理所应当是编译型的语言。依托于 LLVM,Rust 在编译器(Rust 编译器前端系 Rust 语言本身实现,即所谓的自举)上大下功夫,通过“所有权”的概念将内存管理的最佳实践整合在了编译期,在编译期保证了运行时的内存安全,且没有使用垃圾回收机制(垃圾回收是 Rust 的一种可选的额外工具),运行速度不打折扣。这种对内存管理的高要求,也让程序员可以更容易地用 Rust 语言编写正确的高并发程序,自然地实现类似 Erlang 的并发模型。另外受 Haskell 的熏陶,Rust 语言对函数式编程也是非常友好的——我自己认为要比 Python 函数式多了。另外,Rust 的语法一点也不诡异,如果您写过 C/C++/Java/Python/Ruby,您会觉得很多语法似曾相识,上手较快。
不多说了,毕竟不是《半小时,介绍 Rust》——有兴趣大家可以移步这里继续阅读。
ZeroMQ
ZeroMQ 乍一看是一种消息队列,但实际上它并不是传统意义上的消息队列——开一个消息服务器,所有消息都经过它,可以离线什么的。上述传统消息队列主要部分只是 ZeroMQ 指南中的一个叫做泰坦尼克的模式概念,ZeroMQ 的库中甚至都没有这种模式的实现。其实 ZeroMQ 是一种内嵌式的网络库,关注于怎样将程序连在一起。简单来说,您可以认为 ZeroMQ 是对普通 socket 的一种封装和抽象,将常规的通信工具封装成了 ZeroMQ 的 socket。您可以通过 ZeroMQ 的 socket 来实现通信,ZeroMQ 的 socket 则内置了消息排队、流量控制、收发模型、断线重连等机制,方便您设计出自己适合的消息通信模型。
介绍再细一点。
每个 ZeroMQ 的 socket 都有一个类型,决定了它内置的收发模型,比如创建一个用于发送请求的 REQ:
1
2 1pythonsocket = context.socket(zmq.REQ)
2
REQ 就限制了,这个 socket 必须得先发送一个消息,然后才能——且必须——接收一个消息。发一个请求、收一个响应,绝对不能乱套:
1
2
3
4
5
6
7
8
9
10 1python# print(socket.recv_multipart()) -- 这会失败
2
3socket.send_multipart(["Hello"])
4# socket.send_multipart(["Hello2"]) -- 这会失败
5
6print(socket.recv_multipart())
7# print(socket.recv_multipart()) -- 这会失败
8
9socket.send_multipart(["Hello2"])
10
当然了,在收发消息之前,我们得先把我们的 socket 跟别的 socket 连上才行:
1
2 1pythonsocket.connect("tcp://192.168.3.27:8868")
2
意思就是说,跟监听在 192.168.3.27:8868 上的 ZeroMQ socket 建立 TCP 链接。另外,ZeroMQ 还支持进程间通信(ipc)、进程内通信(inproc)和多播。
有趣的是,ZeroMQ 允许先 connect,再创建监听端的 socket(因为内置了断线重连机制):
1
2
3 1pythonserver_socket = context.socket(zmq.REP)
2server_socket.bind("tcp://192.168.3.27:8868")
3
这个 REP 的 socket 也只是又一个普通的 ZeroMQ 的 socket,只不过 REP 的要求与 REQ 正好相反,必须得先收再发——收一个请求,返回一个响应。这里我们就不多做示例了。
每一个 ZeroMQ 的 socket 都(特例除外)可以多次做 connect 或/和 bind,只要 socket 之间建立了连接,他们就可以在各自类型的约束下实现通信。对 REQ 来说,发送时会轮流使用所有连接,接收时必须从上一次发送去的连接来接收;而对 REP 来说,所有连接上进来的请求都会被公平的排队,REP 会公平地接收,而发送时则保证将响应发送回请求的来源。这些就是所谓的收发模型,这对用户都是透明的,您只需要调用 send 或 recv 就可以了。
除了请求-响应模式的 REQ 和 REP,ZeroMQ 常用的一些 socket 类型还有:高级请求-响应模式的 DEALER 和 ROUTER、发布-订阅模式的 PUB 和 SUB、流水线模式的 PUSH 和 PULL 等。
实际应用中,我们通常会将上述这些基本模式综合使用,组成各种各样的高级模式——比如一开头提到的泰坦尼克模式——去解决很多实际中的问题。这些就不多说了,大家有兴趣可以再次移步这里来观摩学习。
Task 模型的小纠结
该进入正题了。这一部分跟之前的那篇英文博文的第一部分对应的。这一部分和接下来的一部分主要做两个关键的技术选型。
我对 zmq.rs 的设计“借鉴”了 libzmq 很多——为了保留借鉴痕迹便于参照,有些名词我连名字都没有改(呃,这句英文博文里没有……)。之前的几篇《Rust 语言学习笔记》中,其实我已经在试图搭 zmq.rs 的架子了,有个关键性的问题在当时就已经出现了:
怎么样正确使用 Rust 的 Task。
目前来看,针对于我的项目,C++(libzmq 的实现语言)和 Rust 有两点重要的不同:
- Rust 还没有提供一个可用的、用于轮询文件描述符的 select() 接口,以及
- Rust 默认提供了一个可选的库 libgreen ——提供了微线程的实现。
作为一个 Gevent 的重度用户,我自然而然地选择了微线程模型,通过创建大量 Task 来实现并发,底层与直接使用 select() 来实现异步并发并无实质区别。这听起来非常理想,但遗憾的是 libgreen 并不是 Rust 的默认选项—— libnative 才是。这样的话,如果用户选择默认使用 libnative,那么 zmq.rs 轻轻松松就可以帮用户创建数百个操作系统级的线程,因为 libnative 1:1 的模型下,一个 Task 就是一个线程嘛。
这就不是一件很令人愉快的事了。那我们先放下这个,看看另外一条路吧:完全借(zhao)鉴(ban)libzmq,Rust 里缺什么再补什么,比如 select()。1:1 的 libnative 模型自然就不会有问题了,因为我们将要自己重新实现异步,所以开启的 Task 数屈指可数。虽然没有了协作式的异步编码优势,但这条路上的代码也可以写的干净整洁——至少不会比 libzmq 差吧。可是,这个时候假如用户又选择了使用 libgreen,又会怎么样呢?咣当!几个协作式的异步 Task 在分别执行一段手动实现的异步代码,何其诡异!因为协作式的异步 Task(即微线程)是应该被主事件循环驱动的,而不是用来跑一个自己写的事件循环!微线程本来就是设计用来将异步代码同步化、提高可读性、降低编程复杂度,而不是像现在这样又全都搞回去了。libgreen 在底层已经使用了 libuv 作为主循环来调度所有的 Task,而我们现在为了实现 select(),还得想办法把 libuv 底层的接口给暴露上来。最后,我们还得使用这些接口,重新自己实现一遍异步任务调度。
就我来看,上述方法有可能是在 libgreen 下实现 select() 的最合理的方法,但这让我觉得非常别扭,感觉好像把 1:1 的 libnative 跟 M:N 的 libgreen 做出统一的接口是个糟糕的主意。这里不深究了,问题交给 Rust 1.0 之后打算实现 select() 的人去吧。这里呢,我选择了第一种方案,也就是把 Task 当微线程的方案,因为目前来看这种方案需要写的代码更少,而且感觉更像是 Rust 期望的样子。
补充:其实也没有必要整个程序全都一致要求要么 libnative 要么 libgreen 的,为什么不能混着用呢?我的 zmq.rs 内部自己搞一个 libgreen 的 Scheduler 好了,自己强制使用微线程模型;调用的人爱咋咋地呗。所以也就不纠结了。
基于接口的继承
这个事儿也让我纠结了很久,也别扭了很久:Rust 里的继承只包括成员方法,不包括成员变量。换句话说,Rust 里没有类,只有接口(Trait)和数据结构(Struct),struct 可以实现 trait,trait 可以继承 trait,但是 struct 不能继承 struct。这让用惯了 Python 的我非!常!不!爽!怎么能这样呢,父类里根本不能定义数据!难道父类搞个多态还得每次要数据的时候调用一个 self.getData() 吗?这据说还是面向对象编程的一个演化方向呢,求高人点解啊……
这里碰到的问题主要是定义 SocketBase 啦,人家 libzmq 里洋洋洒洒几个文件,轻松搞定了漂亮的继承关系。到我这里死活搞不漂亮了:作为父类的 SocketBase 必须得自己负责一部分数据,而子类跟 SocketBase 又是息息相关的。
怎么办嘞?
拆!还是顺应了那句老话,先组合后继承。现在呢,我的 socket 长这样:
所以呢,ReqSocket 和 RepSocket 的 bind() 里只有相同的一句:self.base.bind(),connect() 也是类似。而对于 send() 和 recv() 的实现,两种不同的 socket 则有了不同的实现,且调用 self.base 的函数也不尽相同。
用这种组合的方式固然解决了当下的问题,但不知以后能不能学会 Rust 里基于接口的继承。