Skip to content
目录

Golang

高性能技法

常用数据结构

  1. 优先使用 strconv 包而不是 fmt

    基本数据类型与字符串之间的转换,优先使用 strconv 包 而不是 fmt 包,因为 fmt 使用了反射在运行时进行类型判断,性能有所损耗。

  2. 可以使用少量的重复代码

    少量的重复代码,优于使用反射。这样就避免了反射涉及的类型判断和大量内存分配。

  3. 慎用 binary.Readbinary.Write

    binary.Read 和 binary.Write 使用反射并且很慢。如果有需要用到这两个函数的地方,我们应该手动实现这两个函数的相关功能,而不是直接去使用它们。

  4. 避免多次字符串到字节切片的转换

    不要反复从固定字符串创建字节 slice,因为重复的切片初始化会带来性能损耗。相反,请执行一次转换并捕获结果。

  5. 指定容器容量

    尽可能指定容器容量,以便为容器预先分配内存。这将在后续添加元素时减少通过复制来调整容器大小。make(map[T1]T2, hint)make([]T, length, capacity)

  6. 行内拼接字符串

    推荐使用运算符 + 而不是 fmt.Sprintf(),除非涉及类型转换的变量较多。

  7. 非行内拼接字符串

    字符串拼接还有其他的方式,比如 strings.Join()strings.Builderbytes.Buffer[]byte,这几种不适合行内使用。当待拼接字符串数量较多时可考虑使用。

    如果结果字符串的长度是可预知的,使用 []byte 且预先分配容量的拼接方式性能最佳。

    go
    package strc
    
    import (
        "bytes"
        "strings"
        "testing"
    )
    
    var (
        s1 = "foo"
        s2 = "bar"
        s3 = "baz"
    )
    
    // strings.Join
    func BenchmarkJoinStrWithStringsJoin(b *testing.B) {
        for i := 0; i < b.N; i++ {
            _ = strings.Join([]string{s1, s2, s3}, "")
        }
    }
    
    // strings.Builder
    func BenchmarkJoinStrWithStringsBuilder(b *testing.B) {
        for i := 0; i < b.N; i++ {
            var builder strings.Builder
            builder.WriteString(s1)
            builder.WriteString(s2)
            builder.WriteString(s3)
        }
    }
    
    // 预分配内存的 strings.Builder
    func BenchmarkJoinStrWithStringsBuilderPreAlloc(b *testing.B) {
        for i := 0; i < b.N; i++ {
            var builder strings.Builder
            builder.Grow(9)
            builder.WriteString(s1)
            builder.WriteString(s2)
            builder.WriteString(s3)
        }
    }
    
    // strings.Buffer
    func BenchmarkJoinStrWithBytesBuffer(b *testing.B) {
        for i := 0; i < b.N; i++ {
            var buffer bytes.Buffer
            buffer.WriteString(s1)
            buffer.WriteString(s2)
            buffer.WriteString(s3)
        }
    }
    
    // []byte 拼接
    func BenchmarkJoinStrWithByteSlice(b *testing.B) {
        for i := 0; i < b.N; i++ {
            var bs []byte
            bs = append(bs, s1...)
            bs = append(bs, s2...)
            bs = append(bs, s3...)
        }
    }
    
    // 预分配内存的 []byte 拼接
    func BenchmarkJoinStrWithByteSlicePreAlloc(b *testing.B) {
        for i := 0; i < b.N; i++ {
            bs := make([]byte, 0, 9)
            bs = append(bs, s1...)
            bs = append(bs, s2...)
            bs = append(bs, s3...)
        }
    }
    

    Benchmark 结果:

    text
    goarch: amd64
    pkg: code.lmcw.art/example/strc
    cpu: AMD A10-7870K Radeon R7, 12 Compute Cores 4C+8G
    BenchmarkJoinStrWithStringsJoin
    BenchmarkJoinStrWithStringsJoin-4               14803089         78.17 ns/op
    BenchmarkJoinStrWithStringsBuilder
    BenchmarkJoinStrWithStringsBuilder-4            11825329        100.7 ns/op
    BenchmarkJoinStrWithStringsBuilderPreAlloc
    BenchmarkJoinStrWithStringsBuilderPreAlloc-4    20037103         62.86 ns/op
    BenchmarkJoinStrWithBytesBuffer
    BenchmarkJoinStrWithBytesBuffer-4               11668932        107.4 ns/op
    BenchmarkJoinStrWithByteSlice
    BenchmarkJoinStrWithByteSlice-4                 12921136         93.53 ns/op
    BenchmarkJoinStrWithByteSlicePreAlloc
    BenchmarkJoinStrWithByteSlicePreAlloc-4         132355752          8.915 ns/op
    PASS
    
  8. 使用 range 遍历 slice 或者 array 时,只迭代下标并使用下标访问元素。

    go
    s := []string{s1, s2, s3}
    for i := range s {
        fmt.Println(s[i])
    }
    

