问题

Golang 的 goroutine 是如何实现的?

回答
Golang 的 goroutine 是一种非常轻量级的并发执行单元,它允许你以极低的成本同时运行大量的函数。与操作系统线程(OS Threads)相比,goroutine 的创建和切换开销要小得多,这使得 Golang 在并发编程方面具有显著优势。

理解 goroutine 的实现,关键在于理解 Go 的运行时 (Runtime),特别是其中的调度器 (Scheduler)。

Goroutine 的本质

从开发者视角来看,goroutine 就是通过 `go` 关键字启动的一个函数。例如:

```go
package main

import (
"fmt"
"time"
)

func sayHello() {
fmt.Println("Hello from Goroutine!")
}

func main() {
go sayHello() // 启动一个 goroutine
fmt.Println("Hello from main!")
time.Sleep(1 time.Second) // 等待 goroutine 执行
}
```

然而,在底层,goroutine 并不是直接映射到操作系统的线程。Go 运行时会负责管理大量的 goroutine,并将它们与数量有限的 OS 线程进行关联。

Goroutine 的构成

每个 goroutine 在 Go 运行时内部都对应一个 `goroutine` 结构体。这个结构体包含了很多信息,但最关键的是:

上下文 (Context):保存了函数执行时的 CPU 寄存器状态、栈指针等信息,使得 goroutine 可以暂停和恢复执行。
栈 (Stack):Goroutine 拥有自己的栈空间,用于存储局部变量、函数参数和返回地址。与操作系统线程的栈不同,goroutine 的栈是动态增长的。一开始分配一个较小的栈,当需要更多空间时,运行时会自动为其分配更大的内存块,并将旧栈中的内容复制过去。这比固定大小的线程栈更节省内存。
状态 (State):记录了 goroutine 的当前状态,例如 `running` (正在运行)、`runnable` (可运行,等待被调度) 、`syscall` (正在执行系统调用) 等。

Go 调度器:核心机制

Go 的调度器是实现 goroutine 并发执行的核心。它负责将大量的 goroutine 分配到数量有限的 OS 线程上执行,并进行上下文切换。Go 的调度器采用了多对一的并发模型 (M:N Scheduling),这意味着 M 个 goroutine 可以在 N 个 OS 线程上运行,其中 M 通常远大于 N。

调度器主要由以下几个组件组成:

1. M (Machine/OS Thread):代表一个操作系统的线程。这些是实际在 CPU 上执行代码的实体。Go 运行时可以创建和管理自己的 OS 线程,而不需要直接依赖操作系统来创建所有线程。
2. P (Processor/Context):可以理解为 Goroutine 的“执行上下文”或者“工作队列”。一个 P 持有一个或多个 Goroutine 的可运行队列(runnable queue),并且在任何时刻,只有一个 M 可以持有 P 来执行 Goroutine。P 的数量通常与机器的 CPU 核数相关,可以通过 `runtime.GOMAXPROCS(n)` 来设置。
3. G (Goroutine):就是我们上面提到的 Go 的并发执行单元。

调度器的工作流程

Go 调度器的核心思想是让一个或多个 M 绑定到 P 上,然后在这些 P 上调度 G 的执行。

全局运行队列 (Global Run Queue):Go 运行时维护一个全局的 goroutine 运行队列,里面存放着所有准备好执行的 goroutine。
本地运行队列 (Local Run Queue):每个 P 也持有一个本地的 goroutine 运行队列。当一个 goroutine 被创建或从阻塞状态恢复时,它会被放入一个 P 的本地运行队列中。
调度循环 (Scheduling Loop):当一个 M 获得了一个 P,它就开始在 P 的本地运行队列中查找可运行的 G。
如果本地队列中有 G,M 就会从队列中取出一个 G,设置其状态为 `running`,然后执行该 G 的函数。
如果本地队列为空,M 会尝试从全局运行队列中窃取(steal)一些 G 来填充自己的本地队列。这个窃取过程是为了在多个 P 之间实现负载均衡,避免某些 P 空闲而其他 P 上的 G 等待过久。
如果全局队列也为空,并且有其他 M 正在执行 G,该 M 可能会进入休眠状态,等待新的 G 出现。
Goroutine 的状态转换:
创建:当执行 `go func()` 时,会创建一个新的 G,并将其添加到某个 P 的本地运行队列中(或者全局运行队列)。
运行:M 获取 P,从 P 的队列中取出 G,设置其状态为 `running`。
阻塞:当一个 G 执行了一个会阻塞的操作(如通道通信、锁、系统调用等),它的状态会变为 `waiting` 或 `syscall`。此时,该 G 所绑定的 M 可能会释放 P,以便 P 可以去调度其他 G。这被称为抢占 (Preemption)。
可运行:当一个阻塞的 G 被唤醒(例如通道通信完成),其状态会变回 `runnable`,并重新加入到某个 P 的本地运行队列中。
M 的创建与销毁:如果当前没有足够多的 M 来运行所有准备好的 G,Go 运行时会根据需要创建新的 M。当 M 空闲一段时间后,它可能会被销毁以节省资源。
P 的数量限制:P 的数量由 `GOMAXPROCS` 环境变量或函数决定,它表示 Go 程序最多可以同时使用的 CPU 核数。这意味着即使你创建了成千上万个 goroutine,在任何时刻也只有最多 `GOMAXPROCS` 个 goroutine 可以在 CPU 上真正并行执行。其他 goroutine 则在等待调度。

