时光对比被认为是有害的

2020-09-28 23:33:44

最近在Twitter上的一次讨论(专业提示:永远不要做那些)让我意识到,我对时光比较问题的研究比大多数人都要深入得多。我不知道是应该为此感到自豪还是非常担心,但尽管如此,我们还是在这里。很快,你就会知道我关于这个话题所做的一切。我想你会和我一样后悔的。

Mtime是与给定文件关联的内容的修改时间。通常,如果任何人在文件中的任何位置写入字节,mtime都会更新。如果一个文件有多个名称(即。它是硬链接的。

多个地方),所有名称共享相同的inode和内容,因此所有名称都共享相同的mtime。令人恼火的是,当您更新文件内容时,其包含目录的mtime没有改变。如果目录mtime在包含的文件发生更改时进行更新(递归到根),则可以实现各种非常方便的树形转换,但没有。这可能是因为硬链接:因为内核通常不知道打开文件的所有文件名,所以它实际上不能更新所有包含目录,因为它也不知道它们是什么。无论如何,纯粹主义者可能会认为,当文件指向更改时,目录的内容不会更改;毕竟,内容只是文件名和内部枚举数的列表,无论这些索引节点内发生了什么,这些内容都保持不变。纯粹主义者让我很难过。

(随机附注:在MacOS上,内核确实知道硬链接的所有文件名,因为硬链接是以类似符号链接的数据结构秘密实现的。

。你通常看不到这方面的任何症状,除了MacOS上的硬链接速度慢得令人怀疑。但是作为速度慢的交换,如果内核愿意的话,它实际上可以查找硬链接的所有文件名。我认为这与别名和查找.app文件有关,即使它们四处移动,等等。)。与mtime相关的是ctime,大多数人会猜测它的意思是createtime,但它绝对不是。它意味着";属性更改时间,";不同于";修改时间";,因为它会在各种信息节点字段更改时更新,而不仅仅是文件内容。Mtime是indefield之一,因此每当mtime更改时,ctime也会更改,但反之亦然。其中,当文件所有权、大小或链接计数发生变化时,ctime也会发生变化。

链接计数特别有趣:如果您创建或删除指向给定文件的硬链接,则其ctime会发生变化。重命名定义为创建新的硬链接,然后删除另一个硬链接,这意味着它会更新ctime(而不是mtime),即使当它完成时,链接计数也会恢复正常,因此inode看起来没有变化(不是ctime)。(重命名的创建和解除链接是否应该是单个原子事务是有很大争议的。

。)。所以不管怎样,ctime的变化比mtime敏感得多。事实证明,大多数情况下,您并不关心ctime度量的更改,因此它会导致误报,特别是因为令人讨厌的链接计数,但如果您是偏执狂,这可能会有所帮助。我们现在主要谈谈时光网吧。

为了完整性,还有atime,这意味着访问时间。最初,每当任何人访问文件时都会更新atime,通常定义为从该文件读取字节。但这是没有帮助的,原因有两个:第一,这意味着读取文件系统会导致对该文件系统的写入,这会极大地增加磁盘负载(有些人估计大约增加了30%)。其次,访问时间的定义与最终用户的意思不符

这意味着各种程序(特别是备份软件和搜索引擎)试图避免更新它。此解决方法非常常见,以至于Linux添加了ANO_NOATIME标志来打开(2)顶级更新atime。默认的atime性能影响非常严重,以至于许多文件系统现在都有一个相对装载标志,这会降低atime的精度,从而减少磁盘负载。(琐事:我很早以前就开始了DebianPopular竞赛,它使用atime来判断您实际使用的是哪些已安装的软件包。)。(更多琐事:如果您以只读方式挂载文件系统,那么从技术上讲,它不再符合POSIX,因为文件系统不会被更新。)。它有多精确?这取决于操作系统和文件系统。最初,时光网的精确度是1秒,这是您唯一可以放心依赖的。现在,大多数操作系统都有一个stat(2)syscall,它返回一个包含纳秒的struct timepec,但几乎没有文件系统提供这种级别的精度,这取决于您的内核和磁盘格式。例如,在我的系统(带ext4的Debian Linux4.9.0-7)上,我得到了大约0.01s的粒度。堆积如山是一种解释。

时光是单调增加的吗?不,它可以倒退。例如,touch命令使用的utime(2)syscall可以将mtime设置为任何值。(例如,在提取tarball时,tar可能会这样做。)。如果您的系统时钟从一个时间跳到另一个时间,它会将后续时间设置为与新时钟匹配,即使是向后跳转也是如此。诸若此类。

