设置 GOMAXPROCS 提高程序性能

从 cgroup 的介绍中,我们知道了通过设置 /sys/fs/cgroup/ 的值,并且使用 cgroup-tools 启动程序同时指定一个 cgroup,可以达到控制进程使用系统资源的目的。 起因 一个 Go 程序运行在 k8s 环境中,在某一行代码前后设置 start timestamp 和 end timestamp,发现有时候 p99 的 latency 非常高,正常情况下在 1-3 ms,极端情况下有 50-90 ms。百思不得其解,猜测各种可能加查阅资料后,发现应该是没有正确的设置 runtime.GOMAXPROCS。设置为 1 后,极高 latency 的情况明显减少。 为什么 出现这个问题有三个条件,缺一不可: 是 Go 程序,并且采用系统默认 GOMAXPROCS 运行在 k8s 或者 docker 这样的容器环境 宿主机上有多个 CPU 核 GOMAXPROCS 是什么 回忆一下 Go 并发的 GPM 模型: G代表 goroutine,即用户创建的 goroutines P代表 Logical Processor,是类似于 CPU 核心的概念,其用来控制并发的 M 数量 M是操作系统线程。在绝大多数时候,P的数量和M的数量是相等的。每创建一个P, 就会创建一个对应的M 而 go 的 runtime GOMAXPROCS 代表的就是 P 的数量,其底层就是 runtime 直接调用 Linux 系统调用 sched_getaffinity() ...

2023-04-02 · Me

看懂 Go 汇编 三

本文主要收集一些例子,以后阅读 Go 汇编时遇到忘记的指令可以查询。 例子 1 package main func main() { l := []int{9, 45, 23, 67, 78} t := 0 for _, v := range l { t += v } println(t) } 截取了一段汇编如下 0x0026 00038 (3.go:4) MOVUPS X15, ""..autotmp_5+40(SP) 0x002c 00044 (3.go:4) MOVUPS X15, ""..autotmp_5+48(SP) 0x0032 00050 (3.go:4) MOVUPS X15, ""..autotmp_5+64(SP) 0x0038 00056 (3.go:4) LEAQ ""..autotmp_5+40(SP), AX 0x003d 00061 (3.go:4) MOVQ AX, ""..autotmp_4+80(SP) 0x0042 00066 (3.go:4) TESTB AL, (AX) 其中 MOVUPS 是 Intel 平台的 SIMD 指令,通过 X15 代表的固定的零寄存器对起始地址为SP + 40 的连续 128 bit (16个字节)进行清零。如果是作用在 slice 结构体上,则是 len 和 cap 为0。 LEAQ 取 SP+40 内存单元的地址,存入 AX 寄存器。 TESTB 把 AL 与 AX 寄存器中的值做逻辑与操作,但不会改变寄存器的值,只是设置相关标志位。这里是用做 nil check,如果加载 AX 失败会触发段错误信号 SIGSEGV,触发 Go Runtime 抛出 Panic。选择 TESTB仅仅是因为指令短小。 接下来就是循环体部分 0x00da 00218 (3.go:8) JMP 220 0x00dc 00220 (3.go:8) MOVQ ""..autotmp_6+32(SP), AX 0x00e1 00225 (3.go:8) CMPQ ""..autotmp_7+24(SP), AX 0x00e6 00230 (3.go:8) JGT 234 0x00e8 00232 (3.go:8) JMP 286 0x00ea 00234 (3.go:8) MOVQ ""..autotmp_6+32(SP), AX 0x00ef 00239 (3.go:8) SHLQ $3, AX 0x00f3 00243 (3.go:8) ADDQ ""..autotmp_3+112(SP), AX 0x00f8 00248 (3.go:8) MOVQ (AX), AX 0x00fb 00251 (3.go:8) MOVQ AX, "".v+8(SP) 0x0100 00256 (3.go:9) MOVQ "".t+16(SP), CX 0x0105 00261 (3.go:9) ADDQ CX, AX 0x0108 00264 (3.go:9) MOVQ AX, "".t+16(SP) 0x010d 00269 (3.go:9) JMP 271 0x010f 00271 (3.go:8) MOVQ ""..autotmp_6+32(SP), AX 0x0114 00276 (3.go:8) INCQ AX 0x0117 00279 (3.go:8) MOVQ AX, ""..autotmp_6+32(SP) 0x011c 00284 (3.go:8) JMP 220 0x011e 00286 (3.go:12) PCDATA $1, $0 0x011e 00286 (3.go:12) NOP CMPQ, 比较 SP+24 和 AX 所存值得大小,实际上,这个操作是把 SP+24 的值减去 AX,得到的值存在另一个寄存器中,供 JGT、JLT 指令使用 JGT : 有了前面的结果,JGT 只要判断如果值大于 0 ,则跳到 234 行指令。 接下里 234 - 271 行,就是从数组拿出一个元素,赋值给变量 v,然后加上 t 并把结果存在 t 中。 INCQ: 增加数组 index 的值,这个index 存在 AX 中 。 SHLQ 是左移的意思,我没有看懂这里需要左移 3 位的意思? 例子 2 下面的代码一个用 new,一个直接构建结构体,那么最终生成的代码有区别吗? ...

