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 里怎么保证原子性?
我推荐几种方式:
- 使用 threading.Lock:最传统,也最可靠
- 使用 queue.Queue:内部已经做了线程安全
- 使用 multiprocessing 的原子类型:比如 Value、Array
- 使用第三方库:比如 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 的存在让锁的开销没那么大
- 多线程共享数据时,一定要考虑原子性
最后说一句。竞态条件这东西,测试很难覆盖到。你跑一万次可能都正常,但线上环境一上量就出问题。所以写并发代码时,脑子里要时刻绷着这根弦。
这张图把咱们今天讲的内容串起来了。数据竞争和竞态条件是问题,原子性是解决方案。而 Python 里,我们主要靠锁和队列来保证原子性。
记住一句话:并发编程的难点不在于写代码,而在于想清楚所有可能的执行路径。
避坑指南:
我曾经在一个高并发服务里,用 if key in dict: dict[key] += 1 这种模式。结果两个线程同时检查到 key 不存在,都去初始化,后一个把前一个覆盖了。这就是典型的「检查-执行」竞态条件。后来改成 dict.setdefault(key, 0) 才解决。
好了,这一章的内容就到这里。竞态条件和原子性是个大话题,咱们后面还会反复提到。你先把这些基础概念吃透,后面讲锁、讲并发模型时才能跟得上。