如何實現一個線程調度框架

一、前言

線程是程序執行流的最小單元,很基礎,也很重要。
爲了提高流暢性,耗時任務放後臺線程運行,這是APP開發的常識了。
隨着APP複雜度的提升,越來越多任務需要開線程執行,同時,遇到如下挑戰:

  • 任務場景多樣化,常規的API無法滿足;
  • 隨着組件化,模塊化等演進,可能使得線程管理不統一(比如多個線程池)。

爲此,我們今天來探討一下的如何設計線程調度。
話不多說,從線程池開始吧。

二、線程池

2.1 ThreadPoolExecutor

爲了減少線程創建和銷燬帶來的時間和空間上的代價,開發中通常會用到線程池。
JDK提供了一個很好用的線程池的封裝:ThreadPoolExecutor

    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)

corePoolSize:核心線程大小
maximumPoolSize:線程池最大容量(需大於等於corePoolSize,否則會拋異常)
keepAliveTime:線程執行任務結束之後的存活時間
unit:時間單位
workQueue:任務隊列
threadFactory:線程工廠
handler:拒絕策略

線程池中有兩個任務容器:

private final HashSet<Worker> workers = new HashSet<Worker>();
private final BlockingQueue<Runnable> workQueue;

前者用於存儲工作者線程,後者用於緩衝任務。
值得一提的是,maximumPoolSize限定的是workers的容量,和workQueue無關。

一個任務到來,假設此時容器workers中的線程數爲n,則

  • n < corePoolSize時,創建線程來執行這個任務,並將線程放入workers
  • n >= corePoolSize時,
    • workQueue未滿,則將任務放入workQueue
    • workQueue已滿,
      • n < maximumPoolSize, 創建線程來執行這個任務,並將線程放入workers
      • n >= maximumPoolSize, 執行拒絕策略。

當任務執行結束,線程會存活keepAliveTime的時間;
時間到,
如果allowCoreThreadTimeOuttrue, 或者 n > corePoolSize, 線程銷燬;
否則,線程進入等待,直到新的任務到來(或者線程池關閉)。

關於workQueue,有兩個極端:

  1. new SynchronousQueue<Runnable>(): 容量爲零,一個任務裝也不進;
  2. new LinkedBlockingQueue<Runnable>(): 無限容量,多少任務都裝不滿。

2.2 Executors

爲了方便使用,JDK還封裝了一些常用的ExecutorService:

public class Executors {
    public static ExecutorService newFixedThreadPool(int nThreads) {
        return new ThreadPoolExecutor(nThreads, nThreads,
                                      0L, TimeUnit.MILLISECONDS,
                                      new LinkedBlockingQueue<Runnable>());
    }

    public static ExecutorService newSingleThreadExecutor() {
        return new FinalizableDelegatedExecutorService
            (new ThreadPoolExecutor(1, 1,
                                    0L, TimeUnit.MILLISECONDS,
                                    new LinkedBlockingQueue<Runnable>()));
    }

    public static ExecutorService newCachedThreadPool() {
        return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                      60L, TimeUnit.SECONDS,
                                      new SynchronousQueue<Runnable>());
    }

    public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) {
        return new ScheduledThreadPoolExecutor(corePoolSize);
    }
}
類型 最大併發 適用場景
newFixedThreadPool nThreads 計算密集型任務
newSingleThreadExecutor 1 串行執行的任務
newCachedThreadPool Integer.MAX_VALUE IO密集型任務
newScheduledThreadPool Integer.MAX_VALUE 定時任務,週期任務

衆多ExecutorService中,newCachedThreadPool() 是比較特別的,
1、corePoolSize = 0,
2、maximumPoolSize = Integer.MAX_VALUE,
3、workQueue 爲 SynchronousQueue。
效果是:所有任務立即調度,無容量限制,無併發限制。
這樣的特點比較適合網絡請求任務。
OkHttp的異步請求所用線程池與此類似(除了ThreadFactory ,其他參數一模一樣)。

  public synchronized ExecutorService executorService() {
    if (executorService == null) {
      executorService = new ThreadPoolExecutor(0, Integer.MAX_VALUE, 60, TimeUnit.SECONDS,
          new SynchronousQueue<Runnable>(), Util.threadFactory("OkHttp Dispatcher", false));
    }
    return executorService;
  }

