writev遇到非阻塞IO

这几天在处理一些协议上面的东西,还算简单吧,封装一些字段然后发出去。

问题就出现在了发出去这个地方,发的地方使用的是writev,writev可以发送多个分散的,不连续的内存里面的东西。比方说,我封装了http的头部,放到了一个httpHeadBuf里面,然后又组装了body,放到了sendBuf里面,在组包的时候,各自组包互不干涉。然后发送的时候,使用writev进行发送,writev以顺序iov[0]、iov[1]至iov[iovcnt-1]从各缓冲区中聚集输出数据到fd。

读的时候呢也是这样从fd中读到不同数据到不同的buf里面,各自处理。

是不是很巧妙的方式?

1
2
3
4
5
6
7
#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);
struct iovec {
void *iov_base; /* Starting address */
size_t iov_len; /* Number of bytes to transfer */
};

还有一个很巧妙的地方在于,当writev遇到了非阻塞socket,就会发生神奇的事情。

阻塞 非阻塞

在网络编程中,一个套接字sockfd,它默认就是阻塞IO

1、阻塞IO模型

  最传统的一种IO模型,即在读写数据过程中会发生阻塞现象。

  当用户线程发出IO请求之后,内核会去查看数据是否就绪,如果没有就绪就会等待数据就绪,而用户线程就会处于阻塞状态,用户线程交出CPU。当数据就绪之后,内核会将数据拷贝到用户线程,并返回结果给用户线程,用户线程才解除block状态。

典型的阻塞IO模型的例子为:

1
data = socket.read();

如果数据没有就绪,就会一直阻塞在read方法。

2、非阻塞IO模型

当用户线程发起一个read操作后,并不需要等待,而是马上就得到了一个结果。如果结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦内核中的数据准备好了,并且又再次收到了用户线程的请求,那么它马上就将数据拷贝到了用户线程,然后返回。

所以事实上,在非阻塞IO模型中,用户线程需要不断地询问内核数据是否就绪,也就说非阻塞IO不会交出CPU,而会一直占用CPU。

典型的非阻塞IO模型一般如下:

1
2
3
4
5
6
7
while(true){
data = socket.read();
if(data!= error){
处理数据
break;
}
}

但是对于非阻塞IO就有一个非常严重的问题,在while循环中需要不断地去询问内核数据是否就绪,这样会导致CPU占用率非常高,因此一般情况下很少使用while循环这种方式来读取数据。一般使用epoll, select, poll等来配合使用。

writev readv

为什么引出readv()和writev()

1.因为使用read()将数据读到不连续的内存、使用write()将不连续的内存发送出去,要经过多次的调用read、write
如果要从文件中读一片连续的数据至进程的不同区域,有两种方案:①使用read()一次将它们读至一个较大的缓冲区中,然后将它们分成若干部分复制到不同的区域; ②调用read()若干次分批将它们读至不同区域。
同样,如果想将程序中不同区域的数据块连续地写至文件,也必须进行类似的处理。
2.怎么解决多次系统调用+拷贝带来的开销呢?
UNIX提供了另外两个函数—readv()和writev(),它们只需一次系统调用就可以实现在文件和进程的多个缓冲区之间传送数据,免除了多次系统调用或复制数据的开销。

readv/writev

在一次函数调用中:
① writev以顺序iov[0]、iov[1]至iov[iovcnt-1]从各缓冲区中聚集输出数据到fd
② readv则将从fd读入的数据按同样的顺序散布到各缓冲区中,readv总是先填满一个缓冲区,然后再填下一个

1
2
3
4
5
6
7
#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);
struct iovec {
void *iov_base; /* Starting address */
size_t iov_len; /* Number of bytes to transfer */
};

(1) 参数:readv和writev的第一个参数fd是个文件描述符,第二个参数是指向iovec数据结构的一个指针,其中iov_base为缓冲区首地址,iov_len为缓冲区长度,参数iovcnt指定了iovec的个数。
(2) 返回值:函数调用成功时返回读、写的总字节数,失败时返回-1并设置相应的errno。

示例代码
writev:指定了两个缓冲区,str0和str1,内容输出到标准输出,并打印实际输出的字节数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// writevex.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/uio.h>

int main()
{
char *str0 = "hello ";
char *str1 = "world\n";
struct iovec iov[2];
ssize_t nwritten;

iov[0].iov_base = str0;
iov[0].iov_len = strlen(str0);
iov[1].iov_base = str1;
iov[1].iov_len = strlen(str1);

nwritten = writev(STDOUT_FILENO, iov, 2);
printf("%ld bytes written.\n", nwritten);

exit(EXIT_SUCCESS);
}

执行结果

