关于Go协程的一些理解

本文深入探讨了Go语言中Goroutine的调度原理,通过一个示例代码展示了Goroutine并非按创建顺序执行,并解释了其内部的GMP模型。当Goroutine数量超过256时,多余的Goroutine会被放入全局队列。文章揭示了Go调度器如何通过runnext字段优化通信等待的Goroutine的调度,确保高效和低延迟。同时,分析了调度过程中的随机性和有序性的平衡策略。
摘要由CSDN通过智能技术生成

这段代码执行后的输出是什么?

package main

import (
	"fmt"
	"runtime"
	"sync"
)

var wp sync.WaitGroup

func m(n int) {
	fmt.Printf("%v "n)
	wp.Done()
}
func main() {
	runtime.GOMAXPROCS(1)

	wp.Add(3)
	go m(1)
	go m(2)
	go m(3)
	wp.Wait()
}

代码逻辑

即便你从来没有学过Golang语言也不影响你对它逻辑上的理解:

  1. 在main()中,限制了只能有一个工作线程;映射到操作系统中可以理解为单核多线程的操作环境。
  2. sync.WaitGroup相关的所有操作是为了防止父进程执行过快导致协程没有执行的问题,理解为time.sleep()就行了。
  3. 创建了三个协程1、2、3,每个协程要做的就是打印自己的编号。

猜猜会发生什么

结果可能会出乎大部分人的以外:
执行结果并不是创建时的顺序1、2、3,而是一个看起来非常奇怪的序号:

3 1 2 

看到这里,我们可能会想,Goroutine的执行顺序难道是乱序随机执行的吗?
当然不是,我们再多创建几个goroutine看看输出的结果有什么规律:

func main() {
	runtime.GOMAXPROCS(1)
	wp.Add(256)
	for i := 1; i < 257; i++ {
		go m(i)
	}
	wp.Wait()
}

输出:
256 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 .....

规律似乎找到了,调度并不是无序的,接下来就是要找到实现的逻辑了。

GMP模型

在Go语言中,线程和协程之间的关系就像OS中CPU和线程的关系;线程只负责执行任务,至于任务是什么由协程来决定;

一个main函数编译之后,生成一个可执行文件,执行可执行文件时,加载到虚拟地址空间中的代码段,在经过一系列初始化后来到程序入口,在Go中程序入口并不是main.main(),而会以runtime.mian()作为执行入口;runtime.mian()

创建main groutine,mian groutine执行起来之后才会执行main.main();

工作线程对应的是数据结构是runtime.m;
协程对应的数据结构是runtime.g;
全局变量g0就是主协程的g,与其他协程不同,它的协程栈是在主线程上分配的;
全局变量m0就是主线程对应的m,g0持有m0指针,m0也记录着g0的指针,一开始m0上记录的协程是g0;
allgs记录所有的g;
allm记录所有的m;
初始化数据结构:runtime.runtime2

最开始的Go是简单GM模型,即多个M执行多个G,为了保证隔离性,M获取G时都要加锁解锁,严重影响了性能;
所有后来在GM之外由引入了P,P对应的数据结构是runtime.P,p中定义了一个runq结构(qun queue),这样只要为每一个m分配一个p,m从p中获取需要执行的g,这样就不用从全局队列中争抢任务了,也就减少了加减锁的时间;

虽然每一个p中有一个本地runq,但是在本地还有一个全局runq,保存在全局变量sched中,sched代表全局调度器,对应的数据结构为runtime.schedt,这里记录着所有空闲的m和空闲的p和全局runq;
如果p的本地队列已满(runq [256]guintptr),多出来的的g就会存放到全局runq中;
m会先从自己的p中获取g,如果p中runq为空再去全局runq中获取,如果全局runq中也为空,就去其他m中分担(偷)一些g过来(数量为一半,向下取整)。

当然,和allgs、allm一样,也会有一个allp记录所有的p;

这时候再执行初始化,除了之前初始化的m0、g0、
allgs、allm;还会根据GOMAXPROCS创建n个p保存在allp中;
把第一个p( allp[0])和m0关联起来;

这就是GMP模型

总结

在main goroutine创建之前,会先初始化sched,创建n个p保存到allp中;
main goroutine创建后,加入到当前p0的runq中;
然后通过mastart()开始调度循环 ;
现在p的runq中只有main goroutine在等待执行,所以m0切换到mian goroutine;
执行入口runtime.main();
进行创建监控线程、初始化包、调用main.main等工作;
main.main()执行完之后,runtime.main()就会调用exit函数结束进程。

如何创建协程

在调用mian.mian()时,执行到go m(1)时;
在通过之前词法语法解析后的go方法创建协程时,会被编译器转换为newproc(4,m)调用;

