为什么 goroutines 不是轻量化的线程

原文地址
我没去获得授权来着,随手翻译一下就当做笔记了

如今 golang 获得了从前不可想像的流行度,其主要原因是它那由 goroutineschannels 组合所提供简单且轻量的并发开发体验。

并发已经存在于线程很长一段时间,最近几乎所有应用都使用到了这一特性。

要去了解为什么 goroutines 并不是轻量化的线程,我们需要先了解线程如何在 OS 上进行工作。

如果你已经对线程很熟悉了,你可以直接跳转到这

什么是线程?

一个线程既是可被一段可被处理器所执行的指令顺序,线程 ( Thread ) 比进程 ( process ) 更加轻量,所以我们可以产生并使用很多线程。

一个生动简单的的例子即 web 服务器。

一个 web 服务器必须得设计成去可在同一时间内处理多条不同的请求 。而且通常这些请求不会依赖对方。

所以线程就会被创建( 或者从线程池内被取出 ),然后请求被委托给线程,已处理并发的情况。

现代处理器可以一次执行多个线程(多线程),并且切换不同的线程来实现并行性。

线程比进程轻量吗?

是,也不是。

要先了解一下两个概念:

  • 线程分享内存,所以当它们被创建的时候,不需要创建新的虚拟内存空间,所以也并不需要用到 MMU(memory management unit 内存管理单元) 来进行上下文切换。
  • 线程之间的交流使用的分享内存方法比起进程之间的交流需要各种方法如 IPC (Inter-Process Communications 进程内通讯) 的 信号 ,消息队列,管道等,都显得更加的轻量化。

所以说,多进程处理在多核处理器中并非是保证高性能的不二法宝。

再比如 , Linux 不区分线程以及处理器,并把他们都称为 多任务 (Tasks) 。当它们被克隆的时候,每一个任务都有其最大到最小的分享等级。

当你调用 fork() 的时候 , 一个新的,没有分享的文件描述符,没有 PIDs, 没有内存空间的任务(task)会被创建 。当你调用 pthread_create() 时,一个新的任务会被创建,并包含所有以上所被分享的资源。

当然,使用 分享内存 来同步化数据 以及 在多核情况下使用 L1 缓存 会造成比起在 不同线程上不同内存来同步时 更大的开销。

Linux 开发者们一直在致力于用最小的开销来切换任务,并且他们成功了。创建一个新的任务同窗比创建新的线程花销更大,但是切换任务却并非如此。

还有什么可以对线程进行提升?

这里有 3 种情况会让线程变慢:

  1. 线程因为巨大的栈大小(>= 1 MB ) 而消耗了过多的内存。所以创建 1000 个以上的线程即代表你需要整整 1 GB 的内存。

  2. 线程的回复 需要 一系列的寄存器,包括 AVX(Advanced Vector extension)高级向量拓展指令集 , SSE(Streaming SIMD Ext.流式单指令流多数据流拓展) , 浮点寄存器 ,程序计数器PC(Program Counter),栈指针Stack Pointer(SP),等,都会对应用程序的性能造成影响。 3. 线程的创建与线程的销毁需要对系统资源(比如内存)进行的调用,会非常的慢。

Goroutines

Goroutines 只存在于 go runtime的虚拟空间内,而不在 OS 中。

所以, Go 的运行调度器需要管理其生命周期。

Go Runtime 调度器管理着 三个C 结构体:

  1. G: 相当于单个go routine , 并且包换了栈指针,基于栈,由它的 ID , 缓存,以及状态。
  2. M: 代表了一个系统线程,其同时包含了一个全局可运行goroutines 队列 的指针,当前正在运行的goroutine , 以及所关联的调度器。
  3. 调度架构:一个全局的结构体,和线程一样,但它包含的是空闲以及等待的goroutines的队列。

所以,在启动的时候, go runtime 会启动一定数量的 goroutines 来处理GC , 调度器,以及用户代码,一个OS的县城会被创建以处理这些 goroutines . 这些线程的数量将等同于GOMAXPROCS

从下面开始!

一个 goroutine 被创建的时候仅仅占用 2kb 的栈大小。 , 每个 go 函数都已经做好了是否需要更多栈,以及栈是否能被其他两倍内存大小的内存来源所复制的检查。这使 goroutines 在资源使用上更加轻量。

阻塞是无妨的!

如果一个 goroutine 在系统上的调用了,他会阻塞整个线程。但其他线程会从调度器上取出等待队列,并且用其他可运行 goroutines 来执行。

然而,如果你使用 Channel 这种只存在于虚拟空间的方式来沟通,系统并不会阻塞线程。 这样的 gorouintes 简化了 go 在等待阶段,以及其他可运行 goroutines(在 M 内) 的调度。

请勿中断!

go 的运行时调度器做到了合作的调度,这意味着其他的 goroutines 只会因为当前使用的 goroutine 在阻塞或者已经完成时才会调用 , 如以下情况:

  • 当这些操作会造成阻塞时, Channel 的发送或者接收。
  • Go 的声明阶段,因为这并不会保证新的 goroutine 会被直接调度。
  • 阻塞的系统调用,比如文件以及网络操作。
  • gc 过程所停止之后。

这比基于时序系统(每 10ms 一次)的强占调度要更好,后者会造成阻塞,并调度一个新的线程。这可能会导致任务花更多的时间在 线程数增加 以及在处理 在有低优先级的任务运行时 的 更高优先级的任务的调度。

另外的好处是,因为这些逻辑都是在代码中隐式调用的,当睡眠以及 Channel 等待时,便一直只需要确保/恢复以下几个寄存器。在 Go 中,这意味着在进行上下文切换时仅仅需要调度 3 个寄存器:PC , SP, DX(Data Registers) ,而不是需要所有的寄存器,如(AVX , 浮点寄存器, MMX)。

如果你打算浏览更多关于 go 的并发原理, 你可以查阅下列链接: