文件I/O与系统编程

文件IO与系统编程

本文是作者阅读TLPI(The Linux Programer Interface的总结),为了突出重点,避免一刀砍,我不会过多的去介绍基本的概念和用法,我重点会去介绍原理和细节。因此对于本文的读者,至少要求读过APUE,或者是实际有写过相关代码的程序员,因为知识有点零散,所以我会尽可能以FAQ的形式呈现给读者。

修订历史:
2016/12/05 对于符号链接解引用的判断,补充磁盘空间分配一节
2017/1/14 新增对O_EXCL的解释,对open打开目录的行为进行了补充,新增对于文件空洞的判断。
2017/5/24 新增open打开目录的示例

系统编程概览

如何确定glibc的版本?

可以分为下面两种方式:
第一种就是直接查看,先通过ldd来定位glibc的位置,然后通过直接运行glibc库就可以查看到其版本号了.

[root@localhost ~]# ldd /bin/ls
    ............
    libc.so.6 => /lib64/libc.so.6 (0x00007f62209f3000)
    .............
[root@localhost ~]# /lib64/libc.so.6
GNU C Library (GNU libc) stable release version 2.17, by Roland McGrath et al.
Copyright (C) 2012 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.
There is NO warranty; not even for MERCHANTABILITY or FITNESS FOR A
PARTICULAR PURPOSE.
Compiled by GNU CC version 4.8.5 20150623 (Red Hat 4.8.5-4).
Compiled on a Linux 3.10.0 system on 2016-02-16.
Available extensions:
    The C stubs add-on version 2.1.2.
    crypt add-on version 2.1 by Michael Glad and others
    GNU Libidn by Simon Josefsson
    Native POSIX Threads Library by Ulrich Drepper et al
    BIND-8.2.3-T5B
    RT using linux kernel aio
libc ABIs: UNIQUE IFUNC
For bug reporting instructions, please see:
//www.gnu.org/software/libc/bugs.html>.

如上图,可以看到glibc的版本是2.17,如果我们需要在我们的源代码中检测glibc的版本,因为我们的代码可能使用了一些高版本的glibc库函数。因此你可以使用下面这种方式第二种方式是通过__GLIBC____GLIBC_MINOR__这两个常量,是编译时常量,可以借助#ifdef预处理指令来测试,也可以到运行时借助if来判断,因为这是两个常量,那么就会存在一个问题,如果在A系统上进行了编译,拿到B系统上运行,那么这两个常量就没有任何用处了,因为编译期就已经确定了其数值,除非在B系统上再次编译一次。为了应对这种可能,程序可以调用gnu_get_libc_version这个函数来确定运行时所采用的glibc版本。代码如下:

#include 
#include 
#include 
#include 

int main()
{
    //编译时获取glibc版本,A机器上编译,B机器上运行就会存在问题.需要使用运行时获取glibc版本
    printf("major version:%d \t minor version:%d\n",__GLIBC__,__GLIBC_MINOR__);

    //获取运行时的glibc版本
    printf("glibc runtime version:%s\n",gnu_get_libc_version());

    char buf[65535] ={0};
    //  glibc特有的函数用来获取glibc版本,size_t confstr(int name, char *buf, size_t len);
    assert(confstr(_CS_GNU_LIBC_VERSION,buf,sizeof(buf)) > 0);
    printf("glibc version:%s\n",buf);
}

需要包含gnu/lib-version.h这个头文件,__GLIBC__是主版本号,__GLIBC_MINOR__是次版本号,除了可以使用gnu_get_libc_version函数外,还可以使用glibc特有的函数来获取glibc的版本号。

如何打印系统数据类型值?

对于C语言中的基本类型来说,很方便就可以通过printf来打印,但是linux系统通过typedef重新定义了很多系统数据类型,对于这些类型来说除非我们知道这个类型是对何种基本类型的typedef,否则很难正确的通过printf来打印,往往会导致打印的时候出现很多编译器的警告。例如下面这种情况。

#include 
#include 
#include 
#include 

