0%

最近解决了我们项目中的一个内存泄露问题,事实再次证明pprof是一个好工具,但掌握好工具的正确用法,才能发挥好工具的威力,不然就算你手里有屠龙刀,也成不了天下第一,本文就是带你用pprof定位内存泄露问题。

关于Go的内存泄露有这么一句话不知道你听过没有:

10次内存泄露,有9次是goroutine泄露。

我所解决的问题,也是goroutine泄露导致的内存泄露,所以这篇文章主要介绍Go程序的goroutine泄露,掌握了如何定位和解决goroutine泄露,就掌握了内存泄露的大部分场景

本文草稿最初数据都是生产坏境数据,为了防止敏感内容泄露,全部替换成了demo数据,demo的数据比生产环境数据简单多了,更适合入门理解,有助于掌握pprof。


go pprof基本知识

定位goroutine泄露会使用到pprof,pprof是Go的性能工具,在开始介绍内存泄露前,先简单介绍下pprof的基本使用,更详细的使用给大家推荐了资料。

什么是pprof

pprof是Go的性能分析工具,在程序运行过程中,可以记录程序的运行信息,可以是CPU使用情况、内存使用情况、goroutine运行情况等,当需要性能调优或者定位Bug时候,这些记录的信息是相当重要。

基本使用

使用pprof有多种方式,Go已经现成封装好了1个:net/http/pprof,使用简单的几行命令,就可以开启pprof,记录运行信息,并且提供了Web服务,能够通过浏览器和命令行2种方式获取运行数据。

看个最简单的pprof的例子:

文件:golang_step_by_step/pprof/pprof/demo.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import (
"fmt"
"net/http"
_ "net/http/pprof"
)

func main() {
// 开启pprof,监听请求
ip := "0.0.0.0:6060"
if err := http.ListenAndServe(ip, nil); err != nil {
fmt.Printf("start pprof failed on %s\n", ip)
}
}

提醒:本文所有代码部分可左右滑动

浏览器方式

image-20190516173924325

输入网址ip:port/debug/pprof/打开pprof主页,从上到下依次是5类profile信息

  1. block:goroutine的阻塞信息,本例就截取自一个goroutine阻塞的demo,但block为0,没掌握block的用法
  2. goroutine:所有goroutine的信息,下面的full goroutine stack dump是输出所有goroutine的调用栈,是goroutine的debug=2,后面会详细介绍。
  3. heap:堆内存的信息
  4. mutex:锁的信息
  5. threadcreate:线程信息

这篇文章我们主要关注goroutine和heap,这两个都会打印调用栈信息,goroutine里面还会包含goroutine的数量信息,heap则是内存分配信息,本文用不到的地方就不展示了,最后推荐几篇文章大家去看。

命令行方式

当连接在服务器终端上的时候,是没有浏览器可以使用的,Go提供了命令行的方式,能够获取以上5类信息,这种方式用起来更方便。

使用命令go tool pprof url可以获取指定的profile文件,此命令会发起http请求,然后下载数据到本地,之后进入交互式模式,就像gdb一样,可以使用命令查看运行信息,以下是5类请求的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 下载cpu profile,默认从当前开始收集30s的cpu使用情况,需要等待30s
go tool pprof http://localhost:6060/debug/pprof/profile # 30-second CPU profile
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=120 # wait 120s

# 下载heap profile
go tool pprof http://localhost:6060/debug/pprof/heap # heap profile

# 下载goroutine profile
go tool pprof http://localhost:6060/debug/pprof/goroutine # goroutine profile

# 下载block profile
go tool pprof http://localhost:6060/debug/pprof/block # goroutine blocking profile

# 下载mutex profile
go tool pprof http://localhost:6060/debug/pprof/mutex

上面的pprof/demo.go太简单了,如果去获取内存profile,几乎获取不到什么,换一个Demo进行内存profile的展示:

文件:golang_step_by_step/pprof/heap/demo2.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 展示内存增长和pprof,并不是泄露
package main

import (
"fmt"
"net/http"
_ "net/http/pprof"
"os"
"time"
)

// 运行一段时间:fatal error: runtime: out of memory
func main() {
// 开启pprof
go func() {
ip := "0.0.0.0:6060"
if err := http.ListenAndServe(ip, nil); err != nil {
fmt.Printf("start pprof failed on %s\n", ip)
os.Exit(1)
}
}()

tick := time.Tick(time.Second / 100)
var buf []byte
for range tick {
buf = append(buf, make([]byte, 1024*1024)...)
}
}

上面这个demo会不断的申请内存,把它编译运行起来,然后执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
$ go tool pprof http://localhost:6060/debug/pprof/heap

Fetching profile over HTTP from http://localhost:6060/debug/pprof/heap
Saved profile in /home/ubuntu/pprof/pprof.demo.alloc_objects.alloc_space.inuse_objects.inuse_space.001.pb.gz //<--- 下载到的内存profile文件
File: demo // 程序名称
Build ID: a9069a125ee9c0df3713b2149ca859e8d4d11d5a
Type: inuse_space
Time: May 16, 2019 at 8:55pm (CST)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)
(pprof)
(pprof) help // 使用help打印所有可用命令
Commands:
callgrind Outputs a graph in callgrind format
comments Output all profile comments
disasm Output assembly listings annotated with samples
dot Outputs a graph in DOT format
eog Visualize graph through eog
evince Visualize graph through evince
gif Outputs a graph image in GIF format
gv Visualize graph through gv
kcachegrind Visualize report in KCachegrind
list Output annotated source for functions matching regexp
pdf Outputs a graph in PDF format
peek Output callers/callees of functions matching regexp
png Outputs a graph image in PNG format
proto Outputs the profile in compressed protobuf format
ps Outputs a graph in PS format
raw Outputs a text representation of the raw profile
svg Outputs a graph in SVG format
tags Outputs all tags in the profile
text Outputs top entries in text form
top Outputs top entries in text form
topproto Outputs top entries in compressed protobuf format
traces Outputs all profile samples in text form
tree Outputs a text rendering of call graph
web Visualize graph through web browser
weblist Display annotated source in a web browser
o/options List options and their current values
quit/exit/^D Exit pprof

....

以上信息我们只关注2个地方:

  1. 下载得到的文件:/home/ubuntu/pprof/pprof.demo.alloc_objects.alloc_space.inuse_objects.inuse_space.001.pb.gz,这其中包含了程序名demo,profile类型alloc已分配的内存,inuse代表使用中的内存。
  2. help可以获取帮助,最先会列出支持的命令,想掌握pprof,要多看看,多尝试。

关于命令,本文只会用到3个,我认为也是最常用的:toplisttraces,分别介绍一下。

top

按指标大小列出前10个函数,比如内存是按内存占用多少,CPU是按执行时间多少。

1
2
3
4
5
(pprof) top
Showing nodes accounting for 814.62MB, 100% of 814.62MB total
flat flat% sum% cum cum%
814.62MB 100% 100% 814.62MB 100% main.main
0 0% 100% 814.62MB 100% runtime.main

top会列出5个统计数据:

  • flat: 本函数占用的内存量。
  • flat%: 本函数内存占使用中内存总量的百分比。
  • sum%: 前面每一行flat百分比的和,比如第2行虽然的100% 是 100% + 0%。
  • cum: 是累计量,加入main函数调用了函数f,函数f占用的内存量,也会记进来。
  • cum%: 是累计量占总量的百分比。

list

查看某个函数的代码,以及该函数每行代码的指标信息,如果函数名不明确,会进行模糊匹配,比如list main会列出main.mainruntime.main

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(pprof) list main.main  // 精确列出函数
Total: 814.62MB
ROUTINE ======================== main.main in /home/ubuntu/heap/demo2.go
814.62MB 814.62MB (flat, cum) 100% of Total
. . 20: }()
. . 21:
. . 22: tick := time.Tick(time.Second / 100)
. . 23: var buf []byte
. . 24: for range tick {
814.62MB 814.62MB 25: buf = append(buf, make([]byte, 1024*1024)...)
. . 26: }
. . 27:}
. . 28:
(pprof) list main // 匹配所有函数名带main的函数
Total: 814.62MB
ROUTINE ======================== main.main in /home/ubuntu/heap/demo2.go
814.62MB 814.62MB (flat, cum) 100% of Total
. . 20: }()
. . 21:
..... // 省略几行
. . 28:
ROUTINE ======================== runtime.main in /usr/lib/go-1.10/src/runtime/proc.go
0 814.62MB (flat, cum) 100% of Total
. . 193: // A program compiled with -buildmode=c-archive or c-shared
..... // 省略几行

可以看到在main.main中的第25行占用了814.62MB内存,左右2个数据分别是flat和cum,含义和top中解释的一样。

traces

打印所有调用栈,以及调用栈的指标信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
(pprof) traces
File: demo2
Type: inuse_space
Time: May 16, 2019 at 7:08pm (CST)
-----------+-------------------------------------------------------
bytes: 813.46MB
813.46MB main.main
runtime.main
-----------+-------------------------------------------------------
bytes: 650.77MB
0 main.main
runtime.main
....... // 省略几十行

每个- - - - - 隔开的是一个调用栈,能看到runtime.main调用了main.main,并且main.main中占用了813.46MB内存。

其他的profile操作和内存是类似的,这里就不展示了。

这里只是简单介绍本文用到的pprof的功能,pprof功能很强大,也经常和benchmark结合起来,但这不是本文的重点,所以就不多介绍了,为大家推荐几篇文章,一定要好好研读、实践:

  1. Go官方博客关于pprof的介绍,很详细,也包含样例,可以实操:Profiling Go Programs
  2. 跟煎鱼也讨论过pprof,煎鱼的这篇文章也很适合入门: Golang 大杀器之性能剖析 PProf

什么是内存泄露

内存泄露指的是程序运行过程中已不再使用的内存,没有被释放掉,导致这些内存无法被使用,直到程序结束这些内存才被释放的问题。

Go虽然有GC来回收不再使用的堆内存,减轻了开发人员对内存的管理负担,但这并不意味着Go程序不再有内存泄露问题。在Go程序中,如果没有Go语言的编程思维,也不遵守良好的编程实践,就可能埋下隐患,造成内存泄露问题。

怎么发现内存泄露

在Go中发现内存泄露有2种方法,一个是通用的监控工具,另一个是go pprof:

  1. 监控工具:固定周期对进程的内存占用情况进行采样,数据可视化后,根据内存占用走势(持续上升),很容易发现是否发生内存泄露。
  2. go pprof:适合没有监控工具的情况,使用Go提供的pprof工具判断是否发生内存泄露。

这2种方式分别介绍一下。

监控工具查看进程内在占用情况

如果使用云平台部署Go程序,云平台都提供了内存查看的工具,可以查看OS的内存占用情况和某个进程的内存占用情况,比如阿里云,我们在1个云主机上只部署了1个Go服务,所以OS的内存占用情况,基本是也反映了进程内存占用情况,OS内存占用情况如下,可以看到随着时间的推进,内存的占用率在不断的提高,这是内存泄露的最明显现象

image-20190512111200988

如果没有云平台这种内存监控工具,可以制作一个简单的内存记录工具。

1、建立一个脚本prog_mem.sh,获取进程占用的物理内存情况,脚本内容如下:

1
2
3
4
5
#!/bin/bash
prog_name="your_programe_name"
prog_mem=$(pidstat -r -u -h -C $prog_name |awk 'NR==4{print $12}')
time=$(date "+%Y-%m-%d %H:%M:%S")
echo $time"\tmemory(Byte)\t"$prog_mem >>~/record/prog_mem.log

2、然后使用crontab建立定时任务,每分钟记录1次。使用crontab -e编辑crontab配置,在最后增加1行:

1
*/1 * * * * ~/record/prog_mem.sh

脚本输出的内容保存在prog_mem.log,只要大体浏览一下就可以发现内存的增长情况,判断是否存在内存泄露。如果需要可视化,可以直接黏贴prog_mem.log内容到Excel等表格工具,绘制内存占用图。

image-20190512172935195

go pprof发现存在内存问题

有情提醒:如果对pprof不了解,可以先看[go pprof基本知识](#go pprof基本知识),这是下一节,看完再倒回来看。

如果你Google或者百度,Go程序内存泄露的文章,它总会告诉你使用pprof heap,能够生成漂亮的调用路径图,火焰图等等,然后你根据调用路径就能定位内存泄露问题,我最初也是对此深信不疑,尝试了若干天后,只是发现内存泄露跟某种场景有关,根本找不到内存泄露的根源,如果哪位朋友用heap就能定位内存泄露的线上问题,麻烦介绍下

后来读了Dave的《High Performance Go Workshop》,刷新了对heap的认识,内存pprof的简要内容如下:

image-20190512114048868

Dave讲了以下几点:

  1. 内存profiling记录的是堆内存分配的情况,以及调用栈信息,并不是进程完整的内存情况,猜测这也是在go pprof中称为heap而不是memory的原因。
  2. 栈内存的分配是在调用栈结束后会被释放的内存,所以并不在内存profile中
  3. 内存profiling是基于抽样的,默认是每1000次堆内存分配,执行1次profile记录。
  4. 因为内存profiling是基于抽样和它跟踪的是已分配的内存,而不是使用中的内存,(比如有些内存已经分配,看似使用,但实际以及不使用的内存,比如内存泄露的那部分),所以不能使用内存profiling衡量程序总体的内存使用情况
  5. Dave个人观点:使用内存profiling不能够发现内存泄露

基于目前对heap的认知,我有2个观点:

  1. heap能帮助我们发现内存问题,但不一定能发现内存泄露问题,这个看法与Dave是类似的。heap记录了内存分配的情况,我们能通过heap观察内存的变化,增长与减少,内存主要被哪些代码占用了,程序存在内存问题,这只能说明内存有使用不合理的地方,但并不能说明这是内存泄露。
  2. heap在帮助定位内存泄露原因上贡献的力量微乎其微。如第一条所言,能通过heap找到占用内存多的位置,但这个位置通常不一定是内存泄露,就算是内存泄露,也只是内存泄露的结果,并不是真正导致内存泄露的根源。

接下来,我介绍怎么用heap发现问题,然后再解释为什么heap几乎不能定位内存泄露的根因。

怎么用heap发现内存问题

使用pprof的heap能够获取程序运行时的内存信息,在程序平稳运行的情况下,每个一段时间使用heap获取内存的profile,然后使用base能够对比两个profile文件的差别,就像diff命令一样显示出增加和减少的变化,使用一个简单的demo来说明heap和base的使用,依然使用demo2进行展示。

文件:golang_step_by_step/pprof/heap/demo2.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 展示内存增长和pprof,并不是泄露
package main

import (
"fmt"
"net/http"
_ "net/http/pprof"
"os"
"time"
)

// 运行一段时间:fatal error: runtime: out of memory
func main() {
// 开启pprof
go func() {
ip := "0.0.0.0:6060"
if err := http.ListenAndServe(ip, nil); err != nil {
fmt.Printf("start pprof failed on %s\n", ip)
os.Exit(1)
}
}()

tick := time.Tick(time.Second / 100)
var buf []byte
for range tick {
buf = append(buf, make([]byte, 1024*1024)...)
}
}

将上面代码运行起来,执行以下命令获取profile文件,Ctrl-D退出,1分钟后再获取1次。

1
go tool pprof http://localhost:6060/debug/pprof/heap

我已经获取到了两个profile文件:

1
2
3
$ ls
pprof.demo2.alloc_objects.alloc_space.inuse_objects.inuse_space.001.pb.gz
pprof.demo2.alloc_objects.alloc_space.inuse_objects.inuse_space.002.pb.gz

使用base把001文件作为基准,然后用002和001对比,先执行toptop的对比,然后执行list main列出main函数的内存对比,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
$ go tool pprof -base pprof.demo2.alloc_objects.alloc_space.inuse_objects.inuse_space.001.pb.gz pprof.demo2.alloc_objects.alloc_space.inuse_objects.inuse_space.002.pb.gz

File: demo2
Type: inuse_space
Time: May 14, 2019 at 2:33pm (CST)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)
(pprof)
(pprof) top
Showing nodes accounting for 970.34MB, 32.30% of 3003.99MB total
flat flat% sum% cum cum%
970.34MB 32.30% 32.30% 970.34MB 32.30% main.main // 看这
0 0% 32.30% 970.34MB 32.30% runtime.main
(pprof)
(pprof)
(pprof) list main.main
Total: 2.93GB
ROUTINE ======================== main.main in /home/ubuntu/heap/demo2.go
970.34MB 970.34MB (flat, cum) 32.30% of Total
. . 20: }()
. . 21:
. . 22: tick := time.Tick(time.Second / 100)
. . 23: var buf []byte
. . 24: for range tick {
970.34MB 970.34MB 25: buf = append(buf, make([]byte, 1024*1024)...) // 看这
. . 26: }
. . 27:}
. . 28:

top列出了main.mainruntime.mainmain.main就是我们编写的main函数,runtime.main是runtime包中的main函数,也就是所有main函数的入口,这里不多介绍了,有兴趣可以看之前的调度器文章《Go调度器系列(2)宏观看调度器》

top显示main.main 第2次内存占用,比第1次内存占用多了970.34MB。

list main.main告诉了我们增长的内存都在这一行:

1
buf = append(buf, make([]byte, 1024*1024)...)

001和002 profile的文件不进去看了,你本地测试下计算差值,绝对是刚才对比出的970.34MB。

heap“不能”定位内存泄露

heap能显示内存的分配情况,以及哪行代码占用了多少内存,我们能轻易的找到占用内存最多的地方,如果这个地方的数值还在不断怎大,基本可以认定这里就是内存泄露的位置。

曾想按图索骥,从内存泄露的位置,根据调用栈向上查找,总能找到内存泄露的原因,这种方案看起来是不错的,但实施起来却找不到内存泄露的原因,结果是事半功倍。

原因在于一个Go程序,其中有大量的goroutine,这其中的调用关系也许有点复杂,也许内存泄露是在某个三方包里。举个栗子,比如下面这幅图,每个椭圆代表1个goroutine,其中的数字为编号,箭头代表调用关系。heap profile显示g111(最下方标红节点)这个协程的代码出现了泄露,任何一个从g101到g111的调用路径都可能造成了g111的内存泄露,有2类可能:

  1. 该goroutine只调用了少数几次,但消耗了大量的内存,说明每个goroutine调用都消耗了不少内存,内存泄露的原因基本就在该协程内部
  2. 该goroutine的调用次数非常多,虽然每个协程调用过程中消耗的内存不多,但该调用路径上,协程数量巨大,造成消耗大量的内存,并且这些goroutine由于某种原因无法退出,占用的内存不会释放,内存泄露的原因在到g111调用路径上某段代码实现有问题,造成创建了大量的g111

第2种情况,就是goroutine泄露,这是通过heap无法发现的,所以heap在定位内存泄露这件事上,发挥的作用不大

image-20190512144150064


goroutine泄露怎么导致内存泄露

什么是goroutine泄露

如果你启动了1个goroutine,但并没有符合预期的退出,直到程序结束,此goroutine才退出,这种情况就是goroutine泄露。

提前思考:什么会导致goroutine无法退出/阻塞?

goroutine泄露怎么导致内存泄露

每个goroutine占用2KB内存,泄露1百万goroutine至少泄露2KB * 1000000 = 2GB内存,为什么说至少呢?

goroutine执行过程中还存在一些变量,如果这些变量指向堆内存中的内存,GC会认为这些内存仍在使用,不会对其进行回收,这些内存谁都无法使用,造成了内存泄露。

所以goroutine泄露有2种方式造成内存泄露:

  1. goroutine本身的栈所占用的空间造成内存泄露。
  2. goroutine中的变量所占用的堆内存导致堆内存泄露,这一部分是能通过heap profile体现出来的。

Dave在文章中也提到了,如果不知道何时停止一个goroutine,这个goroutine就是潜在的内存泄露:

7.1.1 Know when to stop a goroutine

If you don’t know the answer, that’s a potential memory leak as the goroutine will pin its stack’s memory on the heap, as well as any heap allocated variables reachable from the stack.

怎么确定是goroutine泄露引发的内存泄露

掌握了前面的pprof命令行的基本用法,很快就可以确认是否是goroutine泄露导致内存泄露,如果你不记得了,马上回去看一下[go pprof基本知识](#go pprof基本知识)。

判断依据:在节点正常运行的情况下,隔一段时间获取goroutine的数量,如果后面获取的那次,某些goroutine比前一次多,如果多获取几次,是持续增长的,就极有可能是goroutine泄露

goroutine导致内存泄露的demo:

文件:golang_step_by_step/pprof/goroutine/leak_demo1.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// goroutine泄露导致内存泄露
package main

import (
"fmt"
"net/http"
_ "net/http/pprof"
"os"
"time"
)

func main() {
// 开启pprof
go func() {
ip := "0.0.0.0:6060"
if err := http.ListenAndServe(ip, nil); err != nil {
fmt.Printf("start pprof failed on %s\n", ip)
os.Exit(1)
}
}()

outCh := make(chan int)
// 死代码,永不读取
go func() {
if false {
<-outCh
}
select {}
}()

// 每s起100个goroutine,goroutine会阻塞,不释放内存
tick := time.Tick(time.Second / 100)
i := 0
for range tick {
i++
fmt.Println(i)
alloc1(outCh)
}
}

func alloc1(outCh chan<- int) {
go alloc2(outCh)
}

func alloc2(outCh chan<- int) {
func() {
defer fmt.Println("alloc-fm exit")
// 分配内存,假用一下
buf := make([]byte, 1024*1024*10)
_ = len(buf)
fmt.Println("alloc done")

outCh <- 0 // 53行
}()
}

编译并运行以上代码,然后使用go tool pprof获取gorourine的profile文件。

1
go tool pprof http://localhost:6060/debug/pprof/goroutine

已经通过pprof命令获取了2个goroutine的profile文件:

1
2
3
$ ls
/home/ubuntu/pprof/pprof.leak_demo.goroutine.001.pb.gz
/home/ubuntu/pprof/pprof.leak_demo.goroutine.002.pb.gz

同heap一样,我们可以使用base对比2个goroutine profile文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$go tool pprof -base pprof.leak_demo.goroutine.001.pb.gz pprof.leak_demo.goroutine.002.pb.gz

File: leak_demo
Type: goroutine
Time: May 16, 2019 at 2:44pm (CST)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)
(pprof) top
Showing nodes accounting for 20312, 100% of 20312 total
flat flat% sum% cum cum%
20312 100% 100% 20312 100% runtime.gopark
0 0% 100% 20312 100% main.alloc2
0 0% 100% 20312 100% main.alloc2.func1
0 0% 100% 20312 100% runtime.chansend
0 0% 100% 20312 100% runtime.chansend1
0 0% 100% 20312 100% runtime.goparkunlock
(pprof)