2.3 線程池大小的估算

一臺設備上,給定一批任務,要想最快時間完成所有任務,併發量應該如何控制?
一些文章提到如下估算公式:


M:併發數;
C:任務佔用CPU的時間;
I:等待IO完成的時間(爲簡化討論,且只考慮IO);
N:CPU核心數。

遺憾的是,對於APP來說,這條公式並不適用:

  • 任務佔用CPU時間和IO時間無法估算
    APP上的異步任務通常是碎片化的,而不同的任務性質不一樣,有的計算耗時多,有的IO耗時多;
    然後同樣是IO任務,比方說網絡請求,IO時間也是不可估計的(受服務器和網速影響)。
  • 可用CPU核心可能會變化
    有的設備可能會考慮省電或者熱量控制而關閉一些核心;
    大家經常吐槽的“一核有難,九核圍觀”映射的就是這種現象。

雖然該公式不能直接套用來求解最大併發,但仍有一些指導意義:
IO等待時間較多,則需要高的併發,來達到高的吞吐率;
CPU計算部分較多,則需要降低併發,來提高CPU的利用率。

換言之,就是:
計算密集型任務時控制併發小一點;
IO密集型任務時控制併發大一點。
比如RxJava就提供了Schedulers.computation()Schedulers.io()
前者默認情況下爲最大併發爲CPU核心數,後者最大併發爲Integer.MAX_VALUE

三、線程框架

JDK提供線程池是比較基礎,通用的API。
APP開發中,大家通常會使用一些爲特定場景做對應的封裝框架,比如AsyncTaskRxJava
AsyncTask的定位是“方便異步任務和主線程交互”的“輕量級線程框架”,RxJava 則不僅僅是線程框架,其內涵更加豐富。

AsyncTask自誕生之初就被廣泛吐槽,但是對其源碼分析倒是樂此不彼;
RxJava開始在Android中普及的階段,AsyncTask又被錘了一遍;
到現在很少人提AsyncTask了,零零星星地會被提起。

其實AsyncTask刨去註釋只有三百多行代碼,而RxJava的jar包有兩M多,猶如單車和汽車,各有各的定位。
我們就不做太多的比較了,這裏主要是提一下,承上啓下的作用。

AsyncTask可能因爲其定位的原因,設計有些保守,但總的來說實現簡單,構思精巧,還是有不少地方值得借鑑的。
接下來,我們以AsyncTask爲藍本,結合APP開發中的使用場景,探討如何設計一個適用性更強的線程框架。

四、線程調度

4.1 線程複用

第二節中我們分析了線程池和幾個ExecutorService,結論是不同的任務特徵,用不同的調度器。
但是,比方說如果直接調用 newFixedThreadPool 和 newSingleThreadExecutor 來分別執行任務的話,
會有兩個線程池,彼此的任務不能複用線程,造成浪費。

對此,AsyncTask給我們提供了一種思路。
先看代碼:

   private static class SerialExecutor implements Executor {
        final ArrayDeque<Runnable> mTasks = new ArrayDeque<Runnable>();
        Runnable mActive;

        public synchronized void execute(final Runnable r) {
            mTasks.offer(new Runnable() {
                public void run() {
                    try {
                        r.run();
                    } finally {
                        scheduleNext();
                    }
                }
            });
            if (mActive == null) {
                scheduleNext();
            }
        }
        protected synchronized void scheduleNext() {
            if ((mActive = mTasks.poll()) != null) {
                THREAD_POOL_EXECUTOR.execute(mActive);
            }
        }
    }

    /**
     * An {@link Executor} that can be used to execute tasks in parallel.
     */
    public static final Executor THREAD_POOL_EXECUTOR;

    static {
        ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(
                CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS,
                sPoolWorkQueue, sThreadFactory);
        threadPoolExecutor.allowCoreThreadTimeOut(true);
        THREAD_POOL_EXECUTOR = threadPoolExecutor;
    }

    /**
     * An {@link Executor} that executes tasks one at a time in serial
     * order.  This serialization is global to a particular process.
     */
    public static final Executor SERIAL_EXECUTOR = new SerialExecutor();

先定義一個線程池THREAD_POOL_EXECUTOR,並行任務可以調用此Executor來執行;
封裝一個SerialExecutor,加了一個任務隊列,控制加入的任務串行執行,但是最終還是運行在THREAD_POOL_EXECUTOR上。
於是,調用者可以選擇串行或者並行,且是在同一個線程池中調度的,線程可以複用。

這裏摳一下細節:
1、源碼中THREAD_POOL_EXECUTOR的註釋,“execute tasks in parallel”。
parallel, 並行;Concurrent,併發。
個人認爲此處應爲“併發”,參考:併發與並行的區別
2、SERIAL_EXECUTOR的註釋:“This serialization is global to a particular process.”。
這裏沒有什麼錯誤,但是要注意一個詞,global。
global, 意味着不同的任務公用一個串行隊列,可能會彼此阻塞。
在3.0之後, AsyncTask默認調度器是這個SERIAL_EXECUTOR。

關於這個設定,印象最深的是這位開發者遇到的“坑”:《使用AsyncTask時需要注意的隱含bug
簡單地說,就是他同時用了兩個SDK,一個用來做圖片剪裁,一個是facebook的廣告SDK。
然後發現圖片加載不出來,經過覈查發現兩個SDK都用了AsyncTask, 但是都是用的串行的Executor。
國內訪問外網速度偏慢,所以facebook的SDK阻塞了後面的任務(圖片剪裁)。
後來作者給這個圖片剪裁庫的開發者提了issue:Android-Image-Cropper, issues 183

關於這個問題,簡單的解決方法是不同的任務用不同的SerialExecutor,共用線程池,但各自串行執行,互不干擾。
後面我們會介紹其他方案,接下來先繼續分析Executor。

4.2 封裝Executor

4.2.1 任務分組

上面我們看到,AsyncTask通過Executor包裝Executor, 創建了SerialExecutor,增加了串行執行的能力。
這種技巧我們在JDK的InputStreamOutputStream也領略過了,大家稱之爲“裝飾者模式”。
雖然拓展串行執行的能力,但是還是不支持分組併發。
爲此,我們仿照SerialExecutor封裝一個Executor:

open class PipeExecutor @JvmOverloads constructor(
        windowSize: Int,
        private val capacity: Int = -1,
        private val rejectedHandler: RejectedExecutionHandler = defaultHandler) : TaskExecutor{

    private val tasks = PriorityQueue<PriorityRunnable>()
    private val windowSize: Int = if (windowSize > 0) windowSize else 1
    private var count = 0

    companion object {
        val defaultHandler = ThreadPoolExecutor.AbortPolicy()
    }

    @Synchronized
    override fun execute(r: Runnable, tag: String, priority: Int, finish: (tag: String) -> Unit) {
        if(capacity > 0 && count + tasks.size() >= capacity){
            rejectedHandler.rejectedExecution(r, TaskCenter.executor)
        }
        val active = PriorityRunnable(r, tag, finish)
        if (count < windowSize || priority == Priority.IMMEDIATE) {
            startTask(active)
        } else {
            tasks.offer(active, priority)
        }
    }

    override fun execute(r: Runnable) {
        execute(r, "")
    }

    // ......

    private fun startTask(active: Runnable?) {
        if (active != null) {
            count++
            // 線程池封裝在 TaskCenter 中,任務最終在該線程池中執行
            TaskCenter.poolExecutor.execute(active)
        }
    }
}
   class PriorityRunnable internal constructor(
            private val r: Runnable,
            private val tag: String,
            private val finish: (tag: String) -> Unit) : Runnable {
        override fun run() {
            try {
                r.run()
            } finally {
                scheduleNext()
                if(!tag.isEmpty()){
                    finish(tag)
                }
            }
        }
        // ......
    }

解析一下代碼中的參數:
windowSize:控制Executor的併發;
capacity:Executor容量,-1時爲不限容量,超過容量觸發rejectedHandler;
rejectedHandler:默認爲AbortPolicy(拋出異常);

priority:調度優先級,當任務數量超過windowSize時,priority高者先被調度;
tag:任務標識;
finish: 任務結束後觸發此回調,搭配tag完成一項功能(接下來會有介紹)。

