随谈
Go 语言写起来相当令人愉快,除了那些乏味的if err != nil
检查。这种愉悦感的根本原因之一是goroutine,它是 Go 语言的核心特性。深入理解 goroutine 是值得的,因为它们极大地提升了使用 Go 语言的乐趣。所以,让我们来谈谈 goroutine,希望能为读者提供一些见解。
TL;DR: 我们将首先讨论组装一台计算机,然后深入探讨操作系统的某些概念,例如进程和线程,然后再探讨 goroutine 本身。
组装一台计算机
让我们关注台式机,而不是笔记本电脑。想想台式机的常见组件:显示器、键盘、鼠标和中央单元。现在,让我们仔细看看中央单元,它通常包含几个基本组件:主板、CPU、显卡(发烧友通常选择独立显卡,而预算用户通常依赖 CPU 中集成的显卡 :( )、硬盘和内存模块。
让我们关注 CPU 和内存模块。从 CPU 的示意图开始:
CPU 是如何工作的?CPU 就像一个傻瓜;它有一组固定的指令。我们从这组指令中选择一些指令,将它们排列起来,然后将它们提供给 CPU。它会乖乖地执行它们,当被告知向东走时就向东走,当被告知向西走时就向西走。CPU 主要由两个组件组成:算术逻辑单元 (ALU) 和控制单元。这两个组件包含各种专用寄存器。
让我们考虑一下操作1 + 1 = 2
:
- 从内存中检索 1 并将其存储在寄存器 A 中。
- 从内存中检索另一个 1 并将其存储在寄存器 B 中。
- 执行加法运算并将结果写入寄存器 A。
- 将寄存器 A 中的结果复制到存储结果的内存位置。
现在,让我们看看字符串连接,例如"hello" + "world" = "helloworld"
:
- 从内存中检索字符串“hello”并计算其长度 (5)。
- 从内存中检索字符串“world”并计算其长度 (5)。
- 添加长度并分配一个大小合适的内存块(在某个地方)。
- 将“hello”复制到分配的内存(某个地方)的前 5 个槽中。
- 将“world”复制到分配的内存(某个地方)的第 6-10 个槽中。
- 返回分配的内存(某个地方)的地址。
CPU 是否正是这样执行这些操作的?就具体的底层细节而言,并非完全如此,因为它处理的是 0 和 1 的块。但是,从抽象的角度来看,是的。所有冯·诺依曼体系结构系统,包括英特尔和 ARM 等特定 CPU 体系结构,以及 JVM 和 Python 的eval.c
中大量的switch...case..
(因为虚拟机模拟物理 CPU)等虚拟机,都遵循类似的过程。
现在我们了解了 CPU 的工作原理,让我们来探讨操作系统的某些概念。
安装操作系统
是时候安装系统了;这令人紧张。经过几个步骤后,Linux 运行起来了。但是为什么我们需要操作系统呢?如果没有它,程序员的生活将会充满挑战,需要执行以下任务:
- 学习购买的 CPU 的指令集并阅读手册。
- 检查硬盘的前 512 个字节以了解分区。
- 找到从 1024 个字节开始的所需程序并将其加载到内存中。
- 意识到内存太小,只加载程序的一部分。
- 尝试一个简单的操作,例如 1+1=2,但意外地覆盖了另一个程序。
- 重新启动并重新开始。
幸运的是,在过去的几十年里,伟大的程序员开发了操作系统,使我们免受这些困难。
如前所述,我们的程序驻留在硬盘上。要运行它们,我们必须将程序从硬盘读取到内存中,然后执行特定指令。如果让我编写一个操作系统,我会将程序的所有信息组织在一个地方以便于管理。它看起来像这样:
type process struct {
instructions unsafe.Pointer;
instruction_size int64;
current_offset int64; // 当前执行指令的偏移量
};
这是一个进程。让我们看看 Linux 中进程的内存布局:
然而,后来人们发现进程似乎有点太大。因此,出现了线程的概念。这是 Linux 中线程的内存布局:
我们只解释进程的内存布局;读者可以自己理解线程的内存布局。查看进程的内存布局图,有几个概念:
text
:这些是前面提到的指令;它是我们的代码,编译后生成的指令。data
:这包括在代码中初始化的变量,例如int i = 1
。bss
:这是用于代码中未初始化的变量,例如int a
。heap
:我们稍后会讨论堆。stack
:我们稍后会讨论栈。
首先,让我们考虑一下为什么我们需要栈。栈数据结构的特点是后进先出 (LIFO),对吧?这正是函数调用的工作方式,不是吗?例如:
func foo() {
println("foo")
}
func main() {
foo()
}
这段代码的执行顺序无疑是首先执行main
,然后是foo
,然后是println
,println
返回,foo
返回,最后,由于main
中没有其他代码,它也返回。
你看到了吗?main 是第一个被调用的函数,但它是最后一个返回的函数(现在,让我们暂时忽略运行时中的其他代码)。在函数调用栈中,每个函数调用都放在一个称为帧的数据结构中。
让我们重建上述函数调用过程(就其在内存中的表示而言):
首先,运行时中的一个函数调用 main 函数:
| runtime | main
然后,main 函数调用 foo:
| runtime | main | foo
接下来,foo 函数调用 println:
| runtime | main | foo | println
之后,println 返回:
| runtime | main | foo
然后,foo 返回:
| runtime | main
最后,main 返回:
| runtime
正如你所看到的,函数返回后,函数中存在的变量和其他数据就不再存在了(从技术上讲,它们仍然存在,但通常情况下,行为良好的程序不会访问它们)。那么,如果我们有一些不想在函数返回后丢失的东西呢?换句话说,我们希望某些数据独立于函数调用生命周期。这就是我们的堆发挥作用的地方。
堆的存在就是为了这个目的。代码分配的哪种内存最终会进入堆?如果是 C 代码,则由 malloc 分配的内存最终会进入堆。如果是 Go 语言,则在逃逸分析之后,类似于:
func open(name string) (*os.File, error) {
f := &File{}
return f
}
函数f
将在堆上分配。为什么?如果f
在栈上分配,则在 open 函数返回后它将消失。调用者如何愉快地继续使用它呢?了解了这一点,让我们现在来看看 goroutine。
Goroutine
Go 语言最初是用 C 语言编写的,后来用 Go 语言实现了它自己的引导程序。现在,让我们想想,如果我们要自己实现 goroutine,我们会怎么做?
等等!什么是 goroutine?我们还没有澄清这一点。Go 中的 goroutine 是一种协程。简单来说,协程是由用户而不是操作系统调度的最小执行单元。线程,正如我们所知,是由操作系统调度的最小单元。协程是由程序员手动调度的最小执行单元。它们类似于函数调用,但关键区别在于多个协程可以保存它们的状态,而在函数调用中,一旦函数退出,状态就会丢失。
所以,我们当然不能使用操作系统分配的栈,对吧?但是函数必须有一个栈才能执行。因此,我们在堆中分配一块内存并将其用作栈。这样应该可以工作,对吧?这就是 goroutine 的结构看起来像这样的原因:
type g struct {
// 堆栈参数。
// stack 描述实际的堆栈内存:[stack.lo, stack.hi)。
// stackguard0 是在 Go 堆栈增长序言中比较的堆栈指针。
// 它通常是 stack.lo+StackGuard,但可以是 StackPreempt 以触发抢占。
// stackguard1 是在 C 堆栈增长序言中比较的堆栈指针。
// 它在 g0 和 gsignal 堆栈上是 stack.lo+StackGuard。
// 在其他 goroutine 堆栈上它是 ~0,以触发对 morestackc 的调用(并崩溃)。
stack stack // runtime/cgo 已知偏移量
stackguard0 uintptr // liblink 已知偏移量
stackguard1 uintptr // liblink 已知偏移量
_panic *_panic // 最内层恐慌 - liblink 已知偏移量
_defer *_defer // 最内层延迟
m *m // 当前 m;arm liblink 已知偏移量
sched gobuf
...
}
Go 语言使用 GMP 架构实现 goroutine。
如前所述,冯·诺依曼体系结构依赖于这些元素。堆栈和堆等内容对于实现状态转换是必要的。由于 goroutine 是程序员可以调度的最小单元,因此它应该有自己的堆栈和堆。堆可以在进程中的各种线程之间共享,但每个线程都有自己的堆栈,防止共享使用。因此,我们在堆中分配一块内存来作为 goroutine 的堆栈。这就是 goroutine 的本质。