业务背景:存在两个进程,进程A生成文件file,然后将其mv到一个新的文件fileA,进程B在需要的时候会取读取文件fileA。如果在进程B读取文件的时候,进程A在进行mv的操作,那么这个操作是否对进程B读取文件生成影响呢?会产生什么结果?
如下图所示:
 



在阐述该问题之前,首先讲解下mv的操作和fopen()函数等对文件的操作的基本原理。那么首先需要初步的了解下Linux的文件系统中所涉及到的一些基础知识,比如说Linux文件系统组件的体系结构,VFS, i节点,元数组等概念。


1.Linux文件系统体系结构


如图1为Linux文件系统组件的体系结构:

 

其中用户空间包含一些应用程序(例如,文件系统的使用者)和 GNU C 库(glibc),它们为文件系统调用(打开、读取、写和关闭)提供用户接口。系统调用接口的作用就像是交换器,它将系统调用从用户空间发送到内核空间中的适当端点。
VFS 是底层文件系统的主要接口。这个组件导出一组接口,然后将它们抽象到各个文件系统,各个文件系统的行为可能差异很大。有两个针对文件系统对象的缓存(inode 和 dentry)。它们缓存最近使用过的文件系统对象。文件系统实现(比如 ext2、JFS 等等)导出一组通用接口,供 VFS 使用。缓冲区缓存会缓存文件系统和相关块设备之间的请求。例如,对底层设备驱动程序的读写请求会通过缓冲区缓存来传递。这就允许在其中缓存请求,减少访问物理设备的次数,加快访问速度(缓存的目的)。以最近使用(LRU)列表的形式管理缓冲区缓存。注意,可以使用 sync 命令将缓冲区缓存中的请求发送到存储媒体(迫使所有未写的数据发送到设备驱动程序,进而发送到存储设备)。具体的信息可以参考IBM develop的《Linux 文件系统剖析》,链接为http://www.ibm.com/developerworks/cn/linux/l-linux-filesystem/

Linux文件系统下如何读取文件:
VFS采用了一组数据结构来描述文件系统,这些数据有超级块、inode、dentry和数据块。Linux版本较多,文件系统也不同,但是对于linux系统,其基本结构还是一致的,都会包含引导块、超级快,目录项i节点表、数据区等几个部分。
1) 引导块:位于文件卷最开始的第一扇区,这512字节是文件系统的引导代码,为根文件系统所特有,其他文件系统这512字节为空。
2) 超级块:位于文件系统第二扇区,紧跟引导块之后,用于描述本文件系统的结构。如i节点长度、文件系统大小等。
3) 目录项:Unix所有文件均存放于目录中,目录本身也是一个文件。目录存放文件的机制如下: 首先,目录文件本身也象普通文件一样,占用一个索引节点; 其次,由这个索引节点得到目录内容的存放位置; 再次,从其内容中取出一个个的文件名和它对应的节点号,从而访问一个文件。
4) i节点:i节点表存放在超级块之后,其长度是由超级块中的s_isize字段决定的,其作用是用来描述文件的属性、长度、属主、属组、数据块表等
Linux会为每一个文件分配一个唯一的inode节点。而dentry是实现了文件名和inode编号的映射,当然还有其他的功能。在linux中,文件的文件名、文件属性、文件内容是分别存储的:文件名存放在目录项(即dentry)中,文件属性存放在inode中,文件内容存放在数据块中。Linux在查找操作文件系统中的文件时,首先先读取超级块信息,找到文件名对应的inode,然后根据inode找到磁盘中的文件,进而根据inode中的信息来完成文件的各种操作。也就是说,Linux通过inode来寻找磁盘中的文件,而不是通过文件名来寻找的。这就是Linux操作文件时的一个大致过程,当然具体情况要比这复杂。
inode的结构:
• inode 编号
• 用来识别文件类型,以及用于 stat C 函数的模式信息
• 文件的链接数目
• 属主的 UID
• 属主的组 ID (GID)
• 文件的大小
• 文件所使用的磁盘块的实际数目
• 最近一次修改的时间
• 最近一次访问的时间
• 最近一次更改的时间
需要注意的是:inode本身并不记录文件名,而是记录文件的相关的属性(在上文提到过的那些属性),文件名则记录在目录所属的块区域。正因为这个原因,使得如果Linux读取一个文件的内容,就要先由根目录/获取该文件的上层目录所在的inode,再由该目录所记录的的文件关联性获取该文件的inode,最后通过inode内提供的块指针来获取最终的文件内容。
可以看到i节点中包含了大多数于文件有关的信息:文件的类型,文件的访问权限,文件所占用的数据块的指针等。接下来我们可以认识下这个常听说的文件的inode节点,并且阐述mv操作对文件的inode影响。
如图2为磁盘、分区和文件系统的结构图



