2. 竞态条件与临界区:定义、示例、在构建脚本中的典型表现

好,咱们进入第二个核心概念。竞态条件和临界区,这两个词你肯定听过。但说实话,很多人在工程构建中踩了坑,却不知道自己到底错在哪。

我刚开始带团队的时候,就遇到过这么一件事。一个并行构建脚本,跑十次有八次能过,剩下两次莫名其妙报错。查了半天,发现是多个任务同时往同一个临时目录写文件。嗯,这就是典型的竞态条件。

2.1 什么是临界区?

临界区,说白了就是一段代码,一次只能让一个线程进去执行。为什么?因为这段代码访问了共享资源——比如一个文件、一块内存、一个全局变量。

你想想看,如果两个线程同时往同一个日志文件里写内容,会发生什么?互相覆盖,内容乱掉。这就是临界区没保护好的后果。

临界区的三个特征:

  • 访问共享资源(文件、变量、数据库连接等)
  • 操作不是原子性的(读-改-写三步)
  • 需要互斥访问(一次只能一个线程)

我个人习惯,判断一段代码是不是临界区,就问自己一个问题:如果两个线程同时执行到这里,会不会出问题?会,那就是临界区。

2.2 什么是竞态条件?

竞态条件,就是程序的行为依赖于线程执行的相对时序。说白了,谁先谁后,结果不一样。

举个例子:

// 伪代码:两个线程同时执行
if (文件不存在) {
    创建文件
    写入数据
}

线程A检查文件不存在,然后准备创建。就在这个瞬间,线程B也检查文件不存在,也准备创建。结果呢?两个线程都去创建文件,后创建的那个可能覆盖前面的内容,或者直接报错。

为什么会这样?因为「检查-创建」不是原子操作。从检查到创建之间,有一个时间窗口。这个窗口就是竞态条件发生的温床。

我曾经踩过的坑:

有一次写构建脚本,用了一个全局计数器来统计编译了多少个源文件。结果每次跑出来的数字都不一样。查了半天,发现是 counter++ 这个操作——读取、加一、写回——三步之间,其他线程插进来了。这就是竞态条件。

2.3 竞态条件在构建脚本中的典型表现

工程构建脚本里,竞态条件特别容易出现在以下几个场景:

场景 典型问题 后果
并行编译 多个任务写同一个输出文件 文件内容损坏,编译失败
资源清理 一个线程删除临时目录,另一个还在用 文件找不到,构建中断
缓存更新 多个线程同时更新缓存索引 缓存不一致,增量构建失效
环境变量 一个任务修改了PATH,影响其他任务 工具链找不到,构建行为异常

我印象最深的一个案例,是某个大型C++项目的构建脚本。用了Make的并行模式(-j8),结果经常出现莫名其妙的链接错误。查到最后,发现是多个编译任务同时往同一个.o文件写——因为源文件里#include了同一个头文件,导致多个任务编译了同一个源文件。

一个小技巧:

在构建脚本里,我习惯给每个临时文件加上进程ID或线程ID作为后缀。比如 temp_$$.log。这样就算多个任务同时写,也不会互相干扰。当然,这只是治标。治本还是要用锁或者队列。

2.4 知识体系:竞态条件与临界区的关系

下面这张图,是我自己总结的。它把竞态条件、临界区、共享资源之间的关系讲清楚了。

竞态条件与临界区知识体系 共享资源 临界区 访问共享资源的代码段 竞态条件 结果依赖于线程执行时序 构建结果不确定 · 偶发失败 · 难以复现 互斥锁 原子操作 任务队列 并行编译 资源清理 缓存更新 环境变量

从这张图你能看到,问题的根源是共享资源。共享资源催生了临界区。临界区没保护好,就出现了竞态条件。竞态条件最终导致构建结果不确定。

解决思路也很清楚:要么用互斥锁保护临界区,要么用原子操作消除时间窗口,要么用任务队列把并行改成串行。

2.5 一个真实的构建脚本例子

最后,我分享一个真实的例子。这是某个构建脚本里的片段,用来生成代码文档:

# 有问题的版本
for project in $PROJECTS; do
    (
        generate_docs $project > docs/$project.html
    ) &
done
wait

这段代码并行生成多个项目的文档。看起来没问题对吧?但问题是,generate_docs 函数内部会更新一个全局的索引文件 docs/index.html。多个进程同时写这个文件,索引就乱了。

修复后的版本:

# 修复版本:用锁保护临界区
for project in $PROJECTS; do
    (
        generate_docs $project > docs/$project.html
        flock -x 200  # 获取文件锁
        update_index $project >> docs/index.html
        flock -u 200  # 释放锁
    ) 200>docs/index.lock &
done
wait

这里用了 flock 文件锁,确保同一时间只有一个进程在更新索引文件。临界区保护好了,竞态条件自然就消失了。

总结一下:

  • 临界区是访问共享资源的代码段,需要互斥保护
  • 竞态条件是临界区没保护好的结果,表现为结果不确定
  • 构建脚本中,文件操作、环境变量、缓存更新是重灾区
  • 解决方案:锁、原子操作、任务队列,选一个合适的

嗯,这一节就到这里。记住一句话:共享资源是万恶之源,临界区是事故高发区,竞态条件是最终表现。 下次写构建脚本的时候,多想想这三个东西,能帮你省不少调试时间。

可可精品资料库