可以看到运行到runtime.gopark的goroutine数量增加了20312个。再通过002文件,看一眼执行到gopark的goroutine数量,即挂起的goroutine数量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
go tool pprof pprof.leak_demo.goroutine.002.pb.gz
File: leak_demo
Type: goroutine
Time: May 16, 2019 at 2:47pm (CST)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) top
Showing nodes accounting for 24330, 100% of 24331 total
Dropped 32 nodes (cum <= 121)
flat flat% sum% cum cum%
24330 100% 100% 24330 100% runtime.gopark
0 0% 100% 24326 100% main.alloc2
0 0% 100% 24326 100% main.alloc2.func1
0 0% 100% 24326 100% runtime.chansend
0 0% 100% 24326 100% runtime.chansend1
0 0% 100% 24327 100% runtime.goparkunlock

显示有24330个goroutine被挂起,这不是goroutine泄露这是啥?已经能确定八九成goroutine泄露了。

是什么导致如此多的goroutine被挂起而无法退出?接下来就看怎么定位goroutine泄露。


定位goroutine泄露的2种方法

使用pprof有2种方式,一种是web网页,一种是go tool pprof命令行交互,这两种方法查看goroutine都支持,但有轻微不同,也有各自的优缺点。

我们先看Web的方式,再看命令行交互的方式,这两种都很好使用,结合起来用也不错。

Web可视化查看

Web方式适合web服务器的端口能访问的情况,使用起来方便,有2种方式:

  1. 查看某条调用路径上,当前阻塞在此goroutine的数量
  2. 查看所有goroutine的运行栈(调用路径),可以显示阻塞在此的时间

方式一

url请求中设置debug=1:

1
http://ip:port/debug/pprof/goroutine?debug=1

效果如下:

看起来密密麻麻的,其实简单又十分有用,看上图标出来的部分,手机上图看起来可能不方便,那就放大图片,或直接看下面各字段的含义:

  1. goroutine profile: total 32023:32023是goroutine的总数量
  2. 32015 @ 0x42e15a 0x42e20e 0x40534b 0x4050e5 ...:32015代表当前有32015个goroutine运行这个调用栈,并且停在相同位置,@后面的十六进制,现在用不到这个数据,所以暂不深究了。
  3. 下面是当前goroutine的调用栈,列出了函数和所在文件的行数,这个行数对定位很有帮助,如下:
1
2
3
32015 @ 0x42e15a 0x42e20e 0x40534b 0x4050e5 0x6d8559 0x6d831b 0x45abe1
# 0x6d8558 main.alloc2.func1+0xf8 /home/ubuntu/heap/leak_demo.go:53
# 0x6d831a main.alloc2+0x2a /home/ubuntu/heap/leak_demo.go:54

根据上面的提示,就能判断32015个goroutine运行到leak_demo.go的53行:

1
2
3
4
5
6
7
8
9
10
11
func alloc2(outCh chan<- int) {
func() {
defer fmt.Println("alloc-fm exit")
// 分配内存,假用一下
buf := make([]byte, 1024*1024*10)
_ = len(buf)
fmt.Println("alloc done")

outCh <- 0 // 53行
}()
}

阻塞的原因是outCh这个写操作无法完成,outCh是无缓冲的通道,并且由于以下代码是死代码,所以goroutine始终没有从outCh读数据,造成outCh阻塞,进而造成无数个alloc2的goroutine阻塞,形成内存泄露:

1
2
3
if false {
<-outCh
}

方式二

url请求中设置debug=2:

1
http://ip:port/debug/pprof/goroutine?debug=2

第2种方式和第1种方式是互补的,它可以看到每个goroutine的信息:

  1. goroutine 20 [chan send, 2 minutes]:20是goroutine id,[]中是当前goroutine的状态,阻塞在写channel,并且阻塞了2分钟,长时间运行的系统,你能看到阻塞时间更长的情况。
  2. 同时,也可以看到调用栈,看当前执行停到哪了:leak_demo.go的53行,
1
2
3
4
5
6
7
goroutine 20 [chan send, 2 minutes]:
main.alloc2.func1(0xc42015e060)
/home/ubuntu/heap/leak_demo.go:53 +0xf9 // 这
main.alloc2(0xc42015e060)
/home/ubuntu/heap/leak_demo.go:54 +0x2b
created by main.alloc1
/home/ubuntu/heap/leak_demo.go:42 +0x3f

命令行交互式方法

Web的方法是简单粗暴,无需登录服务器,浏览器打开看看就行了。但就像前面提的,没有浏览器可访问时,命令行交互式才是最佳的方式,并且也是手到擒来,感觉比Web一样方便。

命令行交互式只有1种获取goroutine profile的方法,不像Web网页分debug=1debug=22中方式,并将profile文件保存到本地:

1
2
3
4
5
6
7
8
9
// 注意命令没有`debug=1`,debug=1,加debug有些版本的go不支持
$ go tool pprof http://0.0.0.0:6060/debug/pprof/goroutine
Fetching profile over HTTP from http://localhost:6061/debug/pprof/goroutine
Saved profile in /home/ubuntu/pprof/pprof.leak_demo.goroutine.001.pb.gz // profile文件保存位置
File: leak_demo
Type: goroutine
Time: May 16, 2019 at 2:44pm (CST)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)

命令行只需要掌握3个命令就好了,上面介绍过了,详细的倒回去看top, list, traces

  1. top:显示正运行到某个函数goroutine的数量
  2. traces:显示所有goroutine的调用栈
  3. list:列出代码详细的信息。

我们依然使用leak_demo.go这个demo,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
$  go tool pprof -base pprof.leak_demo.goroutine.001.pb.gz pprof.leak_demo.goroutine.002.pb.gz
File: leak_demo
Type: goroutine
Time: May 16, 2019 at 2:44pm (CST)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)
(pprof)
(pprof) top
Showing nodes accounting for 20312, 100% of 20312 total
flat flat% sum% cum cum%
20312 100% 100% 20312 100% runtime.gopark
0 0% 100% 20312 100% main.alloc2
0 0% 100% 20312 100% main.alloc2.func1
0 0% 100% 20312 100% runtime.chansend
0 0% 100% 20312 100% runtime.chansend1
0 0% 100% 20312 100% runtime.goparkunlock
(pprof)
(pprof) traces
File: leak_demo
Type: goroutine
Time: May 16, 2019 at 2:44pm (CST)
-----------+-------------------------------------------------------
20312 runtime.gopark
runtime.goparkunlock
runtime.chansend
runtime.chansend1 // channel发送
main.alloc2.func1 // alloc2中的匿名函数
main.alloc2
-----------+-------------------------------------------------------

top命令在怎么确定是goroutine泄露引发的内存泄露介绍过了,直接看traces命令,traces能列出002中比001中多的那些goroutine的调用栈,这里只有1个调用栈,有20312个goroutine都执行这个调用路径,可以看到alloc2中的匿名函数alloc2.func1调用了写channel的操作,然后阻塞挂起了goroutine,使用list列出alloc2.func1的代码,显示有20312个goroutine阻塞在53行:

1
2
3
4
5
6
7
8
9
10
11
12
13
(pprof) list main.alloc2.func1
Total: 20312
ROUTINE ======================== main.alloc2.func1 in /home/ubuntu/heap/leak_demo.go
0 20312 (flat, cum) 100% of Total
. . 48: // 分配内存,假用一下
. . 49: buf := make([]byte, 1024*1024*10)
. . 50: _ = len(buf)
. . 51: fmt.Println("alloc done")
. . 52:
. 20312 53: outCh <- 0 // 看这
. . 54: }()
. . 55:}
. . 56:

友情提醒:使用list命令的前提是程序的源码在当前机器,不然可没法列出源码。服务器上,通常没有源码,那我们咋办呢?刚才介绍了Web查看的方式,那里会列出代码行数,我们可以使用wget下载网页:

1
$ wget http://localhost:6060/debug/pprof/goroutine?debug=1

下载网页后,使用编辑器打开文件,使用关键字main.alloc2.func1进行搜索,找到与当前相同的调用栈,就可以看到该goroutine阻塞在哪一行了,不要忘记使用debug=2还可以看到阻塞了多久和原因,Web方式中已经介绍了,此处省略代码几十行。


总结

文章略长,但全是干货,感谢阅读到这。然读到着了,跟定很想掌握pprof,建议实践一把,现在和大家温习一把本文的主要内容。

goroutine泄露的本质

goroutine泄露的本质是channel阻塞,无法继续向下执行,导致此goroutine关联的内存都无法释放,进一步造成内存泄露。

goroutine泄露的发现和定位

利用好go pprof获取goroutine profile文件,然后利用3个命令top、traces、list定位内存泄露的原因。

goroutine泄露的场景

泄露的场景不仅限于以下两类,但因channel相关的泄露是最多的。

  1. channel的读或者写:
    1. 无缓冲channel的阻塞通常是写操作因为没有读而阻塞
    2. 有缓冲的channel因为缓冲区满了,写操作阻塞
    3. 期待从channel读数据,结果没有goroutine写
  2. select操作,select里也是channel操作,如果所有case上的操作阻塞,goroutine也无法继续执行。

编码goroutine泄露的建议

为避免goroutine泄露造成内存泄露,启动goroutine前要思考清楚:

  1. goroutine如何退出?
  2. 是否会有阻塞造成无法退出?如果有,那么这个路径是否会创建大量的goroutine?

示例源码

本文所有示例源码,及历史文章、代码都存储在Github,阅读原文可直接跳转,Github:https://github.com/Shitaibin/golang_step_by_step/tree/master/pprof

推荐阅读

这些既是参考资料也是推荐阅读的文章,不容错过。

【Go Blog关于pprof详细介绍和Demo】 https://blog.golang.org/profiling-go-programs

【Dave关于高性能Go程序的workshop】 https://dave.cheney.net/high-performance-go-workshop/dotgo-paris.html#using_more_than_one_cpu

【煎鱼pprof文章,很适合入门 Golang大杀器之性能剖析PProf】 https://segmentfault.com/a/1190000016412013

【SO上goroutine调用栈各字段的介绍】https://stackoverflow.com/a/38414527/4296218

【我的老文,有runtime.main的介绍,想学习调度器,可以看下系列文章 Go调度器系列(2)宏观看调度器】http://lessisbetter.site/2019/03/26/golang-scheduler-2-macro-view/

  1. 如果这篇文章对你有帮助,不妨关注下我的Github,有文章会收到通知。
  2. 本文作者:大彬
  3. 如果喜欢本文,随意转载,但请保留此原文链接:http://lessisbetter.site/2019/05/18/go-goroutine-leak/
关注公众号,获取最新Golang文章

