CppCon 2015 学习:Racing the File System
什么是“竞争” (Race)?
通常“竞争”这个术语有两种不同的含义,容易被混淆:
1. 数据竞争(Data Race)
- 指的是多个线程对同一个内存位置进行读/写操作,而这些操作没有正确的同步机制(例如缺少锁),导致行为不可预测。
- 通常是由于未正确管理读写顺序或可见性(内存屏障等)。
- 容易使用工具自动检测,比如:
Clang Thread Sanitizer
Valgrind Helgrind
2. 竞态条件(Race Condition)
- 指的是程序中由于执行顺序的非确定性(线程调度不一致),在某些情况下会导致程序逻辑错误。
- 不一定涉及数据竞争,可能所有访问都有锁,但还是因为顺序问题导致逻辑错误。
- 通常需要经验丰富的程序员手动调试和分析。
什么是竞争:经典示例
设有两个线程,都在尝试对同一个内存位置(比如数组位置0)执行加1操作:
线程1:
x = 读取位置0的值
x = x + 1
写回 x 到位置0
线程2:
x = 读取位置0的值
x = x + 1
写回 x 到位置0
发生了什么?
- 如果这两个线程几乎同时执行,他们可能都读取到同一个值,比如0。
- 然后各自都计算
x + 1 = 1
,并都写回1。 - 结果是,原本应该加2的值,只增加了1。
总结:
- 数据竞争 是并发读写没有同步引起的行为异常,常可自动检测。
- 竞态条件 是程序逻辑因顺序不同而错误,通常需要人来分析。
- 在上面的示例中,两个线程对同一位置做加法操作就是典型的数据竞争,同时也是竞态条件的一个例子。
什么是文件系统竞争(Filing System Race)?
1. 并发 I/O 操作
在并发环境中,当多个线程同时对同一个文件进行读写操作时,可能会发生 文件系统竞争。这是由于线程间的 I/O 操作没有得到适当的同步,导致数据在文件中出现错乱或不一致的情况。
示例:并发的 I/O 操作
假设有两个线程同时操作文件:
线程 1
int x, y;
preadv(fd, x, 0); // 从文件偏移位置 0 读取数据到 x
preadv(fd, y, 4); // 从文件偏移位置 4 读取数据到 y
线程 2
int b[2];
pwritev(fd, b, 0); // 向文件的偏移位置 0 写入数据
发生什么?
- 线程 1 试图读取文件中的数据:
- 从偏移量 0 读取数据到
x
- 从偏移量 4 读取数据到
y
- 从偏移量 0 读取数据到
- 线程 2 试图写入数据到文件的偏移位置 0。
如果两个线程没有正确同步,线程 1 可能会读取到错误的数据:x
和y
可能会被读取到来自不同操作的部分内容,导致x
和y
的数据不匹配。
结果:
- 线程 1 可能会得到不一致或错位的数据。
- 这种竞争问题通常会导致数据丢失或读取不正确的值。
解决方法:
为了防止文件系统竞争(或并发 I/O 竞争),需要通过以下方式进行同步:
- 使用互斥锁(mutex)来确保同一时刻只有一个线程对文件进行读写。
- 使用文件锁(file locks)来防止多个线程同时访问相同的文件区域。
- 确保 I/O 操作的顺序或可见性被正确管理。
总结,文件系统竞争类似于数据竞争,主要发生在多线程对同一文件进行并发读写时,导致文件数据不一致或不正确。
2. 并发路径更改(Concurrent Path Changes)
在并发的文件操作中,如果路径发生变化且没有适当同步,可能会导致文件操作出现错误或不一致的情况。具体来说,如果一个线程正在操作某个路径下的文件,而另一个线程同时更改路径,这可能会导致 路径不一致 或 文件描述符错误匹配。
示例:并发路径更改
假设有两个线程,它们对路径进行不同的操作:
线程 1
path = "/niall/store";
fd1 = open(path + "/file1"); // 打开路径 "/niall/store/file1"
fd2 = open(path + "/file2"); // 打开路径 "/niall/store/file2"
线程 2
rename("/niall", "/niall.old"); // 将路径 "/niall" 重命名为 "/niall.old"
rename("/other", "/niall"); // 将路径 "/other" 重命名为 "/niall"
发生什么?
- 线程 1 试图打开两个文件:
/niall/store/file1
和/niall/store/file2
。 - 线程 2 则试图重命名路径:
- 将路径
/niall
重命名为/niall.old
。 - 将路径
/other
重命名为/niall
。
由于线程 2 对路径进行了更改,线程 1 可能会在后续的文件操作中遇到路径不一致的情况。例如,/niall/store/file1
可能会在路径重命名过程中变得不可用,或者文件描述符fd1
和fd2
可能不再指向预期的文件。
- 将路径
结果:
- 线程 1 可能会遇到文件描述符
fd1
和fd2
的混乱。即,fd1
可能会错误地指向/niall/store/file2
,而fd2
可能指向其他意外的文件,导致文件操作不一致或错误。 - 文件路径的变化导致了路径的不一致,线程 1 获取到的文件可能不是它最初想要操作的文件,进而导致错误。
解决方法:
为了避免这种并发路径更改引发的问题,应该采取以下措施:
- 路径同步:确保在路径操作期间,没有其他线程对路径进行修改。可以使用互斥锁(mutex)来同步路径修改操作。
- 文件锁:使用文件锁来防止文件路径在被打开的同时被其他线程修改。
- 操作原子性:确保路径的更改是原子的,即如果路径的重命名操作没有完成,其他线程的文件操作应当被阻塞。
总结:
并发路径更改问题发生在一个线程操作路径下的文件时,另一个线程修改了路径。这可能导致文件描述符的错位、文件内容不一致或路径错误,从而导致文件操作失败或出错。通过适当的同步和锁机制,可以避免这种竞争条件。
删除目录树的问题
在不同的操作系统上,删除一个目录树(即目录及其所有子目录和文件)的方法有所不同,特别是在 POSIX 和 Windows 系统上。
标准的深度优先算法(POSIX)
在 POSIX 系统(如 Linux、macOS)中,删除目录树的常规方法是 深度优先(Depth-First)算法,步骤如下:
- 列举目录内容:列出当前目录下的所有文件和子目录。
- 递归删除子目录:对于每个子目录,递归调用删除操作,直到删除所有子目录。
- 删除文件:删除目录中的所有文件。
这种方法在 POSIX 系统中有效,因为文件和目录的删除可以直接进行,操作系统允许在删除目录之前,删除该目录中的文件或递归删除子目录。
在 Windows 中删除目录树
然而,在 Windows 系统中,直接按照 POSIX 方法删除目录树会失败。Windows 系统的删除目录树算法如下:
- 列举目录内容:列出当前目录下的所有文件和子目录。
- 递归删除非空目录:对于每个非空子目录,递归调用删除操作。
- 重命名文件:对于每个文件,尝试将文件重命名为
%TEMP%
目录中的随机名称,然后删除文件。 - 重命名空目录:对于每个空目录,尝试将目录重命名为
%TEMP%
目录中的随机名称,然后删除该目录。 - 循环以上步骤,直到整个目录树删除。
为什么 Windows 使用这种删除算法?
1. Windows 文件系统的锁定机制
在 Windows 中,文件和目录在删除之前可能会被锁定,尤其是正在被某个进程使用的文件。Windows 不允许删除正在被使用的文件或目录。因此,直接删除目录树的操作可能会失败,特别是当目录或文件正被其他程序访问时。
2. 使用重命名来解除锁定
通过将文件或目录重命名到 %TEMP%
目录中,Windows 系统可以 解除锁定。此时,文件被移动到一个临时位置,解除文件在原路径下的占用,进而能够顺利删除。重命名操作本身通常不受锁定影响,尤其是当目标路径不再由进程锁定时。
3. 空目录删除
在 Windows 中,删除空目录相对简单,因为没有进程锁定这些目录。因此,空目录可以直接删除或重命名到 %TEMP%
目录后再删除,以确保目录树的删除过程能够进行到底。
4. 循环操作
对于非空目录,Windows 会反复执行上述步骤(重命名文件、删除文件、删除空目录),直到整个目录树删除完毕。这是因为目录和文件可能会在删除过程中被锁定或处于正在使用状态,重命名和删除的过程可以确保操作系统最终能够清理掉这些资源。
总结
Windows 删除目录树的正确算法之所以需要这样的步骤,是因为:
- 文件锁定:Windows 系统对文件和目录的操作通常会受到进程锁定的影响,导致无法直接删除。
- 临时重命名:通过将文件或目录重命名为
%TEMP%
目录中的随机名称,可以解除文件或目录的锁定,从而进行删除操作。 - 递归删除:对于非空目录,重命名文件和目录可以确保删除过程不受锁定影响,最终实现目录树的清理。
这种方法确保了 Windows 可以在文件和目录被锁定的情况下,依然能完成目录树的删除任务。
为什么文件系统竞争条件(races)很重要?
文件系统中的竞争条件问题(file system races)是非常常见且易被忽视的,许多程序员往往在假设文件系统是静态且没有其他程序或进程干预的情况下进行开发。但实际上,文件系统是一个充满并发竞争、潜在安全漏洞和意外失败的地方。以下是关于文件系统竞争条件的一些要点和为什么它们会造成问题。
文件系统竞争条件的重要性
- 文件系统是并发的:
- 假设错误:很多程序员认为在与文件系统交互时,文件系统是静态的且不会变化,或者他们是唯一在操作文件的进程。但现实中,文件系统通常是动态的,多个程序或线程可能同时对文件进行读写操作,从而引发竞争条件。
- 文件系统的竞态和安全漏洞:
- 文件系统经常存在并发访问和操作上的问题,这些问题如果没有得到妥善管理,可能导致程序的错误行为或意外的安全漏洞。例如,race condition 可能导致错误的文件写入、删除、修改,或文件访问权限泄露等问题。
- 这类问题不仅仅限于文件内容的错误,也可能影响到文件的元数据,如权限设置、文件锁定状态等。
- 不正当的文件操作:
- 竞态条件的危险:一个常见的文件系统竞态条件是两个进程或线程同时尝试访问和修改同一个文件或目录。在这种情况下,文件可能在未正确同步的情况下被同时写入,导致数据损坏或不一致的状态。
- 例如,程序可能会同时尝试删除文件和对其进行写入,导致文件丢失或无法恢复。
文件系统中的竞态条件实例
1. 删除文件时的竞态条件:
假设线程 A 正在删除一个文件,而线程 B 正在对同一个文件进行读取或写入操作。如果这两个操作没有适当的同步或协调,就会发生竞态条件,导致文件被删除或修改时出现错误。
2. 路径修改竞态条件:
另一个常见的例子是当两个进程同时操作同一目录或文件路径时。假设线程 A 正在读取路径 /niall/store/file1
,而线程 B 则在试图重命名路径 /niall
。如果没有适当的同步,线程 A 可能会遇到错误的路径,从而导致文件打开失败或操作中断。
竞态条件导致的意外后果
- 不可预测的程序失败:因为文件系统是一个动态的环境,任何竞态条件的发生都可能导致程序行为的不可预测性,从而导致程序失败或异常。
- 安全漏洞:通过文件系统的竞态条件,恶意程序或攻击者可以利用这些竞态漏洞进行 文件权限提升、恶意修改文件内容、窃取文件信息 或 拒绝服务攻击(DoS) 等行为。
为什么文件系统是一个并发问题?
- 多进程、多线程环境:文件系统的并发问题源于现代操作系统中多个进程或线程可能同时访问和修改文件。文件系统通常没有默认的并发访问控制机制,因此开发者需要小心管理文件操作的顺序和同步。
- 文件锁定问题:文件锁定(如
FILE_SHARE_DELETE
)和文件删除过程中的竞争条件是文件系统中常见的并发问题。如果多个进程或线程尝试对同一个文件进行删除和修改,而没有合适的锁机制,就可能导致竞争条件发生。
总结
文件系统的竞态条件问题非常重要,因为它们可能引发各种 意外行为,如数据丢失、文件访问错误、权限泄露等。在开发与文件系统交互的程序时,程序员必须非常小心,确保操作顺序正确,并避免出现未同步的并发访问。通过理解这些潜在的竞争条件和采取适当的同步措施,可以有效避免这类问题,确保程序的可靠性和安全性。
什么是 TOCTTOU(Time Of Check To Time Of Use)?
TOCTTOU(Check时与使用时之间的时间差)是一个常见的安全漏洞,指的是在程序检查某些条件时(例如,检查文件访问权限)与实际使用这些条件之间,攻击者有足够的时间进行恶意操作,导致安全漏洞。这个漏洞通常发生在 检查阶段 和 使用阶段 之间,攻击者可以在这两者之间的时间窗口内改变文件、目录或其他资源的状态,从而绕过原本的安全检查。
TOCTTOU 漏洞示例
假设有以下代码示例:
1. 线程 1:
if (access(path) == 0) { // 检查文件是否可以访问fd = open(path); // 打开文件write(fd, ...); // 写入文件
}
2. 线程 2:
link(otherpath, path); // 将其他路径的文件链接到目标路径
在这个例子中,线程 1 首先检查 path
是否可以访问(即通过 access()
函数),然后打开该文件并写入数据。然而,在检查和使用之间,线程 2 可以通过 link()
操作将另一个文件(otherpath
)链接到 path
。如果线程 2 在 线程 1 检查之后、使用之前进行这次链接操作,那么 线程 1 实际上会在原本应该是安全的文件上进行写操作,而这个文件实际上已经被替换为攻击者控制的文件,从而绕过了安全检查。
TOCTTOU 的危害
- 绕过安全性:通过利用 TOCTTOU 漏洞,攻击者可以在检查阶段和使用阶段之间修改资源,从而绕过原本的安全措施。
- 文件替换:如果程序检查文件权限后尝试打开并写入文件,攻击者可以在这两个步骤之间替换文件,导致程序误操作或修改了未经授权的文件。
TOCTTOU 漏洞的真实世界案例
- CVE-2003-0813 - RPC Denial of Service(RPC 拒绝服务攻击):攻击者利用 TOCTTOU 漏洞,破坏 RPC 服务的正常操作。
- CVE-2004-0594 - PHP Arbitrary Code Execution(PHP 任意代码执行):攻击者通过 TOCTTOU 漏洞绕过文件检查,在 PHP 环境中执行任意恶意代码。
- CVE-2008-2958 - Arbitrary File Modify(任意文件修改):攻击者通过 TOCTTOU 漏洞修改文件内容,可能导致系统受到影响。
- CVE-2008-1570 - Arbitrary File Modify(任意文件修改):通过 TOCTTOU 漏洞修改文件,进而执行不合法的操作,可能导致数据损坏或安全漏洞。
TOCTTOU 漏洞的安全风险
由于 TOCTTOU 漏洞 主要涉及时间窗口的利用,因此攻击者可以通过在检查和使用操作之间迅速改变文件系统状态,导致 不可预见的后果。这种漏洞在文件操作、权限检查以及资源管理等方面都可能存在,通常难以防止,且不容易通过常规的代码审查发现。
如何防止 TOCTTOU 漏洞
- 原子操作:尽量使用原子操作来确保检查和使用之间没有时间差。例如,在 Linux 中,使用
open()
时加上文件锁,避免其他线程修改文件。 - 使用文件锁:通过文件锁来确保文件在访问和修改期间不被其他进程或线程修改。
- 避免在检查后执行不安全的操作:减少程序中检查和操作之间的时间差,尽量在检查时即执行相关操作,减少风险。
总结
TOCTTOU 漏洞是因为程序在检查文件或资源状态与实际使用之间的时间差而出现的安全问题。这种漏洞常常被攻击者利用来绕过安全检查,执行未经授权的操作。为了避免这种漏洞,开发者应考虑使用原子操作、文件锁和减少时间窗口等方式来提高程序的安全性。
可移植的无竞争条件的习惯用法与设计模式(中文理解)
在处理文件系统时,如果你想避免竞争条件(race conditions),尤其是在多线程或多进程环境中,以下是一些通用的设计原则和操作技巧:
一些基本的设计原则:
- 避免使用绝对路径(absolute paths)
- 为什么? 绝对路径在使用时可能会在你访问它们之间被改变(例如被删除或重命名),因此是非常容易引发竞态条件的。
- 用法错误示例:
open("/tmp/myfolder/file.txt");
- 这段代码假设
/tmp/myfolder
永远存在、不被其他线程修改,但在多线程环境中可能不是这样。
- 使用打开的文件描述符(或 Windows 的 HANDLE)作为相对路径操作的基础
- 意思是:先打开一个“目录”并获取其文件描述符(fd),然后再基于它进行相对路径访问,而不是每次都从根目录开始查找。
- 用法正确示例(Linux):
int dirfd = open("/tmp/myfolder", O_RDONLY); openat(dirfd, "file.txt", O_RDONLY);
- 结合“相对路径 API”与下一节的设计模式,实现真正的无竞争行为
- 在不同系统中,你可以通过这些相对路径的系统调用和合理的设计手法来保证文件访问的原子性和安全性。
特殊的相对路径文件系统 API(POSIX)
在 POSIX 系统(如 Linux、FreeBSD、macOS 等)中,提供了专门的以打开目录为基础的相对路径 API 来替代传统的绝对路径系统调用,避免在查找路径时产生竞态问题:
系统调用名 | 用途示例 |
---|---|
openat() | 从目录文件描述符 dirfd 打开相对路径文件 |
unlinkat() | 删除相对路径文件 |
renameat() | 相对路径重命名 |
fstatat() | 获取相对路径文件信息 |
mkdirat() | 创建子目录 |
symlinkat() | 创建符号链接 |
faccessat() | 检查相对路径文件访问权限 |
linkat() | 创建硬链接 |
这些 API 的共同特征是:不再依赖路径字符串,而是以“已打开的目录描述符”为上下文进行操作,从而消除 TOCTTOU 等竞态风险。 |
Windows 下的 race-free 文件系统 API(更棘手)
在 Windows 系统中,实现无竞争访问稍微麻烦一些:
- Windows 的 NT 内核支持 基于 HANDLE 的相对路径访问,这与 POSIX 的
*at()
API 类似。- 但这些功能通常并没有直接暴露在 Win32 API 层(即你常用的 C++ Windows 编程接口中)。
- 需要通过调用底层的 NT 内核函数(例如
NtCreateFile
)或使用一些特殊手段(如CreateFile
+FILE_FLAG_BACKUP_SEMANTICS
)来达到同样的效果。
Windows 的限制特点:
- 你不能重命名一个包含打开文件句柄的目录
- 如果某个文件正在被打开(比如某个进程在读),你就无法把它所在的目录移动或重命名。
- 你不能把文件重命名到某个路径,如果任何进程持有能重命名该路径上任意部分的 HANDLE
- 举个例子,如果
/users/data/
路径中的任何一个文件夹正被某个进程打开并拥有“重命名权限”,那你无法把文件移动到这个目录里。
- 举个例子,如果
这种限制反而是有利的!
它 防止了路径在你使用期间被偷偷改变 —— 一定程度上减少了 TOCTTOU 等竞态攻击可能。
总结:为何这些重要?
- 文件系统操作在大多数语言和框架中都容易写出“看似正确但其实有竞态风险”的代码。
- 使用相对路径 API + 设计模式(比如“打开目录并以其为根进行操作”)是编写健壮、多线程安全代码的基础。
- 在不同操作系统中应使用系统提供的原子操作接口来避免竞态和安全问题。
无竞争设计模式一:使用相对路径代替绝对路径(中文理解)
设计模式一:使用相对路径
不推荐的方式(易发生竞争):
fd1 = open("/niall/foo/file1");
fd2 = open("/niall/foo/file2");
- 问题:在这两个
open()
调用之间,/niall/foo/
目录的内容可能被其他线程或进程修改(如文件被替换、目录被重命名等),这会导致 file1 和 file2 来自不同上下文或不同版本的目录,从而引发竞态条件(race condition)。
推荐的方式(无竞争):
int dirh = open("/niall/foo", O_RDONLY);
int fd1 = openat(dirh, "file1", O_RDONLY);
int fd2 = openat(dirh, "file2", O_RDONLY);
- 好处:所有后续的文件访问(如
openat
)都是以已打开的dirh
为基础的相对路径访问。 - 因为
dirh
是已经打开的文件描述符,对应的目录在使用期间不会变,所以无法在你访问 file1 和 file2 之间发生路径被替换的竞态问题。
结论:
使用 openat()
和类似的“相对路径 API”,可以有效避免在访问同一目录下多个文件时发生竞态条件,提高文件操作的安全性和一致性,特别适合在多线程或多进程环境下使用。
无竞争设计模式二:完全避免使用路径,直接通过文件描述符(fd)操作(中文理解)
设计模式二:完全避免路径,使用直接基于文件描述符的操作
不推荐的做法(容易有竞态):
link("/niall/foo/file1", "/file2");
- 这里用的是文件路径字符串,路径之间可能发生变化(如重命名、删除),存在竞态条件。
推荐的做法(无竞态):
linkat(file1_fd, "", AT_FDCWD, "/file2", AT_EMPTY_PATH);
- 这里通过
file1_fd
(已打开的文件描述符)直接操作文件,而不是使用路径。 AT_EMPTY_PATH
表示目标文件就是对应的file1_fd
,不需要额外路径。- 这种方式利用文件描述符的唯一标识,不会因为路径改变而出错,实现了竞态安全。
- 目前 Linux 和 Windows 平台支持通过文件描述符直接操作。
关于删除操作(unlink)
传统写法(容易竞态):
unlink("/niall/foo/file1");
期望写法(无竞态):
unlinkat(file1_fd, "", AT_EMPTY_PATH);
- 但是,POSIX 标准目前不支持这种通过文件描述符删除的方式。
- 只有 Windows 支持“通过句柄删除”(delete-by-handle)。
- POSIX 平台缺乏对应接口,无法直接实现这一设计模式的删除部分。
结论与解决思路
- 避免使用路径操作,直接通过已打开的文件描述符操作,可以消除竞态条件。
- 对于支持的系统(如 Linux 和 Windows),
linkat
等接口可以直接操作 fd。 - 删除操作在 POSIX 上还没通用的支持,可能需要借助其它设计模式或额外的同步机制来规避竞态。
总结:
设计模式二强调完全绕开路径字符串操作,利用文件描述符的唯一性和稳定性,实现文件操作的竞态安全。但现实中,因系统支持差异,部分操作(如删除)仍有挑战。
设计模式三:通过inode检查结合相对路径系统调用,解决缺乏直接基于fd文件系统操作支持的问题(中文理解)
为什么需要设计模式三?
- 设计模式二(完全基于fd操作)删除操作在POSIX系统中不支持。
- 但我们仍想实现无竞态的文件操作,特别是删除和重命名。
- 解决思路是结合相对路径操作与inode唯一标识的检查,来确保文件身份没有变化。
设计模式三的核心思路
1. 背景知识
- POSIX标准保证:具有相同
st_dev
(设备号)和st_ino
(inode号)的文件是同一个文件。 - Linux、OS X、FreeBSD(仅目录)支持通过打开的fd查询其当前路径。
- 通过查询路径并打开目录fd,再用
fstatat()
比较inode,可以确认文件身份。
2. 实现步骤
假设我们有一个已打开的文件描述符 fd
,其具体路径不确定:
- 获取该fd的当前路径
- 例如 Linux下可以用
/proc/self/fd/<fd>
符号链接读取路径。
- 例如 Linux下可以用
- 从路径中提取父目录路径,并打开父目录
- 通过
open(parent_path)
获得父目录的 fd,记为dirfd
。
- 通过
- 调用
fstatat(dirfd, leafname)
- leafname 是文件名部分,不带路径。
- 比较 inode 和设备号
- 将
fstatat
返回的st_ino
和st_dev
与原fd对应的fstat
信息对比。 - 如果不匹配,说明文件路径已经变化(竞态),回到步骤1重新尝试。
- 将
3. 利用此方法的好处
- 一旦你获得了正确的父目录 fd,你就可以对该目录下的兄弟文件进行无竞态操作。
- 例如,已知某个文件的fd,可以无竞态地打开同一目录下的另一个文件。
- 也可以实现无竞态地删除或重命名该文件,即使操作系统不支持直接基于fd的删除。
总结
- 设计模式三通过利用inode唯一标识和相对路径调用(如
openat
、fstatat
),实现对文件的竞态安全访问。 - 它是设计模式二在不支持直接基于fd删除操作的POSIX系统上的有效替代方案。
- 这让我们在实际系统中,即使没有完美的API支持,也能避免文件系统操作竞态。
设计模式四:使用原子重命名防止读者读取未完成写入的数据(中文理解)
设计模式四的核心思想
- 问题
- 当一个文件正在写入数据时,其他线程或进程如果同时读取该文件,可能会看到不完整或部分写入的数据,这就导致了竞态条件和数据不一致。
- 解决方案:原子重命名
- 先写入一个临时文件(通常是随机命名或者使用
O_TMPFILE
标志创建匿名临时文件)。 - 写入完成后,使用原子操作将临时文件重命名为目标文件的正式名称,替换旧文件。
- 这个重命名操作在文件系统层面是原子的,意味着其他进程不会看到“部分写入”的中间状态。
- 先写入一个临时文件(通常是随机命名或者使用
- 效果
- 读者继续读取旧的文件 inode,直到关闭旧文件句柄,文件才真正被删除。
- 写入完成后,新的读者看到的是完整的、已经写好的文件内容。
平台兼容性说明
- Unix/Linux
- 传统且广泛支持原子重命名(
rename()
函数本身即为原子操作)。
- 传统且广泛支持原子重命名(
- Windows
- 早期Win32 API中,原子重命名难以实现。
- NT内核一直支持原子重命名操作。
- 从Windows Vista开始,Win32提供了原子重命名的API:
SetFileInformationByHandle()
,传入FILE_RENAME_INFO
结构体,并将ReplaceIfExists
参数设置为true
。
- 注意:这不同于常用的
MoveFileEx()
。
总结
- 设计模式四利用文件系统提供的原子重命名操作来确保写入操作对读者是“瞬间完成”的,避免读取未完成数据的竞态条件。
- 这是跨平台高效且安全的文件写入-替换策略。
设计模式五:文件系统中的四种并发控制技术(锁机制)— 中文理解
设计模式五核心内容:锁(Locking)
在文件系统中,处理并发访问的经典方法是加锁,常见的四种锁类型,按性能从低到高排序:
- 独占锁文件(Exclusive lock files)
- 最简单、最容易实现且跨平台兼容性最好。
- 通过创建一个特定的锁文件来表示资源被占用。
- 缺点是性能较低,因为它限制了并发访问。
- 字节范围锁(Byte range locks)
- 允许锁定文件中的某一部分(字节范围),而不是整个文件。
- Windows 和 Linux 支持较好,但在一些非 Linux 的 POSIX 系统上实现较复杂或不稳定。
- 性能优于独占锁,因为可以细粒度控制。
- 原子追加 + 区间回收(Atomic append + extent deallocation)
- 适用于支持区间(extent)管理的现代文件系统。
- 例如一些新型文件系统支持原子追加写入以及文件区间的原子删除。
- 这种技术使得写操作在并发场景下更加高效。
- 顺序保证(Ordering guarantees)
- 这是高级且复杂的技术,通常只在文件系统专家之间讨论。
- 依赖文件系统(如 NTFS、XFS、ZFS、UFS)提供的底层顺序写入保证。
- 允许最大化性能同时确保数据一致性,但实现难度较大。
总结
- 设计模式五介绍了文件系统并发控制的四种锁机制,从最简单(独占锁文件)到高级(顺序保证)。
- 根据应用需求和环境选择合适的锁技术,权衡性能与复杂度。
设计模式5a:独占锁文件(Exclusive lock files)
设计模式5a核心内容:独占锁文件
独占锁文件是一种简单且有效的文件级锁机制,其实现方式是通过创建一个独特的“锁文件”来保证资源在某一时刻只被一个进程或线程访问。基本的工作原理是:尝试创建文件,如果文件已经存在,则说明资源已被锁定,无法继续操作。
实现方法:
- POSIX系统:
while (-1 == open("lockfile", O_EXCL | O_CREAT));
- 使用
open
函数配合O_EXCL
和O_CREAT
标志确保创建文件时文件不存在。
- 使用
- Windows系统:
while (-1 == CreateFile("lockfile", CREATE_NEW, FILE_ATTRIBUTE_TEMPORARY));
- 使用
CreateFile
函数配合CREATE_NEW
标志来确保文件不存在,创建一个临时文件作为锁。
- 使用
优点:
- 网络文件系统兼容
- 独占锁文件在网络文件系统上也能如预期工作,适用于多台机器共享同一文件系统的场景。
- 跨操作系统兼容
- 在相同的网络驱动器上,独占锁文件可以在不同操作系统之间共享并正常工作。
- 概念简单,易于维护
- 实现起来不复杂,逻辑简单,易于理解和维护。
缺点:
- 只能进行独占锁
- 只能确保一个进程获得锁,而不支持多读者共享(例如,多线程同时读取文件内容)。因此,在读写场景中不适用。
- 不适应突然断电情况
- 如果系统突然断电或崩溃,锁文件可能会遗留在文件系统中,从而导致死锁或资源无法释放。
- POSIX中的过时锁文件问题
- 在POSIX系统中,意外退出的进程可能留下过时的锁文件,而交换文件(swap file)中的垃圾数据可能使得锁文件无法正确清理。
- 没有有效的等待锁文件释放的机制
- 在这种设计下,进程只能忙等待(busy-waiting)直到锁文件被释放,这会消耗大量的CPU和电池资源。
- 性能问题
- 性能不是特别好,尤其是在高并发的场景下:
- Windows系统:大约是
O(log WAITERS)
,需要等待锁文件被释放。 - Linux系统:
O(1)
,性能相对较好。 - FreeBSD系统:
O(1)
,性能同样较高。
- Windows系统:大约是
- 性能不是特别好,尤其是在高并发的场景下:
总结
独占锁文件是一种简单有效的锁机制,适用于大多数普通应用,特别是在网络文件系统或跨操作系统环境下。然而,它有一些局限性,特别是无法处理多个读者、无法防止突然断电和死锁、以及高并发性能较差。因此,在需要更高性能或支持并发读写的场景中,可能需要考虑其他更复杂的锁机制。
设计模式5b:字节范围锁(Byte range locks)
设计模式5b核心内容:字节范围锁
字节范围锁(Byte range locks)是一种文件锁定机制,它允许你在一个打开的文件中,对某一特定的偏移量和长度范围应用独占锁或共享锁。这样,你可以锁定文件的部分内容,而不是整个文件,这使得多个进程或线程可以同时访问文件的不同部分。
实现方法:
字节范围锁通常允许你对文件的某个特定区域加锁,而不是整个文件。通过设置一个偏移量和长度,你可以精确控制锁的范围,从而减少锁的争用和提高并发性。
优点:
- 允许并行非修改操作
- 字节范围锁允许多个进程或线程对文件的不同部分进行并行读取,而不发生冲突。它适用于需要读操作并行化的场景。
- 进程退出时自动解锁
- 如果进程意外退出,字节范围锁会自动解除,不会造成死锁。
- 断电问题得到解决
- 字节范围锁不像锁文件那样容易受到突然断电的影响,因为它是基于文件的具体部分进行锁定的,通常是由操作系统管理的。
- 可以阻塞等待锁
- 线程可以选择阻塞,直到锁可用。这意味着你可以安全地等待,直到锁定区域不再被其他进程占用。
- 性能高于锁文件
- 字节范围锁的性能优于锁文件,特别是在高并发情况下:
- Linux:
O(waiters)
,大约3.5k,性能随等待线程数的增加而线性变化。 - Windows:
O(1)
,性能相对较好,几乎不受等待线程数量的影响。 - FreeBSD:
O(waiters)
,性能随着等待线程数增加而有所变化。
- Linux:
- 字节范围锁的性能优于锁文件,特别是在高并发情况下:
缺点:
- 在POSIX系统上使用不便
- 字节范围锁在Windows上非常直观和异步地使用,但在POSIX系统上(除Linux 3.15及更高版本外)使用起来较为困难:
- 在POSIX系统中,字节范围锁是针对inode(文件的唯一标识符)而非文件描述符(fd)进行的。
- 这意味着,如果在同一个进程中关闭一个文件描述符,它会解除该进程中所有与该inode相关的锁,这可能导致锁的管理变得更加复杂。
- 字节范围锁在Windows上非常直观和异步地使用,但在POSIX系统上(除Linux 3.15及更高版本外)使用起来较为困难:
- 网络共享驱动器上的问题
- 在共享网络驱动器上使用字节范围锁时,会出现跨平台的兼容性问题:
- 在POSIX系统中,字节范围锁通常是建议性锁(advisory),即锁的使用由进程控制,并不强制执行。
- 在Windows系统中,字节范围锁是强制性锁(mandatory),必须遵守,否则会导致锁冲突。
- 此外,在POSIX系统中,偏移量和长度是有符号整数,这可能导致在某些情况下发生溢出或负值的错误。
- 在共享网络驱动器上使用字节范围锁时,会出现跨平台的兼容性问题:
总结
字节范围锁提供了一种细粒度的锁定方式,能够允许多个线程或进程并发读取文件的不同部分,避免了对整个文件的锁定,从而提高了效率。在支持字节范围锁的系统中(如Windows和Linux),它是一种非常高效的同步机制。然而,字节范围锁的跨平台兼容性较差,特别是在POSIX系统上,管理锁和文件描述符的方式不同,可能导致问题。此外,字节范围锁在共享网络文件系统上的使用也可能出现问题,特别是涉及强制性和建议性锁的差异。
设计模式5c:原子追加(Atomic Append) + 区间释放(Extent Deallocation)— 中文理解
设计模式5c核心内容:
- 原子追加:在几乎所有文件系统(包括 CIFS,但不包括 NFS)中,对只允许追加写入的文件的写操作是原子的。这意味着多个线程或进程同时写入时,写入的数据不会交错混合,而是一个接一个完整写入。
- 区间释放:基于区间(extent-based)的文件系统允许对文件的任意范围进行释放,也就是物理存储空间可以被回收,不再占用空间。
设计思想:
将 原子追加 和 区间释放 结合起来,就能实现一种并发安全的文件更新模式:
- 所有变更都以追加的方式写入文件末尾(文件“永远增长”)。
- 同时释放(删除)文件中过时的数据区间,物理存储不再增长。
- 这样文件看似不断变大,实际物理占用并不会无限增长。
性能和并发:
- 并发性能极高,理论上可以超过 10 万次每秒的输入输出操作(IOPS),只要不是写时复制(COW)文件系统。
- 写操作复杂度会随着等待线程数的增加而增加(至少是多项式复杂度,指数X ≥ 2)。
优点:
- 非常快速,尤其是在非写时复制文件系统中。
- 跨平台兼容性好,包括多平台使用 CIFS 网络共享。
- 唯一实现后期持久性保证的可移植方案,保证数据在追加后不会丢失。
- 灵活性极高,可以用来实现复杂算法,比如分布式互斥算法(Suzuki & Kasami、Maekawa & Ricart、Agrawala算法)。
缺点:
- 依赖于基于区间的文件系统:
- 只有近15年内创建的主流文件系统才支持区间管理(如 ext4、XFS、NTFS 等)。
- 如果不是基于区间的文件系统,文件会无限增长,造成存储浪费。
- 可以用分段文件(segmented files)作为替代方案。
- 性能最好在追加的记录比较“块状”时:
- 区间粒度一般在4KB到128KB之间,太小的写入可能导致效率降低。
- 算法复杂,难以维护:
- 这个设计模式涉及的算法较为复杂,理解和维护起来对工程师是一种挑战。
总结
设计模式5c利用原子追加写和区间释放两个特性,实现了高性能、高并发且持久安全的文件写入方案。它适用于现代主流文件系统,尤其在需要多进程或多线程安全写入的场景中非常有效。虽然实现和维护较复杂,但其性能和灵活性使其成为许多高端系统中不可或缺的设计选择。
设计模式5d:POSIX并发修改可见性顺序保证(谨防潜在风险)
设计模式5d核心内容:
这个设计模式适用于高端程序员,用于利用 POSIX.2008 提供的部分 读取-写入顺序保证,但它有很多潜在的风险,需要谨慎使用。
基本内容:
- POSIX.2008 提供了读取-写入的顺序保证:对于普通文件的I/O操作,在不涉及网络驱动的情况下(并且在BSD系统、Linux上的XFS或Windows平台上),可以保证读取操作总是看到已完成的写操作,即读操作永远不会看到部分完成的写入。
- 这一行为类似于 std::atomic 中的 std::memory_order_relaxed(对于某些单一的
preadv()
或pwritev()
操作)。
POSIX.2008的标准描述:
- POSIX-2008说:
“I/O操作对于普通文件是原子的…原子性意味着一次操作中的所有字节在操作开始时一起开始,最终也一起结束,不会被其他I/O操作交错。”
这与某些std::atomic中的std::memory_order_relaxed类似。 - POSIX.2008还说:
“如果一个read()
的文件数据可以被证明(通过任何方式)发生在一个write()
之后,它必须反映那个写操作,即使这些调用发生在不同进程中。”- 这确保了写入的内容会被后续读取操作看到,即便是跨进程。
设计模式5d具体保证:
- 保证顺序性:
- 每一个
read()
、readv()
或preadv()
操作,对于某个偏移量和长度,隐式排除了任何并发的write()
、writev()
或pwritev()
,这意味着:- 写操作永远不会被任何读取操作看到部分完成的状态。
- 这不适用于所有读写操作,但对于具体偏移和长度上的读取与写入具有顺序保证。
- 每一个
- 类似于:
- 这种“发生在读之前”的顺序保证类似于 std::memory_order_release(用于
pwritev()
) 和 std::memory_order_acquire(用于preadv()
)的顺序保证。 - 可以在某些情况下作为 无锁编程(lock-free programming) 的基础。
- 这种“发生在读之前”的顺序保证类似于 std::memory_order_release(用于
优势:
- 性能非常高:
- 可以达到超过 1M IOPS(每秒输入输出操作)速度,因为不需要额外的锁,只依赖内核内部的锁。
- 对无锁原子操作编程熟悉的开发者:
- 如果你熟悉无锁编程,这种顺序保证非常容易理解和利用,提供了一种高效的编程方式。
- 跨平台支持:
- 适用于大多数操作系统,尤其是 BSD、Windows,以及 Linux的XFS 文件系统。
缺点:
- Linux的限制:
- 在Linux中,锁是基于4KB页的。
- 如果你的读写操作跨越了4KB的页面边界,就会遇到问题。
- 解决方法是使用2的幂(如4KB、8KB、16KB等)的记录大小。
- 在Linux中,锁是基于4KB页的。
- XFS文件系统的额外锁:
- 在XFS文件系统上,Linux增加了额外的锁来使该特性可用。
- 缺乏广泛的使用案例:
- 这不是一个被广泛测试过的使用案例,没有主流的数据库系统依赖于这种技术。
- 跨平台不完全一致:
- 虽然 FreeBSD、Solaris 和 NT内核(NTFS) 支持这些语义,但微软并没有保证这一点会一直保持,可能会随Windows版本更新而改变。
总结:
设计模式5d通过使用 POSIX.2008 提供的并发修改顺序保证,使得多进程和多线程环境下对文件的读写操作更加高效和一致。它是一种 无锁 的高效并发操作方法,适用于 高并发场景,尤其是在支持这一功能的系统上(如 BSD、XFS、Windows)。然而,由于 Linux 的一些限制和 跨平台兼容性问题,这并不是一个完全无风险的方案,开发者需要非常小心地选择是否使用这种模式。
Proposed Boost.AFIO 中文理解总结
什么是 Proposed Boost.AFIO?
- 它是一个统一的跨平台文件系统编程模型。
- Windows 和 Linux 平台功能齐全,FreeBSD 和 OS X 功能稍少。
- 对于操作系统本身不支持的功能,会尽量用模拟方式实现,以正确性和跨平台一致性为先,性能排其次。
Boost.AFIO 提供了什么?
- 无竞态(race-free)的文件系统 API,扩展自 Filesystem TS 标准。
- 使用了抽象的、引用计数管理的打开文件描述符/句柄模型。
- 支持多种文件系统后端,包括但不限于:
- 本地硬盘(file:///)
- ZIP压缩包中的文件(file:///foo.zip)
- 远程HTTP资源(http://something/index.html)
- 98% 的 API 是异步操作。
- 文件的散布读(scatter read)和聚集写(gather write)支持 100% 异步。
- 也提供了同步 API,支持抛异常和 error_code 两种错误处理方式。
Boost.AFIO 提供的具体 API 功能包括:
- 基于打开的 fd/handle 进行打开、创建、删除文件、目录、符号链接等操作(相对路径操作)。
- 同步数据到物理存储(提供三种算法)。
- 通过打开的 fd/handle 释放物理存储空间。
- 支持原子化的散布读取和聚集写入。
- 查询已挂载存储卷的信息。
- 获取当前打开文件的路径。
- 获取符号链接的目标路径。
- 将文件的存储区段映射到内存。
- 通过打开的 fd/handle 进行链接(link)和取消链接(unlink)。
- 支持基于 fd/handle 的原子重命名操作。
Boost.AFIO 的开发和现状:
- 2013年由学生 Paul Kirth 作为 Google Summer of Code 项目移植到 Boost。
- 2013年10月进入 Boost 同行评审流程。
- 2015年8月进行社区同行评审,结果几乎全部被否决,仅一人支持。
- 计划用更现代的 C++ 特性(轻量级 monadic futures、协程、基于 ASIO 之后的 I/O 反应器)重写。
- 现有的旧引擎成熟且稳定,API 预计不会大改。
- 新引擎更轻量,支持 C++17(当时称为 C++1z)及以后版本。
- 新的非 ASIO I/O 反应器使得完整锁支持变得可行。
总结
Boost.AFIO 是一个追求高正确性、跨平台一致性和无竞态的异步文件系统操作库,尽管性能不是首要目标,但它支持现代异步 I/O 编程模型并规划采用最新的 C++ 特性进行重写。它还支持多种文件系统类型和复杂的文件操作,是对现有 Boost Filesystem 和异步 I/O 的有益补充。
如果你关注跨平台高质量文件系统操作,Boost.AFIO 是一个值得关注和等待发展的项目。