前言

在之前的 Go 语言入门 文章中,我们介绍了 Go 语言的基础知识和大部分数据结构。然而,有一个非常常用且重要的数据结构——通道(channel),我们并未进行详述。这是因为通道是 Go 语言中一个复杂且重要的特性,对其的介绍和理解需要投入更多的篇幅和精力。

因此,在这篇文章中,我将专门对通道进行详细介绍,并深入解析 Go 语言的并发是如何处理的。希望通过这篇文章,读者能够深入理解 Go 语言的并发模型,以及通道在其中的作用和使用方法。

并发

进程、线程和协程

我们首先区分一下并发和并行的概念。并发指的是能处理不同的任务,但这些任务并不是同时处理的。而并行指的是能够同时处理不同的任务。真正的并行,是需要靠多核应用来支持的。

接下来,我们介绍一下标题所说的三种’程’。

进程:

进程是一个程序在一个数据集中的一次动态执行过程,可以简单理解为"正在执行的程序",它是CPU资源分配和调度的独立单位
进程一般由程序、数据集、进程控制块三部分组成。我们编写的程序用来描述进程要完成哪些功能以及如何完成;数据集则是程序在执行过程中所需要使用的资源;进程控制块用来记录进程的外部特征,描述进程的执行变化过程,系统可以利用它来控制和管理进程,它是系统感知进程存在的唯一标志。进程的局限是创建、撤销和切换的开销比较大。

线程:

线程是在进程之后发展出来的概念。线程也叫轻量级进程,它是一个基本的CPU执行单元,也是程序执行过程中的最小单元,由线程ID、程序计数器、寄存器集合和堆栈共同组成。一个进程可以包含多个线程。

线程的优点是减小了程序并发执行时的开销,提高了操作系统的并发性能,缺点是线程没有自己的系统资源,只拥有在运行时必不可少的资源,但同一进程的各线程可以共享进程所拥有的系统资源,如果把进程比作一个车间,那么线程就好比是车间里面的工人。不过对于某些独占性资源存在锁机制,处理不当可能会产生"死锁"。

协程

协程是一种用户态的轻量级线程,又称微线程,英文名Coroutine,协程的调度完全由用户控制。人们通常将协程和子程序(函数)比较着理解。 子程序调用总是一个入口,一次返回,一旦退出即完成了子程序的执行。 与传统的系统级线程和进程相比,协程的最大优势在于其"轻量级",可以轻松创建上百万个而不会导致系统资源衰竭,而线程和进程通常最多也不能超过1万的。这也是协程也叫轻量级线程的原因。
协程与多线程相比,其优势体现在∶协程的执行效率极高。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。

Go语言通过协程来实现并发

goroutine

goroutine 是与其他函数或方法同时运行的函数或方法。 goroutine 可以被认为是轻量级的线程,与线程相比,创建 goroutine 的成本很小,它就是一段代码,一个函数入口,以及在堆上为其分配的一个堆栈(初始大小为2K,会随着程序的执行自动增长删除)。因为它非常廉价,Go应用程序可以并发运行数千个 goroutine。

如何创建一个 goroutine 呢?很简单,使用 go 关键字,这也是我们为什么说 go 是一门很简便进行并发操作的语言的原因。

1
2
3
4
5
// 使用 go 关键字
go func(){
// ...
}
go function()

主 goroutine

main 函数是 go 程序的入口,相对应的,执行 main 函数的协程,我们称之为主 goroutine。但主 goroutine 并不只是用来执行 main 函数这么简单,它还有其他重要的功能。

它首先要做的是设定每一个 goroutine 所能申请的栈空间的最大尺寸,在 32 位的计算机系统中该最大尺寸为 250M,64位系统为 1GB。

如果某个 goroutine 的栈空间尺寸大于这个限制,那么运行时系统就会引发一个栈溢出的运行时恐慌。随后这个 go 程序也就停止运行了。

随后,主 goroutine 会进行一系列的初始化工作:

  1. 创建一个特殊的 defer 语句,用于在主 goroutine 退出时做必要的善后处理。因为主 goroutine 也可能会非正常的结束。
  2. 启动 GC
  3. 执行 main 包的 init 函数(init 函数指的是包在导入时就会执行的函数,后文再详细接受)
  4. 执行 main 函数

go 语言的并发模型

