Kotlin协程中的线程池

7,494 阅读7分钟

建议先阅读上篇文章:Java线程池是如何运行的

Kotlin协程

协程这个概念本身是在很多编程语言都存在的,与线程相比,协程强调的是不同协程的切换因不需要操作系统调度而开销更低,以更实现更高效的异步编程。其实这样说也并不准确,因为协程的实现是依赖与语言与平台的,首先并不是所有的协程的切换都不需要操作系统的调度;其次对于JVM来说,一个Thread直接对应于操作系统中的一个线程,而对于其他编程语言来说,并不一定是这种一对一的关系,可能是m个编程语言的线程对应于操作系统中的n个线程,因此线程的切换也并非一定是低效的。

对于Android开发者而言,我们其实并不需要理解这些概念上的复杂之处,我们只需要明白,对于Android的Dalvik/ART平台上的Kotlin协程,是基于线程池实现的。我们用到的Kotlin协程,不过是线程池的高度封装而已,这种封装给异步编程带来了极大的便利。

Kotlin协程的线程池

下面我们来直接观察Kotlin协程的线程池。

    fun test() {
        lifecycleScope.launch(Dispatchers.IO) { }
    }

指定协程运行的线程池我们知道要通过Dispatchers,有:

  1. Main: 即UI线程
  2. Default: 通常用于执行CPU密集型任务
  3. IO: 通常用于执行会阻塞线程的任务,比如网络请求,文件读写等

Default

以Default为例,查看其实现。

public actual val Default: CoroutineDispatcher = createDefaultDispatcher()

internal actual fun createDefaultDispatcher(): CoroutineDispatcher =
    if (useCoroutinesScheduler) DefaultScheduler else CommonPool

根据属性useCoroutinesScheduler即“kotlinx.coroutines.scheduler”配置来决定使用哪种实现。因为Default线程池是单例中的属性,因此APP运行时只会使用其中一种实现,现尝试打印其值,发现是null,此时使用DefaultScheduler实现。

不过不妨看一下这个与DefaultScheduler对等的CommonPool。其代码很简单,可以迅速定位到其线程池创建方法:

    private fun createPlainPool(): ExecutorService {
        val threadId = AtomicInteger()
        return Executors.newFixedThreadPool(parallelism) {
            Thread(it, "CommonPool-worker-${threadId.incrementAndGet()}").apply { isDaemon = true }
        }
    }

使用我们非常熟悉的newFixedThreadPool静态工厂方法创建了一个线程数固定,拥有无界阻塞任务队列的Java原生线程池,Kotlin协程代码块就运行这个线程池上,并没有什么特别之处。

下面看真实的线程池实现DefaultScheduler。

DefaultScheduler继承自ExperimentalCoroutineDispatcher,里面方法不多,可以迅速发现关键方法dispatch用于分发协程代码块。

    override fun dispatch(context: CoroutineContext, block: Runnable): Unit =
        try {
            coroutineScheduler.dispatch(block)
        } catch (e: RejectedExecutionException) {
            DefaultExecutor.dispatch(context, block)
        }

其又调用了CoroutineScheduler的dispatch方法。

    fun dispatch(block: Runnable, taskContext: TaskContext = NonBlockingContext, tailDispatch: Boolean = false) {
        trackTask() // this is needed for virtual time support
        val task = createTask(block, taskContext)//1
        // try to submit the task to the local queue and act depending on the result
        val currentWorker = currentWorker()
        val notAdded = currentWorker.submitToLocalQueue(task, tailDispatch)//2
        if (notAdded != null) {
            if (!addToGlobalQueue(notAdded)) {
                throw RejectedExecutionException("$schedulerName was terminated")
            }
        }//3
        val skipUnpark = tailDispatch && currentWorker != null
        // Checking 'task' instead of 'notAdded' is completely okay
        if (task.mode == TASK_NON_BLOCKING) {
            if (skipUnpark) return
            signalCpuWork()
        } else {
            // Increment blocking tasks anyway
            signalBlockingWork(skipUnpark = skipUnpark)
        }//4
    }

