Linux IO向量化:iovec与readv、writev系列函数

2023-10-18 10:49:53 浏览数 (1)

本文整合自三篇参考资料,具体引用见文末。

iovec结构体

struct iovec定义了一个向量元素。通常,这个结构用作一个多元素的数组。对于每一个传输的元素,指针成员iov_base指向一个缓冲区,这个缓冲区是存放的是readv所接收的数据或是writev将要发送的数据。成员iov_len在各种情况下分别确定了接收的最大长度以及实际写入的长度。且iovec结构是用于scatter/gather IO的。readv和writev函数用于在一次函数调用中读、写多个非连续缓冲区。有时也将这两个函数称为散布读(scatter read)和聚集写(gather write)。 iovec结构体的定义如下:

代码语言:javascript复制
struct iovec {
   /* Starting address (内存起始地址)*/
   void  *iov_base;   

    /* Number of bytes to transfer(这块内存长度) */
   size_t iov_len;    
};

linux中使用这样的结构体变量作为参数的函数很多,常见的有:

代码语言:javascript复制
 #include <sys/uio.h>

 ssize_t readv(int fd, const struct iovec *iov, int iovcnt);

 ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

 ssize_t preadv(int fd, const struct iovec *iov, int iovcnt, off_t offset);
 ssize_t pwritev(int fd, const struct iovec *iov, int iovcnt,off_t offset);     
 ssize_t preadv2(int fd, const struct iovec *iov, int iovcnt, off_t offset, int flags);
 ssize_t pwritev2(int fd, const struct iovec *iov, int iovcnt, off_t offset, int flags);

简介readv()和writev()

readv和writev作为read与write函数的衍生函数,在一个原子操作中读取或是写入多个缓冲区。readv和writev函数中的各参数的含义如下: 函数原型:

123

ssize_t readv(int fd, const struct iovec *iov, int iovcnt);ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

这两个函数需要三个参数:

  • 要在其上进行读或是写的文件描述符fd
  • 读或写所用的I/O向量(vector)
  • 要使用的向量元素个数(count)

这些函数的返回值是readv所读取的字节数或是writev所写入的字节数。如果有错误发生,就会返回-1,而errno存有错误代码。注意,也其他I/O函数类似,可以返回错误码EINTR来表明他被一个信号所中断。

举个例子,对于readv(),假如传入3个缓冲区每个缓冲区大小为16,当前文件偏移量为20,那么内核会把当前文件的偏移量[20,36)存入iovec[0],[36,52)存入iovec[1],[52,68)存入iovec[2].

对于写入操作同理。

readv()系列详解

read和pread是最基础的对文件读取的系统调用。read会从描述符为fd的文件中读取count个字节存入buf中,而pread则是从描述符为fd的文件中,从offset位置开始,读取count个字节存入buf中。如果读取成功,这两个系统调用都将返回读取的字节数。因此,这两个系统调用主要的区别就在于读取的位置,其它功能均类似。

有几点需要注意:

首先,是从哪开始读。pread64没有问题,就是从offset的位置开始读。而对于read,如果它读取的描述符对应的文件支持seek,那么它是从文件描述符中存储的文件偏移(file offset)处继续读。

对于支持seek的文件来说,read是从文件偏移的位置继续读,pread是从offset的位置开始读。

我们知道,更改文件偏移有单独的系统调用lseek,因此,如果我们要从某个特定的位置读取数据,可以lseek read,也可以pread。但是,系统调用实际上是一个复杂的耗时操作,所以pread就用一次系统调用解决了两个系统调用的问题。

第二,是读多少的问题。read和pread读取的字节数一定不大于count,但有可能小于count。假设说我们的二进制文件只有上述6个字节。那么,如果我们read了8个字节,后2个字节自然是无法被读取的。因此,只能读取到6个字节,read也将返回6。除此之外,还有很多可能会让read和pread读取的字节小于count。比如说,从一个终端读取(输入的字节小于其需求的字节),或者在读取时被某些信号中断。

此外,除了读的字节小于count之外,read和pread还有可能读取失败。此时的返回值将是-1。我们可以用errno查看其错误。文件描述符不可读或无效(EBADF),buf不可使用(EFAULT),文件描述符是目录而非文件(EISDIR)等等,这些都有可能直接造成读取的错误。对于以非阻塞形式打开的文件,还可能返回EAGAIN或EWOULDBLOCK,详情请见open。

