异步aio/io_uring

Direct IO

顺序读写 & 随机读写

定义不难理解,其实就是要有序,不要随机切换位置,比如:

thread1:write position[0~4096)

thread2:write position[4096~8194)

而非

thread1:write position[0~4096)

thread3:write position[8194~12288)

thread2:write position[4096~8194)

当用户发起一个 fileChannel.read(4kb) 时:

操作系统从磁盘加载了磁盘块(block) 16kb进入 PageCache,这被称为预读(为了减少实际磁盘IO)

应用程序(application buffer)操作(read)其实是从 PageCache 拷贝 4kb 进入用户内存

关于扇区、磁盘块、Page(PageCache)在N年前谈过要有一定的了解!

常见的block大小为512Bytes,1KB,4KB,查看本机 blockSize 大小的方式,通常为 4kb

为何加载block为16kb,该值也很讲究blockSize*4,难道也发生了4次IO?非也,这又涉及到IO合并(readahead算法)!另外该值并非固定值值,内核的预读算法则会以它认为更合适的大小进行预读 I/O,比如16-128KB,当然可以手动进行调整。对这块敢兴趣的同学可以看文末的引用资料

Direct IO

去掉PageCache的确可以实现高效的随机读,的确也有存在的价值!采用 Direct IO + 自定义内存管理机制会使得产品更加的可控,高性能。

如何使用呢?

使用 Direct IO 最终需要调用到 c 语言的 pwrite 接口,并设置 O_DIRECT flag,使用 O_DIRECT 存在不少限制:

操作系统限制:Linux 操作系统在 2.4.10 及以后的版本中支持 O_DIRECT flag,老版本会忽略该 Flag;Mac OS 也有类似于 O_DIRECT 的机制

用于传递数据的缓冲区,其内存边界必须对齐为 blockSize 的整数倍

用于传递数据的缓冲区,其传递数据的大小必须是 blockSize 的整数倍

数据传输的开始点,即文件和设备的偏移量,必须是 blockSize 的整数倍

blockdev --setra预读优化和IO请求的大小_javastart的博客-CSDN博客  //预读

linux AIO

IO 模型

下图给出了同步和异步,以及阻塞和非阻塞的模型。简单来说,一个系统调用 (read, select) 立即返回表示非阻塞;在一个时间点只能去处理一个请求表示同步。

 

同步阻塞IO

最常用的模型,当用户空间的应用执行一个系统调用后,会导致应用程序阻塞,直到系统调用完成为止(数据传输完成或发生错误)。应用程序只能处于一种不再消费 CPU 而只是简单等待响应的状态,因此从处理的角度来看,这是非常有效的。

在调用 read 系统调用时,应用程序会阻塞并对内核进行上下文切换;然后会触发读磁盘操作,当从读取的设备中返回后,数据就被移动到用户空间的缓冲区中;然后应用程序就会解除阻塞(read 调用返回)。

同步非阻塞IO

以非阻塞的形式打开,这意味着 I/O 操作不会立即完成,read 操作可能会返回一个错误代码,说明这个命令不能立即满足(EAGAIN 或 EWOULDBLOCK)。

该模型中可能需要应用程序调用多次来等待操作完成,这样的效率仍然不高,因为很多情况下,当内核执行这个命令时,应用程序必须要进行忙碌等待,直到数据可用为止,或者试图执行其他工作。

正如上图所示,这个方法会引入 I/O 操作的延时,因为数据在内核中变为可用到用户调用 read 返回数据之间存在一定的间隔,这会导致整体数据吞吐量的降低。

异步阻塞IO

select 调用的主要问题是它的效率不是非常高,尽管这是异步通知使用的一种方便模型,但是对于高性能的 I/O 操作来说不建议使用。

异步非阻塞IO(AIO)

一种处理与 I/O 重叠进行的模型,读请求会立即返回,说明 read 请求已经成功发起了;然后应用程序会执行其他处理操作;当 read 的响应到达时,就会产生一个信号或执行一个基于线程的回调函数来完成这次 I/O 处理过程。

该模型可以重叠执行多个 I/O 请求以及 CPU 操作,利用了处理速度与 I/O 速度之间的差异。当一个或多个 I/O 请求挂起时,此时 CPU 可以执行其他任务;或者更为常见的是,在发起其他 I/O 的同时对已经完成的 I/O 进行操作。

Linux 中有两套异步 IO,一套是由 glibc 实现的 aio_* 系列,通过线程+阻塞调用在用户空间模拟 AIO 的功能,不需要内核的支持,类似的还有 libeio;另一套是采用原生的 Linux AIO,并由 libaio 来封装调用接口,相比来说更底层。

glibc 的实现性能比较差,在此先介绍 libaio 的使用。libaio 的使用并不复杂,过程为:A) libaio 的初始化; B) IO 请求的下发和回收,C) libaio 销毁。提供了下面五个主要 API 函数以及宏定义:

int io_setup(int maxevents, io_context_t *ctxp);

int io_destroy(io_context_t ctx);

int io_submit(io_context_t ctx, long nr, struct iocb *ios[]);

int io_cancel(io_context_t ctx, struct iocb *iocb, struct io_event *evt);

int io_getevents(io_context_t ctx_id, long min_nr, long nr, struct io_event *events, struct timespec *timeout);

void io_set_callback(struct iocb *iocb, io_callback_t cb);

void io_prep_pwrite(struct iocb *iocb, int fd, void *buf, size_t count, long long offset);

void io_prep_pread(struct iocb *iocb, int fd, void *buf, size_t count, long long offset);

void io_prep_pwritev(struct iocb *iocb, int fd, const struct iovec *iov, int iovcnt, long long offset);

void io_prep_preadv(struct iocb *iocb, int fd, const struct iovec *iov, int iovcnt, long long offset);

struct iocb {

    PADDEDptr(void *data, __pad1);  /* Return in the io completion event */

