四:破解面试秘籍:Go 语言任务调度经典问题速解,站在求职者视角,深度剖析!

1、go的调度

四:破解面试秘籍:Go 语言任务调度经典问题速解,站在求职者视角,深度剖析!

1.1 专业术语化讲解

首先,我们先了解一下Go的调度模型中的三个重要术语:

  1. G:代表Goroutine,是Go的协程/轻量级线程,Go通过他们实现并发操作。
  2. M:代表Machine,是系统线程,是运行Goroutine的实体。
  3. P:代表Processor,是M和G之间的调度器。

Go的调度器用于管理Goroutines,它采取的是M:N调度,也就是说,它会在用户级别的M个线程上调度N个Goroutine。这样,Go调度器可以在少量的OS线程上执行大量的Goroutines,显著降低了系统线程上下文切换的开销。

当调度器需要调度一个Goroutine时,它会将Goroutine放入P的本地队列,并在之后按照FIFO(先入先出)的顺序进行调度。

为了缩短上下文切换的时间,Go提供了工作窃取策略。这一策略是说,当一个线程的本地队列没有Goroutine可以跑时,它会尝试从其他线程的本地队列偷取一部分Goroutine到自己的队列中。这样做的目的是为了使得CPU的利用率最大化,避免线程在没有任务执行的时候空闲。

在Go的调度模型中,还有一个重要的特性就是本地化。通过保存Goroutine的状态到P,可以避免过多的全局锁竞争,实现了高效的线程调度。

总的来说,Go的调度算法通过G,M,P三个实体,加上工作窃取策略和本地化设计,实现了对Goroutine高效的管理和调度。

1.2 举个例子,空中交通管制系统

四:破解面试秘籍:Go 语言任务调度经典问题速解,站在求职者视角,深度剖析!

如果要以形象的方式来描述,我会把它比作空中交通管制系统。每当我们看到带有平稳飞行的飞机的壮观景象时,我们通常会感叹飞行员的技术。但事实上,那些帮助飞机起飞、飞行、降落的空中交通管制员才是真正的英雄。他们控制着飞机的每一个移动,让所有飞机都可以安全、有效地运行。同样,我们将每一个飞机看作一个Goroutine,而Go的调度器就像一个空中交通管制员,不停地调度Goroutine在Go程之间进行上下文切换。

驶入复杂的城市时,你需要一个智能的GPS来引导你躲避堵车,找到最快的路径。这就像Go的工作窃取算法,当一个处理器上的Goroutine排队等待执行时,闲置的处理器会去”偷走”一些工作。

四:破解面试秘籍:Go 语言任务调度经典问题速解,站在求职者视角,深度剖析!

那Go的调度和发生在航空母舰上的飞机起降活动有什么共同之处呢?就像飞行员须根据管制员的指示行动,Go的调度器也需要利用G、M、P三种实体互相协作以保持Goroutine的运行。这种像航空母舰上的协调一样的操作,实现了Go的高效调度。

不过,请别误会,GMP其实并不是肌肉的缩写哈。在Go中,G表示Goroutine, M表示Machine,P表示Processor。而Goroutine就像是那些辛勤工作的员工,无论现在是周一早上,还是周五晚上,它们都在默默地工作。Machine则扮演着资源的角色,比如我们的工作场所或者工具。Processor则是一个桥梁,将Goroutine和Machine连接起来,并确保所有东西正常运行。

总的来看,面试官,Go的调度机制就像是一部精密运行的钟表,无数的部件——每个Goroutine,都有它特定的位置和特定的任务,在调度器精准的控制下,他们共同保障了程序的高效运行。

1.3 再举个例子 继续深入Go的调度机制

一分钟,为了更生动地解释这个话题,我会用一个体育比赛的例子。

四:破解面试秘籍:Go 语言任务调度经典问题速解,站在求职者视角,深度剖析!

你可以想象一下奥运会中的接力比赛。每一个运动员(这里就代表Goroutine)将棒(这里表示处理任务的能力)传递给下一个运动员。这就类似于所谓的“上下文切换”。但是,如果运动员之间切换需要花费的时间过长,会影响整个比赛的效率。那么怎么在快速切换的同时,协调所有的运动员使得投入的较大处理能力不浪费呢?

