跳转至

04 单机吞吐优化(二):高性能数据处理三板斧

你好,我是徐逸。

通过上节课的学习,我们知道了提升单机吞吐的思路是定位到单机瓶颈资源。对于瓶颈资源,要么增加资源,比如提升单机CPU、内存等的规格;要么减少单个请求对瓶颈资源的消耗,让相同的资源可以处理更多请求。

对于CPU和内存瓶颈,我们也介绍了容器类型的使用方法,从而降低CPU和内存资源消耗,提升单机吞吐。

有了数据类型,自然少不了对数据的处理。今天我们就来聊聊对于CPU和内存瓶颈,有哪些常用的高性能数据处理技巧。只要能够灵活运用这些技巧,我们就能降低单个请求对CPU和内存资源消耗,提升单机吞吐。

为了便于说明,我先构造一段代码,这段代码会循环做字符串拼接、整型转字符串和字符串转字节切片操作。我们今天会基于这段代码的性能优化过程,带你掌握这些高性能技巧。

package performance

import (
    "fmt"
)

type User struct {
    Id   int
    Name string
}

// GenerateIdsRaw 原始待优化函数
func GenerateIdsRaw(users []*User) (string, string, []byte) {
    names := ""
    idStr := ""
    var nameByte []byte
    for index := range users {
        idStr = fmt.Sprint(users[index].Id)
        names = names + "," + users[index].Name
        nameByte = []byte(users[index].Name)
    }
    return idStr, names, nameByte
}

接下来是Benchmark代码,也就是对前面构造的那段代码做基准测试,评估前面代码在大规模用户数据处理时的性能。

package performance

import (
    "fmt"
    "testing"
)

// 初始化构造测试用例
var users []*User

func init() {
    for i := 0; i < 1000; i++ {
        users = append(users, &User{Id: i, Name: fmt.Sprintf("user%d", i)})
    }
}

func BenchmarkGenerateIdsRaw(b *testing.B) {
    for n := 0; n < b.N; n++ {
        GenerateIdsRaw(users)
    }
}

后面要讲的技巧既可以降低CPU消耗,也可以降低内存消耗。为了叙述方便,我们这里以降低CPU消耗为例。但在实际应用中,如果你的资源瓶颈是内存,也可以用这些技巧降低内存占用。

现在让我们基于前面的Benchmark代码来生成并查看一下CPU火焰图,看下哪段逻辑消耗CPU资源比较高,我们再针对最消耗CPU的代码逻辑进行优化。

go test -run=none -bench=BenchmarkGenerateIdsRaw -benchtime=10s -gcflags=all=-l -cpuprofile cpu.prof
go tool pprof -http=":8081" cpu.prof

从火焰图中我们可以看到,消耗CPU最多的是runtime.concatstrings函数,而这个函数是Go语言 “+” 操作符进行字符串拼接的底层实现函数。

高性能字符串拼接

那这个 “+” 操作符到底是怎么拼接字符串的呢?这里面是不是有啥优化空间呢?想要解决这个问题,我们就需要先弄清楚 “+” 操作符拼接字符串的逻辑。

用 “+” 连接符拼接两个字符串的时候,得先开辟一块新的内存空间来存放拼接后的字符串,然后把这两个字符串按照拼接的顺序拷贝到新空间。这个新空间的大小等于原来两个字符串长度的和。

比如字符串“ab” 和 字符串“cd” 要拼接,就得先给结果字符串找个地方,然后把字符串“ab” 和 “cd” 分别拷贝过去,这样就得到了新的字符串。

s1 := "ab" + "cd"

如果是循环拼接字符串,每次循环迭代都要分配新空间的话,就需要不停地在堆上分配内存。而且每次迭代还得把拼接的字符串都拷贝到结果空间,需要不停地拷贝。而堆内存分配和拷贝都是比较消耗CPU的操作。

因此,要减少字符串拼接操作对CPU资源的消耗,就需要减少字符串拼接的内存分配和拷贝。Go 语言有没有什么好办法呢?

有。Go 语言里有个 strings.Builder 类型,这个 strings.Builder 类型可以减少内存分配和拷贝,高效地拼接字符串。

给你看看它是怎么用的:

package main

import (
        "fmt"
        "strings"
)

func main() {
    var b strings.Builder
    for i := 3; i >= 1; i-- {
            fmt.Fprintf(&b, "%d...", i)
    }
    b.WriteString("ignition")
    fmt.Println(b.String()) // 输出 3...2...1...ignition
}

这个 strings.Builder 类型有两个很厉害的地方。

第一个是它有内存预分配的功能。这个类型有一个 Grow 方法,可以提前把内存分配好,实现预分配功能。这样每次循环迭代时就不用重新分配内存,内存频繁分配的问题就解决了。

// Grow grows b's capacity, if necessary, to guarantee space for another n bytes. 
// After Grow(n), at least n bytes can be written to b without another allocation. 
func (b *Builder) Grow(n int)

第二个厉害的地方是,字符串拼接时,内存拷贝次数更少

为啥呢?因为 Builder 底层是用 [] byte 类型来存字符串的。往 Builder 里写东西的时候,只有它的 buf 容量不够、需要扩容时,才会发生内存迁移拷贝,不像之前每次循环都得拷贝字符串。要是提前用 Grow 方法分配好足够的内存,在循环拼接的时候,就不会发生扩容迁移,导致拷贝了。

type Builder struct {
    buf  []byte
}

// WriteString appends the contents of s to b's buffer.
// It returns the length of s and a nil error.
func (b *Builder) WriteString(s string) (int, error) {
    b.buf = append(b.buf, s...)
    return len(s), nil
}

现在,让我们用这个 strings.Builder 来重新实现一下我们的函数:

// GenerateIdsBuilder 使用strings.Builder拼接字符串
func GenerateIdsBuilder(users []*User) (string, string, []byte) {
    names := ""
    idStr := ""
    var nameByte []byte
    length := 0
    for index := range users {
        idStr = fmt.Sprint(users[index].Id)
        nameByte = []byte(users[index].Name)
        length += len(users[index].Name) + 1
    }
    var builder strings.Builder
    builder.Grow(length) // 预分配
    for index := range users {
        builder.WriteString(",")
        builder.WriteString(users[index].Name)
    }
    return idStr, names, nameByte
}

到底 strings.Builder类型有没有我们说的这么厉害呢?咱们来做个测试,用 Benchmark 来测一测 “+” 操作符实现字符串拼接和strings.Builder实现字符串拼接的性能。

下面是我们的Benchmark脚本:

package performance

import (
    "fmt"
    "testing"
)

// 初始化构造测试用例
var users []*User

func init() {
    for i := 0; i < 1000; i++ {
        users = append(users, &User{Id: i, Name: fmt.Sprintf("user%d", i)})
    }
}

func BenchmarkGenerateIdsRaw(b *testing.B) {
    for n := 0; n < b.N; n++ {
        GenerateIdsRaw(users)
    }
}

func BenchmarkGenerateIdsBuilder(b *testing.B) {
    for n := 0; n < b.N; n++ {
        GenerateIdsBuilder(users)
    }
}

测试结果出来了,我们可以看到用strings.Builder拼接字符串的方式,性能有巨大的提升

killianxu@KILLIANXU-MB0 performance % go test -run=none -benchmem  -bench=. -gcflags=all=-l
goos: darwin
goarch: amd64
pkg: example.com/performance
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkGenerateIdsRaw-4                    862           1190253 ns/op  4194661 B/op        3739 allocs/op
BenchmarkGenerateIdsBuilder-4               6018            167267 ns/op    30069 B/op        2735 allocs/op
  • 从 CPU 资源消耗来看,“+” 操作符拼接字符串的方式,单次函数调用要1190253ns,而strings.Builder 拼接方式只要167267ns,节约了 86% 左右的 CPU 资源。
  • 从内存消耗来看,“+” 操作符拼接字符串的方式,单次函数调用要4194661字节内存,而用 strings.Builder 拼接字符串的方式,每次函数调用只要30069字节内存,节约了 99% 左右的内存资源。

其实,在Golang官方文档注释中,也特意提到了strings.Builder。

A Builder is used to efficiently build a string using Builder.Write methods. It minimizes memory copying. The zero value is ready to use. Do not copy a non-zero Builder.