Once

这个once的实现有没有什么问题?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Once struct {
m sync.Mutex
done uint32
}

func (o *Once) Do(f func()) {
if o.done == 1 {
return
}

o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
o.done = 1
f()
}
}

有。讨论见这里:https://github.com/smallnest/gitalk/issues/101#issuecomment-490738912

正确的姿势是使用原子操作,原子操作在修改变量的值后,会也让其他核立马看到数据的变动。Once.Do的官方实现就使用的原子操作:

1
2
3
4
5
6
7
8
9
10
11
12
func (o *Once) Do(f func()) {
if atomic.LoadUint32(&o.done) == 1 {
return
}
// Slow-path.
o.m.Lock()
defer o.m.Unlock()
if o.done == 0 {
defer atomic.StoreUint32(&o.done, 1)
f()
}
}

关于缓存,可以看鸟窝的《cacheline 对 Go 程序的影响》和知乎《细说Cache-L1/L2/L3/TLB》

Wait Group

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import (
"sync"
"time"
)

func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
time.Sleep(time.Millisecond)
wg.Done()
wg.Add(1)
}()
wg.Wait()
}

会panic:

1
2
3
4
5
6
7
8
panic: sync: WaitGroup is reused before previous Wait has returned

goroutine 1 [running]:
sync.(*WaitGroup).Wait(0xc000018090)
/Users/shitaibin/go/src/github.com/golang/go/src/sync/waitgroup.go:132 +0xae
main.main()
/Users/shitaibin/Workspace/golang_step_by_step/problems/concurrent/waitgroup0.go:16 +0x79
exit status 2

原因:第13行执行wg.Done()后,wg的计数已经变成了0,wg.Wait()实际以及完成并返回,14行再次使用此wg.Add()报错。

Mutex

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main

import (
"fmt"
"sync"
)

type MyMutex struct {
count int
sync.Mutex
}

func main() {
var mu MyMutex
mu.Lock()
var mu2 = mu
mu.count++
mu.Unlock()
mu2.Lock()
mu2.count++
mu2.Unlock()
fmt.Println(mu.count, mu2.count)
}

结果panic:

1
2
3
4
5
6
7
8
9
fatal error: all goroutines are asleep - deadlock!

goroutine 1 [semacquire]:
sync.runtime_SemacquireMutex(0xc0000180ac, 0x100ae00)
/Users/shitaibin/go/src/github.com/golang/go/src/runtime/sema.go:71 +0x3d
sync.(*Mutex).Lock(0xc0000180a8)
/Users/shitaibin/go/src/github.com/golang/go/src/sync/mutex.go:134 +0x109
main.main()
/Users/shitaibin/Workspace/golang_step_by_step/problems/concurrent/mutex0.go:19 +0xb4

原因:MyMutexsync.Mutex都是结构体,不包含指针,第16行根据mu新建了mu2对象,2者占用不同的内存区域,但2者的“内容”是相同的,所以mu2新建后就已经是Lock状态。第19行mu2.Lock()所以会死锁。

修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
gopackage main

import (
"fmt"
"sync"
)

type MyMutex struct {
count int
sync.Mutex
}

func main() {
var mu MyMutex
mu.Lock()
var mu2 = mu
mu.count++
mu.Unlock()
mu2.Unlock() // 先解锁,或新建mu2时移动到mu.Lock之前
mu2.Lock()
mu2.count++
mu2.Unlock()
fmt.Println(mu.count, mu2.count)
}

Pool

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main

import (
"bytes"
"fmt"
"runtime"
"sync"
"time"
)

var pool = sync.Pool{New: func() interface{} { return new(bytes.Buffer) }}

func main() {
go func() {
for {
processRequest(1 << 28) // 256MiB
}
}()
for i := 0; i < 1000; i++ {
go func() {
for {
processRequest(1 << 10) // 1KiB
}
}()
}
var stats runtime.MemStats
for i := 0; ; i++ {
runtime.ReadMemStats(&stats)
fmt.Printf("Cycle %d: %d MB\n", i, stats.Alloc/1024/1024)
time.Sleep(time.Second)
runtime.GC()
}
}
func processRequest(size int) {
b := pool.Get().(*bytes.Buffer)
time.Sleep(500 * time.Millisecond)
b.Grow(size)
pool.Put(b)
time.Sleep(1 * time.Millisecond)
}

可以编译,运行时内存先暴涨,但是过一会会回收掉。结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Cycle 0: 0 MB
Cycle 1: 256 MB
Cycle 2: 513 MB
Cycle 3: 769 MB
Cycle 4: 1281 MB
Cycle 5: 1281 MB
Cycle 6: 1281 MB
Cycle 7: 1537 MB
Cycle 8: 1793 MB
Cycle 9: 2049 MB
Cycle 10: 2049 MB
......
Cycle 107: 14593 MB
Cycle 108: 15105 MB
Cycle 109: 2304 MB
Cycle 110: 0 MB
Cycle 111: 256 MB
Cycle 112: 513 MB
......

sync.Pool用来存放经常使用的临时对象,如果每次这些内存被GC回收,会加大GC的压力,Pool的出现就是为减缓GC的压力,而不是完全不让GC回收Pool的内存。

关于Pool不可错过Dave在高性能Go程序的这段介绍

channel 1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
"fmt"
"runtime"
"time"
)

func main() {
var ch chan int
// g1
go func() {
ch = make(chan int, 1)
ch <- 1
}()
//g2
go func(ch chan int) {
time.Sleep(time.Second)
<-ch
}(ch)
c := time.Tick(1 * time.Second)
for range c {
fmt.Printf("#goroutines: %d\n", runtime.NumGoroutine())
}
}

结果是持续打印#goroutines: 2ch声明后为nil,在g1中被初始化为缓冲区大小为1的通道,g1向ch写数据后退出;通过参数把ch传递给g2时,ch还是nil,所以在g2内部ch为nil,从nil的通道读数据会阻塞,所以g2无法退出;另外Main协程不会退出,会持续遍历通道c,感谢Bububuger提醒,定时器的通道并不统计在NumGoroutine中,所以会打印存在2个goroutine。

channel 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package main

import "fmt"

func main() {
var ch chan int
var count int
go func() {
ch <- 1
}()
go func() {
count++
close(ch)
}()
<-ch
fmt.Println(count)
}

ch只声明,未进行初始化,所以panic:

1
2
3
4
5
6
7
8
panic: close of nil channel

goroutine 34 [running]:
main.main.func2(0xc000096000, 0x0)
/Users/shitaibin/Workspace/golang_step_by_step/problems/concurrent/channel1.go:13 +0x33
created by main.main
/Users/shitaibin/Workspace/golang_step_by_step/problems/concurrent/channel1.go:11 +0x87
exit status 2

修改为下面这样,还有问题吗?:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import "fmt"

func main() {
// var ch chan int
ch := make(chan int)
var count int
go func() {
ch <- 1
}()
go func() {
count++
close(ch)
}()
<-ch
fmt.Println(count)
}

同样会panic,典型的channel由非发送者关闭,造成在关闭的channel上写数据。

1
2
3
4
5
6
7
8
9
1
panic: send on closed channel

goroutine 4 [running]:
main.main.func1(0xc000070060)
/Users/shitaibin/Workspace/golang_step_by_step/problems/concurrent/channel1.go:10 +0x37
created by main.main
/Users/shitaibin/Workspace/golang_step_by_step/problems/concurrent/channel1.go:9 +0x80
exit status 2

Map 1

1
2
3
4
5
6
7
8
9
10
11
12
13
package main

import (
"fmt"
"sync"
)

func main() {
var m sync.Map
m.LoadOrStore("a", 1)
m.Delete("a")
fmt.Println(m.Len())
}

无法编译,因为Map没有Len()方法。

Map 2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package main

import "sync"

type Map struct {
m map[int]int
sync.Mutex
}

func (m *Map) Get(key int) (int, bool) {
m.Lock()
defer m.Unlock()
i, ok := m.m[key]
return i, ok
}

func (m *Map) Put(key, value int) {
m.Lock()
defer m.Unlock()
m.m[key] = value
}

func (m *Map) Len() int {
return len(m.m)
}

func main() {
var wg sync.WaitGroup
wg.Add(2)
m := Map{m: make(map[int]int)}
go func() {
for i := 0; i < 10000000; i++ {
m.Put(i, i)
}
wg.Done()
}()
go func() {
for i := 0; i < 10000000; i++ {
m.Len()
}
wg.Done()
}()
wg.Wait()
}

能正常编译和运行。map不是协程安全的,需要锁的保护,但Len()的实现并没有加锁,当map写数据时,并且调用Len读长度,则存在map的并发读写问题,因为不是同时读写map所存的内容,所以可以编译和运行,但存在读取的map内存长度不准确问题。map定义和len的声明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// A header for a Go map.
type hmap struct {
// Note: the format of the hmap is also encoded in cmd/compile/internal/gc/reflect.go.
// Make sure this stays in sync with the compiler's definition.
count int // # live cells == size of map. Must be first (used by len() builtin)
flags uint8
B uint8 // log_2 of # of buckets (can hold up to loadFactor * 2^B items)
noverflow uint16 // approximate number of overflow buckets; see incrnoverflow for details
hash0 uint32 // hash seed

buckets unsafe.Pointer // array of 2^B Buckets. may be nil if count==0.
oldbuckets unsafe.Pointer // previous bucket array of half the size, non-nil only when growing
nevacuate uintptr // progress counter for evacuation (buckets less than this have been evacuated)

extra *mapextra // optional fields
}

// The len built-in function returns the length of v, according to its type:
// Array: the number of elements in v.
// Pointer to array: the number of elements in *v (even if v is nil).
// Slice, or map: the number of elements in v; if v is nil, len(v) is zero.
// String: the number of bytes in v.
// Channel: the number of elements queued (unread) in the channel buffer;
// if v is nil, len(v) is zero.
// For some arguments, such as a string literal or a simple array expression, the
// result can be a constant. See the Go language specification's "Length and
// capacity" section for details.
func len(v Type) int

slice

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
package main

import (
"fmt"
"sync"
)

func main() {
var wg sync.WaitGroup
wg.Add(2)
var ints = make([]int, 0, 1000)
go func() {
for i := 0; i < 1000; i++ {
ints = append(ints, i)
}
wg.Done()
}()
go func() {
for i := 0; i < 1000; i++ {
ints = append(ints, i)
}
wg.Done()
}()
wg.Wait()
fmt.Println(len(ints))
}

首先,slice不是协程安全的,自身也又没锁的保护,多协程访问存在并发问题:

1
2
3
4
5
type slice struct {
array unsafe.Pointer
len int
cap int
}

其次,append中有可能还会分配新的内存空间,切片可能指向了新的内存区域:

1
2
3
4
5
6
7
8
9
10
// The append built-in function appends elements to the end of a slice. If
// it has sufficient capacity, the destination is resliced to accommodate the
// new elements. If it does not, a new underlying array will be allocated.
// Append returns the updated slice. It is therefore necessary to store the
// result of append, often in the variable holding the slice itself:
// slice = append(slice, elem1, elem2)
// slice = append(slice, anotherSlice...)
// As a special case, it is legal to append a string to a byte slice, like this:
// slice = append([]byte("hello "), "world"...)
func append(slice []Type, elems ...Type) []Type

