欢迎来到皮皮网网首页

【throwable 源码解析】【doc文档源码】【颠云源码】goroutine源码大全

来源:探探鼠源码出售 时间:2024-11-24 16:55:36

1.goroutineԴ???ȫ
2.go源码:Sleep函数与线程
3.Go并发编程:goroutine,channel和sync详解
4.Golang sync.Cond 条件变量源码分析
5.Golang源码剖析panic与recover,看不懂你打我好了
6.深度解析sync WaitGroup源码

goroutine源码大全

goroutineԴ???ȫ

       大纲

       概述

       chan 是 golang 的核心结构,是与其他高级语言区别的显著特色之一,也是 goroutine 通信的关键要素。尽管广泛使用,但对其深入理解的throwable 源码解析人却不多。本文将从源码编译器的视角,全面剖析 channel 的用法。

       channel 的本质

       从实现角度来看,golang 的 channel 实质上是环形队列(ringbuffer)的实现。我们将 chan 称为管理结构,channel 中可以放置任何类型的对象,称为元素。

       channel 的使用方法

       我们从 channel 的使用方式入手,详细介绍 channel 的使用方法。

       channel 的创建

       创建 channel 时,用户通常有两种选择:创建带有缓冲区和不带缓冲区的 channel。这对应于 runtime/chan.go 文件中的 makechan 函数。

       channel 入队

       用户使用姿势:对应函数实现为 chansend,位于 runtime/chan.go 文件。

       channel 出队

       用户使用姿势:对应函数分别是 chanrecv1 和 chanrecv2,位于 runtime/chan.go 文件。

       结合 select 语句

       用户使用姿势:对应函数实现为 selectnbsend,位于 runtime/chan.go 文件中。

       结合 for-range 语句

       用户使用姿势:对应使用函数 chanrecv2,位于 runtime/chan.go 文件中。

       源码解析

       以上,我们通过宏观的用户使用姿势,了解了不同使用姿势对应的不同实现函数,接下来将详细分析这些函数的实现。

       makechan 函数

       负责 channel 的创建。在 go 程序中,当我们写类似 v := make(chan int) 的初始化语句时,就会调用不同类型对应的初始化函数,其中 channel 的初始化函数就是 makechen。

       runtime.makechan

       定义原型:

       通过这个,我们可以了解到,声明创建一个 channel 实际上是得到了一个 hchan 的指针,因此 channel 的核心结构就是基于 hchan 实现的。

       其中,t 参数指定元素类型,size 指定 channel 缓冲区槽位数量。如果是带缓冲区的 channel,那么 size 就是槽位数;如果没有指定,那么就是 0。

       makechan 函数执行了以下两件事:

       1. 参数校验:主要是越界或 limit 的校验。

       2. 初始化 hchan:分为三种情况:

       所以,我们看到除了 hchan 结构体本身的内存分配,该结构体初始化的关键在于四个字段:

       hchan 结构

       makechan 函数负责创建了 chan 的核心结构-hchan,接下来我们将详细分析 hchan 结构体本身。

       在 makechan 中,doc文档源码初始化时实际上只初始化了四个核心字段:

       我们使用 channel 时知道,channel 常常会因为两种情况而阻塞:1)投递时没有空间;2)取出时还没有元素。

       从以上描述来看,就涉及到 goroutine 阻塞和 goroutine 唤醒,这个功能与 recvq,sendq 这两个字段有关。

       waitq 类型实际上是一个双向列表的实现,与 linux 中的 LIST 实现非常相似。

       chansend 函数

       chansend 函数是在编译器解析到 c <- x 这样的代码时插入的,本质上就是把一个用户元素投递到 hchan 的 ringbuffer 中。chansend 调用时,一般用户会遇到两种情况:

       接下来,我们看看 chansend 究竟做了什么。

       当我们在 golang 中执行 c <- x 这样的代码,意图将一个元素投递到 channel 时,实际上调用的是 chansend 函数。这个函数分几个场景来处理,总结来说:

       关于返回值:chansend 返回值标明元素是否成功入队,成功则返回 true,否则 false。

       select 的提前揭秘:

       golang 源代码经过编译会变成类似如下:

       而 selectnbasend 只是一个代理:

       小结:没错,chansend 功能就是这么简单,本质上就是一句话:将元素投递到 channel 中。

       chanrecv 函数

       对应的 golang 语句是 <- c。该函数实现了 channel 的元素出队功能。举个例子,编译对应一般如下:

       golang 语句:

       对应:

       golang 语句(这次的区别在于是否有返回值):

       对应:

       编译器在遇到 <- c 和 v, ok := <- c 的语句时,会换成对应的 chanrecv1,chanrecv2 函数,这两个函数本质上都是一个简单的封装,元素出队的实现函数是 chanrecv,我们详细分析这个函数。

       chanrecv 函数的返回值有两个值,selected,received,其中 selected 一般作为 select 结合的函数返回值,指明是否要进入 select-case 的代码分支,received 表明是否从队列中成功获取到元素,有几种情况:

       selectnbsend 函数

       该函数是 c <- v 结合到 select 时的函数,我们使用 select 的 case 里面如果是一个 chan 的表达式,那么编译器会转换成对应的 selectnbsend 函数,如下:

       对应编译函数逻辑如下:

       selectnbsend 本质上也就是个 chansend 的封装:

       chansend 的内部逻辑上面已经详细说明过,唯一不同的就是 block 参数被赋值为 false,也就是说,在 ringbuffer 没有空间的情况下也不会阻塞,直接返回。划重点:chan 在这里不会切走执行权限。

       selectnbrecv 函数

       该函数是 v := <- c 结合到 select 时的函数,我们使用 select 的 case 里面如果是一个 chan 的表达式,那么编译器会转换成对应的颠云源码 selectnbsrecv 函数,如下:

       对应编译函数逻辑如下:

       selectnbrecv 本质上也就是个 chanrecv 的封装:

       chanrecv 的内部逻辑上面已经详细说明过,在 ringbuffer 没有元素的情况下也不会阻塞,直接返回。这里不会因此而切走调度权限。

       selectnbrecv2 函数

       该函数是 v, ok = <- c 结合到 select 时的函数,我们使用 select 的 case 里面如果是一个 chan 的表达式,那么编译器会转换成对应的 selectnbrecv2 函数,如下:

       对应编译函数逻辑如下:

       selectnbrecv2 本质上是个 chanrecv 的封装,只不过返回值不一样而已:

       chanrecv 的内部逻辑上面已经详细说明过,在 ringbuffer 没有元素的情况下也不会阻塞,直接返回。这里不会因此而切走调度权限。selectnbrecv2 与 selectnbrecv 函数的不同之处在于还有一个 ok 参数指明是否获取到了元素。

       chanrecv2 函数

       chan 可以与 for-range 结合使用,编译器会识别这种语法。如下:

       这个本质上是个 for 循环,我们知道 for 循环关键是拆分成三个部分:初始化、条件判断、条件递进。

       那么在我们 for-range 和 chan 结合起来之后,这三个关键因素又是怎么理解的呢?简述如下:

       init 初始化:无

       condition 条件判断:

       increment 条件递进:无

       当编译器遇到上面 chan 结合 for-range 写法时,会转换成 chanrecv2 的函数调用。目的是从 channel 中出队元素,返回值为 received。首先看下 chanrecv2 的实现:

       chan 结合 for-range 编译之后的伪代码如下:

       划重点:从这个实现中,我们可以获取一个非常重要的信息,for-range 和 chan 的结束条件只有这个 chan 被 close 了,否则一直会处于这个死循环内部。为什么?注意看 chanrecv 接收的参数是 block=true,并且这个 for-range 是一个死循环,除非 chanrecv2 返回值为 false,才有可能跳出循环,而 chanrecv2 在 block=true 场景下返回值为 false 的唯一原因只有:这个 chan 是 close 状态。

       总结

       golang 的 chan 使用非常简单,这些简单的语法糖背后其实都是对应了相应的函数实现,这个翻译由编译器来完成。深入理解这些函数的实现,对于彻底理解 chan 的使用和限制条件是必不可少的。深入理解原理,知其然知其所以然,你才能随心所欲地使用 golang。

go源码:Sleep函数与线程

       在探索 Go 语言的并发编程中,Sleep 函数与线程的交互方式与 Java 或其他基于线程池的并发模型有所不同。本文将深入分析 Go 语言中 Sleep 函数的实现及其与线程的互动方式,以解答关于 Go 语言中 Sleep 函数与线程关系的问题。

       首先,重要的一点是,当一个 goroutine(g)调用 Sleep 函数时,它并不会导致当前线程被挂起。相反,redis源码运行Go 通过特殊的机制来处理这种情景,确保 Sleep 函数的调用不会影响到线程的执行。这一特性是 Go 语言并发模型中独特而关键的部分。

       具体来说,当一个 goroutine 调用 Sleep 函数时,它首先将自身信息保存到线程的关键结构体(p)中并挂起。这一过程涉及多个函数调用,包括 `time.Sleep`、`runtime.timeSleep`、`runtime.gopark`、`runtime.mcall`、`runtime.park_m`、`runtime.resetForSleep` 等。最终,该 goroutine 会被放入一个 timer 结构体中,并将其放入到 p 关联的一个最小堆中,从而实现了对当前 goroutine 的保存,同时为调度器提供了切换到其他 goroutine 或 timer 的机会。因此,这里的 timer 实际上代表了被 Sleep 挂起的 goroutine,它在睡眠到期后能够及时得到执行。

       接下来,我们深入分析 goroutine 的调度过程。当线程 p 需要执行时,它会通过 `runtime.park_m` 函数调用 `schedule` 函数来进行 goroutine 或 timer 的切换。在此过程中,`runtime.findrunnable` 函数会检查线程堆中是否存在已到期的 timer,如果存在,则切换到该 timer 进行执行。如果 timer 堆中没有已到期的 timer,线程会继续检查本地和全局的 goroutine 队列中是否还有待执行的 goroutine,如果队列为空,则线程会尝试“偷取”其他 goroutine 的任务。这一过程包括了检查 timer 堆、偷取其他 p 中的到期 timer 或者普通 goroutine,确保任务能够及时执行。

       在“偷取”任务的过程中,线程会优先处理即将到期的 timer,确保这些 timer 的准时执行。如果当前线程正在执行其他任务(如 epoll 网络),则在执行过程中会定期检查 timer 到期情况。如果发现其他线程的 timer 到期时间早于自身,会首先唤醒该线程以处理其 timer,确保不会错过任何到期的 timer。

       为了证明当前线程设置的 timer 能够准时执行,本文提出了两种证明方法。第一种方法基于代码细节,重点分析了线程状态的变化和 timer 的执行流程。具体而言,文章中提到的视频slam源码三种线程状态(正常运行、epoll 网络、睡眠)以及相应的 timer 执行情况,表明在 Go 语言中,timer 的执行策略能够确保其准时执行。第二种方法则从全局调度策略的角度出发,强调了 Go 语言中线程策略的设计原则,即至少有一个线程处于“spinning”状态或者所有线程都在执行任务,这保证了 timer 的准时执行。

       总之,Go 语言中 Sleep 函数与线程之间的交互方式,通过特殊的线程管理机制,确保了 goroutine 的 Sleep 操作不会阻塞线程,同时保证了 timer 的准时执行。这一机制是 Go 语言并发模型的独特之处,为开发者提供了一种高效且灵活的并发处理方式。

