Rust in Everything
基于 mio 1.2.4

Windows:IOCP 与 AFD

主线位置:两条主线都在这里换了样子。注册主线不落到任何系统调用:register 只是建一个 SockState 放进队列;真正的 IOCTL_AFD_POLL 推迟到下一次 poll 里、GetQueuedCompletionStatusEx 之前才提交。事件主线上,GetQueuedCompletionStatusEx 取回的是完成包,feed_event 把它翻译成 Event,再由 do_io 在 WouldBlock 时重新武装。

flowchart TB
  reg["register:Token 加 Interest"] --> st[新建 SockState]
  st --> q[进 update_queue]
  pollc["Poll::poll"] --> upd[update_sockets_events]
  q --> upd
  upd --> afd["提交 IOCTL_AFD_POLL"]
  afd --> gq[GetQueuedCompletionStatusEx]
  gq --> fe["feed_event:摘掉已报事件"]
  fe --> ev[Events]
  ev --> io[读写到 WouldBlock]
  io --> rr["do_io:reregister"]
  rr --> q

本页只读源码。 手边没有 Windows 机器,页面里关于 Windows 的说法都来自 mio 1.2.4 的源码、提交说明和 PR,不涉及运行时行为。唯一的实验是在 macOS 上用 --target x86_64-pc-windows-msvc 做 cargo check,确认下文引用的代码在 Windows 目标上确实编译进来(见文末「实验」一节)。

为什么 IOCP 是完成模型、mio 为什么非要把它变成就绪模型,mio 解决什么问题已经讲过,那一页也引了本页的核心一行 self.user_evts &= !afd_events。这一页往下走一层:AFD 句柄从哪来、一个 socket 的状态怎么流转、完成包怎么分给三种来源,以及 named pipe 为什么是另一套机制。Windows 的代码在 src/sys/windows/,共 3 039 行,其中 named_pipe.rs 1 094 行、selector.rs 741 行、iocp.rs 282 行、afd.rs 243 行。

📝NOTE
冷知识:AFD 方案是 0.7.0 从 wepoll 搬来的

issue #1024「Rewrite of the Windows implementation」开头是:「The Windows implementation hasn't been receiving the attention it needs to be a first class citizen.」#1034(首次发布于 0.7.0)做了这次重写,提交说明:「Rewrite the windows implementation using the [wepoll] strategy. This uses the windows AFD system to access socket readiness events. By doing this, mio no longer needs to buffer reads and writes internally.」之前(0.6)socket 走真正的重叠读写,mio 替用户缓冲数据;之后 socket 只用 AFD poll 等就绪,读写直接调 std。0.7.0 的 CHANGELOG 只写了一句「The entire Windows implementation was rewritten.」今天 afd.rs 里 POLL_CONNECT 旁的注释还链着 wepoll 仓库的一个提交。

三层:Selector、SelectorInner、AfdGroup

Unix 上 sys::Selector 基本就是一个 fd。Windows 的 Selector 只是一层壳:

pub struct Selector {
    #[cfg(debug_assertions)]
    id: usize,
    pub(super) inner: Arc<SelectorInner>,
}

try_clone 是 Arc::clone(&self.inner),不复制任何句柄(epoll 上是 dup 一个 fd,见 Poll 与 Registry)。状态都在 SelectorInner 里:

pub struct SelectorInner {
    pub(super) cp: Arc<CompletionPort>,
    update_queue: Mutex<VecDeque<Pin<Arc<Mutex<SockState>>>>>,
    afd_group: AfdGroup,
    is_polling: AtomicBool,
}
字段作用
cp完成端口。Waker 和 NamedPipe 也各拿一份 Arc
update_queue等着「提交或更新 AFD poll」的 socket。register、reregister、收到完成包之后都往这里放
afd_group一组打开的 \Device\Afd\Mio 句柄,socket 共用
is_polling有没有线程正阻塞在 GetQueuedCompletionStatusEx 里,决定注册时要不要立刻提交

SelectorInner 里有 Mutex,结构体上还有一行 unsafe impl Sync for SelectorInner {},注释是「We have ensured thread safety by introducing lock manually.」

