go 进阶 协程相关: 四. 协程G的获取

一. 了解协程底层的前置知识

  1. 先了解了线程模型,与MPG模型,了解了go底层在针对MPG模型实现上对应的M,P,G的底层结构
  2. mai方法启动go程序后,初始化了一些东西,底层在启动时会执行一个rt0_go,这就是Go 程序启动时执行的第一个函数,在rt0_go函数中先后执行了:
  1. runtime.args()保存二进制文件的绝对路径到os.executablePath
  2. runtime.osinit(SB)针对系统环境初始化,例如初始化cpu核数内存也大小等getproccount(), getPageSize()
  3. runtime.schedinit(SB) 调度相关初始化,
  4. runtime.mainPC(SB) 启动监控任务用于标记抢占执行过长时间的 G,以及检测 epoll 里面是否有可执行的 G
  5. runtime.newproc()获取实际的工作协程,该函数中会调用newproc1(),主动开启协程时底层调用的也是该函数
  6. runtime.mstart(SB)启动调度循环,由此处引出协程调度,也是一个重点,后续有专门讲解
  1. 在runtime.schedinit(SB), 该函数内部进行了一些初始化动作
  1. 调用getg()获取g0
  2. 设置m的最大数量是10000
  3. 调用mcommoninit初始化m0,分配 M 和 g0 的内存空间
  4. 调用procresize调整p的数量,也可以通过环境变量GOMAXPROCS来控制P的数量。
  5. _MaxGomaxprocs 控制了最大的 P 数量只能是 1024
  6. 绑定m0和p
  7. 到现在已经有了m0 g0 gsignal和p相互绑定,并且有ncpu个p
  1. 接下来我们详细看一下通过go关键字开启协程后,协程调度执行的源码与底层逻辑,可以从三个角度了解协程底层
  1. 前面main方法启动已经了解到了程序的初始化
  2. 协程的创建,底层也就是newproc()
  3. go程序启动后的调度执行,也就是runtime.mstart(SB)

线程本地存储

  1. 先了解一下线程本地存储(Thread Local Storage,简称TLS),其实就是线程私有全局变量。普通的全局变量,一个线程对其进行了修改,所有线程都可以看到这个修改;线程私有全局变量不同,每个线程都有自己的一份副本,某个线程对其所做的修改不会影响到其它线程的副本。
  2. Golang是多线程程序,当前线程正在执行的协程,显然每个线程都是不同的,这就维护在线程本地存储。所以在Golang协程切换逻辑中,随处可见『get_tls(CX)』,用于获取当前线程本地存储首地址

协程的几种状态

enum{
    //协程创建初始状态
    Gidle,
    //协程在可运行队列等待调度
    Grunnable,
    //协程正在被调度运行
    Grunning,
    //协程正在执行系统调用
    Gsyscall,
    //协程处于阻塞状态,没有在可运行队列
    Gwaiting,
    //协程执行结束,等待调度器回收
    Gmoribund,
    //协程已被回收
    Gdead,
};

调度过程简介

  1. 首先创建一个G对象,G对象保存到P本地队列或者是全局队列。P此时去唤醒一个M。P继续执行它的执行序。M寻找是否有空闲的P,如果有则将该G对象移动到它本身。接下来M执行一个调度循环(调用G对象->执行->清理线程→继续找新的Goroutine执行)。
  2. M执行过程中,随时会发生上下文切换。当发生上线文切换时,需要对执行现场进行保护,以便下次被调度执行时进行现场恢复。Go调度器M的栈保存在G对象上,只需要将M所需要的寄存器(SP、PC等)保存到G对象上就可以实现现场保护。当这些寄存器数据被保护起来,就随时可以做上下文切换了,在中断之前把现场保存起来。如果此时G任务还没有执行完,M可以将任务重新丢到P的任务队列,等待下一次被调度执行。当再次被调度执行时,M通过访问G的vdsoSP、vdsoPC寄存器进行现场恢复(从上次中断位置继续执行)

