1.介绍

Go语言以简单、高效地编写高并发程序而闻名,这离不开Go语言原语中协程(Goroutine)的设计,也正对应了多核处理器的时代需求。

与传统多线程开发不同,GO语言通过更轻量级的协程让开发更便捷,同时避免了许多传统多线程开发需要面对的困难。因此,Go语言在设计和使用方式上都与传统的语言有所不同,必须理解其基本的设计哲学和使用方式才能正确地使用。

2. 系统进化史

为了了解Go语言协程的设计,我们从系统历史设计出发,来看看最终Goroutine怎么一步一步到现在的设计;

2.1 单进程时代

早期的操作系统每个程序就是一个进程,操作系统在一段时间只能运行一个进程,直到这个进程运行完,才能运行下一个进程,这个时期可以成为单进程时代——串行时代,如下图:

image-20230602181728353

单进程时代的问题:

  • 单一执行流程、计算机只能一个一个任务的处理。
  • 进程阻塞所带来的CPU资源浪费;比如上图中的进程A阻塞(需要消耗30分钟),就会导致后面进程一直在等待。单进程时代,CPU没有切换能力。

2.2 多进程时代

从单进程时代,可以看出CPU没有被充分利用,直到后来系统升级为多进程;当一个进程阻塞时,CPU就会切换到另外一个进程并执行,这样就能尽量把CPU利用起来;这样系统就具有了最早期的并发能力。

image-20230602214326945

在多进程时代,有了时间片的概念,进程按照调度算法,分时间片在CPU上执行,由于CPU执行速度很快,1秒中可能切换进程好几千次,这样看上去就像多个进程在同时运行一样。

多进程时代的问题:

多进程带来的优点显而易见,即便是单核系统,也可以并发执行多个进程,即使某个进程IO阻塞时,也能保证CPU的利用率。除了优点之外,还有以下不可忽视的缺点:

  • 上下文切换:进程切换时,需要保存当前正在运行进程的上下文信息(包括寄存器的状态、程序计数器、栈指针等),然后加载下一个要执行的进程的上下文信息。这个上下文切换的过程涉及到寄存器的保存和恢复,栈的切换等操作,会消耗一定的时间和计算资源。

  • 内核开销:进程切换涉及到内核的介入。当进程切换时,操作系统需要完成一系列的任务,如更新进程控制块、调度算法的执行、权限切换等。这些任务需要进行系统调用和内核操作,会带来额外的开销。

  • 缓存失效:当进程切换时,CPU的缓存中可能包含了当前正在执行的进程的数据和指令。切换到另一个进程后,之前的缓存内容可能会变得无效,需要重新加载新进程的数据和指令。这会导致缓存失效,从而降低CPU的效率。

  • 虚拟内存切换:如果系统使用了虚拟内存技术,进程切换时还需要进行页面表的切换和页表的更新。这涉及到虚拟内存的管理和页表的加载,会引入额外的开销。

@注:进程是资源分配的最小单位。

2.3 多线程时代

多进程时代虽然可以提高CPU使用率,但CPU大部分时间都被用于进程调度,除此之外,多进程之间还存在:资源不能进行共享、进程切换消耗大、创建进程开销大等原因;

基于多进程的一些缺点,后来就诞生出:多线程轻量级的进程) 。

2.3.1 线程和进程的关系

  • 当进程只有一个线程时,可以认为进程就等于线程。
  • 当进程拥有多个线程时,这些线程会共享相同的虚拟内存和全局变量等资源。这些资源在上下文切换时是不需要修改的。
  • 线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时也是需要保存的。

@注:线程是CPU调度的最小单位

2.3.2 多线程运行

image-20230605141802003

线程默认栈大小:

  1. 在32位Windows系统上,每个线程默认的栈大小为1MB;在64位Windows系统上,每个线程默认的栈大小为2MB
  2. Linux系统上,每个线程默认的栈大小可以通过ulimit命令或pthread库的相关函数进行配置;通常情况下,默认的栈大小为8MB

多线程时代的问题:

  • 内存占用: 每个线程会都占用 1M 以上的内存空间,满足不了当今需求(动不动就是成千上万的并发量);
  • 调度器: 线程的调度是由操作系统内核负责,线程之间的切换需要进行上下文的保存和恢复,这会引入一定的开销。频繁的线程切换会消耗大量的CPU时间,降低系统的整体性能;
  • 数量上限: 由于由操作系统的设计和实现原理,一般都对线程数量也进行了限制; (threads-max是一个系统参数,用于指定系统中允许的最大线程数。)

2.4 协程时代

由于线程是CPU执行的最小单位,所以线程的切换,执行的调度器依然是操作系统在调度的,我们称之为 内核态,后来发现了用户态的线程 ,也就是协程轻量级的线程)。

@注: CPU并不能感知到用户态线程的存在,它只能感知到内核态的线程,所以用户态线程往往都需要绑定到内核态线程上。

2.4.1 协程和线程的区别

协程和线程是两种不同的并发编程概念,它们具有一些重要的区别。

  1. 调度机制:线程的调度和切换由操作系统内核完成,而协程的调度和切换是由程序员手动控制的。线程的切换需要进行系统调用,涉及上下文切换和保存线程状态,而协程的切换只涉及用户态的切换,开销更小。
  2. 并发性:线程是操作系统级别的并发单元,多个线程可以同时执行在不同的CPU核心上。而协程在任意时刻只有一个在运行,其他协程处于暂停状态,需要等待当前协程主动释放控制权。协程通过协作式调度实现并发,多个协程之间需要显式地进行切换。
  3. 内存占用:线程需要为每个线程分配独立的堆栈和上下文信息,而协程的堆栈可以共用,所以协程的内存占用更小。
  4. 编程模型:线程的并发编程通常需要使用锁、条件变量等机制进行同步和通信,而协程通过消息传递、共享变量等方式进行通信和同步,更加简洁和直观。