Go的答案在于它设法尽量让所有的运动员(Goroutine)都保持繁忙。在Go中,系统线程(就像天生的运动员)并不直接执行Goroutine,取而代之的是,它们会先将Goroutine放入到本地队列,然后从本地队列选取任务执行。这意味着运动员(系统线程)在比赛(调度循环)之前储备了足够多的棒,这样就可以减少切换的时间。

更为高明的是,Go的调度器这位教练还有一个厉害的招数——工作窃取。如果某个运动员手上棒子用完了,它会去别人手上拿棒子,这样保证了闲置的线程可以找到工作做。足够智能,不是吗?

所有这些机制共同实现了高度友好的并行处理。我想这就是为什么Go在云计算和微服务框架中如此受欢迎的原因。

2. 怎么防止G全局队列饥饿

2.1 专业术语

Golang运行时遵循GMP模型,其中,G代表goroutine(逻辑执行单元),M代表machine(工作线程),P代表processor(Goroutine调度上下文)。这个模型有助于理解Go是如何处理并发和并行问题以及如何防止全局队列饥饿的。

Golang调度器采取了工作窃取(work-stealing)和手动调度(hand off)策略来防止G饥饿问题:

  1. 复用的M工作线程:当一个G(goroutine)在执行I/O操作,等待某个条件或者是被阻塞,那么它将会释放其持有的M和P,将M让给运行队列中的其他G使用,这样保证了M的利用效率。
  1. 手动调度: 当某个G在M上运行的时间太长,而其他G等待执行,调度器将会抢占这个G的执行,将执行权让给其他G,这样可以保证所有的G都能均匀在P上执行,防止长期运行的G阻塞掉整个队列导致饥饿。
  1. 工作窃取:Golang调度器还采用了一种叫做工作窃取(work-stealing)的策略。这是防止G饥饿的另一个重要的策略。当goroutine队列中的G都执行完成,而全局队列中还有等待执行的G时,M就会从全局队列中(或者其他P中)“窃取”一半的G到自己的队列中,这样保证了全局队列中的G得到执行,防止饥饿。

这些就是Golang如何通过其运行时调度器来防止全局队列饥饿问题的基本策略。在实际的运行中,Golang 的调度器还会依据实际情况调整策略,以最大化利用计算资源,提高效率。

2.2 举例子

让我们以接力棒的例子来说明golang中的GMP模型以及它如何避免全局队列饥饿问题。

假设GOROUTINE(G)就是接力队伍中的运动员,他们的任务就是跑步;M(Machine)是接力比赛的跑道,运动员需要跑道才能跑步;P(Processor)则如同接力棒,只有拿到接力棒的运动员才能跑步。

  1. 复用的M工作线程– 当一个运动员(G)需要休息(例如模拟I/O操作阻塞),他会把接力棒(P)和跑道(M)交给其他准备好跑步的运动员。这样可以确保跑道(M)始终在使用,而不是空闲下来。
    四:破解面试秘籍:Go 语言任务调度经典问题速解,站在求职者视角,深度剖析!

  2. 手动调度– 如果一个运动员(G)跑得太久,其他运动员没有机会跑步,那么裁判(调度器)会接过他的接力棒(P)并且给其他等待的运动员,以此保证所有的运动员都有机会去跑步,不会因为某一个一直跑步而导致其他运动员“饥饿”。
    四:破解面试秘籍:Go 语言任务调度经典问题速解,站在求职者视角,深度剖析!

  3. 工作窃取– 这就像是当一个队伍的运动员都跑完了,他们看到其他队伍还有些运动员在等待跑步,他们就会邀请这些还没有跑步的运动员加入自己的队伍(窃取工作),这样就保证了所有人都有机会参与接力比赛,避免了“饥饿”现象。

四:破解面试秘籍:Go 语言任务调度经典问题速解,站在求职者视角,深度剖析!

这些机制确保了全局队列中的所有任务都能得到公平的运行机会,防止了任务饿死的情况。这是Golang通过GMP模型和调度策略如何模拟接力赛过程来防止全局goroutine饥饿的例子。

3. P和M的个数

P: 由启动时环境变量 $GOMAXPROCS 或者是由 runtime的方法GOMAXPROCS()决定。这意味着在程序执行的任意时刻都只有$GOMAXPROCS个goroutine在同时运行。
M:
Go 语言本身的限制:Go 程序启动时,会设置 M 的最大数量,默认 10000,但是内核很难支持这么多的线程数,所以这个限制可以忽略。
runtime/debug 中的 SetMaxThreads 函数,设置 M 的最大数量。
一个 M 阻塞了,会创建新的 M。
M 与 P 的数量没有绝对关系,一个 M 阻塞,P 就会去创建或者切换另一个 M,所以,即使 P 的默认数量是 1,也有可能会创建很多个 M 出来。

