Rust 与服务端编程的碎碎念

Rust 是 Mozilla 推出的一门系统编程语言,非常看重内存安全,是一门非常优秀的语言。Mozilla 用它构建了其下一代的浏览器内核 servo,其工程能力毋庸置疑。那么,Rust在服务端编程领域会有什么建树呢?

我们从最简单的服务端程序模型开始说:

工作上,我们经常会去写一些服务,不管是经典的 http 服务,还是各种奇怪的中间件服务。但不论如何服务,其代码上的大框架应该是下面这样的:

pub fn run_unsleep() {
    let worker = num_cpus::get();
    let handlers: Vec<_> = (0..worker)
        .into_iter()
        .map(|_| thread::spawn(move || { loop { do_next(); } }))
        .collect();

    for h in handlers {
        h.join().unwrap();
    }
}

在上面的代码里,我们开启了足够数量的工作线程,每一个线程独占一颗 CPU(超线程)。然后,我们在线程内部,跑了一个循环。这个循环其实要做的事情很简单,就是去不断的拿到下一个任务,然后去执行这个任务。但是,这个事情似乎是不太完整的,比如我们将 do_next 声明为如下:

fn do_next() { }

这其实就是一个典型的服务器端程序,只不过能做的事情有限(除了暖机还能干什么?)。这个时候,你会发现你的 CPU 被占满了,而且停不下来。

等,等等!
我们需要的是想办法停下来。需要知道,我们的服务器资源,尤其是 CPU 资源都是极度宝贵的资源。我们需要有办法能主动让出我们的 CPU,有什么办法主动让出 CPU 呢?当然不是让你在外面 sigstop 和 sigcont 。而是需要让我们的服务在没有请求的情况下放弃 CPU 。一般情况下,我们会将我们的服务端循环等在一些系统调用上,最经典的当然是 accept(2) 了。

accept 是一个系统调用,我们通过这个调用接收新的 TCP 连接,每一个 TCP 连接都是一个 四元组 —— (源ip, 源端口, 目的ip, 目的端口)。当然了,其实在我们的网络编程中一般都描述为五元组——毕竟世界上不止有 TCP 一种传输层协议。不过我们的重点不在于四元组和五元组的区别,我们要知道一件事情,系统调用是可能会导致阻塞的,阻塞就一定会导致上下文切换。

进程状态
我们在学习 OS 课的时候,老师都会给你们画一个 进程的 三大状态: 就绪,运行,挂起。三中状态可以按照一个状态转换图在某些条件下进行有序转化。而在 linux 下干脆更简单的分为了 R (task_runnable/task_running) 和非R状态(包括 S、D、Z、X 等等)。这两个状态的进程之间相互转化。

我们知道的,当一个 fd (file descriptor) 上的某些操作不能被满足 (比如read的时候没数据,比如write的时候写不进去)的时候,系统就会把一个R状态的进程切换成S状态的进程,并保留其现场(堆栈,寄存器等)以便恢复,直到这些特定条件被满足了之后,由系统将这些 S 状态的进程切换回R状态。

我们知道,S 状态的进程是不会消耗 CPU 时间的(当然 R 状态的也不一定就在用CPU),而 accept 调用并阻塞了之后,当前线程便会被置为 S 状态 ,恰恰不会使我们的CPU过频。回想上面,我们发现 accept、read、write 都有这样的功能。于是我们得到了这样的服务器程序:

pub fn run_accept_each() {
    // 首先 bind and listen
    let listener = Arc::new(TcpListener::bind("0.0.0.0:12345").unwrap());

    let worker = num_cpus::get();
    let mut ths = Vec::new();
    for _ in 0..worker {
        // 利用 Arc 共享资源
        let listener = listener.clone();
        let th = thread::spawn(move || {
            loop {
                // 在每个线程里去 accept
                let (stream, _addr) = listener.accept().unwrap();
                echo(stream);
            }
        });
        ths.push(th);
    }
    join_all(ths);
}

fn join_all<T>(hs: Vec<thread::JoinHandle<T>>) {
    for h in hs {
        h.join().unwrap();
    }
}

