← 返回题目列表

Python 并发中的竞态条件是什么?哪些操作是原子的,哪些不是?

高频 困难 第 13 / 27 题 更新于 2026/07/27
竞态条件原子性线程安全

简化版

竞态条件是程序结果依赖线程或进程的执行时序,时序一变结果就可能错。原子操作是不可被中间打断的操作,但 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()

这些代码在单线程里没问题,多线程下如果没有同步,就可能出错。

五、如何避免竞态条件

常见方法:

  1. 用锁保护临界区;
  2. queue.Queue 把共享修改变成单消费者处理;
  3. 使用不可变对象,避免原地修改;
  4. 用数据库事务保护跨步骤一致性;
  5. 减少共享状态,把任务拆成独立输入输出;
  6. 对外部资源使用幂等和重试设计。

例如缓存初始化可以这样做:

with lock:
    value = cache.get(key)
    if value is None:
        value = load_data(key)
        cache[key] = value

如果 load_data 很慢,可以进一步用双重检查或更细粒度锁优化,但正确性永远优先于微小性能。

六、不要依赖实现细节

某些内置操作在 CPython 的某些版本里表现得像原子操作,但这不等于语言层面承诺,也不代表多个操作组合后仍然安全。

写并发代码时,不要把正确性建立在“我猜这个操作不会被切换”上。只要它表达的是业务上的复合状态,就应该显式同步。

七、常见误区与追问

场景看似安全的原因实际风险
counter += 1只有一行代码读、加、写不是一个不可分割动作
先检查再插入逻辑很直观检查后状态可能被其他线程改变
依赖 GILPython 同时只执行一个字节码线程业务不变量跨多步仍会被打断
复合容器操作单个方法可能暂时安全多个操作组合不具备原子性
  • 误区:Python 有 GIL,所以不会有竞态条件。 GIL 保护解释器内部状态,不保证你的业务读改写序列整体原子。
  • 误区:一行 Python 代码就是原子操作。 一行源码可能对应多个字节码步骤,线程切换可能发生在中间。
  • 误区:偶尔测不出来就说明没有竞态。 竞态依赖时序,低并发或本地机器没复现不代表线上安全。
  • 追问:怎么判断一段代码是否需要锁? 看它是否访问共享可变状态,且是否有跨多步必须同时成立的不变量。
  • 追问:除了加锁还有什么办法? 可使用不可变数据、消息队列、线程局部变量、原子数据库操作或把状态收敛到单线程处理。
  • 追问:锁加得越大越安全吗? 锁过大会降低并发并增加死锁风险;应只保护必要临界区,同时保持锁顺序清晰。

易错点:竞态不是“会不会同时执行同一行”,而是“多个线程能不能打断同一个业务不变量的维护过程”。

八、加强记忆

竞态条件的本质是结果依赖不可控时序。判断并发安全不要看“是不是一行代码”,要看业务语义是不是一个不可分割的整体。GIL 不保护业务不变量,共享可变状态要么加锁,要么用队列、事务、不可变设计把竞争消掉。