0%

高级I/O函数

pipe函数

pipe函数可用于创建一个管道,以实现进程间通信(例如父子进程间通信)。pipe函数的定义如下:

1
2
#include <unistd.h>
int pipe(int fd[2]);

pipe函数的参数是一个包含两个int型整数的数字指针,是作结果输出的参数。该函数成功时返回0,并将一对打开的文件描述符值填入其参数指向的数组。如果失败,则返回-1,并设置errno。

通过pipe函数创建的这两个文件描述符fd[0],fd[1]分别构成管道的两端,往fd[1]写入的数据可以从fd[0]读出,并且,fd[0]只能用于从管道读出数据,fd[1]则只能用于往管道写入数据,而不能反过来使用,如果要实现双向的数据传输,就应该使用两个管道。

另外,默认情况下,这一对文件描述符都是阻塞的。如果我们用read系统调用来读取一个空的管道,则read将被阻塞,直到管道内有数据可读,如果我们用write系统调用来读取一个空的管道,则read将被阻塞,同理如果用write系统调用来往一个满的管道中写入数据,则write也将被阻塞,直到管道有足够多的空闲空间可用。

如果管道的写端文件描述符fd[1]的引用计数减少至0,即没有任何进程需要往管道中写入数据,则针对管道的读端文件描述符fd[0]的read操作将返回0,即读取到了文件结束标记(End of File, EOF);反之,如果管道的读端文件描述符fd[0]的引用计数减少至0,即没有任何进程需要从管道读取数据,则针对管道的写端文件描述符fd[1]的write操作将失败,并引发SIGPIPE信号。

管道内传输的数据是字节流,管道本身有一个容量限制,它规定如果应用程序不将数据从管道读走的话,该管道最多能被写入多少个字节的数据。自Linux2.6.11内核起,管道容量的大小默认是65536字节。我们可以使用fcntl函数来修改管道容量。

其实shell命令中的管道操作的实现也是通过pipe函数来实现的。

pipe的实现原理,其实是在操作系统内核中开辟了一个缓冲区(位于内存),然后让返回的两个文件描述符都指向这个内核缓存区,然后设置一个文件描述符只能读,一个文件描述符只能写。写pipe时需要将数据从用户空间拷贝到内核缓冲区,读数据时需要将数据从内核缓冲区拷贝到用户空间。

pipe只能用在两个有亲缘关系的进程上,例如父子进程;如果要在两个没有关系的进程上用管道通信,需要使用fifo命名管道,FiFo命名管道利用了磁盘文件。

dup函数和dup2函数

有时候我们希望把标准输入重定向到一个文件,或者把标准输出重定向到一个网络连接,这可以通过下面的用于复制文件描述符的dup或者dup2函数来实现。

1
2
3
#include <unistd.h>
int dup(inf fd);
int dup2(int fd_one, int fd_two);

dup函数创建一个新的文件描述符,该文件描述符和原有文件描述符fd指向相同的文件、管道或者网络连接。并且dup返回的文件描述符总是取系统当前可用的最小整数值。dup2和dup类型,不过它将返回第一个不小于(大于等于)fd_two的整数值。dup和dup2系统调用失败时返回-1并设置errno。

利用dup函数实现将标准输出重定向到一个网络连接中:

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
47
#include <iostream>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

int main() {
sockaddr_in sockAddress{};
sockAddress.sin_family = AF_INET;
inet_pton(AF_INET, "localhost", &sockAddress.sin_addr);
sockAddress.sin_port = htons(5000);

int sockFD = socket(PF_INET, SOCK_STREAM, 0);
if (sockFD < 0) {
std::cerr << "create socket fail" << std::endl;
return sockFD;
}

int ret = bind(sockFD, (sockaddr *)&sockAddress, sizeof(sockAddress));
if (ret < 0) {
std::cerr << "socket bind fail" << std::endl;
return ret;
}

ret = listen(sockFD, 5);
if (ret < 0) {
std::cerr << "socket listen fail" << std::endl;
return ret;
}

sockaddr_in clientSock{};
socklen_t clientSockLen;
int clientSockFD = accept(sockFD, (sockaddr *)&clientSock, &clientSockLen);
if (clientSockFD < 0) {
std::cerr << "socket accept fail" << std::endl;
return clientSockFD;
} else {
close(STDOUT_FILENO);
int newFd = dup(clientSockFD);
std::cout << "new fd is equal to std out fd: " << (newFd == STDOUT_FILENO) << std::endl;
std::cout << "this is direct from std out" << std::endl;
close(clientSockFD);
close(newFd);
}
close(sockFD);
return 0;
}

readv函数和writev函数

readv函数将数据从磁盘读到分散的内存块中,即分散读;wirtev函数则将多块分散的内存数据一并写入文件描述符中,即集中写。它们的定义如下:

1
2
3
#include <sys/uio.h>
ssize_t readv(int fd, const struct iovec* vector, int count);
ssize_t writev(int fd, const struct iovect* vector, int count);

