在Rust中使用io_uring

写在前面

自从买了相机之后,拍的照片比之前多了好多,那几个朋友和我一样都是喜欢摄影且程序员,各自都有自己的网站,心里直痒痒,于是做了一个相册网站,使用React-Album作为前端,后端则是用Go做了个简单的HTTP请求服务器,实现摄影图集陈列。

后来觉得这个网站核心在于静态文件下载,而每一张图动辄几十M,遂决定直接做一个静态服务器做这个功能。

后来用Rust写了一个zero-copying的静态服务器,用sendfile+mmap,但,因为文件IO还是blocking的,所以开辟线程池,学Go的runtime实现,测了一下性能貌似还行,瓶颈也在于mmap使用的page cache释放不及时问题,于是想到了io_uring技术,这玩意解决了文件IO的blocking,而且还支持splice操作(存疑,还没有真的开始码),所以决定研究一下。

理论

架构

首先,io_uring由两个环形数组+两个控制结构体组成。分别是提交数组(Submission)和完成数组(Completion),两个控制结构体分别控制两个数组的读写指针索引,地址等。

为什么用环形数组,因为节省内存,方便共享,类似的技术在epoll也存在;两个控制结构体存在内核空间,并且通过mmap映射到用户空间,这样避免了拷贝,也实现了更新可见,当然这会引来并发访问问题。

这里需要注意的是,SQ/CQ Ring分别保存两个Ring的头尾指针,在SQ中,App更新尾指针来生产,Kernel更新头指针来消费。CQ相反,向/从 SQ/CQ 添加/移除 SQ项/CQ项 是通过先放置再更新指针来实现的,不过这里会使用内存屏障技术去保证更新可见。

上述图是一个综述,包含了四种模式下的整体逻辑,我们会拆开一个一个讲。

一个简单的流程:

  • 创建SQE:包含操作码指明要做什么操作,比如读,写文件,Socket连接等;还包含涉及到的文件FD等,缓冲区这些。
  • 提交SQE:把SQE添加到SQ中,并且更新尾指针。
  • 调用enter()进入Kernel侧,尝试从SQ消费一个SQE,更新头指针,然后根据具体的任务类型提交到线程池(io-wq)/轮询等去做实际的执行。
  • 挂起,等待中断到达。
  • 构造CQE,提交到CQ,更新CQ尾指针,判断是否数量满足预期。
  • 返回。
  • App侧消费CQE,更新头指针。

这里注意到,任务可能是由io-wq线程池去完成的,这是一个内核创建的轻量级线程池,用来处理任务,类似我们创建线程池处理文件阻塞调用,不过它做的不只是这些。

部分原理

在正式开始之前,先拆开讲讲各个部分的原理。

上述已经展开了涉及到的几个主要结构,CQ-Ring,SQ-Ring,CQ,SQ,CQE,SQE。

CQE最简单,先说它:

它的组成简单如下:

c 复制代码
struct cqe {
  __u64 user_data;
  __s32 res;
  __u32 flags;
};

res代表操作结果,而user_data则是用来溯源对应的SQE的关键字段。它是对应的SQE中的user_data的拷贝。比如可以在SQE设置一个指针,然后从CQE的user_data读取指针,读到指针指向的buffer,然后操作的结果被预先读入了buffer,之后就可以得到操作结果了。

接下来是SQE:

c 复制代码
struct sqe {
  __u8 opcode;
  __u9 flags;
  __u16 io_priority;
  __s32 fd;
  __u64 offset;
  __u64 addr;
  __u32 len;
  union {
    __kernel_rwf_t rw_flags;
    __u32 fsync_flags;
    __u16 poll_events;
    __u32 sync_rang_flags;
    __u32 msg_flags;
  };
  __u64 user_data;
  union {
    __u16 buf_index;
    __u64 __padding2[3];
  };
}

opcode很好理解,指出了此次任务的类型,比如读写文件,还是某些系统调用,还是网络相关的操作。

后面的padding实现了64位对齐,或者保留用于后续添加字段。

其他部分看字段名基本可以理解,比如offset指出此次操作针对addr上数据的偏移,以及需要len字节的数据。

当使用io_uring相关接口时,需要自己处理很多东西,比如setup()调用,返回一个fd代表背后的实例,但是也会返回一堆偏移量,指出SQ-Ring和CQ-Ring针对实例结构体的偏移,因为我们需要更新这两个Ring结构体的指针,所以需要自己计算去构造,同时也需要自己去做mmap,得到访问权限。

