由一个Bug引申出的EventBus原理浅析

337 阅读7分钟

给那些不甘于平庸,还在为梦想而奋斗的人,他们心里有火,眼底有光,浑身充满着乐观和热情

背景描述

在开发新版本功能时,遇到一个bug困扰了0.5天的开发时间。在分析的过程中,自己又对EventBus的设计思想理解深入了一些,写文章简单总结一番。

Bug的表现是,用户从A页面跳转到B页面,同时播放一段语音。在上一版本时,跳转和播放语音是同时发生的,但是当前大家共同开发的版本,发生一个奇怪的现象,语音播放晚了大概5s

代码里是通过EventBus.postStickyEvent(AudioEvent)来触发语音播放的,通过日志可以看到从发射Event到接收Event,中间相差5s

EventBus是一个解决模块间调用的快捷方式,由于接入成本低,在当前的项目中采用广泛。但在我看来,过于滥用的EventBus,并不是一种好的设计模式。

  • 将模块之间的依赖关系变得更加松散和混乱
  • 调用顺序难以保证,虽然可以通过threadMode进行控制,但控制程度很弱,难以满足复杂的业务场景
  • 接口增删不会导致编译失败,容易掩藏问题,将本可以在编译阶段暴露的问题拖延至线上
  • 由于定义Event成本很低,过多使用极易造成Event类膨胀

分析过程

一开始我是没有太多头绪的,按照Common Sense,EventBus发送的事件,理应立即处理才对。把日志翻来覆去看了好几遍,也并没有发现有用的信息。然后我尝试把触发语音播放的代码从EventBus改为同步调用,这次可以正常播放了,因此定位到问题的原因在于EventBus内部处理。

发送语音事件、接收处理事件的代码都是通过@Subscribe(threadMode = ThreadMode.BACKGROUND)指定给了后台线程。好在可以打断点,这部分代码位于BackgroundPoster.java

    public void enqueue(Subscription subscription, Object event) { // 1. 将订阅者和事件组装成PendingPost,并加入队列
        PendingPost pendingPost = PendingPost.obtainPendingPost(subscription, event);
        synchronized (this) {
            queue.enqueue(pendingPost);
            if (!executorRunning) {
                executorRunning = true;
                eventBus.getExecutorService().execute(this);
            }
        }
    }
    
    @Override
    public void run() {
        try {
            try {
                while (true) {
                    PendingPost pendingPost = queue.poll(1000);
                    if (pendingPost == null) {
                        synchronized (this) {
                            // Check again, this time in synchronized
                            pendingPost = queue.poll();
                            if (pendingPost == null) {
                                executorRunning = false;
                                return;
                            }
                        }
                    }
                    eventBus.invokeSubscriber(pendingPost); // 2. 无限循环从队列中取出任务串行执行
                }
            } catch (InterruptedException e) {
                eventBus.getLogger().log(Level.WARNING, Thread.currentThread().getName() + " was interruppted", e);
            }
        } finally {
            executorRunning = false;
        }
    }

其实分析到这里,就基本定位出,是由于前一个任务耗时过多(5s),导致同在BACKGROUND线程执行的语音播放任务被顺延执行。通过断点,找到了前一个任务是读取数据库,由于需要等待数据同步,该同学在代码里增加了3s的等待,因此造成了后续任务全部顺延。

确定原因后,改动也很简单,将上一个任务另起一个IO协程执行,不阻塞当前队列。问题解决。

EB原理:核心思想

问题本身不具有价值,通过问题学到了什么才真正有价值。虽然我不赞成代码里滥用EventBus,但作为最经典的一种事件总线实现,EventBus还是有其值得学习的地方的。

EventBus采用的是典型的发布-订阅模式,可以说理解了EventBus,就理解了发布者-订阅者(Publisher-Subscriber)模式,其控制流程可以概括如下:

注册过程:

  1. EventBus是一个单例对象,初次调用时进行创建(饱汉模式)
  2. 订阅者通过EventBus.getDefault().register(Object),将自身注册给EventBus单例
  3. 注册过程中,EventBus扫描该类的方法列表,找出那些通过@Subscribe进行注解的函数,将它们与Subscriber(订阅者)和Event(所订阅事件)相关联

接收过程:

  1. 发送者通过post/postSticky,将事件发送给总线
  2. 发送者所处的线程会维护一个ThreadLocal类型的名为PostingThreadState的对象,其内部维护了事件队列eventQueue,事件会被加入到这个队列中
  3. while循环遍历该队列,并取出事件分发执行
  4. 根据不同的分发策略,事件被立即执行,或者加入到待执行队列中
  5. 最终通过反射method.invoke()来调用订阅者相应的函数

EB原理:5种线程模式

这次遇到的Bug,本质上是对EventBus的线程模式理解模糊,导致错误地在BACKGROUND模式下执行了耗时操作,导致后续任务被推迟。接下来就盘点下EventBus中5种不同的ThreadMode。

