What is goroutine?

  sonic0002        2024-01-21 03:26:10       1,627        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

CSS is awesome