    PADDED(unsigned key, __pad2);   /* For use in identifying io requests */

    short       aio_lio_opcode;

    short       aio_reqprio;

    int         aio_fildes;

    union {

        struct io_iocb_common    c;

        struct io_iocb_vector    v;

        struct io_iocb_poll      poll;

        struct io_iocb_sockaddr  saddr;

    } u;

};

后五个宏定义是为了操作 struct iocb 结构体,该结构体是 libaio 中很重要的一个结构体,用于表示 IO,但是其结构略显复杂,为了保持封装性不建议直接操作其元素而用上面五个宏定义操作。

初始化

用来初始化类型为 io_context_t 的变量,这个变量为 libaio 的工作空间,可以采用 io_setup() 或者 io_queue_init(),两者功能实际相同。

自定义字段

这一阶段是可选的,在 struct iocb 中保留了供用户自定义的元素,也就是 void *data,可以通过 io_set_callback() 设置回调函数,或者通过 iocbp->data=XXX 自定义。

需要注意的是,两者均使用 data 变量,因此不能同时使用。

读写请求下发

读写均通过 io_submit() 下发,之前需要通过 io_prep_pwrite() 和 io_prep_pread() 生成 struct iocb 做为该函数的参数。在这个结构体中指定了读写类型、起始扇区、长度和设备标志符等信息。

读写请求回收

使用 io_getevents() 等待 IO 的结束信号,该函数返回 events[] 数组,nr 为数组的最大长度,min_nr 为最少返回的 events 数,timeout 可填 NULL 表示一直等待。

struct io_event {

    PADDEDptr(void *data, __pad1);

    PADDEDptr(struct iocb *obj,  __pad2);

    PADDEDul(res,  __pad3);

    PADDEDul(res2, __pad4);

};

其中,res 为实际完成的字节数;res2 为读写成功状态,0 表示成功;obj 为之前下发的 struct iocb 结构体。

销毁

直接通过 io_destory() 销毁即可。

下面是一个简单的示例,通过 AIO 写入到 foobar.txt 文件中。

#include

#include

#include

#include

int main(void)

{

    int               output_fd;

    struct iocb       io, *p=&io;

    struct io_event   e;

    struct timespec   timeout;

    io_context_t      ctx;

    const char        *content="hello world!";

    // 1. init the io context.

    memset(&ctx, 0, sizeof(ctx));

    if(io_setup(10, &ctx)){

        printf("io_setup error\n");

        return -1;

    }

    // 2. try to open a file.

    if((output_fd=open("foobar.txt", O_CREAT|O_WRONLY, 0644)) < 0) {

        perror("open error");

        io_destroy(ctx);

        return -1;

    }

    // 3. prepare the data.

    io_prep_pwrite(&io, output_fd, (void*)content, strlen(content), 0);

    //io.data = content;   // set or not

    if(io_submit(ctx, 1, &p) < 0){

        io_destroy(ctx);

        printf("io_submit error\n");

        return -1;

    }

    // 4. wait IO finish.

    while(1) {

        timeout.tv_sec  = 0;

        timeout.tv_nsec = 500000000; // 0.5s

        if(io_getevents(ctx, 0, 1, &e, &timeout) == 1) {

            close(output_fd);

            break;

        }

        printf("haven't done\n");

        sleep(1);

    }

    io_destroy(ctx);

    return 0;

}

然后,可以通过 gcc foobar.c -o foobar -laio -Wall 进行编译。

(1) 打开对应的文件, 需要注意的是, 如果需要使用aio, 一定要通过O_DIRECT的方式打开, 否则aio 是起不到异步的效果的。

(2) (3) 准备异步IO相关的数据

(4) 调用io_submit 提交io, 这一步是异步的, 会直接放回, 不会等待IO结束。

(5) 等待IO结束

内核实现

比较关键的一个地方是, aio的“异步”是怎么实现的?

简而言之, 其实Linux IO的“异步”其实比“同步”更好实现, 因为它底层调用的那些提交IO的接口本身就是异步的, 比如vfs层通过submit_bio提交一个io请求, 这个过程本身就是异步的, 只不是传统的同步写入中, 会等待IO完成才返回, 而aio中, 调用之后就直接返回了, 通过回调来通知请求完成。 这就是AIO的总体逻辑。

调用流程

下面我们分析一下这个过程的关键函数, 即io_submit 的实现, 这个函数会向底层提交IO请求, 并设置请求完成的回调, 然后异步返回。它的入口定义在:

fs/aio.c
COMPAT_SYSCALL_DEFINE3(io_submit, compat_aio_context_t, ctx_id,
               int, nr, compat_uptr_t __user *, iocbpp)
{
    struct kioctx *ctx;
    long ret = 0;
    int i = 0;
    struct blk_plug plug;
 
  
    if (unlikely(nr < 0))
        return -EINVAL;
 
  
    ctx = lookup_ioctx(ctx_id);
    if (unlikely(!ctx)) {
        pr_debug("EINVAL: invalid context id\n");
        return -EINVAL;
    }
 
  
    if (nr > ctx->nr_events)
        nr = ctx->nr_events;
 
  
    if (nr > AIO_PLUG_THRESHOLD)
        blk_start_plug(&plug);
    for (i = 0; i < nr; i++) {
        compat_uptr_t user_iocb;
 
  
        if (unlikely(get_user(user_iocb, iocbpp + i))) {
            ret = -EFAULT;
            break;
        }
 
  
        ret = io_submit_one(ctx, compat_ptr(user_iocb), true);
        if (ret)
            break;
    }
    if (nr > AIO_PLUG_THRESHOLD)
        blk_finish_plug(&plug);
 
  
    percpu_ref_put(&ctx->users);
    return i ? i : ret;
}

主要是调用了io_submit_one 来执行具体请求:

fs/aio.c
io_submit->io_submit_one
static int io_submit_one(struct kioctx *ctx, struct iocb __user *user_iocb,
             bool compat)
{
    struct aio_kiocb *req;
    struct iocb iocb;
    int err;
 
  
  ....
    req = aio_get_req(ctx);
    if (unlikely(!req))
        return -EAGAIN;
 
  
    err = __io_submit_one(ctx, &iocb, user_iocb, req, compat);
 
  
    /* Done with the synchronous reference */
    iocb_put(req);
 
  
    .....
    return err;
}
 
  
fs/aio.c
io_submit->io_submit_one->__io_submit_one
static int __io_submit_one(struct kioctx *ctx, const struct iocb *iocb,
               struct iocb __user *user_iocb, struct aio_kiocb *req,
               bool compat)
{
 
  
    req->ki_res.obj = (u64)(unsigned long)user_iocb;
    req->ki_res.data = iocb->aio_data;
    req->ki_res.res = 0;
    req->ki_res.res2 = 0;
 
  
    switch (iocb->aio_lio_opcode) {
    case IOCB_CMD_PREAD:
        return aio_read(&req->rw, iocb, false, compat);
    case IOCB_CMD_PWRITE:
        return aio_write(&req->rw, iocb, false, compat);                   /*             1             */
    case IOCB_CMD_PREADV:
        return aio_read(&req->rw, iocb, true, compat);
    case IOCB_CMD_PWRITEV:
        return aio_write(&req->rw, iocb, true, compat);
    case IOCB_CMD_FSYNC:
        return aio_fsync(&req->fsync, iocb, false);
    case IOCB_CMD_FDSYNC:
        return aio_fsync(&req->fsync, iocb, true);
    case IOCB_CMD_POLL:
        return aio_poll(req, iocb);
    default:
        pr_debug("invalid aio operation %d\n", iocb->aio_lio_opcode);
        return -EINVAL;
    }
}

以aio write 为例, 最终会调用到aio_write 这个函数:

static int aio_write(struct kiocb *req, const struct iocb *iocb,
             bool vectored, bool compat)
{
    struct iovec inline_vecs[UIO_FASTIOV], *iovec = inline_vecs;
    struct iov_iter iter;
    struct file *file;
    int ret;
 
  
    ret = aio_prep_rw(req, iocb);                                           /*        1          */
    if (ret)
        return ret;
    file = req->ki_filp;
 
  
    if (unlikely(!(file->f_mode & FMODE_WRITE)))
        return -EBADF;
    if (unlikely(!file->f_op->write_iter))
        return -EINVAL;
 
  
    ret = aio_setup_rw(WRITE, iocb, &iovec, vectored, compat, &iter);
    if (ret < 0)
        return ret;
    ret = rw_verify_area(WRITE, file, &req->ki_pos, iov_iter_count(&iter));
    if (!ret) {
        /*
         * Open-code file_start_write here to grab freeze protection,
         * which will be released by another thread in
         * aio_complete_rw().  Fool lockdep by telling it the lock got
         * released so that it doesn't complain about the held lock when
         * we return to userspace.
         */
        if (S_ISREG(file_inode(file)->i_mode)) {
            sb_start_write(file_inode(file)->i_sb);
            __sb_writers_release(file_inode(file)->i_sb, SB_FREEZE_WRITE);
        }
        req->ki_flags |= IOCB_WRITE;
        aio_rw_done(req, call_write_iter(file, req, &iter));                /*        2          */
    }
    kfree(iovec);
    return ret;
}
 
  
static inline ssize_t call_write_iter(struct file *file, struct kiocb *kio,
                      struct iov_iter *iter)
{
    return file->f_op->write_iter(kio, iter);                                   /*          3         */
}

(1) 这个函数很重要, 因为在这里里面, 会设置aio完成的回调, req->ki_complete = aio_complete_rw;

(2) 这里调用call_write_iter去执行写入

(3) call_write_iter 实际上调用了file->f_op->write_iter。到这里可以发现, aio的写入流程其实和普通io一样,因为普通io写入最终也是调用到了write_iter这个回调里面。而如果是direct io的话, 这里又会走到direct io的流程中去, 所以其实是在direct io的流程中, 对aio和普通io做了区分,区别实现。

direct io 实现

下面我们也exfat 文件系统为例,介绍direct io中, 怎么实现了aio。

每个文件系统都有一个direct io的回调, 在exfat 中, 这个回调实现如下:

fs/exfat/inode.c
static ssize_t exfat_direct_IO(struct kiocb *iocb, struct iov_iter *iter)
{
    struct address_space *mapping = iocb->ki_filp->f_mapping;
    struct inode *inode = mapping->host;
    loff_t size = iocb->ki_pos + iov_iter_count(iter);
    int rw = iov_iter_rw(iter);
    ssize_t ret;
 
  
......
    ret = blockdev_direct_IO(iocb, inode, iter, exfat_get_block);
    if (ret < 0 && (rw & WRITE))
        exfat_write_failed(mapping, size);
    return ret;
}

blockdev_direct_IO 最终会调用到do_blockdev_direct_IO

