基于strings.Builder盘下Go里面的缓冲IO优化

AI 摘要: 本文介绍在使用Go开发SSE下行Push服务中,通过缓冲方式优化从AI输出的流信息,其中采用strings.Builder来优化LLM AI接口到AI服务端到客户端的流式输出链路。文章结合strings.Buffer的工作原理和核心机制,解释了为什么strings.Builder在字符串拼接方面如此高效,对新手Gopher有一定帮助。

1. 背景

最近在工作中使用 Go 开发 SSE 下行 Push 服务时候,会通过strings.Builder缓冲拼接 AI 输出的流信息,并定期推送给到 SSE 下行通道,以优化LLM AI接口->AI服务端->客户端流式输出链路。

加上之前 CR 也发现有一些类似的 Case,这里简要做个文章记录分享出来,希望对新手 Gopher 有一定帮助。

文章会结合 strings.Buffer的工作原理和核心机制,介绍下为什么strings.Builder在字符串拼接方面会这么高效,结合 UT 的 Benchmark 数据给出了对比。

2. 字符串拼接方式

1
2
welcome := fmt.Sprintf("hi, %s", uname)
welcome += "nice meet you!"

2.1. 使用 fmt.Sprintf() 或者 + 拼接字符串有什么问题?

  1. 使用  +  或  fmt.Sprintf  等方式频繁拼接字符串时可能产生的性能问题,每次  s += "a"  操作都会创建一个新的字符串,导致大量的内存分配、数据复制和垃圾回收开销,性能会非常差。
  2. 在 Go 中,字符串是不可变的(immutable),味着当你使用  +  运算符连接两个字符串时,Go 运行时会
    1. 分配一块新的内存空间,其大小足以容纳两个原始字符串的内容。
    2. 第一个字符串的内容复制到这块新内存中。
    3. 第二个字符串的内容复制到这块新内存中,紧随第一个字符串之后。
    4. 返回一个指向这块新内存的字符串值。

2.2. 使用 strings.WriteString()方式写入

1
2
3
4
5
6
7
8
var strBuf strings.Builder
for str := range input {
   // 缓冲写入
   strBuf.WriteString(str)

   // ...
}
ret := strBuf.String()

3. 解析下 strings.Builder{}

3.1. 工作原理

使用一个可增长的内部字节切片([]byte)作为缓冲区,当你向  Builder  写入数据(字符串、字节、rune 等)时,这些数据会被追加到这个内部缓冲区中,而不是立即创建新的字符串

2. 主要方法

  • WriteString(s string): 将字符串  s  追加到缓冲区。
  • WriteByte(c byte): 将字节  c  追加到缓冲区。
  • WriteRune(r rune): 将 rune r  追加到缓冲区(rune 会被编码为 UTF-8 字节序列)。
  • Write(p []byte): 将字节切片  p  追加到缓冲区。
  • String() string: 返回缓冲区当前内容的字符串表示。这是在构建完成后才执行的操作。
  • Len() int: 返回缓冲区当前已写入的字节数。
  • Cap() int: 返回缓冲区当前的容量(底层切片的大小)。
  • Reset(): 清空缓冲区,重置 Builder 状态。

3.2. 缓冲区写满后的处理(核心机制)

当缓冲区写满时,strings.Builder  会进行一次重新分配和数据复制

向  Builder  写入数据时,如果内部缓冲区的当前容量不足以容纳新写入的数据,Builder  会执行以下步骤来扩展缓冲区:

  1. 检查容量: Builder  会检查当前缓冲区(底层  []byte  切片)的剩余容量是否足够。
  2. 计算新容量:  如果容量不足,Builder  会计算一个新的、更大的容量。Go 切片的扩容策略通常是指数级增长,以摊薄多次扩容的成本。常见的策略是:
    • 如果当前容量小于 1024 字节,新容量通常会翻倍。
    • 如果当前容量大于等于 1024 字节,新容量通常会增长 25% 或更多(例如,newCap = oldCap + oldCap/4),但至少要能容纳当前已写入数据加上新数据所需的总大小。
    • 最终的新容量会取计算出的增长容量与当前已写入数据加上新数据所需总大小中的较大值,并向上取整到某个合适的边界(例如,下一个 2 的幂或系统页大小的倍数),以优化内存分配。
  3. 分配新内存:  根据计算出的新容量,Go 运行时会分配一块新的、更大的底层数组内存空间。
  4. 数据复制:  将旧缓冲区中的所有数据(已写入的内容)复制到新的内存空间中。
  5. 更新切片: Builder  内部的切片头部会被更新,指向新的底层数组,并反映新的容量。
  6. 写入新数据:  最后,将当前要写入的新数据复制到新的缓冲区中,紧随已复制的旧数据之后。

3.3. 为什么strings.Builder 这么高效?

在向  Builder  写入大量数据时,大部分写入操作都只是简单地将数据复制到现有缓冲区中(只要容量足够),这是非常快的。

只有少数几次写入会导致扩容。通过这种方式,多次写入操作的平均(摊销)成本接近常数时间 O(1),远优于每次拼接都 O(N)(N 为当前字符串长度)的成本。

