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」,排查起来特别痛苦。
怎么解决?我总结了三种方案:
方案一:使用临时文件 + 重命名
这是我最推荐的做法。核心思路是:
- 先写到一个临时文件(比如
output.tmp) - 写完后,用原子重命名操作替换目标文件
代码示例(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就是这么干的。每个编译单元的输出都放在独立的目录里,互不干扰。最后链接阶段再统一处理。
这样做的好处是:
- 完全避免了文件写入冲突
- 天然支持并行构建
- 增量构建也更容易实现
原子操作在构建中的实际应用
让我画张图,帮你理清思路:
避坑指南
我曾经在一个大型C++项目里,看到有人用fwrite加fflush来实现「原子写入」。结果呢?线上环境偶尔会出现文件内容不完整的情况。
为什么?因为fflush只是把数据从用户态缓冲区刷到内核态缓冲区,并不保证数据已经落盘。如果这时候系统崩溃,数据就丢了。
正确的做法是:
- 写入后调用
fsync()或fdatasync(),确保数据落盘 - 或者用
O_SYNC标志打开文件,每次写入都同步
不过话说回来,在构建系统里,我们通常不需要这么极端的保证。构建失败了大不了重跑一次,又不是银行交易系统。所以临时文件+重命名方案,在绝大多数场景下都够用了。
我的建议:构建系统里优先用「独立目录」方案。如果做不到,就用「临时文件+重命名」。文件锁能不用就不用,除非你真的很清楚自己在做什么。
好了,关于原子操作和文件写入安全,就聊到这儿。记住一句话:并发编程的本质,就是管理好共享资源的访问顺序。原子操作给了我们一把好用的工具,但怎么用、用在哪,还得靠经验积累。
可可精品资料库