Go进阶-并发编程详解

170 阅读17分钟

为什么Go语言会适合高并发场景?
Goland实现了CSP(Communicating Sequential Processes)并发模型为并发基础,底层使用子协程goroutine作为并发实体。而子协程非常轻量级,一次可以创建几十万个实体,而实体间通过通道channel匿名传递消息。在语言方面实现自动调度,屏蔽很多内部细节,对外提供简单的语法关键字,大大简化了并发编程的思维转换和管理线程的复杂性。

关于Go的并发编程,分为以下知识点介绍:

  1. 并发和并行
  2. 子协程Goroutine
  3. 协程之间的通信:共享内存方式和channel通道机制方式
  4. channel通道的简要介绍
  5. 由于共享内存方式带来的并发安全
  6. 并发的协程之间的同步

1. 并发 VS 并行

  • 并行:多个任务同时被调度。
  • 并发:时间被切成了一小段一小段时间片,每个时间片调度一个任务,不同任务在不同的时间片被调度,于是人为看起来像是这些任务"并行"发送了。

2. Goroutine子协程

操作系统调度的最小单位是线程,一个线程可以包含多个协程,一个线程内的多个协程可以切换,但一定是串行执行的,协程又被称为“轻量级的线程”,也是“用户态的线程”。

协程优点:

  • 用户态:指协程切换完全在用户态进行,不会陷入内核,开销小。协程的调度完全取决于用户的实现方式,而与操作系统的调度无关。(线程间的切换可能需要频繁实现内核态和用户态之间的切换。)
  • 轻量级:协程通常只需要一个小空间的栈就足够了(eg:128kb)。可以创建更多的协程用于多任务处理。 1675063919(1).png

与线程区别:

  • 内存占用
    创建一个 goroutine 的栈内存消耗为 2 KB,实际运行过程中,如果栈空间不够用,会自动进行扩容。
    创建一个 thread 则需要消耗 1 MB 栈内存,而且还需要一个被称为 “a guard page” 的区域用于和其他 thread 的栈空间进行隔离。
  • 创建和销毁
    Thread 创建和销毀都会有巨大的消耗,因为要和操作系统打交道,是内核级的,通常解决的办法就是线程池。
    goroutine 因为是由 Go runtime 负责管理的,创建和销毁的消耗非常小,是用户级。
  • 切换
    当 threads 切换时,需要保存各种寄存器,以便将来恢复。线程切换一般会消耗 1000-1500 纳秒
    goroutines 切换只需保存三个寄存器:Program Counter, Stack Pointer and BP。一般切换需要200ns。
    goroutines 切换成本比 threads 要小得多

实际开发中开启协程在子协程函数调用前加go,就可以为一个函数创建一个协程来运行。并行时执行顺序不会按照代码顺序来。

3. 协程之间的通信:CSP(Communicating Sequential Processes )

C++,Java,Python等并发逻辑多是基于操作系统的线程,并发执行单元(线程)之间的通信利用的是操作系统提供的线程或进程间通信原语,如:共享内存/信号/管道/消息队列/套接字等. 使用最广泛的是共享内存.

“提倡通过通信共享内存,而不是通过共享内存实现通信”。

  • 通过通信共享内存(提倡):如果一个协程要传输某个数据给另一个协程,该数据被封装为对象,然后将该对象指针传入channel(通道,常用队列实现),另一个协程从channel中读出该指针,处理其指向的内存对象。channel通道给协程做了一个链接,传输队列,遵循先入先出,能保证收发数据的顺序。

  • 通过共享内存实现通信: 通过共享内存实现数据交换,通过互斥量对内存进行加锁,需要获取“临界区”的权限,不同的协程之间容易发生数据冲突的问题,在一定程序上会影响性能。

    临界区:如果程序中的一部分会被并发访问或修改,为了避免并发访问导致的意想不到的结果,这部分程序需要被保护起来,这部分被保护起来的程序就叫做临界区。 临界区是一个被共享的资源,或者说是一个整体的共享资源,比如对数据库的访问,对某个共享数据结构的操作。对一个I/O设备的使用,对一个连接池中的连接的调用等等。