static inline ssize_t  do_blockdev_direct_IO(struct kiocb *iocb, struct inode *inode,
              struct block_device *bdev, struct iov_iter *iter,
              get_block_t get_block, dio_iodone_t end_io,
              dio_submit_t submit_io, int flags)
{
 
  
    ......
 
  
    dio = kmem_cache_alloc(dio_cache, GFP_KERNEL);
    if (!dio)
        return -ENOMEM;
    /*
     * Believe it or not, zeroing out the page array caused a .5%
     * performance regression in a database benchmark.  So, we take
     * care to only zero out what's needed.
     */
    memset(dio, 0, offsetof(struct dio, pages));
 
  
    dio->flags = flags;
    ......
 
  
    if (align & blocksize_mask) {
        if (bdev)
            blkbits = blksize_bits(bdev_logical_block_size(bdev));
        blocksize_mask = (1 << blkbits) - 1;
        if (align & blocksize_mask)
            goto fail_dio;
    }
  ......
 
  
    if (is_sync_kiocb(iocb))                                            /*        1         */
        dio->is_async = false; 
    else if (iov_iter_rw(iter) == WRITE && end > i_size_read(inode))
        dio->is_async = false;
    else
        dio->is_async = true;
 
  
    .........
    blk_start_plug(&plug);
 
  
    retval = do_direct_IO(dio, &sdio, &map_bh);                 /*        2        */
    if (retval)
        dio_cleanup(dio, &sdio);
 
  
  .......
    blk_finish_plug(&plug);
    ......
    if (dio->is_async && retval == 0 && dio->result &&
        (iov_iter_rw(iter) == READ || dio->result == count))       /*         3        */
        retval = -EIOCBQUEUED;
    else
        dio_await_completion(dio);
 
  
    if (drop_refcount(dio) == 0) {
        retval = dio_complete(dio, retval, DIO_COMPLETE_INVALIDATE);
    } else
        BUG_ON(retval != -EIOCBQUEUED);
 
  
    return retval;
}

这里仅保留几个关键步骤

(1) 设置是否需要异步。is_sync_kiocb 的实现其实就是看kiocb->ki_complete是否设置, 而aio中, 在上文介绍的aio_setup_rw 即设置了这个回调。

static inline bool is_sync_kiocb(struct kiocb *kiocb)
{
    return kiocb->ki_complete == NULL;
}

(2) 提交io请求, 这一步是异步的

(3) 如果是异步io, 直接返回 EIOCBQUEUED。如果是同步IO, 调用dio_await_completion 等待IO结束。

在IO结束时, 会调用到dio的回调函数dio_complete, 它又会调用到上面设置的ki_complete回调,最终走到aio的回调函数里面去。

buffer io实现

另外还存在一个问题是,如果使用aio时, 打开文件没有设置O_DIRECT选项, 这个时候会发生什么?

从上面的分析可以看出, aio 最终实际上调用了file->f_op->write_iter, 如果是buffer io, 这个回调会按照正常buffer io的流程走完后返回, 这时候其实就没有异步而言了, 和正常的IO流程一模一样。

在调用call_write_iter 会根据其返回值判断行为。

aio_rw_done(req, call_write_iter(file, req, &iter));
 
  
static inline void aio_rw_done(struct kiocb *req, ssize_t ret)
{
    switch (ret) {
    case -EIOCBQUEUED:           /*             1           */
        break;
    case -ERESTARTSYS:
    case -ERESTARTNOINTR:
    case -ERESTARTNOHAND:
    case -ERESTART_RESTARTBLOCK:
        /*
         * There's no easy way to restart the syscall since other AIO's
         * may be already running. Just fail this IO with EINTR.
         */
        ret = -EINTR;
        fallthrough;
    default:
        req->ki_complete(req, ret, 0);         /*             2           */
    }
}

(1) 上文已经说明, 如果是direct io, 会返回该返回值, ki_complete回调在io完成时调用

(2) 如果是buffer io, ret 为实际写入的长度, 会直接调用ki_complete, 相当于一个同步过程

POSIX AIO

也就是 glibc 中包含的版本,主要包含如下接口:

#include

// 提交一个异步读/写,通过结构体告知系统读取的文件、起始位置、读取字节数、读取后的写入buffer

int aio_read(struct aiocb *aiocbp);

int aio_write(struct aiocb *aiocbp);

// 检查当前AIO的状态,可用于查看请求是否成功,返回0(成功)EINPROGRESS(正在读取)

int aio_error(const struct aiocb *aiocbp);

// 查看一个异步请求的返回值,如果成功则返回读取字节数,否则返回-1,此时跟同步读写定义的一样

ssize_t aio_return(struct aiocb *aiocbp);        

示例程序

如下是一个测试示例。

#include

#include

#include

#include

#include

#include

#include

#include

#include

// dd if=/dev/urandom of="foobar.txt" count=10000

const int SIZE_TO_READ = 100;

int main()

{

  int file = open("foobar.txt", O_RDONLY, 0);

  if (file == -1) {

    printf("Unable to open file!\n");

    exit(EXIT_FAILURE);

  }

  char* buffer = (char *)malloc(SIZE_TO_READ);

  struct aiocb cb;

  memset(&cb, 0, sizeof(struct aiocb));

  cb.aio_nbytes = SIZE_TO_READ;

  cb.aio_fildes = file;

  cb.aio_offset = 0;

  cb.aio_buf = buffer;

  if (aio_read(&cb) == -1) {

    printf("Unable to create request!\n");

    close(file);

    exit(EXIT_FAILURE);

  }

  printf("Request enqueued!\n");

  // wait until the request has finished

  while(aio_error(&cb) == EINPROGRESS) {

    printf("Working...\n");

  }

  int numBytes = aio_return(&cb); // success?

  if (numBytes != -1)

    printf("Success!\n");

  else

    printf("Error!\n");

  free(buffer);

  close(file);

  return 0;

}

Linux direct-io简介

在 Linux 2.6 使用 direct io 需要按照如下几点来做:

在源文件的最顶端加上 #define _GNU_SOURCE 宏定义,或在编译时在命令行上指定,否则编译报错。

在 open() 时加上 O_DIRECT 标志。

存放文件数据的缓存起始位置以及每次读写数据长度必须是磁盘逻辑块大小的整数倍,一般是 512 字节,否则将导致读写失败,返回 -EINVAL。

对于第 3 点,要满足缓存区起始位置对齐,可以在进行缓存区空间申请时使用 posix_memalign 这样的函数指定字节对齐。

ret = posix_memalign((void **)&buf, 512, BUF_SIZE);

real_buf = malloc(BUF_SIZE + 512);

aligned_buf = ((((unsigned int)real_buf + 512 - 1) / 512) * 512);

