phper学习Go之defer、panic 和 recover的实践,最后手贱开启二百万协程,cpu暴涨93%

2021-04-06 20:41:43 1073 技术小虫有点萌
  • 作为phper,最近想了解一下Go,但是并不代表我就放弃php了,you know ,php 想获取一个对象的地址有多难!这就是静态语言和动态语言的相差之处
  • 接下来就逐个了解一下吧!

defer

defer 语句将一个函数放入一个栈中,defer 会在当前函数返回前执行传入的函数,经常用于关闭文件描述符,数据库连接,redis连接等,用于清理资源,避免资源浪费。比如下面这个栗子

package main

import (
 "fmt"
 "goapp/src/math"
)

func main() {

 sum:=math.Add(2,3)
 fmt.Println(sum)
 defer func() {fmt.Println("i am defer1")}()
 res := test_defer();
 fmt.Println(res)

}

func test_defer() float64  {
 defer func() {fmt.Println("i am defer2")}()
 defer func() {fmt.Println("i am defer3")}()
 res :=math2.Mod(5,3)
 return res;
}


执行结果是什么呢?

  1. 执行 一个加法,打印返回值 5;

2.defer1入栈

3.执行函数test_defer,defer2入栈,defer3入栈,执行函数逻辑,在return 之前 呢,会执行 栈里面的defer,栈嘛,先进后出,和队列相反,所以依次执行defer3,defer2,然后返回结果

4.main函数收到test_defer的返回值,开始打印结果

5.main函数在结束之前呢,会执行一下本函数内的defer,所以开始执行defer1

那结果是不是这样执行的呢?我们来看一下结果,毫无相差

image.png
那么此处可能有小伙伴要问一下,defer为什么要设计成栈?
  • 通俗来讲一个场景,defer 是做清场工作的,对吧,那么这样一个场景,一个小偷去仓库偷东西,干完活了,要擦除脚印对吧,那他不可能从进门的位置开始擦脚印吧,他只能退着擦,先擦最后一步的脚印,而且,很多时候最后一步是基于前面的基础上的,比如,还是这个小偷,他想偷柜子里面的珠宝,那他是不是得先打开门啊,那小偷做清理工作的时候,不可能先关闭门,在关闭柜子吧。
  • defer是用来释放资源的,比如一个操作,先给文件上锁,然后修改文件。那defer执行的时候应该是先关闭文件,再释放锁。如果释放锁,再关闭文件,那不是乱套了吗?从因果关系上说,后分配的资源可能会依赖前面的资源,但是前面的资源肯定不会依赖后面打开的资源。所以倒过来执行关闭 不会产生问题。
那有的人说,我就是让defer先进先出,不行吗?允许是允许,但是不提倡。哈哈,是不是感受到了罗翔老师的气场,请看下面的代码,如果defer嵌套,那么defer会从外往里执行,剥洋葱似的,一层一层剥。
package main

func main() {
 defer func() {
  println("i am defer1")
  defer func() {
   println("i am defer2")
   defer func() {
    println("i am defer3")
   }()
  }()
 }()

 panic("i am panic")
}

image.png

panic

panic往往和recover是成对出现的,与defer也有紧密的联系,panic能够改变程序的控制流,调用panic后会立刻停止执行当前函数的剩余代码,并在当前Goroutine(协程)中递归执行调用方的defer

我们看下面一段代码

package main

import "time"

func main()  {
 defer func() {
  println("i am main defer1")
 }()

 go func() {
  defer func() {
   println("i am goroutine defer2")
  }()

  defer func() {
   println("i am goroutine defer3")
  }()

  panic("i am panic")

  defer func() {
   println("i am goroutine defer4")
  }()
 }()
 time.Sleep(1 * time.Second)
}
  • 从前面的分析我们得知以下结果
  1. defer1 入栈

2.执行goroutine 3.defer2 入栈 4.defer3入栈 5.panic打断程序执行,依次执行defer3,defer2,panic,而panic 后面的程序不会再运行,并且main里面的defer也不会执行

image.png
  • 我为什么要加time.Sleep 如果不加呢?
image.png

从截图里面看到,如果没有time.Sleep,协程好像没有被执行一样,为什么会这样呢?因为我们知道,协程不是抢占式的,如果删除time.Sleep,主goroutine不会放弃对辅助goroutine的控制,但是goroutine 必须放弃控制才能运行另一个goroutine,而time.Sleep就是放弃控制的一种方法。简单来说,你这个程序 从头到尾都是被main 主协程占用着,子协程不会主动抢占cpu,那么必须得是主协程主动让出cpu,让子协程有机会被cpu轮询到,子协程才会被执行

顺道说一下什么是协程

  • 协程是go语言最重要的特色之一,那么我们怎么理解协程呢?协程,简单说就是轻量级的线程,一个协程的大小是2k 左右,这也解释了为什么go能单机百万。
  • go语言里的协程创建很简单,在go关键词后面加一个函数调用就可以了。代码举栗
package main

import "time"

func main()  {
 println("i am main goroutine")
 go func() {
  println("i am goroutine_in_1")
  go func() {
   println("i am goroutine_in_2")
   go func() {
    println("i am goroutine_in_3")
   }()
  }()
 }()

 time.Sleep(1*time.Second);
 println("main goroutine is over")
}

image.png

main 函数是怎么运行的呢?其实main函数也是运行在goroutine里面,不过是主协程,上面的栗子我们是嵌套了几个协程,但是他们中间并没有什么层级关系,协程只有两种,子协程和主协程。上面的代码中,我们让主协程休息了一秒,等待子协程返回结果。如果不让主协程休息一秒,即让出cpu,让子协程是没有机会执行的,因为主协程运行结束后,不管子协程是任何状态,都会全部消亡。

但是在实际使用中,我们要保护好每一个子协程,确保他们安全运行,一个子协程的异常会传播到主协程,直接导致主协程挂掉,程序崩溃。比如下面这个栗子

package main

import "time"

func main()  {
 println("i am main goroutine")
 go func() {
  println("i am goroutine_in_1")
  go func() {
   println("i am goroutine_in_2")
   go func() {
    println("i am goroutine_in_3")
    panic("i am panic")
   }()
  }()
 }()

 time.Sleep(1*time.Second);
 println("main goroutine is over")
}

image.png

最后一句,main goroutine is over没有打印,程序没有执行到。 前面我们说到了。不管你是什么样的程序,遇到panic 我就终止程序往下执行,哪怕是子协程呢!好了,协程先说到这里。我们继续往下看recover

recover

recover 可以中止panic造成的程序崩溃,它是一个只能在defer中发挥作用的函数。在其他作用域中不会发挥作用。为什么这么说呢?我们看下面这个栗子

package main

import "fmt"

func main() {
 defer func() {
  println("i am main")
 }()
 if err := recover();err != nil {
  fmt.Println(err)
 }
 panic("i am panic")
}


看一下执行结果

image.png

我们看到,遇到panic,执行了defer,然后执行了panic ,并没有执行if条件判断,为什么?recover是捕捉错误的,运行到if 还没有错误,捕捉什么?运行到panic 的时候 if 已经执行过了,怎么捕捉?那么可能有人想,我把if放到panic后面不就行了吗?行吗?答案是否定的,panic 我们前面已经说过了,甭管你是谁,看见我就得停止。那就回到我们刚才说的,panic 出现,程序停止往下执行,但是程序会循环执行defer啊,那如果我在defer里面捕捉错误,是不是就可以解决这个问题了呢。可见go的设计者是用心良苦!到这里有没有人会问一个问题defer可以嵌套,那么panic能否嵌套呢?当然可以,defer可以容纳一切,panic放到defer里面一样可以嵌套

package main

func main() {
 defer func() {
  defer func() {
   defer func() {
    panic("i am panic3")
   }()
   panic("i am panic2")
  }()
  panic("i am panic1")
 }()

 panic("i am panic")
}
image.png

为什么会先执行 最后一行panic ,才执行defer呢,这和前面说的遇到panic先执行defer有点出入是吧,但是你这样看 defer优先于panic优先于defer+panic。