int main()
{
    pid_t mypid;
    mypid = getpid();
    printf("mypid=%ld\n",mypid);
    return 0;
}
gcc -Wall 1.c 进行编译,会出现下面的Warnning。
1.c: In function ‘main’:
1.c:10:2: warning: format ‘%ld’ expects argument of type ‘long int’, but argument 2 has type ‘pid_t’ [-Wformat=]
  printf("mypid=%ld\n",mypid);

常见的应对策略是强制转换为long型,再使用%ld。但是有一个例外在一些编译环境中off_t的大小和long long相当,所以对于off_t类型的数据应该强转为long long使用%lld打印。

#include 
#include 
#include 
#include 

int main()
{
    pid_t mypid;
    mypid = getpid();
    printf("mypid=%ld\n",(long)mypid);
    off_t test = 0x9999;
    printf("myoff=%lld\n",(long long)test);
    return 0;
}

到此为此,解决了打印系统数据类型出现Warnning的问题了。其实在C99标准中提供了%z用来打印size_t和ssize_t类型,但是并不是所有的UNIX实现都支持。

文件I/O: 通用I/O模型

如何查看进程打开的所有文件描述符的信息?

通过/proc/PID/fdinfo 可以查看到进程号为PID的进程的打开文件描述符信息,如下:

[root@localhost ~]# ls /proc/854/fdinfo/
0  1  2  3  4  5  6  7  8

如上图显示的是pid为854的进程,其打开的文件描述符号,通过读取上面的文件,还可以知道更多关于该描述符的信息。

[root@localhost ~]# cat /proc/854/fdinfo/3
pos:    0
flags:  02004002
mnt_id: 7
[root@localhost ~]# cat /proc/854/fdinfo/4
pos:    0
flags:  02004002
mnt_id: 10
eventfd-count:                0
[root@localhost ~]# cat /proc/854/fdinfo/5
pos:    0
flags:  02004000
mnt_id: 10
inotify wd:2 ino:608b968 sdev:fd00000 mask:800afce ignored_mask:0 fhandle-bytes:c fhandle-type:81 f_handle:68b908060000000037477d08
inotify wd:1 ino:608b979 sdev:fd00000 mask:800afce ignored_mask:0 fhandle-bytes:c fhandle-type:81 f_handle:79b908060000000036477d08

分别打开了描述符3,描述4,描述符5,其中每个描述符显示的信息都不一样,对于3来说就是一个普通的文件描述符,输出的信息显示其文件偏移量,文件的打开标志信息,文件的挂载点id,通过cat /proc/854/mountinfo可以查看每个挂载点id对应的挂载路径信息。对于描述符4来说,额外多出了一个eventfd-count,这表明这是一个通过eventfd系统调用产生的描述符描述符5则是一个inotify产生的描述符,如果是IO复用机制产生的fd,那么会多出一个tfd的字段,还有signalfd产生的描述符,会多出一个sigmask字段。

O_ASYNC标志的作用是什么?

O_ASYNC称为信号驱动I/O机制,仅对特定类型的文件有效,诸如终端FIFOS,以及socket等,当文件描述符可以实施IO操作的时候(数据准备就绪,已经存储在内核缓冲区中),系统会产生一个信号通知进程。然后进程开始读取数据(把数据从内核缓冲区拷贝到用户缓冲区)。在Linux中在打开文件的时候指定O_ASYNC没有任何实质效果,必须调用fcntlF_SETFL,代码如下:

#include 
#include 
#include 
#include 
#include 
#include 
#include 
#include 

void handle_input(int signum) {
  char buf[256] = {0};
  read(0, buf, 255);
  printf("You input: %s", buf);  // TODO the not asynchronous signal security
}

int main() {
  int fd_flags;
  fcntl(0,F_SETOWN,getpid());
  fd_flags = fcntl(0,F_GETFL);
  fcntl(0,F_SETFL,(fd_flags|O_ASYNC));
  struct sigaction act;
  memset(&act, 0, sizeof(act));
  act.sa_handler = handle_input;
  sigaction(SIGIO,&act, NULL);
  while(1);
  exit(EXIT_SUCCESS);
}

