CXD Linux Engineer

Linux中的异步I/O模型

2018-12-09

前言

这篇文章是翻译自Introduction to AIO,它非常简明的介绍了各种I/O模型的异同,并且详细的介绍了异步I/O在Linux的使用方法。以前一直对异步I/O的了解非常少,翻译这篇文章算是对各种网络模型的一个总结,顺便学习了Linux中的异步I/O模型。一举两得,哈哈!

AIO简介

Linux的异步I/O是内核中新增的功能,在2.6版本中被正式引入,但是你可以通过补丁在2.4内核中使用。AIO(asynchronous I/O)背后的基本思想是允许进程启动大量I/O操作,而不必阻塞或等待任何操作完成。 在稍后的某个时间或者收到I/O操作完成的通知,可以获取I/O操作的结果。

I/O模型

在讲解AIO的API之前,我们先介绍一下Linux中目前支持的各种I/O模型。我们不会详细介绍所有I/O模型,只会介绍常见的I/O模型用来与AIO做对比。图1展示了同步和异步,以及阻塞和非阻塞四种模型。
figure1
这些I/O模型中的每一个都有它适合的应用场景。接下来我们会大概介绍一下每种I/O模型。

同步阻塞I/O模型

同步阻塞I/O模型是最常见的模型之一,用户空间的应用程序在执行系统调用之后会被阻塞,直到系统调用完成(数据传输完成或者出现错误)。此时应用程序只是处于简单的等待响应状态不会消耗CPU,所以站在CPU的角度他是高效的。

图2描述了至今在应用程序中依然非常常用的阻塞I/O模型。它的行为很好理解,并且对于典型应用来说使用它是非常有效的。当read系统函数被调用时,应用程序被阻塞,并且上下文切换到内核空间。读操作开始执行,当请求返回(从你读取的设备中)时数据被传送到用户空间的buffer中。然后应用程序被唤醒(read系统调用返回)。
figure2
图2.同步阻塞I/O模型的典型流程

从应用程序的角度来看,read系统调用持续了很长一段时间,但实际上读操作在和其他内核任务一起执行,此时应用程序处于阻塞状态。

同步非阻塞I/O模型

同步阻塞I/O的一种变体是效率较低的同步非阻塞I/O。在这种模型下,设备以非阻塞方式打开,这意味着如果不能立即完成I/O操作read系统调用会返回一个错误码(EAGAIN或EWOULDBLOCK)来指明无法立即完成读操作,如图3。
figure3
图3.同步非阻塞I/O模型的典型流程

非阻塞意味着如果I/O操作不能立即完成,则需要应用程序多次调用直到任务完成。这可能非常低效,因为大多数时候应用程序必须忙等待或者尝试做其他事情直到数据可用。如图3所示这种方式也可能会造成延时,因为数据可用和用户调用read系统调用之间的时间间隔会降低整体数据的吞吐量。

异步阻塞I/O模型

另一种阻塞范例是具有阻塞通知的非阻塞I/O。在这种模型下,设备还是以非阻塞方式打开,然后应用程序阻塞在select系统调用中,用它来监听可用的I/O操作。select系统调用最大的好处是可以监听多个描述符,而且可以指定每个描述符要监听的事件:可读事件、可写事件和发生错误事件。
figure4
图4.异步阻塞I/O模型的典型流程

select系统调用的主要问题是效率不高。虽然它是一个非常方便的异步通知模型,但不建议将其用于高性能I/O中。(译者注:高性能场景一般使用epoll系统调用)

异步非阻塞I/O模型(AIO)

最后是异步非阻塞I/O模型他是可以并行处理I/O的模型之一。异步非阻塞I/O模型的读请求会立即返回,表明读操作成功启动。然后应用程序就可以在读操作完成之前做其他的事情。当读操作完成时,内核可以通过信号或者基于线程的回调函数来通知应用程序读取数据。
figure5
图5.异步非阻塞I/O模型的典型流程