/*newproc()的作用是为gorotine创建栈帧,
目的是为了协程任务结束后,返回到goexit(),
进行协程资源回收处理等工作。*/
func newproc(siz int32, fn *funcval) {
	argp := add(unsafe.Pointer(&fn), sys.PtrSize)
	gp := getg()
	pc := getcallerpc()
	systemstack(func() {
		newg := newproc1(fn, argp, siz, gp, pc)

		_p_ := getg().m.p.ptr()
		runqput(_p_, newg, true)

		if mainStarted {
			wakep()
		}
	})
}

值得一提的是,如果协程任务还没执行就执行了main.main()返回后的exit()函数,会把进程直接结束掉,所以在之前的代码中加入了WaitGroup等待协程结束再返回。

如果是使用time.sleep()等待,实际上会调用gopark函数将当前main groutine的状态从_Grunning修改为_Gwaiting,然后当前goroutine会到timer中等待;等到sleep时间到,timer会把maingoroutine重新置为_Grunnable状态放回到runq中;
main.main结束,exit调用进程退出。

回到问题

按照上面的流程,首先执行newproc()切换至系统栈,然后调用newproc1()创建一个新的G,再调用runqput()将G添加到runq中,那么输出的内容不应该是 1、2、3吗?

重新再看一遍P的数据结构:

type P struct{
	...
	// Queue of runnable goroutines. Accessed without lock.
	runqhead uint32
	runqtail uint32
	runq     [256]guintptr
	// runnext, if non-nil, is a runnable G that was ready'd by
	// the current G and should be run next instead of what's in
	// runq if there's time remaining in the running G's time
	// slice. It will inherit the time left in the current time
	// slice. If a set of goroutines is locked in a
	// communicate-and-wait pattern, this schedules that set as a
	// unit and eliminates the (potentially large) scheduling
	// latency that otherwise arises from adding the ready'd
	// goroutines to the end of the run queue.
	//
	// Note that while other P's may atomically CAS this to zero,
	// only the owner P can CAS it to a valid G.
	runnext guintptr
	...
}

可以发现,P中不仅有一个本地runq,还有一个runnext字段;
runnext的大致意思是,runnext才是下一个可执行G,而不是runq中的G。

下面再看看runput()中具体是怎么安排调度顺序的:

// runqput tries to put g on the local runnable queue.
// If next is false, runqput adds g to the tail of the runnable queue.
// If next is true, runqput puts g in the _p_.runnext slot.
// If the run queue is full, runnext puts g on the global queue.
// Executed only by the owner P.
func runqput(_p_ *p, gp *g, next bool) {
	if randomizeScheduler && next && fastrand()%2 == 0 {
		next = false
	}

	if next {
	retryNext:
		oldnext := _p_.runnext
		if !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
			goto retryNext
		}
		if oldnext == 0 {
			return
		}
		// Kick the old runnext out to the regular run queue.
		gp = oldnext.ptr()
	}

retry:
	h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with consumers
	t := _p_.runqtail
	if t-h < uint32(len(_p_.runq)) {
		_p_.runq[t%uint32(len(_p_.runq))].set(gp)
		atomic.StoreRel(&_p_.runqtail, t+1) // store-release, makes the item available for consumption
		return
	}
	if runqputslow(_p_, gp, h, t) {
		return
	}
	// the queue is not full, now the put above must succeed
	goto retry
}

这一段的大致意思是新加入的G会先加入到runnext中,如果后续继续加入新的G,就会把之前的G挤下来放到runq中

// Kick the old runnext out to the regular run queue.
将旧的runnext踢到runq中

所以P中应该是这样的

runnext = 3,
runq = [1,2]

按照顺序调度执行,顺序正好是3、1、2

当goroutine数量超过256会怎么样呢?

刚刚分析了常规情况下goroutine的调度顺序,但是runq是有限的。
新的G会和本地runq中的一部分放到全局runq中,而且再调度时也会每隔61次执行就会尝试从全局runq中获取goroutine,以避免当本地P繁忙时,导致全局runq得不到调度的情况。

总结

  1. runnext的设置让runq排列变得非常混乱
  2. runqput设计上本来就是为了追求序中有乱 (在第一行中就引入了随机选)
  3. 调度时schedule()也在每61次调度之后尝试一次从全局runq中拉取
  4. runnext的设置虽然让理解更难,但是能让channel中send和recv的G拥有优先调度的特权。

If a set of goroutines is locked in a communicate-and-wait pattern, this schedules that set as a unit and eliminates the (potentially large) scheduling latency that otherwise arises from adding the ready’d goroutines to the end of the run queue.
如果G在channel上阻塞,当能解开时会通过runnext获得优先调度权,不需要再runq上面排队了!!!

runtime包很多地方实现的过于优雅了hahahh

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值