文章目录
Unix 文件I/O
本文主要记载Unix文件I/O的相关内容,资料主要来源于《Unix环境高级编程》(第三版)与网上的一些资料。本文仅代表笔者对Unix文件I/O的浅薄认识,如有错误,欢迎指正。
1. 引言(Introduction)
“一切皆为文件”
在Unix或者Linux系统中,不管是网卡,磁盘与摄像头等几乎所有的I/O设备,都可以抽象成文件。从这些I/O设备中读写数据都可以看作是对文件进行读写数据的操作。本文主要介绍Unix文件I/O涉及的基本概念,以及常用的几种文件I/O函数:open,read,write,lseek及close。另外,在了解其用途的基础上尽可能地掌握相关的基本原理。
2. 文件描述符(File Descriptor)
对于内核而言,所有的打开的文件都可以通过文件描述符引用,大部分对文件的I/O操作都是通过对文件描述符进行的。文件描述符是一个非负整数,系统决定了每个进程能够打开的文件数量。
另外,每个文件描述符都对应着一个文件(一对一或多对一)。按照惯例,Unix的shell会把文件描述符0与进程的标准输入关联,文件描述符1与进程的标准输出关联,文件描述符2与进程的标准错误关联。
3. I/O Functions
3.1 open and openat Functions
可以通过open或者openat函数创建或打开一个文件:
#include <fcntl.h>
int open(const char *path, int oflag, ... /* mode_t mode*/);
int openat(int fd, const char *path, int oflag, ... /* mode_t mode*/)
/*若成功,则返回文件描述符;若出错,则返回-1*/
- path:要打开或者创建文件的名字;
- oflag:打开文件的模式,如只读打开,只写打开与读、写打开等;
- fd:文件描述符(下同)。
由open和openat函数返回的文件描述符是最小的未用的描述符数值。
3.2 close Function
可以通过close函数关闭一个打开的文件:
#include <unistd.h>
int close(int fd);
/*若成功,则返回0;若出错,则返回-1*/
关闭一个文件时会释放该进程在该文件上的所有的记录锁。当一个进程终止时,内核会自动关闭它所有的打开文件。
3.3 lseek Function
每个打开文件都有一个与其相关联的“当前文件偏移量”(current file offset),用来度量从文件开始处计算的字节数。对文件的读写操作都从当前文件偏移量处开始,并使偏移量增加所读写的字节数。可以用lseek为一个打开文件设置偏移量:
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
/*若成功,则返回新的文件偏移量;若出错,则返回-1*/
- offset:偏移的字节数量;
- whence:为SEEK_SET时,将偏移量设置为距离文件开始处offset个字节处;为SEEK_CUR时,将偏移量设置为距离当前值的offset个字节处;为SEEK_END时,将偏移量设置为文件长度加offset个字节处,offset可正可负。
当文件的偏移量大于文件的当前长度时,对该文件的下一次写将加长该文件,并在文件中构成一个空洞。并且位于文件中没有被写的地方都会被读为0。
3.4 read Function
可以通过read函数从打开文件中读取数据:
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t nbytes);
/*若成功,返回读到的字节数,若已到文件尾,返回0;若出错,则返回-1*/
- buf:保存目标数据的数组;
- nbytes:需要读取的数据字节数。
读操作从文件的当前偏移量处开始,在成功返回后,该偏移量将增加实际读到的字节数。
3.5 write Function
可以通过write函数向打开的文件写数据:
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t nbytes);
/*若成功,返回已写的字节数;若出错,则返回-1*/
- buf:写进文件的原数据数组;
- nbytes:需要写的数据字节数。
write常常出错的一个原因是磁盘已经写满了,或者超出了一个给定进程的文件长度限制。
不管是read还是write函数,都有一个需要注意的问题就是其读写数据的效率(I/O效率)。影响I/O效率的一个因素是nbytes的大小,一般选择磁盘块的长度作为nbytes。因为在nbytes小于磁盘块大小时,读写效率太慢;而在nbytes大于磁盘块时,对I/O效率没有多大影响,并且可能会产生冗余。
另外,大多数文件系统为改善性能,都采用某种预读(read ahead)技术。即当检测到正在进行顺序读取时,系统就尝试读入比应用所要求更多的数据,并假想应用很快就会读这些数据。
4. 文件共享(File Sharing)
在讲解文件共享前,需要先了解内核用于所有I\O的数据结构:
内核使用3种数据结构表示打开的文件:
- 每个进程在进程表中都有一个记录项。记录项包含了打开文件的文件描述符表,每个文件描述符占用了一项。一项的内容包括:文件描述符,指向文件表项的指针;
- 内核为所有打开的文件都维持对应的文件表。每个文件表项包含了:文件状态标志(读、写、添写、同步和非阻塞等),当前文件的偏移量,指向该文件v节点表项的指针;
- 每个打开的文件都有一个v节点(v-node)结构。v节点包含了文件类型和对此文件进行各种操作函数的指针。对于大多数文件,v节点还包含了该文件的i节点(i-node,索引节点)。
所以,如果两个进程的进程表的文件表都指向了相同的v节点,那么这两个进程都可以访问同一个打开的文件。
文件共享可能会引发的一个问题是,有可能会发生脏写等现象,即两个进程轮流对同一个文件进行写操作,或者出现覆盖的现象。这个时候就需要原子操作(Atomic Operations),任何要求多于一个函数调用的操作都不是原子操作。
5. 其它函数(Other Functions)
5.1 dup and dup2 Functions
如果需要复制一个现有的文件描述符,则可以:
#include <unistd.h>
int dup(inf fd);
int dup2(int fd, int fd2);
/*若成功,返回新的文件描述符;若出错,则返回-1*/
- fd:需要复制的文件描述符;
- fd2:指定的新的文件描述符。
由dup返回的新文件描述符是当前可用的最小的文件描述符。对于dup2可以使用fd2指定新的文件描述符,如果fd2已经打开,则先将其关闭,如果fd==fd2,那么直接返回fd2,而不关闭它。
5.2 sync,fsync,and fdatasync Functions
传统的Unix系统在内核中设有缓冲区或者页高速缓冲区,大多数磁盘I/O都通过缓冲区进行。当向文件写入数据时,内核通常会先将数据复制到缓冲区中,然后排入队列,晚些时候再把缓冲区的数据写进磁盘。这就是延迟写(delayed write)。
当内核需要使用到缓冲区来存储其它磁盘块数据时,它会先把所有延迟写的数据块写进磁盘。为了保证磁盘的实际内容与缓冲区的内容相同,Unix提供了sync,fsync,和fdatasync函数。
#include <unistd.h>
void sync(void);
int fsync(int fd);
int fdatasync(int fd);
/*若成功,则返回0;若出错,则返回-1*/
sync把所有修改过的块缓冲排入写队列,然后返回,并没有等待实际写磁盘操作的结束。Unix中存在一个update的系统守护进程周期性地调用sync函数,保证定期冲洗(flush)内核地块缓冲区。命令sync(1)也调用了sync函数。
fsync只对fd指定地文件起作用,并且等待写磁盘操作结束才返回,并且会同步更新文件的属性。
fdatasync与fsync相同,但只影响文件的数据部分。
5.3 fcntl Funciton
fcntl函数可以改变已经打开的文件的属性:
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* int arg */);
/*若成功,则依赖于cmd;若出错,则返回-1*/
- cmd = F_DUPFD or F_DUPFD_CLOEXEC:复制一个已有的文件描述符;
- cmd = F_GETFD or F_SETFD:获取/设置文件描述符标志;
- cmd = F_GETFL or F_SETFL:获取/设置文件状态标志;
- cmd = F_GETOWN or F_SETOWN:获取/设置一步I/O所有权;
- cmd = F_GETLK, F_SETLK, or F_SETLKW):获取/设置记录锁。
6. 有/无缓冲的I/O(I/O with buffer or without buffer)
本章将简要分析一下Linux系统文件I/O的细节。
前面所说的函数是不带缓冲的I/O(unbuffered I/O),不带缓冲并不是指内核不提供缓冲,而是指数据从进程空间到内核中不需要经过一个缓冲区(这个缓冲区是在内核外面的)。从下图中可以看出,数据从进程到内核只能通过系统调用的方式(system calls)。所以不带缓冲的I/O必然需要系统调用,而不需要库函数调用。比如write与read就是单纯的系统调用。
数据在进程,内核与硬盘之间的流动关系如下图:
根据这张图,可以看到数据从进程空间到硬盘的过程中,需要经过许多缓冲区。需要注意的一点是,有没有缓冲的I/O并不涉及内核与硬盘之间的数据传输。下面将分别介绍有缓冲的I/O与没有缓冲的I/O的实例:
-
通过clib buffer(函数库)
{ char *buf = malloc[MAX_BUF_SIZE]; strncpy(buf, src, MAX_BUF_SIZE); fwrite(buf, MAX_BUF_SIZE, 1, fp); fclose(fp); }
这里,buf对应着上图的application buffer。调用fwrite后,数据会从application buffer传输到clib buffer,即C库标准的IO buffer。这个时候如果进程突然挂了,这些数据就会丢失,因为这些数据还是保存在进程空间中。
调用fclose后,数据会从clib buffer刷新到磁盘介质中。除了fclose函数外,还可以使用fflush把数据从clib buffer拷贝到page cache中,然后再使用fsync函数把数据从page cache拷贝到disk cache中。
-
直接到page cache(系统调用)
{ int res; char *buf = malloc[MAX_BUF_SIZE]; strncpy(buf, src, MAX_BUF_SIZE); if((res = write(fd, buf, MAX_BUF_SIZE)) != MAX_BUF_SIZE){ print("Error writing.\n"); } close(fd); }
这里,调用write,数据直接从application buffer拷贝到page cache中。调用fsync可以把数据从page cache拷贝到disk cache中。
通过对比上面两个例子,可以很明显地分辨出有缓冲的I/O与没有缓冲的I/O的区别。缓冲相当于是数据在进程空间与内核空间的一个缓冲区域,没有缓冲的I/O是通过系统调用(用户态与内核态切换)直接把数据从进程空间拷贝到内核中,而有缓冲的I/O则需要先把数据从application buffer拷贝到clib buffer中。
7. 总结
本文的主要内容是关于Unix系统的文件I/O的一些描述,主要介绍了open,close,lseek,read与write这5个函数,同时也介绍了有没有缓冲的I/O的区别。
参考资料
Advanced Programming in the UNIX Environment - Third Edition
版权声明
转载请注明出处。