写在文章开头
go语言
基于协程实现并发使得一个线程可以在用户态上灵活切换,不仅充分利用每个线程,通过协程上下文的切换使得降低了系统的开销,所以本文就基于几个经典案例探讨一下go语言
中的协程——goroutine
。
Hi,我是 sharkChili ,是个不断在硬核技术上作死的 java coder ,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili 。
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。
基础概念
进程与线程
在正是介绍goroutine协程前,我们先来复习一些计算机基础知识,如下图,我们可以看到进程的三大部分:
- 每一个进程都会通过磁盘读取需要的数据和代码。
- 每一个进程都会拥有操作文件、硬件设备和操作系统的句柄。
- 进程执行时,都是以线程为单位,所以每一个进程至少都有一个线程,每一个线程都会对应一个功能代码段,然后操作系统调度器根据某个调度算法执行进程中的线程代码。
尽管多线程提升了IO密集型任务的执行效率,但是频繁的线程上下文切换开销也是很大的,所以对于高并发的任务,使用多线程处理并发IO请求终会出现严重的性能瓶颈。
协程扫盲
我们是否有一种办法可以做到让线程切换工作呢?例如某段代码的函数要发起一个阻塞2s的HTTP请求,我们是否可以让当前线程在函数发起HTTP请求阻塞时,将这个方法执行时的CPU寄存器信息和函数运行时的状态信息(栈帧)信息都记录下来,并存到堆区。
然后这个空闲出来的线程就可以执行另一个代码段了,这种将代码执行信息暂时挂起让线程去执行另一个代码段的方式,避免了频繁的内核态线程切换,只需在用户态即可完成IO阻塞切换另一个业务功能的方式正是本文所说的多协程。
小结一下协程的工作流程:
- 线程1执行协程1的代码。
- 协程1代码阻塞,将执行的栈帧信息存到堆区中,挂起该协程。
- 线程1执行协程2。
- 协程2执行完成,从堆区中获取协程1调用的栈帧信息,继续执行。
- 协程1执行完成,线程1继续寻找需要执行的协程。
可以看到只用一个线程在用户态上灵活的切换避免了在内核态切换的开销,并且单线程利用率显著提高。通俗来说我们完全可以将协程理解为一种轻量级的线程。
详解goroutine
在go语言中,协程这个概念对应着就是goroutine,每个goroutine都有自己的堆栈和寄存器,可以在不同的系统线程中执行,go语言会为每一个线程分配一个逻辑处理器调取这些goroutine,然后基于这个逻辑处理器,我们可以创建成千上万个goroutine,通过用户态级别的协程切换高效实现并发编程。
如下图,在逻辑处理器上,每一个需要被执行的线程会构成一个队列,按照逻辑处理器的调度算法分段时间执行。
若以上图为例,假如我们的goroutine1要执行一个阻塞的系统调用(例如打开一个文件),goroutine就会在阻塞期间被分配到另一个线程的逻辑处理器上等待系统调用完成。
一旦goroutine1系统调用完成,goroutine1就会和线程2逻辑处理器分离,回到线程1的逻辑处理器上等待调度执行。然后线程2的逻辑处理器则继续处理其他的goroutine。
同样的,假如goroutine1执行的是网络IO,那么情况就有些不同的,当goroutine1因为网络IO而发起阻塞时,这个goroutine就会被移动到运行时集成的网络轮询器上,待网络轮询器读或写准备就绪,goroutine1才会再次分配到逻辑处理的队列中等待被处理。
并发与并行
并发与并行的区别
有了上文关于协程的基础之后,我们再来聊聊并发与并行,咋一看这两个概念没有什么区别,实际上并发指的是同一个时间管理多件事,注意是管理多件事,这一点我们完全可以理解为上文中网络中单线程处理网络IO阻塞时,挂起goroutine1去处理别人的goroutine。
而并行则是同时处理多件事,注意是同时处理多见件,可以理解为我们多个物理核心对应的线程同一时间用各自的逻辑处理器处理对应的协程。
goroutine并发工作示例
示例1-打印字母两次
为了更好的理解并发,我们这里给出这样一个例子,我们当前功能会在一个线程上创建两个协程,这两个协程分别打印两次大写的A-Z和小写的a-z。因为仅仅使用一个线程在一个CPU核心上工作,而且打印并不会出现IO阻塞,所以最终我们的输出结果应该是先输出大写的A-Z两次,随后再输出小写的a-z两次。
随后我们给出下面这段代码,整体逻辑为:
- 设置只用一个逻辑处理器个调度器使用。
- 为两个线程设置一个WaitGroup(可以理解为Java的CountDownLatch)。
- 启用两个线程分别开始打印工作,每次打印完成WaitGroup减去1。
func main() {
//启动一个逻辑处理器给调度器使用
runtime.GOMAXPROCS(1)
fmt.Println("启动两个goroutine打印3次英文字母")
//开启一个计数器,设置为2,等待两个协程执行完,从而做到流程控制
var wg sync.WaitGroup
wg.Add(2)
//启动一个协程打印a-z两次
go func() {
//协程结束之后,将计数器减1
defer wg.Done()
for i := 0; i < 3; i++ {
for i := 'a'; i < 'a'+26; i++ {
fmt.Printf("%c ", i)
}
//换行便于查看
fmt.Println("\n")
}
}()
//启动一个协程打印A-Z两次
go func() {
//协程结束之后,将计数器减1
defer wg.Done()
for i := 0; i < 3; i++ {
for i := 'A'; i < 'A'+26; i++ {
fmt.Printf("%c ", i)
}
//换行便于查看
fmt.Println("\n")
}
}()
fmt.Println("等待两个goroutine执行完")
//等待两个goroutine执行完
wg.Wait()
fmt.Println("两个goroutine执行完成")
}
输出结果如下,可以看到两个协程并发执行,一个协程结束后另一个协程才继续工作。
启动两个goroutine打印3次英文字母
等待两个goroutine执行完
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
a b c d e f g h i j k l m n o p q r s t u v w x y z
a b c d e f g h i j k l m n o p q r s t u v w x y z
a b c d e f g h i j k l m n o p q r s t u v w x y z
两个goroutine执行完成
示例2-输出5000以内的质数
从上面例子你可能认为对于计算密集型的任务,并发就是一个线程上的协程按顺序执行,实际上对于计算机密集型的任务go的调度器也是会按照时间分片算法执行的,例如我们现在有两个协程需要分别找出5000以内的质数,对此我们给出下面这样一段代码。
var wg sync.WaitGroup
// 单核情况下长时间执行的协程会被调度器轮流分配
func main() {
//给调度器分配一个处理器
runtime.GOMAXPROCS(1)
//初始化计数器
wg.Add(2)
fmt.Println("两个协程开始工作")
go printPrime("A")
go printPrime("B")
fmt.Println("等待两个协程执行完成")
wg.Wait()
fmt.Println("两个协程执行结束")
}
func printPrime(prefix string) {
defer wg.Done()
next:
for outer := 0; outer < 5000; outer++ {
//除了2和本身以外还能被整数的就不是质数
for inner := 2; inner < outer; inner++ {
if outer%inner == 0 {
continue next
}
}
//打印协程号和质数
fmt.Printf("%s:%d\n", prefix, outer)
}
}
输出结果如下,可以看到对于计算密集型的任务并非一直拿着线程对应的逻辑处理器,我们的协程A输出到2851时,逻辑调度器便切换到协程B继续执行查找质数的逻辑。
两个协程开始工作
等待两个协程执行完成
.....
A:2843
A:2851
A:2857
B:3557
B:3559
B:3571
B:3581
B:3583
B:3593
B:3607
......
goroutine并行工作示例
还记得我们上文中打印英文字母的示例吗?为了演示并发我们将逻辑调度处理器设置为1,确保两个协程都在同一个线程的逻辑处理器上运行。由于两个线程执行的功能并不耗时,所以打印结果是按照两个线程功能顺序执行的。
所以在这个示例中,我们将逻辑调度处理器设置为2,确保两个协程分别在不同线程的逻辑调度器上运行。
改造也很简单,将逻辑处理器设置为2即可。
runtime.GOMAXPROCS(2)
从输出结果可以看出,两个协程并行运行,打印结果混起来了
启动两个goroutine打印3次英文字母
等待两个goroutine执行完
a A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
b c d e f g h i j k l m n o p q r s t u v w x y z
a b c d e f g h i j k l m n o p q r s t u v w x y z
a b c d e f g h i j k l m n o p q r s t u v w x y z
两个goroutine执行完成
goroutine的协程安全问题
问题重现
和多线程一样,多协程修改共享数据是也存在安全问题,对此我们给出下面这样一段代码,代码的逻辑非常简单,启动两个协程分别循环两次对共享数据自增。需要注意的是,为了更好的演示协程安全问题,笔者在每个协程读取共享变量时调用了runtime.Gosched()
,这个操作会使得当前协程让出线程的执行权并将自己回到协程队列中等待下次被执行。
func main() {
//设置计数器为协程数2
wg.Add(2)
//启动两个协程对counter进行自增
go incCounter(1)
go incCounter(2)
//等待两个协程执行完
wg.Wait()
fmt.Println("执行完毕,counter:", counter)
}
func incCounter(id int) {
//函数执行完成后 计数器减1
defer wg.Done()
for i := 0; i < 2; i++ {
value := counter
//将协程对应的线程执行权归还,并回到队列中
runtime.Gosched()
value++
counter = value
}
}
最终输出结果为2,很明显协程安全问题出现了。
执行完毕,counter: 2
原因剖析
以上代码的含义,协程1首先读取counter的值为0,随后让出线程回到队列中等待逻辑处理器下次执行。
随后协程2也读取到0,让出线程执行权。
然后协程1完成自增值为1,进行下一次循环,读取到值为2,让出线程执行权。
然后执行权回到了协程2,协程2执行同样的自增值还是1,由此协程安全问题出现,后续步骤同理不多赘述,最终结果就变为2。
对于这类问题,go语言也为了提供的不错的命令,即在编译阶段执行go build -race
,对于windows用户可需要执行CGO_ENABLED=1 go build -race
。
注意这些操作是需要安装gcc的,windows开发用户执行上述命令前建议参考笔者这篇文章完成编译环境的配置。
因为笔者是在Goland上开发,所以编译参数可以在edit configurations
上设置。
最终会输出下面这样一段调用的堆栈信息,可以看到代码25行的调用49的代码,以及代码26行调用40行的代码之间存在竞争。
WARNING: DATA RACE
Write at 0x00000063c508 by goroutine 7:
main.incCounter()
F:/github/code/chapter6/listing09/listing09.go:49 +0xa4
main.main.func1()
F:/github/code/chapter6/listing09/listing09.go:25 +0x30
Previous read at 0x00000063c508 by goroutine 8:
main.incCounter()
F:/github/code/chapter6/listing09/listing09.go:40 +0x84
main.main.func2()
F:/github/code/chapter6/listing09/listing09.go:26 +0x30
Goroutine 7 (running) created at:
main.main()
F:/github/code/chapter6/listing09/listing09.go:25 +0x44
Goroutine 8 (finished) created at:
main.main()
F:/github/code/chapter6/listing09/listing09.go:26 +0x50
==================
Final Counter: 2
Found 1 data race(s)
Process finished with the exit code 66
最终我们定位到出现协程安全问题的代码。
Line 49: counter = value
Line 40: value := counter
Line 25: go incCounter(1)
Line 26: go incCounter(2)
解决方案
原子函数
和Java语言一样,go语言提供了atomic包,它的AddInt64通过底层硬件原子操作确保同一时刻只有一个协程操作共享数据,从而保证协程安全,所以我们改进后的代码如下所示:
func incCounter(id int) {
//函数执行完成后 计数器减1
defer wg.Done()
for i := 0; i < 2; i++ {
//将自增操作原子化解决协程安全问题
atomic.AddInt64(&counter, 1)
//将协程对应的线程执行权归还,并回到队列中
runtime.Gosched()
}
}
AddXXXX函数的实现我们可以从源码的文档中看出,其实现的代码如下所示,即同一时刻只有一个协程通过硬件原语实现自增。
The add operation, implemented by the AddT functions, is the atomic equivalent of:
addr += delta return *addr
对于原子函数的示例,我们这里再补充一个安全读和安全写的组合操作,代码如下所示,可以看到代码的操作很简单:
- 计数器设置为2控制两个协程的流程。
- main线程启动两个协程,使用LoadInt64原子函数安全读共享变量shutdown,如果发现shutdown变成1则停止工作,反之一直循环工作。
- 主线程休眠1s后使用StoreInt64使用原子硬件原语完成原子修改操作。
- 两个协程在StoreInt64完成原子操作后读取到了最新的值,退出返回,将WaitGroup减去1。
var (
shutdown int64
wg sync.WaitGroup
)
// main 使用原子工具包实现安全读和安全写
func main() {
//计时器设置为2
wg.Add(2)
fmt.Println("两个协程开始工作.....")
go doWork("A")
go doWork("B")
//休眠1s让两个协程多工作一会
time.Sleep(1 * time.Second)
fmt.Println("main准备停止两个协程")
atomic.StoreInt64(&shutdown, 1)
wg.Wait()
}
func doWork(name string) {
defer wg.Done()
for {
fmt.Println("协程", name, "开始工作")
time.Sleep(250 * time.Millisecond)
if atomic.LoadInt64(&shutdown) == 1 {
fmt.Println("收到停止信号,协程", name, "停止工作")
break
}
}
}
引用的这段文本来自于Go语言官方文档中的atomic包。文本中提到,LoadT和StoreT函数分别用于原子读取和存储内存地址中的值,它们分别对应于C语言中的return *addr
和*addr = val
。
在Go内存模型的术语中,如果一个原子操作A的效果被另一个原子操作B所观察到,则A在B之前同步。此外,程序中执行的所有原子操作都表现为按顺序一致的顺序执行。这个定义提供了与C++的顺序一致性原子(即操作原子性,操作会按照执行顺序先后完成原子修改)和Java的volatile变量相同的语义(即StoreT可以保证操作可见性,确保其他后于StoreT操作的协程读取到最新的值)。
The load and store operations, implemented by the LoadT and StoreT functions, are the atomic equivalents of “return *addr” and “*addr = val”.
In the terminology of the Go memory model, if the effect of an atomic operation A is observed by atomic operation B, then A “synchronizes before” B. Additionally, all the atomic operations executed in a program behave as though executed in some sequentially consistent order. This definition provides the same semantics as C++'s sequentially consistent atomics and Java’s volatile variables.
互斥锁
除了原子操作以外,go还提供了mutex互斥锁确保协程安全,其工作原理在文档也给出了说明,某个协程对mutex上锁后,其他尝试取锁的协程就会阻塞,直到锁定mutex的协程释放锁。
Lock locks m. If the lock is already in use, the calling goroutine blocks until the mutex is available.
使用mutex的示例代码如下:
func incCounter(id int) {
defer wg.Done()
fmt.Println("协程", id, "开始工作")
for i := 0; i < 2; i++ {
mutex.Lock()
{
value := counter
value++
counter = value
fmt.Println("协程", id, "上锁成功并修改值成功,counter:", value)
}
mutex.Unlock()
}
}
go语言中的通道
通道是如何解决协程安全问题的
除了原子类和互斥锁以外,go语言也可以将操作共享数据的协程之间建立通道,通过go语言内部所提供的双向通道同步机制,确保同一个时刻通道只有两个协程可以安全的交换数据,从而保证协程安全。
无缓冲通道
go语言提供的通道有两种,我们先来说说无缓冲通道,该通道的特点和名字一样,通道间不提供缓存数据的缓冲区,这意外着两个协程必须都准备好收发后通道才能进行通信,反之只要任何一方没有准备好通道就会阻塞。
无缓冲通道实现击球比赛
用无缓冲区实现一个击球比赛,我们的模拟方式如下:
- 建立一个整型通道,模拟击球场地。
- 用两个协程模拟运动员。
- 主线程对通道传入0,模拟开球。
- 收到信号的第一个协程模拟第一次击球的运动员,随机生成一个数,如果该数会被13整除,则关闭通道,模拟该球员击球失败,反之就将通道的值加一,并传入通道,模拟击球给对手。
- 另一个协程从通道中收到信号,如果收到关闭信号,则说明对方没击中球直接获胜,反之随机生成一个数,如果该数会被13整除,则关闭通道,模拟该球员击球失败,反之就将通道的值加一,并传入通道,模拟击球给对手。
- 重复上述4-5步骤。
示例代码如下:
var wg sync.WaitGroup
func main() {
//计数器设置为协程数2
wg.Add(2)
//创建一个无缓冲通到
court := make(chan int)
go player("运动员A", court)
go player("运动员B", court)
fmt.Println("开球......")
//往无缓冲通到存一个值
court <- 0
wg.Wait()
fmt.Printf("比赛结束")
}
func player(name string, court chan int) {
defer wg.Done()
for {
//等待缓冲通道的值
ball, ok := <-court
//没收到则说明某个协程将通道关闭了(即对方没有击中球)
if !ok {
fmt.Println("对手击球失败", name, "获胜")
break
}
//模拟运动员击球,如果能被13整除则说明该运动员击球失败,关闭通道
n := rand.Intn(100)
if n%13 == 0 {
fmt.Println(name, "未能击中球")
close(court)
break
}
//增加击球数并写入通道中,模拟击球给对方
ball++
fmt.Println(name, "击中球:", ball, "次")
court <- ball
}
}
输出结果如下,可以看到无缓冲通道对于这种交互式通信的场景时效性还是很不错的。
开球......
运动员A 击中球: 1 次
运动员B 击中球: 2 次
运动员A 未能击中球
对手击球失败 运动员B 获胜
比赛结束
无缓冲接力赛
上文提到无缓冲通道因为必须收发双方都得准备好才能进行数据交换,所以也很适合模拟接力赛,整体实现步骤和上述差不多:
- 主线程创建通道。
- 开启一个协程1监听通道。
- 主线程向通道发送1,代表起跑。
- 协程1收到信号,发现是数据为1代表是第一棒,创建一个协程2模拟第2棒等待接力。
- 协程1休眠一会,模拟冲刺中,此时协程2孩子阻塞等待协程1发送信号模拟接力。
- 协程将通道收到的值自增并写入通道模拟接力给第2棒。
- 协程2重复4-6步骤,直到协程4关闭通道。模拟接力完成。
示例代码如下,整体流程和上述差不多,唯一需要补充的就是增加了WaitGroup控制主线程的结束。
var wg sync.WaitGroup
// 基于无缓冲通道模拟接力赛跑
func main() {
wg.Add(1)
baton := make(chan int)
//启动协程1模拟第一个起跑运动员
go Runner(baton)
fmt.Println("接力赛开始.......")
//向通道发送数据,模拟开枪通知第1棒起跑
baton <- 1
//等待第4个协程wg.Done()
wg.Wait()
//关闭通道
close(baton)
fmt.Println("接力赛结束")
}
func Runner(baton chan int) {
var newRunner int
//等待接力棒
runner := <-baton
fmt.Println("选手", runner, "接到第", runner, "棒")
if runner != 4 {
newRunner = runner + 1
fmt.Println("选手", runner, "准备将接力棒交给选手", newRunner)
go Runner(baton)
}
time.Sleep(100 * time.Millisecond)
if runner == 4 {
fmt.Println("第", runner, "选手到达终点")
wg.Done()
return
}
fmt.Println("选手", runner, "将接力棒交给选手", newRunner)
baton <- newRunner
}
输出结果如下,可以看到无缓冲区通道顺序的完成了接力。
接力赛开始.......
选手 1 接到第 1 棒
选手 1 准备将接力棒交给选手 2
选手 1 将接力棒交给选手 2
选手 2 接到第 2 棒
选手 2 准备将接力棒交给选手 3
选手 2 将接力棒交给选手 3
选手 3 接到第 3 棒
选手 3 准备将接力棒交给选手 4
选手 3 将接力棒交给选手 4
选手 4 接到第 4 棒
第 4 选手到达终点
接力赛结束
有缓冲通道示例
有缓冲区通道则允许其中一方未准备好,亦或者允许发送方填满缓冲区时阻塞发送方,缓冲区无数据时阻塞接收方,是典型的生产者消费者模式。
对此我们模拟一个单上传者多消费者的例子,整体流程为:
- 主线程创建有缓冲区通道。
- 创建4个协程阻塞监听有缓冲通道,等待缓冲通道有值。
- 主线程往通道写入10条数据。
- 任何一个协程收到数据时,尝试和通道建立连接,从中获取一条数据并消费,其他通道阻塞等待。
- 所有数据都消费干净,完毕通道,所有协程结束工作。
最后我们给出的代码如下:
const (
numberGoroutines = 4 //协程数设置为4
taskLoad = 10 //任务数为10
)
var wg sync.WaitGroup
func main() {
wg.Add(4)
//创建一个有缓冲通道
taskChannel := make(chan string, taskLoad)
//启动4个协程模拟消费者
for i := 0; i < numberGoroutines; i++ {
go Worker(i, taskChannel)
}
//主线程往通道里提交10个任务
for i := 0; i < taskLoad; i++ {
taskChannel <- fmt.Sprintf("task %d", i)
}
//关闭通道
close(taskChannel)
//等待4个协程执行完成
wg.Wait()
}
func Worker(workNo int, taskChannel chan string) {
//函数退出时扣减计数器
defer wg.Done()
for {
task, ok := <-taskChannel
//如果通道关闭则退出
if !ok {
fmt.Println("任务通道已关闭,worker", workNo, "退出")
return
}
//输出通道收到的值,然后休眠2s
fmt.Println("worker", workNo, "执行任务", task)
time.Sleep(2000 * time.Millisecond)
}
}
输出结果如下,可以看到生产者和消费者有序调度:
worker 1 执行任务 task 1
worker 0 执行任务 task 0
worker 3 执行任务 task 2
worker 2 执行任务 task 3
worker 2 执行任务 task 4
worker 1 执行任务 task 5
worker 0 执行任务 task 6
worker 3 执行任务 task 7
worker 3 执行任务 task 8
worker 0 执行任务 task 9
任务通道已关闭,worker 1 退出
任务通道已关闭,worker 2 退出
任务通道已关闭,worker 0 退出
任务通道已关闭,worker 3 退出
小结
本文从一个Java开发视角简单的介绍了协程的基本概念及其优势和使用场景,可以发现对于高并发的IO密集任务,使用轻量级线程goroutine开销远远小于线程,于此同时我们也需要结合场景选用合适的工具确保协程安全。
对于简单的数值增减操作,我们建议使用原子函数确保安全读和安全写。而对于操作复杂的操作时,我们建议对操作代码块使用互斥锁保证协程安全。
而对于那些协程间共享数据交换,如果需要实时建立连接完成交换的,我们建议使用无缓冲通道。如果要考虑执行效率,我们建议结合场景创建尽可能多的协程并使用有缓冲区通道完成通信。
我是 sharkchili ,CSDN Java 领域博客专家,开源项目—JavaGuide contributor,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。