上图是对标准输入设置O_ASYNC机制,那么用户输入数据就会导致系统发送信号通知进程。

O_EXCL和O_CREAT的妙用?

通过查看open的man文档你会发现,对于O_EXCL标志的作用的解释就是,结合参数O_CREAT参数使用,专门用于创建文件,如果要创建的文件存在就返回错误。这个标志最大的作用在于将检查文件是否存在和创建文件这两步变成一个原子操作。首先我们看下使用O_CREAT的情况下,如何创建文件。

if (access(filename,F_OK)) {
    //文件不存在的情况下,
    fd = open(filename,O_CREAT,0666);
} else {
    //文件存在的情况下,输出错误信息
}

上面是不使用O_EXCL标志的情况下,如何判断一个文件是否存在,不存在则打开这个文件。如果在NFS场景下,或者是多进程,多线程场景下,首先access判断文件是否不存在,发现不存在那么开始open打开这个文件,但是在open未调用之前,其它进程或线程创建了这个文件。然后开始执行open,那么这就存在问题了,open打开的是一个已经存在的文件。为了避免这个问题必须要给这两步加锁,除此之外你就可以使用O_EXCL标志配合O_CREAT标志,将上面两个步骤原子化。

换一种方式理解就是,如果没有O_EXCL标志的话,那么通过O_CREAT打开文件几乎总是成功的,无论文件是否存在,这样就没办法实现文件不存在的情况才创建文件这种逻辑,所以用户需要先access判断文件是否存在,不存在再创建文件,但是这是一个两步操作不是原子的,所以最终有了O_EXCL这个标志和O_CREAT结合后就可以将上面的两个步骤原子化。

如何判断一个文件是否是符号链接?

#include 
#include 
#include 
#include 
#include 

int main() {
  int ret = open("follow.txt", O_NOFOLLOW | O_RDWR);
  if (ret != 0 && errno == ELOOP) {
    printf("follow.txt is follow file\n");  //打开错误,且错误号是ELOOP即表明这是一个符号链接文件
  } else {
    perror("open file:");
  }
}

默认打开文件的时候,会对打开的文件进行解引用,打开的是最终的真实文件。 通过在open的时候指定O_NOFOLLOW选项使得在打开符号链接文件的时候
并不对该文件进行解引用,并且打开失败,返回ELOOP错误。

open打开目录会怎么样?

根据man文档EISDIR pathname refers to a directory and the access requested involved writing (that is, O_WRONLY or O_RDWR is set).,如果以写的方式打开目录的话会导致返回EISDIR错误,但是如果是只读方式来打开目录的话则是可以的。但是后续想要通过read来读取目录中的内容时则会毫无情面的返回 EISDIR fd refers to a directory.,所以open也就只能打开目录产生一个目录文件的fd而已。示例代码如下:

#include 
#include 
#include 
#include 
#include 


int main() {
  int fd = open("/home", O_RDONLY);

  // Open success
  if (fd == -1) {
    perror("open /home director failure:");
  } else {
    printf("open /home success\n");
  }


  char buf[1024] = {0};
  size_t ret = read(fd, buf, 1024);
  if (ret == -1) {
    printf("errno: %d EISDIR is %d\n", errno, EISDIR);
    perror("read data from /home:");
  } else {
    printf("read data from /home success: %s\n", buf);
  }

  int fd2 = open("/bin", O_RDWR);
  if (fd2 == -1) {
    perror("open /bin director :");
  } else {
    printf("open /bin success\n");
  }

  return 0;
}

上面的代码输出结果如下图:

open /home success
errno: 21 EISDIR is 21
read data from /home:: Is a directory
open /bin director failure:: Is a directory

什么是文件空洞,其作用是什么?

文件空洞其实就是文件偏移量超过了文件的结尾,然后执行IO操作,那么文件偏移量到文件结尾这段空间就形成了所谓的空洞,是不占据磁盘空间的。直到后续需要写入数据的时候才会分配磁盘块存储数据,虚拟机的磁盘格式有一种就是稀疏存储,就是用户设置了一个固定的磁盘大小,但是实际上并没有占用这么大的空间,而是使用多少就占用多少,直到超过这个固定的大小,也就是文件空洞的结尾处。

