藏川线前段

--- 摄于 2017 年 9 月 藏川线前段

P2P 网络系列(三)

当库雏形初步完成的时候,我猛然发现,似乎我正在重新实现 https 协议。这是两个月之前,当库初步完成,我的一句感慨。

是的,如果你仔细看整个库的设计和实现,你会发现,这个库的整套逻辑与 https 不谋而合,也许没有那么精细,也许没有那么通用,但是通过实现这个库,让我也更能理解和领悟其他协议为什么会像现在这样设计和实现,甚至可以揣测到设计者当时碰到的各种问题。

整体架构

在实现任何一个框架之前,都需要仔细想清楚,为什么要做这个框架以及框架的目标是什么,很大程度上,第一个问题,需要考虑的是使用场景,第二个问题,需要考虑的是整体架构。第一个问题,在第一篇已经有回答,就不再重复了。我们重点在架构上,我们需要一个轻量、简洁、高效、对用户友好的底层网络库,用来支撑上层的分布式系统;同时,又要求它有一定的灵活度,可以向后兼容,可以轻松地升级通信协议;再之后,也许这个库的实现可以灵活到随时切换最底层的网络协议(TCP/UDP/WebSocket)。

于是,这么多需求转换成计算机术语来说,就是必须分层抽象,每一层实现一个功能,每一层都不知道上下层到底在干什么,当多层抽象合并在一起的时候,这些需求也就实现了。

在计算机世界里,如果一层抽象不够,那就再加一层。

网络,必然少不了加密,随着 Chrome/Firefox 对 http 网站的警告和禁止访问,裸奔时代已经基本结束了,人们对加密传输的重视程度也越来越高,框架层提供加密通信也是一个必然的选择。于是,我们的整体架构也就大致成型,如下图所示:

分层架构简图

Secio 层,承担了加密的工作;Yamux 层,承担了多路复用的功能;P2P 层,承担了对用户的多协议抽象、路由等工作;而最下层的 TCP,未来也许会变成 WebSocket 等各大知名底层协议,这也是一层抽象,为了能在多个底层协议上支持同一套上层架构,当然,当前实现只是 TCP,主要原因也是不言而喻的,我们要首先实现最核心的功能用来支持测试网和 CITA 的网络,后续的扩展,需要更多的时间去打磨。

Future 的拆分调度

确立了整体模型的层次区分之后,我们需要确定另一个模型,就是 Future 的编程模型,我们需要用异步编程保证性能,而在当前的生态中,如何去构建一个 Future 任务也是一个大问题,Future 的结构其实就已经决定了是否能够更高效地利用多核性能。

从 tokio 的文档中,我们可以看到,作者的意图很明显是让用户尽可能将任务拆成一个个小 Future spawn 出去,交给 runtime 的 work-steal 算法去调度,这样能更好地利用现代计算机多核的优势,尽可能不堵塞任务。实际上,作为真实的编程场景,每个 Future 并不会像示例那样干净、不带任何状态,网络编程中,每个 Task 带状态,并且需要同步状态的情况会比较普遍,设计数据结构的时候,这一块的取舍是很重要的。

用代码举例两个很常见的数据结构设计:

层层递进式:

struct Foo {
    state_1: xxx,
    state_2: xxx,
    resource: Bar,
}

struct Bar {
    state_1: xxx,
    state_2: xxx,
    socket: TcpStream
}

impl Stream for Bar {}
impl Stream for Foo {}

这个嵌套可以套无数层,每一层都有自己的状态保存,但是最核心的资源(resource)是底层的 TcpStream,这个是驱动 Executor poll 的核心实现,整个上层逻辑的运行都依赖于 epoll 返回的 readable 和 writeable 事件,总体来讲,这样一个层层嵌套的大状态机,理论上应该是在一个线程上执行任务。

