我们在使用Go语言进行开发时,一般会使用goroutine来处理并发任务。那么大家有没有考虑过goroutine的实现机制是什么样的?很多同学会把goroutine与 线程 等同起来,但是实际上并不是这样的。在这边文章中,我们将介绍以下内容:
- 什么是goroutine?
- Goroutine与线程的区别
- Goroutine是如何调度的
什么是goroutine?
Goroutine是建立在线程之上的轻量级的抽象。它允许我们以非常低的代价在同一个地址空间中并行地执行多个函数或者方法。相比于线程,它的创建和销毁的代价要小很多,并且它的调度是独立于线程的。在golang中创建一个goroutine非常简单,使用“go”关键字即可:
package main import ( "fmt" "time" ) func learning() { fmt.Println("My first goroutine") } func main() { go learning() /* we are using time sleep so that the main program does not terminate before the execution of goroutine.*/ time.Sleep(1 * time.Second) fmt.Println("main function") }
这段代码的输出是这样的:
My first goroutine
main function
如果把Sleep去掉的话,输出就会变成:
main function
这是因为,和线程一样,golang的主函数(其实也跑在一个goroutine中)并不会等待其它goroutine结束。如果主goroutine结束了,所有其它goroutine都将结束。
Goroutine与线程的区别
许多人认为goroutine比线程运行得更快,这是一个误解。Goroutine并不会更快,它只是增加了更多的并发性。当一个goroutine被阻塞(比如等待IO),golang的scheduler会调度其它可以执行的goroutine运行。与线程相比,它有以下几个优点:
内存消耗更少:
Goroutine所需要的内存通常只有2kb,而线程则需要1Mb(500倍)。
创建与销毁的开销更小
由于线程创建时需要向操作系统申请资源,并且在销毁时将资源归还,因此它的创建和销毁的开销比较大。相比之下,goroutine的创建和销毁是由go语言在运行时自己管理的,因此开销更低。
切换开销更小
这是goroutine于线程的主要区别,也是golang能够实现高并发的主要原因。线程的调度方式是抢占式的,如果一个线程的执行时间超过了分配给它的时间片,就会被其它可执行的线程抢占。在线程切换的过程中需要保存/恢复所有的 寄存器 信息,比如16个通用寄存器,PC(Program Counter),SP(Stack Pointer),段寄存器等等。
而goroutine的调度是协同式的,它不会直接地与操作系统内核打交道。当goroutine进行切换的时候,之后很少量的寄存器需要保存和恢复(PC和SP)。因此gouroutine的切换效率更高。
Goroutine的调度
真如前面提到的,goroutine的调度方式是协同式的。在协同式调度中,没有时间片的概念。为了并行执行goroutine,调度器会在以下几个时间点对其进行切换:
- Channel接受或者发送会造成阻塞的消息
- 当一个新的goroutine被创建时
- 可以造成阻塞的系统调用,如文件和网络操作
- 垃圾回收
下面让我们来看一下调度器具体是如何工作的。Golang调度器中有三个概念
- Processor(P)
- OSThread(M)
- Goroutines(G)
在一个Go程序中,可用的线程数是通过GOMAXPROCS来设置的,默认值是可用的CPU核数。我们可以用runtime包动态改变这个值。OSThread调度在processor上,goroutines调度在OSThreads上,如下图所示
Golang的调度器可以利用多processor资源,在任意时刻,M个goroutine需要被调度到N个OS threads上,同时这些threads运行在至多GOMAXPROCS个processor上(N <= GOMAXPROCS)。Go scheduler将可运行的goroutines分配到多个运行在一个或多个processor上的OS threads上。
每个processor有一个本地goroutine队列。同时有一个全局的goroutine队列。每个OSThread都会被分配给一个processor。最多只能有GOMAXPROCS个processor,每个processor同时只能执行一个OSThread。Scheculer可以根据需要创建OSThread。
在每一轮调度中,scheduler找到一个可以运行的goroutine并执行直到其被阻塞。
Search in the local queue if not found Try to steal from other Ps' local queue //see Fig 3 if not found Search in the global queue Also periodically it searches in the global queue (every ~ 1/70)
由此可见,操作系统的一个线程下可以并发执行上千个goroutine,每个goroutine所占用的资源和切换开销都很小,因此,goroutine是golang适合高并发场景的重要原因。