通用文件模型由下列对象类型组成:
• 超级块(superblock)对象: 存放系统中已安装文件系统的有关信息。对于基于磁盘的文件系统,这类对象通常对应于存放在磁盘上的文件系统控制块,也就是说,每个文件系统都有一个超级块对象.
• 索引节点(inode)对象: 存放关于具体文件的一般信息。对于基于磁盘的文件系统,这类对象通常对应于存放在磁盘上的文件控制块(FCB),也就是说,每个文件都有一个索引节点对象。每个索引节点对象都有一个索引节点号,这个号唯一地标识某个文件系统中的指定文件。
• 目录项(dentry)对象: 存放目录项与对应文件进行链接的信息。VFS把每个目录看作一个由若干子目录和文件组成的常规文件。例如,在查找 路径名/tmp/test时 , 内核为 根目录“/ ”创建一个目录项对象, 为根目录下的 tmp项创建一个第二级目录项对象,为 /tmp 目录下的test项创建一个第三级目录项对象。
• 文件(file)对象: 存放打开文件与进程之间进行交互的有关信息。这类信息仅当进程访问文件期间存在于内存中详细信息可以参考《UNIX环境高级编程》的第四章《文件和目录》和《深入分析Linux内核源码》。

2. mv在同一个分区之内是执行的rename的操作,不会更改i节点的信息。


