Linux 的 IO 介绍

Linux文件IO的常用函数

Linux中做文件IO最常用到的5个函数是:open, close, read, writelseek,不是ISO C的组成部分,这5个函数是不带缓冲的IO,也即每个read和write都调用了内核的一个系统调用。另外,dup函数和sync函数也经常用到,下面我们大致看一下这7个函数。

open函数

#include <fcntl.h>
int open(const char *pathname, int oflag, ... /* mode_t mode */);
/* 成功返回文件描述符, 失败返回-1 */

open函数打开或创建一个文件。pathname表示文件的路径,oflag表示打开的方式。通过控制oflag,可以规定打开文件的读写和追加模式、是否创建不存在的文件、是否截短为0、是否阻塞、读操作是否等待写操作完成、写操作是否等待物理IO操作完成等等。

close函数

#include <unistd.h>
int close(int filedes);
/* 成功返回0, 失败返回-1 */

关闭一个文件时,还会释放该文件上的所有记录锁。另外,当一个进程终止时,它会自动关闭所有它打开的文件。

lseek函数

每个打开的文件都有一个“当前文件偏移量(current file offset)”,read和write操作都从这个当前文件偏移量开始。

#include <unistd.h>
off_t lseek(int filedes, off_t offset, int whence);
/* 成功返回新的文件偏移量,出错返回-1 */

参数whence表示到底是从文件开始处、文件末尾还是文件当前偏移位置来计算offset。

read函数

read函数从打开的文件中读取数据,数据存放到buf指向的空间中,最大读取的字节数为nbytes。

#include <unistd.h>
ssize_t read(int filedes, void *buf, size_t nbytes);
/* 成功则返回读取到的字节数,若已到文件的结尾返回0,出错返回-1 */

write函数

write函数向打开的文件写入数据,数据由buf指向的空间提供,最大写入的字节数为nbytes。

#include <unistd.h>
ssize_t write(int filedes, void *buf, size_t nbytes);
/* 成功则返回写入的字节数,出错返回-1 */

dupdup2函数

这两个函数用来复制一个现存的文件描述符。

#include <unistd.h>
int dup(int filedes);
int dup2(int fileds, int filedes2);
/* 成功则返回新的文件描述符,出错返回-1 */

dup函数从当前可用的文件描述符中找一个最小的返回

dup2用filedes2指定新文件描述符的值。

sync, fsync和fdatasync函数

这三个函数都是刷缓存的:

#include <unistd.h>
int fsync(int filedes);     /* 刷filedes指代的文件的缓存到磁盘 */
int fdatasync(int filedes); /* 与fsync类似,但只刷数据,不刷属性 */
/* 成功返回0,失败返回-1 */
void sync(void);            /* 刷所有脏的缓存 */

Linux中的标准IO库

缓冲

标准IO库提供缓冲功能,包括:

  • 全缓冲。即填满IO缓冲区后才进行IO操作。
  • 行缓冲。遇到换行符时执行IO操作。
  • 无缓冲。

一般情况下,标准出错无缓冲。如果涉及终端设备,一般是行缓冲,否则是全缓冲。

可用setbufsetvbuf函数设置缓冲类型已经缓冲区大小,使用fflush函数冲洗缓冲区。

打开流

使用fopen, freopen, fdopen三个函数打开一个流,这三个函数都返回FILE类型的指针。

#include <stdio.h>
FILE *fopen(const char *restrict pathname, const char *restrict type);
FILE *freopen(const char *restrict pathname, const char *restrict type, 
              FILE *restrict fp);
FILE *dopen(int filedes, const char *type);
/* 成功返回FILE类型指针,出错返回NULL */

其中,

  • fopen打开一个指定的文件。
  • freopen在一个指定的流上打开一个文件,比如在标准输出流上打开某文件。
  • dopen打开指定的文件描述符代表的文件。常用于读取管道或者其他特殊类型的文件,因为这些文件不能直接用fopen打开。

type参数指定操作类型,入读写,追加等等。

关闭流

fclose函数关闭一个流:

#include <stdio.h>
int flose(FILE *fp);
/* 成功返回0,出错返回EOF */

读和写流

