C++ 程序员学习 Goroutine 与 Channel:从线程思维到 CSP 思维
面向 C++ 开发者的 Go 并发入门实战,系统讲解 goroutine、channel、select、context 与常见并发陷阱,并提供可直接运行的对照代码示例。
如果你已经写了多年 C++ 并发代码(std::thread、mutex、condition_variable、线程池),学习 Go 并发时最容易卡住的点并不是语法,而是思维模型切换:
- C++ 常见路线:共享内存 + 锁同步;
- Go 推荐路线:通过通信共享数据(CSP 风格)。
本文会用 C++ 程序员熟悉的视角,帮你快速建立一套“可上线”的 Go 并发心智模型,并给出可运行代码示例。
一、先建立映射:C++ 概念 vs Go 概念
| C++ | Go | 你应该怎么理解 |
|---|---|---|
std::thread | goroutine | 都是并发执行单元,但 goroutine 更轻量,创建成本更低 |
mutex + 共享变量 | channel 传消息 | 从“抢同一块数据”转向“谁拥有数据、谁处理数据” |
condition_variable | select + channel | 多路等待、超时、取消更统一 |
future/promise | channel + select | 结果回传与协调通常不需要专门 promise 类型 |
| 线程池队列 | worker pool + channel | Go 用 channel 天然表达任务分发 |
记住一句话:Go 不是不能用锁,而是优先让你把并发问题建模成“消息流动”。
二、Goroutine:先用起来,再理解调度
1)最小示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package main
import (
"fmt"
"time"
)
func worker(id int) {
fmt.Printf("worker %d start\n", id)
time.Sleep(300 * time.Millisecond)
fmt.Printf("worker %d done\n", id)
}
func main() {
for i := 1; i <= 3; i++ {
go worker(i)
}
// 演示用:等待 goroutine 输出
time.Sleep(1 * time.Second)
}
C++ 程序员要注意:
main结束,进程就结束,后台 goroutine 不会“自动保活”;- 工程中不要用
Sleep等待任务完成,而是用WaitGroup或 channel 协调。
2)用 WaitGroup 替代“拍脑袋 Sleep”
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
package main
import (
"fmt"
"sync"
)
func worker(id int, wg *sync.WaitGroup) {
defer wg.Done()
fmt.Printf("worker %d running\n", id)
}
func main() {
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i, &wg)
}
wg.Wait()
fmt.Println("all done")
}
三、Channel:把“共享状态”改成“消息管道”
1)无缓冲 channel:一次发送对应一次接收
1
2
3
4
5
6
7
8
9
10
11
12
13
14
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 42 // 阻塞直到有人接收
}()
v := <-ch // 阻塞直到收到值
fmt.Println(v)
}
无缓冲 channel 的直觉:
- 发送方和接收方会“会合”(rendezvous);
- 很适合表达强同步点。
2)有缓冲 channel:削峰填谷
1
2
3
4
5
6
7
8
9
10
11
12
package main
import "fmt"
func main() {
ch := make(chan string, 2)
ch <- "task-1"
ch <- "task-2"
fmt.Println(<-ch)
fmt.Println(<-ch)
}
这很像 C++ 里的有界队列,但 Go 把接口降到更简单的原语。
3)关闭 channel:用来广播“没有后续数据了”
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main
import "fmt"
func producer(ch chan<- int) {
defer close(ch)
for i := 1; i <= 3; i++ {
ch <- i
}
}
func main() {
ch := make(chan int)
go producer(ch)
for v := range ch {
fmt.Println(v)
}
}
关键规则(面试高频):
- 只有发送方应该关闭 channel;
- 关闭后仍可接收,直到缓冲区清空;
- 向已关闭 channel 发送会 panic。
四、C++ 常见模式迁移:生产者-消费者
你在 C++ 里可能会写:mutex + queue + condition_variable。 在 Go 里通常更直接:jobs channel + workers。
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
package main
import (
"fmt"
"sync"
)
func worker(id int, jobs <-chan int, results chan<- int, wg *sync.WaitGroup) {
defer wg.Done()
for job := range jobs {
fmt.Printf("worker %d got job %d\n", id, job)
results <- job * job
}
}
func main() {
jobs := make(chan int, 5)
results := make(chan int, 5)
var wg sync.WaitGroup
workerNum := 3
for i := 1; i <= workerNum; i++ {
wg.Add(1)
go worker(i, jobs, results, &wg)
}
for j := 1; j <= 5; j++ {
jobs <- j
}
close(jobs)
wg.Wait()
close(results)
for r := range results {
fmt.Println("result:", r)
}
}
这个模型的收益:
- 并发边界清晰:任务输入、结果输出、关闭时机一目了然;
- 锁显著减少,死锁面更小;
- 更容易做背压控制(buffer 大小 + worker 数量)。
五、select:Go 并发控制的核心武器
select 相当于“等待多个 channel 事件”,并支持超时与取消。
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
package main
import (
"fmt"
"time"
)
func main() {
ch1 := make(chan string)
ch2 := make(chan string)
go func() {
time.Sleep(200 * time.Millisecond)
ch1 <- "from ch1"
}()
go func() {
time.Sleep(500 * time.Millisecond)
ch2 <- "from ch2"
}()
select {
case v := <-ch1:
fmt.Println(v)
case v := <-ch2:
fmt.Println(v)
case <-time.After(300 * time.Millisecond):
fmt.Println("timeout")
}
}
C++ 对照理解:
- 类似你手写“多条件等待 + 超时控制”;
- 但 Go 提供了统一语法与运行时支持,代码可读性更高。
六、工程必学:用 context 做取消传播
在 C++ 服务端里,你可能会自己维护“停止标志 + 条件变量”。 Go 里推荐标准化方案:context.Context。
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
package main
import (
"context"
"fmt"
"time"
)
func doWork(ctx context.Context, out chan<- int) {
defer close(out)
i := 0
for {
select {
case <-ctx.Done():
fmt.Println("worker canceled:", ctx.Err())
return
case out <- i:
i++
time.Sleep(100 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 450*time.Millisecond)
defer cancel()
out := make(chan int)
go doWork(ctx, out)
for v := range out {
fmt.Println("recv", v)
}
}
经验法则:
- 跨 goroutine、跨层调用的取消与超时,优先用
context; - 函数签名中通常把
ctx放第一个参数; - 不要把大对象塞进
context.Value。
七、C++ 程序员转 Go 的 7 个高频坑
把 goroutine 当“无限免费资源”
轻量不等于无限,要关注 goroutine 泄漏。忘记关闭或错误关闭 channel
尤其是多发送者场景,关闭责任要先设计清楚。在接收方关闭 channel
这通常是逻辑错误来源之一。忽视
for range ch的退出条件
不关闭 channel,消费者可能永久阻塞。乱用共享内存,回到“锁地狱”
能用消息流解的问题,尽量先不用锁。没有取消机制
请求超时、服务关闭时,没有context很容易残留后台任务。不做并发 race 检测
养成go test -race习惯,收益非常高。
八、建议学习路径(2 周)
第 1 周:建立并发基础
- 掌握 goroutine、channel(有/无缓冲)、
select; - 完成 2 个小练习:
- worker pool;
- 带超时的请求聚合器。
第 2 周:工程化落地
- 把
context融入所有 I/O 或长任务链路; - 学会用
go test -race、pprof 做并发质量检查; - 在真实服务里替换一个 C++ 风格“锁 + 队列”模块为 channel 模型。
九、结语
对 C++ 程序员来说,学习 Go 并发的关键不是“再学一套线程 API”,而是建立一种更偏通信驱动的设计方式。
你可以把迁移策略定为:
- 先用 goroutine/channel 把模型跑通;
- 再看性能瓶颈是否需要回到锁或原子操作;
- 最后用
context和测试体系把并发行为工程化。
当你开始用“数据流”而不是“锁序”描述系统时,Go 并发会变得非常顺手。