Go 并发

Go 并发

goroutine

介绍

goroutine 是由Go运行时(runtime)负责调度的、轻量的用户级线程。可以实现更高的并发性能。

基本概念:

  • 极致轻量:初始栈仅 2KB(动态可伸缩),会根据需要自动扩容(最大可达 GB 级别),(OS 线程栈通常是固定的 MB 级别)。
  • 用户态调度 :由 Go 运行时的 G-M-P 调度器管理,而非直接由操作系统调度,调度开销远低于 OS 线程,能更高效地利用 CPU 资源
  • 简单易用: 由go关键字创建,goroutine 执行完毕后,其占用的资源会被运行时自动回收,无需手动管理;
  • 安全通信 :推荐通过 channel 实现 goroutine 间的安全通信,也可通过共享内存 + 同步原语实现,但需手动处理并发安全问题。

使用示例

go 复制代码
package main

import (
	"fmt"
	"time"
)

var eggCount = 0

func cook(name string) {
	for i := 0; i < 1000; i++ {
		tmp := eggCount
		tmp++
		eggCount = tmp
	}
	fmt.Printf("厨师%s操作完成\n", name)
}

func main() {
	// 启动两个goroutine(两个厨师)同时操作共享数据
	go cook("A")
	go cook("B")
	// 等待goroutine执行完成
	time.Sleep(1 * time.Second)
	// 预期结果是 2000,但实际运行会得到小于2000的随机数(比如1897、1956等)
	fmt.Printf("最终鸡蛋数:%d\n", eggCount)
}

分析:

  1. eggCount 是共享内存数据,所有 goroutine 都能访问
  2. cook 函数里的 temp := eggCount → temp++ → eggCount = temp 是三步操作,不是 "一次性完成" 的
  3. 两个 goroutine 执行时,会互相打断对方的操作,导致最终结果错误

解决:进行加锁操作 ,保证证共享数据的原子性访问(要么不执行,要么执行完,不被打断)

优化后代码

复制代码
package main

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

var eggCount = 0
// 定义一个互斥锁,保护eggCount的访问
var mu sync.Mutex

func cook(name string) {
	for i := 0; i < 1000; i++ {
	// 加锁:同一时间只有一个goroutine能进入这个代码块
		mu.Lock()
		// 临界区:操作共享数据的代码
		tmp := eggCount
		tmp++
		eggCount = tmp
		// 解锁:释放锁,让其他goroutine可以进入
		mu.Unlock()
	}
	fmt.Printf("厨师%s操作完成\n", name)
}

func main() {
	// 启动两个goroutine(两个厨师)同时操作共享数据
	go cook("A")
	go cook("B")
	// 等待goroutine执行完成
	time.Sleep(1 * time.Second)
	// 预期结果是 2000,但实际运行会得到小于2000的随机数(比如1897、1956等)
	fmt.Printf("最终鸡蛋数:%d\n", eggCount)
}

由于一个应用内部启动的所有 goroutine 共享进程空间的资源 ,多个 goroutine 同时读写同一块内存,会导致操作步骤被打断,结果不符合预期,出现数据竞争(Race Condition)。

只要多个 goroutine 操作同一块可写的内存,就必须考虑同步;要么加锁 ,要么用 channel 传递数据。

goroutine 间的通信 - channel

Channel 是 Go 实现 CSP 并发模型的核心载体 ,本质是并发安全的 FIFO 通信管道 ,用于 goroutine 间传递数据 而非共享内存,其内部通过锁机制和原子操作保证并发安全 ,保证每个写入的数据单元只会被一个 goroutine 完整读取

核心定义与创建

Channel 是类型化的通信管道,需指定传递的数据类型,分为无缓冲带缓冲两种:

go 复制代码
// 1. 声明(未初始化的channel为nil,操作会永久阻塞)
var ch chan int // nil channel

// 2. 初始化
ch1 := make(chan int)       // 无缓冲channel(同步通信,缓冲区大小=0)
ch2 := make(chan int, 5)    // 带缓冲channel(异步通信,缓冲区大小=5)

channel 方向:函数参数中声明只读(<-chan T )/ 只写(chan<- T),增强代码可读性和安全性

核心特性(并发安全的底层保障)

  • 原子性操作:channel 的发送(ch <- v)和接收(v := <-ch)操作是原子的,不会出现 "数据被拆分读取 / 写入" 的情况。
  • 内部锁机制:底层通过互斥锁保证同一时间只有一个 goroutine 能完成发送 / 接收操作,天然避免数据竞争(如多个 goroutine 读取同一个 channel 时,每个数据块仅被一个 goroutine 取走)
  • FIFO 队列 :数据按照先进先出的顺序被处理

关键使用规则