fn echo(mut stream: TcpStream) {
    let mut buf = [0u8; 1024];
    loop {
        let rsize = match stream.read(&mut buf) {
            Ok(size) => size,
            Err(err) => panic!(err),
        };
        // EOF: read 0 size
        if rsize == 0 {
            break;
        }
        match stream.write_all(&buf[..rsize]) {
            Ok(_) => {}
            Err(err) => panic!(err),
        }
    }
}

在这里我们写了一个最简单的服务器端 echo 的程序。然后这里我们就又遇到了一个问题,当内核接收到新的 tcp 请求的时候,是该如何去处理呢这些 accept 呢?在某些上古版本的链接里,我们知道了惊群问题。实际上惊群问题就是内核当时设计的时候没有设计好。每次来一个 tcp 请求的时候会唤醒所有的 accept 调用,当然区别是有的线程拿到了这个 fd 有的没有。但是,无意义的唤醒在系统层面上来说是极其浪费服务器资源的。不过呢,现在我们这个程序完全不需要担心这件事儿,因 linux 早在 2.6 版本就改进过这个事情,每次只会在当前 accept 的调用列表里选一个去唤醒而不是头很硬的去唤醒线程。

accept 与 reuseport
继续上面的话题,我们知道,现在的内核里 accept 惊群其实是挺久远的事情了。但是,不惊群并不代表着 accept 就完美无缺了。现在的互联网服务,经常面临着在短时间内接受了巨量的新连接的情况。不过别担心,当然没有那么多人闲着没事儿用半开链接打你。但是,在巨量的短连接下,只能有一个 accept 响应,accept本身的速度就已经成为了瓶颈。那么怎么办呢?于是我们有了 SO_REUSEPORT 这个选项。

reuseport 是干什么的呢?我们在学习算法的时候经常提到一个概念叫 ”分而治之“, reuseport 和这个概念很像。它在内核里维持了两个巨大的 hash 表。第一层 hash 按照 dport 查表,第二层按照 (sip, sport) 查表。第二层 hash 表,将按照我们 reuseport 的 socket fd 数量分区。落到这区内的 tcp 连接将被单独的进行 accept 。这样一来,一个 accept 变成了 N 个互不干扰的 accept,速度自然提升了 N 倍。但是,这样就可以了么?

我们知道的是,第二层 hash 表的分区将会随着 socket fd 的数量进行变化,这一变化就出问题了。假设我们现在有两个 fd 互相 reuseport,将 (0, 100) 的 hash 区间平均分成了 (0,50) 和 (50, 100),那么现在,如果某个请求 A 被 hash 到了区间 45,半开连接已建立,但是这个时候突然又多了一个 socket fd 进来。内核为了负载均衡,将会改变这个 hash 环,现在为 (0, 33), (33, 66), (66, 100)。

这个时候,原先 45 区间上的链接实际上已经失去了对应的 socket,无法再被正确的路由,因为,server 会发送一个 reset 终止掉这个链接。但是,使用 reuseport 选项本身就是为了处理巨量请求的情况,大量的 connection reset 本身就是不可接受的。因此,我们建议使用 reuseport 的时候,只在服务初始化的时候初始化好固定数量的 fd,运行的时候就不要乱动了,就像我厂的 corvus 一样。

现在嘛,因为 reuseport 的天然分治的特征,就有人动气了心思:起足够数量的 worker 线程,并且和 io 线程放到一起,那这样不就不用写多线程程序了么?

是。但是,这种模式其实并不是很好的,因为这种设计虽然在使用上天然避免了多线程竞争和复杂,但是如果单链接负载过高还是存在着单线程被直接打爆的风险。所以选型的时候,需要结合自己的业务特征来选定。

谈谈 epoll
上面我们说了 accept,其典型的工作模式就是来一个链接分配一个线程 ,然而我们的CPU、内存数量有限,不可能分配宝贵的资源给那么多的线程。而且线程多了其调度也是很大的问题,这就触发了网络编程里经典的 C10K 问题。当然,本文的重点不是来和大家探讨线程调度和上下文切换的开销,有喜欢的可以看一下 shell老师的[博客][1]。

