删除正在使用的文件——釜底抽薪?

删除正在使用的文件——釜底抽薪?

彩袖殷勤捧玉钟,当年拚却醉颜红。舞低杨柳楼心月,歌尽桃花扇底风。

从别后,忆相逢。几回魂梦与君同。今宵剩把银釭照,犹恐相逢是梦中。

—— 鹧鸪天 晏几道

缘起

在linux 系统中上线时经常会遇到需要替换原有可执行程序的操作,我通常的做法是:

  1. 删除原有可执行文件。例如:rm a.out
  2. 以相同的文件名把新的可执行文件放到原文件的位置:mv b.out a.out
  3. 重启服务。

一直以来都是如此操作的,也没有出现过什么问题。但是在操作过程中经常会战战兢兢,老觉得原程序正在执行时,删除了对应的磁盘文件,正在运行的程序会崩溃。

在印象中,程序通常是部分载入内存的,当遇到缺页时,系统会从磁盘文件中继续读入新的页面到内存中。如果真是这样的话,那么删除磁盘上的文件之后,系统在读入新的页面时应该会出错。可是在我多次上线的过程中,删除可执行文件后,从未出现过运行的服务出问题的情况。这种釜底抽薪的操作,没有出问题是偶然的吗?

扩展到更为通用的情况:如果一个文件正在被使用,这时对这个文件执行删除或更改其内容的操作,会发生什么?出于好奇心,决定深入研究一下。在本文中,主要探讨了以下几类被正在使用的文件:

  • 文件正在被打开读写
  • 可执行文件正在运行
  • 动态链接库正在被使用

预备知识

在上一篇中,我们探讨了rm及 cp 命令的底层原理,特别是当目标文件存在时cp 命令的行为。当目标文件存在时,cp 命令会清空旧文件,之后把新的内容写入。为了便于理解本文的内容,建议读者先了解一下上一篇的内容。

在本篇的讨论中,由于我们的重点在于“删除正在被使用的文件“,因而我们假设所操作的所有文件的链接数都为1,即没有使用 ln 等命令为文件建立新的硬链接。基于此前提,可以更加方便的理解本篇讲述的原理。

由于一个文件有唯一的 inode 号,如果 inode 号没有变化,说明我们操作的是同一个文件。查看 inode 号可以使用如下命令:

ls -ilL  filename

ls 命令的 -i 选项用来打印文件的inode 号,-lL 两个选项合用时,会跟踪符号链接到真实的目标文件,显示目标文件的详细信息。

删除正在被读写的文件

在上一篇中,我们探讨了 unlink() 系统调用的行为,在其 man page 中明确说明,当文件正在被进程打开时,执行 unlink() 只会删除文件名,并不会删除文件内容,只有所有打开此文件的进程都关闭此文件后(注意当进程退出时,会自动关闭所有打开的文件),文件内容才会被真正删除。下面就对这一描述进行验证。由于 rm 命令中使用的也是 unlink() 系统调用,下面就以 rm 命令作为示例。

当一个进程运行时,会在系统的 /proc 下建立一个名字为进程 id 的子目录,其中的 fd 目录中含有所有被进程打开的文件描述符。即 /proc/pid/fd/fd_num 表示此进程正在打开的文件。我们用以下程序作为打开文件的实验程序:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#define BUFFER_SIZE 1024

int main(void) {
  int fd;
  int i = 0;
  char buffer[BUFFER_SIZE];

  if ((fd=open("data.txt", O_RDONLY)) == -1)   {
    printf("Open file Error\n");
    exit(1);
  }

  int pid = getpid();
  int n = 0;
  while(1)   {
    ++i;
    n= read(fd, buffer, BUFFER_SIZE-1);
    if(n == -1)  {
      printf("read Error\n");
      exit(1);
    }
    buffer[n] = '\0';
    printf("%d pid:%d, fd:%d, content: %s\n", i, pid, fd, buffer);
    sleep(1);
    lseek(fd, 0L, SEEK_SET);
  }

  close(fd);
  exit(0);
}

此程序中打开了文件并进行了读操作,之后在一个死循环中不断打印 pid 和 fd。被打开的文件为 data.txt, 其中只含有 “hello world” 这一行内容。运行此程序的输出如下:

查看 /proc 目录下打开的文件信息:

可以看出,/proc/20207/fd 目录下的 3 是一个符号链接, ls -ilL 3 和 ls -ilL /weboad/cpp_work/data.txt 的结果完全一样,ls -ilL 跟踪了符号链接,显示的是目标文件 data.txt 的 i 节点号。

这时我们删除 /weboad/cpp_work/data.txt 文件,再查看/proc/20207/fd 下的内容:

可以看出,删除后,fd 目录下的符号链接 3 显示指向的是一个被删除的文件。这时需要注意的是,ls -ilL 3 命令依然能执行成功,说明通过符号链接 3 依然能跟踪到目标文件,并且可以发现,通过 ls -ilL 3 命令得到的 i 节点号,和已经被删除的 data.txt 文件的 i 节点号完全一样,这说明虽然 data.txt 在相应目录中已经被删除,但是由于有其他进程打开这一文件后还未关闭,操作系统其实还为这些进程保留了磁盘上被打开的文件内容。

在删除后查看原进程的输出:

发现输出内容和删除 data.txt 内容之前没有差别,输出的 “content” 部分,还是原来的 data.txt 的内容。

如果这时我们在原有目录中再次建立 data.txt 文件,并且更改其内容,会发生什么?我们不妨做一下实验:

可以看出,进程打开一个文件后,如果我们删除被打开的文件,之后再重建这一文件,操作系统依然把之前打开的文件描述符3指向原有旧文件的 i 节点,即使再次建立同名文件,系统也认为文件描述符 3 指向的文件被删除了。

当用 cp 命令不加任何选项复制一个符号链接时,复制的是符号链接指向的目标文件,md5sum 也是自动跟踪链接的目标文件。从这两个命令的输出可以看出,文件描述符 3 指向的内容就是被删除文件的内容,这一内容是可以通过 cp 命令复原的(delete_data.txt 文件)。md5sum delete_data.txt 的结果也与被删除的 data.txt 的md5 值相同,这进一步说明了原 data.txt 的内容没有被删除。

因而可以认为,新建的同名文件与原文件及原进程无任何关系,新建的同名文件未被原进程使用,并且新建的文件使用了新的 i 节点,与原文件即使同名也没有产生任何联系。

删除原文件,再新建同名文件后,程序的输出依然是删除前 data.txt 文件的内容 “hello world”,与新建文件的内容无关。

由此可知,当进程打开一个文件后,如果我们在磁盘上删除这个文件,虽然表面上看在目录中已经成功删除了这个文件名,但是实际上系统依然保留了文件内容,直至所有进程都关闭了这一文件。这种行为与 unlink() 的 man page 描述一致,linux 系统的健壮性可见一斑。

删除正在运行的可执行文件

如果一个程序在执行,此时把此程序对应的磁盘文件删除,程序会崩溃吗?

程序通常是部分载入内存的,当发生缺页时,会去磁盘上读取新的页面,如果从这个角度看,删除磁盘上的可执行文件,程序通常会因为找不到磁盘文件而崩溃。那么事实是怎样的呢?

我们使用一个仅打印序号和进程 pid 的程序来做验证,程序如下:

#include <stdio.h>
#include <unistd.h>

int main(void) {
  int i = 0;
  while (1) {
    printf("ix:%d, pid:%d\n", i, getpid());
    ++i;
    sleep(1);
  }
  return 0;
}

编译生成的可执行文件名称是 pr_pid,运行后,程序的输出如下:

之后查看此文件的信息:

可以看出,此文件正在被 pid 为 20295 的进程使用,与打印出的pid 完全一样。

当一个进程开始运行时,操作系统会在/proc/pid 目录下建立一个名为 exe 的符号链接,这一链接指向磁盘上的可执行文件。通过这一符号链接我们就可以观察到当可执行文件被删除时,程序的表现。

当程序未删除时,查看 /proc/20295/exe 这一符号链接的信息:

如上所述, /proc/20295/exe 正是可执行文件 pr_pid 的符号链接。

当程序删除后,我们可以继续查看 /proc/20295/exe 的信息:

从上图命令的结果中,我们可以得到如下结论:

  • 如果一个可执行文件正在运行,当我们把它删除后,删除的也只是文件名,操作系统依然保留了可执行文件的内容,这一点从 ls -ilL /proc/20295/exe 的结果中可以看出,其 i 节点号与删除前 pr_pid 文件的 i 节点号完全相同,执行md5sum /proc/20295/exe 的结果与之前 pr_pid 的md5 值也完全相同。
  • 删除原可执行文件后,如果我们重建一个同名文件,会发现新文件与被删除的pr_pid 文件的 i 节点号不一样。重建文件后,原程序的输出也没有变化,说明重建的文件与原 pr_pid 无关。这种机制与删除被打开文件的机制类似。

通过以上试验,我们可以看出,如果一个进程正在运行,删除其对应的可执行文件是安全的,正在执行的进程并不会崩溃,这一安全性由操作系统来保证。

如果一个程序在执行时,我们不是删除可执行文件,而是把新的内容写入这一可执行文件,会发生什么?我们重新编译生成一个可执行文件 a.out,在开始执行后,查看相关信息:

当我们试图向正在运行的可执行文件中写入内容时,会写入失败,系统提示 “Text file busy” 表示有进程正在执行这一文件。我们在上一篇中已经探讨过,当目标文件已经存在时,cp 命令会把原目标文件内容清空,之后写入新内容。因而这里用cp 命令覆盖可执行文件时,也要向 a.out 中写入新内容,系统同样会报错 ”Text file busy“。

