12-Go语言并发编程
朋友们,其实当你学到这里,你就正式打开了go语言特色的大门。
高并发编程是每一个程序员追求的梦,2010年,超哥还在用着家里的破电脑艰难的网上冲浪,稍微多开几个软件,电脑就奇卡无比,甚至不能同时打开游戏、音乐、浏览器。。因为配置太低了,为什么要说这个呢?
随着硬件技术的不断发展,更强的CPU、更快的内存,更大的磁盘,都是为了让我们使用计算机能做更多的事,更快的做更多的事。
CPU作为计算机的中央大脑,需要超快速的处理N个任务,以及现代化CPU都是多核优势,那么编程语言在和计算机交互时,能充分利用多核CPU的话,程序运行效率也极高。
例如经典的Python诞生于1989年,那时候计算机还是单核,直到2000以后才诞生多核计算机。
在单核时代,Python作者吉多·范罗苏姆在解释器层面通过GIL全局锁保护Python对象安全,并且充分利用单核CPU。
全局解释器锁(Global Interpreter Lock)是计算机程序设计语言解释器用于同步线程的一种机制。
----它使得任何时刻有且仅有一个线程在执行。-----
即便在多核处理器上,使用GIL的解释器也只允许同一时间执行一个线程,常见的使用GIL的解释器有CPython 和 Ruby MRI。
可以看到GIL并不是Python独有的特性,是解释型语言处理多线程问题的一种机制而非语言特性。
但是多核时代,充分利用CPU的方法就是"并行性",能在同一时刻,同时处理多个程序。

并发编程概念
Go语言诞生于多核CPU时代,天生对多核CPU支持就很友好,这也是Go热火的一个重要因素。
串行、并发、并行
大概理解下这几个名词,理解下计算机的处理任务的三大方式。
串行指只能进行一件事之后才能做另一件事,会发生阻塞情况。
比如一个系统只能支持一个设备在线,这是有一个设备一直在线,另一个设备只能等那个设备下线之后才能上线,这时在线的设备一直不下线,后面的设备就无法上线,效率不高。
串行
任务: 想打饭的学生们
CPU线程:打饭的于超老师
串行: 于超老师在给学生A打饭结束前、不会去处理学生B。
好比你吃泡面,只能先撕开桶面,然后再倒开水,然后再吃。
串行就是CPU处理任务执行完毕1个、再下一个。

并行(parallelism)、平行
并行:直译就是两个及以上的程序同时执行,两条线同时延伸。
明显和串行对比理解,中午12点,到饭点了为了加快打饭效率,学校安排了2个或更多的打饭师傅。
此时多个任务,即可被不同的CPU同时执行,效率很高。
对于多核核计算机而言,并行就是每个
线程分配给独立的CPU核心,多个线程同时在执行。如今基本都是多核CPU,任务都可以并行执行。
并行就是物理角度真实的同时执行。
并行的实现在于代码角度通过多进程/多线程去执行任务。
物理角度就是依赖于多核CPU。
如下的图片就是,多进程/多线程在多核CPU下的任务执行,效率很高。
不用去过多钻研单核CPU,只需要理解,多核CPU可以实现真正的并行执行!!

并发(concurrency)、交替存在
直译:并发就是交替存在,任务交替执行。
是指在一个时间段内、一个时间区间里同时在执行一系列任务,但其实是轮流,穿插着执行多个任务。
例如你同时和微信里的3个姑娘聊天,就是并发。。你不断的切换,一会和小丽说两句,一会给小芳发两句,又担心冷落了小张。。。。我擦。。
因此对于计算机而言,并发就是CPU不断的切换处理的任务,看起来像是同时在运行多个任务了。
操作系统里这种任务交错执行被称为:上下文切换。
并发是指多个任务在逻辑上交替执行的一种程序设计!!

简单看图理解吧,这是Erlang之父画的图,三岁小孩都能看懂。
并发就是多个任务、交替使用一个CPU。
并行就是多个任务,同时使用多个CPU。
串行就是多个任务,按部就班的使用CPU,即使前一个人便秘去蹲了半小时,后面一个人也得搁那等着,明显这个效率是最低的。
最后、在代码角度,我们如何实现并发、并行?这tm到底是什么玩意,我快要懵逼了。
我们就得看看多线程、多进程如何玩了。
再用一个故事解释吧
串行、顺序处理是什么
你陪女朋友看电影(task 1) 2小时
↓
看完后去买衣服(task 2) 30分钟
↓
买完衣服去吃西餐(task 3) 30分钟
↓
吃完饭回家睡觉(task 4) 5分钟
这个过程就是顺序处理,整个过程只有你一个男朋友,并且事情是一件一件做。
如上过程,一共耗时3小时5分钟。
并发处理
你陪女朋友看电影(task 1) 2小时
↓
你让你表弟去把衣服买了,并且提前送到西餐厅(task2)
↓
你提前给餐厅打电话,预约好了位置,点好了餐(task 3)
↓
电影看完后,你直接去西餐厅,立马就吃饭,早点回家睡觉。。(task 4)
这个过程就是并发处理,2小时电影 + 其他过程在电影期间都完成了,全部过程就耗时2小时!!是不是太nice了?
重点就是这里用到了3个CPU,你,表弟,餐厅,在这2小时内各自完成任务。
结论
在并发编程的程序中,我们开启多个线程执行任务,并且当你是多核CPU,每个任务都单独交给了一个CPU,因此就是同时在执行。
所以并行是并发概念的一个子集。

