热门标签 | HotTags
当前位置:  开发笔记 > 编程语言 > 正文

认真一点学Go:18.并发

收录于《Go基础系列》,作者:潇洒哥老苗。>>原文链接学到什么并发与并行的区别?什么是Goroutine?什么是通道?Goroutine如何通信?相关函数的使用?sel











收录于 《Go 基础系列》,作者:潇洒哥老苗。


>> 原文链接


学到什么



  1. 并发与并行的区别?


  2. 什么是 Goroutine?


  3. 什么是通道?


  4. Goroutine 如何通信?


  5. 相关函数的使用?


  6. select 语句如何使用?



并发与并行

为了更有意思的解释这个概念,我借用知乎上的一个回答:



  • 你吃饭吃到一半,电话来了,你一直到吃完了以后才去接,这就说明你不支持并发也不支持并行。


  • 你吃饭吃到一半,电话来了,你停了下来接了电话,接完后继续吃饭,这说明你支持并发。


  • 你吃饭吃到一半,电话来了,你一边打电话一边吃饭,这说明你支持并行。


并发的关键是你有处理多个任务的能力,不一定要同时。

并行的关键是你有同时处理多个任务的能力。

对应到 CPU 上,如果是多核它就有同时执行的能力,即有并行的能力。

对于 Go 语言,它自行安排了我们的代码合适并发合适并行。


什么是 Goroutine

学会这个就知道怎么写一个并发程序,用起来很简单的,现在开始。

Goroutine 是 Go 语言中的协程,其它语言称为的协程字面上叫 Coroutine,简单理解下就是比线程更轻量的一个玩意。

再说白了,就是可以异步执行函数。


main Goroutine

当启动 main 入口函数时,后台就自动跑了一个 main Goroutine,还原给大家看看。


package main
func main() {
panic("看这里")
}

执行上面代码,会输出如下部分信息:


panic: 看这里
goroutine 1 [running]:
main.main()

从结果中可以看到,出现了一个 goroutine 字眼,它对应的索引为 1。


创建 Goroutine

创建 Goroutine 很简单,只需要在函数前增加一个 go 关键字,格式如下:


go fun1(...)

也支持匿名函数。


go func(...){
// ...
}(...)


  • go 关键字后的函数可以写返回值,但无效。因为 Goroutine 是异步的,所以没法接受。

下来看一个完整的例子:


package main
import (
"fmt"
)
func PrintA() {
fmt.Println("A")
}
func main() {
go PrintA()
fmt.Println("main")
}

看上面 main 函数只有两行:



  • 第一行:创建一个 Goroutine,异步打印“A”字符串。


  • 第二行:打印 “main” 字符串。


现在先停留一会,想想执行该代码后,输出结果是啥。

结果如下:


main

你没看错,没有输出“A”字符串。

因为 go PrintA() 创建的 Goroutine 它是异步执行,main 函数执行完退出程序时,也不会管它。所以下来看如何让 main 函数等待 Goroutine 执行完。

方法一:使用 time.Sleep 函数。


func main() {
go PrintA()
fmt.Println("main")
time.Sleep(time.Second)
}
// 输出
main
A

main 函数退出前让等一会。

方法二:使用空的select 语句,非空的 select 用法会配合通道一块讲解。


func main() {
go PrintA()
fmt.Println("main")
select {}
}
// 输出
main
A
fatal error: all goroutines are asleep - deadlock!
...

“A”字符串是输出了,但程序也出现异常了。

原因是,当程序中存在运行的 Goroutine,select{} 就会一直等待,如果 Goroutine 都执行结束了,没有什么可等待的了,就会抛出异常。

在真实项目中,出现异常自然不对,那 select{} 使用场景是啥,例如:



  • 爬虫项目,创建了 Goroutine,需要一直爬取数据,不需要停止。

方法三:使用 WaitGroup 类型等待 Goroutine 结束,项目中常常使用,完整例子如下:


package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func PrintA() {
fmt.Println("A")
wg.Done()
}
func main() {
wg.Add(1)
go PrintA()
wg.Wait()
fmt.Println("main")
}


  • 声明 WaitGroup 类型变量 wg,使用时无需初始化。


  • wg.Add(1) 表示需要等待一个 Goroutine,如果有两个,使用 Add(2)


  • 当一个 Goroutine 运行完后使用 wg.Done() 通知。


  • wg.Wait() 等待 Goroutine 执行完。



控制并发数

Go 语言中可以控制使用 CPU 的核心数量,从 Go1.5 版本开始,默认设置为 CPU 的总核心数。如果想自定义设置,使用如下函数:


num := 2
runtime.GOMAXPROCS(num)

num 如果大于 CPU 的核心数,也是允许的,Go 语言调度器会将很多的 Goroutine 分配到不同的处理器上。


什么是通道

现在明白了怎么创建 Goroutine 后,下一步就要知道它们之间要如何通信。

认真一点学 Go:18. 并发

Goroutine 通信使用“通道(channel)”,如果 Goroutine1 想发送数据给 Goroutine2,就把数据放到通道里,Goroutine2 直接从通道里拿就行,反过来也是一样。

在给通道放数据时,也可以指定通道放置的数据类型。


创建通道

创建通道时,分为无缓冲和有缓冲两种。


1. 无缓冲


strChan := make(chan string)

定义了一个存储数据类型为 string 的无缓冲通道,如果想存储任意类型,那数据类型设置为空接口。


allChan := make(chan interface{})

创建好了通道,下来就要给通道里放数据。


strChan := make(chan string)
strChan <- "老苗"

使用”<-“操作符链接数据,表示将“老苗”字符串送入 strChan 通道变量。

但这样放数据是会报错的,因为 strChan 变量是无缓冲通道,放入数据时 main 函数会一直等待,因此会造成死锁。

如果想解决死锁情况,就要保证有地方在异步读通道,因此需要创建一个 Goroutine 来负责。

例子如下:


// concurrency/channel/main.go
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func Read(strChan chan string) {
data := <-strChan
fmt.Println(data)
wg.Done()
}
func main() {
wg.Add(1)
strChan := make(chan string)
go Read(strChan)
strChan <- "老苗"
wg.Wait()
}
// 输出
老苗


  • Read 函数负责读取通道数据,并打印。


  • 通道是引用类型,因此传递时无需使用指针。


  • <-strChan 表示从通道里拿数据,如果通道里没有数据它会进行阻塞。


  • wg.Wait() 等待 Read 异步函数执行完。



2. 有缓冲

读了上面就会了解到,对于无缓冲通道,它会产生阻塞。为了不让阻塞,必须创建一个 Goroutine 负责从通道读取才行。

而有缓冲的通道,会有缓冲的余地,具体来看看。

创建缓冲通道,如下:


bufferChan := make(chan string, 3)


  • 创建了一个存储数据类型为 string 的通道。


  • 可以缓冲 3 个数据,即给通道送入 3 个数据不会进行阻塞。


测试如下:


// concurrency/bufferchannel/main.go
package main
import "fmt"
func main() {
bufferChan := make(chan string, 3)
bufferChan<-"a"
bufferChan<-"b"
bufferChan<-"c"
fmt.Println(<-bufferChan)
}
// 输出
a


  • bufferChan 变量存入 3 个字符串。


  • 存入 3 个数据时不会阻塞,当存入数量超过 3 时,就需要 Goroutine 异步读取。


缓冲通道何时使用,例如:

爬虫数据,第 1 个 Goroutine 负责爬取数据,第 2 个 Goroutine 负责处理和存储数据。 当第 1 个的处理速度大于第 2 个时,可以使用缓冲通道暂存起来。

暂存起来后,第 1 个 Goroutine 就可以继续爬取,而不像无缓冲通道,放入数据时会阻塞,直到通道数据被读出,才能进行。

为了加深印象,再来一张图:

认真一点学 Go:18. 并发

图解:



  • bufferChan 长度为 3 的缓冲通道,并且已存入 2 个数据。


  • 看图中的两个箭头,箭头在 bufferChan 右边,表示存,左边表示取。


  • 按照先入先出规则存取。



单向通道

现在知道了如何创建一个双向通道,双向通道指的就是即可以存,又可以取。

那单向通道创建如下:


readChan := make(<-chan string)
writeChan := make(chan<- string)


  • readChan 只能读取数据。


  • writeChan 只能存取数据。


但这样创建的通道是无法传递数据的,为什么?

因为,如果只能读的通道,没法存数据,那我存了个寂寞。而存的通道,我数据拿不出来,又有何用。

现在看看如何正确使用单向通道的例子,如下:


// concurrency/onechannel/main.go
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
// 写通道
func write(data chan<- int) {
data<-520
wg.Done()
}
// 读通道
func read(data <-chan int) {
fmt.Println(<-data)
wg.Done()
}
func main() {
wg.Add(2)
dataChan := make(chan int)
go write(dataChan)
go read(dataChan)
wg.Wait()
}
// 输出
520


  • 创建了两个 Goroutine,read 函数负责只读,write 函数负责只写。


  • 通道传递时,将双向通道转化为单向通道。



遍历通道

在实际项目中,通道里会产生大量的数据,这时候就要循环的从通道里读取。

现在改写单向通道写入数据的例子:


func write(data chan<- int) {
for i := 0; i < 10; i++ {
data<-i
}
wg.Done()
}

这段代码是给通道里循环写入数字。

下来使用两种方式循环读取通道数据。


1. 死循环


func read(data <-chan int) {
for {
d := <-data
fmt.Println(d)
}
wg.Done()
}