目前Linux针对io_uring提供的接口就两个,一个构造,一个万能接口:io_uring_setup()和io_uring_enter()。

所以我们会详细说说enter()调用,因为提交任务,推动任务执行,获取完成项,轮询操作等都是它完成的。

在详细开始之前,需要说一下io_uring在构造时可以选定不同的模式:

  • 默认:App侧主动提交SQE,陷入内核,消费SQE,推动任务执行,比如提交到io-wq,挂起,等待中断,构造CQE,提交CQE,等待数量达到,返回,消费CQE。
  • IO_POLL:App侧主动提交SQE,陷入内核,消费SQE,提交任务,轮询任务执行状态直到完成状态出现,构造CQE,提交CQE,等到数量达到,返回,消费CQE。
  • SQ_POLL:App侧主动提交SQE,唤醒内核POLL线程(如果需要),内核消费SQE,推动任务执行,提交给io-wq,挂起App侧;等待中断,中断到达,构造CQE,提交CQE,数量达到唤醒App侧,App侧全程不需要陷入内核(如果不需要唤醒)。
  • IO_POLL + SQ_POLL:App侧主动提交SQE,唤醒内核POLL线程(如果需要),内核消费SQE,提交任务,App侧轮询任务执行状态直到完成状态出现,构造CQE,提交CQE,数量达到,返回。

这里的IO_POLL和SQ_POLL很有误导性,它们是完全不同的两个方面,一个管控任务执行方式,一个管控SQ消费方式。

来看一个执行图:

在这里可以看到不同参数对于执行流的影响。在使用enter()调用时,如果需要等待指定数量的事件完成,则会触发阻塞,这里的阻塞可能是轮询产生的忙等待,也可能是等待中断唤醒的挂起。

如果设置了SQ_POLL,则SQE推动,提交给驱动去处理,或者加入内核的任务队列,则是由Kernel侧的线程去完成,否则则是App侧调用enter()的线程自己去完成。

而如果设置了IO_POLL,则需要App侧在任务推动之后(无论是谁推动的),主动去poll驱动的ready状态;否则挂起,此时任务由io-wq执行,并在中断到达时提交到CQ,并且在数量满足时唤醒调用enter()的线程。

这里的io-wq并不是中断配置下的默认选择,相反可能直接立即执行,比如文件已经存在page cache中,此时直接在当前线程处理即可;这个细节比较复杂,涉及到具体的逻辑,后面会展开细说。

另外,在构造时,默认CQ的大小是SQ的二倍,因为有时App侧拉取不及时会导致CQE堆积,所以App侧需要留意这件事。因为一般来说,App侧把SQE提交到队列就算完事了,之后就可以复用SQE,而Kernel或者IO_POLL执行SQE是需要时间的,所以可能导致App侧提交了两圈的SQE但是CQE未来得及收割。

小结

io_uring的调用虽然只有两个,但是隐藏了复杂的分支流程,作为用户只要简单的使用即可,不过最好还是使用封装好的库,比如liburing,替我们做了很多不必要的封装。

使用

在正式开始讨论用法之前,你必须保证有一个Linux环境,且内核版本(建议5.13以上)符合要求。如果你是Windows,考虑WSL2,如果你是Linux原生勇者,那可以直接进行下一步。

如果你是macOS用户,也不是很难办,要么使用Docker或者OrbStack(推荐)搭建一个虚拟主机;要么使用Multipass搭建一个云服务器,我选择了后者,一方面因为性能更好,另一方面嘛,简单。

Multipass是Ubuntu官方提供的云服务器环境搭建工具,你可以用它在macOS上快速搭建类似阿里云或者腾讯云这种的云服务器。如果你是Arm架构的macOS,搭建出来的也是Arm的Linux,我用起来是没什么问题,Linux本身对于Arm支持比WindowsOnArm强太多了;如果你是老的Intel,那自然是X64架构的。

环境搭建完成,开始准备。

如果你愿意大费周章的去自己做mmap,计算偏移量,设置构造参数,那可以直接使用io_uring_setup()和io_uring_enter()这两个系统调用。不过呢,对于C来说,有liburing可以直接使用,它封装了很多的细节。

如果是Go语言,目前也有一些第三方的包可以调用,对于Java语言直接考虑使用Netty等网络库。

最后我们来一些Rust中的使用,毕竟了解这个库一开始就是为了在Rust中使用。

