Golang源码探究——从Go程序的入口到GMP模型

news2024/11/19 21:53:08

在大多数的编程语言中,main函数都是用户程序的入口函数,go中也是如此。那么main.main是整个程序的入口吗, 肯定不是,因为go程序依赖于runtime,在程序的初始阶段需要初始化运行时,之后才会运行到用户的main函数,那么main.main是在哪里被调用的呢?接下来就从go程序的入口,再到go的GMP模型进行一个探究。

注意:本文使用的go sdk的版本为go1.20

文章目录

    • 1.go程序的入口
    • 2 GMP模型
      • 2.1 GM模型
      • 2.2 改进的GMP模型
      • 2.3 相关数据结构
        • 2.3.1 runtime.g
          • g的状态:
        • 2.3.2 runtime.m
        • 2.3.3 runtime.p
          • p的状态:
        • 2.3.4 runtime.schedt
      • 2.4 g0、m0
    • 3 G的创建与退出
    • 4 调度循环
      • 4.1 runtime.schedule
      • 4.2 runtime.findrunnable
      • 4.3 runtime.execute、runtime.gogo
      • 4.4 runtime.gopark、runtime.goready
      • 4.5 work stealing和handoff机制
    • 5 抢占式调度
      • 5.1 异步抢占
    • 6 系统监控线程sysmon

1.go程序的入口

1 首先,编写一个简单的go程序,并将其进行编译,在此使用linux系统:

package main

import "fmt"

func main() {
	fmt.Println("hello,world")
}

编译:-N -l 用于阻止编译时进行优化和内联

go build -gcflags "-N -l" main.go

2 然后使用gdb来调试go程序:

首先,使用gdb加载支持调试go语言的脚本文件:

在shell中执行gdb命令,然后执行source /usr/local/go/src/runtime/runtime-gdb.py

➜  RemoteWorking git:(master) ✗ gdb
(gdb) source /usr/local/go/src/runtime/runtime-gdb.py

3 调试程序:

gdb main

在这里插入图片描述

使用info files来查看文件

可以看到程序的入口为0x45c020, 在该处打上端点,可以看到入口为_rt0_amd64_linux的函数它位于src/runtime/rt0_liunx_amd64.s的汇编文件中:

TEXT _rt0_amd64_linux(SB),NOSPLIT,$-8
	JMP	_rt0_amd64(SB)

而该函数又调用了_rt0_amd64,在asm_amd64.s文件中:

TEXT _rt0_amd64(SB),NOSPLIT,$-8
	MOVQ	0(SP), DI	// argc
	LEAQ	8(SP), SI	// argv
	JMP	runtime·rt0_go(SB)

进而又跳转到了rt0_go中:

TEXT runtime·rt0_go(SB),NOSPLIT|TOPFRAME,$0
	...
	MOVL	24(SP), AX		// copy argc
	MOVL	AX, 0(SP)
	MOVQ	32(SP), AX		// copy argv
	MOVQ	AX, 8(SP)
	CALL	runtime·args(SB)
	CALL	runtime·osinit(SB)
	CALL	runtime·schedinit(SB)

	// create a new goroutine to start program
	MOVQ	$runtime·mainPC(SB), AX		// entry
	PUSHQ	AX
	CALL	runtime·newproc(SB)
	POPQ	AX

	// start this M
	CALL	runtime·mstart(SB)

	CALL	runtime·abort(SB)	// mstart should never return
	RET
...

// mainPC is a function value for runtime.main, to be passed to newproc.
// The reference to runtime.main is made via ABIInternal, since the
// actual function (not the ABI0 wrapper) is needed by newproc.
DATA	runtime·mainPC+0(SB)/8,$runtime·main<ABIInternal>(SB)
GLOBL	runtime·mainPC(SB),RODATA,$8

rt0_go中先是进行了一些初始化,比如runtime.osinit, runtime.schedinit

然后将runtime.mainPC的地址放入AX寄存器中,然后调用了runtime.newproc,根据下面的注释可以知道,mainPC就是runtime.main,而newproc则是创建goroutine的函数。

我们通常在程序中使用 go func()来启动一个协程,这是在go语言提供的一个语法糖,在编译时它会被翻译为newproc的调用。因此,下面的几行代码则是创建了runtime.maingoroutine,也就是主goroutine,主goroutine被创建后,只是被放入了当前p的本地队列,但是还没有得到运行。

在这里插入图片描述

接下来调用了runtime.mstart, 这个函数是除了sysmon线程以外的其它线程的入口函数,最终该函数会调用schedule函数,在schedule函数中调用findrunnable函数来获取一个可运行的goroutine,然后调用execute来执行,executegoroutine对应的g结构体中的字段进行一些设置,然后调用gogo来切换协程栈,并切换协程,因此main goroutine将会被调度执行。

如下图所示:

在这里插入图片描述

 

2 GMP模型

GMP模型是go语言goroutine的调度系统,调度是将goroutine调度到线程上执行的过程,而操作系统的调度器则负责将线程调度到CPU上运行。

2.1 GM模型

go语言早期的调度模型为GM模型,G代表goroutine,而M代表一个线程,goroutine和线程的数量有多个,那么调度器的职责就是将mgoroutine调度到n个线程上来运行。待调度的goroutine处于一个全局的调度队列globrunq中,每个线程需要从globrunq中获取goroutine来执行,那么多个线程同时访问全局队列,为了保证线程间的同步,需要加锁,那么就会导致锁争用较大,从而降低系统的效率。

在这里插入图片描述

而且一个goroutine创建的goroutine也会被放入全局队列中,同时也需要加锁。这样也会造成程序的局部性较差,因为一个goroutine创建的另一个goroutine大概率不会在同一个线程上运行。

 

2.2 改进的GMP模型

为了改进之前的缺点:1 所有线程都从全局队列获取goroutine,造成锁争用强度大。2. 程序的局部性较差

go语言引入了GMP模型,G同样代表一个goroutine,M代表machine,也就是worker thread,p代表processor,包含了运行go代码所需的资源。

官方解释:

// Goroutine scheduler
// The scheduler's job is to distribute ready-to-run goroutines over worker threads.
//
// The main concepts are:
// G - goroutine.
// M - worker thread, or machine.
// P - processor, a resource that is required to execute Go code.
//     M must have an associated P to execute Go code, however it can be
//     blocked or in a syscall w/o an associated P.

