线程并发-通信模型(消息同步模型)-通道(Golang CSP模型)

189 阅读4分钟

应用场景

在生产者-消费者模型的应用场景下,很自然地会想到线程安全队列 !生产者把产品放到队尾而消费者从队首取出产品消费掉。事实上,人们甚至根据生产者一个还是多个,消费者有一个还是多个,排列组合一下,将线程安全队列分成很多种,然后根据有界(bounded)无界(unbounded)。
Go语言中的buffered channel也可以认为是一种有界多生产者多消费者线程安全队列(Bounded MPMC queue)。在线程并发场景下,buffered channel是bounded MPMC queue,是CSP的实践之一,与Monitor或semaphore有同等表达能力。

概念

CSP(communicating sequential processes)也叫通信顺序进程,是一种并发编程模型,是一个很强大的并发数据模型,是上个世纪七十年代提出的,用于描述两个独立的并发实体通过共享的通讯 channel(管道)进行通信的并发模型。相对于Actor模型,CSP中channel是第一类对象,它不关注发送消息的实体,而关注在发送消息时使用的channel。
严格来说,CSP 是一门形式语言(类似于 ℷ calculus),用于描述并发系统中的互动模式,也因此成为一众面向并发的编程语言的理论源头,并衍生出了 Occam/Limbo/Golang… 而具体到编程语言,如 Golang,其实只用到了 CSP 的很小一部分,即理论中的 Process/Channel(对应到语言中的 goroutine/channel):这两个并发原语之间没有从属关系,Process 可以订阅任意个 Channel,Channel 也并不关心是哪个 Process 使用它进行通信;Process 围绕 Channel 进行读写,形成一套有序阻塞和可预测的并发模型。

Go中的CSP模型

channel的概念

channel是用来传递数据的一个数据结构,是golang中的主要协程间通信模型,是CSP模型的核心实践。这意味着同步通常也是用channel做的,而golang通常也不建议使用mutex、condition_variable等经典的同步方式。

channel的实现

  • 功能 channel可用于两个 goroutine 之间通过传递一个指定类型的值来同步运行和通讯。操作符 <- 用于指定通道的方向,发送或接收。如果未指定方向,则为双向通道。如果通道接收不到数据后 ok 就为 false,这时通道就可以使用 close() 函数来关闭。
  • 底层数据结构
    type hchan struct {
    // chan 里元素数量
        qcount   uint
    // chan 底层循环数组的长度
    dataqsiz uint
    // 指向底层循环数组的指针
    // 只针对有缓冲的 channel
    buf      unsafe.Pointer
    // chan 中元素大小
    elemsize uint16
    // chan 是否被关闭的标志
    closed   uint32
    // chan 中元素类型
    elemtype *_type // element type
    // 已发送元素在循环数组中的索引
    sendx    uint   // send index
    // 已接收元素在循环数组中的索引
    recvx    uint   // receive index
    // 等待接收的 goroutine 队列
    recvq    waitq  // list of recv waiters
    // 等待发送的 goroutine 队列
    sendq    waitq  // list of send waiters
    
    // 保护 hchan 中所有字段
    lock mutex
    }
    
    buf 指向底层循环数组,只有缓冲型的 channel 才有
    sendxrecvx 均指向底层循环数组,表示当前可以发送和接收的元素位置索引值(相对于底层数组) sendqrecvq 分别表示被阻塞的 goroutine,这些 goroutine 由于尝试读取 channel 或向 channel 发送数据而被阻塞
    waitq 是 sudog 的一个双向链表,而 sudog 实际上是对 goroutine 的一个封装
    lock 用来保证每个读 channel 或写 channel 的操作都是原子的

多个通道

  • select的概念
    模仿I/O多路复用机制,select可以在一个语句里使用多个 case 同时监听多个 channl 的读写状态。select语句是channel的重要特性, 其他支持channel的语言通常也有select类似物,比如clojure的alt!宏,rust的select!宏。而select语句因为要锁全部channel, 可能性能不高

  • select的实现
    在解释为什么这么写之前,我们得先说明一下select是怎么work的,golang中,select可以分成以下几个主要步骤:

    1. 执行到 select 时,会锁住所有的 channl 并且,打乱 case 结构体的顺序
    2. 按照打乱的顺序遍历,如果有就绪的信号,就直接走对应 case 的代码段,之后跳出 select
    3. 如果没有就绪的代码段,但是有 default 字段,那就走 default 的代码段,之后跳出 select
    4. 如果没有 default,那就将当前 goroutine 加入所有 channl 的对应等待队列
    5. 当某一个等待队列就绪时,再次锁住所有的 channl,遍历一遍,将所有等待队列中的 goroutine 取出,之后执行就绪的代码段,跳出select
  • case的实现
    case 对应的 channel 都会被封装到一个结构体中,每一个 case 对应的数据结构如下:

    type scase struct {
        c           *hchan         // chan
        elem        unsafe.Pointer // 读或者写的缓冲区地址
        kind        uint16   //case语句的类型,是default、传值写数据(channel <-) 还是  取值读数据(<- channel)
        pc          uintptr // race pc (for race detector / msan)
        releasetime int64
    }