如何用AIO技术提高程序性能

写在前面

这是一篇关于 AIO 的文章。本篇文章详细对比了几个常见的I/O模型,并且介绍了AIO相关的一些API。

我把英文原文翻译过来整理成这篇文章。目的一个是自己学习,一个是方便不习惯看英文资料的同学进行学习。

英文原文地址:

https://developer.ibm.com/articles/l-async/

英文题目是:
Boost application performance using asynchronous I/O


正文开始

AIO介绍

Linux 异步 I/O 是最近 Linux 内核新增功能。 这是 2.6 内核的标准功能,在 2.4 版本是作为补丁出现的。 AIO 背后的基本思想是允许进程启动许多 I/O 操作,而不必阻塞或等待任何操作完成。 然后可以在后面收到 I/O 完成通知后,进程进一步查询 I/O 的结果。

I/O 模型

在深入研究 AIO API 之前,让我们先梳理一下 Linux 下不同 I/O 模型。这里只是简述这些不同的模型,目的是对这些模型做一些比较让你明白他们之间的区别。图 1 显示了同步和异步模型,以及阻塞和非阻塞模型。

在这里插入图片描述

从图中我们可以看到AIO所处的位置。

同步阻塞I/O

最常见的模型之一是同步阻塞I/O模型。在此模型中,用户空间应用程序执行系统调用,这个调用会导致阻塞。 这意味着应用程序会阻塞,直到系统调用完成(数据传输完成或错误)。调用方应用程序处于等待响应的状态,但是不消耗 CPU,从这个角度来看它还算高效。

图 2 展示了传统的阻塞 I/O 模型,这也是应用程序中最常用的模型。它的行为很好理解,并且它的使用对于典型的应用程序是有效的。 当调用 read 系统调用时,应用程序阻塞并且上下文切换到内核。然后开始读取,当响应返回时(从正在读取的设备),数据被移动到用户空间缓冲区。 然后应用程序被解除阻塞(并且读取调用返回)。

在这里插入图片描述

从应用程序的角度来看,读操作的持续时间很长。但是实际上只是在内核被阻塞了。

同步非阻塞I/O

同步阻塞的一个效率较低的变体是同步非阻塞 I/O。 在此模型中,设备以非阻塞方式打开。 这意味着读操作可能不会立即完成 I/O,而是返回一个错误代码表示无法立即满足命令(EAGAIN 或 EWOULDBLOCK),如图 3 所示。

在这里插入图片描述

非阻塞的含义是 I/O 命令可能不会立即得到满足,需要应用程序进行多次调用以等待完成。这可能导致效率极低,因为在许多情况下,应用程序必须一直在等待数据可用,或者在内核中执行命令时尝试执行其他工作。 如图 3 所示,此方法会在 I/O 中引入延迟,因为数据在内核中变得可用与用户调用 read 以返回数据之间的任何间隙都会降低整体数据吞吐量。

异步阻塞I/O

另一个阻塞范例是带有阻塞通知的非阻塞 I/O。 在此模型中,配置了非阻塞 I/O,然后使用阻塞 select 系统调用来确定 I/O 描述符何时有任何的变化。 select 调用的有趣之处在于,它不仅可以为一个描述符提供通知,还可以为多个描述符提供通知。对于每个描述符,可以请求接受的通知包括:描述符写入数据、读取数据以及是否发生错误。

在这里插入图片描述

select 调用的主要问题是效率不高。 虽然它是异步通知的模型,但不建议将其用于高性能 I/O。

异步非阻塞I/O

最后,异步非阻塞 I/O 模型是一种I/O重叠处理的模型。读取请求立即返回,表示读取成功启动。然后应用程序可以在后台读取操作完成时执行其他处理。当读取响应到达时,可以生成信号或基于线程的回调来完成 I/O 操作。

在这里插入图片描述

重叠计算和在单个进程中为多个 I/O 请求的能力来自,利用了处理速度和 I/O 速度之间的差距。当一个或多个慢速I/O请求挂起时,CPU可以执行其他任务,或者更常见的是,在启动其他 I/O 时对已完成的 I/O 进行操作。

下一节将进一步剖析这个模型,看看他的API。

异步I/O出现的动机