进程(process)
程序在操作系统中的一次执行过程,操作系统进行资源分配和调度的一个单位。
多进程是在操作系统层面并发的基本模式,进程间互不影响,但是开销最大。
[yuc-tx-2 root ~]#ps -ef|wc -l
104
线程(thread)
操作系统基于进程开启的轻量级进程,是操作系统进行调度执行的最小单位。
协程(coroutine)
协程是非操作系统提供而是由用户自行创建和控制的用户态线程,比线程更轻量级。
Coroutine是一种用户态线程,寄存于线程中,系统开销极小,可以有效提高线程任务并发性,使用方式简单,结构清晰,避免多线程的缺点。
执行单位是个抽象的概念,操作系统层面有多个概念与之对应,比如操作系统掌管的进程(process)、进程内的线程(thread)以及进程内的协程(coroutine)。
协程在于轻量级,轻松创建百万个而不会导致系统资源衰竭。
多数语言语法层面不直接支持协程,而是通过库的方式支持,然而库的功能也仅仅是线程的创建、销毁与切换,而无法达到协程调用同一个IO操作,如网络通信,文件读写等。
非抢占式多任务处理,由协程主动交出控制权。消耗的资源更少
编译器/解释器/虚拟机层面的多任务,实现的协程调度。
多个协程可能在一个或多个线程上运行。
哪些语言支持协程?
c++ Boost.Coroutine
Java不支持
Python 用yield关键字实现协程 3.5之后async def对协程支持,异步函数的定义
golang go func(){}
生活化理解进程、线程⭐️⭐️
类似”进程是资源分配的最小单位,线程是CPU调度的最小单位“这样的回答感觉太抽象,都不太容易让人理解。
做个简单的比喻:进程=火车,线程=车厢
- 线程在进程下行进(单纯的车厢无法运行)
- 一个进程可以包含多个线程(一辆火车可以有多个车厢)
- 不同进程间数据很难共享(一辆火车上的乘客很难换到另外一辆火车,比如站点换乘)
- 同一进程下不同线程间数据很易共享(A车厢换到B车厢很容易)
- 进程要比线程消耗更多的计算机资源(采用多列火车相比多个车厢更耗资源)
- 进程间不会相互影响,一个线程挂掉将导致整个进程挂掉(一列火车不会影响到另外一列火车,但是如果一列火车上中间的一节车厢着火了,将影响到所有车厢)
- 进程可以拓展到多机,进程最多适合多核(不同火车可以开在多个轨道上,同一火车的车厢不能在行进的不同的轨道上)
- 进程使用的内存地址可以上锁,即一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存。(比如火车上的洗手间)-"互斥锁"
- 进程使用的内存地址可以限定使用量(比如火车上的餐厅,最多只允许多少人进入,如果满了需要在门口等,等有人出来了才能进去)-“信号量”

进程可以创建或销毁多个线程,同一个进程中的多个线程可以并发执行(如百度云盘进程中的,多个下载任务)。
一个程序至少一个进程,一个进程至少一个线程。
关闭百度网盘进程,下载任务全部结束。
goroutine来了
Goroutine 是 Go 语言特有的名词。
区别于进程 Process,线程 Thread,协程 Coroutine。
因为 Go 语言的创造者们觉得和他们是有所区别的,所以专门创造了 Goroutine。
Goroutines 可以被认为是轻量级的线程。与线程相比,创建 Goroutine 的成本很小,它就是一段代码,一个函数入口。
以及在堆上为其分配的一个堆栈(初始大小为 4K,会随着程序的执行自动增长删除)。因此它非常廉价,Go 应用程序可以并发运行数千个 Goroutines。
Goroutine优势
与线程相比,Goroutine 非常便宜。它们只是堆栈大小的几个 kb,堆栈可以根据应用程序的需要增长和收缩。
而在线程的情况下,堆栈大小必须指定并且是固定的。
Goroutine 被多路复用到较少的 OS 线程。在一个程序中可能只有一个线程与数千个 Goroutine。
如果线程中的任何 Goroutine 都表示等待用户输入,则会创建另一个 OS 线程,剩下的 Goroutine 被转移到新的 OS 线程。
所有这些都由运行时进行处理,我们作为程序员从这些复杂的细节中抽象出来,并得到了一个与并发工作相关的干净的 API。
当使用 Goroutine 访问共享内存时,通过设计的通道(channel)可以防止竞态条件发生。
通道可以被认为是 Goroutine 通信的管道。
goroutine是Go语言里并发执行单元,每一个go进程都有一个
main goroutine,程序运行后自动创建。在Go语言编程中你不需要去自己写进程、线程、协程
你的技能包里只有一个技能——goroutine
当你需要让某个任务并发执行的时候,你只需要把这个任务包装成一个函数,开启一个 goroutine 去执行这个函数就可
了,就是这么简单粗暴。
main goroutine实践
一个go关键字必须对应一个函数/方法。
多个goroutine可以执行相同的函数/方法。
package main
import (
"fmt"
)
func hello() {
fmt.Println("www.yuchaoit.cn golang")
}
func main() {
hello() //
fmt.Println("main thread terminate")
}
图解结果