所以,两个协程同时写,是不安全的,并且大概率可能存在数据丢失,所以结果可能不是2000。

源码

golang_step_by_step

  1. 如果这篇文章对你有帮助,不妨关注下我的Github,有文章会收到通知。
  2. 本文作者:大彬
  3. 如果喜欢本文,随意转载,但请保留此原文链接:http://lessisbetter.site/2019/05/03/go-concurrent-problems1/
关注公众号,获取最新Golang文章

以太坊的每一个账号私钥都通过密码加密存放到一个keystore文件中,如果创建一个账户涉及:

  1. 生成ECDSA密钥
  2. 使用密码
  3. 将加密信息生成json格式,等待写入到keystore文件
  4. 创建keystore文件
  5. 将信息写入到keystore文件

经过测试,这一个过程需要耗时大约5s,也就是每创建一个账户都要耗时5s,如果要创建1万个测试账号,需要5万秒,需要将近14小时,需要进行并发,才能尽量减少时间浪费,但是,使用5s才生成1个账号,还是太浪费了。

读取1个keystore文件涉及:

  1. 读keystore文件
  2. 对数据进行解密,将账号保存到内存

经过测试,这个过程需要耗时大约3s,1万个账号顺序读取需要大约8小时。

能不能并发读取?
不能。读取keystore时,指定的是keystore文件所在的目录,它会读取所有的keystore文件,在内存中保存到1个Keystore对象中。

如果我们写一个测试程序,以太坊账号的管理,简直是一个噩梦。

有没有其他办法,能够加快账号的读取,交易的签名等?

交易的签名本质是使用go的ecdsa.PrivateKey,没有以太坊这些账户加密和解密操作,这些操作是上述测试耗时的原因,直接使用ecdsa.PrivateKey,使用后保存到文件,使用时从文件读取,可以从若干小时,降低到不到1s。

我们使用ecdsa.PrivateKey的思路:

  1. 使用ecdsa.GenerateKey()生成私钥
  2. 私钥能转为16进制字符串,把字符串保存到文本文件,每一行都是1个私钥的16进制
  3. 使用时从文件读取每一个账号的16进制字符串,然后转换成所有的私钥

16进制字符串和ecdsa.PrivateKey相互转换是非常简单的ecdsa.Private和以太坊的账号转换,则是非常乱的,整理出来了一幅图,没这幅图,每次看都要梳理一段时间,浪费生命。

图注:

  1. keystore代表包名,Keystore代表类型
  2. ()为函数,否则为类型

各位朋友,这次想跟大家分享一下Go调度器源码阅读相关的知识和经验,网络上已经有很多剖析源码的好文章,所以这篇文章不是又一篇源码剖析文章,注重的不是源码分析分享,而是带给大家一些学习经验,希望大家能更好的阅读和掌握Go调度器的实现

本文主要分2个部分:

  1. 解决如何阅读源码的问题。阅读源码本质是把脑海里已经有的调度设计,看看到底是不是这么实现的,是怎么实现的。
  2. 带给你一个探索Go调度器实现的办法。源码都到手了,你可以修改、窥探,通过这种方式解决阅读源码过程中的疑问,验证一些想法。比如:负责调度的是g0,怎么才能schedule()在执行时,当前是g0呢?

如何阅读源码

阅读前提

阅读Go源码前,最好已经掌握Go调度器的设计和原理,如果你还无法回答以下问题:

  1. 为什么需要Go调度器?
  2. Go调度器与系统调度器有什么区别和关系/联系?
  3. G、P、M是什么,三者的关系是什么?
  4. P有默认几个?
  5. M同时能绑定几个P?
  6. M怎么获得G?
  7. M没有G怎么办?
  8. 为什么需要全局G队列?
  9. Go调度器中的负载均衡的2种方式是什么?
  10. work stealing是什么?什么原理?
  11. 系统调用对G、P、M有什么影响?
  12. Go调度器抢占是什么样的?一定能抢占成功吗?

建议阅读Go调度器系列文章,以及文章中的参考资料:

  1. Go调度器系列(1)起源
  2. Go调度器系列(2)宏观看调度器
  3. Go调度器系列(3)图解调度原理

优秀源码资料推荐

既然你已经能回答以上问题,说明你对Go调度器的设计已经有了一定的掌握,关于Go调度器源码的优秀资料已经有很多,我这里推荐2个:

  1. 雨痕的Go源码剖析六章并发调度,不止是源码,是以源码为基础进行了详细的Go调度器介绍:https://github.com/qyuhen/book
  2. Go夜读第12期,golang中goroutine的调度,M、P、G各自的一生状态,以及转换关系:https://reading.developerlearning.cn/reading/12-2018-08-02-goroutine-gpm/

Go调度器的源码还涉及GC等,阅读源码时,可以暂时先跳过,主抓调度的逻辑。

另外,Go调度器涉及汇编,也许你不懂汇编,不用担心,雨痕的文章对汇编部分有进行解释。

最后,送大家一幅流程图,画出了主要的调度流程,大家也可边阅读边画,增加理解,高清版可到博客下载(原图原文跳转)

如何探索调度器

这部分教你探索Go调度器的源码,验证想法,主要思想就是,下载Go的源码,添加调试打印,编译修改的源文件,生成修改的go,然后使用修改go运行测试代码,观察结果。

下载和编译Go

  1. Github下载,并且换到go1.11.2分支,本文所有代码修改都基于go1.11.2版本。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    $ GODIR=$GOPATH/src/github.com/golang/go
    $ mkdir -p $GODIR
    $ cd $GODIR/..
    $ git clone https://github.com/golang/go.git
    $ cd go
    $ git fetch origin go1.11.2
    $ git checkout origin/go1.11.2
    $ git checkout -b go1.11.2
    $ git checkout go1.11.2
  2. 初次编译,会跑测试,耗时长一点

    1
    2
    $ cd $GODIR/src
    $ ./all.bash
  3. 以后每次修改go源码后可以这样,4分钟左右可以编译完成

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    $ cd  $GODIR/src
    $ time ./make.bash
    Building Go cmd/dist using /usr/local/go.
    Building Go toolchain1 using /usr/local/go.
    Building Go bootstrap cmd/go (go_bootstrap) using Go toolchain1.
    Building Go toolchain2 using go_bootstrap and Go toolchain1.
    Building Go toolchain3 using go_bootstrap and Go toolchain2.
    Building packages and commands for linux/amd64.
    ---
    Installed Go for linux/amd64 in /home/xxx/go/src/github.com/golang/go
    Installed commands in /home/xxx/go/src/github.com/golang/go/bin

    real 1m11.675s
    user 4m4.464s
    sys 0m18.312s

    编译好的go和gofmt在$GODIR/bin目录。

    1
    2
    3
    4
    $ ll $GODIR/bin
    total 16044
    -rwxrwxr-x 1 vnt vnt 13049123 Apr 14 10:53 go
    -rwxrwxr-x 1 vnt vnt 3377614 Apr 14 10:53 gofmt
  4. 为了防止我们修改的go和过去安装的go冲突,创建igo软连接,指向修改的go。

    1
    2
    3
    $ mkdir -p ~/testgo/bin
    $ cd ~/testgo/bin
    $ ln -sf $GODIR/bin/go igo
  5. 最后,把~/testgo/bin加入到PATH,就能使用igo来编译代码了,运行下igo,应当获得go1.11.2的版本:

    1
    2
    $ igo version
    go version go1.11.2 linux/amd64

当前,已经掌握编译和使用修改的go的办法,接下来就以1个简单的例子,教大家如何验证想法。

验证schedule()由g0执行

阅读源码的文章,你已经知道了g0是负责调度的,并且g0是全局变量,可在runtime包的任何地方直接使用,看到schedule()代码如下(所在文件:$GODIR/src/runtime/proc.go):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
// 获取当前g,调度时这个g应当是g0
_g_ := getg()

if _g_.m.locks != 0 {
throw("schedule: holding locks")
}

// m已经被某个g锁定,先停止当前m,等待g可运行时,再执行g,并且还得到了g所在的p
if _g_.m.lockedg != 0 {
stoplockedm()
execute(_g_.m.lockedg.ptr(), false) // Never returns.
}

// 省略...
}

问题:既然g0是负责调度的,为何schedule()每次还都执行_g_ := getg(),直接使用g0不行吗?schedule()真的是g0执行的吗?

《Go调度器系列(2)宏观看调度器》这篇文章中我曾介绍了trace的用法,阅读代码时发现使用debug.schedtraceprint()函数可以用作打印调试信息,那我们是不是可以使用这种方法打印我们想获取的信息呢?当然可以。

另外,注意print()并不是fmt.Print(),也不是C语言的printf,所以不是格式化输出,它是汇编实现的,我们不深入去了解它的实现了,现在要掌握它的用法:

1
2
3
4
5
// The print built-in function formats its arguments in an
// implementation-specific way and writes the result to standard error.
// Print is useful for bootstrapping and debugging; it is not guaranteed
// to stay in the language.
func print(args ...Type)

从上面可以看到,它接受可变长参数,我们使用的时候只需要传进去即可,但要手动控制格式。

我们修改schedule()函数,使用debug.schedtrace > 0控制打印,加入3行代码,把goid给打印出来,如果始终打印goid为0,则代表调度确实是由g0执行的:

1
2
3
if debug.schedtrace > 0 {
print("schedule(): goid = ", _g_.goid, "\n") // 会是0吗?是的
}

schedule()如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// One round of scheduler: find a runnable goroutine and execute it.
// Never returns.
func schedule() {
// 获取当前g,调度时这个g应当是g0
_g_ := getg()

if debug.schedtrace > 0 {
print("schedule(): goid = ", _g_.goid, "\n") // 会是0吗?是的
}

if _g_.m.locks != 0 {
throw("schedule: holding locks")
}
// ...
}

编译igo:

1
2
$ cd  $GODIR/src
$ ./make.bash

编写一个简单的demo(不能更简单):

1
2
3
4
package main

func main() {
}

结果如下,你会发现所有的schedule()函数调用都打印goid = 0,足以证明Go调度器的调度由g0完成(如果你认为还是缺乏说服力,可以写复杂一些的demo):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
$ GODEBUG=schedtrace=1000 igo run demo1.go
schedule(): goid = 0
schedule(): goid = 0
SCHED 0ms: gomaxprocs=8 idleprocs=6 threads=4 spinningthreads=1 idlethreads=0 runqueue=0 [0 0 0 0 0 0 0 0]
schedule(): goid = 0
schedule(): goid = 0
schedule(): goid = 0
schedule(): goid = 0
schedule(): goid = 0
schedule(): goid = 0
schedule(): goid = 0
schedule(): goid = 0
schedule(): goid = 0
schedule(): goid = 0
schedule(): goid = 0
schedule(): goid = 0
schedule(): goid = 0
schedule(): goid = 0
// 省略几百行

启发比结论更重要,希望各位朋友在学习Go调度器的时候,能多一些自己的探索和研究,而不仅仅停留在看看别人文章之上

