Goroutine:Go 的并发魔法

深入讲解 Go 语言 goroutine 的底层原理,包括 GMP 调度模型、栈的动态伸缩、work stealing 机制,并配合完整实战案例、性能对比表与最佳实践,帮助你彻底掌握 Go 并发编程的核心。

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 上也能实现并行。

为什么需要并发?

  1. 提高性能:充分利用多核 CPU,同时处理多个任务
  2. 提升响应性:不让耗时操作阻塞主流程
  3. 简化模型:把复杂的异步操作拆分成多个独立的逻辑单元

举个例子:一个 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
			}
		}
	}()
}

如何避免泄漏?

  1. 使用 context:给 goroutine 设置超时或取消机制
  2. 使用 select:不要无限期等待
  3. 确保有退出条件:每个 goroutine 都应该有明确的退出路径
  4. 监控 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 过来执行。

具体流程如下:

  1. 当 G 执行完毕,M 检查当前 P 的本地队列是否还有 G
  2. 如果本地队列空了,M 先查看全局队列,按批次取 G(通常每次取 1 个,其他 P 也竞争)
  3. 如果全局队列也空了,M 开始 work stealing:随机选择另一个 P,偷取其本地队列后半部分的 G
  4. 如果偷取也失败了,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 GoroutineOS 线程(Linux pthread)Python asyncio 协程
初始栈大小2 KB2 - 8 MB共享主线程栈
启动时间~1 - 2 微秒~1 毫秒~0.1 微秒
切换开销~200 ns~1 - 2 微秒~50 ns
最大数量10W+(取决于内存)数千(受限于内存和调度器)数百万(轻量级)
调度方式Go 运行时(M:N)操作系统内核(1:1)事件循环(单线程)
是否利用多核是是否(需多进程)
阻塞影响M 可切换执行其他 G线程阻塞协程阻塞整个事件循环
通信方式Channel / 共享内存共享内存(需显式同步)无原生通信机制
锁机制sync 包、channelpthread 锁、信号量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 的极罕见场景下才需要手动调用。


延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练