加上go试试
package main
import (
"fmt"
)
func hello() {
fmt.Println("www.yuchaoit.cn golang")
}
func main() {
go hello() //单独开启一个goroutine去运行hello函数
fmt.Println("main thread terminate")
}
你会发现hello()函数的内容去哪了??咋不打印??

火影忍者里的鸣人,影分身之术大招放出来,要是本体被干掉了,分身直接消失。
让main goroutine慢一点
package main
import (
"fmt"
"time"
)
func hello() {
fmt.Println("www.yuchaoit.cn golang")
}
func main() {
go hello() //单独开启一个goroutine去运行hello函数
fmt.Println("main thread terminate")
//让main goroutine慢一点
time.Sleep(time.Second)
}
结果
➜ goStudy go run main.go
main thread terminate
www.yuchaoit.cn golang
又蒙了

go程序去创建goroutine执行函数有一定的时间,同时main goroutine也还在继续执行。
因此实际开发过程,必然不是用time.Sleep()让main goroutine等待,而是使用sync包处理并发编程。
创建多个goroutine
循环执行go关键字就行了
package main
import (
"fmt"
"time"
)
func hello(i int) {
fmt.Println("hello golang,www.yuchaoit.cn", i)
}
func main() {
fmt.Println("主协程启动了!!!")
//循环开启10个协程,分别执行hello()函数
for i := 0; i < 10; i++ {
go hello(i)
}
time.Sleep(time.Second)
fmt.Println("程序结束!!!")
}
/*
➜ goStudy go run main.go
主协程启动了!!!
hello golang,www.yuchaoit.cn 9
hello golang,www.yuchaoit.cn 0
hello golang,www.yuchaoit.cn 5
hello golang,www.yuchaoit.cn 2
hello golang,www.yuchaoit.cn 4
hello golang,www.yuchaoit.cn 7
hello golang,www.yuchaoit.cn 6
hello golang,www.yuchaoit.cn 3
hello golang,www.yuchaoit.cn 1
hello golang,www.yuchaoit.cn 8
程序结束!!!
*/
//循环开启的10个go协程,并发执行函数,因此没有顺序之分了
但是这个写法还是有问题,如果你仅仅睡眠1秒,你启动的goroutine数量很大,还正常么?
package main
import (
"fmt"
"time"
)
func hello(i int) {
fmt.Println("hello golang,www.yuchaoit.cn", i)
}
func main() {
fmt.Println("主协程启动了!!!")
//循环开启10个协程,分别执行hello()函数
for i := 0; i < 10000; i++ {
go hello(i)
}
//time.Sleep(time.Second)
time.Sleep(time.Nanosecond)
fmt.Println("程序结束!!!")
}
//如上代码运行就有误了
sync包
sync.WaitGroup是用于等待一组goroutine操作完成的方法。
package main
import (
"fmt"
"sync"
)
// 声明全局等待组变量,使用wg就不用担心,你到底要写time.Sleep()等待几秒了
var wg sync.WaitGroup
func hello(i int) {
defer wg.Done() //3.每次goroutine结束就-1
fmt.Println("hello golang,www.yuchaoit.cn", i)
}
func main() {
fmt.Println("主协程启动了!!!")
//循环开启10个协程,分别执行hello()函数
for i := 0; i < 10; i++ {
wg.Add(1) //1.启动一个goroutine,计数器就+1
go hello(i)
}
wg.Wait() //2.等待所有等级的goroutine全部结束
fmt.Println("程序结束!!!")
}
打印行号依然是混乱的,因为goroutine并发执行,谁先抢夺fmt.Println 就优先os.Stdout,并且goroutine调度是随机的。
你爬虫去下载100个网页,循环开启100个goroutine,而main goroutine就需要等待所有下载任务结束。
使用sync.WaitGroup
Sync.Waitgroup
type WaitGroup struct {
noCopy noCopy //禁止复制,传递指针
// 64-bit value: high 32 bits are counter, low 32 bits are waiter count.
// 64-bit atomic operations require 64-bit alignment, but 32-bit
// compilers only guarantee that 64-bit fields are 32-bit aligned.
// For this reason on 32 bit architectures we need to check in state()
// if state1 is aligned or not, and dynamically "swap" the field order if
// needed.
state1 uint64
state2 uint32
}
goroutine调度
- goroutine的运行必须传入函数/方法。
- 操作系统的线程一般是固定栈内存(2MB),而Go语言的goroutine初始栈空间只有(2KB)
- 因此你笔记本for循环创建100w个也是没问题的,并且goroutine的栈不固定,可以动态增大、缩小。
- Go语言在操作系统提供的内核线程之上,Go 搭建了一个特有的两级线程模型。
- Goroutine 机制实现了 M : N 的线程模型,Goroutine 机制是协程(coroutine)的一种实现,Go 内置的调度器,可以让多核 CPU 中每个 CPU 执行一个协程。
// 用go关键字加上一个函数(这里用了匿名函数)
// 调用就做到了在一个新的“线程”并发执行任务
go func() {
// do something in one new goroutine
}()
Go语言的goroutine调度完全由runtime内部实现内部调度系统,按照规则将所有goroutine调度到操作系统线程上执行。
GO语言调度器采用GPM调度模型
G:Goroutine 的简称
用 go 关键字加函数调用的代码就是创建了一个 G 对象,是对一个要并发执行的任务的封装,也可以称作用户态线程。属于用户级资源,对 OS 透明,具备轻量级,可以大量创建,上下文切换成本低等特点。
M:Machine 的简称
在 Linux 平台上是用 clone 系统调用创建的,其与用 Linux pthread 库创建出来的线程本质上是一样的,都是利用系统调用创建出来的 OS 线程实体。
M 的作用就是执行 G 中包装的并发任务。
Go 运行时系统中的调度器的主要职责就是将 G 公平合理的安排到多个 M 上去执行。
其属于 OS 资源,可创建的数量上也受限了 OS,通常情况下 G 的数量都多于活跃的 M 的。
P:Processor 的简称
逻辑处理器,主要作用是管理 G 对象(每个 P 都有一个 G 队列),并为 G 在 M 上的运行提供本地化资源。
Go 运行时系统通过构造 G-P-M 对象模型实现了一套用户态的并发调度系统,可以自己管理和调度自己的并发任务,所以可以说 Go 语言 原生支持并发。
自己实现的调度器负责将并发任务分配到不同的内核线程上运行,然后内核调度器接管内核线程在 CPU 上的执行与调度。