那么现在,我们来写一个例子,看defer 如何捕捉panic并恢复程序正常执行

package main

import "fmt"

func main() {
 havePanic();
 println("i will go on ")
}

func havePanic()  {
 defer func() {
  if err:=recover();err !=nil {
   fmt.Println("i get a panic")
   fmt.Println(err)
  }
 }()
 panic("i am panic");
}

解读一下上面的程序,执行havePanic ,havePanic的第一个defer入栈,往下执行碰到panic,首先会执行defer,defer里面打印了err信息,并可以做一些其他的处理,比如记录日志,重试什么的。然后main继续执行下面的print,看一下执行结果

image.png

下面再补充一点协程的知识

go不是号称百万协程吗?那么我们真给它来个百万协程看一下我的电脑到底能不能hold住

来!写一段代码

package main

import (
 "fmt"
 "time"
)

func main()  {
 i :=1;
 for  {
  go func() {
   for  {
    time.Sleep(time.Second)
   }
  }()
  if i > 1000000 {
   fmt.Printf("我已经启动了%d个协程\n",i)
  }else{
   fmt.Printf("当前是第%d个协程\n",i)
  }
  i++
 }

}

截图看一下我当前的机器状态

image.png

百万协程挂起之后的截图

image.png

因为输出跟不上速度其实最后跑了1842504个协程

image.png

说一下跑后感:风扇呼呼的转了大概三分钟的样子 ,我算了一下一个协程大概是2.45kb的样子

image.png

协程和线程的区别

一个进程内部可以运行多个线程,而每个线程又可以运行很多协程。线程要负责对协程进行调度,保证每个协程都有机会得到执行。当一个协程睡眠时,它要将线程的运行权让给其它的协程来运行,而不能持续霸占这个线程。同一个线程内部最多只会有一个协程正在运行。

线程的调度是由操作系统负责的,调度算法运行在内核态,而协程的调用是由 Go 语言的运行时负责的,调度算法运行在用户态。

协程可以简化为三个状态,运行态、就绪态和休眠态。同一个线程中最多只会存在一个处于运行态的协程,就绪态的协程是指那些具备了运行能力但是还没有得到运行机会的协程,它们随时会被调度到运行态,休眠态的协程还不具备运行能力,它们是在等待某些条件的发生,比如 IO 操作的完成、睡眠时间的结束等。

操作系统对线程的调度是抢占式的,也就是说单个线程的死循环不会影响其它线程的执行,每个线程的连续运行受到时间片的限制。

Go 语言运行时对协程的调度并不是抢占式的。如果单个协程通过死循环霸占了线程的执行权,那这个线程就没有机会去运行其它协程了,你可以说这个线程假死了。不过一个进程内部往往有多个线程,假死了一个线程没事,全部假死了才会导致整个进程卡死。

每个线程都会包含多个就绪态的协程形成了一个就绪队列,如果这个线程因为某个别协程死循环导致假死,那这个队列上所有的就绪态协程是不是就没有机会得到运行了呢?Go 语言运行时调度器采用了 work-stealing 算法,当某个线程空闲时,也就是该线程上所有的协程都在休眠(或者一个协程都没有),它就会去其它线程的就绪队列上去偷一些协程来运行。也就是说这些线程会主动找活干,在正常情况下,运行时会尽量平均分配工作任务。

我的线程数到底有多少?

默认情况下,Go 运行时会将线程数会被设置为机器 CPU 逻辑核心数。同时它内置的 runtime 包提供了 GOMAXPROCS(n int) 函数允许我们动态调整线程数,注意这个函数名字是全大写。该函数会返回修改前的线程数,如果参数 n <=0 ,就不会产生修改效果,等价于读操作。

package main

import (
 "fmt"
 "runtime"
)

func main()  {
 fmt.Print(runtime.GOMAXPROCS(0))//获取默认线程数 8
 println("\n")
 runtime.GOMAXPROCS(10)//设置线程数为10
 fmt.Print(runtime.GOMAXPROCS(0))//获取新线程数 10
}

image.png