Go语言之路————并发
Go语言之路————并发
- 前言
- 协程
- 管道
- Select
- sync
- WaitGroup
- 锁
前言
- 我是一名多年Java开发人员,因为工作需要现在要学习go语言,Go语言之路是一个系列,记录着我从0开始接触Go,到后面能正常完成工作上的业务开发的过程,如果你也是个小白或者转Go语言的,希望我这篇文章对你有所帮助。
- 有关go其他基础的内容的文章大家可以查看我的主页,接下来主要就是把这个系列更完,更完之后我会在每篇文章中挂上连接,方便大家跳转和复习。
协程
在学go之前,大家肯定听说过go底层天然支持并发,相信这也是很多人选择学习这款语言的原因之一,那么它到底怎么个天然法,怎么个支持,下面我就一一道来。
Goroutine(轻量级线程),正如标题一样,它也叫做协程,它是go的并发执行单元,是一种比线程更加轻量级的单位,创建一个协程非常简单,只需要用到一个关键词:go,go后面一定要更一个函数:
func main() {go func() {fmt.Print(1)}()
}
我这里用一个go启动一个匿名函数,如果你copy这个代码去执行,你会发现控制台没有任何打印,因为协程就跟Java的线程一样,它是并发去执行的,当我们的main方法跑完的时候,如果协程未执行,那么 整个程序都会关掉,就没有任何输出了。
那怎样让它正常输出呢?聪明的同学肯定会想到,让main线程沉睡一下不就行了,我们来看看代码:
func main() {go func() {fmt.Print(1)}()time.Sleep(1 * time.Second)
}控制台打印:1
由此可见,让主线程沉睡确实可以做到这点,那么我就要提出下一个问题了,如果有多个协程呢?看看下面代码:
func main() {for i := 0; i < 10; i++ {go fmt.Println(i)}time.Sleep(1 * time.Second)
}
当把这段代码执行后,你会发现每次执行的结果都是不一样的,这也引出了协程的一个特性,那就是执行的时候是无序的,那有啥方法解决吗,我们先用上面的sleep看能否解决:
每次执行协程前,我们都让它沉睡一秒,然后主线程沉睡十秒
func main() {for i := 0; i < 10; i++ {time.Sleep(1 * time.Second)go fmt.Println(i)}time.Sleep(10 * time.Second)
}
执行后的结果:
0
1
2
3
4
5
6
7
8
9
目前来看,是做到了,但是这个方法太笨了,有啥办法可以优雅的解决吗,当然,go提供了管道、信号量、上下文、锁等各种工具来辅助开发者进行并发编程。
管道
管道:channel,官方对它的解释:Do not communicate by sharing memory; instead, share memory by communicating.
我用白话文在翻译一次:它的作用就是解决协程之间的通信的,数据传输或者共享的。
一个通道,用chan来定义,定义的时候必须要指定它存的数据类型:
var ch chan int
此时的管道还没初始化,是不能使用的,在go中,初始化一个管道,有且只有一个办法,那就是make关键词,make关键词提供一个额外参数:缓冲区
var ch = make(chan int, 1)
这里就是用make创建了一个缓冲区为1的管道,先看看使用:
func main() {var ch = make(chan int, 1)ch <- 1println(<-ch)
}
输出:1
结合例子,说一下管道的输出和输出:<-,没错就是用箭头表示,箭头的指向表示数据流向,a <- 1,表示把1发到a,<- a,表示从a读取数据
如何理解缓冲区:可以理解为Java中线程池中的阻塞队列,往管道中发送的数据会先存到缓冲区,然后才会被读取,如果一个管道没有缓冲区,那么发送信息后需要立马有读取的操作,否则程序就会阻塞,我们通过下面例子来看:
func main() {var ch = make(chan int)ch <- 1<-ch
}
我们创建一个没有缓冲区的管道,像管道里面输入1,马上再读取。看似人畜无害的代码,执行起来确是这个结果:deadlock
fatal error: all goroutines are asleep - deadlock!goroutine 1 [chan send]:
main.main()D:/goland/workspace/test/main.go:5 +0x2dProcess finished with the exit code 2
那读者又会想了,既然这样,那岂不是所有的管道创建都需要缓冲区。其实不然,如果我们通过协程去输入就能正常输出:
func main() {var ch = make(chan int)go func() {ch <- 1}()println(<-ch)
}
输出:1
思考:为啥有协程的参与就能正常读写?我们回到缓冲区的本质,它是存数据的缓冲的,如果我们没有缓冲区,那么证明这个管道是没办法存数据的,就意味着,我这边写了,必须马上有人读,但是通过同步操作是实现不了的,有协程异步来操作才可行。
注意:每个管道用完后需要我们手段关闭,直接调用系统提供的close方法,一个管道只能close一次,多次close会报错。
func close(c chan<- Type)
但是通常,我们建议把通道的关闭结合defer来用:
func main() {var ch = make(chan int)go func() {ch <- 1defer close(ch)}()println(<-ch)
}
注意点,除了同步读写无缓冲管道会造成堵塞之外,下面几种情况也会造成deadlock:
- 缓冲区满了继续噻数据:
缓冲区大小为1,写入一个后满了没读,继续写func main() {var ch = make(chan int, 1)defer close(ch)ch <- 1ch <- 1println(<-ch) }
- 有缓冲区,但是数据为空
func main() {// 创建的有缓冲管道intCh := make(chan int, 1)defer close(intCh)// 缓冲区为空,阻塞等待其他协程写入数据<-intCh }
- 管道未初始化
func main() {var intCh chan intintCh <- 1 }
管道数据除了一个个读之外,我们还可以用for range来遍历一个管道:
func main() {intCh := make(chan int, 10)go func() {for i := 0; i < 10; i++ {intCh <- i}}()for ch := range intCh {println(ch)}
}
看看输出:
0
1
2
3
4
5
6
7
8
9
fatal error: all goroutines are asleep - deadlock!goroutine 1 [chan receive]:
main.main()D:/goland/workspace/test/main.go:10 +0xa8
在输出之后出现了阻塞,这是因为for range会一直去读写管道中的数据,当管道中数据为空时就会死锁,直到有其他协程向管道写入数据才会解除,所以我们代码改一下,在写入数据完毕后就关闭管道:
func main() {intCh := make(chan int, 10)go func() {for i := 0; i < 10; i++ {intCh <- i}close(intCh)}()for ch := range intCh {println(ch)}
}
最后再补充一个知识点,管道的读取其实是有返回值的:
v, ok := <-intCh
第一个是值,第二个是个bool代表是否读取成功:
func main() {intCh := make(chan int, 10)go func() {intCh <- 1}()a, ok := <-intChprintln(a, ok)
}输出:1 true
Select
在 Go 中,select 是一种管道多路复用的控制结构,某一时刻,同时监测多个元素是否可用,在这里我们可以用来检测多个管道:
func main() {ch1 := make(chan int, 10)ch2 := make(chan int, 10)ch3 := make(chan int, 10)defer func() {close(ch1)close(ch2)close(ch3)}()select {case i := <-ch1:fmt.Println("ch1 is ", i)case j := <-ch2:fmt.Println("ch2 is ", j)case k := <-ch3:fmt.Println("ch3 is ", k)default:fmt.Print("检测失败")}
}
创建三个管道,然后用select分别去监测三个管道的数据,然后doSomething,让我们没有往管道输入任何数据的时候,默认输出检测失败,我们在select前往ch1输入一个数据看看:
func main() {ch1 := make(chan int, 10)ch2 := make(chan int, 10)ch3 := make(chan int, 10)defer func() {close(ch1)close(ch2)close(ch3)}()ch1 <- 1select {case i := <-ch1:fmt.Println("ch1 is ", i)case j := <-ch2:fmt.Println("ch2 is ", j)case k := <-ch3:fmt.Println("ch3 is ", k)default:fmt.Print("检测失败")}
}输出:ch1 is 1
sync
讲到了并发,怎么能离开锁,go的sync包下面提供了很多锁相关的工具类,就类似于Java的juc包,我们下面简单说点常用的。
WaitGroup
WaitGroup 即等待执行,它的方法只有三个,使用起来也非常简单:
- Add:添加一个计数器,表示总数
- Done:每调用一次计数器减1
- Wait:如果计数器不为0,则等待
还记得我们文章开头提到的例子吗,就是在main线程中使用了协程,协程还未执行但是main已经结束了,当时我们用的是sleep方法,现在我们看看怎么用WaitGroup去解决这个问题:
先看看原例子:
func main() {println("start")go func() {println("doSomething")}()println("end")
}
再看看解决后的:
var waitGroup sync.WaitGroupfunc main() {println("start")waitGroup.Add(1)go func() {println("doSomething")waitGroup.Done()}()waitGroup.Wait()println("end")
}看看输出:
start
doSomething
end
锁
go中常用的锁有两个:
- 互斥锁:sync.Mutex
- 读写锁:sync.RWMutex
互斥锁sync.Mutex ,实现了Locker 接口,它的用法非常简单,就三个:
func (m *Mutex) Lock() {m.mu.Lock()
}func (m *Mutex) TryLock() bool {return m.mu.TryLock()
}func (m *Mutex) Unlock() {m.mu.Unlock()
}
我们先来看看互斥锁Mutex,下面我来模拟一个经典的场景,就是不同线程对共享数据操作,让我们看看不用锁的情况下,会不会得到正确结果:
var wait sync.WaitGroup
var count = 0func main() {wait.Add(10)for i := 0; i < 10; i++ {go func(data *int) {// 模拟访问耗时time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))// 访问数据,这里必须要用temp当前数据存起来temp := *data// 模拟计算耗时time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))// 修改数据*data = temp + 1fmt.Println(*data)wait.Done()}(&count)}wait.Wait()fmt.Println("最终结果", count)
}
运行起来发现,每次的输出都不一样,跟Java一样,多线程对共享数据的修改是不安全的,必须要加锁
1
1
2
1
1
1
1
1
1
3
最终结果 3
下面我们改进一下代码,将同步代码用互斥锁包起来,类似于Java的同步代码块:
var lock sync.Mutex
var wait sync.WaitGroup
var count = 0func main() {wait.Add(10)for i := 0; i < 10; i++ {go func(data *int) {lock.Lock()// 模拟访问耗时time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))// 访问数据temp := *data// 模拟计算耗时time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))// 修改数据*data = temp + 1lock.Unlock()fmt.Println(*data)wait.Done()}(&count)}wait.Wait()fmt.Println("最终结果", count)
}
go的互斥锁很简单,用的时候就调用lock()方法,解锁就调用unlock()方法,看看输出:
1
2
3
4
5
6
7
8
9
10
最终结果 10Process finished with the exit code 0
读写锁和互斥锁一样,只是说读写锁的精度更高一点,可以根据读多写少,或者读少写多的情况来判断,它同样实现了Locker接口,只是方法多一些,读写锁内部的读和写是互斥锁,并不是说有两个锁
// 加读锁
func (rw *RWMutex) RLock()// 尝试加读锁
func (rw *RWMutex) TryRLock() bool// 解读锁
func (rw *RWMutex) RUnlock()// 加写锁
func (rw *RWMutex) Lock()// 尝试加写锁
func (rw *RWMutex) TryLock() bool// 解写锁
func (rw *RWMutex) Unlock()
下面看个读写锁的例子(本例来自官方中文文档):
var wait sync.WaitGroup
var count = 0
var rw sync.RWMutexfunc main() {wait.Add(12)// 读多写少go func() {for i := 0; i < 3; i++ {go Write(&count)}wait.Done()}()go func() {for i := 0; i < 7; i++ {go Read(&count)}wait.Done()}()// 等待子协程结束wait.Wait()fmt.Println("最终结果", count)
}func Read(i *int) {time.Sleep(time.Millisecond * time.Duration(rand.Intn(500)))rw.RLock()fmt.Println("拿到读锁")time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))fmt.Println("释放读锁", *i)rw.RUnlock()wait.Done()
}func Write(i *int) {time.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))rw.Lock()fmt.Println("拿到写锁")temp := *itime.Sleep(time.Millisecond * time.Duration(rand.Intn(1000)))*i = temp + 1fmt.Println("释放写锁", *i)rw.Unlock()wait.Done()
}
该例开启了 3 个写协程,7 个读协程,在读数据的时候都会先获得读锁,读协程可以正常获得读锁,但是会阻塞写协程,获得写锁的时候,则会同时阻塞读协程和写协程,直到释放写锁,如此一来实现了读协程与写协程互斥,保证了数据的正确性。例子输出如下:
拿到读锁
拿到读锁
释放读锁 0
释放读锁 0
拿到写锁
释放写锁 1
拿到读锁
拿到读锁
拿到读锁
拿到读锁
拿到读锁
释放读锁 1
释放读锁 1
释放读锁 1
释放读锁 1
释放读锁 1
拿到写锁
释放写锁 2
拿到写锁
释放写锁 3
最终结果 3Process finished with the exit code 0
OK 上面就是go中并发的一些常用案例,不多,但是一定是最常用的,掌握了这些你就可以去深入扩展了。