2022-10-26 · Me

看懂 Go 汇编 二

本文翻译自 https://github.com/teh-cmc/go-internals/tree/master/chapter1_assembly_primer 先看一个简单的 code // go tool compile -N -l -S once.go // go build -gcflags -S once.go package main //go:noinline func add(a, b int32) (int32, bool) { return a + b, true } func main() { add(10, 32) } 生成汇编 GOOS=linux GOARCH=amd64 go tool compile -S x.go 在我的机器 Ubuntu kernel 5.4.0, Go version go1.18.3 amd64 上出来的结果与原文中还是有些差异的,但为了文章通顺,下面还是用的原文的结果。 0x0000 TEXT "".add(SB), NOSPLIT, $0-16 0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB) 0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x0000 MOVL "".b+12(SP), AX 0x0004 MOVL "".a+8(SP), CX 0x0008 ADDL CX, AX 0x000a MOVL AX, "".~r2+16(SP) 0x000e MOVB $1, "".~r3+20(SP) 0x0013 RET 0x0000 TEXT "".main(SB), $24-0 ;; ...omitted stack-split prologue... 0x000f SUBQ $24, SP 0x0013 MOVQ BP, 16(SP) 0x0018 LEAQ 16(SP), BP 0x001d FUNCDATA $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB) 0x001d MOVQ $137438953482, AX 0x0027 MOVQ AX, (SP) 0x002b PCDATA $0, $0 0x002b CALL "".add(SB) 0x0030 MOVQ 16(SP), BP 0x0035 ADDQ $24, SP 0x0039 RET ;; ...omitted stack-split epilogue... Add 函数 0x0000 TEXT "".add(SB), NOSPLIT, $0-16 0x0000 : 表示当前指令相对于函数的偏移量 TEXT “”.add : 定义函数,在链接阶段会被链接器替换为 main.add (SB): SB 是 static base pointer,代表程序地址空间的起始地址。"".add(SB) 代表 add 在程序地址空间的一个固定 offset。比如像下面这样 $ objdump -j .text -t binary | grep 'main.add' 000000000044d980 g F .text 000000000000000f main.add FP 和 SB 的作用可以参见 《Go 语言汇编 一》 ...

2022-10-24 · Me

看懂 Go 汇编 一

寄存器 学过 X86 汇编的同学都知道汇编有AX,BX等寄存器,除此之外,Go 还添加了 PC、FP、SP、SB四个伪寄存器。如下图所示,其中第二列为 GO 添加的4 个伪寄存器,第三列为 X86 寄存器。 看到这里,尘封已久的汇编语言知识需要拿出来复习一下。 FLAGS 是状态寄存器。 IP 是指令寄存器。 AX、BX、CX、DX、SI、DI、BP、SP 是通用寄存器。在X86-64中又增加了八个以R8-R15 方式命名的通用寄存器。 另外 GO 的 4 个伪寄存器作用如下: FP: Frame pointer:伪FP寄存器对应函数的栈帧指针,一般用来访问函数的参数和返回值;golang语言中,函数的参数和返回值,函数中的局部变量,函数中调用子函数的参数和返回值都是存储在栈中的,我们把这一段栈内存称为栈帧(frame),伪FP寄存器对应栈帧的底部,但是伪FP只包括函数的参数和返回值这部分内存,其他部分由伪SP寄存器表示;注意golang中函数的返回值也是通过栈帧返回的,这也是golang函数可以有多个返回值的原因; PC: Program counter:指令计数器,用于分支和跳转,它是汇编的IP寄存器的别名; SB: Static base pointer:一般用于声明函数或者全局变量,对应代码区(text)内存段底部; SP: Stack pointer:指向当前栈帧的局部变量的开始位置,一般用来引用函数的局部变量,这里需要注意汇编中也有一个SP寄存器,它们的区别是:1.伪SP寄存器指向栈帧(不包括函数参数和返回值部分)的底部,真SP寄存器对应栈的顶部;所以伪SP寄存器一般用于寻址函数局部变量,真SP寄存器一般用于调用子函数时,寻址子函数的参数和返回值(后面会有具体示例演示);2.当需要区分伪寄存器和真寄存器的时候只需要记住一点:伪寄存器一般需要一个标识符和偏移量为前缀,如果没有标识符前缀则是真寄存器。比如(SP)、+8(SP)没有标识符前缀为真SP寄存器,而a(SP)、b+8(SP)有标识符为前缀表示伪寄存器; Symbols 符号 有些符号比如 R1、LR 是不同架构预定义的寄存器。除此之外,还有 GO 定义的 4 个伪寄存器。 FP: Frame pointer: arguments and locals. PC: Program counter: jumps and branches. SB: Static base pointer: global symbols. SP: Stack pointer: the highest address within the local stack frame. 所有用户定义的符号都可以写成 FP 或者 SB + offset 的形式。 ...