GOMAXPROCS
Go runtime的调度器使用GOMAXPROCS参数,控制用多少个OS线程同时运行goroutine,默认设置为目标机器的CPU核心数。
例如你创建10个goroutine,就均分给10个CPU核心处理,充分利用多核高性能并发执行任务。
package main
import (
"fmt"
"runtime"
"sync"
)
// 声明全局等待组变量,使用wg就不用担心,你到底要写time.Sleep()等待几秒了
var wg sync.WaitGroup
func hello(i int) {
defer wg.Done() //3.每次goroutine结束就-1
fmt.Println("hello golang,www.yuchaoit.cn", i)
}
func main() {
fmt.Println("主协程启动了!!!")
//循环开启10个协程,分别执行hello()函数
for i := 0; i < 5; i++ {
wg.Add(1) //1.启动一个goroutine,计数器就+1
go hello(i)
}
fmt.Printf("当前有%d个goroutine正在运行中。。\n", runtime.NumGoroutine())
wg.Wait() //2.等待所有等级的goroutine全部结束
fmt.Printf("本机CPU核心数:%d\n", runtime.NumCPU())
fmt.Println("程序结束!!!")
}
/*
➜ goStudy go run main.go
主协程启动了!!!
当前有6个goroutine正在运行中。。
hello golang,www.yuchaoit.cn 4
hello golang,www.yuchaoit.cn 2
hello golang,www.yuchaoit.cn 3
hello golang,www.yuchaoit.cn 0
hello golang,www.yuchaoit.cn 1
本机CPU核心数:10
程序结束!!!
*/
默认GOMAXPROCS
Go语言新版本后,默认最大化利用CPU核数。

阅读代码,解读
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func foo() {
for i := 0; i < 5; i++ {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println(i)
}()
}
wg.Wait()
}
func main() {
foo()
}
/*
➜ goStudy go run main.go
5
5
3
5
5
➜ goStudy go run main.go
5
5
5
5
4
➜ goStudy
结果是混乱的,为什么没有是0,1,2,3,4这种?顺序乱的没问题。
*/
如何
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func foo() {
for i := 0; i < 5; i++ {
wg.Add(1)
//开5个协程
//若是主动传入5个值,那没问题,首次传入0,然后传入1,然后2 ,
go func(num int) {
defer wg.Done()
fmt.Println(num)
}(i)
}
wg.Wait()
}
func main() {
foo()
}
/*
➜ goStudy go run main.go
4
0
3
2
1
➜ goStudy go run main.go
1
0
4
3
2
*/
坑
使用goroutine的坑,注意使用的协程内的参数到底是副本?还是引用类型?是否要传参!!

