3. 原子操作:什么是原子操作?在构建中如何利用原子操作保证文件写入安全。

聊到并发编程,有个概念你肯定绕不开——原子操作。

说白了,原子操作就是「不可分割」的操作。要么全部执行完,要么一点都没执行。中间不会被打断,别的线程也看不到中间状态。

我刚开始接触并发时,总觉得这玩意儿离我很远。直到有一次,我在构建系统里遇到了一个诡异的bug:两个构建任务同时写同一个日志文件,结果文件内容全乱了,一半是A任务的数据,一半是B任务的数据,中间还夹杂着乱码。嗯,从那以后,我再也不敢小看原子操作了。

原子操作的核心特征

一个操作要称得上「原子」,得满足三个条件:

  • 不可分割:操作过程中不能被线程调度机制中断
  • 互斥执行:同一时刻只能有一个线程在执行
  • 可见性:操作结果对后续线程立即可见

你想想看,如果文件写入不是原子的,会发生什么?

两个线程同时写文件,A线程写了前半段,CPU切到B线程,B线程写了后半段,再切回A线程继续写。最后文件里是什么?一团糟。

核心观点:原子操作是构建线程安全系统的基石。没有原子性,锁、信号量这些高级机制都无从谈起。

硬件层面的原子操作

现代CPU都提供了硬件级别的原子指令。比如x86架构的CMPXCHG(比较并交换),ARM架构的LDREX/STREX(加载独占/存储独占)。

这些指令能保证:

  • 在单核CPU上,一条指令本身就是原子的
  • 在多核CPU上,通过总线锁或缓存锁来保证原子性

我记得有一次排查性能问题,发现团队里有人用volatile关键字来实现计数器。我一看就摇头——volatile只能保证可见性,不能保证原子性。正确的做法是用std::atomic或者Java里的AtomicInteger。

小提示:在C++里,std::atomic<int>的++操作会被编译成lock xadd指令,这才是真正的原子操作。别用volatile int来计数,那是给自己挖坑。

文件写入中的原子操作

好了,回到构建场景。构建系统里最常见的并发问题就是文件写入冲突。

我见过太多团队踩过这个坑:多个编译任务同时写同一个输出文件,结果文件损坏,构建失败。更坑的是,这种失败不是每次都出现,属于典型的「偶发bug」,排查起来特别痛苦。

怎么解决?我总结了三种方案:

方案一:使用临时文件 + 重命名

这是我最推荐的做法。核心思路是:

  1. 先写到一个临时文件(比如output.tmp)
  2. 写完后,用原子重命名操作替换目标文件

代码示例(C++风格):

// 不安全的写法
void writeFile(const std::string& path, const std::string& data) {
    std::ofstream file(path);
    file << data;  // 可能被中断!
}

// 安全的写法
void writeFileAtomic(const std::string& path, const std::string& data) {
    std::string tmpPath = path + ".tmp";
    {
        std::ofstream file(tmpPath);
        file << data;
    } // 文件关闭后再重命名
    std::rename(tmpPath.c_str(), path.c_str()); // 原子操作
}

为什么rename是原子的?因为文件系统层面,重命名操作就是原子的。要么新文件完全替换旧文件,要么什么都不变。不会出现「替换了一半」的情况。

注意:rename在同一个文件系统内是原子的。跨文件系统就不一定了。所以临时文件和目标文件最好放在同一个磁盘分区。

方案二:文件锁

如果必须直接写目标文件,那就用文件锁。

在Linux上可以用flock或fcntl,在Windows上可以用LockFileEx。

// Linux上的文件锁示例
int fd = open("output.txt", O_WRONLY | O_CREAT, 0644);
flock(fd, LOCK_EX);  // 加独占锁
// 安全写入...
flock(fd, LOCK_UN);  // 解锁
close(fd);

不过说实话,文件锁有个缺点:性能开销大。而且如果程序崩溃了没解锁,其他进程就得一直等着。我个人不太喜欢用文件锁,除非实在没办法。

方案三:写入到独立目录

在构建系统里,我更喜欢用这个方案:每个构建任务写自己的输出文件,最后再合并。

比如Gradle和Bazel就是这么干的。每个编译单元的输出都放在独立的目录里,互不干扰。最后链接阶段再统一处理。

这样做的好处是:

  • 完全避免了文件写入冲突
  • 天然支持并行构建
  • 增量构建也更容易实现

原子操作在构建中的实际应用

让我画张图,帮你理清思路:

构建系统中的原子写入策略 方案一:临时文件+重命名 1. 写入 .tmp 文件 2. 关闭文件句柄 3. rename() 原子替换 4. 删除临时文件 ★ 推荐做法 方案二:文件锁 1. flock(LOCK_EX) 2. 写入目标文件 3. flock(LOCK_UN) 4. 关闭文件 ⚠ 性能开销大 方案三:独立目录 1. 每个任务独立目录 2. 写入各自输出文件 3. 无冲突,无需同步 4. 最后统一合并 ★ 构建系统首选 方案对比 方案 原子性 性能 复杂度 推荐度 临时文件+重命名 高 高 中 ★★★★★ 文件锁 高 低 低 ★★★☆☆ 独立目录 高 高 中 ★★★★★

避坑指南

我曾经在一个大型C++项目里,看到有人用fwrite加fflush来实现「原子写入」。结果呢?线上环境偶尔会出现文件内容不完整的情况。

为什么?因为fflush只是把数据从用户态缓冲区刷到内核态缓冲区,并不保证数据已经落盘。如果这时候系统崩溃,数据就丢了。

正确的做法是:

  • 写入后调用fsync()或fdatasync(),确保数据落盘
  • 或者用O_SYNC标志打开文件,每次写入都同步

不过话说回来,在构建系统里,我们通常不需要这么极端的保证。构建失败了大不了重跑一次,又不是银行交易系统。所以临时文件+重命名方案,在绝大多数场景下都够用了。

我的建议:构建系统里优先用「独立目录」方案。如果做不到,就用「临时文件+重命名」。文件锁能不用就不用,除非你真的很清楚自己在做什么。

好了,关于原子操作和文件写入安全,就聊到这儿。记住一句话:并发编程的本质,就是管理好共享资源的访问顺序。原子操作给了我们一把好用的工具,但怎么用、用在哪,还得靠经验积累。

可可精品资料库