什么是 M0, G0

  1. 在main方法启动时,底层rt0_go中执行的runtime.schedinit(SB), 该函数内部进行了一些初始化动作,有初始化创建M0,G0的逻辑
  2. M0 是什么? 当程序启动时,runtime 系统会创建一个名为 m0(也称为 scheduler thread)的特殊 M,其主要职责是协调、分派和管理所有其他 M 和 goroutine,并提供调度服务,与普通的 M 相比,m0 具有以下几个特点
  1. m0 是 runtime 系统中唯一的 scheduler thread,它是与操作系统线程一一对应的,在运行时仅有一个实例存在。
  2. m0 负责初始化和启动整个 runtime 系统,包括全局变量的初始化、内存管理器的初始化、垃圾回收器的初始化等。
  3. m0 负责处理和维护跨 M 的全局资源。例如,m0 在需要进行跨 M 调度时,会检查目标 M 上的 runqueue,如果队列为空,则从其他 M 中偷取一些 goroutine 过来。
  4. 在默认情况下,m0 不参与普通的 goroutine 执行,因此不会占用 P。只有在全局没有其他空闲的 M 可以调度 goroutine 时,m0 才会使用 P 来运行一些 goroutine。
  1. 什么是G0:G可以分为三种类型,执行用户任务的普通G, 启动runtime.main用到的 G,第三种执行 runtime 下调度工作的叫 G0,每个 M 都绑定一个 G0,多个M内部的G0实际是同一个
  1. go程序启动时创建的第一个goroutine, main函数所在的goroutine,也是整个程序的主goroutine
  2. g0 不会被放入全局的 runqueue 队列里,它也没有自己的私有栈,并且它也不会被阻塞。
  3. g0 会跑在操作系统线程上,而不是其他的 M 上。
  4. g0 的调度、内存管理、垃圾回收等都是由操作系统线程负责。g0 开始的时候会执行 runtime.main() 函数,从而启动Go程序的 main() 函数。
  5. 落到底层代码上go语言底层MPG模型中提供了对应的M,P,G结构体, 在M中有一个g0属性,指的就是这个g0,多个M中的g0指向的是同一个

二. 协程的创建

  1. 通过go关键字可以很方便的创建协程,Golang编译器会将go关键字替换为runtime.newproc函数调用,并且在启动go程序时rt0_go内部也会执行这个函数,函数newproc实现了协程的创建逻辑, 查看runtime下的newproc函数源码,在newproc内部调用了newproc1()函数
func newproc(fn *funcval) {
	gp := getg()
	pc := getcallerpc()
	systemstack(func() {
		//获取g
		newg := newproc1(fn, gp, pc)

		_p_ := getg().m.p.ptr()
		//把newg放到_p_的runnext运行队列中
      	//runqput第三个参数如果是True就把g放到runnext,runnext原有的放到runq。 否则g放到runq
      	//如果runq满了就放到sched.runq(要加锁)
		runqput(_p_, newg, true)

		if mainStarted {
			wakep()
		}
	})
}
  1. 先思考一个问题: func1调用func2,func2函数栈帧入栈,func2执行完毕,func2函数栈帧出栈,重新回到func1的函数栈帧。那如果func1以及func2代表着两个协程呢?这两个函数会并行执行,还能像函数调用过程一样吗?显然是不行的,因为func1以及func2函数栈帧需要随意切换,golang自己维护了协程的用户栈, 在堆上申请一块内存,将寄存器%rsp以及寄存器%rbp指过去,从而将这块内存伪装成用户栈,寄存器%rsp以及寄存器%rbp指向了用户栈,CPU在这个用户栈上完成基于寄存器%rsp入栈以及%rbp出栈操作
  2. 了解协程创建可以分两个部分
  1. newproc1()执行获取协程G
  2. runqput()执行,把G放到_p_的runnext运行队列或全局队列

1. newproc1() 获取G

  1. 协程创建主要逻辑由函数runtime·newproc1实现,在newproc1()中重点完成了以下工作:
  1. 执行getg()获取g0,也就是当前工作线程主线程
  2. 执行acquirem()获取m,并且设置绑定到当前线程的M实例不可抢占
  3. 执行_p_ := g.m.p.ptr()通过当前m找到p,
  4. 执行gfget(p) 先从p的本地队列获取空闲的g,如果当前P和sched都没有空闲的g,就创建新
  5. 执行malg(_StackMin)创建g,并且新建一个2k的栈,包括分配结构体G以及协程栈,系统中的每个g都是由该函数创建而来的
  6. 执行allgadd(newg)将新建的g添加到全局变量allgs中
  7. 判断是否有参数,如果有执行memmove()将参数拷贝到获取的g goroutine栈
  8. 执行memclrNoHeapPointers()初始化设置sched,后续调度器需要依靠这些字段才能把goroutine调度到CPU上运行,重点将goexit+1设置到sched的pc属性上(为什么要+1参考下面的gostartcallfn()函数,因为当协程执行完函数后,会通过这个+1后的地址继续向下执行)
  9. 执行gostartcallfn(),执行该函数中的gostartcall()
  10. 执行casgstatus()将newg的状态变更为Grunnable
  11. 执行[p.goidcache,p.goidcacheend) 获取goid。 不够用就从sched.goidgen里面批量获取16个
  12. releasem (m.lock–),释放M实例的不可抢占状态,返回新的G实例