而且,Go本身的库函数,也有很多是用strings.Builder实现的。比如我们常用的strings.Join和strings.Replace函数。

func Join(elems []string, sep string) string {
    n := len(sep) * (len(elems) - 1)
    for i := 0; i < len(elems); i++ {
        n += len(elems[i])
    }

    var b Builder
    b.Grow(n)
    b.WriteString(elems[0])
    for _, s := range elems[1:] {
        b.WriteString(sep)
        b.WriteString(s)
    }
    return b.String()
}

func Replace(s, old, new string, n int) string {
    if old == new || n == 0 {
        return s // avoid allocation
    }

    // Compute number of replacements.
    if m := Count(s, old); m == 0 {
        return s // avoid allocation
    } else if n < 0 || m < n {
        n = m
    }

    // Apply replacements to buffer.
    var b Builder
    b.Grow(len(s) + n*(len(new)-len(old)))
    start := 0
    for i := 0; i < n; i++ {
        j := start
        if len(old) == 0 {
            if i > 0 {
                _, wid := utf8.DecodeRuneInString(s[start:])
                j += wid
            }
        } else {
            j += Index(s[start:], old)
        }
        b.WriteString(s[start:j])
        b.WriteString(new)
        start = j + len(old)
    }
    b.WriteString(s[start:])
    return b.String()
}

现在让我们再看一下CPU火焰图,看看将字符串拼接方式优化后,我们的代码是否还有优化空间。

go test  -run=none -bench=BenchmarkGenerateIdsBuilder -benchtime=10s -gcflags=all=-l -cpuprofile cpu.prof
go tool pprof -http=":8081" cpu.prof

从火焰图中我们可以看到,用strings.Builder优化后,消耗CPU最多的函数变成了fmt.Sprint函数。

高性能整型转字符串

这个函数在我们代码里的作用是将整型转化为字符串。这个fmt.Sprint函数为什么会消耗CPU?这里面是不是也有优化空间呢?

idStr := fmt.Sprint(user.Id)

fmt.Sprint及其变体函数,需要用反射来识别它们正在处理的类型,然后确定如何将其格式化为字符串。而这两者都增加了时间和内存开销。

func (p *pp) doPrint(a []any) {
    prevString := false
    for argNum, arg := range a {
        // 反射
        isString := arg != nil && reflect.TypeOf(arg).Kind() == reflect.String
        // Add a space between two non-string arguments.
        if argNum > 0 && !isString && !prevString {
            p.buf.writeByte(' ')
        }
        // 格式化逻辑
        p.printArg(arg, 'v')
        prevString = isString
    }
}

有没有开销更小的整型转字符串的方式呢?

这时候一个叫strconv 库的东西就派上用场了。strconv库里面的函数是为特定的转换任务设计的,所以它们比更通用的 fmt 函数执行得更快。

让我们用strconv库来重新实现一下我们的函数:

// GenerateIdsStrconv 使用strconv实现整型转字符串
func GenerateIdsStrconv(users []*User) (string, string, []byte) {
    names := ""
    idStr := ""
    var nameByte []byte
    length := 0
    for index := range users {
        idStr = strconv.Itoa(users[index].Id)
        nameByte = []byte(users[index].Name)
        length += len(users[index].Name) + 1
    }
    var builder strings.Builder
    builder.Grow(length) // 预分配
    for index := range users {
        builder.WriteString(",")
        builder.WriteString(users[index].Name)
    }
    return idStr, names, nameByte
}

strconv库的使用,又能给咱们带来多大的性能提升呢?咱们继续用Benchmark来测一测。

下面是Benchmark脚本:

func BenchmarkGenerateIdsStrconv(b *testing.B) {
    for n := 0; n < b.N; n++ {
        GenerateIdsStrconv(users)
    }
}

然后你会发现,使用strconv库将整型转换为字符串的方式,性能提升明显。

