What-is-a-Goroutine, seriously

拖延了很久的一篇. 借面试的机会, 梳理一遍.

init and pstree

在系统中, 所有的执行和操作都是通过进程实现的. 当我们按下电源按钮的那一刻, 就相当于盘古开天辟地了, 从此, 所有的进程都会"依附于"一个叫init进程的特殊进程. 关于Linux初始化init进程, 可以看下这一个系列:

浅析 Linux 初始化 init 系统:

我们可以通过 pstree 来查看进程之间的关系.

Process and Thread

严格意义上讲, 并没有进程,线程的区别, Linus 在一篇邮件中表达了自己的观点: http://lkml.iu.edu/hypermail/linux/kernel/9608/0191.html

不管是Process,还是Threads, 都是一些可被执行的实体,context of execution (COE) or independent sequences of execution.

但如果说区别, 进程之间不会共享内存(一般来讲), 而线程之间则会共享进程的内存. 线程是进程资源的子集, 他们唯一的区别在于是否共享了资源.

That state includes things like CPU state (registers etc), MMU state (page mappings), permission state (uid, gid) and various “communication states” (open files, signal handlers etc).

一个进程所包含的资源(或者称之为状态)有, CPU状态(比如寄存器), 内存状态(Memory management unit, 内存映射), 权限状态(用户是谁, 在哪个组里), 以及一些其他的可通信的状态, 比如打开的文件, 接受的信号等.

在threads之间, 以上这些状态都是共享的. 而不同的processes之间则是相互独立的.

如果说进程是线程的容器, 也能讲得通, 因为在大多数现代操作系统中(面向线程设计的系统), 一个进程最少包含了一个线程, 这里的包含, 就有包容, 容纳的意思. 进程就是线程执行的容器和"载体”.

“进程/线程"状态变化:

task state

关系图:

  • Every process is a thread (primary thread).
  • But every thread is not a process. It is a part(entity) of a process.

总的来说, 进程独立拥有"进行操作"所需要的全部资源, 而线程共享他们. 进程可以在 /proc目录下发现, 线程则不会. 进程是通过forkexec创建出来的, 而线程是通过clone创建出来的. 但本质上, 两者都是可以被执行的实体, 或者叫 task, 是被CPU调度的单位.

另外, 一个process在内存中分为了几个部分: Text Segment, Data Segment, Stack Segment, 具体的细节和解释可以看这里: Process and Memory

fork/vfork/exec/clone

fork: 对当前进程进行复制, 复制出来的进程几乎跟原始进程一摸一样. 新的进程(子进程)拥有一个新的ID (PID), 他的 “parent proess ID” (PPID) 是其原始进程的PID. 因为这两个进程运行的代码是一摸一样的, 他们可以相互区分开来, 通过 fork 调用. 原始进程的fork会返回子进程的PID, 而子进程则会返回0. 如果在fork中有异常发生, 那么父进程则不会得到子进程的ID(而是得到一个错误码), 子进程也不会被创建.

vfork: vforkfork 的区别在于, 当父进程调用了vfork的时候, 其本身会被suspended(临时), 即不再RUNNING. 子进程则会借用父进程的地址空间. 这种奇怪的状态将会一直持续到子进程调用了 execve或者退出. 然后父进程接着执行. 这就意味着, 由vfork产生的子进程必须要格外的小心, 以避免对父进程变量的异常操作. 而且子进程也不应该直接exit(), 而是要 _exit().

exec: 这是用来替换当前进程的一个调用. 它将程序加载到当前进程的空间内, 并从入口处开始执行. 除非exec有错误产生, 否则控制权就不会交给原先的进程了.

clone: 类似于fork, 都是创建一个新的进程. 但是与fork不同的是, clone允许创建出来的新的进程共享其父进程的一部分资源(这里创建出来的新的进程, 也可以被称作线程, 或者协程, 因为他们共享资源了). 这些资源包括, 内存空间, 文件描述符(们), 接受的信号(们), 等.

当子进程(线程/协程)通过clone创建出来的时候, 他们执行的是某个具体的function(fn(arg)), 这一点就与fork不同了, 因为fork执行的是整个程序, 是从程序的入口处开始执行的. 这个function是一个指向某个函数的指针, 这个指针由子进程执行(作为子进程的entrypoint). fn(arg) arg作为参数传递给子进程(线程/协程).

当这个fn(arg)函数返回的时候, 子进程也就结束了. fn(arg)的返回码就是子进程的退出状态码.

在底层, fork也是由clone产生的, 只不过参数不同. 不同的参数让clone能够复制进程, 或者创造线程.

Goroutine

综上来看, 进程, 线程, 都是一些有资源(内存, 文件描述符, CPU状态), 可以被执行的实体. 那么goroutine也不例外.

本质上, goroutine也是通过clone产生的, 其也共享了进程的各种资源, 但是又比线程更轻量, 因为相比于thread的内存占用(≥ 1MB), goroutine要小很多(2KB).

而且thread是由OS管理的, 这就给系统带来了开销, 而goroutine是由Go的Runtime管理的, go有自己的一套调度模型, 这也减少了对系统的负担.

那么为什么goroutine的内存占用会小很多呢? 可以参见 five-things-that-make-go-fast 的第五部分.

大致上是说, 因为thread之间需要一个guard page来避免stack和heap之间的内存碰撞(如果碰撞了, 程序就退出). 因为threads很难预测自己的内存使用, 所以就需要预留一些内存给每个thread的stack以及其gard page(为了不让stack和heap碰撞), 这就造成了thread的"内存浪费”(也不能说是浪费, 因为是没办法的事情). 这个内存大概是"兆级别"的.

而goroutine在这一方面则有很大不同. 首先就是取消了gard page. go runtime在每个函数执行之前都会检查一下是否有足够的stack供其使用, 如果没有, runtime就会分配更多内存给它. 因为有这样一种检查机制, goroutine的初始stack就可以变得很小(不够了再分配), 就可以省下很多资源. goroutine结束的时候, 再将内存释放回heap.

而go runtime的调度, 可以看一下我之前的一篇翻译.

另外, 下面的参考链接中都说了很多, 非常建议感兴趣的同学读一下.

Refs

comments powered by Disqus