参考资料

  1. Installing Go from source
  1. 如果这篇文章对你有帮助,不妨关注下我的Github,有文章会收到通知。
  2. 本文作者:大彬
  3. 如果喜欢本文,随意转载,但请保留此原文链接:http://lessisbetter.site/2019/04/14/golang-scheduler-4-explore-source-code/
关注公众号,获取最新Golang文章

如果你已经阅读了前2篇文章:《调度起源》《宏观看调度器》,你对G、P、M肯定已经不再陌生,我们这篇文章就介绍Go调度器的基本原理,本文总结了12个主要的场景,覆盖了以下内容:

  1. G的创建和分配。
  2. P的本地队列和全局队列的负载均衡。
  3. M如何寻找G。
  4. M如何从G1切换到G2。
  5. work stealing,M如何去偷G。
  6. 为何需要自旋线程。
  7. G进行系统调用,如何保证P的其他G’可以被执行,而不是饿死。
  8. Go调度器的抢占。

12场景

提示:图在前,场景描述在后。

上图中三角形、正方形、圆形分别代表了M、P、G,正方形连接的绿色长方形代表了P的本地队列。

场景1:p1拥有g1,m1获取p1后开始运行g1,g1使用go func()创建了g2,为了局部性g2优先加入到p1的本地队列。

场景2g1运行完成后(函数:goexit),m上运行的goroutine切换为g0,g0负责调度时协程的切换(函数:schedule。从p1的本地队列取g2,从g0切换到g2,并开始运行g2(函数:execute)。实现了线程m1的复用

场景3:假设每个p的本地队列只能存4个g。g2要创建了6个g,前4个g(g3, g4, g5, g6)已经加入p1的本地队列,p1本地队列满了。

蓝色长方形代表全局队列。

场景4:g2在创建g7的时候,发现p1的本地队列已满,需要执行负载均衡,把p1中本地队列中前一半的g,还有新创建的g转移到全局队列(实现中并不一定是新的g,如果g是g2之后就执行的,会被保存在本地队列,利用某个老的g替换新g加入全局队列),这些g被转移到全局队列时,会被打乱顺序。所以g3,g4,g7被转移到全局队列。

场景5:g2创建g8时,p1的本地队列未满,所以g8会被加入到p1的本地队列。

场景6在创建g时,运行的g会尝试唤醒其他空闲的p和m执行。假定g2唤醒了m2,m2绑定了p2,并运行g0,但p2本地队列没有g,m2此时为自旋线程(没有G但为运行状态的线程,不断寻找g,后续场景会有介绍)。

场景7:m2尝试从全局队列(GQ)取一批g放到p2的本地队列(函数:findrunnable)。m2从全局队列取的g数量符合下面的公式:

1
n = min(len(GQ)/GOMAXPROCS + 1, len(GQ/2))

公式的含义是,至少从全局队列取1个g,但每次不要从全局队列移动太多的g到p本地队列,给其他p留点。这是从全局队列到P本地队列的负载均衡

假定我们场景中一共有4个P,所以m2只从能从全局队列取1个g(即g3)移动p2本地队列,然后完成从g0到g3的切换,运行g3。

场景8:假设g2一直在m1上运行,经过2轮后,m2已经把g7、g4也挪到了p2的本地队列并完成运行,全局队列和p2的本地队列都空了,如上图左边。

全局队列已经没有g,那m就要执行work stealing:从其他有g的p哪里偷取一半g过来,放到自己的P本地队列。p2从p1的本地队列尾部取一半的g,本例中一半则只有1个g8,放到p2的本地队列,情况如上图右边。

场景9:p1本地队列g5、g6已经被其他m偷走并运行完成,当前m1和m2分别在运行g2和g8,m3和m4没有goroutine可以运行,m3和m4处于自旋状态,它们不断寻找goroutine。为什么要让m3和m4自旋,自旋本质是在运行,线程在运行却没有执行g,就变成了浪费CPU?销毁线程不是更好吗?可以节约CPU资源。创建和销毁CPU都是浪费时间的,我们希望当有新goroutine创建时,立刻能有m运行它,如果销毁再新建就增加了时延,降低了效率。当然也考虑了过多的自旋线程是浪费CPU,所以系统中最多有GOMAXPROCS个自旋的线程,多余的没事做线程会让他们休眠(见函数:notesleep())。

场景10:假定当前除了m3和m4为自旋线程,还有m5和m6为自旋线程,g8创建了g9,g8进行了阻塞的系统调用,m2和p2立即解绑,p2会执行以下判断:如果p2本地队列有g、全局队列有g或有空闲的m,p2都会立马唤醒1个m和它绑定,否则p2则会加入到空闲P列表,等待m来获取可用的p。本场景中,p2本地队列有g,可以和其他自旋线程m5绑定。

场景11:(无图场景)g8创建了g9,假如g8进行了非阻塞系统调用(CGO会是这种方式,见cgocall()),m2和p2会解绑,但m2会记住p,然后g8和m2进入系统调用状态。当g8和m2退出系统调用时,会尝试获取p2,如果无法获取,则获取空闲的p,如果依然没有,g8会被记为可运行状态,并加入到全局队列。

场景12:(无图场景)Go调度在go1.12实现了抢占,应该更精确的称为请求式抢占,那是因为go调度器的抢占和OS的线程抢占比起来很柔和,不暴力,不会说线程时间片到了,或者更高优先级的任务到了,执行抢占调度。go的抢占调度柔和到只给goroutine发送1个抢占请求,至于goroutine何时停下来,那就管不到了。抢占请求需要满足2个条件中的1个:1)G进行系统调用超过20us,2)G运行超过10ms。调度器在启动的时候会启动一个单独的线程sysmon,它负责所有的监控工作,其中1项就是抢占,发现满足抢占条件的G时,就发出抢占请求。

场景融合

如果把上面所有的场景都融合起来,就能构成下面这幅图了,它从整体的角度描述了Go调度器各部分的关系。图的上半部分是G的创建、负债均衡和work stealing,下半部分是M不停寻找和执行G的迭代过程。

如果你看这幅图还有些似懂非懂,建议赶紧开始看雨痕大神的Golang源码剖析,章节:并发调度。

总结,Go调度器和OS调度器相比,是相当的轻量与简单了,但它已经足以撑起goroutine的调度工作了,并且让Go具有了原生(强大)并发的能力,这是伟大的。如果你记住的不多,你一定要记住这一点:Go调度本质是把大量的goroutine分配到少量线程上去执行,并利用多核并行,实现更强大的并发。

下集预告

下篇会是源码层面的内容了,关于源码分析的书籍、文章可以先看起来了,先剧透一篇图,希望阅读下篇文章赶紧关注本公众号。

推荐阅读

Go调度器系列(1)起源
Go调度器系列(2)宏观看调度器

参考资料

在学习调度器的时候,看了很多文章,这里列一些重要的:

  1. The Go scheduler
  2. Go’s work-stealing scheduler中文翻译版
  3. Go夜读:golang 中 goroutine 的调度
  4. Scheduling In Go : Part I、II、III 中文翻译版
  5. 雨痕大神的golang源码剖析
  6. 也谈goroutine调度器
  7. kavya的调度PPT
  8. 抢占的设计提案,Proposal: Non-cooperative goroutine preemption
  1. 如果这篇文章对你有帮助,不妨关注下我的Github,有文章会收到通知。
  2. 本文作者:大彬
  3. 如果喜欢本文,随意转载,但请保留此原文链接:http://lessisbetter.site/2019/04/04/golang-scheduler-3-principle-with-graph/
关注公众号,获取最新Golang文章

上一篇文章《Go语言高阶:调度器系列(1)起源》,学goroutine调度器之前的一些背景知识,这篇文章则是为了对调度器有个宏观的认识,从宏观的3个角度,去看待和理解调度器是什么样子的,但仍然不涉及具体的调度原理

三个角度分别是:

  1. 调度器的宏观组成
  2. 调度器的生命周期
  3. GMP的可视化感受

在开始前,先回忆下调度器相关的3个缩写:

  • G: goroutine,每个G都代表1个goroutine
  • M: 工作线程,是Go语言定义出来在用户层面描述系统线程的对象 ,每个M代表一个系统线程
  • P: 处理器,它包含了运行Go代码的资源。

3者的简要关系是P拥有G,M必须和一个P关联才能运行P拥有的G。

调度器的功能

《Go语言高阶:调度器系列(1)起源》中介绍了协程和线程的关系,协程需要运行在线程之上,线程由CPU进行调度。

在Go中,线程是运行goroutine的实体,调度器的功能是把可运行的goroutine分配到工作线程上

Go的调度器也是经过了多个版本的开发才是现在这个样子的,

  • 1.0版本发布了最初的、最简单的调度器,是G-M模型,存在4类问题
  • 1.1版本重新设计,修改为G-P-M模型,奠定当前调度器基本模样
  • 1.2版本加入了抢占式调度,防止协程不让出CPU导致其他G饿死

$GOROOT/src/runtime/proc.go的开头注释中包含了对Scheduler的重要注释,介绍Scheduler的设计曾拒绝过3种方案以及原因,本文不再介绍了,希望你不要忽略为数不多的官方介绍。

Scheduler的宏观组成

Tony Bai《也谈goroutine调度器》中的这幅图,展示了goroutine调度器和系统调度器的关系,而不是把二者割裂开来,并且从宏观的角度展示了调度器的重要组成。

自顶向下是调度器的4个部分:

  1. 全局队列(Global Queue):存放等待运行的G。
  2. P的本地队列:同全局队列类似,存放的也是等待运行的G,存的数量有限,不超过256个。新建G’时,G’优先加入到P的本地队列,如果队列满了,则会把本地队列中一半的G移动到全局队列。
  3. P列表:所有的P都在程序启动时创建,并保存在数组中,最多有GOMAXPROCS个。
  4. M:线程想运行任务就得获取P,从P的本地队列获取G,P队列为空时,M也会尝试从全局队列一批G放到P的本地队列,或从其他P的本地队列一半放到自己P的本地队列。M运行G,G执行之后,M会从P获取下一个G,不断重复下去。

Goroutine调度器和OS调度器是通过M结合起来的,每个M都代表了1个内核线程,OS调度器负责把内核线程分配到CPU的核上执行

调度器的生命周期

接下来我们从另外一个宏观角度——生命周期,认识调度器。

所有的Go程序运行都会经过一个完整的调度器生命周期:从创建到结束。

即使下面这段简单的代码:

1
2
3
4
5
6
7
8
package main

import "fmt"

// main.main
func main() {
fmt.Println("Hello scheduler")
}

也会经历如上图所示的过程:

  1. runtime创建最初的线程m0和goroutine g0,并把2者关联。
  2. 调度器初始化:初始化m0、栈、垃圾回收,以及创建和初始化由GOMAXPROCS个P构成的P列表。
  3. 示例代码中的main函数是main.mainruntime中也有1个main函数——runtime.main,代码经过编译后,runtime.main会调用main.main,程序启动时会为runtime.main创建goroutine,称它为main goroutine吧,然后把main goroutine加入到P的本地队列。
  4. 启动m0,m0已经绑定了P,会从P的本地队列获取G,获取到main goroutine。
  5. G拥有栈,M根据G中的栈信息和调度信息设置运行环境
  6. M运行G
  7. G退出,再次回到M获取可运行的G,这样重复下去,直到main.main退出,runtime.main执行Defer和Panic处理,或调用runtime.exit退出程序。