killianxu@KILLIANXU-MB0 performance % go test -run=none -benchmem  -bench=. -gcflags=all=-l 
goos: darwin
goarch: amd64
pkg: example.com/performance
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkGenerateIdsBuilder-4               7215            165601 ns/op         30069 B/op       2735 allocs/op
BenchmarkGenerateIdsStrconv-4              15163             79333 ns/op         23392 B/op       1901 allocs/op
  • 从 CPU 资源消耗来看,fmt的方式,单次函数调用要165601ns,而strconv的方式,只要 79333ns,节约了 52% 左右的 CPU 资源。
  • 从内存消耗来看,fmt的方式,单次函数调用要30069字节内存,而strconv的方式,每次函数调用只要23392字节内存,节约了 22% 左右的内存资源。

现在让我们继续看一下火焰图,看看将整型转化为字符串的方式改为strconv库后,我们的代码是否还有优化空间。

go test  -run=none -bench=GenerateIdsStrconv -benchtime=10s -gcflags=all=-l -cpuprofile cpu.prof
go tool pprof -http=":8081" cpu.prof

从火焰图中我们可以看到,用strconv库优化后,除了已经优化过的字符串拼接和整型转字符串操作,还有个runtime.stringtoslicebyte函数消耗CPU资源也比较多。

高性能字符串转字节切片

这个函数其实就是我们的字符串转字节切片操作。runtime.stringtoslicebyte函数是怎么实现字符串转换为字节切片的呢?这里面是不是也有优化空间呢?

nameByte = []byte(users[index].Name)

在搞明白runtime.stringtoslicebyte函数的实现逻辑之前,咱们先看看字符串和切片的数据结构。

字符串在Golang底层对应的是 stringStruct 结构,这个结构里有两个成员变量,str 指针和len,str指针指向字符串的内容,len 存储字符串的长度。

切片对应的是 slice 结构,这个结构里有三个成员变量,array、len 和 cap。array 是指向数组的指针,这个数组里面存的就是切片内容,len 表示切片的长度,cap 表示切片的容量。

// 字符串数据结构
type stringStruct struct {
    str unsafe.Pointer //指针类型,指向字节数组
    len int
}

// 切片数据结构
type slice struct {
    array unsafe.Pointer // 数组指针类型,指向数据数组
    len   int
    cap   int
}

回到前面的问题,runtime.stringtoslicebyte函数是怎么实现字符串转换为字节切片的呢?这里面有三个步骤。

  • 第一步,根据字符串的长度,为字节数组申请内存。
  • 第二步,构建字节切片对象,设置slice结构的成员变量。
  • 第三步,把字符串的内容拷贝到字节切片的底层数组里。

这个函数的具体实现逻辑如下:

func stringtoslicebyte(s string) []byte {
    var b []byte
    // 分配内存,构建字节切片对象
    b := rawbyteslice(len(s))
    // 字符串拷贝到字节切片的array数组
    copy(b, s)
    return b
}
// rawbyteslice allocates a new byte slice. The byte slice is not zeroed.
func rawbyteslice(size int) (b []byte) {
    cap := roundupsize(uintptr(size))
    // 分配内存
    p := mallocgc(cap, nil, false)
    *(*slice)(unsafe.Pointer(&b)) = slice{p, size, int(cap)}
    return
}

可以看出,当将字符串转换为字节切片时,会发生底层字节数组空间的内存申请和拷贝。而且随着字符串长度变长,内存拷贝的性能损耗也会变大

有没有一种方法,可以不用申请字节数组内存和做内存拷贝,实现高性能转换呢?

在说这个方法之前,咱们先了解一些前置知识,unsafe 包和 Go 语言里几个类型的大小。

unsafe 包可以做一些绕过 Go 类型安全检查的操作,更灵活地操作内存。它有两个很重要的功能。

第一个是定义了 Pointer 类型,任何类型的指针都能和这个 Pointer 互相转换,有点像 C 语言里的万能指针void*。

var a int = 1
p := unsafe.Pointer(&a) // 其它类型指针转Pointer
b := (*int)(p) // Pointer类型转其它类型指针
fmt.Println(*b) // 输出1

第二个功能是定义了 uintptr 类型,Pointer 和 uintptr 可以互相转换,这样就能做指针的加减等算术运算了。