channel通道
在Go语言中,关键字go的引入使得Go语言并发编程更加简单而优雅,但是并发编程的复杂性,以及时刻关注并发编程容易出现的问题需要时刻警惕。
并发编程的难度在于协调,然而协调就必须要交流,那么并发单元之间的通信是最大的问题。
单纯的让函数并发去执行意义不大,意义在于并发函数执行之间可以交换数据,才能体现其意义。
并发编程里有2个主流模型:共享内存数据、通信顺序进程。
- 共享内存是指并发单元,如多线程操作同一个数据,对数据共享,可能是内存数据,可能是磁盘文件,网络字节数据。
- Go语言用CSP并发模型,
以消息通信而非共享内存作为通信方式。
channel是什么
goroutine是Go语言并发执行的执行体。channel是goroutine并发单元之间的通道。- channel是让一个goroutine发送值到另一个goroutine的通信机制。
- channel是go语言在语言级别提供的goroutine间的通信方式。
- channel是有类型的,一种channel只能传递一种类型值,这个类型在声明channel时定义。
- 一个string类型的channel只能放入string类型数据。
- channel本质是一个数据结构,结构体
- channel数据遵循
FIFO,first in first out,先入先出,保证收发数据的顺序。 - channel本身是线程安全的,多个goroutine访问时不需要加锁。
channel类型
声明语法
var 通道名 chan 元素类型
var intChan chan int
var strChan chan string
var stuChan chan Student //结构体类型channel
var mapChan chan map[string]string //map类型channel
var boolChan chan bool //布尔类型channel
var scores chan []int //存放int切片的 通道
零值channel
package main
import "fmt"
func main() {
//channel是引用类型,必须分配内存后使用
var ch chan int
fmt.Printf("通道类型:%T、值:%v\n", ch, ch) // 通道类型:chan int、值:<nil>
//初始化channel,使用内置函数make()分配内存
//语法 make(chan 元素类型,[缓冲大小])
ch2 := make(chan int, 5) //缓冲区为5的通道,可写可不写
fmt.Printf("通道类型:%T、值:%v\n", ch2, ch2) // 通道类型:chan int、值:0xc0000b8000
}
channel读写玩法
package main
import "fmt"
func main() {
//使用符号 <- 操作channel
intChan := make(chan int, 3) //缓冲区3
//放入数据,只能放int类型
intChan <- 1 //元素1
n1 := 6
intChan <- n1 //元素2
intChan <- 9 //元素3
//intChan <- 4 再放入数据就报错了
fmt.Printf("intChan通道类型:%T、值:%v、通道元素个数:%d\n", intChan, intChan, len(intChan))
//接受通道的值,遵循先进先出
res1 := <-intChan
fmt.Println(res1)
res2 := <-intChan
fmt.Println(res2)
//也可以直接丢弃通道内的值
<-intChan
//<-intChan //依然注意,明显此时通道内没元素了,再拿东西,就会panic崩溃
//关闭通道,不主动关也没事,没有goroutine调用的话会被GC回收,当然应该更优化的关闭
close(intChan)
}
close关闭channel细节
关于关闭 channel 有几点需要注意的是:
- 重复关闭 channel 会导致 panic。
- 向关闭的 channel 发送数据会 panic。
- 从关闭的 channel 读数据不会 panic,读出 channel 中已有的数据之后再读就是 channel 类似的默认值,比如 chan int 类型的 channel 关闭之后读取到的值为 0。
package main
import "fmt"
func main() {
//使用符号 <- 操作channel
intChan := make(chan int, 3) //缓冲区3
//放入数据,只能放int类型
intChan <- 1 //元素1
n1 := 6
intChan <- n1 //元素2
intChan <- 9 //元素3
//intChan <- 4 再放入数据就报错了
fmt.Printf("intChan通道类型:%T、值:%v、通道元素个数:%d\n", intChan, intChan, len(intChan))
//接受通道的值,遵循先进先出
res1 := <-intChan
fmt.Println(res1)
//关闭通道,不主动关也没事,没有goroutine调用的话会被GC回收,当然应该更优化的关闭
close(intChan)
//再通道关闭后,依然取值,会拿走剩余数据,以及得到对应类型的零值
v := <-intChan
v1 := <-intChan
v2 := <-intChan
v3 := <-intChan
v4 := <-intChan
v5 := <-intChan
fmt.Println(v, v1, v2, v3, v4, v5)
//通道不得重复关闭
//close(intChan)
}
channel数据类型实践
Map
package main
import "fmt"
func main() {
//map类型 channel
//stusScore := make(map[string]int)
stusScore := map[string]int{
"小王": 88,
"小李": 99,
}
heroInfo := map[string]int{
"李信": 18888,
"凯": 13888,
}
ch1 := make(chan map[string]int, 50)
//先入先出
ch1 <- stusScore
ch1 <- heroInfo
fmt.Printf("通道类型:%T、元素数量:%d、值:%v\n", ch1, len(ch1), ch1)
//通道取值
//先入先出
res1 := <-ch1
fmt.Printf("%v\n", res1)
res2 := <-ch1
fmt.Printf("%v\n", res2)
}
其他如结构体、指针、空接口都一样用法。
无缓冲channel

无缓冲的通道也叫做阻塞通道
package main
import "fmt"
func main() {
ch1 := make(chan int)
ch1 <- 666
fmt.Println("done")
}
/*
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [chan send]:
main.main()
/Users/yuchao/goStudy/main.go:7 +0x31
exit status 2
*/
如上代码是报错的,所谓无缓冲,你发送数据给通道,必须有另一个人接受,否则就一直处于等待发生的状态。
同理,你对无缓冲通道进行接收,如果没人发送数据也一样阻塞卡死。
如何解决?
让2个人玩这个通道游戏就好了么。
package main
import "fmt"
func game(ch chan int) {
res := <-ch //独立协程取数据
fmt.Printf("channel取值成功:%v \n", res)
}
func main() {
ch1 := make(chan int)
go game(ch1) //得先创建好2个玩家且等待中
ch1 <- 666 //主协程写入
fmt.Println("发送成功")
}
代码执行结果依然不固定,前面说了就看main goroutine和game goroutine谁先执行了。
这种无缓冲通道进行goroutine通信也叫做goroutine同步化,也叫做同步通道。
有缓冲channel

