Jochen的golang小抄-进阶篇-并发编程
小抄系列进阶篇涉及的概念较多,知识点重要,故每块知识点独立成篇,方便日后笔记的查询
本篇的主题是:并发编程
在前面我们介绍了go并发模型以及其实现原来,本章要介绍的是go语言中常用的并发编程操作以及要注意的问题
runtime包
runtime包下包含了go运行时系统交互的操作(如控制goroutine函数)、反射包的使用、运行时信息
尽管go编译器产生的是本地可执行的代码,但是这些代码仍然需要运行在go的runtime中,这个runtime就类似于Java和.NET语言中的虚拟机,负责管理内存分配、垃圾回收、堆栈处理、反射等
接下来我们介绍runtime包一些函数
package main
import (
"fmt"
"runtime"
"time"
)
func main() {
/*
runtime包常用函数
*/
//runtime.GOROOT:获取goroot目录
rootPath := runtime.GOROOT()
fmt.Println(rootPath) ///usr/local/go
//runtime.GOOS:获取操作系统
osName := runtime.GOOS
fmt.Println(osName) //linux
//runtime.NumCPU:获取当前计算机逻辑cpu数量(cpu是几核的)
cpuName := runtime.NumCPU()
fmt.Println(cpuName) //4
//runtime.GOMAXPROCS:设置go程序运行期间最大的P数量:[1,256] 如果小于1则为默认的逻辑cpu数
//一般设置为自己电脑的核心数,GO1.8后默认让程序执行在多核上,所以可以不用设置了
n := runtime.GOMAXPROCS(runtime.NumCPU()) //返回的是设置之前的数量
fmt.Println(n) //4
//runtime.Gosched:让当前的goroutine让出当前执行的时间片给其他线程执行
/*
当一个goroutine发生阻塞的时候,go会自动的把与该goroutine处于同一系统线程的其他goroutine转移
转移当另一个系统线程去,使得这些goroutine不会一同被阻塞
*/
go func() {
for i := 0; i < 8; i++ {
fmt.Println("子goroutine")
}
}()
//普通的执行逻辑是,两个goroutine谁先抢到cpu资源就谁先执行
for i := 0; i < 6; i++ {
runtime.Gosched() //主协程主动让出自己的执行时间片
//这边让出完后,已经让出的紧接着又可以去抢占资源
//所以在这个循环打印中前面让出的时间片后面又可以继续抢占资源,执行的结果就会不一样
fmt.Println("主goroutine")
}
//终止当前的goroutine,但是当前的goroutine中的defer函数还是会被执行
go func() {
fmt.Println("goroutine开始")
//调用f
f()
fmt.Println("goroutine结束了")
}()
/*
输出:
goroutine开始
我是defer
可以看到,f()中使用了runtime.Goexit,所以当前协程被终止了,后面也不会输出了
*/
//为了保证主程序不结束,睡一会
time.Sleep(2 * time.Second)
}
func f() {
defer fmt.Println("我是defer")
runtime.Goexit()
fmt.Println("我被终止没有输出")
}
临界资源
什么是临界资源
临界资源:
临界资源即共享资源,其主要指的是并发环境中多个进程/线程/协程共享的资源
临界资源安全问题
临界资源安全问题也就是以往我们常听到的线程安全问题
在并发环境下,如果临界资源处理不当可能会导致数据一致性出现问题,也就是说如果多个goroutine
在访问同一个数据资源的时候,其中一个线程修改了数据,那么这个数值就被修改了,对于其他的goroutine
来讲,这个数值可能是不对的。
如下有一段资源争夺(data race
(数据资源竞争))的代码:
package main
import (
"fmt"
"time"
)
func main() {
/*
临界资源
*/
n := 1 //在主协程和子协程都可以共同访问,这就是一个临界资源
go func() {
n = 2
fmt.Println("子goroutine中,n的值为:", n)
}()
n = 3
time.Sleep(2 * time.Second)
fmt.Println("主goroutine中,n的值为:", n)
/*
输出:
子goroutine中,n的值为: 2
主goroutine中,n的值为: 2
分析可以得出:主协程先持有资源,将n改为3,然后进入睡眠 此时子协程抢占cpu执行时间片,
将n改为2,所以最终输出的结果都是n=2
按正常的顺序执行的逻辑分析,我们想要的效果是子协程中打印n=2,主协程打印n=3,但是,
在主协程中先修改了n=3,再要打印却未答应的时候,被子协程抢占了cpu资源,改变了n的数值,
导致了我们数据出现了不一致的情况
*/
}
拓展:使用go的CLI
命令编译或者执行go源文件的时候,添加-race
选项可以看到竞争资源的分析情况:
[jochen@jochen-inspiron7559 concurrency_goroutine]$ go run -race race1.go
==================
WARNING: DATA RACE #表示发现数据竞争问题
Write at 0x00c000134010 by goroutine 7: #在编号为7的子协程中访问了变量n
main.main.func1()
/home/GoWorkSpace/src/concurrency_goroutine/race1.go:14 +0x3c
Previous write at 0x00c000134010 by main goroutine: #在主协程中又访问了变量n
main.main()
/home/GoWorkSpace/src/concurrency_goroutine/race1.go:17 +0x88
Goroutine 7 (running) created at:
main.main()
/home/GoWorkSpace/src/concurrency_goroutine/race1.go:13 +0x7a
==================
子goroutine中,n的值为: 2
主goroutine中,n的值为: 2
Found 1 data race(s) #表示发现了一个数据竞争(race是竞争的意思)
exit status 66
再看一个卖票的例子,熟悉下data race
(数据竞争)问题,即共享资源在并发状态的不安全问题
package main
import (
"fmt"
"math/rand"
"time"
)
//全局变量,票的数量
var ticketNums = 10
func main() {
/*
模拟高铁售票:
启用4个goroutine模拟四个售票口,四个售票口同时执行,表示四个售票口同时卖票
*/
go saleTickets("售票口1")
go saleTickets("售票口2")
go saleTickets("售票口3")
saleTickets("售票口4") //保留一个在主goroutine调用,避免主goroutine提前结婚苏
/*
多次执行发现输出结果发现余票居然有时候会出现负数,为什么会这样呢?在下面的卖票函数中有过程分析和解释
*/
}
//卖票
func saleTickets(name string) {
rand.Seed(time.Now().UnixNano()) //随机数睡眠
for {
//1.为什么余票可能为负数?
if ticketNums > 0 {
//1.1协程执行到此处后进入睡眠,让出自己的时间片,其他的协程就会来抢占cpu的是时间片
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)//睡眠,模拟被其他协程抢占cpu时间片
/*
1.2当抢占到时间片后,恢复执行,此时即使票已经卖光了,但是由于该协程会保留了休眠前的执行上下文,
协程还是会恢复休眠前位置继续往下执行,所以导致了可能会出现票数为负数的情况出现
*/
fmt.Println("剩余票数:", ticketNums)
fmt.Println(name, "售票一张")
ticketNums-- //1.3可能此时的ticketNum已经被别的协程置为0,或者-x
} else {
fmt.Println(name, "没票卖啦")
break
}
}
}
如何解决临界资源安全问题
临界资源安全问题其实就是平时我们经常说到的线程安全问题,即共享资源在并发环境下出现 data race
进而引发数据不一致的问题
线程安全问题的一般都是使用同步的解决方案,实现同步一般就是通过把共享资源加锁的方式
把一个资源加锁后,在某一时间段内,该资源只能允许一个goroutine
访问。当前goroutine
访问完毕,则释放锁,此时资源才可被其他goroutine
访问
在go语言中,可以通过sync包
下的锁操作给资源上锁
对于锁、原子操作等其他语言中常见的为了解决线程安全问题所使用的同步机制,go语言也有相应的实现库,下面我们逐一了解诶它们
同步(sync)包
为了解决共享资源的线程安全问题,一般会使用同步操作
go语言将同步相关操作定义在sync
包下
sync包提供了基本的同步基元,如互斥锁。除了Once和WaitGroup类型,大部分都是适用于低水平程序线程,高水平的同步使用channel通信更好一些
同步等待组(WaitGroup)
type WaitGroup struct {
// 包含隐藏或非导出字段
}
WaitGroup
用于等待一组goroutine
的结束
在每一个WaitGroup
中都维护一个计数器,用以记录要等待执行goroutine
的数量,可以通过父线程调用Add
方法来设定应等待的线程的数量
每个被等待的线程在结束时应调用Done
方法,来表示把计数器的数值-1,当计数器为0时,父goroutine
同步阻塞结束
同时,主协程里可以调用Wait
方法阻塞至所有线程结束
//Add方法向内部计数加上delta,delta可以是负数;如果内部计数器变为0,Wait方法阻塞等待的所有线程都会释放,如果计数器小于0,方法panic
func (wg *WaitGroup) Add(delta int)
func (wg *WaitGroup) Done() //Done方法减少WaitGroup计数器的值,应在线程的最后执行。
func (wg *WaitGroup) Wait() //Wait方法阻塞直到WaitGroup计数器减为0。
/*
注意Add加上正数的调用应在Wait之前,否则Wait可能只会等待很少的线程。一般来说本方法应在创建新的线程或者其他应等待的事件之前调用。
*/
牛刀小试
package main
import (
"fmt"
"sync"
)
//1.1创建全局同步等待组对象
var wg sync.WaitGroup
func main() {
/*
WaitGroup : 同步等待组
Add() 设置等待组中要执行的子goroutine的数量
Done() 让等待组计数器-1
Wait() 让主goroutine等待
*/
//1.使用同步等待组同步等待子协程结束
wg.Add(2) //等待子协程数为2
go test1()
go test2() //直接这样执行,主协程结束子goroutine也会结束,所以需要使用同步等待组
fmt.Println("主goroutine阻塞...等待子goroutine执行结束")
wg.Wait() //阻塞等待
fmt.Println("主goroutine解除阻塞")
}
func test1() {
for i := 1; i < 10; i++ {
fmt.Println("test1函数打印:A", i)
}
wg.Done() //给wg等待组中的计数器-1,实际内部调用的Add(-1)
}
func test2() {
defer wg.Done() //给wg等待组中的计数器-1
for i := 1; i < 10; i++ {
fmt.Println("\ttest2函数打印:B", i)
}
}
可能遇到的错误:
fatal error: all goroutines are asleep - deadlock!
主线程一直等不到计数器被设置为0,就会一直阻塞等待,造成死锁。一般出现在在于你的子
goroutine
忘记调用Done方法
或者是你的Add
方法设置的计数器大于要等待的子协程数量
互斥锁
互斥锁对象也定义在同步包下
互斥锁:也称为同步锁,用以资源访问同步操作。当协程访问某一共享资源时可上锁,上锁后的资源不可被其他的协程访问,待访问结束后,该协程释放锁,此时该资源才能被其他协程访问
在go语言中,互斥锁并非是锁主一个对象,而锁的是一段代码(上锁和解锁之间的代码段)
对于每一个资源,都对应一个互斥锁的标记,该标记用来保证在一个时刻只能有一个goroutine
来访问资源,其他goroutine
只能等待
互斥锁对象定义如下:
type Mutex struct {
// 包含隐藏或非导出字段
}
Mutex是一个互斥锁,可以创建为其他结构体的字段;零值为解锁状态。Mutex类型的锁和线程无关,可以由不同的线程加锁和解锁。
Mutex
中两个重要的方法为Lock
(上锁)和UnLock
(释放锁)
func (m *Mutex) Lock() //Lock方法锁住m,如果m已经加锁,则阻塞直到m解锁。
func (m *Mutex) Unlock() //Unlock方法解锁m,如果m未加锁会导致运行时错误。锁和线程无关,可以由不同的线程加锁和解锁
下面使用互斥锁解决上面售票案例中,余票为可能为负数的临界资源安全问题
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
//全局变量,票的数量
var ticketNums = 10
//互斥锁对象
var mutex sync.Mutex //可以理解创建一把锁头
var wg sync.WaitGroup
func main() {
/*
模拟高铁售票:
启用4个goroutine模拟四个售票口,四个售票口同时执行,表示四个售票口同时卖票
*/
wg.Add(4)
//通过同步锁的方式解决临界资源安全问题
go saleTickets("售票口1")
go saleTickets("售票口2")
go saleTickets("售票口3")
go saleTickets("售票口4") //保留一个在主goroutine调用,避免主goroutine提前结婚苏
wg.Wait()
}
//卖票
func saleTickets(name string) {
rand.Seed(time.Now().UnixNano()) //随机数睡眠
for {
//上锁
mutex.Lock() //锁的是一段代码(上锁和解锁之间的代码段),从该行开始只能有一个goroutine来访问
if ticketNums > 0 {
//因为上锁,g1先夺得锁,即使睡眠,g2过来抢占资源,也只能干等到锁释放才能进入上锁后的代码
time.Sleep(time.Duration(rand.Intn(1000)) * time.Millisecond)//睡眠,模拟被其他协程抢占cpu时间片
fmt.Println("剩余票数:", ticketNums)
fmt.Println(name, "售票一张")
ticketNums-- //可能此时的ticketNum已经被别的协程置为0,或者-x
} else {
//要注意如果某个争夺的协程进来后break,没有释放锁资源,会导致其他goroutine没法正常执行而报错
mutex.Unlock()
fmt.Println(name, "没票卖啦")
break
}
//释放锁
mutex.Unlock()
}
wg.Done()
}
ps:切记,在使用互斥锁的时候,对资源操作完,一定要解锁,否则会出现程序异常,死锁的问题,可以使用defer
语句确保解锁操作
读写锁(RWMutex)
互斥锁强行同步往往会降低性能,开发中往往是这样的场景:
当某个数据永远不会被修改,是只读的时候,就不会存在资源竞争的问题
所以,读取数据并不是造成线程安全的原因。真正造成临界资源安全问题的原因是在并发条件对数据的写操作
读写锁:
RWMutex读写锁是专门针对于读操作和写操作的互斥锁。它和普通的互斥锁最大的不同在于它可以分别针对“读操作”和"写操作"进行锁定和解锁
读写锁可以让多个读操作同时并发读取,但是对于写操作是完全互斥的(当一个goroutine进行写操作时,其他的goroutine即不能读也不能写)
读写锁对象也定义在同步包下
type RWMutex struct {
// 包含隐藏或非导出字段
}
RWMutex是读写互斥锁。该锁可以被同时多个读取者持有或唯一个写入者持有。RWMutex可以创建为其他结构体的字段;零值为解锁状态。RWMutex类型的锁也和线程无关,可以由不同的线程加读取锁/写入和解读取锁/写入锁。
RWMutex
实现了如下方法
func (rw *RWMutex) Lock() //Lock方法将rw锁定为写入状态,禁止其他线程读取或者写入
func (rw *RWMutex) Unlock() //Unlock方法解除rw的写入锁状态,如果m未加写入锁会导致运行时错误
func (rw *RWMutex) RLock() //RLock方法将rw锁定为读取状态,禁止其他线程写入,但不禁止读取
func (rw *RWMutex) RUnlock() //Runlock方法解除rw的读取锁状态,如果未加读取锁会导致运行时错误
func (rw *RWMutex) RLocker() Locker //Rlocker方法返回一个互斥锁,通过调用rw.Rlock和rw.Runlock实现了Locker接口。
牛刀小试
package main
import (
"fmt"
"sync"
"time"
)
var wg *sync.WaitGroup
var rwMutex *sync.RWMutex //定义读取锁变量
func main() {
/*
读写锁
*/
rwMutex = new(sync.RWMutex) //创建读取锁对象
wg = new(sync.WaitGroup)
//wg.Add(3)
//go readData(333)
//go readData(666)
//go readData(888)
/*
输出:
读操作 start...
reading... 888
读操作 start...
reading... 333
读操作 start...
reading... 666
end
end
end
可以发现,读操作是可以同时执行的
*/
wg.Add(3)
go writeData(111)
go readData(222) //当写入锁未释放时,读取锁会阻塞无法上锁,直到锁释放才能上锁
go writeData(999)
/*
输出:
写操作 start...
writing... 999
读操作 start...
写操作 start...
reading... 222
写完over~
end
writing... 111
写完over~
可以发现,无法同时执行写入操作
*/
wg.Wait()
}
//读操作
func readData(i int) {
fmt.Println("读操作", "start...")
rwMutex.RLock() //给读操作上锁
fmt.Println("reading...",i)
time.Sleep(1 * time.Second) //模拟耗时读取操作
rwMutex.RUnlock() //释放读取锁
wg.Done()
fmt.Println("end")
}
//写操作
func writeData(i int) {
defer wg.Done()
fmt.Println("写操作", "start...")
rwMutex.Lock() //给写操作上锁
fmt.Println( "writing...",i)
time.Sleep(1 * time.Second) //模拟耗时写入操作
rwMutex.Unlock() //释放写入锁
fmt.Println("写完over~")
}
读写锁总结:
- 多个
goroutine
可以同时读 - 写的时候,只能等,读不了也写不了
Go语言中有一句很经典的话:不要以共享内存的方式去通信,而是要以通信的方式去共享内存
即go语言并不鼓励用锁保护的机制去多个goroutine
来访问共享的数据,而是应该通过后面介绍到的channel
机制实现goroutine
之间的共享资源状态变化的传递,这样在goroutine
内就可以通过判断资源的状态实现和锁一样效果
sync.Map
首先,先看下面一段代码
package main
import (
"fmt"
"strconv"
"sync"
)
var m = make(map[string]int)
func get(key string) int {
return m[key]
}
func set(key string, value int) {
m[key] = value
}
var WG sync.WaitGroup
func main() {
for i := 0; i < 30; i++ {
WG.Add(1)
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()
}
猜测一下程序能否正常执行?答案是有时候可以,但并发次数多了后会报下面一段错误
fatal error: concurrent map writes
这因为go内置的Map结构并不是线程安全的,当对一个map并发进行读写操作时,go的程序就可能会报出上次异常
针对这种情况我们可以通过加锁来解决并发安全问题,但实际使用不需要那么繁琐,sync
报中提供了开箱即用的并发安全版本的map-> sync.Map
(其内部也是通过锁操作保证并发安全的),其并不需要初始化就能直接使用
少说多撸
package main
import (
"fmt"
"strconv"
"sync"
)
var m = sync.Map{} //不需要使用make初始化,开箱即用
var WG sync.WaitGroup
func main() {
for i := 0; i < 1000; i++ {
WG.Add(1)
go func(n int) {
key := strconv.Itoa(n)
m.Store(key, n) //设置值,使用内置的方法存储值即可保证线程安全
value, ok := m.Load(key) //取值,使用内置的方法存读取即可保证线程安全
if ok {
fmt.Printf("k=:%v, v=%v\n", key, value)
} else {
fmt.Println("取值失败")
}
WG.Done()
}(i)
}
WG.Wait()
}
此外,sync.Map
还提供了下面并发安全的方法:
Delete
:删除键值对LoadOrStroe
:先加载看键值对是否存在,不存在则添加Range
:按提供的返回bool值的方法对map中的每一个键值对进行遍历调用,若为false停止遍历
原子(atomic)包
sync
包中提供了锁功能以解决并发安全问题,但锁的使用会带来性能上的损耗。在满足需求的前提下(atomic提供的方法足以解决应用场景的情况)使用atomic包下提供的原子操作可以带来更好的性能提升
atomic包提供的方法不多,也很见名知意,大致能通过方法名猜到其用途,具体细节感兴趣可以参考官方文档~
看一段代码简单了解下其使用
package main
import (
"fmt"
"sync"
"sync/atomic"
)
//原子操作
var x int64
var wg sync.WaitGroup
var locker sync.Mutex
//非线程安全对变量操作
func add() {
x++
wg.Done()
}
//线程安全加锁版对变量操作,性能很菜
func addMutex() {
locker.Lock()
x++
locker.Unlock()
wg.Done()
}
//线程安全原子操作版本对变量操作,性能比加锁好
func addAtomic() {
atomic.AddInt64(&x, 1) //原子操作 加操作
// value := atomic.LoadInt64(&x) //获取值
// atomic.StoreInt64(&x,888) //赋值
// atomic.SwapInt64(&x,888) //交换值
// atomic.CompareAndSwapInt64(&x,200,100) //比较并交换值,若x=200则使用100交换
wg.Done()
}
func main() {
wg.Add(1000)
for i := 0; i < 1000; i++ {
// go add() //非并发安全
// go addMutex() //并发安全,性能很菜
go addAtomic() //并发安全,性能比加锁好
}
wg.Wait()
fmt.Println(x)
}
通道(channel)
通道是goroutine之间通信的管道,用于实现多个goroutine之间数据的传递
go语言中,可以通过数据结构channel
实现某一goroutine
中的数据传递给另一goroutine
go语言会在语言层面确保,在某一时间点,只有一个goroutine
能访问channel
中的数据。为解决线程安全问题提供了极为优雅的方式
使用channel
每个通道都有其相关的类型,该类型是通道允许传递的类型。通道的零值为nil,其没有任何用处,所以通道必须使用和map、slice的方法来定义
老样子,数据结构的学习还是少说多撸
package main
import (
"fmt"
"time"
)
func main() {
/*
通道channel
*/
//1.声明通道
var c1 chan int //代表c1通道能传递int类型数据
//通道默认值为nil, 没有任何卵用,要使用必须先创建通道
fmt.Printf("%T, %v\n", c1, c1) //chan int, <nil>
//2.创建通道 和map slice一样
if c1 == nil {
c1 = make(chan int)
//可以看到打印通道的值是内存地址,说明他是一个引用类型的数据。前面证明过很多次这个结论,不搞了
fmt.Printf("%T, %v\n", c1, c1) //chan int, 0xc000112000
}
/*
3.使用通道
通过通道发送数据 c <- data
从通道接收数据 data := <- c 从箭头方向看数据流入流出记忆,很形象
ps:对于通道来说,从其中发送和接收数据的过程中都是阻塞的
案例:子协程写数据,主协程读数据
*/
go func() {
for i := 0; i < 10; i++ {
fmt.Println("子goroutine准备写入数据:", i)
time.Sleep(2 * time.Second) //模拟延迟效果
c1 <- i
}
fmt.Println("子goroutine end...")
}()
data := <-c1 //当通道没数据时,读取操作会发生阻塞,待通道有数据为止
fmt.Println("从通道c1读取到数据", data)
fmt.Println("main goroutine end...")
}
总结channel注意事项:
- 每个通道都要有一个关联的数据类型, 表示的是传递的数据类型
- nil通道是不能使用的(nil map也无法直接存储键值对),必须要去创建对应对象(一般使用
make()
) chan <- data
表示向管道写入数据
data <- chan
表示从通道中读取数据- 当从通道中发生或读取数据是阻塞式的
当发送数据时,直到另一个goroutine
读取数据,阻塞才会接触
当接收数据时,知道另一个goroutine
写入数据,阻塞才会接触 channel
是同步的,意味着同一时间只允许一条goroutine
进行操作- 通道的发送和接收必须处在不同的
goroutine
中
ps:当一个goroutine
在一个通道上发送数据,而没有其他goroutine
去从通道接收数据,或反过来操作,就会发生死锁现象,要千万注意!
关闭channel
channel
是阻塞式的,上面我们知道,当发送的数据没人接收,或接收方没有数据接收,都会造成死锁。
为了避免这种情况发送,必须要有一种方式可以提前的告诉某一方,通道里还有没有数据,go语言中,通过发送方主动关闭通道和给接收方一个而外变量判断通道是否关闭来达成上述目的,从而避免死锁
发送者可以主动关闭通道close(c)
,来通知接收者没有数据了
接收者在向channel
接收数据时,可以使用额外的变量来检查通道是否关闭
就像
Map
的v, ok := m["key"]
中的ok判断键值对是否存在差不多意思
v, ok := <- ch
//ok = true 表示从通道成功读取值,
//ok = false 表示正在从一个以关闭的通道读取数据,此时读到v为channel类型的零值
channel也可以使用for range循环
还是少说多撸:
package main
import (
"fmt"
"time"
)
func main() {
/*
关闭通道:close(c)
goroutine1:写数据
每写一个都会阻塞一次,直到写入的数据被读取出来
goroutine2:读数据
每读一都会阻塞一次,直到有数据写入
*/
ch := make(chan int)
go sendData(ch)
//读取通道数据
//for {
// v, ok := <-ch //读取数据,ok判断通道是否关闭
// if !ok {
// break
// }
// fmt.Println("从通道读取到数据:", v)
//} //这种写法略繁琐,不优雅,不推荐!
//也可以用range循环简化上面的写法
for v := range ch {
fmt.Println(v)
//range范围循环chan本质和上面循环判断没啥区别,只是不用我们主动判断通道是否关闭罢了
}
}
//发送数据,chan是引用类型,传入的是地址
func sendData(chW chan int) {
time.Sleep(2 * time.Second) //模拟处理时间差,可以看清阻塞式的操作
//发送方,发送数据
for i := 0; i < 10; i++ {
chW <- i //将数值i写入通道chW
}
close(chW) //将通道关闭
}
ps:千万注意,使用for range从通道遍历获取值时,如果发送方数据发完后若未主动关闭通道,就会报死锁异常
fatal error: all goroutines are asleep - deadlock!
缓冲channel
上面所说channel
是非缓冲的,所以每一次的发送和接收都是阻塞式的
当读写速度不一致的时候这种同步阻塞的方式显得就有点笨拙且效率低下了
这时候有经验的小伙伴就可能会想了,有没有一种类似别的语言中队列
的解决方案,一头不停的向channel
写入数据,一头慢悠悠读取数据处理,而不是这边没处理完另外一头还没法继续写入,这样效率多低
go语言提供缓冲channel
,解决读写速度不一致所带来的性能问题,其通过定义缓冲区容量的大小来决定一个channel
“暂存”几条数据
要注意的是当缓冲区中暂存的数据量已经满了的时候,此时继续往channel
写入数据会回到老样子发生阻塞。待到缓冲区数据被取走,缓存区暂存的数据量足以存放下一条数据时,阻塞结束并写入数据
实际上,前面所讲的无缓冲channel和缓冲channel是一个玩意,只不过无缓冲的channel默认将缓冲通道的容量设置为0罢了,创建的语法也基本一毛一样,创建缓冲channel只需要在make方法的第二个设置下缓冲区容量的大小就ok了
创建缓冲channel
的语法:
ch := make(chan type, capacity) //表示创建了一个能容纳capacity条type类型数据量的缓冲channel
少说多撸:
package main
import "fmt"
func main() {
/*
非缓冲通道:make(chan T)
一次发送,一次接收,都是阻塞的
缓冲通道:make(chan T, capacity)
发送:在缓冲区数据满了才会阻塞
接收:缓冲区的数据空了才会阻塞
*/
//创建的非缓冲通道通道
ch1 := make(chan int)
//等价于下面创建的方式,上述只是省略了
ch2 := make(chan int,0)
//查看通道缓冲区长度和容量
fmt.Println(len(ch1),cap(ch1)) //0 0
fmt.Println(len(ch2),cap(ch2)) //0 0
//ch2 <- 100 //阻塞式的,需要有其他的goroutine解除阻塞,否则会死锁
//创建缓冲通道
ch3 := make(chan int, 8) //缓冲通道,缓冲区大小为8
fmt.Println(len(ch3),cap(ch3)) //0 8
//向缓冲通道写数据,数据暂存在缓冲区,缓冲区容量没有放满前,不会发生阻塞,也不会死锁
ch3 <- 666
ch3 <- 888
ch3 <- 888
ch3 <- 888
ch3 <- 888
ch3 <- 888
ch3 <- 888
ch3 <- 888
fmt.Println(len(ch3),cap(ch3)) //8 8
//缓冲区满后,此时再写入数据,发送阻塞,若未有接收者接收数据,死锁拉闸
//ch3 <- 886 //fatal error: all goroutines are asleep - deadlock!
//从缓冲区读取数据,当缓冲区有数据时,不会发送阻塞
rece := <- ch3
rece = <- ch3
rece = <- ch3
rece = <- ch3
rece = <- ch3
rece = <- ch3
rece = <- ch3
rece = <- ch3
fmt.Println(rece)
fmt.Println(len(ch3),cap(ch3)) //0 8 通道数据被读取完了
//通道中没有数据,此时再去读会发送阻塞,如果没有发送方,死锁拉闸
//rece = <- ch3 //fatal error: all goroutines are asleep - deadlock!
}
缓冲channel
完全可以理解为队列
,它是按先进先出的顺序进行读取的
定向channel
前面学的的channel
,对于goroutine
来说即可以发送数据,也可以读取数据,这样的channel
我们称之为双向通道
go语言中允许我们定义单向通道,即一个channel
只允许goroutine
对其进行某一操作(写入或从其中读取数据)
单向通道一般用来用来作为函数的参数,用以限制该函数所要进行的操作,可以明确的划分函数的功能界限,避免许多误操作
少说多撸,从代码看就能体会什么意思了:
package main
import "fmt"
func main() {
/*
双向channel:
定义:chan T
chan <- data 从通道发送数据
data <- chan 从通道获取数据
单向channel
定义:
1.chan <- T 只支持写
2.<- chan T 只支持读
*/
//1. 双向通道
ch1 := make(chan string)
go sendDataToTwoWayChan(ch1)
//1.2冲双向通道读数据
data := <- ch1
fmt.Println(data)
//2. 单向通道
//定义一个只写的单向通道
//ch2 := make(chan <- string)
//ch2 <- "往单向通道写" //只写不读会死锁
//只写通道不可读
//data = <- ch2 //向单向通道读 报错:invalid operation: <-ch2 (receive from send-only type chan<- string)
/*
单向通道一般不是这样用,直接定义的单向通道一般没有意义,因为对于通道来说,读写操作唯一搭配使用才有意义
不然数据发出去没人收有啥用,程序也会死锁
首先要知道:单向channel是可以接受双向channel作为值的
所以单向通道的用法一般是用在限定函数的参数,可以明确函数的功能界限并避免一些误操作()
比如:定义一个对channel写一个数据函数,那么该函数的channel参数就应该是一个只写的单向channel,这样该函数的功能就更加清晰,
在复杂系统中也避免了某处多读多写导致的死锁问题
*/
//正确用法
fmt.Printf("ch1通道的地址:%v\n",ch1) //ch1通道的地址:0xc0000240c0
go sendDataToOneWayChan(ch1) //通道操作必须发生在两个goroutine中,如果同一线程对一个channel读写,也会报死锁
ReadDataFromOneWayChan(ch1)
}
//1.1从双向通道写数据
func sendDataToTwoWayChan(ch chan string) {
ch <- "我写!"
}
//2.单向通道的用法
//只写
func sendDataToOneWayChan(ch chan <- string) {
//这是一个只负责写操作的函数,所以应该用单向的只写channel限定,明确界限,避免误操作
fmt.Printf("只写函数中单向通道的地址:%v\n",ch) //只写函数中单向通道的地址:0xc0000240c0
//data := <- ch //想读?读不了 这函数就是用来写的,别花里胡俏
ch <- "只写函数中往单向通道写入"
}
//2.单向通道的用法
//只读
func ReadDataFromOneWayChan(ch <- chan string) {
//这是一个只负责读操作的函数,所以应该用单向的只读channel限定,明确界限,避免误操作
fmt.Printf("只读函数中单向通道的地址:%v\n",ch) //只读函数中单向通道的地址:0xc0000240c0
//ch <- "噼里啪啦" //想写?写不了 这函数就是用来读的,别花里胡俏
data := <- ch
fmt.Println(data) //只写函数中往单向通道写入
}
总结:
- 在创建通道时,我们往往还是会创建一个双向的通道
- 在某些函数中为了一些限制和保护,会把接收的通道参数设置为单向的,在函数内部限定只能进行读取或写入数据的其中之一操作
- 单向通道类型可以接收双向通道类型作为参数
定时器
在time
包中定义了一些与channel
相关的函数,其中最常用到的定时器就在其中
定时器在time包中定义是Timer
类型和Ticker
类型,它们二者的区别是:
Timer
是一次性的时间触发事件,即满足指定时间条件,干一次活就over了Ticker
是按一定的时间间隔持续触发事件,即每隔一段时间就干一次活
这里我们只介绍Timer的使用,因为Ticker
的用法基本一样,看文档或源码介绍照葫芦画瓢就行
Timer的定义:
type Timer struct {
C <-chan Time
// 内含隐藏或非导出字段
}
Timer类型代表单次时间事件。当Timer到期时,当时的时间会被发送给C,除非Timer是被AfterFunc函数创建的。
每个Timer对象里面都封装了一个时间channel
成员,到达执行时间后,Timer
就往时间channel
中输出执行动作而时间
Timer对象的创建方式:
func NewTimer(d Duration) *Timer //NewTimer创建一个Timer,它会在最少过去时间段d后到期,向其自身的C字段发送当时的时间。
func AfterFunc(d Duration, f func()) *Timer //AfterFunc另起一个go程等待时间段d过去,然后调用f。它返回一个Timer,可以通过调用其Stop方法来取消等待和对f的调用。
func After(d Duration) <-chan Time //创建一个timer并直接返回其时间通道,底层就是NewTimer(d).C
少说多撸:
package main
import (
"fmt"
"sync"
"time"
)
var ws sync.WaitGroup
func main() {
/*
定时器
*/
//1.创建定时器
//func NewTimer(d Duration) *Timer 创建定时器,d时间以后触发
timer1 := time.NewTimer(3 * time.Second)
fmt.Printf("%T\n", timer1) //*time.Timer
//获取时间channel 当计时器触发时,会向channel写入触发时间
fmt.Printf("%T,%v\n", timer1.C, timer1.C) //<-chan time.Time,0xc0000ba120
//从通道获取值,会阻塞计时器设置的时间
fmt.Println(<-timer1.C) //2021-03-14 22:57:10.424077378 +0800 CST m=+3.000268798
//在计时器到期前,可以主动终止计数器
time2 := time.NewTimer(3 * time.Second)
//开启goroutine,来处理计时器触发后的事件
go func() {
fmt.Println(<-time2.C)
fmt.Println("<- time2 over~")
}()
time2.Stop() //主动终止计时器触发,上面goroutine里面的打印不会被执行
//2.如果创建timer,想直接获取返回的时间通道可以通过time.After()方法
//func After(d Duration) <-chan Time 该方法启用后,gc不会主动恢复其底层的timer对象,我们也没法反向获取timer
//所以如果计时器不是长时间需要,为了性能还是建议使用NewTimer创建timer对象,并在不需要的时候stop掉他们
time3 := time.After(3 * time.Second)
fmt.Printf("%T,%v\n",time3,time3) //<-chan time.Time,0xc0000b00c0
fmt.Println(<-time3) //202021-03-14 23:12:11.124651971 +0800 CST m=+6.001046836
//3.如果想计时器到时间后触发一个方法,则使用time.AfterFunc
//注意,该计时器的时间通道此时在触发时不会去发送时间值了,因为动作替换成了触发某个函数执行
ws.Add(1) //避免main提前结束,使用同步对象
time4 := time.AfterFunc(3 * time.Second,
func() {
fmt.Println("时间到了,我触发了")
ws.Done()
})
fmt.Printf("%T\n",time4) //*time.Timer
//<-time4.C //使用AfterFunc创建的timer不会再向时间channel发送值,向通道读值会报fatal error: all goroutines are asleep - deadlock!
ws.Wait()
}
Select语句
select
是go语言提供的选择语句,与switch
语句类似:
- 与
switch
不同的是每个case必须是一个通信操作(从channel
读取或者写入数据) select
语句中会有多个选择块,每个块都是一个可运行的case
,select
会随即执行一个可运行的case
- 如果没有
case
可执行且没有default
的存在,它将阻塞直到有case
可运行 - 如果没有
case
可执行但是定义了default
,它将直接执行default
定义的代码块
看的一头雾水?通过一段伪代码再来说明一下:
select{
case communication clause :
statement(s);
case communication clause :
statement(s);
case communication clause :
statement(s);
//可以定义任意的case communication clause(通信表达式)
default :
statement(s);
}
上面示例中:
- 每个
case
是一个通信操作,即对一个channel
进行写入或读取的操作 - 所有的
case
中的通信操作都会被执行一遍- 如果只有某个通信操作(
channel
表达式)没有阻塞,就是说明对应的case
块可以被执行,此时select
会选择该case
块代码块执行,其他的case语句不会执行 - 如果多个通信操作(
channel
表达式)都没有阻塞,就是说明对应的case
块都可以被执行,此时select
会随即公平地选择出一个执行,其他的case
语句不会被执行
- 如果只有某个通信操作(
- 当
case
中所有通信都处于阻塞时- 如果有
default
子句存在,执行default
的代码可,其他case
不会执行 - 如果没有
default
存在,select
将阻塞,直到某个case
通信可以运行
- 如果有
少说多撸(下面代码是随机运行的):
package main
import (
"fmt"
"time"
)
func main() {
/*
select分支语句:
select会随即执行一个可运行的case
如果没有case可运行,要看是否有default,如果有就执行default,否则就进入阻塞,知道有case可运行为止
*/
ch1 := make(chan int)
ch2 := make(chan int)
go func() {
time.Sleep(1 * time.Second)
ch1 <- 100
}()
go func() {
time.Sleep(1 * time.Second)
ch2 <- 100
}()
//通信都可执行,会随即选择一个
select {
case num := <-ch1: //通信操作,
fmt.Println("ch1获取的数据:", num)
case num, ok := <-ch2: //通信操作
if ok {
fmt.Println("ch2获取的数据:", num)
} else {
fmt.Println("ch2已经被通道关闭")
}
case <-time.After(1 * time.Second): //After()也是个通信操作,因为它通过时间channel传递值
fmt.Println("定时器被执行")
//default:
// fmt.Println("执行default") //当两个case都被阻塞,就会执行default
}
fmt.Println("主函数goroutine结束")
}
总结结语:
goroutine和channel是go语言并发编程的两大基石,goroutine用于执行并发人物,channel用于goroutine直接的线程同步与通信
channel在goroutine间架起了一条管道,在管道里传输数据,实现goroutine间的通讯;由于channel是线程安全的,用起来不要太爽
channel拥有先进先出的特性,还可以影响goroutine的阻塞和唤醒
本系列学习资料参考:
https://www.bilibili.com/video/BV1jJ411c7s3?p=15
https://books.studygolang.com/The-Golang-Standard-Library-by-Example/chapter01/01.1.html