调度器的生命周期几乎占满了一个Go程序的一生,runtime.main的goroutine执行之前都是为调度器做准备工作,runtime.main的goroutine运行,才是调度器的真正开始,直到runtime.main结束而结束。

GMP的可视化感受

上面的两个宏观角度,都是根据文档、代码整理出来,最后我们从可视化角度感受下调度器,有2种方式。

方式1:go tool trace

trace记录了运行时的信息,能提供可视化的Web页面。

简单测试代码:main函数创建trace,trace会运行在单独的goroutine中,然后main打印”Hello trace”退出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func main() {
// 创建trace文件
f, err := os.Create("trace.out")
if err != nil {
panic(err)
}
defer f.Close()

// 启动trace goroutine
err = trace.Start(f)
if err != nil {
panic(err)
}
defer trace.Stop()

// main
fmt.Println("Hello trace")
}

运行程序和运行trace:

1
2
3
4
5
6
7
8
9
➜  trace git:(master) ✗ go run trace1.go
Hello trace
➜ trace git:(master) ✗ ls
trace.out trace1.go
➜ trace git:(master) ✗
➜ trace git:(master) ✗ go tool trace trace.out
2019/03/24 20:48:22 Parsing trace...
2019/03/24 20:48:22 Splitting trace...
2019/03/24 20:48:22 Opening browser. Trace viewer is listening on http://127.0.0.1:55984

效果:

trace1

从上至下分别是goroutine(G)、堆、线程(M)、Proc(P)的信息,从左到右是时间线。用鼠标点击颜色块,最下面会列出详细的信息。

我们可以发现:

  • runtime.main的goroutine是g1,这个编号应该永远都不变的,runtime.main是在g0之后创建的第一个goroutine。
  • g1中调用了main.main,创建了trace goroutine g18。g1运行在P2上,g18运行在P0上。
  • P1上实际上也有goroutine运行,可以看到短暂的竖线。

go tool trace的资料并不多,如果感兴趣可阅读:https://making.pusher.com/go-tool-trace/ ,中文翻译是:https://mp.weixin.qq.com/s/nf_-AH_LeBN3913Pt6CzQQ

方式2:Debug trace

示例代码:

1
2
3
4
5
6
7
// main.main
func main() {
for i := 0; i < 5; i++ {
time.Sleep(time.Second)
fmt.Println("Hello scheduler")
}
}

编译和运行,运行过程会打印trace:

1
2
➜  one_routine2 git:(master) ✗ go build .
➜ one_routine2 git:(master) ✗ GODEBUG=schedtrace=1000 ./one_routine2

结果:

1
2
3
4
5
6
7
8
9
10
11
SCHED 0ms: gomaxprocs=8 idleprocs=5 threads=5 spinningthreads=1 idlethreads=0 runqueue=0 [0 0 0 0 0 0 0 0]
SCHED 1001ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]
Hello scheduler
SCHED 2002ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]
Hello scheduler
SCHED 3004ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]
Hello scheduler
SCHED 4005ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]
Hello scheduler
SCHED 5013ms: gomaxprocs=8 idleprocs=8 threads=5 spinningthreads=0 idlethreads=3 runqueue=0 [0 0 0 0 0 0 0 0]
Hello scheduler

看到这密密麻麻的文字就有点担心,不要愁!因为每行字段都是一样的,各字段含义如下:

  • SCHED:调试信息输出标志字符串,代表本行是goroutine调度器的输出;
  • 0ms:即从程序启动到输出这行日志的时间;
  • gomaxprocs: P的数量,本例有8个P;
  • idleprocs: 处于idle状态的P的数量;通过gomaxprocs和idleprocs的差值,我们就可知道执行go代码的P的数量;
  • threads: os threads/M的数量,包含scheduler使用的m数量,加上runtime自用的类似sysmon这样的thread的数量;
  • spinningthreads: 处于自旋状态的os thread数量;
  • idlethread: 处于idle状态的os thread的数量;
  • runqueue=0: Scheduler全局队列中G的数量;
  • [0 0 0 0 0 0 0 0]: 分别为8个P的local queue中的G的数量。

看第一行,含义是:刚启动时创建了8个P,其中5个空闲的P,共创建5个M,其中1个M处于自旋,没有M处于空闲,8个P的本地队列都没有G。

再看个复杂版本的,加上scheddetail=1可以打印更详细的trace信息。

命令:

1
➜  one_routine2 git:(master) ✗ GODEBUG=schedtrace=1000,scheddetail=1 ./one_routine2

结果:


截图可能更代码匹配不起来,最初代码是for死循环,后面为了减少打印加了限制循环5次

每次分别打印了每个P、M、G的信息,P的数量等于gomaxprocs,M的数量等于threads,主要看圈黄的地方:

  • 第1处:P1和M2进行了绑定。
  • 第2处:M2和P1进行了绑定,但M2上没有运行的G。
  • 第3处:代码中使用fmt进行打印,会进行系统调用,P1系统调用的次数很多,说明我们的用例函数基本在P1上运行。
  • 第4处和第5处:M0上运行了G1,G1的状态为3(系统调用),G进行系统调用时,M会和P解绑,但M会记住之前的P,所以M0仍然记绑定了P1,而P1称未绑定M。

总结时刻

这篇文章,从3个宏观的角度介绍了调度器,也许你依然不知道调度器的原理,心里感觉模模糊糊,没关系,一步一步走,通过这篇文章希望你了解了:

  1. Go调度器和OS调度器的关系
  2. Go调度器的生命周期/总体流程
  3. P的数量等于GOMAXPROCS
  4. M需要通过绑定的P获取G,然后执行G,不断重复这个过程

示例代码

本文所有示例代码都在Github,可通过阅读原文访问:golang_step_by_step/tree/master/scheduler

参考资料

最近的感受是:自己懂是一个层次,能写出来需要抬升一个层次,给他人讲懂又需要抬升一个层次。希望朋友们有所收获。

  1. 如果这篇文章对你有帮助,不妨关注下我的Github,有文章会收到通知。
  2. 本文作者:大彬
  3. 如果喜欢本文,随意转载,但请保留此原文链接:http://lessisbetter.site/2019/03/26/golang-scheduler-2-macro-view/
关注公众号,获取最新Golang文章

从宏观看,交易在区块链网络中的传播,像广度搜索算法,也像湖面上的水纹,一圈圈向外扩散。但实际场景由于网络通信环境,可能效果上并非一圈一圈向外的,但总体上是向外扩散。

从微观上讲,两个节点间交易的传播如下图。从钱包到节点,节点把可打包的交易发送给相连的节点。主要流程如下:

  1. 钱包(浏览器、APP)发送交易到节点。
  2. 节点把收到的交易插入txpool
  3. 可打包(nonce值连续)的交易加入txpool.pending,不可打包的交易(nonce值存在断开)插入到txpool.queued
  4. 交易进入txpool.pending后,txpool发布NewTxsEvent
  5. Protocol Manager收到事件后,txBroadcastLoop将交易加入到连接的peer的交易队列(得缓冲一下,不一定能很快发,毕竟连接上有很多类型的数据需要传送)。
  6. 各peer协程从各自的交易队列取交易合成消息,发送给peer。
  7. peer收到交易消息后,加入txpool,回到步骤2。

狭义上的分布式系统是指通过网络连接的计算机系统, 每个计算节点承担独立的计算和存储, 节点之间通过网络协同工作, 因此整个系统中的事件可以同时发生. 广义上的分布式系统是一个非常相对的概念, 比如多核CPU的计算也包含在内。

了解分布式系统不得不提网络模型,《分布式系统中的网络模型和故障模型》这篇文章给了很好的解释:

  1. 同步网络(synchronous network): 这里的同步网络和编程中的同步阻塞io和异步非阻塞io是两回事, 不要弄混了. 同步网络是指:

    1. 所有节点的时钟漂移有上限,

    2. 网络的传输时间有上限,

    3. 所有节点的计算速度一样.

      这意味着整个网络按照round运行, 每个round中任何节点都要执行完本地计算并且可以完成一个任意大小消息的传输. 一个发出的消息如果在一个round内(或者说超时时间内)没有到达, 那么一定是网络中断造成的, 这个消息会丢失, 不会延迟到第二个round到达. 在现实生活中这种网络比较少, 尽管很少, 同步网络仍然是在计算机科学中是不可缺少的一个模型, 在这种模型下可以解决一些问题, 比如拜占庭式故障. 但我们每天打交道的网络大多都是异步网络.

  2. 异步网络(asynchornous network): 和同步网络相反, 节点的时钟漂移无上限, 消息的传输延迟无上限(无法区分消息丢失还是延迟), 节点计算的速度不可预料. 这就是和我们每天打交道的网络类型. 在异步网络中, 有些故障非常难解决, 比如当你发给一个节点一个消息之后几秒钟都没有收到他的应答, 有可能这个节点计算非常慢, 但是也可能是节点crash或者网络延迟造成的, 你很难判断到底是发生了什么样的故障.

现实世界中99%场景都是异步网络,同步网络是比较理想的场景,经常拿来做研究,如果在同步网络下都无法解决的问题,更不可能在异步网络中解决。

分布式系统永远离不开对网络分区的考虑,大名鼎鼎的CAP定理更是指出有P的情况下,只能A和C二选一,虽然CAP的提出人Brewer在2012年又写文章说大家对CAP是有误解的,尤其是三选二,但这不在讨论范围,我们只探究下网络分区。

下图来自Wiki。

定义

WIKI给的定义是:网络分区指由于网络设备的failure,造成网络分裂为多个独立的组

Gilbert和Lynch在论文中定义分区容忍性的:网络允许丢失一个节点发给另一个节点的任意多的消息

我认为Gilbert和Lynch给的更有通用意义,因为造成网络分区的不仅仅是网络设备的failure,两个节点无法再交互的时候就形成分区了,分区原因有:

  • 网络设备failure:比如网线断了,交换机故障了。
  • 节点failure:节点的软件或者硬件坏了,节点成为故障节点。当故障的节点非常多,故障节点和正常节点就不在1个分区,如果正常的节点数量达不到quorum,分布式系统无法正常运作。

所以我认为网络分区的原因即有Network failure又有Node failure。

网络分区和网络模型

我们考虑下网络分区的检测。

  • 同步网络下,橘黄色节点和青色节点间的通信中断,就当网线断裂好了,节点向对方发送的消息如果在超时时间内没有收到响应,就可以认为消息丢失,形成了网络分区。
  • 异步网络下,通信差是一种常态,消息的传递是非常不可靠的,消息无限延时,也可能丢失,根本无法确认消息是否到达对方节点,所以也无法确认/检测是否形成分区。

如果把语言比喻为武侠小说中的武功,如果只是会用,也就是达到四五层,如果用的熟练也就六七层,如果能见招拆招也得八九层,如果你出神入化,立于不败之地十层。

如果你想真正掌握一门语言的,怎么也得八层以上,需要你深入了解这门语言方方面面的细节。

希望以后对Go语言的掌握能有八九层,怎么能不懂调度器!?