在了解 go 的并发模型之前,我们先了解一下线程模型。

线程模型大致可以分为三类:

  1. 内核级线程模型:这是一种一对一的模型,每一个用户线程对应着一个内核线程KSE,由单独的处理器来对用户线程进行调度。优点是各线程之间互不影响,一个线程阻塞了不会影响到其他的线程。缺点则是上下文切换的开销大,且没有那么多的KSE。
  2. 用户级线程模型:这是一种多对一的模型,多个用户线程对应着一个内核线程,一个处理器调度多个线程。优点是上下文切换方便,缺点则是线程之间会互相影响,一个线程阻塞了会导致其他线程阻塞。为了解决这个问题,很多语言都将完全阻塞的方法封装为了未完全阻塞的方法。
  3. 两级线程模型:这种模型是对上述两种模型的整合,是一种多对多的模型,一个处理器可以调度多个线程,同时,当某个线程阻塞的时候,可以将该处理器上的其他线程分配到其他的处理器上。优点显而易见,上线文开销小且减小了线程间的影响。缺点则是实现起来比较困难。

go 语言的并发调度就是采用上述的两级线程模型。

G-M-P 模型

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

理解goroutine机制的原理,关键是理解Go语言scheduler的实现。

Go语言中支撑整个scheduler实现的主要有4个重要结构,分别是M、G、P、Sched。

Sched 结构就是调度器,它维护有存储 M 和 G 的队列以及调度器的一些状态信息等。

G 是 goroutine 实现的核心结构,它包含了栈、指令指针以及其他对调度 goroutine 很重要的信息。

M 结构就是 Machine,系统线程。它是由操作系统管理的,是实际执行 goroutine 的线程,调度器会将 goroutine 映射到 M 上执行。

P 结构是 Processor,处理器。它是属于调度器的一部分,负责调度和管理 M,每一个 P 都绑定到了一个 M 上,它维护了一个 goroutine 队列,即 runqueue。P 的任务就是从自己的本地队列(runqueue) 中取出 goroutine,并分配给 M 执行。runqueue 的长度不超过 256个。

以下引用中的描述不一定准确,仅作参考

在单核处理器的场景下,所有的 goroutine 运行在同一个 M 系统线程中,每一个 M 系统线程维护一个 P,任何时刻,一个 M 中只有一个goroutine 在执行,其他的 goroutine 在 runqueue 中等待。一个 goroutine 运行完自己的时间片后,让出上下文,回到 runqueue 中等待。

多核处理器的场景下,对应的就是有多个 M 系统线程,每个 M 系统线程都会持有一个 Processor。

GMP模型的简单架构图如下:

gmp.png

了解完以上的知识,我们整理一下GMP模型的调度流程:

  1. 首先,我们通过 go 关键字创建了一个 goroutine,该 goroutine 会优先加入到当前 P 的本地队列,如果队列满了,就会将本地队列中的一半移动到全局队列中;
  2. 调度器会根据调度策略,从本地队列中取出可执行的 goroutine,并分配给 M 执行;
  3. 当一个 goroutine 发生阻塞,P 会将其从 M 上解绑,并将其放入相应的等待队列中
    • 系统调用:放入系统调用的等待队列中,按照手动交接机制调度
    • 时间片用完:暂停,放回本地队列中,按照抢占式调度选择另外就绪的 goroutine 执行
  4. 当一个 M 绑定的队列中没有可执行的 goroutine,会从全局队列中取出可执行的 goroutine 进行执行,全局队列也没有的话会从其他的 M 上窃取 goroutine,没有窃取到的话会再次尝试从全局队列中取 goroutine;
  5. 当一个 M 长期没有执行 goroutine,会被调度器标记为空闲,并将其销毁

通过 GMP 模型,golang 调度器可以高效地管理并发任务的执行,它能够动态地创建和销毁 M,根据系统负载自动调整并发度,并通过工作窃取算法实现负载均衡。

临界资源的安全问题

并发本身并不复杂,但是因为有了资源竞争的问题,就使得我们开发出好的并发程序变得复杂起来,因为会引起很多莫名其妙的问题。
如果多个 goroutine 在访问同一个数据资源的时候,其中一个线程修改了数据,那么这个数值就被修改了,对于其他的 goroutine 来讲,这个数值可能是不对的。