Go并发编程:goroutine,channel和sync详解

       ä¼˜é›…的并发编程范式,完善的并发支持,出色的并发性能是Go语言区别于其他语言的一大特色。

       åœ¨å½“今这个多核时代,并发编程的意义不言而喻。使用Go开发并发程序,操作起来非常简单,语言级别提供关键字go用于启动协程,并且在同一台机器上可以启动成千上万个协程。

       ä¸‹é¢å°±æ¥è¯¦ç»†ä»‹ç»ã€‚

goroutine

       Go语言的并发执行体称为goroutine,使用关键词go来启动一个goroutine。

       go关键词后面必须跟一个函数,可以是有名函数,也可以是无名函数,函数的返回值会被忽略。

       go的执行是非阻塞的。

       å…ˆæ¥çœ‹ä¸€ä¸ªä¾‹å­ï¼š

packagemainimport("fmt""time")funcmain(){ gospinner(*time.Millisecond)constn=fibN:=fib(n)fmt.Printf("\rFibonacci(%d)=%d\n",n,fibN)//Fibonacci()=}funcspinner(delaytime.Duration){ for{ for_,r:=range`-\|/`{ fmt.Printf("\r%c",r)time.Sleep(delay)}}}funcfib(xint)int{ ifx<2{ returnx}returnfib(x-1)+fib(x-2)}

       ä»Žæ‰§è¡Œç»“果来看,成功计算出了斐波那契数列的值,说明程序在spinner处并没有阻塞,而且spinner函数还一直在屏幕上打印提示字符,说明程序正在执行。

       å½“计算完斐波那契数列的值,main函数打印结果并退出,spinner也跟着退出。

       å†æ¥çœ‹ä¸€ä¸ªä¾‹å­ï¼Œå¾ªçŽ¯æ‰§è¡Œæ¬¡ï¼Œæ‰“印两个数的和:

packagemainimport"fmt"funcAdd(x,yint){ z:=x+yfmt.Println(z)}funcmain(){ fori:=0;i<;i++{ goAdd(i,i)}}

       æœ‰é—®é¢˜äº†ï¼Œå±å¹•ä¸Šä»€ä¹ˆéƒ½æ²¡æœ‰ï¼Œä¸ºä»€ä¹ˆå‘¢ï¼Ÿ

       è¿™å°±è¦çœ‹Go程序的执行机制了。当一个程序启动时,只有一个goroutine来调用main函数,称为主goroutine。新的goroutine通过go关键词创建,然后并发执行。当main函数返回时,不会等待其他goroutine执行完,而是直接暴力结束所有goroutine。

       é‚£æœ‰æ²¡æœ‰åŠžæ³•è§£å†³å‘¢ï¼Ÿå½“然是有的,请往下看。

channel

       ä¸€èˆ¬å†™å¤šè¿›ç¨‹ç¨‹åºæ—¶ï¼Œéƒ½ä¼šé‡åˆ°ä¸€ä¸ªé—®é¢˜ï¼šè¿›ç¨‹é—´é€šä¿¡ã€‚常见的通信方式有信号,共享内存等。goroutine之间的通信机制是通道channel。

       ä½¿ç”¨make创建通道:

ch:=make(chanint)//ch的类型是chanint

       é€šé“支持三个主要操作:send,receive和close。

ch<-x//发送x=<-ch//接收<-ch//接收,丢弃结果close(ch)//关闭无缓冲channel

       make函数接受两个参数,第二个参数是可选参数,表示通道容量。不传或者传0表示创建了一个无缓冲通道。

       æ— ç¼“冲通道上的发送操作将会阻塞,直到另一个goroutine在对应的通道上执行接收操作。相反,如果接收先执行,那么接收goroutine将会阻塞,直到另一个goroutine在对应通道上执行发送。

       æ‰€ä»¥ï¼Œæ— ç¼“冲通道是一种同步通道。

       ä¸‹é¢æˆ‘们使用无缓冲通道把上面例子中出现的问题解决一下。

packagemainimport"fmt"funcAdd(x,yint,chchanint){ z:=x+ych<-z}funcmain(){ ch:=make(chanint)fori:=0;i<;i++{ goAdd(i,i,ch)}fori:=0;i<;i++{ fmt.Println(<-ch)}}

       å¯ä»¥æ­£å¸¸è¾“出结果。

       ä¸»goroutine会阻塞,直到读取到通道中的值,程序继续执行,最后退出。

缓冲channel

       åˆ›å»ºä¸€ä¸ªå®¹é‡æ˜¯5的缓冲通道:

ch:=make(chanint,5)

       ç¼“冲通道的发送操作在通道尾部插入一个元素,接收操作从通道的头部移除一个元素。如果通道满了,发送会阻塞,直到另一个goroutine执行接收。相反,如果通道是空的,接收会阻塞,直到另一个goroutine执行发送。

       æœ‰æ²¡æœ‰æ„Ÿè§‰ï¼Œå…¶å®žç¼“冲通道和队列一样,把操作都解耦了。

单向channel

       ç±»åž‹chan<-int是一个只能发送的通道,类型<-chanint是一个只能接收的通道。

       ä»»ä½•åŒå‘通道都可以用作单向通道,但反过来不行。

       è¿˜æœ‰ä¸€ç‚¹éœ€è¦æ³¨æ„ï¼Œclose只能用在发送通道上,如果用在接收通道会报错。

       çœ‹ä¸€ä¸ªå•å‘通道的例子:

packagemainimport"fmt"funccounter(outchan<-int){ forx:=0;x<;x++{ out<-x}close(out)}funcsquarer(outchan<-int,in<-chanint){ forv:=rangein{ out<-v*v}close(out)}funcprinter(in<-chanint){ forv:=rangein{ fmt.Println(v)}}funcmain(){ n:=make(chanint)s:=make(chanint)gocounter(n)gosquarer(s,n)printer(s)}sync

       sync包提供了两种锁类型:sync.Mutex和sync.RWMutex,前者是互斥锁,后者是读写锁。

       å½“一个goroutine获取了Mutex后,其他goroutine不管读写,只能等待,直到锁被释放。

packagemainimport("fmt""sync""time")funcmain(){ varmutexsync.Mutexwg:=sync.WaitGroup{ }//主goroutine先获取锁fmt.Println("Locking(G0)")mutex.Lock()fmt.Println("locked(G0)")wg.Add(3)fori:=1;i<4;i++{ gofunc(iint){ //由于主goroutine先获取锁,程序开始5秒会阻塞在这里fmt.Printf("Locking(G%d)\n",i)mutex.Lock()fmt.Printf("locked(G%d)\n",i)time.Sleep(time.Second*2)mutex.Unlock()fmt.Printf("unlocked(G%d)\n",i)wg.Done()}(i)}//主goroutine5秒后释放锁time.Sleep(time.Second*5)fmt.Println("readyunlock(G0)")mutex.Unlock()fmt.Println("unlocked(G0)")wg.Wait()}

       RWMutex属于经典的单写多读模型,当读锁被占用时,会阻止写,但不阻止读。而写锁会阻止写和读。

packagemainimport("fmt""sync""time")funcmain(){ varrwMutexsync.RWMutexwg:=sync.WaitGroup{ }Data:=0wg.Add()fori:=0;i<;i++{ gofunc(tint){ //第一次运行后,写解锁。//循环到第二次时,读锁定后,goroutine没有阻塞,同时读成功。fmt.Println("Locking")rwMutex.RLock()deferrwMutex.RUnlock()fmt.Printf("Readdata:%v\n",Data)wg.Done()time.Sleep(2*time.Second)}(i)gofunc(tint){ //写锁定下是需要解锁后才能写的rwMutex.Lock()deferrwMutex.Unlock()Data+=tfmt.Printf("WriteData:%v%d\n",Data,t)wg.Done()time.Sleep(2*time.Second)}(i)}wg.Wait()}总结

       å¹¶å‘编程算是Go的特色,也是核心功能之一了,涉及的知识点其实是非常多的,本文也只是起到一个抛砖引玉的作用而已。

       æœ¬æ–‡å¼€å§‹ä»‹ç»äº†goroutine的简单用法,然后引出了通道的概念。

       é€šé“有三种:

       æ— ç¼“冲通道

       ç¼“冲通道

       å•å‘通道

       æœ€åŽä»‹ç»äº†Go中的锁机制,分别是sync包提供的sync.Mutex(互斥锁)和sync.RWMutex(读写锁)。

       goroutine博大精深,后面的坑还是要慢慢踩的。

       æ–‡ç« ä¸­çš„脑图和源码都上传到了GitHub,有需要的同学可自行下载。

       åœ°å€ï¼šgithub.com/yongxinz/gopher/tree/main/sc

       ä½œè€…:yongxinz

Golang sync.Cond 条件变量源码分析

       sync.Cond 是 Golang 标准库 sync 包中一个关键的条件变量类型,用于在多个goroutine间协调等待特定条件。它常用于生产者-消费者模型等场景,确保在某些条件满足后才能继续执行。本文基于 go-1. 源码,深入解析 sync.Cond 的核心机制与用法。

       sync.Cond 的基本用法包括创建条件变量、等待唤醒与发送信号。使用时,通常涉及到一个互斥锁(Locker)以确保并发安全性。首先,通过`sync.NewCond(l Locker)`创建条件变量。其次,`cond.Wait()`使当前执行的goroutine等待直到被唤醒,期间会释放锁并暂停执行。`cond.Signal()`和`Broadcast()`用于唤醒等待的goroutine,前者唤醒一个,后者唤醒所有。

       在底层实现中,sync.Cond 采用了一种称为 notifyList 的数据结构来管理等待和唤醒过程。notifyList 由一组元素构成,其中`wait`和`notify`表示当前最大ticket值和已唤醒的最大ticket值,而`head`和`tail`则分别代表等待的goroutine链表的头和尾。在`Wait`操作中,每次调用`runtime_notifyListAdd`生成唯一的ticket,并将当前goroutine添加到链表中。当调用`Signal`或`Broadcast`时,会查找并唤醒当前`notify`值对应的等待goroutine,并更新`notify`值。

       信号唤醒过程确保了FIFO的顺序,即最早等待的goroutine会首先被唤醒。这种机制有效地防止了并发操作下列表的乱序,确保了正确的唤醒顺序,尽管在实际执行中,遍历整个列表的过程在大多数情况下效率较高。

       在使用sync.Cond时,需注意避免潜在的死锁风险和错误的唤醒顺序。确保合理管理互斥锁的使用,以及在适当情况下使用`Signal`或`Broadcast`来唤醒等待的goroutine。正确理解和应用sync.Cond,能有效提升并发编程的效率与稳定性。

Golang源码剖析panic与recover,看不懂你打我好了

       哈喽,大家好,我是asong,今天与大家来聊一聊go语言中的"throw、try.....catch{ }"。如果你之前是一名java程序员,我相信你一定吐槽过go语言错误处理方式,但是这篇文章不是来讨论好坏的,我们本文的重点是带着大家看一看panic与recover是如何实现的。上一文我们讲解了defer是如何实现的,但是没有讲解与defer紧密相连的recover,想搞懂panic与recover的实现也没那么简单,就放到这一篇来讲解了。废话不多说,直接开整。

       Go 语言中panic 关键字主要用于主动抛出异常,类似 java 等语言中的 throw 关键字。panic 能够改变程序的控制流,调用 panic 后会立刻停止执行当前函数的剩余代码,并在当前 Goroutine 中递归执行调用方的 defer;

       Go 语言中recover 关键字主要用于捕获异常,让程序回到正常状态,类似 java 等语言中的 try ... catch 。recover 可以中止 panic 造成的程序崩溃。它是一个只能在 defer 中发挥作用的函数,在其他作用域中调用不会发挥作用;

       recover只能在defer中使用这个在标准库的注释中已经写明白了,我们可以看一下:

       这里有一个要注意的点就是recover必须要要在defer函数中使用,否则无法阻止panic。最好的验证方法是先写两个例子:

       运行我们会发现example2()方法的panic是没有被recover住的,导致整个程序直接crash了。这里大家肯定会有疑问,为什么直接写recover()就不能阻止panic了呢。我们在 详解defer实现机制(附上三道面试题,我不信你们都能做对)讲解了defer实现原理,一个重要的知识点**defer将语句放入到栈中时,也会将相关的值拷贝同时入栈。**所以defer recover()这种写法在放入defer栈中时就已经被执行过了,panic是发生在之后,所以根本无法阻止住panic。

       通过运行结果可以看出panic不会影响defer函数的使用,所以他是安全的。

       这里我开了两个协程,一个协程会发生panic,导致程序崩溃,但是只会执行自己所在Goroutine的延迟函数,所以正好验证了多个 Goroutine 之间没有太多的关联,一个 Goroutine 在 panic 时也不应该执行其他 Goroutine 的延迟函数。

       其实我们在实际项目开发中,经常会遇到panic问题, Go 的 runtime 代码中很多地方都调用了 panic 函数,对于不了解 Go 底层实现的新人来说,这无疑是挖了一堆深坑。我们在实际生产环境中总会出现panic,但是我们的程序仍能正常运行,这是因为我们的框架已经做了recover,他已经为我们兜住底,比如gin,我们看一看他是怎么做的。

       我们先来写个简单的代码,看看他的汇编调用:执行go tool compile -N -l -S main.go就可以看到对应的汇编码了,我们截取部分片段分析:

       上面重点部分就是画红线的三处,第一步调用runtime.deferprocStack创建defer对象,这一步大家可能会有疑惑,我上一文忘记讲个这个了,这里先简单概括一下,defer总共有三种模型,编译一个函数里只会有一种defer模式。在讲defer实现机制时,我们一起看过defer的结构,其中有一个字段就是_panic,是触发defer的作用,我们来看看的panic的结构:

       简单介绍一下上面的字段:

       上面的pc、sp、goexit我们单独讲一下,runtime包中有一个Goexit方法,Goext能够终止调用它的goroutine,其他的goroutine是不受影响的,goexit也会在终止goroutine之前运行所有延迟调用函数,Goexit不是一个panic,所以这些延迟函数中的任何recover调用都将返回nil。如果我们在主函数中调用了Goexit会终止该goroutine但不会返回func main。由于func main没有返回,因此程序将继续执行其他gorountine,直到所有其他goroutine退出,程序才会crash。

       下面就开始我们的重点吧~。

       在讲defer实现机制时,我们一起看过defer的结构,其中有一个字段就是_panic,是触发defer的作用,我们来看看的panic的结构:简单介绍一下上面的字段:上面的pc、sp、goexit我们单独讲一下,runtime包中有一个Goexit方法,Goext能够终止调用它的goroutine,其他的goroutine是不受影响的,goexit也会在终止goroutine之前运行所有延迟调用函数,Goexit不是一个panic,所以这些延迟函数中的任何recover调用都将返回nil。如果我们在主函数中调用了Goexit会终止该goroutine但不会返回func main。由于func main没有返回,因此程序将继续执行其他gorountine,直到所有其他goroutine退出,程序才会crash。写个简单的例子:运行上面的例子你就会发现,即使在主goroutine中调用了runtime.Goexit,其他goroutine是没有任何影响的。所以结构中的pc、sp、goexit三个字段都是为了修复runtime.Goexit,这三个字段就是为了保证该函数的一定会生效,因为如果在defer中发生panic,那么goexit函数就会被取消,所以才有了这三个字段做保护。看这个例子:

       英语好的可以看一看这个: github.com/golang/go/is...,这就是上面的一个例子,这里就不过多解释了,了解就好。

       接下来我们再来看一看gopanic方法。

       gopanic的代码有点长,我们一点一点来分析:

       根据不同的类型判断当前发生panic错误,这里没什么多说的,接着往下看。

       上面的代码都是截段,这些部分都是为了判断当前defer是否可以使用开发编码模式,具体怎么操作的就不展开了。

       在第三部分进行defer内联优化选择时会执行调用延迟函数(reflectcall就是这个作用),也就是会调用runtime.gorecover把recoverd = true,具体这个函数的操作留在下面讲,因为runtime.gorecover函数并不包含恢复程序的逻辑,程序的恢复是在gopanic中执行的。先看一下代码:

       这段代码有点长,主要就是分为两部分:

       第一部分主要是这个判断if gp._panic != nil && gp._panic.goexit && gp._panic.aborted { ... },正常recover是会绕过Goexit的,所以为了解决这个,添加了这个判断,这样就可以保证Goexit也会被recover住,这里是通过从runtime._panic中取出了程序计数器pc和栈指针sp并且调用runtime.recovery函数触发goroutine的调度,调度之前会准备好 sp、pc 以及函数的返回值。

       第二部分主要是做panic的recover,这也与上面的流程基本差不多,他是从runtime._defer中取出了程序计数器pc和栈指针sp并调用recovery函数触发Goroutine,跳转到recovery函数是通过runtime.call进行的,我们看一下其源码(src/runtime/asm_amd.s 行):

       因为go语言中的runtime环境是有自己的堆栈和goroutine,recovery函数也是在runtime环境执行的,所以要调度到m->g0来执行recovery函数,我们在看一下recovery函数:

       在recovery 函数中,利用 g 中的两个状态码回溯栈指针 sp 并恢复程序计数器 pc 到调度器中,并调用 gogo 重新调度 g , goroutine 继续执行,recovery在调度过程中会将函数的返回值设置为1。这个有什么作用呢? 在deferproc函数中找到了答案:

       当延迟函数中recover了一个panic时,就会返回1,当 runtime.deferproc 函数的返回值是 1 时,编译器生成的代码会直接跳转到调用方函数返回之前并执行 runtime.deferreturn,跳转到runtime.deferturn函数之后,程序就已经从panic恢复了正常的逻辑。

       在这里runtime.fatalpanic实现了无法被恢复的程序崩溃,它在中止程序之前会通过 runtime.printpanics 打印出全部的 panic 消息以及调用时传入的参数。

       这就是这个逻辑流程,累死我了。。。。

       结尾给大家发一个小福利,哈哈,这个福利就是如果避免出现panic,要注意这些:这几个是比较典型的,还有很多会发生panic的地方,交给你们自行学习吧~。

       好啦,这篇文章就到这里啦,素质三连(分享、点赞、在看)都是笔者持续创作更多优质内容的动力!

深度解析sync WaitGroup源码

       waitGroup

       waitGroup 是 Go 语言中并发编程中常用的语法之一,主要用于解决并发和等待问题。它是 sync 包下的一个子组件,特别适用于需要协调多个goroutine执行任务的场景。

       waitGroup 主要用于解决goroutine间的等待关系。例如,goroutineA需要在等待goroutineB和goroutineC这两个子goroutine执行完毕后,才能执行后续的业务逻辑。通过使用waitGroup,goroutineA在执行任务时,会在检查点等待其他goroutine完成,确保所有任务执行完毕后,goroutineA才能继续进行。

       在实现上,waitGroup 通过三个方法来操作:Add、Done 和 Wait。Add方法用于增加计数,Done方法用于减少计数,Wait方法则用于在计数为零时阻塞等待。这些方法通过原子操作实现同步安全。

       waitGroup的源码实现相对简洁,主要涉及数据结构设计和原子操作。数据结构包括了一个 noCopy 的辅助字段以及一个复合意义的 state1 字段。state1 字段的组成根据目标平台的不同(位或位)而有所不同。在位环境下,state1的第一个元素是等待线程数,第二个元素是 waitGroup 计数值,第三个元素是信号量。而在位环境下,如果 state1 的地址不是位对齐的,那么 state1 的第一个元素是信号量,后两个元素分别是等待线程数和计数值。

       waitGroup 的核心方法 Add 和 Wait 的实现原理如下:

       Add方法通过原子操作增加计数值。当执行 Add 方法时,首先将 delta 参数左移位,然后通过原子操作将其添加到计数值上。需要注意的是,delta 的值可正可负,用于在调用 Done 方法时减少计数值。

       Done方法通过调用 Add(-1)来减少计数值。

       Wait方法则持续检查 state 值。当计数值为零时,表示所有子goroutine已完成,调用者无需等待。如果计数值大于零,则调用者会变成等待者,加入等待队列,并阻塞自己,直到所有任务执行完毕。

       通过使用waitGroup,开发者可以轻松地协调和同步并发任务的执行,确保所有任务按预期顺序完成。这在多goroutine协同工作时,尤其重要。掌握waitGroup的使用和源码实现,将有助于提高并发编程的效率和可维护性。

       如果您对并发编程感兴趣,希望持续关注相关技术更新,请通过微信搜索「迈莫coding」,第一时间获取更多深度解析和实战指南。