总的来说,线程是由操作系统内核进行调度和切换的并发模型,具有系统级别的并发能力,而协程是由程序员手动控制调度和切换的并发模型,更加轻量级且具有更高的性能。协程在编程模型上更加灵活和简洁,但需要显式地进行切换管理。

3. Go协程和线程

为了更深入地理解Go协程与线程的区别,我们从调度方式、上下文切换的速度、调度策略、内存占用这四个方面,分析线程与协程的不同之处。

3.1 调度方式

Go语言中,协程的管理,依赖Go语言运行时自身提供的调度器。同时,Go语言中的协程从属于某一个线程;协程与线程的对应关系为M:N,即多对多,如下图所示:

Go语言调度器可以将多个协程调度到一个线程中,一个协程也可能切换到多个线程中执行。

3.2 上下文切换速度

协程(Goroutines)是Go语言中的轻量级线程,由Go语言运行时环境(runtime)管理。与传统的操作系统线程相比,协程的上下文切换开销通常较小。下面是协程上下文切换和线程上下文切换的速度对比:

  1. 协程上下文切换速度: 协程的上下文切换由Go语言的运行时环境自行管理,通常不需要与操作系统进行交互,因此上下文切换的开销较小。
  2. 线程上下文切换速度: 线程的上下文切换由操作系统内核负责管理。线程上下文切换需要保存和恢复线程的执行状态,包括寄存器、栈等信息,这些操作都需要与内核进行交互,开销相对较大。

总体而言,协程的上下文切换速度通常比线程的上下文切换速度更快。这是由于协程的调度是在用户态完成的,而线程的调度需要涉及内核态和用户态之间的切换。协程的轻量级特性和协作式调度机制使得上下文切换的开销较小,这使得Go语言在高并发场景下能够更高效地利用系统资源。

上下文切换的速度受到诸多因素的影响,这里列出一些值得参考的量化指标:线程切换的速度大约为1~2微秒,Go语言中协程切换的速度比它快数倍,为0.2微秒左右。

3.3 调度策略

3.3.1 协程调度策略

Go语言的协程调度采用了协作式调度策略。在协作式调度中,协程主动让出CPU资源给其他协程运行,而不是由操作系统进行强制性的抢占当一个协程遇到阻塞操作时,如等待I/O完成或休眠时间到达,它会主动交出控制权,让其他就绪的协程运行。这种调度策略避免了抢占式调度带来的频繁上下文切换和锁竞争的开销。

Go语言的协程调度器会根据一些策略在协程之间平均分配CPU时间片,以实现公平调度。此外,调度器还会根据当前系统负载和协程的阻塞情况等因素进行动态调整,以提高系统的整体性能。

3.3.2 线程调度策略

传统的线程调度采用了抢占式调度策略。在抢占式调度中,操作系统内核可以在任何时间中断正在执行的线程,并将CPU资源分配给其他就绪的线程。这种调度策略依赖于时钟中断或其他事件触发的机制,以进行上下文切换和线程的调度。

线程调度器通常采用一些调度算法,如时间片轮转、优先级调度等,以决定哪个线程能够执行,并在需要时进行上下文切换。这种调度策略可以在多核系统中更好地利用硬件资源,并允许操作系统对线程进行强制性的抢占。

3.4 栈的大小

线程的栈大小一般是在创建时指定的,为了避免出现栈溢出(Stack Overflow),默认的栈会相对较大(例如2MB),这意味着每创建1000个线程就需要消耗2GB的虚拟内存,大大限制了线程创建的数量(64位的虚拟内存地址空间已经让这种限制变得不太严重)。

Go语言中的协程栈默认为2KB,在实践中,经常会看到成千上万的协程存在。同时,线程的栈在运行时不能更改,但是Go语言中的协程栈在Go运行时的帮助下会动态检测栈的大小,并动态地进行扩容。

4. 主协程和子协程

4.1 示例代码

package main

import "fmt"

func main() {
// 在循环中开启协程打印1~10的数字
for i := 1; i <= 10; i++ {
n := i
go func() {
fmt.Println("i:", n)
}()
}
}

示意图

@注:当执行上述程序,不会有任何输出;原因是: 当主协程退出时,程序就会直接退出,这是主协程与其他协程的显著区别。

4.2 定义和区别

4.2.1 定义

  • 主协程: 主协程是指程序的主要执行线程,在程序启动时由系统自动创建。主协程负责执行程序的入口函数(通常是main()函数)以及其他顶层协程的创建和管理。主协程一般不会被阻塞或长时间执行耗时操作,它通常负责启动并管理整个程序的并发执行流程。

  • 子协程: 子协程是由主协程创建的额外协程,用于执行并发任务。子协程可以通过Go关键字创建,例如go funcName(),其中funcName()是一个函数或匿名函数。子协程可以独立执行,并且可以与其他协程并发运行。

4.2.2 区别

主协程和子协程之间的区别主要体现在以下几个方面:

  • 创建方式:主协程是由系统自动创建的,而子协程需要通过go关键字显式创建。
  • 主要任务:主协程负责程序的整体控制流程和顶层逻辑,而子协程用于执行具体的并发任务。
  • 生命周期:主协程的生命周期与整个程序的生命周期相同,而子协程可以在任意时刻创建和销毁。
  • 管理关系:主协程负责管理子协程的创建、执行和等待,确保协程的协同工作。主协程可能会等待子协程的完成或等待子协程的结果。
  • 阻塞与非阻塞:主协程可能会被阻塞,例如等待子协程的完成或等待通道的消息。而子协程通常是非阻塞的,并且可以独立执行,不会阻塞主协程或其他子协程的运行。