解决共享资源的访问,也是老生常谈的问题了,主流的编程语言为了保证多线程之间共享数据的安全性和一致性,都会提供一套基本的同步工具,如锁、条件变量、原子操作等等。

go 语言的标准库也毫不意外的提供了这些同步机制,以下我们就来讲一讲 go 语言中的并发原语。

解决共享数据的冲突,最直接的办法就是加锁了。go 标准库为我们提供了互斥锁和读写锁,在 sync 包下,我们可以直接使用。

使用方法如下:

1
2
3
4
5
6
7
8
9
10
11
// 互斥锁 sync.mutex 只能锁一次,其他的会阻塞
var mutex sync.Mutex
mutex.Lock() // 加锁
mutex.Unlock() // 解锁

// 读写锁 sync.RWMutex 写锁只能锁一次,读写可以任意多把,读锁和写锁不能同时存在
var rwmutex sync.RWMutex
rwmutex.RLock() // 上读锁
rwmutex.RUnlock() // 解读锁
rwmutex.Lock() // 上写锁
rwmutex.Unlock() // 解写锁

原子操作

一个或者多个操作在 CPU 执行的过程中不被中断的特性,称为原子性(atomicity)

当我们在写入一个数据的时候,cpu 是需要根据操作系统的位数,从低位到高位逐步写入数据的。若是在没有写完的时候,其他协程对该数据进行读取,
就会读取到一个毫无逻辑的中间状态的值,这时候可能就会出现一些诡异的bug。

这时候就到我们的原子操作出场的时候了,go 为我们提供了 atomic 包,可以很方便地进行原子操作。

使用方法很简单:

1
2
3
4
5
6
7
8
9
10
var v atomic.Value
// func (v *Value) Store(val any) 通过 Store 来赋值,值为 any 类型,首次传值可以传任意类型的值,不能传 nil
// 后续再次使用必须传的值类型就必须与首次所传值的类型一致
v.Store(100)
// func (v *Value) Load() (val any) 通过 Load 来取值,返回值也是 any,可以断言成实际类型
v.Load()
// func (v *Value) Swap(new any) (old any) 通过 Swap 来交换值,赋新值的同时取得旧值,没有旧值的话返回 nil
old := v.Swap(200)
// func (v *Value) CompareAndSwap(old any, new any) (swapped bool) 通过 CompareAndSwap 来比较并交换值,v 的值与 old 相等就赋值为 new,不等就不赋值,通过返回值确定是否发生了赋值
swapped := v.CompareAndSwap(200, 300)

这时候我们可以发现,使用锁的话其实也能够避免这种读取到中间状态的情况,那为什么要用原子操作呢?很简单,因为性能。

Mutex由操作系统实现,而 atomic 包中的原子操作则由底层硬件直接提供支持。
在 CPU 实现的指令集里,有一些指令被封装进了 atomic 包,这些指令在执行的过程中是不允许中断(interrupt)的,
因此原子操作可以在 lock-free 的情况下保证并发安全,并且它的性能也能做到随 CPU 个数的增多而线性扩展。

以上 atomic.Value 类型以及其方法,其实是为了方便使用,经过了二次封装的,该类型在 go 1.4 版本之后才引入。其底层其实是调用了 atomic 包下的其他函数来实现的。

如果去看文档会发现 atomic 的函数签名有很多,但是大部分都是重复的为了不同的数据类型创建了不同的签名。这些函数都是通过汇编来实现的,我们只能看到其函数签名。

Add 类的函数是用来加减值的:

1
2
3
4
5
func AddInt32(addr *int32, delta int32) (new int32)
func AddInt64(addr *int64, delta int64) (new int64)
func AddUint32(addr *uint32, delta uint32) (new uint32)
func AddUint64(addr *uint64, delta uint64) (new uint64)
func AddUintptr(addr *uintptr, delta uintptr) (new uintptr)

CompareAndSwap 类就是上面介绍过的 CAS 操作:

1
2
3
4
5
6
func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool)
func CompareAndSwapInt64(addr *int64, old, new int64) (swapped bool)
func CompareAndSwapPointer(addr *unsafe.Pointer, old, new unsafe.Pointer) (swapped bool)
func CompareAndSwapUint32(addr *uint32, old, new uint32) (swapped bool)
func CompareAndSwapUint64(addr *uint64, old, new uint64) (swapped bool)
func CompareAndSwapUintptr(addr *uintptr, old, new uintptr) (swapped bool)