两者对比:
1675050557(1).png

  • 通过共享内存实现通信:
    共享内存:在一个系统中,两个线程或进程都可以读写同一块内存空间。
    优点:
    1. 系统之间不需要做频繁沟通,必要信息就在内存中,想取随时可取
    2. 不会过期,所有数据都是最新的
    3. 传输超大量数据时,不用在内存中拷来拷去, 缺点:数据冲突
    • 数据冲突概念:
      多线程或多进程场景下,多个对象同时访问同一块数据,会导致读写错乱,影响系统正确性。
    • 对抗方法1:
      锁, 多线程算法等。(这些方法要么影响并发性能, 要么对使用场景有要求,要么无法证明其正确性)。
    • 对抗方法2:
      经过多年试错和迭代,"通过通信实现进程/线程间交互"方案脱颖而出。
  • 通过通信实现进程/线程间交互:
    优点:
    1. 逻辑简单清楚,系统高正确性,
    2. 每个协程里面事件都是顺序一致的,不会有多个协程抢占同一个资源现象的发生
    3. 比单纯共享内存要快

4. channel

channel通道实际上是让一个goroutine能发送值到另外一个goroutine的机制。

如果说goroutine是Go程序并发的执行体,channel就是它们之间的连接。channel是可以让一个goroutine发送特定值到另一个goroutine的通信机制。

Go 语言中的通道(channel)是一种特殊的类型。通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。每一个通道都是一个具体类型的导管,也就是声明channel的时候需要为其指定元素类型。

有缓存管道和无缓冲管道

无缓冲通道:发送和接收同步化,也叫同步通道。
有缓冲通道:解决同步化问题,通道容量代表通道中能存放元素,装满则堵塞发送直到有数据被取出。

1675050600(1).png

通道是引用类型通过make关键字创建

  src = make(chan int)     // 创建无缓冲通道
  src1 = make(chan int, 2) // 创建缓冲大小为2的通道

以下是一个简单的协程之间使用channel通信的例子:

    func CalSquare() {
	src := make(chan int) // 无缓冲通道
	dest := make(chan int, 3) // 有缓存通道
        // 协程 A:将0:9按照顺序输入通道src
	go func() {  
		defer close(src)
		for i := 0; i < 10; i++ {
			src <- i
		}
	}()
        // 协程 B:和协程A并发
            // 将通道src中的数据同步取出,并做平方后存入通道dest
	go func() {  
		defer close(dest)
		for i := range src {
			dest <- i * i
		}
	}()
        // 主协程:和协程A,B并发
            // 通道dest中的数据被异步取出
	for i := range dest { 
		println(i)
	}
}

单向管道

在 Go 语言中,有的时候我们会将管道作为参数在多个任务函数间传递,很多时候我们在不同的任务函数中使用管道都会对其进行限制,比如限制管道在函数中只能发送或者只能接收。

var ch1 chan<- int // 声明只写channel
var ch2 <-chan int // 声明只读channel
ch3 := make(chan<- int,10) // 初始化一个只写的channel
ch4 := make(<-chan int,10) // 初始化一个只写的channel

channel底层

Go面试题(五):图解 Golang Channel 的底层原理 - 掘金 (juejin.cn)

  • 为什么channel底层需要用到互斥锁来保证并发安全而不是通过原子操作保证并发安全

1698682016432.png

channel的读写流程

5. 并发安全LOCK

Go语言提供了sync包(锁)和channel机制来解决并发机制中不同子协程之间的通信。虽然提倡通过通信来共享内存(channel实现通信),但Goland并没有舍弃通过共享内存实现通信,共享内存会导致数据冲突,引发了并发安全问题,需要用到锁对抗数据冲突问题,为此sync包提供了锁。

通过加锁保证并发安全缺点:每次去获取对象都需要先获取锁,并发性能非常地差,极端情况下,可能会出现卡顿现象。

  • 互斥锁_Sync.Mutex:
    当一个子协程获取互斥锁权限后,其他请求锁的子协程会阻塞在Lock()方法的调用上,直到调用Unlock()方法被释放,可以由不同的子协程加锁和解锁。

    使用互斥锁后,限定临界区只能同时由一个线程持有。其他线程请求进入临界区会返回失败或是等待。 1675052950(1).png

下面结合一个例子分析:

  • 代码功能:
    开启五个子协程,每个子协程负责给同一个变量x,做两千次加一操作。
  • 结果分析:
    不加锁最终x=8382。是一个未知结果,原因在于发生了读写混乱,有多个线程同时读出x,写入后放回。
    而加锁后最终x=10000,及每个进程依次获取互斥锁权限,依次完成两千次加一操作。 1675060806(1).png

实际开发中,避免对共享内存做出一些非并发安全的读写操作

6. sync.waitGroup

Go语言中除了可以使用通道(channel)和互斥锁(sync.Mutex)进行并发协程间的通信外,sync.waitGroup也是Goland中的并发措施,可以用来等待一批子协程的结束。 它的内部维护了一个计数器,有以下3个方法:

1675062467(1).png