Mtime是否设置为>;=当前时间?不,这取决于时钟粒度。例如,在我的系统上,gettimeofday()可以返回以微秒为单位的时间,但是ext4将时间戳向下舍入到前一个~10ms(但不是10ms)增量,结果令人惊讶的是,几乎总是在过去创建新创建的文件:

$python-c";import os,time t0=time.time()open(';testfile';,';w';).close()print os.stat(';testfile';).st_mtime-t0";-0.00234484672546。

Mtime是否设置为<;=当前时间?不,可能会设为未来时间。例如,假设您有一台NFS服务器,其时钟设置为未来相对于您的客户端5秒。Mtime是由服务器分配的,所以当您创建文件时,它的mtime在将来将是5秒。(更改标准以使mtime由客户端设置实际上没有帮助:那么服务器上运行的程序将在过去5秒内看到一个文件。此外,依赖ntpd也不是十全十美的:它只能减少机器之间的时钟偏差,而不是消除它。)。对于额外的不一致,IFA客户端使用utime(2)。

为了强制将时间设置为特定值,这将原封不动地传递到服务器。时光总是非零的吗?不是的。各种编写成本低廉的虚拟文件系统,就像许多基于FUSE的虚拟文件系统一样,不必费心设置mtime。

更改的mtime是否保证文件具有不同的内容?不是的。也许您编写的块恰好与文件中已经存在的块完全相同;mtime无论如何都会改变。也许您编写了一个块,然后将其改回;mtime更改了两次。

更改的内容是否保证更改的mtime?不是的。时钟偏差、低精度或utime(2)可能会导致mtime与您上次检查时相同。(对于ctime等也是如此)

像git这样的版本控制系统能节省时间吗?不,不太喜欢。Git存储的树和BLOB对象根本不包含时间戳信息。(这对于重复数据删除非常有用。)。提交对象包含不变的时间戳(提交时间、作者时间等),您可以使用该时间戳。

要对给定文件的mtime进行反向工程猜测:例如,更改该文件内容的最近一次提交的提交时间。但这不是人们会做的事,主要是因为它会给Make带来问题,我们很快就会了解到这一点。(Git没有内置危险的时间设置功能,但它似乎确实存在于SVN中。你可能仍然不应该这么做。)。(这一切都产生了有趣的哲学问题。文件的上次修改时间是创建新内容本身的时间,还是将其特定实例写入磁盘的时间?如果你有ASCI-FI设备,可以对我的身体进行完美的扫描,并在模拟中运行我,输入文件的时间是多少?以此类推。)。

(我启动的BUP项目使用GIT格式的repo来备份您的文件系统,它确实需要保存mtime和其他元数据。它将元数据存储在git树中单独的隐藏文件中,并在恢复时重新应用它。)。

在GIT中交换分支会搞砸mtime吗?不,不比其他任何东西都多。Git只是重写更改后的文件,并让内核更新mtime,因此它们看起来就像是有人用文本编辑器编辑了它们。

通过mmap()写入文件是否会更新mtime?哈。嗯,也许吧。你看,POSIX保证时光。

";将在对映射区域的写入引用和下一次调用mSync()之间的时间间隔内的某个点标记为更新...。如果没有这样的调用,这些字段可能会在写引用之后的任何时候被标记为要更新。";这个定义实际上给怪异留下了很大的回旋余地。我编写了一个小测试程序(mmap_test.c)来检查目前的工作方式,当然,它因操作系统而异。在Linux(4.9.0,ext4)上,mtime在anmmap()或msync()之后的第一个脏页面更新。在FreeBSD(11.2,UFS)上,它在msync()或munmap()时间更新。在MacOS(10.11.6)上,它仅在msync()时间更新,而不在munmap()时间更新。我甚至在Windows10上尝试了WSL个性(4.4.0-17134-微软),结果特别糟糕:Mmmaped的写入根本没有更新过时光。

我认为MacOS的行为是允许的,因为在第二句话中,规范上说的是“可能”而不是“将”,但这有点牵强。Linux行为可能是非法的,这取决于您如何定义";一个写引用";;Linux似乎将其解释为";第一个";或";一个随机选择的";write引用,而我希望将其解释为";每个";写引用(结果是,在lastrereference和mSync()之间,mtime必须至少更新一次,这样就可以了)。