package main
import "fmt"
func main() {
ch1 := make(chan int, 3)
ch1 <- 666
ch1 <- 999
ch1 <- 888
fmt.Printf("ch1元素个数:%d\n", len(ch1))
//如果再继续放元素,就会死锁报错
}
只要channel初始化容量大于0就是有缓冲通道,容量标识可以存放的最大元素个数的数量。
如果再继续放元素,就会死锁报错,理解为蜂巢快递柜,总共10个格子,满了你还想放??
除非有人拿走快递,给你留一个坑,你才可以放1个。
channel返回值
前面说了可以close关闭通道,好比你让丰巢快递柜下线,不用了!
但关闭后,你还想往channel放元素,那肯定报错,那该如何避免pannic?
channel提供了两个返回值,让我们判断通道状态。
package main
import "fmt"
/*
value, ok := <- ch
value:从通道中取出的值,如果通道被关闭则返回对应类型的零值。
ok:通道ch关闭时返回 false,否则返回 true。
*/
func f1(ch chan int) {
for {
v, ok := <-ch
if !ok {
fmt.Println("channel已关闭")
break
}
fmt.Printf("值:%v、状态:%v\n", v, ok)
}
}
func main() {
ch1 := make(chan int, 3)
ch1 <- 666
ch1 <- 999
ch1 <- 888
fmt.Printf("ch1元素个数:%d\n", len(ch1))
close(ch1) //主动关闭通道
//如果还有函数会去操作通道
f1(ch1)
}
优雅循环channel
package main
import "fmt"
/*
value, ok := <- ch
value:从通道中取出的值,如果通道被关闭则返回对应类型的零值。
ok:通道ch关闭时返回 false,否则返回 true。
*/
func f1(ch chan int) {
//for range 循环可以很方便的从通道中接收值,且数据取完后自动退出循环
for v := range ch {
fmt.Printf("值:%v\n", v)
}
fmt.Println("channel值已取完。")
}
func main() {
ch1 := make(chan int, 3)
ch1 <- 666
ch1 <- 999
ch1 <- 888
fmt.Printf("ch1元素个数:%d\n", len(ch1))
close(ch1) //主动关闭通道
//如果还有函数会去操作通道
f1(ch1)
}
单向通道

经典的生产消费者模式可以利用单向通道实现、
Producer函数只作用于向channel写入数据,发送完毕后关闭channel。
Consumer函数只从channel接收值进行计算处理。
语法
var ch1 chan int //可以读写channel,存放int元素
var ch2 chan <- string //只写channel,只能写入string类型元素
var ch3 <- chan string //只读channel,只能读取string类型元素
普通channel用法
package main
import "fmt"
//生产者,包子加工厂,返回channel
//持续将质量过关的包子,发到channel
//数据发完后,关闭通道,包子制作结束
func Producer() chan string {
ch := make(chan string, 2)
//开一个协程生产包子
go func() {
for i := 0; i < 10; i++ {
//奇数包子合规
if i%2 == 1 {
ch <- fmt.Sprintf("包子%d号", i)
}
}
//循环结束,关闭通道
close(ch)
}()
return ch
}
// 消费者,吃包子,从通道里拿包子
func Consumer(ch chan string) {
for baozi := range ch {
fmt.Printf("消费者吃掉了---%s\n", baozi)
}
}
func main() {
//生产包子
ch := Producer()
//消费者来了
Consumer(ch)
}
单向channel用法
<- chan int // 只接收通道,只能接收不能发送
chan <- int // 只发送通道,只能发送不能接收
其中,箭头<-和关键字chan的相对位置表明了当前通道允许的操作,这种限制将在编译阶段进行检测。
另外对一个只接收通道执行close也是不允许的,因为默认通道的关闭操作应该由发送方来完成。
使用单向 channel 编程体现了一种非常优秀的编程范式:convention over configuration,中文一般叫做 约定优于配置。
代码
package main
import "fmt"
// 生产者,包子加工厂,返回channel
// 持续将质量过关的包子,发到channel
// 数据发完后,关闭通道,包子制作结束
// 这里的返回值是普通channel,改造为只读channel,只能拿数据
func Producer() <-chan string {
ch := make(chan string, 2)
//开一个协程生产包子
go func() {
for i := 0; i < 10; i++ {
//奇数包子合规
if i%2 == 1 {
ch <- fmt.Sprintf("包子%d号", i)
}
}
//循环结束,关闭通道
close(ch)
}()
return ch
}
// 消费者,吃包子,从通道里拿包子
// 此时参数是只读通道
func Consumer(ch <-chan string) {
for baozi := range ch {
fmt.Printf("消费者吃掉了---%s\n", baozi)
}
}
func main() {
//生产包子
ch := Producer()
//消费者来了
Consumer(ch)
}
单向通道在标准库里有大量的写法,属于是标准写法,从编译器角度限制了channel的用法,更规范。

总结channel用法

