非零基础入门 Go_了解 Go 协程
学习协程之前,我们需要再看看 GO的函数和一般语言的区别以及与协程相关的匿名函数
在 Go 语言中,函数可以分为两种:
- 带有名字的普通函数
- 没有名字的匿名函数
由于 Go语言是编译型语言,所以函数编写的顺序是无关紧要的,它不像 Python 那样,函数在位置上需要定义在调用之前
Go 的普通函数结构是这样的
func 函数名(形式参数列表)(返回值列表){
函数体
}
注意返回值列表可以返回多个,这需要调用者同样接收多个返回值
可变参数
GO 同样也有可变参数的概念 它不规定死函数的参数列表而采用动态接收的形式
可变参数分为几种:
- 多个类型一致的参数
- 多个类型不一致的参数
多个类型一致的参数
典型应用:求和
// 使用 ...类型,表示一个元素为int类型的切片
func sum(args ...int) int {
var sum int
for _, v := range args {
sum += v
}
return sum
}
func main() {
fmt.Println(sum(1, 2, 3))
}
其中 ...
是 Go 语言为了方便程序员写代码而实现的语法糖,如果该函数下有多个类型的参数,这个语法糖必须得是最后一个参数。
多个类型不一致的参数
上面那个例子中,我们的参数类型都是 int,如果你希望传多个参数且这些参数的类型都不一样,可以指定类型为 ...interface{}
,然后再遍历
import "fmt"
//接收多个类型不一致的参数
func MyPrintf(args ...interface{}) {
for _, arg := range args {
switch arg.(type) {
case int:
fmt.Println(arg, "is an int value.")
case string:
fmt.Println(arg, "is a string value.")
case int64:
fmt.Println(arg, "is an int64 value.")
default:
fmt.Println(arg, "is an unknown type.")
}
}
}
func main() {
var v1 int = 1
var v2 int64 = 234
var v3 string = "hello"
var v4 float32 = 1.234
MyPrintf(v1, v2, v3, v4)
}
匿名函数
匿名函数与协程关系密切,它允许一段函数不被调用即可即时执行
//当行号指示器执行到这里的时候,立刻执行
func(参数列表)(返回参数列表){
函数体
}(参数)
协程
说到Go语言,很多没接触过它的人,对它的第一印象,一定是它从语言层面天生支持并发,非常方便,让开发者能快速写出高性能且易于理解的程序。
在 Java中,并发编程的门槛并不低,你要学习多进程,多线程,还要掌握各种支持并发的库 juc等,同时你还要清楚它们之间的区别及优缺点,懂得在不同的场景选择不同的并发模式
而 Golang 作为一门现代化的编程语言,它不需要你直面这些复杂的问题。在 Golang 里,你不需要学习如何创建进程池/线程池,也不需要知道什么情况下使用多线程,什么时候使用多进程。因为你没得选,也不需要选,它原生提供的 goroutine (也即协程)已经足够优秀,能够自动帮你处理好所有的事情,而你要做的只是执行它,就这么简单。
一个 goroutine 本身就是一个函数,当你直接调用时,它就是一个普通函数,如果你在调用前加一个关键字 go
,那你就开启了一个 goroutine。
// 执行一个函数
func()
// 开启一个协程执行这个函数
go func()
协程的初步使用
一个 Go 程序的入口通常是 main 函数,程序启动后,main 函数最先运行,我们称之为 main goroutine
。
在 main 中或者其下调用的代码中才可以使用 go + func()
的方法来启动协程。
main 的地位相当于主线程,当 main 函数执行完成后,这个线程也就终结了,其下的运行着的所有协程也不管代码是不是还在跑,也得乖乖退出。
因此如下这段代码运行完,只会输出 hello, world
,而不会输出hello, go
(因为协程的创建需要时间,当 hello, world
打印后,协程还没来得及并执行)
import "fmt"
func mytest() {
fmt.Println("hello, go")
}
func main() {
// 启动一个协程
go mytest()
fmt.Println("hello, world")
}
对于刚学习Go的协程同学来说,可以使用 time.Sleep 来使 main 阻塞,使其他协程能够有机会运行完全,但你要注意的是,这并不是推荐的方式(后续我们会学习其他更优雅的方式)。
当我在代码中加入一行 time.Sleep 输出就符合预期了。
import (
"fmt"
"time"
)
func mytest() {
fmt.Println("hello, go")
}
func main() {
go mytest()
fmt.Println("hello, world")
time.Sleep(time.Second)
}
输出如下
hello, world
hello, go
多个协程的效果
package main
import (
"fmt"
"time"
)
//子goroutine
func newTask(name string) {
i := 0
for {
i++
fmt.Printf("new Goroutine %s : i = %d\n", name, i)
time.Sleep(1 * time.Second)
}
}
//主goroutine
func main() {
//创建两个个协程去执行自增
go newTask("协程一号")
go newTask("协程二号")
fmt.Println("main goroutine exit")
//main 程也自增
i := 0
for {
i++
fmt.Printf("main goroutine: i = %d\n", i)
time.Sleep(1 * time.Second)
}
}
main goroutine: i = 1
new Goroutine 协程二号 : i = 1
new Goroutine 协程一号 : i = 1
new Goroutine 协程一号 : i = 2
new Goroutine 协程二号 : i = 2
main goroutine: i = 2
main goroutine: i = 3
new Goroutine 协程二号 : i = 3
new Goroutine 协程一号 : i = 3
new Goroutine 协程一号 : i = 4
main goroutine: i = 4
new Goroutine 协程二号 : i = 4
new Goroutine 协程二号 : i = 5
main goroutine: i = 5
new Goroutine 协程一号 : i = 5
信道/通道
Go 语言之所以开始流行起来,很大一部分原因是因为它自带的并发机制。
如果说 goroutine 是 Go语言程序的并发体的话,那么 channel(信道) 就是 它们之间的通信机制。channel,是一个可以让一个 goroutine 与另一个 goroutine 传输信息的通道,我把他叫做信道,也有人将其翻译成通道,二者都是一个概念。
信道,就是一个管道,连接多个goroutine程序 ,它是一种队列式的数据结构,遵循先入先出的规则
单向信道与协程的基本使用
通过信道实现主线程与协程之间的数据交换
package main
import (
"fmt"
"time"
)
func main() {
//定义channel并初始化
c := make(chan int)
//开启协程向channel中写入数据
go func() {
defer fmt.Println("goroutine over..")
fmt.Println("goroutine running..")
//等待五秒后
time.Sleep(5 * time.Second)
//向channel 传输 555
c <- 555
}()
//从c中接收数据 并赋值给num
//信道如果是空的 这里会阻塞
num := <- c
fmt.Println("num = ",num)
fmt.Println("main goroutine over")
}
goroutine running…
num = 555
main goroutine over
goroutine over…
按照是否可缓冲数据可分为:缓冲信道 与 无缓冲信道
无缓冲信道
在信道里无法存储数据,这意味着,接收端必须先于发送端准备好,以确保你发送完数据后,有人立马接收数据,否则发送端就会造成阻塞,原因很简单,信道中无法存储数据。也就是说发送端和接收端是同步运行的。
pipline := make(chan int)
// 或者
pipline := make(chan int, 0)
以上示例就是一个无缓冲信道,下面我们再看一个缓冲信道
缓冲信道
允许信道里存储一个或多个数据,这意味着,设置了缓冲区后,发送端和接收端可以处于异步的状态。
pipline := make(chan int, 10)
package main
import (
"fmt"
"time"
)
func main() {
//缓冲区 给定容量 即缓冲信道
c := make(chan int, 3)
fmt.Println("len(c) =",len(c), ", cap(c) =",cap(c))
go func() {
defer fmt.Println("子go程结束")
for i := 0; i < 4; i++ {
//写入缓冲区
c <- i
fmt.Println("子go程正在运行 发送的元素=",i,"len(c)=",len(c),"cap(c)=",cap(c))
}
}()
time.Sleep(4 * time.Second)
//main 从channel读取数据
for i := 0; i < 4; i++ {
//从c中接收数据,并赋值给num
num := <-c
fmt.Println("num = ", num)
}
fmt.Println("main 结束")
}
信道的遍历
遍历信道,可以使用 for 搭配 range关键字,在range时,要确保信道是处于关闭状态,否则循环会阻塞。
package main
import (
"fmt"
)
func main() {
//无缓冲信道
c := make(chan int)
go func() {
for i := 0; i < 5; i++ {
c <- i
}
//对通道进行关闭
close(c)
}()
//main
//for {
// //ok为true表示channel没有关闭
// if data,ok := <-c; ok {
// fmt.Println(data)
// }else {
// break
// }
//}
//可以使用range来迭代不断操作channel
for data := range c {
fmt.Println(data)
}
fmt.Println("main finished")
}
0
1
2
3
4
main finished
使用协程 + 信道 实现斐波那契
select 是 go 的轮询函数 可用与监控多个信道的事件
package main
import "fmt"
//输入参数为两个channel
func fibonacii(c, quit chan int) {
x,y := 1,1
for {
//轮询
select {
//如果c可写且已将x写入c
case c <- x:
x = y
y = x + y
//可读
case <-quit:
fmt.Println("quit")
return
}
}
}
func main() {
//搞两个缓冲区
c := make(chan int)
quit := make(chan int)
//子go读取channel
go func() {
//向 c 写入数据
for i := 0; i < 10; i++ {
fmt.Println(<-c)
}
//读取完成后写入 将go程终结掉
quit <- 0
}()
fibonacii(c,quit)
}
WaitGroup
学习了 协程
和 信道
的内容,里面有很多例子,为了保证 main goroutine 在所有的 goroutine 都执行完毕后再退出,我使用了 time.Sleep 这种简单的方式。
使用time.Sleep 是一种极不推荐的方式,这里主要就要来介绍 一下如何优雅的处理这种情况
使用信道实现协程同步
我们知道,信道可以实现多个协程间的通信,那么我们只要定义一个信道,在任务完成后,往信道中写入true,然后在主协程中获取到true,就认为子协程已经执行完毕。
import "fmt"
func main() {
done := make(chan bool)
go func() {
for i := 0; i < 5; i++ {
fmt.Println(i)
}
done <- true
}()
<-done
}
使用 WaitGroup
上面使用信道的方法,在单个协程或者协程数少的时候,并不会有什么问题,但在协程数多的时候,代码就会显得非常复杂。
那么有没有一种更加优雅的方式呢?
有,这就要说到 sync包 提供的 WaitGroup 类型
WaitGroup 你只要实例化了就能使用
var 实例名 sync.WaitGroup
实例化完成后,就可以使用它的几个方法:
- Add:初始值为0,你传入的值会往计数器上加,这里直接传入你子协程的数量
- Done:当某个子协程完成后,可调用此方法,会从计数器上减一,通常可以使用 defer 来调用。
- Wait:阻塞当前协程,直到实例里的计数器归零。
举一个例子:
import (
"fmt"
"sync"
)
func worker(x int, wg *sync.WaitGroup) {
defer wg.Done() //WaitGroup 实例 计数器-1
for i := 0; i < 5; i++ {
fmt.Printf("worker %d: %d\n", x, i)
}
}
func main() {
var wg sync.WaitGroup
//两个协程
wg.Add(2)
go worker(1, &wg)
go worker(2, &wg)
//等待计数器归零
wg.Wait()
}
输出如下
worker 2: 0
worker 2: 1
worker 2: 2
worker 2: 3
worker 2: 4
worker 1: 0
worker 1: 1
worker 1: 2
worker 1: 3
worker 1: 4
互斥锁与读写锁
在 Go 语言中,信道的地位非常高,它是 first class 级别的,面对并发问题,我们始终应该优先考虑使用信道,如果通过信道解决不了的,不得不使用共享内存来实现并发编程的,那 Golang 中的锁机制,就是你绕不过的知识点了
今天就来讲一讲 Golang 中的锁机制
在 Golang 里有专门的方法来实现锁,还是上一节里介绍的 sync 包
这个包有两个很重要的锁类型
一个叫 Mutex
, 利用它可以实现互斥锁
一个叫 RWMutex
,利用它可以实现读写锁
互斥锁 :Mutex
使用互斥锁(Mutex,全称 mutual exclusion)是为了来保护一个资源不会因为并发操作而引起冲突导致数据不准确 此锁与 Java 中的 synchronize基本一致
举个例子,就像下面这段代码,我开启了三个协程,每个协程分别往 count 这个变量加1000次 1,理论上看,最终的 count 值应试为 3000
package main
import (
"fmt"
"sync"
)
func add(count *int, wg *sync.WaitGroup) {
for i := 0; i < 1000; i++ {
*count = *count + 1
}
wg.Done()
}
func main() {
var wg sync.WaitGroup
count := 0
wg.Add(3)
go add(&count, &wg)
go add(&count, &wg)
go add(&count, &wg)
wg.Wait()
fmt.Println("count 的值为:", count)
}
可运行多次的结果,都不相同
// 第一次
count 的值为: 2854
// 第二次
count 的值为: 2673
// 第三次
count 的值为: 2840
原因就在于这三个协程在执行时,先读取 count 再更新 count 的值,而这个过程并不具备原子性,所以导致了数据的不准确。
解决这个问题的方法,就是给 add 这个函数加上 Mutex 互斥锁,要求同一时刻,仅能有一个协程能对 count 操作。
在写代码前,先了解一下 Mutex 锁的两种定义方法
// 第一种
var lock *sync.Mutex
lock = new(sync.Mutex)
// 第二种
lock := &sync.Mutex{}
然后就可以修改你上面的代码,如下所示
import (
"fmt"
"sync"
)
func add(count *int, wg *sync.WaitGroup, lock *sync.Mutex) {
for i := 0; i < 1000; i++ {
lock.Lock()
*count = *count + 1
lock.Unlock()
}
wg.Done()
}
func main() {
var wg sync.WaitGroup
lock := &sync.Mutex{}
count := 0
wg.Add(3)
go add(&count, &wg, lock)
go add(&count, &wg, lock)
go add(&count, &wg, lock)
wg.Wait()
fmt.Println("count 的值为:", count)
}
此时,不管你执行多少次,输出都只有一个结果
count 的值为: 3000
使用 Mutext 锁虽然很简单,但仍然有几点需要注意:
- 同一协程里,不要在尚未解锁时再次使加锁
- 同一协程里,不要对已解锁的锁再次解锁
- 加了锁后,别忘了解锁,必要时使用 defer 语句
RWMutex
Mutex 是最简单的一种锁类型,他提供了一个傻瓜式的操作,加锁解锁加锁解锁,让你不需要再考虑其他的。
简单同时意味着在某些特殊情况下有可能会造成时间上的浪费,导致程序性能低下。
举个例子,我们平时去图书馆,要嘛是去借书,要嘛去还书,借书的流程繁锁,没有办卡的还要让管理员给你办卡,因此借书通常都要排老长的队,假设图书馆里只有一个管理员,按照 Mutex(互斥锁)的思想, 这个管理员同一时刻只能服务一个人,这就意味着,还书的也要跟借书的一起排队。
可还书的步骤非常简单,可能就把书给管理员扫下码就可以走了。
如果让还书的人,跟借书的人一起排队,那估计有很多人都不乐意了。
因此,图书馆为了提高整个流程的效率,就允许还书的人,不需要排队,可以直接自助还书。
图书管将馆里的人分得更细了,对于读者的不同需求提供了不同的方案。提高了效率。
RWMutex,也是如此,它将程序对资源的访问分为读操作和写操作
- 为了保证数据的安全,它规定了当有人还在读取数据(即读锁占用)时,不允计有人更新这个数据(即写锁会阻塞)
- 为了保证程序的效率,多个人(线程)读取数据(拥有读锁)时,互不影响不会造成阻塞,它不会像 Mutex 那样只允许有一个人(线程)读取同一个数据。
理解了这个后,再来看看,如何使用 RWMutex?
定义一个 RWMuteux 锁,有两种方法
// 第一种
var lock *sync.RWMutex
lock = new(sync.RWMutex)
// 第二种
lock := &sync.RWMutex{}
RWMutex 里提供了两种锁,每种锁分别对应两个方法,为了避免死锁,两个方法应成对出现,必要时请使用 defer。
- 读锁:调用 RLock 方法开启锁,调用 RUnlock 释放锁
- 写锁:调用 Lock 方法开启锁,调用 Unlock 释放锁(和 Mutex类似)
接下来,直接看一下例子吧
package main
import (
"fmt"
"sync"
"time"
)
func main() {
lock := &sync.RWMutex{}
lock.Lock()
for i := 0; i < 4; i++ {
go func(i int) {
fmt.Printf("第 %d 个协程准备开始... \n", i)
lock.RLock()
fmt.Printf("第 %d 个协程获得读锁, sleep 1s 后,释放锁\n", i)
time.Sleep(time.Second)
lock.RUnlock()
}(i)
}
time.Sleep(time.Second * 2)
fmt.Println("准备释放写锁,读锁不再阻塞")
// 写锁一释放,读锁就自由了
lock.Unlock()
// 由于会等到读锁全部释放,才能获得写锁
// 因为这里一定会在上面 4 个协程全部完成才能往下走
lock.Lock()
fmt.Println("程序退出...")
lock.Unlock()
}
输出如下
第 1 个协程准备开始...
第 0 个协程准备开始...
第 3 个协程准备开始...
第 2 个协程准备开始...
准备释放写锁,读锁不再阻塞
第 2 个协程获得读锁, sleep 1s 后,释放锁
第 3 个协程获得读锁, sleep 1s 后,释放锁
第 1 个协程获得读锁, sleep 1s 后,释放锁
第 0 个协程获得读锁, sleep 1s 后,释放锁
程序退出...