线程是goroutine运行的载体,goroutine必须要在线程上运行。而一个线程想要运行goroutine,就需要和一个p进行关联,在每个p中都包含了一个本地runq,其中存放待运行的goroutine,线程可以从本地runq中无锁访问,减少了锁竞争的力度。本地runq的大小是有限的,最多可以存放256goroutine。除此之外,还存在一个全局的globrunq,当创建goroutine时,优先放入相关联的p的本地runq,当本地runq满了之后,新创建的goroutine就会被添加到全局globrunq中。

  • p的数量:p代表了一个逻辑处理器,p的数量一般与CPU的核心数相同,代表了可以并行运行的goroutine的数量,可以通过runtime.GOMAXPROC来设置。
  • m的数量:m表示一个线程,m的数量是不确定的,最大数量为10000个,但是正常情况下达不到这么大的数量。

在这里插入图片描述

 

2.3 相关数据结构

2.3.1 runtime.g

goroutine在runtime中表示为一个g结构体:

type g struct {
	stack       stack   // offset known to runtime/cgo
	stackguard0 uintptr // offset known to liblink

	...
	m         *m      // current m; offset known to arm liblink
	sched     gobuf
	...
	atomicstatus atomic.Uint32
	
	goid         uint64
	
	preempt       bool // preemption signal, duplicates stackguard0 = stackpreempt
}

type stack struct {
	lo uintptr
	hi uintptr
}

type gobuf struct {
	sp   uintptr
	pc   uintptr
	g    guintptr
	...
}

省略了一些不太关心的字段,其中的一些字段的含义如下:

字段用途
stackgoroutine的栈空间,表示栈空间的一个界限
stackguard0栈的上限,它的值一般是stack.lo+StackGuard,用于判断是否需要栈增长。由于goroutine的栈在初始化只有2K,并且是可以动态增长的,因此在函数调用时会判断栈空间是否够用,如果不够用会进行扩容。同时该字段可能会被设置为StackPreempt来表示抢占当前goroutine。
m关联到当前正在运行goroutine的m
sched保存gouroutine的执行上下文,比如栈指针sp,程序计数器pc
atomicstatus一个原子变量,表示goroutine的当前状态
goidgoroutine的id,goroutine的id由p进行分配,p会从全局缓存处取一批id缓存起来
preempt抢占标识,为true时,调度器会在合适的时机触发一次抢占

goroutine是一个有栈协程,stack字段用于描述协程的栈,goroutine的初始栈大小为2K,并且是从堆中分配的,是可以动态增长的。

sched用来存储goroutine执行的上下文,它与goroutine切换的底层实现相关,其中sp标识stack pointer,pc为program counter,g用来反向关联到当前g。

 

g的状态:

atomicstatus字段表示goroutine的状态,goroutine有多种状态:

状态含义
_Gidle当前goroutine刚被分配,还没有被初始化
_Grunnable当前goroutine处于待运行状态,他可能处于p的本地runq或者globrunq中,当前并没有在运行用户代码,它的栈也不归自己所有。
_Grunning当前goroutine正在运行用户代码,有关联的M和P。不会处于任何runq中,栈归该goroutine所有。
_Gsyscall当前goroutine正在执行系统调用,并没有在执行用户代码,拥有栈,而且被分配了M。
_Gwaiting当前goroutine处于阻塞状态,即不再runq中,也没有得到运行。它肯定被记录在某个地方,比如chan的阻塞队列、mutex的阻塞队列中。
_Gdead当前goroutine没有在使用,可能存在一个free list中或者刚刚被初始化。
_Gcopystack当前goroutine的栈正在被移动,没有在执行用户代码也不在一个runq中。

 

2.3.2 runtime.m

GMP中的M代表一个工作线程,在runtime中使用m结构体来表示:

type m struct {
	g0      *g     // goroutine with scheduling stack
	gsignal       *g                // signal-handling g	
	curg          *g       // current running goroutine	
	p             puintptr // attached p for executing go code (nil if not executing go code)	
	id            int64	
	preemptoff    string // if != "", keep curg running on this m
	locks         int32
	spinning      bool // m is out of work and is actively looking for work
	mOS
}

省略了其中一些不太关心的字段,其中一些字段的含义如下:

字段用途
g0每个工作线程都拥有一个g0,它的栈比普通线程的栈要大,是分配在线程栈上的。g0主要用来运行调度器代码,当需要调度新的协程运行时,就会切换到g0栈上来运行调度程序。
gsignal用来处理操作系统信号的goroutine
curg指向当前正在运行的g
p关联到的p
id线程的唯一ID
preemptoff不为空时表示要关闭对curg的抢占,字符串的内容给出了相关的原因
locks当前M持有锁的数量
spining表示当前线程处于自旋状态
mOS平台相关的线程

 

2.3.3 runtime.p

GMP中的p代表processor,其中包含了一系列用于运行goroutine的资源,比如本地runq、堆内存缓存、栈内存缓存、goroutine id缓存等,在runtime中使用p结构体表示:

type p struct {
	id          int32
	status      uint32 // one of pidle/prunning/...

	schedtick   uint32     // incremented on every scheduler call
	syscalltick uint32     // incremented on every system call
	sysmontick  sysmontick // last tick observed by sysmon
	m           muintptr   // back-link to associated m (nil if idle)
    
	// Cache of goroutine ids, amortizes accesses to runtime·sched.goidgen.
	goidcache    uint64
	goidcacheend uint64

	// Queue of runnable goroutines. Accessed without lock.
	runqhead uint32
	runqtail uint32
	runq     [256]guintptr
	
	runnext guintptr

	// Available G's (status == Gdead)
	gFree struct {
		gList
		n int32
	}

	// preempt is set to indicate that this P should be enter the
	// scheduler ASAP (regardless of what G is running on it).
	preempt bool
}

其中省略了一些不太关心的字段,一些字段的含义如下:

字段用途
idp的唯一ID,等于在allp数组中的下标
status表示p的状态
schedtick记录了调度发生的次数,每调度一次goroutine并且不继承时间片的情况下,将该字段加1
syscalltick记录发生系统调用的次数
sysmontick被监控线程用来存储上一次检查时的调度器时钟滴答,用以实现时间片算法
m当前关联的m
goidcache、goidcacheendgoroutine id缓存,会从全局缓存中申请一批来减少锁争用
runqhead、runqtail、runq本地goroutine运行队列,使用一个数组和一头一尾组成一个环形队列
runnext如果不为nil,则指向一个被当前G准备好的就绪的G,接下来会继承当前G的时间片开始运行。
gFree用来缓存已经推出的g,方便下次申请时复用
preempt该字段用于支持异步抢占机制
p的状态:
状态含义
_Pidle当前p处于空闲状态,没有被用于执行用户代码或调度。p处于idle list中,它的本地runq是空的
_Prunning当前p与一个m进行关联并且被用于执行用户代码或者调度
_Psyscall当前p没有在运行用于代码,它与系统调用中的M有亲和关系,但不属于它,并且可能被另一个M窃取。这类似于_Pidle,但使用轻量级转换并维护M亲和关系。
_Pgcstop当前p因为STW而停止
_Pdead停用状态,因为GOMAXPROC可用收缩,会造成多余的p被停用。一旦GOMAXPROC重新增长,那么停用的p会被重新启用。

 

2.3.4 runtime.schedt

还有另一个和调度相关的数据结构需要关注,就是runtime.schedt,其中包含了调度的一些全局数据,schedt类型的实例只会存在一个:

var (
	allm       *m        // 所有m组成一个链表
	gomaxprocs int32     // 对应与GOMAXPROC
	ncpu       int32     // CPU核心数
	
	sched      schedt   // 调度器相关的数据结构
	


	allpLock mutex     // 保护allp的锁
	allp []*p          // 所有的p
)

schedt结构如下:

其中全局runq就存在与schedt结构中

type schedt struct {
	goidgen   atomic.Uint64    
	
	midle        muintptr // idle m's waiting for work
	nmidle       int32    // number of idle m's waiting for work
	mnext        int64    // number of m's that have been created and next M ID
	maxmcount    int32    // maximum number of m's allowed (or die)
	nmsys        int32    // number of system m's not counted for deadlock
	nmfreed      int64    // cumulative number of freed m's

	ngsys atomic.Int32 // number of system goroutines

	pidle        puintptr // idle p's
	npidle       atomic.Int32
	nmspinning   atomic.Int32  // See "Worker thread parking/unparking" comment in proc.go.

	// Global runnable queue.
	runq     gQueue
	runqsize int32

	// Global cache of dead G's.
	gFree struct {
		lock    mutex
		stack   gList // Gs with stacks
		noStack gList // Gs without stacks
		n       int32
	}
}
字段用途
goidgen全局goid的分配器,以保证goid的唯一性。P中的goidcache就是从这里批量获取的。
midle空闲M链表的链表头
nmidle空闲M的数量
mnext记录共创建了多少个M,同时也被用于下一个M的ID
maxmcount允许创建的M的最大数量
nmsys系统M的数量
nmfreed统计已经释放的M的数量
ngsys系统goroutine的数量
pidle空闲P链表的表头
npidle空闲P的数量
nmspining处于自旋状态的M的数量
qunq、runqsize全局就绪goroutine队列,需要加锁访问
gFree用来缓存已经退出的g

 

2.4 g0、m0

在每个工作线程M中都存在一个g0,g0的主要功能就是执行调度程序,当需要执行调度程序时会将运行栈切换的g0栈,然后运行调度程序来寻找一个就绪的goroutine并切换运行。

有两个函数可用切换到g0栈来运行:

func mcall(fn func(*g))

func systemstack(fn func())
  • mcall:将调用mcall的协程栈切换的g0栈并且在g0栈上运行fn,mcall仅可以被除了g0、gsingal之外的g调用。
  • systemstack:在系统栈上运行fn,然后再切换回来。

 

m0为进程的第一个线程,也就是运行main goroutine的线程

 

3 G的创建与退出

我们再程序中通常使用下面的方式来创建一个goroutine:

go func()

这只是go语言为我们提供的一个语法糖,事实上,在编译时,该方式会被翻译为对runtime.newproc函数的调用,newproc用于创建一个新的goroutine,并将其添加到就绪队列中。

我们可用通过将代码编译为汇编来查看go func()是怎么执行的:

将下面的示例代码编译为汇编程序:

go build -gcflags -S main.go

package main

import (
	"fmt"
	"time"
)

func print() {
	fmt.Println("hello, GMP")
	time.Sleep(time.Second)
}

func main() {
	go print()

	select {}
}

汇编代码如下:

"".main STEXT size=50 args=0x0 locals=0x10 funcid=0x0 align=0x0
        0x0000 00000 (/root/RemoteWorking/main.go:13)   TEXT    "".main(SB), ABIInternal, $16-0
        0x0000 00000 (/root/RemoteWorking/main.go:13)   CMPQ    SP, 16(R14)
        0x0004 00004 (/root/RemoteWorking/main.go:13)   PCDATA  $0, $-2
        0x0004 00004 (/root/RemoteWorking/main.go:13)   JLS     43
        0x0006 00006 (/root/RemoteWorking/main.go:13)   PCDATA  $0, $-1
        0x0006 00006 (/root/RemoteWorking/main.go:13)   SUBQ    $16, SP
        0x000a 00010 (/root/RemoteWorking/main.go:13)   MOVQ    BP, 8(SP)
        0x000f 00015 (/root/RemoteWorking/main.go:13)   LEAQ    8(SP), BP
        0x0014 00020 (/root/RemoteWorking/main.go:13)   FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0014 00020 (/root/RemoteWorking/main.go:13)   FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0014 00020 (/root/RemoteWorking/main.go:14)   LEAQ    "".print·f(SB), AX
        0x001b 00027 (/root/RemoteWorking/main.go:14)   PCDATA  $1, $0
        0x001b 00027 (/root/RemoteWorking/main.go:14)   NOP
        0x0020 00032 (/root/RemoteWorking/main.go:14)   CALL    runtime.newproc(SB)

可以看到,print函数的地址被保存在了AX寄存器中,然后调用了runtime.newproc函数

runtime.newproc代码如下

func newproc(fn *funcval) {
	gp := getg()          // 获取当前g
	pc := getcallerpc() 
	systemstack(func() {
		newg := newproc1(fn, gp, pc)     // 创建一个新的goroutine
 
		pp := getg().m.p.ptr()           // 获取当前g运行的m关联的p
		runqput(pp, newg, true)          // 将新的goroutine加入到就绪队列中

		if mainStarted {
			wakep()                     // 唤醒新的p
		}
	})
}

runqput会优先将新创建的goroutine放入当前prunnext中。如果runnext已经有goroutine了,则会将旧的goroutine放入本地队列中,如果本地队列满了,那么则会将旧的goroutine以及本地队列一半的goroutine放入全局队列中。

