在java/c++中要实现并发编程的时候,通常需要自己维护一个线程池,并且需要自己去包装一个又一个的任务,同时需要自己去调度线程执行任务并维护上下文切换,这一切通常会耗费程序员大量的心智

goroutine和thread有什么区别

占用内存

Go语言中的goroutine就是这样一种机制,goroutine的概念类似于线程,但 goroutine是由Go的运行时(runtime)调度和管理的。Go程序会智能地将 goroutine 中的任务合理地分配给每个CPU。Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制。

启动多个goroutine

使用了sync.WaitGroup来实现goroutine的同步

  1. var wg sync.WaitGroup
  2. func hello(i int) {
  3. defer wg.Done() // goroutine结束就登记-1
  4. fmt.Println("Hello Goroutine!", i)
  5. }
  6. func main() {
  7. for i := 0; i < 10; i++ {
  8. wg.Add(1) // 启动一个goroutine就登记+1
  9. go hello(i)
  10. }
  11. wg.Wait() // 等待所有登记的goroutine都结束
  12. }

多次执行上面的代码,会发现每次打印的数字的顺序都不一致。这是因为10个goroutine是并发执行的,而goroutine的调度是随机的。

如果主协程退出了,其他任务(协程)不会执行

goroutine与线程

可增长的栈

OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine的栈不是固定的,他可以按需增大和缩小,goroutine的栈大小限制可以达到1GB,虽然极少会用到这个大。所以在Go语言中一次创建十万左右的goroutine也是可以的。

在1.4新版本发布的运行时信息当中明确指出,从以前的1.2版本到1.3版本协程占用大小4kb到8kb,到现在的2kb左右,是一个性能上和的大跃进。

goroutine调度

GPM是Go语言运行时(runtime)层面的实现,是go语言自己实现的一套调度系统。区别于操作系统调度OS线程。

  • 1.G很好理解,就是个goroutine的,里面除了存放本goroutine信息外 还有与所在P的绑定等信息。
  • 2.P管理着一组goroutine队列,P里面会存储当前goroutine运行的上下文环境(函数指针,堆栈地址及地址边界),P会对自己管理的goroutine队列做一些调度(比如把占用CPU时间较长的goroutine暂停、运行后续的goroutine等等)当自己的队列消费完了就去全局队列里取,如果全局队列里也消费完了会去其他P的队列里抢任务。
  • 3.M(machine)是Go运行时(runtime)对操作系统内核线程的虚拟, M与内核线程一般是一一映射的关系, 一个groutine最终是要放到M上执行的;

P与M一般也是一一对应的。他们关系是: P管理着一组G挂载在M上运行。当一个G长久阻塞在一个M上时,runtime会新建一个M,阻塞G所在的P会把其他的G 挂载在新建的M上。当旧的G阻塞完成或者认为其已经死掉时 回收旧的M。

P的个数是通过runtime.GOMAXPROCS设定(最大256),Go1.5版本之后默认为物理线程数。 在并发量大的时候会增加一些P和M,但不会太多,切换太频繁的话得不偿失。
单从线程调度讲,Go语言相比起其他语言的优势在于OS线程是由OS内核来调度的,goroutine则是由Go运行时(runtime)自己的调度器调度的,这个调度器使用一个称为m:n调度的技术(复用/调度m个goroutine到n个OS线程)。 其一大特点是goroutine的调度是在用户态下完成的, 不涉及内核态与用户态之间的频繁切换,包括内存的分配与释放,都是在用户态维护着一块大的内存池, 不直接调用系统的malloc函数(除非内存池需要改变),成本比调度OS线程低很多。 另一方面充分利用了多核的硬件资源,近似的把若干goroutine均分在物理线程上, 再加上本身goroutine的超轻量,以上种种保证了go调度方面的性能。

hello Goroutine的执行过程

一个hello world 程序,编译为成为一个可执行文件,执行时可执行文件被加载到内存,对于进程虚拟地址空间中的代码段,感兴趣的是程序的执行入口,它并不是我们熟悉的main.main

image.png

不同平台下程序执行入口不同,在进行一系列检查和初始化等准备工作后,代码段的程序入口runtine.main,创建main goroutine ,其执行起来后才会调用编写的main.main,协程对应的数据结构是runtine.g 工作线程对应的数据结构是runtine.m 全局变量g0就是主协程对应的g,与其他的协程不同,它的协程栈主要是在主线程上分配的。全局变量m0就是主线程对应的m,g0持有m0的指针,m0里也记录着g0的指针 ,而且一开始m0上执行的协程正是g0 全局变量allg记录所有的g,allm记录所有的m。

image.png

