最近忽然被问到一个问题:程序A打开了文件a.txt,程序B覆盖了a.txt,那这时候程序A读取到的内容是怎么样的?是读取到旧内容,还是新内容,或者是半新半旧?
为了解答这个问题,得先明白系统的文件管理机制。以Linux为例,文件属于一种资源,它是由系统内核统一管理的。操作文件也只能是通过内核的系统调用去间接操作文件。简单来说,硬盘上的每一个文件,系统都会给它分配一个inode。当一个程序去读写这个文件时,内核会把这个inode对应的内容加载到内存中,然后该程序通过系统调用去操作这块内存。
回到最开始的问题,当程序A打开文件a.txt时,a.txt对应的inode的内容将会被加载到内存中。程序B覆盖了a.txt时,程序A读取到的内容,就取决于这个inode对应的内容。那问题就转变为:程序B覆盖a.txt时,它会修改a.txt的inode对应的内容吗?
“覆盖”是一个比较模糊的概念,这里先把它明确为cp
、mv
、tar解压
这三种常见的操作,现在使用strace
来查看一下这几个指令到底对文件进行了什么操作。
strace cp a.txt b.txt
openat(AT_FDCWD, "b.txt", O_RDONLY|O_PATH|O_DIRECTORY) = -1 ENOTDIR (不是目录)
newfstatat(AT_FDCWD, "a.txt", {st_mode=S_IFREG|0644, st_size=5, ...}, 0) = 0
newfstatat(AT_FDCWD, "b.txt", {st_mode=S_IFREG|0644, st_size=5, ...}, 0) = 0
openat(AT_FDCWD, "a.txt", O_RDONLY) = 3
newfstatat(3, "", {st_mode=S_IFREG|0644, st_size=5, ...}, AT_EMPTY_PATH) = 0
openat(AT_FDCWD, "b.txt", O_WRONLY|O_TRUNC) = 4
ioctl(4, BTRFS_IOC_CLONE or FICLONE, 3) = -1 EOPNOTSUPP (不支持的操作)
newfstatat(4, "", {st_mode=S_IFREG|0644, st_size=0, ...}, AT_EMPTY_PATH) = 0
fadvise64(3, 0, 0, POSIX_FADV_SEQUENTIAL) = 0
copy_file_range(3, NULL, 4, NULL, 9223372035781033984, 0) = 5
copy_file_range(3, NULL, 4, NULL, 9223372035781033984, 0) = 0
close(4) = 0
close(3) = 0
从上面的日志可以看到cp a.txt b.txt
是分别打开a.txt
和b.txt
,然后用copy_file_range
把a.txt
的内容复制到b.txt
,这个过程中,b.txt
的inode将不会变化,但inode对应的内容将会被修改。
strace mv a.txt b.txt
ioctl(0, TCGETS, {c_iflag=ICRNL|IXON, c_oflag=NL0|CR0|TAB0|BS0|VT0|FF0|OPOST|ONLCR, c_cflag=B38400|CS8|CREAD, c_lflag=ISIG|ICANON|ECHO|ECHOE|ECHOK|IEXTEN|ECHOCTL|ECHOKE, ...}) = 0
renameat2(AT_FDCWD, "a.txt", AT_FDCWD, "b.txt", RENAME_NOREPLACE) = -1 EEXIST (文件已存在)
openat(AT_FDCWD, "b.txt", O_RDONLY|O_PATH|O_DIRECTORY) = -1 ENOTDIR (不是目录)
newfstatat(AT_FDCWD, "a.txt", {st_mode=S_IFREG|0644, st_size=5, ...}, AT_SYMLINK_NOFOLLOW) = 0
newfstatat(AT_FDCWD, "b.txt", {st_mode=S_IFREG|0644, st_size=5, ...}, AT_SYMLINK_NOFOLLOW) = 0
geteuid() = 1000
faccessat2(AT_FDCWD, "b.txt", W_OK, AT_EACCESS) = 0
renameat(AT_FDCWD, "a.txt", AT_FDCWD, "b.txt") = 0
lseek(0, 0, SEEK_CUR) = -1 ESPIPE (非法 seek 操作)
从日志可以看到,一开始mv a.txt b.txt
尝试直接把a.txt
重命名为b.txt
,但发现b.txt
已存在。接着判断b.txt
是否为目录,发现不是目录,再检测是否对b.txt
有操作权限,发现有权限。于是接下来直接强制把a.txt
重命名为b.txt
。在这种情况下,b.txt
对应的inode会直接变为a.txt
的inode(因为a.txt
已经不存在了)。
接下来先把b.txt
压缩tar -zcvf b.tar.gz b.txt
,再解压strace tar -zxvf b.tar.gz
,那b.txt
将会被覆盖
newfstatat(1, "", {st_mode=S_IFCHR|0620, st_rdev=makedev(0x88, 0), ...}, AT_EMPTY_PATH) = 0
write(1, "b.txt\n", 6b.txt
) = 6
openat(AT_FDCWD, "b.txt", O_WRONLY|O_CREAT|O_EXCL|O_NOCTTY|O_NONBLOCK|O_CLOEXEC, 0644) = -1 EEXIST (文件已存在)
unlinkat(AT_FDCWD, "b.txt", 0) = 0
openat(AT_FDCWD, "b.txt", O_WRONLY|O_CREAT|O_EXCL|O_NOCTTY|O_NONBLOCK|O_CLOEXEC, 0644) = 4
write(4, "\37\213\10\0\0\0\0\0\0\3\355\301\1\r\0\0\0\302\240\367Om\0167\240\0\0\0\0\0\0\0"..., 45) = 45
utimensat(4, NULL, [UTIME_OMIT, {tv_sec=1731033001, tv_nsec=0} /* 2024-11-08T10:30:01+0800 */], 0) = 0
close(4) = 0
close(3)
从日志可以看出,tar尝试直接创建b.txt
,结果发现文件已存在,接着调用unlinkat
先删除旧文件,再创建一个新的同名文件。在这种情况下,新文件肯定拥有新的inode,而旧b.txt
对应的inode则看情况。Linux,当一个文件被打开时,在内核那边会有一个引用计数,此时如果该文件被删除,那文件将会从硬盘上消失,但它对应的inode还保留在内存中直到引用计数为0。这就是为什么在Linux下一个文件被打开后,还可以删掉文件并且不影响对已打开文件的读写的原因。
通过ls -i
可以查看文件的inode
xzc@debian12:~/test$ ls -i
2098870 a.txt 2098858 b.tar.gz 2098857 b.txt 2097242 test 2098869 test.cpp
通过测试可以发现,cp是不会修改inode的,mv的话,b.txt的inode会变成a.txt的inode。而tar则是会更新b.txt的inode。意外的发现,tar覆盖b.txt时,新的inode居然是打包时b.txt的inode。需要写个程序测试一下inode的复用。如果旧的inode一直被占用,那b.txt的inode是全新的吗?
那么,怎么去验证这个问题呢?首先,有很多命令可以查看文件的inode,比如ls -i
$ ls -i
785817 a.out 785815 a.txt 785816 b.txt 785820 test.cpp
然后,再写一个小程序来辅助测试
#include <iostream>
#include <fstream>
#include <unistd.h>
int main() {
// 打开文件
std::ifstream file("b.txt");
if (!file) return 1;
std::cout << "sleep now" << std::endl;
sleep(20);
// 读取文件
std::string line;
while (std::getline(file, line)) {
std::cout << line << std::endl;
}
return 0;
}
这个文件打开b.txt
会,会sleep一段时间,在这段时间里,可以用cp
、mv
等指令对b.txt
进行操作,最后看程序读取到的是哪些内容,即可验证上面的说法。
最终的结论是:程序A打开了文件a.txt,程序B使用cp
指令时进行操作,则会直接影响程序A读取的内容,使用mv
、tar
等指令时,是不会影响程序A的。同样的,当代码里使用fopen
之类的函数读写文件时,也是直接对inode的内容进行操作,所以多线程、多进程对同一个文件读写是不安全的。
另外,这里我们还可以看出,inode像文件描述符一样,只要没被占用,也是会复用。
$ ls -i
785817 a.out 785815 a.txt 785816 b.txt 785820 test.cpp
$ mv a.txt b.txt
$ ls -i
785817 a.out 785818 b.tar.gz 785815 b.txt 785820 test.cpp
$ echo 'a' > a.txt
$ tar -zxvf b.tar.gz
$ ls -i
785817 a.out 785815 a.txt 785818 b.tar.gz 785816 b.txt 785820 test.cpp