【Go】sync package官方示例代码学习

官方文档链接

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 类型的变量 oncesync.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): 这是核心操作。

    1. 快速路径(Fast Path) : 检查内部的标志位是否已设置为 1。如果已经设置为 1(表示已执行过),则直接返回,这是后续 Goroutine 调用时的零开销(几乎没有性能损失)。
    2. 慢速路径(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))的函数,并确保这个函数在整个程序生命周期中:

  1. 只会被执行一次。
  2. 执行结果(值和错误)会被缓存。
  3. 后续并发的调用者将直接获取缓存的结果,而不会重新执行函数,也不会重新获取锁。

示例代码

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() 函数。
    • 结果 :
      1. 第一个 调用 once() 的 Goroutine 会进入慢速路径,执行文件读取操作,并打印 "Reading file once"
      2. 初始化函数执行完毕后,其返回值(dataerr)会被缓存起来。
      3. 其余 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 的高效写入方法(WriteStringWriteByte)构建日志消息。
  • 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.GoWaitGroup 的内部计数器就加 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) 模式最简洁且惯用的方式,用于确保批量并发任务的整体完成。

相关推荐
遥望九龙湖2 小时前
3.析构函数
开发语言·c++
gihigo19982 小时前
MATLAB中进行综合孔径微波辐射成像仿真
开发语言·matlab
testpassportcn2 小时前
CompTIA A+ 220-1201 認證介紹|最新版本 A+ Core 1 220-1201 考試完整指南
网络·学习·改行学it
d111111111d2 小时前
C语言中static修斯局部变量,全局变量和函数时分别由什么特性
c语言·javascript·笔记·stm32·单片机·嵌入式硬件·学习
Three K2 小时前
Redisson限流器特点
java·开发语言
Halo_tjn2 小时前
Java 多线程机制
java·开发语言·windows·计算机
Jeff-Nolan2 小时前
C++运算符重载
java·开发语言·c++
YouEmbedded2 小时前
解码智能指针
开发语言·c++·unique_ptr·shared_ptr·auto_ptr·weak_ptr
非凡ghost2 小时前
Xournal++(PDF文档注释工具) 中文绿色版
学习·pdf·生活·软件需求