Load 类用来取值:

1
2
3
4
5
6
func LoadInt32(addr *int32) (val int32)
func LoadInt64(addr *int64) (val int64)
func LoadPointer(addr *unsafe.Pointer) (val unsafe.Pointer)
func LoadUint32(addr *uint32) (val uint32)
func LoadUint64(addr *uint64) (val uint64)
func LoadUintptr(addr *uintptr) (val uintptr)

Store 类用来赋值:

1
2
3
4
5
6
func StoreInt32(addr *int32, val int32)
func StoreInt64(addr *int64, val int64)
func StorePointer(addr *unsafe.Pointer, val unsafe.Pointer)
func StoreUint32(addr *uint32, val uint32)
func StoreUint64(addr *uint64, val uint64)
func StoreUintptr(addr *uintptr, val uintptr)

Swap 类用来交换:

1
2
3
4
5
6
func SwapInt32(addr *int32, new int32) (old int32)
func SwapInt64(addr *int64, new int64) (old int64)
func SwapPointer(addr *unsafe.Pointer, new unsafe.Pointer) (old unsafe.Pointer)
func SwapUint32(addr *uint32, new uint32) (old uint32)
func SwapUint64(addr *uint64, new uint64) (old uint64)
func SwapUintptr(addr *uintptr, new uintptr) (old uintptr)

视不同需求,我们也可以直接调用这些函数。

协程间协调

我们创建的任务并不都是独自运行的,常常都是需要跟其他的协程进行交互,或者将一个任务拆成几个子任务并发执行来提高效率。

那协程之间要如何通信和交互,这就是我们这一小节要介绍的内容。

同步等待组(WaitGroup)

同步等待组一般是在创建了多个协程同时处理任务时,用来等待所有的协程中的任务执行完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var wg sync.WaitGroup // 创建等待组
wg.Add(2) // 添加要等待执行的 goroutine 数量
go func(){
defer wg.Done() // 将结束信号通知到等待组,等同与 wg.Add(-1)
time.Sleep(5 * time.Second)
}()
go func(){
defer wg.Done()
time.Sleep(10 * time.Second)
}()
time.Sleep(2 * time.Second)
fmt.Println("waiting")
wg.Wait() // 等待计数器清零
fmt.Println("end")

错误等待组(ErrGroup)

WaitGroup 能够解决基本的协程间协调的问题,但是其不支持返回错误,若是协程执行出错了,没办法通过 wg 来返回错误信息,不利于问题排查。

因此,在需要返回错误信息的场景,我们可以使用 ErrGroup。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
eg, cancel := errgroup.WithContext(context.TODO()) // 不需要携带上下文以及错误取消的话,通过 var 定义一个 eg 结构即可使用
eg.Go(func() error { // 通过 Go 方法启动一个协程
time.Sleep(time.Second)
fmt.Println("sleep one ok")
return nil
})
eg.Go(func() error {
time.Sleep(2 * time.Second)
fmt.Println("sleep two ok")
return errors.New("new err")
})
eg.Go(func() error {
time.Sleep(3 * time.Second)
fmt.Println("sleep three ok")
return nil
})

err := eg.Wait() // 等待所有启动的协程执行完毕,出错也会等所有协程执行完,使用并监听了 cancel 只是让协程提前终止
if err != nil {
fmt.Println(err)
}

可以使用 WithContext 来创建一个携带上下文的 eg,这样在一个协程执行出错时就会调用该上下文的 cancel。
不过要做到退出其他协程,需要在协程内监听 ctx.Done() 信号。上述例子中并没有写,有需要的根据具体的需求实现一下逻辑即可。

另外,eg 还提供了协程数的上限设置以及尝试启动协程的功能:

1
2
// func (g *Group) SetLimit(n int)
// func (g *Group) TryGo(f func() error) bool

通过 SetLimit 设置上限,通过 TryGo 尝试启动协程,在限制范围内则启动成功,返回 true,否则返回 false

SetLimit 需要 eg 没有任何协程在运行的情况下使用。

通道(channel)

接下来要讲的就是我们的重头戏,通道。

在 go 中,所有开发者都会接触到的一句话就是:“不要用共享内存的方式来通信,而是使用通信的方式来共享内存”。