1
2
3
4
$ gcc writevex.c 
$ ./a.out
hello world
12 bytes written.

readv:从标准输入读数据,缓冲区为长度是(8 - 1)的buf1和buf2,并打印读到的字节总数和两个缓冲区各自的内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/uio.h>

int main()
{
char buf1[8] = { 0 };
char buf2[8] = { 0 };
struct iovec iov[2];
ssize_t nread;

iov[0].iov_base = buf1;
iov[0].iov_len = sizeof(buf1) - 1;
iov[1].iov_base = buf2;
iov[1].iov_len = sizeof(buf2) - 1;

nread = readv(STDIN_FILENO, iov, 2);
printf("%ld bytes read.\n", nread);
printf("buf1: %s\n", buf1);
printf("buf2: %s\n", buf2);

exit(EXIT_SUCCESS);
}

执行结果:

1
2
3
4
5
6
$ gcc readvex.c
$ ./a.out
helloreadv
11 bytes read.
buf1: hellore
buf2: adv

writev的问题

POSIX提供了一个比write函数更加高级的writev,在很多场景下,它相对于write有一定的优势。但是并没有什么特别的。APUE一书将writev的介绍放在了Advanced I/O部分,个人拙见,它和write应该是属于同层次的IO,谈不上Advanced。

writev并不比write高级

大体而言,write面向的是连续内存块,writev面向的是分散的数据块,两个函数的最终结果都是将内容写入连续的空间。

假设我们需要将N个key-value组合dump到文件中。

1
2
3
key1 = val1
key2 = val2...
keyN = valN

已知每个pair的空间是单独分配的,那么在这个场景下,如果想要使用write完成任务,有如下2种做法:

  1. 分配一块大空间,将每个pair复制到其中,然后write
  2. 分别write每个pair

在数据量不是太大的情况下,方案1比方案2要高效,应为syscall的开销是很可观的,当每个pair的数据都比较大时,首选方案2。怎么判断这里的临界值?对于磁盘IO,考虑pagesize,这个临界值很可能是N*pagesize(N>=1),当然我没有具体考证。

这个时候,我们就会期望能够有一个函数可以做到:

  1. 一次syscall
  2. 无拷贝

显然,它就是writev了,其函数原型如下:

1
2
3
4
5
writev(int fd, struct iovect* iov, int iovcnt); 
struct iovec {
void* iov_base;
size_t iov_len;
};

iov_base就是每个pair的基址,iov_len则是长度,不用包含“\0”。

APUE中指出writev的固有开销比write大,因此对于小内存的写而言,很可能也没有copy+write高效,具体参考APUE(en) P.522。另外,iovcnt不应超过IOV_MAX,Linux上的IOV_MAX=1024,且iov_len的总和不应溢出,虽然超过限制的情况很少出现,但应该考虑到。

除此之外,另一种比较高效的做法是,分配一大块内存,想办法让所有的pair连续,这样就不需要考虑write了,但是很多情况下你可能根本无法预先获知总长度,例如从socket中读取的数据,为了能够容纳所有的数据,就不得不分配适当大的数据,当不够用的时候再realloc。

如此一来,会造成空间利用率稍低的问题,且realloc很可能带来潜在的内存拷贝开销。

writev的实现

在Linux2.2之前,由于IOV_MAX过于小,glibc会提供一个wrapper function,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/* Write data pointed by the buffers described by VECTOR, which
is a vector of COUNT 'struct iovec's, to file descriptor FD.
The data is written in the order specified.
Operates just like 'write' (see <unistd.h>) except that the data
are taken from VECTOR instead of a contiguous buffer. */
ssize_t __writev (int fd, const struct iovec *vector, int count) {
/* Find the total number of bytes to be written. */
size_t bytes = 0;
for (int i = 0; i < count; ++i) {
/* Check for ssize_t overflow. */
if (SSIZE_MAX - bytes < vector[i].iov_len) {
__set_errno (EINVAL);
return -1;
}
bytes += vector[i].iov_len;
}

/* Allocate a temporary buffer to hold the data. We should normally
use alloca since it's faster and does not require synchronization
with other threads. But we cannot if the amount of memory
required is too large. */
char *buffer;
char *malloced_buffer __attribute__ ((__cleanup__ (ifree))) = NULL;
if (__libc_use_alloca (bytes))
buffer = (char *) __alloca (bytes);
else {
malloced_buffer = buffer = (char *) malloc (bytes);
if (buffer == NULL)
/* XXX I don't know whether it is acceptable to try writing
the data in chunks. Probably not so we just fail here. */
return -1;
}

/* Copy the data into BUFFER. */
size_t to_copy = bytes;
char *bp = buffer;
for (int i = 0; i < count; ++i) {
size_t copy = MIN (vector[i].iov_len, to_copy);
bp = __mempcpy ((void *) bp, (void *) vector[i].iov_base, copy);
to_copy -= copy;
if (to_copy == 0) break;
}
ssize_t bytes_written = __write (fd, buffer, bytes);
return bytes_written;
}
weak_alias (__writev, writev)

