go语言笔记

这里记录一些与 go 相关的点

GODBUEG

可以通过环境GODEBUG 来输出 go 程序的相关信息,可选命令如下:

  • allocfreetrace
  • clobberfree
  • cgocheck
  • efence
  • gccheckmark
  • gcpacertrace
  • gcshrinkstackoff
  • gctrace
  • madvdontneed
  • memprofilerate
  • invalidptr
  • sbrk
  • scavenge
  • scheddetail
  • schedtrace
  • tracebackancestors
  • asyncpreemptoff

完整命令参考 这里 值得一提得是,可以通过name=val,name=val来启用多个命令,如:

  • ``

http库的 ServeHttp 不能修改 request 的内容

很有意思的一点,在 ServeHttp 代码注释中写着

Except for reading the body, handlers should not modify the provided Request.

稍微调查了下原因,是因为大量的现存代码会受到影响,所以在 http.StripPrefix 函数中,对 Request 进行了深拷贝。

func StripPrefix(prefix string, h Handler) Handler {
	if prefix == "" {
		return h
	}
	return HandlerFunc(func(w ResponseWriter, r *Request) {
		if p := strings.TrimPrefix(r.URL.Path, prefix); len(p) < len(r.URL.Path) {
			r2 := new(Request)
			*r2 = *r
			r2.URL = new(url.URL)
			*r2.URL = *r.URL
			r2.URL.Path = p
			h.ServeHTTP(w, r2)
		} else {
			NotFound(w, r)
		}
	})
}

参考资料: net/http: allow handlers to modify the http.Request, stack-overflow

编译的二进制无法在 alpine 中运行

当项目引用了 net 包之后,因为网络库在不同平台下的实现不同,所以它默认依赖了 cgo来做动态链接,这里有几个解决办法:

  • 禁用 cgo,如果你的项目没有依赖的话。CGO_ENABLED=0 go build -a-a 表示让所有依赖库进行重编(这里验证过不带 -a 也可以正常工作,有点奇怪),禁用 cgo 后将会以静态连接的方式编译二进制包。
  • 强制 go 使用一个特定的网络实现。go build -tags netgo -a
  • 添加动态连接库,如果你的项目要依赖cgo
RUN apk add --no-cache \
        libc6-compat

这里简单提一下,还可以使用 -ldflags="-s -w" 来减少生成的二进制体积,它裁剪了程序的调试信息。 还有个-installsuffix cgo,go 1.10 以后已经不再需要。

参考 这里

goroutine 的channel性能小记

由于 goroutine 实现中包含了一把互斥锁,因此在多个 worker 竞争一个 channel 时会比较消耗性能,此时可以考虑对 channel 进行分片,实验发现以下两个配置效果比较好:

  • 一个 worker 对应一个 channel,不过这样比较占用内存,同时也要注意数量不能无限递增,性能最高,但是空间占用最大
  • 取CPU逻辑线程数(runtime.GOMAXPROCES) 作为分片数,这是在 m3 中看到的做法,效果不错,性能次之不过很节省空间

其他配置当然也可以,不过几乎和上面两种差不太多,前者比后者快了20%左右。做分片要注意考虑哈希算法的实现和性能影响,可选方案:

  • atomic 自增 + 取余是一个不错的实现,但是注意自增的上限值。
  • 如果有特征值,murmur3 + 取余是个完美的解决方案。

在优雅退出时,有几种方式实现:

  • 利用 for + select + 退出信号
// task
for {
	select {
	case <- aChan:
		doTask()
	case <- closeChan:
		end()
	}
}

// close
closeChan <- struct{}{}
// 这个非常有有意思,考虑正常 channel 的用法,也许第一直觉我们是使用上面的方式
// 但是多数开源项目用的都是下面这种方式,你是不是会好奇为啥 close 也会触发 select
// 其实这个可以理解为:如果select 了一个已关闭的 channel 会发生什么?
// 答案是:立即触发这个 case ,并且 ret, ok := closeCh 中,ok 为 false
// 这个特性配合 select-default 可以对 channel 的存活进行检测(但是不推荐做这样的事)
close(aChan)
  • 利用 atomic + for
// task
for isClose > 0 {
	<- aChan
	doTask()
}

// close
atomic.AddInt32(&isClose, 1)
aChan <- nil

使用信号的方式非常优雅,但是性能损耗较大,比后者慢了50%

静态库

当你对一个 package 不是 main 的 go文件进行编译时,会得到一个 pkg 的归档文件 x.a,它可以提供给其他go引用,但是看不见声明与源码

失败重试

常见的失败重试机制:

  • 固定时间 (FixedDelay)
  • 指数退避算法( Exponential Backoff )