dd if=/dev/zero of=txt seek=1 bs=1M count=1

上面的命令会在当前目录生成一个txt文件,这是一个空洞文件,文件的开始处有1M空间是空洞,在1M~2M范围内才是文件的实际内容。

通过stat看到的是2M大小,并非真实大小,因为stat只是拿到文件的元数据信息,通du命令才可以看到真实的大小

如何判断一个文件是否是文件空洞?

最简单的思路就是看一个文件的大小(元数据信息中记录文件大小)和实际大小(分配的块数和块大小)是否相等即可判断一个文件是否是空洞文件。

struct stat {
    off_t     st_size;    /* total size, in bytes */
    blkcnt_t  st_blocks;  /* number of 512B blocks allocated */
  ..........
};

if (st_size > (512 * st_blocks)) {
  // maybe hole file
} else {
  // maybe normal file
}

上面的方法虽然比较简单,一定程度上它也是可用的,但是在一些特殊情况下结果却是不正确的,st_blocks是文件实际占用的blocks(这个block是512字节)数目,当文件的大小不足一个block的时候也是按照一个block来算,如果此时空洞部分的大小加上文件的大小也不足一个block的话,那么st_size总是小于512 * st_blocks,也就是表明它是一个正常文件,但实际上并不是,通过下面的代码可以复现问题。

#include 
#include 
#include 
#include 
#include 

int main() {
  struct stat st;
  stat("hole_file", &st);
  if ( st.st_size > (st.st_blocks * 512)) {
    printf("is hole file\n");
  } else {
    printf("is normal file\n");
  }
  return 0;
}

hole_file文件dd if=/dev/zero of=hole_file bs=1 seek=20 count=500
通过上面的命令创建了一个大小约是520B的稀疏文件,其中前20B是文件空洞。通过上面的代码可以检测出来
这是一个正常文件,这是和实际不符合的,这是因为文件的空洞和文件本身的大小加起来不足512B,但是实际上分配了1个blocks导致的。

这里的st_blocks的意思是如果按照512B一个block算的话,实际上分配了多少block,但这并不意味着底层的文件系统就是按照512B来作为一个block算的。

除了上面这个方法外,可以使用Linux 3.1内核引入的lseek参数 SEEK_HOLE/SEEK_DATA 来探测空洞,不过目前两个参数对文件系统的支持还不够全,不太建议生产环境下使用,后面我会单独讲解这两个参数。

参考文献:

  • check file sparse

  • return of SEEK_HOLE

如何分配磁盘空间?

分配磁盘空间?没听错是分配磁盘空间,平时我们都知道分配内存空间,但是很少知道分配磁盘空间,那么为什么需要分配磁盘空间这样的概念呢?,其实我们在使用write的时候,就会触发磁盘空间的分配,如果磁盘满了,或者达到文件空洞的边界,会导致write失败,为此我们可以预先分配磁盘空间,如果分配失败就可以不进行write操作了。linux下提供了一个特殊的系统调用如下:

       #define _GNU_SOURCE             /* See feature_test_macros(7) */
       #include -
       int fallocate(int fd, int mode, off_t offset, off_t len);

这个系统调用用于给fd,分配磁盘空间,POSIX标准提供了posix_fallocate。但是前者更高效。mode参数决定了是分配磁盘空间,还是回收磁盘空间,除此之外还有很多有趣的功能,mode的取值如下:

  • 默认值0 分配空间
#define _GNU_SOURCE
#include 
#include 
#include 
#include 
int main() {
  int fd = open("fallocate.txt", O_CREAT|O_RDWR, 0666);
  int ret = fallocate(fd, 0, 0, 10000000);
   if (ret != 0) {
    perror("fallocate");
  }
  return 0;
}