type Person struct {
    age int
    name string
}
person := Person{age:18,name:"k哥"}
p := unsafe.Pointer(&person) // 其它类型指针转Pointer
u := uintptr(p) // Pointer类型转为uintptr
u=u+8 // uintptr加减操作
pName := unsafe.Pointer(u) // uintptr转换为Pointer
name := *(*string)(pName)
fmt.Println(name) // 输出k哥

在 Go 语言里,int、uintptr、unsafe.Pointer 这三个类型所占的大小是相等的,32 位机器上是 4 字节,64 位机器上是 8 字节。

咱们可以写个小测试来看看:

func TestSize(t *testing.T) {
    var i int
    var u uintptr
    var p unsafe.Pointer
    fmt.Printf("int size: %d byte\n", unsafe.Sizeof(i))
    fmt.Printf("uintptr size: %d byte\n", unsafe.Sizeof(u))
    fmt.Printf("unsafe.Pointer size: %d byte\n", unsafe.Sizeof(p))
}

运行这个测试函数,可以看到int、uintptr和unsafe.Pointer都是占了8字节。

=== RUN   TestSize
int size: 8 byte
uintptr size: 8 byte
unsafe.Pointer size: 8 byte

有了这些功能,我们就可以用 unsafe 包和这几个类型大小相等的特性,重新解释底层的数据结构,避免字节数组的内存分配和拷贝,实现高性能类型转换。

让我们用unsafe包实现一个字符串转换为字节切片的函数。

  • 第一步,可以把字符串对象想象成一个长度为 2 的 uintptr 类型数组 x,这个数组的 0 号位置其实就是字符串的 str 成员变量,1 号位置就是字符串的 len 成员变量。
  • 第二步,构造一个长度为 3 的 uintptr 类型数组 b,0 号位置代表字节数组指针,1 号位置代表字节切片长度,2 号位置代表字节切片容量。
  • 第三步,把这个 uintptr 类型数组重新解释成字节切片。
func Str2Bytes(s string) []byte {
    x := (*[2]uintptr)(unsafe.Pointer(&s))
    b := [3]uintptr{x[0], x[1], x[1]}
    res := *(*[]byte)(unsafe.Pointer(&b))
    return res
}

让我们用unsafe包来重新实现一下我们的函数:

func GenerateIdsUnsafe(users []*User) (string, string, []byte) {
    names := ""
    idStr := ""
    var nameByte []byte
    length := 0
    for index := range users {
        idStr = strconv.Itoa(users[index].Id)
        // unsafe包实现字符串转字节切片
        nameByte = Str2Bytes(users[index].Name)
        length += len(users[index].Name) + 1
    }
    var builder strings.Builder
    builder.Grow(length) // 预分配
    for index := range users {
        builder.WriteString(",")
        builder.WriteString(users[index].Name)
    }
    return idStr, names, nameByte
}

使用unsafe包将字符串转换为字节切片,又能给咱们带来多大的性能提升呢?咱们用 Benchmark 来测一测。下面是Benchmark脚本:

func BenchmarkGenerateIdsUnsafe(b *testing.B) {
    for n := 0; n < b.N; n++ {
        GenerateIdsUnsafe(users)
    }
}

果然,使用 unsafe包将字符串转换为字节切片的方式,性能提升非常明显。

killianxu@KILLIANXU-MB0 performance % go test -run=none -benchmem  -bench=. -gcflags=all=-l
goos: darwin
goarch: amd64
pkg: example.com/performance
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkGenerateIdsStrconv-4              14966             78975 ns/op    23392 B/op        1901 allocs/op
BenchmarkGenerateIdsUnsafe-4               26529             44976 ns/op    11072 B/op         901 allocs/op
  • 从 CPU 资源消耗来看,runtime.stringtoslicebyte的方式,单次函数调用要78975ns,而unsafe包的方式,只要44976ns,节约了 43% 左右的 CPU 资源。
  • 从内存消耗来看,runtime.stringtoslicebyte的方式,单次函数调用要23392字节内存,而unsafe包的方式,每次函数调用只要11072字节内存,节约了 52.7% 左右的内存资源。

实际上,unsafe包除了可以高效地将字符串转换为字节切片,也可以高效地将字节切片转换为字符串。

func Bytes2Str(b []byte) string {
    return *(*string)(unsafe.Pointer(&b))
}

