协程(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
*/

在这里会发现输出结果是交替执行的,这是因为Channelsendreceive是挂起函数,而默认参数创建的Channel是没有缓存容量的,所以调用完send后,如果没有消费者来消费,就会挂起;同理receive也是如此,这些知识点我们在之前学习Channel文章时,已经说过这些特性了。
再结合挂起函数的本质,这种交替执行的输出结果,我相信都能明白。本篇文章,就来探索一下,Channel到底是如何实现的。
和我们之前分析的CoroutineScopeJob等类似,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实例,比如RendezvousChannelArrayChannel等,我们等会以默认的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接口,就表示一个管道发送方,所以它约束了一些统一操作:sendtrySend等。
    而抽象类,更多的是公共代码的抽取,或者一个抽象事务的基本实现。比如这里的AbstractChannel<E>就代表传递E类型的抽象管道实现,在里面实现了大多数的公共函数功能。

  • 这里Channel接口,继承至SendChannelReceiveChannel,即把发送端和接收端给分开了,根据接口的定义,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就可以看出,它是一个没有使用锁LockLinkedList

//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是没有容量的,所以是满的。

  • 然后把elementcont封装为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,这里是不是很疑惑呢?在以前我们实现挂起函数时,都是通过continuationresume方法来传递挂起函数的值,同时也是恢复的步骤,这里居然没有恢复。那这个挂起函数该什么时候恢复呢?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)

这里会调用continuationcompleteResume方法,这里就需要结合前面文章所说的原理了,其实这个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方法中,无法pollSendElement,则会调用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

Kotlin 协程Channel 通道 ① ( Channel#send 发送数据 | Channel#receive 接收数据 )

Kotlin 协程Channel 通道 ① ( Channel#send 发送数据 | Channel#receive 接收数据 )

Unity 协程深入解析与原理

协程库st(state threads library)原理解析