无缓冲 channel:严格同步通信
  • 特性:发送方和接收方必须同时就绪,一方未就绪则另一方阻塞;
  • 禁忌:禁止在单个 goroutine 中执行发送 + 接收操作,会直接导致死锁
  • 正确用法:至少两个 goroutine 配合实现严格同步
  • 原理:主要依赖原子操作 实现发送 / 接收的配对,无需互斥锁
go 复制代码
// 正确示例:生产者+消费者goroutine配对
func main() {
    ch := make(chan int)
    // 生产者goroutine
    go func() {
        ch <- 1 // 等待消费者就绪后发送
    }()
    // 消费者goroutine(主goroutine)
    fmt.Println(<-ch) // 等待生产者就绪后接收
}
带缓冲 channel:弹性异步通信
  • 特性:缓冲区未装满时,发送方无需等待接收方;缓冲区未空时,接收方无需等待发送方;
  • 阻塞规则:
    ✅ 缓冲区满 → 发送操作阻塞,直到消费者取走数据腾出空间;
    ✅ 缓冲区空 → 接收操作阻塞,直到生产者发送数据;
  • 适用场景:生产 / 消费速度不匹配(如任务队列、IO 异步处理),需合理设置缓冲大小(过小易频繁阻塞,过大浪费内存)。
  • 原理:因涉及缓冲区(环形队列)的读写,底层会用到互斥锁(mutex) 保护缓冲区的状态(如长度、头尾指针),同时结合原子操作保证数据安全
Channel 关闭:生产者唯一责任
  • 核心原则:生产者负责关闭 channel(生产者明确生产结束时机),消费者仅读取,禁止关闭(否则 panic);
  • 禁止操作:
    ❌ 关闭已关闭的 channel → panic;
    ❌ 向已关闭的 channel 发送数据 → panic
go 复制代码
// 生产者关闭channel,消费者通过range/ok判断结束
func producer(ch chan<- int) {
    for i := 0; i < 3; i++ {
        ch <- i
    }
    close(ch) // 生产完成,关闭channel
}

func consumer(ch <-chan int) {
    // 方式1:range遍历(自动感知channel关闭)
    for v := range ch {
        fmt.Println("接收:", v)
    }
    // 方式2:ok判断(v为数据,ok=false表示channel关闭且无数据)
    for {
        v, ok := <-ch
        if !ok {
            break
        }
        fmt.Println("接收:", v)
    }
}
多 goroutine 读取同一 channel(无数据竞争)

channel 天然支持多消费者并发读取,内部锁保证数据仅被一个 goroutine 取走,无需额外同步

go 复制代码
package main

import "fmt"

type add struct {
	a int
	b int
}

func worker(sum add, results chan<- int) {
	results <- sum.a + sum.b
}

func main() {
	num := add{1, 2}
	results := make(chan int, 1)
	go worker(num, results)

	//  channel 是一个引用类型,打印的是channel的内存地址
	fmt.Println(results)

	// 从 channel 中读取实际的计算结果
	sum := <-results
	fmt.Println(sum)
}

0xc00001c1c0
3

先发送所有数据,关闭channel,启动一个 worker 处理所有数据

go 复制代码
package main

import (
	"fmt"
)

type Animal struct {
	A int
	B int
}

func worker(animal <-chan Animal, results chan<- int) {
	for a := range animal {
		sum := a.A + a.B
		results <- sum
	}
}

func main() {
	const jobNums = 10
	animal1 := make(chan Animal, jobNums)
	results := make(chan int, jobNums)

    // 先发送所有数据
	for i := 0; i < jobNums; i++ {
		animal1 <- Animal{A: 3 * i, B: 6 * i}
	}
    // 发送完数据后关闭 channel
	close(animal1)
    // 启动一个 worker 处理所有数据
	go worker(animal1, results)
    // 接收所有结果
	for i := 0; i < jobNums; i++ {
		fmt.Println("result", <-results)
	}
}

多 worker 并发处理

go 复制代码
package main

import (
	"fmt"
	"sync"
)

type Animal struct {
	A int
	B int
}

func worker(id int, animal <-chan Animal, results chan<- int, wg *sync.WaitGroup) {
	defer wg.Done() // goroutine结束就登记-1
	//worker 通过 for range 循环监听任务到来
	for a := range animal {
		sum := a.A + a.B
		fmt.Printf("Worker %d processed: A=%d, B=%d, sum=%d\n", id, a.A, a.B, sum)
		results <- sum
	}
}