使用死循环读取数据,但这个有个问题,什么时候退出 for 循环?

read 函数在读取通道时是不知道数据写入完了,如果读取不到数据,它会一直阻塞,因此,如果写数据完成时,需要使用 close 函数关闭通道。


func write(data chan<- int) {
// ...
close(data)
wg.Done()
}

关闭后,读取通道时也需要检测判断。


func read(data <-chan int) {
for {
d, ok := <-data
if !ok {
break
}
fmt.Println(d)
}
wg.Done()
}


  • ok 变量为 false 时,表示通道已关闭。


  • 关闭通道后,ok 变量不会立马变成 false,而是等已放入通道的数据都读取完。



ch := make(chan string, 1)
ch <- "a"
close(ch)
val, ok := <-ch
fmt.Println(val, ok)
val1, ok1 := <-ch
fmt.Println(val1, ok1)
// 输出
a true
false

2. for-range

也可以使用 for-range 语句读取通道,这比死循环使用起来简单一点。


func read(data <-chan int) {
for d := range data{
fmt.Println(d)
}
wg.Done()
}


  • 如果想退出 for-range 语句,也需要关闭通道。


  • 如果关闭通道后,不需要增加 ok 判断,等通道数据读取完,自行会退出。



通道函数

使用 len 函数获取通道里还有多少个消息未读,cap 函数获取通道的缓冲大小


ch := make(chan int, 3)
ch<-1
fmt.Println(len(ch))
fmt.Println(cap(ch))
// 输出
1
3

select 语句

上面已经知道了空 select 语句的作用,现在看看非空 select 的用法。

select 语句 和 switch 语句类似,它也有 case 分支,也有 default 分支,但 select 语句的不同点有两个:



  • case 分支只能是“读通道”或“写通道”,如果读写成功,即不阻塞,则 case 分支就满足。


  • fallthrough 关键字不能使用。



1. 无 default 分支

select 语句会在 case 分支中选择一个可读写成功的通道。

正确例子:


// concurrency/select/main.go
package main
import "fmt"
func main() {
ch1 := make(chan int, 1)
ch2 := make(chan int, 1)
ch1 <- 1
select {
case v, ok := <-ch1:
if ok {
fmt.Println("ch1通道", v)
}
case v, ok := <-ch2:
if ok {
fmt.Println("ch2通道", v)
}
}
}
// 输出
ch1通道 1


  • ch1 通道有数据,因此进入了第一个 case 分支。


  • 这里展示了读通道,也可以给通道写数据,例:case ch2<-2


  • 如果删除 ch1 <- 1select 语句会在 main 函数中一直等待,因此会造成死锁。



fatal error: all goroutines are asleep - deadlock!
goroutine 1 [select]:
main.main()
C:/workspace/go/src/gobasic/cocurrency/select/main.go:9 +0xe7

2. 有 default 分支

为了防止 select 语句出现死锁,可以增加 default 分支。意思就是,当没有一个 case 分支可以进行通道读写,那就走 default 分支。


// ...
func main() {
ch1 := make(chan int, 1)
ch2 := make(chan int, 1)
select {
case v, ok := <-ch1:
if ok {
fmt.Println("ch1通道", v)
}
case v, ok := <-ch2:
if ok {
fmt.Println("ch2通道", v)
}
default:
fmt.Println("没有可读写通道")
}
}
// 输出
没有可读写通道

总结

这节课很关键,也是很容易出现问题的地方,我再针对重点的重点强调一下:



  • 在函数调用前增加 go 关键字,表示创建 Goroutine。


  • 执行 Goroutine 不会同步等待,常用的使用WaitGroup 类型处理。


  • Goroutine 的通信使用通道传输。


  • 无缓冲的通道,不要进行同步读写,不然会阻塞。


最后,再揣摩一句话,不要用共享内存来通信,要用通信来共享内存。




