最近忽然被问到一个问题:程序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对应的内容吗?

“覆盖”是一个比较模糊的概念,这里先把它明确为cpmvtar解压这三种常见的操作,现在使用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.txtb.txt,然后用copy_file_rangea.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一段时间,在这段时间里,可以用cpmv等指令对b.txt进行操作,最后看程序读取到的是哪些内容,即可验证上面的说法。

最终的结论是:程序A打开了文件a.txt,程序B使用cp指令时进行操作,则会直接影响程序A读取的内容,使用mvtar等指令时,是不会影响程序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
posted on 2024-11-09 17:33  coding my life  阅读(9)  评论(0编辑  收藏  举报