4. 竞态条件与原子性:数据竞争与竞态条件、原子操作与CAS、Python中的原子性保证

好,咱们今天聊一个老生常谈但又特别容易翻车的话题——竞态条件与原子性。

说实话,我见过太多线上事故,最后定位下来都是竞态条件搞的鬼。你想想看,代码在单线程下跑得稳稳当当,一上多线程就各种诡异。嗯,这背后其实就是数据竞争和原子性没处理好。

4.1 数据竞争与竞态条件

先理清两个概念。很多人把它们混为一谈,其实不一样。

数据竞争,说白了就是多个线程同时访问同一块内存,而且至少有一个在写。没有同步机制保护,结果不可预测。

竞态条件,指的是程序的行为依赖于事件发生的时序。就算没有数据竞争,也可能出现竞态条件。

举个例子你就明白了:

# 数据竞争的典型场景
counter = 0

def increment():
    global counter
    # 这里不是原子操作!
    # 实际上是:读取counter -> 加1 -> 写回counter
    counter += 1

# 两个线程同时执行 increment()
# 可能两个线程都读到 counter=0,都加1,都写回1
# 结果 counter=1,但我们期望是2

我在项目中遇到过类似的问题。一个高并发下的计数器服务,上线后数据对不上。查了半天,就是 counter += 1 这个看似简单的操作,在底层被拆成了三步。两个线程交错执行,数据就丢了。

核心要点:

  • 数据竞争是「共享内存 + 无同步」的问题
  • 竞态条件是「时序依赖」的问题
  • 两者经常同时出现,但本质不同

4.2 原子操作与CAS

那怎么解决?最直接的办法就是保证操作的原子性。

原子操作,就是不可分割的操作。要么全部执行完,要么完全不执行。中间不会被其他线程打断。

但问题来了——很多操作在CPU层面就不是原子的。比如上面那个 counter += 1,在x86架构下对应的是 LOAD、ADD、STORE 三条指令。

这时候就需要 CAS(Compare-And-Swap) 登场了。

CAS 是硬件层面支持的原子操作。它的逻辑很简单:

// 伪代码:CAS 的语义
bool CAS(内存地址, 期望值, 新值) {
    if (当前值 == 期望值) {
        当前值 = 新值;
        return true;
    }
    return false;
}

你想想看,CAS 把「比较」和「交换」合并成了一个不可分割的步骤。这就避免了「读-改-写」三步操作中间的竞态窗口。

我习惯把 CAS 叫做「乐观锁」。为什么?因为它假设冲突很少发生。先尝试更新,失败了再重试。不像传统锁那样直接阻塞等待。

个人经验:

CAS 在高并发下有个问题叫 ABA 问题。就是值从 A 变成 B 又变回 A,CAS 检查时发现还是 A,就认为没变过。但实际上中间被改过。解决办法是加版本号或时间戳。

4.3 Python中的原子性保证

好,咱们回到 Python 的世界。Python 的原子性保证,其实比 C/C++ 要宽松一些。

为什么?因为 Python 有 GIL(全局解释器锁)。GIL 保证同一时刻只有一个线程在执行 Python 字节码。所以很多操作在 Python 层面看起来是原子的。

但注意——这只是「看起来」。

我举个例子:

import threading

# 看似原子的操作
shared_list = []

def append_item():
    # 这个操作在 Python 层面是原子的吗?
    shared_list.append(1)

# 答案:append() 在 CPython 中是原子的
# 因为 list.append 是一个完整的字节码指令

但下面这个就不行了:

shared_dict = {}

def update_dict():
    # 这个不是原子的!
    # 它包含:计算哈希、定位桶、插入键值对
    shared_dict['key'] = 'value'

# 实际上,dict 的 __setitem__ 在 CPython 中也是原子的
# 但更复杂的操作就不一定了

嗯,这里要注意。Python 的原子性保证其实依赖于具体实现。CPython 因为有 GIL,很多操作是原子的。但 Jython、IronPython 这些没有 GIL 的实现,行为就完全不同了。

我曾经踩过一个坑。用 threading 模块做并发计数,以为 counter += 1 是原子的。结果在 PyPy 上跑,数据就乱了。因为 PyPy 的 JIT 编译器会优化字节码,导致操作被拆分。

避坑指南:

我曾经以为 GIL 能保证一切,结果在 PyPy 上翻车了。记住:

  • CPython 中,单个字节码指令是原子的
  • 但 +=、-= 这类操作不是单条字节码
  • 跨解释器实现的行为可能不同
  • 别依赖 GIL 来保证原子性,用锁才是正道

那 Python 里怎么保证原子性?

我推荐几种方式:

  1. 使用 threading.Lock:最传统,也最可靠
  2. 使用 queue.Queue:内部已经做了线程安全
  3. 使用 multiprocessing 的原子类型:比如 Value、Array
  4. 使用第三方库:比如 atomicwrites 用于文件写入

看个例子:

import threading

class SafeCounter:
    def __init__(self):
        self.lock = threading.Lock()
        self.value = 0
    
    def increment(self):
        with self.lock:
            # 在锁保护下,这就是原子的
            self.value += 1
    
    def get(self):
        with self.lock:
            return self.value

# 使用
counter = SafeCounter()

def worker():
    for _ in range(1000):
        counter.increment()

threads = [threading.Thread(target=worker) for _ in range(10)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(counter.get())  # 稳定输出 10000

你想想看,加了锁之后,虽然性能会下降一点,但数据一致性得到了保证。在高并发场景下,数据正确比性能更重要。

总结一下我的经验:

  • 别迷信 GIL,它只是 CPython 的实现细节
  • 能用锁就用锁,简单可靠
  • CAS 在 Python 中不常用,因为 GIL 的存在让锁的开销没那么大
  • 多线程共享数据时,一定要考虑原子性

最后说一句。竞态条件这东西,测试很难覆盖到。你跑一万次可能都正常,但线上环境一上量就出问题。所以写并发代码时,脑子里要时刻绷着这根弦。

竞态条件与原子性知识体系 数据竞争 竞态条件 原子性 共享内存无同步 多线程同时写 时序依赖问题 检查-执行非原子 CAS操作 锁保护 Python 解决方案 threading.Lock queue.Queue 原子类型

这张图把咱们今天讲的内容串起来了。数据竞争和竞态条件是问题,原子性是解决方案。而 Python 里,我们主要靠锁和队列来保证原子性。

记住一句话:并发编程的难点不在于写代码,而在于想清楚所有可能的执行路径。

避坑指南:

我曾经在一个高并发服务里,用 if key in dict: dict[key] += 1 这种模式。结果两个线程同时检查到 key 不存在,都去初始化,后一个把前一个覆盖了。这就是典型的「检查-执行」竞态条件。后来改成 dict.setdefault(key, 0) 才解决。

好了,这一章的内容就到这里。竞态条件和原子性是个大话题,咱们后面还会反复提到。你先把这些基础概念吃透,后面讲锁、讲并发模型时才能跟得上。

可可精品资料库