0%

从 C++ 到 Go(三):并发模型与错误处理

本文是「从 C++ 到 Go」系列的第三篇,承接上一篇对集合、结构体与接口的讨论,进入 Go 最有特色的两个领域——并发模型与错误处理。对于 C++ 程序员来说,这两部分恰好是思维转换最大的地方:并发从「手动管理线程和锁」变成「goroutine + channel」,错误处理从「异常冒泡」变成「错误即返回值」。

并发

Goroutine:不是线程

在 C++ 中启动一个线程需要 std::thread,每个线程占用 MB 级别的栈空间,创建和切换的成本都不低。Go 的 goroutine 完全不同——初始栈只有几 KB,可以动态增长,由 Go 运行时(而非操作系统)调度。创建数十万个 goroutine 是稀松平常的事。

1
2
3
4
5
6
7
8
9
10
func sayHello(name string) {
for i := 0; i < 3; i++ {
fmt.Printf("[%s] %d\n", name, i)
time.Sleep(100 * time.Millisecond)
}
}

go sayHello("goroutine-A") // go 关键字启动
go sayHello("goroutine-B")
sayHello("main") // main 自身也在一个 goroutine 中

go 关键字后跟一个函数调用,就启动了一个新的 goroutine。语法上比 C++ 的 std::thread t(func, args...) 简洁得多。

一个关键的区别:std::thread 必须 joindetach,否则析构时程序会终止。Go 没有这个约束——但如果 main 函数返回,所有 goroutine 会被直接终止,不会等待它们完成。所以需要同步机制来协调。

Channel:goroutine 间的通信

Go 社区有一句经典格言:不要通过共享内存来通信,而要通过通信来共享内存。Channel 就是这句话的具体实现。

用 C++ 的视角来理解,channel 本质上是一个封装好的 std::queue + std::mutex + std::condition_variable——一个线程安全的阻塞队列。Go 把这三样东西打包成了语言原语,并配上了简洁的语法。

1
2
ch := make(chan int)       // 无缓冲 channel
ch := make(chan int, 10) // 缓冲区大小为 10 的 channel

方向限定

Channel 可以限定为只读或只写:

1
2
3
4
5
6
7
8
9
10
11
12
func producer(ch chan<- int) {   // chan<- 只写
for i := 0; i < 5; i++ {
ch <- i
}
close(ch)
}

func consumer(ch <-chan int) { // <-chan 只读
for val := range ch {
fmt.Println("收到:", val)
}
}

chan<- 表示箭头「指向」channel——只能写入;<-chan 表示箭头「从」channel 出来——只能读取。方向限定是编译期检查的,类似 C++ 中用 const 修饰参数来防止误写。

无缓冲 vs 有缓冲

无缓冲 channel(make(chan T))要求发送方和接收方同时就绪,类似面对面交接——发送方阻塞直到有人接收,接收方阻塞直到有人发送。

1
2
3
4
5
6
7
8
ch := make(chan string)   // 无缓冲

go func() {
val := <-ch // 等待发送方
fmt.Println("收到:", val)
}()

ch <- "hello" // 等待接收方

如果在同一个 goroutine 中先发后收,就会死锁——没有人能完成另一端的握手。

有缓冲 channel(make(chan T, n))允许发送方在缓冲区未满时不阻塞,接收方在缓冲区非空时不阻塞。缓冲区大小就是队列的最大长度。

1
2
3
4
buffered := make(chan string, 2)
buffered <- "hello" // 不阻塞
buffered <- "world" // 不阻塞
// buffered <- "block" // 阻塞!缓冲区已满

close 与 range

关闭 channel 是一个重要的信号机制:

1
2
3
4
5
6
7
8
9
10
11
ch := make(chan int, 5)
ch <- 1
ch <- 2
ch <- 3
close(ch)

for val := range ch { // 读完 1, 2, 3 之后才退出
fmt.Println("读到:", val)
}

val, ok := <-ch // val=0, ok=false

range 循环在 channel 上的退出条件是两个同时满足:channel 已关闭,且缓冲区为空。换言之,close 不会丢弃缓冲区中的数据——所有已发送的值都会被消费完毕,之后 range 才退出。

关闭后继续读取会返回零值和 ok=false,而不是 panic。但往已关闭的 channel 发送会 panic,重复关闭也会 panic。

多生产者的关闭问题

如果有多个生产者向同一个 channel 发送数据,任何一个生产者直接 close 都是危险的——其他生产者可能还在发送,导致「向已关闭 channel 发送」的 panic。正确做法是用 sync.WaitGroup 等所有生产者完成后,再由一个单独的 goroutine 关闭 channel:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ch := make(chan int, 10)
var wg sync.WaitGroup