rust 复制代码
use std::{
    collections::VecDeque,
    io,
    net::TcpListener,
    os::fd::{AsRawFd, RawFd},
    ptr,
};

use io_uring::{cqueue, opcode, squeue, types, IoUring};
use slab::Slab;

#[derive(Debug, Clone)]
enum Token {
    Accept,
    Poll {
        fd: RawFd,
        read: bool,
        buf_idx: usize,
        offset: usize,
        // size of bytes need to be sent
        // or the size of the buffer can be filled.
        len: usize,
    },
    Read {
        fd: RawFd,
        buf_idx: usize,
    },
    Write {
        fd: RawFd,
        buf_idx: usize,
        // offset + len should equal to the length of the buffer
        offset: usize,
        // size of bytes need to be sent
        len: usize,
    },
}

fn main() -> io::Result<()> {
    let mut backlog = VecDeque::new();
    let mut token_vec = Slab::with_capacity(1024);
    let mut buffer_pool = Vec::with_capacity(1024);
    let mut buffer_alloc = Slab::with_capacity(1024);
    let listener = TcpListener::bind(("0.0.0.0", 8190))?;

    let mut ring: IoUring<squeue::Entry, cqueue::Entry> = IoUring::builder()
        // .setup_iopoll()
        .setup_sqpoll(500)
        .build(1024)?;
    let (submitter, mut sq, mut cq) = ring.split();

    let accept_idx = token_vec.insert(Token::Accept);
    let accept = opcode::Accept::new(
        types::Fd(listener.as_raw_fd()),
        ptr::null_mut(),
        ptr::null_mut(),
    )
    .build()
    .user_data(accept_idx as _);
    unsafe {
        _ = sq.push(&accept);
    }
    sq.sync();

    loop {
        match submitter.submit_and_wait(1) {
            Ok(_) => {}
            Err(e) => {
                println!("submit_and_wait error: {:?}", e);
                break;
            }
        }
        cq.sync();

        loop {
            if sq.is_full() {
                _ = submitter.submit();
            }
            sq.sync();

            match backlog.pop_front() {
                Some(sqe) => unsafe {
                    _ = sq.push(&sqe);
                },
                None => break,
            }
        }
        unsafe {
            _ = sq.push(&accept);
        }
        for cqe in &mut cq {
            let res = cqe.result();
            let token_idx = cqe.user_data() as usize;
            if res < 0 {
                eprintln!("cqe error: {:?}", io::Error::from_raw_os_error(-res));
                continue;
            }

            let token = &mut token_vec[token_idx];
            match *token {
                Token::Accept => {
                    println!("new connection");
                    let (buf_idx, _buf) = match buffer_pool.pop() {
                        Some(buf_index) => (buf_index, &mut buffer_alloc[buf_index]),
                        None => {
                            let buf = vec![0u8; 2048].into_boxed_slice();
                            let buf_entry = buffer_alloc.vacant_entry();
                            let buf_index = buf_entry.key();
                            (buf_index, buf_entry.insert(buf))
                        }
                    };
                    let token = token_vec.insert(Token::Poll {
                        fd: res as _,
                        read: true,
                        buf_idx,
                        offset: 0,
                        len: 2048,
                    });
                    let poll = opcode::PollAdd::new(types::Fd(res as _), libc::POLLIN as _)
                        .build()
                        .user_data(token as _);
                    unsafe {
                        if sq.push(&poll).is_err() {
                            backlog.push_back(poll);
                        }
                    }
                }
                Token::Poll {
                    fd,
                    read,
                    buf_idx,
                    offset,
                    len,
                } => {
                    if read {
                        *token = Token::Read { fd, buf_idx };
                        let buf = &mut buffer_alloc[buf_idx][offset..];
                        let read =
                            opcode::Recv::new(types::Fd(fd), buf.as_mut_ptr(), len as _)
                                .build()
                                .user_data(token_idx as _);
                        unsafe {
                            if sq.push(&read).is_err() {
                                backlog.push_back(read);
                            }
                        }
                    } else {
                        *token = Token::Write {
                            fd,
                            buf_idx,
                            offset,
                            len,
                        };
                        let buf = &buffer_alloc[buf_idx][offset..];
                        let write = opcode::Send::new(types::Fd(fd), buf.as_ptr(), len as _)
                            .build()
                            .user_data(token_idx as _);
                        unsafe {
                            if sq.push(&write).is_err() {
                                backlog.push_back(write);
                            }
                        }
                    }
                }
                Token::Read { fd, buf_idx } => {
                    if res == 0 {
                        println!("connection closed");
                        buffer_pool.push(buf_idx);
                        token_vec.remove(token_idx);
                        unsafe { libc::close(fd) };
                        continue;
                    }
                    let len = res as usize;
                    let buf = &buffer_alloc[buf_idx][..len];
                    println!("server read: {}", String::from_utf8_lossy(buf).to_string());
                    *token = Token::Poll {
                        fd,
                        read: false,
                        buf_idx,
                        offset: 0,
                        len,
                    };
                    let poll = opcode::PollAdd::new(types::Fd(fd), libc::POLLOUT as _)
                        .build()
                        .user_data(token_idx as _);
                    unsafe {
                        if sq.push(&poll).is_err() {
                            backlog.push_back(poll);
                        }
                    }
                }
                Token::Write {
                    fd,
                    buf_idx,
                    offset,
                    len,
                } => {
                    if res == 0 {
                        println!("connection closed");
                        buffer_pool.push(buf_idx);
                        token_vec.remove(token_idx);
                        unsafe { libc::close(fd) };
                        continue;
                    }
                    let sent = res as usize;
                    if sent < len {
                        *token = Token::Poll {
                            fd,
                            read: false,
                            buf_idx,
                            offset: offset + sent,
                            len: len - sent,
                        };
                        let poll = opcode::PollAdd::new(types::Fd(fd), libc::POLLOUT as _)
                            .build()
                            .user_data(token_idx as _);
                        unsafe {
                            if sq.push(&poll).is_err() {
                                backlog.push_back(poll);
                            }
                        }
                    } else {
                        *token = Token::Poll {
                            fd,
                            read: true,
                            buf_idx,
                            offset: 0,
                            len: 2048,
                        };
                        let poll = opcode::PollAdd::new(types::Fd(fd), libc::POLLIN as _)
                            .build()
                            .user_data(token_idx as _);
                        unsafe {
                            if sq.push(&poll).is_err() {
                                backlog.push_back(poll);
                            }
                        }
                    }
                }
            }
        }
    }

    Ok(())
}

