Go第十四课-并发

219 阅读8分钟
  1. 并行(parallelism),指的就是在同一时刻,有两个或两个以上的任务(这里指进程)的代码在处理器上执行
  2. 这种将程序分成多个可独立执行的部分的结构化程序的设计方法,就是并发设计
  3. 进程并不适合用于承载采用了并发设计的应用的模块执行流。因为进程是操作系统中资源拥有的基本单位,它不仅包含应用的代码和数据,还有系统级的资源,比如文件描述符、内存地址空间等等。进程的“包袱”太重,这导致它的创建、切换与撤销的代价都很大
  4. 线程就是运行于进程上下文中的更轻量级的执行流。线程作为执行单元可被独立调度到处理器上运行。
  5. 当这个应用的多个线程同时被调度到不同的处理器核上执行时,我们就说这个应用是并行的。
  6. 就像 Go 语言之父 Rob Pike 曾说过那样:并发不是并行,并发关乎结构,并行关乎执行。
  7. 并发考虑的是如何将应用划分为多个互相配合的、可独立执行的模块的问题。采用并发设计的程序并不一定是并行执行的。

Go 的并发方案:goroutine

  1. Go 并没有使用操作系统线程作为承载分解后的代码片段(模块)的基本执行单元,而是实现了goroutine这一由 Go 运行时(runtime)负责调度的、轻量的用户级线程,为并发程序设计提供原生支持。

  2. goroutine 的优势主要是:

    1. 资源占用小,每个 goroutine 的初始栈大小仅为 2k;
    2. 由 Go 运行时而不是操作系统调度,goroutine 上下文切换在用户层完成,开销更小;
    3. 在语言层面而不是通过标准库提供。goroutine 由go关键字创建,一退出就会被回收或销毁,开发体验更佳;
    4. 语言内置 channel 作为 goroutine 间通信原语,为并发设计提供了强大支撑。
  3. goroutine 的基本用法

    1. 通过go关键字+函数/方法的方式创建一个 goroutine
    2. goroutine 的执行函数的返回,就意味着 goroutine 退出。
  4. CSP(Communicating Sequential Processes,通信顺序进程)并发模型

  5. 一个符合 CSP 模型的并发程序应该是一组通过输入输出原语连接起来的 P 的集合

  6. 比如涉及性能敏感的区域或需要保护的结构体数据时,我们可以使用更为高效的低级同步原语(如 mutex),保证 goroutine 对数据的同步访问。

  7. Goroutine 调度器(Goroutine Scheduler)。Goroutine 们要竞争的“CPU”资源就是操作系统线程。这样,Goroutine 调度器的任务也就明确了:将 Goroutine 按照一定算法放到不同的操作系统线程中去执行。

  8. 从最初的 G-M 模型、到 G-P-M 模型,从不支持抢占,到支持协作式抢占,再到支持基于信号的异步抢占,Goroutine 调度器经历了不断地优化与打磨。

  9. G-M 模型::G(Goroutine) ,M(machine)。

    1. 调度器引入了 GOMAXPROCS 变量来表示 Go 调度器可见的“处理器”的最大数量。

    2. G-M 模型的一个重要不足:限制了 Go 并发程序的伸缩性,尤其是对那些有高吞吐或并行计算需求的服务程序。这个问题主要体现在这几个方面:

      1. 单一全局互斥锁(Sched.Lock) 和集中状态存储的存在,导致所有 Goroutine 相关操作,比如创建、重新调度等,都要上锁;
      2. Goroutine 传递问题:M 经常在 M 之间传递“可运行”的 Goroutine,这导致调度延迟增大,也增加了额外的性能损耗;
      3. 每个 M 都做内存缓存,导致内存占用过高,数据局部性较差;
      4. 由于系统调用(syscall)而形成的频繁的工作线程阻塞和解除阻塞,导致额外的性能损耗。
  10. G-P-M 调度模型:

    1. P 是一个“逻辑 Proccessor”

    2. 不支持抢占式调度

    3. 实现了基于协作的“抢占式”调度

      1. Go 编译器在每个函数或方法的入口处加上了一段额外的代码 (runtime.morestack_noctxt),让运行时有机会在这段代码中检查是否需要执行抢占调度。
    4. 对非协作的抢占式调度的支持,

      1. 这种抢占式调度是基于系统信号的,也就是通过向线程发送信号的方式来抢占正在运行的 Goroutine。