AfdGroup 是 Mutex<Vec<Arc<Afd>>>。acquire 给新 socket 分一个 AFD 句柄:

    const POLL_GROUP_MAX_GROUP_SIZE: usize = 32;

    impl AfdGroup {
        pub fn acquire(&self) -> io::Result<Arc<Afd>> {
            let mut afd_group = self.afd_group.lock().unwrap();
            if afd_group.len() == 0 {
                self._alloc_afd_group(&mut afd_group)?;
            } else {
                // + 1 reference in Vec
                if Arc::strong_count(afd_group.last().unwrap()) > POLL_GROUP_MAX_GROUP_SIZE  {
                    self._alloc_afd_group(&mut afd_group)?;
                }
            }
            // ...

Vec 自己占一个引用,每个 SockState 占一个。最后一个 Afd 的引用数超过 32,也就是已经有 32 个 socket 在用它,就再开一个。release_unused_afd 反过来,retain(|g| Arc::strong_count(g) > 1),只剩 Vec 那一个引用的句柄关掉。每次 update_sockets_events 和 feed_events 结束时都调它。所以 AFD 句柄的数量大约是「注册的 socket 数 / 32」向上取整。

afd.rs:一个设备句柄,一个 IOCTL

打开 \Device\Afd\Mio

AFD 是 Winsock 下面的内核驱动。Afd::new 用 NtCreateFile 打开一个名字写死的设备路径,AFD_HELPER_NAME 是一个逐字写出的 &[u16]:\Device\Afd\Mio。打开之后做两件事:

                let fd = File::from_raw_handle(afd_helper_handle as RawHandle);
                // Increment by 2 to reserve space for other types of handles.
                // Non-AFD types (currently only NamedPipe), use odd numbered
                // tokens. This allows the selector to differentiate between them
                // and dispatch events accordingly.
                let token = NEXT_TOKEN.fetch_add(2, Ordering::Relaxed) + 2;
                let afd = Afd { fd };
                cp.add_handle(token, &afd.fd)?;
                match SetFileCompletionNotificationModes(
                    afd_helper_handle,
                    FILE_SKIP_SET_EVENT_ON_HANDLE as u8 // This is just 2, so fits in u8
                ) {
  1. 把 AFD 句柄关联到完成端口,completion key 是一个偶数(2、4、6……)。这里的 key 不是用户的 Token,用户的 Token 存在 SockState::user_data 里。key 的奇偶只用来分辨完成包来自 AFD 还是 named pipe,见下文 feed_events。
  2. SetFileCompletionNotificationModes 只设了 FILE_SKIP_SET_EVENT_ON_HANDLE,没有设 FILE_SKIP_COMPLETION_PORT_ON_SUCCESS(「同步成功时不送完成包」的那个选项)。后面的 update 对 AFD poll 的同步完成(Ok(true))和挂起(Ok(false))一视同仁,都把交给内核的那份引用留到完成包回来再归还,依赖的就是同步完成也会有完成包。

注意被关联到完成端口的是 AFD 句柄,不是 socket。socket 本身从不和完成端口关联;所有 socket 的就绪通知都经由它们共用的 AFD 句柄回来。

IOCTL_AFD_POLL

一次「等这个 socket 上的这些事件」的请求是一个 AfdPollInfo:

#[repr(C)]
pub struct AfdPollInfo {
    pub timeout: i64,
    // Can have only value 1.
    pub number_of_handles: u32,
    pub exclusive: u32,
    pub handles: [AfdPollHandleInfo; 1],
}

handles[0] 里是 socket 句柄、要等的事件位、状态。Afd::poll 把它交给 NtDeviceIoControlFile,控制码 IOCTL_AFD_POLL 是 0x00012024:

        let info_ptr = info as *mut _ as *mut c_void;
        (*iosb).Anonymous.Status = STATUS_PENDING;
        let status = NtDeviceIoControlFile(
            self.fd.as_raw_handle() as HANDLE,
            std::ptr::null_mut(),
            None,
            overlapped,
            iosb,
            IOCTL_AFD_POLL,
            info_ptr,
            size_of::<AfdPollInfo>() as u32,
            info_ptr,
            size_of::<AfdPollInfo>() as u32,
        );
        match status {
            STATUS_SUCCESS => Ok(true),
            STATUS_PENDING => Ok(false),
            _ => Err(io::Error::from_raw_os_error(
                RtlNtStatusToDosError(status) as i32
            )),
        }

三个细节:

  • 输入缓冲和输出缓冲是同一块 AfdPollInfo。请求完成时,内核把发生的事件写回 handles[0].events,feed_event 从那里读。
  • overlapped 参数放的不是一个 OVERLAPPED,而是 Arc<Mutex<SockState>> 转出来的裸指针。完成包的 lpOverlapped 就是它,feed_events 用 from_overlapped 把它变回 Arc。
  • iosb(IO_STATUS_BLOCK)在请求挂起期间归内核写。Afd::poll 先把 Status 置成 STATUS_PENDING,Afd::cancel 先看它还是不是 STATUS_PENDING,不是就说明已经完成了,不用取消;是就调 NtCancelIoFileEx,STATUS_NOT_FOUND 也当成功。

文件注释把约束说得很直接:「All operations are unsafe due to IO_STATUS_BLOCK parameter are being used by Afd driver during STATUS_PENDING before I/O Completion Port returns its result.」这块内存要一直有效到完成包回来,所以 SockState 带着 PhantomPinned、放在 Pin<Arc<...>> 里。

AFD_POLL_* 和 Interest

afd.rs 末尾定义了九个事件位:

常量位mio 怎么用
POLL_RECEIVE0x001有数据可读
POLL_RECEIVE_EXPEDITED0x002带外数据;is_priority 看它
POLL_SEND0x004可写
POLL_DISCONNECT0x008对端关闭(读半边)
POLL_ABORT0x010连接被重置
POLL_LOCAL_CLOSE0x020本地关了这个 socket;mio 总是请求,收到后删除 SockState
POLL_CONNECT0x040不用。注释:每个事件都会带上它,不只是第一次连上时
POLL_ACCEPT0x080listener 上有连接可 accept
POLL_CONNECT_FAIL0x100非阻塞 connect 失败

注册时 interests_to_afd_flags 把 Interest 换成位:READABLE 是 READABLE_FLAGS | READ_CLOSED_FLAGS | ERROR_FLAGS,WRITABLE 是 WRITABLE_FLAGS | WRITE_CLOSED_FLAGS | ERROR_FLAGS。这几个常量在 event.rs,同一组常量也用来解释事件,所以注册和解释用的是同一张表:

常量包含的位用在
READABLE_FLAGSRECEIVE、DISCONNECT、ACCEPT、ABORT、CONNECT_FAILInterest::READABLE、is_readable
WRITABLE_FLAGSSEND、ABORT、CONNECT_FAILInterest::WRITABLE、is_writable
READ_CLOSED_FLAGSDISCONNECT、ABORT、CONNECT_FAILis_read_closed
WRITE_CLOSED_FLAGSABORT、CONNECT_FAILis_write_closed
ERROR_FLAGSCONNECT_FAILis_error

在这之上还有两处固定追加。SockState::set_event 总是加上 POLL_CONNECT_FAIL | POLL_ABORT(注释:「afd::POLL_CONNECT_FAIL and afd::POLL_ABORT are always reported, even when not requested by the caller.」);update 提交时再加 POLL_LOCAL_CLOSE。于是只注册了 WRITABLE 的 socket 在连接被重置时,事件里是 POLL_ABORT,而 POLL_ABORT 同时在 READABLE_FLAGS 和 WRITABLE_FLAGS 里,is_readable() 和 is_writable() 都为真。

📝NOTE
没有蠢问题

问: Windows 上 Event::is_priority() 能返回 true 吗?event.rs 里明明有 event.flags & afd::POLL_RECEIVE_EXPEDITED != 0。

答: 对 socket 来说不能。Interest::PRIORITY 只在 Linux 和 Android 上存在,interests_to_afd_flags 产生的位里也没有 POLL_RECEIVE_EXPEDITED。feed_event 先做 afd_events &= self.user_evts,没请求的位在这里就被清掉了,所以 is_priority 看的那一位永远是 0。它的存在是为了让每个平台都有同一组 is_* 函数;is_aio、is_lio 在 Windows 上直接写死 false。

iocp.rs:CompletionPort 与 CompletionStatus

CompletionPort 包一个句柄,四个方法各对应一个 Win32 调用:

方法Win32 调用谁用
new(threads)CreateIoCompletionPort(INVALID_HANDLE_VALUE, null, 0, threads)SelectorInner::new,传 0
add_handle(token, t)CreateIoCompletionPort(handle, port, token, 0)Afd::new(偶数 key)、NamedPipe::register(奇数 key)
get_many(list, timeout)GetQueuedCompletionStatusExselect2、SelectorInner::drop
post(status)PostQueuedCompletionStatusWaker::wake、named pipe 的 schedule_event

CompletionStatus 是 #[repr(transparent)] 包着的 OVERLAPPED_ENTRY,有用的是三个字段:bytes_transferred()、token()(completion key)、overlapped()。get_many 一次最多取 list.len() 个,list 就是 Events 里的 statuses: Box<[CompletionStatus]>,长度等于 Events::with_capacity 的参数。所以「一次 poll 最多拿多少」在 Windows 上限制的是完成包的数量,不是事件的数量。Events 里另有一个 events: Vec<Event>,named pipe 的一个完成包可以往里推不止一个事件,with_capacity 的注释也说「it's possible for the output events to grow beyond the capacity」。

超时换算在 duration_millis:向上取整到毫秒(避免把 0.5 ms 变成 0),再截到 u32::MAX;None 直接给 u32::MAX,也就是 Win32 的 INFINITE。和 epoll 那边 1.2.1 修的截断问题不同,这里一开始就是 cmp::min 截断,不会溢出成负数。

一次 poll

SelectorInner::select 和 select2:

    pub fn select(&self, events: &mut Events, timeout: Option<Duration>) -> io::Result<()> {
        events.clear();

        if timeout.is_none() {
            loop {
                let len = self.select2(&mut events.statuses, &mut events.events, None)?;
                if len == 0 {
                    continue;
                }
                break Ok(());
            }
        } else {
            self.select2(&mut events.statuses, &mut events.events, timeout)?;
            Ok(())
        }
    }

    pub fn select2(
        &self,
        statuses: &mut [CompletionStatus],
        events: &mut Vec<Event>,
        timeout: Option<Duration>,
    ) -> io::Result<usize> {
        assert!(!self.is_polling.swap(true, Ordering::AcqRel));

        unsafe { self.update_sockets_events() }?;

        let result = self.cp.get_many(statuses, timeout);

        self.is_polling.store(false, Ordering::Relaxed);

        match result {
            Ok(iocp_events) => Ok(unsafe { self.feed_events(events, iocp_events) }),
            Err(ref e) if e.raw_os_error() == Some(WAIT_TIMEOUT as i32) => Ok(0),
            Err(e) => Err(e),
        }
    }

一次 select2 是三步:先把 update_queue 里的 socket 都提交或更新 AFD poll,再阻塞在 GetQueuedCompletionStatusEx,最后把完成包翻译成事件。几处和 Unix 不一样的地方:

  • is_polling 的 assert! 保证同时只有一个线程在等这个完成端口;Selector::select 要 &mut self 也是为此(注释:「only a single thread can poll IOCP at a time」)。
  • select2 返回的是产生的事件数,不是完成包数。完成包可能一个事件都不产生:被取消的 AFD poll、只报告了用户已经不关心的事件的 AFD poll,都会被 feed_event 吃掉。timeout 为 None 时,select 遇到 0 就再来一轮,保证 poll(None) 不会空手返回。有超时时不重试,所以 poll(Some(t)) 可能在 t 到期之前带着 0 个事件返回。
  • WAIT_TIMEOUT 被当作 Ok(0)。

注册为什么推迟到 poll 里

register 不立刻提交 AFD poll。SelectorInner::register 建一个 SockState、设好 user_evts,queue_state 放进队列,然后调 update_sockets_events_if_polling:

        unsafe fn update_sockets_events_if_polling(&self) -> io::Result<()> {
            if self.is_polling.load(Ordering::Acquire) {
                self.update_sockets_events()
            } else {
                Ok(())
            }
        }

它上面那段长注释给了原因:「Since it is not possible to modify or synchronously cancel an AFD_POLL operation, and there can be only one active AFD_POLL operation per (socket, completion port) pair at any time, it is expensive to change a socket's event registration after it has been submitted to the kernel.」一个 socket 一次只能挂一个 AFD poll,改关注集合要先取消、等取消的完成包回来、再提交新的。如果用户在两次 poll 之间连着 register、reregister 好几次,推迟提交就只需要在 poll 前提交一次。只有另一个线程已经阻塞在 GetQueuedCompletionStatusEx 里时才立刻提交,否则那个线程等不到这个 socket 的事件。

📝NOTE
冷知识:推迟提交是 0.7.0 正式发布之前加的

#1140(首次发布于 0.7.0)在 Windows 重写之后不久合入,标题是「Submit socket events to kernel immediately only if another thread is polling」。PR 说明和今天 update_sockets_events_if_polling 上的注释是同一段话:「if no other threads are polling when interest in a socket event is (re)registered, the socket is added to the 'update queue', but the actual syscall to start the IOCTL_AFD_POLL operation is deferred until just before the GetQueuedCompletionStatusEx() call is made.」之前的做法是 register 时立即提交;现在立即提交只发生在 is_polling 为真的时候,其余情况都在 select2 开头的 update_sockets_events 里批量处理。

update_sockets_events 遍历队列,对每个没被标记删除的 SockState 调 update(任何一个出错,? 就让整个函数返回),全部做完后 update_queue.retain(|sock| sock.lock().unwrap().has_error()),只留下带着错误记录的项,其余出队。

SockState 的状态机

SockState 是一个 socket 在 Windows selector 里的全部状态:

pub struct SockState {
    iosb: IoStatusBlock,
    poll_info: AfdPollInfo,
    afd: Arc<Afd>,

    base_socket: RawSocket,

    user_evts: u32,
    pending_evts: u32,

    user_data: u64,

    poll_status: SockPollStatus,
    delete_pending: bool,

    // last raw os error
    error: Option<i32>,

    _pinned: PhantomPinned,
}
  • iosb、poll_info 是交给内核的那两块内存,所以整个结构要钉住。
  • base_socket 是 get_base_socket 用 WSAIoctl(SIO_BASE_HANDLE) 取到的底层 socket。装了 LSP(分层服务提供者)时用户拿到的 socket 可能不是 AFD 认识的那个;注释说至少有一个 LSP 故意让 SIO_BASE_HANDLE 失败,所以失败后依次试 SIO_BSP_HANDLE_SELECT、SIO_BSP_HANDLE_POLL、SIO_BSP_HANDLE。
  • user_evts:用户现在想等的 AFD 位。pending_evts:正在内核里挂着的那个 AFD poll 等的位。
  • user_data:用户的 Token,事件回来时原样放进 Event::data。
  • poll_status 是 SockPollStatus,三个值 Idle、Pending、Cancelled,加上 delete_pending 标志,构成下面的状态机:
stateDiagram-v2
  state "delete_pending 为真" as Deleting
  [*] --> Idle : register
  Idle --> Pending : update 提交 AFD poll
  Pending --> Pending : update 关注已被覆盖
  Pending --> Cancelled : update 关注变多 cancel
  Pending --> Idle : 完成包 feed_event
  Cancelled --> Idle : 取消的完成包
  Idle --> Deleting : mark_delete
  Pending --> Deleting : mark_delete 先 cancel
  Cancelled --> Deleting : mark_delete
  Deleting --> [*] : 最后一个 Arc 释放

update:提交、保留或取消

update 按 poll_status 分三种情况:

        if let SockPollStatus::Pending = self.poll_status {
            if (self.user_evts & afd::KNOWN_EVENTS & !self.pending_evts) == 0 {
                /* All the events the user is interested in are already being monitored by
                 * the pending poll operation. It might spuriously complete because of an
                 * event that we're no longer interested in; when that happens we'll submit
                 * a new poll operation with the updated event mask. */
            } else {
                /* A poll operation is already pending, but it's not monitoring for all the
                 * events that the user is interested in. Therefore, cancel the pending
                 * poll operation; when we receive it's completion package, a new poll
                 * operation will be submitted with the correct event mask. */
                if let Err(e) = self.cancel() {
                    self.error = e.raw_os_error();
                    return Err(e);
                }
                return Ok(());
            }
        } else if let SockPollStatus::Cancelled = self.poll_status {
            /* The poll operation has already been cancelled, we're still waiting for
             * it to return. For now, there's nothing that needs to be done. */
        } else if let SockPollStatus::Idle = self.poll_status {
            /* No poll operation is pending; start one. */
            self.poll_info.exclusive = 0;
            self.poll_info.number_of_handles = 1;
            self.poll_info.timeout = i64::MAX;
            self.poll_info.handles[0].handle = self.base_socket as HANDLE;
            self.poll_info.handles[0].status = 0;
            self.poll_info.handles[0].events = self.user_evts | afd::POLL_LOCAL_CLOSE;

            // Increase the ref count as the memory will be used by the kernel.
            let overlapped_ptr = into_overlapped(self_arc.clone());
            // ...
            self.poll_status = SockPollStatus::Pending;
            self.pending_evts = self.user_evts;
        }
  • Pending,关注没变多:什么也不做。关注变少也算这种情况:挂着的 poll 可能因为一个用户已经不要的事件完成,feed_event 会把它过滤掉,再按新集合提交。
  • Pending,关注变多:只能取消。取消是异步的,cancel 把状态改成 Cancelled、pending_evts 清零就返回;等取消的完成包回来,feed_event 把状态改回 Idle,socket 重新入队,下一次 update 再按完整集合提交。
  • Idle:填 poll_info,timeout 是 i64::MAX(一直等),exclusive 是 0,事件位是 user_evts | POLL_LOCAL_CLOSE。

into_overlapped(self_arc.clone()) 是引用计数的关键:克隆一个 Arc,Arc::into_raw 成裸指针交给内核。内核手里这一份引用要等完成包回来、feed_events 里 from_overlapped 把它变回 Arc 时才归还。提交失败时内核不会用这块内存,代码立刻 drop(from_overlapped(...)) 还掉。失败码是 ERROR_INVALID_HANDLE 时说明 socket 已经关了,直接 mark_delete;其他错误记进 self.error 并返回 Err,update_sockets_events 里的 ? 让这一次 poll 带着这个错误返回。

mark_delete 在 Pending 时先 cancel,再置 delete_pending。deregister、InternalState 的 Drop、SockState 的 Drop 都调它。被标记的 SockState 不会再被 update,完成包回来时 feed_event 直接返回 None,feed_events 也不再把它放回队列,最后一个 Arc 一走就释放。

feed_event:完成包变成 Event

feed_events 先按完成包的来源分流:

        for iocp_event in iocp_events.iter() {
            if iocp_event.overlapped().is_null() {
                events.push(Event::from_completion_status(iocp_event));
                n += 1;
                continue;
            } else if iocp_event.token() % 2 == 1 {
                // Handle is a named pipe. This could be extended to be any non-AFD event.
                let callback = (*(iocp_event.overlapped() as *mut super::Overlapped)).callback;

                let len = events.len();
                callback(iocp_event.entry(), Some(events));
                n += events.len() - len;
                continue;
            }

            let sock_state = from_overlapped(iocp_event.overlapped());
            let mut sock_guard = sock_state.lock().unwrap();
            if let Some(e) = sock_guard.feed_event() {
                events.push(e);
                n += 1;
            }

            if !sock_guard.is_pending_deletion() {
                update_queue.push_back(sock_state.clone());
            }
        }
完成包怎么认出来去哪
Waker::wake 投递的lpOverlapped 为空Event::from_completion_status:bytes_transferred 当事件位,completion key 当 token
named pipe 的completion key 是奇数调 Overlapped 里存的回调函数
AFD poll 的其余(key 是偶数)from_overlapped 拿回 SockState,feed_event

AFD 那一支里,每个没被删除的 socket 处理完都重新入队,不管有没有产生事件。下一次 select2 开头的 update 会按当时的 user_evts 再提交一次 AFD poll。

feed_event 本身:

    fn feed_event(&mut self) -> Option<Event> {
        self.poll_status = SockPollStatus::Idle;
        self.pending_evts = 0;

        let mut afd_events = 0;
        // We use the status info in IO_STATUS_BLOCK to determine the socket poll status. It is unsafe to use a pointer of IO_STATUS_BLOCK.
        unsafe {
            if self.delete_pending {
                return None;
            } else if self.iosb.Anonymous.Status == STATUS_CANCELLED {
                /* The poll request was cancelled by CancelIoEx. */
            } else if self.iosb.Anonymous.Status < 0 {
                /* The overlapped request itself failed in an unexpected way. */
                afd_events = afd::POLL_CONNECT_FAIL;
            } else if self.poll_info.number_of_handles < 1 {
                /* This poll operation succeeded but didn't report any socket events. */
            } else if self.poll_info.handles[0].events & afd::POLL_LOCAL_CLOSE != 0 {
                /* The poll operation reported that the socket was closed. */
                self.mark_delete();
                return None;
            } else {
                afd_events = self.poll_info.handles[0].events;
            }
        }

        afd_events &= self.user_evts;

        if afd_events == 0 {
            return None;
        }

        // In mio, we have to simulate Edge-triggered behavior to match API usage.
        // The strategy here is to intercept all read/write from user that could cause WouldBlock usage,
        // then reregister the socket to reset the interests.
        self.user_evts &= !afd_events;

        Some(Event {
            data: self.user_data,
            flags: afd_events,
        })
    }

先看完成状态:取消的不产生事件;请求本身失败(NTSTATUS 为负)报成 POLL_CONNECT_FAIL,于是 is_error() 为真;POLL_LOCAL_CLOSE 表示本地关了 socket,删除状态、不报事件。剩下的是正常完成,事件位在 poll_info.handles[0].events 里。然后两步位运算:

  1. afd_events &= self.user_evts:只留用户现在还关心的位。上文「关注变少时不取消」靠的就是这一步。
  2. self.user_evts &= !afd_events:把刚报告过的位从关注集合里摘掉。

第二步就是边沿触发的模拟。设一个 socket 注册了 READABLE,user_evts 是 RECEIVE | DISCONNECT | ACCEPT | ABORT | CONNECT_FAIL。数据到达,AFD poll 带着 RECEIVE 完成,用户拿到一个可读事件,user_evts 变成 DISCONNECT | ACCEPT | ABORT | CONNECT_FAIL。这个 socket 重新入队,下一次 poll 提交的 AFD poll 就不再等 RECEIVE:之后再来多少数据,都不会再报可读,直到用户把数据读完、碰到 WouldBlock。这和 epoll 的 EPOLLET 在用户看来是一样的:就绪只报一次,读到 WouldBlock 才会有下一次。

💡TIP
打个比方:一张会被划掉的订阅单

你在报刊亭留了一张订阅单(user_evts),勾了「新到杂志」「停刊通知」。报刊亭每次只接一张单子(一个 socket 同时只挂一个 AFD poll),按单子等着(pending_evts)。新杂志到了,报刊亭打电话通知你(完成包 → Event),同时把单子上「新到杂志」划掉(user_evts &= !afd_events),再按划过的单子继续等。之后又到了几期,因为那一项已经划掉,不会再打电话。你去取杂志,取到架子空了(WouldBlock),顺手把那一项重新勾上(do_io 里的 reregister)。「停刊通知」一直没划,所以期间停刊了照样会通知你。

比方到这里为止:报刊亭不能在单子挂着的时候改它。你要多勾一项,它得先把旧单子作废(cancel),等作废确认回来(取消的完成包),再挂新单子;真实机制里这一步是异步的,比方里看不出来。

只摘掉报告过的那几位,而不是整组 READABLE,是为了不丢后续的关闭事件:数据和对端关闭先后到达时,RECEIVE 被摘掉之后 DISCONNECT 还在集合里,下一次 AFD poll 仍然会报它。

📝NOTE
冷知识:user_evts &= !afd_events 这一行是 0.7.4 改出来的

0.7.4 的 CHANGELOG 有一条「lost "socket closed" events on windows」,对应 #1370(「Fix HUP notifications on windows」)。PR 说明:「Currently, on windows, when receiving a "writeable" notification, HUP interest is disabled. This results in not receiving HUP notifications.」改之前,feed_event 收到可读事件就把 interests_to_afd_flags(Interest::READABLE) 整组摘掉,收到可写事件就把 interests_to_afd_flags(Interest::WRITABLE) 整组摘掉,而这两组里都带着 ABORT、CONNECT_FAIL 这些关闭和错误位;一次可写通知之后,连接断开就再也报不出来。改成 self.user_evts &= !afd_events 之后,只摘掉这次真正报告了的位。

重新武装:IoSourceState::do_io

摘掉的位在哪里加回去?在 IoSourceState::do_io,mio::net 的每个 read、write、accept、peek 都经过它(为什么所有 I/O 都必须经过 do_io,见 Source 与 IoSource):

        pub fn do_io<T, F, R>(&self, f: F, io: &T) -> io::Result<R>
        where
            F: FnOnce(&T) -> io::Result<R>,
        {
            let result = f(io);
            if let Err(ref e) = result {
                if e.kind() == io::ErrorKind::WouldBlock {
                    self.inner.as_ref().map_or(Ok(()), |state| {
                        state
                            .selector
                            .reregister(state.sock_state.clone(), state.token, state.interests)
                    })?;
                }
            }
            result
        }

inner 是 InternalState,注册时建立,存着 Arc<SelectorInner>、Token、Interest 和 sock_state。碰到 WouldBlock 就用注册时的 Token 和 Interest 调 SelectorInner::reregister:set_event 把 user_evts 恢复成完整集合,queue_state 入队,有线程在 poll 就立刻 update。这时 socket 上多半挂着一个不等 RECEIVE 的 AFD poll,于是 update 走「关注变多」那一支:取消它,等取消的完成包回来,再按完整集合提交。

读到 WouldBlock 之前不会有新的可读事件,读到之后要经过「取消、取消完成、重新提交」才能收到下一个。这是 Windows 上一次边沿的代价,Unix 的 epoll 和 kqueue 上 do_io 只有一行 f(io)。

📝NOTE
冷知识:peek 曾经不经过 do_io

#1895(首次发布于 1.1.0)之前,TcpStream::peek 直接调 self.inner.peek(buf)。PR 说明把原因追到了这里:「SockState::feed_event mio have to simulate Edge-triggered behavior and clean readable interest after send a readable interest, but 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」。peek 返回 WouldBlock 时没人把 RECEIVE 加回去,这个 socket 就再也收不到可读事件。现在的 peek 是 self.inner.do_io(|inner| inner.peek(buf)),注释写着「Need to re-register if peek returns WouldBlock」。PR 还提到 Linux 上设了 mio_unsupported_force_poll_poll 时有同样的问题,poll(2) 后备也要靠 do_io 重新武装,见 poll(2) 后备。

IoSourceState 的另外三个方法也和 Unix 不同:

  • register 在 inner 已经是 Some 时返回 ErrorKind::AlreadyExists。这个检查不在 cfg(debug_assertions) 里,所以 release 下在 Windows 上重复注册同一个 socket 也会报错。Poll 与 Registry 的误用矩阵里 release 下 Linux 是 EEXIST、macOS 是 Ok,Windows 在源码上属于报错的一边。
  • reregister 在没注册过时返回 ErrorKind::NotFound。
  • deregister 不需要 Registry,只是 mark_delete,然后丢掉 InternalState。

Waker:投递一个空的完成包

Waker 不建任何内核对象,只拿一份完成端口的 Arc:

pub struct Waker {
    token: Token,
    port: Arc<CompletionPort>,
}

impl Waker {
    pub fn new(selector: &Selector, token: Token) -> io::Result<Waker> {
        Ok(Waker {
            token,
            port: selector.clone_port(),
        })
    }

    pub fn wake(&self) -> io::Result<()> {
        let mut ev = Event::new(self.token);
        ev.set_readable();

        self.port.post(ev.to_completion_status())
    }
}

wake 构造一个 flags = POLL_RECEIVE、data = token 的 Event,to_completion_status 把 flags 放进 dwNumberOfBytesTransferred、data 放进 completion key、lpOverlapped 为空,用 PostQueuedCompletionStatus 投递。feed_events 看到空的 lpOverlapped 就原样翻译回来,所以用户看到的是一个 is_readable() 为真、token 是 waker token 的事件。这里的 completion key 就是用户的 Token,不分奇偶:空指针这一判断排在奇偶判断前面。

和 Linux 的 eventfd(见 Waker)相比:eventfd 是一个计数器,多次 wake 在内核里合成一个就绪状态;这里每次 wake 都 post 一个独立的完成包,feed_events 对每个 lpOverlapped 为空的包各推一个事件,mio 自己不做合并。drop 掉 Waker 也不会撤回已经投递的包。

NamedPipe:唯一自带缓冲的类型

AFD 只认 socket,Selector 的文档写着「This selector is currently only support socket due to Afd driver is winsock2 specific.」Windows 上也没有 SourceFd 那样的「任意句柄」入口(见下面的动脑筋)。命名管道不是 socket,NamedPipe(os-ext feature)只好用真正的重叠 I/O,在 mio 内部把完成结果存起来,再以就绪事件的形式交给用户。它的文档:

/// Note, IOCP is a *completion* based model whereas mio is a *readiness* based
/// model. To bridge this, `NamedPipe` performs internal buffering. Writes are
/// written to an internal buffer and the buffer is submitted to IOCP. IOCP
/// reads are submitted using internal buffers and `NamedPipe::read` reads from
/// this internal buffer.

Poll 文档里那句「intermediate buffer」在 1.2.4 只对它成立,mio 解决什么问题讲过这句话的来历。

结构

NamedPipe 是 Arc<Inner>,Inner 是 #[repr(C)],开头四个 Overlapped:

#[repr(C)]
struct Inner {
    // NOTE: careful modifying the order of these three fields, the `ptr_from_*`
    // methods depend on the layout!
    connect: Overlapped,
    read: Overlapped,
    write: Overlapped,
    event: Overlapped,
    // END NOTE.
    handle: Handle,
    connecting: AtomicBool,
    io: Mutex<Io>,
    pool: Mutex<BufferPool>,
}

mio 的 Overlapped 是 #[repr(C)] 的 OVERLAPPED 后面跟一个回调函数指针,四个分别是 connect_done、read_done、write_done、event_done。完成包回来时,feed_events 从 lpOverlapped 读出回调并调用;回调再用 ptr_from_read_overlapped 这类函数按字段偏移倒推出 Inner 的地址(read 是第二个字段,指针减一个 Overlapped 就是 Inner 开头)。注释里的「these three fields」是加 event 之前留下的。

register 时 named pipe 句柄自己关联到完成端口,key 取自另一个计数器:static NEXT_TOKEN: AtomicUsize = AtomicUsize::new(1),每次加 2,所以都是奇数。AFD 的计数器从 0 开始加 2,都是偶数。feed_events 里 token() % 2 == 1 就是靠这个分流的。

读、写和就绪

读写状态在 Io 里,每个方向一个 State:None、Pending(Vec<u8>, usize)、Ok(Vec<u8>, usize)、Err(io::Error)。

  • 读:注册(或连上)之后,schedule_read 从 BufferPool 拿一个 4 KiB 的 Vec(get_buffer 里的 4 * 1024),发起 ReadFile,状态是 Pending。完成后 read_done 改成 Ok(buf, 0) 并报可读。用户的 read 从这个缓冲里拷数据,拷完才发起下一次 ReadFile;状态是 Pending 时返回 WouldBlock;Err 里是 ERROR_BROKEN_PIPE 时返回 Ok(0)。
  • 写:用户的 write 在上一次写还没完成时返回 WouldBlock;否则把数据拷进一个自有的 Vec,发起 WriteFile,挂起时直接返回 Ok(buf.len())。数据其实还在 mio 手里,写失败要到下一次 write 才以 Err 交给用户。
  • 就绪事件:notify_readable / notify_writable 在完成回调里直接往 events 推事件;不在回调里(比如用户的 read 里同步失败)时走 schedule_event,用 event 那个 Overlapped 给自己 post 一个完成包。为了让 selector 把它认成 named pipe 的包,event.data 先左移一位再加一,变成奇数,event_done 再右移回来,并且先检查 io.token 还在,取消注册之后不再报事件。这段检查来自 #1760(「named-pipes: fix receiving IOCP events after deregister」,0.8.11 通过 #1761 回移,1.0.0 起在主线)。

引用计数的约定

每发起一个重叠操作,都要给内核留一份 Arc<Inner>:成功提交后 mem::forget(me.clone()),完成回调里 Arc::from_raw 拿回来,回调结束时释放。约定是一个会到达的完成包,对应一份预留的引用。多留一份是内存泄漏,少留一份就是 use-after-free。1.2.1 和 1.2.4 的两个修复都是这条约定被破坏的地方。

1.2.1,#1954「Fix race condition in NamedPipe::connect」。 之前 connect 先调 connect_overlapped,返回挂起之后才 mem::forget(self.inner.clone())。PR 说明:「If connect_overlapped completes before we bump the reference count, then reference count of self.inner will temporarily hit zero and self.inner will be dropped.」也就是 poll 线程在 forget 之前就取到了完成包、在 connect_done 里先释放了一份不存在的引用。修复是先克隆再提交:

        // If `connect_overlapped` does not complete immediately then we need
        // to pass a reference to `self.inner` to the completion handler.
        // We need to increase the reference count here because `connect_done`
        // could be called as soon as we call `connect_overlapped`.
        let inner_for_completion = self.inner.clone();

挂起时 mem::forget(inner_for_completion),同步完成时这份克隆随作用域释放。PR 还解释了读写为什么没有这个问题:「both issuing the operation (schedule_read / schedule_write) and completions (read_done / write_done) are executed under a mutex (self.io)」。

1.2.4,#2014「Fix named pipe use-after-free when I/O fails right after submit」,修的是 #1819(read_done 里撞到 unreachable!)和 #1984(write_done 里撞到 unreachable!)。1.2.3 的 read_overlapped 是这样的:

        if res == 0 {
            let err = io::Error::last_os_error();
            if err.raw_os_error() != Some(ERROR_IO_PENDING as i32) {
                return Err(err);
            }
        }

        let mut bytes = 0;
        let res = GetOverlappedResult(self.handle.raw(), overlapped, &mut bytes, 0);
        if res == 0 {
            let err = io::Error::last_os_error();
            if err.raw_os_error() == Some(ERROR_IO_INCOMPLETE as i32) {
                Ok(None)
            } else {
                Err(err)
            }
        } else {
            Ok(Some(bytes as usize))
        }

ReadFile 返回 ERROR_IO_PENDING,说明操作已经提交、一定会有完成包;但代码接着用 GetOverlappedResult(bWait = 0) 再看一眼。绝大多数时候这一眼看到 ERROR_IO_INCOMPLETE,返回 Ok(None)。如果这时操作已经失败(比如对端恰好断开,读以 ERROR_BROKEN_PIPE 完成),这一眼看到的就是那个错误,返回 Err。调用方把 Err 理解成「没提交,不会有完成包」,于是不 forget:一个在路上的完成包,零份预留。被取代的 #2004 写了两条路径各自的后果:

  • 读:schedule_read 存下 State::Err,完成包到了,read_done 把 Err 放回去并释放一份引用,「one release too many. Inner is freed while NamedPipe still points at it, and the next legitimate drop double-frees」。
  • 写:Err 从 Write::write 直接返回给用户,io.write 留在 State::None,完成包到了,write_done 撞上 State::None => unreachable!()。

这个窗口为什么能被撞上,#2004 也给了解释:按 IOCP 文档,状态块「will not be updated until the packet is removed from the completion port」,所以那一眼能看到失败,说明 poll 线程已经把完成包取走了,回调正等在 io 的锁上。要撞上,需要对端在提交之后立刻让操作失败,同时提交线程在那一眼之前被抢占;#1819 报告的复现条件是笔记本合盖休眠。

1.2.4 的修复改的是接口的含义:Err 只表示「没提交,不会有完成包」。一旦提交,就不再返回 Err:

        if res == 0 {
            let err = io::Error::last_os_error();
            // `ERROR_MORE_DATA` is only a warning (the message didn't fit), the
            // read still completes through its packet.
            if err.raw_os_error() != Some(ERROR_IO_PENDING as i32)
                && err.raw_os_error() != Some(ERROR_MORE_DATA as i32)
            {
                return Err(err);
            }
            // Submitted: a completion packet will be queued, so this must not return Err.
            return Ok(None);
        }

        // Completed, but the completion still delivers the outcome, so this must not return Err.
        let mut bytes = 0;
        if GetOverlappedResult(self.handle.raw(), overlapped, &mut bytes, 0) == 0 {
            Ok(None)
        } else {
            Ok(Some(bytes as usize))
        }

失败照样会报给用户:完成回调用 me.result(...)(也是 GetOverlappedResult)取到真实结果,存成 State::Err,再以就绪事件的形式交出去。write_overlapped 做了同样的改动。PR 还顺带修了一个独立的 bug:同步返回的 ERROR_MORE_DATA 以前在 schedule_read 里有单独一支,不预留引用、把缓冲当成已经读完;PR 说明里写这会让「message-mode reads larger than 4 KiB lost data」。现在 ERROR_MORE_DATA 也算「已提交」,那一支删掉了。

1.2.1:socket 不再被子进程继承

另一条 1.2.1 的 Windows 改动不在 named pipe 里,在 socket 创建。#1946 回应 issue #1892:「With the current implementation of mio's TcpListener, the socket is inherited by any child processes. The std library's TcpListener uses WSASocketW which does not have this issue due to the explicit WSA_FLAG_NO_HANDLE_INHERIT.」现在的 new_socket:

    let flags = WSA_FLAG_OVERLAPPED | WSA_FLAG_NO_HANDLE_INHERIT;
    let socket = syscall!(
        WSASocketW(domain as i32, socket_type, 0, std::ptr::null(), 0, flags),
        PartialEq::eq,
        INVALID_SOCKET
    )?;

之前是 socket(domain, socket_type, 0)。这相当于 Unix 上的 SOCK_CLOEXEC。TcpListener::bind、TcpStream::connect、UdpSocket::bind 都经过 new_socket;accept 得到的流来自 std 的 TcpListener::accept,不经过这里。

退出时:排空完成端口

SelectorInner 被 drop 时,内核手里可能还有 SockState 的引用(挂着的 AFD poll)和 Inner 的引用(挂着的 named pipe 操作)。Drop for SelectorInner 用 0 超时反复 get_many,每次最多 1024 个,直到一个包都取不到:AFD 的包 from_overlapped 后立即丢掉,named pipe 的包调回调并传 events = None(只释放资源,不产生事件),waker 的包忽略。

实验:在 macOS 上为 Windows 做 cargo check

没有 Windows 机器,能验证的只有「这些代码确实会编进 Windows 目标」。src/bin/wincheck.rs 用到 TcpListener、TcpStream、Waker,#[cfg(windows)] 里再注册一个 NamedPipe:

use std::io::{self, Read};
use std::thread;

use mio::net::{TcpListener, TcpStream};
use mio::{Events, Interest, Poll, Token, Waker};

const LISTENER: Token = Token(0);
const CLIENT: Token = Token(1);
const WAKER: Token = Token(2);

fn main() -> io::Result<()> {
  let mut poll = Poll::new()?;
  let mut events = Events::with_capacity(16);

  let mut listener = TcpListener::bind("127.0.0.1:0".parse().unwrap())?;
  poll.registry().register(&mut listener, LISTENER, Interest::READABLE)?;
  let mut client = TcpStream::connect(listener.local_addr()?)?;
  poll
    .registry()
    .register(&mut client, CLIENT, Interest::READABLE | Interest::WRITABLE)?;

  let waker = Waker::new(poll.registry(), WAKER)?;
  let handle = thread::spawn(move || waker.wake());

  #[cfg(windows)]
  {
    // Windows-only type from os-ext: the one mio type that buffers internally.
    let mut pipe = mio::windows::NamedPipe::new(r"\\.\pipe\mio-windows-check")?;
    poll.registry().register(&mut pipe, Token(3), Interest::READABLE)?;
  }

  poll.poll(&mut events, None)?;
  for event in events.iter() {
    println!("{:?} r={} w={}", event.token(), event.is_readable(), event.is_writable());
  }
  let mut buf = [0u8; 16];
  match client.read(&mut buf) {
    Err(e) if e.kind() == io::ErrorKind::WouldBlock => println!("read: WouldBlock"),
    other => println!("read: {:?}", other.map(|n| n)),
  }
  handle.join().unwrap()?;
  Ok(())
}

M2 Max、macOS、rustc 1.99.0,cargo check --release --target x86_64-pc-windows-msvc --bin wincheck(mio 1.2.4,features os-poll、net、os-ext,依赖里 windows-sys 0.61.2):

    Checking exp-mio-windows v0.1.0 (exp-mio-windows)
    Finished `release` profile [optimized] target(s) in 0.07s

check 通过,说明 sys/windows 这一套(包括 named_pipe.rs)在这个目标上能类型检查。换成 cargo build 就停在链接:

   Compiling exp-mio-windows v0.1.0 (exp-mio-windows)
error: linker `link.exe` not found
  |
  = note: No such file or directory (os error 2)

note: the msvc targets depend on the msvc linker but `link.exe` was not found

note: please ensure that Visual Studio 2017 or later, or Build Tools for Visual Studio were installed with the Visual C++ option

note: VS Code is a different product, and is not sufficient

error: could not compile `exp-mio-windows` (bin "wincheck") due to 1 previous error

改用 -C linker=rust-lld 也只是换一个报错:rust-lld: error: could not open 'kernel32.lib': No such file or directory(还有 ntdll.lib、ws2_32.lib 等),没有 Windows SDK 的导入库。所以本页没有任何运行结果。

❗IMPORTANT
动脑筋:Windows 上有 SourceFd 吗?

src/bin/sourcefd.rs 只有几行,在 macOS 上 cargo check 能通过:

use mio::unix::SourceFd;

fn main() {
  let fd = 0;
  let _source = SourceFd(&fd);
}

加上 --target x86_64-pc-windows-msvc 再 cargo check,结果是什么?如果编译不过,报错指向 mio 源码的哪一行?

动脑筋答案

编译不过(M2 Max、macOS、rustc 1.99.0,cargo check --release --target x86_64-pc-windows-msvc --bin sourcefd):

    Checking exp-mio-windows v0.1.0 (exp-mio-windows)
error[E0432]: unresolved import `mio::unix`
  --> src/bin/sourcefd.rs:1:10
   |
 1 | use mio::unix::SourceFd;
   |          ^^^^ could not find `unix` in `mio`
   |
note: found an item that was configured out
  --> ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/mio-1.2.4/src/lib.rs:77:9
   |
75 | #[cfg(all(unix, feature = "os-ext"))]
   |           ---- the item is gated here
76 | #[cfg_attr(docsrs, doc(cfg(all(unix, feature = "os-ext"))))]
77 | pub mod unix {
   |         ^^^^

For more information about this error, try `rustc --explain E0432`.
error: could not compile `exp-mio-windows` (bin "sourcefd") due to 1 previous error
  • E0432:mio::unix 整个模块不存在。rustc 指出它被 lib.rs 第 75 行的 #[cfg(all(unix, feature = "os-ext"))] 去掉了。
  • Windows 上对应的模块是 mio::windows(#[cfg(all(windows, feature = "os-ext"))]),里面只有 NamedPipe,没有「包一个任意 HANDLE」的类型。原因就在本页:Windows selector 只有两条路,socket 走 AFD(IoSource 要求 T: AsRawSocket),named pipe 走它自己的重叠 I/O 和奇数 completion key。别的句柄类型没有能把完成变成就绪的操作,mio 也就不提供注册入口。

小结

  • Windows selector 是 Arc<SelectorInner>:一个完成端口、一个 update_queue、一组 \Device\Afd\Mio 句柄(每个最多给 32 个 socket 用)、一个 is_polling 标志。关联到完成端口的是 AFD 句柄,不是 socket。
  • 等就绪的办法是对 AFD 句柄发 IOCTL_AFD_POLL(0x00012024),overlapped 位置放 Arc<Mutex<SockState>> 的裸指针,内核持有这份引用直到完成包回来。
  • register 只入队;AFD poll 在 select2 开头、GetQueuedCompletionStatusEx 之前批量提交,只有别的线程正在 poll 时才立即提交(#1140)。一个 socket 同时只挂一个 AFD poll,关注变多只能先取消。
  • SockState 在 Idle、Pending、Cancelled 之间流转,delete_pending 标记删除。feed_event 先 afd_events &= user_evts 过滤,再 user_evts &= !afd_events 摘掉报过的位(0.7.4 的 #1370 改成只摘报过的位)。
  • do_io 碰到 WouldBlock 就 reregister,恢复完整关注集合;所以 Windows 上所有 I/O 都必须经过 mio,peek 到 1.1.0(#1895)才补上。
  • 完成包三路分流:lpOverlapped 为空是 Waker(PostQueuedCompletionStatus,每次 wake 一个包),key 为奇数是 named pipe,其余是 AFD。
  • NamedPipe 用真正的重叠读写和 4 KiB 内部缓冲,是 1.2.4 里唯一做缓冲的类型。它的约定是一个完成包对应一份预留的 Arc<Inner>;1.2.1 的 #1954(connect 先克隆再提交)和 1.2.4 的 #2014(提交后不再返回 Err)修的都是这条约定。1.2.1 的 #1946 让 socket 用 WSA_FLAG_NO_HANDLE_INHERIT 创建。
  • 本页只读源码;在 macOS 上 cargo check --target x86_64-pc-windows-msvc 通过,链接需要 MSVC 工具链,没有运行结果。

💬 关联讨论

发起讨论
加载中...