工程师们为了解决 C10K 问题,长足发展了多路复用这门技术。简单来说,就是一个线程上同时监听多个 fd,从最开始的 select 到现在的 epoll,中间又是一个长长的故事,这里不展开讲。我们需要讨论的是,accept 不会有惊群,那么 epoll 会有么?

答案是,现在大部分情况下会。内核在新版本里加入了 EPOLLEXCLUSIVE 的选项(kernel 4.5),但是其实我们线上的服务的大部分内核版本还是很低的,没有这个兼容就。。。

想修复很简单,只需要更新内核(有的服务器可能还需要升级下系统),然后重启下服务器就好了。

(当然了,这想想也是不可能的事儿)

那么如何避免呢?我们翻翻 nginx 的源码,发现为了避免惊群效应, nginx 用了一个锁,每次 epoll_wait 之前,都先进行抢锁,那么这个时候,多个 nginx worker 之间互相等在了 lock 而不是 epoll_wait,这样。实际上,每次就只有一个进程在进行 epoll_wait,实际上也就不存在什么惊群问题了 —— 群都没有惊个啥。

mio
我们现在有了一系列的多路复用手段,但是麻烦事儿又来了:我们手段太多了。尤其是当你写的程序需要跨平台兼容的时候,问题就更大了。linux 上有 epoll,Unix 上有 kqueue, Windows 上有 iocp。各种技术需要一个统一的抽象层,于是 mio 应运而生。

mio 是 Rust 的一个多路复用+非阻塞调用的网络库,用比较小的额外开销统一了全平台上的多路复用库。当然了,它不是第一个做这个的库,很多库都做过这方面的尝试,比如用力过猛的 boost::aio,比如被用在 chrome 上的 libevent。当然了,我估计很多写 C 的人都是用的自己写的兼容代码,比如著名的redis。mio 本身就是一层抽象,抽象的层级比较低,下面是一段典型的 mio 代码(摘自mio自己的doc example):

use mio::*;
use mio::tcp::{TcpListener, TcpStream};

// Setup some tokens to allow us to identify which event is
// for which socket.
const SERVER: Token = Token(0);
const CLIENT: Token = Token(1);

let addr = "127.0.0.1:13265".parse().unwrap();

// Setup the server socket
let server = TcpListener::bind(&addr).unwrap();

// Create a poll instance
let poll = Poll::new().unwrap();

// Start listening for incoming connections
poll.register(&server, SERVER, Ready::readable(),
              PollOpt::edge()).unwrap();

// Setup the client socket
let sock = TcpStream::connect(&addr).unwrap();

// Register the socket
poll.register(&sock, CLIENT, Ready::readable(),
              PollOpt::edge()).unwrap();

// Create storage for events
let mut events = Events::with_capacity(1024);

loop {
    poll.poll(&mut events, None).unwrap();

    for event in events.iter() {
        match event.token() {
            SERVER => {
                // Accept and drop the socket immediately, this will close
                // the socket and notify the client of the EOF.
                let _ = server.accept();
            }
            CLIENT => {
                // The server just shuts down the socket, let's just exit
                // from our event loop.
                return;
            }
            _ => unreachable!(),
        }
    }
}

我们可以看到,在这段代码里,mio 的核心便是 Poll, 而且代码超级像 epoll 的使用方式。我们将 tcp socket fd 注册为 Token(0) 的 fd,然后接受并直接关闭链接。

let _ = server.accept();

Token 即 fd 到 mio 的映射,我们会在mio的高速分配器(slab::Slab) 里存储下这些 fd ,并进行合适的路由。每次触发的一个 event,比如可读可写事件,都会拿到其对应的 token 。

这样,由 fd 到 token 再对应上 event 的关系已经建立了,利用 mio 的 poll (会被转换成对应的 syscall 如 epoll_wait 等),我们上面的暖机程序终于可以正确的用上 多路复用了。高兴不?