从之前的 I/O 模型分类中,可以看出 AIO 存在的必要性。 阻塞模型要求启动应用程序在 I/O 启动时阻塞。 这意味着不可能同时重叠处理和 I/O。 同步非阻塞模型允许处理和 I/O 重叠,但它要求应用程序定期检查 I/O 的状态。 这留下了异步非阻塞 I/O,它允许处理和 I/O 重叠,包括 I/O 完成通知。

select 函数(异步阻塞 I/O)提供的功能类似于 AIO,只是它在获取结果时仍然是阻塞的。

linux中的异步I/O

本节探讨 Linux 的异步 I/O 模型,以帮助你了解如何在应用程序中应用它。

AIO 在 2.5 中首次进入 Linux 内核,现在是 2.6 生产内核的标准功能。

在传统的 I/O 模型中,有一个由唯一句柄标识的 I/O 通道。 在 UNIX® 中,这些叫做文件描述符(对于文件、管道、套接字等都是相同的)。在阻塞I/O模型中,你启动传输,系统调用在传输完成或发生错误时返回。

在异步非阻塞 I/O 模型中,可以同时启动多个传输。这需要每次传输都有一个唯一的上下文,以便可以在传输完成时识别它。 在 AIO中,一个叫aiocb(AIOI/O控制块)结构扮演这个角色。该结构包含有关传输的所有信息,包括数据的用户缓冲区。 当发生 I/O 通知(完成)时,将提供 aiocb 结构来唯一标识已完成的 I/O。 下一个章节的API部分展示了如何执行此操作。

AIO API 介绍

AIO 接口 API 非常简单,它通过几种不同的通知模型为数据传输提供了必要的功能。

这些 API 函数中的每一个都使用 aiocb 结构来启动或检查。这个结构体有许多成员变量,下面这个清单 1 只显示了必要的元素。

struct aiocb {

  int aio_fildes;               // 文件描述符
  int aio_lio_opcode;           // lio_listio (r/w/nop)使用
  volatile void ∗aio_buf;       // 数据缓冲区
  size_t aio_nbytes;            // 数据缓冲区的数据大小
  struct sigevent aio_sigevent; // 通知结构体

  /∗ Internal fields ∗/
  ...

};

sigevent 结构告诉 AIO 在 I/O 完成时要做什么。 后面还会讲到此结构。 现在我们来看看 AIO 的各个 API 函数如何工作以及如何使用它们。

aio_read

aio_read 函数请求对有效文件描述符的异步读取操作。文件描述符可以是一个文件、一个套接字,甚至是一个管道。 aio_read 函数具有以下原型:

int aio_read( struct aiocb ∗aiocbp );

aio_read 函数在请求排队后立即返回。 成功时返回值为零,错误时返回 -1,其中定义了 errno。

要执行读取,应用程序必须初始化 aiocb 结构。 以下示例说明了填充 aiocb 请求结构并使用 aio_read 执行异步读取请求(暂时忽略通知)。 它还显示了 aio_error 函数的使用,稍后会解释这个函数。

#include <aio.h>

...

  #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 结构清零,然后分配一个数据缓冲区。

对数据缓冲区的引用放在 aio_buf 中。 随后将缓冲区的大小初始化为 aio_nbytes。 aio_offset 设置为零(文件中的第一个偏移量)。 您将要从中读取的文件描述符设置为 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, 请求发生错误

aio_return

异步 I/O 和标准阻塞 I/O 之间的另一个区别是无法立即访问函数的返回状态,因为您没有阻塞 read 调用。 在标准读取调用中,返回状态在函数返回时提供。 对于异步 I/O则可以使用 aio_return 函数。 该函数原型:

ssize_t aio_return( struct aiocb ∗aiocbp );

只有在 aio_error 调用确定您的请求已完成(成功或错误)后,才会调用此函数。 aio_return 的返回值与同步上下文中的 read 或 write 系统调用的返回值相同(传输的字节数或 -1 表示错误)。

aio_write

aio_write 用来执行异步写入操作。 它的原型是:

int aio_write( struct aiocb ∗aiocbp );

aio_write 函数立即返回,表明请求已入队(成功时返回 0,失败时返回 -1,并正确设置了 errno)。

这类似于 read 系统调用,但有一个行为差异值得注意。 回想一下,偏移量对于 read 调用很重要。 但是,对于写入,偏移量只有在未设置 O_APPEND 选项的文件上下文中使用时才重要。 如果设置了 O_APPEND,则忽略偏移量并将数据附加到文件末尾。否则,aio_offset字段确定数据写入文件的偏移量。