4. P和M何时会被创建

P: 在确定了 P 的最大数量 n 后,运行时系统会根据这个数量创建 n 个 P。

M: 没有足够的 M 来关联 P 并运行其中的可运行的 G 时创建。比如所有的 M 此时都阻塞住了,而 P 中还有很多就绪任务,就会去寻找空闲的 M,而没有空闲的,就会去创建新的 M。

5. goroutine创建流程

Go 语言中,goroutine 的创建非常简单,只需要使用 go 关键字即可。go 关键字后面跟随一个函数调用(或者匿名函数),该函数就会在新的 goroutine 中并发执行。

创建一个 Goroutine 会涉及以下一些步骤:

  1. Goroutine的申请 首先,当你在代码中使用 go 关键字启动一个 Goroutine,Go 语言运行时就会去申请一个 G(也就是 Goroutine)。这个 G 实体包含必要的信息:栈信息、栈大小、运行的状态等等。

  2. Goroutine的调度 Goroutine 转为待运行状态,其实就是放入到本地运行队列中。本地运行队列对应一个 P(Processor),P 可以看作是 Golang 运行时的上下文环境。每个 P 可以关联一个 M(Machine,系统线程)进行运行。

  3. Goroutine的运行 一旦调度器发现有空闲的 P 和需要运行的 G,就会安排他们一起进行任务的执行。

值得注意的是,创建 Goroutine 的操作非常轻量级,因为在 Go 语言运行时中,每个 Goroutine 的初始栈大小相比操作系统线程要小很多,一般只有 2KB~8KB,所以在实际编程中,我们可以大量并且自由地创建 Goroutine,让 Go 语言运行时进行调度和管理。
但是,如果你的程序中存在频繁创建和销毁 goroutine 的情况,应该考虑使用 sync.Pool 等技术来优化内存分配和回收效率。

6. 当函数执行结束时,goroutine 自动终止,其状态和堆栈等资源也会被回收,这句话如何理解

简单来说就是:当你通过 go 关键字开启的函数运行完,那个由此函数驱动的 Goroutine 任务就结束了,和它相关联的内存和状态信息会被 Go 语言的运行环境自动清理和回收。

理解这句话的关键是理解 Go 语言中的 Goroutine 执行模型。

一个 Goroutine 对应一个函数(或者方法)的执行。就像任何函数执行一样,当函数的指令全部执行完毕,达到结束点(如遇到 return 指令或者到达函数体末尾)时,函数执行就被认为是结束。

针对 Goroutine,当 Goroutine 对应的函数执行结束后,Go 语言运行时系统自动将其标记为结束。运行时系统会在适当的时间,如垃圾回收周期,自动回收(recycle)它,同时也会回收与其关联的资源。

关于“状态和堆栈等资源也会被回收”,Goroutine 的状态包括它当前处于运行(running)、就绪(runnable)或阻塞(blocked)等状态,在 Goroutine 结束后,这些状态信息都没必要保留并会被回收。同时,Goroutine 在运行过程中会使用到自己私有的一段栈内存(用于保存局部变量,函数调用链等信息),在 Goroutine 结束后,这块栈内存也会被回收,以便再次利用。

需要注意的是,Goroutine 结束并不影响其他正在运行的 Goroutine,它们会继续运行直到各自的函数执行完。这就是 Goroutine 的并发性。执行完的 Goroutine 不会对其他 Goroutine 造成任何影响,除非在有明确的资源共享(如通过通道(Channel)等机制共享数据)的上下文环境中。

7. goroutine什么时候会被挂起

goroutine 会在以下情况下被挂起:

发生阻塞,例如等待 I/O 操作的完成或者发送或接收通道上的数据时没有可用的对等方。
发生调用 runtime.Gosched(),让出 CPU 给其他 goroutine 执行。
发生同步操作,例如 sync.Mutex 或 sync.WaitGroup 的锁定和解锁操作。
发生垃圾回收(GC)。
发生错误,例如 panic 或者超时。

8. 同时启动了一万个goroutine,会如何调度