newproc的主要逻辑就是创建了一个新的g,并将其放入当前g运行的m关联的p的本地runq中。

newproc1的代码如下:

func newproc1(fn *funcval, callergp *g, callerpc uintptr) *g {
	...
	mp := acquirem() // 禁止抢占
	pp := mp.p.ptr()    // 获取当前m关联的p
	newg := gfget(pp)   // 从p的缓存中获取一个g
	if newg == nil {
		newg = malg(_StackMin)    // 如果从缓存中获取不到,则新创建一个,_StackMin的值为2048,也就是2K
		casgstatus(newg, _Gidle, _Gdead)
		allgadd(newg) // publishes with a g->status of Gdead so GC scanner doesn't look at uninitialized stack.
	}
	...

	// goexit函数被放在了pc上,gostartcallfn会对其进行特殊处理
	newg.sched.pc = abi.FuncPCABI0(goexit) + sys.PCQuantum // +PCQuantum so that previous instruction is in same function
	newg.sched.g = guintptr(unsafe.Pointer(newg))
	gostartcallfn(&newg.sched, fn)
	newg.gopc = callerpc
	newg.ancestors = saveAncestors(callergp)
	newg.startpc = fn.fn
	...
	casgstatus(newg, _Gdead, _Grunnable)    // 改变g的状态
	
	newg.goid = pp.goidcache  // 分配goid
	pp.goidcache++
	
	releasem(mp)

	return newg
}

func gostartcallfn(gobuf *gobuf, fv *funcval) {
	var fn unsafe.Pointer
	if fv != nil {
		fn = unsafe.Pointer(fv.fn)
	} else {
		fn = unsafe.Pointer(abi.FuncPCABIInternal(nilfunc))
	}
	gostartcall(gobuf, fn, unsafe.Pointer(fv))
}

func gostartcall(buf *gobuf, fn, ctxt unsafe.Pointer) {
	sp := buf.sp
	sp -= goarch.PtrSize
	*(*uintptr)(unsafe.Pointer(sp)) = buf.pc   // 在goroutine的栈帧中插入了goexit函数
	buf.sp = sp
	buf.pc = uintptr(fn)
	buf.ctxt = ctxt
}

在newproc1中获取了一个g实例,对其中的字段进行了设置,为其分配id,并修改状态为*_Grunnable*。特别需要注意的时,在gostartcall函数中,往goroutine的栈帧中插入了一个goexit函数,因此当goroutine从运行的函数退出时,就会返回到goexit函数中。

使用goland调试go程序时,可以从调用栈中查看到runtime.goexit函数,仿佛是runtime.goexit函数调用了runtime.main,而runtime.main又调用了main.main函数

在这里插入图片描述

而runtime.goexit是一段使用汇编实现的代码:

TEXT runtime·goexit(SB),NOSPLIT|TOPFRAME,$0-0
	BYTE	$0x90	// NOP
	CALL	runtime·goexit1(SB)	// does not return
	// traceback from goexit1 must hit code range of goexit
	BYTE	$0x90	// NOP

而其中又调用了runtime.goexit1函数:

func goexit1() {
	mcall(goexit0)
}
func goexit0(gp *g) {
    // 重置g的状态
	mp := getg().m
	pp := mp.p.ptr()

	casgstatus(gp, _Grunning, _Gdead)
	gcController.addScannableStack(pp, -int64(gp.stack.hi-gp.stack.lo))
	if isSystemGoroutine(gp, false) {
		sched.ngsys.Add(-1)
	}
	gp.m = nil
	locked := gp.lockedm != 0
	gp.lockedm = 0
	mp.lockedg = 0
	gp.preemptStop = false
	gp.paniconfault = false
	gp._defer = nil // should be true already but just in case.
	gp._panic = nil // non-nil for Goexit during panic. points at stack-allocated data.
	gp.writebuf = nil
	gp.waitreason = waitReasonZero
	gp.param = nil
	gp.labels = nil
	gp.timer = nil

	dropg()    // 将当前g从m移除
  
	gfput(pp, gp)  //将g放入p的gFreelist中
	
	schedule()   // 触发新一轮的调度
}

从runtime.goexit到runtime.goexit1,最终到runtime.goexit0函数中,对g的状态进行了重置,然后将g从m中移除,放入p的gFree List中,,以便后续重用。然后调用了scheduler函数,scheduler函数正是调度的入口,如此一来便形成了一个闭环。

 

总结:当我们使用go func()来启动一个goroutine时,实际上是调用了newproc函数来创建一个新的goroutine,然后将新的goroutine加入到当前m关联的p的本地队列中。后续,goroutine会得到调度运行,当goroutine运行结束后,会进入goexit函数中,对该g进行回收,然后调用schedule触发新一轮的调度。

 

4 调度循环

go的调度器会不断调度goroutine到线程上运行,当一个goroutine结束运行、发生阻塞、主动让出、或者时间片用尽时就会触发新一轮的调度,重新选择一个goroutine来运行。整个流程如下:

在这里插入图片描述

  • mstart:mstart是工作线程的入口函数,最终会触发schedule来进行goroutine的调度
  • schedule:是调度循环的开始,寻找可运行的g,并调用execute运行
  • execute:对g的状态进行设置,调用gogo来切换goroutine
  • user code:用户代码,在执行用户代码时有多种方式会触发重新调度,比如1.用户代码执行完毕,通过goexit退出到schedule函数中;2.用户代码发生阻塞(操作chan、获取锁、读取网络数据等),发生阻塞时会调用gopark来切换goroutine,最终也会进入schedule函数中;3.runtime.Gosched,用户主动调用Gosched让出当前goroutine的执行权。

 

4.1 runtime.schedule

工作线程通过schedule函数来触发一次调度,该函数是调度逻辑的主要实现,schedule函数会在g0栈上运行。

代码如下:

func schedule() {
	mp := getg().m
	
    // 线程持有锁时,不能进行调度,以免造成runtime内部错误
	if mp.locks != 0 {       
		throw("schedule: holding locks")
	}
	
    // 判断当前M有没有和G绑定,如果有,这个M就不能用来执行其它的G
	if mp.lockedg != 0 {
		stoplockedm()
		execute(mp.lockedg.ptr(), false) // Never returns.
	}

	// 判断是否在进行cgo调用,如果在就不能进行调度,因为g0栈正在被cgo使用
	if mp.incgo {
		throw("schedule: in cgo")
	}

top:
	pp := mp.p.ptr()        // 获取当前m关联的p
	pp.preempt = false

	if mp.spinning && (pp.runnext != 0 || pp.runqhead != pp.runqtail) {
		throw("schedule: spinning with local work")
	}
	
    // 寻找一个可运行的g,阻塞直到找到
	gp, inheritTime, tryWakeP := findRunnable() // blocks until work is available

	// 如果当前线程正在自旋寻找新的工作,因为已经找到工作了,重置自旋状态
	if mp.spinning {
		resetspinning()
	}

	...

	// If about to schedule a not-normal goroutine (a GCworker or tracereader),
	// wake a P if there is one.
	if tryWakeP {
		wakep()
	}
	if gp.lockedm != 0 {
		// Hands off own p to the locked m,
		// then blocks waiting for a new p.
		startlockedm(gp)
		goto top
	}
	
    // 调用execute来运行g
	execute(gp, inheritTime)
}

schedule中,首先是进行一些检测,比如在线程持有锁时,不能进行调度;如果当前线程一个G进行了绑定,那么就不能用于调度其它的G运行。同时判断是否在进行cgo调用,在执行cgo调用时会使用g0栈,因此也不可以进行调度。

接下来就是调用findrunnable函数来寻找一个可运行的g,最终调用execute来运行该g

 

4.2 runtime.findrunnable

findrunnable的主要逻辑就是寻找一个处于_Runnable状态的goroutine

在这里插入图片描述

首先,如果启动了trace,则会获取trace reader

func findRunnable() (gp *g, inheritTime, tryWakeP bool) {
	mp := getg().m

	// The conditions here and in handoffp must agree: if
	// findrunnable would return a G to run, handoffp must start
	// an M.
	...
	// Try to schedule the trace reader.
	if trace.enabled || trace.shutdown {
		gp := traceReader()
		if gp != nil {
			casgstatus(gp, _Gwaiting, _Grunnable)
			traceGoUnpark(gp, 0)
			return gp, false, true
		}
	}
	...
}

如果没有启动trace或者获取不到trace reader,则会查询gc的标记工作是否启动,如果启动了,则尝试获取一个GC Worker来执行标记任务。

// Try to schedule a GC worker.
	if gcBlackenEnabled != 0 {
		gp, tnow := gcController.findRunnableGCWorker(pp, now)    // 尝试获取一个GC Worker
		if gp != nil {
			return gp, false, true
		}
		now = tnow
	}

为了保证两个goroutine交替执行,从而导致全局队列中的goroutine饥饿的问题,每进行61次调度,就会从全局队列中取出一个goroutine来运行,p.schedtick记录了调度的次数。

	// Check the global runnable queue once in a while to ensure fairness.
	// Otherwise two goroutines can completely occupy the local runqueue
	// by constantly respawning each other.
	if pp.schedtick%61 == 0 && sched.runqsize > 0 {
		lock(&sched.lock)
		gp := globrunqget(pp, 1)   // 每执行61次调度,就从全局队列中获取一个goroutine来运行    
		unlock(&sched.lock)
		if gp != nil {
			return gp, false, false
		}
	}

如果还没获取到,则从p的本地runq中获取一个goroutine

// local runq
	if gp, inheritTime := runqget(pp); gp != nil {       // 从p的本地runq中获取
		return gp, inheritTime, false
	}

如果p的本地runq中也没有可运行的goroutine,那么则会从全局队列中取出一批goroutine。因为访问全局runq需要加锁,因此会从中获取一批goroutine并加入到p的本地runq中。

// global runq
	if sched.runqsize != 0 {
		lock(&sched.lock)
		gp := globrunqget(pp, 0)
		unlock(&sched.lock)
		if gp != nil {
			return gp, false, false
		}
	}

如果全局队列中也没有可运行的goroutine,就会尝试使用netpoller来轮询网络,从而获取可运行的goroutine

// 如果netpoller启动了,并且其中管理的fd数量大于0,调用netpoll来轮询网络,以此来获取在网络中就绪的goroutine
if netpollinited() && netpollWaiters.Load() > 0 && sched.lastpoll.Load() != 0 {
		if list := netpoll(0); !list.empty() { // non-blocking
			gp := list.pop()        // 获取到一批goroutine,组成一个链表,获取链表头第一个
			injectglist(&list)      // 将其它goroutine放入本地runq中
			casgstatus(gp, _Gwaiting, _Grunnable)
			if trace.enabled {
				traceGoUnpark(gp, 0)
			}
			return gp, false, false
		}
	}

如果上面的方法都无法获取一个待运行goroutine,则会选择从其它P的本地runq中偷取一批goroutine。为了充分利用多核cpu的并行性,并不会将当前线程挂起,而是尝试从其它P那偷取工作,防止P忙的忙死、闲的闲死。任务窃取会循环尝试四次,从allp中选择一个P,并从其中窃取工作,每次任务窃取都会随机选择一个下标开始窃取,以保证公平性。

if mp.spinning || 2*sched.nmspinning.Load() < gomaxprocs-sched.npidle.Load() {
		if !mp.spinning {
			mp.becomeSpinning()
		}

		gp, inheritTime, tnow, w, newWork := stealWork(now)      // 从其它P那偷取工作
		if gp != nil {
			// Successfully stole.
			return gp, inheritTime, false
		}
		if newWork {
			// There may be new timer or GC work; restart to
			// discover.
			goto top
		}

		now = tnow
		if w != 0 && (pollUntil == 0 || w < pollUntil) {
			// Earlier timer to wait for.
			pollUntil = w
		}
	}

 

4.3 runtime.execute、runtime.gogo

runtime.execute中将要执行寻找到的g,设置g的状态,最后调用gogo来切换goroutine运行。gogo是一段由汇编实现的代码,主要逻辑就是切换协程栈,恢复选中的goroutine的执行。

func execute(gp *g, inheritTime bool) {
	mp := getg().m

	...

	mp.curg = gp  // 设置当前运行的g
	gp.m = mp     // 关联当前的m 
	casgstatus(gp, _Grunnable, _Grunning)   // 将当前g的状态切换为_Grunning
	gp.waitsince = 0
	gp.preempt = false
	gp.stackguard0 = gp.stack.lo + _StackGuard
	if !inheritTime {
		mp.p.ptr().schedtick++
	}

	...
    
	gogo(&gp.sched)       // 调用gogo来切换协程
}

 

4.4 runtime.gopark、runtime.goready

gopark将当前正在运行的goroutine挂起(状态为_Gwaiting),并触发新一轮的调度:

func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason waitReason, traceEv byte, traceskip int) {
	...
	// can't do anything that might move the G between Ms here.
	mcall(park_m)
}

func park_m(gp *g) {
	mp := getg().m

	if trace.enabled {
		traceGoPark(mp.waittraceev, mp.waittraceskip)
	}

	// 修改g的状态为Gwaiting
	casgstatus(gp, _Grunning, _Gwaiting)
	dropg()

	if fn := mp.waitunlockf; fn != nil {
		ok := fn(gp, mp.waitlock)
		mp.waitunlockf = nil
		mp.waitlock = nil
		if !ok {
			if trace.enabled {
				traceGoUnpark(gp, 2)
			}
			casgstatus(gp, _Gwaiting, _Grunnable)
			execute(gp, true) // Schedule it back, never returns.
		}
	}
    // 触发新一轮的调度
	schedule()
}

goroutine发生阻塞时,通常会调用gopark来触发调度。比如,当读取一个空的chan时,goroutine就会被放入chan的读阻塞队列中,然后调用gopark来切换协程。

goready通常用来唤醒一个协程,比如,当另一个协程往chan中写入数据时,就需要负责唤醒读阻塞的协程。goready通过systemstack切换到g0栈并运行ready,在ready中将goroutine的状态切换为_Grunnable并且添加到runq中,该goroutine后面便会得到调度执行。

func goready(gp *g, traceskip int) {
	systemstack(func() {
		ready(gp, traceskip, true)
	})
}

func ready(gp *g, traceskip int, next bool) {
	status := readgstatus(gp)

	// Mark runnable.
	mp := acquirem() // disable preemption because it can be holding p in a local var
	if status&^_Gscan != _Gwaiting {
		dumpgstatus(gp)
		throw("bad g->status in ready")
	}

	// 将g的状态切换为_Grunnable
	casgstatus(gp, _Gwaiting, _Grunnable)
    // 将g添加到runq中
	runqput(mp.p.ptr(), gp, next)
    // 唤醒新的p
	wakep()
	releasem(mp)
}

 

4.5 work stealing和handoff机制

work stealing

当一个线程没有可用的工作并且从全局队列中也找不到时,该线程并不会立马陷入休眠或者被销毁,而且尝试从其它P中窃取一部分的工作来运行。

handoff

当一个goroutine处于系统调用时,可能会导致整个线程发生阻塞。为了充分利用多核CPU,当前P会与M进行解绑,并且寻找或创建一个新的M来运行工作。

 

5 抢占式调度

就像操作系统的调度器负责线程的调度一样,go的调度器负责goroutine的调度。现代操作系统调度器都是抢占式的,基于经典的时间片算法。当一个线程的时间片用完后,便会触发时钟中断,调度器将其执行的上下文进行保存,然后选择下一个线程,恢复其执行上下文,分配新的时间片,令其开始执行。这种抢占式对于线程本身是无感知的,由操作系统提供支持。

基于时间片算法的调度有一个明显的优点,能够避免一个线程持续占有CPU资源,从而使其它线程长期处于饥饿状态。goroutine的调度也用到了时间片算法(每个goroutine 10ms),但是和操作系统的调度还是有明显区别的。因为go的调度程序完全工作在用户态,无法使用时钟中断这种方式来触发调度。也得益于用户态的实现,go的调度器要更加轻量。

除了goroutine退出、阻塞以及用户调用Gosched主动让出的情况,go调度器的抢占式调度实际上是通过hook的方式来实现的。由于goroutine的栈比较小,因此在调用函数时,需要检测栈是否够用,如果不够用,则会触发栈增长。因此,go程序在编译时,会在发生函数调用处插入栈检测的相关代码,而在栈检测的代码中也包含了抢占调度的逻辑。

编译器会在发生函数调用之前插入runtime.morestackruntime.morestack_noctxt函数:

在这里插入图片描述

这两个函数是由汇编实现的,最终它们都会调用runtime.newstack函数:

抢占相关的代码如下:

func newstack() {
    ...
    // 加载 stackguard0
    stackguard0 := atomic.Loaduintptr(&gp.stackguard0)
	
    // 判断stackguard0是否被标记为了抢占
	preempt := stackguard0 == stackPreempt
    
    if preempt {
		...

		// 触发抢占
		gopreempt_m(gp) // never return
	}
}

func gopreempt_m(gp *g) {
	...
	goschedImpl(gp)
}

func goschedImpl(gp *g) {
	status := readgstatus(gp)
	if status&^_Gscan != _Grunning {
		dumpgstatus(gp)
		throw("bad g status")
	}
	casgstatus(gp, _Grunning, _Grunnable)
	dropg()
	lock(&sched.lock)
	globrunqput(gp)
	unlock(&sched.lock)
	
    // 触发新的一轮调度
	schedule()
}

newstack中会判断stackguard0是否被设置为了抢占标识,也就是stackPreempt,值为0xfffffade,是一个很大的数,正常的栈不会达到该值。

系统监控线程会检测goroutine的运行时间,一旦一个goroutine的运行时间超过了其分配的时间片,就会将当前goroutine设置为抢占。因此当goroutine发生函数调用前,会进入编译器插入的morestack函数,然后进行抢占判断,如果该goroutine被标识为抢占,则最终会进入schedule函数触发一次新的调度。

这种方式似乎看起来很完美,但是存在一个问题。就是如果一个goroutine不发生函数调用,也不会阻塞更不会主动让出,比如一个无限循环的计算任务,那么它就会一直占用CPU,甚至会导致程序卡死。

 

5.1 异步抢占

go1.13版本前,只有上面的抢占方式,因此可能会导致一些问题,接下来做一个实验,需要的go版本为小于go1.13

package main

import "fmt"

func fn(i int) {
	for {
		i++
		fmt.Println(i)
	}
}

func main() {
	go fn(0)

	for {
	}
}

程序启动后,会一直打印数字,但是在我机器上打印到15万多的时候就会停止,整个程序卡死了。

在这里插入图片描述

在fn函数中会调用fmt.Println函数打印数组,而在该函数中会进行内存分配,当内存分配到一定量就会触发GC。开始GC前需要STW(Stop The World)因此需要对所有的线程进行抢占,但是main goroutien没有发生函数调用,也就无法对其抢占,因此造成了死锁

go1.14版本后引入了异步抢占机制,这样的事情就不会发生了。

那么只要有一种机制可以让没有发生函数调用的goroutine可以被打断,跳转到一个函数中去,那么不就可以对其进行抢占了吗。