你高兴的太早了。mio 有个问题,它太接近底层了, 为了写好一个正确的 io 库,你需要一个写一个复杂并且巨大的状态机来处理 mio 的各种情况,它有着太多的不足。比如缺少 buffer 管理,比如 IO和业务逻辑耦合。稍微一个不注意,mio 教你做人不只是说说而已的。

那么有没有更好写 mio 的方式呢?当然有了,就是 tokio 。tokio 是基于 mio 和 futures 库的更高一层抽象, 事实上,现阶段,整个 mio 库的存在就是完全为 tokio 准备的。简单来说,就是我们在写 mio 的时候需要写一个超大的超复杂的状态机,futures 提供了一种让你简单写状态机的方式,二者结合,生了个娃娃就是 tokio。那么,首先我们从 futures 开始。

futures
很多语言里都内置了(或者利用库)支持了 future 、promise模式,Rust也不例外 —— futures 库就提供了这种支持。

Future 是什么 ? 理论上,我们写的一切程序动作都可以被视为一个 Future ,打开一个连接、从一个 FD 读取一些数据、等待一个超时等等。Future 的定义大概长这样:

pub trait Future {
    type Item;
    type Error;

    fn poll(&mut self) -> Poll<Self::Item, Self::Error>;
}

其中,Poll是一个类型别名:

pub type Poll<T, E> = Result<Async<T>, E>;

表示一个 future 的结果。我们可以预想到,一个 future 的结果大概有三种情况:

  1. 执行失败:则 poll 返回 Result::Error 。
  2. 执行还未完成但是还没失败:则 poll 返回 Result::Ok(Async::NotReady) 。
  3. 执行完成并且返回了数据: 则 poll 返回 Result::Ok(Async::Ready) 。

并且,我们还需要保证的一点是:每次 poll 调用都必须是可重入的,因为你也不知道最后你的 poll 会被调用几次。那么怎么保证可重入呢?常见的办法是状态机。

这像是什么呢,像是你拿着一个记事本,将你的经历都写了下来,每次重入的都翻一下你的小本本,做到哪里了,然后接着上一次去做。当然了,光有着一个一个独立的 Future 是不行的,Future 还需要的是组合。一个经典的 Future 组合是可以写成如下代码(示例代码,不代表真的有这些 API):

let task = TcpResolver::resolve("192.168.123.123:12345")
  .and_then(|tcp_addr| TcpConnection::connect(tcp_addr))
  .and_then(|(conn, _remote_addr)| Io::read_to_string(conn))
  .and_then(|data| serde_json::from_str(&data))
  .and_then(|jobj| check_json(jobj))
  .map_err(|err| log_err(err));

task.wait().unwrap();

这里我们频繁调用 and_then 其实是只表达一个含义:如果调用无错则继续进行。正确与否,当然是靠每个闭包的返回值是 Ok 还是 Err 来判定的了。

那么事情完了么?当然还没完。

我们现在有了一系列的 Future 组合,但是,请看最下面: 我们调用了 task.wait() 。这个函数通过 executor模块 spawn 并且循环等待任务完成(wait_future):

let unpark = Arc::new(ThreadUnpark::new(thread::current()));

loop {
    match self.poll_future_notify(&unpark, 0)? {
        Async::NotReady => unpark.park(),
        Async::Ready(e) => return Ok(e),
    }
}

协程
这里有人会问了, executor 模块是个什么玩意儿?

答:这是一个假装自己是协程的模块。

注意了,敲黑板,这个 executor 本质上就是一系列的状态机组合,并没有开任何的啥程来。不过,这里并不妨碍我们来分析下协程,因为 Rust 已经开始试验并合并进了自己的 coroutine 实现。

协程是什么?协程在概念上可以认为是非抢占式的用户态线程。当然有人要说协程是轻量级的线程我是不认的,因为你照样可以把一个协程写的和一个线程一样甚至更重量级(内存,调度开销)。

不过,rust 即将实现的这个协程和我们现在所熟知的 go 里面的 goroutine 还是有着本质上的不同:因为这个协程本身是 stackless 的。简单来说就是,这个的协程并不会为单独的协程开辟一个单独的栈,当然也就没有栈扩容,缩容,分裂等一系列东西了,性能也会相对的更好一点(代价就是损失了 M:N 运行的可能)。与之相对的,go 的 goroutine 就是 stackfull 的。关于 goroutine 的栈分析可以看我们往期的[文章][2]这里不展开分析。

