What is goroutine?

  sonic0002        2024-01-21 03:26:10       1,403        0          English  简体中文  繁体中文  ภาษาไทย  Tiếng Việt 

随谈

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. 从内存中检索 1 并将其存储在寄存器 A 中。
  2. 从内存中检索另一个 1 并将其存储在寄存器 B 中。
  3. 执行加法运算并将结果写入寄存器 A。
  4. 将寄存器 A 中的结果复制到存储结果的内存位置。

现在,让我们看看字符串连接,例如"hello" + "world" = "helloworld"

  1. 从内存中检索字符串“hello”并计算其长度 (5)。
  2. 从内存中检索字符串“world”并计算其长度 (5)。
  3. 添加长度并分配一个大小合适的内存块(在某个地方)。
  4. 将“hello”复制到分配的内存(某个地方)的前 5 个槽中。
  5. 将“world”复制到分配的内存(某个地方)的第 6-10 个槽中。
  6. 返回分配的内存(某个地方)的地址。

CPU 是否正是这样执行这些操作的?就具体的底层细节而言,并非完全如此,因为它处理的是 0 和 1 的块。但是,从抽象的角度来看,是的。所有冯·诺依曼体系结构系统,包括英特尔和 ARM 等特定 CPU 体系结构,以及 JVM 和 Python 的eval.c 中大量的switch...case..(因为虚拟机模拟物理 CPU)等虚拟机,都遵循类似的过程。

现在我们了解了 CPU 的工作原理,让我们来探讨操作系统的某些概念。

安装操作系统

是时候安装系统了;这令人紧张。经过几个步骤后,Linux 运行起来了。但是为什么我们需要操作系统呢?如果没有它,程序员的生活将会充满挑战,需要执行以下任务:

  1. 学习购买的 CPU 的指令集并阅读手册。
  2. 检查硬盘的前 512 个字节以了解分区。
  3. 找到从 1024 个字节开始的所需程序并将其加载到内存中。
  4. 意识到内存太小,只加载程序的一部分。
  5. 尝试一个简单的操作,例如 1+1=2,但意外地覆盖了另一个程序。
  6. 重新启动并重新开始。

幸运的是,在过去的几十年里,伟大的程序员开发了操作系统,使我们免受这些困难。

如前所述,我们的程序驻留在硬盘上。要运行它们,我们必须将程序从硬盘读取到内存中,然后执行特定指令。如果让我编写一个操作系统,我会将程序的所有信息组织在一个地方以便于管理。它看起来像这样:

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,然后是printlnprintln返回,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 的本质。

参考文献

EXPLANATION  GOLANG  GOROUTINE 

       

  RELATED


  0 COMMENT


No comment for this article.



  RANDOM FUN

Be serious, Google

This is the Google doodle in Hong Kong for 2014 Mid-Autumn festival. Are you serious, Google? Do Chinese need to climb the ladder to see the moon? Are you meaning that the Chinese need to bypass the Great Firewall to use Google?