异步抢占的主要机制是基于操作系统信号,当系统监控线程检测到一个goroutine执行了过长的时间,就会发出异步抢占,也就是给该goroutine所在线程发送一个信号,线程收到信号后就会跳转到提前注册的信号处理函数sigHandler中,从而就可以对该goroutine进行抢占了。

用作抢占的信号正是SIGURG,这个信号很少使用,可以用来实现基于信号的异步抢占。

信号处理函数sigHandler中抢占机制的代码如下:

func sighandler(sig uint32, info *siginfo, ctxt unsafe.Pointer, gp *g) {
	...
	// 如果当前信号是sigPreempt 而且 开启了异步抢占
	if sig == sigPreempt && debug.asyncpreemptoff == 0 && !delayedSignal {
		//进行抢占
		doSigPreempt(gp, c)
		
	}
	...
}

doSigPreempt会在goroutine的栈中注入一个asyncPreempt的调用,因此当信号处理函数返回,重新回到goroutine中时就会执行asyncPreempt函数

func doSigPreempt(gp *g, ctxt *sigctxt) {
	...
	if wantAsyncPreempt(gp) {
		if ok, newpc := isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()); ok {
			// Adjust the PC and inject a call to asyncPreempt.
			ctxt.pushCall(abi.FuncPCABI0(asyncPreempt), newpc)
		}
	}

	...
}

asyncPreempt是由汇编实现的,它又会调用asyncPreempt2,最终又会回到schedule中触发一次新的调度。

func asyncPreempt2() {
	...
		mcall(gopreempt_m)
	...
}

func gopreempt_m(gp *g) {
	...
	goschedImpl(gp)
}

func goschedImpl(gp *g) {
	...

	schedule()
}

 

6 系统监控线程sysmon

系统监控线程sysmon(system mointor)是一个特殊的线程,它会在go程序执行期间常驻,它不需要和P绑定就可以执行,它的工作主要是负责对系统的情况进行检测,并协调系统的运行。

系统监控线程会在runtime.main执行时被创建:

在这里插入图片描述

sysmon是这个线程的入口函数:

sysmon的主要任务如下:

  1. 死锁检测
  2. 轮询网络:当其他P都比较繁忙时,它会负责轮询网络,并将就绪的goroutine加入全局队列中
  3. 夺取处于系统调用中的P:当一个goroutine因为系统调用而阻塞时,sysmon会将线程绑定的P hand off出去,它可以寻找或创建一个新的M来继续运行
  4. 抢占长时间运行的G:当一个goroutine运行时间多长时,sysmon会将其标记为抢占。如果支持异步抢占,则会执行异步抢占
  5. 周期性触发GC:GC的时间周期为2min