刚才说了,协程是非抢占式的。为什么要这么设计?

这个我们就不得不需要从协程的前辈——线程说起。

我们知道,系统调度的最小单位是线程。因此,线程无论设计的如何抢占式,也不会因为两个线程抢夺CPU造成其他线程饿死。但是协程不行,协程本身是建立在用户态的,我们没有办法在系统级别强制的让出当前的时间片给其他的协程用。因为一旦你让出了时间片,系统就会自动把你所依附的线程改成非 R 的状态,而让其他没肉吃的线程吃一口。

事情的真相就是:根本抢不到啊!

协程里,没有 kernel 这个大佬镇场子怎么办?我们只能谦让一点,谁在调用的时候发现可能会被卡住,就自动放弃掉当前的CPU好了。 比如,我要进行一个 io 操作,读取一些数据。读到了还则罢了,读不到的时候,我们一般会返回一个 EAGAIN (std::io::Error::WouldBlock) 。告诉你,哎呀没读到,再试一次看看?这个时候就需要我们的程序主动将自己的标记为阻塞状态,并启动用户态调度程序。

注意这里,从整个线程上来看,它没有放弃任何CPU,但是我们执行的代码实际上从一个协程切换到了下一个协程里。那么,如果我们让一个协程里面调用某些 API 的时候(比如在协程里读一个文件),使用阻塞 API ,那么一旦这个文件的没有 ready ,整个线程很可能会被 kernel 强制给调度走。

毕竟吧,你内核大爷还是你大爷。

futures 这个库吧,我承认它已经装的非常像了,但是它缺少了一项至关重要的技能:保存现场。

前面我说了,Future 必须保证是可重入的,因为实际上每次 futures 调用的时候都是陷入调用,无法保存现场,无法保存现场也就导致了无法恢复现场,将保护现场的工作,直接交给了用户。

mio 和 tokio
futures ,单独的看这个库其实是不够完整的。为了要使用这个库,你会发现你最终写出来了一个特大号的调度器来,就为了单纯的决定该唤醒谁,该阻塞谁。为了这个事儿, alex 同学亲自写了一个 CpuPool 的库来实现了一个调度器。但是这个东西并不是我们的重点,我们的重点是另一个调度器:tokio。

上面说,协程需要自己将自己标记成阻塞状态,并且启动用户态调度程序。

事实上,调度程序也是有一定要求的,最简单的就是,将当前需要操作的 fd和需要被调度的协程关联起来。当然,fd 这个概念,在 Rust 的 mio 库里已经被抽象成了 Token。

tokio 通过将 socket fd 取值为 Token(fd2),然后将对应的futures任务队列的 Token 标记为 Token(fd2+1) 的方式,把 Future 和 focket fd 对应起来。当 mio 收集到 epoll_wait 的事件的时候,会自动去寻找其 Token 对应的协程队列,然后去执行。

当其调用陷入阻塞的时候,则会由用户自己主动返回 Async::NotReady,调度器将会自动的将当前 fd 关联的任务的运行状态改掉阻止其再次进入。

这遍是 tokio 的整个运行模式。

不过这里又有一些问题,我们的 socket fd 上发生的事件可能不是不止一个的。那么,如何表示连续发生的事件呢? Future ?显然不行。 于是,我们现在有了一种办法能自由的将多个同类型的 Future 组合,并接受其顺序发生,就组成了一个新的组合类型: Stream

Stream
Stream 是什么?

它表示的是一系列 Future 事件依次发生,构成一个流。我们可以不断的从这个流中取出正确的执行结果,或者暂停这个流,直到终结。

其类型签名大概如下:

pub trait Stream {
    type Item;
    type Error;
    fn poll(&mut self) -> Poll<Option<Self::Item>, Self::Error>;
}

和 Future 类似,只不过将返回值类型改成了 Option ,因为 Stream 比 Future 多了一个状态: 流终止。

