编辑
2023-11-14
服务端
0
请注意,本文编写于 338 天前,最后修改于 79 天前,其中某些信息可能已经过时。

目录

操作系统知识和概念
并行与并发
同步与异步
上下文切换和任务调度
进程与线程与协程
进程
线程
协程
高并发编程的挑战
Go的并发
原理
原理
GMP
可运行队列
调度循环
Goroutine 状态
调度算法和两级线程模型
性能对比
常见问题
GMP中P的数量
线程池
Go
GMP中G的数量
goroutine 泄漏与避免
一个真实案例
结语

操作系统知识和概念

并行与并发

并行并发其实是不同的概念,官方有专门的介绍视频,大家有兴趣可以去看看。我个人是这么理解:

  • 并行:同一时刻执行多个任务。通过多核的 CPU 可以让多个任务做到真正意义上的同时运行,每个任务都在不同的处理器核心或线程中运行。
  • 并发:同一时间段内执行多个任务。通过时间片或者让出控制权来实现任务切换,达到“同时”运行多个任务的目的。其本质是在任意时刻都只有一个任务处于执行态中,而在其余任务则是通过某种算法等待被唤醒执行。

我们以 Node.js 为例来理解并发和并行的概念。

  • 并发:在 Node.js 中,通过事件驱动的非阻塞I/O模型,实现了高并发的处理能力。当一个请求到达时,Node.js 将会创建一个事件,并将其放入事件队列中。然后,Node.js 会立即继续处理下一个请求,而不会等待前一个请求的I/O操作完成。这样,在等待I/O操作返回结果的过程中,Node.js 能够继续处理其他请求,充分利用了CPU的空闲时间,提高了系统的吞吐量和响应速度。这种利用事件循环的方式来处理多个请求的方式就是并发。
  • 并行:在 Node.js 中,可以通过像 PM2 这样的工具来创建多个 Node.js 进程或者集群,每个进程都可以独立处理请求。这样一来,不同的请求可以被分配到不同的进程上处理,从而实现真正的并行处理。通过这种方式,可以充分利用多核CPU的性能,提高系统的并发能力和处理能力。

在开发中,同步和异步是两种常见的方法调用方式,它们在处理任务时有着不同的执行方式和特点。

同步与异步

在实际业务开发中,同步和异步调用是代码逻辑中最常见的方法调用形式。

  • 同步调用:
    • 在同步方法调用中,调用者发起方法调用后,会等待被调用者执行完任务并返回结果,然后才继续执行下面的代码。
    • 调用者在执行同步方法时会被阻塞,直到方法执行完成为止。这意味着在同步方法执行期间,调用者的执行会暂停,无法处理其他任务。
    • 同步方法调用通常用于简单的、耗时较短的操作,因为长时间的同步操作会导致调用者的阻塞,影响系统的响应性能。
  • 异步调用:
    • 在异步方法调用中,调用者发起方法调用后,不会立即等待被调用者执行任务和返回结果,而是继续执行后续的代码。
    • 异步方法的执行通常会在后台或另一个线程中进行,被调用者会在执行完任务后通过回调函数或事件通知调用者。
    • 异步方法调用可以提高系统的并发性和响应性能,因为调用者不会被阻塞,可以继续执行其他任务,而不必等待异步任务执行完成。

总的来说,同步方法调用会阻塞调用者的执行,直到方法执行完成,而异步方法调用则允许调用者在等待任务执行的同时继续执行其他任务,提高了系统的并发性和响应性能。在实际开发中,根据任务的特点和系统的需求选择合适的调用方式非常重要。

上下文切换和任务调度

上下文切换是指在多任务系统中,由于 CPU 需要从一个任务(例如线程或进程)切换到另一个任务时,需要保存当前任务的状态(即上下文),并恢复另一个任务的状态。这个过程确实是昂贵的,因为涉及到保存和恢复大量的状态信息。

在单个 CPU 核心上进行线程切换,会消耗大量时间。上下文切换的延迟大约在 50 到 100 纳秒之间,这意味着即使在纳秒级的时间尺度上,也会有显著的性能损失。考虑到硬件平均每个核心每纳秒执行 12 条指令,一次上下文切换可能会消耗 600 到 1200 条指令的执行时间。这个消耗是非常显著的,尤其对于高性能要求的应用程序而言。

如果是跨 CPU 进行线程切换,还会导致 CPU 缓存失效,进一步增加了成本。CPU 从缓存访问数据的成本相比从主存访问数据要低很多,但是当线程切换跨越 CPU 时,可能会导致缓存失效,需要重新从主存中加载数据,这会增加切换的成本。

因此,有效地管理上下文切换成本是系统设计和优化中的一个重要方面,特别是在高并发和高性能的应用场景下。使用轻量级线程或协程等技术,可以减少上下文切换的开销,提高系统的并发性能。

大部分操作系统的任务调度都是使用时间片轮转的抢占式调度算法,即某一线程运行完一个时间分片之后,系统内核进行调度,中断处理器,将这个线程的寄存器放入内存中,然后执行下一个线程,并从内存中恢复它的寄存器数据,开始执行新的线程。 为什么需要线程

在早期的操作系统中并没有线程的概念,进程是能拥有资源和独立运行的最小单位,也是程序执行的最小单位。后来,随着计算机的发展,对CPU的要求越来越高,进程之间的切换开销较大,已经无法满足越来越复杂的程序的要求了。于是就诞生了线程,线程是程序执行中一个单一的顺序控制流程,是程序执行流的最小单元,是处理器调度和分派的基本单位。

进程与线程的关系和区别

  • 进程将CPU资源分给线程,即真正在CPU上运行的是线程。
  • 计算机将系统资源分配给进程,同一进程的所有线程共享该进程的所有资源。
  • 同一个进程中的线程共享同一内存空间和数据,但是进程之间是独立的。
  • 同一个进程的线程之间可以直接通信,但是进程之间的交流需要借助中间代理来实现。

进程与线程与协程

这里推荐一篇阮老师的文章:进程与线程的一个简单解释

image.png

进程

进程是计算机进行资源分配和调度的基本单位,是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,是操作系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。每个进程都拥有独立的内存空间和自己的地址空间。一个进程的主要组成部分包括程序指令集、数据集、程序控制块(PCB)。

线程

线程是计算机进行CPU分配的基本单位。它依附于进程存在,共享相同的内存空间和资源。一个进程可以拥有多个线程。打开 macOS 中的活动监视器,我们可以看到每条任务都有线程这一列,可以看到某个进程开启了多少个线程。

线程的组成部分有线程ID、当前指令计数器(PC)、寄存器和堆栈等。线程拥有自己独立的栈和共享堆。

协程

协程是比线程更加轻量化的任务,具有内核不可见性的特征,是由开发者编写的应用程序来进行管理的,又被叫做用户空间线程。它有如下特性

  • 轻量级
    • 资源占用少:协程的创建和切换开销非常小,远小于线程。一个进程可以启动成千上万个协程,而不需要大量的系统资源。协程的默认栈大小为kb级,比线程的mb级更小。因次,在同样大小的内存中,可以开启更多的协程。
    • 用户态管理:协程的调度通常在用户态完成,不需要进入内核态,因此切换速度快。
  • 协作式调度
    • 非抢占式:协程的调度是协作式的,即协程主动让出控制权。一个协程运行时,只有它主动调用某些操作(如等待 I/O、显式的让出操作)才会切换到其他协程。
    • 控制权转移:协程可以显式地将控制权转移给其他协程,这种机制使得编写异步代码更加直观。
  • 独立的执行单元
    • 独立栈空间:每个协程有自己的栈空间,独立执行,不会与其他协程的栈空间混淆。协程之间共享堆,不共享栈。
    • 独立的上下文:协程有自己的寄存器状态、程序计数器等上下文信息,可以独立运行。
  • 并发执行
    • 高并发:协程允许程序并发执行多个任务,充分利用多核 CPU 的能力,提高程序的吞吐量。
    • 异步 I/O:协程可以通过异步 I/O 操作有效地处理大量 I/O 密集型任务。
  • 简单的编程模型
    • 同步编程风格:尽管协程是并发执行的,但它们的编程模型通常是同步的,这使得代码更易于编写和理解。
    • 线性逻辑:使用协程编写的代码通常具有线性逻辑,避免了传统回调地狱的问题。
  • 灵活性
    • 可控性:开发者可以精确控制协程的启动、暂停和恢复,提供了灵活的并发控制。
    • 组合性:协程可以方便地组合成复杂的并发操作,提升代码的可重用性和可维护性。

协程Goroutine也是Go语言并发模型最重要的特性之一。它由 Go语言的运行时环境(runtime)管理,相比于传统的线程,Goroutine 的创建和销毁的成本更低,可以轻松创建数以千计甚至更多的并发执行单元。通过 Goroutine 和 Channel 在多个协程间进行通信和数据同步,避免了传统并发编程中锁的复杂性和可能带来的问题。这种并发模型让开发者能够更容易地编写出高效、可靠的并发代码,从而提高了开发效率和程序的性能。

协程与线程的映射关系 协程和线程之间的关系可能有多种情况。 一、N : 1 最简单的做法是用多个协程绑定一个线程,协程的切换不会导致内核态的陷入,故而协程切换非常轻量。

缺点

  • 由于协程都绑定在同一个线程上,所以也都运行在一个核心中,无法实现多核心的并行,不能利用多核硬件的性能优势,故而不适合计算密集型的任务。
  • 当一个协程阻塞,导致线程阻塞,会使得线程中的其他协程也无法执行,失去并发能力。

二、1 : 1 协程与线程之间使用 1 : 1 的映射关系,避免了上述阻塞问题,然而这样与线程类似,协程的调度也需要CPU来完成,代价较大,失去协程的轻便型。

三、M : N M个协程绑定了N个线程,解决了上面两种方法的缺点,代价是实现的复杂性。

高并发编程的挑战

线程级并行的确是随着多核微处理器的普及而变得越来越重要。在过去,提高单个核心的时钟频率是提高处理器性能的主要手段之一,但是随着时钟频率的逐渐达到物理极限,进一步提升变得越来越困难。因此,利用多核处理器的并行计算能力成为了提高整体系统性能的必然选择。

通过线程级并行,应用程序能够更好地利用多核处理器的潜在计算能力,实现更高的并发性和吞吐量。当一个线程在等待I/O操作或内存访问时,其他线程可以继续执行,从而最大程度地减少了系统的闲置时间,提高了系统的整体利用率和响应速度。

随着计算机体系结构的不断发展,线程级并行的重要性将会持续增加。因此,开发者需要逐渐转向设计和优化能够充分利用多核处理器的并发应用程序,以实现ultimate的性能和效率。

另一方面,并发编程中的各种问题确实是挑战性的,而且需要认真考虑和解决。让我们逐一来看:

  • 数据竞争:数据竞争是并发编程中最棘手的问题之一。它发生在多个线程同时访问共享数据时,其中至少一个线程在写入数据。解决数据竞争通常涉及使用同步机制,如互斥锁或信号量,来确保在任何给定时间只有一个线程能够修改共享数据。
  • 原子性操作:确保操作的原子性对于并发编程至关重要。原子操作是不可分割的单个操作,要么全部完成,要么全部不完成,没有中间状态。在处理非原子性操作时,可以使用原子操作或者加锁来确保操作的完整性。
  • 锁的相关问题: 锁是常用的同步机制,但它们可能引发死锁、活锁或饥饿等问题。死锁是指两个或多个线程相互等待对方持有的资源而无法继续执行的情况。活锁是指线程们相互响应彼此的动作,但却无法继续向前推进的情况。饥饿是指一个或多个线程长期无法获得所需资源的情况。避免这些问题需要仔细设计锁的获取顺序以及使用超时机制等策略。
  • 内存访问同步:在并发编程中,确保对共享内存的访问是同步的至关重要。锁和信号量是常用的同步机制,它们可以确保在任何给定时间只有一个线程能够访问共享资源。除此之外,还有一些更高级的同步原语,如条件变量、读写锁等,可以根据具体情况选择合适的同步机制来处理内存访问同步问题。

总的来说,并发编程是一项复杂的任务,需要仔细考虑各种可能的问题,并采取适当的措施来确保程序的正确性和性能。

Go的并发

在 Go 语言中,Goroutine 是程序的最基本执行单元,类似于线程,但是它们的创建和管理由 Go 运行时环境(runtime)进行调度,而不是由操作系统。这使得 Goroutine 的创建和销毁成本非常低,从而使得可以轻松地创建成千上万个 Goroutine,而不会产生太大的性能开销。与 Java 中的线程池不同,Go 中的 Goroutine 由运行时环境进行隐式管理和调度。这意味着开发者不需要显式地创建线程池或手动管理 Goroutine 的生命周期。Go 运行时环境会自动处理 Goroutine 的调度和复用,以确保最佳的性能和资源利用率。

原理

Goroutine的使用非常简单,就是函数前面加上go关键词。看下面一段示例代码:

go
func Hello(){ fmt.Println(“hello”) } func main(){ go Hello() fmt.Println("start") }

代码的执行结果是什么呢,有可能为空只打印了end,因为在 main函数运行结束退出的时候,该goroutine尚未执行就被销毁了。

start

如果想要让程序在Hello函数执行之后,main函数再退出,我们可以使用sync.waitGroup或者 channel 来实现简单的并发控制,这里不再举例。Go 推荐 CSP (Communicating Sequential Processes) 并发模型来实现。 Golang中的CSP模型的实现基础就是 goroutine 和 channel。其中,channel 是go的并发单位之间的通信机制,类似于管道。

Go语言的并发哲学:不要以共享内存的方式来通信;相反,要通过通信的方式来共享内存。

原理

Go 语言的调度器(Go Scheduler)负责管理和调度所有的 Goroutine。它是一个高效的调度器,能够有效地利用系统资源,并在多个 Goroutine 之间进行合理的调度,从而实现并发执行。

GMP

Go的并发实现原理是采用一种类似 M : N 协程映射关系的模型-GMP 模型

上图中,G、M、P节点的含义如下

  • G (goroutine):表示协程。参与调度与执行的最小单位,执行异步操作时会进入休眠状态,待操作完成后再恢复,无需占用系统线程。
  • M (machine):表示操作系统的线程。它由操作系统的调度器调度和管理。当没有足够的 M 来关联 P 并运行其中的可运行的 G 时,会创建新的 M。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲。Go 默认限定M的最多10000,可以通过runtime/debug包中的SetMaxThreads函数来设置最大数量
  • P(processor):表示逻辑处理器。P关联了的本地可运行G的队列(也称为LRQ),最多可存放256个G。可以通过环境变量GOMAXPROC修改。 在确定了 P 的最大数量后,运行时系统会根据这个数量创建 P。由启动时环境变量 GOMAXPROCS或者是由runtime的方法GOMAXPROCS()决定。这意味着在程序执行的任意时刻都只有GOMAXPROCS 或者是由 runtime 的方法 GOMAXPROCS() 决定。这意味着在程序执行的任意时刻都只有 GOMAXPROCS 个 goroutine 在同时运行

调度流程 任何用户线程最终都要交由 OS 线程来执行的,goroutine(下面简称 G)也不例外,但是 G 并不直接绑定 OS 线程运行,而是由 Goroutine Scheduler 中的 P(逻辑处理器)来作为两者的中介,P 可以看作是一个抽象的资源或者一个上下文,一个 P 绑定一个 OS 线程。

G 实际上是由 M 通过 P 来进行调度运行的,但是在 G 的层面来看,P 提供了 G 运行所需的一切资源和环境,因此在 G 看来 P 就是运行它的 “CPU”。

P 持有一个由可运行的 Goroutine 组成的环形的运行队列 runq,还反向持有一个线程。调度器在调度时会从 P 的队列中选择队列头的 Goroutine 放到线程 M 上执行。

由 G、P、M 这三种由 Go 抽象出来的实现,最终形成了 Go 调度器的基本结构。

可运行队列

我们将go的调度看作一个生产消费模型,那生产的goroutine放在哪里呢?来详细看一下可运行队列的结构:

这实际上是一个三级可运行队列,runnext 实际上只能指向一个 goroutine,是一个特殊的队列。 那新的 goroutine 将被放到哪个可运行队列呢?

  1. 如果 runnext 为空,那么 goroutine 就会顺利地放入 runnext,之后以最高优先级得到消费。
  2. 如果 runnext 不为空,则要替换掉 runnext 上的 old goroutine。老的g被替换到哪里呢?
    1. local queue 是一个大小为 256 的数组,实际上用 head 和 tail 指针把它当成一个环形数组在使用。如果 local queue 不满,则将 runnext 放入 local queue;
    2. 否则,P 的本地队列上的 goroutine 太多了,说明当前 P 的任务太重了,需要减负,因此需要得到其他 P 协助。从而,将 runnext 以及当前 P 的一半 goroutine 一起加入到 global queue 里去。

调度循环

go
init() //... for i := 0; i < max_sched; i++ { create_os_thread(sched); // 创建系统线程来运行调度器 } func sched() { for { g := find_g(); // 根据算法寻找goroutine run(g); // 运行一个goroutine save(g); // 保存现场 } }

在寻找goroutine时,根据可执行队列来找;runnext -> local -> global 。 如果都没有找到,那就从别的P来偷取。

Goroutine 状态

GMP的内部结构中重要字段如下,详细结构参见源码

go
type g struct { stack stack // g自己的栈 m *m // 隶属于哪个M sched gobuf // 保存了g的现场,goroutine切换时通过它来恢复 atomicstatus uint32 // G的运行状态 goid int64 schedlink guintptr // 下一个g, g链表 preempt bool //抢占标记 lockedm muintptr // 锁定的M,g中断恢复指定M执行 gopc uintptr // 创建该goroutine的指令地址 startpc uintptr // goroutine 函数的指令地址 } type m struct { g0 *g // g0, 每个M都有自己独有的g0 curg *g // 当前正在运行的g p puintptr // 隶属于哪个P nextp puintptr // 当m被唤醒时,首先拥有这个p id int64 spinning bool // 是否处于自旋 park note alllink *m // on allm schedlink muintptr // 下一个m, m链表 mcache *mcache // 内存分配 lockedg guintptr // 和 G 的lockedm对应 freelink *m // on sched.freem } type p struct { id int32 status uint32 // P的状态 link puintptr // 下一个P, P链表 m muintptr // 拥有这个P的M mcache *mcache // P本地runnable状态的G队列,无锁访问 runqhead uint32 runqtail uint32 runq [256]guintptr runnext guintptr // 一个比runq优先级更高的runnable G // 状态为dead的G链表,在获取G时会从这里面获取 gFree struct { gList n int32 } gcBgMarkWorker guintptr // (atomic) gcw gcWork }

可以发现,Goroutine 有以下 9 种状态:

状态描述
_Gidle刚刚被分配并且还没有被初始化
_Grunnable没有执行代码,没有栈的所有权,存储在运行队列中
_Grunning可以执行代码,拥有栈的所有权,被赋予了内核线程 M 和处理器 P
_Gsyscall正在执行系统调用,拥有栈的所有权,没有执行用户代码,被赋予了内核线程 M 但是不在运行队列上
_Gwaiting由于运行时而被阻塞,没有执行用户代码并且不在运行队列上,但是可能存在于 Channel 的等待队列上
_Gdead没有被使用,没有执行代码,可能有分配的栈
_Gcopystack栈正在被拷贝,没有执行代码,不在运行队列上
_Gpreempted由于抢占而被阻塞,没有执行用户代码并且不在运行队列上,等待唤醒
_GscanGC 正在扫描栈空间,没有执行代码,可以与其他状态同时存在

这些不同的状态被聚合成三种:等待中、可运行、运行中,运行期间会在这三种状态来回切换:

  • 等待中:Goroutine 正在等待某些条件满足,例如:系统调用结束等,包括 _Gwaiting、_Gsyscall 和 _Gpreempted 几个状态;
  • 可运行:Goroutine 已经准备就绪,可以在线程运行,如果当前程序中有非常多的 Goroutine,每个 Goroutine 就可能会等待更多的时间,即 _Grunnable;
  • 运行中:Goroutine 正在某个线程上运行,即 _Grunning;

调度算法和两级线程模型

  • 复用。两种机制来实现对 goroutine 的复用,减少频繁创建、销毁带来的开销
    • Work stealing 会从其他 P 的 G 队列中偷取 G
    • hand off 则在当系统调用阻塞之时,释放绑定的P,交给其他空闲的线程执行
  • 并行:多个 P 来并行地运行 goroutine
  • 抢占:早期版本的Go不支持抢占式调度,产生了一些问题,例如:
    • 某些 Goroutine 可以长时间占用线程,造成其它 Goroutine 的饥饿;
    • 垃圾回收需要暂停整个程序(Stop-the-world,STW),最长可能需要几分钟的时间,导致整个程序无法工作;在 1.14 版本之后的Go实际上采用了“基于信号的抢占式调度”算法。基于信号的抢占式调度主要解决了垃圾回收和栈扫描时存在的问题。

实际上,虽然 Go 早期对并发的实现是协作式的,但是在更新的版本之中,Goroutine 与其说是协程,不如说是用户态线程(两级线程模型)更为贴近了。这两者之间有一些相似,例如都对内核不可见;但也存在差别,例如 Goroutine采用抢占式的调度,而协程一般是非抢占的。在语义上,我们通常将 Goroutine 更加视作线程来使用,不关注它们之间如何调度。 首先,区别于用户级线程模型,两级线程模型中的一个进程可以与多个内核线程 (KSE) 关联,也就是说

  • 一个进程内的多个线程可以分别绑定一个自己的 KSE,这点和内核级线程模型相似;
  • 其次,又区别于内核级线程模型,它的进程里的线程并不与 KSE 唯一绑定,而是可以多个用户线程映射到同一个 KSE,当某个 KSE 因为其绑定的线程的阻塞操作被内核调度出 CPU 时,其关联的进程中其余用户线程可以重新与其他 KSE 绑定运行。

所以,两级线程模型既不是用户级线程模型那种完全靠自己调度的也不是内核级线程模型完全靠操作系统调度的,而是中间态(自身调度与系统调度协同工作)。Go的 runtime 调度算法就是采取上述两级线程模型。

性能对比

在这里用用网上看到的的一个实验来对比并发性能。实现方式采用java内置实现的线程池与go自带的goroutine调度做直观的对比。 将Go与Java的结果制成对比图表:

语言\任务数量1,000,00010,000,000100,000,000
Go耗时316 ms3.03 s31.93 s
Java耗时436 ms3.33 s95.51 s

可以看到,当任务数量较大时,两者之间性能存在巨大的差别。

常见问题

GMP中P的数量

创建线程和销毁线程都是比较耗时的操作,频繁的创建和销毁线程会浪费很多CPU的资源。通常使用线程池可以缓解这个问题。

线程池

当使用线程池时,我们可以设置线程池的最大线程数。在高并发的情况下,如何选择最优的线程数量呢?

我们希望至少可以创建处理器核心数那么多个线程。这就保证了有尽可能多地处理器核心可以投入到工作中去。所以,应用程序的最小线程数应该等于可用的处理器核数。

如果所有的任务都是计算密集型的,则创建处理器可用核心数那么多个线程就可以了。在这种情况下,创建更多的线程对程序性能而言反而是不利的。因为当有多个任务处于就绪状态时,处理器核心需要在线程间频繁进行上下文切换,而这种切换对程序性能损耗较大。

但如果任务都是IO密集型的,那么我们就需要开更多的线程来提高性能。 当一个任务执行IO操作时,其线程将被阻塞,于是处理器可以立即进行上下文切换以便处理其他就绪线程。如果我们只有处理器可用核心数那么多个线程的话,则即使有待执行的任务也无法处理,因为我们已经拿不出更多的线程供处理器调度了。

总结: 对于计算密集型任务,由于任务执行时间较长,线程可能不会频繁地被阻塞,因此线程数可以与处理器核心数相匹配或稍少一些,以避免不必要的上下文切换。而对于 IO 密集型任务,由于任务执行时间大部分都花费在等待 IO 操作上,因此需要更多的线程来充分利用处理器核心,以确保在一个线程被阻塞时,其他线程仍然可以执行 我们提供了一个理想公式。

线程数 = CPU可用核心数/(1 - 阻塞系数),其中阻塞系数的取值在0和1之间。计算密集型任务的阻塞系数为0,而IO密集型任务的阻塞系数则接近1。

所以,在通常情况下,根据程序是IO密集型还是计算密集型,线程数有不同的最佳值(其中N代表CPU核数):

  • IO 密集型配置线程数经验值是:2N。
  • CPU 密集型配置线程数经验值是:N + 1。

Go

那同样地,在go中,线程的数量不受 CPU 核心数限制,能在系统调用中被阻塞的线程可以被大量创建。(我们可以理解为 GMP 模型中, M 的数量)

Go 官方: There is no limit to the number of threads that can be blocked in system calls on behalf of Go code; those do not count against the GOMAXPROCS limit

在 go 原生 runtime 中,我们可以找到一个调节 GMP 模型中 machine 这个内核线程限制的方法即 runtime.debug.SetMaxThreads

go
// SetMaxThreads sets the maximum number of operating system // threads that the Go program can use. If it attempts to use more than // this many, the program crashes. // SetMaxThreads returns the previous setting. // The initial setting is 10,000 threads. // // The limit controls the number of operating system threads, not the number // of goroutines. A Go program creates a new thread only when a goroutine // is ready to run but all the existing threads are blocked in system calls, cgo calls, // or are locked to other goroutines due to use of runtime.LockOSThread. // // SetMaxThreads is useful mainly for limiting the damage done by // programs that create an unbounded number of threads. The idea is // to take down the program before it takes down the operating system. func SetMaxThreads(threads int) int { return setMaxThreads(threads) }

SetMaxThreads 的主要作用是防止线程过多导致的操作系统崩溃,当限制被打破时,程序会直接退出,从而避免OS崩溃。接下来我们来测试下。我们使用net.LookupHost来创建一个使用线程超过50的测试程序。

go
var threadProfile = pprof.Lookup("threadcreate") func main() { fmt.Printf("初始线程数=%d\n", threadProfile.Count()) var wg sync.WaitGroup taskCnt := 50 wg.Add(taskCnt) for i := 0; i < taskCnt; i++ { go func() { defer wg.Done() for j := 0; j < taskCnt; j++ { _, _ = net.LookupHost("www.baidu.com") } }() } wg.Wait() fmt.Printf("结束时线程数=%d\n", threadProfile.Count()) }

go中默认限制每个程序最多创建10000个线程,50个线程肯定没有任何问题,假如在测试代码中加入限制:

go
func init() { debug.SetMaxThreads(5) }

再次运行,情况如下

所以,官方的说明确实得到了验证。我们不能像 java 线程池那样,调整 runtime 的最大线程数来获得最大并发性能,这个方法只能用来保护系统的稳定性。不过,我们可以通过控制 GMP 中 P 的数量。 在开发程序时,我们都希望多核CPU的算力都被利用到;在默认情况下,Go的最大 P 数量受限制于 GOMAXPROCS(支持修改),而它的默认值数为CPU的逻辑核数。因此,Go程序可以充分使用机器的每一个核心,最大程度地提高程序的并发性能。

GMP模型中,每个 M 都要绑定一个 P 才能够运行。当然,通过控制 P,我们就可以控制线程调度的最大数量。理论上,GOMAXPROCS 高于真正可使用的CPU核心数后会导致Go调度器不停地进行OS线程切换。 在任何情况下,Go运行时并行执行(注意不是并发)的 goroutines 数量是小于等于 P 的数量的。

调度器的设计是为了在充分利用系统资源的同时,尽可能地减少因系统调用而导致的性能损失。调度器将 goroutines 分配给一组逻辑处理器(P),这些 P 可以映射到系统的物理处理器上。为了最大限度地利用系统资源,通常会将 P 的数量设置为与物理处理器核心数相匹配。这样可以确保在多核系统上 goroutine 能够并行执行,从而提高系统的性能。

然而,在涉及系统调用的情况下,调度器可能会面临一些挑战。当一个 goroutine 执行系统调用时,它可能会阻塞当前的逻辑处理器(P),这意味着该 P 上的其他 goroutines 将无法执行,从而导致性能下降。sysmon 是 Go 运行时的监控组件,负责监视系统状态并做出相应的调度决策。然而,由于 sysmon 的调度周期通常较长(最快约为 20 微秒,最慢可达 10-20 毫秒,操作系统在 1ms 内可以完成几十次线程调度),这可能导致在发现 goroutine 阻塞并重新分配 P 前会有一定的延迟。这种情况下可能会出现的问题是,即使一个 goroutine 阻塞了当前的 P,但调度器可能会在较长的时间内没有意识到这一点,导致该 P 上的其他 goroutines 无法执行,从而浪费了系统资源。为了解决这个问题,需要调整调度器的策略,以缩短监控周期或实现更及时的系统调用检测。这样可以更快地发现并处理因系统调用而导致的 P 阻塞,从而最大程度地减少系统资源的浪费,提高系统的性能。

补充说明:调度器迟钝不是 M 迟钝,M 也就是操作系统线程,是非常的敏感的,只要阻塞就会被操作系统调度(除了极少数自旋的情况)。但是 GO 的调度器会等待一个时间间隔才会行动,这也是为了减少调度器干预的次数。也就是说,如果一个 M 调用了什么 API 导致了操作系统线程阻塞了,操作系统立刻会把这个线程 M 调度走,挂起等阻塞解除。这时候,Go 调度器不会马上把这个 M 持有的 P 抢走。这就会导致一定的 P 被浪费了

我们应用场景中,Go 项目很多的并发其实是网络 IO;这种场景,由于 Go 封装的 net/http 库底层是基于 epoll 机制的,在建立连接以后并不会阻塞线程,所以增加GOMAXPROCS是没有效果的。 但是对于其他会阻塞线程的 I/O 密集型或者系统调用比较多的场景,通过把GOMAXPROCS的值适当地增大到 CPU核心数以上,实际上可以提高系统的吞吐性能。而这个值应该是多少呢? 这里我认为可以沿用上面线程池的结论,或者利用公式来进行计算。当然,最终落实到业务上来,还要结合压测等方式,来确定一个最佳的值。

GMP中G的数量

前面提到,goroutine有非常轻量的特性。在 Go 1.4 版本之后的中,最小栈从8kb降低到了2kb,并延续到现在。通过查看go最新版本的源码 https://golang.org/src/runtime/stack.go 可以验证这一点:

go
// The minimum size of stack used by Go code _StackMin = 2048

所以,每个goroutine也至少占用2kb的内存。虽然每个goroutine很小,但是如果数量太大,也会造成非常危险的后果。我们可以用下面这段代码来做一个暴力尝试:

func main() { for i := 0; i < math.MaxInt64; i++ { go func(i int) { println("go func: ", i) time.Sleep(time.Second) }(i) } }

实测在本机开始运行一小会后,IDE发生卡死,CPU占用增加,系统卡顿,程序占用的内存超过了物理内存的上限,操作系统开始进行内存交换。同时标准输出卡住。

所以在实际开发中,要对goroutine调用进行合理的控制。通过控制goroutine 数量,可以做到

  • 减少 CPU 使用率
  • 减少内存占用
  • 防止主进程崩溃

如何控制goroutine数量呢,可以使用 channel + sync 的方式来完成,可以看下面这个栗子:

go
package main import ( "runtime" "time" ) func run(index int, ch chan bool) { println(time.Now().Local().String(), "index is", index, ", goroutine cnt is", runtime.NumGoroutine()) time.Sleep(time.Second * 3) <-ch // 释放 } func main() { maxTask := 100 ch := make(chan bool, 3) // 控制goroutine数量 for i := 0; i < maxTask; i++ { ch <- true go goRunTask(i, ch) } }

运行结果:

通过这个方式就实现了goroutine数量的控制。 而当任务情况更加复杂时,这样简单的实现在性能上会无法满足要求,也非常不够灵活:

  • goroutine 不能进行复用,会存在频繁创建、销毁的问题。
  • 最大goroutine数量不能动态调整,不适应复杂的业务场景。
  • 当goroutine数量达到最大值,对新加入的任务只能阻塞等待;

要解决这些问题,正是使用 goroutine pool 的绝赞理由。通过控制 pool 的 size ,也就是程序最多同时运行多少个 worker,我们可以巧妙地限制 goroutine 的数量,篇幅有限,我们后续会对goroutine pool 进行介绍。

goroutine 泄漏与避免

内存泄漏是指程序在运行过程中分配的内存空间,在不再需要时没有被正确释放,导致这部分内存无法被重新利用,最终可能耗尽系统的可用内存资源。内存泄漏通常发生在动态内存分配和释放的过程中,如果程序没有正确释放不再使用的内存,就会导致内存泄漏问题。内存泄漏可能由多种原因引起,例如:

  • 未释放动态分配的内存:当程序动态分配内存(比如使用malloc或new操作符),但在不再需要该内存时未调用对应的释放函数(如free或delete),就会导致内存泄漏。
  • 循环引用:在某些编程语言中,如果对象之间存在循环引用,并且这些对象之间相互持有对方的引用,那么即使程序不再使用这些对象,它们也无法被垃圾回收机制释放,从而导致内存泄漏。
  • 文件描述符泄漏:在涉及文件操作的程序中,如果打开文件后没有正确关闭文件描述符,就会导致文件描述符泄漏,占用系统资源而不释放。

在Go开发中,goroutine 泄漏是常见的一种类型,它通常是指 goroutine 从创建后一直运行到进程结束而没有符合预期地退出,一直占有资源。当大量的 goroutine 一直保持运行,将造成未能释放已经不再使用的内存,程序可用内存减少,程序最终发生崩溃。 在Go中,goroutine的结束不能由外部来决定,只能自行结束。

要知道什么时候会发生协程泄漏,我们首先得知道协程怎样终止:

  • 正常执行完goroutine内的业务逻辑代码块
  • 发生了没有处理的panic
  • 被其他协程终止

基于上面几种泄漏的触发时机,总结原因可能有:

  • channel 发生了阻塞
  • goroutine 中逻辑死循环
  • goroutine 中逻辑进入长时间阻塞等待

因此,在进行开发工作时,要注意不要写出无法自行结束的 Goroutine;当发生内存泄漏的时候,要检查代码中goroutine的生命周期。

一个真实案例

在翻看我们的业务代码时,我们发现了这样一个案例。 使用 sync.WaitGroup 来控制并发,使用 channel 来处理并发任务中产生的错误。把这段逻辑抽象以后的代码:

package main import ( "fmt" "time" ) func main() { ch := make(chan int) // 启动一个 goroutine go func() { for { select { case val := <-ch: fmt.Println("Received:", val) // 这里没有处理退出信号 } } }() // 模拟发送一条消息 ch <- 1 // 程序在这里睡眠,模拟某种延迟或阻塞 time.Sleep(2 * time.Second) // 程序退出,goroutine 没有正常关闭 }
  • 启动 Goroutine:在 main 函数中启动了一个 goroutine,该 goroutine 在一个无限循环中等待从通道 ch 中接收数据。
  • 无限循环:goroutine 中的 for 循环没有任何退出条件,因此会一直运行,直到程序终止。
  • 未处理退出信号:goroutine 中的 select 语句没有处理任何形式的退出信号。例如,没有一个 done 通道来通知 goroutine 退出。
  • 程序退出:当 main 函数结束时,程序退出,但 goroutine 并没有正常退出。尽管 Go 运行时会在程序退出时强制终止所有 goroutine,但在长时间运行的服务中,这种泄漏会导致资源耗尽。

改进

go
package main import ( "fmt" "time" ) func main() { ch := make(chan int) done := make(chan struct{}) // 启动一个 goroutine go func() { for { select { case val := <-ch: fmt.Println("Received:", val) case <-done: fmt.Println("Goroutine exiting...") return } } }() // 模拟发送一条消息 ch <- 1 // 程序在这里睡眠,模拟某种延迟或阻塞 time.Sleep(2 * time.Second) // 发送退出信号 close(done) // 等待一段时间以确保 goroutine 退出 time.Sleep(1 * time.Second) }

分析

  • 退出信号通道:添加了一个 done 通道,用于通知 goroutine 退出。
  • 处理退出信号:在 select 语句中添加了对 done 通道的处理,当接收到退出信号时,goroutine 会打印一条消息并正常退出。
  • 关闭退出信号通道:在 main 函数中,在适当的时机关闭 done 通道,通知 goroutine 退出。 通过这些改进,可以确保 goroutine 在程序退出前能够正常退出,从而避免 goroutine 泄漏问题。

结语

通过合理利用多核CPU的计算能力和 Go 语言的并发特性,可以更好地解决复杂业务环境中的高并发问题,并提升程序的性能和稳定性。

本文作者:sora

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!