其实用unsafe包将字节切片转换为字符串的操作,在Golang库里也很常见,比如我们在前面介绍的高性能字符串拼接,Builder类型最后转化为字符串的源码,就是用unsafe包实现的。

// String returns the accumulated string.
func (b *Builder) String() string {
    return *(*string)(unsafe.Pointer(&b.buf))
}

让我们用火焰图再看看我们的函数是否还有优化空间。

go test  -run=none -bench=BenchmarkGenerateIdsUnsafe -benchtime=10s -gcflags=all=-l -cpuprofile cpu.prof
go tool pprof -http=":8081" cpu.prof

从火焰图上可以看出,经过前面几次的优化,我们的函数最耗时的操作就只剩下已经优化过之后的字符串转字节切片、整型转字符串和字符串拼接这3个操作,因此没有进一步优化的空间了。

让我们来和最初实现的函数逻辑对比下,看下经过前面几次的优化,我们的性能总体提升了多少。

killianxu@KILLIANXU-MB0 performance % go test -run=none -benchmem  -bench=. -gcflags=all=-l 
goos: darwin
goarch: amd64
pkg: example.com/performance
cpu: Intel(R) Core(TM) i5-7360U CPU @ 2.30GHz
BenchmarkGenerateIdsRaw-4            951           1226580 ns/op         4194656 B/op       3739 allocs/op
BenchmarkGenerateIdsUnsafe-4       26372             45221 ns/op           11072 B/op        901 allocs/op

从Benchmark测试,可以看出来,我们对CPU资源的消耗降低了27倍,对内存的消耗降低了379倍。

小结

今天这节课,我以一段待优化的函数代码为例,在逐步优化其对CPU资源的消耗过程中,向你展示了能降低CPU和内存资源消耗的3个高性能数据处理技巧。

  • 高性能字符串拼接技巧。当我们代码有大量字符串拼接操作时,可以使用 strings.Builder 类型,并利用它的内存预分配功能做字符串拼接。
  • 高性能整型转字符串技巧。当我们代码有大量整型转字符串操作时,可以用 strconv 库做转换,避免使用fmt.Sprint函数的反射和格式化资源消耗。
  • 高性能字符串转字节切片技巧。当我们代码有大量字符串转字节切片操作时,可以用 unsafe 包,通过字符串和字节切片底层数组空间共用,实现高性能转换。并且,也可以用unsafe包将字节切片转换为字符串。

希望你好好体会这个用火焰图寻找瓶颈,再结合Go语言底层实现分析寻找更优方案的过程。在遇到内存和CPU瓶颈时,别忘了尝试运用这些高性能操作技巧,帮你节约更多CPU和内存资源,提升单机吞吐。

思考题

除了这节课里提到的3种高性能数据处理技巧,你还知道哪些高性能数据处理技巧呢?

欢迎你把你的答案分享在评论区,也欢迎你把这节课的内容分享给需要的朋友,我们下节课再见!

精选留言(3)
  • lJ 👍(1) 💬(4)

    1. 使用标准库的 encoding/json 编解码性能较低,推荐使用github.com/json-iterator/go。 2. 使用 io.Reader 和 io.Writer 进行流式传输,io.Copy 方法是一个高效实现的拷贝工具,可以在两个流之间传输数据。比如,如果 src 实现了 WriterTo 接口(如 TCPConn),会直接调用 src.WriteTo(dst)。这避免了 io.Copy 手动分配缓冲区并循环读取/写入的过程,将高效传输的责任交给底层类型的实现。优先尝试调用内核的 splice 系统调用,避免数据从内核空间拷贝到用户空间再拷贝回内核空间的开销。如果 spliceTo 无法处理,则回退到通用的拷贝逻辑 genericWriteTo。

    2024-12-18

  • Jayleonc 👍(0) 💬(1)

    但是不过用unsafe就违反了字符串的只读特性,使用不当有可能导致程序异常

    2025-01-13

  • jxs1211 👍(0) 💬(1)

    这种写法可以吗,benchmark测下来感觉差不多 func Str2Bytes(s string) []byte { return *(*[]byte)(unsafe.Pointer(&s)) }

    2024-12-19