但是有个问题是,前一个消息未处理完之前,下一个消息在上层是无法收到的,这是一个非常大的局限性,这里稍微推导一下就可以想到这是为什么。这个局限,对于多路复用的通道来说,非常不友好,意味着有可能因为一个协议的问题影响到所有协议的响应。自然而然,我们可以想到,把最核心的资源通过 Arc<Mutex<..>> 的方式进行共享,这样就可以支持多线程调度,同时缓解协议之间的相互影响,这是个不错的方法,但是我们并没有采纳,因为我们觉得 channel 分发以一种更优雅的方式解决了这个问题。

channel 分发:

struct Foo {
    receiver: Receiver<Event>,
    sender: Sender<OtherEvent>,
    state_1: xxx,
    state_2: xxx,
}

struct Bar {
    socket: TcpStream,
    sender: HashMap<Id, Sender<Event>>,
    receiver: Reciever<OtherEvent>,
    state_1: xxx,
    state_2: xxx,
}

impl Stream for Bar {}
impl AsyncWrite for Foo {}
impl AsyncRead for Foo {}

这是 channel 分发式 Future 的典型结构体构造,底层 Bar 是一个资源流,通过 channel 将数据向 Foo 进行发送,同时接受 Foo 发过来的数据,并转发到资源流中,根据实现,FooBar 的关系可以是多对一,也可以是一对一。多对一就是多路复用分流的典型场景,一对一就是最简单的单通道场景。

这样的实现,可以天生让 FooBar 无干扰地跑在多核环境中,对比上一个层层嵌套会略显复杂,但思路可能会更加清晰。我们的实现中,大量采用了这种结构。

多资源统一处理

struct Foo {
    resource_1: Bar_1,
    resource_2: Bar_2,
	...
    state_1: xxx,
    state_2: xxx
}

impl Stream for Foo {
    fn poll(&mut self) -> Result<Async<Option<Self::Item>>, Self::Error> {
        loop {
            match self.resource_1.poll()? {
                Async::Ready(Some(x)) => ...
                Async::Ready(None) => ...
                Async::NotReady => break
            }
        }
        loop {
            match self.resource_2.poll()? {
                Async::Ready(Some(x)) => ...
                Async::Ready(None) => ...
                Async::NotReady => break
            }
        }
        Ok(Aysnc::NotReady)
    }
}

struct Bar_1 {
    state_1: xxx,
    state_2: xxx,
    socket: TcpStream
}

struct Bar_2 {
    state_1: xxx,
    state_2: xxx,
    socket: UdpSocket
}
...

将多资源统一到一个 stream 上处理,状态相互影响,在同一个层级进行反馈,这样好处是用一个层级表达了多个资源的统一,但是,要注意的是,如何平衡多个资源之间的时间片占用问题,如果其中某一个资源因为短期内的大量反馈,导致完全占据一个或多个短暂的系统调用时间,会导致其他资源无法进行反馈,间接堵住了上层可能急需的某些消息或者是状态,使得调度极其不均匀。可选的方法是,对每个资源规定一个 poll 可执行的上限次数,强行平衡多资源的时间占用。

这种做法,在我们的实现中,也大量用到,实际上 channel 分发中,每一个 receiver 就是一种资源的抽象,必然会存在这样的平衡问题。

代码架构

经过上面 Future 任务拆分的讲解,我们很自然做出了如下图的代码实现架构图,同层次间通过 channel 进行任务分发,上下层之间,封装加组合形成,最后完成整体架构要达到的效果。

代码实现架构图

我觉得,图已经很详细了,这里就不展开讲解,后续章节,将对每一层的实现有一个简单的介绍。

小结

拖了这么久,最后这篇整体介绍总算是出来了,原因有不少,重点是懒(2333)。顺便一说,这个库最近已经作为底层网络库合进了我司的两个主要产品中,奇怪的是,合之前非常想合入,合之后又感觉压力大增,神奇的人类心理。

评论区

加载更多

登录后评论