执行完成后,使用ls,stat等命令即可查询到文件变大了,此后往这个文件写数据的时候就不会因为磁盘空间不足导致写失败。

  • FALLOC_FL_KEEP_SIZE 分配空间,但是不改变文件的大小
....
#include  //新增这个头文件,声明了FALLOC_FL_KEEP_SIZE等宏


int main() {
  int fd = open("fallocate.txt", O_CREAT|O_RDWR, 0666);
  int ret = fallocate(fd, FALLOC_FL_KEEP_SIZE, 0, 10000000);
   if (ret != 0) {
    perror("fallocate");
  }
  return 0;
}

执行完成后,使用ls,stat等命令查看文件的大小,发现文件的大小没有变化,只有使用du命令才能看到文件实际上变大了。

  • FALLOC_FL_PUNCH_HOLE 释放空间,但是不改变大小,用于实现文件空洞

对于一个大文件,可以删除其中部分空间的数据,那么这段空间就被称之为文件空洞,但是因为FALLOC_FL_PUNCH_HOLE标志并不改变文件的大小,为此,需要结合FALLOC_FL_KEEP_SIZE标志来做文件空洞,使用FALLOC_FL_KEEP_SIZE来预分配一段空间,然后使用FALLOC_FL_PUNCH_HOLE来进行文件打洞,最后整个文件的大小却没有变化。

#define _GNU_SOURCE
#include 
#include 
#include 
#include 
#include 


int main() {
  int fd = open("fallocate.txt", O_CREAT|O_RDWR, 0666);
  int ret = fallocate(fd, FALLOC_FL_PUNCH_HOLE, 0, 10000000);
  ret = fallocate(fd, FALLOC_FL_KEEP_SIZE, 0, 10000000);
   if (ret != 0) {
    perror("fallocate");
  }
  return 0;
}

执行完后,你会发现整个文件的大小没有变化,但是实际上这个文件已经存在文件空洞了,通过du 命令可以查看到大小。

参考文献man手册

MySQL利用这个标志实现了透明的页压缩Innodb Transaparent PageIO Compression

关于这个标志的另外一个使用场景如下:

Back when my colleagues and I developed the original ReplayTV (which was not Linux-based, alas), we used the same concept to implement the variable-sized live-TV rewind buffer. Some of the engineers wanted to use a fixed-size file as a circular buffer, but I pointed out that if we used hole-punching (and 64-bit file offsets), the live-TV buffer could potentially use all otherwise unused disk space, and that as we needed to reclaim space for new video, we could just punch a hole at the beginning of the file. Effectively, the conceptual beginning of the file moves forward as the oldest video in the buffer is discarded.

致于为什么,释放空间形成文件空洞后,为什么不改变大小,可以参考这个add hole punching to fallocate

  • FALLOC_FL_COLLAPSE_RANGE 磁盘空间折叠,相当于在挖洞后,这个洞并没有留下,而是把后面的数据往前面移,因此被打洞的部分,在被随后访问到时,读取到的数据就不是0,而是后面的数据。

  • FALLOC_FL_ZERO_RANGE 实现文件空洞,但是不造成实际的磁盘IO。
    参考Introduce FALLOC_FL_ZERO_RANGE flag for fallocate

  • FALLOC_FL_INSERT_RANGE 在现有文件的基础上插入一个空洞,增加文件的大小,参考man文档

参考文献:
fallocate和reflink

深入探究文件I/O

如何原子性向文件中追加数据?

如果只是谈论如何向文件中追加数据,那么我很容易想到下面两种解决方式:

  • lseek定位到文件某尾,然后开始write写入数据
  • 打开文件的时候使用O_APPEND标志,然后开始write写入数据

但是如果问我如何原子性的向文件追加数据,那么只能是第二种方法,使用lseek定位的方法不是原子的,如果lseek定位到文件末尾,而另外一个进程向这个文件追加了数据,那么此时lseek返回的值已经不再是文件末尾了,这个时候写入数据存在问题。引起这个问题的原因就是lseek和write其实不是原子的。而是用O_APPEND标志不一样,加入这个标志后,每次write其实就是原子性的lseek和write操作。

