【Go】P17 Go语言并发编程核心:深入理解 Goroutine (从入门到实战)

目录

在当今这个追求高性能和高效率的时代,并发编程已成为软件开发者的必备技能。然而,传统的基于线程和锁的并发模型往往复杂且容易出错。Go 语言(Golang)从设计之初就将并发作为其核心特性,并提供了一种极其轻量级且强大的并发原语------Goroutine

本博文将带你从最基础的概念开始,逐步深入 Goroutine 的世界,通过丰富的代码示例,让你真正掌握 Go 语言并发编程的魅力。


前置知识:厘清并发与并行

在深入 Goroutine 之前,我们必须先理解几个基本概念。

进程(Process)与线程(Thread)

  • 进程: 是程序在操作系统中的一次执行过程,是系统进行资源分配和调度的基本单位。你可以把进程看作是一个正在运行的应用程序(比如你的浏览器或代码编辑器),它拥有自己独立的内存空间。
  • 线程: 是进程的一个执行实例是程序执行的最小单元,它比进程更小,是 CPU 调度的基本单位。一个进程可以包含多个线程,它们共享该进程的内存空间(如堆内存),但各自拥有独立的栈。

通俗地讲,一个"工厂"(进程)可以雇佣多个"工人"(线程)来共同完成任务。

并发(Concurrency)与并行(Parallelism)

  • 并发: 逻辑上同时处理多个任务但是实际上交替执行 。在一个时间段内,多个任务都在推进,但不一定在同一时刻同时执行。好比你一个人在"同时"做饭、接电话和看孩子。你可能在切菜(任务A),电话响了,你暂停切菜去接电话(任务B),接完电话发现孩子哭了(任务C),你又去哄孩子,然后再回来继续切菜。并发的核心是任务的切换与调度
  • 并行: 指物理上同时执行 多个任务的能力。这需要多核处理器的支持。好比你有两个帮厨(多核),你(核心1)在切菜,同时你的帮厨(核心2)在烧水。并行的核心是同时执行

总结一下:

  • 并发: 多个线程竞争一个 CPU 核心,通过快速切换实现"看似同时"的执行。
  • 并行: 多个线程分配在多个 CPU 核心上,实现"真正同时"的执行。

在多核 CPU 上,Go 语言的 Goroutine 可以同时实现并发与并行,这也是它强大的原因之一。


为什么选择 Goroutine?

传统的线程(OS 线程)由操作系统调度,创建和销毁的开销很大,上下文切换成本也很高。一个程序如果创建成千上万个线程,系统资源会迅速耗尽。

Go 语言引入了 Goroutine(协程),它带来了革命性的改变:

  1. 极其轻量: 一个 Goroutine 的初始栈空间通常只有 2KB,而一个 OS 线程通常需要 1MB 甚至更多。这意味着你可以轻易地创建成千上万个 Goroutine 而不消耗太多系统资源。
  2. Go 运行时调度: Goroutine 由 Go 语言的运行时(runtime)调度器管理,而不是操作系统。这使得切换成本非常低,Go 调度器会智能地将 Goroutine (G) 调度到逻辑处理器 § 上,再由逻辑处理器绑定到 OS 线程 (M) 上执行。
  3. 使用简单: 开启一个 Goroutine 异常简单,只需在函数调用前加上 go 关键字即可。

Goroutine 是 Go 语言实现高并发、高效率的基石。


如何使用 Goroutine?

启动你的第一个 Goroutine

go 复制代码
package main

import "fmt"

func sayHello() {
	fmt.Println("Hello from Goroutine!")
}

func main() {
	go sayHello() // 启动一个协程
	fmt.Println("Hello from main function!")
}

上述代码,我们通过 go 命令创建了一个协程 sayHello 并在其中设置打印内容。但是实际上,当你运行这段代码时,很可能会发现这个协程并没有执行,为什么?

原因: 主进程执行过快,执行完毕即退出,不会等待其他 Goroutine 执行完毕。

那么为了让主程序等待协程的输出,我们甚至可以添加时间等待函数 time.sleep(),但是这并不符合程序设计思想。

sync.WaitGroup 阻塞等待

为了解决上述问题,Go 的 sync 包提供了一个非常有用的工具:WaitGroup协程计数器 )。它能帮助我们阻塞 main goroutine,直到所有我们关心的子 goroutine 都执行完毕。

WaitGroup 包含三个核心方法:

  • wg.Add(n):计数器增加 n。通常在启动 Goroutine 前调用。
  • wg.Done():计数器减 1。通常在 Goroutine 执行完毕时,使用 defer 来确保调用。
  • wg.Wait():阻塞当前 Goroutine(通常是 main goroutine),直到计数器归零。
go 复制代码
package main

import (
	"fmt"
	"sync"
	"time"
)

func worker(id int, wg *sync.WaitGroup) {
	// 在函数退出时通知 WaitGroup 任务已完成
	defer wg.Done() 

	fmt.Printf("Worker %d starting\n", id)
	time.Sleep(1 * time.Second) // 模拟耗时操作
	fmt.Printf("Worker %d done\n", id)
}

func main() {
	// 声明一个 WaitGroup
	var wg sync.WaitGroup

	// 启动 3 个 worker Goroutine
	for i := 1; i <= 3; i++ {
		fmt.Printf("Main: Starting worker %d\n", i)
		// 每次启动一个 Goroutine,计数器加 1
		wg.Add(1) 
		go worker(i, &wg)
	}

	fmt.Println("Main: Waiting for workers to finish...")
	// 等待所有 Goroutine 完成(即计数器归零)
	wg.Wait() 

	fmt.Println("Main: All workers done. Exiting.")
}

