Rust笔记 - crate mio

1,385 阅读5分钟

Mio 是一个低层次、高效的I/O库,通过非阻塞API和事件通知方式达到高性能的I/O操作。非阻塞(异步)和事件通知(eventloop)是网络编程很重要的一部分内容。下面来看一下通过mio提供的APIs,如何编写网络程序。下面是一个Echo程序的实例。

// mio = "0.6.0"
extern crate mio;

use std::collections::HashMap;
use std::error::Error;
use std::io::{self, Read, Write};

use mio::net::{TcpListener, TcpStream};
use mio::{Events, Poll, PollOpt, Ready, Token};

第一部分代码是引入外部的crate 和 引入std 库。这里使用了mio 0.6的库。


fn main() -> Result<(), Box<dyn Error>> {
	// 保存token与tcp流的关系,方便在事件循环中找回tcp流
    let mut sockets: HashMap<Token, TcpStream> = HashMap::new();
    
    // 保存token与读取数据的数据,在写事件中发送回客户端
    let mut response: HashMap<Token, String> = HashMap::new();
    
    // 接收缓冲区
    let mut buffer = [0 as u8; 4096];
    
    // 最为Token的值,递增
    let mut counter: usize = 0;

    // 监听地址
    let address = "0.0.0.0:8080";
    let listener = TcpListener::bind(&address.parse().unwrap()).unwrap();
    
    // 创建事件循环
    let poll = Poll::new().unwrap();
    poll.register(&listener, Token(0), Ready::readable(), PollOpt::edge())
        .unwrap();
    
    // 设置返回事件最大数量
    let mut events = Events::with_capacity(1024);
    
    // 进入事件循环,进行读写监听
    loop {
        ....
    }
}

第二部分是一些模板代码,主要是进行一些初始化操作,为进入事件循环做准备。这里值得注意的是采用了边缘触发的方式,相对于水平触发 具有更好的性能。如果对Linux 网络编程熟悉的,一定知道select 采用的是水平触发;epoll同时支持边缘触发水平触发,一般使用epoll 进行网络编程时,优先采用边缘触发。虽然边缘触发模式带来更好的性能,但代码复杂度也会大一点。


...   
loop {
    	// 监听事件
        poll.poll(&mut events, None).unwrap();
        
    	// 处理事件
        for event in &events {
            match event.token() {
                Token(0) => loop {
                   ...
                    
                },
                token => {
                    // 读事件
                    if event.readiness().is_readable() {
                        let mut read_ok = false;
    
                        ...
    
                        // 读到数据,需要激活写事件,及时将数据发送回客户端
                        if read_ok {
                            poll.reregister(
                                sockets.get_mut(&token).unwrap(),
                                token,
                                Ready::readable() | Ready::writable(),
                                PollOpt::edge(),
                            )
                            .unwrap();
                        }
                    }
    
                    // 写事件
                    if event.readiness().is_writable() {
                       ...
                    }
                }
            }
        }
    }
}

第三部分也是模板代码。流程大概是:事件监听->事件处理。这里值得注意的有两点:

  • 写事件的激活方式,相对于读事件会不同。读事件可以一直处于激活状态,但写事件不行。如果写事件一直处于激活状态,每次事件监听都会立刻返回(不考虑套接字内核缓冲区满的情况,导致写事件不被激活),导致cpu使用率会飙升。正确的处理方式是,有数据时才激活写事件;没有数据写入时,关闭写事件。
  • 我个人推荐同一个套接字的读写事件最好放在同一个事件循环中。原因很简单,这样代码会简单很多。在上面的模板代码中,读写事件就是放在同一个事件循环中。写过Linux C 网络方面代码的,总是会遇到EPIPE这么一个系统信号。程序向一个已经关闭的文件描述符(套接字)写入数据就会触发该信号,该信号的默认行为是关闭程序(当然可以通过设置信号处理函数屏蔽该信号)。如果同一个套接字在两个线程分别进行读写,很容易产生EPIPE信号。而且释放与套机字关联的资源也是一个麻烦的事情,读写操作失败时需要清理资源,就会产生烦人的数据竞争,需要引入一些线程同步机制。想想都烦。

下面是监听套接字的模板代码:

...
Token(0) => loop {            
    match listener.accept() {
        Ok((socket, address)) => {
            println!("Got connection from {}", address);
            counter += 1;
            let token = Token(counter);
            poll.register(&socket, token, Ready::readable(), PollOpt::edge())
            .unwrap();
            sockets.insert(token, socket);
        }
        Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => break,
        Err(e) => panic!("Unexpected error: {}", e),
   }
},
 ...

上面的代码通过一个loop循环将接收队里中的客户端套接字注册到事件循环中。值得注意的是:

  • 客户端仅需要设置读事件
  • 监听套接字要一直获取客户端连接直到返回WouldBlock错误,才不会遗漏客户端连接。

下面是读事件模板代码:

....
if event.readiness().is_readable() {
    ...
    
    loop {
        let read = sockets.get_mut(&token).unwrap().read(&mut buffer);
        match read {
            Ok(0) => {
                sockets.remove(&token);
                break;
            }
            Ok(len) => {
                println!("Read len: {} for token {}", len, token.0);
                let mut res_str = String::from_utf8(buffer[..len].to_vec())
                .unwrap_or("Err: msg isn't valid utf8".to_string());

                if let Some(mut s) = response.remove(&token) {
                    s.push_str(&res_str);
                    res_str = s;
                }
                response.insert(token, res_str);
                read_ok = true;
                // std::thread::sleep_ms(1000* 3);
            }
            Err(ref e) if e.kind() == io::ErrorKind::WouldBlock => break,
            Err(e) => panic!("Unexpected error: {}", e),
        }
    }
    ...
}

值得注意的是:

  • 采用循环方式将读缓存区的数据全部读出,避免数据遗留在读缓冲区中。
  • 设置read_ok = true 激活写事件

下面是写事件的模板代码:

if event.readiness().is_writable() { 
    if let Some(res_str) = response.remove(&token) {
        sockets
        .get_mut(&token)
        .unwrap()
        .write_all(res_str.as_bytes())
        .unwrap();

        poll.reregister(
            sockets.get_mut(&token).unwrap(),
            token,
            Ready::readable(),
            PollOpt::edge(),
        )
        .unwrap();
    }
}

这里很重要的是,写完数据后需要取消套接字关联写事件。要不然会导致cpu占用率飙升。

上面是mio demo代码(实现Echo功能)的全部片段,程序的错误处理采用简单粗暴的unwrap方式。在正式开发的时候,需要有更加完善的错误处理机制。

题外话

在看mio API文档的时候,发现mio 的API和Linux epoll的API非常相似,后续写一篇epoll实现Echo功能的文章,感受一下两者的相似之处。