首先可以通过常用的命令,如ls -li,stat等命令来认识下inode节点。
2.1)首先可以通过,df命令来查看磁盘的分区,如下操作:
//用df -i来查一下磁盘空间
# df -i
Filesystem Inodes IUsed IFree IUse% Mounted on
/dev/sda1 7913472 126186 7787286 2% /
2.2)然后可以通过ls -li查看i节点的信息,如以下操作中会在第一列中显示文件的inode的编号。
//ls -li来查一个文件的inumber
#ls -i /bin/ping
1032194 -rwsr-xr-x 1 root root 33272 Apr 14 2006 /bin/ping
2.3)进一步,可以通过stat来查文件的信息。该显示的信息会比较多,其中stat结构中大多数的信息都是来自i节点,只有两项数据是存放在目录项当中:文件名和i节点的编号。
# stat /bin/ping
File: `/bin/ping'
Size: 33272 Blocks: 80 IO Block: 4096 regular file
Device: 801h/2049d Inode: 1032194 Links: 1
Access: (4755/-rwsr-xr-x) Uid: ( 0/ root) Gid: ( 0/ root)
Access: 2009-08-04 11:13:08.000000000 +0800
Modify: 2006-04-14 21:16:26.000000000 +0800
Change: 2009-07-01 14:04:44.000000000 +0800

2.4)mv对inode的影响:
如果mv命令的目标和源文件所在的分区相同:
1)使用新文件名建立目录项(dentry),将新文件名称对应到inode 编号;
2)解除与旧目录项的链接;
需要注意的是:该操作对inode表没有影响(除时间戳),对数据的位置也没有影响,不移动任何数据。只需要构造一个现有i节点的新目录项,并解除和旧目录项的链接。在实践中也能得知:不论文件有多大,执行mv的时间都是“瞬间”完成。
2.5)查看到i节点的信息并未改变:
通过ls –li或者stat filename来查看具体的信息,如实际的例子:如下图3所示,通过mv操作之后,并不会改变i节点的信息。
 



 

2.6)通过strace mv a b来追踪mv的实现机制。如下图4所示,其中该例子中,原有的a,b文件都是存在。

 

2.7)strace mv a b,如下图5所示,其中b文件是不存在的。
 



 

从上述图中可以得出,mv操作并不改变i节点的编号,并且其实现是通过rename的机制来实现的。

2.8) 通过mv的源码来查看其信息。可以查看(coreutils-8.9)的源码。
mv操作是针对cp_options这个结构体,其中该结构体中的move_mode决定了方式。即判断是否在一个分区内,如下示意图为调用的信息。
在mv.c中movefile->do_move->copy,在movefile中传入了cp_options的结构体,并通过move_mode来决定是进行rename还是read和write的方式。

结论:由以上对inode的实践,并通过strace来追踪mv的实现机制、mv的源码,可以得出在同一个分区中,mv实际进行的是rename操作。接下来本文将讲述rename的实现。


3. rename是一种原子操作


“Rename是一种原子操作”,如果要将这个问题讲述清楚,则需要讲到的是“Linux文件系统中元数据的加锁机制与组织方式”。
在Linux系统中,需要对元数据进行加锁,元数据操作是一种事务操作,需要满足原子性,一致性,独立性和持久性。为了解决这种元数据操作带来的一致性的问题和多个元数据操作的交互和重叠的问题,采用加锁的方式。
如:操作1,在目录a下创建了b, 递增目录 a 的 nlink 值,操作2删除目录a,如果并发的进行就会出问题,但是如果通过加锁的方式,先对a进行加锁,再解锁,就是一种串行的执行,则不会出现问题。
如图6元数据操作的死锁所示:如果此时系统中没有相应的加锁机制对元数据操作进行互斥,那么当操作①创建了对象 b 以后,接下来操作②有可能就将目录 a 删除了,当操作①要递增目录 a 的 nlink 值时,就会发现没有可操作的对象了,于是操作出错。



 

加锁能够带来一定的优势,但是会引发新的问题:即死锁的问题。需要注明的一个问题是元数据的加锁操作基本上都是由其虚拟文件系统(VFS)来规定的,这样做的好处是可以统一管理所有元数据操作的加锁机制,底层的具体文件系统可以不理会这些问题,只需要按照 VFS 的调用来执行对元数据的操作。
为了防止死锁的问题:Linux也做了一些规定:即在VFS中,对于大多数的元数据操作,可以通过制定统一的加锁顺序来避免死锁的发生。这个顺序是:先对父目录加锁,再对要操作的对象(目录或文件)加锁。但是rename和link是除外的。如图7所示,仍然不能够解决mv操作的死锁问题。



 

为了解决这个问题:Linux规定同一时间只能有一个rename的操作,当然这个是针对同一个文件系统内的,该实现机制也是通过加锁来实现的。具体信息可以参考IBM develop上的《Linux文件系统中元数据的加锁机制与组织方式》,链接为:http://www.ibm.com/developerworks/cn/linux/l-cn-fsmeta/

继续看mv的操作实现原理,如图8所示,解释刚才出现的问题:



总结mv操作的实现过程:
1) 如果通过stat返回与此命名文件有关的信息结构,lstat函数类似于stat,但是当命名的文件是一个符号链接时,lstat返回该符号链接的有关信息,而不是由该符号链接引用文件的信息。
2) 通过access文件b(W_OK),查看文件b是否有写入的权限。对于access,它会检查是否可以读/写某一已存在的文件。参数mode有几种情况组合, R_OK,W_OK,X_OK 和F_OK。R_OK,W_OK与X_OK用来检查文件是否具有读取、写入和执行的权限。由于access只作权限的核查,并不理会文件形态或文件内容。
3) 进行rename的重命名操作。
注意:rename操作可以根据所操作的为文件还是目录项,分为几种情况,由于本文的业务背景,目前只是讨论所操作的为文件的情况,所以不将详细阐述,具体的可以参考《UNIX环境高级编程》中第四章的内容,对于rename操作,其原型为:
int rename(const char * oldname,const char * newname)
需要说明的是:如果newname已经存在,且为文件的,则先将该目录项删除,然后将oldname更名为newname,若newname不存在,则只需要进行更名即可。


4. fopen()和fclose()操作

 

 

在Linux中,进程是通过文件描述符(file descriptors,简称fd)而不是文件名来访问文件的,文件描述符实际上是一个整数。文件名的信息是存在文件的目录项当中。其中文件位置是比较重要的一个参数,每个文件都有一个32位的数字来表示下一个读写的字节位置,根据这个位置来决定了文件的写入和读出。为了描述打开的位置,Linux中专门用了一个数据结构file来保存打开文件的文件位置,这个结构称为打开的文件描述(open file description)。这个数据结构的与进程的联系非常紧密。
file结构中主要保存了文件位置,此外,还把指向该文件索引节点的指针也放在其中。
file结构在include\linux\fs.h中定义如下:
struct file
{
struct list_head f_list; /*所有打开的文件形成一个链表*/
struct dentry *f_dentry; /*指向相关目录项的指针*/
struct vfsmount *f_vfsmnt; /*指向VFS安装点的指针*/
struct file_operations *f_op; /*指向文件操作表的指针*/
mode_t f_mode; /*文件的打开模式*/
loff_t f_pos; /*文件的当前位置*/
unsigned short f_flags; /*打开文件时所指定的标志*/
unsigned short f_count; /*使用该结构的进程数*/
unsigned long f_reada, f_ramax, f_raend, f_ralen, f_rawin;
/*预读标志、要预读的最多页面数、上次预读后的文件指针、预读的字节数以及
预读的页面数*/
int f_owner; /* 通过信号进行异步I/O数据的传送*/
unsigned int f_uid, f_gid; /*用户的UID和GID*/
int f_error; /*网络写操作的错误码*/
unsigned long f_version; /*版本号*/
void *private_data; /* tty驱动程序所需 */
};
fopen在打开文件时是根据文件名,之后的fread,fwrite等操作都是根据文件描述符进行的。Linux中对文件的加锁,一般有flock(),fcntl()等机制,单纯的fopen和fread和fwrite是不会对文件进行加解锁的。fclose函数关闭了文件的流,实际是进行了一个写文件的操作。 文件内容写入失败,这时fclose函数就会出错,如果写权限不存在或者是超过了大小才会失败,所以一般情况下,fclose失败的概率也是很小的。而在调用fclose关闭一个打开的流,在文件被关闭之前,冲洗缓冲区中的输出的数据。丢弃缓冲区中的任何输入数据,如果标准I/O库已经为该流自动分配了一个缓冲区,则释放此缓冲区。


5.扩展:在业务中存在的各种操作的结果现象以分析


通过实验查看一下四种情况,该实现是基于进程见的。
操作1
1) 进程A:fopen fileA, sleep 10
2) 进程B:mv fileB fileA
3) 进程A:fclose fileA
结果:在mv完成之后,立即可以看到fileA中的文件已经变为了fileB,fclose成功。

操作2
1)进程A:fopen fileA, sleep 10
2)进程B:mv fileA fileB
3)进程A:fclose fileA
结果:在mv完成之后,fileA文件已不存在,仅有fileB文件,且fileB文件的内容为原来fileA文件的内容。关闭文件fclose成功。

操作3
1)进程A:fopen fileA, 通过fwrite写入到文件的一行之后进行sleep
2)进程B:mv fileB fileA
3)进程A:fclose fileA
结果:在mv完成之后,可以立即看到fileA中的内容变为了fileB,而且当写文件操作完成之后,也可以看到fileA中文件仍然为fileB,且关闭文件fileB依然是成功的。
原因: mv仅仅是rename操作。fopen写入的是磁盘位置,所以虽然mv使得文件名变了但是磁盘位置并没有变还是可以写入并关闭正常。在mv fileB fileA之后。fopen还是打开的原来的fileA文件(已经不能被文件系统索引到)的磁盘位置,写入操作也还是在那块进行,fclose由于是可以写入文件就是成功故其返回值也是正确的。但在这之后查看fileA文件实际上是查看的原来的fileB文件,而并不是原来的fileA文件。
以上描述的原因可以参考下图对于mv的解释

 


操作4
1)fopen fileA, 写入到文件的一行后,sleep
2)mv fileA fileB
3)fclose fileA

结果:当mv完成之后,可以看到文件fileA已经不存在,当fclose之后,可以看到fileB当中的内容为写入的内容。
原因: fopen()和fclose()都是对磁盘空间做操作的,并不记录文件名。Mv仅是rename操作,并没有对磁盘做实际意义的操作。问题点:fopen(), fwrite(), fclose()都是带缓存,比如在执行文件读操作的时候,从磁盘文件将数据先读入内存“缓冲区”,装满之后再从内存“缓冲区”依次读入接收的变量。执行文件写操作的时候,先将数据写入到内存的“缓冲区”中。待缓冲区装满之后再写入到文件中。
于是对上述的3,4实验,希望每次写入文件时候,及时的刷新到磁盘上,并重新进行了实验。没写入一行进行fflush(fp)进行刷新。发现结果和上述一致,不同之处是在于由于刷新之后会及时将文件的内容写入到文件中,可以看到在写的过程当中文件的大小是逐渐递增的。而不带缓冲的时候,知道flose执行完毕才将写入的内容一次性写入到磁盘当中。

着,做了如下的实验,采用open()和write()等unix标准的函数进行。
区别:fopen()和open()等函数区别,fopen等是ANSIC标准中的C语言库函数,在不同的系统中调用不同的内核API, open(),close(),write(),read()是标准的UNIX函数,参数不是指向文件的指针,而是指向文件的句柄,并且是不带缓冲的。因此以上的实验将fopen换成open,fclose换成close,在实验中可以看到,在通过write写入的时候,可以看到文件的大小是递增的,说明是直接写入到磁盘空间上的。
重新尝试上述的3,4实验,将fwrite()改为write(),fclose()改为close()。
操作3.1
1)open fileA, 写入到文件的一行之后进行sleep
2)mv fileB fileA
3)close fileA
结果:执行完mv fileB fileA之后,文件fileA的内容立即为fileB文件的内容,而且close文件也是成功的。

操作4.2
1)open fileA, 写入到文件的一行之后进行sleep
2)mv fileA fileB
3)close fileA
结果:执行完mv fileB fileA之后,由于A中已经有了部分的内容,可以看到fileB中文件的内容成为fileA文件的内容,文件关闭也是成功的。

由上述的实验当中可以看到:是否带缓冲的机制不会影响上述的结果。
原因:缓冲机制的作用是提高IO的读写速度,但是fopen之后文件的写入还是根据i节点的位置来实现的,故还是会产生如上的结果。


6.结论


就之前提到的业务情况,进程B在读取文件的时候,进程A在进行mv的操作,那么这个操作不会对进程B读取文件生成影响。由于读取文件都是根据文件名操作,并且mv操作是原子操作,故每次mv之后,都读得是“新的”文件。
总之,分析此类问题的关键在于理解mv和fopen操作的区别和本质。mv是对文件名操作的,文件名属性记录在目录项中。fopen虽然打开是关联文件名的,单在打开之后是对真正的磁盘做操作,和文件名无关。

作者:zjchao