select
select 一定程度上可以类比于 linux 中的 IO 多路复用中的 select。
后者相当于提供了对多个 IO 事件的统一管理,而 Golang 中的 select 相当于提供了对多个 channel 的统一管理。
当然这只是 select 在 channel 上的一种使用方法。
select关键字类似于switch语句,有一系列的case分支和一个default分之。
每个case对应一个channel的读、写过程。
select会一直等待,直到某个case下的channel通信完毕,执行该case语句。
select {
case <-chan1:
//如果读取到channel的数据,就执行这里
case chan2<-1:
//如果成功向chan2写入数据,就执行这里
default:
//上述都失败了,进入这里
}
select特点
- 可以处理1个或多个channel的收、发操作
- 如果多个case同时满足,select随机选择一个执行
- 没有case的select语句会一直阻塞,例如用于阻塞main协程,防止程序结束。
- 如下代码,证明了select特点
package main
import (
"fmt"
)
func main() {
//定义一个管道
intChan := make(chan int, 10)
//循环写入数据
for i := 0; i < 5; i++ {
intChan <- i
}
//定义管道,可以写入string
strChan := make(chan string, 5)
for i := 0; i < 5; i++ {
//格式化后写入string数据
strChan <- "hello" + fmt.Sprintf("www.yuchaoit.cn %d", i)
}
//传统的for循环遍历,必须close关闭channel,否则造成死锁
//但是到底关闭哪个channel,并不那么容易选择
//加上for无限循环,匹配所有的case,直到结束return
for {
select {
//如果有数据被读取到,进入这个分支,并且intChan没关闭的话,也会自动匹配下一个case
case v := <-intChan:
fmt.Printf("intChan读取到数据%d\n", v)
//time.Sleep(time.Second)
case v2 := <-strChan:
fmt.Printf("\t strChan中读取到数据%s\n", v2)
//time.Sleep(time.Second)
default:
fmt.Printf("什么也没读到,再见\n")
//time.Sleep(time.Second)
return
}
}
}
并发编程与锁
- 进程使用的内存地址可以上锁,即一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存。(比如火车上的洗手间)-"互斥锁"
- 进程使用的内存地址可以限定使用量(比如火车上的餐厅,最多只允许多少人进入,如果满了需要在门口等,等有人出来了才能进去)-“信号量”
在并发编程里,很可能因为多个goroutine同时操作一个资源,出现资源竞争问题。
如下是3个协程做+1动作,结果就是3000应该是期望的。
package main
import (
"fmt"
"sync"
)
var (
nums int
wg sync.WaitGroup
)
// 测试资源竞争问题
func add() {
for i := 0; i < 1000; i++ {
//本希望是循环+1操作,就是+1000而已
nums = nums + 1
}
//等待goroutine结束
wg.Done()
}
func main() {
//开3个协程执行函数,想着是不是能快点?3个1000,期望是3000总和
for i := 0; i < 3; i++ {
wg.Add(1)
go add()
}
wg.Wait()
fmt.Println("nums+1 三千次的结果:", nums)
}
/*
➜ goStudy for i in {1..5};do go run main.go;done
nums+1 三千次的结果: 2039
nums+1 三千次的结果: 2298
nums+1 三千次的结果: 3000
nums+1 三千次的结果: 2255
nums+1 三千次的结果: 2272
*/
可以看到,当你开启2个或更多的协程去运行函数,特别是修改全局变量的值就会出现数据竞争,某个goroutine修改了nums为50,另一个goroutine修改了nums为43,覆盖了之前的操作,导致结果错乱。
互斥锁
竞态条件:多线程的核心矛盾是“竞态条件”,即多个线程同时读写某个字段。
竞态资源:竞态条件下多线程争抢的是“竞态资源”。
临界区:涉及读写竟态资源的代码片段叫“临界区”。
互斥:保证竟态资源安全的最朴素的一个思路就是让临界区代码“互斥”,即同一时刻最多只能有一个线程进入临界区。

互斥锁能保证同一时间只有一个goroutine可以访问共享资源,是常见控制共享资源的方法。
Go语言提供了sync包的Mutex类型创建互斥锁。
sync.Mutex提供了两个方法供我们使用。
方法名 功能
func (m *Mutex) Lock() 获取互斥锁
func (m *Mutex) Unlock() 释放互斥锁
创建互斥锁
package main
import (
"fmt"
"sync"
)
// 创建所
var (
nums int64
wg sync.WaitGroup
m sync.Mutex //互斥锁,结构体类型
)
// 执行1000次+1
func add() {
for i := 0; i < 10000; i++ {
//协程操作变量前加锁(门关起来,防止其他人进来捣乱)
m.Lock()
nums += 1 //计算操作
//你个人完事后,你得解锁,让别人也玩一玩啊
m.Unlock()
}
wg.Done() //等待组,协程一个自动减1个
}
func main() {
for i := 0; i < 3; i++ {
wg.Add(1)
go add()
}
//main goroutine等待
wg.Wait()
fmt.Println("nums总和:", nums)
}
/*
➜ goStudy for i in {1..5};do go run main.go;done
nums总和: 30000
nums总和: 30000
nums总和: 30000
nums总和: 30000
nums总和: 30000
*/
互斥锁保证同一时间有且只有一个goroutine进行计算操作,其他的goroutine就等着锁释放吧!
互斥锁释放后,其他等待的goroutine才有机会获取锁,然后进去临界区。
多个goroutine等待同一个锁时,唤醒的策略是随机的。
读写互斥锁
互斥锁是完全互斥的,但是实际上有很多场景是读多写少的。
当我们并发的去读取一个资源而不涉及资源修改的时候是没有必要加互斥锁的。
这种场景下使用读写锁是更好的一种选择。
读写锁在 Go 语言中使用sync包中的RWMutex类型。
sync.RWMutex提供了以下5个方法。
| 方法名 | 功能 |
|---|---|
| func (rw *RWMutex) Lock() | 获取写锁 |
| func (rw *RWMutex) Unlock() | 释放写锁 |
| func (rw *RWMutex) RLock() | 获取读锁 |
| func (rw *RWMutex) RUnlock() | 释放读锁 |
| func (rw *RWMutex) RLocker() Locker | 返回一个实现Locker接口的读写锁 |
读写锁分为两种:读锁和写锁。
当一个 goroutine 获取到读锁之后,其他的 goroutine 如果是获取读锁会继续获得锁,如果是获取写锁就会等待;
而当一个 goroutine 获取写锁之后,其他的 goroutine 无论是获取读锁还是写锁都会等待。