内存管理

  1. 使用空结构体节省内存

    go
    // 空结构体不占内存空间
    fmt.Println(unsafe.Sizeof(struct{}{}))
    
    // 不发送数据的信道
    ch := make(chan struct{})
    go doSomething(ch)
    ch <- struct{}{}
    close(ch)
    
    // 仅包含方法的结构体
    type Door struct{}
    
    func (d Door) Open() {
        fmt.Println("Open the door")
    }
    
    func (d Door) Close() {
        fmt.Println("Close the door")
    }
    
  2. struct 内存对齐

    CPU 访问内存时,并不是逐个字节访问,而是以字长(word size)为单位访问。比如 32 位的 CPU ,字长为 4 字节,那么 CPU 访问内存的单位也是 4 字节。

    这么设计的目的,是减少 CPU 访问内存的次数,加大 CPU 访问内存的吞吐量。比如同样读取 8 个字节的数据,一次读取 4 个字节那么只需要读取 2 次。

    CPU 始终以字长访问内存,如果不进行内存对齐,很可能增加 CPU 访问内存的次数。

    Go 内存对齐规则:

    • 对于任意类型的变量 xunsafe.Alignof(x) 至少为 1
    • 对于结构体类型的变量 x,计算 x 每一个字段 funsafe.Alignof(x.f)unsafe.Alignof(x) 等于其中的最大值。
    • 对于数组类型的变量 xunsafe.Alignof(x) 等于构成数组的元素类型的对齐系数。

    其中函数 unsafe.Alignof 用于获取变量的对齐系数。对齐系数决定了字段的偏移和变量的大小,两者必须是对齐系数的整数倍。

    合理的 struct 布局:

    go
    type demo1 struct {
        a int8
        b int16
        c int32
    }
    
    type demo2 struct {
        a int8
        c int32
        b int16
    }
    
    func main() {
        fmt.Println(unsafe.Sizeof(demo1{})) // 8
        fmt.Println(unsafe.Sizeof(demo2{})) // 12
    }
    

    空结构与空数组对内存对齐的影响:

    空结构与空数组在 Go 中比较特殊。没有任何字段的空 struct{} 和没有任何元素的 array 占据的内存空间大小为 0

    因为这一点,空 struct{} 或空 array 作为其他 struct 的字段时,一般不需要内存对齐。但是有一种情况除外:即当 struct{} 或空 array 作为结构体最后一个字段时,需要内存对齐。因为如果有指针指向该字段,返回的地址将在结构体之外,如果此指针一直存活不释放对应的内存,就会有内存泄露的问题(该内存不因结构体释放而释放)。

    go
    type demo3 struct {
        a struct{}
        b int32
    }
    type demo4 struct {
        b int32
        a struct{}
    }
    
    func main() {
        fmt.Println(unsafe.Sizeof(demo3{})) // 4
        fmt.Println(unsafe.Sizeof(demo4{})) // 8
    }
    
  3. 减少逃逸,将变量限制在栈上

    变量逃逸一般发生在如下几种情况:

    • 变量较大
    • 变量大小不确定
    • 变量类型不确定
    • 返回指针
    • 返回引用
    • 闭包

sync.Once

延迟初始化功能,可以用于提高系统启动的速度,同时还能保证只初始化一次,可以用于数据库连接池等场景。

go
var (
	dbOnce   sync.Once
	dbClient *gorm.DB
)

func DB() (*gorm.DB, error) {
	var err error
	dbOnce.Do(func() {
		l := logger.New(
			logrus.NewWriter(),
			logger.Config{
				SlowThreshold: time.Millisecond,
				Colorful:      false,
				LogLevel:      logger.Info,
			},
		)
		dbClient, err = gorm.Open(mysql.Open(consts.MySQLDefaultDSN),
			&gorm.Config{
				PrepareStmt: true,
				Logger:      l,
			},
		)
		if err == nil {
			err = dbClient.Use(tracing.NewPlugin())
		}
	})

	return dbClient, err
}

sync.Pool

对象复用。如:json 的反序列化在文本解析和网络通信过程中非常常见,当程序并发度非常高的情况下,短时间内需要创建大量的临时对象。而这些对象是都是分配在堆上的,会给 GC 造成很大压力,严重影响程序的性能。

sync.Pool 的大小是可伸缩的,高负载时会动态扩容,存放在池中的对象如果不活跃了会被自动清理。

假设我们有个对外开放的接口,每次接收到来自第三方的请求后,需要解析请求体并进行签名验证。这个过程中,请求体参数则会频繁的创建销毁。

go
type body struct {
	Appid     string `json:"appid"`
	SignType  string `json:"sign_type"` // RSA2 | SM2
	Nonce     string `json:"nonce"`
	Timestamp string `json:"timestamp"`
	Version   string `json:"version"`
	Data      string `json:"data"`
	Sign      string `json:"sign"`
}

var bodyPool = sync.Pool{
	New: func() interface{} {
		return &body{}
	},
}

func Sign() app.HandlerFunc {
	return func(ctx context.Context, c *app.RequestContext) {		
		// 解析请求体
		body := bodyPool.Get().(*body)
		defer bodyPool.Put(body)
        if err := json.Unmarshal(c.GetRawData(), body); err != nil {
			c.JSON(consts.StatusOK, utils.H{
				"code": response.CodeBadRequest,
				"msg":  "请求体解析失败",
			})
			c.Abort()
			return
		}

		// 验证签名
	}
}

singleflight

重复的函数调用抑制机制。即将相同的并发请求合并成一个请求,以减少下游压力。常用于缓存击穿。当大量请求查询同一个共享资源,而这个共享资源正好失效,则所有的请求都会打到数据库,导致数据库的负载陡然上升。

go
import (
	"context"
	"golang.org/x/sync/singleflight"
)

type Material struct{}

var sfg singleflight.Group

func Mat(ctx context.Context, mchtId string) (Material, error) {
	// 从本地缓存中获取商户资料
	mat, err := local_cache.Get(ctx, mchtId)
	if err != nil {
		return Material{}, err
	}
	if mat != nil {
		return mat, nil
	}

	// 从redis缓存中获取商户资料
	mat, err = redis_cache.Get(ctx, mchtId)
	if err != nil {
		return Material{}, err
	}
	if mat != nil {
		return mat, nil
	}

	mat, err, _ = sfg.Do(mchtId, func() (interface{}, error) {
		// 从数据库中获取商户资料
		mat, err := db.Get(ctx, mchtId)
		// 回写本地缓存和redis缓存
		local_cache.Set(ctx, mchtId, mat)
		redis_cache.Set(ctx, mchtId, mat)

		return mat, err
	})

	return mat, err
}

Do 操作存在超时或阻塞的情况时,使用 DoChan 是更好的选择。

如果在失败时需要更多的尝试机会,可以另开 goroutine 定时 Forget

测试

竞态检测及代码覆盖率

shell
$ go test -race -cover  -coverprofile=./coverage.out -timeout=10m -short -v ./...
# 结果命令行输出
$ go tool cover -func ./coverage.out
# 结果以HTML形式输出
$ go tool cover -html=coverage.out -o coverage.html

基准测试

以字符串拼接函数作为示例。创建 strcontact_test.go

go
// + 连接
func BenchmarkPlus(b *testing.B) {
	s := ""

	for n := 0; n < b.N; n++ {
		s += "hello world"
	}

	s = ""
}

// bytes buffer
func BenchmarkBytesBuffer(b *testing.B) {
	var buf bytes.Buffer
	for i := 0; i < b.N; i++ {
		buf.WriteString("hello world")
	}
	_ = buf.String()
}

// strings builder
func BenchmarkStringsBuilder(b *testing.B) {
	var sb strings.Builder
	for i := 0; i < b.N; i++ {
		sb.WriteString("hello world")
	}
	_ = sb.String()
}

// 预分配内存
func BenchmarkPreAlloc(b *testing.B) {
	str := "hello world"
	buf := make([]byte, 0, b.N*len(str))

	for i := 0; i < b.N; i++ {
		buf = append(buf, str...)
	}
}

// strings builder 预分配内存
func BenchmarkStringsBuilderPreAlloc(b *testing.B) {
	str := "hello world"

	var sb strings.Builder
	sb.Grow(b.N * len(str))

	for i := 0; i < b.N; i++ {
		sb.WriteString(str)
	}
	_ = sb.String()
}

使用 go test 命令测试:

shell
# 测试当前目录下的所有基准测试
go test -bench .
# 递归测试当前目录及下级目录下的所有基准测试
go test -bench ./...
# 使用正则表达式匹配测试用例
go test -bench='StringsBuilder' .

解释

benchmark 用例的参数 b *testing.B,有个属性 b.N 表示这个用例需要运行的次数。b.N 对于每个用例都是不一样的。

那这个值是如何决定的呢?b.N 从 1 开始,如果该用例能够在 1s 内完成,b.N 的值便会增加,再次执行。b.N 的值大概以 1, 2, 3, 5, 10, 20, 30, 50, 100 这样的序列递增,越到后面,增加得越快。

一个测试的输出类似下面这样:

text
goos: linux
goarch: amd64
pkg: gitee.com/dlpay/strcontact
cpu: 13th Gen Intel(R) Core(TM) i7-13700K
BenchmarkPlus-24                          335173            326902 ns/op
BenchmarkBytesBuffer-24                 71131600                14.29 ns/op
BenchmarkStringsBuilder-24              143914178               10.28 ns/op
BenchmarkPreAlloc-24                    495671646                4.749 ns/op
BenchmarkStringsBuilderPreAlloc-24      1000000000               1.268 ns/op
PASS
ok      gitee.com/dlpay/strcontact     117.193s

BenchmarkPlus-24 中的 -24 表示 GOMAXPROCS ,即使用了 24 个 CPU 核心数来测试, 使用 -cpu 即可改变 GOMAXPROCS ,同时 -cpu 也支持传入列表,如:-cpu=2,4。使用 go test -cpu=2,4 -bench . 的输出如下:

text
goos: linux
goarch: amd64
pkg: gitee.com/dlpay/strcontact
cpu: 13th Gen Intel(R) Core(TM) i7-13700K
BenchmarkPlus-2                           389607            182764 ns/op
BenchmarkPlus-4                           236176            155908 ns/op
BenchmarkBytesBuffer-2                  89882253                12.49 ns/op
BenchmarkBytesBuffer-4                  100000000               12.41 ns/op
BenchmarkStringsBuilder-2               251099154               10.52 ns/op
BenchmarkStringsBuilder-4               254379927                6.826 ns/op
BenchmarkPreAlloc-2                     491578856                2.957 ns/op
BenchmarkPreAlloc-4                     518177994                2.692 ns/op
BenchmarkStringsBuilderPreAlloc-2       952742638                1.129 ns/op
BenchmarkStringsBuilderPreAlloc-4       982389376                1.134 ns/op
PASS
ok      gitee.com/dlpay/strcontact     121.870s

上述输出中的 BenchmarkPlus-2 389607 182764 ns/op389607 表示代码被测试了 389607 次,182764 表示平均每次耗时 182764 纳秒。由此可以看出 StringsBuilderPreAlloc 具有最好的性能。

微调测试参数

Benchmark 的默认时间是 1s,那么我们可以使用 -benchtime 指定为 5s。-benchtime 的值除了是时间外,还可以是具体的次数,执行 30 次可以用 -benchtime=30x

内存分配

-benchmem 参数可以度量内存分配的次数。内存分配次数也性能也是息息相关的,例如不合理的切片容量,将导致内存重新分配,带来不必要的开销。例如 go test -cpu=4 -benchmem -run=^$ -bench ^BenchmarkPlus$ 的输出:

text
goos: linux
goarch: amd64
pkg: gitee.com/dlpay/strcontact
cpu: 13th Gen Intel(R) Core(TM) i7-13700K
BenchmarkPlus-4           369964            218001 ns/op         2038881 B/op          1 allocs/op
PASS
ok      gitee.com/dlpay/strcontact     80.687s

1 allocs/op 表示执行了一次内存分配。

准备与清理工作

如果在 Benchmark 开始前,需要一些准备工作,如果准备工作比较耗时,则需要将这部分代码的耗时忽略掉。

go
func BenchmarkPay(b *testing.B) {
    for n := 0; n < b.N; n++ {
        time.Sleep(time.Second * 3) // 模拟支付前的一系列预处理操作
    	b.ResetTimer() // 重置定时器
        Pay() // 执行支付操作
    }
}

func BenchmarkPay(b *testing.B) {
    for n := 0; n < b.N; n++ {
        b.StopTimer()
    	playload := MixOps() // 模拟支付前的一系列预处理操作
    	b.StartTimer()
        Pay(playload) // 执行支付操作
    }
}

工具

go-global-update

更新全局安装的 go 可执行程序的工具。

安装:

shell
go install github.com/Gelio/go-global-update@latest

使用:

shell
go-global-update
# 模拟更新,不实际更新
go-global-update --dry-run
# 指定要更新的命令
go-global-update gofumpt

Govulncheck

GO项目漏洞检查工具,更多详情参考GO官方文章

安装:

shell
go install golang.org/x/vuln/cmd/govulncheck@latest

使用,在 go.mod 目录中执行:

shell
govulncheck ./...

golangci-lint

GO语言静态代码检测工具,更多用法参考官方网站

安装

shell
go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest

使用

shell
golangci-lint run ./...

.golangci.yaml 示例

yaml
run:
  concurrency: 4
  timeout: 5m
  skip-dirs:
    - idl
  skip-dirs-use-default: true
  tests: true
  modules-download-mode: readonly
  allow-parallel-runners: true

output:
  format: colored-line-number
  print-issued-lines: true
  print-linter-name: true
  uniq-by-line: false
  sort-results: true

linters-settings:
  # See the dedicated "linters-settings" documentation section.

linters:
  disable-all: true
  enable:
    - errcheck
    - gosimple
    - gofumpt
    - govet
    - ineffassign
    - staticcheck
    - typecheck
    - unused
    - whitespace
    - misspell
    - goimports
    - unparam
    - gosec
    - nakedret
    - lll
    - goconst
    - gocritic
    - dupl
    - gocognit
    - goheader
    - gocyclo
    - unconvert
    - gosec
    - bodyclose
    - containedctx
    - contextcheck
    - cyclop
    - decorder
    - dupl
    - durationcheck
    - errchkjson
    - errname
    - errorlint
    - execinquery
    - exhaustive
    - exhaustruct
    - exportloopref
    - forbidigo
    - forcetypeassert
    - funlen
    - gocheckcompilerdirectives
    - gochecknoinits
    - gocognit
    - goconst
    - gocritic
    - gosec
    - loggercheck
    - makezero
    - wsl
    - wrapcheck
    - whitespace
    - unconvert

issues:
  # See the dedicated "issues" documentation section.

severity:
  # See the dedicated "severity" documentation section.

cobra-cli

快速创建GO语言命令行程序。

安装

shell
go install github.com/spf13/cobra-cli@latest

初始化应用程序

shell
cd myapp
go mod init gitee.com/dlpay/myapp
cobra-cli init

添加命令

shell
cobra-cli add serve

定义生成模板

shell
vi ~/.cobar.yaml

添加如下内容:

yam
author: BiLuoHui <biluohui@163.com>
license: MIT
useViper: true

自定义License及文件头:

yaml
author: BiLuoHui <biluohui@163.com>
year: 2023
license:
  header: This file is part of CLI application MyApp.
  text: |
    {{ .copyright }}

    This is my license. There are many like it, but this one is mine.
    My license is my best friend. It is my life. I must master it as I must
    master my life.