RxJava優化之幹掉殭屍線程

背景

最近在做Android應用線程優化,其中有一個核心指標就是收斂進程中的線程數,這是一段很長的故事,本文只是關於RxJava的一個方面的優化,其中有些坑值得每位使用RxJava的筒子注意。背景是這樣的,我們APP在進入之後,通過一些正常業務流程的使用,穩定之後,通過Android Profiler發現有一類RxComputationScheduler-的線程,數量爲8個(我的測試機有8個計算核心)。

通過直接搜索關鍵詞,RxComputationScheduler-很容易就定位到了它是RxJava默認提供的computation調度器產生的線程。我印象裏面,我們很少使用computation調度器。這8個線程幾乎沒有任何負載,也就說它們雖然存在,卻一直在睡覺😂,總所周知,Java的線程模型和系統線程模型是1:1映射關係,所以這些睡大覺的傢伙是我這次要幹掉的!

別的不說,至少Rx在使用線程的時候,還是挺規範的,它會給自己使用的線程命名,這樣在進行線程調試的時候,我們能找到對應的線程調起方,至於反面呢?我直接上圖吧


這些線程直接使用Executors.defaultThreadFactory 爲線程池指定ThreadFactory,這樣的後果就是我們這裏看到了一堆pool-${線程池編號}-thread-${線程編號}的殭屍線程,試問如果我們要去進行線程優化、鎖排查,怎麼去定位問題?

PS:就算他們沒有指定線程名字,也難不住聰明又伶俐的我,後面我會介紹一種定位這種殭屍線程的方法 😊

調度器之殤

既然是computation調度器產生的殭屍線程,那麼關於computation調度器,看名字都知道它其實是Rx提供給開發者進行CPU密集型任務的調度器,爲什麼這麼說?因爲computation調度器內部最多隻會創建當前設備的計算核心個數的線程(注意,它不是採用線程池來實現的)。

CPU密集型任務是和IO密集型任務對應的,所謂CPU密集型,指的是任務是大規模的計算工作,會一直佔用CPU,所以對於這類任務,線程數超過計算核心沒有任何意義,因爲他們很少會把線程掛起,增加線程只會導致線程直接爭搶時間片和上下文切換帶來的開銷,所以一般來說,CPU密集型任務設計的線程池中線程個數都需要嚴格限制(常用計算核心數)
IO密集型任務,是我們最常見的,比如發送個網絡請求,比如讀寫個文件,這類任務的突出特徵就是對於CPU佔用少,一般都會阻塞在IO設備上面,所以對於這些任務,通常我們會設置比較大的線程數量,因爲反正它們執行期間大部分時間都是在睡覺,那麼更多的線程可以提高系統的吞吐量。
一些語言中常見的協程,其實就是爲了解決我們創建過多線程,然後其實對於CPU使用時間很短,很多線程在佔用系統資源,所以在語言層面提供一種新思維,不去阻塞系統線程,在一個線程上面處理多個IO任務;

既然如此,看起來就是業務中使用了computation調度器,導致系統中產生了8個計算線程,那它們爲什麼不會被回收呢?這就需要看一下代碼了,computation默認基於EventLoopsScheduler來實現的,它內部使用自定義的一個類來做線程管理:

static final class FixedSchedulerPool {
   // 默認可計算核心數量
    final int cores; 
  //poolworker就是一個NewThreadWorker,直接通過一個線程數組來管理線程
    final PoolWorker[] eventLoops; 
    long n;

    FixedSchedulerPool(ThreadFactory threadFactory, int maxThreads) {
        // initialize event loops
        this.cores = maxThreads;
        this.eventLoops = new PoolWorker[maxThreads];
        for (int i = 0; i < maxThreads; i++) {
            //直接一上來就初始化數組,生成各個NewThreadWorker
            this.eventLoops[i] = new PoolWorker(threadFactory);
        }
    }

    //獲取Worker直接是內部計數器 和 cores取餘保證任務在各個Worker來回分配
    public PoolWorker getEventLoop() {
        int c = cores;
        if (c == 0) {
            return SHUTDOWN_WORKER;
        }
        // simple round robin, improvements to come
        return eventLoops[(int)(n++ % c)];
    } 
}