2022-10-23 · Me

Go 中 for range 和 slice 的坑

for range 的实现 下面这段代码会永无止境的循环吗 ? package main import ( "fmt" ) func main() { sl := []int{1,2,3,4} for _, v := range sl{ sl = append(sl, v) } fmt.Println(sl) } 要验证它很简单,运行一下即可得到结果,最后的结果是 [1 2 3 4 1 2 3 4] 要理解为什么会有这样的结果不难,首先我们需要清楚一点 go 语言中的赋值语句都是赋值,那么就意味着 如果赋值的是一个指针, 那么拷贝的是指针指向对象的地址(就是一个数值, 至于这个数值有多大, 具体要看运行的平台)也就是指针的值 如果赋值的是一个对象, 那么就会拷贝这个对象 然后,我们再来看一下,当 for range 遇到不同的迭代对象时,编译器是如何展开代码的 数组 range_temp := range len_temp := len(range) for index_temp = 0; index_temp < len_temp; index_temp++ { value_temp = range_temp[index_temp] index = index_temp value = value_temp original body } slice 切片 for_temp := range len_temp := len(for_temp) for index_temp = 0; index_temp < len_temp; index_temp++ { value_temp = for_temp[index_temp] index = index_temp value = value_temp original body } map // Lower a for range over a map. // The loop we generate: var hiter map_iteration_struct for mapiterinit(type, range, &hiter); hiter.key != nil; mapiternext(&hiter) { index_temp = *hiter.key value_temp = *hiter.val index = index_temp value = value_temp original body } 注: ...

2022-07-29 · Me

记一次 go-redis 的 debug 过程

Background 本文背景是这样的: 有一个线上服务使用了 go-redis 库连接 redis,目前 QPS 也不是很高,大约每秒一次的样子,但是通过 log 发现每次 redis 操作花费的时间如下: redis call cost: 0 ms redis call cost: 2 ms redis call cost: 0 ms redis call cost: 1 ms redis call cost: 0 ms redis call cost: 17 ms redis call cost: 0 ms .... 正常一个简单的 redis get 操作耗费 0-3ms 时间可以理解,但是为什么会出现 17 ms 呢? 而且出现的频率还不低,大概每 30 个正常的中会出现一个。 尝试 debug 首先总结一下场景和条件 service 部署在 k8s 中,大概 10 个 pod 在运行。 整个 service 的 QPS 大概一秒一个,很低。 高延迟的情况大概每 30 个 log 出现一个。 service 使用简单的 redis get(),没有复杂操作。 但是 service 本身是有很多 go routine 并发的。 所以可能出现问题的地方 ...

2022-05-01 · Me

gRPC client 如何实现 TCP 重连

之前写过一篇 gRPC-go 建立 TCP 连接的过程 博客,主要研究了 client 程序启动后,如何与 server 建立 TCP 连接。 今天,在思考 redis-go 的连接池实现的时候,突然想到: 当 gRPC 的 TCP 连接断开后,能自动重连吗? 如果可以,是如何实现的 ? 首先要注意,这里指的是 TCP 连接,而不是 http2 中的 stream。 我们知道,gRPC 数据的传输使用 http2 的多路复用,也就是在一个 TCP 连接上有多个全双工的 http2 stream,这里的 stream 如果被断开后怎么重连与 http2 的实现有关,不在本文讨论范围。 对于上面第一个问题,使用 gRPC 的经验告诉我是可以自动重连的,不妨再做个简单的测试,client 端代码如下: func main() { conn, _ := grpc.Dial("127.0.0.1:8080", grpc.WithInsecure()) defer conn.Close() cli := protobuf.NewTestClient(conn) req := &protobuf.EchoRequest{ Msg: "hi", } for i := 0; i < 10000; i++ { time.Sleep(time.Second) resp, err := cli.Echo(context.Background(), req) if err != nil { log.Printf("%v\n", err) continue } log.Printf("[D] resp: %s", resp.Reply) } } server 端代码略。 启动 client 后,不断启动和 ctrl-c 结束 server,证实 client 能自动重连 TCP 。 使用 netstat 查看 TCP 连接也能看到 client 使用了新的端口号重连。 ...

2022-04-04 · Me

Gin HTTP 框架学习笔记

