Go CSP 介绍

Go CSP 介绍

1 简介

​ 本文档主要介绍了Go的Channel结构和CSP并发模型。
提到Go的CSP模型就离不开它的GPM调度模型,所以大家有兴趣的也可以看看Go的GPM调度器介绍。 Go GPM调度模型介绍
​ CSP模型(Communicating Sequential Process)是上个世纪七十年代提出的,用于描述两个独立的并发实体通过共享的通讯 channel(管道)进行通信的并发模型。 CSP中channel是第一类对象,它不关注发送消息的实体,而关注与发送消息时使用的channel。

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

2 Go Channel

​ channel是Golang在语言层面提供的goroutine间的通信方式。channel主要用于进程内各goroutine间通信,如果需要跨进程通信,建议使用分布式系统的方法来解决。

2.1 chan数据结构

src/runtime/chan.go:hchan定义了channel的数据结构:

type hchan struct {
	qcount   uint           // 当前队列中剩余元素个数
	dataqsiz uint           // 环形队列长度,即可以存放的元素个数
	buf      unsafe.Pointer // 环形队列指针
	elemsize uint16         // 每个元素的大小
	closed   uint32	        // 标识关闭状态
	elemtype *_type         // 元素类型
	sendx    uint           // 队列下标,指示元素写入时存放到队列中的位置
	recvx    uint           // 队列下标,指示元素从队列的该位置读出
	recvq    waitq          // 等待读消息的goroutine队列
	sendq    waitq          // 等待写消息的goroutine队列
	lock mutex              // 互斥锁,chan不允许并发读写
}

从数据结构可以看出channel由队列、类型信息、goroutine等待队列组成。

2.2 环形队列

​ chan内部实现了一个环形队列作为其缓冲区,队列的长度是创建chan时指定的。
在这里插入图片描述

  • dataqsiz指示了队列长度为6,即可缓存6个元素;
  • buf指向队列的内存,队列中还剩余两个元素;
  • qcount表示队列中还有两个元素;
  • sendx指示后续写入的数据存储的位置,取值[0, 6);
  • recvx指示从该位置读取数据, 取值[0, 6);

2.3 等待队列

​ 从channel读数据,如果channel缓冲区为空或者没有缓冲区,当前goroutine会被阻塞。
​ 向channel写数据,如果channel缓冲区已满或者没有缓冲区,当前goroutine会被阻塞。

​ 被阻塞的goroutine将会挂在channel的等待队列中:

  • 因读阻塞的goroutine会被向channel写入数据的goroutine唤醒;
  • 因写阻塞的goroutine会被从channel读数据的goroutine唤醒;

下图展示了一个没有缓冲区的channel,有几个goroutine阻塞等待读数据:
在这里插入图片描述

​ 注意,一般情况下recvq和sendq至少有一个为空。只有一个例外,那就是同一个goroutine使用select语句向channel一边写数据,一边读数据。

2.4 类型信息

一个channel只能传递一种类型的值,类型信息存储在hchan数据结构中。

  • elemtype代表类型,用于数据传递过程中的赋值;
  • elemsize代表类型大小,用于在buf中定位元素位置。

2.5 锁

​ 一个channel同时仅允许被一个goroutine读写。

2.6 channel读写

2.6.1 创建channel

​ 创建channel的过程实际上是初始化hchan结构。其中类型信息和缓冲区长度由make语句传入,buf的大小则与元素大小和缓冲区长度共同决定。

创建channel的伪代码如下所示:

func makechan(t *chantype, size int) *hchan {
	var c *hchan
	c = new(hchan)
	c.buf = malloc(元素类型大小*size)
	c.elemsize = 元素类型大小
	c.elemtype = 元素类型
	c.dataqsiz = size

	return c
}
2.6.2 向channel写数据

