GO语言并发编程入门:Goroutine、Channel、Context、并发安全、GMP调度模型
1.GO并发介绍
并发:多线程程序在一个核的cpu上运行。
并行:多线程程序在多个核的cpu上运行。
由上可知并发不是并行,并行是直接利用多核实现多线程的运行,并发则主要由切换时间片来实现”同时”运行,go可以设置使用核数,以发挥多核计算机的能力。
Go语言之所以被称为现代化的编程语言,就是因为它在语言层面已经内置了调度和上下文切换的机制。Go语言的并发编程特点主要体现在Goroutine协程和Channel通道的使用上。
- Goroutine协程:Goroutine是Go语言中的并发执行单位。它是一种轻量级的协程,由负责整个Go程序的执行的底层系统组件Go运行时(Go runtime)调度和管理。在调用函数的时候在前面加上go关键字,就可以为一个函数创建一个goroutine。与传统的线程、协程相比,Goroutine的创建和销毁代价非常小,可以高效地创建大量的Goroutine,Goroutine 奉行通过通信来共享内存,而不是共享内存来通信。Goroutine4~5KB的栈内存占用和由于实现机制而大幅减少的创建和销毁开销是go高并发的根本原因。
- Channel通道:通道是Goroutine之间进行安全通信和数据共享的机制。它提供了同步和互斥的功能,确保数据的有序传输和访问。通过通道,不同的Goroutine可以安全地进行数据交换和共享状态。
Goroutine是一种特殊的协程,这是因为普通的协程和操作系统线程是多对一的关系,而在Go语言中,Goroutine和操作系统线程是多对多的关系。具体来说:
- 一个操作系统线程(OS Thread)可以对应多个Goroutine。
- Go程序可以同时使用多个操作系统线程,这使得Go语言能够充分利用多核处理器的计算能力。
- Go运行时调度器(Go Scheduler)负责将多个Goroutine调度到少量的操作系统线程上执行,并处理它们之间的通信。
2.GO并发编程
2.1 父子协程
在Go语言中,可以通过创建协程(Goroutine)来实现并发执行的任务。当父协程创建一个子协程时,父协程和子协程是相互独立的并发执行单元。父协程可以继续执行其他操作,而不需要等待子协程完成。子协程会在创建后立即开始执行,与父协程并发执行。父协程和子协程之间不存在直接的调用关系,它们是相互独立的执行流程。父协程的结束不会影响子协程的执行。即使父协程结束,子协程仍然可以继续执行,直到完成或被终止。父协程和子协程之间是独立的执行上下文,彼此之间的运行状态不会相互影响。
然而,需要注意的是,如果主协程(即main函数所在的协程)结束了,整个程序会终止,所有的协程也会被强制结束。这意味着如果主协程提前结束,尚未完成的子协程也会被中止。因此,在使用协程进行并发编程时,我们需要确保主协程不会过早地结束,以确保子协程能够完成任务,可以考虑采用以下方法:
使用time.Sleep使协程睡眠确保并发子协程完成
使用sync.WaitGroup等待组确保并发子协程完成
2.1.1 使用time.Sleep使协程睡眠确保并发子协程完成
time包提供了时间相关的功能,其中最常用的是time.Sleep函数,它可以让当前的Goroutine休眠一段时间。通过结合Goroutine和time.Sleep,我们可以实现协程的并发执行。
package main
import (
"fmt"
"time"
)
func main() {
go task("Task 1") // 启动协程1
go task("Task 2") // 启动协程2
// 主协程休眠一段时间,确保协程有足够的时间执行
time.Sleep(3 * time.Second)
}
func task(name string) {
for i := 0; i < 5; i++ {
fmt.Println(name+":", i) // 打印任务名称和当前迭代值
time.Sleep(500 * time.Millisecond)
}
}
在上面的示例中,我们通过启动两个协程(task1和task2)来实现并发执行。主协程(main函数)休眠3秒钟,确保协程有足够的时间执行。这样,我们就实现了协程的并发执行。
2.1.2 使用sync.WaitGroup等待组确保并发子协程完成
sync.WaitGroup文档介绍:draveness.me/golang/docs…
sync包提供了一些同步原语,如WaitGroup等待组,它可以用来等待一组协程的完成。通过WaitGroup,类似于操作系统中的PV信号量,可以实现协程的并发执行和同步等待。
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(2) // 设置等待组的计数器为2,表示有两个协程需要等待
go func() {//开启协程1
defer wg.Done() // 协程完成后调用Done方法,减少等待组的计数器
task("Task 1")
}()
go func() {//开启协程1
defer wg.Done() // 协程完成后调用Done方法,减少等待组的计数器
task("Task 2")
}()
wg.Wait() // 等待所有协程完成,完成后才结束main协程
}
func task(name string) {
for i := 0; i < 5; i++ {
fmt.Println(name+":", i) // 打印任务名称和当前迭代值
}
}
在上述示例中,我们使用sync包中的WaitGroup来实现协程的并发执行和同步等待。通过调用wg.Add方法设置等待组的计数器为2,然后在每个协程中使用defer wg.Done()来减少计数器。最后,通过wg.Wait()等待所有协程完成。
2.2 Channel实现并发与协程通信
Channel文档介绍:draveness.me/golang/docs…
在并发编程中,单纯地将函数并发执行是没有意义的。函数与函数间需要交换数据才能体现并发执行函数的意义,对共享资源的正确访问需要精确的控制。
在目前的绝大多数语言中,都是通过加锁等线程同步方案来解决这一困难问题,而Go语言却另辟蹊径,它将共享的值通过Channel传递(实际上多个独立执行的线程很少主动共享资源)。channel像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序,可以让一个goroutine发送特定值到另一个goroutine的通信机制。在任意给定的时刻,最好只有一个Goroutine能够拥有该资源,数据竞争从设计层面上就被杜绝了。
这也是Go语言作者提出的并发编程哲学:不要通过共享内存来通信,而应通过通信来共享内存。
虽然我们在 Go 语言中也能使用共享内存加互斥锁进行通信,但是 Go 语言提供了一种不同的并发模型,即通信顺序进程(Communicating sequential processes,CSP)。Goroutine 和 Channel 分别对应 CSP 中的实体和传递信息的媒介,Goroutine 之间会通过 Channel 传递数据。
CSP(Communicating sequential processes)是一种并发编程模型,它强调通过通信来实现并发。在CSP中,程序被分解成一组独立的进程,这些进程通过通道进行通信。通道是一种同步的通机制,它允许进程之间传递数据。CSP模型的一个重要特点是,进程之间的通信是通过发送和收消息来实现的,而不是通过共享内存。
CSP模型的一个优点是,它可以避免一些常见的并发编程问题,例如死锁和竞态条件。这是因为CSP模型中的进程是独立的,们不会相互干扰或阻塞彼此。此外,CSP模型还可以使并发程序更易于理解和调试,因为它们的行为是通过进程之间的通信来定义的。
上图中的两个 Goroutine,一个会向 Channel 中发送数据,另一个会从 Channel 中接收数据,它们两者能够独立运行并不存在直接关联,但是能通过 Channel 间接完成通信。
2.2.1 使用Channel实现协程并发并进行协程通信
package main
import "fmt"
func main() {
ch := make(chan int) // 创建一个int类型的通道
go func() {
ch