每次一个字符的IO

#include <stdio.h>
/* 输入 */
int getc(FILE *fp);
int fgetc(FILE *fp);
int getchar(void);
/* 上面三个函数的返回值为int,因为EOF常实现为-1,返回int就能与之比较 */

/* 判断出错或者结束 */
int ferror(FILE *fp);
int feof(FILE *fp);
void clearerr(FILE *fp); /* 清除error或者eof标志 */

/* 把字符压送回流 */
int ungetc(intc FILE *fp);

/* 输出 */
int putc(int c, FILE *fp);
int fputc(int c, FILE *fp);
int putchar(int c);

每次一行的IO

#include <stdio.h>
/* 输入 */
char *fgets(char *restrict buf, int n, FILE *restrict fp);
char *gets(char *buf);
/* gets由于没有指定缓冲区,所以有可能造成缓冲区溢出,要小心 */

/* 输出 */
int fputs(char *restrict buf, FILE *restrict fp);
int puts(const char *buf);

二进制IO

#include <stdio.h>
size_t fread(void *restrict ptr, size_t size, size_t nobj,
            FILE *restrict fp);
size_t fwrite(const void *restrict ptr, size_t size, size_t nobj,
            FILE *restrict fp);
/* 返回值:读或写的对象数 */

定位流

#include <stdio.h>
long ftell(FILE *fp);
/* 成功则返回当前文件位置指示,出错返回-1L */

int fseek(FILE *fp, long offset, int whence);
/* 成功返回0, 出错返回非0 */

int fgetpos(FILE *restrict fp, fpos_t *restrict pos);
int fsetpos(FILE *fp, fpos_t *pos);
/* 成功返回0,出错返回非0 */

格式化IO

执行格式化输出的主要是4个printf函数:

  • printf 输出到标准输出
  • fprintf 输出到指定流
  • sprintf 输出到指定数组
  • snprintf 输出到指定数组并在数组的尾端自动添加一个null字节

格式化输入主要是三个scanf函数:

  • scanf 从标准输入获取
  • fscanf 从指定流获取
  • sscanf 从指定数组获取

高级IO

记录锁(record locking)

记录锁应该叫“字节范围锁”,因为它就是把文件中的某个部分加上锁。 使用fcntl函数操作记录锁:

#include <fcntl.h>
int fcntl(int filedes, int cmd, ... /* struct flock *flockptr */ );
/* 成功的返回依赖cmd,出错返回-1 */

对于记录锁,cmd可以是F_GETLKF_SETLK或者F_SETLKW

第三个参数flockptr是一个指向flock结构的指针,该结构如下:

struct flock {
    short l_type;    /* F_RDLCK, F_WRLCK or F_UNLCK */
    off_t l_start;   /* offset in bytes, relative to l_whence */
    short l_whence;  /* SEEK_SET, SEEK_CUR or SEEK_END */
    off_t l_leng;    /* length in bytes, 0 means lock to EOF */
    pid_t l_pid;     /* returned with F_GETLK */
};

通过flock结构中的l_type字段定义了2种锁:共享读锁(F_RDLCK)和 独占写锁(F_WRLCK)。从名字可以看出,等一个区域被某个进程加上了独占写锁时, 其他进程不能获取它的读锁或者写锁。

当两个进程相互等待对方持有并锁定的资源时,就会发生死锁。 文件锁与文件以及进程相关,文件close时,锁也被清空,fork出的子进程也不继承父进 程的锁。

IO多路转接

“IO多路转接”是指这样一种技术:先构造好一张关于文件描述符的列表,然后调用一个函 数,直到这些描述符中的一个准备好IO时,函数才返回,并且告诉进程哪些描述符准备好 了。这类函数主要有selectpoll

select函数

#include <sys/select.h>
int select(int maxfdp1,
           fd_set *restrict readfds,
           fd_set *restrict writefds,
           fd_set *restrict exceptfds,
           struct timeval *restrict tvptr);
/* 返回值:准备好的描述符数,超时返回0,出错返回-1 */

简单地说,该函数告诉内核每个状态(读、写、异常)下我们关系的描述符有哪些,超时 时间是多少。然后内核返回给进程准备好的描述符数之和。