这个例子完美地展示了 WaitGroup 的用法:main 函数准确地等待了所有 3 个 worker 完成后才退出。


设置应用核数

我们已经知道了 Goroutine 可以实现并发。那它如何利用多核 CPU 实现并行呢?

Go 语言提供了 runtime 包来与运行时环境交互:

  • runtime.NumCPU():返回当前系统的 CPU 核心数。
  • runtime.GOMAXPROCS(n): 设置 Go 程序可以同时使用的 OS 线程数(即 P 的数量)。

注: GOMAXPROCS 默认值为 runtime.NumCPU(),我们几乎不需要手动设置它,Go 运行时会自动为你利用所有可用的 CPU 核心。


实战对比:计算素数

让我们用一个计算密集型任务来直观感受串行并行的效率差异:统计 1 到 200,000 之间有多少个素数。

go 复制代码
package main

import (
	"fmt"
	"runtime"
	"sync"
	"sync/atomic"
	"time"
)

// isPrime 是一个简单的(非最高效)素数判断函数
func isPrime(n int) bool {
	if n <= 1 {
		return false
	}
	for i := 2; i*i <= n; i++ {
		if n%i == 0 {
			return false
		}
	}
	return true
}

// --- 版本一:单线程串行计算 ---
func serialCalculate(max int) int64 {
	var count int64 = 0
	for i := 1; i <= max; i++ {
		if isPrime(i) {
			count++
		}
	}
	return count
}

// --- 版本二:多 Goroutine 并行计算 ---
func parallelCalculate(max int) int64 {
	// 获取 CPU 核心数,决定开启多少个 Goroutine
	numCores := runtime.NumCPU() 
	
	var count int64 = 0 // 使用 atomic 来安全地累加
	var wg sync.WaitGroup
	
	// 将任务均分给每个核心
	segmentSize := max / numCores

	for i := 0; i < numCores; i++ {
		// 计算每个 goroutine 的起止范围
		start := i*segmentSize + 1
		end := (i + 1) * segmentSize
		if i == numCores-1 { // 最后一个 goroutine 处理所有剩余的
			end = max
		}

		wg.Add(1)
		go func(start, end int) {
			defer wg.Done()
			var localCount int64 = 0 // Goroutine 内部计数
			for j := start; j <= end; j++ {
				if isPrime(j) {
					localCount++
				}
			}
			// 使用原子操作,安全地将局部结果加到总数上
			// 避免了多 Goroutine 同时写入 count 造成的竞态问题
			atomic.AddInt64(&count, localCount)
		}(start, end)
	}

	wg.Wait()
	return count
}

func main() {
	const MAX_NUM = 200000

	// --- 串行测试 ---
	startSerial := time.Now()
	countSerial := serialCalculate(MAX_NUM)
	timeSerial := time.Since(startSerial)
	fmt.Printf("[串行] 1-%d 之间共有 %d 个素数\n", MAX_NUM, countSerial)
	fmt.Printf("[串行] 耗时: %v\n", timeSerial)

	fmt.Println("--------------------")

	// --- 并行测试 ---
	startParallel := time.Now()
	countParallel := parallelCalculate(MAX_NUM)
	timeParallel := time.Since(startParallel)
	fmt.Printf("[并行] 1-%d 之间共有 %d 个素数 (使用 %d 核心)\n", MAX_NUM, countParallel, runtime.NumCPU())
	fmt.Printf("[并行] 耗时: %v\n", timeParallel)
}

一次可能的运行效果(注意不同的电脑因性能不同结果会完全不同)

go 复制代码
[串行] 1-200000 之间共有 17984 个素数
[串行] 耗时: 1.65s
--------------------
[并行] 1-200000 之间共有 17984 个素数 (使用 8 核心)
[并行] 耗时: 245.5ms

结果分析: 效率提升是惊人的!串行版本耗时 1.65 秒,而并行版本仅耗时 245 毫秒(约为 0.245 秒),速度提升了近 7 倍。

这就是并行的力量。Go 运行时将 8 个计算任务(segmentSize)分别交给了 8 个不同的 CPU 核心去"同时"执行,极大地缩短了总耗时。


总结

Goroutine 协程,是 Go 语言并发设计的灵魂。它以其轻量级、低开销和强大的调度能力,让开发者能以极其简单的方式编写出高性能的并发乃至并行程序。

  • Goroutine ( go 关键字) 让我们轻松发起并发任务。
  • sync.WaitGroup 让我们能可靠地同步和等待任务完成。
  • Go 运行时 默认利用多核 CPU,让我们(在计算密集型任务中)"免费"获得并行带来的性能提升。

掌握 Goroutine,是深入 Go 语言、构建现代化高性能服务的必经之路。


2025.10.29 杭州西湖畔

相关推荐
Yeniden4 小时前
【设计模式】# 外观模式(Facade)大白话讲解!
java·设计模式·外观模式
Yeniden5 小时前
【设计模式】 组合模式(Composite)大白话讲解
java·设计模式·组合模式
初学小白...5 小时前
线程同步机制及三大不安全案例
java·开发语言·jvm
CS Beginner5 小时前
【搭建】个人博客网站的搭建
java·前端·学习·servlet·log4j·mybatis
JavaTree20176 小时前
【Spring Boot】Spring Boot解决循环依赖
java·spring boot·后端
lang201509286 小时前
Maven 五分钟入门
java·maven
cj6341181506 小时前
SpringBoot配置Redis
java·后端
用坏多个鼠标6 小时前
Nacos和Nginx集群,项目启动失败问题
java·开发语言
满天星83035776 小时前
【C++】右值引用和移动语义
开发语言·c++·redis·visual studio