Google、百度、微信搜索了许多Go语言调度的文章,这些文章上来就讲调度器是什么样的,它由哪些组成,它的运作原理,搞的我只能从这些零散的文章中形成调度器的“概貌”,这是我想要的结果,但这还不够。

学习不仅要知其然,还要知其所以然。

学习之前,先学知识点的历史,再学知识,这样你就明白,为什么它是当下这个样子。

所以,我打算写一个goroutine调度器的系列文章,从历史背景讲起,循序渐进,希望大家能对goroutine调度器有一个全面的认识。

这篇文章介绍调度器相关的历史背景,请慢慢翻阅。

远古时代

上面这个大家伙是ENIAC,它诞生在宾夕法尼亚大学,是世界第一台真正的通用计算机,和现代的计算机相比,它是相当的“笨重”,它的计算能力,跟现代人手普及的智能手机相比,简直是一个天上一个地下,ENIAC在地下,智能手机在天上。

它上面没有操作系统,更别提进程、线程和协程了。

进程时代

后来,现代化的计算机有了操作系统,每个程序都是一个进程,但是操作系统在一段时间只能运行一个进程,直到这个进程运行完,才能运行下一个进程,这个时期可以成为单进程时代——串行时代

和ENIAC相比,单进程是有了几万倍的提度,但依然是太慢了,比如进程要读数据阻塞了,CPU就在哪浪费着,伟大的程序员们就想了,不能浪费啊,怎么才能充分的利用CPU呢?

后来操作系统就具有了最早的并发能力:多进程并发,当一个进程阻塞的时候,切换到另外等待执行的进程,这样就能尽量把CPU利用起来,CPU就不浪费了。

线程时代

多进程真实个好东西,有了对进程的调度能力之后,伟大的程序员又发现,进程拥有太多资源,在创建、切换和销毁的时候,都会占用很长的时间,CPU虽然利用起来了,但CPU有很大的一部分都被用来进行进程调度了,怎么才能提高CPU的利用率呢?

大家希望能有一种轻量级的进程,调度不怎么花时间,这样CPU就有更多的时间用在执行任务上。

后来,操作系统支持了线程,线程在进程里面,线程运行所需要资源比进程少多了,跟进程比起来,切换简直是“不算事”。

一个进程可以有多个线程,CPU在执行调度的时候切换的是线程,如果下一个线程也是当前进程的,就只有线程切换,“很快”就能完成,如果下一个线程不是当前的进程,就需要切换进程,这就得费点时间了。

这个时代,CPU的调度切换的是进程和线程。多线程看起来很美好,但实际多线程编程却像一坨屎,一是由于线程的设计本身有点复杂,而是由于需要考虑很多底层细节,比如锁和冲突检测。

协程

多进程、多线程已经提高了系统的并发能力,但是在当今互联网高并发场景下,为每个任务都创建一个线程是不现实的,因为会消耗大量的内存(每个线程的内存占用级别为MB),线程多了之后调度也会消耗大量的CPU。伟大的程序员们有开始想了,如何才能充分利用CPU、内存等资源的情况下,实现更高的并发

既然线程的资源占用、调度在高并发的情况下,依然是比较大的,是否有一种东西,更加轻量?

你可能知道:线程分为内核态线程和用户态线程,用户态线程需要绑定内核态线程,CPU并不能感知用户态线程的存在,它只知道它在运行1个线程,这个线程实际是内核态线程。

用户态线程实际有个名字叫协程(co-routine),为了容易区分,我们使用协程指用户态线程,使用线程指内核态线程。

User-level threads, Application-level threads, Green threads都指一样的东西,就是不受OS感知的线程,如果你Google coroutine相关的资料,会看到它指的就是用户态线程,在Green threads的维基百科里,看Green threads的实现列表,你会看到好很多coroutine实现,比如Java、Lua、Go、Erlang、Common Lisp、Haskell、Rust、PHP、Stackless Python,所以,我认为用户态线程就是协程。

协程跟线程是有区别的,线程由CPU调度是抢占式的,协程由用户态调度是协作式的,一个协程让出CPU后,才执行下一个协程。

协程和线程有3种映射关系:

  • N:1,N个协程绑定1个线程,优点就是协程在用户态线程即完成切换,不会陷入到内核态,这种切换非常的轻量快速。但也有很大的缺点,1个进程的所有协程都绑定在1个线程上,一是某个程序用不了硬件的多核加速能力,二是一旦某协程阻塞,造成线程阻塞,本进程的其他协程都无法执行了,根本就没有并发的能力了。
  • 1:1,1个协程绑定1个线程,这种最容易实现。协程的调度都由CPU完成了,不存在N:1缺点,但有一个缺点是协程的创建、删除和切换的代价都由CPU完成,有点略显昂贵了。
  • M:N,M个协程绑定N个线程,是N:1和1:1类型的结合,克服了以上2种模型的缺点,但实现起来最为复杂。

协程是个好东西,不少语言支持了协程,比如:Lua、Erlang、Java(C++即将支持),就算语言不支持,也有库支持协程,比如C语言的coroutine(云风大牛作品)、Kotlin的kotlinx.coroutines、Python的gevent。

goroutine

Go语言的诞生就是为了支持高并发,有2个支持高并发的模型:CSP和Actor。鉴于Occam和Erlang都选用了CSP(来自Go FAQ),并且效果不错,Go也选了CSP,但与前两者不同的是,Go把channel作为头等公民。

就像前面说的多线程编程太不友好了,Go为了提供更容易使用的并发方法,使用了goroutine和channel。goroutine来自协程的概念,让一组可复用的函数运行在一组线程之上,即使有协程阻塞,该线程的其他协程也可以被runtime调度,转移到其他可运行的线程上。最关键的是,程序员看不到这些底层的细节,这就降低了编程的难度,提供了更容易的并发。

Go中,协程被称为goroutine(Rob Pike说goroutine不是协程,因为他们并不完全相同),它非常轻量,一个goroutine只占几KB,并且这几KB就足够goroutine运行完,这就能在有限的内存空间内支持大量goroutine,支持了更多的并发。虽然一个goroutine的栈只占几KB,但实际是可伸缩的,如果需要更多内容,runtime会自动为goroutine分配。

Go语言的老调度器

终于来到了Go语言的调度器环节。

调度器的任务是在用户态完成goroutine的调度,而调度器的实现好坏,对并发实际有很大的影响,并且Go的调度器就是M:N类型的,实现起来也是最复杂

现在的Go语言调度器是2012年重新设计的(设计方案),在这之前的调度器称为老调度器,老调度器的实现不太好,存在性能问题,所以用了4年左右就被替换掉了,老调度器大概是下面这个样子:

最下面是操作系统,中间是runtime,runtime在Go中很重要,许多程序运行时的工作都由runtime完成,调度器就是runtime的一部分,虚线圈出来的为调度器,它有两个重要组成:

  • M,代表线程,它要运行goroutine。
  • Global G Queue,是全局goroutine队列,所有的goroutine都保存在这个队列中,goroutine用G进行代表

M想要执行、放回G都必须访问全局G队列,并且M有多个,即多线程访问同一资源需要加锁进行保证互斥/同步,所以全局G队列是有互斥锁进行保护的。

老调度器有4个缺点:

  1. 创建、销毁、调度G都需要每个M获取锁,这就形成了激烈的锁竞争
  2. M转移G会造成延迟和额外的系统负载。比如当G中包含创建新协程的时候,M创建了G’,为了继续执行G,需要把G’交给M’执行,也造成了很差的局部性,因为G’和G是相关的,最好放在M上执行,而不是其他M’。
  3. M中的mcache是用来存放小对象的,mcache和栈都和M关联造成了大量的内存开销和差的局部性。
  4. 系统调用导致频繁的线程阻塞和取消阻塞操作增加了系统开销。

Go语言的新调度器

面对以上老调度的问题,Go设计了新的调度器,设计文稿:https://golang.org/s/go11sched

新调度器引入了:

  • PProcessor,它包含了运行goroutine的资源,如果线程想运行goroutine,必须先获取P,P中还包含了可运行的G队列。
  • work stealing:当M绑定的P没有可运行的G时,它可以从其他运行的M’那里偷取G。

现在,调度器中3个重要的缩写你都接触到了,所有文章都用这几个缩写,请牢记

  • G: goroutine
  • M: 工作线程
  • P: 处理器,它包含了运行Go代码的资源,M必须和一个P关联才能运行G。

这篇文章的目的不是介绍调度器的实现,而是调度器的一些理念,帮助你后面更好理解调度器的实现,所以我们回归到调度器设计思想上。

thoughts-of-scheduler

调度器的有两大思想

复用线程:协程本身就是运行在一组线程之上,不需要频繁的创建、销毁线程,而是对线程的复用。在调度器中复用线程还有2个体现:1)work stealing,当本线程无可运行的G时,尝试从其他线程绑定的P偷取G,而不是销毁线程。2)hand off,当本线程因为G进行系统调用阻塞时,线程释放绑定的P,把P转移给其他空闲的线程执行。

利用并行:GOMAXPROCS设置P的数量,当GOMAXPROCS大于1时,就最多有GOMAXPROCS个线程处于运行状态,这些线程可能分布在多个CPU核上同时运行,使得并发利用并行。另外,GOMAXPROCS也限制了并发的程度,比如GOMAXPROCS = 核数/2,则最多利用了一半的CPU核进行并行。

调度器的两小策略

抢占:在coroutine中要等待一个协程主动让出CPU才执行下一个协程,在Go中,一个goroutine最多占用CPU 10ms,防止其他goroutine被饿死,这就是goroutine不同于coroutine的一个地方。

全局G队列:在新的调度器中依然有全局G队列,但功能已经被弱化了,当M执行work stealing从其他P偷不到G时,它可以从全局G队列获取G。

上面提到并行了,关于并发和并行再说一下:Go创始人Rob Pike一直在强调go是并发,不是并行,因为Go做的是在一段时间内完成几十万、甚至几百万的工作,而不是同一时间同时在做大量的工作。并发可以利用并行提高效率,调度器是有并行设计的

并行依赖多核技术,每个核上在某个时间只能执行一个线程,当我们的CPU有8个核时,我们能同时执行8个线程,这就是并行。

结束语

这篇文章的主要目的是为后面介绍Go语言调度器做铺垫,由远及近的方式简要介绍了多进程、多线程、协程、并发和并行有关的“史料”,希望你了解为什么Go采用了goroutine,又为何调度器如此重要。

如果你等不急了,想了解Go调度器相关的原理,看下这些文章:

声明:关于老调度器的资料已经完全搜不到,根据新版调度器设计方案的描述,想象着写了老调度器这一章,可能存在错误。

参考资料

  1. https://en.wikipedia.org/wiki/Computer#History
  2. https://en.wikipedia.org/wiki/Process_(computing)#History
  3. https://en.wikipedia.org/wiki/Thread_(computing)#History
  4. https://golang.org/doc/faq#goroutines
  5. https://golang.org/s/go11sched
  6. https://golang.org/src/runtime/proc.go
  1. 如果这篇文章对你有帮助,不妨关注下我的Github,有文章会收到通知。
  2. 本文作者:大彬
  3. 如果喜欢本文,随意转载,但请保留此原文链接:http://lessisbetter.site/2019/03/10/golang-scheduler-1-history
关注公众号,获取最新Golang文章