对Linux和Windows的多个进程的并发状态文件操作超出了我们的控制

Concurent state file manipulation with multiple process beyond our control for Linux and Windows

本文关键字:操作 文件 状态 我们 控制 并发 Linux Windows 进程      更新时间:2023-10-16

下面的问题可能听起来有点长和复杂,但实际上它是一个非常简单,通用和常见的三个进程处理同一个文件的问题。在下面的文章中,我试图用一些说明性的例子将问题分解为一组特定的需求。

任务序言

有一个文本文件,叫做index,其中包含一些元数据。

有一个应用程序(APP)),它理解文件格式并对其执行有意义的更改。

文件存储在版本控制系统下(VCS)),这是其他用户对同一文件执行的更改的来源。

我们需要设计一个应用程序(APP),它将在一个合理的文件中与文件一起工作,最好不涉及VCS,因为假设VCS用于保持一个大型项目,而索引文件只是其中的一小部分,并且用户可能想要在任何时候更新VCS而不考虑APP中的任何正在进行的操作。在这种情况下,APP应该优雅地处理这种情况,以防止任何可能的数据丢失。

Preable评论

请注意VCS是未指定的,它可以是perforce, git, svn, tarballs,闪存驱动器或你最喜欢的二战莫尔斯收音机和文本编辑器。

文本文件可以是二进制的,这不会改变太多。但是考虑到VCS存储,它很容易被合并,因此文本/人类可读的格式是最合适的。

可能的例子是:复杂的配置(AI行为树,游戏对象描述),资源列表,其他不打算手工编辑的东西,与手头的项目相关,但历史很重要。

请注意,除非您热衷于实现自己的版本控制系统,否则将大部分配置"外包"到一些外部的、基于客户机-服务器的解决方案中并不能解决问题——您仍然必须在版本控制系统中保留一个参考文件,其中包含数据库中有问题的配置的匹配版本的参考。这意味着,你仍然有同样的问题,但规模更小-在一个文件中的单个文本行而不是十几行。

任务本身

一个通用的APP在真空中可以分三个阶段对指数进行读取,修改,read阶段—读取和反序列化文件,修改—更改内存中的状态,write—序列化状态并写入文件。

对于这样的应用程序,有三种通用工作流:
  1. read->
  2. read-><显示信息并等待用户输入>->modify->write
  3. ->修改->

第一个工作流是只读的"用户",像一个游戏客户端,读取数据一次,忘记文件。

第二个工作流是用于编辑应用程序。由于外部更新很少发生,并且用户不可能同时在几个编辑应用程序中编辑相同的文件,因此只能合理地假设,一般的编辑应用程序只希望读取一次状态(特别是如果这是一个消耗资源的操作),并且仅在外部更新的情况下重新读取。

第三个工作流是用于自动使用cli的——构建服务器、脚本等。

考虑到这一点,威胁readmodify+write是合理的。让我们将一个只产生阶段并准备一些信息的操作称为读操作. 和写操作将是读操作修改状态并将写入磁盘的操作。

由于工作流1和工作流2可能由不同的应用程序实例同时运行,因此允许多个读取操作同时运行也是合理的。一些读取操作,比如编辑应用程序的读取操作,可能需要等到所有现有的写入操作完成后才能读取最新的状态。其他读取操作,就像在游戏客户端中这样,可能想要读取当前状态,无论它是什么,都不会被阻塞。

另一方面,写操作检测任何其他写操作正在运行并中止是合理的。写操作还应该检测到对索引文件所做的任何外部更改并中止。基本原理——执行(和等待)任何工作都没有意义,因为它们是基于可能过时的状态创建的,因此会被丢弃。

对于一个健壮的应用程序,应该在应用程序的每个单点假设一个星系尺度的临界故障的可能性。在任何情况下,这种失败都不应该导致索引文件不一致。

需求
  1. file读取是一致的——在任何情况下,我们都不应该在文件被修改之前读取一半,或者在文件被修改之后读取另一半。
  2. 写操作是排他性的——不允许对同一个文件同时进行其他写操作
  3. 写操作是鲁棒可等待的——我们应该能够等待写操作完成或失败。
  4. 写操作是事务性的——在任何情况下都不应该让文件处于部分更改或不一致的状态,或者基于过期状态。在操作之前或操作期间对索引文件的任何更改都应被检测到,并应尽快终止操作。

Linux