由于要满足每一次读写数据长度必须是磁盘逻辑块大小的整数倍,所以最后一次文件操作可能无法满足,此时只能重新以cached io模式打开文件后,fseek到对应位置进行剩余数据的读写。

参考资料:linux AIO_西门吹大雪的博客-CSDN博客_aio linux

IO_URING

原理与结构

io_uring的原理是让用户态进程与内核通过一个共享内存的无锁环形队列进行高效交互。相关的技术原理其实与DPDK/SPDK中的rte_ring以及virtio的vring是差不多的,只是这些技术不涉及用户态和内核态的共享内存。高性能网络IO框架netmap与io_uring技术原理更加接近,都是通过共享内存和无锁队列技术实现用户态和内核态高效交互。但上述的这些技术都是在特定场景或设备上应用的,io_uring第一次将这类技术应用到了通用的系统调用上。

共享内存

为了最大程度的减少系统调用过程中的参数内存拷贝,io_uring采用了将内核态地址空间映射到用户态的方式。通过在用户态对io_uring fd进行mmap,可以获得io_uring相关的两个内核队列(IO请求和IO完成事件)的用户态地址。用户态程序可以直接操作这两个队列来向内核发送IO请求,接收内核完成IO的事件通知。IO请求和完成事件不需要通过系统调用传递,也就完全避免了copy_to_user/copy_from_user的开销。

无锁环形队列

io_uring使用了单生产者单消费者的无锁队列来实现用户态程序与内核对共享内存的高效并发访问,生产者只修改队尾指针,消费者只修改队头指针,不会互相阻塞。对于IO请求队列来说,用户态程序是生产者内核是消费者,完成事件队列则相反。需要注意的是由于队列是单生产者单消费者的,因此如果用户态程序需要并发访问队列,需要自己保证一致性(锁/CAS)。

内存屏障与保序

使用共享内存和无锁队列最需要注意的就是保证内存操作的顺序和一致性。这部分内容在Efficient IO with io_uring中做了简单的介绍。简单的说就是要保证两点:

1. 修改队列状态时,必须保证对队列元素的写入已经完成。这时需要调用write barrier来保证之前的写入已经完成。在x86架构上这一点其实是针对编译器优化的,防止编译器将修改队列状态的指令放到队列元素写入完成之前。

2. 读取队列状态时,需要获取到最新写入和修改的值。这时需要调用read barrier来保证之前的写入都能被读取到,这主要是对缓存一致性的刷新。

内存屏障在不同架构的CPU上有不同的实现和效果,要正确使用需要对CPU和编译器有一定了解。在liburing中已经屏蔽了这些细节,因此建议一般情况下使用liburing来实现对队列的操作。

轮询模式

io_uring提供了io_uring_enter这个系统调用接口,用于通知内核IO请求的产生以及等待内核完成请求。但这种方式仍然需要反复调用系统调用,进行上下文切换,并在内核中唤醒异步处理逻辑去处理请求。显然这种方式会产生额外的开销,而且受限于系统调用速率,无法发挥IO设备的极限性能。为了在追求极致IO性能的场景下获得最高性能,io_uring还支持了轮询模式。轮询模式在DPDK/SPDK中有广泛应用,这种模式下会有一个线程循环访问队列,一旦发现新的请求和事件就立即处理。

对于用户态程序来说,轮询只需要一个线程持续访问请求完成事件队列即可。但这个层次的轮询只是轮询了io_uring的队列,但内核从IO设备获取完成情况仍然是基于设备通知的。通过在初始化时设置IORING_SETUP_IOPOLL标志,可以将io_uring配置为IO设备轮询模式。在这种模式下,调用io_uring_enter获取完成事件时,内核会使用轮询方式不断检查IO设备是否已经完成请求,而非等待设备通知。通过这种方式,能够尽可能快的获取设备IO完成情况,开始后续的IO操作。

同时,在内核中还支持了一个内核IO模式,通过IORING_SETUP_SQPOLL标志设置。在这个模式下,io_uring会启动一个内核线程,循环访问和处理请求队列。内核线程与用户态线程不同,不能在没有工作时无条件的无限循环等待,因此当内核线程持续运行一段时间没有发现IO请求时,就会进入睡眠。这段时间默认为1秒,可以通过参数sq_thread_idle设置。如果内核线程进入睡眠,会通过IO请求队列的flag字段IORING_SQ_NEED_WAKEUP通知用户态程序,用户态程序需要在有新的IO请求时通过带IORING_ENTER_SQ_WAKEUP标识的io_uring_enter调用来唤醒内核线程继续工作。

需要注意的是,如果IORING_SETUP_IOPOLL和IORING_SETUP_SQPOLL同时设置,内核线程会同时对io_uring的队列和设备驱动队列做轮询。在这种情况下,用户态程序又不需要调用io_uring_enter来触发内核的设备轮询了,只需要在用户态轮询完成事件队列即可,这样就可以做到对请求队列、完成事件队列、设备驱动队列全部使用轮询模式,达到最优的IO性能。当然,这种模式会产生更多的CPU开销。

调用接口

上文提到,io_uring使用两个队列来传递IO请求和完成情况,这两个队列中的元素结构如下:

// Submission Queue Entry

struct io_uring_sqe {

   __u8 opcode;        //请求类型,例如IORING_OP_READV

   __u8 flags;         //

   __u16 ioprio;       //优先级,和ioprio_set系统调用的作用类似

   __s32 fd;           //需要操作的文件fd

   __u64 off;          //文件偏移位置

   __u64 addr;         //读写数据地址,如果是readv/writev请求则是iovec数组地址

   __u32 len;          //读写数据长度,如果是readv/writev请求则是iovec数组长度

   union {

     __kernel_rwf_t rw_flags;    //请求相关的选项,其含义与对应的blocking syscall相同,例如preadv2

     __u32 fsync_flags;

     __u16 poll_events;

     __u32 sync_range_flags;

     __u32 msg_flags;  

   };