func main() {
	const jobNums = 10
	animal1 := make(chan Animal, jobNums)
	results := make(chan int, jobNums)
	var wg sync.WaitGroup

	// 先启动 worker
	//用少量 worker 处理大量任务,避免频繁创建/销毁 goroutine
	numWorkers := 3
	for i := 0; i <= numWorkers; i++ {
		wg.Add(1)
		go worker(i, animal1, results, &wg)
	}

	// 发送所有数据
	for i := 0; i < jobNums; i++ {
		animal1 <- Animal{A: 3 * i, B: 6 * i}
	}
	// 关闭channel
	close(animal1)
	// 启动一个 goroutine 来关闭 results channel
	go func() {
		wg.Wait() // 等待所有登记的goroutine都结束
		close(results)
	}()

	for i := 0; i < jobNums; i++ {
		fmt.Println("result", <-results)
	}
}

ps :
执行顺序 很重要:通常先启动消费者 (worker),再生产数据

Worker 数量 ≠ 任务数量:worker 是并发处理器,一个 worker 可以处理多个任务

Channel 关闭时机:关闭 channel 是给 worker 的"任务完成"信号

CSP

CSP 一种基于通信 的并发模型

核心思想:不是通过共享内存来实现并发协作,而是通过独立的并发实体(进程/协程)之间的通信 来传递数据,协调行为。
不要通过共享内存通信,要通过通信共享内存

核心要素:

  • goroutine :对应 CSP 中的 "并发实体"(轻量级协程,替代了 CSP 中的进程 / 线程)
  • channel :对应 CSP 中的 "通信管道",是 goroutine 之间唯一的通信方式,数据通过 channel 传递,而非直接共享。
go 复制代码
package main

import (
	"fmt"
	"sync"
)

func produce(ch chan<- int, wg *sync.WaitGroup) {
	defer wg.Done()
	for i := 0; i < 10; i++ {
		ch <- i
		fmt.Println("生产:", i)
	}
	close(ch)
}

func consuse(ch <-chan int, wg *sync.WaitGroup) {
	defer wg.Done()
	for num := range ch {
		fmt.Println("消费:", num)
	}

}

func main() {
	// 无缓冲区,保证生产一个,消费一个
	ch := make(chan int)
	var wg sync.WaitGroup
	wg.Add(2)
	go produce(ch, &wg)
	go consuse(ch, &wg)
	wg.Wait()
	fmt.Println("结束")
}

生产者和消费者模型

  1. 生产者和消费者是两个独立的 goroutine(CSP 并发实体)
  2. 数据(数字 1-10)不是通过共享变量传递,而是通过 channel(通信管道)
  3. 无缓冲 channel 保证了 "生产一个、消费一个" 的同步,无需手动加锁,体现 CSP 思想
Go 中 channel 的缓冲和无缓冲,在 CSP 模型中分别对应什么场景?

无缓冲 channel :对应 CSP 的 "同步通信 "------ 发送方和接收方必须同时就绪,一方未就绪则另一方阻塞,适合需要严格同步的场景(比如上面的生产者 - 消费者 ,确保生产和消费一一对应);
有缓冲 channel :对应 CSP 的 "异步通信 "------ 发送方可以先把数据放入缓冲区,无需等待接收方立即接收,适合 "生产速度略快于消费速度" 的场景(比如任务队列,生产者快速提交任务,消费者慢慢处理)

使用场景限制