大致流程就是:

  1. 计算总长度
  2. 分配空间(栈/堆)
  3. 拷贝数据
  4. 使用write

这么做完全是权宜之计,并没有体现writev的优点,如果没有一次写完,那么就需要多次复制。

内核中的实现则是有点类似分别对内存块write,只不过由于已经位于内核空间,自然没有什么syscall的开销了,也能使用更加直接的方式,比如直接写buf,代码在fs/read_write.c,感兴趣的读者可以挖掘下。

writev的问题

writev的出发点是好的,并且看起来似乎也比较美好,因此很受推崇。

通过这两天的使用情况来看,我个人认为writev在设计上可能存在一定的问题,产生这些问题的具体场景为socket IO,令人遗憾的是,尽管很多人推崇writev,但是google相关内容,资料却少得可怜。。。

对于socket IO而言,write经常不能够一次写完,好在它会返回已经写了多少字节,如果继续写,此时就会阻塞;对于非阻塞socket而言,write会在buf不可写时返回的EAGAIN,那么在下一次write时,便可通过之前返回的值重新确定基址和长度。

manual中对于writev的相关描述为:和write类似。也就是说,它也会返回已经写入的长度或者EAGAIN(errno)。千万不可天真地认为,每次传同样的iovec就能解决问题,writev并不会为你做任何事情,重新处理iovec是调用者的任务。

问题是,这个返回值“实用性”并不高,因为参数传入的是iovec数组,计量单位是iovcnt,而不是字节数,用户依旧需要通过遍历iovec来计算新的基址,另外写入数据的“结束点”可能位于一个iovec的中间某个位置,因此需要调整临界iovec的io_base和io_len。

可以通过如下代码确认:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

while (iov_iter_count(iter)) {
struct iovec iovec = iov_iter_iovec(iter);
ssize_t nr;
nr = fn(filp, iovec.iov_base, iovec.iov_len, ppos);

if (nr < 0) {
if (!ret)
ret = nr;
break;
}
ret += nr;
if (nr != iovec.iov_len)
break;
iov_iter_advance(iter, nr);
}

个人认为这个设计和write并不是一个风格,write使用很方便,writev却很繁琐,难道仅从参数和返回的类型就能确定一套API的风格了?

个人认为下述方案或许更好:

  1. 返回已经写入了多少个iovec,并通过参数返回部分写入的iovec的字节数
  2. 提供offset参数,供指定写入的开始位置

在do_loop_readv_writev中可以直接加入相关逻辑,对于do_iter_readv_writev或许会麻烦点,但是在回调中应该不难解决问题。好吧,说再多也只是纸上谈兵。相信这并不是实现者的问题,而是POSIX在制定接口时就欠缺考虑。

总结

对于磁盘IO,可以放心使用writev,对于socket,尤其是非阻塞socket,还是尽可能避免的好,实现连续的内存块反而可以简化实现。

好吧,我写这篇文章,其实只是想吐槽一下writev函数的,如果你用这个函数来处理非阻塞的文件描述符,应该会感觉这个玩意和鸡肋一样.

man手册里说,它的行为和write差不多:

The writev() system call works just like write(2) except that multiple buffers are written out.

不过writev是分散写,也就是你的数据可以这里一块,那里一块,然后只要将这些数据的首地址,长度什么的写到一个iovc的结构体数组里,传递给writev,writev就帮你来写这些数据,在你看来,分散的数据就像是连成了一体.

对于阻塞IO,这个函数应该是很好用的,对于非阻塞IO,你如果想用的话,要做的工作估计还很多,我们假想一下,如果writev返回一个大于0的值num,这个值又小于所有要传递的文件块的总长度,这意味着什么,意味着数据还没有写完啊.如果你还想写的话,你下一次调用writev的时候要重新整理iovc数组,这坑爹呢.

首先,你要一块一块比对大小,确定已经写了多少块数据,然后对于那个写了一点的块,要将iovc[0].iov_base指向下一个开始的字节…,好吧,听起来就烦,好吧,你看到了,其实还不如直接用write呢.


原文:https://blog.csdn.net/lishuhuakai/article/details/52967050

原文:https://blog.csdn.net/weixin_36750623/article/details/84579243

原文:https://blog.csdn.net/linuxheik/article/details/76125411