EventLoopsScheduler創建Worker就簡單了,直接從上面的數據結構中取出一個PoolWorker即可,然後給EventLoopWorker包裝一下:
public Worker createWorker() {
return new EventLoopWorker(pool.get().getEventLoop());
}

我前面說過了,PoolWorker只是一個普通的NewThreadWorker,所以這個EventLoopWorker的包裝肯定做了什麼不可告人的祕密:

 private static class EventLoopWorker extends Scheduler.Worker {
  
    public void unsubscribe() {
        both.unsubscribe();
    }

     @Override
    public Subscription schedule(final Action0 action) {
        return poolWorker.scheduleActual(new Action0() {
            @Override
            public void call() {
                if (isUnsubscribed()) {
                    return;
                }
                action.call();
            }
        }, 0, null, serial);
    }
}

ok,如你所見,這個EventLoopWorker好像啥都沒幹呀,只是把任務代理給了之前傳進來的Worker,然而你在仔細看看它的unsubscribe方法,調用了both的反註冊,而這個both僅僅是每次Worker. schedule任務的Subscription,它並沒有去調用Worker的unsubscribe(Super),那Super中做了什麼呢?NewThreadWorker:

 @Override
public void unsubscribe() {
    isUnsubscribed = true;
    executor.shutdownNow();
    deregisterExecutor(executor);
}

所以結論就是EventLoopWorker把unsubscribe的線程關閉代碼給去掉了,😂

直接上結論吧:computation調度器在使用過程中會創建線程核心數個數的線程,然後這些線程會一直存活。

因爲RxJava並不是一個針對移動端設計的框架,所以在服務端來說,通常準備8個左右線程進行計算工作沒有問題,然而客戶端上業務進行純計算的任務實在是太少了,而且不會存在很高的併發度,所以浪費八個線程一直在這裏睡覺,感覺不太合適,怎麼破?

所以在移動端來說,我覺得通過直接創建線程來處理計算任務是合適的,處理完,直接釋放

 RxJavaPlugins.getInstance().registerSchedulersHook(new RxJavaSchedulersHook() {
        @Override
        public Scheduler getIOScheduler() {
            return new CachedThreadScheduler(new MYRxThreadFactory("MYRxIoScheduler-"));
        }

        @Override
        public Scheduler getComputationScheduler() {
            return  createNewThreadScheduler(new RxThreadFactory("RxCom"));
        }
    });

這裏這麼成NewThread其實有問題,後面會解釋,這樣方便現在調試和定位問題

所以我通過RxJavaPlugins中修改computation默認的行爲,改成每次都創建線程(名稱爲RxCom),這次修復之後,滿心歡喜,build,run,打開Android Profiler:


還是有五位大爺穩坐釣魚臺,然後:


RxBus惹的禍

看到上面還存在5個線程,我內心很崩潰了,不是都NewThreadWorder了麼,怎麼還沒有被回收?看起來我們得找到這5個線程是哪些地方打開的了!這裏我取了點巧,使用了一個hook庫:
Epic,它基於Xposed,用來Hook自己進程,所以我的思路也很清楚,HOOK開啓線程的代碼,加入日誌,存儲對應線程的名稱,然後不就找到罪魁禍首了麼?
so:

這樣就hook住每次打開創建線程的方法(線程的構造函數)了,在hook方法裏面:


通過存儲線程名和當前堆棧,然後在run起來吧~
然後在Android Profiler中找到對應的線程名,它不就是我這個Map裏面的Key嗎?

這樣我就拿到了寶貴的啓動堆棧:

java.lang.Throwable
at com.sankuai.movie.ThreadMethodHook.afterHookedMethod(ThreadMethodHook.java:29)
at com.taobao.android.dexposed.DexposedBridge.handleHookedArtMethod(DexposedBridge.java:273)
at me.weishu.epic.art.entry.Entry.onHookObject(Entry.java:69)
at me.weishu.epic.art.entry.Entry.referenceBridge(Entry.java:186)
at rx.internal.util.RxThreadFactory.newThread(RxThreadFactory.java:39)
at java.util.concurrent.ThreadPoolExecutor$Worker.<init>(ThreadPoolExecutor.java:631)
at java.util.concurrent.ThreadPoolExecutor.addWorker(ThreadPoolExecutor.java:945)
at java.util.concurrent.ThreadPoolExecutor.ensurePrestart(ThreadPoolExecutor.java:1611)
at java.util.concurrent.ScheduledThreadPoolExecutor.delayedExecute(ScheduledThreadPoolExecutor.java:342)
at java.util.concurrent.ScheduledThreadPoolExecutor.schedule(ScheduledThreadPoolExecutor.java:562)
at java.util.concurrent.ScheduledThreadPoolExecutor.submit(ScheduledThreadPoolExecutor.java:664)
at rx.internal.schedulers.NewThreadWorker.scheduleActual(NewThreadWorker.java:240)
at rx.internal.schedulers.NewThreadWorker.schedule(NewThreadWorker.java:224)
at rx.internal.schedulers.NewThreadWorker.schedule(NewThreadWorker.java:216)
at rx.internal.operators.OperatorObserveOn$ObserveOnSubscriber.schedule(OperatorObserveOn.java:190)
at rx.internal.operators.OperatorObserveOn$ObserveOnSubscriber$1.request(OperatorObserveOn.java:147)
at rx.Subscriber.setProducer(Subscriber.java:209)
at rx.Subscriber.setProducer(Subscriber.java:205)
at rx.internal.operators.OperatorObserveOn$ObserveOnSubscriber.init(OperatorObserveOn.java:141)
at rx.internal.operators.OperatorObserveOn.call(OperatorObserveOn.java:75)
at rx.internal.operators.OperatorObserveOn.call(OperatorObserveOn.java:40)
at rx.internal.operators.OnSubscribeLift.call(OnSubscribeLift.java:46)
at rx.internal.operators.OnSubscribeLift.call(OnSubscribeLift.java:30)
at rx.Observable.subscribe(Observable.java:8759)
at rx.Observable.subscribe(Observable.java:8726)
at rx.Observable.subscribe(Observable.java:8581)
at com.dianping.nvnetwork.tunnel2.ConnectionPoolManager.<init>(ConnectionPoolManager.java:105)
at com.dianping.nvnetwork.tunnel2.NIOTunnel.<init>(NIOTunnel.java:58)
at com.dianping.nvnetwork.tunnel2.RxAndroidNIOTunnelService.<init>(RxAndroidNIOTunnelService.java:51)
忽略N行

PS:還記得之前我說過,有一堆殭屍線程沒有命名怎麼查找麼,你GET到方法了嗎?😝

我們發現是某個Manager代碼啓動的這個線程,根據Log點進去看看:


看起來沒有任何毛病呀,一個RxBus訂閱,結果切換到computation線程,然後計算工作,不過這裏看着隱隱有點擔心,總所周知,時間總線的訂閱是沒有結束時候的,所以這個流一直在註冊中,現在的問題就簡單了,observeOn到底拿這個computation調度器做了什麼導致它不能回收了呢?

這裏又涉及到RxJava關於lift和OperatorObserveOn兩個類的講解,但是由於篇幅原因,我這裏不去展開說明了,observeOn中會依據給的Scheduler,create一個Worker,然後在流完成之後,會被反註冊;因爲我目前指定的是NewThread調度器,Worker和Thread一一對應,Thread存活也就不意外啦!

解決方案

本來我是想把computation調度器直接替換成newThread ,然而我看到下圖的時候還是有些震驚的,就一個啓動,computation就給我霍霍了66個線程,所以我們還是得考慮線程複用的問題;


直接上最後的方案吧:

核心是我們需要將核心線程數也可以超時,幹掉!
通過Android Profiler再次打開,我們可以看到在最開始的時候,有一些computation調度器的線程存活,數量是8個,過一段時間之後,這些線程就自己銷燬啦!

至此,我們項目中又被幹掉了8個殭屍線程!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章