下面我们使用代码构造一个读多写少的场景,然后分别使用互斥锁和读写锁查看它们的性能差异。
package main
import (
"fmt"
"sync"
"time"
)
//0.全局先创建锁
var (
nums int64
wg sync.WaitGroup
m sync.Mutex
rwM sync.RWMutex
)
//1. 用互斥锁的写操作
func wLock() {
m.Lock()
nums += 1 //模拟写入,修改操作耗时,10ms
time.Sleep(10 * time.Millisecond)
m.Unlock()
wg.Done()
}
// 2.模拟读取数据,互斥锁
func rLock() {
m.Lock()
time.Sleep(1 * time.Millisecond) //读的快,1ms
m.Unlock()
wg.Done()
}
// 3.用读写锁、模拟写入
func wRWLock() {
rwM.Lock() //上一把写锁,其他goroutine依然是无法读、写
time.Sleep(10 * time.Millisecond)
rwM.Unlock() //释放写锁
wg.Done()
}
// 4.用读写锁,模拟读取,是允许并发读取的
func rRWLock() {
rwM.RLock() //读锁,其他goroutine可以并发读取
time.Sleep(1 * time.Millisecond)
rwM.RUnlock() //释放读锁
wg.Done()
}
// 5.并发goroutine,测试锁的性能
func start(wl, rl func(), wn, rn int) {
start := time.Now() //开始时间
//wn write nums,模拟多少个写入并发
for i := 0; i < wn; i++ {
wg.Add(1)
go wl()
}
//rl read lock,模拟读取并发
for k := 0; k < rn; k++ {
wg.Add(1)
go rl()
}
wg.Wait() //等待协程结束
stop := time.Since(start)
fmt.Printf("nums结果:%d、耗时:%s\n", nums, stop)
}
func main() {
//互斥锁,10并发写,1000并发读
start(wLock, rLock, 10, 1000)
//读写互斥锁,10并发写,1000并发读
start(wRWLock, rRWLock, 10, 1000)
}
/*
➜ goStudy go run main.go
nums结果:10、耗时:1.252027375s 互斥锁
nums结果:10、耗时:112.702459ms 读写锁
结论,在读写互斥锁,并且是读多、写少的场景下,可以大幅度提高程序性能,仅测试与大并发请求下
*/
等待组
前面也测试了在go协程执行任务时,main goroutine需要等待其他协程,使用sync.WaitGroup实现并发任务的同步。
sync.WaitGroup有如下几个方法
| 方法名 | 功能 |
|---|---|
| func (wg * WaitGroup) Add(delta int) | 计数器+delta |
| (wg *WaitGroup) Done() | 计数器-1 |
| (wg *WaitGroup) Wait() | 阻塞直到计数器变为0 |
sync.WaitGroup内部维护着一个计数器,计数器的值可以增加和减少。例如当我们启动了 N 个并发任务时,就将计数器值增加N。
每个任务完成时通过调用 Done 方法将计数器减1。
通过调用 Wait 来等待并发任务执行完,当计数器值为 0 时,表示所有并发任务已经完成。
sync.Map
go语言的map如果是单goroutine读写没问题,多个协程并发访问map就会出现数据竞争问题。
package main
import (
"fmt"
"strconv"
"sync"
)
// map生命
var m = make(map[string]int)
// 封装函数,读取map值
func get(key string) int {
return m[key]
}
// 封装函数,写入map,k:v
func set(key string, value int) {
m[key] = value
}
func main() {
wg := sync.WaitGroup{}
for i := 0; i < 100; i++ {
wg.Add(1)
//测试用100个协程,并发写入map,k,v
go func(n int) {
key := strconv.Itoa(n)
set(key, n)
fmt.Printf("k=:%v,v:=%v\n", key, get(key))
wg.Done()
}(i)
}
wg.Wait()
}
/*
➜ goStudy go run main.go
fatal error: concurrent map writes
fatal error: concurrent map writes
fatal error: concurrent map writes
*/
Go语言提供了sync.Map并发安全版,内置了锁来确保并发写入map安全性。
开箱即用,表示sync.Map不需要像普通map先make初始化再使用。
| 方法名 | 功能 |
|---|---|
| func (m *Map) Store(key, value interface{}) | 存储key-value数据 |
| func (m *Map) Load(key interface{}) (value interface{}, ok bool) | 查询key对应的value |
| func (m *Map) LoadOrStore(key, value interface{}) (actual interface{}, loaded bool) | 查询或存储key对应的value |
| func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) | 查询并删除key |
| func (m *Map) Delete(key interface{}) | 删除key |
| func (m *Map) Range(f func(key, value interface{}) bool) | 对map中的每个key-value依次调用f |
下面的代码示例演示了并发读写sync.Map。
package main
import (
"fmt"
"strconv"
"sync"
)
// 并发安全的map,是一个结构体
var m = sync.Map{}
func main() {
wg := sync.WaitGroup{}
//10个并发读写map
for i := 0; i < 100; i++ {
wg.Add(1)
//匿名函数,协程去执行
go func(n int) {
key := strconv.Itoa(n)
m.Store(key, n) //写入k:v
v, _ := m.Load(key) //根据key取值
fmt.Printf("k--%v \t v--%v\n", key, v)
wg.Done()
}(i)
}
wg.Wait()
}
/*
➜ goStudy go run main.go|wc -l
100
*/