​ 向一个channel中写数据简单过程如下:

  1. 如果等待接收队列recvq不为空,说明缓冲区中没有数据或者没有缓冲区,此时直接从recvq取出G,并把数据写入,最后把该G唤醒,结束发送过程;
  2. 如果缓冲区中有空余位置,将数据写入缓冲区,结束发送过程;
  3. 如果缓冲区中没有空余位置,将待发送数据写入G,将当前G加入sendq,进入睡眠,等待被读goroutine唤醒;

简单流程图如下:

在这里插入图片描述

2.6.2 向channel读数据

​ 从一个channel读数据简单过程如下:

  1. 如果等待发送队列sendq不为空,且没有缓冲区,直接从sendq中取出G,把G中数据读出,最后把G唤醒,结束读取过程;
  2. 如果等待发送队列sendq不为空,此时说明缓冲区已满,从缓冲区中首部读出数据,把G中数据写入缓冲区尾部,把G唤醒,结束读取过程;
  3. 如果缓冲区中有数据,则从缓冲区取出数据,结束读取过程;
  4. 将当前goroutine加入recvq,进入睡眠,等待被写goroutine唤醒;

简单流程图如下:
在这里插入图片描述

2.6.3 关闭channel

​ 关闭channel时会把recvq中的G全部唤醒,本该写入G的数据位置为nil。把sendq中的G全部唤醒,但这些G会panic。

​ 除此之外,panic出现的常见场景还有:

  1. 关闭值为nil的channel
  2. 关闭已经被关闭的channel
  3. 向已经关闭的channel写数据

3 Go Goroutine

​ Go语言最大的特色就是从语言层面支持并发(goroutine),goroutine是Go中最基本的执行单元。事实上每一个Go程序至少有一个goroutine:主goroutine。当程序启动时,它会自动创建。我们在使用Go语言进行开发时,一般会使用goroutine来处理并发任务。

​ goroutine机制有点像线程池:

​ go 内部有三个对象: P(processor) 代表上下文(M所需要的上下文环境,也就是处理用户级代码逻辑的处理器),M(work thread)代表内核线程,G(goroutine)协程。正常情况下一个cpu核运行一个内核线程,一个内核线程运行一个goroutine协程。当一个goroutine阻塞时,会启动一个新的内核线程来运行其他goroutine,以充分利用cpu资源。所以线程往往会比cpu核数更多。

​ 例如:

​ 在单核情况下,所有goroutine运行在同一个内核线程(M0)中,每一个内核线程维护一个上下文(P),任何时刻,一个上下文中只有一个goroutine,其他goroutine在runqueue中等待。一个goroutine运行完自己的时间片后,让出上下文,自己回到runqueue中。如下图左边所示,只有一个G0在运行,而其他goroutine都挂起了。

在这里插入图片描述

​ 当正在运行的G0阻塞的时候(IO之类的),会再创建一个新的内核线程(M1),P转到新的内核线程中去运行。

​ 当M0返回时(不再阻塞),它会尝试从其他线程中“偷”一个上下文(cpu)过来,如果没有偷到,会把goroutine放到global runqueue中去,然后把自己放入线程缓存中。上下文会定时检查global runqueue切换goroutine运行。

3.1 Goroutine的一些优点

  • 创建与销毁的开销小

    线程创建时需要向操作系统申请资源,并且在销毁时将资源归还,因此它的创建和销毁的开销比较大。相比之下,goroutine的创建和销毁是由go语言在运行时自己管理的,因此开销更低。所以一个Golang的程序中可以支持10w级别的goroutine。每个 goroutine (协程) 默认占用内存远比 Java 、C 的线程少(*goroutine:*2KB ,线程:8MB)

  • 切换开销小

    这是goroutine于线程的主要区别,也是golang能够实现高并发的主要原因。

    线程的调度方式是抢占式的,如果一个线程的执行时间超过了分配给它的时间片,就会被其它可执行的线程抢占。在线程切换的过程中需要保存/恢复所有的寄存器信息,比如16个通用寄存器,PC(Program Counter),SP(Stack Pointer),段寄存器等等。

    而goroutine的调度是协同式的,没有时间片的概念,由Golang完成,它不会直接地与操作系统内核打交道。当goroutine进行切换的时候,之后很少量的寄存器需要保存和恢复(PC和SP)。因此gouroutine的切换效率更高。

    总的来说,操作系统的一个线程下可以并发执行上千个goroutine,每个goroutine所占用的资源和切换开销都很小,因此,goroutine是golang适合高并发场景的重要原因。

    生成一个goroutine的方法十分简单,直接使用go关键字即可:go func()