//创建一个状态为_Grunnable等待调度的g
//参数1: 协程入口
//参数2: 参数地址
//参数3: 参数大小
//参数4: 父协程
//参数五: 返回地址
func newproc1(fn *funcval, argp unsafe.Pointer, narg int32, callergp *g, callerpc uintptr) *g {
	//1.获取g0,也就是当前工作线程主线程
    _g_ := getg()

    if fn == nil {
        _g_.m.throwing = -1 // do not dump full stacks
        throw("go of nil func value")
    }
    //2.执行acquirem()获取m,并且设置绑定到当前线程的M实例不可抢占
    acquirem()     
    siz := narg
    siz = (siz + 7) &^ 7

    //参数大小不能大于初始栈大小
    if siz >= _StackMin-4*sys.RegSize-sys.RegSize {
        throw("newproc: function arguments too large for new goroutine")
    }
	//3.通过当前m找到p
    _p_ := _g_.m.p.ptr()
    //4.在p的本地缓冲中获取一个没有使用的g
    newg := gfget(_p_)   
    //如果获取不到则新建                          
    if newg == nil {
    	//5.重点初始化g stack大小
    	//缓存中没有g,则新建,分配栈为2k大小的G对象                        
        newg = malg(_StackMin)                      
        casgstatus(newg, _Gidle, _Gdead)            
        //6.添加到allg数组,防止gc扫描清除掉
        allgadd(newg)
    }
    if newg.stack.hi == 0 {
        throw("newproc1: newg missing stack")
    }

    if readgstatus(newg) != _Gdead {
        throw("newproc1: new g is not Gdead")
    }
	// extra space in case of reads slightly beyond frame
    totalSize := 4*sys.RegSize + uintptr(siz) + sys.MinFrameSize 
    totalSize += -totalSize & (sys.SpAlign - 1)                  
    //新协程的栈顶计算,将栈顶减去参数占用的空间
    sp := newg.stack.hi - totalSize
    spArg := sp
    if usesLR {
        // caller's LR
        *(*uintptr)(unsafe.Pointer(sp)) = 0
        prepGoExitFrame(sp)
        spArg += sys.MinFrameSize
    }
     //7.如果有参数
    if narg > 0 {          
        //将参数从执行的newproc函数栈拷贝到g的栈中
        //也就是将 fn 的参数从 g0 栈上拷贝到 newg 的栈上
        //注意通过sp计算的spArg一般为0,所以此处很大几率上相当于没做什么
        memmove(unsafe.Pointer(spArg), argp, uintptr(narg))
        if writeBarrier.needed && !_g_.m.curg.gcscandone {
            f := findfunc(fn.fn)
            stkmap := (*stackmap)(funcdata(f, _FUNCDATA_ArgsPointerMaps))
            //一些gc相关的工作省略
            if stkmap.nbit > 0 {
                // We're in the prologue, so it's always stack map index 0.
                bv := stackmapdata(stkmap, 0)
                bulkBarrierBitmap(spArg, spArg, uintptr(bv.n)*sys.PtrSize, 0, bv.bytedata)
            }
        }
    }
    //8.初始化sched,初始化G的gobuf,保存sp,pc,traceback信息任务函数等
   
    //把 newg.sched 结构体成员的所有成员设置为 0
    memclrNoHeapPointers(unsafe.Pointer(&newg.sched), unsafe.Sizeof(newg.sched))
	// 设置 newg 的 sched 成员,调度器需要依靠这些字段才能把 goroutine 调度到 CPU 上运行
    newg.sched.sp = sp
    newg.stktopsp = sp                            
    //sys.PCQuantum等于1, goexit+1后设置到sched.pc上
    //表示当 newg 被调度起来运行时从这个地址开始执行指令
    newg.sched.pc = funcPC(goexit) + sys.PCQuantum  
    //sched.g保存当前新的G
    newg.sched.g = guintptr(unsafe.Pointer(newg))
    //将当前的pc压入栈,保存g的任务函数为pc
    //9.gostartcallfn首先从参数fn中提取出函数地址(初始化时是runtime.main)然后执行该方法中的gostartcall函数
    gostartcallfn(&newg.sched, fn)
    //调用者pc, 计算traceback
    newg.gopc = callerpc       
    //祖先g, 计算traceback                                 
    newg.ancestors = saveAncestors(callergp)                   
    //将协程入口函数赋值给g的startpc,注意startpc主要用于函数调用的traceback和栈的收缩
    //newg的真正执行并不依赖该成员,而是sched.pc
    newg.startpc = fn.fn
    if _g_.m.curg != nil {
        newg.labels = _g_.m.curg.labels 
    }
    // 堆栈转储和死锁检测器中是否必须省略g。
    if isSystemGoroutine(newg, false) {
        atomic.Xadd(&sched.ngsys, +1)
    }
    //10.切换状态,设置g的状态为_Grunnable表示可以运行了
    casgstatus(newg, _Gdead, _Grunnable)                        
    
    //11.从[_p_.goidcache,_p_.goidcacheend) 获取goid, 不够用就从sched.goidgen里面批量获取16个
    if _p_.goidcache == _p_.goidcacheend {
        // Sched.goidgen is the last allocated id,
        // this batch must be [sched.goidgen+1, sched.goidgen+GoidCacheBatch].
        // At startup sched.goidgen=0, so main goroutine receives goid=1.
        _p_.goidcache = atomic.Xadd64(&sched.goidgen, _GoidCacheBatch)
        _p_.goidcache -= _GoidCacheBatch - 1
        _p_.goidcacheend = _p_.goidcache + _GoidCacheBatch
    }
    newg.goid = int64(_p_.goidcache)
    _p_.goidcache++
    
    if raceenabled {
        newg.racectx = racegostart(callerpc)
    }
    if trace.enabled {
        traceGoCreate(newg, newg.startpc)
    }
    //12.
    releasem(_g_.m)
    return newg
}