fd_set结构是“描述符集”,它相当于一个数组,为每一个可能的描述符保持了一位,最 大的位数可以设置。

maxfdp1表示“最大描述符加1”,即在三个fd_set中有效的最大描述符加上1的值,这样 内核只需要关心三个fd_set中的小于maxfdp1的那些描述符了。

poll函数

#include <poll.h>
int poll(struct pollfd fdarray[], nfds_t nfds, int timeout);
/* 返回值:准备好的描述符数,超时返回0,出错返回-1 */

与select不同的是,poll不为每个状态设置一个描述符集,而是统一用一个pollfd类型 的数组,里面放上我们关心的描述符以及所关心的状态。

struct pollfd {
    int fd;        /* <0 to ignore */
    short events;  /* events of interest on fd */
    short revents; /* events that occurred on fd */
};

常用的events和revents标志有:

POLLIN    不阻塞地可读除高优先级以外的数据
POLLPRT   不阻塞地可读高优先级数据
POLLOUT   不阻塞地可写普通数据
POLLERR   已出错
POLLHUP   已挂断

通过这些值,进程告诉内核我们关心的是什么,然后内核通过设置revents来告诉进程实际 发生的是什么。

epoll函数

使用select和poll不可比避免的一个操作就是遍历文件描述符,看看哪些准备好了。当进 程关注的文件描述符较多时,比如一个web服务器,遍历的开销就会变得非常大,以至于成 为性能的瓶颈。另外,select和poll支持的最大文件描述符数也有限制,即FD_SETSIZE ,这个数一般是1024。为了解决这些问题,于是有了epoll。和poll一样,epoll也是监控 大量的文件描述符,看他们是否准备好IO。

epoll的API

主要有三个API来创建和管理epoll实例:

  • epoll_create 创建一个epoll实例,返回与这个实例关联的一个文件描述符。
  • epoll_ctl 对于我们感兴趣的文件描述符,可以用epoll_ctl来注册。 注册到一个epoll实例上哪些文件描述符组成一个集合,一般叫做“epoll set”。
  • epoll_wait 等待IO事件,当前没有事件发生时,就阻塞调用它的进程。
“边缘触发”和“水平触发”

epoll支持“水平出发(Level-triggered)”或者“边缘触发(Edge-triggered)”。如果学学过“电子线路”之类的课,就很容易理解这两种触发方式。

我们可以把文件描述符没有准备好的状态设想为“0(低电平)”,已经准备好的状态设想为 “1(高电平)”。那么当文件从没准备好变成准备好了时,状态就从“0”变到“1”,如果有个 示波器,那图像就是一个直角矩形。这时,就造成一个“边缘触发”。而“水平触发”,就是 只要状态为“1”,则总处于触发状态。如果某次检测时有一个文件描述符准备好了,则它从 “0”变为“1”,此时无论是“边缘触发”还是“水平触发”都能检测到改描述符准备好了。但如 果一个文件描述符的状态一直是“1”,那么对于“水平触发”,它就能一直被检测到。

epoll的默认触发方式就是“水平触发”。

/proc 接口

epoll支持的最大文件描述符数并非没有限制。以下出自epoll的man手册。

The following interfaces can be used to limit the amount of kernel memory consumed by epoll:

/proc/sys/fs/epoll/max_user_watches (since Linux 2.6.28)

This specifies a limit on the total number of file descriptors that a user can register across all epoll instances on the system. The limit is per real user ID. Each registered file descriptor costs roughly 90 bytes on a 32-bit kernel, and roughly 160 bytes on a 64-bit kernel. Currently, the default value for max_user_watches is 1/25 (4%) of the available low memory, divided by the registration cost in bytes.

epoll_create

epoll_create函数创建一个epoll文件描述符:

#include <sys/epoll.h>
int epoll_create(int size);
/* 成功返回文件描述符,出错返回-1,并通过设置errno指定错误类型 */

epoll_create()创建一个epoll实例。 从2.6.8版本的内核开始,参数size只要不是0,它事实上是被忽略的。 该函数返回与这个新的epoll实例关联的文件描述符,它之后所有对epoll实例的调,都要 用到这个文件描述符,当它不再需要时,应该通过close来关闭它。 当关联到一个epoll实例的所有文件描述符都被关闭时,内核就会销毁epoll实例,并释放 相关资源。