可以发现,这是一套将任务分发到线程的调度逻辑,这里自己实现了一个线程池,dispatch方法相当于ThreadPoolExecutor的void execute(Runnable command)方法。注意到CoroutineScheduler也实现了Executor接口,查看其execute方法实现,直接委托给了dispatch方法。

override fun execute(command: Runnable) = dispatch(command)

现分析其实现:

  1. 将协程的代码块从Runnable封装成了内部类Task(Java中ThreadPoolExecutor没有这层封装)
  2. 将任务提交到当前线程的任务队列。这是与Java原生线程池最大的不同,Java的ThreadPoolExecutor中只有一个任务队列,被池中所有线程共享。而协程线程池除了这个线程间共享队列外,池中线程还存在一个线程内队列localQueue
  3. 当任务向线程内队列提交失败,则向线程池全局队列提交,若仍然失败则抛出异常
  4. 那么4处if-else块中的代码是干什么用的呢?

为了解释这个问题,要先思考一下为什么Kotlin协程中线程池每个线程中要有一个localQueue,和Java中ThreadPoolExecutor一样只有一个线程间共享的任务阻塞队列不行么?

我自己的理解是当然是可以的,协程中的线程池并没有什么特殊之处。只是,只有一个线程间共享的任务队列,线程间对这个队列操作而发生竞争的概率极大的增加,以我们在Java中定制线程池常用的有界阻塞队列ArrayBlockingQueue为例,其存取方法实现如下:

    public void put(E e) throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            while (count == items.length)
                notFull.await();
            enqueue(e);
        } finally {
            lock.unlock();
        }
    }
    public E take() throws InterruptedException {
        final ReentrantLock lock = this.lock;
        lock.lockInterruptibly();
        try {
            while (count == 0)
                notEmpty.await();
            return dequeue();
        } finally {
            lock.unlock();
        }
    }

可以发现对该并发容器的修改第一步就是加锁操作,当竞争发生时,会对任务的存取效率产生影响,也就影响线程池的整体效率。因此,Kotlin协程中线程池为了减少此竞争产生的概率,在每个线程中设置一个localQueue,如2处代码所示,添加任务时会先尝试添加进线程内任务队列。其实在Java中也存在这种线程池内包含子任务队列的实现ForkJoinPool,其中的线程ForkJoinWorkerThread内有一个ForkJoinPool.WorkQueue类型的workQueue,就像这里的localQueue。

可是这种线程池内线程使用localQueue会引发一个新的问题,就是可能出现任务在各localQueue添加不均匀的问题,使得某些子线程十分忙碌,另一些却无事可做,而这种现象在只有一个线程间共享的任务队列的线程池实现中是不会出现的。

那该如何调和这种任务的分配不均?上篇我们在Java线程池实现中,分析线程复用原理时跟踪了线程的run方法,这里也一样查看Kotlin协程线程池中线程的run方法,在不考虑细节,删除了大量无关代码后的线程池中线程实现如下:

    internal inner class Worker private constructor() : Thread() {
        @JvmField
        val localQueue: WorkQueue = WorkQueue()
        override fun run() = runWorker()
        private fun runWorker() {
            var rescanned = false
            while (!isTerminated && state != WorkerState.TERMINATED) {
                val task = findTask(mayHaveLocalTasks)//1
                if (task != null) {
                    executeTask(task)
                    continue
                }
				...
                tryPark()//7
            }
            tryReleaseCpu(WorkerState.TERMINATED)
        }
        fun findTask(scanLocalQueue: Boolean): Task? {
            if (tryAcquireCpuPermit()) return findAnyTask(scanLocalQueue)
            val task = if (scanLocalQueue) {
                localQueue.poll() ?: globalBlockingQueue.removeFirstOrNull()
            } else {
                globalBlockingQueue.removeFirstOrNull()
            }//2
            return task ?: trySteal(blockingOnly = true)//3
        }
        private fun findAnyTask(scanLocalQueue: Boolean): Task? {
			...
            return trySteal(blockingOnly = false)
        }
        private fun trySteal(blockingOnly: Boolean): Task? {
            val created = createdWorkers
            if (created < 2) {
                return null
            }
            var currentIndex = nextInt(created)
            var minDelay = Long.MAX_VALUE
            repeat(created) {
                ++currentIndex
                if (currentIndex > created) currentIndex = 1
                val worker = workers[currentIndex]//4
                if (worker !== null && worker !== this) {
                    assert { localQueue.size == 0 }
                    val stealResult = if (blockingOnly) {
                        localQueue.tryStealBlockingFrom(victim = worker.localQueue)
                    } else {
                        localQueue.tryStealFrom(victim = worker.localQueue)
                    }//5
                    if (stealResult == TASK_STOLEN) {
                        return localQueue.poll()//6
                    } else if (stealResult > 0) {
                        minDelay = min(minDelay, stealResult)
                    }
                }
            }
            minDelayUntilStealableTaskNs = if (minDelay != Long.MAX_VALUE) minDelay else 0
            return null
        }
    }

