1、Go语言并发基础知识
在学习Go语言并发编程之前,我们需要对进程、线程、协程、并发、并行等概念有所了解。
平常,在我们编译代码后就会产生可执行的二进制文件,当我们运行这个文件时,其会被装载到内存中,这个运行中的程序就称为进程。然而,当运行到读写磁盘的指令时,磁盘的读写速度非常慢,如果CPU选择等待IO完成再继续,那么CPU利用率会非常低。因此,当执行到这样的指令时,CPU不会阻塞等待,而是去执行其他进程,等到数据返回时再执行该进程。
由此我们引入了两个概念:并发 与 并行。
一言以蔽之,并发就是多线程程序在单核上运行,并行就是多线程程序在多核上运行。并发通过多个程序快速交替执行,使得看起来像是并行一样,然而实际上一瞬间只能运行一个进程。如下图所示:
进程是资源分配的单位,但是单进程的程序并没有并发执行,效率低;而多进程的程序又面临着通信和共享数据、进程上下文切换开销大的问题,因此线程就呼之欲出了。线程是进程当中的一条执行流程。 同一个进程的多个线程可以共享资源,而每个线程拥有自己的寄存器和栈。因此线程的上下文切换比进程的上下文切换开销要小得多。
线程包括用户级线程、内核级线程、轻量级线程。而在Go语言中,其描述略有不同,main函数被称为主线程,在一个Go线程上可以跑多个协程,协程拥有独立的栈空间和共享程序的堆空间,调度由用户控制,属用户态,可以理解为轻量级线程。比较协程和内核态线程而言,栈空间的量级分别是KB和MB的级别。
2、goroutine
(1) 创建协程
我们要怎么在主线程中创建协程呢?在Go语言中使用go关键字为一个函数创建一个goroutine,这意味着一个函数可以被创建多个协程。
package main
import (
"fmt"
"time"
)
func hello(i int) {
fmt.Println("hello I am", i)
}
func main() {
for i := 1; i <= 10; i++ {
go hello(i)//使用go关键字为函数创建协程
}
time.Sleep(time.Second)
}
当主线程结束时,无论子协程是否运行完成,都会直接结束。所以我们可以利用这一点写一个无限循环的计数器,当接收到用户输入回车时程序结束。
package main
import (
"fmt"
"time"
)
func run() {
counter := 0
for {
fmt.Println(counter)
counter++
time.Sleep(time.Second)
}
}
func main() {
go run()
var s string
fmt.Scanln(&s)
}
同理我们可以为匿名函数创建协程,这样可以将上面的代码改写为:
package main
import (
"fmt"
"time"
)
func main() {
go func() {
counter := 0
for {
fmt.Println(counter)
counter++
time.Sleep(time.Second)
}
}()
var s string
fmt.Scanln(&s)
}
(2) 调整并发的运行性能
在runtime包中,提供了NumCPU()函数查询本机的核数,同时提供GOMAXPROCS()函数,即设置运行主线程的最大核数。我们可以通过下列代码将其设置为本机的核数:
runtime.GOMAXPROCS(runtime.NumCPU())
笔者测试了一份用并发实现30000000个数做质因数分解的代码的时间,如果设置为单核(即runtime.GOMAXPROCS(1)),运行了58s;如果设置为满核(笔者的笔记本是16核,即runtime.GOMAXPROCS(runtime.NumCPU()),运行了7s)
package main
import (
"fmt"
"runtime"
"time"
)
var ch chan int
func cal(l, r int) {
//对每个数质因数分解.
for i := l; i <= r; i++ {
cur := []int{}
x := i
for j := 2; j*j <= x; j++ {
if x%j == 0 {
cur = append(cur, j)
for x%j == 0 {
x /= j
}
}
}
if x > 1 {
cur = append(cur, x)
}
}
ch <- 1
}
func main() {
ch = make(chan int)
runtime.GOMAXPROCS(runtime.NumCPU()) //这里可以测试不同数字来看时耗差距
start := time.Now().Unix()
for i := 1; i <= 30000000; i += 10000 {
go cal(i, i+9999)
}
for i := 1; i <= 30000000; i += 10000 {
<-ch
}
end := time.Now().Unix()
fmt.Println("耗时为", end-start, "秒")
}
3、Go语言竞争状态
(1) 并发竞争
并发竞争,指并发的多个协程同时对某个共享资源进行读写操作,使其处于相互竞争的状态。我们可以看一个简单的例子:并发地调用100000次加1的函数,看看会出现什么?
package main
import (
"fmt"
"sync"
)
var (
a int64
wg sync.WaitGroup
)
func main() {
a = 0
for i := 1; i <= 100000; i++ {
wg.Add(1)
go func() {
a++
wg.Done()
}()
}
wg.Wait() //使用等待组让主线程等到子协程运行完成再进入后面的代码
fmt.Println(a)
}
我们发现,输出的结果不是我们预期的100000,而是会比100000少,而且每次都很有可能不同。这是因为可能在某一瞬间,多个goroutine同时对a进行写,使得已经+1的结果被覆盖掉了,导致结果错误,学过数据库的小伙伴都知道这种错误称为丢失更新。
为了检测是否存在并发竞争,我们可以用go build -race指令编译代码生成可执行文件,再运行可执行文件查看是否存在并发竞争。
为了解决这种并发竞争,Go语言为我们提供了许多方法,我们先介绍原子函数、互斥锁、读写互斥锁。
(2) 原子函数
原子函数在内置包sync/atomic中,是通过CPU指令实现的,可以保证在读写变量时不会被其他的协程所影响。其提供的原子操作主要为Add、Load、Store、Swap。分别表示对给定变量(指针传递)加上一个值、从给定变量读取、存储值到给定变量、将值交换给指定变量,返回其原值。上述操作都是并发安全的。因此我们可以把源代码改写为:
package main
import (
"fmt"
"sync"
"sync/atomic"
)
var (
a int64
wg sync.WaitGroup
)
func main() {
a = 0
for i := 1; i <= 100000; i++ {
wg.Add(1)
go func() {
atomic.AddInt64(&a, 1)
atomic.LoadInt64(&a) //原子函数的读也是并发安全的
wg.Done()
}()
}
wg.Wait() //使用等待组让主线程等到子协程运行完成再进入后面的代码
fmt.Println(a)
}
这样输出的结果就是100000了。
(3) 互斥锁
互斥锁位于包sync里,即sync.Mutex,当一个goroutine要读写数据时,会加锁,而其他协程加锁被阻塞,只能等待该协程释放锁来唤醒。加锁和解锁的函数分别为lock()和unlock()。
这样我们可以将上面的代码中匿名函数的部分改写为:
go func() {
lock.Lock()
a++
lock.Unlock()
wg.Done()
}()
运行发现这样也是并发安全的。
死锁
当两个协程互相等待对方释放锁以加锁获取资源而阻塞时,会永远等待不到进入死锁。在Go语言中,编译运行时就会报fatal error: all goroutines are asleep - deadlock!错误。但是注意这种错误只有在主线程阻塞时才会报,即就算子协程直接产生死锁,只要主线程可以正常结束,就不会报错。
for i := 1; i <= 10; i++ {
wg.Add(1)
go func() {
lock.Lock()
a++
//lock.Unlock() //协程互相等待对方释放锁而陷入阻塞状态,导致永远阻塞
wg.Done()
}()
}
我们将上面代码中的lock.Unlock()去掉,发现代码报了死锁的错误。但是如果将wg.Wait()去掉,让主线程不等待子协程结束,改成time.Sleep(..)之类的,就不会报错,可正常运行。
注意,在Go语言里,这样写也会报死锁:
//...在某个协程里:
lock.Lock()
lock.Lock()
称为锁重入导致的死锁。这是因为Go的互斥锁是不可重入锁。
(4) 读写互斥锁
其实就是将互斥锁拆成了读锁和写锁,当我们需要读取数据时,其实不需要直接上互斥锁,我们上读锁即可。这是因为读之间是不会互相影响的,也不会产生并发竞争,所以我们允许多个协程给共享资源上读锁。
读写互斥锁即RWMutex,上读锁函数为Rlock(),解读锁函数为RUnlock(),写锁的函数与互斥锁相同。
使用方法如下:
for i := 1; i <= 10; i++ {
wg.Add(1)
go func() {
rwlock.Lock() //要修改a,加写锁
a++
rwlock.Unlock()
rwlock.RLock() //需要读取a,加读锁
fmt.Println(a)
rwlock.RUnlock()
wg.Done()
}()
}
注意读写互斥锁的写锁重入也会导致死锁。
4、Channel
Go语言实现并发安全的通信机制就是channel,任意时刻,同时只能有一个goroutine访问通道发送或接收数据。Go语言采用的并发模型是:通信顺序进程,提倡通过通信共享内存而不是通过共享内存而实现通信。channel本身是先进先出的队列结构,创建时需要声明存储的变量类型。
(1) Channel初始化和基本操作
声明通道的类型是chan type,type表示通道内存储的数据类型。需要注意的是,channel是引用类型,声明完后其空值为nil,仍然不可使用,必须配合make才可以使用。
创建通道
通道分为无缓冲通道和有缓冲通道,创建时的区别如下:
make(chan type) //无缓冲通道
make(chan type,size) //有缓冲通道
可以参考如下代码:
package main
import (
"fmt"
)
func main() {
ch1 := make(chan int) //无缓冲通道.
ch2 := make(chan int, 3) //有缓冲通道,在第二个参数声明,声明的数字即通道的最大容量
go func() {
ch1 <- 2
}()
x := <-ch1
fmt.Println(x)
ch2 <- 1
x = <-ch2
fmt.Println(x)
}
输出结果为2 1。
发送、接收数据
向通道中发送数据时,格式为通道变量 <- 值,如上面代码所示。
从通道中接收数据时,格式为<- 通道变量,如果要接收并赋值前面加上变量名 = ,如上面代码所示。
关于这一块接收、发送数据的细节和死锁处理、等待队列等内容,我们放在后面深入讲解。
关闭通道
关闭通道的语法为close(通道变量),不能向关闭后的通道发送数据,但可以从通道中接收数据:
close(ch2)
ch2 <- 3
这样写是会报错的!报错:panic: send on closed channel。
判断channel是否关闭
使用多变量返回值:val,ok:= <- ch,这与map中find的方式和类型断言的语法类似。若已关闭,获取的val就是该类型的零值,ok则为false。这种方式称为非阻塞接收数据,可能造成高CPU占用。
package main
import "fmt"
var ch chan int
func test(i int) {
ch <- i
}
func main() {
ch = make(chan int)
go test(1)
val, ok := <-ch
fmt.Println(val, ok)
close(ch)
val, ok = <-ch
fmt.Println(val, ok)
}
输出结果为1 true和0 false。
(2) 等待队列
我们从通道的底层入手,它的底层是hchan结构体:
type hchan struct {
//channel分为无缓冲和有缓冲两种。
//对于有缓冲的channel存储数据,借助的是如下循环数组的结构
qcount uint // 循环数组中的元素数量
dataqsiz uint // 循环数组的长度
buf unsafe.Pointer // 指向底层循环数组的指针
elemsize uint16 //能够收发元素的大小
closed uint32 //channel是否关闭的标志
elemtype *_type //channel中的元素类型
//有缓冲channel内的缓冲数组会被作为一个“环型”来使用。
//当下标超过数组容量后会回到第一个位置,所以需要有两个字段记录当前读和写的下标位置
sendx uint // 下一次发送数据的下标位置
recvx uint // 下一次读取数据的下标位置
//当循环数组中没有数据时,收到了接收请求,那么接收数据的变量地址将会写入读等待队列
//当循环数组中数据已满时,收到了发送请求,那么发送数据的变量地址将写入写等待队列
recvq waitq // 读等待队列
sendq waitq // 写等待队列
lock mutex //互斥锁,保证读写channel时不存在并发竞争问题
}
总结而言,底层存储数据使用的是循环队列(链表)。
等待队列指的是:
当goroutine从channel中读取数据时,若缓冲区为空/没有缓冲区(无缓冲区的通道),当前goroutine被阻塞,进入读等待队列。
当goroutine向channel中写数据时,若缓冲区满/没有缓冲区,当前goroutine被阻塞,进入写缓冲队列。
recvq的goroutine由写入数据的goroutine唤醒,反之亦然。
因此,使用通道时,只要主线程被永久阻塞,就会形成死锁:
func main() {
ch1 := make(chan int)
ch1 <- 1
}
在上面的代码中,主线程向管道写入1时没有缓冲区,进入阻塞状态,等待读数据的goroutine唤醒,但是根本没有协程来读,所以形成死锁。
func main() {
ch1 := make(chan int)
go func() {
ch1 <- 1
}()
}
但这样是不会报错的,因为虽然goroutine永久阻塞了,但是主线程可以正常结束。
在经过了前面对等待队列的讲解后,我们考虑从channel读写数据会发送什么?
向channel中写数据:
-
若读等待队列
recvq不为空,则将等待的协程G取出并写入数据,最后唤醒G,结束。 -
若
recvq为空,且缓冲区有空位,那么将数据写入缓冲区。 -
否则将当前
goroutine阻塞,加入写等待队列sendq,等待被唤醒。
从channel中读数据:
-
若写等待队列
sendq不为空,且没有缓冲区,则将等待的协程G取出并读出数据,最后唤醒G,结束。 -
若写等待队列
sendq不为空,且存在缓冲区,说明缓冲区已满,从缓冲区头部读出数据,并将sendq头部等待的协程G取出并将其数据写入缓冲区尾部,最后唤醒G,结束。 -
若
sendq为空,且缓冲区内有数据,那么从缓冲区头部读出数据。 -
否则将当前
goroutine阻塞,加入读等待队列recvq,等待被唤醒。
(3) 单向通道和通道遍历
顾名思义,单向通道就是只能读入或写。
声明方式为var 变量名 <-chan type和var 变量名 chan<- type,分别表示只读和只写的通道。
单向通道更多时候是对channel的一种使用限制。
package main
import (
"fmt"
)
var ch chan int
func main() {
ch = make(chan int)
go func() {
var writeonly chan<- int = ch //只能写的管道,写入到ch中
writeonly <- 1
writeonly <- 2
close(writeonly)
}()
var readonly <-chan int = ch //将ch赋给只读的管道,将数据读出.
x := <-readonly
fmt.Println(x)
x = <-readonly
fmt.Println(x)
}
除了前面介绍的通道遍历方式外,我们还可以使用range for来进行遍历。需要注意的是,使用range for遍历时通道必须关闭,否则会报死锁。
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func main() {
ch := make(chan int, 10)
wg.Add(1)
go func() {
for i := 1; i <= 10; i++ {
ch <- i
}
close(ch) //不关闭通道就无法使用range for。读者可以试试删掉这句会发生什么
wg.Done()
}()
wg.Wait()
for v := range ch {
fmt.Println(v)
}
}
5、其他
(1) 等待组
在前面的代码中,我们虽然没有介绍,但是已经多次使用了WaitGroup,主要的场景是我们无法预估子协程会进行多久,但我们又希望主线程等待协程运行完毕再关闭,而使用sleep让主线程等待我们可能难以估计时间,因此WaitGroup呼之欲出。
等待组位于包sync中,拥有Add(delta int) 、Done()、Wait()三种方法。在WaitGroup中维护着一个计数,初始时为0。当调用Add(1)和Done()时,将使得计数+1和-1;调用Wait()方法会使得此协程/主线程阻塞,直到WaitGroup维护的计数变成0。当某次操作使得计数变为负数时,会产生panic。
一般的使用方式就是在开启子协程时Add(),子协程结束时Done(),这样可以保证主线程会等待各协程结束后再继续后面的部分。
package main
import (
"fmt"
"sync"
"sync/atomic"
)
var (
a int64
wg sync.WaitGroup
)
func main() {
a = 0
for i := 1; i <= 100000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&a, 1)
atomic.LoadInt64(&a)
}()
}
wg.Wait()
fmt.Println(a)
}
(2) Select
select语句一般用来在主线程中监听和channel有关的IO操作。和Switch-Case的语法类似。
package main
import (
"fmt"
)
var ch chan int
func main() {
ch = make(chan int)
go func() {
ch <- 1
}()
select {
//可写
case ch <- 1:
fmt.Println("Write!")
//可读
case <-ch:
fmt.Println("Read!")
default:
fmt.Println("No")
}
}
上述代码可能输出Read!也可能输出No,这是因为可能协程没来得及写入1主线程就执行完成了。此外,若有多个Case同时被满足,Select语句并非顺序执行的,而是先到先执行,同时到则随机执行的原则。如果没有Case满足且不存在Default,那么主线程/协程将堵塞。
注意Select语句中Case的读不像普通的读可能会堵塞进入等待队列,因为case中如果读不到channel那么不会进入等待队列而是会直接返回。