这里简单介绍指数退避算法,即每次失败重试后,延迟的时间倍增,等待的时间为 2^n + random_number_milliseconds,比如: 失败第一次后,等待 1 + random_number_milliseconds 失败第二次后,等待 2 + random_number_milliseconds 失败第三次后,等待 3 + random_number_milliseconds

这里之所有会有一个随机的毫秒数,是为了防止通信双方如果在失败都尝试按照指数倍增的方式去重传,那么有可能会一直冲突,所以补充了一个随机值。

go 有个代码结构非常不错的重试库:retry-go

避免混用 GOPATH 和 go module

go 在编译时会优先使用 GOPATH 下面的源码,即便你的工作目录不处于 GOPATH 中,踩过一个坑就是一直在项目目录下编译,但是得到二进制文件都是没有改动过的,就是因为 GOPATH 下存在同名项目

defer 特殊性

defer 先进后出性质应该是非常常见了,但是在返回值的地方有个特殊处理可能很少人注意到:

func f1() (result int) {
    defer func() {
        result++
    }()
    return 0
}

func f2() (r int) {
     t := 5
     defer func() {
       t = t + 5
     }()
     return t
}

f1 的执行结果是 1, f2 的执行结果是 5,这里需要注意的是 return 语句并不是原子执行的,它是按照以下顺序来执行:

  • 返回值赋值
  • 插入 defer
  • 函数返回

也就是说上面的 f1 例子中,是按照以下顺序:

  • result = 0
  • result ++
  • return

f2同理。

math/rand 与 crypto/rand 区别

两者区别在于前者是运算而得,后者从机器上获取,后者性能差异较大(数量级的差异)

后台 Goroutine 相关

我们经常会在一个程序中跑很多 goroutine 来执行一些周期性任务,这里会涉及到两个常见的动作:

  • 同步:如果程序关闭时某些任务正在运行,我们应该尽量等待它运行完毕,此时可以使用 sync.WaitGroup 来做同步,然后组件暴露一个 Close 方法让用户显式调用。
  • 处理泄露:如果你的组件是一个可能在程序的生命周期内反复创建又销毁的,那么要注意在组件不再使用后要停止这些 goroutine,你可以像前面一个场景一样暴力一个 Close 来解决,但是如果你不需要做同步,还有另一个简单而优雅的办法 runtime.SetFinalizer ,它可以在对象被GC时执行,你可以在这个函数中回收后台 goroutine,go-cache 和 istio 的 lrucache 中都使用了这种方法来避免泄露。使用该函数有几点注意
    • 避免在终结器中添加循环引用,GC可以识别正常的循环引用,但是在终结器里面的无法处理
    • 这会造成对象生命周期拉长,因为终结器执行后会将对象的终结器清空,且留到下一个GC才会回收,大并发场景下不太友好
    • 由于终结器只会在对象被 GC 时回6收,因此要注意在程序结束时不会触发的,所以不要用它来做一些持久化的工作,还需要注意如果有后台 goroutine 引用了对象也会导致它无法进入不可达状态,而无法触发终结器,这种时候可以使用一个 wrapper 对象将它包住,而将 wrapper 暴露给用户使用。

sync.Cond 与 channel

查看 client-go 代码时发现他的限速队列用了 sync.Cond 看了下作用其实就是阻塞若干 goroutine 然后可以单个 or 整体唤醒(signal or broadcast),有点好奇为啥有了 channel 还需要这个,然后就发现 go 有个issue 在讨论是否要在 go2 中取消这个功能, 我总结了下:

  • sync.Cond 的 signal 和 broadcase 都可以使用 channel 的 send 和 close 来替代
  • sync.Cond 的性能更好(但是没看见有对比用例)
  • sync.Cond 的语义符合 c 的 pthread 原语
  • 只保持 channel 在用法上会更简洁
  • sync.Cond 的用法较为繁琐,用不好容易出bug

go mod 版本

go mod 正常会获取最新的 tag 作为默认版本,要求 tag 遵从语义版本 (vx.0.0-xxx)。 另外为了针对内部包快速迭代又不想打 tag 情况,提出了 伪版本的概念(pseudo-version),格式如 vx.0.0(基础语义版本) - yyyymmddhhmmss(修订版本的UTC创建日期) - abcdefabcdef(VCS的commit hash 前缀) 伪版本不要手工输入可以使用

go get xxxxx@branch
go get xxxxx@commit hash
go get xxxxx@tag

来获取指定版本, 值得注意的是如果一个仓库使用了有效的语义版本作为 tag 且主版本不是 v0 v1, 那么在 go.mod 清单中它会在后面追加一个 “+incompatible” 的元数据标识

go 的引用传递和值传递