尽管扩容时也涉及内存分配和数据复制,但由于采用了**指数级增长**的策略,扩容操作发生的频率远低于每次拼接都创建新字符串的方式(后面的 UT Benchmark 数据库对比可见

3.4. 与  bytes.Buffer  的关系

strings.Builder  在很多方面与  bytes.Buffer  类似,并且底层实现可能共享一部分逻辑(strings.Builder  实际上是基于  bytes.Buffer  的一个轻量级封装, 两者主要区别在于:

  • strings.Builder  专注于构建字符串,其  String()  方法直接返回  stringstrings.Builder  的方法(如  WriteString)针对字符串操作进行了微调优化。
  • bytes.Buffer  专注于处理字节序列,其  Bytes()  方法返回  []byteString()  方法是将  []byte  转换为  string

3.5. Benchmark 测试字符串拼接效率对比

Benchmark 数据解读

Benchmark 数据解读: // BenchmarkStringBuilder-14 21478 56610 ns/op 514802 B/op 23 allocs/op

  • 14: 这个数字表示运行这个基准测试时,GOMAXPROCS 的值
  • 21478: 迭代次数 (iterations),每次增加迭代次数,直到获得一个稳定且足够长的测量时间。
  • 56610 ns/op: 最重要的性能指标之一,平均每次操作耗时 (nanoseconds per operation),56.61 µs (微秒)
  • 514802 B/op: 平均每次操作分配的内存字节数 (bytes per operation),这个值越小越好,大量的内存分配会增加垃圾回收器 (GC) 的工作负担,从而可能影响整体性能
  • 23 allocs/op: 平均每次操作发生的内存分配次数 (allocations per operation),这个值越小越好,通常少量大块的分配(低 allocs/op,高 B/op)比大量小块的分配(高 allocs/op,可能低或高的 B/op)对性能的影响更小,因为它减少了分配的元数据开销和 GC 扫描的次数。

总结下来就是:在 14 个逻辑 CPU 核心的环境下,BenchmarkStringBuilder 函数平均每次执行需要 56.61 微秒,在这次执行过程中,它平均分配了 514802 字节的内存,并且这个内存分配过程涉及了 23 次独立的内存分配操作。

具体 UT 代码

注释附带了 Benchmark 数据,可以看到 StringBuilder 的拼接每次 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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// BenchmarkStringConcat 基准测试:衡量 + 拼接的性能
// BenchmarkStringConcat-14    	      28	  44089879 ns/op	632848145 B/op	   10031 allocs/op
func BenchmarkStringConcat(b *testing.B) {
	// b.N 是基准测试框架自动调整的循环次数
	// 我们在 b.N 循环内部构建一个完整的字符串
	b.ResetTimer()   // 重置计时器
	b.ReportAllocs() // 报告内存分配次数

	for i := 0; i < b.N; i++ {
		s := "" // 在每次迭代中初始化空字符串
		for j := 0; j < numAppends; j++ {
			s += appendStr // 字符串拼接
		}
		_ = s // 最终字符串
	}
}

// BenchmarkStringBuilder 基准测试:衡量 strings.Builder 的性能
// BenchmarkStringBuilder-14    	   21478	     56610 ns/op	  514802 B/op	      23 allocs/op
func BenchmarkStringBuilder(b *testing.B) {
	// b.N 是基准测试框架自动调整的循环次数
	// 我们在 b.N 循环内部构建一个完整的字符串
	b.ResetTimer()   // 重置计时器,不计算 setup 时间
	b.ReportAllocs() // 报告内存分配次数

	for i := 0; i < b.N; i++ {
		var builder strings.Builder // 在每次迭代中创建新的 Builder
		for j := 0; j < numAppends; j++ {
			builder.WriteString(appendStr)
		}
		_ = builder.String() // 获取最终字符串,这部分成本也包含在内
	}
}

// BenchmarkStringBuilderWithGrow 基准测试:衡量预分配容量的 Builder 性能
// 这个测试展示了如果提前知道大概的最终字符串长度,可以进一步优化 Builder
// BenchmarkStringBuilderWithGrow-14    	   52521	     22170 ns/op	  122880 B/op	       1 allocs/op
func BenchmarkStringBuilderWithGrow(b *testing.B) {
	expectedLen := numAppends * len(appendStr) // 估算最终长度
	b.ResetTimer()
	b.ReportAllocs()

	for i := 0; i < b.N; i++ {
		var builder strings.Builder
		builder.Grow(expectedLen) // 预分配容量
		for j := 0; j < numAppends; j++ {
			builder.WriteString(appendStr)
		}
		_ = builder.String()
	}
}

3.6. 小结

strings.Builder{}  通过使用一个可增长的内部字节切片作为缓冲区,并采用指数级扩容策略,有效地减少了构建字符串过程中内存的重新分配和数据复制次数;

当缓冲区写满时,它会分配一个更大的新缓冲区,将旧数据复制过去,然后继续追加新数据。这种机制使得向  Builder  追加数据的摊销成本非常低,从而实现了高效的字符串构建。

在需要拼接大量字符串的场景下,例如 SSE 流式字符串输出常见下,强烈推荐使用  strings.Builder替代fmt.Springf+对字符串进行拼接。

更进一步看,无论是在 Linux 系统、高并发的服务器设计下,多层缓冲 IO,以空间换时间的思路、复用已有资源,减少 I/O 阻塞,提高吞吐量的 case 比比皆是~