并发编程
Golang并发通过
goroutine
来实现。goroutine是由Go语言的运行时(runtime)调度完成的,而线程是由操作系统调度完成。goroutine
和channel
是Go秉承的CSP(Communicating Sequential Process)并发模式的重要实现基础。
1. 协程(goroutine)
Go中使用goroutine
非常简单,只需要在调用函数时在前面加上go
关键字。一个goroutine
必定对应一个函数,可以创建多个goroutine
去执行相同的函数。
OS线程(操作系统线程)一般都有固定的栈内存(通常为2MB),一个goroutine的栈在其生命周期开始时只有很小的栈(典型情况下2KB),goroutine的栈不是固定的,他可以按需增大和缩小,goroutine的栈大小限制可以达到1GB,虽然极少会用到这么大。所以在Go语言中一次创建十万左右的goroutine也是可以的。
1.1. 创建一个goroutine
下面的代码开启一个单独的goroutine
来执行hello
函数。结果只打印了main,是因为执行hello的goroutine
还没来得及调度,main函数就已经终止了。
package main
import (
"fmt"
)
func hello() {
fmt.Println("hello")
}
func main() {
go hello()
//time.Sleep(time.Second)
fmt.Println("main")
}
如何将上述hello()函数执行呢?一种直接的方法,使用time.Sleep()
,让main函数所在的goroutine休眠一段时间。但是在实际的业务逻辑中, 这样的方式实现goroutine
同步,绝对是一种憨憨做法。于是,便有了一种优雅的方式来等待goroutine
的结束,即sync.WaitGroup
。
1.2. sync.WaitGroup
使用sync.WaitGroup的难点在于确定goroutine是什么时候结束的,即在哪里调用wg.Done()
让计数器减一。
- 下面我开启了100个
goroutine
,来执行hello()
中的任务,为了使得0-99个goroutine调度顺序依次执行,我开启每个goroutine
以后,休眠1ms再开启下一个goroutine
。
package main
import (
"fmt"
"time"
)
func hello(i int) {
fmt.Println("hello goroutine", i)
}
func main() {
for i := 0; i < 100; i++ {
go hello(i)
time.Sleep(time.Millisecond)
}
}
如果一个goroutine
花费0.1ms就可以调度完成,这里我给每个goroutine
分配1ms就很不合理,而且每个goroutine
有可能执行时间不一样,那么我是否可以让一个goroutine
里的任务一致性完,就调度另一个goroutine
而不是这种sleep
的憨憨做法。
- 使用
sync.WaitGroup
同步方式
通常情况下,会使用sync.WaitGroup
的方式实现多个goroutine
之间的同步。
package main
import (
"fmt"
"sync"
)
func hello(i int) {
defer wg.Done() // goroutine将hello中任务执行完,计数器减一
fmt.Println("hello goroutine", i)
}
var wg sync.WaitGroup
func main() {
for i := 0; i < 100; i++ {
wg.Add(1) // 每开启一个goroutine,计数器加一
go hello(i)
//time.Sleep(time.Millisecond)
wg.Wait() // 等待计数器降为0,再继续执行
}
// 更多的业务场景是,多个goroutine和main之间的同步,上述代码只是为了模拟sleep同样的效果。
}
2. channel
单纯的将函数并发执行是没有意义的,函数和函数之间需要交换数据才能体现并发的价值。我们可以使用共享内存的方式进行数据交换(通过共享内存实现通信),但是它会导致不同的goroutine
中发生竞态问题。为了保证数据交换的正确性,必须使用互斥量对内存进行加锁,这种做法会导致性能问题。
Golang并发模型(CSP : Communicating Sequential Processes),提倡通过通信共享内存而不是通过共享内存而实现通信。channel
可以让一个goroutine
发送特定值到另一个goroutine
,可以看作是一种通信机制。
2.1. channel类型
// 1. 声明
var ch1 chan int
var ch2 chan bool
// 2. 创建channel
make(chan int, 16)
// 3. 将一个值发送到通道
ch <- 10
// 4. 从一个通道中接收值
x := <- ch // 赋值给x
<- ch // 忽略结果
// 5. 关闭通道
close(ch)
2.2. 优雅的操作channel
- 循环读取数据
package main
import "fmt"
func main() {
ch1 := make(chan int)
ch2 := make(chan int)
// 开启一个goroutine,把0-24之间的数发送到ch1
go func() {
for i := 0; i < 25; i++ {
ch1 <- i
}
close(ch1)
}()
// 从ch1中取出数据,计算平方,放到ch2
go func() {
for {
x, ok := <- ch1
if ok {
ch2 <- x * x
}
}
close(ch2)
}()
for i := range ch2 {
fmt.Println(i)
}
}
2.3. 单向通道
有些通道可能只用于发送,或者只用于接收。单向通道多用于函数的参数中。
func f1(ch1 chan<- int) {
}
func f2(ch1 <-chan int){}
2.4. 通道注意事项
关闭: 只有在通知接收方goroutine所有的数据都发送完毕的时候才需要关闭通道。通道是可以被GC回收的,它和关闭文件是不一样的,在结束操作之后关闭文件是必须要作的,但关闭通道不是必须的。
2.5. 生产者消费者模型案例
开启两个协程,一个协程生产数据,另一个协程对数据进行处理,处理完再把数据发回去。
package main
import (
"fmt"
"sync"
)
var wg sync.WaitGroup
func producer(in chan<- int) {
defer wg.Done()
for i := 0; i < 10; i++ {
in <- i * i
}
close(in)
}
func consumer(out <-chan int) {
defer wg.Done()
for {
x, ok := <-out
if !ok {
break
}
fmt.Println("num: ", x)
}
}
func main() {
ch := make(chan int, 16)
wg.Add(2)
go producer(ch)
go consumer(ch)
wg.Wait()
fmt.Println("主协程结束...")
}
3. 锁
3.1. 竞态
发生竞态现象的必要条件是:两个goroutine并发读写同一个变量,并且至少其中一个goroutine是写入操作。
在存款操作和取款操作并发执行的时候,就会发生静态现象。有三种方法可以避免数据静态。
- 不要修改变量。
- 避免从多个goroutine访问同一变量。
- 允许多个goroutine访问同一变量,但同一时间只有一个goroutine可以访问。这种方法称为互斥机制。
Golang中代码中加锁,一般体现在是对临界区加锁。
临界区:程序片段访问临界资源的代码,临界区同一时刻只能有一个线程运行,其他线程必须等待访问,所以需要用到锁;
3.2. 互斥锁:sync.Mutex
互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个goroutine
可以访问共享资源。Go语言中使用sync
包的Mutex
类型实现互斥锁。
使用互斥锁能够保证同一时间只有一个goroutine
进入临界区,其他的goroutine
则在等待锁;当互斥锁释放后,等待的goroutine
才可以获取锁进入临界区,多个goroutine
同时等待一个锁时,唤醒的策略是随机的。
3.3. 读写互斥锁:sync.RWMutex
互斥锁是完全互斥的,但是有很多实际的场景下是读多写少(数据库读写分离),当我们并发的去读取一个资源不涉及资源修改的时候,没必要加锁,这种情况下使用读写锁是更好的一种选择。读写锁在Go语言中使用sync
包中的RWMutex
类型。
读写锁分为两种:读锁和写锁。当一个goroutine获取读锁之后,其他的goroutine
如果获取的是读锁无需等待,如果是获取写锁就会等待;当一个goroutine
获取写锁之后,其他的goroutine
无论是获取读锁还是写锁都会等待。
4. sync包其它应用
4.1. sync.Once
在编程的中,我们需要确保某些操作在高并发的场景下只执行一次,例如只加载一次配置文件、只关闭一次通道等。Golang中sync包提供了一个解决方案sync.Once
。
// 如果要执行函数f,就需要搭配闭包来使用
func (o *Once) Do(f func()) {}
4.2. sync.Map
Go语言内置的map不是并发安全的,多个goroutine并发访问map时会发生错误(一般超过21个goroutine读写map时编译器就会报错)。可以使用互斥锁的方式访问临界资源(在操作map前加锁,操作完释放锁)。由于Golang针对map的使用场景较多,系统提供了开箱即用(不需要使用make初始化)的并发安全的map即 sync.Map
。
5. atomic原子性操作
- 我开10万个goroutine,每个goroutine对全局变量x加一,每次执行结果都不一样。
package main
import (
"fmt"
"sync"
)
var x int
var wg sync.WaitGroup
func add() {
x++
wg.Done()
}
func main() {
for i := 0; i < 100000; i++ {
wg.Add(1)
go add()
}
wg.Wait()
fmt.Println(x)
}
2. 加锁(sync.Mutex)
package main
import (
"fmt"
"sync"
)
var x int
var wg sync.WaitGroup
var lock sync.Mutex
func add() {
defer wg.Done()
// 对临界区加锁
lock.Lock()
x++
lock.Unlock()
}
func main() {
for i := 0; i < 100000; i++ {
wg.Add(1)
go add()
}
wg.Wait()
fmt.Println(x)
}
3. 使用原子操作
package main
import (
"fmt"
"sync"
"sync/atomic"
)
var x int64
var wg sync.WaitGroup
var lock sync.Mutex
func add() {
defer wg.Done()
//lock.Lock()
//x++
//lock.Unlock()
atomic.AddInt64(&x, 1)
}
func main() {
for i := 0; i < 100000; i++ {
wg.Add(1)
go add()
}
wg.Wait()
fmt.Println(x)
}