Python 并发中的竞态条件是什么?哪些操作是原子的,哪些不是?
简化版
竞态条件是程序结果依赖线程或进程的执行时序,时序一变结果就可能错。原子操作是不可被中间打断的操作,但 Python 里不要依赖“看起来一行代码”判断原子性;复合读改写、先检查再修改、多个对象状态更新都应该显式同步。
详细版
典型竞态条件:
counter = 0
def incr():
global counter
for _ in range(100000):
counter += 1
counter += 1 是复合操作,可能发生丢失更新。
更隐蔽的例子:
if key not in cache:
cache[key] = load_data(key)
多个线程可能同时发现 key 不存在,然后重复加载并覆盖。
应该使用同步:
with lock:
if key not in cache:
cache[key] = load_data(key)
但更好的做法可能是缩小锁范围、使用线程安全队列、用不可变数据、或者把并发写入集中到单线程。
面试重点:
- GIL 不等于没有竞态条件;
- 一行代码不等于原子操作;
- 原子性要看语义边界;
- 共享可变状态要同步;
- 不要依赖 CPython 当前实现细节写并发正确性。
完整版教学
一、竞态条件为什么难排查
竞态条件最讨厌的地方在于:它不是每次都复现。程序可能跑 100 次都对,第 101 次错一次。
原因是线程调度、I/O 时机、CPU 负载、系统状态都会影响执行顺序。一旦结果依赖这些不可控顺序,程序就不稳定。
例如两个线程同时给余额扣款,如果没有同步,可能出现多扣、少扣、覆盖等问题。
二、原子性不是看代码行数
很多人以为“一行 Python 代码就是原子的”,这是危险误解。
counter += 1
它看起来一行,但语义上是读取、加一、写回。线程切换如果发生在这些步骤之间,就可能丢失更新。
同样:
if not initialized:
init()
initialized = True
这是典型的检查再执行,也不是原子的。
三、GIL 只能保护解释器内部,不保护业务不变量
GIL 的主要目标是保护解释器内部状态,让 CPython 内存管理更简单。它不是业务锁。
业务不变量是你自己定义的,比如:
- 库存不能小于 0;
- 余额扣减和流水写入要一致;
- cache 中某个 key 只能初始化一次;
- 两个字段必须同时更新。
这些不变量需要你用锁、事务、队列或其他机制保护。
四、哪些场景特别容易产生竞态
常见高危模式:
# 读-改-写
counter += 1
# 检查-再执行
if key not in cache:
cache[key] = value
# 多对象一致性
account_a.balance -= amount
account_b.balance += amount
# 先读长度再操作
if len(items) > 0:
item = items.pop()
这些代码在单线程里没问题,多线程下如果没有同步,就可能出错。
五、如何避免竞态条件
常见方法:
- 用锁保护临界区;
- 用
queue.Queue把共享修改变成单消费者处理; - 使用不可变对象,避免原地修改;
- 用数据库事务保护跨步骤一致性;
- 减少共享状态,把任务拆成独立输入输出;
- 对外部资源使用幂等和重试设计。
例如缓存初始化可以这样做:
with lock:
value = cache.get(key)
if value is None:
value = load_data(key)
cache[key] = value
如果 load_data 很慢,可以进一步用双重检查或更细粒度锁优化,但正确性永远优先于微小性能。
六、不要依赖实现细节
某些内置操作在 CPython 的某些版本里表现得像原子操作,但这不等于语言层面承诺,也不代表多个操作组合后仍然安全。
写并发代码时,不要把正确性建立在“我猜这个操作不会被切换”上。只要它表达的是业务上的复合状态,就应该显式同步。
七、常见误区与追问
| 场景 | 看似安全的原因 | 实际风险 |
|---|---|---|
counter += 1 | 只有一行代码 | 读、加、写不是一个不可分割动作 |
| 先检查再插入 | 逻辑很直观 | 检查后状态可能被其他线程改变 |
| 依赖 GIL | Python 同时只执行一个字节码线程 | 业务不变量跨多步仍会被打断 |
| 复合容器操作 | 单个方法可能暂时安全 | 多个操作组合不具备原子性 |
- 误区:Python 有 GIL,所以不会有竞态条件。 GIL 保护解释器内部状态,不保证你的业务读改写序列整体原子。
- 误区:一行 Python 代码就是原子操作。 一行源码可能对应多个字节码步骤,线程切换可能发生在中间。
- 误区:偶尔测不出来就说明没有竞态。 竞态依赖时序,低并发或本地机器没复现不代表线上安全。
- 追问:怎么判断一段代码是否需要锁? 看它是否访问共享可变状态,且是否有跨多步必须同时成立的不变量。
- 追问:除了加锁还有什么办法? 可使用不可变数据、消息队列、线程局部变量、原子数据库操作或把状态收敛到单线程处理。
- 追问:锁加得越大越安全吗? 锁过大会降低并发并增加死锁风险;应只保护必要临界区,同时保持锁顺序清晰。
易错点:竞态不是“会不会同时执行同一行”,而是“多个线程能不能打断同一个业务不变量的维护过程”。
八、加强记忆
竞态条件的本质是结果依赖不可控时序。判断并发安全不要看“是不是一行代码”,要看业务语义是不是一个不可分割的整体。GIL 不保护业务不变量,共享可变状态要么加锁,要么用队列、事务、不可变设计把竞争消掉。