在单个进程可以并行执行多个I/O请求是因为CPU的处理速度要远大于I/O的处理速度。 当一个或多个I/O请求在等待处理时,CPU可以处理其他任务或者处理其他已完成的I/O请求。

异步I/O的优点

在上面的介绍中可以发现同步阻塞模型在I/O请求时会被阻塞所以不能并行处理I/O请求。同步非阻塞模型可以并行处理但是它要求应用程序定期检查I/O的状态。只剩下异步模型可以并行处理I/O请求并且能够I/O完成通知。select系统函数的功能和AIO类似,但是它仍然是阻塞的,只不过它是阻塞在I/O通知上而不是I/O调用时。

Linux下的AIO

传统I/O模型中每个I/O通道都会使用一个唯一的句柄来指定,类UINX系统中这个句柄是文件描述符。阻塞I/O中你初始化一个I/O通道,系统调用会返回一个描述符给你或者出错返回错误码。

在异步I/O中,你可以同时初始化多个I/O通道。这样每个I/O通道都需要保存一个唯一的上下文,以便于当I/O操作完成后你能够识别时哪一个I/O通道。在AIO中这个上下文就是aiocb(AIO I/O Control Block)结构体。这个结构体保存了每个I/O通道的所有信息包括用于缓存数据的用户空间缓冲区。当I/O操作完成时,内核会提供这个I/O通道特定的aiocb结构体。下面的API会展示怎么使用它。

AIO的API

AIO的接口API非常简单,但它为几种不同的通知模型都提供了必要的函数。表1展示了AIO的所有API函数。

API函数 解释
aio_read 异步读请求
aio_error 检查异步请求的状态
aio_return 获取已完成的异步请求的返回状态
aio_write 异步写请求
aio_suspend 挂起调用进程,直到一个或多个异步请求完成(或者失败)
aio_cancel 取消一个异步请求
lio_listio 初始化I/O操作列表

上面的每个API函数都是通过aiocb结构体来初始化或者查询状态的。这个结构体有里面很多成员,下面这个列表只列出我们需要用到的成员:

struct aiocb {
 
  int aio_fildes;               // File Descriptor
  int aio_lio_opcode;           // Valid only for lio_listio (r/w/nop)
  volatile void *aio_buf;       // Data Buffer
  size_t aio_nbytes;            // Number of Bytes in Data Buffer
  struct sigevent aio_sigevent; // Notification Structure
 
  /* Internal fields */
  ...
 
};

其中的sigevent结构体用于告诉AIO当I/O请求完成后需要怎么做。你可以在AIO示例中看到这个结构体,接下来我会单独介绍每个API函数的使用方法。

aio_read

aio_read函数用于对一个有效的文件描述符发送异步读请求。这个文件描述符可以是一个文件、套接字或者管道。aio_read函数的定义如下:

int aio_read( struct aiocb *aiocbp );

当读请求被插入到队列之后aio_read函数会立即返回,成功时返回值为0,失败时返回值为-1,并且会设置errno全局变量。要执行读请求应用程序必须初始化aiocb结构体。下面的示例程序展示了如何填充aiocb结构体并使用aio_read函数去执行异步读请求(暂时忽略完成通知)。

#include <aio.h>
 
...
 
  int fd, ret;
  struct aiocb my_aiocb;
 
  fd = open( "file.txt", O_RDONLY );
  if (fd < 0) perror("open");
 
  /* Zero out the aiocb structure (recommended) */
  bzero( (char *)&my_aiocb, sizeof(struct aiocb) );
 
  /* Allocate a data buffer for the aiocb request */
  my_aiocb.aio_buf = malloc(BUFSIZE+1);
  if (!my_aiocb.aio_buf) perror("malloc");
 
  /* Initialize the necessary fields in the aiocb */
  my_aiocb.aio_fildes = fd;
  my_aiocb.aio_nbytes = BUFSIZE;
  my_aiocb.aio_offset = 0;
 
  ret = aio_read( &my_aiocb );
  if (ret < 0) perror("aio_read");
 
  while ( aio_error( &my_aiocb ) == EINPROGRESS ) ;
 
  if ((ret = aio_return( &my_iocb )) > 0) {
    /* got ret bytes on the read */
  } else {
    /* read failed, consult errno */
  }