一万个G会按照P的设定个数,尽量平均地分配到每个P的本地队列中。如果所有本地队列都满了,那么剩余的G则会分配到GMP的全局队列上。接下来便开始执行GMP模型的调度策略:

  • 本地队列轮转:每个P维护着一个包含G的队列,不考虑G进入系统调用或IO操作的情况下,P周期性的将G调度到M中执行,执行一小段时间,将上下文保存下来,然后将G放到队列尾部,然后从队首中重新取出一个G进行调度。
  • 系统调用:P的个数默认等于CPU核数,每个M必须持有一个P才可以执行G,一般情况下M的个数会略大于P的个数,这多出来的M将会在G产生系统调用时发挥作用。当该G即将进入系统调用时,对应的M由于陷入系统调用而进被阻塞,将释放P,进而某个空闲的M1获取P,继续执行P队列中剩下的G。
  • 工作量窃取:多个P中维护的G队列有可能是不均衡的,当某个P已经将G全部执行完,然后去查询全局队列,全局队列中也没有新的G,而另一个M中队列中还有3很多G待运行。此时,空闲的P会将其他P中的G偷取一部分过来,一般每次偷取一半。

9. goroutine内存泄漏原因和处理

原因:

Goroutine 是轻量级线程,需要维护执行用户代码的上下文信息。在运行过程中也需要消耗一定的内存来保存这类信息,而这些内存在目前版本的 Go 中是不会被释放的。
因此,如果一个程序持续不断地产生新的 goroutine、且不结束已经创建的 goroutine 并复用这部分内存,就会造成内存泄漏的现象。

造成泄露的大多数原因有以下三种:
Goroutine 内正在进行 channel/mutex 等读写操作,但由于逻辑问题,某些情况下会被一直阻塞。
Goroutine 内的业务逻辑进入死循环,资源一直无法释放。
Goroutine 内的业务逻辑进入长时间等待,有不断新增的 Goroutine 进入等待。

解决方法:

是的,这个回答也是对的,提供了一些其它的处理 Goroutine 可能的内存泄露的方法:

  1. 使用 Channel 接收业务完成的通知:确保每一个 Goroutine 在完成它的任务后都能发送一个完成的信号,这样创建 Goroutine 的代码就知道何时它们完成了任务,并可以安全的结束它们。

  2. 业务执行阻塞超过设定的超时时间,就会触发超时退出:对一些可能阻塞的操作设置超时时间,防止 Goroutine 无限期的等待下去。

  3. 使用 pprof 排查:pprof 是 Go 提供的性能分析工具,它可以帮助我们找出程序中 CPU、内存使用高的地方,包括 Goroutine 的堆栈信息等,是一个强大的诊断内存泄露的工具。

  4. 使用 context 进行超时控制:context 是 Go 提供的一种可以包含多个 Goroutine 之间可共享的数据和状态的结构,我们可以使用它提供的 WithTimeout、WithCancel 方法对一些可能运行长时间的 Goroutine 进行超时设置或者强制取消。

  5. 利用 Go 的垃圾回收机制:Go 提供垃圾回收机制,可以自动的回收 Goroutine 运行中产生但没有被引用的内存,减少因疏忽而发生的内存泄露。

10. 速问速答速记

  1. Goroutine 是什么? 这是一个基础问题,用来检查你对 Goroutine 的理解。你应该能够解释 Goroutine 是 Golang 特有的并发编程模型,更轻量级也更易于管理的线程。

    Goroutine 是 Go 语言中并发设计的核心。是一种更轻量级比线程(thread)的执行体,它使得同时运行多项任务(函数或者方法)变得非常简单,类似线程但管理是在 Go 运行时(runtime),而非操作系统。

  2. Goroutine 与线程的区别是什么? 这个问题用来评估你对更深层级的并发编程概念理解如何,并且能否对比不同的并发模型。
    Goroutines 和操作系统线程最大的不同是它们的大小和启动速度。Goroutines 极其轻量级,一个 goroutine 初始只占用4KB内存,稍大的 goroutine 栈可能会自动扩容以容纳更大的数据,而线程的栈通常为2MB。此外,Goroutines的调度和管理都由Go语言的运行时环境负责,使得 Goroutines 更易于开发和维护。

  3. Goroutine 是如何被调度的? 这个问题需要你对 Golang 的调度器有深入的理解。你应该了解 Golang 是如何在操作系统线程之间切换 Goroutines 的,并能解释 M(内核线程), P(处理器), G(goroutine)这三者的关系。