第三,是读完当read或pread读取结束后的工作。read会更新文件描述符中的文件偏移,它们读了多少字节,就向后移动多少字节。但是,值得注意的是,pread并不会更新文件偏移。pread不更新文件偏移这一点对于多线程的程序来说极其有用。我们知道,多条线程有可能共用同一个文件描述符,但文件偏移是存储在文件描述符中。如果我们在多线程中使用read,会导致文件偏移混乱;但是,如果我们使用pread,则会完满避免这个问题。

第四,是如何读。在Linux的哲学中,如何读并不是read和pread能决定的,而是由文件描述符本身决定的。文件描述符在创建的时候,就决定了它将被如何读取,比如说是否阻塞等等。

我们在上面提到,pread除了在多线程中发挥大作用之外,也可以将两次系统调用lseek read化为一次系统调用。而这一节所讲的系统调用,则是更进一步。read和pread是将文件读取到一块连续内存中,那如果我们想要将文件读取到多块连续内存中(也就是说,有多块内存,内存内部连续,但内存之间不连续),就得多次使用这些系统调用,造成很大的开销。而readv, preadv, preadv2则是为了解决这样的问题。

readv, preadv, preadv2的第二个参数iov是一个iovec结构体组成的数组,其元素个数由第三个参数iovcnt给出。这三个系统调用的作用就是“分散读”(scatter input),将一块连续的文件内容,按顺序读入多块连续区域中。

在preadv和preadv2中,我们可以看到,其系统调用接口含有两个参数pos_l与pos_h,但glibc封装后只有一个参数offset。这是因为,考虑到64位地址的问题,pos_l和pos_h分别包含了offset的低32位和高32位。

此外,还需要注意,这里的读取虽然说是“向量化”,但实际上,缓冲区是按数组顺序处理的,也就是说,只有在iov[0]被填满之后,才会去填充iov[1]。

同样类似read与pread,这三个系统调用也是返回读取的字节数,同样可能会小于iov->iov_len之和。

与read和pread不同的是,这三个系统调用是原子性的,它们读取的文件内容永远是连续的,也就是说不会因为文件偏移被别的线程改变而混乱。比如说,我们想将文件中的内容读入三块缓冲区中。如果我们是使用三次read,但是在第一次read结束之后,第二次read开始之前,另外一个线程对这个文件描述符的文件偏移进行了改变,那么接下来的两次read读出的数据与第一次read读出的数据是不连续的。但是,如果我们用readv,读出的数据一定是连续的。

而preadv与preadv2的区别,主要在于最后一个参数。它通过一些标志位来改变读取的行为。具体可以看其手册preadv2。

关于文件偏移的更新,readv和read一样,在结束之后会更新文件偏移;preadv和pread一样,在结束之后不会更新文件偏移;对于preadv2来说,如果offset为-1,其会使用当前的文件偏移而不是前往指定的文件偏移,并且在结束后会更新文件偏移,但是如果其不为-1,则不会更新文件偏移。

writev()系列详解

write和pwrite是最基础的对文件写入的系统调用。write会将buf中count个字节写入描述符为fd的文件中,而pread则会将buf中count个字节写入描述符为fd的文件从offset开始的位置中。如果写入成功,这两个系统调用都将返回写入的字节数。因此,这两个系统调用主要的区别就在于写入的位置,其它功能均类似。

注意点:

首先,是文件偏移的问题:

·写入前位置

显然,pwrite是从文件偏移为offset的位置开始写入,但是write的问题则比较特殊。一般来说,write开始写入时的文件偏移就是当前的文件偏移,但是,当文件描述符是通过open系统调用创建,且创建时使用了O_APPEND标志位的话,每次write开始写入前,都会默认将文件偏移移到文件末尾。

·写入后位置

同read和pread类似,write在成功写入n个字节后,会将文件偏移更新n个字节;但pwrite则不会更新文件偏移,因此和pread一起常用于多线程的代码中。

readv, preadv, preadv2类似,writev(), pwritev(), pwritev2()是为了解决一次性从多个连续内存向一个文件描述符写入的问题,这三个系统调用被称为“聚合写”(gather output)。

这三个函数的特性与readv, preadv, preadv2十分类似,这里不再赘述。

参考资料

  • https://blog.csdn.net/baidu_15952103/article/details/109888362
  • https://tangyilong.com/2019/02/20/struct-iovec/
  • https://evian-zhang.github.io/introduction-to-linux-x86_64-syscall/src/filesystem/write-pwrite64-writev-pwritev-pwritev2.html
  • https://evian-zhang.github.io/introduction-to-linux-x86_64-syscall/src/filesystem/read-pread64-readv-preadv-preadv2.html

0 人点赞