gostartcallfn下的 gostartcall()

  1. 在gostartcallfn()中调用了gostartcall()
func gostartcallfn(gobuf *gobuf, fv *funcval) {
	//fn: gorotine的入口地址,初始化
	var fn unsafe.Pointer
	if fv != nil {
		// fn: gorotine 的入口地址,初始化时对应的是 runtime.main
		fn = unsafe.Pointer(fv.fn)
	} else {
		fn = unsafe.Pointer(abi.FuncPCABIInternal(nilfunc))
	}
	//重点
	gostartcall(gobuf, fn, unsafe.Pointer(fv))
}
  1. gostartcall函数的主要作用有两个:
  1. 调整newg的栈空间,把goexit函数的第二条指令的地址入栈,伪造成goexit函数调用了fn,从而使fn执行完成后执行ret指令时返回到goexit继续执行完成最后的清理工作;
  2. 重新设置newg.buf.pc 为需要执行的函数的地址,即fn,这里才真正让newg的ip寄存器指向fn函数,等到newg被调度起来运行时,调度器会把buf.pc放入cpu的IP寄存器,从而使newg得以在cpu上真正的运行起来
func gostartcall(buf*gobuf, fn, ctxtunsafe.Pointer) {
    sp := buf.sp//newg的栈顶,目前newg栈上只有fn函数的参数,sp指向的是fn的第一参数
    if sys.RegSize > sys.PtrSize {
        sp -= sys.PtrSize
        *(*uintptr)(unsafe.Pointer(sp)) =0
    }
    sp -= sys.PtrSize//为返回地址预留空间,
    //这里在伪装fn是被goexit函数调用的,使得fn执行完后返回到goexit继续执行,从而完成清理工作
    *(*uintptr)(unsafe.Pointer(sp)) =buf.pc//在栈上放入goexit+1的地址
    buf.sp = sp//重新设置newg的栈顶寄存器
    //这里才真正让newg的ip寄存器指向fn函数,注意这里只是在设置newg的一些信息,newg还未执行,
   //等到newg被调度起来运行时,调度器会把buf.pc放入cpu的IP寄存器,
    //从而使newg得以在cpu上真正的运行起来
    buf.pc = uintptr(fn) 
    buf.ctxt = ctxt
}

2. runqput()

  1. 在前面看P的源码时内部存在几个重要的字段其中就有runnext, 查看runqput()函数,当获取到g后
  1. 首先通过cas判断是否有其它线程在操作runnext ,如果有重试
  2. 取出原runnext上保存的等待运行的g,放入runq队列尾部,将 newg 加入到 P 的 runnext 字段,具有最高优先级,以上防止并发安全问题都是基于cas执行的
  3. 然后执行入列操作,如果 P 的runq本地队列没有满,入队
  4. 如果本地运行队列满了调用runqputslow把g放到"全局队列",注意runqputslow会把本地运行队列中一半的g放到全局队列
  1. 待确认问题点: 在入队时,入的并不是当前新创建的g,好像是runnext上原来等待执行的g,先通过 head,tail,len(p.runq) 来判断队列是否已满,如果没满,则直接写到队列尾部,同时修改队列尾部的指针。
