Linux 的 tail -f 命令是如何实现无限读取的?

收集日志的时候,经常会使用 tail -f xxx.log 命令来实现,我看一本书 《Linux 内核分析及应用》的时候看到了 tail 使用了 inotify 来实现主动发现文件的变更。(相关问题:Python 如何检查文件是否发生变化

在2.6内核之后,Linux提供了inotify功能,内核通过监控文件系统的变更来反向通知用户,这样减少了轮询的开销。我们来看其实现:

tail_forever_inotify (int wd, struct File_spec *f, size_t n_files,
                          double sleep_interval)
{
  ..
f[i].wd = inotify_add_watch (wd, f[i].name, inotify_wd_mask);
...
    if (pid)
        {
            if (writer_is_dead)
                  exit (EXIT_SUCCESS);
            writer_is_dead = (kill (pid, 0) ! = 0 && errno ! = EPERM);
            struct timeval delay; //等待文件变化的时间
            if (writer_is_dead)
                  delay.tv_sec = delay.tv_usec = 0;
            else
                  {
                      delay.tv_sec = (time_t) sleep_interval;
                      delay.tv_usec = 1000000 * (sleep_interval - delay.tv_sec);
                  }
                  fd_set rfd;
                  FD_ZERO (&rfd);
                  FD_SET (wd, &rfd);
                  int file_change = select (wd + 1, &rfd, NULL, NULL, &delay);
                  if (file_change == 0)
                      continue;
                  else if (file_change == -1)
                      die (EXIT_FAILURE, errno, _("error monitoring inotify event"));
              }
...
  len = safe_read (wd, evbuf, evlen);
.

所以以上步骤主要分为三步:
1)注册inotify的watch。
2)用select等待watch事件发生。
3)用safe_read读取准备好的数据。

这是类似于 IO 多路复用的技术,但是 inotify 只能实现告知文件发生了变化,不能告知文件具体多了哪些字符。

所以我觉得 tail 要把新的内容打印出来,他需要通过记录文件的偏移量(一个记录举例文件开头共有几个字节指针,或者说变量),然后 inotify 告知有变化后,读取偏移量起始到末尾的所有字符。

问题:这看起来工作的很好,但是我突然在,如果变化发生在文件的中部会怎么样?

我发现如果我支持 > xxx.log,把这个日志文件清空之后,tail -f 依然正常工作。

大概意思就是说,如果这个日志文件已经有 100 GB 了,tail -f 的偏移量应该也是在 100GB 的位置, 然后 > xxx.log 清空日志文件,但是 tail -f 的偏移量貌似就自动变成了 0