用到的依赖:

toml 复制代码
[dependencies]
io-uring = "0.6.2"
slab = "0.4.9"
libc = "0.2.149"

这里给出了一个"简单的"Tcp Echo服务器。如你所见,写了很多,而仅仅做到了Echo功能。

这里我们选用Tokio封装的io-uring,查看源码就知道Tokio做的封装很纯(简)粹(陋),Rust中还有很多针对io-uring的封装,可以根据自己的爱好选用。

另外一提,Tokio有一个异步化的io-uring库,如果你喜欢async可以考虑,或者自己封装裸操作,比如定义几个结构体实现Future之类的。

上述代码有一个有趣的backlog,它用于处理环满的时候,把额外的SQE保存下来,后面循环时再提交。之后就是简单易懂的循环,从Accept->新连接到达->PollAdd_Read->可读->Recv->PollAdd_Write->可写->Send->循环往复。

Tokio的思路是使用一个枚举来保存user_data字段,里面包含此次操作完成之后需要进行的处理,这是一种状态机思想。

参考

io_uring_enter(2) --- Linux manual page

Efficient IO with io_uring

io_uring.c

io_uring 的接口与实现

图解原理|Linux I/O 神器之 io_uring

相关推荐
矛取矛求3 小时前
Linux如何更优质调节系统性能
linux
内核程序员kevin4 小时前
在Linux环境下使用Docker打包和发布.NET程序并配合MySQL部署
linux·mysql·docker·.net
kayotin4 小时前
Wordpress博客配置2024
linux·mysql·docker
Ztiddler5 小时前
【Linux Shell命令-不定期更新】
linux·运维·服务器·ssh
小小不董5 小时前
Oracle OCP认证考试考点详解082系列16
linux·运维·服务器·数据库·oracle·dba
a1denzzz5 小时前
Linux系统的网络设置
linux·服务器·网络
ac.char6 小时前
在CentOS下安装RabbitMQ
linux·centos·rabbitmq
m0_519523106 小时前
Linux——简单认识vim、gcc以及make/Makefile
linux·运维·vim
VertexGeek6 小时前
Rust学习(一):初识Rust和Rust环境配置
开发语言·学习·rust
mit6.8247 小时前
[Docker#4] 镜像仓库 | 部分常用命令
linux·运维·docker·容器·架构