和我们标注库里表示 EOF 的办法一样——读0——这里如果返回值是 Async::Ready(None) ,则表示整个流是终止的,后面不会再有值返回了。

那么 Stream 可以来表示什么呢?

一个 socket 上不断来的连接,一个连接上不断传递来的数据,一个定时器不断触发的 tick 等等,只要是连续发生的事件其实都可以表示成 Stream 的。

Stream 和 Framed
我们在写网络程序的时候,不光需要处理读取,还需要正确的处理写入。Futures 为了解决这个事情,专门引入了一个 trait :Sink 。

Stream 可以理解为上游数据来源, Sink 则是下游输出。这样,输入和输出都有了。

怎么组合呢?直接在写么?当然不需要这么麻烦,tokio 为我们提供了两个 Codec trait, tokio_io::Codec表示TCP 和 tokio_core::net::UdpCodec 表示UDP 。

比如我在 statsd-rs 里的实现:

pub struct RecvCodec;

impl UdpCodec for RecvCodec {
    type In = Packet;
    type Out = ();

    fn decode(&mut self, _addr: &SocketAddr, buf: &[u8]) -> io::Result<Self::In> {
        debug!("get a new packet");
        Ok(Packet::from(buf.to_vec()))
    }

    fn encode(&mut self, _out: Self::Out, _into: &mut Vec<u8>) -> SocketAddr {
        unreachable!("never send back !");
    }
}

当然这里只实现了 decode ,因为我这个服务是只接收而不回发的。实现了 UdpCodec ,我们可以简单的通过 tokio 的 framed api 来将一个 TcpStream 的byte流,转换成我们需要的 Packet 流。

pub fn run(ring: HashRing, bufs: Arc<Vec<MergeBuffer>>) {
    let mut core = Core::new().unwrap();
    let handle = core.handle();
    let socket = Self::build_socket(&*CONFIG.bind.clone(), &handle, true);
    info!("worker: bind at {:?}", &CONFIG.bind);
    let service = socket.framed(RecvCodec {}).flatten().for_each(|item| {
        let pos = ring.position(&item.metric);
        Ok(bufs[pos].push(item))
    });
    core.run(service).unwrap();
}

flatten 即我们在函数式语言里常见的 flatMap,这里其实就是将流展开然后一个一个的塞进后端处理队列。

更高层次的组合
tokio_core ,这个库从名字来看就知道了,这是一个非常基础的库。tokio 在这个基础上,对 request/response 一一对应的这种特殊的网络模式提供了更高级的两个抽象库,tokio-proto 和 tokio-service。

tokio-proto 规定了如何解析tcp数据流到基本的数据结构,当然了,用的就是 Codec 这个 trait。并且对 streaming 和 multiple 的请求流做了分解和兼容。 tokio-service 则利用 proto 的请求抽象出了服务接口。

具体的例子可以参考这里: https://tokio.rs/docs/getting-started/simple-server/

不过嘛,口号虽然喊得挺好的,但是实际上 tokio-proto 的局限性太大,灵活度不高,笔者我还是喜欢使用更底层的 tokio_core 库。相对于高度抽象的上层世界来说,我还是喜欢底层的现充生活,程序员的控制欲?:-D。

例子
我这里根据 tokio 写了几个非常简单的协议转换工具,权当练手。

https://github.com/wayslog/lin-rs https://github.com/wayslog/statsd-rs

[1]: http://shell909090.org/blog/archives/2703/ 《上下文切换技术》

[2]: https://zhuanlan.zhihu.com/p/28409657 《聊一聊goroutine stack》

作者简介
赵雪松,2016年5月份加入饿了么。人称wayslog;Python3,双引号,大括号不换行;Rust铁粉,联合著有《RustPrimer》。

编辑于 2017-10-11

转自:Rust 与服务端编程的碎碎念

阅读更多
文章标签: rust
个人分类: Rust
上一篇Rust中使用Protocol Buffers
下一篇epoll笔记
想对作者说点什么? 我来说一句

没有更多推荐了,返回首页

关闭
关闭
关闭