for id := 0; id < 3; id++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for i := 0; i < 3; i++ {
ch <- id*10 + i
}
// 不能在这里 close!
}(id)
}

go func() {
wg.Wait()
close(ch) // 所有生产者完成后,只关闭一次
}()

for val := range ch {
fmt.Println("收到:", val)
}

select:channel 的多路复用

select 语句同时监听多个 channel 操作,哪个先就绪就执行哪个。这是 Go 并发编程中最强大的控制结构,没有直接的 C++ 对应物。

一个典型场景是同时请求多个数据源,谁先返回用谁的结果,超时则放弃:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
func search(engine string, query string) <-chan string {
ch := make(chan string, 1)
go func() {
delay := time.Duration(100+rand.Intn(500)) * time.Millisecond
time.Sleep(delay)
ch <- fmt.Sprintf("[%s] 搜索 %q 耗时 %v", engine, query, delay)
}()
return ch
}

google := search("Google", "Go 语言")
bing := search("Bing", "Go 语言")
timeout := time.After(500 * time.Millisecond)

select {
case r := <-google:
fmt.Println(r)
case r := <-bing:
fmt.Println(r)
case <-timeout:
fmt.Println("超时")
}

time.After 返回一个 channel,在指定时间后发送一个值。把它放在 select 的一个 case 里,就实现了超时控制——如果 500ms 内没有搜索引擎返回结果,timeout 分支就会被选中。

select 也可以用于优雅退出:

1
2
3
4
5
6
7
8
9
10
11
12
13
func fibonacci(n int, ch chan<- int, quit <-chan struct{}) {
a, b := 0, 1
for i := 0; i < n; i++ {
select {
case ch <- a:
a, b = b, a+b
case <-quit:
fmt.Println("fibonacci 被通知退出")
return
}
}
close(ch)
}

这里的 quit channel 类型是 chan struct{}struct{} 是零大小的类型,不占内存,专门用来做纯信号传递——只关心「事件发生了」,不需要携带任何数据。

WaitGroup:等待一组 goroutine 完成

1
2
3
4
5
6
7
8
9
10
var wg sync.WaitGroup

for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
fmt.Printf("worker %d 完成\n", id)
}(i)
}
wg.Wait() // 阻塞直到计数器归零

WaitGroup 内部维护一个计数器:Add(n) 加 n,Done() 减 1,Wait() 阻塞到计数器为 0。类似 C++ 中手写一个 std::atomic<int> 配合 std::condition_variable 实现的倒计时门闩——但 Go 把它封装好了。

Mutex:传统互斥锁

虽然 Go 鼓励用 channel 通信,但有些场景直接用锁更简洁。sync.Mutex 的用法和 C++ 的 std::mutex 几乎一样:

1
2
3
4
5
6
7
8
9
10
11
12
var mu sync.Mutex
counter := 0

for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
counter++
mu.Unlock()
}()
}

如果不加锁会怎样?Go 的竞态检测器(go run -race)会直接报警:

1
2
3
4
5
6
counter := 0
for i := 0; i < 100; i++ {
go func() {
counter++ // 数据竞争!
}()
}

C++ 中类似场景是未定义行为(可能静默出错),Go 则提供了 -race 工具在运行时检测。

用 channel 代替锁

Go 的哲学是优先用 channel。同样的计数器可以这样实现:

1
2
3
4
5
6
7
8
9
10
counterCh := make(chan int, 1)
counterCh <- 0 // 初始值

for i := 0; i < 1000; i++ {
go func() {
val := <-counterCh // 取出(相当于加锁)
val++
counterCh <- val // 放回(相当于解锁)
}()
}

缓冲区大小为 1 的 channel 天然保证同一时刻只有一个 goroutine 持有数据。取出值相当于获取锁,放回相当于释放锁。如果把缓冲区改大,就可能有多个 goroutine 同时操作——所以大小必须严格为 1。

Context:取消与超时的传播

context.Context 是 Go 并发编程中用于传递取消信号和超时的标准机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func worker(ctx context.Context, id int) {
for {
select {
case <-ctx.Done():
fmt.Printf("worker %d 退出: %v\n", id, ctx.Err())
return
default:
fmt.Printf("worker %d 工作中...\n", id)
time.Sleep(200 * time.Millisecond)
}
}
}

ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()

go worker(ctx, 1)
go worker(ctx, 2)

context.WithTimeout 创建一个在指定时间后自动取消的 context。当超时到达或手动调用 cancel() 时,ctx.Done() 返回的 channel 被关闭,所有监听该 channel 的 goroutine 都会收到通知。

Context 还可以用 context.WithValue 携带键值对数据,但社区共识是不要用它传递业务数据——它主要用于传递请求级别的元数据(如 trace ID、认证信息),业务数据应该通过函数参数显式传递。

