Go的并发(协程)

文章目录

进程,线程,协程

进程:内存与资源的隔离单位,切换贵,安全性最高。

线程: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()
}
相关推荐
米花町的小侦探5 小时前
Ubuntu安装多版本golang
linux·ubuntu·golang
Dev7z5 小时前
基于MATLAB小波分析的图像压缩算法研究与仿真实现
开发语言·matlab
枫叶丹45 小时前
【Qt开发】Qt窗口(七) -> QColorDialog 颜色对话框
c语言·开发语言·c++·qt
froginwe115 小时前
CSS 选择器
开发语言
海上飞猪5 小时前
【Python】JSON的基本使用-JSON 模式(Schema)与数据解析
开发语言·python·json
问道飞鱼5 小时前
【开发语言】Rust语言介绍
开发语言·后端·rust
IMPYLH5 小时前
Lua 的 setmetatable 函数
开发语言·笔记·后端·游戏引擎·lua
风象南5 小时前
Spring Boot实现文件访问安全
后端
综合热讯5 小时前
远健生物宣布“重生因子 R-01”全球首创研发成功 细胞炎症逆转方向实现里程碑式突破
开发语言·人工智能·r语言