并发
Go 的并发方案:goroutine
- 并行(parallelism),指的就是在同一时刻,有两个或两个以上的任务(这里指进程)的代码在处理器上执行。
- 并发不是并行,并发关乎结构,并行关乎执行。
- 将程序分成多个可独立执行的部分的结构化程序的设计方法,就是并发设计。
- Go 并没有使用操作系统线程作为承载分解后的代码片段(模块)的基本执行单元,而是实现了 goroutine 这一由 Go 运行时(runtime)负责调度的、轻量的用户级线程,为并发程序设计提供原生支持。
- 相比传统操作系统线程来说,goroutine 的优势主要是:
- 资源占用小,每个 goroutine 的初始栈大小仅为 2k;
- 由 Go 运行时而不是操作系统调度,goroutine 上下文切换在用户层完成,开销更小;
- 在语言层面而不是通过标准库提供。goroutine 由go关键字创建,一退出就会被回收或销毁,开发体验更佳;
- 语言内置 channel 作为 goroutine 间通信原语,为并发设计提供了强大支撑。
- 和传统编程语言不同的是,Go 语言是面向并发而生的,所以,在程序的结构设计阶段,Go 的惯例是优先考虑并发设计。这样做的目的更多是考虑随着外界环境的变化,通过并发设计的 Go 应用可以更好地、更自然地适应规模化(scale)。
goroutine 的基本用法
- 并发是一种能力,它让你的程序可以由若干个代码片段组合而成,并且每个片段都是独立运行的。
- goroutine 恰恰就是 Go 原生支持并发的一个具体实现。
- 无论是 Go 自身运行时代码还是用户层 Go 代码,都无一例外地运行在 goroutine 中。
- Go 语言通过 go 关键字+函数/方法的方式创建一个 goroutine。
- 创建后,新 goroutine 将拥有独立的代码执行流,并与创建它的 goroutine 一起被 Go 运行时调度。
go fmt.Println("I am a goroutine") var c = make(chan int) go func(a, b int) {c <- a + b }(3,4) // $GOROOT/src/net/http/server.go c := srv.newConn(rw) go c.serve(connCtx)
- 通过 go 关键字,我们可以基于已有的具名函数 / 方法创建 goroutine,也可以基于匿名函数 / 闭包创建 goroutine。
- 创建 goroutine 后,go 关键字不会返回 goroutine id 之类的唯一标识 goroutine 的 id,你也不要尝试去得到这样的 id 并依赖它。
- 另外,和线程一样,一个应用内部启动的所有 goroutine 共享进程空间的资源,如果多个 goroutine 访问同一块内存数据,将会存在竞争,我们需要进行 goroutine 间的同步。
- goroutine 的执行函数的返回,就意味着 goroutine 退出。
- goroutine 执行的函数或方法即便有返回值,Go 也会忽略这些返回值。
- 所以,如果获取 goroutine 执行后的返回值需要另行考虑其他方法,比如通过 goroutine 间的通信来实现。
goroutine 间的通信
- 传统的编程语言(比如:C++、Java、Python 等)并非面向并发而生的,所以他们面对并发的逻辑多是基于操作系统的线程。
- 并发的执行单元(线程)之间的通信,利用的也是操作系统提供的线程或进程间通信的原语,比如:共享内存、信号(signal)、管道(pipe)、消息队列、套接字(socket)等。
- 在这些通信原语中,使用最多、最广泛的(也是最高效的)是结合了线程同步原语(比如:锁以及更为低级的原子操作)的共享内存方式,因此,我们可以说传统语言的并发模型是基于对内存的共享的。
- 一个符合 CSP(Communicationing Sequential Processes,通信顺序进程)并发模型的并发程序应该是一组通过输入输出原语连接起来的 P 的集合。
- 从这个角度来看,CSP 理论不仅是一个并发参考模型,也是一种并发程序的程序组织方法。它的组合思想与 Go 的设计哲学不谋而合。
- 在 Go 中,与“Process”对应的是 goroutine。
- 为了实现 CSP 并发模型中的输入和输出原语,Go 还引入了 goroutine(P)之间的通信原语 channel。
- goroutine 可以从 channel 获取输入数据,再将处理后得到的结果数据通过 channel 输出。
- 通过 channel 将 goroutine(P)组合连接在一起,让设计和编写大型并发系统变得更加简单和清晰,我们再也不用为那些传统共享内存并发模型中的问题而伤脑筋了。
- 虽然 CSP 模型已经成为 Go 语言支持的主流并发模型,但 Go 也支持传统的、基于共享内存的并发模型,并提供了基本的低级别同步原语(主要是 sync 包中的互斥锁、条件变量、读写锁、原子操作等)。
- Go 始终推荐以 CSP 并发模型风格构建并发程序,尤其是在复杂的业务层面,这能提升程序的逻辑清晰度,大大降低并发设计的复杂性,并让程序更具可读性和可维护性。
- 不过,对于局部情况,比如涉及性能敏感的区域或需要保护的结构体数据时,我们可以使用更为高效的低级同步原语(如 mutex),保证 goroutine 对数据的同步访问。
Goroutine 调度器
- 一个 Go 程序对于操作系统来说只是一个用户层程序,操作系统眼中只有线程,它甚至不知道有一种叫 Goroutine 的事物存在。所以,Goroutine 的调度全要靠 Go 自己完成。那么,实现 Go 程序内 Goroutine 之间“公平”竞争“CPU”资源的任务,就落到了 Go 运行时(runtime)头上了。
- Goroutine 竞争的资源就是操作系统线程。Goroutine调度器的任务就是将 Goroutine 按照一定算法放到不同的操作系统线程中去执行。
深入 G-P-M 模型
- G、P 和 M
- G: 代表 Goroutine,存储了 Goroutine 的执行栈信息、Goroutine 状态以及 Goroutine 的任务函数等,而且 G 对象是可以重用的;
- P: 代表逻辑 processor,P 的数量决定了系统内最大可并行的 G 的数量,P 的最大作用还是其拥有的各种 G 对象队列、链表、一些缓存和状态;
- M: M 代表着真正的执行计算资源。
- 在绑定有效的 P 后,进入一个调度循环,而调度循环的机制大致是从 P 的本地运行队列以及全局队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。
- M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。
- Goroutine 调度器的目标,就是公平合理地将各个 G 调度到 P 上“运行”。
- G 被抢占调度
- 除非极端的无限循环,否则只要 G 调用函数,Go 运行时就有了抢占 G 的机会。
- Go 程序启动时,运行时会去启动一个名为 sysmon 的 M(一般称为监控线程),这个 M 的特殊之处在于它不需要绑定 P 就可以运行(以 g0 这个 G 的形式),这个 M 在整个 Go 程序的运行过程中至关重要。
- sysmon 每 20us~10ms 启动一次,sysmon 主要完成了这些工作:
- 释放闲置超过 5 分钟的 span 内存;
- 如果超过 2 分钟没有垃圾回收,强制执行;
- 将长时间未处理的 netpoll 结果添加到任务队列;
- 向长时间运行的 G 任务发出抢占调度,这个事情由函数 retake 实施;
- 收回因 syscall 长时间阻塞的 P;
- 如果一个 G 任务运行 10ms,sysmon 就会认为它的运行时间太久而发出抢占式调度的请求。一旦 G 的抢占标志位被设为 true,那么等到这个 G 下一次调用函数或方法时,运行时就可以将 G 抢占并移出运行状态,放入队列中,等待下一次被调度。
- 两个特殊情况下 G 的调度方法
- 第一种:channel 阻塞或网络 I/O 情况下的调度。
- 如果 G 被阻塞在某个 channel 操作或网络 I/O 操作上时,G 会被放置到某个等待(wait)队列中,而 M 会尝试运行 P 的下一个可运行的 G。
- 如果这个时候 P 没有可运行的 G 供 M 运行,那么 M 将解绑 P,并进入挂起状态。
- 当 I/O 操作完成或 channel 操作完成,在等待队列中的 G 会被唤醒,标记为可运行(runnable),并被放入到某 P 的队列中,绑定一个 M 后继续执行。
- 第二种:系统调用阻塞情况下的调度。
- 如果 G 被阻塞在某个系统调用(system call)上,那么不光 G 会阻塞,执行这个 G 的 M 也会解绑 P,与 G 一起进入挂起状态。
- 如果此时有空闲的 M,那么 P 就会和它绑定,并继续执行其他 G;如果没有空闲的 M,但仍然有其他 G 要去执行,那么 Go 运行时就会创建一个新 M(线程)。
- 当系统调用返回后,阻塞在这个系统调用上的 G 会尝试获取一个可用的 P,如果没有可用的 P,那么 G 会被标记为 runnable,之前的那个挂起的 M 将再次进入挂起状态。
- 第一种:channel 阻塞或网络 I/O 情况下的调度。
作为一等公民的 channel
- channel 作为一等公民意味着我们可以像使用普通变量那样使用 channel,比如,定义 channel 类型变量、给 channel 变量赋值、将 channel 作为参数传递给函数 / 方法、将 channel 作为返回值从函数 / 方法中返回,甚至将 channel 发送到其他 channel 中。
- 创建 channel
- 和切片、结构体、map 等一样,channel 也是一种复合数据类型。也就是说,我们在声明一个 channel 类型变量时,必须给出其具体的元素类型:
var ch chan int
。这里,我们声明了一个元素为 int 类型的 channel 类型变量 ch。 - 如果 channel 类型变量在声明时没有被赋予初值,那么它的默认值为 nil。
- 并且,和其他复合数据类型支持使用复合类型字面值作为变量初始值不同,为 channel 类型变量赋初值的唯一方法就是使用 make 这个 Go 预定义的函数:
ch1 := make(chan int) ch2 := make(chan int, 5)
- 这里,我们声明了两个元素类型为 int 的 channel 类型变量 ch1 和 ch2,并给这两个变量赋了初值。
- 第一行我们通过make(chan T)创建的、元素类型为 T 的 channel 类型,是无缓冲 channel,而第二行中通过带有 capacity 参数的make(chan T, capacity)创建的元素类型为 T、缓冲区长度为 capacity 的 channel 类型,是带缓冲 channel。
- 并且,和其他复合数据类型支持使用复合类型字面值作为变量初始值不同,为 channel 类型变量赋初值的唯一方法就是使用 make 这个 Go 预定义的函数:
- 和切片、结构体、map 等一样,channel 也是一种复合数据类型。也就是说,我们在声明一个 channel 类型变量时,必须给出其具体的元素类型:
- 发送与接收
- Go 提供了<-操作符用于对 channel 类型变量进行发送与接收操作:
ch1 <- 13 // 将整型字面值 13 发送到无缓冲 channel 类型变量 ch1 中 n := <- ch1 // 从无缓冲 channel 类型变量 ch1 中接收一个整型值存储到整型变量 n 中 ch2 <- 17 // 将整型字面值 17 发送到带缓冲 channel 类型变量 ch2 中 m := <- ch2 // 从带缓冲 channel 类型变量 ch2 中接收一个整型值存储到整型变量 m 中
- channel 是用于 Goroutine 间通信的,所以绝大多数对 channel 的读写都被分别放在了不同的 Goroutine 中。
- 由于无缓冲 channel 的运行时层实现不带有缓冲区,所以 Goroutine 对无缓冲 channel 的接收和发送操作是同步的。
- 也就是说,对同一个无缓冲 channel,只有对它进行接收操作的 Goroutine 和对它进行发送操作的 Goroutine 都存在的情况下,通信才能得以进行,否则单方面的操作会让对应的 Goroutine 陷入挂起状态。
- 对无缓冲 channel 类型的发送与接收操作,一定要放在两个不同的 Goroutine 中进行,否则会导致 deadlock。
- 和无缓冲 channel 相反,带缓冲 channel 的运行时层实现带有缓冲区,因此,对带缓冲 channel 的发送操作在缓冲区未满、接收操作在缓冲区非空的情况下是异步的(发送或接收不需要阻塞等待)。
- 也就是说,对一个带缓冲 channel 来说,在缓冲区未满的情况下,对它进行发送操作的 Goroutine 并不会阻塞挂起;
- 在缓冲区有数据的情况下,对它进行接收操作的 Goroutine 也不会阻塞挂起。
- 但当缓冲区满了的情况下,对它进行发送操作的 Goroutine 就会阻塞挂起;
- 当缓冲区为空的情况下,对它进行接收操作的 Goroutine 也会阻塞挂起。
- 使用操作符 <-,我们还可以声明只发送 channel 类型(send-only)和只接收 channel 类型(recv-only):
ch1 := make(chan<- int, 1) // 只发送channel类型 ch2 := make(<-chan int, 1) // 只接收channel类型 <-ch1 // invalid operation: <-ch1 (receive from send-only type chan<- int ch2 <- 13 // invalid operation: ch2 <- 13 (send to receive-only type <-chan int
- 通常只发送 channel 类型和只接收 channel 类型,会被用作函数的参数类型或返回值,用于限制对 channel 内的操作,或者是明确可对 channel 进行的操作的类型。
- channel 的一个使用惯例是发送端负责关闭 channel。
- 这是因为发送端没有像接受端那样的、可以安全判断 channel 是否被关闭了的方法。
- 同时,一旦向一个已经关闭的 channel 执行发送操作,这个操作就会引发 panic。
- Go 提供了<-操作符用于对 channel 类型变量进行发送与接收操作:
- select
- 当涉及同时对多个 channel 进行操作时,我们会结合 Go 为 CSP 并发模型提供的另外一个原语 select,一起使用。
- 通过 select,我们可以同时在多个 channel 上进行发送 / 接收操作:
select { case x := <-ch1: // 从 channel ch1 接收数据 ... ... case y, ok := <-ch2: // 从 channel ch2 接收数据,并根据 ok 值判断 ch2 是否已经关闭 ... ... case ch3 <- z: // 将 z 值发送到 channel ch3 中: ... ... default: // 当上面 case 中的 channel 通信均无法实施时,执行该默认分支 }
- 当 select 语句中没有 default 分支,而且所有 case 中的 channel 操作都阻塞了的时候,整个 select 语句都将被阻塞,直到某一个 case 上的 channel 变成可发送,或者某个 case 上的 channel 变成可接收,select 语句才可以继续进行下去。
- 无缓冲 channel 的惯用法
- 第一种用法:用作信号传递
- 无缓冲 channel 用作信号传递的时候,有两种情况,分别是 1 对 1 通知信号和 1 对 n 通知信号。
- 关闭一个无缓冲 channel 会让所有阻塞在这个 channel 上的接收操作返回,从而实现一种 1 对 n 的“广播”机制。
- 第二种用法:用于替代锁机制
- 无缓冲 channel 具有同步特性,这让它在某些场合可以替代锁,让我们的程序更加清晰,可读性也更好。
- 第一种用法:用作信号传递
- 带缓冲 channel 的惯用法
- 带缓冲的 channel 与无缓冲的 channel 的最大不同之处,就在于它的异步性。
- 第一种用法:用作消息队列
- 无论是 1 收 1 发还是多收多发,带缓冲 channel 的收发性能都要好于无缓冲 channel;
- 对于带缓冲 channel 而言,发送与接收的 Goroutine 数量越多,收发性能会有所下降;
- 对于带缓冲 channel 而言,选择适当容量会在一定程度上提升收发性能。
- 第二种用法:用作计数信号量(counting semaphore)
- Go 并发设计的一个惯用法,就是将带缓冲 channel 用作计数信号量(counting semaphore)。
- 带缓冲 channel 中的当前数据个数代表的是,当前同时处于活动状态(处理业务)的 Goroutine 的数量,而带缓冲 channel 的容量(capacity),就代表了允许同时处于活动状态的 Goroutine 的最大数量。
- 向带缓冲 channel 的一个发送操作表示获取一个信号量,而从 channel 的一个接收操作则表示释放一个信号量。
- len(channel) 的应用
- 针对 channel ch 的类型不同,len(ch) 有如下两种语义:
- 当 ch 为无缓冲 channel 时,len(ch) 总是返回 0;
- 当 ch 为带缓冲 channel 时,len(ch) 返回当前 channel ch 中尚未被读取的元素个数。
- channel 原语用于多个 Goroutine 间的通信,一旦多个 Goroutine 共同对 channel 进行收发操作,len(channel) 就会在多个 Goroutine 间形成“竞态”。
- 单纯地依靠 len(channel) 来判断 channel 中元素状态,是不能保证在后续对 channel 的收发时 channel 状态是不变的。
- 为了不阻塞在 channel 上,常见的方法是将“判空与读取”放在一个“事务”中,将“判满与写入”放在一个“事务”中,而这类“事务”我们可以通过 select 实现。
func producer(c chan<- int) {var i int = 1for {time.Sleep(2 * time.Second)ok := trySend(c, i)if ok {fmt.Printf("[producer]: send [%d] to channel\n", i)i++continue}fmt.Printf("[producer]: try send [%d], but channel is full\n", i)} } func tryRecv(c <-chan int) (int, bool) {select {case i := <-c:return i, truedefault:return 0, false} }func trySend(c chan<- int, i int) bool {select {case c <- i:return truedefault:return false} }func consumer(c <-chan int) {for {i, ok := tryRecv(c)if !ok {fmt.Println("[consumer]: try to recv from channel, but the channeltime.Sleep(1 * time.Second)continue}fmt.Printf("[consumer]: recv [%d] from channel\n", i)if i >= 3 {fmt.Println("[consumer]: exit")return}} }func main() {var wg sync.WaitGroupc := make(chan int, 3)wg.Add(2)go func() {producer(c)wg.Done()}()go func() {consumer(c)wg.Done()}()wg.Wait() }
- 这种方法适用于大多数场合,但是这种方法有一个“问题”,那就是它改变了 channel 的状态,会让 channel 接收了一个元素或发送一个元素到 channel。
- 有些时候我们不想这么做,我们想在不改变 channel 状态的前提下,单纯地侦测 channel 的状态,而又不会因 channel 满或空阻塞在 channel 上。但很遗憾,目前没有一种方法可以在实现这样的功能的同时,适用于所有场合。
- 但是在特定的场景下,我们可以用 len(channel) 来实现。
- 多发送单接收
- 也就是有多个发送者,但有且只有一个接收者。
- 在这样的场景下,我们可以在接收 goroutine 中使用 len(channel) 是否大于 0 来判断是否 channel 中有数据需要接收。
- 多接收单发送
- 也就是有多个接收者,但有且只有一个发送者。在这样的场景下,我们可以在发送 Goroutine 中使用 len(channel) 是否小于 cap(channel) 来判断是否可以执行向 channel 的发送操作。
- 多发送单接收
- 如果一个 channel 类型变量的值为 nil,我们称它为 nil channel。nil channel 有一个特性,那就是对 nil channel 的读写都会发生阻塞。
- 针对 channel ch 的类型不同,len(ch) 有如下两种语义:
- channel 与 select 结合使用的一些惯用法
- 第一种用法:利用 default 分支避免阻塞
- select 语句的 default 分支的语义,就是在其他非 default 分支因通信未就绪,而无法被选择的时候执行的,这就给 default 分支赋予了一种“避免阻塞”的特性。
- 第二种用法:实现超时机制
- 带超时机制的 select,是 Go 中常见的一种 select 和 channel 的组合用法。
- 通过超时事件,我们既可以避免长期陷入某种操作的等待中,也可以做一些异常处理工作。
func worker() {select {case <-c:// ... do some stuffcase <-time.After(30 *time.Second):return} }
- 我们要尽量减少在使用 Timer 时给 Go 运行时和 Go 垃圾回收带来的压力,要及时调用 timer 的 Stop 方法回收 Timer 资源。
- 第三种用法:实现心跳机制
- 结合 time 包的 Ticker,我们可以实现带有心跳机制的 select。
- 这种机制让我们可以在监听 channel 的同时,执行一些周期性的任务:
func worker() {heartbeat := time.NewTicker(30 * time.Second)defer heartbeat.Stop()for {select {case <-c:// ... do some stuffcase <- heartbeat.C://... do heartbeat stuff}} }
- 第一种用法:利用 default 分支避免阻塞
如何使用共享变量?
sync 包低级同步原语可以用在哪?
- 首先是需要高性能的临界区(critical section)同步机制场景。
- 在 Go 中,channel 并发原语也可以用于对数据对象访问的同步,我们可以把 channel 看成是一种高级的同步原语,它自身的实现也是建构在低级同步原语之上的。
- 也正因为如此,channel 自身的性能与低级同步原语相比要略微逊色,开销要更大。
- 第二种就是在不想转移结构体对象所有权,但又要保证结构体内部状态数据的同步访问的场景。
- 基于 channel 的并发设计,有一个特点:在 Goroutine 间通过 channel 转移数据对象的所有权。所以,只有拥有数据对象所有权(从 channel 接收到该数据)的 Goroutine 才可以对该数据对象进行状态变更。
- 如果设计中没有转移结构体对象所有权,但又要保证结构体内部状态数据在多个 Goroutine 之间同步访问,那么可以使用 sync 包提供的低级同步原语来实现,比如最常用的 sync.Mutex。
sync 包中同步原语使用的注意事项
- Go 标准库中 sync.Mutex 的定义是这样的:
// $GOROOT/src/sync/mutex.go type Mutex struct {state int32sema uint32 }
- Mutex 的定义非常简单,由两个整型字段 state 和 sema 组成:
state:表示当前互斥锁的状态; sema:用于控制锁状态的信号量。
- 初始情况下,Mutex 的实例处于 Unlocked 状态(state 和 sema 均为 0)。
- 对 Mutex 实例的复制也就是两个整型字段的复制。一旦发生复制,原变量与副本就是两个单独的内存块,各自发挥同步作用,互相就没有了关联。如果发生复制后,仍然认为原变量与副本保护的是同一个数据对象,那可就大错特错了。
- 一旦 Mutex 类型变量被拷贝,原变量与副本就各自发挥作用,互相没有关联了。甚至,如果拷贝的时机不对,比如在一个 mutex 处于 locked 的状态时对它进行了拷贝,就会对副本进行加锁操作,将导致加锁的 Goroutine 永远阻塞下去。
- 如果对使用过的、sync 包中的类型的示例进行复制,并使用了复制后得到的副本,将导致不可预期的结果。所以,在使用 sync 包中的类型的时候,我们推荐通过闭包方式,或者是传递类型实例(或包裹该类型的类型实例)的地址(指针)的方式进行。这就是使用 sync 包时最值得我们注意的事项。
- Mutex 的定义非常简单,由两个整型字段 state 和 sema 组成:
- sync 包提供了两种用于临界区同步的原语:互斥锁(Mutex)和读写锁(RWMutex)。
- 它们都是零值可用的数据类型,也就是不需要显式初始化就可以使用,并且使用方法都比较简单。
var mu sync.Mutex mu.Lock() // 加锁 doSomething() mu.Unlock() // 解锁
- 一旦某个 Goroutine 调用的 Mutex 执行 Lock 操作成功,它将成功持有这把互斥锁。
- 这个时候,如果有其他 Goroutine 执行 Lock 操作,就会阻塞在这把互斥锁上,直到持有这把锁的 Goroutine 调用 Unlock 释放掉这把锁后,才会抢到这把锁的持有权并进入临界区。
- 由此,我们也可以得到使用互斥锁的两个原则:
- 尽量减少在锁中的操作。这可以减少其他因 Goroutine 阻塞而带来的损耗与延迟。
- 一定要记得调用 Unlock 解锁。忘记解锁会导致程序局部死锁,甚至是整个程序死锁,会导致严重的后果。
- 读写锁与互斥锁用法大致相同,只不过多了一组加读锁和解读锁的方法:
var rwmu sync.RWMutex rwmu.RLock() //加读锁 readSomething() rwmu.RUnlock() //解读锁 rwmu.Lock() //加写锁 changeSomething() rwmu.Unlock() //解写锁
- 写锁与 Mutex 的行为十分类似,一旦某 Goroutine 持有写锁,其他 Goroutine 无论是尝试加读锁,还是加写锁,都会被阻塞在写锁上。
- 读锁就宽松多了,一旦某个 Goroutine 持有读锁,它不会阻塞其他尝试加读锁的 Goroutine,但加写锁的 Goroutine 依然会被阻塞住。
- 通常,互斥锁(Mutex)是临时区同步原语的首选,它常被用来对结构体对象的内部状态、缓存等进行保护,是使用最为广泛的临界区同步原语。相比之下,读写锁的应用就没那么广泛了,只活跃于它擅长的场景下。
- 读写锁适合应用在具有一定并发量且读多写少的场合。在大量并发读的情况下,多个 Goroutine 可以同时持有读锁,从而减少在锁竞争中等待的时间。
- 它们都是零值可用的数据类型,也就是不需要显式初始化就可以使用,并且使用方法都比较简单。
- 条件变量
- sync.Cond是传统的条件变量原语概念在 Go 语言中的实现。
- 我们可以把一个条件变量理解为一个容器,这个容器中存放着一个或一组等待着某个条件成立的 Goroutine。
- 当条件成立后,这些处于等待状态的 Goroutine 将得到通知,并被唤醒继续进行后续的工作。
- 条件变量是同步原语的一种,如果没有条件变量,开发人员可能需要在 Goroutine 中通过连续轮询的方式,检查某条件是否为真,这种连续轮询非常消耗资源,因为 Goroutine 在这个过程中是处于活动状态的,但它的工作又没有进展。
- sync.Cond是传统的条件变量原语概念在 Go 语言中的实现。
原子操作(atomic operations)
- atomic 包是 Go 语言给用户提供的原子操作原语的相关接口。原子操作(atomic operations)是相对于普通指令操作而言的。
- 原子操作由底层硬件直接提供支持,是一种硬件实现的指令级的“事务”,因此相对于操作系统层面和 Go 运行时层面提供的同步技术而言,它更为原始。
- atomic 包封装了 CPU 实现的部分原子操作指令,为用户层提供体验良好的原子操作函数,因此 atomic 包中提供的原语更接近硬件底层,也更为低级,它也常被用于实现更为高级的并发同步技术,比如 channel 和 sync 包中的同步原语。
- atomic 包提供了两大类原子操作接口,一类是针对整型变量的,包括有符号整型、无符号整型以及对应的指针类型;另外一类是针对自定义类型的。因此,第一类原子操作接口的存在让 atomic 包天然适合去实现某一个共享整型变量的并发同步。
- atomic 原子操作的特性:随着并发量提升,使用 atomic 实现的共享变量的并发读写性能表现更为稳定,尤其是原子读操作,和 sync 包中的读写锁原语比起来,atomic 表现出了更好的伸缩性和高性能。
- atomic 包更适合一些对性能十分敏感、并发量较大且读多写少的场合。不过,atomic 原子操作可用来同步的范围有比较大限制,只能同步一个整型变量或自定义类型变量。如果我们要对一个复杂的临界区数据进行同步,那么首选的依旧是 sync 包中的原语。