文章目录
- 进程,线程,协程
- 协程
- 并发
- channel(管道)
- select
- WaitGroup
- Context
- 锁
-
- [互斥锁 Mutex](#互斥锁 Mutex)
- [读写锁 RWMutex](#读写锁 RWMutex)
进程,线程,协程
进程:内存与资源的隔离单位,切换贵,安全性最高。
线程:CPU 调度单位,共享内存,可并行,切换中等。
协程:用户态轻量任务,主动让出,不阻塞线程,切换极低
在 Go 里可以这样记:
进程:你的 Go 程序,对应一个 OS 进程。
线程(内核态调度):Go runtime 维护的 M(OS 线程),由操作系统调度,在用户态/内核态之间切换。
goroutine(用户态调度):跑在这些线程上的 G,由 Go runtime 在 用户态切换。
而:
用户态:你写的 Go 代码 + Go 调度器在正常跑的时候,都在用户态。
内核态:你代码里做 I/O、系统调用时,才"跳进内核"让 OS 帮你做事情。
可能说到这里你还是不理解什么是协程,说实话,在笔者看来,协程这个名字起的并不是很好,并且带有误解性,让人误以为是和线程类似的东西,其实应该叫做 "线程并发执行最小任务单元",这样就好理解了:
goroutine(协程) 是 Go 的轻量任务单元
协程的真正执行必须依附在线程(M)上。
Go runtime(可以理解为java的jvm) 的调度器(P)在用户态把多个 协程 安排到少量线程上轮流执行,因此大量 协程 看起来像同时执行
所以总结起来就是:
goroutine 是 Go 的轻量任务单元,由 Go runtime 在用户态调度到有限的线程上轮流执行,因此看起来像"同时执行"。真正能并行的只有线程。
协程
在go中开启协程就是在一个函数后面加上go就可以,具有返回值的内置函数不允许跟随在go关键字后面,注意是内置函数,自定义的有返回值的函数可以
go
func main() {
go sum(1, 2)
}
func sum(a, b int) int {
return a + b
}
可能开不到任何输出,是因为,主协程结束了,子协程还没有开始调度执行
但是其中发了什么事,笔者这里详细解答写:
第一步:main goroutine(协程) 运行在某个 OS 线程(比如 M1)上
所以 main () 正在 M1 线程上运行。
第二步:当执行到了:go sum(1, 2)
Go 做的事情是:
1.创建一个新的 G(goroutine 对象),把这个 G 放进调度器的"可运行队列",可能是当前 P 的本地队列,也可能放 全局队列,不会立即执行,不会创建新的 OS 线程,它只是"被排队等待执行"。
第三步:哪个线程执行它?
情况 A:当前线程(M1)空闲了,M1 可能就直接拿这个 goroutine(协程) 来执行。
情况 B:当前线程正在忙,Go 可能把 sum(1,2) 分配给另一个线程:G(main) 在 M1,G(sum) 可能跑在 M2
所以:
goroutine 执行在哪个 OS 线程上,是由 Go runtime 的调度器(P)决定的,而不是由 main goroutine 决定的。
并发
协程可以并发,那多个协程的执行就是无序的且由线程调度执行,那么就会存在类似于Java中JUC并发工具包一样的东西:
channel:管道:更适合协程间通信
WaitGroup:信号量:可以动态的控制一组指定数量的协程
Context:上下文:更适合子孙协程嵌套层级更深的情况
对于较为传统的锁控制,Go也对此提供了支持:
Mutex:互斥锁
RWMutex :读写互斥锁
channel(管道)
一种在协程间通信的解决方案,同时也可以用于并发控制
声明和定义:
go
func main() {
// 声明
var ch chan int
// 声明并定义:没有缓冲
ch2 := make(chan int)
// 声明并定义:有缓冲
ch3 := make(chan int, 1)
fmt.Println(ch, ch2, ch3)
}
读写并关闭
go
func main() {
// 声明并定义:有缓冲
ch2 := make(chan int, 1)
//函数返回前关闭ch2
defer close(ch2)
// 将11 写入 ch2
ch2 <- 11
// 将ch2中的数据 传输到 tmp,ok是bool 表示是否写入成功
tmp, ok := <-ch2
fmt.Println(tmp, ok)
}
无缓冲
就是make中不写第二个参数
因为缓冲区容量为0,所以不会临时存放任何数据,在向管道写入数据时必须有其他协程来读取数据,否则就会阻塞等待
go
func main() {
// 声明并定义:有缓冲
ch2 := make(chan int)
defer close(ch2)
ch2 <- 11
tmp, ok := <-ch2
fmt.Println(tmp, ok)
}
上述例子就会报错:fatal error: all goroutines are asleep - deadlock!,根本就执行不到tmp, ok := <-ch2这一行,代码会阻塞在 ch2 <- 11这一行,导致了异常
写成下面这样就可以了:
go
func test33() {
ch2 := make(chan int)
defer close(ch2)
go func() {
ch2 <- 11
}()
tmp, ok := <-ch2
fmt.Println(tmp, ok)
}
主协程,开启一个子协程,接着子协程在ch2 <- 11这一步阻塞,接着主协程立刻读取了:tmp, ok := <-ch2,所以就没有问题
有缓冲
管道有缓冲则就类似于java的阻塞队列一样
对于有缓冲管道写入数据时,会先将数据放入缓冲区里,只有当缓冲区容量满了才会阻塞的等待协程来读取管道中的数据。
读取有缓冲管道时,会先从缓冲区中读取数据,直到缓冲区没数据了,才会阻塞的等待协程来向管道中写入数据
go
func test34() {
// 创建有缓冲管道,注意容量长度1是指可以存一个整型数据,不是指字节等容量
ch := make(chan int, 1)
defer close(ch)
// 写入数据
ch <- 123
// 读取数据
n := <-ch
fmt.Println(n)
}
上面的代码可以直接运行不会阻
还可以像下面一样弄成一个阻塞队列:
go
func test35() {
ch1 := make(chan int, 10)
go func() {
for _, value := range []int{1, 2, 3, 4, 5, 6, 7, 8, 9} {
time.Sleep(1000 * time.Millisecond)
ch1 <- value
}
}()
go func() {
for {
fmt.Println(<-ch1)
}
}()
time.Sleep(10 * time.Second)
}
上面的例子的运行过程:
先开启一个协程往ch1里面放东西,在开启一个协程消费ch1里面的东西,会发现时按照顺序输出的。
下面会导致panic:
关闭一个nil管道
写入已关闭的管道
关闭已关闭的管道
单向通道
想象 channel 是一个水管:
双向管道 = 你既能往里倒水,又能从另一端接水
只读通道 = 这个端只能出水(你不能往里倒)
只写通道 = 这个端只能倒水进去(但不能接)
单向通道不是新的通道,而是给普通通道加上"权限限制"。
<-chan T:只能读
chan<- T:只能写
这样可以防止错误使用,让程序更安全。
go
func main() {
ch := make(chan int)
// 启动生产者(只写)
go producer(ch)
// 启动消费者(只读)
go consumer(ch)
time.Sleep(2 * time.Second)
}
// 生产者:只写通道 chan<- int
func producer(out chan<- int) {
for i := 1; i <= 3; i++ {
fmt.Println("生产:", i)
out <- i // 只能写,不允许读
}
close(out) // 只能在写端关闭
}
// 消费者:只读通道 <-chan int
func consumer(in <-chan int) {
for v := range in { // 只能读,不允许写
fmt.Println("消费:", v)
}
}
管道关闭的时机:应该尽量在向管道发送数据的那一方关闭管道,而不要在接收方关闭管道,因为大多数情况下接收方只知道接收数据,并不知道该在什么时候关闭管道。
select
select 就是在同时监听多个 channel。谁先有数据,就执行谁。
go
func test37() {
ch1 := make(chan string)
ch2 := make(chan string)
// ch1 会在 1 秒后发送
go func() {
time.Sleep(2 * time.Second)
ch1 <- "来自 ch1 的数据"
}()
// ch2 会在 2 秒后发送
go func() {
time.Sleep(1 * time.Second)
ch2 <- "来自 ch2 的数据"
}()
select {
case msg := <-ch1:
fmt.Println("收到:", msg)
case msg := <-ch2:
fmt.Println("收到:", msg)
}
}
默认值
go
select {
case msg := <-ch1:
fmt.Println(msg)
case msg := <-ch2:
fmt.Println(msg)
default:
fmt.Println("现在两个 channel 都没数据")
}
"超时机制"
go
select {
case msg := <-ch:
fmt.Println("收到消息:", msg)
case <-time.After(3 * time.Second):
fmt.Println("等了 3 秒,没有消息,超时")
}
ch 有数据 → 立即执行第一个 case
ch 3 秒内没数据 → time.After 自动触发 → 打印"超时"
for+select+timeout:循环监听 + 超时退出
例:
go
for {
select {
case msg := <-chA:
fmt.Println("收到 A:", msg)
case msg := <-chB:
fmt.Println("收到 B:", msg)
case <-time.After(time.Second * 2):
fmt.Println("2 秒内没消息,退出监控")
return
}
}
WaitGroup
WaitGroup通常适用于可动态调整协程数量的时候,例如事先知晓协程的数量,又或者在运行过程中需要动态调整。
go
func test38() {
var wg sync.WaitGroup
wg.Add(3) // 要等三个孩子
go func() {
fmt.Println("大儿子:作业写完了!")
wg.Done()
}()
go func() {
fmt.Println("二儿子:作业写完了!")
wg.Done()
}()
go func() {
fmt.Println("三儿子:作业写完了!")
wg.Done()
}()
wg.Wait() // 妈:等你们三个
fmt.Println("可以吃饭啦!")
}
Context
Context时一个接口:里面有4个方法
go
// Context 是 Go 用来控制协程生命周期的接口:
// 它能提供取消通知、超时控制、错误原因和轻量级数据传递。
type Context interface {
// Deadline 返回 Context 的截止时间(若有设置)。
// deadline:任务最晚完成时间
// ok:是否设置了截止时间(false 表示没有设置,不会自动超时)
Deadline() (deadline time.Time, ok bool)
// Done 返回一个只读 channel,当 Context 被取消或超时后会被关闭。
// 协程可以通过 <-ctx.Done() 来获知"任务应该停止"。
// 如果 Context 不支持取消,则可能返回 nil。
Done() <-chan struct{}
// Err 返回 Context 被取消的具体原因。
// 当 Done() 未关闭时,返回 nil(表示一切正常)。
// Done() 关闭后:
// - 手动取消返回 context.Canceled
// - 超时返回 context.DeadlineExceeded
Err() error
// Value 根据 key 获取存储在 Context 中的轻量级数据。
// 一般用于传递请求级别的小信息(如用户ID、traceID)。
// 如果 key 不存在或 Context 不支持 Value,则返回 nil。
Value(key any) any
}
context标准库提供了几个实现,分别是:
emptyCtx
cancelCtx
timerCtx
valueCtx
emptyCtx
emptyCtx = 最原始、最基础、永远不会取消、没有值、没有超时的上下文。
它是所有其他 Context 的 根节点(父亲)。
把 Context 想成家庭树:
emptyCtx = 老祖宗
其他 Context(WithCancel、WithDeadline、WithValue)= 老祖宗的子孙
整个 Context 功能都是从祖宗延伸出来的
emptyCtx 的特点:
老祖宗不工作
老祖宗不会发取消命令
老祖宗没有截止时间
老祖宗没有携带信息
但是:
没有老祖宗,就没有整棵树
理解成一棵树的root节点即可
valueCtx
valueCtx 就是一个能在协程链路上传递 key-value 的小背包。只能存 一对 key/value
如果当前找不到 → 会继续到"父 context"找(像链式查找,"向上找爸爸")
valueCtx 不能取消、不能超时,所以 Done() 永远是 nil。valueCtx = 每个协程手里的"传话小纸条",谁都能往下传。
cancelCtx
cancelCtx 用来发取消信号(手动叫停)。
WithCancel 会创建一个 cancelCtx,当你调用 cancel() 时:
Done() channel 会关闭,所有子协程都会收到"要停了"的信号,Err() 返回 context.Canceled
cancelCtx = 父亲拍一下桌子(cancel),所有儿子(子协程)马上停。
timerCtx
timerCtx = cancelCtx + 定时器功能
自动取消场景:超过指定时间自动停止,数据库超时,HTTP 请求超时,RPC 超时
timerCtx = 给你开了一个倒计时闹钟,时间到自动喊你停。
context这里笔者也只能给出一些概念性的解释,因为笔者这里也是初学,没有在实际业务中用到过,后面用到了,再补上用例
锁
互斥锁 Mutex
不加锁:
go
var count = 0
func main() {
go add()
go add()
time.Sleep(time.Second)
fmt.Println("最终:", count)
}
func add() {
tmp := count
time.Sleep(time.Millisecond) // 模拟耗时
count = tmp + 1
}
多次输出可能为1
加锁后:
go
var (
count int
lock sync.Mutex
)
func main() {
go add()
go add()
time.Sleep(time.Second)
fmt.Println("最终:", count)
}
func add() {
lock.Lock() // 上锁(别人不能修改 count)
tmp := count
time.Sleep(time.Millisecond)
count = tmp + 1
lock.Unlock() // 解锁
}
输出为2
读写锁 RWMutex
go
var (
data = 0
rw = sync.RWMutex{}
)
func main() {
// 启动 3 个读协程
for i := 0; i < 3; i++ {
go read(i)
}
// 启动 1 个写协程
go write()
time.Sleep(time.Second * 2)
}
func read(id int) {
rw.RLock() // 加读锁
fmt.Println("读协程", id, "读取到:", data)
time.Sleep(time.Millisecond * 300)
rw.RUnlock()
}
func write() {
time.Sleep(time.Millisecond * 100)
rw.Lock() // 加写锁
data++
fmt.Println("写协程修改 data 为:", data)
time.Sleep(time.Millisecond * 300)
rw.Unlock()
}