2

写在前面:这是一篇近一年前的草稿了,翻出来发现,关于 Task(已改名为 Thread)退出的一些做法仍然适用,而且 zmq.rs 0.2 不出意外也要用到,所以仍然把这篇写完贴出来备查。但请注意,文中关于 libgreen 的一些描述已不属实。

这一篇隔的时间比较长,期间我们的游戏在准备上线,所以也没时间写 Rust,着重在课余时间研读了各种文档、源码和 issue report——关于 Rust 的 I/O。

从试图杀掉一个 Task 开始

我的 zmq.rs 项目终于开始碰到网络操作了。由于 Rust 给封装出来的 I/O 接口都是相对于 Task 同步的,所以目前来看还得给每一批 I/O 操作创建一个 Task

这里得多插一点内容了。首先是关于 ZeroMQ,它的一个 socket 是可以跟很多个别的 ZMQ socket 通讯的,而且是可以通过不同的 endpoint 来完成。比如一个 REP 服务端 socket,同时监听了 8080 和 9090 两个端口(endpoint),每个端口可能都有数十个 REQ 客户端 socket 连接上去;更有甚者,这个 REP socket 也可以主动去连接某些客户端 socket 监听的 endpoint,上门服务。而在所有这些网络拓扑结构的上面,一个 REP socket 对程序员的接口是始终一致的,您只需要反复地从这个 REP socket 读一个数据包,然后发一个数据包就好了。

这样一来呢,我就得给 REP socket 底层的每一个 TCP socket 连接创建至少一个 Task,以满足其并发性。之前我们有提到,Rust 提供了 libgreenlibnative 两种运行时环境,对应了两种不同的 Task 实现模型。对于 zmq.rs 来说,基于目前的阻塞式的 I/O 接口来看,我们很有可能需要创建大量的 Task——这对于 libgreenM:N 模型来说是轻而易举的,但对于 libnative 来说却是值得商榷的,因为 1:1 的模型意味着我们将会用大量操作系统的线程来微操极少量的 ZMQ socket,这对于追求高并发的 ZMQ 也许并不是一个好主意——虽然 Rust 的 Task 模型能极大地避免常规多线程编程中最糟心的那一部分,但是内存占用会不会太高(哈!高!-2015),上下文切换的代价会不会太大等问题还有待于进一步测试。如果结果不理想,也许每批 I/O 一个 Task 的这种设计就要被推倒,新的设计将需要 Rust 提供异步的 I/O 接口。(0.2 确实将这么改 -2015

回到原来的话题。因为创建了好多 Task,一定会碰到的问题就是怎么结束它们,所以我一上来就打算先看一眼这个问题。没想到这一看,看出了好多问题。

因为受 Python greenlet 的严重影响,我自然而然地以为,结束一个 Task 应该用 kill()——对于一个暂停状态的微线程,扔进去一个 GreenletExit 异常是多么正常的一种方式。可是 Rust 不那么认为。我也想到了由于需要支持 libnative,停止一个阻塞在 I/O 调用中的线程绝非扔一个 GreenletExit 那么简单,但我还是义无反顾地去搜各种 rust task kill terminate shutdown 之类的关键词。

结果逐步明朗,原来 Rust 在 0.9 之前确实有过 Taskkill 功能,是通过 supervisor 模型实现的——即一对 Task,任何一个挂掉都会导致另一个挂掉。但是呢,由于一些原因这个功能被砍掉了,也就是说,在 Rust 里,一个 Task 只能从内部抛错误死掉,没有办法从外部直接杀掉。另外,这个还(居然!)导致了我的第一个 stackoverflow 回答

正确结束一个 Task

既然无法从别的 Task 中主动杀掉一个 Task,那么就想办法让这个 Task 自杀。一个长时间运行的 Task 通常处于两种状态:1、执行代码;2、等待事件。

对于一个正在执行代码的 Task,我们是无法让 Task 自己忽然想到该结束了然后戛然而止。只有在某些特殊情况下,我们才能手工写一些代码,让程序执行一段代码之后,去检查一下是否应该结束了,比如在一个死循环里:

rustwhile self.running {
    // Do everything else
}

而大多数其他情况下,从事件等待中跳出来结束一个 Task 更为常见。这里也分两种情况:A、等待 I/O 事件;B、等待 channel 事件。两种情况处理都比较简单,A 的话就给调用加一个稍短的超时,然后重复前面的那个例子,比如:

rusttcp_stream.set_read_timeout(Some(1000));
while self.running {
    let result = tcp_stream.read_byte();
    // Do the rest
}

而对于等待一个 channelTask 来说就更容易了,只要 channel 的另一端销毁了,这个等待的调用就会自动结束,只需要正确处理调用结果就好了。

其实,上面两个例子中的 self.running 应(至少)为一个 Arc,因为需要从别的 Task 中来设置这个值。这里还有一种也是用 channel 的处理方式,就是在每次循环的开始处,向一个连接到父 TaskchannelSender 端,发送一个空白消息,这样如果另一端已经销毁了,发送会失败,也就意味着我们该退出这个 Task 了,比如这样:

rustlet (tx, rx) = channel();

// ... in task
let mut a = TcpListener::bind("127.0.0.1:8482").listen().unwrap();
a.set_timeout(Some(1000));
loop {
    match a.accept() {
        Ok(s) => { tx.send(Some(s)); }
        Err(ref e) if e.kind == TimedOut => { tx.send(None).unwrap(); }
        Err(e) => println!("err: {}", e), // something else
    }
}

fantix
1.7k 声望174 粉丝

Linux、Python 与开源爱好者一枚,GINO 项目作者,EdgeDB 团队成员。