并发编程技术

1. UNIX下的五种IO模型

1.1. 概述

I/O输入/输出(Input/Output),在POSIX兼容的系统上,例如Linux系统,I/O操作可以有多种方式,比如DIO(Direct I/O),AIO(Asynchronous,I/O 异步I/O),Memory-Mapped I/O(内存映设I/O)等,不同的I/O方式有不同的实现方式和性能,在不同的应用中可以按情况选择不同的I/O方式。

本文我们就来详细介绍一下 UNIX 系统中有哪些 IO 模型以及他们的通信方式

1.2. 应用

UNIX系统将所有的外部设备都看作一个文件来看待,所有打开的文件都通过文件描述符来引用。文件描述符是一个非负整数,它指向内核中的一个结构体。当打开一个现有文件或创建一个新文件时,内核向进程返回一个文件描述符。而对于一个socket的读写也会有相应的文件描述符,称为socketfd(socket描述符)。 

在UNIX系统中,I/O输入操作(例如标准输入或者套接字的输入)通常包含以下两个不同的阶段:

  • 等待数据准备好

  • 从内核向进程复制数据

1.3. UNIX 下的 IO 模型

1.3.1. 下面介绍五种 IO 模型:

  • 阻塞式IO

  • 非阻塞式IO

  • IO复用(select 和 poll)

  • 信号驱动式IO(SIGIO)

  • 异步IO(POSIX的aio_系列函数)

1.3.2. 阻塞式IO模型

阻塞式IO模型是最常用的IO模型。
默认情况下,所有套接字都是阻塞的,如下图所示:

这样的IO模型中,系统调用会从应用进程空间切换到内核空间中运行一段时间后再切换回来。
只有当数据报到达并且被复制到应用进程缓冲区中或者发生错误才返回,最常见的错误返回是被信号中断。

1.3.3. 非阻塞式IO模型

非阻塞式IO并不让进程睡眠,而是在数据报没有准备好的时候由内核立刻返回一个错误。
当一个进程循环调用非阻塞式IO等待数据报时,我们称之为“轮询”。
这样做会耗费大量的CPU时间,通常只在专门提供某一功能的系统中才会用到。

1.3.4. IO复用模型

IO复用的系统调用有select和poll。
系统阻塞在这两个系统调用上,而不是阻塞在真正的IO系统调用上。
select 调用等待数据报套接字变为可读,然后调用真正的IO系统调用去进行IO操作,将所读的数据写入应用程序缓冲区中。
使用 IO 复用模型的好处在于可以同时等待多个描述符就绪,甚至可以实现复杂的等待条件。

等待多个描述符的另一种实现是创建多个线程,每个线程使用一个阻塞式IO系统调用去等待一个描述符。

1.3.5. 信号驱动式IO模型

我们也可以设定 SIGIO 信号的信号处理方式,然后让内核在描述符就绪时发送 SIGIO 信号通知我们。
这种方式就是信号驱动式IO。
信号驱动式IO模型的优势在于等待数据报到达期间进程不必被阻塞,一旦数据报准备好,可以立即进行处理。

1.3.6. 异步IO模型

aio_开头的一些列系统调用实现了异步IO模型。
这些函数的工作机制是:告知内核启动某个操作,并让内核在整个操作完全进行完成之后通知进程。
信号驱动式IO是在IO操作进行之前通知进程进行相应的操作,而与此不同,异步IO模型让内核完成全部的缓冲区复制工作,直到全部工作完成才去通知进程(比如产生某个信号)

1.4. 各种IO模型的比较

 

2. 程序设计中的两大经典模式 -- Reactor & Proactor

在 IO 模型中,IO 复用模型,例如 epoll、select 等就是在 Reator 思想下诞生的,而异步 IO 模型,例如 glibc 实现的 posix aio 或是 linux 原生的 libaio 就是在 Proactor 思想下诞生的。
如果你非常熟悉 IO 复用模型与异步 IO 模型之间的差异,那么,关于 Reactor 与 Proactor 思想的区别就非常清晰了。

2.1. Reactor 模式

2.1.1. 模式构成

Reactor包含以下角色:

Event Handler — 事件处理接口。

  1. Handle 句柄 — 在 linux 中,就是常见的文件描述符,用来标识 socket 连接或是打开的文件

  2. Reactor — 反应器,定义抽象接口,实现:

    1. 供应用程序注册和删除关注的事件句柄

    2. 运行事件循环

    3. 有就绪事件到来时,分发事件到之前注册的回调函数上处理
      Synchronous Event Dispatcher — 同步事件多路分发器,由操作系统内核实现,用于阻塞等待发生在句柄上的一个或多个事件,我们系统中的 select、poll、epoll 等多路复用 IO 就充当了这一角色。

2.1.2. 工作时序

下面展示了整个 Reactor 模式工作的时序:

整体的思想分为以下几步:

  1. 初始化启动应用,将事件注册到 Reactor 中

  2. 调用 get_handle() 接口,获取事件处理对象

  3. 调用 Reactor 进入事件循环,等待注册的事件到来

  4. 注册的事件触发,select() 返回,Reactor 回调已注册的回调函数

这一思想就是基于经典的回调思想“不要调用我,让我来调用你”的“好莱坞法则”设计的,具体的执行过程可以参看 epoll 的使用

2.2. Proactor 模式

Proactor 模式是另一个消息异步通知的设计模式,与 Reactor 的最大区别在于,Proactor 通知的不是就绪事件,而是操作完成事件,这也就是操作系统异步 IO 的主要模型。

2.2.1. 模式构成

Proactor 模式包含以下角色:

  1. Handle 句柄 — 在 linux 中,就是常见的文件描述符,用来标识 socket 连接或是打开的文件

  2. Asynchronous Operation Processor — 异步操作处理器;负责执行异步操作,一般由操作系统内核实现,也可以被用户态线程或进程模拟

  3. Asynchronous Operation — 异步操作

  4. Completion Event Queue — 完成事件队列,用来缓存已经完成的异步操作

  5. Proactor — 主动器,定义抽象接口,实现:

    1. 为应用程序进程提供事件循环

    2. 从完成事件队列中取出异步操作的结果

    3. 分发调用已完成时间相应的后续处理逻辑

  6. Completion Handler — 完成事件接口,一般是由回调函数组成的接口。

  7. Concrete Completion Handler — 完成事件后的具体处理逻辑,实现接口定义特定的应用处理逻辑。

2.2.2. 模式执行时序

下图展现了 Proactor 执行的时序:

主要分为以下几步:

  1. 初始化启动,注册异步操作完成后的回调操作

  2. 主程序调用异步操作处理器提供的异步操作接口

  3. Asynchronous Operation Processor 执行异步操作,完成后将结果放入事件完成队列

  4. Proactor 从完成事件队列中取出结果,分发到相应的完成事件回调函数处理逻辑中

2.3. 优势与不足

2.3.1. 主动与被动 — Reactor 与 Proactor 的区别

Reactor 调用后,需要被动等待对象进入就绪状态,然后再进行后续处理。
Proactor 则会待操作完全完成后由内核返回,主进程可以主动切换去执行其他任务。

2.3.2. Reactor 的优势与不足

优势

Reactor 在实现上相对比较简单,对于大量对象,频繁从非就绪态触发到就绪态的场景处理十分高效。
同时,操作系统可以同时去等待多个对象触发,并且可以在事件触发后自由地选择后续执行流程,具有很高的灵活性。
虽然并发编程实现阻塞式同步 IO 也可以实现同时等待多个对象触发的效果,但在编程的复杂度与资源的消耗等方面,Reactor 模式拥有明显的优势。

不足

但是 Reactor 的不足也很明显,如果就绪态长时间没有触发,则进程一直等待,长时间阻塞主进程,影响到整个系统的吞吐。

2.3.3. Proactor 的优势与不足

此前我们介绍了 glibc 实现的 POSIX aio 与 linux 原生实现的 libaio,他们是典型的 Proactor 模式的处理模型:



POSIX AIO — glibc 版本异步 IO 简介

1. 概述