使用時,可以實例化多個PipeExecutor,他們各自根據參數調度自己的任務隊列,但最終都是在同一個線程池中運行。
比方說可以創建windowSize設置爲cpu數量的PipeExecutor,用於計算密集型任務;
也可以創建windowSize多一點的PipeExecutor,用於IO密集型任務;
還可以使windowSize=1,用於串行執行。

PipeExecutor支持優先級,當優先級設定爲IMMEDIATE爲立即執行。
優先級相同的任務,遵循先進先出(FIFO)的調度規則。

4.2.2 任務去重

APP開發中常會遇到任務重複的情況。
比方說一個頁面所展示的數據可能來自多個數據源,而每個數據源的變更入口有多個,當同時有幾個數據變更時,如果不做去重,會浪費計算資源,甚至使得APP卡頓;
又如,有幾個數據項所記錄的是同一張圖片,需要上傳,然後更新路徑爲服務端回傳的URL,如果數據上傳是併發的,會導致圖片重複上傳。

說到去重,首先要定義重複;
要定義重複,就要給任務設定標識,相同標識視爲重複。
所以TaskExecutor給到的execute方法可以傳tag參數,用tag標識一類任務。

不同的任務類型,去重策略也不一樣。
1、數據刷新任務
當刷新任務在執行時,忽略後面的任務。不妥。忽略後面的任務,可能造成頁面沒有正確更新。
有任務正在執行,取消之,新建任務。也不妥。取消前面的任務,極端情況下(比如間隔性持續有刷新通知到達),可能會造成頁面遲遲得不到更新。
這類任務的特徵是,當任務未開始,一個和多個是等價的,故此對應的策略爲:當有任務在執行時,保留一個任務在隊列,忽略後來者。
其示意圖如下:

其特徵爲:
不相同tag的任務併發,想同tag的任務串行;
但是tag相同的任務,最多隻能存2個,更多的後來者將會被忽略。
進入調度的任務也不一定會被馬上執行,只是被放到PipeExecutor中,進行下一層的調度。

2、圖片加載任務
圖片加載任務通常用圖片的路徑作爲tag。
但圖片加載除了path之外,還有target(要加載到哪個ImageView)。
所以不能採用上面的“忽略後來者”的策略,否則有可能導致有的ImageView加載不出圖片(多個ImageView需要加載同一張圖片的情況)。
把target混入tag?不行。有可能導致重複下載或者重複解碼。
而如果讓path相同的加載任務串行,則可以複用緩存。
從這個角度看,也是一種“去重”。
對應示意圖如下:

其調度模式和前面的“數據刷新任務”很像,只是沒有"ignore"。
從另一個角度看,這種模式可以用於執行“串行的任務”,只需要給同類的任務加tag即可。
這樣的話就不用到處創建windowSize=1的PipeExecutor了。

任務去重的實現如下:

class LaneExecutor(private val executor: PipeExecutor, private val limit: Boolean = false) : TaskExecutor {
    private val scheduledTasks = HashMap<String, Runnable>()
    private val waitingQueues by lazy { HashMap<String, CircularQueue<TaskWrapper>>() }
    private val waitingTasks by lazy { HashMap<String, TaskWrapper>() }

    private class TaskWrapper(val r: Runnable, val priority: Int)

    private val finishCallback: (tag: String) -> Unit = { tag ->
        scheduleNext(tag)
    }

    @Synchronized
    override fun scheduleNext(tag: String) {
        scheduledTasks.remove(tag)
        if (limit) {
            waitingTasks.remove(tag)?.let { start(it.r, tag, it.priority) }
        } else {
            waitingQueues[tag]?.let {
                val wrapper = it.poll()
                if (wrapper == null) {
                    waitingQueues.remove(tag)
                } else {
                    start(wrapper.r, tag, wrapper.priority)
                }
            }
        }
    }

    @Synchronized
    override fun execute(r: Runnable, tag: String, priority: Int, finish: (tag: String) -> Unit) {
        if (scheduledTasks.containsKey(tag)) {
            if (limit) {
                if (waitingTasks.containsKey(tag)) {
                    if (r is Future<*>) {
                        r.cancel(false)
                    }
                } else {
                    waitingTasks[tag] = TaskWrapper(r, priority)
                }
            } else {
                val queue = waitingQueues[tag]
                        ?: CircularQueue<TaskWrapper>().apply { waitingQueues[tag] = this }
                queue.offer(TaskWrapper(r, priority))
            }
        } else {
            start(r, tag, priority)
        }
    }