用 C++ 类比,context 类似于 std::stop_token(C++20),但更强大——它支持超时、可以携带元数据、并且形成树状结构(父 context 取消时,所有子 context 也会被取消)。

Goroutine 的局限

Goroutine 并非没有代价:

  • 调试更难:goroutine 的调度不确定,bug 难以复现。
  • goroutine 泄漏:忘记关闭 channel 或没有退出机制,goroutine 会永远挂起,类似内存泄漏但更隐蔽。
  • 无法绑定 CPU:Go 的调度器不暴露线程亲和性,对于需要精细控制 CPU 核心绑定的场景(如高频交易),Go 不如 C++。
  • GC 暂停:所有 goroutine 在垃圾回收时可能被短暂暂停,对延迟极度敏感的场景有影响。

Go 没有暴露操作系统线程的 API。如果确实需要线程级控制,可以通过 runtime.LockOSThread() 将当前 goroutine 绑定到一个 OS 线程上——但这是边缘情况,绝大多数时候 goroutine 就是正确的选择。

错误处理

错误即返回值

C++ 用异常处理错误——throw 抛出,try-catch 捕获,异常沿调用栈自动冒泡。Go 做了一个截然不同的选择:错误就是普通的返回值,调用方必须在每个调用点显式检查。

1
2
3
4
5
6
7
8
9
10
11
func divide(a, b float64) (float64, error) {
if b == 0 {
return 0, errors.New("除数不能为零")
}
return a / b, nil
}

result, err := divide(10, 0)
if err != nil {
fmt.Println("错误:", err)
}

error 是一个内置接口,只有一个方法:

1
2
3
type error interface {
Error() string
}

任何实现了 Error() string 的类型都是 error。errors.New() 创建最简单的错误值,nil 代表没有错误。

这种设计的好处是错误路径完全可见——读代码时不需要猜测某个函数是否会抛出异常、抛出什么异常。代价是著名的 if err != nil 重复——这是 Go 社区中争议最多的话题之一,但也是 Go「显式优于隐式」哲学的直接体现。

fmt.Errorf 与 %w:给错误加上下文

实际开发中,底层函数返回的错误信息往往不够——调用方需要加上「我在做什么」的上下文:

1
2
3
4
5
6
7
8
9
10
func parsePort(s string) (int, error) {
port, err := strconv.Atoi(s)
if err != nil {
return 0, fmt.Errorf("无效的端口号 %q: %w", s, err)
}
if port < 0 || port > 65535 {
return 0, fmt.Errorf("端口号 %d 超出范围 [0, 65535]", port)
}
return port, nil
}

%w 是关键——它把原始错误包装进新的错误消息里,形成一条错误链。类似 C++ 的 std::throw_with_nested,但这不是异常嵌套,而是值的嵌套。

如果用 %v 而非 %w,原始错误的文本会被格式化进去,但链条断掉了——后续无法用 errors.Iserrors.Unwrap 找回原始错误。

自定义错误类型

只要实现了 Error() string,就是合法的 error。自定义错误类型可以携带结构化信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type ValidationError struct {
Field string
Message string
}

func (e *ValidationError) Error() string {
return fmt.Sprintf("验证失败 [%s]: %s", e.Field, e.Message)
}

func validateAge(age int) error {
if age < 0 {
return &ValidationError{Field: "age", Message: "不能为负数"}
}
if age > 150 {
return &ValidationError{Field: "age", Message: "不太合理"}
}
return nil
}

调用方用 errors.As 提取具体类型:

1
2
3
4
5
err := validateAge(-5)
var ve *ValidationError
if errors.As(err, &ve) {
fmt.Printf("字段: %s, 原因: %s\n", ve.Field, ve.Message)
}

errors.As 类似 C++ 的 dynamic_cast——它沿着错误链逐层检查,找到匹配的类型就赋值给目标变量。即使错误被 %w 包装了好几层,errors.As 也能穿透找到它。

C++ 中对应的写法是:

1
2
3
4
try { ... }
catch (const ValidationError& e) {
std::cout << e.field << e.message;
}

Go 没有 catch,所以用 errors.As 手动做类型匹配。更显式,但也更啰嗦。

哨兵错误与 errors.Is

哨兵错误(Sentinel Error)是包级别的全局变量,代表特定的错误语义:

1
2
3
4
5
6
7
8
9
10
11
12
var ErrNotFound = errors.New("未找到")
var ErrPermission = errors.New("权限不足")

func findUser(id int) (string, error) {
if id == 0 {
return "", ErrNotFound
}
if id < 0 {
return "", ErrPermission
}
return "Liam", nil
}

检查时用 errors.Is,不要用 ==

1
2
3
4
_, err := findUser(0)
if errors.Is(err, ErrNotFound) {
fmt.Println("用户未找到")
}