waitGroup的常见用途是使得主线程一直阻塞等待,直到所有相关的子协程都已经完成了任务。该用途也可通过time.Sleep()暴力阻塞,但是不优雅,不知道子协程的确切执行时间,无法精确阻塞时间。

一个简单的使用例子如下:

  • 代码功能:五个子协程分别调用一次hello()函数,使用waitGroup实现协程同步
  • waitGroup使用分析:通过add方法初始化计数器的值为5,然后开启协程,每个协程执行完后通过Done函数让计数器值减1,wait函数阻塞主协程,直到5个协程实现结束后计数器清0,退出主协程

1675063184(1).png

7. GMP协程调度原理

image.png image.png Goroutine的并发编程模型基于GMP模型,简要解释一下GMP的含义:

  • G:表示goroutine,每个goroutine都有自己的栈空间,定时器,初始化的栈空间在2k左右,空间会随着需求增长。
  • M:抽象化代表内核线程,记录内核线程栈信息,当goroutine调度到线程时,使用该goroutine自己的栈信息。每个M代表了1个内核线程。数量对应真实的CPU数(真正干活的对象)。
  • P:代表调度器processor,负责调度goroutine,每个P都维护一个本地goroutine队列,M从P上获得goroutine并执行,同时还负责部分内存的管理。P 还负责管理 Goroutine 的状态(运行、阻塞、就绪等)。其数量可通过 GOMAXPROCS() 来设置,默认为核心数。
  • 全局队列:存放等待运行的Goroutine,可以看作是本地队列的中转器,本地队列满了后Processor会移动一部分到全局队列,本地队列空了后,M线程会尝试从全局队列拉一批到到本地。
  • Processor的本地队列:存放等待运行的Goroutine,数量有限<=256个。新建Goroutine时优先加入本地队列,满了后移动本地队列的一半到全局队列。

线程要运行任务,就需要从processor本地队列中获取协程,当前Gouroutine执行完成后,获取下一个G,不断重复。通过M线程Goroutine调度器和操作系统结合起来了,Goroutine调度器负责将协程调度将不同协程分配到对应内核线程,操作系统负责把内核线程分配到CPU的核上执行。如果当前M阻塞,P会自动寻找空闲的M,如果M不够,P会去创建新的M

Processor和M线程在何时被创建:P运行时,M在没有足够的M来关联P并运行其中可运行的G时,就会去创建

  • 什么情况下 M 会进入自旋的状态 (M 是系统线程。为了保证自己不被释放,所以自旋。这样一旦有 G 需要处理,M 可以直接使用,不需要再创建。M 自旋表示此时没有 G 需要处理)
  • 优点: 1698684437086.png

  • 缺点: 1698684476620.png

  • goroutine调度策略

    • 队列轮转:(P会周期性的轮转调度本地队列中的Goroutine,同时还会周期性的检查全局队列)
      P 会周期性的将G调度到M中执行,执行一段时间后,保存上下文,将G放到队列尾部,然后从队列中再取出一个G进行调度。除此之外,P还会周期性的查看全局队列是否有G等待调度到M中执行。

    • 系统调用:(M0上运行的G0系统调用时,MO释放P;调用结束,M0获取空闲P继续执行G0,获取不到P则G0放入全局队列,M0放入缓存池沉睡) 当G0即将进入系统调用时,M0将释放P,进而某个空闲的M1获取P,继续执行P队列中剩下的G。M1的来源有可能是M的缓存池,也可能是新建的。
      当G0系统调用结束后,如果有空闲的P,则获取一个P,继续执行G0。如果没有,则将G0放入全局队列,等待被其他的P调度。然后M0将进入缓存池睡眠。

8. Go select

Go 语言中的 select 语句是一种用于多路复用通道的机制,它允许在多个通道上等待并处理消息。相比于简单地使用 for 循环遍历通道,使用 select 语句能够更加高效地管理多个通道。 select 的主要作用是在处理多个通道时提供了一种高效且易于使用的机制,简化了多个 goroutine 的同步和等待,使程序更加可读、高效和可靠。

使用场景:

  • 等待多个channel的消息(多路复用)
  • 超时等待通道消息:select和time结合实现定时等待
  • 非阻塞读写:channel无数据时读写将阻塞,使用select结合default分支可以实现非阻塞读写,避免死锁or死循环等问题。

select使用:

select { 
    case <- channel1: 
        // channel1准备好了 
    case data := <- channel2: 
        // channel2准备好了,并且可以读取到数据data 
    case channel3 <- data: 
        // channel3准备好了,并且可以往其中写入数据data 
    default: 
        // 没有任何channel准备好了 
}
// 如果有多个case同时可执行,则会随机选择其中一个
// 如果没有任何可执行的case,则会执行default 分支
// 如果没有default分支则会阻塞等待直到至少有一个case可执行。
// 在select中使用channel时,必须保证channel是已经被初始化的