    private fun start(r: Runnable, tag: String, priority: Int) {
        scheduledTasks[tag] = r
        executor.execute(r, tag, priority, finishCallback)
    }
}

PipeExecutor和LaneExecutor的關係如下圖:

之前PipeExecutor通過裝飾者模式,在ThreadPoolExecutor之上包裝了一層,拓展了分組,優先級等特性,
如今LaneExecutor在PipeExecutor上又包了一層,拓展了去重的特性。
關於組合和繼承,普遍的觀點是組合優先於繼承。
所以在設計LaneExecutor時,用PipeExecutor作爲成員而非繼承於PipeExecutor。

4.3 全局調度

當項目複雜度到了一定程度,如果沒有相對嚴格的規範約束的話,可能會看到各種各樣的冗餘對象,比如緩存和Executor。
因爲不想被其他模塊所幹擾,或者圖方便,開發者可能會在自己的模塊定義自己的Executor。
分散的Executor有隔離的效果(不會相互影響),但副作用就是無法集中管控,過多的線程併發執行可能會導致資源爭搶以及帶來更多線程切換代價。
如果各自創建的原生JDK提供的線程池,則還要加上一條:降低線程複用。

故此,可以集中定義執行器,各模塊統一調用。

object TaskCenter {
    private val cpuCount = Runtime.getRuntime().availableProcessors()
    // ......

    // standard Executor
    val io = PipeExecutor(16, 512)
    val computation = PipeExecutor(Math.min(Math.max(2, cpuCount), 6), 256)

    // use to execute tasks which need to run in serial,
    // such as writing logs, reporting app info to server ...
    val lane = LaneExecutor(PipeExecutor(Math.min(Math.max(2, cpuCount), 4), 512))

    // use to execute general tasks,such as loading data.
    val laneIO = LaneExecutor(io, true)
    val laneCP = LaneExecutor(computation, true)
}

很多開源項目都設計了API來使用外部的Executor,例如RxJava的話可以這樣使用:

object TaskSchedulers {
    val io: Scheduler by lazy { Schedulers.from(TaskCenter.io) }
    val computation: Scheduler by lazy { Schedulers.from(TaskCenter.computation) }
    val single by lazy { Schedulers.from(PipeExecutor(1)) }
}

使用:

Observable.range(1, 8)
       .subscribeOn(TaskSchedulers.computation)
       .subscribe { Log.d(tag, "number:$it") }

五、拓展AsyncTask

通過上面構造的相對完善的Executor,我們可以用於擴展AsyncTask。
通過繼承AsyncTask無法做到我們預想的效果,所以沒辦法,只能重新寫一個了。
限於篇幅,這裏就不分析具體實現了。

大體框架還是Executor + Handler, 只是Executor換上了TaskExecutor,以及添加生命週期(被錘得最多的缺點之一)的支持。
簡單地說,就是通過觀察者模式實現對宿主的生命週期(onDestroy, onPause, onResume)的監聽,在onDestroy是取消任務,在onPause時降低優先級,在onResume時恢復優先級。

這裏補充一點,關於AsyncTask的cancel, 有不少文章說不一定能立即取消任務。
確實是不一定能立即取消,但這其實是合理的。
當調用AsyncTask的cancel(mayInterruptIfRunning), 並傳入true時,會觸發interrupt()。
關於interrupt()知乎上有不錯的討論:Java裏一個線程調用了Thread.interrupt()到底意味着什麼
interrupt() 雖然不能保證馬上終止任務,但是能夠中斷sleep(), wait()等方法;
如果使用OkHttp, interrupt()能夠中斷網絡請求。
爲什麼不用Thread.stop()呢? Thread.stop()是個危險的方法。
比方說一個線程正在寫入數據,如果突然中止,可能數據就不對了;
更有甚者,可能導致文件不完整,可能導致文件的數據都丟失了。

拓展後用法和原生的AsyncTask用法是類似的,只是多了一些方法,以提供額外的功能,例如優先級,以及監聽Activity/Fragment生命週期。

六、下載

implementation 'com.horizon.task:task:1.0.1'

相關代碼已上傳GitHub,
項目地址:https://github.com/No89757/Task

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