常见的引用传递,比如slice, pointer等,在网上都有资料,这里不复述了。 需要注意的可能有以下几点:

  • 当你在一个 struct 里面直接引用另一个 struct 的话,表示你的strcut的内容包含这个struct,而不像其他OO语言一样会视为一个对其他对象的指针。在初始化实例也会为包含的struct分配连续且完整的内存空间。
  • strcut 的方法可以指定指针 func (t *T) Method,也可以指定strcut func (t T) Method。这两者差别很大,后者会隐式创建一个新的 struct 传入,因此当你包含了一些值传递不安全的类型后(如sync.Map, sync.Mutex等),请注意不要使用后者。
  • 在 struct 直接引用一个 struct 本身没有太大隐患,你可以类比为其他OO语言的继承,对你的strcut取指针之后你也可以正常调用组合的strcut的指针方法

go的type和alias

go 里面有两类类型定义:

  • Type Define: type SomeObject xxxxx
  • Alias Define: type SomeAlias = xxxxx

区别在于,Type Define 是定义了一类新的类型,它可以添加方法,同时在类型检测时也会提醒你不匹配。 而 Alias Define 就是字面的 别名,别名的type等同于它指向的目标,在任何场景下都可以互换,一般用于大型项目重构时,将老的类型名直接指向新类型,用于过渡期。

go的GPM模型

模型的详述在网上有大量文章了,这里仅仅补充一些很容易忽略的细节:

  • go 在1.12版本以前使用的是 GM 模型,没有P,导致在全局队列上的锁竞争非常严重
  • goroutine 在用户态阻塞时(channel、timesleep等)会解开与M的关联,单独分配到一个队列等待唤醒,如果是内核态阻塞(网络IO,磁盘IO等),这里可能会解绑GM也可能会新起一个M去执行剩余的G,这取决于几种情况:
  • 如果进行的是异步系统调用,比如网络IO,go实现了一个 network poller 模块(kqueue-MacOS), epoll-Linux or iocp-Windows),它负责处理G而无需阻塞M
  • 如果进行的是同步系统调用,比如在CGO中的系统调用、Linux下的文件IO等(kqueue和iocp都支撑文件IO的异步操作,但是epoll不行),都没有实现异步操作,因此只能阻塞M而新起另一个M来继续执行剩余的M

可以参考: Scheduling In Go : Part II - Go Scheduler

go build 参数

  • asan: 开启asan检测(构建环境要求 golang >= 1.18, gcc >= 7)
  • ldflags: 传入到连接器的参数,常用的有 -w, -s,前者是删除调试信息,后者删除符号连接
  • gcflags: 编译器参数,常用的有 -gcflags="all=-N -l" 禁用编译器优化和函数内联,更方便在core文件里面使用 delve or gdb进行调试, all=-d=checkptr=0可以关闭禁用指针对齐检测,asan会默认开启这个(正常在 test 的 -race 选项也会开启) (参考这里)

cgo 问题调试

  • delve 可以看到 golang 的堆栈,gdb 可以看到 c 的堆栈,前提是使用 CGO_CFLAGS=-g 环境变量开启符号连接(没有参数设置,只能通过环境变量)。

cgo 注意点

  • 要调查一些c的问题必须要通过coredump来调查,使用 GOTRACEBACK=crash 来启用它
  • cgo 管理的内存模型是基于 C 的,不属于go管理,因此在 delve, pprof等工具中都看不见,同时 cgo 代码中申请的内存如果在 c 代码中没有释放,那么必须要在 go 中调用 free 来释放,在 go 中分配的 C 内存也是如此,典型的比如 C.CString
  • 如果参数可能会在 C 代码中驻留,那么不要在 go代码中释放,否则可能会引起不安全的内存访问,比如以下例子:
package main

// #include <stdio.h>
// #include <stdlib.h>
//
// char* text;
// static void set_config(char* s) {
//	text = s;
// }
// static void myprint() {
//   printf("%s\n", text);
// }
import "C"
import "unsafe"

func cCall() {
   cs := C.CString("Hello from stdio")
   C.set_config(cs)
   C.free(unsafe.Pointer(cs))

   C.myprint()
}

func main() {
   cCall()
   C.CString("Hello from stdio1")
   C.myprint()
}

// Output
// ??
// Hello from stdio1
//
// 有的时候可能是
// Hello from stdio
// Hello from stdio1
// 上面的结果为什么不相同,是因为内存分配可能会从已经释放的空间去分配,也可能不会。
// 同时 free 只是标记该内存不再使用,不代表数据会被擦除。
  • 同时golang的堆栈可能会因为扩容和收缩而发生地址变化,因此要注意不要直接传递地址给C(除非对C函数的调用能够在当前函数中完成,而不是在C函数中继续持有它)