linux 中最常用的 IO 模型是同步 IO,在这个模型中,请求发出后应用程序会阻塞直到满足条件(阻塞 IO),或在不满足条件的情况下立即返回出错(非阻塞 IO),这样做的好处是程序在等待 IO 请求完成时不会占用 CPU。
POSIX 定义了异步 IO 应用程序接口(AIO API),linux 2.6 以上版本的内核也实现了内核级别的异步 IO 调用。
异步 IO 的基本思想是允许进程发起很多 IO 操作,而不用阻塞任何一个,也不用等待任何操作的完成,直到 IO 操作完成时,进程可以检索 IO 操作的结果。

linux 下主要有两套异步 IO,分别是 glibc 实现版本,和 linux 内核实现、libaio 封装的版本。

2. IO 模型简介

write、read 如果没有设置 O_NONBLOCK 标识则为同步阻塞式 IO,一旦 IO 发起,则进程一直等待直到操作完成。
设置了 O_NONBLOCK 标识后,write、read 成为非阻塞 IO,调用后如果资源可用则进行操作,并立即返回,如果资源不可用则直接返回出错,这样的情况下,程序通常需要进入忙等待状态,反复调用 IO 操作,直到正常返回。
以上两种 IO 模型虽然可以很好地完成单机的 IO 操作,但是对于并发的请求则无法实现。

对于并发的多个请求,可以使用 IO 复用模型,如 select、poll、epoll 等,但是进程必须阻塞直到操作完成。
如果需要进行并发、非阻塞的 IO 操作,比如 CPU 密集型应用及较慢的 IO 操作应用场景下,使用异步 IO 是一个很好地选择。

2.1. 同步阻塞式 IO 模型

2.2. 同步非阻塞 IO 模型

2.3. 异步 IO 模型

3. POSIX AIO — glibc 版本异步 IO 简介

glibc 版本异步 IO 主要包含以下接口(全部定义于 aio.h 中,调用时必须使用 POSIX 实时扩展库 librt):

glibc 版本异步 IO 调用接口

函数功能原型
aio_read请求异步读操作int aio_read(struct aiocb *aiocbp);
aio_write请求异步写操作int aio_write(struct aiocb *aiocbp);
aio_error检查异步请求的状态int aio_error(const struct aiocb *aiocbp);
aio_return获得完成的异步请求的返回状态ssize_t aio_return(struct aiocb *aiocbp);
aio_suspend挂起调用进程,直到一个或多个异步请求已经完成(或失败)

int aio_suspend(const struct aiocb  const list[], int nent, 

const struct timespec timeout);

aio_cancel取消异步 I/O 请求int aio_cancel(int fildes, struct aiocb *aiocbp);
lio_listio同时发起多个异步IO传输int lio_listio( int mode, struct aiocb list[], int nent, struct sigevent sig );

3.1. aiocb 结构

上述函数用到了一个 struct aiocb 结构。
主要包含以下字段:

struct aiocb {
    int                aio_fildes;        // 要被读写的文件描述符
    volatile void    *aio_buf;        // 读写操作的内存 buffer
    __off64_t        aio_offset;        // 读写操作的文件偏移
    size_t            aio_nbytes;        // 需要读写的字节长度
    int                aio_reqprio;    // 请求优先级
    struct sigevent aio_sigevent;    // 异步操作完成后的信号或回调函数

    /* Internal fields */
    ...
};

上述结构中有一个 aio_sigevent 域,用于定义异步操作完成时通知信号或回调函数。

struct sigevent
{
    int                sigev_notify;                    // 响应类型
    int                sigev_signo;                    // 信号
    union sigval    sigev_value;                    // 信号传递的参数
    void (*sigev_notify_function)(union sigval);    // 回调函数
    pthread_attr_t    *sigev_notify_attributes;        // 线程回调
}

上述结构中用到了一个联合体 sigval:

typedef union sigval
{
    int        sival_int;
    void    *sival_ptr;
} sigval_t;

通常被称为“信号的 4 字节值”,制定了信号传递的参数。

4. 函数说明

4.1. aio_read、aio_write

int aio_read( struct aiocb *aiocbp );
int aio_write( struct aiocb *aiocbp );

调用成功返回 0,失败返回 -1 并设置 errno。