适合场景 : goroutine之间需要传递数据,协调执行顺序的场景(比如生产者 - 消费者、任务分发、流水线处理
不适用场景

  1. 高频读写的共享状态 (比如计数器):用 channel 传递数据的开销略高于互斥锁,此时用 sync/atomic 原子操作或 sync.Mutex 更高效
  2. 简单的同步等待(比如等待多个 goroutine 完成):用 sync.WaitGroup 比 channel 更简洁
不关闭channel影响

不关闭,不一定会直接导致程序报错,但是可能会引发资源泄露程序逻辑异常。

Go 运行时会在channel被垃圾回收的时候自动清理其资源,关闭channel的核心目的是向接收方传递"数据发送完毕"信号 ,而不是释放资源。

不关闭 channel 的问题,本质是接收方无法感知 "数据已全部发送",进而导致逻辑异常或资源泄漏,而非 channel 本身的内存泄漏。

场景 1. 接收方用 for range 读取 channel

for range ch 会持续读取 channel 中的数据,直到 channel 被关闭且缓冲区为空 。如果不关闭 channel,接收方会一直阻塞在读取操作上,导致 goroutine 永久阻塞(泄漏)

go 复制代码
package main

import (
    "fmt"
    "time"
)

func producer(ch chan int) {
    // 生产者发送3个数据后退出,但不关闭channel
    for i := 1; i <= 3; i++ {
        ch <- i
    }
    // 未执行 close(ch)
}

func consumer(ch chan int) {
    // for range 会一直等待读取,直到channel被关闭
    for v := range ch {
        fmt.Println("收到数据:", v)
    }
    fmt.Println("消费结束") // 这行永远不会执行
}

func main() {
    ch := make(chan int, 3)
    go producer(ch)
    go consumer(ch)

    // 主goroutine等待5秒后退出
    time.Sleep(5 * time.Second)
    fmt.Println("主程序退出,但consumer goroutine仍阻塞")
}
场景 2.接收方用 "带 ok 的读取"(v, ok := <-ch)但无退出逻辑

如果接收方通过 v, ok := <-ch 读取,但没有额外的退出条件,不关闭 channel 会导致接收方陷入 "无效等待"。

go 复制代码
func consumer(ch chan int) {
    for {
        v, ok := <-ch
        if !ok { // 只有channel关闭且缓冲区空,ok才为false
            fmt.Println("通道关闭,退出消费")
            break
        }
        fmt.Println("收到:", v)
    }
}
场景 3.大量创建未关闭的 channel(隐性资源泄漏)

如果程序循环创建 channel,且每个 channel 都有 goroutine 持有引用(即使没有数据传递),不关闭 channel 会导致

  • channel 本身占用的内存(缓冲区、内部锁等)无法被 GC 回收;
  • 持有 channel 引用的 goroutine 永久阻塞,资源泄漏

调度器核心原理

Goroutine 调度器是 Go 运行时(runtime)层面的组件,负责把大量轻量级的 goroutine 高效映射到操作系统的线程 (M,Machine)上执行,核心目标是最大化利用 CPU 核心 ,同时最小化 goroutine 切换开销

核心设计:GMP模型

多线程多处理器模型

核心是将用户态的 goroutine(G)映射到内核态的线程(M)上执行,通过 P 作为中间层解耦,最大化 CPU 利用率。

角色 全称 含义
G Goroutine 轻量级协程,包含执行栈、程序计数器、状态等,是调度的基本单位(用户态),而且 G 对象是可以重用的
M Machine 操作系统线程(内核态),是真正执行代码的 "载体",对应 1:1 的系统线程
P Processor 处理器(调度器核心),是 G 和 M 之间的 "桥梁",代表 "执行上下文",P 的最大作用还是其拥有的各种 G 对象队列、链表、一些缓存和状态

M和P进行有效绑定后,进入一个调度循环
从P的本地运行队列以及全局队列中获取G,切换到G的执行栈上并执行G的函数,调用goexit做清理工作并回到M

sysmon (Go 1.14+)

介绍

Go 1.14 之前是协作式调度,如果 goroutine 死循环、长时间纯计算,会霸占 P 导致其他 goroutine 饿死。

Go 1.14+ 引入了基于 sysmon(系统监控)抢占式调度防止某个 goroutine 死循环、长时间计算,霸占 P 不让其他 goroutine 运行,导致程序卡顿、饿死。

sysmon 是 runtime 里一个后台常驻的系统监控 M,不绑定 P,专门干脏活

  1. 检查网络、锁、timer 是否过期
  2. 检查每个 P 上正在运行的 G 跑了多久
  3. 发现某个 G 跑太久 → 发信号抢占
抢占式调度(Preemptive Scheduling)

当 sysmon 发现:

这个 G 在 P 上连续运行 ≥ 10ms

它会:

  1. 给当前绑定这个 P 的 M 发送一个 SIGURG 信号
  2. M 收到信号,会强制暂停当前 G
  3. 保存 G 的栈、寄存器、程序计数器
  4. 把 G 放回 P 的本地队列
  5. P 立刻换一个新的 G 执行
所有 G 都能被抢占吗?

不是,运行在 g0 栈调度器自身代码 )、系统调用、锁保护的临界区 不能被抢占。

只有运行在用户栈、普通代码的 G 才能被抢占。

抢占会不会影响性能?

几乎不会。

  • 抢占开销极小
  • 10ms 一次,频率很低
  • 换来的是整个程序不卡死、调度公平收益远大于开销。
相关推荐
Reisentyan2 小时前
GoLang Learn Data Day 0
开发语言·rpc·golang
读研的武8 小时前
Golang学习笔记 入门篇
笔记·学习·golang
呆萌很19 小时前
【GO】逻辑运算练习题
golang
zh_xuan1 天前
测试go语言函数和结构体
开发语言·golang
水痕011 天前
go语言里面使用elasticsearch
开发语言·elasticsearch·golang
不会写DN1 天前
golang的fs除了定权限还能干什么?
开发语言·爬虫·golang
风中凌乱1 天前
linux服务器安装部署mayfly-go
linux·服务器·golang
不会写DN1 天前
Go 语言并发编程的 “工具箱”
开发语言·后端·golang
小二·1 天前
Go 语言系统编程与云原生开发实战(第34篇)
大数据·云原生·golang