golang 并发
前言
在之前的 Go 语言入门 文章中,我们介绍了 Go 语言的基础知识和大部分数据结构。然而,有一个非常常用且重要的数据结构——通道(channel),我们并未进行详述。这是因为通道是 Go 语言中一个复杂且重要的特性,对其的介绍和理解需要投入更多的篇幅和精力。
因此,在这篇文章中,我将专门对通道进行详细介绍,并深入解析 Go 语言的并发是如何处理的。希望通过这篇文章,读者能够深入理解 Go 语言的并发模型,以及通道在其中的作用和使用方法。
并发
进程、线程和协程
我们首先区分一下并发和并行的概念。并发指的是能处理不同的任务,但这些任务并不是同时处理的。而并行指的是能够同时处理不同的任务。真正的并行,是需要靠多核应用来支持的。
接下来,我们介绍一下标题所说的三种’程’。
进程:
进程是一个程序在一个数据集中的一次动态执行过程,可以简单理解为"正在执行的程序",它是CPU资源分配和调度的独立单位。
进程一般由程序、数据集、进程控制块三部分组成。我们编写的程序用来描述进程要完成哪些功能以及如何完成;数据集则是程序在执行过程中所需要使用的资源;进程控制块用来记录进程的外部特征,描述进程的执行变化过程,系统可以利用它来控制和管理进程,它是系统感知进程存在的唯一标志。进程的局限是创建、撤销和切换的开销比较大。
线程:
线程是在进程之后发展出来的概念。线程也叫轻量级进程,它是一个基本的CPU执行单元,也是程序执行过程中的最小单元,由线程ID、程序计数器、寄存器集合和堆栈共同组成。一个进程可以包含多个线程。
线程的优点是减小了程序并发执行时的开销,提高了操作系统的并发性能,缺点是线程没有自己的系统资源,只拥有在运行时必不可少的资源,但同一进程的各线程可以共享进程所拥有的系统资源,如果把进程比作一个车间,那么线程就好比是车间里面的工人。不过对于某些独占性资源存在锁机制,处理不当可能会产生"死锁"。
协程:
协程是一种用户态的轻量级线程,又称微线程,英文名Coroutine,协程的调度完全由用户控制。人们通常将协程和子程序(函数)比较着理解。 子程序调用总是一个入口,一次返回,一旦退出即完成了子程序的执行。 与传统的系统级线程和进程相比,协程的最大优势在于其"轻量级",可以轻松创建上百万个而不会导致系统资源衰竭,而线程和进程通常最多也不能超过1万的。这也是协程也叫轻量级线程的原因。
协程与多线程相比,其优势体现在∶协程的执行效率极高。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。
Go语言通过协程来实现并发。
goroutine
goroutine
是与其他函数或方法同时运行的函数或方法。 goroutine 可以被认为是轻量级的线程,与线程相比,创建 goroutine 的成本很小,它就是一段代码,一个函数入口,以及在堆上为其分配的一个堆栈(初始大小为2K,会随着程序的执行自动增长删除)。因为它非常廉价,Go应用程序可以并发运行数千个 goroutine。
如何创建一个 goroutine 呢?很简单,使用 go
关键字,这也是我们为什么说 go 是一门很简便进行并发操作的语言的原因。
1 | // 使用 go 关键字 |
主 goroutine
main 函数是 go 程序的入口,相对应的,执行 main 函数的协程,我们称之为主 goroutine。但主 goroutine 并不只是用来执行 main 函数这么简单,它还有其他重要的功能。
它首先要做的是设定每一个 goroutine 所能申请的栈空间的最大尺寸,在 32 位的计算机系统中该最大尺寸为 250M,64位系统为 1GB。
如果某个 goroutine 的栈空间尺寸大于这个限制,那么运行时系统就会引发一个栈溢出的运行时恐慌。随后这个 go 程序也就停止运行了。
随后,主 goroutine 会进行一系列的初始化工作:
- 创建一个特殊的 defer 语句,用于在主 goroutine 退出时做必要的善后处理。因为主 goroutine 也可能会非正常的结束。
- 启动 GC
- 执行 main 包的 init 函数(init 函数指的是包在导入时就会执行的函数,后文再详细接受)
- 执行 main 函数
go 语言的并发模型
在了解 go 的并发模型之前,我们先了解一下线程模型。
线程模型大致可以分为三类:
- 内核级线程模型:这是一种一对一的模型,每一个用户线程对应着一个内核线程KSE,由单独的处理器来对用户线程进行调度。优点是各线程之间互不影响,一个线程阻塞了不会影响到其他的线程。缺点则是上下文切换的开销大,且没有那么多的KSE。
- 用户级线程模型:这是一种多对一的模型,多个用户线程对应着一个内核线程,一个处理器调度多个线程。优点是上下文切换方便,缺点则是线程之间会互相影响,一个线程阻塞了会导致其他线程阻塞。为了解决这个问题,很多语言都将完全阻塞的方法封装为了未完全阻塞的方法。
- 两级线程模型:这种模型是对上述两种模型的整合,是一种多对多的模型,一个处理器可以调度多个线程,同时,当某个线程阻塞的时候,可以将该处理器上的其他线程分配到其他的处理器上。优点显而易见,上线文开销小且减小了线程间的影响。缺点则是实现起来比较困难。
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模型的调度流程:
- 首先,我们通过
go
关键字创建了一个 goroutine,该 goroutine 会优先加入到当前 P 的本地队列,如果队列满了,就会将本地队列中的一半移动到全局队列中; - 调度器会根据调度策略,从本地队列中取出可执行的 goroutine,并分配给 M 执行;
- 当一个 goroutine 发生阻塞,P 会将其从 M 上解绑,并将其放入相应的等待队列中
- 系统调用:放入系统调用的等待队列中,按照手动交接机制调度
- 时间片用完:暂停,放回本地队列中,按照抢占式调度选择另外就绪的 goroutine 执行
- 当一个 M 绑定的队列中没有可执行的 goroutine,会从全局队列中取出可执行的 goroutine 进行执行,全局队列也没有的话会从其他的 M 上窃取 goroutine,没有窃取到的话会再次尝试从全局队列中取 goroutine;
- 当一个 M 长期没有执行 goroutine,会被调度器标记为空闲,并将其销毁
通过 GMP 模型,golang 调度器可以高效地管理并发任务的执行,它能够动态地创建和销毁 M,根据系统负载自动调整并发度,并通过工作窃取算法实现负载均衡。
临界资源的安全问题
并发本身并不复杂,但是因为有了资源竞争的问题,就使得我们开发出好的并发程序变得复杂起来,因为会引起很多莫名其妙的问题。
如果多个 goroutine 在访问同一个数据资源的时候,其中一个线程修改了数据,那么这个数值就被修改了,对于其他的 goroutine 来讲,这个数值可能是不对的。
解决共享资源的访问,也是老生常谈的问题了,主流的编程语言为了保证多线程之间共享数据的安全性和一致性,都会提供一套基本的同步工具,如锁、条件变量、原子操作等等。
go 语言的标准库也毫不意外的提供了这些同步机制,以下我们就来讲一讲 go 语言中的并发原语。
锁
解决共享数据的冲突,最直接的办法就是加锁了。go 标准库为我们提供了互斥锁和读写锁,在 sync 包下,我们可以直接使用。
使用方法如下:
1 | // 互斥锁 sync.mutex 只能锁一次,其他的会阻塞 |
原子操作
一个或者多个操作在 CPU 执行的过程中不被中断的特性,称为原子性(atomicity)
当我们在写入一个数据的时候,cpu 是需要根据操作系统的位数,从低位到高位逐步写入数据的。若是在没有写完的时候,其他协程对该数据进行读取,
就会读取到一个毫无逻辑的中间状态的值,这时候可能就会出现一些诡异的bug。
这时候就到我们的原子操作出场的时候了,go 为我们提供了 atomic 包,可以很方便地进行原子操作。
使用方法很简单:
1 | var v atomic.Value |
这时候我们可以发现,使用锁的话其实也能够避免这种读取到中间状态的情况,那为什么要用原子操作呢?很简单,因为性能。
Mutex由操作系统实现,而 atomic 包中的原子操作则由底层硬件直接提供支持。
在 CPU 实现的指令集里,有一些指令被封装进了 atomic 包,这些指令在执行的过程中是不允许中断(interrupt)的,
因此原子操作可以在 lock-free 的情况下保证并发安全,并且它的性能也能做到随 CPU 个数的增多而线性扩展。
以上 atomic.Value
类型以及其方法,其实是为了方便使用,经过了二次封装的,该类型在 go 1.4 版本之后才引入。其底层其实是调用了 atomic 包下的其他函数来实现的。
如果去看文档会发现 atomic 的函数签名有很多,但是大部分都是重复的为了不同的数据类型创建了不同的签名。这些函数都是通过汇编来实现的,我们只能看到其函数签名。
Add 类的函数是用来加减值的:
1 | func AddInt32(addr *int32, delta int32) (new int32) |
CompareAndSwap 类就是上面介绍过的 CAS 操作:
1 | func CompareAndSwapInt32(addr *int32, old, new int32) (swapped bool) |
Load 类用来取值:
1 | func LoadInt32(addr *int32) (val int32) |
Store 类用来赋值:
1 | func StoreInt32(addr *int32, val int32) |
Swap 类用来交换:
1 | func SwapInt32(addr *int32, new int32) (old int32) |
视不同需求,我们也可以直接调用这些函数。
协程间协调
我们创建的任务并不都是独自运行的,常常都是需要跟其他的协程进行交互,或者将一个任务拆成几个子任务并发执行来提高效率。
那协程之间要如何通信和交互,这就是我们这一小节要介绍的内容。
同步等待组(WaitGroup)
同步等待组一般是在创建了多个协程同时处理任务时,用来等待所有的协程中的任务执行完成。
1 | var wg sync.WaitGroup // 创建等待组 |
错误等待组(ErrGroup)
WaitGroup 能够解决基本的协程间协调的问题,但是其不支持返回错误,若是协程执行出错了,没办法通过 wg 来返回错误信息,不利于问题排查。
因此,在需要返回错误信息的场景,我们可以使用 ErrGroup。
1 | eg, cancel := errgroup.WithContext(context.TODO()) // 不需要携带上下文以及错误取消的话,通过 var 定义一个 eg 结构即可使用 |
可以使用 WithContext
来创建一个携带上下文的 eg,这样在一个协程执行出错时就会调用该上下文的 cancel。
不过要做到退出其他协程,需要在协程内监听 ctx.Done() 信号。上述例子中并没有写,有需要的根据具体的需求实现一下逻辑即可。
另外,eg 还提供了协程数的上限设置以及尝试启动协程的功能:
1 | // func (g *Group) SetLimit(n int) |
通过 SetLimit
设置上限,通过 TryGo
尝试启动协程,在限制范围内则启动成功,返回 true,否则返回 false
SetLimit 需要 eg 没有任何协程在运行的情况下使用。
通道(channel)
接下来要讲的就是我们的重头戏,通道。
在 go 中,所有开发者都会接触到的一句话就是:“不要用共享内存的方式来通信,而是使用通信的方式来共享内存”。
在Go语言中并不鼓励用锁保护共享状态的方式在不同的 goroutine 中分享信息(以共享内存的方式去通信)。而是鼓励通过 channel 将共享状态或共享状态的变化在各个 goroutine 之间传递〈以通信的方式去共享内存),这样同样能像用锁一样保证在同一的时间只有一个 goroutine 访问共享状态。
通道可以理解为各个 goroutine 之间的管道,数据可以通过这个管道进行传输,各个协程可以通过这个管道进行通信。
相比较于锁和原子操作,channel 是一个更高层级的抽象,使用起来会更加方便,且保证了并发安全。
基本用法:
1 | // 创建 ch := make(chan type, cap) |
在上面的介绍中,出现了有缓冲以及无缓冲这两种 channel,那这两种有什么区别呢?
无缓冲:
通道中只能有一条数据,向通道写入数据后会阻塞,直到数据被读出,往未读出数据的通道内写入数据会发生阻塞。
读数据时若是通道中没有数据,也会发生阻塞,直到成功读取。
有缓冲:
通道中可以最多存放 n 条数据,n 为创建 channel 时指定的缓冲区的大小。在通道满前写入不会阻塞,满后写入会阻塞,直到成功写入。
读数据时若是缓冲区中有数据,则不会阻塞,会读出一条数据,若是缓冲区为空,则会阻塞等待数据。
可以运行下面的程序,观察输出来查看有缓冲和无缓冲的区别。
1 | package main |
上述的例子都是每次从通道中取出一个数据,通过无限循环来取出数据。除去这种方法,go 还为我们提供了遍历通道的方法:
1 | // for v := range channel{ |
观察上述例子,思考代码会打印出 end?
吗?答案是不会的,如果代码只有这些内容的话,程序在打印出 read:9
后就会爆出一个无苏醒协程的恐慌。
这表示我们在 for range 处死锁了,因为 for range 会一直尝试从 ch1 中读取数据,直到 ch1 被关闭,但是并没有其他存活的协程往 ch1 中写入数据或者关闭它。
这时,我们就需要知道,如何关闭一个协程了。
1 | close(ch1) |
通过 close 方法,即可关闭一个 channel。这时候如果有协程正在 range 这个 channel,当读取完这个 channel 缓冲区的所有数据后,就会退出循环了。
另外,我们若是通过 <- ch
的方式读取通道数据,通道被关闭的话会该语句也会立即返回。
1 | ch1 := make(chan int) |
从上文的介绍中,我们知道一般都会有两个或两个以上的协程对通道进行操作,有的协程专门用来读,有的协程专门用来写。基于此呢,我们可以使用只读或者只写的通道(单向通道),来限制一个协程只能对通道进行读或者写。
1 | // 只写通道 chan<- type |
对于 channel 的使用大概就这么多内容了,下面总结一下使用 channel 时的注意事项:
- 在程序设计时要避免死锁,读和写成对存在(特殊应用场景除外,如阻塞等待信号)
- 关闭一个 nil 的channel(未使用 make 进行初始化的 channel)或者已关闭过的 channel,会 panic
- 关闭 channel 后所有正在阻塞等待该 channel 的语句都会立即返回
- 向一个已关闭的 channel 中写入数据也会 panic (此处引出一个原则:只在发送端关闭 channel,若是存在多个发送端,则需要有一个专门的 stop channel,发送数据的 channel 可以不关闭,通过 stop channel 的信号判断是否继续发送或接收数据)
了解完 channel,我们接下来了解一下与其息息相关的一个语句,即 selcet
。
我们可以发现,在上文的例子中出现了很多 select 语句,但是我并没有对其进行介绍,以下我们通过用法来了解一下这个语句的功能:
1 | // select { |
可以看到,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的出发点是因为它具有以下特点:
- 用户空间避免了内核态和用户态的切换导致的成本
- 可以由语言和框架层进行调度
- 更小的栈空间允许创建大量的实例
总结
本文从基本概念入手,引入了 Go 语言中的并发模型,这是 Go 语言中一个非常重要的特性。我们首先介绍了并发的基本概念,然后详细阐述了在 Go 语言中如何使用并发,包括锁、原子操作、同步等待组和通道等并发原语的使用。希望通过这篇文章,读者能够对 Go 语言的并发模型有一个清晰的理解,并能够在实际编程中正确地使用 Go 语言提供的并发原语。