为什么不用 ==?因为如果错误被 %w 包装过,== 比较的是最外层的新错误对象,永远不等于原始哨兵。errors.Is 会沿着错误链逐层 Unwrap,直到找到匹配项。类似 C++ 中的错误码(如 std::errc::no_such_file_or_directory),但检查方式更安全。

错误包装与解包

把上述机制串联起来,看一个完整的错误链:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func loadConfig(path string) error {
_, err := os.Open(path)
if err != nil {
return fmt.Errorf("加载配置失败: %w", err)
}
return nil
}

err := loadConfig("/不存在的文件.toml")
if err != nil {
fmt.Println("错误:", err)
// 输出: 加载配置失败: open /不存在的文件.toml: no such file or directory

if errors.Is(err, os.ErrNotExist) {
fmt.Println("文件不存在") // 穿透两层包装找到了底层错误
}

fmt.Println("原始错误:", errors.Unwrap(err))
// 输出: open /不存在的文件.toml: no such file or directory
}

整条链路是这样的:

1
2
3
"加载配置失败: open /不存在的文件.toml: no such file or directory"
└── Unwrap → *os.PathError
└── 内部包含 → os.ErrNotExist

errors.Is 能一路穿透这些层级。这套机制让你可以在每一层加上业务上下文(「加载配置失败」),同时保留原始错误信息供底层判断。

errors.Is 与 errors.As 的对比

errors.Is errors.As
目的 判断错误链中是否包含某个特定值 判断错误链中是否包含某个特定类型
类比 err == target(但能穿透包装) dynamic_cast(但能穿透包装)
用法 errors.Is(err, ErrNotFound) errors.As(err, &ve)
典型场景 检查哨兵错误 提取自定义错误类型中的字段

panic 与 recover:真正的异常

panic 才是 Go 里对应 C++ throw 的机制——它中断当前函数,逐层退栈执行 defer,直到被 recover 捕获或者程序崩溃。

1
2
3
4
5
6
7
8
9
10
11
func safeCall() {
defer func() {
if r := recover(); r != nil {
fmt.Println("捕获 panic:", r)
}
}()

fmt.Println("即将 panic")
panic("出了大问题")
// 下面的代码不会执行
}

recover 只能在 defer 函数中调用,用于捕获当前 goroutine 的 panic。如果没有 panic 发生,recover 返回 nil

但 Go 社区的共识是:几乎不应该用 panic。它只适用于三种场景:

  1. 程序逻辑上不可能到达的分支,类似 C++ 的 assert
  2. 初始化阶段的致命错误——配置文件缺失、数据库连不上。
  3. 标准库内部优化——如 encoding/json 内部用 panic 跳出深层递归,外层统一 recover,但这是库的实现细节,不会暴露给调用者。

日常的错误处理,全部用 return error。如果你在业务代码里写了 panic,大概率是设计出了问题。

Go 与 C++ 错误处理的全景对比

概念 C++ Go
常规错误 throw / try-catch return error
错误携带信息 自定义异常类 自定义 error 类型
添加上下文 std::throw_with_nested fmt.Errorf("%w", err)
按值匹配 catch + 比较错误码 errors.Is(err, sentinel)
按类型匹配 catch (Type& e) errors.As(err, &target)
致命崩溃 std::terminate panic
拦截崩溃 无标准机制 defer + recover

C++ 的异常机制更强大(自动冒泡、栈展开),但也更不透明——函数签名上看不出它会不会抛异常、抛什么异常(noexcept 只是提示,不是强制)。Go 的方式更冗长,但每个可能出错的调用点都清清楚楚写在代码里。

小结

并发和错误处理是 Go 与 C++ 差异最剧烈的两个领域。

在并发方面,C++ 给你全部的底层控制——线程、互斥量、条件变量、原子操作、内存序——代价是手动管理的复杂性。Go 把并发抽象成 goroutine 和 channel,用 select 实现多路复用,用 context 传播取消信号,大幅降低了编写并发代码的门槛。这种抽象在绝大多数场景下够用,但也意味着你放弃了对线程绑定、内存序等底层细节的控制。

在错误处理方面,C++ 的异常是「乐观路径优先」——正常代码一路写下去,异常自动冒泡到合适的 catch。Go 是「悲观路径优先」——每个函数调用都要检查错误,错误路径和正常路径在代码中的地位完全对等。这让 Go 代码的错误处理更加显式和可预测,代价是 if err != nil 的冗余。

两种哲学各有拥趸。但理解它们背后的设计取舍,比争论谁更「好」有意义得多。

俗话说,投资效率是最好的投资。 如果您感觉我的文章质量不错,读后收获很大,预计能为您提高 10% 的工作效率,不妨小额捐助我一下,让我有动力继续写出更多好文章。