在所有这些行为中,唯一有用的行为似乎是FreeBSD;,至少,我们肯定希望mtime在所有文件更改完成后至少更新一次。MacOS和Linux并不总是这样做,WSL也从来不这样做。这支持使用mmap的.git/index文件被依赖mtime的文件同步工具错误同步的说法

。具有讽刺意味的是,文件同步工具越快越好,就越容易达到竞争状态。一个简单的解决方法是让git在关闭索引文件之前始终写入()无用户字节。但我更希望内核不那么笨。我列出了上面的警告,有点毁了这个惊喜。但是,让我们来看看当我们试图使用时光网做点什么的时候,这一切意味着什么。

使依赖关系以一种非常简单的方式工作。现在,作为一个行业,我们已经有了几十年学习所有上述警告的经验,我们可以将其描述为天真,因为当Make第一次被发明时,没有人听说过所有这些问题,所以指望作者围绕这些问题进行设计是不公平的。在Make最早被写成的世界里:

计算机和编译器非常慢,一秒的时间戳粒度从来不是问题。

在那个世界里,他们做出了一个看似显而易见的决定,如果任何目标的依赖时间是>;目标的时间,他们就重建任何目标。(如果希望在出现粒度问题时更加安全,请在>;=而不是>;的情况下重新生成。)。这在当时是一项激动人心的创新。

使用NFS和时钟偏差时,如果在一台计算机上编辑源文件,而您在另一台计算机上运行make,则输入文件可能具有mtime<;目标mtime,因此不会发生任何事情。或者,您可以重新构建目标,其mtime仍为<;source mtime,因此稍后将再次重新构建。

如果您不小心将系统时钟调快了一天,并构建了一些内容,然后将时钟调回到现在,那么您在此期间构建的所有内容将来都将显示为";,因此比您今天编辑的源文件更新,从而阻止所有重建。(最终,GNU make开始检测未来日期的文件并打印警告。)。

如果您通过mmap修改了文件,则mtime可能不是最新的。(幸运的是,在编辑源文件或构建软件时,mmap非常少见。通常,您不会直接使用实时数据库作为源文件。)。

则mtime不会更新,make将看到foo.c.new的旧mtime。它可能比您的foo二进制文件旧,即使二进制文件还没有包含新的foo.c。它不会被重建。

(即)。从所有C源文件生成的所有.o文件中生成foo.a),那么如果删除其中一个源文件,它将完全不再是依赖项之一。但是所有剩余的从属仍然比foo.a旧,所以foo.a不会重建。

如果您将auto ake/autoconf生成的文件(如./configure和Makefile)放在版本控制中,您会得到令人惊讶的结果。假设Automamake有一条Makefile规则,每当自动生成输入文件时重新生成Makefile(例如,Makefile.am)更改。在保存mtime的tarball中,这是可行的,因为Makefile将比Makefile.am更新。但在使用默认内核分配的mtime写入文件的版本控制系统中,未定义的是先写入Makefile还是Makefile.am。如果你的时间戳是高精度的(或者它们是低精度的,你不走运),那么Makefile可能比Makefile.am早,Automake无论如何都会尝试运行。如果不是,那么它就不会。所以不同的人签出相同的源代码会基于随机的运气得到不同的结果。

现在的计算机速度如此之快,以至于您可以在编辑器中保存foo.c,然后生成foo.o,然后编译foo,所有这些都在同一时间段内完成。如果您这样做,比如说,在同一秒内保存foo.c两次(并且您有一秒的粒度m次),那么make不能判断foo.o和foo是否是最新的。(如上所述,make可以通过假设如果源mtime==目标mtime,目标仍然需要重建来解决此问题。这可能会导致虚假的重建,但比错过重建的危险要小。)。

(如果您使用的是基于inotify的奇特新工具,每次在编辑器中单击保存时都会立即启动编译,则通常会发生这种情况。例如,TypeScript会做这样的事情,各种现代网络语言的自动重载器也是这样做的。症状:在自动编译器捕获源文件之前,需要保存源文件两次。而且,这种情况在MacOS上比在Linux上发生得更多,MacOS的mtime粒度为1秒,Linux的粒度为0.01秒。)。

如果您的源文件位于mtime始终为0的虚拟文件系统中,那么make将始终认为您的源文件没有更改,并且目标永远不会重新构建。

当我们在这里的时候,还有一些其他常见问题,这些问题并不完全是mtime的错,而是make的常见依赖问题:

如果您升级工具链(例如,C编译器),make不知道重新生成源文件,除非您声明了对工具链文件的显式依赖关系,而没有人这样做,因为很难将依赖于系统的内容作为Makefile依赖关系规则来编写。(这就是autoconf需要是生成Makefile的./configure脚本,而不仅仅是Makefile执行的依赖项的原因之一。)。

就这一点而言,当您更新工具链时,它通常来自发行版提供的包(基本上是tarball),其中包含过去有用的时间戳,这些时间戳可能比您所有的输出文件都旧。所以,让韩元无论如何都不要认为它是最新的!

如果您在make命令行上传递变量(如CFLAGS=-O2),它们通常不会成为依赖项的一部分,因此不会导致重新构建,您最终会得到使用旧标志和新标志各编译一半的程序。您可以通过将CFLAGS写入文件来修复此问题,仅当内容不同时才自动替换它,具体取决于该文件。但是没有人知道。

如果修改生成文件,则默认情况下,生成不会重新生成任何目标。您可以通过添加对Makefile的显式依赖来解决这个问题,但这在开发过程中是一个巨大的痛苦,因为Makefile包含所有的构建规则;例如,您不希望仅仅因为更改了链接器命令行就重新编译每个源文件。(根据规则,一些现在很少见的make版本实际上会尝试跟踪Makefile更改,并导致这些情况的重新构建。)。

Make并不是唯一受到mtime天真使用影响的程序。这很常见。例如,Go遇到了很大的麻烦,以至于他们最近将Go编译器更改为每次运行时只读取和散列所有输入文件。

。(感谢bradfitz提供此链接。)。我碰巧意识到了所有这些问题(嗯,不是mmap()的疯狂;Bleah!)。当我开始写重做时。

很多年前的事了。我还受到了djb#39;的重做设计的影响,他在其中写道,当重做被要求创建一个它以前从未听说过的文件时,它会假定该文件是源文件(如果存在),否则就是目标文件。在第二种情况下(新目标),redo会立即将此决定保存到磁盘上。换句话说,重做的设计基本上依赖于保存目标数据库,即使只是为了记住哪些文件是由重做生成的,哪些不是。从那里开始,将数据库扩展到包括有关源的时间信息就足够容易了。从那里,我们可以添加更多的元数据,使时间戳更加可靠。

如果这些属性中的任何一个自上次构建目标以来发生更改,重做都认为依赖项是脏的。请注意这是如何避开时间偏差的各种问题的:

NFS客户端/服务器时间偏差不要紧,只要mtime朝任何方向改变,就没问题。

Mmap()的怪诞程度降低了,因为我们注意到文件大小的变化,以及源mtime发生了变化,但仍然比目标mtime旧。

如果您对一个文件进行MV以替换另一个文件,它将具有不同的inode编号,我们注意到这一点。它也可能具有不同的大小和(即使不比目标更新)mtime,任何一个都足够了。

由于REDO具有用于生成给定目标的所有依赖项的数据库,因此如果其中一个输入消失,则需要重新构建目标。Make不会记住上次使用的依赖项,它只会记住这次声明的依赖项,因此它可能会遗漏依赖项列表中的重要更改。

(更广泛地说,要正确构建软件,我们不仅需要知道它们现在的依赖关系,而且需要知道它们以前的依赖关系,这是一个有趣的数学现象。这两个列表的用法非常不同。我不认为大多数构建系统都是用这种实现来设计的,它会导致微妙的故障。)。

如果您将autoconf/auto ake生成的文件放入源文件库中,重做将假定该文件是源文件,并将其记下来,而不是重新构建它。(将这些签入到版本控制中可能仍然不是一个好主意。但至少现在您的构建系统不会疯狂。)。如果您随后将其删除,重做将认为它们是要构建的目标。

重做对其Mtime==目标Mtime的源文件有特殊处理,因此即使您的文件系统具有非常粗糙的时间戳粒度,它也可以纠正重叠。此外,如果您继续编辑源文件,它通常会以更改的大小结束,这也会将其标记为更改。

如果您的源文件位于Braindead FUSE文件系统中,重做可以使用inode编号和大小来检测更改(尽管它仍然很糟糕,您应该修复您的FUSE文件系统)。

声明工具链上的依赖关系很容易,因为每个目标的规则可以跟踪构建时使用了工具链的哪些部分,然后追溯声明对这些部分的依赖关系。如果新的mtime在PAS中,我们仍然会注意到变化。

.