文件 IO
本部分讲述的内容是不带缓冲的、以文件描述符为基准的 IO,这与标准 IO 库中带有缓冲的、以 FILE* 为基准的 IO 形成了区分
文件描述符就是一个非负整数,文件描述符的范围为 0~OPEN_MAX-1,现在 OPEN_MAX 为 63(也就是说进程最多能打开 64 个文件)。当进程创建时,系统自动为其打开三个文件描述符,这三个文件描述符分别是:
文件描述符 |
宏 |
解释 |
|---|---|---|
0 |
STDIN_FILENO |
标准输入 |
1 |
STDOUT_FILENO |
标准输出 |
2 |
STDERR_FILENO |
标准错误 |
另外,当打开一个文件时,系统会使用当前可用的最低文件描述符,这样,我们可以通过先关闭 STDOUT_FILENO,然后再打开文件的形式将标准输出重定向到文件中
内核使用三个数据结构用来描述进程打开的文件:进程表项、文件表项、 v 节点表项
v 节点表项是文件在物理磁盘中的索引。当文件第一次被打开时,系统将其载入内存
文件表项由内核维护,是进程共享的,包含的三个字段用来描述文件打开的状态:文件状态标志、当前文件偏移量、v 节点指针
进程表项是进程私有的,其将文件描述符映射到文件指针上,而文件指针指向了文件表项
dup
dup 函数用于复制一个已有的文件描述符,其函数原型为:
int dup(int fd);
int dup2(int fd1, int fd2);
dup 函数复制 fd 并将复制后的文件描述符返回。dup2 将 fd1 的描述符复制到 fd2,如果 fd2 已经打开,则先关闭 fd2,如果 fd1 == fd2,则直接返回。
复制后的文件描述符和以前的文件描述符只是文件描述符一样,其指向的文件表项相同
/dev/fd
备注
本文的场景为 Linux,Unix 暂不考虑
在 Linux 中提供了 /dev/fd 目录,此目录中的目录项为当前进程打开的所有文件描述符。由于 Linux “一切皆文件的思想”,此目录中的目录项是指向底层物理文件的符号链接,直接打开 /dev/fd 中的目录项相当于 dup 函数,且新文件描述符的属性与原有描述符无关(这点和 Unix 不同)
另外,Linux 还创建了 /dev/stdin, /dev/stdout, /dev/stdout 三个符号链接。在终端中可以很方便地使用它们。例如将消息发送到 stderr
echo 'hello' > /dev/stderr
或是
echo 'hello' > /dev/fd/3
小技巧
C++ 可以通过打开 /dev/fd 的形式读写文件,例如
std::ofstream os("/dev/fd/1");
os<<"hello";
会将消息发送到 stdout
打开文件
调用 open 或者 openat 可以打开一个文件:
int open(const char* path, int oflag, ... /* mode_t mode */);
int openat(int fd, const char* path, int oflag, ... /* mode_t mode */);
fd 用来代指 path 的起始路径,当 fd 具有 AT_FDCWD 属性时,openat 和 open 函数并无差距。另外, open 和 openat 总是选择当前可用的最小的文件描述符
备注
常量 _POSIX_NO_TRUNC 决定了当文件名太长时函数出错还是截断路径。一般而言,现代操作系统允许的最长路径为 255 个字符
oflag 指定了打开文件时的行为,主要有:
标志 O_RDONLY |
作用 只读打开 |
O_WRONLY |
只写打开 |
O_RDWR |
读写打开 |
O_EXEC |
只执行打开 |
O_APPEND |
每次写时追加到文件末端 |
O_CLOEXEC |
把 FD_CLOEXEC 设置为文件描述符标志 |
O_CREAT |
文件不存在时则创建 |
O_DIRECTORY |
若 path 不是路径则报错 |
O_EXCL |
如果同时指定了 O_CREAT 而文件又存在,则报错,否则创建新文件。此操作可用来确定文件是否存在,且是一个原子操作 |
O_NOCTTY |
若 path 指向的是终端设备,则不将此设备作为此进程的控制终端 |
O_NONBLOCK |
将文件设置为非阻塞模式 |
O_SYNC |
同步写文件内容和文件属性 |
O_TRUNC |
截断文件 |
O_TTY_INIT |
|
O_DSYNC |
同步写文件内容 |
O_RSYNC |
使所有以 fd 为参数的 write 操作等待至对文件的同一部分写操作完成 |
另外,当指定 O_CREAT 选项时,mode 参数必须指定,此参数用来说明新建文件的权限位
关闭文件可以使用 close:
int close(int fd);
当关闭文件时会自动释放进程在此文件上持有的记录锁
当进程终止时,内核会自动关闭它打开的文件
备注
很多程序利用上述第二个特性自动关闭文件,但是这里并不建议,因为当你的程序被脚本循环调用时可能会导致占用大量文件句柄
fcntl
fcntl 用来更改已打开文件的属性:
int fcntl(int fd, int cmd, ...);
fcntl 最后一个参数有两种形式,要么是 int,要么是一个指向结构体的指针。当为 int 时含义即为 oflags
根据 cmd 参数,fcntl 具有以下能力:
功能 |
getter |
setter |
|---|---|---|
复制文件描述符 |
F_DUPFD/F_DUPFD_CLOEXEC |
|
设置文件描述符 |
F_GETFD |
F_SETFD |
设置文件标志 |
F_GETFL |
F_SETFL |
获取异步 I/O 所有权 |
F_GETOWN |
F_SETOWN |
设置记录锁 |
F_SETLK |
F_SETLK/F_SETLKW |
对于 F_DUPFD 而言,返回的文件描述符是大于/等于第三个参数的最小值,新的文件描述符与原文件描述符的描述符标志不同,其 F_CLOEXEC 标志被清除
lseek
程序持有的每个文件句柄都有一个与其相关联的文件偏移量属性,其代表了下次读写文件时开始的位置。可以使用 lseek 改变此属性:
off_t lseek(int fd, off_t offset, int whence);
此函数会将文件指针定位到 whence + offset 的位置,whence 的取值为:
取值 |
含义 |
|---|---|
SEEK_SET |
文件开头 |
SEEK_CUR |
文件当前偏移位置 |
SEEK_END |
文件末尾 |
若 lseek 成功,则返回新的文件偏移位置,否则返回 -1。对于管道、FIFO、套接字这些无法设置偏移量的文件而言,还会置 error = ESPIPE。另外,文件偏移位置:
某些文件允许负的偏移位置
文件偏移量允许超过文件末尾
在第二种情况下,会生成文件空洞,空洞部分不占用任何磁盘空间,并默认为 0。如果复制一个含空洞的文件,则新文件的空洞位置会占用磁盘空间
提示
Intel x86 CPU 下 FreeBSD 的 /dev/kmem 允许负的偏移量
读写数据
读写数据需要用到两个函数:
ssize_t read(int fd, void* buf, size_t nbytes);
ssize_t write(int fd, const void* buf, size_t nbytes);
备注
与 size_t 相比而言,ssize_t 允许返回负数
对于两个函数而言,参数 nbytes 代表了 buf 的大小。当函数成功时返回读出的字节数,失败时返回 -1。read 从文件向 buf 写数据,write 从 buf 读出数据
另外,以下情况会导致 read 返回的字节数少于 nbytes:
已经到达文件末端
剩余数据不足
终端设备一般每次只允许读一行
面向记录的设备一次只允许读一个记录
信号中断
write 成功时返回值与 nbytes 相等,否则出错。一般 write 失败的原因是:
磁盘已满
文件长度超出限制
当缓冲区大小与磁盘扇区大小相等时,磁盘的效率一般最高。
离散读和聚集写
离散读 和 聚集写 使得可以在一次函数调用中读写多个缓冲区,其函数签名为:
ssize_t readv(int fd, const iovec* iov, int iovcnt);
ssize_t writev(int fd, const iovec* iov, int iovcnt);
其中,iovec 的定义为:
struct iovec{
void* iov_base;
size_t iov_len;
};
也就是说 iov 实际上是一个数组的数组。参数 iovcnt 指明了 iov 的大小。两个函数都是依次对缓冲区进行访问,当一个缓冲区完全访问后才访问下一个。
使用轮询的非阻塞 IO
我们可以看一下以轮询的方式查询如何从 stdin 读取数据:
#include <assert.h>
#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <stdio.h>
#include <unistd.h>
int main(int argc, char* argv[]) {
int flags = fcntl(STDIN_FILENO, F_GETFL, 0);
assert(flags >= 0);
flags |= O_NONBLOCK;
fcntl(STDIN_FILENO, F_SETFL, flags);
char buf[255];
int nRead = 0;
while(nRead != 2) {
errno = 0;
nRead = read(STDIN_FILENO, buf, 255);
fprintf(stderr, "nRead = %d, errno = %d\n", nRead, errno);
if(errno == EAGAIN) {
puts("没有数据");
continue;
}
if(errno == 0) write(STDOUT_FILENO, buf, nRead);
}
return 0;
}
当 stdin 没有数据时。read 返回 -1,error == EAGAIN。当没有数据时我们一直进行轮询。但是由于不清楚 stdin 到底有多少数据,因此我们规定当读取到的数据数量为 2 时跳出循环(换行符也计算在内)
IO 多路复用
select
select 是 IO 多路复用的一种 API,其典型特点是构造三个 1024 容量的数组,在其中保存了需要观察的文件描述符。每次调用 select 就会对这三个数组进行一次遍历。这三个数组分别是 readfds、writefds 和 exceptfds。另外,为了节省空间,这三个数组使用位图的形式实现。select 的 API 为:
int select(int maxfdp, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, timeval* tvptr);
正如上述所言,readfds 用来储存需要读取的文件描述符,writefds 用来储存需要写入的文件描述符,exceptfds 用来储存发生异常的文件描述符,tvptr 用来表示 select 最高允许等待的时间。而 maxfdp 用来表示需要遍历的范围,其值为最大文件描述符加一,如果不指明的话,select 将会遍历 1024 的元素。
select 会返回已经准备好的描述符的数量,返回 -1 说明出错。描述符准备好的含义对于上述三个 fd_set 而言分别是是可读、可写、含未决异常。如果一个描述符已经准备好了读和写,那么会进行两次计数。
fd_set 是一个位图,对其修改需要使用特定的函数,这些函数根据实现可能被实现为宏:
int FD_ISSET(int fd, fd_set* fdset); // 若 fd 在 fdset 中,返回非零值
int FD_CLR(int fd, fd_set* fdset);
int FD_SET(int fd, fd_set* fdset);
int FD_ZERO(fd_set* fdset);
当三个 fd_set 指针均为 NULL 时,select 提供了比 sleep 更加精细的休眠功能。而当 tvptr == NULL 时,则表明阻塞等待至有文件描述符准备好。
另外,select 还有一个变体 pselect 来提供更加精细的定时功能:
int pselect(int maxfdp, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, const timespec* tvptr, sigset_t* sigmask);
使用 sigmask 可以屏蔽指定信号
然后是使用 select 实现的非阻塞 IO:
#include <assert.h>
#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <stdio.h>
#include <strings.h>
#include <sys/select.h>
#include <unistd.h>
int main(int argc, char* argv[]) {
int flags = fcntl(STDIN_FILENO, F_GETFL, 0);
assert(flags >= 0);
flags |= O_NONBLOCK;
fcntl(STDIN_FILENO, F_SETFL, flags);
fd_set readFds;
fd_set excFds;
FD_ZERO(&readFds);
FD_ZERO(&excFds);
char buf[255];
int ret;
while(1) {
bzero(buf, 255);
FD_SET(STDIN_FILENO, &readFds);
FD_SET(STDIN_FILENO, &excFds);
ret = select(STDIN_FILENO + 1, &readFds, NULL, &excFds, NULL);
assert(ret >= 0);
if(FD_ISSET(STDIN_FILENO, &readFds)) {
read(STDIN_FILENO, buf, 255);
write(STDOUT_FILENO, buf, 255);
} else if(FD_ISSET(STDIN_FILENO, &excFds)) {
fprintf(stderr, "errno = %d", errno);
}
}
return 0;
}
poll
poll 是另一种 IO 多路复用的手段,相比 select 最大支持 1024 个文件描述符而言,poll 支持的文件描述符是无穷的:
int poll(pollfd* fdarray, nfds_t nfds, int timeout);
备注
poll 底层使用链表储存 fd
其中,pollfd 的定义为:
struct pollfd{
int fd; // 需要监控的文件描述符
short events; // 需要监控的事件
short revents; // 实际发生的事件
};
pollfd.revents 在 poll 返回时由内核更改,用户无需设定。
其中,events 为:
标志 |
写入 events ? |
写入 revents ? |
描述 |
|---|---|---|---|
POLLIN |
\(\checkmark\) |
\(\checkmark\) |
可以不阻塞地读取高优先级以外的数据 |
POLLRDNORM |
\(\checkmark\) |
\(\checkmark\) |
可以不阻塞地读取普通数据 |
POLLRDBAND |
\(\checkmark\) |
\(\checkmark\) |
可以不阻塞地读取优先级数据 |
POLLPRI |
\(\checkmark\) |
\(\checkmark\) |
可以不阻塞地读取高优先级数据 |
POLLOUT |
\(\checkmark\) |
\(\checkmark\) |
可以不阻塞地写普通数据 |
POLLWRNORM |
\(\checkmark\) |
\(\checkmark\) |
同上 |
POLLWRBAND |
\(\checkmark\) |
\(\checkmark\) |
可以不阻塞地写优先级数据 |
POLLERR |
\(\checkmark\) |
已出错 |
|
POLLHUP |
\(\checkmark\) |
已挂断 |
|
POLLNVAL |
\(\checkmark\) |
描述符没有引用一个文件 |
文件描述符被挂断后依然可读
事件与 select 略有不同:
值 |
含义 |
|---|---|
-1 |
永远等待 |
0 |
立即返回 |
非零值 |
等待 timeout 毫秒 |
使用 poll 实现的非阻塞 IO 方式为:
#include <assert.h>
#include <fcntl.h>
#include <poll.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char* argv[]) {
int flags = fcntl(STDIN_FILENO, F_GETFL, 0);
assert(flags >= 0);
flags |= O_NONBLOCK;
fcntl(STDIN_FILENO, F_SETFL, flags);
int nfds = 1;
struct pollfd* pfds;
pfds = calloc(nfds, sizeof(struct pollfd));
assert(pfds != NULL);
pfds[0].fd = STDIN_FILENO;
pfds[0].events |= POLLIN;
while(1) {
int ready;
ready = poll(pfds, 1, -1);
assert(ready != -1);
fprintf(stderr, "Ready: %d\n", ready);
char buf[255];
memset(buf, '\0', 255);
for(int i = 0; i < nfds; ++i) {
if(pfds[i].revents & POLLIN) {
ssize_t l = read(pfds[i].fd, buf, 255);
write(STDOUT_FILENO, buf, 255);
}
}
}
return 0;
}
epoll
epoll API 的行为与 poll 类似:监控多个文件描述符以查看其上可用的 IO。epoll 分为边缘触发和水平触发。
备注
另一方面,由于 select 和 poll 都是使用的轮询的方式,导致当文件描述符很多时性能较低,而 epoll 底层使用了红黑树来保证效率
更准确地来说,epoll 监控的是可读可写事件
epoll 的核心概念是 epoll示例:一个内核中数据结构,包含了两个列表:
兴趣列表(也叫做 epoll set):包含了需要监控的文件描述符
就绪列表:包含了兴趣列表中已经就绪的文件描述符。就绪列表是内核根据当前 IO 状况动态创建的
以下 API 用于管理 epoll 实例:
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, epoll_event* event);
int epoll_wait(int epfd, epoll_event* events, int maxevents, int timeout);
epoll_create 用来创建一个 epoll 实例,并返回一个指向此实例的文件描述符
epoll_ctl 用来向 epoll 示例中添加、删除、更改感兴趣的文件描述符,op 的参数为:
命令 |
描述 |
|---|---|
EPOLL_CTL_ADD |
增加要监视的文件描述符 |
EPOLL_CTL_MOD |
更改目标文件描述符的事件 |
EPOLL_CTL_DEL |
删除要监视的文件描述符,event 参数会被忽略,可以传入 NULL |
epoll_wait 在没有可用文件描述符时阻塞当前线程。当有可用文件描述符时将其写入 events
其中 epoll_event 的定义为:
struct epoll_event {
uint32_t events; // 与 poll 能监视的事件差不多,只是宏名前面加了个E
epoll_data_t data; // 用户数据,除了能保存文件描述符以外,还能保存一些其它有关数据
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
epoll_wait 用来等待文件描述符可用。当其成功返回时,可以通过 events->data->fd 拿到可用的文件描述符
水平触发和边缘触发
假设有以下事件发生:
一个代表管道的文件描述符 rfd 被添加到兴趣列表
管道内在写端被写入了 2kB 数据
epoll_wait 将 rfd 写入到就绪列表中
管道读端读了 1kB 的数据
epoll_wait 再次被调用
则:
如果 rfd 是边缘触发。则第五步时的就绪列表中不会包含 rfd,因为边缘触发只在文件描述符状态变化时才将其加入就绪列表
如果 rfd 是水平触发。则第五步时返回的就绪列表中仍会包含 rfd,因为水平触发会在文件描述符可用时将其加入就绪列表
重要
边缘触发要求文件描述符是非阻塞的
使用 epoll 的非阻塞 IO 例子为:
#include <assert.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <sys/epoll.h>
#include <unistd.h>
int main(int argc, char* argv[]) {
int flags = fcntl(STDIN_FILENO, F_GETFL, 0);
assert(flags >= 0);
flags |= O_NONBLOCK;
fcntl(STDIN_FILENO, F_SETFL, flags);
int epfd = epoll_create1(0);
assert(epfd != -1);
struct epoll_event ev;
struct epoll_event events[10];
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = STDIN_FILENO;
int rtn = epoll_ctl(epfd, EPOLL_CTL_ADD, STDIN_FILENO, &ev);
assert(rtn != -1);
int nfds = 0;
char buf[255];
while(1) {
nfds = epoll_wait(epfd, events, 10, -1);
assert(nfds != -1);
for(size_t i = 0; i < nfds; ++i) {
int fd = events[i].data.fd;
bzero(buf, 255);
read(fd, buf, 255);
write(STDOUT_FILENO, buf, 255);
}
}
return 0;
}
epoll 的五种写法
整理自 6 种epoll的设计,让你吊打面试官,而且他不能还嘴
epoll 有六种常用写法:
单线程 accept,多线程 recv/send
多线程 accept,多线程 recv/send
对于多线程而言,监听一个端口的方式是:
listen(fd) pthread_create(thid, NULL, cd, &fd); pthread_create(thid, NULL, cd, &fd); pthread_create(thid, NULL, cd, &fd); pthread_create(thid, NULL, cd, &fd);
多线程处理同一个 listenfd,应当使用水平触发
多线程 Listen 的另一个问题是当链接进入时,会将所有的 listen 进程唤醒,解决的办法是在 epoll_wait 之前加锁。每次请求进入时只唤醒一个进程
多线程 epoll_wait,不区分 accept 和 recv/send
多进程 epoll_wait,不区分 accept 和 recv/send
master 进程 accept,工作进程 recv/send
备注
多进程适用于 session 是独立的,前后不关联的情况。比如即时通信是长链接,session 不是独立的就不推荐多进程模型
Nginx 使用的是第三种模型
最后一种只是理论上,但是因为 fd 没法在进程间传递(fd 创建于 fork 之后),所以实际上没人用
mmap
mmap 可以将文件的一部分映射到内存中。之后程序对此内存的更改会由操作系统负责写回到磁盘上。尽管程序在写数据的时候可以超过映射文件的大小,但是超过的部分会被忽略掉。
提示
mmap 将文件映射到进程的文件映射区,fork 的子进程会得到副本,但是执行 exec 的进程不会
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);
mmap 将文件 fd 中的 [offset, offset + length] 部分映射到地址 addr 处。addr 如果置空则意味着让操作系统选择。函数的返回值即文件映射后指向的内存
提示
对于 Linux 而言,addr 是一个 hint 类型的参数。也就是说,mmap 不一定会将文件映射到 addr 处,一切应以返回值为准
prot 表明了映射区的权限,其权限不得超过 fd 拥有的权限:
命令 |
含义 |
|---|---|
PROT_READ |
映射区可读 |
PROT_WRITE |
映射区可写 |
PROT_EXEC |
映射区可执行 |
PROT_NONE |
映射区不可访问 |
另外,映射区的权限后面还可以使用 mprotect 更改:
int mprotect(void *addr, size_t len, int prot);
flags 指明了内核是否回写和是否进程共享的问题:
标志 |
含义 |
|---|---|
MAP_SHARED |
共享映射。对映射区的更改是进程间可见的,而且会回写到底层文件 |
MAP_SHARED_VALIDATE |
与 MAP_SHARED 类似,但是会无效的 flag 会导致 mmap 失败 |
MAP_PRIVATE |
对映射区的更改不会影响底层文件 |
重要
在进程结束时需要使用 munmap 取消映射
映射区的大小不能超过文件大小,超过部分会被忽略
flags 在不同版本的内核中变化比较大,使用前应当先查阅手册
另外,你还可以通过 msync 强制系统将内容回写到文件:
int msync(void *addr, size_t length, int flags);
flags 的参数为:
标志 |
含义 |
MS_ASYNC |
请求回写。函数会立即返回 |
MS_SYNC |
请求阻塞至回写完成 |
MS_INVALIDATE |
使其他人对此文件的映射失效(以便刷新文件) |
signalfd
TODO
timerfd
TODO
inotify
inotify 可以用来监控文件变化。但文件发生改变时,将会产生系统调用,inotify 通过 hack 这些系统调用来监控文件系统的变更。但是 inotify 不支持 FUSE 文件系统,更通俗地来讲是不支持网络文件系统和虚拟文件系统(例如 samba 挂载和 /proc 虚拟文件系统)
在 inotify-tools 软件包里提供了一些工具可以用来监控文件操作。例如如果我们打算监控 /srv/test 文件上的操作,只需要执行
$ inotifywait -rme modify,attrib,move,close_write,create,delete,delete_self /srv/test
fanotify
fanotify 是 inotify 的更完善版本,其在 inotify 基础上添加了更多的功能,使得监听者实现了从“监听”到“监控”的跨越,同时也扩展了其应用的范围