   __u64 user_data;    //使用者任意指定的字段,在复制到对应的cqe中,一般用于标识cqe与sqe的对应关系。

   union {

     __u16 buf_index;

     __u64 __pad2[3];

   };

};

// Completion Queue Event

struct io_uring_cqe {

   __u64 user_data;    //来自对应的sqe中的user_data字段

   __s32 res;          //请求处理结果,和普通IO操作的返回值差不多。一般成功时返回字节数,处理失败时返回-errno。

   __u32 flags;        //暂未使用

};

io_uring有3个系统调用接口,分别是:

io_uring_setup,创建io_uring。

io_uring_enter,通知内核有IO请求待处理,并根据参数等待请求完成。

io_uring_register,注册fd和buffer为常用对象,避免内核反复操作。

io_uring_setup

原型为:

int io_uring_setup(unsigned entries, struct io_uring_params *params);

这个函数返回一个io_uring的fd,后续通过这个fd来操作io_uring。entries是创建出的io_uring中包含的sqe(请求)数量,必须是1-4096间的2的幂级数。io_uring_params是一个与内核的交互参数,用户态调用者在其中指定需要的参数,内核也在其中反馈实际创建的情况,其定义和解释如下:

struct io_uring_params {

    __u32 sq_entries;                    /* IO请求sqe数量,内核输出 */

    __u32 cq_entries;                    /* IO完成事件cqe数量,内核输出 */

    __u32 flags;                         /* io_uring运行模式和配置,调用者输入 */

    __u32 sq_thread_cpu;

    __u32 sq_thread_idle;

    __u32 resv[5];                       /* 预留空间,用于对其cacheline,同时为将来扩展留下空间 */

    struct io_sqring_offsets sq_off;     /* sqe队列的偏移地址 */

    struct io_cqring_offsets cq_off;     /* cqe队列的偏移地址 */

};

struct io_sqring_offsets {

   __u32 head;            /* offset of ring head */

   __u32 tail;            /* offset of ring tail */

   __u32 ring_mask;       /* ring mask value */

   __u32 ring_entries;    /* entries in ring */

   __u32 flags;           /* ring flags */

   __u32 dropped;         /* number of sqes not submitted */

   __u32 array;           /* sqe index array */

   __u32 resv1;

   __u64 resv2;

};

这里需要关注的是io_sqring_offsets和io_cqring_offsets。这是内核分配的ring结构中需要用户态操作部分的相对偏移,用户态程序需要使用mmap将ring结构的内存映射到用户态来供后续交互:

struct app_sq_ring {

    unsigned *head;

    unsigned *tail;

    unsigned *ring_mask;

    unsigned *ring_entries;

    unsigned *flags;

    unsigned *dropped;

    unsigned *array;

};

struct app_sq_ring app_setup_sq_ring(int ring_fd, struct io_uring_params *p)

{

    struct app_sq_ring sqring;

    void *ptr;

    ptr = mmap(NULL, p→sq_off.array + p→sq_entries * sizeof(__u32),

    PROT_READ | PROT_WRITE, MAP_SHARED | MAP_POPULATE,

    ring_fd, IORING_OFF_SQ_RING);

    sring→head = ptr + p→sq_off.head;

    sring→tail = ptr + p→sq_off.tail;

    sring→ring_mask = ptr + p→sq_off.ring_mask;

    sring→ring_entries = ptr + p→sq_off.ring_entries;

    sring→flags = ptr + p→sq_off.flags;

    sring→dropped = ptr + p→sq_off.dropped;

    sring→array = ptr + p→sq_off.array;

    return sring;

}

这里的一个疑问是为什么要让用户态程序再调用一次mmap,而不是在内核中就将地址映射好直接返回,这显然让接口的使用复杂度上升了很多。笔者认为这么做唯一的意义在于尽量保留了用户态程序对地址空间的控制力,可能在一些特殊场景下程序会需要特定的地址空间用于特殊用途,内核直接映射可能引入难以发现的问题。为了解决接口易用性的问题,liburing中封装了io_uring_queue_init接口,对于没有上述特殊需求的程序,直接使用这个接口即可。

io_uring_enter

原型为:

int io_uring_enter(unsigned int fd, unsigned int to_submit, unsigned int min_complete, unsigned int flags, sigset_t sig);

在程序向sqring,即请求队列中插入了IO请求后,需要通知内核开始处理,这时就需要调用io_uring_enter。参数中的fd是io_uring的fd,to_submit是提交的IO请求数。

min_complete可以用来阻塞等待内核完成特定数量的请求,前提是flags中设置IORING_ENTER_GETEVENTS。这个功能可以单独调用来等待内核处理完成。需要注意的是由于采用共享内存队列的方式来同步请求完成情况,因此程序也可以不使用这个接口而是直接判断cqring的状态来获取IO完成情况并处理cqring中的完成事件。

io_uring_register

原型为:

int io_uring_register(unsigned int fd, unsigned int opcode, void *arg, unsigned int nr_args);

这个syscall用于支持一些高级的优化用法,主要有两种模式,opcode分别为:

IORING_REGISTER_FILES。内核异步处理sqe请求时,需要保证fd不会在处理过程中被关闭,因此需要在开始处理前增加fd引用计数,结束后再减少。而调用这个接口后就可以避免这种反复的引用计数操作。在调用后指定的文件fd的引用计数会增加,后续提交请求时只要在sqe的flags中指定IOSQE_FIXED_FILE就不会再修改引用计数。如果不再需要操作这个fd,可以用IORING_UNREGISTER_FILES这个opcode解除注册。

IORING_REGISTER_BUFFERS。在使用O_DIRECT模式时,内核需要先映射用户态的页面,处理完后再解除映射,这也是一种重复开销。使用这个opcode后,就可以把指定的buffer页面固定映射到内核中,处理请求时就不需要反复映射、解除映射。

liburing

