1. sync.Once 的作用与原理
sync.Once 是 Go 语言标准库 sync 包提供的一个结构体,其唯一目的就是确保一段给定的代码块(即示例中的 onceBody)在程序的整个生命周期内 ,不论被调用多少次、被多少个 Goroutine 并发调用,都只会被执行且仅执行一次。
示例代码
go
package main
import (
"fmt"
"sync"
)
func main() {
var once sync.Once
onceBody := func() {
fmt.Println("Only once")
}
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
once.Do(onceBody)
done <- true
}()
}
for i := 0; i < 10; i++ {
<-done
}
}
核心结构分析
var once sync.Once: 声明一个sync.Once类型的变量once。sync.Once内部包含一个uint32类型的标志位和一个互斥锁(Mutex)。
go
type Once struct {
_ noCopy
// done indicates whether the action has been performed.
// It is first in the struct because it is used in the hot path.
// The hot path is inlined at every call site.
// Placing done first allows more compact instructions on some architectures (amd64/386),
// and fewer instructions (to calculate offset) on other architectures.
done atomic.Bool
m Mutex
}
标志位(done 字段): 初始值为 0。当 onceBody 被成功执行后,该值会原子地更新为 1。
互斥锁(m 字段): 用于保护 onceBody 的执行过程,确保在检查标志位和执行代码块期间不会发生竞态条件(Race Condition)。
-
onceBody := func() { fmt.Println("Only once") }: 定义了一个匿名函数,在这个例子中,它只是简单地打印一句话。 -
once.Do(onceBody): 这是核心操作。- 快速路径(Fast Path) : 检查内部的标志位是否已设置为 1。如果已经设置为 1(表示已执行过),则直接返回,这是后续 Goroutine 调用时的零开销(几乎没有性能损失)。
- 慢速路径(Slow Path) : 如果标志位是 0,说明是第一次执行。
- 它会获取内部的互斥锁
m。 - 再次检查标志位(防止在等待锁的过程中其他 Goroutine 完成了初始化)。
- 如果标志位仍为 0,则执行
onceBody函数。 - 执行完毕后,释放互斥锁,并将标志位原子地设置为 1。
- 它会获取内部的互斥锁
并发执行流程分析
这段代码模拟了 10 个 Goroutine 尝试并发执行同一段初始化代码的场景。
-
done := make(chan bool): 创建了一个无缓冲的布尔类型通道(Channel),用于 Goroutine 间的同步和通信。 -
for i := 0; i < 10; i++ { go func() { ... }(): 启动 10 个 Goroutine。 -
在每个 Goroutine 中:
once.Do(onceBody): 所有 10 个 Goroutine 都会并发地调用Do方法。- 结果 : 只有第一个 成功通过慢速路径的 Goroutine 会执行
fmt.Println("Only once"),因此程序最终只会打印一次"Only once"。其余 9 个 Goroutine 要么在等待锁,要么直接走快速路径返回。 done <- true: 每个 Goroutine 在调用完once.Do后,都会向done通道发送一个信号。
-
for i := 0; i < 10; i++ { <-done }: 主 Goroutine 通过 10 次接收操作,等待所有 10 个 Goroutine 都完成并发送信号到done通道。- 目的: 这确保了主程序在所有后台 Goroutine 执行完毕后才退出,否则主 Goroutine 可能会提前退出,导致后台 Goroutine 还没来得及运行就被终止。
2. sync.OnceValues 的作用与原理
sync.OnceValues 是 Go 1.21 版本引入的新特性,用于包装一个返回 两个值 (通常是 (ResultType, error))的函数,并确保这个函数在整个程序生命周期中:
- 只会被执行一次。
- 执行结果(值和错误)会被缓存。
- 后续并发的调用者将直接获取缓存的结果,而不会重新执行函数,也不会重新获取锁。
示例代码
go
package main
import (
"fmt"
"os"
"sync"
)
func main() {
once := sync.OnceValues(func() ([]byte, error) {
fmt.Println("Reading file once")
return os.ReadFile("example_test.go")
})
done := make(chan bool)
for i := 0; i < 10; i++ {
go func() {
data, err := once()
if err != nil {
fmt.Println("error:", err)
}
_ = data // Ignore the data for this example
done <- true
}()
}
for i := 0; i < 10; i++ {
<-done
}
}
核心结构分析
-
once := sync.OnceValues(func() ([]byte, error) { ... }):sync.OnceValues接收一个返回两个值的函数(func() (T1, T2))。- 它返回一个新的、无参数且返回缓存结果 的函数(在这个例子中是
func() ([]byte, error))。 - 它内部封装了与
sync.Once类似的机制来保证函数的原子性执行和结果的缓存。
-
func() ([]byte, error) { fmt.Println("Reading file once"); return os.ReadFile("example_test.go") }:- 这是我们要确保只执行一次的初始化函数。
- 它尝试读取名为
example_test.go的文件内容。 - 无论并发调用多少次,
fmt.Println("Reading file once")只会被打印一次,os.ReadFile也只会被执行一次。
与 sync.Once 的对比
| 特性 | sync.Once (Do(f func())) |
sync.OnceValues (OnceValues(f func() (T1, T2))) |
|---|---|---|
| 用途 | 确保一个操作只执行一次。 | 确保一个初始化 操作只执行一次,并缓存其返回值。 |
| 返回值 | 无返回值。 | 返回两个值(通常是结果和错误)。 |
| 获取结果 | 结果必须通过共享变量/闭包捕获。 | 后续调用返回的函数 once() 直接获取缓存的结果。 |
| 错误处理 | 初始化函数中的错误必须手动在闭包内处理。 | 错误值被缓存。后续调用者可以直接获取并处理这个错误。 |
并发执行流程分析
这段代码模拟了 10 个 Goroutine 并发地尝试获取文件内容
-
once现在是一个函数 (func() ([]byte, error)),而不是一个结构体变量。 -
当
main函数启动时,sync.OnceValues不会立即 执行初始化函数,而是返回一个延迟执行的函数once。 -
for i := 0; i < 10; i++ { go func() { ... }(): 启动 10 个 Goroutine。 -
在每个 Goroutine 中:
data, err := once(): 所有 10 个 Goroutine 都会并发调用once()函数。- 结果 :
- 第一个 调用
once()的 Goroutine 会进入慢速路径,执行文件读取操作,并打印"Reading file once"。 - 初始化函数执行完毕后,其返回值(
data和err)会被缓存起来。 - 其余 9 个 Goroutine 调用
once()时会进入快速路径,直接返回那个缓存的结果,而不会再次执行文件读取。
- 第一个 调用
- 错误处理 : 如果
os.ReadFile失败,错误会被缓存。所有 Goroutine 都会接收到相同的错误值并打印它。如果成功,所有 Goroutine 都会接收到相同的文件数据。
-
done := make(chan bool)和for i := 0; i < 10; i++ { <-done }: 同样的,这些通道操作确保主 Goroutine 等待所有 10 个子 Goroutine 完成对once()的调用和结果处理后才退出。
sync.OnceValues 是对 sync.Once 的功能增强,它使得线程安全的懒加载和错误缓存变得非常简洁。它在初始化需要返回配置、连接或资源的场景下,避免了手动在闭包外定义共享变量来保存结果的复杂性。
3.sync.Pool 的作用与原理
sync.Pool(临时对象池)是用于存储和重用一组临时对象(Transient Objects)的机制。核心目的是提高性能,降低 GC 压力。在高并发场景下,如果需要频繁地创建和销毁大量临时对象(例如网络请求的缓冲区),这些对象的创建会产生大量的内存分配,给 Go 的垃圾回收器带来沉重负担。sync.Pool 通过缓存 这些对象,使得代码可以重用现有的对象而不是创建新的,从而显著减少分配次数和 GC 暂停时间。sync.Pool 可以类比于对象池(Object Pool)模式 ,但它在 Go 中是专门为减少内存分配 (Allocation) 和垃圾回收 (GC) 压力而设计的。
示例代码
go
package main
import (
"bytes"
"io"
"os"
"sync"
"time"
)
var bufPool = sync.Pool{
New: func() any {
// The Pool's New function should generally only return pointer
// types, since a pointer can be put into the return interface
// value without an allocation:
return new(bytes.Buffer)
},
}
// timeNow is a fake version of time.Now for tests.
func timeNow() time.Time {
return time.Unix(1136214245, 0)
}
func Log(w io.Writer, key, val string) {
b := bufPool.Get().(*bytes.Buffer)
b.Reset()
// Replace this with time.Now() in a real logger.
b.WriteString(timeNow().UTC().Format(time.RFC3339))
b.WriteByte(' ')
b.WriteString(key)
b.WriteByte('=')
b.WriteString(val)
w.Write(b.Bytes())
bufPool.Put(b)
}
func main() {
Log(os.Stdout, "path", "/search?q=flowers")
}
核心结构分析
var bufPool = sync.Pool{...}: 声明了一个全局的sync.Pool变量。New: func() any { return new(bytes.Buffer) }: 这是Pool的初始化函数 。- 当调用
bufPool.Get()时,如果池中没有可用的对象,New函数会被调用来创建一个新的对象并返回。 - Go 官方建议:
New函数应该返回指针类型 (这里是*bytes.Buffer),这样可以将指针直接放入any接口返回值中,避免不必要的内存分配。
- 当调用
Log 函数中的对象重用流程
Log 函数展示了如何利用 sync.Pool 实现高性能的日志格式化操作。
go
b := bufPool.Get().(*bytes.Buffer)
bufPool.Get(): 尝试从对象池中取出一个可用的对象。- 如果池中有对象,则直接返回该对象(类型是
any)。 - 如果池中没有对象,则调用
New函数创建一个新的*bytes.Buffer对象并返回。
- 如果池中有对象,则直接返回该对象(类型是
.(*bytes.Buffer): 进行类型断言,将其转换为实际的*bytes.Buffer类型以便使用。
go
b.Reset()
// ... 格式化日志内容 ...
b.WriteString(timeNow().UTC().Format(time.RFC3339))
b.WriteByte(' ')
b.WriteString(key)
b.WriteByte('=')
b.WriteString(val)
w.Write(b.Bytes())
b.Reset(): 这是使用sync.Pool的规范 。由于获取到的是一个被重用 的对象,它可能还保留着上次使用的数据。调用Reset()清空bytes.Buffer的内容,使其可以重新用于新的日志消息。- 代码随后利用
bytes.Buffer的高效写入方法(WriteString和WriteByte)构建日志消息。 w.Write(b.Bytes()): 将格式化好的字节内容写入到目标io.Writer(这里是os.Stdout)。
go
bufPool.Put(b)
bufPool.Put(b): 将使用完毕的bytes.Buffer对象放回对象池中。该对象现在对其他并发的 Goroutine 是可用的。
sync.Pool 的设计哲学是 缓存那些可以被安全丢弃、且创建开销相对较大 的临时对象。在这个例子中,bytes.Buffer 就是一个典型的临时对象,因为它会被频繁用于构建日志字符串,使用 sync.Pool 极大地优化了日志在高并发写入时的性能。
4. sync.WaitGroup 的作用与原理
sync.WaitGroup 的作用非常类似于 Java 中的 java.util.concurrent.CountDownLatch。它的核心功能是:让主 Goroutine 等待一组子 Goroutine 完成任务。
示例代码
这段代码模拟了一个并发抓取 URL 的场景,确保所有抓取任务完成后程序才退出。
go
package main
import (
"sync"
)
type httpPkg struct{}
func (httpPkg) Get(url string) {}
var http httpPkg
func main() {
var wg sync.WaitGroup
var urls = []string{
"http://www.golang.org/",
"http://www.google.com/",
"http://www.example.com/",
}
for _, url := range urls {
// Launch a goroutine to fetch the URL.
wg.Go(func() {
// Fetch the URL.
http.Get(url)
})
}
// Wait for all HTTP fetches to complete.
wg.Wait()
}
核心结构分析
这部分代码的目的是构建一个模拟的 HTTP 客户端,以便在并发任务中调用。
go
type httpPkg struct{}
func (httpPkg) Get(url string) {}
var http httpPkg
type httpPkg struct{}:定义了一个名为httpPkg的空结构体。func (httpPkg) Get(url string) {}:为httpPkg类型定义了一个Get方法。这模拟了 HTTP 请求操作,接收一个 URL 字符串并执行操作(这里是空实现)。var http httpPkg:创建了一个httpPkg类型的全局变量http。
go
func main() {
var wg sync.WaitGroup
var urls = []string{...}
for _, url := range urls {
// ...
}
// Wait for all HTTP fetches to complete.
wg.Wait()
}
var wg sync.WaitGroup:声明一个sync.WaitGroup变量wg。这是 Go 语言用于同步 Goroutine 的主要原语。wg.Wait():调用Wait方法。这是阻塞点。主 Goroutine 会在此处暂停,直到WaitGroup内部的计数器归零。这保证了主函数不会在子 Goroutine 尚未完成 HTTP 请求时就提前退出。
go
for _, url := range urls {
// Launch a goroutine to fetch the URL.
wg.Go(func() {
// Fetch the URL.
http.Get(url)
})
}
-
for _, url := range urls:循环遍历所有 URL。 -
wg.Go(func() { ... }):这是 Go 1.20 之后sync.WaitGroup引入的更简洁的语法。它等效于:go// 传统写法 for _, url := range urls { // Increment the WaitGroup counter. wg.Add(1)// 计数器加 1 // Launch a goroutine to fetch the URL. go func(url string) { // Decrement the counter when the goroutine completes. defer wg.Done()// 函数退出时计数器减 1 // Fetch the URL. http.Get(url) }(url) }wg.Go()简化了操作:它在内部完成了wg.Add(1)和 使用defer确保调用wg.Done()的步骤。每调用一次wg.Go,WaitGroup的内部计数器就加 1,并启动一个 Goroutine。
陷阱?
这个示例中隐藏了一个 Go 并发编程中的常见陷阱,尽管在这个特定的简化示例中它可能不会导致错误,但在真实场景中必须注意:
- 循环变量
url是在循环的所有迭代中共享的。 - 当 Goroutine 启动时,它捕获了变量
url。由于 Goroutine 的执行是异步的,当它真正执行http.Get(url)时,url可能已经被循环的下一次迭代更新,或者循环已经结束,url变量指向了最后一个 URL。
解决方案(推荐的做法):
为 Goroutine 显式传入参数,创建 url 的局部副本:
go
for _, url := range urls {
// 传入 url 作为参数,使其在 Goroutine 内部拥有自己的局部副本
wg.Go(func(url string) {
http.Get(url)
}(url)) // (url) 立即执行该匿名函数,将当前 url 值传入
}
sync.WaitGroup 是 Go 语言中实现 "分叉-合并" (Fork-Join) 模式最简洁且惯用的方式,用于确保批量并发任务的整体完成。