示例程序首先打开你要读取数据的文件、初始化aiocb结构体为0,然后分配一块内存空间并将返回值赋值给aio_buf,将内存空间长度赋值给aio_nbytes,将aio_offset设置为0(表示同文件头开始读取数据),将你要读取的文件描述符赋值给aio_fildes。设置完这些字段之后调用aio_read函数。之后你可以调用aio_error函数来检查aio_read的状态。如果状态一直是EINPROGRESS则忙等直到状态改变,此时你的读请求要么成功要么失败。

请注意这和使用标准库函数执行读操作的区别,除了aio_read本身的不同之外,另一个区别是设置读操作时的偏移,在标准库函数中这个偏移在文件描述符的上下文中维护,每次读操作都会自动更新文件偏移,所以接下来的读操作总是读取的是下一个数据块。这在异步I/O中是不可能实现的因为你可以同时执行多个读操作,所以你必须每次执行读操作时自己指定文件偏移。

aio_error

aio_error函数用于检查请求的状态。它的定义如下:

int aio_error( struct aiocb *aiocbp );

此函数可以返回一下信息:

  • EINPROGRESS,表示此请求还没有完成
  • ECANCELLED,表示此请求被应用程序取消
  • -1,表示请求出现错误,你可以通过errno的值来检查错误的说明。

aio_return

异步I/O和标准阻塞I/O的另外一个不同之处在于你不能立即访问函数的返回状态,因为你没有被阻塞在read系统调用上。标准的read系统调用会将返回状态赋值在函数的返回值上。在异步I/O中你只能使用aio_return函数,此函数的定义如下:

ssize_t aio_return( struct aiocb *aiocbp );

这个函数只能在aio_error返回请求完成(成功或者出错)之后被调用。它的返回值和同步模型中的readwrite系统调用的返回值相同(成功传输的字节数或者错误返回-1)。

aio_write

aio_write用于异步I/O中的写请求,此函数的定义如下:

int aio_write( struct aiocb *aiocbp );

aio_write函数会立即返回,表示这个请求已经被加入到写队列中(成功时返回0,失败返回-1,并设置errno全局变量)。它和异步读函数类似但是有一个区别需要特别注意:异步读函数中设置文件偏移是非常重要的,但是在异步写操作中文件偏移只有在O_APPEND选项没有设置时才会起作用。如果O_APPEND选项被设置,则文件偏移会被忽略,数据总是会写入到文件的末端,否则数据会被写入到文件偏移所指定的地方。

aio_suspend

你可以调用aio_suspend函数阻塞进程直到产生一个信号来通知异步I/O请求已经完成,或者超时。调用者传入一组指向aiocb结构体的指针,至少其中一个完成操作则aio_suspend函数返回,此函数的定义如下:

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

aio_suspend函数的使用非常简单,一组指向aiocb结构体的指针。只要其中一个完成操作,此函数就会返回0成功或者-1失败,代码如下:

struct aioct *cblist[MAX_LIST]
 
/* Clear the list. */
bzero( (char *)cblist, sizeof(cblist) );
 
/* Load one or more references into the list */
cblist[0] = &my_aiocb;
 
ret = aio_read( &my_aiocb );
 
ret = aio_suspend( cblist, MAX_LIST, NULL );

注意aio_suspend的第二个参数是cblist的大小,不是aiocb结构体指针的数量。cblist中的NULL元素会被aio_suspend函数忽略。如果提供了一个超时时间给aio_suspend,当发生超时的时候会返回-1,并且errno会被设置为EAGAIN

aio_cancel

aio_cancel函数允许你取消一个或者一个给定文件描述符的所有未完成I/O请求。函数定义如下:

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