Golang 的任务调度模型是基于 M(OS主线程), P(处理器,上下文环境), G(goroutine)的。每个 P 上都有一个本地 goroutine 队列和一个全局的队列。新创建的 goroutine 首先会被放到本地队列,当本地队列满了或者为空时,会和全局的队列进行协调。P 执行 G 特定时间后,会检查是否有更高优先级的 G 需要运行,如系统调用,网络 IO 等。

  1. 什么是 Golang 的 GOMAXPROCS? 你应该了解这个参数的作用以及如何设置它。

    GOMAXPROCS 是一个调整可运行 goroutine 的 OS 线程数量的环境变量,在 Go 1.5 之后默认为机器上的 CPU 核心数。通过设置这个值,我们可以调整 Go 并发程序的并行度。一般情况下,不需要手动调整,Go 语言运行环境会自动进行管理。

  2. 在 Golang 中会发生什么样的条件下会发生 Goroutine 泄露?怎么防止? 这涉及到你对 Golang 并发编程的实践理解。

    Goroutine 泄漏通常发生在 Goroutines 是无限制的在创建,而没有对应的停止它们的机制,或者 Goroutine 阻塞等待某个永不发生的事件。为了防止 Goroutine 泄露,我们需要确保每一个 Goroutine 都有相应的退出或者结束的条件。对于可能导致阻塞的操作,我们经常使用 context 或者 time.After 等方法,加上超时或者外部可控制的取消信号。

  3. 请解释下 Golang 中的 Channel,并描绘一下它们在 Goroutines 之间的通信中起什么作用? 这个问题旨在了解你是否理解 Golang 的 CSP 并发模型,也就是通过 Channels 在 Goroutines 之间传递数据以达到同步的目的。

Channel 在 Golang 里是的一种数据类型,它是用来处理数据共享,实现多 Goroutine 之间的通信的一种重要途径。我们可以把它看作是 Goroutines 之间通信的管道。一方面,Goroutines 可以通过 Channel 发送和接收数据,实现数据的共享。另一方面,Channel 还可以用于实现锁的作用,保证并发环境下的数据安全。通过合理使用 Channel,我们可以高效的解决并发编程中的同步和通信问题。

11. 扩展:golang 任务调度与并发有什么关系

Go 语言的任务调度(scheduler)和并发(concurrent programming)密切相关。关系表现在如下几个方面:

  1. Go 的并发是通过 Goroutine 实现的。Goroutine 是 Go 语言的并发体,每个 Goroutine 对应一个任务,可以理解为一个轻量级的线程。Go 的任务调度就是调度 Goroutine 的执行。

  2. Go 的任务调度器负责协调多个 Goroutine 的运行。Go 语言的运行时环境内建了一个调度器,这个调度器会在逻辑处理器上相互调度多个 Goroutine 的执行,以实现 Goroutine 的并发和并行。

  3. Go 的任务调度实现了并发与并行。Go的并发指的是独立执行的多个任务能同时存在,而并行则是在多个处理器上同时执行多个任务。Go语言的运行时环境内建的调度器也支持这两种模式,通过 GOMAXPROCS 变量控制并行执行的 Goroutine 数量。

  4. Go 的任务调度利用了并发的优势,简化了并发编程。例如,Goroutine 的创建和任务调度都由 Go 语言的运行时环境自动管理,程序员只需使用 go 关键字就可以创建一个新的并发任务,无需关心底层的线程创建、同步与调度等复杂操作,这极大简化了并发编程的难度。

总的来说,Go 的任务调度与并发是紧密相关的,任务调度是实现并发的明显特性,而并发是 Go 语言最主要的设计思想之一。

12. 并发,并行,下节课再说

参考文章
www.topgoer.cn/docs/go_mistakes/go...

本作品采用《CC 协议》,转载必须注明作者和本文链接
嗨,我是波波。曾经创业,有收获也有损失。我积累了丰富教学与编程经验,期待和你互动和进步! 公众号:上海PHP自学中心
wangchunbo
讨论数量: 0
(= ̄ω ̄=)··· 暂无内容!

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!
司机 @ 某医疗行业
文章
307
粉丝
350
喜欢
564
收藏
1129
排名:61
访问:12.5 万
私信
所有博文
社区赞助商