首页 > 代码库 > 深入探究文件I/O
深入探究文件I/O
读本文章前,必须先有一些通过I/O模型的系统调用的基础,即 open() , create() , read() , write() , close() , lseek() 函数的调用。
原子操作
在文件读写中,很容易有多个进程读取同一文件的情况,这时候竞争状态便不可避免。文件I/O的函数提供的一些参数配合系统调用的原子性很好的解决了这个问题。
来看一个关于竞争创建者的例子:
int main(int argc, char *argv[])
{
int fd;
fd = open(argv[1], O_WRONLY); /*只写形式打开文件,若指定文件不存在则打开错误*/
if(fd != -1)
{
printf("[PID %ld] File \"%s\" already exits", (long)getpid(), argv[1]);
close(fd);
}
else
{
if( errno == ENOENT ) /*ENOENT错误指代文件不存在*/
{
printf("[PID %ld] File \"%s\" doesn‘t exits yet\n", (long)getpid(), argv[1]);
if(argc > 2) {
sleep(5);
printf("[PID%ld] Done sleeping\n", (long)getpid());
}
fd = open(argv[1], O_WRONLY | O_CREAT, S_IRUSR | S_IWUSR); /*加入参数O_CREAT,文件不存在则创建*/
if(fd == -1)
{
printf("创建失败\n");
}
printf("[PID %lld] Created file \"%s\" exclusively\n", (long)getpid(), argv[1]);
}
}
return 0;
}
看起来程序没有什么问题,先打开文件,如果文件不存在则加O_CREAT参数创建打开,但是如果此时两个进程同时运行这个代码,出现竞争问题(抢占CPU是不可控制的),这里利用sleep假定出进程B抢到资源的情况,运行中会出现问题。
运行结果:
图中可看出,先加参数运行代码,则此时进程A运行中会有5秒的睡眠,在这个时间开起线程B,此时file还未被创建,B创建文件,然后A睡醒,因为睡前判断过没有file,所以直接加参数open,此时file应为B创建,但A误以为是自己创建,所以,,恩,懵逼了,实际情况中是抢,谁知道哪个先抢到。。
所以这里要提的是原子性,因为系统调用本身具有原子性,所以这里就不需要封装阿啥的,只需要把参数加进去,让它承着函数的特性,实现原子性的操作。规避竞争状态。
还有文件偏移量的问题,多个进程向同一文件写入数据,然而这个进程写入数据引起的偏移量不会同步到另一进程,那么另一个进程在添加数据就会覆盖上个进程写入的数据。这里我们有两个函数解决这个问题。
#include <unistd.h>
ssize_t pread(int fd, void *buf, size_t count, off_t offset);
ssize_t pwrite(int fd, void *buf, size_t count, off_t offset);
pread() 调用等同于将如下调用纳入原子操作:
第三个参数解释:
- 如果为SEEK_SET,文件偏移量将被设置为 offset。
- 如果为SEEK_CUR,文件偏移量被设置为 当前位置+offset。可正可负。
- 如果为SEEK_END,文件偏移量将被设置为文件长度加上offset。可正可负。
off_t orig;
orig = lseek(fd, 0, SEEK_CUR); /*移动0,返回当前偏移量*/
lseek(fd, offset, SEEK_SET); /*将偏移量移动到指定读取位置*/
s = read(fd, buf, len); /*读文件*/
lseek(fd, orig, SEEK_SET); /*移回来*/
上面的代码就能看出 pread() 的功能了,即指定位置读取文件但不更改原偏移量位置。pwrite() 同理。
进程管辖下的所有线程共享同一文件描述符表,这意味着每个已打开的文件被所有线程共享,所以有了这两个函数,线程就可以同时对同一文件描述符执行I/O操作,且不会因为其他线程修改文件偏移量而影响。
文件控制操作
这里里由一个函数比较强大,可对一个已经打开的文件描述符执行一系列控制操作。
#include <fcntl.h>
int fcntl(int fd, int cmd, ...); /*第三个参数可以设置任意类型或者省略*/
因为文件在操作时是已经打开的,有时候不能直接通过上文得知打开模式,这里需要使用掩码 O_ACCMODE 与 flag 相与,然后与别的标志做比即可。示例代码如下:
accessMode = flags & O_ACCMODE;
if(accessMode == O_WRONLY || accessMode == O_RDWR)
printf("file is writable\n);
修改文件的状态标志时,需要用到参数 F_GETFL ,代码是里如下,如下给文件添加 O_APPEND 标志。
int flags;
flags = fcntl(fd, F_GETFL); /*获取当前标志的副本*/
if(flags == -1) errExit("fcntl");
flags |= O_APPEND; /*添加标志*/
if(fcntl(fd,F_SETFL,flags) == -1) errExit("fcntl"); /*更新状态*/
文件描述符和打开文件的关系
讲真,看这章前,已经见过不少关于文件描述符阿,文件句柄阿之类的名词,然后被搞得晕头转向,还好,终于遇上这章了,终于等到你还好我没放弃~~~
首先要明白,文件描述符和打开的文件不是一一对应!!不是!!
接着来看看内核维护的三个数据结构:
- 进程级的文件描述符表(每个条目包含:控制文件描述符操作–close-on-exec标志 and 对打开的文件句柄的引用)
- 系统级的打开文件表(每个条目包含:当前文件偏移量 and 打开文件时使用的状态 and 文件访问权限-一般为创建文件时设置 and 与信号驱动I/O相关的设置 and 对该文件 i-node 对象的引用)
- 文件系统的i-node表(每个条目包含:文件类型和访问权限 and 一个指向该文件所持有的锁的列表的指针 and 文件各种属性)访问一个文件时,会在内存中为i-node创建一个副本。
来看一下三个数据结构的关系图:
现在就图中的几种情况举例说明关系:
进程A中,描述符 fd1 和 fd20 都指向同一个打开的文件句柄23,即同一进程中的不同描述符对应同一文件句柄,可通过dup(),dup2()或fcntl()形成。
进程A中 fd2 和进程B中fd2都指向同一个打开的文件句柄,这种情况出现在调用 fork() 后的父子进程。
进程A的 fd0 和 进程B的 fd3 分别指向不同的打开文件句柄,但两个句柄指向i-node表中的相同条目即相同文件,这种情况通常为两个进程各自对一份文件发起 open() 调用。
通过上述揭示两个要点:
- 两个不同的文件描述符,若指向同一打开文件句柄,将共享同一文件偏移量。
- 获得和修改打开文件标志,可执行 fcntl() 的 F_GETFL 和 F_SETFL 操作。
- 文件描述符标志(close-on-exec)为进程和文件描述符所私有。
#include <unistd.h>
int dup(int oldfd);
int dups(int oldfd, int newfd);
int dup3(int oldfd, int newfd, int flags);
调用复制一个打开的文件描述符oldfd,并返回一个新描述符,二者指向同一打开的文件句柄,系统保证新描述符是编号值最低的未用文件描述符。dup2() 可指定新的文件描述符。
如果 oldfd 并非有效的文件描述符,那么 dups2() 将调用失败并返回错误EBADF ,且不关闭 newfd。
fcntl() 的 F_DUPFD 操作是复制文件描述符的另一接口,更具灵活性。
/*创建oldfd的副本,将使用大于等于startfd的最小未用的值作为编号*/
newfd = fcntl(oldfd,F_DUPFD,startfd);
文件描述符的正,副本之间共享同一打开文件句柄所含的文件偏移量和状态标志。dup3() 的 flags 参数支持一个标志 O_CLOEXEC , 促使内核为新文件描述符设置 close-on-exec 标志。
分散输入和集中输出
readv() 和 writev() 系统调用分别实现了分散输入和集中输出的功能。
#include <sys/uio.h>
struct iovec {
void *iov_base; /*缓冲区的开始位置*/
size_t iov_len; /*缓冲区大小*/
};
/*从文件中读取一片连续的字节,然后将其散置于 iov 指定的缓冲区中*/
ssize_t readv(int fd,const struct iovec *iov, int iovcnt);
/*将 iov 指定的所有缓冲区中的数据拼接起来,然后以连续的字节序列写入文件*/
ssize_t writev(int fd, struct iovec *iov, int iovcnt);
函数将缓冲区 iov 中的数据写入文件fd,iovcnt 指定缓冲区中有几个struct iovec结构体。可通过定义<limit.h >
标准库中的 IOV_MAX 来对 iovcnt 加以限制。也可调用 sysconf(_SC_IOV_MAX) 获得此限额。此外,glibc 对 readv() 和 writev() 的封装函数做了额外处理,若系统因 iovcnt 过大而调用失败,外壳函数将临时分配一块缓冲区足够容纳 iov 所有成员的数据缓冲区,然后调用 read() 或 write() 调用。还有 preadv() 和 pwritev() 系统调用在文件指定位置读写。
截断文件
#include <unistd.h>
int truncate(const char *pathname, off_t length);
int ftruncate(int fd, off_t length);
若文件长度大于参数 length,调用将丢弃超出部分,若小于参数 length,调用将在文件尾部添加一系列空字节或者一个文件空洞。区别在于一个通过路径打开,且对文件拥有可写权限,另一个通过描述符打开,此描述符必须有可写权限。
大文件I/O
通常我们用来存放文件偏移量的 off_t 为有符号的长整型,在32位机中,这将文件大小置于 2^31 -1 字节之下,(同理64位的理论上范围达到 2^63-1,基本已经超出磁盘容量)所以对文件的长度有限制。但有时候会有大文件然后长整型范围已经表示不了的情况。
所以提供了对LFS的支持。
要获取LFS功能,有两种办法:
1.在编译时加入 -D_FILE_OFFSET_BITS=64
2.在代码开头加入 #define _FILE_OFFSET_BITS 64
要使用过渡型的LFS API,必须在编译程序时定义 LARGEFILE64_SOURCE 功能测试宏。该 API 所属函数具有处理 64 位文件大小和文件偏移量的能力。命名为 fopen64(), open64(), lseek64(), truncate64(), stat64(), mmap64(), setrlimit64()。除了函数,好有数据类型:struct stat64, off64_t 。
注意,一旦使用LFS,off_t54输出时转换为 (long long) 型。
对于每个进程,内核提供一个特殊的虚拟目录/dev/fd,该目录包含/dev/fd/n形式的文件名,打开该目录下一个文件等同于复制相应的文件描述符。
fd = open("/dev/fd/1", O_WRONLY); 等同于 fd = dup(1);
/dev/fd 实际上是一个符号连接,链接到 Linux 所专有的 /proc/self/fd 目录。
在程序中,我们有时候需要创建一些临时文件,仅供其在运行期间使用。
#include <stdlib.h>
int mkstemp(char *template);
template 为路径名,其中最后6个字符必须为XXXXXX,这六个字符由系统自由分配,保证了文件名的唯一性,并通过template参数返回。文件用完后使用unlink系统调用返回。如下:
#include <stdio.h>
#include <stdlib.h>
int main( int argc, char *argv[] )
{
int fd;
char template[] = "/tmp/aaaXXXXXX"; /*必须为字符数组,不可为常量*/
fd = mkstemp(template); /*创建*/
printf("chuangjian chengong\n");
unlink(template); /*删除*/
close(fd);
return 0;
}
同样作用的还有函数tmpfile():
#include <stdio.h>
FILE *tmpfile(void);
tmpfile() 会创建一个名称唯一的临时文件,将返回一个文件流供 stdio 库函数使用。
深入探究文件I/O