如果需要取消单个请求,需要提供文件描述符和一个aiocb结构体指针。如果I/O请求被成功取消,此函数会返回AIO_CANCELED,如果I/O请求已经完成,此函数会返回AIO_NOTCANCELED

如果需要取消给定描述符的所有请求。需要提供此描述符并将aiocbp设置为NULL。如果全部被取消则会返回AIO_CANCELED ,如果至少有一个不能被取消则会返回AIO_NOT_CANCELED,如果没有请求可以被取消则会返回AIO_ALLDONE。然后你可以使用aio_error函数来检查每个AIO请求,如果此I/O请求被取消则aio_error会返回-1,并且errno会被设置为ECANCELED

lio_listio

最后,AIO提供lio_listio函数用于同时初始化多个aiocb结构体。这个函数非常重要它意味着你可以在一次用户空间到内核的上下文切换(系统调用)上启动多个I/O操作。从性能的角度来看他是非常棒的,值得研究一番。lio_listio函数的定义如下:

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

其中的mode参数可以填写为LIO_WAIT或者LIO_NOWAITLIO_WAIT会阻塞调用直到所有I/O请求完成。LIO_NOWAIT会在I/O请求被加入到队列之后立即返回。list参数用于存放aiocb结构体的指针数组,数组的最大长度由参数nent指定。注意list数组中的NULL元素会被lio_listio函数直接忽略。sigevent参数用于指定所有I/O请求完成之后的信号通知方法。

lio_listio和常规的读写请求有些不同,因为他必须明确指定请求的类型。示例代码如下:

struct aiocb aiocb1, aiocb2;
struct aiocb *list[MAX_LIST];
 
...
 
/* Prepare the first aiocb */
aiocb1.aio_fildes = fd;
aiocb1.aio_buf = malloc( BUFSIZE+1 );
aiocb1.aio_nbytes = BUFSIZE;
aiocb1.aio_offset = next_offset;
aiocb1.aio_lio_opcode = LIO_READ;
 
...
 
bzero( (char *)list, sizeof(list) );
list[0] = &aiocb1;
list[1] = &aiocb2;
 
ret = lio_listio( LIO_WAIT, list, MAX_LIST, NULL );

aio_lio_opcode成员被赋值为LIO_READ表示为读操作。如果是写操作则为LIO_WRITE。也可以使用LIO_NOP表示无操作。

AIO通知

此时你已经知道了AIO的所有函数,接下来讨论几种异步通知的方法。信号和函数回调都会被介绍。

通过信号通知异步I/O

通过信号来进行进程间的通信时UNIX系统中的传统方法,它也支持AIO。在下面的示例中,应用程序定义了一个信号处理函数当指定信号产生时会调用此函数。然后设置异步请求完成时使用信号通知方式。 提供一个aiocb结构体作为信号上下文的一部分用于识别具体是哪一个I/O请求。

void setup_io( ... )
{
  int fd;
  struct sigaction sig_act;
  struct aiocb my_aiocb;
 
  ...
 
  /* Set up the signal handler */
  sigemptyset(&sig_act.sa_mask);
  sig_act.sa_flags = SA_SIGINFO;
  sig_act.sa_sigaction = aio_completion_handler;
 
 
  /* Set up the AIO request */
  bzero( (char *)&my_aiocb, sizeof(struct aiocb) );
  my_aiocb.aio_fildes = fd;
  my_aiocb.aio_buf = malloc(BUF_SIZE+1);
  my_aiocb.aio_nbytes = BUF_SIZE;
  my_aiocb.aio_offset = next_offset;
 
  /* Link the AIO request with the Signal Handler */
  my_aiocb.aio_sigevent.sigev_notify = SIGEV_SIGNAL;
  my_aiocb.aio_sigevent.sigev_signo = SIGIO;
  my_aiocb.aio_sigevent.sigev_value.sival_ptr = &my_aiocb;
 
  /* Map the Signal to the Signal Handler */
  ret = sigaction( SIGIO, &sig_act, NULL );
 
  ...
 
  ret = aio_read( &my_aiocb );
 
}
 
 
void aio_completion_handler( int signo, siginfo_t *info, void *context )
{
  struct aiocb *req;
 
 
  /* Ensure it's our signal */
  if (info->si_signo == SIGIO) {
 
    req = (struct aiocb *)info->si_value.sival_ptr;
 
    /* Did the request complete? */
    if (aio_error( req ) == 0) {
 
      /* Request completed successfully, get the return status */
      ret = aio_return( req );
 
    }
 
  }
 
  return;
}

示例中设置了操作系统监听SIGIO信号并调用aio_completion_handler函数来处理,然后设置aio_sigevent结构体指定异步请求完成时发起SIGIO信号通知(通过aio_sigevent结构体中的SIGEV_SIGNAL指定)。当读请求完成时,信号处理函数通过信号中的si_value结构体提取特定的aiocb结构体指针,然后检查它的错误状态和返回状态来确定I/O操作是否已经完成。

从性能的角度考虑,在信号处理函数中继续调用下一次异步I/O请求是一个非常好的选择。这样你就可以在完成一次I/O请求之后立即开始下一次I/O请求。

通过回调函数通知异步I/O

另一种通知机制是系统回调。不同于信号通知方式,系统回调是通过调用用户空间的一个函数来完成通知的。通过将aiocb结构体的指针赋值给aio_sigevent结构体中来识别特定的I/O请求。示例如下:

void setup_io( ... )
{
  int fd;
  struct aiocb my_aiocb;
 
  ...
 
  /* Set up the AIO request */
  bzero( (char *)&my_aiocb, sizeof(struct aiocb) );
  my_aiocb.aio_fildes = fd;
  my_aiocb.aio_buf = malloc(BUF_SIZE+1);
  my_aiocb.aio_nbytes = BUF_SIZE;
  my_aiocb.aio_offset = next_offset;
 
  /* Link the AIO request with a thread callback */
  my_aiocb.aio_sigevent.sigev_notify = SIGEV_THREAD;
  my_aiocb.aio_sigevent.notify_function = aio_completion_handler;
  my_aiocb.aio_sigevent.notify_attributes = NULL;
  my_aiocb.aio_sigevent.sigev_value.sival_ptr = &my_aiocb;
 
  ...
 
  ret = aio_read( &my_aiocb );
 
}
 
 
void aio_completion_handler( sigval_t sigval )
{
  struct aiocb *req;
 
  req = (struct aiocb *)sigval.sival_ptr;
 
  /* Did the request complete? */
  if (aio_error( req ) == 0) {
 
    /* Request completed successfully, get the return status */
    ret = aio_return( req );
 
  }
 
  return;
}

示例中创建一个aiocb结构体之后,通过SIGEV_THREAD来指定使用基于线程的回调函数进行通知。然后指定一个特定的回调函数来处理通知并加载要传递给回调函数的上下文(在这个例子中是一个aiocb结构体的指针)。在回调函数中通过传入的sigval指针获取aiocb结构体,然后使用AIO函数检查I/O操作是否已经完成。

AIO的系统设置

proc文件系统中有两个可以针对异步I/O性能进行调整的虚拟文件:

  • /proc/sys/fs/aio-nr文件提供了当前系统中所有异步I/O请求的数量
  • /proc/sys/fs/aio-max-nr文件用于设置异步I/O请求的最大并行数量。通常为64KB,它满足绝大多数应用。

总结

使用异步I/O可以帮助你创建更快更高效的I/O应用。如果你的应用程序可以并行处理I/O请求,AIO可以帮助你创建更加有效利用CPU的应用程序。而且这种I/O模型不同于绝大多数应用中使用的传统阻塞模型,异步通知模型非常简单可以简化你的设计(译者注:不是很赞同这里的说法,特别是信号通知方式,并不是很简单。。。)。

相关资料


下一篇 经典文章汇集

Comments

Content