Goroutine 调度器的关键特性

非协作式抢占 (Cooperative Preemption):虽然 goroutine 可以在阻塞时释放 M 和 P,但对于 CPU 密集型且不执行阻塞操作的 goroutine,Go 调度器也会进行一定程度的抢占。当一个 goroutine 运行一段时间(大约 1000 个函数调用或纳秒级别)后,调度器会在函数调用点或特殊指令处检查是否需要抢占。如果需要,它会修改 goroutine 的执行栈,插入一个跳转到调度器的指令,然后执行上下文切换。
偷窃算法 (Work Stealing):为了提高效率和负载均衡,当一个 M 的本地运行队列为空时,它会尝试从其他 M 的本地运行队列中“偷取” goroutine 来执行。这有助于确保所有可用的 CPU 资源都被充分利用。
通道通信的协调:通道通信是 goroutine 之间安全通信的主要方式。当一个 goroutine 向通道发送数据,但通道已满或没有接收者时,它会阻塞。当一个 goroutine 从通道接收数据,但通道为空或没有发送者时,它也会阻塞。调度器会在这时将这些 goroutine 置于等待状态,并将它们所绑定的 M 释放,以便调度其他 goroutine。当通道操作条件满足时,被阻塞的 goroutine 会被唤醒并重新加入到运行队列中。

与传统线程的区别

轻量级:goroutine 的栈空间非常小(通常是 2KB),而且是动态增长的,相比之下,OS 线程的栈通常是几 MB,非常消耗资源。
创建和切换开销小:goroutine 的创建和切换是在用户空间(Go 运行时)完成的,不需要调用操作系统内核,因此开销比线程小得多。
数量可观:由于轻量级,一个 Go 程序可以轻松启动成千上万甚至数百万个 goroutine,而通常无法在同一时间创建这么多 OS 线程。
调度自主性:Go 调度器在用户空间进行调度,可以更灵活地处理 goroutine 的状态转换和优先级(尽管 Go 的调度器相对简单,没有复杂的优先级机制)。

总结

Golang 的 goroutine 是通过 Go 运行时内部的 调度器 实现的。调度器使用 M:N (M Goroutines : N OS Threads) 的模型,将大量的 goroutine 管理并分配到有限的 OS 线程上执行。每个 goroutine 拥有自己的上下文和动态增长的栈。当 goroutine 执行阻塞操作时,它会释放其绑定的 OS 线程,以便调度其他 goroutine。调度器通过全局运行队列、本地运行队列和偷窃算法来管理 goroutine 的执行,并保证了高效的并发和资源利用。理解这些底层机制,对于编写高效和可伸缩的 Go 并发程序至关重要。

网友意见

user avatar

更新说明:这个答案是在golang1.8的时候搬运的,现在随着时间的推移,请大家多多关注golang本身的进化,这个答案或许还有效,但是没有与时俱进的更新,多多包涵。

The Go scheduler 纯翻译如下:

Go runtime的调度器:
在了解Go的运行时的scheduler之前,需要先了解为什么需要它,因为我们可能会想,OS内核不是已经有一个线程scheduler了嘛?
熟悉POSIX API的人都知道,POSIX的方案在很大程度上是对Unix process进场模型的一个逻辑描述和扩展,两者有很多相似的地方。 Thread有自己的信号掩码,CPU affinity等。但是很多特征对于Go程序来说都是累赘。 尤其是context上下文切换的耗时。另一个原因是Go的垃圾回收需要所有的goroutine停止,使得内存在一个一致的状态。垃圾回收的时间点是不确定的,如果依靠OS自身的scheduler来调度,那么会有大量的线程需要停止工作。

单独的开发一个GO得调度器,可以是其知道在什么时候内存状态是一致的,也就是说,当开始垃圾回收时,运行时只需要为当时正在CPU核上运行的那个线程等待即可,而不是等待所有的线程。

用户空间线程和内核空间线程之间的映射关系有:N:1,1:1和M:N
N:1是说,多个(N)用户线程始终在一个内核线程上跑,context上下文切换确实很快,但是无法真正的利用多核。
1:1是说,一个用户线程就只在一个内核线程上跑,这时可以利用多核,但是上下文switch很慢。
M:N是说, 多个goroutine在多个内核线程上跑,这个看似可以集齐上面两者的优势,但是无疑增加了调度的难度。


Go的调度器内部有三个重要的结构:M,P,S
M:代表真正的内核OS线程,和POSIX里的thread差不多,真正干活的人
G:代表一个goroutine,它有自己的栈,instruction pointer和其他信息(正在等待的channel等等),用于调度。
P:代表调度的上下文,可以把它看做一个局部的调度器,使go代码在一个线程上跑,它是实现从N:1到N:M映射的关键。


图中看,有2个物理线程M,每一个M都拥有一个context(P),每一个也都有一个正在运行的goroutine。
P的数量可以通过GOMAXPROCS()来设置,它其实也就代表了真正的并发度,即有多少个goroutine可以同时运行。
图中灰色的那些goroutine并没有运行,而是出于ready的就绪态,正在等待被调度。P维护着这个队列(称之为runqueue),
Go语言里,启动一个goroutine很容易:go function 就行,所以每有一个go语句被执行,runqueue队列就在其末尾加入一个
goroutine,在下一个调度点,就从runqueue中取出(如何决定取哪个goroutine?)一个goroutine执行。

为何要维护多个上下文P?因为当一个OS线程被阻塞时,P可以转而投奔另一个OS线程!
图中看到,当一个OS线程M0陷入阻塞时,P转而在OS线程M1上运行。调度器保证有足够的线程来运行所以的context P。


图中的M1可能是被创建,或者从线程缓存中取出。

当MO返回时,它必须尝试取得一个context P来运行goroutine,一般情况下,它会从其他的OS线程那里steal偷一个context过来,
如果没有偷到的话,它就把goroutine放在一个global runqueue里,然后自己就去睡大觉了(放入线程缓存里)。Contexts们也会周期性的检查global runqueue,否则global runqueue上的goroutine永远无法执行。


另一种情况是P所分配的任务G很快就执行完了(分配不均),这就导致了一个上下文P闲着没事儿干而系统却任然忙碌。但是如果global runqueue没有任务G了,那么P就不得不从其他的上下文P那里拿一些G来执行。一般来说,如果上下文P从其他的上下文P那里要偷一个任务的话,一般就‘偷’run queue的一半,这就确保了每个OS线程都能充分的使用。

