翻译 为什么goroutines不是轻量级线程

2020/07/18 编程语言

GoLang 已经变得越来越流行,一个主要原因是它提供给开发者能够非常简单和轻量便能实现并发的机制—— goroutines 和 channels 。其实,在很长一段时间内,应用程序获得并发的一个主要方式还是通过线程。为了能够理解Go的goroutines并不是轻量级的线程,我们先要理解操作系统中的线程是如何工作的。

什么是线程?

一个线程就是能够供处理器独立执行的一系列的指令,线程比进程更轻量,所以我们可以创建很多线程。现实生活中,web服务器就是典型地被设计用来处理多个并发请求的,通常这些请求之前互不依赖。所以,把请求分配给新建的线程(或者从线程池中获取)来获得并发能力。现代处理器能够同时执行多个线程并且通过线程切换来并行执行。

线程比进程更轻量吗?

是,也不是。从概念来说:

  1. 线程之间共享内存,并且当他们被创建的时候不需要创建一个虚拟内存空间,所以并不会发生MMU(内存管理单元)的上下文切换
  2. 线程之间因为共享部分内存,因此它们之间通信更加容易,这并不像进程,需要各种IPC机制,如信号量、消息队列、管道等。

话虽如此,这也并不能保证线程在多处理器的世界里一定能够获得比进程更好的性能。比如,Linux中并不区分线程还是进程,因为在底层他们都是任务,每个任务在克隆的时候能够有最小到最大程度的共享。

当你调用 fork() 的时候,一个不包含共享文件描述符,PIDS,内存空间的新任务被创建。当你调用 pthread_create()的时候,一个共享上述所有资源的新任务被创建。

相同的,在共享内存以及运行在多个内核上的任务的一级缓存中同步数据比在独立内存上运行不同的进程要付出更大的代价。Linux开发者尝试使用最小的代价来减小任务切换的开销并获得了成功。然而,创建一个任务仍然是比创建一个线程开销大的操作,尽管任务切换已不是。

线程还可以怎么提高性能?

线程慢的原因,主要有以下三个:

  1. 创建线程的开销尽管比创建进程小得多,但是每创建一个线程至少需要申请1MB的栈内存空间,所以如果想要创建1000个线程,那么至少需要申请1G的内存。
  2. 线程的状态恢复需要非常多的寄存器,比如AVX、SSE、浮点指针,程序计数器、栈指针,这些都会影响到程序的性能。
  3. 线程的创建和销毁都需要向操作系统申请资源(比如内存),这些操作都是比较慢的。

Goroutines

Goroutines只存在于go运行时(runtime)虚拟的空间,对操作系统来说是透明的。然而,一个Go运行时调度器管理着goroutines的生命周期。Go运行时维护着3个C语言的结构体:

  1. G结构体:它代表了一个独立的go routine,包含了栈指针,ID,缓存以及状态等属性。
  2. M结构体:它表示一个操作系统线程,它包含一个指向包含可执行goroutines的全局队列的指针,当前正在执行的goroutine以及调度器的引用。
  3. Sched结构体:它是一个全局结构体,包含一些包含空闲或等待协程的队列,线程也有对应的队列。

所以,在启动的时候,go运行时启动一些协程来进行GC,调度以及完成用户代码任务。实际上,操作系统层面只有一个线程来处理这些协程,而线程的数量可以通过GOMAXPROCS参数指定。

从底层开始!

一个goroutine被创建时,只需要申请不到2kB大小的栈空间。go的每个方法的调用都会去检查是否需要占用更多栈空间,如果需要,则通常会将栈空间扩大一倍,并拷贝到新的内存空间,这使得go的协程在操作系统层面来说非常轻量。

阻塞也是可以接受的!

如果一个系统调用阻塞了一个go协程,那么它会挂起执行该协程的线程。但是go会使用调度器的等待队列(Sched结构体)中的其中一个线程来处理其他的可执行协程。然而,如果你使用只存在于虚拟空间的通道来进行通信,那么操作系统并不会阻塞线程。这些协程会进入等待状态,另一些可执行的协程则会被该线程处理。

不会中断!

go运行时调度器采用的是一种协同调度的策略,也就是说go协程只会在被阻塞或者完成的时候让出执行权。一些列子如下:

  • 只有在操作被阻塞的时候,通道channel才会发送或者接受到信息。
  • 并不会保证新创建的go协程能够立即被执行
  • 在阻塞类的系统调用,如文件或网络操作
  • 被GC周期结束掉

这种机制会比抢占式的调度策略更好,抢占式的调度系统采用时间片(每10ms)来阻塞和调度一个新的线程,这类策略会使得运行时间变得更长,比如当线程数量较多或者当一个更高优先级的任务等待执行而此时正在执行一个低优先级的任务。

另一个好处是,这种机制在代码层面是透明的,比如说调用sleep或者等待channel消息,编译器只需要挂起或恢复几个关键的寄存器状态,简单的说,在Go中只需要更新三个寄存器(PC,SP和DX(数据寄存器))的状态就可以完成上下文的切换,相比与线程,少了AVX,浮点指针,MMX等。

原文链接:https://codeburst.io/why-goroutines-are-not-lightweight-threads-7c460c1f155f

Search

    Table of Contents