A读取操作:

  1. 获取共享锁,如果请求-打开(2)(O_CREAT|O_RDONLY)和flock(2) (LOCK_SH)"锁"文件
  2. 打开(2)(O_RDONLY)索引文件。
  3. 创建内容快照并解析
  4. 关闭索引文件
  5. 解锁-羊群(2)(LOCK_UN)并关闭(2)"锁"文件

A写操作:

  1. 获得排他锁- open(2) (O_CREAT|O_RDONLY)和flock(2) (LOCK_EX) "lock"文件
  2. 打开(2)(O_RDONLY)索引文件。
  3. fcntl(2) (F_SETLEASEF_RDLCK)索引文件。-我们只对写入感兴趣,那些rdck租约。
  4. 检查状态是否是最新的,做一些事情,改变状态,将其写入附近的临时文件。
  5. 重命名(2)临时文件到索引-这是原子的,如果到目前为止我们还没有得到租约中断,我们不会-这将是一个不同的文件,而不是我们有租约的文件。
  6. fcntl(2) (*F_SETLEASE,F_UNLCK)索引文件
  7. 关闭(2)索引文件("旧的"文件,文件系统中没有引用)
  8. 解锁-关闭(2)"锁定"文件

如果收到来自租约的信号-中止和清理,不重命名。rename(2)没有提到它可能会被中断,POSIX要求它是原子的,所以一旦我们得到了它-我们已经做到了。

我知道有共享内存互斥体和命名信号量(而不是应用程序实例之间协作的建议锁),但我认为我们都同意,它们对于手头的任务来说是不必要的复杂,并且有它们自己的问题。

<<h2>窗口/h2>A读取操作:

  1. 获取共享锁,如果请求- CreateFile (OPEN_ALWAYSGENERIC_READFILE_SHARE_READ)和LockFileEx(1字节)"锁"文件
  2. CreateFile (OPEN_EXISTINGGENERIC_READFILE_SHARE_READ)索引文件
  3. 读取文件内容
  4. CloseHandle索引
  5. 解锁-关闭"锁"文件

A写操作:

  1. 获取排他锁- CreateFile (OPEN_ALWAYSGENERIC_READFILE_SHARE_READ)和LockFileEx (LOCKFILE_EXCLUSIVE_LOCK, 1字节)"锁"文件
  2. CreateFile (OPEN_EXISTINGGENERIC_READFILE_SHARE_READ|FILE_SHARE_WRITE)索引文件
  3. ReadDirectoryChanges (FALSE,FILE_NOTIFY_CHANGE_LAST_WRITE)在索引文件目录上,具有OVERLAPPED结构和一个事件
  4. 检查状态是否为最新状态。修改状态。将其写入临时文件
  5. 将索引文件替换为临时的
  6. CloseHandle索引
  7. 解锁-关闭"锁"文件

在修改部分使用WaitForSingleObject检查OVERLAPPED结构中的事件(零超时)。如果索引有事件-中止操作。否则-再次启动手表,检查我们是否仍然是最新的,如果是-继续。

评论

  1. Windows版本使用锁定而不是Linux版本的通知机制,这可能会干扰外部进程进行写操作,但在Windows中似乎没有其他方法。

在Linux中,您还可以使用强制文件锁定。

参见"语义"一节:

如果一个进程用强制读锁锁定了文件的一个区域,那么允许其他进程从该区域读取数据。如果其中任何一个进程尝试写入它将阻塞的区域,直到锁定释放,除非进程已经用O_NONBLOCK打开了文件标志,在这种情况下,系统调用将立即返回错误EAGAIN地位。

:

如果一个进程用强制写锁锁定了一个文件的区域,所有的尝试读写该区域块,直到锁被释放;除非进程打开了带有O_NONBLOCK标志的文件,在这种情况下系统调用将立即返回错误状态EAGAIN。

使用这种方法,APP可能会对文件设置读或写锁,并且VCS将被阻塞直到锁被释放。


请注意,如果VCS可以unlink()索引文件或使用rename()替换它,则强制锁和文件租约都不能很好地工作:

  • 如果使用强制锁,VCS不会被阻塞。
  • 如果你使用文件租约,APP不会收到通知。

您也不能在目录上建立锁或租约。在这种情况下你能做的是:

  • 读取操作后,APP可以手动检查文件是否还存在,是否有相同的i-node。

  • 但是这对于写操作来说是不够的。由于APP不能自动检查文件i-node和修改文件,它可能会意外地覆盖VCS所做的更改而无法检测到它。您可能可以使用inotify(7)来检测这种情况。