在Go语言中并不鼓励用锁保护共享状态的方式在不同的 goroutine 中分享信息(以共享内存的方式去通信)。而是鼓励通过 channel 将共享状态或共享状态的变化在各个 goroutine 之间传递〈以通信的方式去共享内存),这样同样能像用锁一样保证在同一的时间只有一个 goroutine 访问共享状态。

通道可以理解为各个 goroutine 之间的管道,数据可以通过这个管道进行传输,各个协程可以通过这个管道进行通信。

相比较于锁和原子操作,channel 是一个更高层级的抽象,使用起来会更加方便,且保证了并发安全。

基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
// 创建 ch := make(chan type, cap)
ch1 := make(chan int) // 创建一个无缓冲的 int 类型的通道
ch2 := make(chan struct{},2) // 创建一个缓冲长度为 2 的空结构体类型的通道

// 向通道写入数据 ch <- data
ch1 <- 1
ch2 <- struct{}{}

// 从通道中读取数据
a := <- ch1 // 使用变量 a 接收数据
<- ch2 // 从通道中取出数据,但不接收

在上面的介绍中,出现了有缓冲以及无缓冲这两种 channel,那这两种有什么区别呢?

无缓冲:
通道中只能有一条数据,向通道写入数据后会阻塞,直到数据被读出,往未读出数据的通道内写入数据会发生阻塞。
读数据时若是通道中没有数据,也会发生阻塞,直到成功读取。

有缓冲:
通道中可以最多存放 n 条数据,n 为创建 channel 时指定的缓冲区的大小。在通道满前写入不会阻塞,满后写入会阻塞,直到成功写入。
读数据时若是缓冲区中有数据,则不会阻塞,会读出一条数据,若是缓冲区为空,则会阻塞等待数据。

可以运行下面的程序,观察输出来查看有缓冲和无缓冲的区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
package main

import (
"fmt"
"time"
)

func main() {
testChannel()
}

func testChannel() {
ch1 := make(chan int)
ch2 := make(chan struct{})

go func() {
for {
a := <-ch1
fmt.Println("读取数据:", a)
time.Sleep(1500 * time.Millisecond)
}
}()
for i := 0; i < 5; i++ {
select {
case ch1 <- i:
fmt.Println("写入数据成功", i)
time.Sleep(1 * time.Second)
default:
fmt.Println("等待写入")
time.Sleep(1 * time.Second)
}
}
go func() {
ch2 <- struct{}{}
}()
<-ch2
fmt.Println("*-------------------------------*")

ch3 := make(chan int, 2)
go func() {
for {
a := <-ch3
fmt.Println("读取数据:", a)
time.Sleep(1500 * time.Millisecond)
}
}()
for i := 0; i < 5; i++ {
select {
case ch3 <- i:
fmt.Println("写入数据成功", i)
time.Sleep(1 * time.Second)
default:
fmt.Println("等待写入")
time.Sleep(1 * time.Second)
}
}
<-ch2
}

上述的例子都是每次从通道中取出一个数据,通过无限循环来取出数据。除去这种方法,go 还为我们提供了遍历通道的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// for v :=  range channel{
//
// }
ch1 := make(chan int64, 10)
go func() {
var i int64
for i < 10 {
ch1 <- i
i++
time.Sleep(time.Second)
}
}()

for v := range ch1 {
fmt.Println("read:", v) // 一直从 ch1 中读取数据,直到 ch1 关闭
}
fmt.Println("end?")

观察上述例子,思考代码会打印出 end? 吗?答案是不会的,如果代码只有这些内容的话,程序在打印出 read:9 后就会爆出一个无苏醒协程的恐慌。

这表示我们在 for range 处死锁了,因为 for range 会一直尝试从 ch1 中读取数据,直到 ch1 被关闭,但是并没有其他存活的协程往 ch1 中写入数据或者关闭它。

这时,我们就需要知道,如何关闭一个协程了。

1
close(ch1)

通过 close 方法,即可关闭一个 channel。这时候如果有协程正在 range 这个 channel,当读取完这个 channel 缓冲区的所有数据后,就会退出循环了。

