第一阶段:共享一套读写逻辑,谁都可以写

一开始,多个进程共用一套读写逻辑:

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 异常直接放大成系统故障。

第五阶段:开始探索原子化写入

最后的问题是:

只要还是直接覆盖目标文件,读者就仍然可能看到中间态。

更稳妥的思路是:

  1. 先把完整内容写到临时文件
  2. 确认临时文件写完
  3. 最后一步再用临时文件替换正式文件

重点不是“临时文件”本身,而是把两件事分开:

  • 生成完整内容
  • 切换正式版本

直接覆盖正式文件,通常不是原子写入。
因为这类写法本质上都是先创建或截断目标文件,再把内容一点点写进去。别的进程就有机会看到:

  • 文件已经存在,但大小还是 0 KB
  • 文件已经存在,但内容只写了一部分
var tempPath = configPath + ".tmp";

WriteAllContent(tempPath, content);
Replace(tempPath, configPath);

目标是让正式路径尽量只暴露完整版本。
这个思路在同一文件系统内通常最有效,因为同一文件系统内的 rename 往往更接近“原子替换”:

  • 要么读到旧文件
  • 要么读到新文件
  • 不容易读到一个写到一半的正式文件

从系统调用看,差别主要在最后一步是不是一次命名空间切换。

Linux 上,同一文件系统内最终走的是 rename(2)
这一步更新的是目录项到 inode 的绑定关系,不是重新拷贝文件内容。对并发读取方来说,目标路径通常只会解析到两个结果:

  • 旧 inode
  • 新 inode

不会先看到“目标文件没了”,再看到“目标文件回来了”。

Windows 不能简单理解成“没有原子 rename”。更准确的说法是:底层也有 rename / replace 原语,但很多应用层覆盖保存不是一次系统调用完成,而是拆成多步:

  1. 先处理旧的目标文件
  2. 再把临时文件挂到目标名字上

如果读者刚好落在这两步之间,看到的就不是“半截内容”,而是目标路径本身短暂失效。表现出来就是:

  • File.Exists 偶尔返回 false
  • 读取方瞬时找不到文件

临时文件和正式文件最好在同一个文件系统里,最好就在同一个目录下。
因为一旦跨文件系统,最后那一步通常就不再是单次命名空间切换,而会退化成复制、刷盘、删除,目标路径重新暴露出中间态。

所以更准确的说法不是“绝对原子”,而是:

尽量减少中间态暴露,让读者尽量只看到旧值或新值。

最后留下来的几个原则

  • 配置文件不是普通变量,而是共享资源
  • 写入口越集中越好
  • 直接覆盖写入容易暴露中间态
  • 稳定性有时比“绝对最新”更重要

结语

回头看,问题不在“文件有没有写成功”,而在“别的进程在这个过程中看到了什么”。

从共享写,到单点写;从直接覆盖,到读侧兜底;再到原子替换,解决的都是同一个问题:

如何让一个脆弱的共享文件,在多进程环境里尽量表现得像一个稳定的配置源。