与Java一样runWorker中不断循环获取任务(1),不过在findTask中localQueue与globalBlockingQueue中都获取不到任务时(2),并不会阻塞在这里,而是会调用trySteal方法(3),从其他线程的任务队列中窃取任务。具体就是在repeat循环中,对workers线程集合遍历(4),尝试将该worker中的任务队列中的任务窃取至本队列(5),直至有能窃取成功的任务则返回(6),对于窃取的细节,我们不再深究,从结果上来看,就是在(1)处findTask取到了任务,能够继续执行下去。

而对于findTask获取到的任务为空,即localQueue为空,globalBlockingQueue为空,也没有可供窃取的任务的情况,才会调用tryPark方法,阻塞线程(7),tryPark中会调用Java中LockSupport类定义的一套方法,阻塞当前线程。

回到我们最初的问题:dispatch方法中4处if-else块中的代码是干什么用的呢?因为此时刚刚成功添加了任务,当然要唤醒线程继续从localQueue获取任务继续运行了,同样是执行LockSupport类唤醒线程的方法。

至此,我们分析了协程中Default线程池的实现,总结一下就是:Kotlin协程中的线程池,是完全由Kotlin自己实现的一整套线程池,因为实现了工作窃取算法(work-stealing) 来减少池中线程是竞争与切换,所以包括内部使用的任务队列也没有使用Java中的阻塞队列,而完全由自己实现的。

IO

下面来看一下IO的实现:

public val IO: CoroutineDispatcher = DefaultScheduler.IO

internal object DefaultScheduler : ExperimentalCoroutineDispatcher() {
    val IO = blocking(systemProp(IO_PARALLELISM_PROPERTY_NAME, 64.coerceAtLeast(AVAILABLE_PROCESSORS)))
}

    public fun blocking(parallelism: Int = BLOCKING_DEFAULT_PARALLELISM): CoroutineDispatcher {
        return LimitingDispatcher(this, parallelism, TASK_PROBABLY_BLOCKING)
    }

我们可以跟踪到blocking方法,里面的LimitingDispatcher就是其线程池实现,这里传入了this,也就是ExperimentalCoroutineDispatcher,而LimitingDispatcher内部的dispatch方法就直接调用了这个ExperimentalCoroutineDispatcher的dispatch方法,这与我们刚才分析的Default线程池一致。因此Default与IO的实现使用了相同的线程池。

因此,文章开头说的 Default通常用于执行CPU密集型任务,IO通常用于执行会阻塞线程的任务,在目前Android开发使用的Kotlin协程的实现下,其实只是一种习惯,并无区别。但需要注意的是Kotlin的协程使用,是通过库的方式提供的,完全可能更换成另外的实现,因此还是遵守这种习惯比较好。

总结

至此,我们分析了Kotlin协程中的线程池的实现,它是Kotlin协程较为独立的部分,调配了协程产生的任务的执行,相比较Java中ThreadPoolExecutor,使用工作窃取算法(work-stealing) 来对线程池中线程是竞争与切换进行了一定优化。