另外,我们若是通过 <- ch 的方式读取通道数据,通道被关闭的话会该语句也会立即返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
ch1 := make(chan int)
close(ch1)
<- ch1 // 立即返回
// <- ch 可以用两个变量接收,第二个变量表示通道是否是开启的。 v,ok := <- ch
ch2 := make(chan int)
go func(){
ch2 <- 1
close(ch2)
}()
a,ok := <- ch2
fmt.Println(a,ok) // 1,true
b,ok := ch2
fmt.Println(b,ok) // 0,false 此时,第一个变量的值为通道类型的零值

从上文的介绍中,我们知道一般都会有两个或两个以上的协程对通道进行操作,有的协程专门用来读,有的协程专门用来写。基于此呢,我们可以使用只读或者只写的通道(单向通道),来限制一个协程只能对通道进行读或者写。

1
2
3
4
5
6
7
8
9
10
11
// 只写通道 chan<- type
// 只读通道 <-chan type
ch1 := make(chan int)
go func(ch <-chan int){ // 通过参数传递只读通道
<- ch
// ch <- 1 is not allow
}(ch1)
go func(ch chan<- int){ // 通过参数传递只写通道
ch <- 1
// <- ch is not allow
}(ch1)

对于 channel 的使用大概就这么多内容了,下面总结一下使用 channel 时的注意事项:

  1. 在程序设计时要避免死锁,读和写成对存在(特殊应用场景除外,如阻塞等待信号)
  2. 关闭一个 nil 的channel(未使用 make 进行初始化的 channel)或者已关闭过的 channel,会 panic
  3. 关闭 channel 后所有正在阻塞等待该 channel 的语句都会立即返回
  4. 向一个已关闭的 channel 中写入数据也会 panic (此处引出一个原则:只在发送端关闭 channel,若是存在多个发送端,则需要有一个专门的 stop channel,发送数据的 channel 可以不关闭,通过 stop channel 的信号判断是否继续发送或接收数据)

了解完 channel,我们接下来了解一下与其息息相关的一个语句,即 selcet

我们可以发现,在上文的例子中出现了很多 select 语句,但是我并没有对其进行介绍,以下我们通过用法来了解一下这个语句的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// select {
// case clause1:
// // ...
// case clause2:
// // ...
// default:
// // ...
// }
ch1 := make(chan int)
ch2 := make(chan int)
select{
case v := <-ch1:
fmt.Println("ch1 read:",v)
case v := <-ch2:
fmt.Println("ch2 read:",v)
default:
fmt.Println("nothing")
}

可以看到,select 语句的结构和 switch 很类似,不同的是,select 的每一个 case 子句都必须是一个通道的操作,select 会随机执行一个可执行的 case, 若是没有 case 可以运行,就看是否有 default 子句,有的话就执行 default,没有的话就阻塞等待,直到存在可以执行的 case。此处所说的可不可以执行,指的是该操作是否被阻塞了, 例如我要向一个已经满了的通道写入数据,该操作就是阻塞的,此时该 case 子句就不会被执行。

  • 每个 case 都必须是一个通信(channel操作)
  • 所有 channel 表达式都会被求值
  • 所有被发送的表达式都会被求值
  • 如果有多个 case 可以运行,select 会随机公平的选出一个执行,其他的不会执行

若是 select 中没有任何子句,则会导致当前协程一直阻塞,这也是上文中有些例子会存在空的 select 的原因,这是为了在主协程中等待所有的子协程执行完毕, 等所有的子协程执行完后,只剩主协程,就会报一个死锁的恐慌,这样就不会在子协程执行完之前就退出了主协程。

CSP 模型

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

与主流语言通过共享内存来进行并发控制方式不同,Go语言采用了CSP模式。这是一种用于描述两个独立的并发实体通过共享的通讯Channel(管道)进行通信的并发模型。Golang就是借用CSP模型的一些概念为之实现并发进行理论支持,其实从实际上出发,go语言并没有完全实现了CSP模型的所有理论,仅仅是借用了process和channel这两个概念。process是在go语言上的表现就是goroutine是实际并发执行的实体,每个实体之间是通过channel通讯来实现数据共享。

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

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

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

总结

本文从基本概念入手,引入了 Go 语言中的并发模型,这是 Go 语言中一个非常重要的特性。我们首先介绍了并发的基本概念,然后详细阐述了在 Go 语言中如何使用并发,包括锁、原子操作、同步等待组和通道等并发原语的使用。希望通过这篇文章,读者能够对 Go 语言的并发模型有一个清晰的理解,并能够在实际编程中正确地使用 Go 语言提供的并发原语。