上文中提到,由于io_uring要实现强大的功能和最优的效率,因此其接口和使用方式会比较复杂。但对于大部分不需要极致IO性能的场景和开发者来说,只使用io_uring的基本功能就能获得大部分的性能收益。当只需要基本功能时,io_uring的复杂接口中很大一部分是不会使用的,同时一部分初始化操作也是基本不变的。因此,io_uring的作者又开发了liburing来简化一般场景下io_uring的使用。使用liburing后,io_uring初始化时的大部分参数都不再需要填写,也不需要自己再做内存映射,内存屏障和队列管理等复杂易错的逻辑也都封装在liburing提供的简单接口中,大幅降低了使用难度。

io_uring的创建与销毁

在liburing中封装了io_uring的创建与销毁操作接口,几乎不需要指定任何参数即可完成创建:

struct io_uring ring;

io_uring_queue_init(ENTRIES, &ring, 0);

io_uring_queue_exit(&ring);

提交请求与处理完成事件

struct io_uring_sqe sqe;

struct io_uring_cqe cqe;

/* get an sqe and fill in a READV operation */

sqe = io_uring_get_sqe(&ring);

io_uring_prep_readv(sqe, fd, &iovec, 1, offset);

/* tell the kernel we have an sqe ready for consumption */

io_uring_submit(&ring);

/* wait for the sqe to complete */

io_uring_wait_cqe(&ring, &cqe);

/* read and process cqe event */

app_handle_cqe(cqe);

io_uring_cqe_seen(&ring, cqe);

上面这段代码是io_uring作者提供的样例,其中体现了几个关键操作接口:

io_uring_get_sqe(&ring),从请求队列中获取一个空闲的请求结构。

io_uring_prep_readv(sqe, fd, &iovec, 1, offset),构造一个readv读请求。

io_uring_submit(&ring),将请求提交给内核。

io_uring_wait_cqe(&ring, &cqe),等待请求完成。这是一个阻塞操作,还有一个非阻塞版本:io_uring_peek_cqe。

io_uring_cqe_seen(&ring, cqe),通知内核已经完成对完成事件的处理。

通过这些接口,开发者已经能够很容易的写出异步IO的代码。

具体实现

上文已经介绍了io_uring的接口和基本使用方法,以及io_uring的技术原理。这里再讨论一下io_uring在内核中的实现。笔者认为,以前没有在内核中支持这类接口是有原因的,让用户态程序与内核共享内存、并发访问和修改同一数据结构,是一种危险行为。如果不能妥善的控制共享内存的操作权限,完整覆盖各种并发操作、特别是用户态异常操作的内核处理逻辑,就很可能让用户态程序能够通过这个机制破坏内核状态,造成严重错误和漏洞。因此,与技术原理相比,io_uring的具体实现更加重要,我们需要了解io_uring是如何保障其可靠性和安全性的。

风险分析与解决思路

在分析代码之前,我们先分析一下io_uring机制存在的风险以及可能的规避方式。

使用共享内存的ring进行同步,逻辑上并不复杂,但共享内存的结构如果损坏,内核在处理这个结构时就可能出现错误,造成内核崩溃等问题。结构损坏可能有几种:

1. ring的head/tail指针错误。这会导致内核处理没有设置过的请求sqe。由于sqe是内核预分配的内存,因此这个操作不会造成内核访问非法内存地址。如果sqe中的参数是非法的,内核会直接生成错误事件,如果参数合法,则会按参数执行IO。这种错误的影响和使用了错误的syscall参数是差不多的,对内核不会产生严重影响。

2. ring的mask/entries/flags被错误修改。由于对ring内的entry数组访问以及一些特性实现和这些元素是强相关的,如果这些内存被错误修改,就可能造成内核严重异常。但这些元素在创建io_uring时就已经确定了,内核可以单独为每个io_uring单独保存一份用于实际处理逻辑,而不使用共享内存中的部分。这样就可以避免用户态修改对内核产生影响。

3. sqe的内容在内核处理时被修改。这个情况比较复杂,理论上有可能做到对sqe的元素都只做单次访问,从而避免元素值变化造成的逻辑异常。但由于io_uring支持的操作类型和特性很丰富,可能很难实现这一点。因此更可行的方式是在处理sqe前直接复制一份,之后只访问复制的sqe参数。

内核实现

io_uring的实现在fs/io_uring.c中,这是一个长达9300行的c文件。。。

SYSCALL_DEFINE2(io_uring_setup),即sys_io_uring_setup是io_uring_setup系统调用的实现函数。可以看到,在创建io_uring时,内核创建了两个数据结构io_ring_ctx和io_rings。其中io_rings就是和用户态共享内存的ring结构,包含了ring的head、tail指针,mask、entries以及sq_off、cq_off中的其他字段。而io_ring_ctx中则包含了那些不能被修改的参数。和我们在上一节分析的一样,内核将不应被修改的部分参数在内核专用的io_ring_ctx结构中保存了一份,内核实际访问的是io_ring_ctx中的元素,而不是和用户态共享的io_rings中的元素。这就避免了共享的关键参数被修改可能导致的问题。事实上,io_ring_ctx中还包含了sq_ring的head和cq_ring的tail这两个应该由内核控制的ring参数,内核只会根据io_ring_ctx中的这两个值来访问ring,并将修改后的值写回到io_rings中。因此用户态修改这些值不会对内核逻辑产生影响,而是会被内核不断修正。

SYSCALL_DEFINE6(io_uring_enter),即sys_io_uring_enter是io_uring_enter系统调用的实现函数。在主要实现逻辑io_submit_sqes函数中,会为每一个待处理的sqe分配一个io_kiocb结构,并将sqe中的请求参数逐个复制到io_kiocb中,后续真正实现io请求时访问的是io_kiocb,因此sqe被修改也不会对io处理逻辑产生影响。

