27、面向对象与并发:线程安全的对象设计,锁与条件变量,生产者-消费者模式

并发编程,说白了就是让程序同时干多件事。但对象一旦被多个线程同时访问,就容易出乱子。我早年做后台服务时,就吃过这个亏——一个共享的计数器,两个线程同时加一,结果丢数据了。嗯,从那以后,我对线程安全就特别上心。

一、线程安全的对象设计原则

一个对象要线程安全,核心就一句话:多个线程同时访问它,不需要外部加锁,行为也是正确的。怎么做到?我总结了几条经验。

1. 封装可变状态

把可能被并发修改的成员变量藏起来,只通过同步方法暴露。你想想看,如果所有字段都是 public 的,别人随便改,你怎么保证安全?

核心原则:不要暴露内部可变状态。能用 private 就别用 public。

2. 使用不可变对象

如果一个对象创建后状态再也不变,那它天然就是线程安全的。我个人习惯,能设计成不可变的类,绝不设计成可变的。比如 String、Integer 这些,随便多少个线程读,都不会出问题。

3. 加锁保护临界区

如果必须修改共享状态,那就加锁。锁的本质,就是让同一时刻只有一个线程能进入临界区。

二、锁与条件变量

锁是并发编程的基础工具。Python 里最常用的是 threading.Lockthreading.RLock。我刚开始用的时候,经常忘记释放锁,后来养成了用 with 语句的习惯,再也没漏过。

基本锁的使用

import threading

lock = threading.Lock()
shared_counter = 0

def increment():
    global shared_counter
    with lock:
        shared_counter += 1

# 启动10个线程
threads = [threading.Thread(target=increment) for _ in range(10)]
for t in threads:
    t.start()
for t in threads:
    t.join()

print(shared_counter)  # 结果一定是10

你看,用 with lock 包裹,进入时自动获取锁,退出时自动释放。省心。

条件变量:让线程学会等待

有时候,一个线程需要等某个条件成立才能继续。比如生产者生产了数据,消费者才能消费。这时候就需要条件变量 threading.Condition

条件变量内部其实有一把锁。调用 wait() 会释放锁并阻塞,直到被 notify()notify_all() 唤醒。唤醒后,它会重新获取锁再继续执行。

避坑指南:我曾经在 wait() 之前忘了检查条件,结果线程被唤醒后条件还是不满足,导致逻辑错误。正确的做法是:wait() 一定要放在 while 循环里,醒来后重新检查条件。

三、生产者-消费者模式

这是并发编程里最经典的模型。生产者负责生产数据,消费者负责处理数据。中间用队列做缓冲,解耦两者。

我当年做一个日志收集系统,就是用这个模式。多个生产者线程写日志,一个消费者线程批量写入磁盘。效率高,还不会丢数据。

用队列实现

Python 的 queue.Queue 本身就是线程安全的,内部已经处理好了锁和条件变量。直接用就行。

import threading
import queue
import time
import random

# 共享队列,最大容量5
q = queue.Queue(maxsize=5)

def producer(name):
    for i in range(10):
        item = f"{name}-{i}"
        q.put(item)  # 如果队列满了,会自动阻塞
        print(f"生产者 {name} 生产了 {item}")
        time.sleep(random.random())

def consumer(name):
    while True:
        item = q.get()  # 如果队列空了,会自动阻塞
        print(f"消费者 {name} 消费了 {item}")
        q.task_done()   # 通知队列任务完成
        time.sleep(random.random() * 2)

# 启动2个生产者,3个消费者
for i in range(2):
    threading.Thread(target=producer, args=(f"P{i}",), daemon=True).start()
for i in range(3):
    threading.Thread(target=consumer, args=(f"C{i}",), daemon=True).start()

# 等待所有生产完成
time.sleep(5)
print("主线程结束")

你看,代码很简洁。生产者只管 put,消费者只管 get,队列自己处理同步。这就是面向对象封装的好处——你不用关心锁怎么加,条件变量怎么用,队列内部都帮你搞定了。

自己实现一个简易版

为了让你理解原理,我写一个用条件变量实现的简易队列。嗯,这里要注意,实际开发直接用 queue.Queue 就好,但理解底层有助于你排查问题。

import threading

class SimpleQueue:
    def __init__(self, maxsize=0):
        self.maxsize = maxsize
        self.items = []
        self.mutex = threading.Lock()
        self.not_full = threading.Condition(self.mutex)
        self.not_empty = threading.Condition(self.mutex)

    def put(self, item):
        with self.not_full:
            while self.maxsize > 0 and len(self.items) >= self.maxsize:
                self.not_full.wait()  # 队列满了,等待
            self.items.append(item)
            self.not_empty.notify()   # 通知消费者

    def get(self):
        with self.not_empty:
            while len(self.items) == 0:
                self.not_empty.wait()  # 队列空了,等待
            item = self.items.pop(0)
            self.not_full.notify()     # 通知生产者
            return item

核心逻辑就两个:满了等,空了等。生产者发现队列满了,就在 not_full 上等;消费者发现队列空了,就在 not_empty 上等。生产一个,通知消费者;消费一个,通知生产者。

注意:wait() 必须在持有锁的情况下调用。因为条件变量内部需要释放锁并重新获取。如果你在 wait() 之前没有获取锁,会抛出 RuntimeError。

四、知识体系图

下面这张图总结了本章的核心知识点。你可以看到,线程安全设计、锁与条件变量、生产者-消费者模式,这三者层层递进。

面向对象与并发:知识体系 线程安全对象设计 锁与条件变量 生产者-消费者模式 封装可变状态 不可变对象 加锁保护 Lock / RLock Condition wait / notify queue.Queue put / get 解耦与缓冲 核心思想:封装可变状态 + 同步机制 = 线程安全 用锁保护临界区,用条件变量协调等待,用队列解耦生产与消费

五、避坑总结

最后,我把自己踩过的坑列出来,你写代码时多留个心眼。

常见问题 原因 解决方案
忘记释放锁 手动 lock() 后没有 unlock() 用 with 语句自动管理锁
条件变量虚假唤醒 wait() 返回后条件可能不成立 把 wait() 放在 while 循环里
死锁 多个锁获取顺序不一致 固定锁的获取顺序,或使用 RLock
队列无界导致内存溢出 生产者速度远快于消费者 设置 maxsize 限制队列长度

我的习惯:写并发代码时,先画清楚哪些是共享状态,哪些是临界区。然后问自己:这个对象被多个线程同时访问,会不会出问题?如果会,就加锁或用队列。想清楚了再动手,比写完了再调试省心得多。

好了,这一章就到这里。线程安全的设计思想,说白了就是「管好你的共享状态」。锁和条件变量是工具,生产者-消费者是经典模式。把这些用熟了,并发编程就没那么可怕了。


公众号:蓝海资料掘金营,微信deep3321