TCP
主线位置:两条主线的起点和终点。注册主线从一个已经是非阻塞的 fd 开始,这个 fd 由本页的
bind、accept、connect造出来;事件主线的最后一步「读写到WouldBlock」由本页的Read/Write实现完成。非阻塞connect是两条线交叉的地方:先注册WRITABLE,等一个可写事件,再用take_error和peer_addr问内核连接的结果。
sequenceDiagram
participant U as 用户代码
participant M as mio
participant K as 内核
U->>M: TcpStream::connect
M->>K: socket 加 NONBLOCK
M->>K: connect
K-->>M: EINPROGRESS
M-->>U: Ok 未连上的流
U->>K: register WRITABLE
U->>K: poll
K-->>U: 可写事件
U->>K: take_error 即 SO_ERROR
alt 连上
K-->>U: None,peer_addr 有地址
else 被拒
K-->>U: ConnectionRefused
end
代码在 net/tcp/listener.rs(310 行)、net/tcp/stream.rs(481 行),以及 Unix 上的 sys/unix/tcp.rs(139 行)和 sys/unix/net.rs(221 行)。两个公开文件里文档占了一大半。
结构:一个 IoSource 字段
TcpListener 和 TcpStream 都只有一个字段:
pub struct TcpStream {
inner: IoSource<net::TcpStream>,
}
IoSource<T> 怎么实现 event::Source、debug 下怎么查 SelectorId,在 Source 与 IoSource。这一页只关心另一面:IoSource<T> 实现了 Deref<Target = T>,所以 mio 的方法大多是一行转发到 std:
| mio 方法 | 实现 | 落到哪里 |
|---|---|---|
peer_addr、local_addr、shutdown | self.inner.xxx() | std,getpeername / getsockname / shutdown |
set_nodelay、nodelay、set_ttl、ttl | self.inner.xxx() | std,setsockopt / getsockopt |
take_error | self.inner.take_error() | std,getsockopt(SO_ERROR) |
read、write、peek、try_io | self.inner.do_io(...) | std 的读写,外面包一层 do_io |
TcpStream::connect、TcpListener::bind、accept | mio 自己写 | sys::tcp 里的 libc 调用 |
mio 自己写的只有最后一行:std 的 bind、connect、accept 会建出阻塞的 socket,或者干脆在 connect 里阻塞等待,这三处 mio 必须自己调 libc。选项类的方法全部借 std,Cargo.toml 的 [dependencies] 里只有 log、libc(Unix)、windows-sys(Windows)和 wasi,没有 socket2。std 没提供的选项(SO_REUSEPORT、keepalive 参数、收发缓冲区大小)mio 也不提供:0.8.0 删掉了可以设这些选项的 TcpSocket 类型(#1514),PR 的说明是「The socket2 crate provide all the functionality and more. Furthermore supporting all socket options is beyond the scope of Mio.」需要这些选项时,用 socket2 建好 socket,再用 from_std、from_raw_fd 或 From<OwnedFd> 交给 mio。tokio 的 TcpSocket 就是这样:内部是一个 socket2::Socket,连上或监听之后用 mio::net::TcpStream::from_raw_fd / TcpListener::from_raw_fd 转过去。
bind:四个系统调用
TcpListener::bind 的文档列了四步,代码一一对应:
pub fn bind(addr: SocketAddr) -> io::Result<TcpListener> {
let socket = new_for_addr(addr)?;
#[cfg(any(unix, target_os = "hermit", target_os = "wasi"))]
let listener = unsafe { TcpListener::from_raw_fd(socket) };
// ...
// On platforms with Berkeley-derived sockets, this allows to quickly
// rebind a socket, without needing to wait for the OS to clean up the
// previous one.
//
// On Windows, this allows rebinding sockets which are actively in use,
// which allows “socket hijacking”, so we explicitly don't set it here.
// https://docs.microsoft.com/en-us/windows/win32/winsock/using-so-reuseaddr-and-so-exclusiveaddruse
#[cfg(not(windows))]
set_reuseaddr(&listener.inner, true)?;
bind(&listener.inner, addr)?;
// Use the same backlog value as the standard library.
// <https://github.com/rust-lang/rust/blob/0028f344ce9f64766259577c998a1959ca1f6a0b/library/std/src/sys/net/connection/socket/mod.rs#L559-L571>
let backlog = if cfg!(target_os = "horizon") {
20
} else if cfg!(target_os = "haiku") {
32
} else {
128
};
listen(&listener.inner, backlog)?;
Ok(listener)
}
几处安排:
- fd 先交给
TcpListener,再设选项。from_raw_fd之后 fd 归TcpListener所有,后面任何一步用?返回错误时,listener被 drop,fd 随之关闭,不会泄漏。 SO_REUSEADDR只在非 Windows 上设。 Unix 上它让监听端口在上一个进程退出后马上能重新bind,不用等TIME_WAIT过去;Windows 上同名选项的语义是「允许抢占正在使用的端口」,所以不设。- backlog 和 std 一致,大多数平台是 128,下面的冷知识讲它改过两次。
第一步 new_for_addr 按地址族选 AF_INET / AF_INET6,调 new_socket。这个函数是 mio 所有 Unix socket(TCP、UDP、Unix 域)共用的出生地,按平台分三段:
pub(crate) fn new_socket(domain: libc::c_int, socket_type: libc::c_int) -> io::Result<libc::c_int> {
#[cfg(any(
target_os = "android",
// ...
target_os = "linux",
// ...
))]
let socket_type = socket_type | libc::SOCK_NONBLOCK | libc::SOCK_CLOEXEC;
// ...
let socket = syscall!(socket(domain, socket_type, 0))?;
// Mimic `libstd` and set `SO_NOSIGPIPE` on apple systems.
#[cfg(any(
target_os = "ios",
target_os = "macos",
// ...
))]
if let Err(err) = syscall!(setsockopt(
socket,
libc::SOL_SOCKET,
libc::SO_NOSIGPIPE,
&1 as *const libc::c_int as *const libc::c_void,
size_of::<libc::c_int>() as libc::socklen_t
)) {
let _ = syscall!(close(socket));
return Err(err);
}
// Darwin (and others) doesn't have SOCK_NONBLOCK or SOCK_CLOEXEC.
#[cfg(any(
target_os = "aix",
target_os = "ios",
target_os = "macos",
// ...
))]
{
if let Err(err) = syscall!(fcntl(socket, libc::F_SETFL, libc::O_NONBLOCK)) {
let _ = syscall!(close(socket));
return Err(err);
}
#[cfg(not(any(target_os = "espidf", target_os = "vita", target_os = "nto")))]
if let Err(err) = syscall!(fcntl(socket, libc::F_SETFD, libc::FD_CLOEXEC)) {
let _ = syscall!(close(socket));
return Err(err);
}
}
Ok(socket)
}
Linux 上非阻塞和 CLOEXEC 在 socket(2) 这一次调用里就设好了;macOS 没有这两个类型标志,要多三次调用:SO_NOSIGPIPE(对端关闭后写入时返回 EPIPE 而不是发 SIGPIPE 信号,和 std 一样)、F_SETFL O_NONBLOCK、F_SETFD FD_CLOEXEC。这里 fd 还是裸的 c_int,所以每一步失败都要手动 close。
| 步骤 | Linux | macOS(按源码) |
|---|---|---|
| 建 socket | socket(AF_INET, SOCK_STREAM|SOCK_NONBLOCK|SOCK_CLOEXEC, 0) | socket(AF_INET, SOCK_STREAM, 0) |
| 补标志 | — | setsockopt(SO_NOSIGPIPE)、fcntl(F_SETFL, O_NONBLOCK)、fcntl(F_SETFD, FD_CLOEXEC) |
| 复用地址 | setsockopt(SOL_SOCKET, SO_REUSEADDR, 1) | 同左 |
| 绑定、监听 | bind、listen(fd, 128) | 同左 |
实验:bind、accept、set_nodelay
src/bin/tcp_bind.rs 建一个监听 socket,用 fcntl 和 getsockopt 读回它的标志;一个阻塞的 std 客户端连上来,poll 之后 accept 到 WouldBlock,读回新连接的标志,再 set_nodelay(true)。fd_flags 和 sockopt 是本实验 crate 里的两个小函数,分别用 F_GETFL / F_GETFD 和 getsockopt 读值:
mio = { version = "=1.2.4", features = ["os-poll", "net", "os-ext"] }
libc = "=0.2.191"
use std::io::ErrorKind;
use std::os::fd::AsRawFd;
use exp_mio_net::{fd_flags, sockopt};
use mio::net::TcpListener;
use mio::{Events, Interest, Poll, Token};
fn main() -> std::io::Result<()> {
println!("os={}", std::env::consts::OS);
let mut poll = Poll::new()?;
let mut events = Events::with_capacity(8);
let mut listener = TcpListener::bind("127.0.0.1:0".parse().unwrap())?;
let lfd = listener.as_raw_fd();
println!("listener: {} reuseaddr={}", fd_flags(lfd), sockopt(lfd, libc::SOL_SOCKET, libc::SO_REUSEADDR) != 0);
#[cfg(target_os = "macos")]
println!("listener: nosigpipe={}", sockopt(lfd, libc::SOL_SOCKET, libc::SO_NOSIGPIPE) != 0);
poll.registry().register(&mut listener, Token(0), Interest::READABLE)?;
// a blocking std client; connect completes in the kernel backlog
let _client = std::net::TcpStream::connect(listener.local_addr()?)?;
poll.poll(&mut events, None)?;
println!("poll: {} event(s)", events.iter().count());
loop {
match listener.accept() {
Ok((stream, peer)) => {
let fd = stream.as_raw_fd();
println!("accepted {peer}: {}", fd_flags(fd));
#[cfg(target_os = "macos")]
println!("accepted: nosigpipe={}", sockopt(fd, libc::SOL_SOCKET, libc::SO_NOSIGPIPE) != 0);
println!("nodelay before={}", stream.nodelay()?);
stream.set_nodelay(true)?;
println!("nodelay after={}", stream.nodelay()?);
}
Err(e) if e.kind() == ErrorKind::WouldBlock => {
println!("accept: {e}");
break;
}
Err(e) => return Err(e),
}
}
Ok(())
}
M2 Max、macOS、rustc 1.99.0:
os=macos
listener: nonblock=true cloexec=true reuseaddr=true
listener: nosigpipe=true
poll: 1 event(s)
accepted 127.0.0.1:61825: nonblock=true cloexec=true
accepted: nosigpipe=true
nodelay before=false
nodelay after=true
accept: Resource temporarily unavailable (os error 35)
Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0,用 strace -f -o ... -e trace=socket,setsockopt,bind,listen,epoll_ctl,epoll_pwait,connect,accept4,write 跑,程序自己的标准输出丢掉,只看 strace 文件(write(1, ...) 就是程序的 println!,当路标用;行首的 4 是线程号):
4 write(1, "os=linux\n", 9) = 9
4 socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 4
4 setsockopt(4, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
4 bind(4, {sa_family=AF_INET, sin_port=htons(0), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
4 listen(4, 128) = 0
4 write(1, "listener: nonblock=true cloexec="..., 52) = 52
4 epoll_ctl(3, EPOLL_CTL_ADD, 4, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0}) = 0
4 socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 5
4 connect(5, {sa_family=AF_INET, sin_port=htons(41159), sin_addr=inet_addr("127.0.0.1")}, 16) = 0
4 epoll_pwait(3, [{events=EPOLLIN, data=0}], 8, -1, NULL, 8) = 1
4 write(1, "poll: 1 event(s)\n", 17) = 17
4 accept4(4, {sa_family=AF_INET, sin_port=htons(33720), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC|SOCK_NONBLOCK) = 6
4 write(1, "accepted 127.0.0.1:33720: nonblo"..., 53) = 53
4 write(1, "nodelay before=false\n", 21) = 21
4 setsockopt(6, SOL_TCP, TCP_NODELAY, [1], 4) = 0
4 write(1, "nodelay after=true\n", 19) = 19
4 accept4(4, 0xffffdcd68bc8, [128], SOCK_CLOEXEC|SOCK_NONBLOCK) = -1 EAGAIN (Resource temporarily unavailable)
4 write(1, "accept: Resource temporarily una"..., 55) = 55
4 +++ exited with 0 +++
Linux 上不加 strace 的输出和 macOS 相比,少了两行 nosigpipe,最后一行的错误码是 11(EAGAIN)而不是 35,其余各行(listener: nonblock=true cloexec=true reuseaddr=true、accepted ...: nonblock=true cloexec=true、两行 nodelay)相同。两个平台各跑 3 次,除端口外输出相同;strace 也跑了 3 次,除端口和地址外相同。
- 第 2 到 5 行就是
bind的四步。socket的类型里直接带SOCK_CLOEXEC|SOCK_NONBLOCK,之后没有fcntl。 - 第 8、9 行是 std 客户端:
socket只带SOCK_CLOEXEC,阻塞的connect返回 0。 - 第 15 行:
set_nodelay(true)是一次setsockopt(SOL_TCP, TCP_NODELAY),由 std 发出。nodelay()用的getsockopt没在跟踪列表里。
1.0.x 及以前,bind 里写的是 listen(&listener.inner, 1024)。1.1.0 的 #1896(PR)把它换成按平台取值的常量 LISTEN_BACKLOG_SIZE,想和 std 保持一致;PR 说明里提到「It is sometimes provided as a "magic" value, for example, -1」,于是 Linux、FreeBSD、OpenBSD 和 Apple 平台上这个常量是 -1,源码注释写着 Linux 上会被「Silently capped to /proc/sys/net/core/somaxconn」。可是这组值抄的是 std 给 UnixListener 用的。1.2.0 的 #1934(PR)改了回来:「For TCP we were using the values std lib used for UnixListener, now we're matching.」TCP 从此用 std TcpListener 的值,大多数平台 128,就是上面 bind 里那段 if cfg! 和 strace 第 5 行的 listen(4, 128)。
所以同一个 TcpListener::bind,在 Linux 上 mio 1.0 是 1024,1.1 是 -1(实验用的容器里 somaxconn 是 4096),1.2 是 128。tokio 的 TcpListener::bind 直接调 mio 的 bind(bind_addr),本站锁的 mio 1.2.1 也就是 128。Unix 域套接字仍然用 -1,见 UDP 与 Unix 域套接字。
accept:新连接一出生就是非阻塞的
TcpListener::accept 经过 do_io,里面调 sys::tcp::accept,再把 std 的流包成 TcpStream::from_std:
pub fn accept(&self) -> io::Result<(TcpStream, SocketAddr)> {
self.inner.do_io(|inner| {
sys::tcp::accept(inner).map(|(stream, addr)| (TcpStream::from_std(stream), addr))
})
}
sys::tcp::accept 分两条路,Linux 用 accept4(..., SOCK_CLOEXEC | SOCK_NONBLOCK),macOS 用普通的 accept 加一次 fcntl(F_SETFD, FD_CLOEXEC),非阻塞靠新 fd 从监听 socket 继承。两条路的选择、x86 Android 为什么是例外(#1445、#1450),见 一个事件循环 里的冷知识。上面的实验把两条路都验证了:Linux 第 12 行的 accept4 带着 SOCK_NONBLOCK;macOS 上新连接 nonblock=true,而 macOS 这条路上 mio 只设了 FD_CLOEXEC,非阻塞确实是继承来的。macOS 的输出还多一行 accepted: nosigpipe=true:accept 路径上没有任何 SO_NOSIGPIPE 的代码,这个选项也是从监听 socket 继承的。
accept 返回的地址由 to_socket_addr 从 sockaddr_storage 转成 std::net::SocketAddr,ss_family 既不是 AF_INET 也不是 AF_INET6 时返回 InvalidInput。
connect:立刻返回的 connect
std 的 TcpStream::connect 会阻塞到三次握手结束。mio 的 TcpStream::connect 先建一个非阻塞 socket,再调 sys::tcp::connect:
pub(crate) fn connect(socket: &net::TcpStream, addr: SocketAddr) -> io::Result<()> {
let (raw_addr, raw_addr_length) = socket_addr(&addr);
match syscall!(connect(
socket.as_raw_fd(),
raw_addr.as_ptr(),
raw_addr_length
)) {
Err(err) if err.raw_os_error() != Some(libc::EINPROGRESS) => Err(err),
_ => Ok(()),
}
}
非阻塞 socket 上的 connect 发出 SYN 就返回,几乎总是 EINPROGRESS。mio 把 EINPROGRESS 和成功一样当成 Ok(()),其他错误原样返回。所以 TcpStream::connect 返回 Ok 只说明「连接已经开始」,不说明连上了。文档里写了判断连接结果的六步:
/// 1. Call `TcpStream::connect`
/// 2. Register the returned stream with at least [write interest].
/// 3. Wait for a (writable) event.
/// 4. Check `TcpStream::take_error`. If it returns an error, then
/// something went wrong. If it returns `Ok(None)`, then proceed to
/// step 5.
/// 5. Check `TcpStream::peer_addr`. If it returns `libc::EINPROGRESS` or
/// `ErrorKind::NotConnected` it means the stream is not yet connected,
/// go back to step 3. If it returns an address it means the stream is
/// connected, go to step 6. If another error is returned something
/// went wrong.
/// 6. Now the stream can be used.
take_error 读的是 SO_ERROR:内核把异步 connect 的失败原因存在这里,读一次就清零。peer_addr 是 getpeername,只有连上的 socket 才有对端地址。文档还提到 connect 本身也可能返回 WouldBlock,「it usually means there are insufficient entries in the routing cache」。
实验:连上一次,被拒一次
src/bin/tcp_connect.rs 对两个地址各走一遍六步:一个是正在监听的 std listener;另一个是先 bind 一个 listener 拿到端口、再立刻 drop 掉,这个端口上没人监听。raw 和 bools 与 Token、Interest 与 Event 的实验里相同,分别打印平台原始标志位和为真的 is_* 方法:
use std::net::SocketAddr;
use std::time::Duration;
use exp_mio_net::{bools, raw};
use mio::net::TcpStream;
use mio::{Events, Interest, Poll, Token};
fn try_connect(title: &str, addr: SocketAddr) -> std::io::Result<()> {
println!("== {title}");
let mut poll = Poll::new()?;
let mut events = Events::with_capacity(8);
let mut stream = TcpStream::connect(addr)?;
println!("connect returned Ok");
println!("before event: peer_addr={:?}", stream.peer_addr().map_err(|e| e.kind()));
poll.registry().register(&mut stream, Token(0), Interest::WRITABLE)?;
poll.poll(&mut events, Some(Duration::from_secs(1)))?;
for e in &events {
println!("event: {} => {}", raw(e), bools(e));
}
println!("take_error #1 = {:?}", stream.take_error()?);
println!("take_error #2 = {:?}", stream.take_error()?);
println!("peer_addr = {:?}", stream.peer_addr());
Ok(())
}
fn main() -> std::io::Result<()> {
println!("os={}", std::env::consts::OS);
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
try_connect("listening port", listener.local_addr()?)?;
// bind then drop: the port is (almost certainly) closed now
let closed = std::net::TcpListener::bind("127.0.0.1:0")?.local_addr()?;
try_connect("closed port", closed)?;
Ok(())
}
M2 Max、macOS、rustc 1.99.0:
os=macos
== listening port
connect returned Ok
before event: peer_addr=Err(NotConnected)
event: filter=WRITE flags=CLEAR fflags=0 data=146988 udata=0x0 => writable
take_error #1 = None
take_error #2 = None
peer_addr = Ok(127.0.0.1:61925)
== closed port
connect returned Ok
before event: peer_addr=Err(NotConnected)
event: filter=WRITE flags=EOF|CLEAR fflags=61 data=2048 udata=0x0 => writable error write_closed
take_error #1 = Some(Os { code: 61, kind: ConnectionRefused, message: "Connection refused" })
take_error #2 = None
peer_addr = Err(Os { code: 22, kind: InvalidInput, message: "Invalid argument" })
Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0:
os=linux
== listening port
connect returned Ok
before event: peer_addr=Ok(127.0.0.1:35333)
event: events=OUT data=0x0 => writable
take_error #1 = None
take_error #2 = None
peer_addr = Ok(127.0.0.1:35333)
== closed port
connect returned Ok
before event: peer_addr=Err(NotConnected)
event: events=OUT|ERR|HUP data=0x0 => writable error read_closed write_closed
take_error #1 = Some(Os { code: 111, kind: ConnectionRefused, message: "Connection refused" })
take_error #2 = None
peer_addr = Err(Os { code: 107, kind: NotConnected, message: "Socket not connected" })
macOS 跑了 8 次、Linux 跑了 3 次,除端口外输出逐行相同。Linux 上的系统调用(strace -e trace=socket,connect,epoll_ctl,epoll_pwait,getsockopt,getpeername,write,3 次除端口和地址外相同):
4 write(1, "os=linux\n", 9) = 9
4 socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 3
4 write(1, "== listening port\n", 18) = 18
4 socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 5
4 connect(5, {sa_family=AF_INET, sin_port=htons(43595), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 EINPROGRESS (Operation in progress)
4 write(1, "connect returned Ok\n", 20) = 20
4 getpeername(5, {sa_family=AF_INET, sin_port=htons(43595), sin_addr=inet_addr("127.0.0.1")}, [128 => 16]) = 0
4 write(1, "before event: peer_addr=Ok(127.0"..., 44) = 44
4 epoll_ctl(4, EPOLL_CTL_ADD, 5, {events=EPOLLOUT|EPOLLET, data=0}) = 0
4 epoll_pwait(4, [{events=EPOLLOUT, data=0}], 8, 1000, NULL, 8) = 1
4 write(1, "event: events=OUT data=0x0 => w"..., 40) = 40
4 getsockopt(5, SOL_SOCKET, SO_ERROR, [0], [4]) = 0
4 write(1, "take_error #1 = None\n", 21) = 21
4 getsockopt(5, SOL_SOCKET, SO_ERROR, [0], [4]) = 0
4 write(1, "take_error #2 = None\n", 21) = 21
4 getpeername(5, {sa_family=AF_INET, sin_port=htons(43595), sin_addr=inet_addr("127.0.0.1")}, [128 => 16]) = 0
4 write(1, "peer_addr = Ok(127.0.0.1:43595)\n", 32) = 32
4 socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 4
4 write(1, "== closed port\n", 15) = 15
4 socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK, IPPROTO_IP) = 5
4 connect(5, {sa_family=AF_INET, sin_port=htons(36965), sin_addr=inet_addr("127.0.0.1")}, 16) = -1 EINPROGRESS (Operation in progress)
4 write(1, "connect returned Ok\n", 20) = 20
4 getpeername(5, 0xfffffa95eb40, [128]) = -1 ENOTCONN (Socket not connected)
4 write(1, "before event: peer_addr=Err(NotC"..., 42) = 42
4 epoll_ctl(4, EPOLL_CTL_ADD, 5, {events=EPOLLOUT|EPOLLET, data=0}) = 0
4 epoll_pwait(4, [{events=EPOLLOUT|EPOLLERR|EPOLLHUP, data=0}], 8, 1000, NULL, 8) = 1
4 write(1, "event: events=OUT|ERR|HUP data=0"..., 79) = 79
4 getsockopt(5, SOL_SOCKET, SO_ERROR, [ECONNREFUSED], [4]) = 0
4 write(1, "take_error #1 = Some(Os { code: "..., 95) = 95
4 getsockopt(5, SOL_SOCKET, SO_ERROR, [0], [4]) = 0
4 write(1, "take_error #2 = None\n", 21) = 21
4 getpeername(5, 0xfffffa95eb40, [128]) = -1 ENOTCONN (Socket not connected)
4 write(1, "peer_addr = Err(Os { code: 107, "..., 87) = 87
4 +++ exited with 0 +++
对照六步读:
connect两次都是EINPROGRESS(strace 第 5、21 行),mio 都返回Ok。连得上和连不上,在这一步看不出区别。- 注册
WRITABLE时没有EPOLLRDHUP(第 9、25 行):EPOLLRDHUP只跟着READABLE走,见 epoll。 - 连上时,事件只有一个可写位:Linux
EPOLLOUT,macOS 一个EVFILT_WRITE。take_error是None,peer_addr给出地址,到第 6 步。 - 被拒时,可写事件照样会来,但带着错误位:Linux 是
EPOLLOUT|EPOLLERR|EPOLLHUP,macOS 是带EV_EOF、fflags=61的EVFILT_WRITE。is_writable()两边都为真,所以只看is_writable会以为连上了;take_error第一次返回ConnectionRefused(Linux 111、macOS 61,strace 第 28 行能看到内核交出的ECONNREFUSED),第二次就是None(第 30 行,SO_ERROR读一次就清零)。各个is_*位的含义见 Token、Interest 与 Event。 - 被拒之后的
peer_addr,两个平台的错误不同:Linux 仍是NotConnected,macOS 是InvalidInput(EINVAL)。 - 事件来之前的
peer_addr:连本机正在监听的端口时,Linux 在connect返回后立刻getpeername已经成功了(第 7 行),macOS 是NotConnected。
最后一条说明:在环回地址上,Linux 的握手在第一次 getpeername 之前就已经完成了,虽然 connect 返回的是 EINPROGRESS。这是时序上的巧合,不是保证;六步流程不依赖它,照样等可写事件。
问: 第 5 步说 peer_addr 返回 NotConnected 表示「还没连上,回到第 3 步」。可上面 Linux 上被拒的连接,peer_addr 也是 NotConnected。按第 5 步,程序不就会永远等下去吗?
答: 所以第 4 步必须在第 5 步之前。被拒的连接,SO_ERROR 里存着 ECONNREFUSED,第 4 步的 take_error 先把它取出来,流程在那里就以错误结束,到不了第 5 步。顺序反过来,或者第 4 步的结果被丢掉(比如先调了一次 take_error 但没看返回值),SO_ERROR 已经清零,这时 Linux 上只剩一个和「正在连」一模一样的 NotConnected。macOS 上这时是 EINVAL,按第 5 步属于「another error」,倒是能当成失败。tokio 的 connect_mio 只做到第 4 步:等到可写,take_error() 有错误就返回,否则直接当作连上。
只注册 READABLE 又会怎样?下面这个问题的答案在页末。
把上面的程序改成 src/bin/connect_readable.rs:TcpStream::connect 之后只注册 Interest::READABLE,poll 一次,超时 1 秒,打印事件个数。对端连上之后不发任何数据。在 Linux 和 macOS 上,「listening port」和「closed port」两种情况各收到几个事件?
from_std:非阻塞由调用方负责
TcpStream::from_std 和 TcpListener::from_std 只是把 std 类型放进 IoSource:
pub fn from_std(stream: net::TcpStream) -> TcpStream {
TcpStream {
inner: IoSource::new(stream),
}
}
文档说得很清楚:「The conversion assumes nothing about the underlying stream; it is left up to the user to set it in non-blocking mode.」mio 不检查、也不替你设。from_raw_fd、From<OwnedFd> 也是转到 from_std,同样不设。mio 内部的 accept 和 bind 也调 from_std,但它们交进去的 fd 已经是非阻塞的。
不设会怎样:注册、poll 一切正常,事件也会来;但事件主线最后一步「读到 WouldBlock」永远等不到,读干净之后的那次 read 在内核里阻塞,整个事件循环跟着停住。
实验:设与不设
src/bin/from_std.rs 用 std 的 listener accept 出一个阻塞的流,第一次先 set_nonblocking(true) 再 from_std,第二次直接 from_std。对端写 5 字节,poll 之后照边沿触发的规矩读到 WouldBlock。第二次永远读不到 WouldBlock,所以另起一个线程,1 秒后打印一行并退出进程:
use std::io::{ErrorKind, Read, Write};
use std::thread;
use std::time::Duration;
use mio::net::TcpStream;
use mio::{Events, Interest, Poll, Token};
fn run(set_nonblocking: bool) -> std::io::Result<()> {
println!("== set_nonblocking({set_nonblocking})");
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
let mut client = std::net::TcpStream::connect(listener.local_addr()?)?;
let (std_stream, _) = listener.accept()?;
if set_nonblocking {
std_stream.set_nonblocking(true)?;
}
let mut stream = TcpStream::from_std(std_stream);
let mut poll = Poll::new()?;
let mut events = Events::with_capacity(8);
poll.registry().register(&mut stream, Token(0), Interest::READABLE)?;
client.write_all(b"hello")?;
poll.poll(&mut events, None)?;
println!("poll: {} event(s)", events.iter().count());
let mut buf = [0u8; 64];
loop {
match stream.read(&mut buf) {
Ok(n) => println!("read {n} byte(s)"),
Err(e) if e.kind() == ErrorKind::WouldBlock => {
println!("read: WouldBlock, back to poll");
return Ok(());
}
Err(e) => return Err(e),
}
}
}
fn main() -> std::io::Result<()> {
run(true)?;
// guard: the blocking variant never returns on its own
thread::spawn(|| {
thread::sleep(Duration::from_secs(1));
println!("guard: read still blocked after 1s, exiting");
std::process::exit(0);
});
run(false)?;
println!("unreachable");
Ok(())
}
两个平台输出相同(各跑 3 次,逐字节相同):
== set_nonblocking(true)
poll: 1 event(s)
read 5 byte(s)
read: WouldBlock, back to poll
== set_nonblocking(false)
poll: 1 event(s)
read 5 byte(s)
guard: read still blocked after 1s, exiting
Linux 上的系统调用(strace -f -o ... -e trace=socket,accept4,ioctl,epoll_ctl,epoll_pwait,recvfrom,write,exit_group;行首是线程号,4 是主线程,5 是看门狗线程):
4 write(1, "== set_nonblocking(true)\n", 25) = 25
4 socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 3
4 socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 4
4 accept4(3, {sa_family=AF_INET, sin_port=htons(53384), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC) = 5
4 ioctl(5, FIONBIO, [1]) = 0
4 epoll_ctl(6, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0}) = 0
4 epoll_pwait(6, [{events=EPOLLIN, data=0}], 8, -1, NULL, 8) = 1
4 write(1, "poll: 1 event(s)\n", 17) = 17
4 recvfrom(5, "hello", 64, 0, NULL, NULL) = 5
4 write(1, "read 5 byte(s)\n", 15) = 15
4 recvfrom(5, 0xffffc96fe260, 64, 0, NULL, NULL) = -1 EAGAIN (Resource temporarily unavailable)
4 write(1, "read: WouldBlock, back to poll\n", 31) = 31
4 write(1, "== set_nonblocking(false)\n", 26) = 26
4 socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 3
4 socket(AF_INET, SOCK_STREAM|SOCK_CLOEXEC, IPPROTO_IP) = 4
4 accept4(3, {sa_family=AF_INET, sin_port=htons(56984), sin_addr=inet_addr("127.0.0.1")}, [128 => 16], SOCK_CLOEXEC) = 5
4 epoll_ctl(6, EPOLL_CTL_ADD, 5, {events=EPOLLIN|EPOLLRDHUP|EPOLLET, data=0}) = 0
4 epoll_pwait(6, [{events=EPOLLIN, data=0}], 8, -1, NULL, 8) = 1
4 write(1, "poll: 1 event(s)\n", 17) = 17
4 recvfrom(5, "hello", 64, 0, NULL, NULL) = 5
4 write(1, "read 5 byte(s)\n", 15) = 15
4 recvfrom(5 <unfinished ...>
5 write(1, "guard: read still blocked after "..., 44) = 44
5 exit_group(0) = ?
4 <... recvfrom resumed> <unfinished ...>) = ?
5 +++ exited with 0 +++
4 +++ exited with 0 +++
- std 的
accept在 Linux 上也是accept4,但只带SOCK_CLOEXEC(第 4、16 行),新连接是阻塞的。和上一个实验里 mio 自己的accept4(..., SOCK_CLOEXEC|SOCK_NONBLOCK)对比,差的就是这一个标志。 - std 的
set_nonblocking(true)在 Linux 上是ioctl(FIONBIO)(第 5 行)。设过之后,第二次recvfrom返回EAGAIN(第 11 行),循环回到poll。 - 没设的那次,注册和事件和前面一模一样(第 17、18 行),区别只在第二次
recvfrom:它没有返回,直到看门狗线程exit_group把进程结束(第 22 到 25 行)。事件循环里任何别的 socket 在这期间都得不到处理。
poll 拿到事件、第一次读成功,这两步都看不出 fd 是阻塞的,问题只在「读干净之后再读一次」时出现。这个坑在常见坑一章里还会单独出现。
读写:经过 do_io 转给 std
Read 和 Write 的每个方法都是一行:
impl Read for TcpStream {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.inner.do_io(|mut inner| inner.read(buf))
}
fn read_vectored(&mut self, bufs: &mut [IoSliceMut<'_>]) -> io::Result<usize> {
self.inner.do_io(|mut inner| inner.read_vectored(bufs))
}
}
&TcpStream 也实现了 Read 和 Write(闭包里的 mut inner 是 &net::TcpStream,std 给 &TcpStream 实现了读写),所以一个共享引用就能同时读写。Linux 上 std 的读写落到 recvfrom / sendto(sendto 带 MSG_NOSIGNAL),见 一个事件循环 的 strace。
do_io 在 epoll 和 kqueue 上什么也不加,在 poll(2) 后备和 event ports 上负责 WouldBlock 之后重新武装,见 Source 与 IoSource。这就是 IoSource 的 Deref 上那句警告的由来:「Be careful when using this method. All I/O operations that may block must go through the [do_io] method.」Deref 让 self.inner.read(...) 也能编译,在 epoll 和 kqueue 上也能正常工作,只有在需要重新武装的平台上才会丢事件。想对 fd 做 mio 没包装的 I/O(比如 libc::recvmsg),要放进 try_io 的闭包里,它就是 self.inner.do_io(|_| f())。
peek 曾经绕过了 do_io1.0.x 的 peek 是 self.inner.peek(buf),经 Deref 直接调 std。1.1.0 的 #1895(PR,「fix: peek reregister after would block」)改成现在的样子,加了一行注释:
pub fn peek(&self, buf: &mut [u8]) -> io::Result<usize> {
// Need to re-register if `peek` returns `WouldBlock`
// to ensure the socket will receive more events once it is ready again.
self.inner.do_io(|inner| inner.peek(buf))
}
问题先在 Windows 上被发现:Windows 的 selector 也要模拟边沿触发,报出可读事件后清掉可读关注。PR 的说明是「since peek is also a kind of special read, it also cancelled interest to readable event, hence cause the blocking peek bug on windows because interest is lost so blocking is forever」,随后又发现「under linux with cfg(mio_unsupported_force_poll_poll) peek would also have similiar problem so just write do_io on all platform instead」。CHANGELOG 1.1.0 的条目是「Fixed an issue where TcpStream::peek returned WouldBlock the events would stop working correctly」。1.2.3 的 UnixListener::accept(#1998)是同一类问题,见 怎么读这一卷 和 poll(2) 后备实现。
动脑筋答案
src/bin/connect_readable.rs 和 tcp_connect.rs 只差注册那一行(Interest::READABLE),每种情况 poll 一次后打印事件个数和事件。M2 Max、macOS、rustc 1.99.0:
os=macos
listening port: 0 event(s)
closed port: 1 event(s)
filter=READ flags=EOF|CLEAR fflags=61 data=0 udata=0x0 => readable error read_closed
Linux 6.12.28(Apple container 里的 Alpine,aarch64 musl 静态链接)、rustc 1.99.0:
os=linux
listening port: 0 event(s)
closed port: 1 event(s)
events=IN|ERR|HUP|RDHUP data=0x0 => readable error read_closed write_closed
两个平台各跑 3 次,输出相同。
- 连上时 0 个事件。 连接建立只让 socket 变得可写,不会让它可读;对端不发数据,
READABLE就一直没有变化,poll等满 1 秒超时返回。只注册READABLE的程序要等到对端第一次发数据才知道连上了,如果协议是客户端先说话,就会一直等下去。这就是文档第 2 步要求「at least write interest」的原因。 - 被拒时 1 个事件。 失败会同时影响两个方向:Linux 给出
IN|ERR|HUP|RDHUP(只关注可读时也会报EPOLLERR和EPOLLHUP,内核总是报告这两位),macOS 给出带EV_EOF、fflags=61的EVFILT_READ。所以失败用READABLE也能发现,成功却不能。
小结
TcpListener/TcpStream只有一个IoSource<std 类型>字段。选项、地址、take_error经Deref直接用 std,mio 不依赖 socket2;0.8.0 删掉TcpSocket(#1514),更多的选项交给 socket2。bind是socket(Linux 带SOCK_NONBLOCK|SOCK_CLOEXEC,macOS 事后补SO_NOSIGPIPE和两次fcntl)、SO_REUSEADDR(Windows 不设)、bind、listen(128)。backlog 在 1.0 是 1024,1.1 是 -1,1.2 起和 std 一样是 128(#1896、#1934)。accept经过do_io。Linux 用accept4(SOCK_CLOEXEC|SOCK_NONBLOCK);macOS 用accept加FD_CLOEXEC,实测非阻塞和SO_NOSIGPIPE都从监听 socket 继承。connect把EINPROGRESS当成Ok。连上还是被拒,要注册WRITABLE、等可写事件、先take_error(读一次就清零)再peer_addr。被拒时可写事件照样来,带着错误位;被拒后的peer_addr在 Linux 上是NotConnected、macOS 上是EINVAL,所以take_error必须先做。只注册READABLE等不到连接成功。from_std不设非阻塞。忘了设时事件照常来,读干净后的那次recvfrom阻塞住整个事件循环。- 读写、
peek、try_io都经过do_io;绕过它(经Deref直接调 std)只在需要重新武装的平台上出错,peek在 1.1.0 之前就是这样(#1895)。