// runqput 尝试将 g 放到本地可执行队列里。
// 如果 next 为假,runqput 将 g 添加到可运行队列的尾部
// 如果 next 为真,runqput 将 g 添加到 p.runnext 字段
// 如果 run queue 满了,runnext 将 g 放到全局队列里
//
// runnext 成员中的 goroutine 会被优先调度起来运行
func runqput(_p_ *p, gp *g, next bool) {
	if randomizeScheduler && next && fastrandn(2) == 0 {
		next = false
	}

	if next {
	retryNext:
		oldnext := _p_.runnext
		if !_p_.runnext.cas(oldnext, guintptr(unsafe.Pointer(gp))) {
			// 有其它线程在操作 runnext 成员,需要重试
			goto retryNext
		}
		// 老的 runnext 为 nil,不用管了
		if oldnext == 0 {
			return
		}
		// 把之前的 runnext 踢到正常的 runq 中
		// 原本存放在 runnext 的 gp 放入 runq 的尾部
		gp = oldnext.ptr()
	}

retry:
	h := atomic.LoadAcq(&_p_.runqhead) // load-acquire, synchronize with consumers
	t := _p_.runqtail
	// 如果 P 的本地队列没有满,入队
	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
	}
	// 没有成功放入全局队列,说明本地队列没满,重试一下
	goto retry
}
  1. runqputslow()函数内部
  1. 先将 P 本地队列里所有的 goroutine 加入到一个数组中,数组长度为 len(p.runq)/2 + 1,也就是 runq 的一半加上 newg。
  2. 接着,将从 runq 的头部开始的前一半 goroutine 存入 bacth 数组。然后,使用原子操作尝试修改 P 的队列头,因为出队了一半 goroutine,所以 head 要向后移动 1/2 的长度。如果修改失败,说明 runq 的本地队列被其他线程修改了,因此后面的操作就不进行了,直接返回 false,表示 newg 没被添加进来。
  3. 最后,将链表添加到全局队列中。由于操作的是全局队列,因此需要获取锁,因为存在竞争,所以代价较高。这也是本地可运行队列存在的原因
// 将 g 和 _p_ 本地队列的一半 goroutine 放入全局队列。
// 因为要获取锁,所以会慢
func runqputslow(_p_ *p, gp *g, h, t uint32) bool {
	var batch [len(_p_.runq)/2 + 1]*g

	// First, grab a batch from local queue.
	n := t - h
	n = n / 2
	if n != uint32(len(_p_.runq)/2) {
		throw("runqputslow: queue is not full")
	}
	//通过循环将 batch 数组里的所有 g 串成链表
	for i := uint32(0); i < n; i++ {
		batch[i] = _p_.runq[(h+i)%uint32(len(_p_.runq))].ptr()
	}
	// 如果 cas 操作失败,说明本地队列不满了,直接返回
	if !atomic.Cas(&_p_.runqhead, h, h+n) { // cas-release, commits consume
		return false
	}
	batch[n] = gp

    // …………………………

	// Link the goroutines.
	// 全局运行队列是一个链表,这里首先把所有需要放入全局运行队列的 g 链接起来,
	// 减小锁粒度,从而降低锁冲突,提升性能
	for i := uint32(0); i < n; i++ {
		batch[i].schedlink.set(batch[i+1])
	}

	// Now put the batch on global queue.
	lock(&sched.lock)
	globrunqputbatch(batch[0], batch[n], int32(n+1))
	unlock(&sched.lock)
	return true
}
  1. globrunqputbatch(): 如果全局的队列尾 sched.runqtail 不为空,则直接将其和前面生成的链表头相接,否则说明全局的可运行列队为空,那就直接将前面生成的链表头设置到 sched.runqhead。最后,再设置好队列尾,增加 runqsize
func globrunqputbatch(ghead *g, gtail *g, n int32) {
	gtail.schedlink = 0
	if sched.runqtail != 0 {
		sched.runqtail.ptr().schedlink.set(ghead)
	} else {
		sched.runqhead.set(ghead)
	}
	sched.runqtail.set(gtail)
	sched.runqsize += n
}

参考博客

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值