Go语言的CSP模型

go语言的最大两个俩点,一个是goRoutine,一个就是chan了。俩者合体的典型应用CSP基本就是大家认可的并行开发神器

CSP是什么

CSP是communicating Sequential Process的简称,中文可以叫做通信顺序进程,是一种并发编程模型,是一个很强大的并发数据模型,是上世纪七十年代提出的,用于描述俩个独立的并发实体通过共享机制的通讯channel(管道)进行通信的并发模型。想对于Actor模型,CSP中的channel是第一类对象,它不关注发送消息的实体,而关注发送消息时使用的channel。

严格来说,CSP是一门形式语言(类似于 ℷ calculus),用于描述并发系统中的互动模式,也因此成为一众面向并发的编程语言的理论源头,并衍生出了 Occam/Limbo/Golang…

而具体到编程语言,如 Golang,其实只用到了 CSP 的很小一部分,即理论中的Process/Channel对应到语言中的goroutine/channel

这俩个并发原语之间没有从属关系,Process可以订阅任意个Channel,Channel也不关心是哪个Process在利用它尽显通信;

Process围绕Channel进行读写,行程一套有序阻塞和可预测的并发模型。

Golang CSP

与主流语言通过共享内存来进行并发控制方式不同,Go 语言采用了 CSP 模式。这是一种用于描述两个独立的并发实体通过共享的通讯 Channel(管道)进行通信的并发模型。

Golang 就是借用CSP模型的一些概念为之实现并发进行理论支持,其实从实际上出发,go语言并没有,完全实现了CSP模型的所有理论,仅仅是借用了 process和channel这两个概念。process是在go语言上的表现就是 goroutine 是实际并发执行的实体,每个实体之间是通过channel通讯来实现数据共享。

Go语言的CSP模型是由协程Goroutine与通道Channel实现:

  • Go协程goroutine: 是一种轻量线程,它不是操作系统的线程,而是将一个操作系统线程分段使用,通过调度器实现协作式调度。是一种绿色线程,微线程,它与Coroutine协程也有区别,能够在发现堵塞后启动新的微线程。
  • 通道channel: 类似Unix的Pipe,用于协程之间通讯和同步。协程之间虽然解耦,但是它们和Channel有着耦合。

Channel

Goroutine 和 channel 是 Go 语言并发编程的 两大基石。Goroutine 用于执行并发任务,channel 用于 goroutine 之间的同步、通信。

Channel 在 gouroutine 间架起了一条管道,在管道里传输数据,实现 gouroutine 间的通信;由于它是线程安全的,所以用起来非常方便;channel 还提供 “先进先出” 的特性;它还能影响 goroutine 的阻塞和唤醒。

相信大家一定见过一句话:

Do not communicate by sharing memory; instead, share memory by communicating.

不要通过共享内存来通信,而要通过通信来实现内存共享。

这就是 Go 的并发哲学,它依赖 CSP 模型,基于 channel 实现。

channel 实现 CSP

Channel 是 Go 语言中一个非常重要的类型,是 Go 里的第一对象。通过 channel,Go 实现了通过通信来实现内存共享。Channel 是在多个 goroutine 之间传递数据和同步的重要手段。

使用原子函数、读写锁可以保证资源的共享访问安全,但使用 channel 更优雅。

channel 字面意义是 “通道”,类似于 Linux 中的管道。声明 channel 的语法如下:

chan T // 声明一个双向通道
chan<- T // 声明一个只能用于发送的通道
<-chan T // 声明一个只能用于接收的通道COPY

单向通道的声明,用 <- 来表示,它指明通道的方向。你只要明白,代码的书写顺序是从左到右就马上能掌握通道的方向是怎样的。

因为 channel 是一个引用类型,所以在它被初始化之前,它的值是 nil,channel 使用 make 函数进行初始化。可以向它传递一个 int 值,代表 channel 缓冲区的大小(容量),构造出来的是一个缓冲型的 channel;不传或传 0 的,构造的就是一个非缓冲型的 channel。

两者有一些差别:非缓冲型 channel 无法缓冲元素,对它的操作一定顺序是 “发送 -> 接收 -> 发送 -> 接收 -> ……”,如果想连续向一个非缓冲 chan 发送 2 个元素,并且没有接收的话,第一次一定会被阻塞;对于缓冲型 channel 的操作,则要 “宽松” 一些,毕竟是带了 “缓冲” 光环。

对chan的发送和接受操作都会在编译期间转换为底层发送接收函数。

Channel分为俩种模式:带缓冲、不带缓冲。对不带缓冲的channel进行实际操作可以看做同步模式,带缓冲的则称为异步模式。

同步模式下,发送方和接收方要同步就绪,只有在俩者都ready的情况下,数据才能在俩者间传输(后面会看到实际就是内存拷贝)。否则,任意乙方先进性发送或接收操作,都会被挂起,等待另一方的出现才能被唤醒。

同步模式下,必须要使发送方和接受方配对,操作才会成功,否则会被阻塞;异步模式下,缓冲槽要有余量,否则也会被阻塞

简单来说,CSP 模型由并发执行的实体(线程或者进程或者协程)所组成,实体之间通过发送消息进行通信,
这里发送消息时使用的就是通道,或者叫 channel。

CSP 模型的关键是关注 channel,而不关注发送消息的实体。Go 语言实现了 CSP 部分理论,goroutine 对应 CSP 中并发执行的实体,channel 也就对应着 CSP 中的 channel。

Goroutine

Goroutine是实际并发执行的实体,它底层是使用协程(coroutine)实现并发,coroutine是一种运行在用户态的用户线程,类似于greenthread,greenthread,go底层选择使用coroutine的出发点是因为,它具有以下特点:

  • 用户空间 避免了内核态和用户态的切换导致的成本
  • 可以由语言和框架进行调度
  • 更小的栈空间允许创建大量的实例

