- 并行(parallelism),指的就是在同一时刻,有两个或两个以上的任务(这里指进程)的代码在处理器上执行
- 这种将程序分成多个可独立执行的部分的结构化程序的设计方法,就是并发设计
- 进程并不适合用于承载采用了并发设计的应用的模块执行流。因为进程是操作系统中资源拥有的基本单位,它不仅包含应用的代码和数据,还有系统级的资源,比如文件描述符、内存地址空间等等。进程的“包袱”太重,这导致它的创建、切换与撤销的代价都很大
- 线程就是运行于进程上下文中的更轻量级的执行流。线程作为执行单元可被独立调度到处理器上运行。
- 当这个应用的多个线程同时被调度到不同的处理器核上执行时,我们就说这个应用是并行的。
- 就像 Go 语言之父 Rob Pike 曾说过那样:并发不是并行,并发关乎结构,并行关乎执行。
- 并发考虑的是如何将应用划分为多个互相配合的、可独立执行的模块的问题。采用并发设计的程序并不一定是并行执行的。
Go 的并发方案:goroutine
-
Go 并没有使用操作系统线程作为承载分解后的代码片段(模块)的基本执行单元,而是实现了goroutine这一由 Go 运行时(runtime)负责调度的、轻量的用户级线程,为并发程序设计提供原生支持。
-
goroutine 的优势主要是:
- 资源占用小,每个 goroutine 的初始栈大小仅为 2k;
- 由 Go 运行时而不是操作系统调度,goroutine 上下文切换在用户层完成,开销更小;
- 在语言层面而不是通过标准库提供。goroutine 由go关键字创建,一退出就会被回收或销毁,开发体验更佳;
- 语言内置 channel 作为 goroutine 间通信原语,为并发设计提供了强大支撑。
-
goroutine 的基本用法
- 通过go关键字+函数/方法的方式创建一个 goroutine
- goroutine 的执行函数的返回,就意味着 goroutine 退出。
-
CSP(Communicating Sequential Processes,通信顺序进程)并发模型
-
一个符合 CSP 模型的并发程序应该是一组通过输入输出原语连接起来的 P 的集合
-
比如涉及性能敏感的区域或需要保护的结构体数据时,我们可以使用更为高效的低级同步原语(如 mutex),保证 goroutine 对数据的同步访问。
-
Goroutine 调度器(Goroutine Scheduler)。Goroutine 们要竞争的“CPU”资源就是操作系统线程。这样,Goroutine 调度器的任务也就明确了:将 Goroutine 按照一定算法放到不同的操作系统线程中去执行。
-
从最初的 G-M 模型、到 G-P-M 模型,从不支持抢占,到支持协作式抢占,再到支持基于信号的异步抢占,Goroutine 调度器经历了不断地优化与打磨。
-
G-M 模型::G(Goroutine) ,M(machine)。
-
调度器引入了 GOMAXPROCS 变量来表示 Go 调度器可见的“处理器”的最大数量。
-
G-M 模型的一个重要不足:限制了 Go 并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序。这个问题主要体现在这几个方面:
- 单一全局互斥锁(Sched.Lock) 和集中状态存储的存在,导致所有 Goroutine 相关操作,比如创建、重新调度等,都要上锁;
- Goroutine 传递问题:M 经常在 M 之间传递“可运行”的 Goroutine,这导致调度延迟增大,也增加了额外的性能损耗;
- 每个 M 都做内存缓存,导致内存占用过高,数据局部性较差;
- 由于系统调用(syscall)而形成的频繁的工作线程阻塞和解除阻塞,导致额外的性能损耗。
-
-
G-P-M 调度模型:
-
P 是一个“逻辑 Proccessor”
-
不支持抢占式调度
-
实现了基于协作的“抢占式”调度
- Go 编译器在每个函数或方法的入口处加上了一段额外的代码 (runtime.morestack_noctxt),让运行时有机会在这段代码中检查是否需要执行抢占调度。
-
对非协作的抢占式调度的支持,
- 这种抢占式调度是基于系统信号的,也就是通过向线程发送信号的方式来抢占正在运行的 Goroutine。
-
GPM
- 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 调度的基础。
channel
-
在声明一个 channel 类型变量时,必须给出其具体的元素类型
var ch chan int -
为 channel 类型变量赋初值的唯一方法就是使用 make 这个 Go 预定义的函数
ch1 := make(chan int) // 通过make(chan T)创建的、元素类型为 T 的 channel 类型,是无缓冲 channel, ch2 := make(chan int, 5) // 通过带有 capacity 参数的make(chan T, capacity)创建的元素类型为 T、 // 缓冲区长度为 capacity 的 channel 类型,是带缓冲 channel。 ch1 <- 13 // 将整型字面值13发送到无缓冲channel类型变量ch1中 n := <- ch1 // 从无缓冲channel类型变量ch1中接收一个整型值存储到整型变量n中 ch2 <- 17 // 将整型字面值17发送到带缓冲channel类型变量ch2中 m := <- ch2 // 从带缓冲channel类型变量ch2中接收一个整型值存储到整型变量m中 -
对同一个无缓冲 channel,只有对它进行接收操作的 Goroutine 和对它进行发送操作的 Goroutine 都存在的情况下,通信才能得以进行,否则单方面的操作会让对应的 Goroutine 陷入挂起状态。
-
对无缓冲 channel 类型的发送与接收操作,一定要放在两个不同的 Goroutine 中进行,否则会导致 deadlock。
-
带缓冲 channel 的发送操作在缓冲区未满、接收操作在缓冲区非空的情况下是异步的(发送或接收不需要阻塞等待)。
-
当缓冲区满了的情况下,对它进行发送操作的 Goroutine 就会阻塞挂起;当缓冲区为空的情况下,对它进行接收操作的 Goroutine 也会阻塞挂起。
-
不要通过共享内存来通信,而是通过通信来共享内存:使用 channel 进行不同 Goroutine 间的通信。
-
无缓冲 channel 的惯用法
- 第一种用法:用作信号传递
- 第二种用法:用于替代锁机制
-
带缓冲 channel 的惯用法
- 第一种用法:用作消息队列
- 第二种用法:用作计数信号量(counting semaphore)
-
与 select 结合使用的一些惯用法
- 第一种用法:利用 default 分支避免阻塞
- 第二种用法:实现超时机制
- 第三种用法:实现心跳机制
sync 包低级同步原语
-
可以用在哪?
- 首先是需要高性能的临界区(critical section)同步机制场景。
- 第二种就是在不想转移结构体对象所有权,但又要保证结构体内部状态数据的同步访问的场景。
-
使用的注意事项
- 推荐通过闭包方式,或者是传递类型实例(或包裹该类型的类型实例)的地址(指针)的方式进行。
- 尽量减少在锁中的操作。这可以减少其他因 Goroutine 阻塞而带来的损耗与延迟。
- 一定要记得调用 Unlock 解锁。忘记解锁会导致程序局部死锁,甚至是整个程序死锁,会导致严重的后果。同时,我们也可以结合第 23 讲学习到的 defer,优雅地执行解锁操作。
-
读写锁适合应用在具有一定并发量且读多写少的场合。
-
sync.Cond为 Goroutine 在这个场景下提供了另一种可选的、资源消耗更小、使用体验更佳的同步方式。使用条件变量原语,我们可以在实现相同目标的同时,避免对条件的轮询。
atomic 包、
- 原子操作由底层硬件直接提供支持,是一种硬件实现的指令级的“事务”,
- atomic 包更适合一些对性能十分敏感、并发量较大且读多写少的场合。
- atomic 原子操作可用来同步的范围有比较大限制,只能同步一个整型变量或自定义类型变量。如果我们要对一个复杂的临界区数据进行同步,那么首选的依旧是 sync 包中的原语。