最近要做一个 REST API server,在网上搜索了一遍以后,发现常用的是 Gin 和 Echo,并且很多人都说 golang 本身提供的 http server 已经足够强大,gin 和 echo 也只是在外包了一层。 我看 Gin 的源码行数比 Echo 少很多,而且测试覆盖率也高很多,因此决定学习一下 Gin,本文目标有以下这些 学习如何设计一个 REST 风格的 server ? 学习 Gin 在 go 自带的 http server 基础上做了哪些工作? 启动 Gin http server 在使用 Gin 框架的时候,最后都会调用 gin.Run(":8080") ,这样你的 http server 就可以就收 client 请求了, func (engine *Engine) Run(addr ...string) (err error) { defer func() { debugPrintError(err) }() address := resolveAddress(addr) debugPrint("Listening and serving HTTP on %s\n", address) err = http.ListenAndServe(address, engine) return } 可见,Run 函数最后调用了 http.ListenAndServe,所以说 http 协议层的解析等工作都是 go 标准库完成的,Gin 只负责后续针对不同 URL 的路由 (Router) 工作。 ...

2021-09-03 · Me

错误使用 time.After() 导致内存泄漏

今天看到了一篇有关 timer 泄露的文章,觉得很有意思,于是把它记录下来。 一般没有问题的写法 说道 time.After() 会导致内存泄露,很多人一定会觉得奇怪,因为代码里经常会用到它,也没见有内存泄漏啊? 是的,一般我们这样写的话是没有问题的 func main() { ch := make(chan int) go func() { ch <- 1 }() select { case _ = <-ch: case <-time.After(time.Second * 1): fmt.Println("timeout") } } 有问题的写法 那么,什么样的写法有问题呢? 当使用 for loop 的时候,比如这样 for { select { case _ = <-ch: // do something... continue case <-time.After(300 * time.Millisecond): fmt.Printf("time.After() fire!\n") } } 很不幸的是,上面这样的写法也非常常见,我自己就写过这样的代码。那么它真的会造成内存泄露吗?试一下便知道 前一篇博客中已经介绍了如何使用 pprof 对 Go 程序进行 profiling,简单提一下步骤 在代码中引入 _ "net/http/pprof", 并开启一个http server 导出 metrics 运行你的 binary 执行 go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap 浏览器就会自动打开 localhost:8081 显示结果了 测试代码如下: package main import ( "fmt" "net/http" _ "net/http/pprof" "time" ) func main() { go func() { ip := "127.0.0.1:6060" if err := http.ListenAndServe(ip, nil); err != nil { fmt.Printf("start pprof failed on %s\n", ip) } }() ch := make(chan int, 10) go func() { in := 1 for { in++ ch <- in } }() for { select { case _ = <-ch: // do something... continue case <-time.After(3 * time.Minute): fmt.Printf("time.After() fire!\n") } } } 使用 pprof 后,可见 timer 内存占用居然超过了 1GB !! ...

2021-08-08 · Me

Golang pprof 的使用姿势

首先,在代码中引入 pprof 的方式非常简单,只要把下面这段代码放到 main 函数中即可 _ "net/http/pprof" go func() { if err := http.ListenAndServe(":9090", nil); err != nil { panic(err) } os.Exit(0) }() 然后启动你的程序,再用以下这些命令去对应的端口做 profiling // cpu profile 默认从当前开始收集 30s 的 cpu 使用情况,需要等待 30s go tool pprof http://47.93.238.9:9090/debug/pprof/profile # wait 120s go tool pprof http://47.93.238.9:9090/debug/pprof/profile?seconds=120 // 以下 second 参数不起作用,因为采样是一瞬间完成的 go tool pprof http://47.93.238.9:9090/debug/pprof/heap go tool pprof http://47.93.238.9:9090/debug/pprof/goroutine go tool pprof http://47.93.238.9:9090/debug/pprof/block go tool pprof http://47.93.238.9:9090/debug/pprof/mutex 还有一种是 import "runtime/pprof“的方式,这种不太常用,不在本文范围。 运行了 go tool pprof 命令以后,会进入到一个交互界面, $ go tool pprof "http://localhost:9090/debug/pprof/goroutine" Fetching profile over HTTP from http://localhost:9090/debug/pprof/goroutine //出现以下内容,代表采样已经完成,可以查看了 Saved profile in /Users/xx/pprof/pprof.goroutine.001.pb.gz File: xx Type: goroutine Time: Jul 18, 2021 at 4:55pm (PDT) Entering interactive mode (type "help" for commands, "o" for options) (pprof) quit 一旦进入了可交互模式后,代表采样已经完成,这时候可以 输入 svg > xxx.svg可以生成一个图片存下来,或者 输入 quit 退出,所有数据文件存在上面提到的目录下。 然后就是查看这个 perf data 了,用 go tool pprof data.pb.gz可以进入到一个命令行界面 ...

2021-07-18 · Me