可以看到第二条,用户空间线程的调度不是由操作系统来完成的,像java1.3中使用的greenthread的是由JVM统一调度的(后java已经改为内核线程),还有在ruby中的fiber(半协程) 是需要在重新中自己进行调度的,而goroutine是在golang层面提供了调度器,并且对网络IO库进行了封装,屏蔽了复杂的细节,对外提供统一的语法关键字支持,简化了并发程序编写的成本。

Goroutine调度器

Go并发调度G-P-M模型

在操作系统提供的内核线程之上,Go搭建了一个特有的俩级线程模型。goroutine机制实现了M:N的线程模型,goroutine机制是携程(coroutine)的一种实现,golang内置的调度器,可以让多喝CPU中的每个CPU执行一个携程。

最后

Golang 的 channel 将 goroutine 隔离开,并发编程的时候可以将注意力放在 channel 上。在一定程度上,这个和消息队列的解耦功能还是挺像的。如果大家感兴趣,还是来看看 channel 的源码吧,对于更深入地理解 channel 还是挺有用的。

Go 通过 channel 实现 CSP 通信模型,主要用于 goroutine 之间的消息传递和事件通知。

有了 channel 和 goroutine 之后,Go 的并发编程变得异常容易和安全,得以让程序员把注意力留到业务上去,实现开发效率的提升。

要知道,技术并不是最重要的,它只是实现业务的工具。一门高效的开发语言让你把节省下来的时间,留着去做更有意义的事情,比如写写文章。

代码

package main

import "fmt"

func main() {
    var a chan int
    if a == nil {
        fmt.Println("channel 是 nil 的, 不能使用,需要先创建通道。。")
        a = make(chan int)
        fmt.Printf("数据类型是:%T", a)
    }
}

运行程序输出结果:

channel 是 nil 的, 不能使用,需要先创建通道。。数据类型是:chan int

也可以简短的声明;

a := make(chan int)

channel的数据类型

channel是引用数据类型,在作为参数传递的时候,传递的是内存地址

package main

import (
    "fmt"
)

func main() {
    ch1 := make(chan int)
    fmt.Printf("%T,%p\n",ch1,ch1)

    test1(ch1)
}

func test1(ch chan int){
    fmt.Printf("%T,%p\n",ch,ch)
}

运行结果为

chan int,0xc00008c060
chan int,0xc00008c060

我们能看到ch和ch1的地址是一样的,说明它们是一个通道

注意事项

Channel通道在使用的时候,有以下几个注意点:

  • 用于goroutine传递消息的
  • 通道,每个都有相关联的数据类型,nil chan,不能使用,类似于nil map,不能直接存储键值对
  • 使用通道传递数据:chan<-data,发送数据到通道(向通道中写数据);
    data<-chan,从通道读取数据(从通道中读取数据)
  • 阻塞:发送数据:chan <- data,阻塞的,直到另一条goroutine,读取数据来解除阻塞;读取数据:data <- chan,也是阻塞的。直到另一条goroutine,写出数据解除阻塞。
  • 本身channel就是同步的,意味着同一时间,只能有一条goroutine来操作。

最后:通道是goroutine之间的连接,所以通道的发送和接收必须处在不同的goroutine中。

Channel的使用

发送和接受

一个通道发送和接收数据,默认是阻塞的。当一个数据被发送到通道时,在发送语句中被阻塞,直到另一个Goroutine从该通道读取数据。相对地,当从通道读取数据时,读取被阻塞,直到一个Goroutine将数据写入该通道。

这些通道的特性是帮助Goroutines有效地进行通信,而无需像使用其他编程语言中非常常见的显式锁或条件变量。

package main

import "fmt"

func main() {
    var ch1 chan bool       //声明,没有创建
    fmt.Println(ch1)        //
    fmt.Printf("%T\n", ch1) //chan bool
    ch1 = make(chan bool)   //0xc0000a4000,是引用类型的数据
    fmt.Println(ch1)

    go func() {
        for i := 0; i < 10; i++ {
            fmt.Println("子goroutine中,i:", i)
        }
        // 循环结束后,向通道中写数据,表示要结束了。。
        ch1 <- true

        fmt.Println("结束。。")

    }()

    data := <-ch1 // 从ch1通道中读取数据
    fmt.Println("data-->", data)
    fmt.Println("main。。over。。。。")
}

运行结果

<nil>
chan bool
0xc00011c000
子goroutine中,i:0
子goroutine中,i:1
子goroutine中,i:2
子goroutine中,i:3
子goroutine中,i:4
子goroutine中,i:5
子goroutine中,i:6
子goroutine中,i:7
子goroutine中,i:8
子goroutine中,i:9
结束。。
data--> true
main。。over。。。。

在上面的程序中,我们先创建了一个chan bool通道。

然后启动了一条子Goroutine,并循环打印10个数字。

然后我们向通道ch1中写入输入true。

然后在主goroutine中,我们从ch1中读取数据。这一行代码是阻塞的,这意味着在子Goroutine将数据写入到该通道之前,主goroutine将不会执行到下一行代码。

因此,我们可以通过channel实现子goroutine和主goroutine之间的通信。当子goroutine执行完毕前,主goroutine会因为读取ch1中的数据而阻塞。从而保证了子goroutine会先执行完毕。

这就消除了对时间的需求。在之前的程序中,我们要么让主goroutine进入睡眠,以防止主要的Goroutine退出。要么通过WaitGroup来保证子goroutine先执行完毕,主goroutine才结束。

Last modification:November 20, 2023
如果觉得我的文章对你有用,请随意赞赏