推荐阅读
  • Java太阳系小游戏分析和源码详解
    本文介绍了一个基于Java的太阳系小游戏的分析和源码详解。通过对面向对象的知识的学习和实践,作者实现了太阳系各行星绕太阳转的效果。文章详细介绍了游戏的设计思路和源码结构,包括工具类、常量、图片加载、面板等。通过这个小游戏的制作,读者可以巩固和应用所学的知识,如类的继承、方法的重载与重写、多态和封装等。 ... [详细]
  • 本文介绍了一个Java猜拳小游戏的代码,通过使用Scanner类获取用户输入的拳的数字,并随机生成计算机的拳,然后判断胜负。该游戏可以选择剪刀、石头、布三种拳,通过比较两者的拳来决定胜负。 ... [详细]
  • Go Cobra命令行工具入门教程
    本文介绍了Go语言实现的命令行工具Cobra的基本概念、安装方法和入门实践。Cobra被广泛应用于各种项目中,如Kubernetes、Hugo和Github CLI等。通过使用Cobra,我们可以快速创建命令行工具,适用于写测试脚本和各种服务的Admin CLI。文章还通过一个简单的demo演示了Cobra的使用方法。 ... [详细]
  • 开发笔记:实验7的文件读写操作
    本文介绍了使用C++的ofstream和ifstream类进行文件读写操作的方法,包括创建文件、写入文件和读取文件的过程。同时还介绍了如何判断文件是否成功打开和关闭文件的方法。通过本文的学习,读者可以了解如何在C++中进行文件读写操作。 ... [详细]
  • Java SE从入门到放弃(三)的逻辑运算符详解
    本文详细介绍了Java SE中的逻辑运算符,包括逻辑运算符的操作和运算结果,以及与运算符的不同之处。通过代码演示,展示了逻辑运算符的使用方法和注意事项。文章以Java SE从入门到放弃(三)为背景,对逻辑运算符进行了深入的解析。 ... [详细]
  • 本文整理了Java面试中常见的问题及相关概念的解析,包括HashMap中为什么重写equals还要重写hashcode、map的分类和常见情况、final关键字的用法、Synchronized和lock的区别、volatile的介绍、Syncronized锁的作用、构造函数和构造函数重载的概念、方法覆盖和方法重载的区别、反射获取和设置对象私有字段的值的方法、通过反射创建对象的方式以及内部类的详解。 ... [详细]
  • 使用C++编写程序实现增加或删除桌面的右键列表项
    本文介绍了使用C++编写程序实现增加或删除桌面的右键列表项的方法。首先通过操作注册表来实现增加或删除右键列表项的目的,然后使用管理注册表的函数来编写程序。文章详细介绍了使用的五种函数:RegCreateKey、RegSetValueEx、RegOpenKeyEx、RegDeleteKey和RegCloseKey,并给出了增加一项的函数写法。通过本文的方法,可以方便地自定义桌面的右键列表项。 ... [详细]
  • 本文讨论了使用差分约束系统求解House Man跳跃问题的思路与方法。给定一组不同高度,要求从最低点跳跃到最高点,每次跳跃的距离不超过D,并且不能改变给定的顺序。通过建立差分约束系统,将问题转化为图的建立和查询距离的问题。文章详细介绍了建立约束条件的方法,并使用SPFA算法判环并输出结果。同时还讨论了建边方向和跳跃顺序的关系。 ... [详细]
  • C语言注释工具及快捷键,删除C语言注释工具的实现思路
    本文介绍了C语言中注释的两种方式以及注释的作用,提供了删除C语言注释的工具实现思路,并分享了C语言中注释的快捷键操作方法。 ... [详细]
  • 本文介绍了如何在给定的有序字符序列中插入新字符,并保持序列的有序性。通过示例代码演示了插入过程,以及插入后的字符序列。 ... [详细]
  • 个人学习使用:谨慎参考1Client类importcom.thoughtworks.gauge.Step;importcom.thoughtworks.gauge.T ... [详细]
  • 本文介绍了Swing组件的用法,重点讲解了图标接口的定义和创建方法。图标接口用来将图标与各种组件相关联,可以是简单的绘画或使用磁盘上的GIF格式图像。文章详细介绍了图标接口的属性和绘制方法,并给出了一个菱形图标的实现示例。该示例可以配置图标的尺寸、颜色和填充状态。 ... [详细]
  • 基于Socket的多个客户端之间的聊天功能实现方法
    本文介绍了基于Socket的多个客户端之间实现聊天功能的方法,包括服务器端的实现和客户端的实现。服务器端通过每个用户的输出流向特定用户发送消息,而客户端通过输入流接收消息。同时,还介绍了相关的实体类和Socket的基本概念。 ... [详细]
  • 本文介绍了在Java中检查字符串是否仅包含数字的方法,包括使用正则表达式的示例代码,并提供了测试案例进行验证。同时还解释了Java中的字符转义序列的使用。 ... [详细]
  • 本文介绍了关于Java异常的八大常见问题,包括异常管理的最佳做法、在try块中定义的变量不能用于catch或finally的原因以及为什么Double.parseDouble(null)和Integer.parseInt(null)会抛出不同的异常。同时指出这些问题是由于不同的开发人员开发所导致的,不值得过多思考。 ... [详细]
author-avatar
暗恋达志_227
这个家伙很懒,什么也没留下!
PHP1.CN | 中国最专业的PHP中文社区 | DevBox开发工具箱 | json解析格式化 |PHP资讯 | PHP教程 | 数据库技术 | 服务器技术 | 前端开发技术 | PHP框架 | 开发工具 | 在线工具
Copyright © 1998 - 2020 PHP1.CN. All Rights Reserved | 京公网安备 11010802041100号 | 京ICP备19059560号-4 | PHP1.CN 第一PHP社区 版权所有