类变量在多线程下的修改是否需要加锁(class 行为)

类变量被多个线程同时写入时不一定崩溃但一定存在竞态风险:自增等复合操作会丢失更新,赋值虽不崩溃却可能导致逻辑错误;必须用类级别Lock保护临界区,或用threading.local()实现线程隔离。

类变量被多个线程同时写入时一定会出问题

Python 中的类变量(如 MyClass.

counter = 0)本质上是存储在类的 __dict__ 中的共享对象。一旦多个线程同时执行类似 MyClass.counter += 1 这样的操作,就不是原子行为——它实际拆成「读取、计算、写回」三步,中间可能被其他线程打断,导致丢失更新。哪怕只是赋值 MyClass.flag = True,如果多个线程写不同值,最终结果也取决于谁最后写,但至少不会引发崩溃;而自增/自减/列表 .append() 等复合操作,大概率产生数据错误。

threading.Lock 是最直接可控的保护方式

对类变量的读写临界区加锁,是最清晰、副作用最小的做法。注意锁本身也得是类级别的共享对象,不能每次新建:

import threading

class Counter: count = 0 _lock = threading.Lock() # 类变量,所有实例和线程共用

@classmethod
def increment(cls):
    with cls._lock:
        cls.count += 1

  • 不要把 _lock 放在方法里创建,否则锁无效
  • 避免在锁内做耗时操作(如网络请求、文件读写),否则严重拖慢并发性能
  • 如果只读不写,通常不需要锁;但若“读”依赖于多个类变量的一致状态(比如 cls.min_valcls.max_val 需同步读),仍需加锁

threading.local() 是另一种思路,但解决的是不同问题

threading.local() 创建的是线程局部副本,适用于「每个线程需要自己独立的类变量视图」的场景,比如请求上下文、数据库连接句柄等。它不保护共享修改,而是绕过共享:

import threading

class RequestContext: _local = threading.local()

@classmethod
def set_user_id(cls, uid):
    cls._local.user_id = uid  # 每个线程写自己的副本

@classmethod
def get_user_id(cls):
    return getattr(cls._local, 'user_id', None)

  • 这不能替代锁来保护真正的共享计数器或开关标志
  • 局部变量不会自动继承,子线程需手动设置
  • 容易误以为“用了 local 就线程安全了”,其实只是隔离了访问路径

CPython 的 GIL 不足以保护类变量复合操作

GIL 只保证单个字节码指令的原子性,而 +=.append()del cls.cache[key] 等都会编译成多条字节码。你可以用 dis.dis(lambda: MyClass.counter += 1) 看到 LOAD_ATTRINPLACE_ADDSTORE_ATTR 三步。GIL 在每条字节码间都可能释放并切换线程,所以必须靠显式锁来围住整个逻辑块。

真正容易被忽略的是:即使你只用类变量做标记(如 is_running = False),若多个线程同时设为 TrueFalse,虽不报错,但语义上可能已混乱——比如本该只允许一个激活实例,却因竞态导致多个线程都通过了 if not cls.is_running: 判断。这种逻辑漏洞比数值错误更难排查。