epoll_ctl

epoll_ctl是epoll文件描述符的控制接口。

#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/* 成功时返回0,出错返回-1并设置errno */

它控制由epfd关联的epoll实例,op代表想要的操作,这些操作会作用在目标fd上。 上。op可能的值是:

EPOLL_CTL_ADD    注册操作
EPOLL_CTL_MOD    修改操作
EPOLL_CTL_DEL    删除操作

event表示我们在fd上关心的IO类型

typedef union epoll_data {
    void        *ptr;
    int          fd;
    uint32_t     u32;
    uint64_t     u64;
} epoll_data_t;

struct epoll_event {
    uint32_t     events;      /* Epoll events */
    epoll_data_t data;        /* User data variable */
};

其中,events可以是下面这些值中的一个:

EPOLLIN      可读
EPOLLOUT     可写
EPOLLRDHUP   Stream socket对关闭连接,或者关闭连接的写端
EPOLLPRI     紧急数据可读
EPOLLERR     出错
EPOLLHUP     挂断
EPOLLET      设置边缘触发
EPOLLONESHOT 设置one-shot模式
epoll_wait

epoll_wait等待在一个epoll文件描述符上的IO事件。

#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
               int maxevents, int timeout);
/* 成功时返回可以IO的文件描述符数量,出错返回-1并设置errno */
  • epfd表示我们关心的epoll实例。
  • eventsstruct epoll_event类型的指针,它指向的数组存放准备好了的事件。
  • maxevents只我们想要接受的最大事件的数目。可以设置为struct epoll_event数组 events的长度。
  • timeout表示超时时间,单位是ms。0表示不阻塞,-1表示永远阻塞。
epoll的使用

下面我们通过man手册中给出的标准示例看看epoll的用法。

listener 是一个飞阻塞的socket,通过listen系统调用在这个socket上进行请求的接收。 do_use_fd()函数使用已经准备好IO的文件描述符,read或者write,直到遇到EAGAIN ,然后记录它自己的状态,这样在下一次调用do_use_fd()时它能够从之前停止的的地方 继续read或write。

#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;

/* Set up listening socket, 'listen_sock' (socket(),
   bind(), listen()) */

epollfd = epoll_create(10);    /* 这里的10事实上被忽略的 */
if (epollfd == -1) {
    perror("epoll_create");
    exit(EXIT_FAILURE);
}

ev.events = EPOLLIN;          /* 对于listen_sock,我们关系它是否可读 */
ev.data.fd = listen_sock;
/* 在epollfd关联的epoll实例上注册listen_sock */
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
    perror("epoll_ctl: listen_sock");
    exit(EXIT_FAILURE);
}

for (;;) {
    nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
    if (nfds == -1) {
        perror("epoll_pwait");
        exit(EXIT_FAILURE);
    }

    for (n = 0; n < nfds; ++n) {
        if (events[n].data.fd == listen_sock) {
            /* 检测到请求,建立新的socket来处理请求 */
            conn_sock = accept(listen_sock,
                            (struct sockaddr *) &local, &addrlen);
            if (conn_sock == -1) {
                perror("accept");
                exit(EXIT_FAILURE);
            }
            setnonblocking(conn_sock);
            ev.events = EPOLLIN | EPOLLET;
            ev.data.fd = conn_sock;
            /* 把新的socket注册到epollfd代表的epoll实例上 */
            if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
                        &ev) == -1) {
                perror("epoll_ctl: conn_sock");
                exit(EXIT_FAILURE);
            }
        } else {
            do_use_fd(events[n].data.fd);
        }
    }
}

readv 和 writev函数

readv 和 writev函数用于在一次函数调用中读、写多个非连续缓冲区。有时也将这两个函数 成为散布读(scatter read)聚集写(gather write)。如果使用read或者 write,完成同样的功能需要多次的系统调用。现在用readv和writev主要调用一次就OK。