struct iovect用来描述一块内存区,它的定义如下:

1
2
3
4
struct iovec {
void *iov_base; //内存起始地址
size_t iov_len; //这块内存长度
}

sendfile函数

sendfile函数在两个文件描述符之间直接传递数据(完全在内核中操作),从而避免了内核缓冲区和用户缓冲区之间的数据拷贝,效率很高,这被称为零拷贝。sendfile函数的定义如下:

1
2
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t* offset, size_t count);
  • in_fd参数是待读出内容的文件描述符
  • out_fd参数是待写入内容的文件描述符
  • offset参数指定从读入文件流的哪个位置开始读,如果为空,则使用读入文件流默认的起始位置
  • count参数指定在文件描述符in_fd和out_fd之间传输的字节数

sendfile成功时返回传输的字节数。失败则返回-1并设置errno。

sendfile函数有一个限制:in_fd必须是一个支持类似mmap函数的文件描述符,即它必须指向真实的文件,不能是socket管道;而out_fd则必须是一个socket。由此可见,sendfile几乎是专门为在网络上传输文件而设计的。 # mmap、munmap、msync函数 mmap函数用于申请一段内存空间。`我们可以将这段内存作为进程间通信的共享内存,也可以将文件直接映射到其中`。munmap函数则释放由mmap创建的这段内存空间。它们定义如下:
1
2
3
#include <sys/mman.h>
void* mmap(void* start, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void* start, size_t length);
* start参数允许用户使用某个特定的地址作为这段内存的起始地址,如果它被设置为NULL,则系统自动分配一个地址。 * length参数指定内存段的长度。 * prot参数用来设置内存段的访问权限。它可以取以下几个值的按位或 * PROT_READ,内存段可读 * PROT_WRITE,内存段可写 * PROT_EXEC,内存段可执行 * PROT_NONE,内存段不能被访问 * flags参数控制内存段内容被修改后程序的行为。它的`常用`取值可以是如下这些值的按位或 * MAP_SHARED,在进程间共享这段内存,对该内存段的修改将反映到被映射的文件中 * MAP_PRIVATE,内存段为调用进程所私有,对该段内存的修改不会反映到被映射的文件中 * MAP_ANONYMOUS,这段内存不是从文件映射来的。其内容被初始化为全0。这种情况下,mmap函数的最后两个参数将被忽略 * MAP_FIXED,内存段必须位于start参数指定的地址处,start必须是内存页面大小(4096字节)的整数倍,考虑到可移植性,addr 通常设为 NULL ,不指定 MAP_FIXED * MAP_HUGETLB,按照“大内存页面”来分配内存空间,“大内存页面”的大小可通过/proc/meminfo文件来查看 * fd参数是被映射文件对应的文件描述符。它一般通过open系统调用来获得 * offset参数设置从文件的何处开始映射 mmap函数成功时返回指向目标内存区域的指针,失败则返回MAP_FAILED,并设置errno。munmap函数成功时返回0,失败时返回-1并设置errno。`当 mmap 成功返回时,fd 就可以关闭,这并不影响创建的映射区。` `进程退出的时候,映射区会自动删除`。不过当不再需要映射区时,可以调用 munmap 显式删除。当映射区删除后,后续对映射区的引用会生成 SIGSEGV 信号。 文件一旦被映射后,调用mmap()的进程对返回地址的访问是对某一内存区域的访问,暂时脱离了磁盘上文件的影响。所有对mmap()返回的地址空间的操作只在内存中有意义。并且只有在调用了munmap()后或者msync()时,才把内存中的相应内容写回磁盘文件。 ## linux内存映射mmap原理 在调用mmap函数的时候,实际上只是创建并初始化了相关的结构体,这个结构体中记录了从`逻辑地址`到磁盘空间的映射,`此时并没有发生任何磁盘数据的传输`。 当使用mmap返回的逻辑地址对数据进行访问时,操作系统将逻辑地址转换为物理地址,如果发现页表项中没有对应的物理页时,此时就会根据mmap建立的映射关系,从磁盘中间对应数据加载到物理页中,`这个物理页是属于用户空间的物理页` `为什么使用mmap会比使用普通的read/write快?`先看看使用普通的read/write的过程,当使用read/write函数从某个fd上读取数据时,操作系统首先从磁盘将数据加载到属于内核的`物理地址`中,然后需要将这些数据从属于内核的`物理地址`拷贝到属于用户空间的`物理地址`中,此时才会在用户空间中获取到这些数据。这个过程经过了两次数据拷贝的过程。而使用mmap,会直接把数据从磁盘中拷贝到属于用户的物理空间中,只经过了一次数据拷贝的操作。 # splice函数 splice函数用于在两个文件描述符之间移动数据,也是零拷贝操作。splice函数的定义如下:
1
2
#include <fcntl.h>
ssize_t splice(int fd_in, loff_t* off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
* fd_in参数是需要读数据的文件描述符。 * off_in表示从输入数据流的何处开始读取数据,如果fd_in是一个管道文件描述符,那么off_in参数必须被设置为NULL,表示从输入数据流的当前偏移位置开始读取数据。 * fd_out表示需要写数据的文件描述符。 * off_out表示从何处开始写数据,如果fd_out是一个管道文件描述符,那么off_out参数必须设置为NULL,表示从当前偏移位置开始写数据。 * len参数表示需要拷贝数据的长度 * flags参数控制数据如何移动,它可以被设置为下列这些值的按位或 * SPLICE_F_MOVE:如果合适的话,按整页内存移动数据。这只是给内核一个提示。不过,因为它的实现存在BUG,自内核2.6.21后,它实际上没有任何效果。 * SPLICE_F_NONBLOCK:非阻塞的splice操作,但实际效果还会受文件描述符本身的阻塞状态的影响 * SPLICE_F_MORE:给内核一个提示:后续的splice调用将读取更多的数据 * SPLICE_F_GIFT:对splice没有效果 使用splice函数时,fd_in和fd_out必须至少有一个是管道文件描述符,但因为管道文件有大小限制,所以splice函数一次移动太多数据可能会导致长时间阻塞

tee函数

tee函数在两个管道文件描述符之间复制数据,也是零拷贝操作。它不消耗数据,因此源文件描述符上的数据仍然可以用于后续的读操作。tee函数的原型如下:

1
2
#include <fcntl.h>
ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);

tee函数的参数的含义和splice函数相同,只不过fd_in和fd_out必须都是管道文件描述符。

fcntl函数

fcntl函数,正如其名字(file control)描述的那样,提供了对文件描述符的各种控制操作。另外一个常见的控制文件描述符属性和行为的系统调用是ioctl,而且ioctl比fcntl能够执行更多的操作。但是,对于控制文件描述符常用的属性和行为,fcntl函数是有POSIX规范指定的首选方法。fcntl函数的定义如下:

1
2
#include <fcntl.h>
int fcntl(int fd, int cmd, ...);

fd参数是被操作的文件描述符,cmd参数指定执行何种类型的操纵。根据操作类型的不同,该函数可能还需要第三个可选参数arg。fcnt函数支持的常用操作及其参数如下表所示:

操作分类 操纵 含义 第三个参数类型 成功时的返回值
复制文件描述 F_DUPFD 创建一个新的文件描述符,其值大于或等于arg long 新创建的文件描述符的值
F_DUPFD_CLOEXEC 于F_DUPFD相似,不过在创建文件描述符的同时,设置其close-on-exec表示 long 新创建的文件描述符的值
获取和设置文件描述符的标志 F_GETFD 获取fd的标志,比如说close-on-exec fd的标志
F_SETFD 设置fd的标志 long 0
获取和设置文件描述符的状态标志 F_GETFL 获取fd的状态标志,这些标志包括由open系统调用设置的标志(O_APPEND、O_CREAT等)和访问模式(O_RDONLY、O_WRONLY和O_RDWR) void fd的状态标志
F_SETFL 设置文件的状体标志,但部分标志是不能被修改的(比如访问模式标志) long 0
管理信号 F_GETOWN 获得SIGIO和SIGURG信号的宿主进程的PID或进程组的组ID 信号的宿主进程的PID或进程组的组ID
F_SETOWN 设定SIGIO和SIGURG信号的宿主进程的PID或者进程组的组ID long 0
F_GETSIG 获取当应用程序被通知fd可读可写时,是那个信号通知该事件的 信号值,0表示SIGIO
F_SETSIG 设置当fd可读或可写时,系统应该触发哪个信号来通知应用程序 long 0
操作管道容量 F_SETPIPE_SZ 设置由fd指定的管道的容量,/proc/sys/fs/pipe-size-max内核参数指定了fcntl能设置的管道容量的上限。 long 0
F_GETPIPE_SZ 获取由fd指定的管道的容量 管道容量

在网络编程中,fcntl函数通常用来将一个socket文件描述符设置为非阻塞的:

1
2
3
4
5
6
int setnonblocking(int fd) {
int old_option = fcntl(fd, F_GETFL);
int new_option = old_option | O_NONBLOCK;
fcntl(fd, F_SETFL, new_option);
return old_option;/*返回文件描述符旧的状态标志,方便之后恢复该状态标志*/
}
此外,SIGIO和SIGURG这两个信号与其他Linux信号不同,他们必须与某个文件描述符相关联才能使用,当被关联的文件描述符可读或可写时,系统将触发SIGIIO信号,当被关联的文件描述符(而且必须是一个socket)上有带外数据可读时,系统将触发SIGURG信号,将信号和文件描述符关联的方法,就是使用fcntl函数为目标文件描述符指定宿主进程或进程组,那么被指定的宿主进程或进程组将捕获这两个信号。