Goroutine:Go 的并发魔法
如果你问我 Go 语言最酷的特性是什么,我会毫不犹豫地回答:goroutine。
在当今这个多核 CPU 普及的时代,并发编程已经不是什么新鲜事了。但传统的并发模型——不管是 Java 的线程、Python 的多进程,还是 Node.js 的事件循环——都有各自的痛点:要么太重、要么太复杂、要么性能不够。
Go 语言另辟蹊径,提出了 goroutine 这个概念。它是一种轻量级的线程,由 Go 运行时管理,而不是操作系统。你可以轻松启动成千上万个 goroutine,而不用担心系统资源耗尽。
今天我们就来揭开 goroutine 的神秘面纱,看看它到底是怎么工作的,以及如何正确使用它。
什么是并发?为什么需要它?
在深入 goroutine 之前,我们先聊聊并发编程的基本概念。
并发 vs 并行
很多人把这两个概念搞混了,其实它们是不同的:
- 并发(Concurrency):多个任务在同一时间段内交替执行。单核 CPU 也能实现并发。
- 并行(Parallelism):多个任务在同一时刻同时执行。需要多核 CPU。
打个比方:
- 并发:一个厨师同时做三道菜,先切 A 菜的菜,然后去炒 B 菜,再回来炒 A 菜。三道菜在同一时间段内都在"进行中",但同一时刻厨师只做一件事。
- 并行:三个厨师同时做三道菜,每道菜都有一个专门的厨师在做。三道菜在同一时刻都在"被制作"。
Go 的 goroutine 支持并发,在多核 CPU 上也能实现并行。
为什么需要并发?
- 提高性能:充分利用多核 CPU,同时处理多个任务
- 提升响应性:不让耗时操作阻塞主流程
- 简化模型:把复杂的异步操作拆分成多个独立的逻辑单元
举个例子:一个 Web 服务器需要同时处理多个用户的请求。如果没有并发,服务器只能一个接一个地处理请求,后面的用户就得排队等待。有了并发,服务器可以同时处理多个请求,用户体验就好多了。
第一个 Goroutine
让我们从一个最简单的例子开始:
package main
import (
"fmt"
"time"
)
func sayHello() {
fmt.Println("Hello from goroutine!")
}
func main() {
// 启动一个 goroutine
go sayHello()
// 主函数继续执行
fmt.Println("Hello from main!")
// 等待一下,让 goroutine 有机会执行
time.Sleep(100 * time.Millisecond)
}
就这么简单!在函数调用前加上 go 关键字,这个函数就会在一个新的 goroutine 中异步执行。
运行结果可能是:
Hello from main!
Hello from goroutine!
或者:
Hello from goroutine!
Hello from main!
顺序是不确定的,因为两个 goroutine 是并发执行的。
⚠️ 注意:我们在最后加了 time.Sleep(100 * time.Millisecond)。如果不加这个,程序可能在 goroutine 还没执行完就退出了,你就看不到 “Hello from goroutine!” 的输出。
Goroutine 的生命周期
启动 goroutine
启动 goroutine 有两种方式:
方式一:调用函数
go functionName(args)
方式二:匿名函数
go func() {
// 做一些事情
}()
主 goroutine 退出
重要:当 main() 函数返回时,所有的 goroutine 都会被强制终止,不管它们是否执行完毕。
package main
import (
"fmt"
"time"
)
func worker(id int) {
for i := 0; i < 5; i++ {
fmt.Printf("Worker %d: working... %d\n", id, i)
time.Sleep(100 * time.Millisecond)
}
}
func main() {
go worker(1)
go worker(2)
fmt.Println("Main: I'm done!")
// 没有等待,直接退出
}
运行结果:
Main: I'm done!
Worker 根本没有机会执行!因为 main 函数一返回,整个程序就结束了。
如何等待 goroutine?
这是我们遇到的第一个实际问题。目前我们用的是 time.Sleep(),但这显然不是个好办法——你很难准确估计要等多久。
后面我们会学习更好的方式:
- Channel:通过通道同步
- WaitGroup:专门的同步工具
- Context:带超时的等待
现在先用 time.Sleep() 凑合一下,后面会详细介绍这些工具。
Goroutine 的轻量级特性
Goroutine 和操作系统线程最大的区别在于轻量级。
栈大小对比
- 操作系统线程:初始栈大小通常是 1-8 MB(Linux 默认 8 MB)
- Goroutine:初始栈大小只有 2 KB!
这意味着你可以在同样的内存中启动多得多的 goroutine。
栈的动态增长
Goroutine 的栈不是固定的,它会根据需要动态增长和缩小。这得益于 Go 运行时的栈分段(segmented stacks)技术。
package main
import (
"fmt"
"runtime"
)
func recursive(depth int) {
if depth > 10000 {
return
}
recursive(depth + 1)
}
func main() {
fmt.Println("当前 goroutine 数量:", runtime.NumGoroutine())
for i := 0; i < 1000; i++ {
go recursive(0)
}
// 等待 goroutine 启动
runtime.Gosched()
fmt.Println("启动后 goroutine 数量:", runtime.NumGoroutine())
}
这段代码启动了 1000 个 goroutine,每个都执行深度为 10000 的递归。如果是操作系统线程,早就因为栈溢出崩溃了。但 goroutine 可以轻松应对。
启动成本
启动一个 goroutine 的成本非常低:
- 内存:2 KB 初始栈
- CPU:几百条指令
- 时间:微秒级
相比之下,启动一个操作系统线程的成本要高得多:
- 内存:1-8 MB 栈
- CPU:数千条指令
- 时间:毫秒级
这就是为什么你可以轻松启动成千上万个 goroutine,而启动同样数量的线程会让系统崩溃。
实战:多任务下载器
让我们用 goroutine 写一个实用的程序——并发下载多个文件:
package main
import (
"fmt"
"math/rand"
"time"
)
// simulateDownload 模拟下载文件
func simulateDownload(filename string, duration time.Duration) {
fmt.Printf("[%s] 开始下载...\n", filename)
time.Sleep(duration)
fmt.Printf("[%s] 下载完成!耗时: %v\n", filename, duration)
}
func main() {
// 模拟要下载的文件
files := []struct {
name string
duration time.Duration
}{
{"file1.zip", time.Duration(rand.Intn(3)+1) * time.Second},
{"file2.zip", time.Duration(rand.Intn(3)+1) * time.Second},
{"file3.zip", time.Duration(rand.Intn(3)+1) * time.Second},
{"file4.zip", time.Duration(rand.Intn(3)+1) * time.Second},
}
start := time.Now()
// 启动所有下载任务
for _, file := range files {
go simulateDownload(file.name, file.duration)
}
// 等待所有下载完成(粗略的方式)
time.Sleep(4 * time.Second)
elapsed := time.Since(start)
fmt.Printf("\n总耗时: %v\n", elapsed)
}
运行结果类似:
[file1.zip] 开始下载...
[file2.zip] 开始下载...
[file3.zip] 开始下载...
[file4.zip] 开始下载...
[file2.zip] 下载完成!耗时: 1s
[file1.zip] 下载完成!耗时: 2s
[file4.zip] 下载完成!耗时: 2s
[file3.zip] 下载完成!耗时: 3s
总耗时: 4.001s
看到了吗?虽然每个文件下载需要 1-3 秒,但 4 个文件同时下载,总耗时只有约 4 秒(最长的下载时间 + 一些开销),而不是串行下载的 1+2+2+3=8 秒。
这就是并发的力量!
Goroutine 调度器
Goroutine 的执行是由 Go 运行时的调度器管理的。了解调度器的工作原理,有助于你写出更高效的并发代码。
M:N 调度模型
Go 使用的是 M:N 调度模型:
- M(Machine):操作系统线程
- N(Goroutine):用户态的 goroutine
Go 调度器会把 N 个 goroutine 调度到 M 个操作系统线程上执行。默认情况下,M 的数量等于 CPU 核心数。
package main
import (
"fmt"
"runtime"
)
func main() {
// 查看当前可用的 CPU 核心数
fmt.Println("CPU 核心数:", runtime.NumCPU())
// 查看当前使用的线程数
fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))
// 可以手动设置使用的线程数
// runtime.GOMAXPROCS(4) // 使用 4 个线程
}
抢占式调度
在 Go 1.14 之前,goroutine 使用的是协作式调度——goroutine 必须主动让出 CPU(比如调用 time.Sleep()、I/O 操作、channel 操作等),否则它会一直占用线程。
从 Go 1.14 开始,Go 引入了抢占式调度——调度器可以强制中断一个运行中的 goroutine,即使它没有主动让出 CPU。这解决了"一个 goroutine 死循环占用整个线程"的问题。
runtime.Gosched()
你可以手动让当前 goroutine 让出 CPU,给其他 goroutine 执行的机会:
package main
import (
"fmt"
"runtime"
)
func worker(id int) {
for i := 0; i < 3; i++ {
fmt.Printf("Worker %d: iteration %d\n", id, i)
runtime.Gosched() // 主动让出 CPU
}
}
func main() {
go worker(1)
go worker(2)
// 主 goroutine 也让出 CPU
for i := 0; i < 3; i++ {
fmt.Printf("Main: iteration %d\n", i)
runtime.Gosched()
}
}
输出结果会交替显示各个 goroutine 的执行情况。
Goroutine 泄漏
Goroutine 泄漏是 Go 程序中常见的 bug。它指的是一个 goroutine 因为某种原因永远无法退出,一直占用资源。
常见的泄漏原因
1. 等待永远不会发生的 channel 操作
func leaky() {
ch := make(chan int)
go func() {
// 这个 goroutine 永远不会收到数据
// 因为它等待的 channel 永远不会被发送
value := <-ch
fmt.Println(value)
}()
// 忘记发送数据就返回了
// ch <- 42
}
2. 无限循环
func leaky() {
go func() {
for {
// 没有退出条件的死循环
time.Sleep(time.Second)
}
}()
}
3. 阻塞在 I/O 操作
func leaky() {
go func() {
conn, _ := net.Dial("tcp", "example.com:80")
// 如果连接一直不关闭,这个 goroutine 就泄漏了
defer conn.Close()
buf := make([]byte, 1024)
for {
_, err := conn.Read(buf)
if err != nil {
return
}
}
}()
}
如何避免泄漏?
- 使用 context:给 goroutine 设置超时或取消机制
- 使用 select:不要无限期等待
- 确保有退出条件:每个 goroutine 都应该有明确的退出路径
- 监控 goroutine 数量:使用
runtime.NumGoroutine()检测泄漏
package main
import (
"context"
"fmt"
"runtime"
"time"
)
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("Worker: 收到取消信号,退出")
return
default:
fmt.Println("Worker: 工作中...")
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
fmt.Println("开始时 goroutine 数量:", runtime.NumGoroutine())
// 创建一个 2 秒后取消的 context
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
go worker(ctx)
time.Sleep(3 * time.Second)
fmt.Println("结束时 goroutine 数量:", runtime.NumGoroutine())
}
Goroutine 和闭包
在循环中启动 goroutine 时,要特别小心闭包的问题:
// ❌ 错误示例
for i := 0; i < 5; i++ {
go func() {
fmt.Println(i) // 所有 goroutine 共享同一个 i
}()
}
time.Sleep(100 * time.Millisecond)
// 输出:5 5 5 5 5(或者其他的 5)
所有 goroutine 都捕获了同一个变量 i。当它们执行时,循环已经结束了,i 的值已经是 5。
// ✅ 正确方式一:通过参数传递
for i := 0; i < 5; i++ {
go func(n int) {
fmt.Println(n) // 每个 goroutine 有自己的 n
}(i)
}
// ✅ 正确方式二:在循环内创建局部变量
for i := 0; i < 5; i++ {
i := i // 创建新的局部变量
go func() {
fmt.Println(i)
}()
}
time.Sleep(100 * time.Millisecond)
// 输出:0 1 2 3 4(顺序不确定)
进阶概念
GMP 调度模型的三要素
Go 的调度器被称为 GMP 模型,是 Go 并发高效的核心设计。理解它对于调优高并发程序至关重要。
- G(Goroutine):代表一个 goroutine。每个 G 包含栈指针、程序计数器、状态等元信息。G 的初始栈大小为 2KB,远小于操作系统线程的 1-8MB,且支持动态伸缩。一个 Go 程序可以同时存在数以万计的 G。
- M(Machine / OS Thread):代表一个操作系统线程。M 是实际执行代码的载体,由操作系统调度。Go 1.5 之后,M 的数量默认等于 CPU 核心数(通过 GOMAXPROCS 控制),但不会超过这个数量。一个 M 必须绑定一个 P 才能执行 G。
- P(Processor / 调度上下文):代表一个逻辑处理器,维护本地 G 的运行队列。P 的数量默认等于 GOMAXPROCS。P 持有从全局队列中分配到的 G,以及本地队列中的 G,还通过 work stealing 从其他 P stealing 任务。P 作为 G 和 M 之间的中间层,极大地减少了全局锁的竞争。
当一个 M 需要执行 G 时,它会先找一个 P 绑定,然后从 P 的本地队列中取出 G 来执行。如果 P 的本地队列空了,M 会尝试去其他 P 偷取 G(work stealing),或者去全局队列获取。
Goroutine 栈的初始大小与动态伸缩
Go 1.4 之前,goroutine 栈使用分段栈(segmented stacks):栈由许多小的栈帧组成,当栈不够用时,在堆上分配一个新的栈段,并通过指针连接。这种方式的缺点是栈增长时会有性能开销(称为 stack split)。
Go 1.4 引入了连续栈(contiguous stacks):当栈空间不足时,分配一个更大的连续内存块,将旧栈数据复制过去,然后调整指针。连续栈避免了分段栈的碎片化问题,且复制成本可以通过逃逸分析优化。
Go 1.2 起,初始栈大小从 4KB 缩小到 2KB。Go 1.4 后使用连续栈,最小 2KB,最大可达 1GB(64 位系统)。这种设计使得大量轻量 goroutine 不会消耗过多内存,而递归等场景也能自动扩展。
package main
import (
"fmt"
"runtime"
"unsafe"
)
func main() {
// 查看当前 goroutine 数量
fmt.Println("Goroutine 数量:", runtime.NumGoroutine())
// 在 64 位系统上,uintptr 为 8 字节
fmt.Println("uintptr 大小:", unsafe.Sizeof(uintptr(0)))
}
Work Stealing 机制
Work stealing 是 Go 调度器实现负载均衡的核心算法。其基本思想是:如果一个 P 的本地队列为空,它的 M 不会闲着,而是随机选择另一个 P,偷取它队列中一半的 G 过来执行。
具体流程如下:
- 当 G 执行完毕,M 检查当前 P 的本地队列是否还有 G
- 如果本地队列空了,M 先查看全局队列,按批次取 G(通常每次取 1 个,其他 P 也竞争)
- 如果全局队列也空了,M 开始 work stealing:随机选择另一个 P,偷取其本地队列后半部分的 G
- 如果偷取也失败了,M 会进入自旋状态(spinning),等待新任务;如果长时间没有任务,M 会被休眠以节省资源
这种机制确保了即使某个 goroutine 创建了海量子 goroutine,负载也会被均匀分摊到所有 P 上,不会出现某个 CPU 核心空闲而其他核心过载的情况。
实战案例
并发爬虫完整实现
下面的例子实现了一个带深度限制和并发控制的 Web 爬虫:
package main
import (
"context"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
// Crawler 并发爬虫
type Crawler struct {
visited map[string]bool
mu sync.Mutex
client *http.Client
wg sync.WaitGroup
semaphore chan struct{}
}
func NewCrawler(maxConcurrent int) *Crawler {
return &Crawler{
visited: make(map[string]bool),
client: &http.Client{
Timeout: 10 * time.Second,
},
semaphore: make(chan struct{}, maxConcurrent),
}
}
func (c *Crawler) fetch(url string) (string, error) {
c.semaphore <- struct{}{} // 获取信号量
defer func() { <-c.semaphore }() // 释放信号量
resp, err := c.client.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("status: %d", resp.StatusCode)
}
// 简化处理,实际应解析 HTML 提取链接
return url + " content", nil
}
func (c *Crawler) crawl(ctx context.Context, url string, depth int) {
defer c.wg.Done()
if depth <= 0 {
return
}
c.mu.Lock()
if c.visited[url] {
c.mu.Unlock()
return
}
c.visited[url] = true
c.mu.Unlock()
select {
case <-ctx.Done():
return
default:
}
content, err := c.fetch(url)
if err != nil {
fmt.Printf("Error fetching %s: %v\n", url, err)
return
}
fmt.Printf("Crawled: %s -> %s\n", url, content)
// 模拟发现子链接
for i := 0; i < 2 && depth > 1; i++ {
childURL := fmt.Sprintf("%s/page%d", strings.TrimSuffix(url, "/"), i)
c.wg.Add(1)
go c.crawl(ctx, childURL, depth-1)
}
}
func main() {
crawler := NewCrawler(5) // 最多 5 个并发请求
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
crawler.wg.Add(1)
go crawler.crawl(ctx, "https://example.com", 3)
crawler.wg.Wait()
fmt.Println("Crawling complete")
}
这个爬虫使用了信号量来控制并发数,使用 WaitGroup 等待所有 goroutine 完成,使用 context 控制超时,是一种工业级的并发模式。
并行计算任务分派
下面的例子演示如何将大任务拆分成小任务并行计算:
package main
import (
"fmt"
"math"
"sync"
"time"
)
// 计算一批数字的平方和
func sumOfSquares(nums []int) int {
sum := 0
for _, n := range nums {
sum += n * n
}
return sum
}
// 并行分片计算
func parallelSumOfSquares(nums []int, workers int) int {
chunkSize := int(math.Ceil(float64(len(nums)) / float64(workers)))
results := make(chan int, workers)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
start := i * chunkSize
end := start + chunkSize
if end > len(nums) {
end = len(nums)
}
if start >= end {
break
}
wg.Add(1)
go func(slice []int) {
defer wg.Done()
results <- sumOfSquares(slice)
}(nums[start:end])
}
go func() {
wg.Wait()
close(results)
}()
total := 0
for partial := range results {
total += partial
}
return total
}
func main() {
nums := make([]int, 10000000)
for i := range nums {
nums[i] = i + 1
}
// 串行计算
start := time.Now()
serial := sumOfSquares(nums)
serialTime := time.Since(start)
fmt.Printf("串行结果: %d, 耗时: %v\n", serial, serialTime)
// 并行计算
start = time.Now()
parallel := parallelSumOfSquares(nums, 8)
parallelTime := time.Since(start)
fmt.Printf("并行结果: %d, 耗时: %v\n", parallel, parallelTime)
fmt.Printf("加速比: %.2fx\n", float64(serialTime)/float64(parallelTime))
}
性能对比:Goroutine vs 线程 vs 协程
| 指标 | Go Goroutine | OS 线程(Linux pthread) | Python asyncio 协程 |
|---|---|---|---|
| 初始栈大小 | 2 KB | 2 - 8 MB | 共享主线程栈 |
| 启动时间 | ~1 - 2 微秒 | ~1 毫秒 | ~0.1 微秒 |
| 切换开销 | ~200 ns | ~1 - 2 微秒 | ~50 ns |
| 最大数量 | 10W+(取决于内存) | 数千(受限于内存和调度器) | 数百万(轻量级) |
| 调度方式 | Go 运行时(M:N) | 操作系统内核(1:1) | 事件循环(单线程) |
| 是否利用多核 | 是 | 是 | 否(需多进程) |
| 阻塞影响 | M 可切换执行其他 G | 线程阻塞 | 协程阻塞整个事件循环 |
| 通信方式 | Channel / 共享内存 | 共享内存(需显式同步) | 无原生通信机制 |
| 锁机制 | sync 包、channel | pthread 锁、信号量 | asyncio.Lock |
| 典型适用场景 | 高并发网络服务、微服务 | 计算密集型任务 | I/O 密集型脚本 |
注意:Go goroutine 的优势在于它结合了线程的"真正并行"能力(多核利用)和协程的"轻量"特性(低开销),同时提供了 channel 这种优雅的通信方式。这种设计让 Go 在构建高并发网络服务时具有显著优势。
最佳实践
1. 控制 Goroutine 数量
不要盲目启动大量 goroutine。使用信号量或 worker pool 限制并发数:
// 使用带缓冲的 channel 作为信号量
semaphore := make(chan struct{}, 100) // 最多 100 个并发
for _, task := range tasks {
semaphore <- struct{}{}
go func(t Task) {
defer func() { <-semaphore }()
process(t)
}(task)
}
2. WaitGroup 的正确使用
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1) // 必须在启动 goroutine 之前调用
go func() {
defer wg.Done()
// 工作代码
}()
}
wg.Wait() // 等待全部完成
3. 使用 Context 传递取消信号
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, url := range urls {
go func(u string) {
req, _ := http.NewRequestWithContext(ctx, "GET", u, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
}(url)
}
4. 避免 Goroutine 泄漏
- 永远不要让 goroutine 无限等待一个永远不会关闭的 channel
- 给阻塞操作设置超时
- 使用
runtime.NumGoroutine()监控 goroutine 数量变化 - 在循环中启动 goroutine 时,通过参数传递变量值
常见问题(FAQ)
Q1:goroutine 和操作系统线程有什么区别?
A:goroutine 是用户态的轻量级线程,由 Go 运行时调度,初始栈只有 2KB;操作系统线程由内核调度,初始栈 2-8MB。一个操作系统线程可以承载多个 goroutine(M:N 模型)。goroutine 的创建和切换开销远低于线程。
Q2:一个程序可以同时启动多少个 goroutine?
A:理论上只受限于内存和 Go 运行时的内部限制(垃圾回收等)。启动 10 万个 goroutine 在 Go 中很常见,每个约 2KB 栈,总内存占用仅约 200MB。相比之下,启动 10 万个线程在大多数系统上是不可能的。
Q3:GOMAXPROCS 设为多少合适?
A:默认值等于 CPU 核心数,适用于大多数场景。对于 I/O 密集型任务,可以略大于核心数(比如 2 倍),因为 goroutine 在等待 I/O 时会让出 CPU。但过大可能导致过多的线程切换开销。
Q4:goroutine 在什么情况下会发生阻塞?
A:goroutine 在以下场景会阻塞:channel 发送/接收时对方未就绪、锁获取失败、网络 I/O 等待、系统调用、time.Sleep、select 中没有就绪的 case。阻塞的 goroutine 会让出 M,调度器会把 M 分配给其他可运行的 G。
Q5:为什么循环里用闭包启动 goroutine 会得到错误的结果?
A:因为闭包捕获的是变量的引用而不是值。当 goroutine 实际执行时,循环可能已经结束,变量值已经改变。正确做法是将变量作为参数传入 goroutine,或在循环体内重新定义局部变量。
Q6:goroutine 泄漏怎么检测?
A:可以在关键点打印 runtime.NumGoroutine() 的数量变化。更专业的方式是使用 pprof 的 goroutine profile:go tool pprof http://localhost:6060/debug/pprof/goroutine。另外,goleak 等第三方库可以帮助在测试中检测 goroutine 泄漏。
Q7:goroutine 安全地读写共享变量有哪些方式?
A:主要有四种方式:(1) 使用 sync.Mutex 或 sync.RWMutex 进行显式加锁;(2) 使用 sync/atomic 包进行原子操作(适用于简单类型);(3) 使用 channel 传递数据副本(通过通信共享内存);(4) 使用 sync.Map 作为并发安全的 map。
Q8:什么时候 goroutine 的栈会增长?
A:当 goroutine 的栈空间不够用时(比如深层递归、创建大块局部变量),Go 运行时会自动分配更大的连续栈并将数据复制过去。栈也可以缩小,当 goroutine 进入 GC 安全点时,如果发现栈使用率很低,会缩小栈以节省内存。
Q9:channel 发送时是值传递还是引用传递?
A:channel 发送的是值的副本。如果发送的是指针、切片、map 等引用类型,虽然复制的是引用本身(指针值),但底层数据是共享的。发送结构体时是整个结构体的拷贝。发送大对象时考虑使用指针以避免不必要的复制开销。
Q10:是否应该手动调用 runtime.Gosched()?
A:绝大多数情况下不需要。Go 1.14 引入了真正的抢占式调度,长时间运行的 goroutine 会被强制让出 CPU。只有在编写特殊测试或需要在特定时机切换 goroutine 的极罕见场景下才需要手动调用。
延伸阅读
- Go 语言规范 - Go statements
- Effective Go - Goroutines
- Go 官方博客 - Go Concurrency Patterns
- Go 调度器设计文档 - runtime scheduler
- Visualizing Concurrency in Go
- The Go Memory Model
- Inside the Go Runtime: GMP Scheduler
- Go 1.14 抢占式调度
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。