GoLang 学习(二)

Go 语言进阶(并发编程)

Goroutine(协程)

核心逻辑对比:

  • 线程(OS Thread):由操作系统内核管理,切换成本高(毫秒级),初始栈空间大(通常1-2MB)

  • Goroutine:由 go 运行是调度,切换成本低(纳秒级),初始栈空间仅2KB,且能根据需要动态伸缩

  • M:N 调度:go 内部实现了一个调度器,它将 M 个 Goroutine 映射到 N 个内核线程上。这意味着可以在一台普通pc上轻松开启10w 个Goroutine
    tip:进程、线程、协程的区别

  • 进程;像一个独立的应用(自己有独立的内存、互相隔离、开销大)

  • 线程:像进程里面的一条执行线(共享同一个进程的内存、比进程低、还是由操作系统调度)

  • 协程:像线程里面更小、更轻的任务(非常轻、可以开很多很多、有go自己调度、通常比进程更省资源)

开启 Goroutine

在 go 里开启并发只需要一个关键字:go

主协程启动后,子协程会并发运行;但如果主协程先结束,整个程序就退出,所以要花一点时间等子协程执行完(主协程和子协程谁先完并不知道)
img
tip:非阻塞:调用 go sayHelo( )后,程序不会等待函数执行完,而是立即执行下一行

并发同步:WaitGroup

这本质上就是一个原子计数器,用来知道什么时候所有子任务都跑完了
img

Channel (通道)

“不要通过共享内存来通信,而要通过通信来共享内存。” Channel 就像是一根带有类型约束的管道,确保数据在协程之间安全、有序地流动。

无缓冲通道

Channel 的默认形式,容量为0

核心逻辑:握手机制

无缓冲通道的发送和接受是同步

  • 发送方阻塞:直到有另一个协程来接受
  • 接收方阻塞:直到有另一个协程来发送
    tip:这种特性常用于协程间的强同步,确保一个任务完成后另一个才开始
    img

有缓冲通道

在创建时指定容量,容量大于0的通道就像一个先进先出的队列

核心逻辑:解耦

  • 发送方:只要缓冲区没满,发送操作就是非阻塞的
  • 接收方:只要缓冲区不空,接收操作就是非阻塞的
    tip:这种特性非常适合处理 生产者-消费者模型,或者应对瞬时流量洪峰
    img

单向通道

如果希望限制某个函数对通道的操作权限(一个下载器只能"写"通道,一个解析器只能"读"通道)

核心逻辑:类型安全

  • chan<- T:只写通道(只能发送数据到通道)
  • <-chan T:只读通道(只能从通道接受数据)
    tip:这种限制通常出现在函数参数中,利用编译器检查来防止误操作
    img

通道的“开关”管理

核心规则:

  • 谁先谁关:通常由发送方关闭通道,接收方感知
  • 写已关必崩:向已关闭的通道发送数据会触发 panic
  • 读已关拿零:从已关闭的通道读取会立刻返回该类型的“零值”
    img
  1. 读一个已关闭但还有缓冲数据的通道:还能把缓冲里的数据读出来
  2. 读一个已关闭且空了的通道:返回零值 + false
  3. 继续读也一样,不会 panic

Select

select 会阻塞当前协程,直到其中的某一个 case(通道操作)准备就绪

  • 随机公平性:如果同时有多个 case 准备好了,go 运行时会随机选择一个执行,这不同于 switch 的顺序匹配,目的是为了防止某个通道被“饿死”
  • 阻塞性:如果没有任何 case 准备好,且没有 default 分支,select 会一直阻塞。
  • 单次执行:一个 select 语句执行一次就结束。如果要持续监听,必须把它放进 for 循环里

基础用法:多路复用

监听多个通道,谁先有数据就处理谁
img

超时控制

在开发中,如果 API 响应极其不稳定,可以利用 select 配合 time.After 实现秒级熔断
img

非阻塞通信

默认情况下,往通信发数据或收数据都会阻塞。如果想尝试,如果不行就立刻干别的,就需要 default
img

退出信号

一个主协程可能开启了多个子协程。当主任务结束时,需要通知所有子协程优雅退出
img

tip:在 select 中,如果一个 case 的通道值是 nil,这个 case 永远不会被选中。这可以用来在运行期动态地屏蔽掉某个分支。

tip:空 select 的死锁:如果你写一个 select {},它不包含任何 case,该协程会永久阻塞

sync 包的介绍

虽然 Go 强烈推荐 Channel,但在某些极致追求性能的底层场景,或者简单的状态同步时,传统的锁机制依然是不可或缺的

Mutex(互斥锁)

最基础的锁,同一时刻只允许一个协程进入临界区

核心逻辑:保护共享资源:

如果多个协程同时写一个变量,会导致数据竞态。Mutex 确保了操作的原子性
img
tip:样例中的锁的作用是:保证多个 goroutine 同时修改 count 时,不会把数据改乱。 可以尝试把锁去掉,然后你会发现答案基本每次都不同,因为并发下是不稳定的

RWMutex(读写锁)

在很多场景中,我们是“读多写少”,如果用普通的 Mutex,多个读取者也会互相阻塞,这在逻辑上是没必要的性能损耗。

核心逻辑:多读单写

  • 读锁:允许多个协程同时持有,只要没有写锁
  • 写锁:排他的。一旦有人在写,其他人都不能读也不能写
    img

WaitGroup(计数信号量)

在协程那一章提过,它是等待一组协程结束的利器。

核心逻辑:引用计数

  • Add( ):计数器加 n
  • Done( ):计数器减 1
  • Wait( ):阻塞直到计数器为 0
    tip:不要在协程内部执行 Add,因为协程启动有延迟,可能主协程直接跑到了 Wait 而子协程还没来得及 Add。

Once(单次执行)

解决无论多少个协程并发调用,这个初始化逻辑只执行一次的问题

核心逻辑:原子性初始化

sync.Once 内部有一个状态位和一把锁,保证了 Do(func) 里的函数在全球范围内只跑一遍
img

Cond(条件变量)

这是 sync 包里相对冷门但逻辑最复杂的组件。它通常用于:一个协程在等待某个条件达成,而另一个协程在达成条件后发出通知。

核心逻辑:Wait 和 Signal

Cond 必须配合 Locker(通常是 Mutex)使用

  • Wait():释放锁并挂起协程,等待通知。被唤醒时会自动重新拿锁
  • Signal():唤醒一个正在等待的协程
  • Broadcast():唤醒所有正在等待的协程
    img

tip:锁的范围越小性能越好,但逻辑复杂度会指数级上升

tip:Go 运行时有简单的死锁检测。如果你所有的协程都在 Lock 处互相等待,程序会崩溃报错

tip:

锁是面向内存地址的:适合简单的状态同步(计数、开关)
Channel 是面向逻辑流的:适合数据的加工、传递、解耦

Context(上下文控制)

如果开启了一个复杂的线程树,想要在中途瞬间关闭整个子树的所有线程,context包提供了一套标准的“自杀”和“超时”机制

Context 的核心逻辑:树状传播

Context 是一个树状结构。当创建一个子 Context 时,它会继承父级的状态。

  • 向下传播:如果父级 Context 被取消,所有由它派生出来的子级、孙级协程都会收到取消信号。
  • 向上隔离:子级的取消不会影响父级

常用的操作全家桶

  • 根节点:Background 与 TODO
    • context.Background():通常作为整个请求的最顶层,代表一个空的上下文。
    • context.TODO():当你还不确定要用哪个 Context 时占位用。
  • WithCancel:手动开关
    • 返回一个副本和 cancel 函数。调用 cancel() 后,所有监听该 Context 的协程都会收到信号。
    • img(ctx) 是调用匿名函数时传入的参数,前面的 ctx context.Context 是函数定义的参数列表。(定义的同时,马上把 ctx 传进去执行)
      tips:
      ctx:一个“可取消”的上下文
      cancel:一个专门用来取消这个ctx的函数
  • WithTimeout / WithDeadline:自动熔断
    • img
  • WithValue:跨协程传值
    • imgtip:不要用它传业务大数据!它不安全,且查找效率是 $O(n)$
  • Context 的四大使用军规
    准则 逻辑理由
    函数首参数 约定俗成。第一个参数永远写 ctx context.Context。
    不要存储在结构体中 应该显式传递,保证生命周期随函数调用结束。
    必须手动调用 cancel 否则会导致子 Context 挂载在父级下,产生内存泄漏。
    传递的是接口 Context 是接口,底层是线程安全的。

tip:<-ctx.Done() 的底层其实返回的是一个 只读 Channel (<-chan struct{})。当 cancel() 被调用时,Go 运行时会关闭这个 Channel。

并发安全

在多线程环境下,最怕的就是 Data Race(数据竞态)。Go 提供了三种逻辑维度的保护方案

原子操作

如果并发安全逻辑只是为了保护一个简单的计数器(如 int64),用 Mutex 就太重了。sync/atomic 直接利用 CPU 指令集保证操作的不可分割性。

核心逻辑:

  • 效率极高:它不需要上下文切换,直接在硬件层面锁定缓存行。
  • 适用场景:全局状态位、简单的自增 ID。
    img

传统锁机制

当需要保护的是一整块复杂的逻辑(比如修改一个结构体、往 Map 里存数据)时,就必须划定临界区。

核心逻辑:

  • 锁定范围:锁定的代码块越小,并发性能越好。
  • 逻辑补丁:Go 的 map 是并发不安全的!多个协程同时写 map 会直接抛出致命错误 fatal error: concurrent map writes
    img

通信机制

如果数据只在一个协程里处理,就不存在安全问题。可以通过 Channel 将数据的所有权“移交”给下一个协程。

核心逻辑:所有权转移

把复杂的逻辑串行化。比如 10 个协程负责抓取网页,1 个协程负责把结果存入数据库。通过 Channel 传递,数据库协程成了唯一的写者,逻辑自然安全。

竞态检测器

Go 在语言工具链里内置了检测器。当运行测试或程序时,加上 -race 参数
img
tip:如果代码中存在哪怕万分之一概率的并发读写冲突,Go 会直接输出:
1. 冲突的变量地址
2. 发生读操作的协程栈
3. 发生写操作的协程栈

posted @ 2026-04-29 10:48  Lunor  阅读(10)  评论(0)    收藏  举报