goroutine

2023-11-30 23:28:39 浏览数 (2)

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

goroutine和thread有什么区别

占用内存

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

启动多个goroutine

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

代码语言:go复制
var wg sync.WaitGroup
func hello(i int) {
    defer wg.Done() // goroutine结束就登记-1
    fmt.Println("Hello Goroutine!", i)
}
func main() {
    for i := 0; i < 10; i   {
        wg.Add(1) // 启动一个goroutine就登记 1
        go hello(i)
    }
    wg.Wait() // 等待所有登记的goroutine都结束
}

多次执行上面的代码,会发现每次打印的数字的顺序都不一致。这是因为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

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

最初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(allp0)与m0关联起来。

在main goroutine 创建之前,G、P、M的关系是这样的,在创建之后是这样的(上图加了一个G),main goroutine 创建之后,被加入到当前p的本地队列中,然后通过mstart函数开启调度循环,这个mstart函数是所有工作线程的入口,主要就是调用schedule函数,也就是执行调度循环。其实对于一个活跃的m而言,不是在执行某个G,就是在执行调度程序获取某个g 。 runtime.main 会做很多事情,包括创建监控线程,进行包初始化等,也包括main.main (输出hello world)。

代码语言:go复制
_rt0_amd64_windows
_rt0_amd64_linux

....

osinit

...


schedinit 

new main goroutine(newproc)


runtime.main
sysmon, package init....
call main.main
 
		newproc(0,hello)

		wait

exit.

goexit()   处理协程资源

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

代码语言:go复制
package main 
func hello(){
	println("hello world !")
}

func main(){
	go hello()
}

如果在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 栈空间,如果大小分配不当,便会出现栈溢出的问题

我正在参与2023腾讯技术创作特训营第三期有奖征文,组队打卡瓜分大奖!

0 人点赞