9. Go并发模式

并发实现方式一共四种

  • goroutine:Golang 在语言层面对并发编程进行了支持, 使用go关键字来使用协程。
  • Channel:Channel 中 Go语言在语言级别提供了对 goroutine 之间通信的支持,我们可以使用 channel 在两个或者多个goroutine之间进行信息传递,能过 channel 传递对像的过程和调用函数时的参数传递行为一样,可以传递普通参数和指针。
  • Select:当我们在实际开发中,我们一般同时处理两个或者多个 channel 的数据,我们想要完成一个那个 channel 先来数据,我们先来处理个那 - channel ,避免等待。
  • 传统的并发控制:sync.Mutex加锁和sync.WaitGroup等待组。

扇入扇出模式

扇入:当一个函数从多个 channel 中读取数据,直到所有 channel 关闭,指的是将多路通道聚合到一条通道中处理。在golang中最简单的扇入就是使用select聚合多条通道服务; 当生产者的速度很慢时,需要使用扇入技术聚合多个生产者满足消费者。 1684072244(1).png

扇出:当多个函数从一个 channel 中读取数据,直到 channel 关闭,指的是将一条通道发散到多条通道中处理,在golang中的具体实现就是使用go关键字启动多个goroutine并发处理。 当消费者的速度很慢时,需要使用扇出技术来并发处理请求。

10.Go并发之原子操作

CAS原理:

11. Golang并发操作之context包

12. 并发安全的map实现

  1. 使用互斥锁:sync.Mutex 缺点:读写互斥。
  2. 使用读写锁:sync.RWMutex 缺点:可以并发读,但是写的时候是互斥的,性能相对 sync.Mutex 要好一些。

Sync.Map:

在锁的基础上做了进一步优化,在一些场景下使用原子操作来保证并发安全,性能更好。 原理是通过分离读写map和原子指令来实现读的近似无锁,并通过延迟更新的方式来保证读的无锁化。一般情况下可以替换上面2种锁。
适合场景:读多写少or不同的协程操作不同的key。

核心思路:

  1. 读写分离空间换时间
    • 数据结构: read:只读结构,无读写冲突; dirty:需要加锁操作的部分map数据 misses:计数器、记录read中没有而dirty中有的数据个数;当dirty的大小=misses时,dirty数据全部迁移到read。
    • 读:从read里原子操作读、读不到则加锁去dirty里读,同时misses++
    • 写:若read存在,CAS操作更新value;若read不存在,加锁操作dirty
    • 删除:

    read的数据完整性:存在标志位amended,如果为true则表明当前read只读map的数据不完整,dirty map中包含部分数据

  2. 原子操作避免加锁影响

其他细节点:

  • read和dirty的交互过程:
    1. misses == dirty.size():数据从dirty迁移到read。
    2. 写入不存在read和dirty的新值时,amended为false时:将read中未被标记为expunged(未删除)复制过来给dirty。
  • 采用动态调整,当misses次数过多时,将dirty map提升为read map
  • 延迟删除、删除一个键值只是打标记(会将key对应value的pointer置为nil,但read中仍然有这个key:key;value:nil的键值对),只有在提升dirty的时候才清理删除的数据

参考:Golang中sync.Map的实现原理-CSDN博客

小结:

Goroutine(协程):Go可以通过高效的调度模型实现协程的高并发操作,
channel:提倡通过通信共享内存
Sync:关键字,包含lock和waitGroup,主要是实现并发安全操作和协程同步

参考文献

[1] 如何理解 Golang 中“不要通过共享内存来通信,而应该通过通信来共享内存”? - 知乎 (zhihu.com)
[2] 不要通过共享内存来通信,要通过通信来共享内存_阿冬哥的博客-CSDN博客_不要用共享内存的方式通信
[3] Go sync.Mutex - 简书 (jianshu.com)
[4] (35条消息) Go为什么天生支持高并发_Hzy_han的博客-CSDN博客_go天生支持高并发
[5] 协程篇(二)-- 协程切换篇 - 知乎 (zhihu.com)
[6] Go 语言进阶与依赖管理 - 掘金 (juejin.cn)
[7] Go-并发模式总结(扇入模式,超时模式,callback模式等)_lady_killer9的博客-CSDN博客 [8] Go-并发模式2(Patterns)_lady_killer9的博客-CSDN博客 [9] (28条消息) Golang常见面试题及解答_golang 面试_西木Qi的博客-CSDN博客