将请求添加到 request_queue。
通过参数 aiocbp 指向的结构可以设置文件描述符、文件偏移量、缓冲区及大小等属性,函数执行后立即返回。
对于 aio_write,如果设置了 O_APPEND,则文件偏移量属性会被忽略。

4.2. aio_error

int aio_error( struct aiocb *aiocbp );

用于查询请求的状态。
返回值如下:

aio_error 函数返回值

返回值意义
EINPROGRESS请求尚未完成
ECANCELLED请求已经被用用程序取消
-1调用出错,出错原因查看 errno

4.3. aio_return

ssize_t aio_return( struct aiocb *aiocbp );

获取异步 IO 返回值。
调用成功返回读写的字符数,出错返回 -1。

4.4. aio_suspend

int aio_suspend( const struct aiocb *const cblist[],
        int n,
        const struct timespec *timeout );

阻塞进程,直到列表中的某个异步请求完成。
cblist 中任何一个异步请求完成,函数都会返回 0,出错返回 -1。

4.5. aio_cancel

int aio_cancel( int fd, struct aiocb *aiocbp );

取消一个异步请求,第二个参数为 NULL 则取消所有该 fd 上的异步请求。
成功取消返回 AIO_CANCELED,请求已经完成则返回 AIO_NOTCANCELED。
在取消多个请求的情况下,如果至少有一个请求没有被取消,则返回 AIO_NOT_CANCELED,如果没有一个请求可以被取消,则返回 AIO_ALLDONE。

4.6. lio_listio

int lio_listio( int mode,
        struct aiocb *list[],
        int nent,
        struct sigevent *sig );

同时发起多个异步请求,可以很大程度上提高系统的性能。

mode 参数可选 LIO_WAIT 或 LIO_NOWAIT 来声明该函数是否阻塞。
nent 参数定义了 list 列表的最大元素个数。
list 列表中可以有值为 NULL 的请求,则该请求被忽略。
sigevent 的指针定义了在所有 IO 操作都完成时产生的信号或调用的回调函数。

linux AIO — libaio 实现的异步 IO 简介及实现原理

1. linux AIO — libaio 实现的异步 IO

POSIX AIO 是在用户控件模拟异步 IO 的功能,不需要内核支持,而 linux AIO 则是 linux 内核原声支持的异步 IO 调用,行为更加低级。

关于 linux IO 模型及 AIO、POSIX AIO 的简介,请参看上一篇文章

libaio 实现的异步 IO 主要包含以下接口:

libaio 实现的异步 IO

函数功能原型
io_setup创建一个异步IO上下文(io_context_t是一个句柄)int io_setup(int maxevents, io_context_t *ctxp);
io_destroy销毁一个异步IO上下文(如果有正在进行的异步IO,取消并等待它们完成)int io_destroy(io_context_t ctx);
io_submit提交异步IO请求long io_submit(aio_context_t ctx_id, long nr, struct iocb **iocbpp);
io_cancel取消一个异步IO请求long io_cancel(aio_context_t ctx_id, struct iocb iocb, struct io_event result);
io_getevents等待并获取异步IO请求的事件(也就是异步请求的处理结果)long io_getevents(aio_context_t ctx_id, long min_nr, long nr, struct io_event events, struct timespec timeout);

1.1. iocb 结构

struct iocb主要包含以下字段:

struct iocb
{
    /*
     * 请求类型
     * 如:IOCB_CMD_PREAD=读、IOCB_CMD_PWRITE=写、等
     */
    __u16     aio_lio_opcode;
    /*
     * 要被操作的fd
     */
    __u32     aio_fildes;
    /*
     * 读写操作对应的内存buffer
     */
    __u64     aio_buf;
    /*
     * 需要读写的字节长度
     */
    __u64     aio_nbytes;
    /*
     * 读写操作对应的文件偏移
     */
    __s64     aio_offset;
    /*
     * 请求可携带的私有数据
     * 在io_getevents时能够从io_event结果中取得)
     */
    __u64     aio_data;
    /*
     * 可选IOCB_FLAG_RESFD标记
     * 表示异步请求处理完成时使用eventfd进行通知
     */
    __u32     aio_flags;
    /*
     * 有IOCB_FLAG_RESFD标记时,接收通知的eventfd
     */
    __u32     aio_resfd;
}