4 协程通信和数据交互

不要用共享内存来通信,要用通信来共享内存

​ 我们常见的多线程模型一般是通过共享内存实现的,但是共享内存就会有很多问题。比如资源抢占的问题、一致性问题等等。为了解决这些问题,我们需要引入多线程锁、原子操作等等限制来保证程序执行结果的正确性。

​ 为了避免上面的问题,Golang使用了CSP模型。CSP描述了并发系统中的互动模式,是一种面向并发的语言的源头。Golang只使用了CSP当中关于Process/Channel的部分。简单来说Process映射Goroutine,Channel映射Channel。Goroutine即Golang当中的协程,Goroutine之间没有任何耦合,可以完全并发执行。Channel用于给Goroutine传递消息,保持数据同步。虽然Goroutine之间没有耦合,但是它们与Channel依然存在耦合。

在这里插入图片描述

4.1 GPM模型

​ 无论在语言层面用的是何种并发模型,到了操作系统层面,一定是以线程的形态存在的。而操作系统根据资源访问权限的不同,体系架构可分为用户空间和内核空间。

  • 内核空间主要操作访问CPU资源、I/O资源、内存资源等硬件资源,为上层应用程序提供最基本的基础资源。
  • 用户空间就是上层应用程序的固定活动空间,用户空间不可以直接访问资源,必须通过“系统调用”、“库函数”或“Shell脚本”来调用内核空间提供的资源。

golang使用goroutine做为最小的执行单位,但是这个执行单位还是在用户空间,实际上最后被处理器执行的还是内核中的线程,用户线程和内核线程的调度方法有:

  • 1:1,即一个内核线程对应一个用户级线程(并发度低,浪费cpu资源,上下文切换需要消耗额外的资源)。
  • 1:N,即一个内核线程对应N个用户级线程(并发度高,但是只用一个内核线程,不能有效利用多核CPU)。
  • M:N,即M个内核线程对应N个用户级线程(上述两种方式的折中,缺点是线程调度会复杂一些)

golang 通过为goroutine提供语言层面的调度器,来实现了高效率的M:N线程对应关系。

在这里插入图片描述

M:是内核线程

P : 是调度协调,用于协调M和G的执行,内核线程只有拿到了 P才能对goroutine继续调度执行,一般都是通过限定P的个数来控制golang的并发度

G : 是待执行的goroutine,包含这个goroutine的栈空间

Gn : 灰色背景的Gn 是已经挂起的goroutine,它们被添加到了执行队列中,然后需要等待网络IO的goroutine,当P通过 epoll查询到特定的fd的时候,会重新调度起对应的,正在挂起的goroutine。

Golang为了调度的公平性,在调度器加入了steal working 算法 ,在一个P自己的执行队列,处理完之后,它会先到全局的执行队列中偷G进行处理,如果没有的话,再会到其他P的执行队列中抢G来进行处理。

5 简单的CSP例子

package main
import (
   "fmt"
   "time"
)
// 生产者
func Producer (queue chan int){
        for i:= 0; i < 10; i++ {
                queue <- i
        }
}
// 消费者
func Consumer(queue chan int){
        for i :=0; i < 10; i++{
                v := <- queue
                fmt.Println("receive:", v)
        }
}
func main(){
        queue := make(chan int, 1)
        go Producer(queue)
        go Consumer(queue)
        time.Sleep(1e9) //让Producer与Consumer完成
}

​ 生产者goroutine往channel传值,消费者goroutine往channel取值,这两个goroutine通过channel完成通信。

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值