值得注意的是,io_uring_enter中并不一定是完全异步化的处理sqe中的IO请求,只有当sqe中设置了REQ_F_FORCE_ASYNC时才会立即将对应的io_kiocb加入异步队列,否则会尝试启动文件的异步IO操作,只有当文件不支持异步模式时才会将其加入异步队列。这部分逻辑理解的不是很透彻,需要进一步阅读代码。

应用编程示例

liburing

直接使用系统调用来进行io_uring的开发还是比较复杂的,特别是需要对共享内存中的环形队列进行操作。所幸开源社区上提供了封装好的liburing库,大大简化了其使用。该库正是由io_uring的作者Jens Axboe实现的,其主要接口包括:

struct io_uring ring;
int io_uring_queue_init(unsigned entries, struct io_uring *ring, unsigned flags);

该接口用于io_uring实例的初始化,entries用于指定提交实例的数量;flags用于设置标志,比如用于启动iopoll模式的IORING_SETUP_IOPOLL,用于启动sqpoll模式的IORING_SETUP_SQPOLL等。

struct io_uring_sqe *io_uring_get_sqe(struct io_uring *ring)

获取一个空闲的提交实体用于IO的提交。

static inline void io_uring_prep_read(struct io_uring_sqe *sqe, int fd,
                                 void *buf, unsigned nbytes, off_t offset)

该函数为提交实体初始化的封装,使用提供的参数将提交实体初始化为“读”操作。除此之外,还要write/send/recv/...等操作的封装函数,简化了代码的编写。

static inline void io_uring_sqe_set_data(struct io_uring_sqe *sqe, void *data)
static inline void *io_uring_cqe_get_data(const struct io_uring_cqe *cqe)

为提交实体设置(获取)私有数据,该数据为自定义数据,用于在提交实体完成后,从完成队列中获取到该对象时的识别等作用。

int io_uring_submit(struct io_uring *ring)

将提交队列中的SQE提交给内核处理。如果开启了SQPOLL模式,该函数不一定会陷入系统调用,只有在检查到内核进程没有运行的情况下才会产生系统调用。

static inline int io_uring_peek_cqe(struct io_uring *ring,
                               struct io_uring_cqe **cqe_ptr)
static inline int io_uring_wait_cqe(struct io_uring *ring,
                               struct io_uring_cqe **cqe_ptr)

从完成队列中获取完成实例,提供了阻塞和非阻塞两个版本。

编程示例

下面以UDP收包为例,来演示如何使用liburing来进行异步IO的实现。

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 
 
  
#include "liburing.h"
 
  
#define MAX_PKT_SIZE    1500
#define MAX_PKT_COUNT   10
 
  
static void submit_recv(struct io_uring *ring, int sockfd, void *data)
{
        struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
        if (!sqe)
                return;
 
  
        io_uring_prep_recv(sqe, sockfd, data, MAX_PKT_SIZE, 0);
        io_uring_sqe_set_data(sqe, data);
}
 
  
static void submit_all_recv(struct io_uring *ring, int sockfd)
{
        struct io_uring_sqe *sqe;
        void *data;
 
  
       //获取空闲的sqe
        while ((sqe = io_uring_get_sqe(ring))) {
                data = malloc(MAX_PKT_SIZE);
               //sqe初始化为recv操作
                io_uring_prep_recv(sqe, sockfd, data, MAX_PKT_SIZE, 0);
               //设置sqe的私有数据,方便我们在操作完成后获取到其中的报文数据
                io_uring_sqe_set_data(sqe, data);
        }
}
 
  
static int do_recv(struct io_uring *ring, int sockfd)
{
        struct io_uring_cqe *cqe;
        void *data;
        int count;
 
  
       //对提交数组中所有空闲的提交实体进行初始化,并放到提交队列
        submit_all_recv(ring, sockfd);
       //将提交队列中的请求提交给内核处理
        io_uring_submit(ring);
        count = 0;
 
  
        while (true) {
               //从完成队列中取出一个实例,返回非0的话代表完成队列中没有可取实例
                if (io_uring_peek_cqe(ring, &cqe)) {
                       //进行一次提交操作,将提交队列中的请求批量提交给内核处理
                        io_uring_submit(ring);
                       //以阻塞的方式等待完成队列中存在可用实例
                        io_uring_wait_cqe(ring, &cqe);
                }
                if (!cqe) {
                        fprintf(stderr, "io_uring_get_sqe failed\n");
                        continue;
                }
               //获取完成实例中之前设置的私有数据
                data = io_uring_cqe_get_data(cqe);
                count++;
                if (!(count % 1000))
                        printf("recved packet count: %d, queue len:%d\n", count, io_uring_sq_ready(ring));
                           //将完成实例标识为完成处理,其对应的提交实例可以被使用了
                io_uring_cqe_seen(ring, cqe);
               //继续进行请求的提交。这里使用之前分配好的data,避免重复的内存分配
                submit_recv(ring, sockfd, data);
        }
 
  
        return 0;
}
 
  
int main()
{
        struct sockaddr_in saddr;
        struct io_uring ring;
        int ret, sockfd;
 
  
       //初始化uring,设置提交队列长度为10
        ret = io_uring_queue_init(10, &ring, 0);
        if (ret < 0)
        {
                perror("queue_init");
                goto err;
        }
 
  
       //初始化UDP套接字
        memset(&saddr, 0, sizeof(saddr));
        saddr.sin_family = AF_INET;
        saddr.sin_addr.s_addr = htonl(INADDR_ANY);
        saddr.sin_port = htons(8080);
        sockfd = socket(AF_INET, SOCK_DGRAM, 0);
        ret = bind(sockfd, (struct sockaddr *)&saddr, sizeof(saddr));
        if (ret < 0)
        {
                perror("bind");
                goto err;
        }
    
       //开始报文接收
        do_recv(&ring, sockfd);
err:
        return -1;
}

可以看出使用还是比较简洁的,用户也可以对liburing接口进行二次封装以达到更加简洁的目的。

你可能感兴趣的:(网络,运维,linux)