协程(22) | Channel原理解析
Posted 嘴巴吃糖了
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了协程(22) | Channel原理解析相关的知识,希望对你有一定的参考价值。
前言
在前面文章我们介绍过 Channel
的使用,Channel
主要用于协程间的通信,相比于Flow
,它还是热的,即不管有没有消费者,它都会往Channel
中发射数据,即发射端一直会工作,就和一位热情的服务员一样。
那本篇文章,就来解析一波Channel
的原理,看看是如何实现在协程间通信的,以及探究"热"的原因。
正文
我们还是以简单例子入手,来逐步分析。
Channel()
顶层函数
我们创建一个没有缓存容量的Channel
,如下:
fun main()
val scope = CoroutineScope(Job())
//创建管道,都使用默认参数
val channel = Channel<Int>()
scope.launch
//在一个单独的协程当中发送管道消息
repeat(3)
channel.send(it)
println("Send: $it")
channel.close()
scope.launch
//在一个单独的协程当中接收管道消息
repeat(3)
val result = channel.receive()
println("Receive $result")
println("end")
Thread.sleep(2000000L)
/*
输出结果:
end
Receive 0
Send: 0
Send: 1
Receive 1
Receive 2
Send: 2
*/
在这里会发现输出结果是交替执行的,这是因为Channel
的send
和receive
是挂起函数,而默认参数创建的Channel
是没有缓存容量的,所以调用完send
后,如果没有消费者来消费,就会挂起;同理receive
也是如此,这些知识点我们在之前学习Channel
文章时,已经说过这些特性了。
再结合挂起函数的本质,这种交替执行的输出结果,我相信都能明白。本篇文章,就来探索一下,Channel
到底是如何实现的。
和我们之前分析的CoroutineScope
、Job
等类似,Channel()
也是一个顶层函数充当构造函数使用的案例,该方法代码如下:
//顶层函数充当构造函数使用
public fun <E> Channel(
//容量
capacity: Int = RENDEZVOUS,
//背压策略
onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND,
//元素投递失败回调
onUndeliveredElement: ((E) -> Unit)? = null
): Channel<E> =
when (capacity)
//根据容量分类
RENDEZVOUS ->
//默认参数下,所创建的Channel
if (onBufferOverflow == BufferOverflow.SUSPEND)
RendezvousChannel(onUndeliveredElement)
else
//背压策略是非挂起情况下的实现
ArrayChannel(1, onBufferOverflow, onUndeliveredElement)
CONFLATED ->
...
ConflatedChannel(onUndeliveredElement)
UNLIMITED -> LinkedListChannel(onUndeliveredElement)
//容量为2,默认也是ArrayChannel
BUFFERED -> ArrayChannel(
if (onBufferOverflow == BufferOverflow.SUSPEND) CHANNEL_DEFAULT_CAPACITY else 1,
onBufferOverflow, onUndeliveredElement
)
//其他自定义容量
else ->
if (capacity == 1 && onBufferOverflow == BufferOverflow.DROP_OLDEST)
ConflatedChannel(onUndeliveredElement)
else
ArrayChannel(capacity, onBufferOverflow, onUndeliveredElement)
由该顶层函数我们可以看出,根据我们所传入的参数不同,会创建不同的Channel
实例,比如RendezvousChannel
、ArrayChannel
等,我们等会以默认的RendezvousChannel
为例来分析。
这里有个小知识点,就是onUndeliveredElement
参数,这里使用函数类型,即符合Kotlin的语法规则,又不用创建多余接口。
但是(E) -> Unit
这种函数类型是否会造成误解呢?因为毕竟丢失的元素可以用这个函数类型表示,那我再定义一个到达元素的回调呢,是不是也可以定义为(E) -> Unit。为了避免造成这种误解,我们看看是如何实现的,我们看看RendezvousChannel
的定义:
internal open class RendezvousChannel<E>(onUndeliveredElement: OnUndeliveredElement<E>?) : AbstractChannel<E>(onUndeliveredElement)
会发现这里参数类型居然是OnUndeliveredElement,这就很容易理解了。这里难道是定义了接口吗?我们查看一下:
internal typealias OnUndeliveredElement<E> = (E) -> Unit
可以发现这里只是给类型起了一个别名,通过typealias
可以给一些容易造成理解混乱的函数类型起个名字,这个小知识点,在实际业务中,还是蛮有用的。
回到主线,我们来分析RendezvousChannel
的继承关系:
//该类继承至AbstractChannel
internal open class RendezvousChannel<E>(onUndeliveredElement: OnUndeliveredElement<E>?) :
AbstractChannel<E>(onUndeliveredElement)
//继承至AbstractSendChannel类,实现Channel接口
internal abstract class AbstractChannel<E>(
onUndeliveredElement: OnUndeliveredElement<E>?
) : AbstractSendChannel<E>(onUndeliveredElement), Channel<E>
//实现SendChannel接口
internal abstract class AbstractSendChannel<E>(
@JvmField protected val onUndeliveredElement: OnUndeliveredElement<E>?
) : SendChannel<E>
//Channel接口,继承至SendChannel和ReceiveChannel接口
public interface Channel<E> : SendChannel<E>, ReceiveChannel<E>
乍一看,这里的接口和抽象类定义的有点复杂,但是我们稍微分析一下,就会发现这样定义挺合理:
-
首先就是一个最基础的问题,接口和抽象类的区别?
从面向对象解读来看,以及使用角度来分析,接口是倾向于约束公共的功能,或者给一个类添加额外的功能,某个类实现了接口,它就有了一些额外的能力行为。同时约束了该类,有这些功能。
比如这里的SendChannel
接口,就表示一个管道发送方,所以它约束了一些统一操作:send
、trySend
等。
而抽象类,更多的是公共代码的抽取,或者一个抽象事务的基本实现。比如这里的AbstractChannel<E>
就代表传递E类型的抽象管道实现,在里面实现了大多数的公共函数功能。 -
这里
Channel
接口,继承至SendChannel
和ReceiveChannel
,即把发送端和接收端给分开了,根据接口的定义,Channel
就是具有发送端和接收端的管道。 -
这里
AbstractChannel
代表发送方的抽象实现或者公共实现,构造函数的参数可以接收发送失败的回调处理。
搞明白这几个抽象类,我们接下来就很好分析了。
LockFreeLinkedList简析
首先是AbstractChannel
,为什么发送端单独需要抽离出一个抽象类呢?这也是因为,发送端的逻辑比较复杂,同时它还也是Channel是线程安全的核心实现点。
在AbstractChannel
中,有下面一个变量:
internal abstract class AbstractSendChannel<E>(
@JvmField protected val onUndeliveredElement: OnUndeliveredElement<E>?
) : SendChannel<E>
protected val queue = LockFreeLinkedListHead()
...
可以发现这是一个queue
,即队列,同时它还是一个线程安全的队列,从LockFreeLinkedList
就可以看出,它是一个没有使用锁Lock
的LinkedList
。
//Head只是一个哨兵节点
public actual open class LockFreeLinkedListHead : LockFreeLinkedListNode()
//线程安全的双向链表
public actual open class LockFreeLinkedListNode
private val _next = atomic<Any>(this) // Node | Removed | OpDescriptor
private val _prev = atomic(this) // Node to the left (cannot be marked as removed)
private val _removedRef = atomic<Removed?>(null)
关于这个数据结构,这里不做过多分析,等后面有时间可以专门研究一下,这个线程安全的数据结构,有如下特点:
- 它是一个双向链表结构,按理说双向链表的插入可以从头或者尾都是可以的,但是在这里,定义了插入只能是尾部,即右边;而获取元素,只能从头部,即左边。
- 它有一个哨兵节点,哨兵节点是不存储数据的,它的
next
节点是数据节点的头节点,它的pre节点是数据节点的尾节点,当数据节点为空时,依旧有哨兵节点。 - 该数据结构中,保存数据使用了
atomic
,即CAS
技术,这样可以保证这个链表的操作是线程安全的。
到这里,我们已经知道了在AbstractChannel
中存在一个线程安全的双向队列,至于节点保存的数据是什么,后面待会再分析。
send
流程分析
我们以文章开始的测试代码为例,当调用send(0)时,实现方法就是AbstractChannel中:
//发送数据
public final override suspend fun send(element: E)
// fast path -- try offer non-blocking
if (offerInternal(element) === OFFER_SUCCESS) return
// slow-path does suspend or throws exception
//挂起函数
return sendSuspend(element)
在该方法中,有2个分支,当offerInternal
方法返回结果为OFFER_SUCCESS
时,就直接return
,否则调用挂起发送函数sendSuspend
。
看到这个offerInternal(element)
方法,我相信肯定会立马和前面所说的队列结合起来,因为offer这个单词就属于队列中的一种术语,表示增加的意思,和add
一样,但是返回值不一样。
所以我们可以大致猜出该方法作用:把element
添加到队列中,如果添加成功,则直接返回,否则则挂起。我们来看看offerInternal()
方法:
//尝试往buffer中增加元素,或者给消费者增加元素
protected open fun offerInternal(element: E): Any
while (true)
val receive = takeFirstReceiveOrPeekClosed() ?: return OFFER_FAILED
val token = receive.tryResumeReceive(element, null)
if (token != null)
assert token === RESUME_TOKEN
receive.completeResumeReceive(element)
return receive.offerResult
该方法会往buffer
中或者消费者增加数据,会成功返回数据,或者增加失败。
根据前面我们设置的是默认Channel
,是没有buffer
的,且没有调用receive
,即也没有消费者,所以这里会直接返回OFFER_FAILED
。
所以我们执行流程跳转到sendSuspend
:
//send的挂起函数
private suspend fun sendSuspend(element: E): Unit = suspendCancellableCoroutineReusable sc@ cont ->
loop@ while (true)
//buffer是否已满,本例中,是满的
if (isFullImpl)
//封装为SendElement
val send = if (onUndeliveredElement == null)
SendElement(element, cont) else
SendElementWithUndeliveredHandler(element, cont, onUndeliveredElement)
//入队
val enqueueResult = enqueueSend(send)
when
enqueueResult == null -> // enqueued successfully
cont.removeOnCancellation(send)
return@sc
enqueueResult is Closed<*> ->
cont.helpCloseAndResumeWithSendException(element, enqueueResult)
return@sc
enqueueResult === ENQUEUE_FAILED -> // try to offer instead
enqueueResult is Receive<*> -> // try to offer instead
else -> error("enqueueSend returned $enqueueResult")
...
这就是send的挂起函数方式实现,分析:
-
这里使用
suspendCancellableCoroutineReusable
挂起函数,和我们之前所说的suspendCancellableCoroutine
高阶函数一样,属于能接触到的最底层实现挂起函数的方法了,其中cont就是用来向挂起函数外部传递数据。 -
在实现体中,首先判断
isFullImpl
即是否满了,由于本例测试代码的Channel
是没有容量的,所以是满的。 -
然后把
element
和cont
封装为SendElement
对象,这里的element就是我们之前所发送的0, 而continuation
则代表后续的操作。
这个SendElement
类定义如下:
//发送元素
internal open class SendElement<E>(
override val pollResult: E,
@JvmField val cont: CancellableContinuation<Unit>
) : Send()
override fun tryResumeSend(otherOp: PrepareOp?): Symbol?
val token = cont.tryResume(Unit, otherOp?.desc) ?: return null
assert token === RESUME_TOKEN // the only other possible result
// We can call finishPrepare only after successful tryResume, so that only good affected node is saved
otherOp?.finishPrepare() // finish preparations
return RESUME_TOKEN
override fun completeResumeSend() = cont.completeResume(RESUME_TOKEN)
override fun resumeSendClosed(closed: Closed<*>) = cont.resumeWithException(closed.sendException)
override fun toString(): String = "$classSimpleName@$hexAddress($pollResult)"
从这里我们可以看出,这个Element
就是把要发送的元素和Continuation
给包装起来,而前面所说的双向链表中的元素也就是这种Element
。
- 接着调用
enqueueSend
方法,把上面这个Element
入队,根据该方法的返回值定义,这里会返回null
,表示插入成功。 - 然后当入队成功时,会调用下面代码块:
enqueueResult == null -> // enqueued successfully
cont.removeOnCancellation(send)
return@sc
这里先是给cont
设置了一个监听:
//给CancellableContinuation设置监听
internal fun CancellableContinuation<*>.removeOnCancellation(node: LockFreeLinkedListNode) =
invokeOnCancellation(handler = RemoveOnCancel(node).asHandler)
//当Continuation被取消时,节点自动从队列中remove掉
private class RemoveOnCancel(private val node: LockFreeLinkedListNode) : BeforeResumeCancelHandler()
override fun invoke(cause: Throwable?) node.remove()
override fun toString() = "RemoveOnCancel[$node]"
这个监听作用就是当Continuation
执行完成或者被取消时,该节点可以从双向队列中被移除。
然后就是return@sc
,这里是不是很疑惑呢?在以前我们实现挂起函数时,都是通过continuation
的resume
方法来传递挂起函数的值,同时也是恢复的步骤,这里居然没有恢复。那这个挂起函数该什么时候恢复呢?Channel是如何来恢复的呢?
receive流程分析
我们接着分析,其实就是当调用receive()
的时候。
receive()
的实现,根据前面分析就是在AbstractChannel
中:
//接收方法的实现
public final override suspend fun receive(): E
// fast path -- try poll non-blocking
val result = pollInternal()
@Suppress("UNCHECKED_CAST")
if (result !== POLL_FAILED && result !is Closed<*>) return result as E
// slow-path does suspend
return receiveSuspend(RECEIVE_THROWS_ON_CLOSE)
这里同样是类似的逻辑,首先是pollInternal
方法,这里的poll
同样和offer
一样,属于队列的术语,有轮询的意思,和remove
类似的意思,所以该方法就是从队列中取出元素,我们来看看实现:
//尝试从buffer或者发送端中取出元素
protected open fun pollInternal(): Any?
while (true)
//取出SendElement
val send = takeFirstSendOrPeekClosed() ?: return POLL_FAILED
//注释1
val token = send.tryResumeSend(null)
if (token != null)
assert token === RESUME_TOKEN
//注释2
send.completeResumeSend()
return send.pollResult
// too late, already cancelled, but we removed it from the queue and need to notify on undelivered element
send.undeliveredElement()
根据前面我们send
的流程,这时可以成功取出我们之前入队的SendElement
对象,然后调用注释2处的send.completeResumeSend()
方法:
override fun completeResumeSend() = cont.completeResume(RESUME_TOKEN)
这里会调用continuation
的completeResume
方法,这里就需要结合前面文章所说的原理了,其实这个continuation就是状态机,它会回调CancellableContinuationImpl
中的completeResume
:
override fun completeResume(token: Any)
assert token === RESUME_TOKEN
dispatchResume(resumeMode)
而该类的继承关系:
internal open class CancellableContinuationImpl<in T>(
final override val delegate: Continuation<T>,
resumeMode: Int
) : DispatchedTask<T>(resumeMode), CancellableContinuation<T>, CoroutineStackFrame
这里相关的类,我们在线程调度那篇文章中有所提及,这里的dispatchResume
:
private fun dispatchResume(mode: Int)
if (tryResume()) return // completed before getResult invocation -- bail out
// otherwise, getResult has already commenced, i.e. completed later or in other thread
dispatch(mode)
internal fun <T> DispatchedTask<T>.dispatch(mode: Int)
...
if (dispatcher.isDispatchNeeded(context))
dispatcher.dispatch(context, this)
...
这里最终会调用dispatcher.dispatch()
方法,而这个我们在之前调度器文章说过,这个最后会在Java线程池上执行,从而开始状态机。
既然该状态机恢复了,也就是前面send
流程中的挂起也恢复了。
当send
挂起函数恢复后,再通过
return send.pollResult
就可以获取我们之前发送的值0了。
同样的,当pollInternal
方法中,无法poll
出SendElement
,则会调用receiveSuspend
挂起方法:
private suspend fun <R> receiveSuspend(receiveMode: Int): R = suspendCancellableCoroutineReusable sc@ cont ->
val receive = if (onUndeliveredElement == null)
ReceiveElement(cont as CancellableContinuation<Any?>, receiveMode) else
ReceiveElementWithUndeliveredHandler(cont as CancellableContinuation<Any?>, receiveMode, onUndeliveredElement)
while (true)
if (enqueueReceive(receive))
removeReceiveOnCancel(cont, receive)
return@sc
// hm... something is not right. try to poll
val result = pollInternal()
if (result is Closed<*>)
receive.resumeReceiveClosed(result)
return@sc
if (result !== POLL_FAILED)
cont.resume(receive.resumeValue(result as E), receive.resumeOnCancellationFun(result as E))
return@sc
和send
类似,这里也会封装为ReceiveElement
,同时入队到队列中,等待着send方法来恢复这个协程。
"热"的探究
分析完默认的Channel
的发送和接收,我们来探究一下为什么Channel是热的。
这里所说的热是因为Channel会在不管有没有接收者的情况下,都会执行发送端的操作,当策略为Suspend
时,它会一直持续到管道容量满。
这里我们还是拿之前文章的例子:
fun main() = runBlocking
//创建管道 val channel = produce(capacity = 10)
(1 .. 3).forEach
send(it)
logX("Send $it")
logX("end")
这里虽然没有调用receive
方法,即没有消费者,send依旧会执行,也就是"热"的。
根据前面所说的Channel()
顶层函数源码,这里容量为10,策略不变,最终会创建出ArrayChannel
实例。
该类定义:
internal open class ArrayChannel<E>(
/**
* Buffer capacity.
*/
private val capacity: Int,
private val onBufferOverflow: BufferOverflow,
onUndeliveredElement: OnUndeliveredElement<E>?
) : AbstractChannel<E>(onUndeliveredElement)
这里同样是AbstractChannel
的子类,所以send方法还是依旧:
public final override suspend fun send(element: E)
// fast path -- try offer non-blocking
if (offerInternal(element) === OFFER_SUCCESS) return
// slow-path does suspend or throws exception
return sendSuspend(element)
还是先尝试往队列中offer数据,当无法offer时,执行挂起;但是这里的offerInternal
方法在ArrayChannel
中被重写了:
//ArrayChannel中的方法
protected override fun offerInternal(element: E): Any
//接收者
var receive: ReceiveOrClosed<E>? = null
//当多个线程都同时调用该方法时,为了容量安全,这里进行加锁
lock.withLock
//元素个数
val size = this.size.value
//发送已经关闭,直接返回
closedForSend?.let return it
// update size before checking queue (!!!)
//在入队之前,更新管道容量,当元素小于管道容量,返回null
//只有管道中的元素个数,大于管道容量时,该方法才会return
//根据策略,会返回挂起或者丢弃或者失败等
updateBufferSize(size)?.let return it
...
//容量没满时,把元素入队
enqueueElement(size, element)
//返回入队成功
return OFFER_SUCCESS
...
在这里我们可以发现,不管有没有接收者的情况下,当我们多次调用send方法,当队列没满时,在这里都会返回OFFER_SUCCESS
,即发送端已经在工作了,所以也就是我们所说的热的效果。
总结
Channel
作为线程安全的管道,可以在协程之间通信,同时可以实现交替执行的效果,通过本篇文章学习,我相信已经知道其原因了。小小总结一下:
Channel
接口在设计时就非常巧妙,充分利用了接口和抽象,把发送端和接收端能力分开,这个值得我们学习。Channel
的线程安全原因是发送端维护了一个线程安全的双向队列:LockFreeLinkedList,我们把值和continutaion
封装为SendElement/ReceiveElement
保存其中,这样就保证了线程安全。Channel
的发送和接收挂起函数的恢复时机,是通过队列中的continuation
控制,在CancellableContinuationImpl
进行直接恢复,而不是我们常见的调用resumeWith
方法。
作者:元浩875
链接:https://juejin.cn/post/7172451416340955144
最后
如果想要成为架构师或想突破20~30K薪资范畴,那就不要局限在编码,业务,要会选型、扩展,提升编程思维。此外,良好的职业规划也很重要,学习的习惯很重要,但是最重要的还是要能持之以恒,任何不能坚持落实的计划都是空谈。
如果你没有方向,这里给大家分享一套由阿里高级架构师编写的《android八大模块进阶笔记》,帮大家将杂乱、零散、碎片化的知识进行体系化的整理,让大家系统而高效地掌握Android开发的各个知识点。
相对于我们平时看的碎片化内容,这份笔记的知识点更系统化,更容易理解和记忆,是严格按照知识体系编排的。
全套视频资料:
一、面试合集
二、源码解析合集
三、开源框架合集
欢迎大家一键三连支持,若需要文中资料,直接点击文末CSDN官方认证微信卡片免费领取↓↓↓
以上是关于协程(22) | Channel原理解析的主要内容,如果未能解决你的问题,请参考以下文章
go语言之行--golang核武器goroutine调度原理channel详解
Kotlin 协程Channel 通道 ① ( Channel#send 发送数据 | Channel#receive 接收数据 )
Kotlin 协程Channel 通道 ① ( Channel#send 发送数据 | Channel#receive 接收数据 )