func sysmon() {
	lock(&sched.lock)
	sched.nmsys++
    // 1.死锁检测
	checkdead()
	unlock(&sched.lock)

	...

	for {
        // 休眠一定时间
		if idle == 0 { // start with 20us sleep...
			delay = 20
		} else if idle > 50 { // start doubling the sleep after 1ms...
			delay *= 2
		}
		if delay > 10*1000 { // up to 10ms
			delay = 10 * 1000
		}
		usleep(delay)

		...
		
		// poll network if not polled for more than 10ms
        // 2.轮询网络如果截至上次已经超过10ms
		lastpoll := sched.lastpoll.Load()
		if netpollinited() && lastpoll != 0 && lastpoll+10*1000*1000 < now {
			sched.lastpoll.CompareAndSwap(lastpoll, now)
			list := netpoll(0) // non-blocking - returns list of goroutines
			if !list.empty() {
				incidlelocked(-1)
				injectglist(&list)
				incidlelocked(1)
			}
		}
		...
        
		// retake P's blocked in syscalls
		// and preempt long running G's
        // 3.夺回阻塞与系统调用中的P
        // 4.抢占长时间运行的G
		if retake(now) != 0 {
			idle = 0
		} else {
			idle++
		}
        
		// check if we need to force a GC
        // 5.周期性触发GC
		if t := (gcTrigger{kind: gcTriggerTime, now: now}); t.test() && forcegc.idle.Load() {
			lock(&forcegc.lock)
			forcegc.idle.Store(false)
			var list gList
			list.push(forcegc.g)
			injectglist(&list)
			unlock(&forcegc.lock)
		}
		if debug.schedtrace > 0 && lasttrace+int64(debug.schedtrace)*1000000 <= now {
			lasttrace = now
			schedtrace(debug.scheddetail > 0)
		}
		unlock(&sched.sysmonlock)
	}
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/699456.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

【话题达人】做开发时遇到过无理的需求吗?面对这些无理需求你是怎么做的?

导读 工作过程中难免遇见一些“神奇的甲方”&#xff0c;他们总是会给你提出一些匪夷所思甚至无厘头的需求。你是否也有这样的经历&#xff0c;面对这样“无理的需求”你又是怎么做的呢&#xff1f; 面对这些无理需求时你是怎么做的&#xff1f; 首先深入了解需求&#xff0…

C++一键安装工具(vcpkg)

0. 简介 相较于python而言&#xff0c;C因为其复杂的环境安装一直受到很多人的诟病&#xff0c;比如说一个pcl的安装就需要有很多操作步骤。译过程仍然复杂和多样化。当了解了这些还不够&#xff0c;我们还需要考虑预先编译出哪种类型的开源库程序。比如&#xff1a;Debug还是…

快速掌握MongoDB数据库(入门一条龙)

目录 一、介绍 二、安装指导 2.1 下载 2.2 安装注意事项 2.3 配置环境变量 2.4 校验是否配置成功 2.5 启动服务器 2.6 打开客户端 2.7 退出 2.8 修改参数配置 2.9 设置开启自启动服务 三、MongoDB基本操作 3.1 基本概念 3.2 基本命令 3.3 数据库的crud命令 四、…

七、云尚办公-Activiti入门

云尚办公系统&#xff1a;Activiti入门 B站直达【为尚硅谷点赞】: https://www.bilibili.com/video/BV1Ya411S7aT 本博文以课程相关为主发布&#xff0c;并且融入了自己的一些看法以及对学习过程中遇见的问题给出相关的解决方法。一起学习一起进步&#xff01;&#xff01;&am…

《C++高级编程》读书笔记(十三:C++ I/O揭秘)

1、参考引用 C高级编程&#xff08;第4版&#xff0c;C17标准&#xff09;马克葛瑞格尔 2、建议先看《21天学通C》 这本书入门&#xff0c;笔记链接如下 21天学通C读书笔记&#xff08;文章链接汇总&#xff09; 1. 使用流 1.1 流的含义 C 中预定义的流 缓冲的流和非缓冲的流…

SpringBoot:配置Jetty容器

&#x1f468;‍&#x1f393;作者&#xff1a;bug菌 ✏️博客&#xff1a; CSDN、 掘金、 infoQ、 51CTO等 &#x1f389;简介&#xff1a;CSDN、 掘金等社区优质创作者&#xff0c;全网合计7w粉&#xff0c;对一切技术都感兴趣&#xff0c;重心偏Java方向&#xff0c;目前运营…

c语言查漏补缺

例子一 #include<iostream> using namespace std;int main() {int a[5]{1,2,3,4,5};int* ptr (int*)(a1);printf("%d",*(ptr-1));return 0; }输出结果是&#xff1a;1&#xff0c;这个很好理解&#xff0c;数组名即数组的首地址&#xff0c;&#xff08;a1&a…

Android 13(T) - binder阅读(5)- 使用ServiceManager注册服务2

上一篇笔记我们看到了binder_transaction&#xff0c;这个方法很长&#xff0c;这一篇我们将把这个方法拆分开来看binder_transaction做了什么&#xff0c;从而学习binder是如何跨进程通信的。 1 binder_transaction static void binder_transaction(struct binder_proc *proc…

如何将自定义起步依赖打成包

说明&#xff1a;之前做过一个自定义的OSS起步依赖&#xff08;http://t.csdn.cn/9aYr5&#xff09;&#xff0c;但是当时只是新建了一个Demo模块来测试自定义起步依赖能成功使用&#xff0c;本文介绍如何把自定义的起步依赖打成jar包&#xff0c;供其他项目或其他人引入依赖就…

华为云CodeArts TestPlan测试设计:守护产品开发质量之魂

华为产品质量的守护神 华为云CodeArts TestPlan测试设计是华为产品质量的守护神。华为云CodeArts TestPlan提供多维度测试设计模板、“需求-场景-测试点-测试用例” 四层测试分解设计能力&#xff0c;启发测试人员发散性思维&#xff0c;对项目环境、测试对象、质量标准、测试…

【SpringBlade-权限缺陷】API鉴权逻辑缺陷漏洞

目录 一、理论部分 简介 如何通过认证 API 鉴权 配置API放行 细颗粒度鉴权配置 结尾 二、实战部分 一、理论部分 简介 Secure 基于 JWT 封装&#xff0c;每次请求的时候&#xff0c;会拦截到需要鉴权的API请求&#xff0c;并对其请求头携带的Token进行认证。若 Token…

Js时间倒计时

&#x1f607;作者介绍&#xff1a;一个有梦想、有理想、有目标的&#xff0c;且渴望能够学有所成的追梦人。 &#x1f386;学习格言&#xff1a;不读书的人,思想就会停止。——狄德罗 ⛪️个人主页&#xff1a;进入博主主页 &#x1f5fc;推荐系列&#xff1a;点击进入 &#…

vue 0-1搭建项目

vue 从0-1搭建项目 前提&#xff1a;进入到需要创建项目的文件夹中&#xff0c;打开命令行窗口 windowsr 打开命令行窗口 创建vue2.0项目&#xff1a; 自动创建 1.vue create 项目名称 仅包含3个功能&#xff1a;vue2/3,babel&#xff0c;eslint 手动创建 1.vue create 项目名…

ModaHub魔搭社区:详解向量数据库Milvus的Mishards:集群分片中间件(五)

目录 在 Kubernetes 中部署 Mishards 集群 安装前提 安装流程 卸载 Mishards 从单机升级到 Mishards 集群 注意事项 基本案例 在 Kubernetes 中部署 Mishards 集群 安装前提 Kubernetes 版本 1.10 及以上Helm 版本 2.12.0 及以上 关于 Helm 的使用请参考 Helm 使用指…

【python入门系列】第一章:Python基础语法和数据类型

文章目录 前言一、简单语法1. 注释 这是一个单行注释2. 变量 二、数据类型1.字符串2.整数3.浮点数4.布尔值5.列表 三、运算符1.算术运算符&#xff1a;用于执行基本的算术操作&#xff0c;如加、减、乘和除。2.比较运算符&#xff1a;用于比较两个值的大小或相等性。3.逻辑运算…

01.网络编程-基础概念

网络编程就是指编写互联网项目&#xff0c;项目可以通过网络传输数据进行通讯 网络编程最主要的工作就是在发送端把信息通过规定好的协议进行组装包&#xff0c;在接收端按照规定好的协议把包进行解析&#xff0c;从而提取出对应的信息&#xff0c;达到通信的目的 1.1 软件结构…

Oracle 11g安装配置完美教程 - Windows

写在前面&#xff1a;博主是一只经过实战开发历练后投身培训事业的“小山猪”&#xff0c;昵称取自动画片《狮子王》中的“彭彭”&#xff0c;总是以乐观、积极的心态对待周边的事物。本人的技术路线从Java全栈工程师一路奔向大数据开发、数据挖掘领域&#xff0c;如今终有小成…

用Java解决华为OD机试考题,目标300+真题,清单奉上,祝你上岸

华为OD机考大纲 其它语言版本华为 OD 机试题清单&#xff08;机试题库还在逐日更新&#xff09;详细大纲 其它语言版本 本目录为华为od机试JS题解目录&#xff0c;其它版本清单如下 ⭐️华为OD机考 Python https://blog.csdn.net/hihell/category_12199275.html ⭐️华为OD机考…

BOSHIDA DC电源模块低温试验检测详细分析

BOSHIDA DC电源模块低温试验检测详细分析 DC电源模块的低温试验是电源应用领域中的一项重要测试&#xff0c;它可以检测模块在低温环境下的性能表现是否与设计要求相符。这是因为在一些极端环境下&#xff0c;电源模块的性能会受到影响&#xff0c;从而影响整个系统的运行稳定…

山西电力市场日前价格预测【2023-06-30】

日前价格预测 预测明日&#xff08;2023-06-30&#xff09;山西电力市场全天平均日前电价为362.38元/MWh。其中&#xff0c;最高日前价格为477.68元/MWh&#xff0c;预计出现在21: 15。最低日前电价为247.28元/MWh&#xff0c;预计出现在13: 00。以上预测仅供学习参考&#xff…