写在前面:
Rust 1.0 临近,libgreen 由于统一接口代价太大以及其伪轻量级的事实被降级为不推荐的社区项目,zmq.rs 项目也面临着一次基于 mio 的重新设计——除非更合适的协程实现能立即出现。所以呢,草稿箱里积存了数月的“命令通道”部分不再有意义了,但考虑到新的设计中也将有类似的概念,仍将其贴出来。
命令通道(该设计即将删除!!)
之前的类图显示了几个重要的结构:socket 接口、不同类型 socket 的实现,以及他们的共享功能 SocketBase。但是,这几个结构其实都运行在用户的 Task 里,也就是拥有 socket 的代码所在的 Task。前面也说了 zmq.rs 会创建好多 Task 的,下面就介绍几个跑在独立 Task 中的结构。
我们就按当时实现的顺序来吧。
TcpListener 是用来接受连接请求的,每次调用 bind() 时,SocketBase 就会先创建一个 Task,然后在其中创建一个 TcpListener。我们知道,Rust 中最经典的任务间通信方式就是通道,无不例外,TcpListener 和 SocketBase 之间的数据交换也是通过通道,这个通道叫做命令通道。
命令通道目前是单方向的,命令一般由不同的独立的 Task 发给 SocketBase 对象。上述类图中的 tx 和 rx 就是命令通道的两个端点,SocketBase.process_commands() 会从 rx 端接收命令并执行命令;而发送端 tx 有多个副本,是一个多发单收的通道,当创建 TcpListener 的同时,SocketBase 会克隆一个 tx 出来交给新创建的 TcpListener。
TcpListener 在单独的 Task 中就可以为所欲为了。目前的实现是一个死循环,每次循环会异步阻塞在等待 bind() 给定端口上的新连接请求。每当有新的连接请求,TcpListener 都会接受,创建一个 TCP 连接,然后创建一个连接处理的任务(稍后提到),最后通过命令通道告知 SocketBase。TcpListener 会一直重复这样的行为,一直到……什么时候呢?
为了不让 TcpListener 的死循环影响到程序的正常结束,我们必须在 SocketBase 被销毁时(以后如果有了 unbind 还得再单独考虑),尽快跳出死循环。还好 Rust 的通道考虑到了这一点,提供了这样的功能:在一个通道的接收端被销毁后,继续往发送端发送数据的操作将会失败。所以我们只要能往 tx 里发一个空白命令,如果发送失败就可以跳出死循环了。因此,我在 TcpListener 的阻塞调用 self.acceptor.accept() 上加了一个 1 秒(也许会改成 100 毫秒)的超时,然后每次循环都会发送一个空白命令。这样,当用户的 Task 退出之后,TcpListener 最多再存活 1 秒钟,然后也会自我销毁了。
看完了命令通道的发送端,我们再来看一下接收端。
SocketBase 就没有 TcpListener 那么自由自在了,因为 SocketBase 是存活在用户的 Task 中,这也就意味着,它不能无节制的阻塞,不然用户还怎么做自己的事情。所以呢,当用户在执行自己的代码时,我们只能等着先不去接收命令,这时命令就会在命令通道里排队。一旦 SocketBase 有机会执行了,比如用户调用了 recv() 或是 connect(),我们就抓紧机会,先将待处理的命令一并处理干净,然后再去完成用户的请求。
这个设计跟 libzmq 是一致的:SocketBase.process_commands() 接受一个布尔型的参数 block,来决定是阻塞式地处理一个命令——如果没有命令就一直阻塞、等到一个再处理,还是非阻塞式地尽量去处理一个命令——如果有现成的命令就处理且返回真,否则立即返回假,绝不多等。处理命令是在一个循环里用非阻塞的方式进行的,每次循环先试图处理一个命令,然后同样是非阻塞地去调用底层。
翻天覆地
正如一开始所说,libgreen 已经不复存在了,所以以上所有基于轻量级协程的并发设计就不合适了,因为 Task 的开销太大——即使是继续使用 green-rs。关于这一段故事,我在这些幻灯片里也记录了,稍后我再把明天的录像发出来,大家有兴趣可以看看。
在新的设计出来之前,这个话题就先暂告一段落了。