// EventBus.java
    private void postToSubscription(Subscription subscription, Object event, boolean isMainThread) {
        switch (subscription.subscriberMethod.threadMode) {
            case POSTING:
                invokeSubscriber(subscription, event);
                break;
            case MAIN:
                if (isMainThread) {
                    invokeSubscriber(subscription, event);
                } else {
                    mainThreadPoster.enqueue(subscription, event);
                }
                break;
            case MAIN_ORDERED:
                if (mainThreadPoster != null) {
                    mainThreadPoster.enqueue(subscription, event);
                } else {
                    // temporary: technically not correct as poster not decoupled from subscriber
                    invokeSubscriber(subscription, event);
                }
                break;
            case BACKGROUND:
                if (isMainThread) {
                    backgroundPoster.enqueue(subscription, event);
                } else {
                    invokeSubscriber(subscription, event);
                }
                break;
            case ASYNC:
                asyncPoster.enqueue(subscription, event);
                break;
            default:
                throw new IllegalStateException("Unknown thread mode: " + subscription.subscriberMethod.threadMode);
        }
    }

分发任务的核心代码如上,根据订阅者声明的ThreadMode区分,它们有的直接调用订阅者响应函数,有的将任务放入Poster的执行队列中。Poster内部则封装了不同的实现(BACKGROUND、ASYNC、MAIN)

  • POSTING:在发送者的线程执行任务,直接调用invoke
  • MAIN:在UI线程执行任务,如果发送者本身处于UI线程,则直接执行;否则将任务放入mainThreadPoster的队列中,内部通过mainLooper与UI线程进行绑定
  • MAIN_ORDERED:将任务放入mainThreadPoster的队列顺序执行
  • BACKGROUND:它是一个单线程的模型,所有任务在一个while循环里顺序得到执行
  • ASYNC:任务被丢给CachedThreadPool,如果当前有空闲线程则立即执行,否则进行等待。CachedThreadPool用于执行大量短期异步任务,默认参数是
    • corePoolSize=0
    • maximumPoolSize=Integer.MAX_VALUE
    • keepAliveTime=60s
    • SynchronousQueue,不存储元素,每个插入操作必须等待空闲线程出现,如若没有则阻塞
    • DefaultThreadFactory

分析到这里,EventBus的核心思想基本都盘点清楚了,接下来思考,对于前文中发生的bug,应该如何降低解决它的成本,更进一步,怎样提前规避这一类滥用、误用BACKGROUND事件的问题发生?

AOP(Aspect Oriented Programming)提供了一个很好的思路,对于BACKGROUND类型的任务,记录其执行时间,如果超出阈值(例如100ms)则发出警告。

Work Around: AspectJ无缝打点

通过分析我们知道,EventBus中所有的回调,最终都会走到EventBus.invokeSubscriber(Subscription, Object)这个函数:

    void invokeSubscriber(Subscription subscription, Object event) {
        try {
            subscription.subscriberMethod.method.invoke(subscription.subscriber, event);
        } catch (InvocationTargetException e) {
            handleSubscriberException(subscription, event, e.getCause());
        } catch (IllegalAccessException e) {
            throw new IllegalStateException("Unexpected exception", e);
        }
    }

AspectJ的功能非常强大,因此,想要完全掌握它需要认真花费一番功夫,好在这里我只需要关注指定函数的执行耗时。这里要用到AspectJ的功能是切点函数execution,其语法如下

execution(<修饰符模式>?<返回类型模式><方法名模式>(<参数模式>)<异常模式>?)

通过上述5个参数,就可以匹配一类或者某一个函数。

@Aspect
class TraceAspect {
    private val TAG = "TraceAspect"

    // 匹配指定函数
    @Pointcut("execution(* org.greenrobot.eventbus.EventBus.invokeSubscriber(Subscription, Object))")
    fun monitorMethod() {}

    @Around("monitorMethod()")
    fun profile(pjb: ProceedingJoinPoint) {
        val beginTime = SystemClock.elapsedRealtime()
        pjb.proceed()
        val endTime = SystemClock.elapsedRealtime()
        val costTime = endTime - beginTime
        Log.i(TAG, "Execution of ${pjb.signature.toShortString()}, cost time: $costTime ms")
    }
}

一切似乎都按照计划进行,预期这段代码运行起来后,可以计算出EventBus中每一次invokeSubscriber执行耗时。但实际运行起来,事情并不像想象那样发生,日志窗口纹丝不动,没有一点输出。

aop.webp

问题出在了自己对AOP的理解上,AspectJ仅仅能够处理本地存在Java代码的场景。而我们是通过aar包的方式接入EventBus的,此时代码是class字节码的形式,应该采取另一个更偏底层的工具——Javassit,对字节码进行修改。

确定了方向路线,剩下的就都是体力活了,Javassit的接入和使用方法不是本文的重点,不赘述。