【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 杭州西湖畔

相关推荐
e***74952 分钟前
SpringBoot项目集成ONLYOFFICE
java·spring boot·后端
T***16078 分钟前
JavaScript打包
开发语言·javascript·ecmascript
qq_336313939 分钟前
java基础-常用的API
java·开发语言
百锦再14 分钟前
第21章 构建命令行工具
android·java·图像处理·python·计算机视觉·rust·django
极光代码工作室20 分钟前
基于SpringBoot的校园招聘信息管理系统的设计与实现
java·前端·spring
道一2326 分钟前
C# 读取文件方法介绍
开发语言·c#
蒋星熠26 分钟前
常见反爬策略与破解反爬方法:爬虫工程师的攻防实战指南
开发语言·人工智能·爬虫·python·网络安全·网络爬虫
是店小二呀28 分钟前
在家搭个私人影院?LibreTV+cpolar,随时随地看片自由
开发语言·人工智能
未若君雅裁30 分钟前
斐波那契数列 - 动态规划实现 详解笔记
java·数据结构·笔记·算法·动态规划·代理模式