多线程环境下的观察者:线程安全的事件分发、异步观察者模式

好,咱们今天聊点硬核的。观察者模式本身不难,但一旦放到多线程环境里,事情就变得有意思了。我在好几个高并发项目里都踩过这个坑——明明观察者模式写得挺漂亮,一上生产环境就出各种诡异问题。说白了,就是线程安全没处理好。

传统观察者模式的线程安全问题

先想想看,标准的观察者模式长什么样?一个Subject维护一个观察者列表,状态变化时挨个通知。这在单线程下没问题,但多线程并发时呢?

举个例子。你有一个事件源,两个线程同时往里面注册观察者。另一个线程正在遍历列表发通知。这时候会发生什么?

  • ConcurrentModificationException:一个线程在遍历,另一个线程在修改列表
  • 通知丢失:刚注册的观察者没收到本该收到的通知
  • 死锁:观察者的回调方法里又去注册/注销其他观察者

嗯,这些都是我在项目中真实遇到过的。有一次线上告警,查了半天才发现是观察者列表的并发修改问题。

注意:不要以为用个CopyOnWriteArrayList就万事大吉了。它确实解决了遍历时的并发修改问题,但每次修改都复制整个数组,写频繁的场景下性能堪忧。

线程安全的事件分发策略

我个人习惯把线程安全的观察者模式分成三个层次来处理:

层次 策略 适用场景
基础 同步加锁 观察者少,通知快
进阶 读写分离 读多写少,通知频繁
高级 异步分发 观察者处理慢,或需要解耦

先看同步加锁的实现。最简单,但要注意锁的粒度:

public class ThreadSafeSubject {
    private final List<Observer> observers = new ArrayList<>();
    private final Lock lock = new ReentrantLock();

    public void register(Observer observer) {
        lock.lock();
        try {
            observers.add(observer);
        } finally {
            lock.unlock();
        }
    }

    public void notifyObservers(Event event) {
        List<Observer> snapshot;
        lock.lock();
        try {
            snapshot = new ArrayList<>(observers);
        } finally {
            lock.unlock();
        }
        // 在锁外遍历,避免死锁
        for (Observer observer : snapshot) {
            observer.onEvent(event);
        }
    }
}

这里有个关键点:我先把观察者列表快照出来,然后在锁外遍历。为什么?因为观察者的onEvent方法里可能又去调用register或unregister,如果还在锁内,就死锁了。我曾经在这个问题上栽过跟头,排查了好久。

异步观察者模式

说白了,同步通知有个硬伤:如果某个观察者处理得很慢,所有其他观察者都得等着。这在UI事件分发或者高并发场景下完全不能忍。

异步观察者模式的核心思路:事件分发线程和事件处理线程分离。我一般用事件队列+工作线程池来实现:

public class AsyncEventBus {
    private final ExecutorService executor;
    private final Map<Class<?>, List<EventHandler<?>>> handlers;
    private final ConcurrentLinkedQueue<Event> eventQueue;

    public AsyncEventBus(int threadCount) {
        this.executor = Executors.newFixedThreadPool(threadCount);
        this.handlers = new ConcurrentHashMap<>();
        this.eventQueue = new ConcurrentLinkedQueue<>();
        startDispatcher();
    }

    private void startDispatcher() {
        executor.submit(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                Event event = eventQueue.poll();
                if (event != null) {
                    dispatch(event);
                } else {
                    Thread.sleep(1); // 避免忙等
                }
            }
        });
    }

    @SuppressWarnings("unchecked")
    private void dispatch(Event event) {
        List<EventHandler<?>> matched = handlers.get(event.getClass());
        if (matched == null) return;

        for (EventHandler handler : matched) {
            executor.submit(() -> handler.handle(event));
        }
    }
}
我的建议:异步观察者模式里,线程池大小要谨慎设置。太小了事件堆积,太大了资源浪费。我一般根据事件处理耗时和QPS来估算,线上再调优。

核心知识体系

下面这张图是我自己总结的,把多线程观察者模式的核心脉络理清楚了:

多线程观察者模式核心体系 事件分发中心 同步加锁策略 读写分离策略 异步分发策略 ReentrantLock + 快照遍历 CopyOnWriteArrayList ReadWriteLock StampedLock(乐观读) 事件队列 + 线程池 响应式流(Reactive) 选择策略时考虑:并发度、事件处理耗时、一致性要求 没有银弹,只有最适合当前场景的方案

避坑指南与最佳实践

我这些年积累了一些经验,分享给你:

  • 不要阻塞事件分发线程:异步模式下,分发线程只负责投递事件,不负责处理。一旦分发线程被阻塞,整个系统就瘫痪了。
  • 注意事件处理顺序:异步模式下,事件到达顺序和处理顺序不一定一致。如果业务上要求严格顺序,得用单线程池或者序列号机制。
  • 处理回调中的异常:观察者的回调方法抛异常了怎么办?我的做法是捕获异常,记录日志,然后继续分发下一个。不能让一个观察者的异常影响整个系统。

核心原则:事件分发要快,事件处理要稳。分发线程只做两件事——从队列取事件、投递给线程池。其他什么都不干。

实际项目中的取舍

我记得有个项目,需要实时推送行情数据到几百个客户端。一开始用的同步通知,结果一个客户端网络慢,所有客户端都跟着卡。后来改成异步,每个客户端独立线程处理,问题就解决了。

但异步也不是没有代价。你想想看,异步意味着事件处理延迟增加了,而且不好做事务回滚。所以我的建议是:

  • 如果观察者处理很快(微秒级),同步加锁就够了
  • 如果观察者处理慢(毫秒级以上),必须用异步
  • 如果既要求顺序又要求异步,用单线程池+有界队列

嗯,说到底,架构设计就是取舍的艺术。没有完美的方案,只有最适合当前场景的方案。

一个小技巧:调试多线程观察者模式时,可以在事件里加个唯一ID和时间戳。这样排查问题时,能清楚地看到事件什么时候分发的、什么时候被处理的、被哪个线程处理的。这个习惯帮我解决了不少线上问题。

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