#include <sys/uio.h>
ssize_t readv(int filedes, const struct iovec *iov, int iovcnt);
ssize_t writev(int filedes, const struct iovec *iov, int iovcnt);
/* 成功时返回已读、写的字节数,出错返回-1 */

这两个文件的第二个参数是指向iovec结构数组的指针,该结构表示一个缓冲区:

struct iovec {
    void *iov_base;    /* starting address of bufffer */
    size_t iov_len;    /* size of buffer */
};

存储映射IO

创建映射存储区

存储映射IO(Memory-mapped IO)使一个磁盘文件与存储空间中的一个缓冲区映射。 操作缓冲区就相当于操作磁盘上的文件。mmap函数实现这个功能。

#include <sys/mman.h>
void *mmap(void *addr, size_t len, int prot, int flag, int filedes,
           off_t off);
/* 若成功则返回映射区的起始地址,若出错则返回MAP_FAILED */
  • addr 参数表示映射区的起始地址。通常设为0,表示由系统选择该映射区的起始地址
  • len 表示映射的字节数
  • off 表示要映射字节在文件中的起始偏移量
  • prot 表示对映射存储区的保护要求,其值可以下面这些值的任意组合:
  • fileds 表示要被映射的文件的描述符 PROT_HEAD 映射区可读 PROT_WRITE 映射区可写 PROT_EXEC 映射区可执行 PROT_NONE 映射区不可访问
  • flag 参数影响映射区的多种属性: MAP_FIXED 返回值必须等于addr,但一般把addr设为0 MAP_SHARED 对存储区的操作相当于对文件write MAP_PRIVATE 对存储区的操作不会产生对文件的write 其中,MAP_SHAREDMAP_PRIVATE两个参数必须选一个。

与映射存储区相关的有两个信号:

  • SIGSEGV 试图访问对它不可用的存储区,或企图写数据到只读的存储区时产生该信号
  • SIGBUS 访问映射区的某个部分,而在访问时这一部分实际上已经不存在时产生该信号

fork时子进程继承父进程的映射存储区。

mprotectfsyncmunmap函数

使用mprotect函数可以修改映射存储区的权限:

#include <sys/mman.h>
int mprotect(void *addr, size_t len, int prot);

使用fsync函数可以冲洗映射存储区的数据到被映射的文件中:

#include <sys/mman.h>
int msync(void *addr, size_t len, int flags);

当进程终结或者调用了munmap函数时,映射存储区就被解除映射:

#include <sys/mman.h>
int munmap(caddr_t addr, size_t len);

需要注意的是,munmap函数不会自动把映射区的内容写到磁盘文件上。

sendfile函数

sendfile函数用来在两个文件描述符之间传递数据。

#include <sys.sendfile.h>
int sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
/* 成功时返回写到out_fd的数据的字节数,出错时返回-1并设置errno */

参数解释如下:

  • out_fd 数据将要写入的那个文件描述符
  • in_fd 数据从这个描述符取出,必须是一个真实存在的文件,或者是能够mmap的设备
  • offset 从文件的哪里开始传输
  • count 要传输的字节数

对于像web服务器这样的应用,经常需要把某个文件的内容传输到客户端,也就是写到与客 户端通信的socket上,基本的操作类似于这样:

open source (disk file)
open destination (network connection)
while there is data to be transferred:
    read data from source to a buffer
    write data from buffer to destination
close source and destination

数据的读取和写入需要调用read和write系统调用。 在一个read系统调用中,数据的传输主要经过了如下几个路径:

从硬盘中取出数据 --传输到--> 内核缓冲区 --复制到--> 程序的缓冲区

而在write系统调用中中,数据传输的路径则是:

程序的缓冲区 --复制到--> 内核缓冲区 --传输到--> 文件或设备(比如网卡)

进程每次使用系统调用,都会出现一次在用户态和内核态的上下文切换,大量的系统调用 消耗的资源是非常可观的。为了处理这种情况,sendfile出现了,使用sendfile时, 数据传输的路径是:

从硬盘中取出数据 --传输到--> 内核缓冲区 --传输到--> 文件或设备(比如网卡)

省去了数据在内核空间和用户空间的两次传输。

JH, 2013-05-01


参考资料: