go并发
进程/线程
进程是程序在操作系统中的一次执行过程,系统进行资源分配和调度的独立单位
线程是进程的一个执行实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位。
一个进程可以创建和撤销多个线程,同一个进程中的多个进程可以并发执行
并发/并行
多线程程序在单核心的CPU上运行,称为并发;多线程程序在多核心的CPU上运行,go 1.8后默认设置为最大核数,不需自己设置
协程/线程
协程:独立的栈空间,共享堆空间,调度由用户自己控制,本质上有点类似于用户级线程,这些用户级线程的调度也是自己实现的。
线程:一个线程上可以跑多个协程,协程是轻量级的线程。
Goroutine 介绍
基本概念
goroutine是一种非常轻量级的实现,可在单个进程里执行执行成千上万的并发任务,它是Go语言并发设计的核心。
说到底 goroutine 其实就是线程,但是它比线程更小,十几个 goroutine 可能体现在底层就是五六个线程,而且Go语言内部也实现了 goroutine 之间的内存共享。
使用 go 关键字就可以创建 goroutine,将 go 声明放到一个需调用的函数之前,在相同地址空间调用运行这个函数,这样该函数执行时便会作为一个独立的并发线程,这种线程在Go语言中则被称为 goroutine。
注意
主函数也是一个g程,如果想在主函数中使用其他goroutine,一定要让主函数等待其他goroutine,比如锁,管道,和time包中的sleep函数
用法
//go 关键字放在方法调用前新建一个 goroutine 并执行方法体
go GetThingDone(param1, param2);
//新建一个匿名方法并执行
go func(param1, param2) {
}(val1, val2)
//直接新建一个 goroutine 并在 goroutine 中执行代码块
go {
//do someting...
}
为什么并发
从整个操作系统层面来说,多个进程是可以并发的,那么并发的价值何在?下面我们先看以下几种场景。
-
一方面我们需要灵敏响应的图形用户界面,一方面程序还需要执行大量的运算或者 IO 密集操作,而我们需要让界面响应与运算同时执行。
-
当我们的 Web 服务器面对大量用户请求时,需要有更多的“Web 服务器工作单元”来分别响应用户。
-
我们的事务处于分布式环境上,相同的工作单元在不同的计算机上处理着被分片的数据,计算机的 CPU 从单内核(core)向多内核发展,而我们的程序都是串行的,计算机硬件的能力没有得到发挥。
-
我们的程序因为 IO 操作被阻塞,整个程序处于停滞状态,其他 IO 无关的任务无法执行。
从以上几个例子可以看到,串行程序在很多场景下无法满足我们的要求。下面我们归纳了并发程序的几条优点,让大家认识到并发势在必行:
- 并发能更客观地表现问题模型;
- 并发可以充分利用 CPU 核心的优势,提高程序的执行效率;
- 并发能充分利用 CPU 与其他硬件设备固有的异步性。
Go并发通信
并发中的资源竞争
原子性
原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“同生共死”的感觉。即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。
示例和分析
如果两个或者多个goroutine在没有相互同步的情况下,访问某个共享资源比如同时对该资源进行读写时,就会处于相互竞争的状态,这就是并发中的资源竞争。
var (
count int32
wg sync.WaitGroup//这个后面讲锁时讲
)
func main() {
wg.Add(2)//这个后面讲锁时讲
go incCount()
go incCount()
wg.Wait()
fmt.Println(count)//这个后面讲锁时讲
}
func incCount() {
defer wg.Done()//这个后面讲锁时讲
for i := 0; i < 2; i++ {
value := count
// runtime.Gosched() 是让当前 goroutine 暂停的意思,退回执行队列,让其他等待的 goroutine 运行,目的是为了使资源竞争的结果更明显。
runtime.Gosched()
value++
count = value
}
}
这是一个资源竞争的例子,大家可以将程序多运行几次,会发现结果可能是 2,也可以是 3,还可能是 4。这是因为 count 变量没有任何同步保护,所以两个 goroutine 都会对其进行读写,会导致对已经计算好的结果被覆盖,以至于产生错误结果。
下面我们来分析一下这个例子
- g1读取到count的值为0
- 然后g1暂停了,切换到g2运行,g2读取到count的值也为0
- g2暂停,切换到g1,g1对 count+1,count的值为1
- g1暂停,切换到g2,g2刚刚已经获取到值0,对其+1,最后赋值给count,其结果还是1
- 可以看出g1对count+1的结果被g2给覆盖了,两个goroutine 都 +1 而结果还是 1
通过上面的分析可以看出,之所以出现上面的问题,是因为两个 goroutine 相互覆盖结果。
所以我们对于同一个资源的读写必须是原子性的,也就是说,同一时间只能允许有一个 goroutine 对共享资源进行读写操作。
如何检查资源竞争
共享资源竞争的问题,非常复杂,并且难以察觉,好在 Go 为我们提供了一个工具帮助我们检查,这个就是go build -race 命令。在项目目录下执行这个命令,生成一个可以执行文件,然后再运行这个可执行文件,就可以看到打印出的检测信息。
==================
WARNING: DATA RACE
Read at 0x000000619cbc by goroutine 8:
main.incCount()
D:/code/src/main.go:25 +0x80
Previous write at 0x000000619cbc by goroutine 7:
main.incCount()
D:/code/src/main.go:28 +0x9f
Goroutine 8 (running) created at:
main.main()
D:/code/src/main.go:17 +0x7e
Goroutine 7 (finished) created at:
main.main()
D:/code/src/main.go:16 +0x66
==================
4
Found 1 data race(s)
通过运行结果可以看出 goroutine 8 在代码 25 行读取共享资源value := count,而这时 goroutine 7 在代码 28 行修改共享资源count = value,而这两个 goroutine 都是从 main 函数的 16、17 行通过 go 关键字启动的。
锁住共享资源
Go语言提供了传统的同步 goroutine 的机制,就是对共享资源加锁。atomic 和 sync 包里的一些函数就可以对共享的资源进行加锁操作。
原子函数
原子函数只是让大家理解下原子性和资源竞争,实际开发中用处不大,看懂就行了
原子函数能够以很底层的加锁机制来同步访问整型变量和指针,示例代码如下所示:
package main
import (
"fmt"
"runtime"
"sync"
"sync/atomic"
)
var (
counter int64
wg sync.WaitGroup
)
func main() {
wg.Add(2)
go incCounter(1)
go incCounter(2)
wg.Wait() //等待goroutine结束
//最后的运行结果为4,而不会出现上述例子的资源竞争,导致结果出错
fmt.Println(counter)
}
func incCounter(id int) {
defer wg.Done()
for count := 0; count < 2; count++ {
atomic.AddInt64(&counter, 1) //安全的对counter加1
runtime.Gosched()
}
}
上述代码中使用了 atmoic 包的 AddInt64 函数,这个函数会同步整型值的加法,方法是强制同一时刻只能有一个 gorountie 运行并完成这个加法操作。当 goroutine 试图去调用任何原子函数时,这些 goroutine 都会自动根据所引用的变量做同步处理。
另外两个有用的原子函数是 LoadInt64 和 StoreInt64。这两个函数提供了一种安全地读和写一个整型值的方式。下面是代码就使用了 LoadInt64 和 StoreInt64 函数来创建一个同步标志,这个标志可以向程序里多个 goroutine 通知某个特殊状态。
package main
import (
"fmt"
"sync"
"time"
)
var (
shutdown int64
wg sync.WaitGroup
)
//main 函数使用 StoreInt64 函数来安全地修改 shutdown 变量的值。如果哪个 doWork goroutine 试图在 main 函数调用 StoreInt64 的同时调用 LoadInt64 函数,那么原子函数会将这些调用互相同步,保证这些操作都是安全的,不会进入竞争状态。
func main() {
wg.Add(2)
go doWork("A")
go doWork("B")
time.Sleep(1 * time.Second)
fmt.Println("Shutdown Now")
//atomic.StoreInt64(&shutdown, 1)
wg.Wait()
}
func doWork(name string) {
defer wg.Done()
for {
fmt.Printf("Doing %s Work\n", name)
time.Sleep(250 * time.Millisecond)
/*if atomic.LoadInt64(&shutdown) == 1 {
fmt.Printf("Shutting %s Down\n", name)
break
}*/
}
}
锁
另一种同步访问共享资源的方式是使用互斥锁,互斥锁这个名字来自互斥的概念。互斥锁用于在代码上创建一个临界区,保证同一时间只有一个 goroutine 可以执行这个临界代码。
sync.WaitGroup
sync.WaitGroup是一个结构体,大家通过上节课也应该了解结构体可以包含很多方法现在让我们看看他的方法
Add
//Add将delta(可能为负值)添加到WaitGroup计数器。
//如果计数器变为零,则释放在等待时阻塞的所有goroutine。
//如果计数器为负,则Add panics。
//
//请注意,当计数器为零时会发生具有正增量的调用
//必须在等待之前发生。带有负增量的调用,或带有
//当计数器大于零时开始的正增量可能会发生
//任何时候。
//通常,这意味着对Add的调用应该在语句之前执行
//创建要等待的goroutine或其他事件。
//如果WaitGroup被重用以等待几个独立的事件集,
//新的Add调用必须在所有以前的Wait调用返回后发生。
func (wg *WaitGroup) Add(delta int) {
//有兴趣可以自己去看源码
....
}
函数输入变量delta代表现在所在函数要调用的g程的个数,如果为零则释放所有阻塞的g程
Done
// 完成将WaitGroup计数器递减一。
func (wg *WaitGroup) Done() {
wg.Add(-1)
}
等待组的计数器 -1,一般在被调用的g程中使用。
Wait
// 等待块,直到WaitGroup计数器为零。
func (wg *WaitGroup) Wait() {
...
}
wait其实就是一个阻塞当前调用wait方法的函数,比如在main函数调用就可以让main等待其他g程完毕,如果没有等待main函数会直接退出,可能就做不到main中运行g程的结果了
示例
package main
import (
"fmt"
"net/http"
"sync"
)
func main() {
// 声明一个等待组
var wg sync.WaitGroup
// 准备一系列的网站地址
var urls = []string{
"http://www.github.com/",
"https://www.qiniu.com/",
"https://www.golangtc.com/",
}
// 遍历这些地址
for _, url := range urls {
// 每一个任务开始时, 将等待组增加1
wg.Add(1)
// 开启一个并发
go func(url string) {
// 使用defer, 表示函数完成时将等待组值减1
defer wg.Done()
// 使用http访问提供的地址
_, err := http.Get(url)
// 访问完成后, 打印地址和可能发生的错误
fmt.Println(url, err)
// 通过参数传递url地址
}(url)
}
// 等待所有的任务完成
wg.Wait()
fmt.Println("over")
}
- 第 12 行,声明一个等待组,对一组等待任务只需要一个等待组,而不需要每一个任务都使用一个等待组。
- 第 15 行,准备一系列可访问的网站地址的字符串切片。
- 第 22 行,遍历这些字符串切片。
- 第 25 行,将等待组的计数器加1,也就是每一个任务加 1。
- 第 28 行,将一个匿名函数开启并发。
- 第 31 行,在匿名函数结束时会执行这一句以表示任务完成。wg.Done() 方法等效于执行 wg.Add(-1)。
- 第 34 行,使用 http 包提供的 Get() 函数对 url 进行访问,Get() 函数会一直阻塞直到网站响应或者超时。
- 第 37 行,在网站响应和超时后,打印这个网站的地址和可能发生的错误。
- 第 40 行,这里将 url 通过 goroutine 的参数进行传递,是为了避免 url 变量通过闭包放入匿名函数后又被修改的问题。
- 第 44 行,等待所有的网站都响应或者超时后,任务完成,Wait 就会停止阻塞。
sync.Mutex(互斥锁)
定义
Mutex 是最简单的一种锁类型,同时也比较暴力,当一个 goroutine 获得了 Mutex 后,其他 goroutine 就只能乖乖等到这个 goroutine 释放该 Mutex
方法
任何一个 Lock() 均需要保证对应有 Unlock()调用与之对应,否则可能导致等待该锁的所有 goroutine 处于饥饿状态,甚至可能导致死锁。
使用场景
适用于写少读多的场景
示例
package main
import (
"fmt"
"sync"
)
var (
// 逻辑中使用的某个变量
count int
// 与变量对应的使用互斥锁
countGuard sync.Mutex
)
func GetCount() int {
// 锁定
countGuard.Lock()
// 在函数退出时解除锁定
defer countGuard.Unlock()
return count
}
func SetCount(c int) {
countGuard.Lock()
count = c
countGuard.Unlock()
}
func main() {
// 可以进行并发安全的设置
SetCount(1)
// 可以进行并发安全的获取
fmt.Println(GetCount())
}
代码说明如下:
-
第 10 行是某个逻辑步骤中使用到的变量,无论是包级的变量还是结构体成员字段,都可以。
-
第 13 行,一般情况下,建议将互斥锁的粒度设置得越小越好,降低因为共享访问时等待的时间。这里笔者习惯性地将互斥锁的变量命名为以下格式:
-
第 16 行是一个获取 count 值的函数封装,通过这个函数可以并发安全的访问变量 count。
-
第 19 行,尝试对 countGuard 互斥量进行加锁。一旦 countGuard 发生加锁,如果另外一个 goroutine 尝试继续加锁时将会发生阻塞,直到这个 countGuard 被解锁。
-
第 22 行使用 defer 将 countGuard 的解锁进行延迟调用,解锁操作将会发生在 GetCount() 函数返回时。
-
第 27 行在设置 count 值时,同样使用 countGuard 进行加锁、解锁操作,保证修改 count 值的过程是一个原子过程,不会发生并发访问冲突。
sync.RWMutex(读写互斥锁)
定义
RWMutex 相对友好些,是经典的单写多读模型。在读锁占用的情况下,会阻止写,但不阻止读,也就是多个 goroutine 可同时获取读锁(调用 RLock() 方法;而写锁(调用 Lock() 方法)会阻止任何其他 goroutine(无论读和写)进来,整个锁相当于由该 goroutine 独占。从 RWMutex 的实现看,RWMutex 类型其实组合了 Mutex:
type RWMutex struct {
w Mutex
writerSem uint32
readerSem uint32
readerCount int32
readerWait int32
}
方法
任何一个RLock() 均需要保证对应有 RUnlock() 调用与之对应,否则可能导致等待该锁的所有 goroutine 处于饥饿状态,甚至可能导致死锁。锁的典型使用模式如下:
使用场景
在读多写少的环境中,可以优先使用读写互斥锁(sync.RWMutex),它比互斥锁更加高效。sync 包中的 RWMutex 提供了读写互斥锁的封装。
示例
我们只需要把互斥锁的示例稍稍修改下
var (
// 逻辑中使用的某个变量
count int
// 与变量对应的使用互斥锁
countGuard sync.RWMutex
)
func GetCount() int {
// 锁定
countGuard.RLock()
// 在函数退出时解除锁定
defer countGuard.RUnlock()
return count
}
代码说明如下:
- 第 6 行,在声明 countGuard 时,从 sync.Mutex 互斥锁改为 sync.RWMutex 读写互斥锁。
- 第 12 行,获取 count 的过程是一个读取 count 数据的过程,适用于读写互斥锁。在这一行,把 countGuard.Lock() 换做 countGuard.RLock(),将读写互斥锁标记为读状态。如果此时另外一个 goroutine 并发访问了 countGuard,同时也调用了 countGuard.RLock() 时,并不会发生阻塞。
- 第 15 行,与读模式加锁对应的,使用读模式解锁
使用锁出现的问题
会出现死锁,活锁,饥饿等问题(有些复杂,感兴趣自己百度下吧),而且效率低下,所以go语言提供了管道
channel
基本概念
如果说 goroutine 是 Go语言程序的并发体的话,那么 channels 就是它们之间的通信机制。一个 channels 是一个通信机制,它可以让一个 goroutine 通过它给另一个 goroutine 发送值信息。每个 channel 都有一个特殊的类型,也就是 channels 可发送数据的类型。一个可以发送 int 类型数据的 channel 一般写为 chan int。
Go语言提倡使用通信的方法代替共享内存,当一个资源需要在 goroutine 之间共享时,通道在 goroutine 之间架起了一个管道,并提供了确保同步交换数据的机制。声明通道时,需要指定将要被共享的数据的类型。可以通过通道共享内置类型、命名类型、结构类型和引用类型的值或者指针。
channel是Go语言在语言级别提供的goroutine间的通信方式。我们可以使用channel在两个或多个goroutine间通信。
channel是类型相关的,意思是,一个channel只能传递一种类型的值,这个类型需要在声明channel时指定,可以将其认为是一种类型安全的管道
在地铁站、食堂、洗手间等公共场所人很多的情况下,大家养成了排队的习惯,目的也是避免拥挤、插队导致的低效的资源使用和交换过程。代码与数据也是如此,多个 goroutine 为了争抢数据,势必造成执行的低效率,使用队列的方式是最高效的,channel 就是一种队列一样的结构。
通道的特性
Go语言中的通道(channel)是一种特殊的类型。在任何时候,同时只能有一个 goroutine 访问通道进行发送和获取数据。goroutine 间通过通道就可以通信。
通道像一个传送带或者队列,总是遵循先入先出(First In First Out)的规则,保证收发数据的顺序。
创建和声明
//声明
var 管道名 chan 类型
//创建
//必须使用 make 创建 channel
ci := make(chan int)
cs := make(chan string)
cf := make(chan interface{})
使用通道发送消息
通道创建后,就可以使用通道进行发送和接收操作。
1) 通道发送数据的格式
通道的发送使用特殊的操作符<-,将数据通过通道发送的格式为:
通道变量 <- 值
- 通道变量:通过make创建好的实例
- 值:可以是变量、常量、表达式或者函数返回值等。值的类型必须与ch通道的元素类型一致。
2) 通过通道发送数据的例子
使用 make 创建一个通道后,就可以使用<-向通道发送数据,代码如下:
//创建一个空接口的函数
ch:=make(chan interface{})
//将0放入通道中
ch<-=0
//将hello字符放入通道中
ch-<="hello"
3) 发送将持续阻塞直到数据被接收
把数据往通道发送时,如果接收方一直没有接受,那么发送操作将持续阻塞。Go 程序运行时能智能地发现一些永远无法发送成功的语句并做出提示,代码如下:
package main
func main() {
// 创建一个整型通道
ch := make(chan int)
// 尝试将0通过通道发送
ch <- 0
}
运行结果
fatal error: all goroutines are asleep - deadlock!
报错的意思是:运行时发现所有的 goroutine(包括main)都处于等待 goroutine。也就是说所有 goroutine 中的 channel 并没有形成发送和接收对应的代码。
使用通道接受数据
通道接受数据有以下特性
- 通道的收发操作在两个不同goroutine间进行,由于通道的数据在没有接收方处理时,数据发送方会持续阻塞,因此通道的接收必定在另外一个 goroutine 中进行。
- 接受数据将持续阻塞到发送方发送数据
- 每次接收一个元素
通道的数据接收一共有以下 4 种写法。
1) 阻塞接收数据
阻塞模式接收数据时,将接收变量作为<-操作符的左值,格式如下:
data := <-ch
执行该语句时将会阻塞,直到接收到数据并赋值给 data 变量。
2) 非阻塞接收数据
使用非阻塞方式从通道接收数据时,语句不会发生阻塞,格式如下
data, ok := <-ch
-
data:表示接收到的数据。未接收到数据时,data 为通道类型的零值。
-
ok:表示是否接收到数据。
非阻塞的通道接收方法可能造成高的 CPU 占用,因此使用非常少。如果需要实现接收超时检测,可以配合 select 和计时器 channel 进行,
3) 接收任意数据,忽略接收的数据
阻塞接收数据后,忽略从通道返回的数据,格式如下:
<-ch
执行该语句时将会发生阻塞,直到接收到数据,但接收到的数据会被忽略。这个方式实际上只是通过通道在 goroutine 间阻塞收发实现并发同步。
使用通道做并发同步的写法,可以参考下面的例子:
package main
import (
"fmt"
)
func main() {
// 构建一个通道
ch := make(chan int)
// 开启一个并发匿名函数
go func() {
fmt.Println("start goroutine")
// 通过通道通知main的goroutine
ch <- 0
fmt.Println("exit goroutine")
}()
fmt.Println("wait goroutine")
// 等待匿名goroutine
<-ch
fmt.Println("all done")
}
结果
wait goroutine
start goroutine
exit goroutine
all done
代码说明如下:
-
第 10 行,构建一个同步用的通道。
-
第 13 行,开启一个匿名函数的并发。
-
第 18 行,匿名 goroutine 即将结束时,通过通道通知 main 的 goroutine,这一句会一直阻塞直到 main 的 goroutine 接收为止。
-
第 27 行,开启 goroutine 后,马上通过通道等待匿名 goroutine 结束
4) 循环接收
通道的数据接收可以借用 for range 语句进行多个元素的接收操作,格式如下:
for data := range ch {
}
通道 ch 是可以进行遍历的,遍历的结果就是接收到的数据。数据类型就是通道的数据类型。通过 for 遍历获得的变量只有一个,即上面例子中的 data。
package main
import (
"fmt"
"time"
)
func main() {
// 构建一个通道
ch := make(chan int)
// 开启一个并发匿名函数
go func() {
// 从3循环到0
for i := 3; i >= 0; i-- {
// 发送3到0之间的数值
ch <- i
// 每次发送完时等待
time.Sleep(time.Second)
}
}()
// 遍历接收通道数据
for data := range ch {
// 打印通道数据
fmt.Println(data)
// 当遇到数据0时, 退出接收循环
if data == 0 {
break
}
}
}
执行代码,输出如下:
3
2
1
0
代码说明如下:
- 第 12 行,通过 make 生成一个整型元素的通道。
- 第 15 行,将匿名函数并发执行。
- 第 18 行,用循环生成 3 到 0 之间的数值。
- 第 21 行,将 3 到 0 之间的数值依次发送到通道 ch 中。
- 第 24 行,每次发送后暂停 1 秒。
- 第 30 行,使用 for 从通道中接收数据。
- 第 33 行,将接收到的数据打印出来。
- 第 36 行,当接收到数值 0 时,停止接收。如果继续发送,由于接收 goroutine 已经退出,没有 goroutine 发送到通道,因此运行时将会触发宕机报错。
单向通道
Go语言的类型系统提供了单方向的 channel 类型,顾名思义,单向 channel 就是只能用于写入或者只能用于读取数据。当然 channel 本身必然是同时支持读写的,否则根本没法用。
假如一个 channel 真的只能读取数据,那么它肯定只会是空的,因为你没机会往里面写数据。同理,如果一个 channel 只允许写入数据,即使写进去了,也没有丝毫意义,因为没有办法读取到里面的数据。所谓的单向 channel 概念,其实只是对 channel 的一种使用限制。
单向通道的声明格式
我们在将一个 channel 变量传递到一个函数时,可以通过将其指定为单向 channel 变量,从而限制该函数中可以对此 channel 的操作,比如只能往这个 channel 中写入数据,或者只能从这个 channel 读取数据。
单向 channel 变量的声明非常简单,只能写入数据的通道类型为chan<-,只能读取数据的通道类型为<-chan,格式如下:
var 通道实例 chan<- 元素类型 //只能写入数据的管道
var 通道实例 <-chan 元素类型 //只能读取数据的管道
- 元素类型:通道包含的元素类型。
- 通道实例:声明的通道变量。