Go 并发模型
共 3377字,需浏览 7分钟
·
2023-08-24 21:45
1. 调度模型
CPU执行指令的速度是非常快的。在 3.0GHz 主频的单核 CPU 核心上,大部分简单指令的执行仅需 1 个时钟周期,也就是三分之一纳秒。在仅考虑执行,不考虑读取数据耗时的情况下,1s 可以执行 30 亿条简单指令,速度极快。CPU 慢的原因是慢在对外部数据的读/写上。外部 I/O 的速度慢和阻塞是导致 CPU 使用效率不高的最大原因。其实在大部分真实系统中,CPU 都不是瓶颈,CPU 的大部分时间被白白浪费了,所以增加 CPU 的吞吐量是程序员的重要指标。
所谓提高 CPU 的有效吞吐量,就是让 CPU 尽量多干活,而不是在空跑或等待。理想的状态是机器的每个 CPU 核心都有事情做,尽可能快地做一些事情。
-
尽可能让每个 CPU 核心都有事情做。
这就要求工作的线程要大于 CPU 的核心数,单进程的程序最多使用一个 CPU 干活,没有办法有效利用机器资源。由于 CPU 要和外部设备通信,单个线程经常会被阻塞,这其中包括 I/O 等待、缺页中断、等待网络等。所以 CPU 和线程的比例是 1:1,大部分情况下不能充分发挥 CPU 的威力。实际上依据程序的特性,合理地调整 CPU 和线程的关系,一般情况下,线程数要大于 CPU 的个数,才能发挥机器的价值。
-
尽可能提高每个 CPU 核心做事情的效率
现在操作系统虽然能够进行并行调度,但当进程数大于 CPU 核心的时候,就存在进程切换的问题。该切换需要保存上下文,恢复堆栈。频繁的切换也很耗时,我们的目标是尽量让程序减少阻塞和切换,尽量让进程跑满操作系统分配的时间片。
上面是从整个系统的角度来看程序的运行效率问题,但具体到应用程序又有所不同。应用程序的并发模型是多样的,主要看以下三种。
-
多进程模型
进程都能被多核 CPU 并发调度,优点是每个进程都有自己独立的内存空间,隔离性好、健壮性高;缺点是进程比较重,进程的切换消耗较大,进程间的通信需要多次在内核区和用户区之间复制数据。
-
多线程模型
这里的多线程是指启动多个内核线程进行处理,线程的优点是通过共享内存进行通信更快捷,切换代价小;缺点是多个线程共享内存空间,很容易导致数据访问混乱,某个线程误操作内存挂掉可能危及整个线程组,健壮性不高。
-
用户级多线程模型
用户级多线程分为两种情况,一种是 M:1 的方式,M 个用户线程对应一个内核线程,这种情况很容易因为一个系统阻塞,其他用户线程都会阻塞,不能利用机器多核的优势。还有一种是 M:N 的方式,M 个用户线程对应 N 个内核线程,这种模式一般需要语言运行时或库的支持,效率最高。
程序并发处理的要求越来越高,但是不能无限制地增加系统线程数,线程数过多会导致操作系统的调度开销大,单个线程的单位时间内被分配的运行时间片减少,单个线程的运行速度降低,单靠增加系统线程数不能满足要求。为了不让系统线程无限膨胀,于是就有了协程的概念。
协程是一种用户态的轻量级线程,协程的调度完全由用户态程序控制,协程拥有自己的寄存器上下文和栈。协程调度切换时,将寄存器上下文和栈保存到其他地方,在切回来的时候,恢复先前保存的寄存器上下文和栈,每个内核线程可以对应多个用户协程,当一个协程执行体阻塞了,调度器会调度另一个协程执行,最大效率地利用操作系统分给系统线程的时间片。我们提到的用户级多线程模型就是一种协程模型,尤其以 M:N 模型最为高效。好处就是:
-
控制了系统线程数,保证每个线程的运行时间片充足。
-
调度层能进行用户态的切换,不会导致单个协程阻塞整个程序的情况,尽量减少上下文切换,提升运行效率。
所以,协程是一种非常高效、理想的执行模型。Go 的并发执行模型就是变种的协程模型。
2. 并发和调度
Go 语言在语言层面引入了 goroutine。
-
goroutine 可以在用户空间调度,避免了内核态和用户态切换导致的成本。
-
goroutine 是语言原生支持的,提供了非常简洁的语法,屏蔽了大部分复杂底层实现。
-
goroutine 更小的栈空间允许用户创建成千上万的实例。
Go 的调度模型中抽象出三个实体:M、P、G。
G(Goroutine)
G 是 Go 运行时对 goroutine 的抽象描述,G 中存放并发执行的代码入口地址、上下文、运行环境(关联的 P 和 M)、运行栈等执行相关的元信息。
G 的新建、休眠、恢复、停止都受到 Go 运行时的管理。Go 运行时的监控线程会监控 G 的调度,G 不会长久地阻塞系统线程,运行时的调度器会自动切换到其他 G 上继续执行。G 新建或恢复时会添加到运行队列,等待 M 取出并运行。
M(Machine)
M 代表 OS 内核线程,是操作系统层面调度和执行的实体。M 仅负责执行,M 不停地被唤醒或创建,然后执行。M 启动时进入的是运行时的管理代码,由这段代码获取 G 和 P 资源,然后执行调度。另外,Go 语言运行时会单独创建一个监控线程,负责对程序的内存、调度等信息进行监控和控制。
P(Processor)
P 代表 M 运行 G 所需要的资源,是对资源的一种抽象和管理,P 不是一段代码实体,而是一个管理的数据结构,P 主要是降低 M 管理调度 G 的复杂性,增加一个间接的控制层数据结构。把 P 看作资源,而不是处理器,P 控制 Go代码的并行度,它不是运行实体。P 持有 G 的队列,P 可以隔离调度,解除 P 和 M 的绑定就解除了 M 对一串 G 的调用。P 在运行模型中只是一个数据模型,而不是程序控制模型。
M 和 P 一起构成一个运行时环境,每个 P 有一个本地的可调度 G 队列,队列里的 G 会被 M 依次调度执行,如果本地队列空了,则会去全局队列偷取一部分 G,如果全局队列也是空的,则去其他的 P 中偷取一部分 G。下面是调度结构。
G 并不是执行体,而是用于存放并发执行体的元信息,包括并发执行的入口函数、堆栈、上下文等信息。G 对象是可以复用的,只需将相关元信息初始化为新值即可。M 仅负责执行,M 启动时进入运行时的管理代码,这段管理代码必须拿到可用的 P 后,才能执行调度。P 的数目默认是 CPU 核心的数量,可以通过 runtime.GOMAXPROCS 函数设置或查询,M 和 P 的数目差不多,运行时会根据当前的状态动态地创建 M,M 有一个最大值上限,目前是 10000;G 与 P 是一种 M:N 的关系,M 可以成千上万,远远大于 N。
Go 启动初始化过程
-
分配和检查栈空间
-
初始化参数和环境变量
-
当前运行线程标记为 m0,m0 是程序启动的主线程
-
调用运行时初始化函数 runtime.schedinit 进行初始化。主要是初始化内存空间分配器、GC、生成空闲 P 列表
-
在 m0 上调度第一个 G,这个 G 运行 runtime.main 函数
runtime.main 会拉起运行时的监控线程,然后调用 main 包的 init() 初始化函数,最后执行 main 函数。
什么时候创建 M、P、G
程序启动过程中会初始化空闲 P 列表,P 是在这个时候被创建的,同时第一个 G 也是在初始化过程中被创建的。后续在有 go 并发调用的地方都有可能创建 G。
每个并发调用都会初始化一个新的 G 任务,然后唤醒 M 执行任务。先尝试获取当前线程 M,如果无法获取,则从全局调度的空闲 M 列表中获取可用的 M,如果没有可用的,则新建 M,然后绑定 P 和 G 进行运行。
M 线程里有管理调度和切换堆栈的逻辑,但是 M 必须拿到 P 后才能运行,可以看到 M 是自驱动的,但是需要 P 的配合。