1.2. io_event 结构

struct io_event
{
    /*
     * 对应iocb的aio_data的值
     */
    __u64     data;
    /*
     * 指向对应iocb的指针
     */
    __u64     obj;
    /*
     * 对应IO请求的结果
     * >=0: 相当于对应的同步调用的返回值;<0: -errno
     */
    __s64     res;
}

2. 异步 IO 上下文

aio_context_t 即 AIO 上下文句柄,该结构体对应内核中的一个 struct kioctx 结构,用来给一组异步 IO 请求提供一个上下文环境,每个进程可以有多个 aio_context_t,io_setup 的第一个参数声明了同时驻留在内核中的异步 IO 上下文数量。
kioctx 结构主要包含以下字段:

struct kioctx
{
    /*
     * 调用者进程对应的内存管理结构
     * 代表了调用者的虚拟地址空间
     */
    struct mm_struct*     mm;
    /*
     * 上下文ID,也就是io_context_t句柄的值
     * 等于ring_info.mmap_base
     */
    unsigned long         user_id;
    /*
     * 属于同一地址空间的所有kioctx结构通过这个list串连起来
     * 链表头是mm->ioctx_list
     */
    struct hlist_node     list;
    /*
     * 等待队列
     * io_getevents系统调用可能需要等待
     * 调用者就在该等待队列上睡眠
     */
    wait_queue_head_t     wait;
    /*
     * 进行中的请求数目
     */
    int                   reqs_active;
    /*
     * 进行中的请求队列
     */
    struct list_head      active_reqs;
    /*
     * 最大请求数
     * 对应io_setup调用的int maxevents参数
     */
    unsigned              max_reqs;
    /*
     * 需要aio线程处理的请求列表
     * 某些情况下,IO请求可能交给aio线程来提交
     */
    struct list_head      run_list;
    /*
     * 延迟任务队列
     * 当需要aio线程处理请求时,将wq挂入aio线程对应的请求队列
     */
    struct delayed_work   wq;
    /*
     * 存放请求结果io_event结构的ring buffer
     */
    struct aio_ring_info  ring_info;
}

其中,aio_ring_info 结构用于存放请求结果 io_event 结构的 ring buffer,主要包含以下字段:

struct aio_ring_info
{
    unsigned long    mmap_base;    // ring buffer 的首地址
    unsigned long    mmap_size;    // ring buffer 空间大小
    struct page**    ring_pages;    // ring buffer 对应的 page 数组
    long            nr_pages;    // 分配空间对应的页面数目
    unsigned        nr;            // io_event 的数目
    unsigned        tail;        // io_event 的存取游标
}

aio_ring_info 结构中,nr_page * PAGE_SIZE = mmap_size。
以上数据结构都是在内核地址空间上分配的,是内核专有的,用户程序无法访问和使用。
但是 io_event 结构是内核在用户地址空间上分配的 buffer,用户可以修改,但是首地址、大小等信息都是由内核维护的,用户程序通过 io_getevents 函数修改。

3. 实现原理

io_setup 函数创建了一个 AIO 上下文,并通过值-结果参数 aio_context_t 类型指针返回其句柄。
io_setup 调用后,内核会通过 mmap 在对应的用户地址空间分配一段内存,由 aio_ring_info 结构中的 mmap_base、mmap_size 描述这个映射对应的位置和大小,由 ring_pages、nr_pages 描述实际分配的物理内存页面信息,异步 IO 完成后,内核会将异步 IO 的结果写入其中。

在 mmap_base 指向的用户地址空间上,会存放着一个 struct aio_ring 结构,用来管理 ring buffer,主要包含以下字段:

unsigned    id;        // 等于 aio_ring_info 中的 user_id
unsigned    nr;        // 等于 aio_ring_info 中的 nr
unsigned    head;    // io_events 数组队首
unsigned    tail;    // io_events 数组游标
unsigned    magic;    // 用于确定数据结构有没有异常篡改
unsigned    compat_features;
unsigned    incompat_features;
unsigned    header_length;    // aio_ring 结构大小
struct io_event    *io_events;    // io_event buffer 首地址