最初Go语言的调度模型里面只有G和M,每个M来获取一个G时都要加锁,多个M分担多个G的执行任务就会因频繁加锁和解锁等待,影响程序并发性能;后来引入了P,P对应的数据结构是runtine.p,它有一个本地runq,这样只要把一个p关联到一个m,这个m就可以从p这里直接获取待执行的g,就不用每次从和众多M从一个全局队列中争抢任务了。虽然p有一个本地runq,但是依然有一个全局runq,它保存在全局变量sched中,这个全局变量代表的就是调度器,对应的数据结构是runtime.schedt ,这里记录着所有空闲的m,空闲的p。如果p的本地队列已满,那么等待执行的G就会被放到这个全局队列里去。而M会先从关联P持有的本地runq中获取待执行的G,没有的话再到调度器持有的全局队列这里去领一些任务,如果这里也没有了,就会去别的P那里去”分担”一些G过来.在程序初始化过程中会进行调度器优化,这时会按照GOMAXPROCS这个环境变量决定创建多少的P,保存在全局变量allp中,并且把第一个p(allp[0])与m0关联起来。
在main goroutine 创建之前,G、P、M的关系是这样的,在创建之后是这样的(上图加了一个G),main goroutine 创建之后,被加入到当前p的本地队列中,然后通过mstart函数开启调度循环,这个mstart函数是所有工作线程的入口,主要就是调用schedule函数,也就是执行调度循环。其实对于一个活跃的m而言,不是在执行某个G,就是在执行调度程序获取某个g 。 runtime.main 会做很多事情,包括创建监控线程,进行包初始化等,也包括main.main (输出hello world)。

  1. _rt0_amd64_windows
  2. _rt0_amd64_linux
  3. ....
  4. osinit
  5. ...
  6. schedinit
  7. new main goroutine(newproc)
  8. runtime.main
  9. sysmon, package init....
  10. call main.main
  11. newproc(0,hello)
  12. wait
  13. exit.
  14. goexit() 处理协程资源

在main.main 返回之后,runtime.main会调用exit()函数结束进程

  1. package main
  2. func hello(){
  3. println("hello world !")
  4. }
  5. func main(){
  6. go hello()
  7. }


如果在main.main中不直接输出,而是通过一个协程来输出,那么到main.main被调用时,就会创建一个新的goroutine,记为 “hello goroutine”。通过go关键词创建协程,会被编译器转化为newproc函数调用。创建goroutine时我们只负责指定入口,参数。而newproc会给goroutine构造一个栈帧,目的是让协程任务结束后,返回到goexit函数中去,进行协程资源回收处理等工作。一个协程任务完成后,是该放到空闲G队列里备用,还是该释放。总归要有个出路。

如果设置GOMAXPROCS 只创建一个P,新创建的hello goroutine 被添加到当前p的本地runq,然后main.main就结束返回了。再然后exit()函数被调用,进程就结束了。所以hello goroutine就没有执行。问题就在于main.main函数返回后,exit函数就会被调用。直接把进程给结束调,没给hello goroutine空出调度执行的时间。所以要想让hello goroutine执行,就要在main.main返回之前拖延下时间,如果使用time.sleep,就会调用gopark函数,把当前协程的状态从_Grunning 修改为_Gwaitting ,然后main goroutine不会回到当前p的runq中,而是在timer中等待,继而调用schedule()进行调度,hello goroutine 得以执行,等到sleep的时间到了后,timer会把main goroutine重置为_Grunnable状态,放回到p的runq中,再然后,main.main结束,exit()调用,进程退出。这是只有一个p的情况,如果创建了多个p,hello goroutine创建之后,虽然默认会添加到当前p的本地队列里,但是在有空闲p的情况下,就可以启动新的线程关联到空闲的p,并把hello goroutine 放到它的本地队列中了。


总结

  1. 调用osinit()获取CPU核数与内存页大小;
  2. 执行schedinit()初始化调度器,创建指定个数的P,并建立m0与P的关联;
  3. 以runtime.main为执行入口创建goroutine,也就是main goroutine;
  4. mstart开启调度循环,此时等待队列里只有main goroutine等待执行;
  5. main goroutine得到调度,开始执行runtime.main;
  6. runtime.main会调用main.main,开始执行我们编写的内容。main.main返回后,会调用exit函数结束进程。

goroutine的创建、让出与恢复

一些结论

  • goroutine 所占用的内存,均在栈中进行管理
  • goroutine 所占用的栈空间大小,由 runtime 按需进行分配
  • 以 64位环境的 JVM 为例,会默认固定为每个线程分配 1MB 栈空间,如果大小分配不当,便会出现栈溢出的问题