aio_suspend

可以使用 aio_suspend 函数挂起(或阻塞)调用进程,直到异步 I/O 请求完成、发出信号或发生可选超时。 调用者提供了一个 aiocb 引用列表,其中至少一个的完成将导致 aio_suspend 返回。 aio_suspend 的函数原型是:

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

aio_suspend 使用非常简单。 提供一个 aiocb 参考列表。 如果其中任何一个完成,则调用返回 0。否则,返回 -1,表示发生错误。 请参见下面的示例:

struct aioct ∗cblistMAX_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 引用的数量。 aio_suspend 将忽略 cblist 中的任何 NULL 元素。

如果向 aio_suspend 提供超时并且发生超时,则返回 -1 并且 errno 包含 EAGAIN。

aio_cancel

aio_cancel 函数可以针对一个文件描述符取消一个或者所有的 I/O 请求。它的原型是:

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

要取消单个请求,需要提供文件描述符和 aiocb 引用。 如果请求成功取消,该函数返回 AIO_CANCELED。 如果请求完成,该函数返回 AIO_NOTCANCELED。

取消对给定文件描述符的所有请求,请提供该文件描述符和aiocbp的NULL引用。如果所有请求都被取消,该函数返回 AIO_CANCELED,如果至少有一个请求无法取消,则返回AIO_NOT_CANCELED,如果没有一个请求可以取消,则返回 AIO_ALLDONE。 然后,可以使用 aio_error 评估每个单独的 AIO 请求。 如果请求被取消,aio_error 返回 -1,并且 errno 设置为 ECANCELED。

lio_listio

最后,AIO 提供了一种使用lio_listioAPI函数同时启动多个传输的方法。这个函数很重要,因为它意味着可以在单个系统调用的上下文中启动大量I/O(内核上下文切换)。从性能的角度来看,这很棒,值得研究。 lio_listio API 函数具有以下原型:

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

mode参数可以是 LIO_WAIT 或 LIO_NOWAIT。 LIO_WAIT 会阻塞调用,直到所有 I/O 完成。 LIO_NOWAIT 在操作排队后返回。 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 );

读取操作在带有 LIO_READ 的 aio_lio_opcode 字段中注明。 对于写操作,使用 LIO_WRITE,但LIO_NOP 对于无操作也有效。

AIO通知

相信现在你已经了解了可用的AIO函数,本节将深入研究可用于异步通知的方法。我将通过信号和函数回调两个方面来说明异步通知。

使用signal做异步通知

使用信号进行进程间通信 (IPC)是UNIX中的传统机制,AIO也支持。在此范例中,应用程序定义了一个信号处理程序,当指定信号发生时调用该处理程序。

然后,应用程序指定异步请求将在请求完成时发出信号。 作为信号上下文的一部分,提供特定的 aiocb 请求以跟踪多个潜在未完成的请求。 下面这个示例演示了这种通知方法。

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;
}

在这个示例中,设置了信号处理程序以在 aio_completion_handler 函数中捕获 SIGIO 信号。

然后初始化 aio_sigevent 结构以引发 SIGIO 通知(通过 sigev_notify 中的 SIGEV_SIGNAL 定义指定)。 当读取完成时,信号处理程序从信号的 si_value 结构中提取特定的 aiocb 并检查错误状态和返回状态以确定 I/O 完成。

就性能而言,完成处理程序是通过请求下一个异步传输来继续I/O的理想位置。这样,当一个传输完成后,立即开始下一个。

使用回调做异步通知

另一种通知机制是系统回调。 该机制不是发出通知信号,而是调用用户空间中的函数进行通知。 这个函数在 aiocb 引用初始化到 sigevent 结构中,以唯一标识正在完成的特定请求。下面是示例:

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 指针并使用 AIO 函数来验证请求的完成。

总结

使用异步 I/O 可以帮助你构建更快、更高效的 I/O 应用程序。 如果您的应用程序可以重叠处理 I/O,那么 AIO 可以帮助你构建更有效地使用可用 CPU 资源的应用程序。 虽然这种 I/O 模型与大多数 Linux 应用程序中的传统阻塞模式不同,但异步通知模型在概念上很简单,还可以简化你的设计。

猜你喜欢

转载自blog.csdn.net/pony_maggie/article/details/122766050
今日推荐