类似的话题

  • 回答
    Golang 的 goroutine 是一种非常轻量级的并发执行单元,它允许你以极低的成本同时运行大量的函数。与操作系统线程(OS Threads)相比,goroutine 的创建和切换开销要小得多,这使得 Golang 在并发编程方面具有显著优势。理解 goroutine 的实现,关键在于理解 G.............
  • 回答
    C++20 的协程(coroutines)和 Go 的 goroutines 都是用于实现并发和异步编程的强大工具,但它们的设计理念、工作方式以及适用的场景有显著的区别。简单地说,C++20 协程虽然强大且灵活,但与 Go 的 goroutines 在“易用性”和“轻量级”方面存在较大差距,不能完全.............
  • 回答
    如果要我放弃 Golang,那一定不是一时冲动,而是经过了深思熟虑,并且我得找到一个足够有力的替代方案,让我觉得“这值得”。毕竟,Golang 在很多方面做得还是相当不错的,尤其是它的并发模型和部署的便捷性,这几年确实帮我解决了不少问题。但话说回来,没有任何一种语言是完美的,也不是所有场景都适合 G.............
  • 回答
    Golang 团队在 2023 年 8 月份发布了一个新的字体项目,名为 Go Fonts。这个举动在软件开发领域并不常见,通常我们更关注语言本身的发展、库的更新或者工具链的改进。那么,Golang 为什么要发布一个新字体呢?这背后有着深思熟虑的原因和目标。要理解 Golang 发布新字体的动机,我.............
  • 回答
    在 Go 语言中,如果你想让程序在 `go` 关键字修饰的函数(通常称为 Goroutine)执行完成后再结束,你需要掌握 Goroutine 的同步和通信机制。这就像是给你的主程序一个信号,告诉它:“嘿,我这边还有一个正在忙活的家伙,等他忙完了,你再走。”下面我将详细讲解实现这一目标的几种常用方法.............
  • 回答
    好的,咱们来聊聊告警业务里的“分发频率”这个事儿,而且还得考虑告警源随时会增增减减的情况。这事儿听起来有点复杂,但咱们一步步拆解,肯定能设计出一个稳健的方案。核心挑战:动态、个性化、高效咱们面对的核心问题是: 动态性 (Dynamic): 告警源不是固定不变的。新的告警源可能接入,旧的也可能下线.............
  • 回答
    2010 年前后诞生的编程语言,如 Go、Rust 和 Swift,它们普遍采用强类型和静态类型的组合,这并非偶然,而是反映了当时软件开发领域面临的挑战、技术进步以及对更高质量、更可靠软件的追求。下面我将详细解释为什么会出现这种趋势:核心概念:什么是强类型和静态类型?在深入探讨原因之前,我们先明确这.............
  • 回答
    没问题,咱们就来聊聊这些语言里的“协程”这玩意儿,它们听起来都挺炫酷,但骨子里还是有点小差别的。我尽量讲得深入点,把那些AI味儿的东西都去掉,让你一看就明白。 协程这玩意儿,为啥大家都爱?先别急着说区别,咱们先得明白为啥协程这么受欢迎。你想象一下,以前多线程编程那叫一个热闹,创建线程、切换上下文、同.............
  • 回答
    在 C 中实现 Go 语言 `select` 模式的精髓,即 等待多个异步操作中的任何一个完成,并对其进行处理,最贴切的类比就是使用 `Task` 的组合操作,尤其是 `Task.WhenAny`。Go 的 `select` 语句允许你监听多个通道(channel)的状态,当其中任何一个通道有数据可.............
  • 回答
    很多初次接触 Go 语言的开发者都会有一个疑问:“为什么 Go 语言没有三元运算符?” 这个问题其实触及到了 Go 设计哲学中的一些核心考量。要深入理解这一点,我们需要从多个角度去审视。什么是三元运算符?首先,我们得明确一下什么是三元运算符。它是一种特殊的运算符,顾名思义,它有三个操作数。最常见的形.............
  • 回答
    好的,我们来详细深入地理解 Golang 中这句著名的口号:“不要通过共享内存来通信,而应该通过通信来共享内存”(Do not communicate by sharing memory; instead, share memory by communicating)。这句话是 Golang 设计哲.............
  • 回答
    Golang 1.5 是 Go 语言发展历程中的一个重要里程碑版本,于 2015 年 8 月发布。它带来了许多令人期待的改进和新特性,对 Go 的性能、工具链、语言特性以及生态系统都产生了深远的影响。下面我将从几个关键维度来详细评价 Golang 1.5 的更新: 1. 运行时与垃圾回收 (Runt.............
  • 回答
    这个问题很有意思,也触及到了Go语言设计哲学的一个核心点。确实,我们看到大多数现代编程语言,比如Java、C++、C、TypeScript、Swift等等,在声明和使用泛型时,不约而同地选择了尖括号 `<>`。而Go语言,在引入泛型时,却选择了中括号 `[]`。这并非“标新立异”那么简单,而是Go语.............
  • 回答
    Python 固然是一个全能选手,在数据科学、Web 开发、自动化脚本、机器学习等众多领域都表现出色,赢得了无数开发者的青睐。然而,你观察到的“很多企业转向 Golang”的现象,并非偶然,而是基于现实需求和技术演进的理性选择。这背后,并非 Python 不行,而是 Golang 在某些关键场景下,.............

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有