第一阶段:共享一套读写逻辑,谁都可以写
一开始,多个进程共用一套读写逻辑:
if (!Exists(configPath))
{
WriteDefaultConfig(configPath);
}
var config = ReadConfig(configPath);
问题有两个:
- 多个进程可能同时发现“文件不存在”,然后一起创建
- 一个进程正在写,另一个进程正好在读
如果是直接覆盖写,读取方可能看到:
- 文件刚被创建,内容还是空的
- 文件已经存在,但只写了一半
- 配置结构还没写完整
- 目标文件在替换过程中短暂不可用
对应的现象就是:
- 偶发读取失败
- 配置解析异常
- 文件内容丢失
- 某些进程启动时报错
根因很简单:读者看到了写入中的中间状态。
第二阶段:加锁、重试、缓存,先把系统稳住
先说 FileShare。
它想解决的其实是两个很具体的问题:
- 读取正好撞上写入,结果读到一半内容
- 两次写入互相重叠,结果把文件写坏
最直接的做法,就是用共享模式限制文件如何被同时打开。
它的目标不是提供原子替换,而是尽量减少下面这些情况:
- 读取撞上写入中的半成品
- 两次写入同时落到同一个目标文件
- 文件直接被另一进程占用导致操作失败
FileShare 带来的直接结果是:冲突不会消失,只会变成可以观察、可以重试的失败。
所以后面又加了重试。
这能减少两类问题:
- 少报“文件被占用”
- 让偶发冲突尽量靠重试跨过去
再往后,问题变成了独占时间。
配置读取很频繁,导致写入一直在等待读取释放文件锁。
所以开始引入缓存:
var config = ReadConfig(configPath);
var lastWriteTime = GetLastWriteTime(configPath);
cache = (config, lastWriteTime);
下次访问时先比较时间戳:
var currentWriteTime = GetLastWriteTime(configPath);
if (currentWriteTime == cache.LastWriteTime)
{
return cache.Config;
}
cache = ReadConfig(configPath);
return cache.Config;
没变,缓存有效;变了,缓存失效,重新加载。
但这一阶段的边界也很清楚:
文件锁解决的是“能不能同时打开”,缓存解决的是“要不要每次都重新读”。它们都不能保证读取方看不到中间态。
配置读取不是一次必然成功的本地调用,而是一个需要容错的 I/O 操作。
第三阶段:把写入口收缩到一个进程
- 大家都能读
- 只有一个进程负责写
多个写者会直接带来两个问题:
- 谁的写入结果最后生效变得不确定
- 不同快照上的修改会互相覆盖
第四阶段:读取失败不再致命,优先复用旧值
即使只剩一个进程写,读取也不能假设永远成功。因为仍然可能有:
- 旧版本进程仍然直接覆盖同一个文件
- 读取时目标路径正好暂时不存在
- 文件被手工修改后不再满足原来的格式约束
读侧策略就变成了:
try
{
current = ReadConfig(configPath);
cached = current;
}
catch
{
current = cached ?? DefaultConfig();
}
能读到最新值最好;读不到就先用旧值,不把瞬时 I/O 异常直接放大成系统故障。
第五阶段:开始探索原子化写入
最后的问题是:
只要还是直接覆盖目标文件,读者就仍然可能看到中间态。
更稳妥的思路是:
- 先把完整内容写到临时文件
- 确认临时文件写完
- 最后一步再用临时文件替换正式文件
重点不是“临时文件”本身,而是把两件事分开:
- 生成完整内容
- 切换正式版本
直接覆盖正式文件,通常不是原子写入。
因为这类写法本质上都是先创建或截断目标文件,再把内容一点点写进去。别的进程就有机会看到:
- 文件已经存在,但大小还是
0 KB - 文件已经存在,但内容只写了一部分
var tempPath = configPath + ".tmp";
WriteAllContent(tempPath, content);
Replace(tempPath, configPath);
目标是让正式路径尽量只暴露完整版本。
这个思路在同一文件系统内通常最有效,因为同一文件系统内的 rename 往往更接近“原子替换”:
- 要么读到旧文件
- 要么读到新文件
- 不容易读到一个写到一半的正式文件
从系统调用看,差别主要在最后一步是不是一次命名空间切换。
Linux 上,同一文件系统内最终走的是 rename(2)。
这一步更新的是目录项到 inode 的绑定关系,不是重新拷贝文件内容。对并发读取方来说,目标路径通常只会解析到两个结果:
- 旧 inode
- 新 inode
不会先看到“目标文件没了”,再看到“目标文件回来了”。
Windows 不能简单理解成“没有原子 rename”。更准确的说法是:底层也有 rename / replace 原语,但很多应用层覆盖保存不是一次系统调用完成,而是拆成多步:
- 先处理旧的目标文件
- 再把临时文件挂到目标名字上
如果读者刚好落在这两步之间,看到的就不是“半截内容”,而是目标路径本身短暂失效。表现出来就是:
File.Exists偶尔返回false- 读取方瞬时找不到文件
临时文件和正式文件最好在同一个文件系统里,最好就在同一个目录下。
因为一旦跨文件系统,最后那一步通常就不再是单次命名空间切换,而会退化成复制、刷盘、删除,目标路径重新暴露出中间态。
所以更准确的说法不是“绝对原子”,而是:
尽量减少中间态暴露,让读者尽量只看到旧值或新值。
最后留下来的几个原则
- 配置文件不是普通变量,而是共享资源
- 写入口越集中越好
- 直接覆盖写入容易暴露中间态
- 稳定性有时比“绝对最新”更重要
结语
回头看,问题不在“文件有没有写成功”,而在“别的进程在这个过程中看到了什么”。
从共享写,到单点写;从直接覆盖,到读侧兜底;再到原子替换,解决的都是同一个问题:
如何让一个脆弱的共享文件,在多进程环境里尽量表现得像一个稳定的配置源。