GPM

  1. G: 代表 Goroutine,存储了 Goroutine 的执行栈信息、Goroutine 状态以及 Goroutine 的任务函数等,而且 G 对象是可以重用的;
  2. P: 代表逻辑 processor,P 的数量决定了系统内最大可并行的 G 的数量,P 的最大作用还是其拥有的各种 G 对象队列、链表、一些缓存和状态;
  3. M: M 代表着真正的执行计算资源。在绑定有效的 P 后,进入一个调度循环,而调度循环的机制大致是从 P 的本地运行队列以及全局队列中获取 G,切换到 G 的执行栈上并执行 G 的函数,调用 goexit 做清理工作并回到 M,如此反复。M 并不保留 G 状态,这是 G 可以跨 M 调度的基础。

channel

  1. 在声明一个 channel 类型变量时,必须给出其具体的元素类型var ch chan int

  2. 为 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中
    
  3. 对同一个无缓冲 channel,只有对它进行接收操作的 Goroutine 和对它进行发送操作的 Goroutine 都存在的情况下,通信才能得以进行,否则单方面的操作会让对应的 Goroutine 陷入挂起状态。

  4. 对无缓冲 channel 类型的发送与接收操作,一定要放在两个不同的 Goroutine 中进行,否则会导致 deadlock。

  5. 带缓冲 channel 的发送操作在缓冲区未满、接收操作在缓冲区非空的情况下是异步的(发送或接收不需要阻塞等待)。

  6. 当缓冲区满了的情况下,对它进行发送操作的 Goroutine 就会阻塞挂起;当缓冲区为空的情况下,对它进行接收操作的 Goroutine 也会阻塞挂起。

  7. 不要通过共享内存来通信,而是通过通信来共享内存:使用 channel 进行不同 Goroutine 间的通信。

  8. 无缓冲 channel 的惯用法

    1. 第一种用法:用作信号传递
    2. 第二种用法:用于替代锁机制
  9. 带缓冲 channel 的惯用法

    1. 第一种用法:用作消息队列
    2. 第二种用法:用作计数信号量(counting semaphore)
  10. 与 select 结合使用的一些惯用法

    1. 第一种用法:利用 default 分支避免阻塞
    2. 第二种用法:实现超时机制
    3. 第三种用法:实现心跳机制

sync 包低级同步原语

  1. 可以用在哪?

    1. 首先是需要高性能的临界区(critical section)同步机制场景。
    2. 第二种就是在不想转移结构体对象所有权,但又要保证结构体内部状态数据的同步访问的场景。
  2. 使用的注意事项

    1. 推荐通过闭包方式,或者是传递类型实例(或包裹该类型的类型实例)的地址(指针)的方式进行。
    2. 尽量减少在锁中的操作。这可以减少其他因 Goroutine 阻塞而带来的损耗与延迟。
    3. 一定要记得调用 Unlock 解锁。忘记解锁会导致程序局部死锁,甚至是整个程序死锁,会导致严重的后果。同时,我们也可以结合第 23 讲学习到的 defer,优雅地执行解锁操作。
  3. 读写锁适合应用在具有一定并发量且读多写少的场合。

  4. sync.Cond为 Goroutine 在这个场景下提供了另一种可选的、资源消耗更小、使用体验更佳的同步方式。使用条件变量原语,我们可以在实现相同目标的同时,避免对条件的轮询。

atomic 包、

  1. 原子操作由底层硬件直接提供支持,是一种硬件实现的指令级的“事务”,
  2. atomic 包更适合一些对性能十分敏感、并发量较大且读多写少的场合。
  3. atomic 原子操作可用来同步的范围有比较大限制,只能同步一个整型变量或自定义类型变量。如果我们要对一个复杂的临界区数据进行同步,那么首选的依旧是 sync 包中的原语。