隔离性隔离性意味着对同一文件的并发更新是可串行化的——存在一个串行调度使得实际执行的并行调度返回相同 的结果。“真实的”数据库系统使用像MVCC这种高级技术维护可串行性,同时允许高等级的可并行性。回到 我们的场景,我们最后使用加锁来串行文件更新。 对截断-写更新进行加锁是容易的。仅仅在所有文件操作前获取一个独占锁就可以。下面的例子代码从文件 中读取一个整数,然后递增,最后更新文件: 2 | with open (filename, 'r+' ) as f: |
3 | fcntl.flock(f, fcntl.LOCK_EX) |
8 | f.write( '{}\n' . format (n)) |
使用 写-替换模式加锁更新就有点儿麻烦啦。像 截断-写那样使用锁可能导致更新冲突。某个幼稚的实 现可能看起来像这样: 02 | with open (filename) as f: |
03 | fcntl.flock(f, fcntl.LOCK_EX) |
06 | with tempfile.NamedTemporaryFile( |
07 | 'w' , dir = os.path.dirname(filename), delete = False ) as tf: |
08 | tf.write( '{}\n' . format (n)) |
10 | os.rename(tempname, filename) |
这段代码有什么问题呢?设想两个进程竞争更新某个文件。第一个进程运行在前面,但是第二个进程 阻塞在fcntl.flock()调用。当第一个进程替换了文件,释放了锁,现在在第二个进程中打开的文件 描述符指向了一个包含旧内容的“幽灵”文件(任意路径名都不可达)。想要避免这个冲突,我们必 须检查打开的文件是否与fcntl.flock()返回的相同。所以我写了一个新的LockedOpen上下文管理器 来替换内建的open上下文。来确保我们实际打开了正确的文件: 01 | class LockedOpen( object ): |
03 | def __init__( self , filename, * args, * * kwargs): |
04 | self .filename = filename |
06 | self .open_kwargs = kwargs |
10 | f = open ( self .filename, * self .open_args, * * self .open_kwargs) |
12 | fcntl.flock(f, fcntl.LOCK_EX) |
13 | fnew = open ( self .filename, * self .open_args, * * self .open_kwargs) |
14 | if os.path.sameopenfile(f.fileno(), fnew.fileno()): |
23 | def __exit__( self , _exc_type, _exc_value, _traceback): |
2 | with LockedOpen(filename, 'r+' ) as f: |
5 | with tempfile.NamedTemporaryFile( |
6 | 'w' , dir = os.path.dirname(filename), delete = False ) as tf: |
7 | tf.write( '{}\n' . format (n)) |
9 | os.rename(tempname, filename) |
给追加更新上锁如同给截断-写更新上锁一样简单:需要一个排他锁,然后追加就完成了。需要长期运行的 会将文件长久的打开的进程,可以在更新时释放锁,让其它进入。 spooldir模式有个很优美的性质就是它不需要任何锁。此外,你建立在使用灵活的命名模式和一个 健壮的文件名分代。邮件目录规范就是一个spooldir模式的好例子。它可以很容易的适应其它情况, 不仅仅是处理邮件。
持久性 持久性有点特殊,因为它不仅依赖于应用,也与OS和硬件配置有关。理论上来说,我们可以假定, 如果数据没有到达持久存储,os.fsync()或os.fdatasync()调用就没有返回结果。在实际情况中, 我们有可能会遇到几个问题:我们可能会面对不完整的fsync实现,或者糟糕的磁盘控制器配置, 它们都无法提供任何持久化的保证。有一个来自 MySQL 开发者 的讨论对哪里会发生错误进行了 详尽的讨论。有些像PostgreSQL 之类的数据库系统,甚至提供了持久化机制的选择 ,以便管理员 在运行时刻选择最佳的一个。然而不走运的人只能使用os.fsync(),并期待它可以被正确的实现。
通过截断-写模式,在结束写操作以后关闭文件以前,我们需要发送一个同步信号。注意通常这还 牵涉到另一个层次的写缓存。glibc 缓存 甚至会在写操作传递到内核以前,在进程内部拦住它。 同样为了得到空的glibc缓存,我们需要在同步以前对它flush(): 1 | with open (filename, 'w' ) as f: |
要不,你也可以带参数-u调用Python,以此为所有的文件I/O获得未缓冲的写。 大多数时候相较os.fsync()我更喜欢os.fdatasync(),以此避免同步元数据的更新 (所有权、大小、mtime…)。元数据的更新可最终导致磁盘I/O搜索操作,这会使整个过程慢不少。
对写-替换风格更新使用同样的技巧只是成功了一半。我们得确保在代替旧文件之前,新写入文件的内容已经 写入了非易失性存储器上了,但是替换操作怎么办?我们不能保证那个目录更新是否执行的刚刚好。在网络 上有很多关于怎么让同步目录更新的长篇大论。但是在我们这种情况,旧文件和新文件都在同一个目录下, 我们可以使用简单的解决方案来逃避这个这题。 1 | os.rename(tempname, filename) |
2 | dirfd = os. open (os.path.dirname(filename), os.O_DIRECTORY) |
我们调用底层的os.open()来打开目录(Python自带的open()方法不支持打开目录),然后在目录文件描述 符上执行os.fsync()。 对待追加更新和我以及说过的截断-写是相似的。 spooldir模式与写-替换模式同样的目录同步问题。幸运地是,可以使用同样的解决方案:第一步同步文件, 然后同步目录。
总结
这使可靠的更新文件成为可能。我已经演示了满足ACID的四大性质。这些展示的实例代码充当一个工具箱。 掌握这编程技术最大的满足你的需求。有时,你并不需要满足所有的ACID性质,可能仅仅需要一到两个。 我希望这篇文章可以帮助你去做已充分了解的决定,什么该去实现以及什么该舍弃。
|