我想知道这是怎么实现的,去看了 tail 的源代码(http://git.savannah.gnu.org/c...),但是限于本人的 c 工程能力,表示看不懂🤡,也去看了 promtail 的实现(https://github.com/grafana/lo...),但是限于本人的 golang 工程能力,表示看不懂🤡

但是因为我在研究一个问题:loko、elk等日志收集工具如何处理日志的时间轮换问题,从而诞生了这个问题。

参考文章:
python中文件变化监控-watchd
Linux tail 命令源码解析 (这个文章是有问题的,🤡 文章中说 tail 是检查文件变化是根据时间戳而不是 inotify)
linux inotify 监控文件系统事件

阅读 4.1k
1 个回答

确认一下你的问题:

  1. 文件被覆盖写后,tail -f 如何正常工作?
  2. 变化发生在文件中部会怎么样?

验证环境

我们将使用tail -f 和 strace验证以上两种环境。测试环境如下:

Linux DESKTOP-42TPKER 5.4.72-microsoft-standard-WSL2 #1 SMP Wed Oct 28 23:40:43 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
Ubuntu 18.04.4 LTS

$ tail --version
tail (GNU coreutils) 8.28
Copyright (C) 2017 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
...

问题1:覆盖写

验证覆盖写情况

准备工作

  • 创建测试文件a.txt

    touch a.txt
    echo "abcd" > a.txt
  • tail -f该文件

    tail -f a.txt
  • 在其他终端中,strace tail进程

    strace -p 1102
  • 追加写入"1234"

    echo "1234" >> a.txt
  • tail -f 输出如下

    1234
  • strace输出如下

    select(5, [4], NULL, NULL, NULL)        = 1 (in [4])
    read(4, "\1\0\0\0\2\0\0\0\0\0\0\0\0\0\0\0", 22) = 16
    fstat(3, {st_mode=S_IFREG|0644, st_size=10, ...}) = 0
    read(3, "1234\n", 8192)                 = 5
    write(1, "1234\n", 5)                   = 5
    read(3, "", 8192)                       = 0

覆盖写

  • 覆盖写入"qwer"到a.txt文件
    echo "qwer" > a.txt
  • tail输出为

    tail: a.txt: file truncated
    qwer
  • strace输出为:

    select(5, [4], NULL, NULL, NULL)        = 1 (in [4])
    read(4, "\1\0\0\0\2\0\0\0\0\0\0\0\0\0\0\0", 22) = 16
    fstat(3, {st_mode=S_IFREG|0644, st_size=5, ...}) = 0
    ...
    write(2, "tail: ", 6)                   = 6
    write(2, "a.txt: file truncated", 21)   = 21
    write(2, "\n", 1)                       = 1
    # 读取位置被设置到文件头
    lseek(3, 0, SEEK_SET)                   = 0
    read(3, "qwer\n", 8192)                 = 5
    write(1, "qwer\n", 5)                   = 5
    read(3, "", 8192)                       = 0

由输出可知,tail -f 执行了以下逻辑

static void
tail_forever (struct File_spec *f, size_t n_files, double sleep_interval) {
  ...
  /* XXX: This is only a heuristic, as the file may have also
     been truncated and written to if st_size >= size
     (in which case we ignore new data <= size).
     Though in the inotify case it's more likely we'll get
     separate events for truncate() and write().  */
  if (S_ISREG (fspec->mode) && stats.st_size < fspec->size)
  {
    error (0, 0, _("%s: file truncated"), quotef (name));
    xlseek (fspec->fd, 0, SEEK_SET, name);
    fspec->size = 0;
  }
  ...
}

该部分注释也说明了,文件被truncated时,fspec->size会被置为0,同时调用lseek将读取位置设置到文件头。这样就做到了正常输出。

问题2:文件中部变化

接下来测试一下文件中部变化的实际情况,继续在问题1的基础上操作。

  • 使用vi修改a.txt,在中部位置分别增加1个字符、删除1个字符
  • tail -f 无新增输出
  • strace 无新增输出,可见调用了select之后再未进行系统调用

    select(5, [4], NULL, NULL, NULL
  • 查看tail进程文件描述符发现,a.txt已被删除。尚不清除该情况是否因vi引起。

    $ ls -l /proc/1998/fd
    total 0
    lrwx------ 1 konka konka 64 Dec 18 23:03 0 -> /dev/pts/2
    lrwx------ 1 konka konka 64 Dec 18 23:03 1 -> /dev/pts/2
    lrwx------ 1 konka konka 64 Dec 18 23:03 2 -> /dev/pts/2
    lr-x------ 1 konka konka 64 Dec 18 23:03 3 -> '/home/konka/a.txt~ (deleted)'
    lr-x------ 1 konka konka 64 Dec 18 23:03 4 -> anon_inode:inotify
  • 再对a.txt进行追加,tail 及 strce未再有输出

总结,问题2无法给出原因,仅能给出一种特定环境下的执行结果。

撰写回答
你尚未登录,登录后可以
  • 和开发者交流问题的细节
  • 关注并接收问题和回答的更新提醒
  • 参与内容的编辑和改进,让解决方法与时俱进
推荐问题