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功能的文章,感受一下两者的相似之处。