从上面的分析中可以看出,为什么用 rm 命令删除一个正在运行的可执行文件会成功,而用 cp 命令覆盖正在运行的可执行文件却会失败。这是因为用 rm 删除时,只是删除了文件名,系统为运行的进程自动保留了可执行文件的内容。而用 cp 命令覆盖时,会尝试向当前可执行文件中写入新内容,如果成功写入,必然会影响当前正在运行的进程,因而操作系统禁止了对可执行文件的写入操作。

删除正在使用的动态链接库

如果一个动态连接库正在被使用,这时删除它,正在使用动态库的进程会崩溃吗?我们可以设计以下两个程序来测试此种情形下操作系统的行为。

首先我们设计一个库文件,由以下程序构成:

/* shared.c */
#include <stdio.h>
#include <unistd.h>

void justsit(void) {
  int i = 0;
  for (;;) {
    printf("%d I am in shared\n", i++);
    sleep(1);
  }
}

使用上述库的主程序内容如下:

/* main.c */
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>

void justsit(void);

int main(int argc, char **argv) {
  printf("My PID is %d\n", getpid());
  justsit();
  return 0;
}

将上面两个源文件放到同一目录下,编译生成库及主程序:

gcc -Wall --shared -fPIC -o libshared.so shared.c    # 编译库
gcc -Wall -L. -o main main.c -lshared    # 编译主程序并链接库

运行程序(这时要用 LD_LIBRARY_PATH 环境变量指定动态链接库的搜索路径):

LD_LIBRARY_PATH=. ./main

程序运行后输出如下:

可以看出,程序开始运行后,一直处于动态库中的循环中。

当程序运行时,使用的动态链接库信息可以在 /proc/pid/map_files 目录中看到,这一目录中的文件全部是符号链接,链接到进程正在使用的动态库文件:

可以看到,我们生成的 libshared.so 动态链接库文件被映射到了虚拟内存中的三个位置。本文不对虚拟内存及内存映射做探讨,有兴趣的读者可查阅相关资料。

查看这些链接和实际的动态链接库文件信息:

从中可以看出,/proc 中的三个链接都是指向了相同的 libshared.so 库文件,并且三个链接和 libshared.so 的md5 值全部相同(因为md5sum 会跟踪符号链接)。

接下来,我们看一下,当删除动态链接库文件后,会发生什么:

从上图我们可以得出结论:

​ 当动态链接库正在被使用时,rm 命令删除的也只是文件名,虽然在原目录下已经没有了对应的库文件,但是操作系统会为使用库的进程保留库文件内容,因而rm 并未真正删除磁盘上的库文件。从这点上来看,当删除使用中的动态链接库时,操作系统的机制和删除可执行文件及删除被打开的文件是一样的。

那么,如果一个动态链接库正在被使用,向此库文件中写入内容,会发生什么?我们重新运行程序,并且向库文件中写入新的内容:

可以看出,操作系统是允许我们向使用中的动态库中写入内容的,并不会像写入可执行文件一样报告“Text file busy”,因而在写入方面,操作系统只对可执行文件进行了保护。

再查看向动态库写入新内容后程序的表现:

程序崩溃了,这是由于内存映射区与磁盘文件的自动同步造成的。关于内存映射将会在后续文章中探讨。

因而,向动态库写入内容会对进程的运行造成影响。

结语

本文主要研究了删除以下三类文件时操作系统的行为:

  • 删除正在被打开的文件
  • 删除正在被运行的可执行文件
  • 删除正在被使用的动态链接库文件

针对上述三类文件,操作系统提供了合理的保护机制,即我们虽然”在表面上“成功删除了文件,但是操作系统依然为使用它们的进程保留了原始的磁盘文件内容,直到所有进程都释放这些文件后,操作系统才会真正的把文件内容从磁盘上删除。这体现了linux 系统的健壮性。

对上面三类使用中的文件进行写入时,只有正在运行的可执行文件得到了操作系统的保护,被打开的文件及正在使用的动态链接库文件都是可以被写入的。对使用中的动态链接库的写入,通常是不需要的,并且很可能导致程序崩溃。因而要避免对动态库文件的写入。特别的,由于cp命令覆盖已存在的文件时,采用的是写入操作,因而对动态链接库的更新,不要使用cp 命令,而是要使用 rm 删除原库文件。之后把再新的库放到相应位置,重启使用它的程序或者使用 dlopen 动态加载新的库。

在上线需要更新可执行程序或动态链接库时,不要使用 cp 命令覆盖,而是要使用 rm 删除旧有文件,然后再把新的文件移动到原文件的位置。 install 命令和rpm包安装时使用的机制都是先删除旧文件,再建立新文件。这种操作能安全的更新文件,并且不影响当前进程的运行。当然,如果要想让新文件生效,则需要重新载入文件(重新打开文件、重启程序、重新加载动态链接库)。

The End.



我就是我,疾驰中的企鹅;

我就是我,不一样的焰火。

编辑于 2017-03-09

文章被以下专栏收录