部分文件系统是不支持O_APPEND语义的比如NFS文件系统,所以在这种文件系统下是无法保证写入数据的原子性

O_APPEND后是否可以lseek?

通过测试发现lseek是无法影响O_APPEND的,lseek只影响了read操作开始读取的位置,对于写操作来说仍然会原子的定位到文件末尾然后进行写入。

如何原子性的读取文件特定pos的数据?

很容易我会想到lseek到特定pos,然后读取数据,很可惜这样不是原子的,因为多进程是共享文件的pos,所以很有可能在你开始写入数据之前pos被改动了。为此linux内核提供了一套原子性的系统调用,用于原子性的定位和读写,函数原型如下:

       #include 
       ssize_t pread(int fd, void *buf, size_t count, off_t offset);
       ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);

这两个系统调用基本等同于下面操作:

off_t orig;
orig = lseek(fd,0,SEEK_CUR);
lseek(fd,offset,SEEK_SET);
s = read(fd,buf,len);
lseek(fd,orig,SEEK_SET)

pread基本包含了上述几个操作,不过对于pread来说这些操作是原子性的,并且操作完成后,还会还原回原来的文件偏移量。除了这个原子性优势外单个pread的操作的系统调用成本要低于上述几个操作带来的开销。

什么是Scatter-Gather I/O?

这个术语在linux内核层面也有一个同样的概念,不过我们这里谈论的不是Linux内核里面的这个概念。我们平常在编程的过程中是否遇到过,需要一次性将分布在内存多处的地方的数据写入到文件或者网卡上呢?,有没有需要从文件描述符中读取数据,并将这些数据依次放在多个buf里面的需求呢,这样的需求在网络编程中经常遇到,我们知道网络协议中经常有一个概念就是包头和包体,通常包头是放在一个地方,包体放在另外一个地方,这样导致发送数据的时候需要发送多次才能将数据发送出去,但是有了Scatter-Gather I/O只需要一次操作就可以完成,避免了多次系统调用的开销。Linux为我们提供了这样的系统调用,如下:

#include 
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

struct iovec {
   void  *iov_base;    /* Starting address */
   size_t iov_len;     /* Number of bytes to transfer */
};

struct iovec设置需要写入/读取的多个buf的位置和长度。linux同时也提供了preadv/pwritev用于原子性的定位Scatter-Gather I/O。

什么是非阻塞I/O?

我们平常读写文件的时候使用read/write的时候,好像没有阻塞的情况吧,其实不然,那是因为内核缓冲区保证了普通文件I/O不会陷入阻塞,因此打开普通文件一般会忽略阻塞标志,但是对于设备,管道和套接字文件来说是会发生阻塞的,比如,读取标准输入设备的时候,如果用户不输入数据,那么read会阻塞,读取套接字的时候,如果对端没有发送数据,那么read也会阻塞。有的时候我并不希望我的read操作是阻塞的,我希望在没有数据的时候可以立刻告诉我,这样我好去做其他事情,而不是浪费在等待上。因此有了非阻塞IO的概念,通过fcntl设置描述符的O_NONBLOCK标志就可以将描述符变为非阻塞的,此时如果发起read操作,内核没有就绪的数据那么就会立刻返回,并出现EAGAIN或EWOULDBLOCK错误。

如何创建临时文件?

有些程序需要创建一些临时文件,仅供其在运行期间使用,程序终止后立即删除,linux为我们提供了一系列的函数用于创建临时文件,其函数原型如下:

       #include 
       int mkstemp(char *template);
        #include 
       FILE *tmpfile(void);

mkstemp需要用户提供一个文件名模板,模板中的最后六个字符必须是XXXXXX,这六个字符将会被替换以保证文件名的唯一性。但是需要注意的是,生成的临时文件名是通过template传回的,因此template要求应该是一个字符数组。mkstemp的返回值就是这个临时文件的文件描述符。tmpfile也是用于创建临时文件,不过其返回的是待缓冲的IO。

你可能感兴趣的:(linux系统编程)