• golang 编程规范查漏补缺


    背景

    公司最近出了 golang 语言规范,大部分参考 uber 的 go 语言规范(原版翻译),以及官方的 Effective Go。这里分享一下自己之前没注意的点,查漏补缺

    方法和函数

    defer 和返回值赋值的执行顺序

    对应知识点为方法返回值是有名还是无名的时候,defer 的顺序的差异

    package main
    
    func deferWithAnonymous() int {
    	ret := 1
    	defer func() {
    		ret++
    	}()
    	return ret
    }
    
    func deferWithNamed() (ret int) {
    	ret = 1
    	defer func() {
    		ret++
    	}()
    	return
    }
    
    func main() {
    	println(deferWithAnonymous()) // 1
    	println(deferWithNamed()) // 2
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    defer 和返回值之间的关系: 设置函数返回值 -> 执行 defer -> 最终返回给调用方
    关键在第一步,匿名返回值函数中,设置的返回值就是具体的值,而在有名返回值函数,设置的是返回值的引用(即 ret 的引用)
    所以有名返回值函数的 defer 会影响最后的返回值

    对 defer 的字节码解析可以参考这篇文章

    sync.Mutex 作为传参的时候,需要传指针,否则可能导致死锁

    因为 Mutex 的加锁和释放锁逻辑是通过内部的state和sema两个整数对象控制的,直接拷贝 Mutex 只是复制了锁的状态,但和原来的锁并不是同一个,所以释放复制后的 Mutex 并不能解锁原来的 Mutex

    一个复现这个问题的示例,是通过 pointer receiver 占锁,通过 value receiver 释放锁,由于 value receiver 会拷贝调用者对象,所以释放的锁对象和外面的不同,导致死锁

    参考-Detect locks passed by value in Go

    package main
    
    import "sync"
    
    type T struct {
        lock sync.Mutex
    }
    func (t *T) Lock() {
        t.lock.Lock()
    }
    func (t T) Unlock() {
       t.lock.Unlock()
    }
    func main() {
        t := T{lock: sync.Mutex{}}
        t.Lock()
        t.Unlock()
        t.Lock() // 死锁
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    基本类型

    interface 的判空

    Go 面试题:Go interface 的一个 “坑” 及原理分析

    interface 表示 golang 的接口类型,它和其他语言的“基类”(如 Java 的 interface)相比,在空对象上的表现不太一样

    示例代码: 思考以下代码会输出什么

    type MyError struct {
    	msg string
    }
    
    func (err *MyError) Error() string {
    	return err.msg
    }
    
    func workWithBalance() bool {
    	return true
    }
    
    func workTooHard() bool {
    	return false
    }
    
    func getError(f func() bool) error {
    	var err *MyError
    	if !f() {
    		err = &MyError{
    			msg: "need relax",
    		}
    	}
    	return err
    }
    
    func main() {
    	if err := getError(workTooHard); err != nil {
    		println("work too hard caused " + err.Error())
    	}
    	if getError(workWithBalance) == nil {
    		println("work with balance")
    	}
    }
    
    • 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

    以上代码对自定义错误 MyError 进行了判空,预期是通过 getError(workWithBalance) 获取到的 error 为空,结果却不为空(work with balance 不会打印)

    那么为什么 var err *MyError 声明,但没有赋值的 err 判空得到的是 false 呢?我们可以从 interface 的内部结构 iface、eface 可以了解到端倪

    // runtime/runtime2.go
    
    type eface struct {
        _type *_type
        data  unsafe.Pointer
    }
    
    type iface struct {
        tab  *itab
        data unsafe.Pointer
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    其中,iface 包含了接口的类型、方法和数据,iface 的 tab 描述了接口的类型和方法,data 则指向实际的接口数据

    itab 的结构如下:

    type itab struct {
    	inter *interfacetype // abi.InterfaceType(abi: application binary interface 二进制接口),包含接口类型,pkg path(import 的路径)和接口方法(Imethod)
    	_type *_type // abi.Type,实体类型
    	hash  uint32 // _type.hash 拷贝而来
    	_     [4]byte // 占位,留给以后可能用到的对象
    	fun   [1]uintptr // 接口方法对应的地址,多个方法则在这个数组后面继续添加,fun[0] == 0 表示未实现接口的方法
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    而 eface 的数据结构就简单很多了,只包含实体类型 _type 和数据指针 data,不包含方法信息
    不包含方法的 eface 对应 var i interface{} 这种对象声明,主要用于 传参、序列化和泛型场景

    那么 go 是如何判断一个 interface 类型对象是否为空呢?需要两个条件:data 对应的值为空,且 _type 类型也为空
    通过 getError(workWithBalance) 获取的 error,虽然没有被初始化,但它有具体实现类型(MyError)而不是纯接口类型(error),所以 err == nil 为 false

    想要判断 interface 背后的对象的值确实为空,有两种办法:先强转成具体的类型指针再判断,或者是通过反射方法 reflact.ValueOf 获取到内部的值来判断

    e := getError(workWithBalance)
    v := reflect.ValueOf(e)
    if e.(*MyError) == nil {
    	println("err is nil")
    }
    // 注意: IsNil 对一些无法判断空值的类型,或者未初始化的 interface 会直接 panic,所以需要先判断 value 的 kind
    if v.Kind() == reflect.Pointer {
    	if v.IsNil() {
    		println("err is nil")
    	}
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    扩展: 空接口对象,是否可以调用接口方法呢?

    type MyError struct {
    	msg string
    }
    
    func (err *MyError) Error() string {
    	if err == nil {
    		return "empty error"
    	}
    	return err.msg
    }
    
    func main() {
    	var emptyErr *MyError
    	println(emptyErr.Error()) // 不会 panic
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    结论是可以调用,这一点和其他语言很不同。一个指针是否可以调用方法,取决于它的类型而不是实际值是否为空,空接口对象调用 pointer receiver 不会报空指针,但注意只是能调用,如果 pointer receiver 内部有获取对象属性的操作,还是会报空指针错误

    参考-nil receiver in GoLang

    参考-Calling a method on a nil struct pointer doesn’t panic. Why not?

    nil channel 的使用场景

    在公司规范中,说明“禁止对 nil 或已关闭的 channel 进行读写关闭操作”,这一句算是规范中为数不多需要指正的一点:nil channel 在特定场景是有用的

    先了解一下各种特殊情况下使用 channel 会出现什么情况

    closed channel: 读不阻塞(会读完剩下的数据,之后返回零值)、写 panic、再次 close panic
    nil channel: 读阻塞、写阻塞、close panic

    对于 nil channel 读写都会阻塞的特性,有一个使用场景是 合并多个 channel 数据的时候,对于已经取完数据的 channel 可以置为空,这样在继续使用 select 的同时也不影响其他还有数据的 channel 的读取,参考

    func merge(a, b <-chan int) <-chan int {
    	c := make(chan int)
    	go func() {
    		defer close(c)
    		for a != nil || b != nil {
    			select {
    			case v, ok := <-a:
    				if !ok {
    					fmt.Println("a is done")
    					a = nil
    					continue
    				}
    				c <- v
    			case v, ok := <-b:
    				if !ok {
    					fmt.Println("b is done")
    					b = nil
    					continue
    				}
    				c <- v
    			}
    		}
    	}()
    	return c
    }
    
    • 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

    高性能场景

    使用 sync.Pool 获取需要频繁申请的对象

    比较典型的场景是在高并发的数据流读取和写入场景中,通过 pool 缓存 buffer,避免每次都申请新的 buffer 造成频繁内存资源申请

    在框架层代码中会比较容易看到 pool 的使用,如 gin 用来缓存处理请求的 Context 对象,gorm 用来缓存序列化对象(SerializerInterface)等

    性能测试结果:

    func BenchmarkByteBufferWithoutPool(b *testing.B) {
    	for i := 0; i < b.N; i++ {
    		buf := bytes.Buffer{}
    		buf.WriteString(longStr)
    		io.Copy(io.Discard, &buf)
    	}
    }
    
    func BenchmarkByteBufferWithPool(b *testing.B) {
    	pool := sync.Pool{
    		New: func() any {
    			return new(bytes.Buffer)
    		},
    	}
    
    	for i := 0; i < b.N; i++ {
    		buf := pool.Get().(*bytes.Buffer)
    		buf.WriteString(longStr)
    		io.Copy(io.Discard, buf)
    		buf.Reset()
    		pool.Put(buf)
    	}
    }
    
    // 测试结果
    // BenchmarkByteBufferWithoutPool-8           55544210               211.1 ns/op          1072 B/op          2 allocs/op
    // BenchmarkByteBufferWithPool-8           355192696               33.25 ns/op            0 B/op          0 allocs/op
    
    • 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

    从执行次数和内存开销来看,pool 在多协程下达到的对象复用的效果,都能带来很大的提升

    bytes 和 string 的 0 内存申请方法

    直接看无内存开销的转换方式:

    func ByteSliceToString(bytes []byte) string {
    	var s string
    	sliceHeader := (*reflect.SliceHeader)(unsafe.Pointer(&bytes))
    	stringHeader := (*reflect.StringHeader)(unsafe.Pointer(&s))
    	stringHeader.Data = sliceHeader.Data
    	stringHeader.Len = sliceHeader.Len
    	return s
    }
    
    func StringToByteSlice(s string) (bytes []byte) {
    	bh := (*reflect.SliceHeader)(unsafe.Pointer(&bytes))
    	sh := *(*reflect.StringHeader)(unsafe.Pointer(&s))
    	bh.Data = sh.Data
    	bh.Len = sh.Len
    	bh.Cap = sh.Len
    	return
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    参考

    两者的相互转换都用到了反射包中表示底层结构的对象,如 slice 的 SliceHeader,string 和 StringHeader。因为 string 和 byte 数组两者的底层数据结构非常相似,只相差 slice 的 cap,所以转换逻辑并不复杂

    string 和 slice 的底层结构在go源码中如下:

    // runtime/string.go
    type stringStruct struct {
    	str unsafe.Pointer
    	len int
    }
    
    // runtime/slice.go
    type slice struct {
    	array unsafe.Pointer
    	len   int
    	cap   int
    }
    
    // reflect/value.go
    type StringHeader struct {
    	Data uintptr
    	Len  int
    }
    
    type SliceHeader struct {
    	Data uintptr
    	Len  int
    	Cap  int
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    go 1.20 之后,StringHeader 和 SliceHeader 被标注为 Deprecated,改为推荐使用 StringData 和 SliceData,写法上更简单了

    参考-The conversion of byte slice and string has changed again in Go 1.20

    func byteSliceToString(bytes []byte) string {
    	return unsafe.String(unsafe.SliceData(bytes), len(bytes))
    }
    
    func stringToByteSlice(s string) (bytes []byte) {
    	return unsafe.Slice(unsafe.StringData(s), len(s))
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    实测: 直接强转和通过反射转换的benchmark测试结果对比

    bytes 转 string

    BenchmarkForceConvertBytesToString-8    66501550               178.7 ns/op          1024 B/op          1 allocs/op
    
    BenchmarkConvertBytesToString-8         1000000000               0.3236 ns/op          0 B/op          0 allocs/op
    
    • 1
    • 2
    • 3

    可以看到,强转的方式执行速度(平均每次 178ns)远小于通过反射方式执行的,并且强转每次需要申请 1kb 内存,刚好和转换的字符串大小对应

    string 转 bytes

    BenchmarkForceConvertStringToBytes-8    67139846               200.6 ns/op          1024 B/op          1 allocs/op
    
    BenchmarkConvertStringToBytes-8         1000000000               0.3230 ns/op          0 B/op          0 allocs/op
    
    • 1
    • 2
    • 3

    结果和 bytes 转 string 类似,不再赘述

    高并发的任务(如接口)创建协程池去消费和执行

    协程确实很”轻“,相比操作系统线程默认大小为1M 来说,它的初始大小只有 2k,确实很小(但随着栈空间扩大可能会扩缩容),不过在高并发场景下还是需要对开启协程进行控制的

    协程池的选型有很多,常见的开源项目有 tunnyants,两者实现方式略有区别,tunny 提交任务时是同步提交,可以拿到执行后的返回值,ants 是异步提交,不支持获取返回值,要拿到返回值的话得自己实现。示例如下:

    import (
    	"github.com/Jeffail/tunny"
    	"github.com/panjf2000/ants/v2"
    )
    
    func TestTunnyPool(t *testing.T) {
    	wg := sync.WaitGroup{}
    	wg.Add(100)
    	pool := tunny.NewFunc(10, func(payload interface{}) interface{} {
    		time.Sleep(3 * time.Second)
    		wg.Done()
    		return payload
    	})
    	defer pool.Close()
    
    	for i := 0; i < 100; i++ {
    		// tunny.pool.Process 是同步方法,所以需要开启协程才能并发
    		go func(i int) {
    			pool.Process(i)
    		}(i)
    	}
    
    	wg.Wait()
    }
    
    func TestAntsPool(t *testing.T) {
    	wg := sync.WaitGroup{}
    	wg.Add(100)
    	pool, _ := ants.NewPoolWithFunc(10, func(i interface{}) {
    		fmt.Printf("%d execute\n", i)
    		time.Sleep(3 * time.Second)
    		fmt.Printf("%d finish\n", i)
    		wg.Done()
    	})
    	defer pool.Release()
    
    	for i := 0; i < 100; i++ {
    		pool.Invoke(i)
    	}
    
    	wg.Wait()
    }
    
    • 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

    当然,对于 web 框架来说,这种控制并发的功能官方都有。如 gin 通过 limit 插件,本质也是通过 channel 控制并发协程数

  • 相关阅读:
    【网络编程】UDP Socket编程
    Vue3兼容低版本浏览器(ie11,chrome63)
    Triage沙箱监控
    企业计算机服务器中了mallox勒索病毒怎么解决,勒索病毒解密文件恢复
    java 枚举
    《docker基础篇:7.Docker容器数据卷》包括坑、回顾下上一讲的知识点,参数V、是什么、更干嘛、数据卷案例
    【LeetCode刷题】-- 29.两数相除
    Elasticsearch倒排索引
    在前端开发中需要考虑的常见web安全问题和攻击原理以及防范措施
    百度之星题目记录
  • 原文地址:https://blog.csdn.net/xiaoliizi/article/details/133747191