这个数据结构存在于用户地址空间中,内核作为生产者,在 buffer 中放入数据,并修改 tail 字段,用户程序作为消费者从 buffer 中取出数据,并修改 head 字段。

每一个请求用户都会创建一个 iocb 结构用于描述这个请求,而对应于用户传递的每一个 iocb 结构,内核都会生成一个与之对应的 kiocb 结构,并只该结构中的 ring_info 中预留一个 io_events 空间,用于保存处理的结果。

struct kiocb
{
    struct kioctx*        ki_ctx;           /* 请求对应的kioctx(上下文结构)*/
    struct list_head    ki_run_list;      /* 需要aio线程处理的请求,通过该字段链入ki_ctx->run_list */
    struct list_head    ki_list;          /* 链入ki_ctx->active_reqs */
    struct file*        ki_filp;          /* 对应的文件指针*/
    void __user*        ki_obj.user;      /* 指向用户态的iocb结构*/
    __u64                ki_user_data;     /* 等于iocb->aio_data */
    loff_t                ki_pos;           /* 等于iocb->aio_offset */
    unsigned short        ki_opcode;        /* 等于iocb->aio_lio_opcode */
    size_t                ki_nbytes;        /* 等于iocb->aio_nbytes */
    char __user *        ki_buf;           /* 等于iocb->aio_buf */
    size_t                ki_left;          /* 该请求剩余字节数(初值等于iocb->aio_nbytes)*/
    struct eventfd_ctx*    ki_eventfd;       /* 由iocb->aio_resfd对应的eventfd对象*/
    ssize_t (*ki_retry)(struct kiocb *);  /*由ki_opcode选择的请求提交函数*/
}

这以后,对应的异步读写请求就通过调用 file->f_op->aio_read 或 file->f_op->aio_write 被提交到了虚拟文件系统,与普通的文件读写请求非常类似,但是提交完后 IO 请求立即返回,而不等待虚拟文件系统完成相应操作。
对于虚拟文件系统返回 EIOCBRETRY 需要重试的情况,内核会在当前 CPU 的 aio 线程中添加一个任务,让 aio 完成该任务的重新提交。

4. 与 POSIX AIO 区别

从上图中的流程就可以看出,linux 版本的 AIO 与 POSIX 版本的 AIO 最大的不同在于 linux 版本的 AIO 实际上利用了 CPU 和 IO 设备异步工作的特性,与同步 IO 相比,很大程度上节约了 CPU 资源的浪费。
而 POSIX AIO 利用了线程与线程之间的异步工作特性,在用户线程中实现 IO 的异步操作。

POSIX AIO 支持非 direct-io,而且实现非常灵活,可配置性很高,可以利用内核提供的page cache来提高效率,而 linux 内核实现的 AIO 就只支持 direct-io,cache 的工作就需要用户进程考虑了。

 

5.3.1. 优势

Proactor 最显著的优势在于处理耗时长的 IO 操作和并发场景。
同时,针对 IO 操作,一旦提交,内核只有在完全执行完成后才会再次通知到用户进程,在这个过程中,用户进程可以做任何其他操作,这给与了用户进程更大的灵活性。

5.3.2. 不足

Proactor 的实现相对比较复杂,在实际编程中,与基本的同步 IO 相比,aio 在使用上也不那么容易,尤其是 linux 的 libaio 具有五个 api,同时需要自己构造执行上下文和 buffer,性能与 windows 下的 IOCP 相比也有一定的差距,普通场景中还是不建议使用 linux 的 aio 的。

参考

http://www.dre.vanderbilt.edu/~schmidt/PDF/reactor-siemens.pdf。
https://en.wikipedia.org/wiki/Reactor\_pattern。
https://en.wikipedia.org/wiki/Proactor\_pattern。
https://www.dre.vanderbilt.edu/~schmidt/PDF/Proactor.pdf。
http://lse.sourceforge.net/io/aio.html


workflow 

https://github.com/sogou/workflow/wiki

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值