go语言并发编程
在了解go语言的并发编程之前,我们必须先了解并发和并行的概念。我们知道,当启动一个应用的时候实际上是启动了一个进程,通过该进程实现资源的调度和分配,并且多个进程之间是相互隔离的,所以我们运行其中一个应用不会对其他应用造成影响。
默认情况下一个进程只有一个线程,也就是单线程应用,这种模式下所有的操作都是同步的,处理完A事件以后才能接着处理B事件再接着处理C事件。
那么,单线程模式下能否进行并发编成,当然可以。我们设想一下,我有A、B、C三件事情要做,但是我又不想一个一个同步的去做,这时候我们可以使用并发模式,我先做A的一部分,接着做B的一部分,然后是C的一部分,通过这种不停切换的方式可以保证三件事情同时推进,这就算并发。
在多核处理器之前,也就是单核时代,我们只能在一个线程之间进行多任务的调度,这种调度方式也就算并发,随之技术的进步,现在已经进入了多核时代,为计算机的并行开发提供了条件。
我们在设想一下,以前就只有自己一个人,现在我又多个几个双胞胎兄弟(多核),那我是不是可以再请两个兄弟帮着做B、C事情,我自己做A事情,这样同时推进A、B、C事情,是的,这就是并行。
有时候并发和并行是同时的,比如多个线程同时处理数百件任务,多个线程并行的执行任务,一个线程又并发的执行多个任务。
言归正传,接下来我们开始分析go语言的并发编程。
1、goroutine
package main
import (
"fmt"
"time"
)
func printString(value string) {
for i := 0; i < 10; i++ {
fmt.Println(value)
time.Sleep(time.Second) //0.1 second
}
}
func main() {
go printString("A")
go printString("B")
time.Sleep(time.Second * 10)//暂时挂起主线程,让goroutine的两个线程跑完
}
输出结果:ABABBAABABBABABAABAB
两个线程交叉输出,说明是以并发形式执行,这里为什么说是并发而不是并行,因为go语言默认是单核执行线程。如果想要多核并行执行,需要在启动goroutine之前首先调用以下语句配置cpu核数:
runtime.GOMAXPROCS()
自从Go 1.5开始, Go的GOMAXPROCS默认值已经设置为 CPU的核数,我们不用手动设置这个参数。
2、go语言的线程通信
无论是哪种编程语言,实现多线程之间的通信方式无非是共享数据和消息两种方式。
1)我们先看下go语言是如何通过共享数据实现线程通信。
package main
import (
"fmt"
"sync"
"time"
)
var counter int = 0
func Count(lock *sync.Mutex) {
lock.Lock()
counter++
fmt.Println(counter)
lock.Unlock()
}
func main() {
lock := &sync.Mutex{}
for i := 0; i < 5; i++ {
go Count(lock)
}
time.Sleep(time.Second * 5)
}
输出结果:1 2 3 4 5
此时,多个线程共享数据counter,实际上当业务逻辑比较复杂并且共享数据比较多的情况下,使用这种方式是一件十分头疼的事情。我们这里暂且不提这些。
2)消息机制实现多线程通信
go语言使用channel在两个或多个线程之间传递消息。channel翻译成中文就算通道的意思,我们直接看代码:
package main
import (
"fmt"
"sync"
"time"
)
func WriteIn(ch chan int) {
for i := 0; i < 10; i++ {
ch <- i
}
}
func ReadOut(ch chan int) {
for i := 0; i < 10; i++ {
value := <-ch
fmt.Println(value)
}
}
func main() {
ch := make(chan int)
go WriteIn(ch)
go ReadOut(ch)
time.Sleep(time.Second * 5)
}
输出:1 2 3 4 5 6 7 8 9,通过chanel在不需要锁的情况下实现了数据的共享,chanel的用法中,常见的操作包括写入和读出,写入和读出都会导致线程的阻塞,也就是说数据写入后线程会等待另一个线程的读取,而读取之前如果没有数据写入也会进入阻塞状态等待数据写入。
3、go语言的线程同步
1)使用互斥锁实现线程同步
互斥锁是最简单的一种锁类型,同时也比较暴力,当一个goroutine获得了锁之后,其他goroutine就只能乖乖等到这个goroutine释放该锁。go语言使用sync.Mutex实现互斥锁。
代码如下:
package main
import (
"fmt"
"sync"
"time"
)
type MutexInfo struct {
mutex sync.Mutex
infos []int
}
func (m *MutexInfo) addInfo(value int) {
m.mutex.Lock()
m.infos = append(m.infos, value)
m.mutex.Unlock()
}
func main() {
m := MutexInfo{}
for i := 0; i < 10; i++ {
go m.addInfo(i)
}
time.Sleep(time.Second * 5)
fmt.Println(m.infos)
}
输出:[0 1 2 3 4 5 6 7 8 9]
我们通过多次运行发现,输出的结果并不总是从0到9按顺序输出,说明创建的10个goroutine并不是有序的抢占线程的执行权,也就是说这种同步并不是有序的同步,我们可以让10个goroutine一个一个的同步执行,但是并不能安排执行次序。
运行到这里,假如我们注释掉同步锁的代码为发生什么?
我们将addInfo方法修改如下:
func (m *MutexInfo) addInfo(value int) {
//m.mutex.Lock()
m.infos = append(m.infos, value)
//m.mutex.Unlock()
}
运行代码,输出:[1 0 2]
结果是不是出乎意料?为什么写了10个输入,只有3个值输入成功?这时候我们不得不解释线程的另一个概念,那就是线程安全。
我们先看下go语言中slice的append过程,使用append添加一个元素时,可能会有两步来完成:先获取当前切片数组的容量,比如当前容量是2,然后在新的存储区开辟一块新的存储单元,容量为2+1,并将原来的值和新的值存入新的存储单元。在没有同步锁的情况下,如果两个线程同时执行添加元素的操作,这时候可能只有一个被写入成功。这种情况就是非线程安全,相比之下,如果同时对一个int类型数据进行操作,就不会出现这种非线程安全的情况。
2)读写锁实现线程同步
go语言提供了另一种更加友好的线程同步的方式:sync.RWMutex。相对于互斥锁的简单暴力,读写锁更加人性化,是经典的单写多读模式。在读锁占用的情况下,会阻止写,但不阻止读,也就是多个goroutine可同时获取读锁,而写锁会阻止其他线程的读写操作。
代码如下:
package main
import (
"fmt"
"sync"
"time"
)
type MutexInfo struct {
mutex sync.RWMutex
infos []int
}
func (m *MutexInfo) addInfo(value int) {
m.mutex.Lock()
defer m.mutex.Unlock()
fmt.Println("write start", value)
m.infos = append(m.infos, value)
fmt.Println("write start end", value)
}
func (m *MutexInfo) readInfo(value int) {
m.mutex.RLock()
defer m.mutex.RUnlock()
fmt.Println("read start", value)
fmt.Println("read end", value)
}
func main() {
m := MutexInfo{}
for i := 0; i < 10; i++ {
go m.addInfo(i)
go m.readInfo(i)
}
time.Sleep(time.Second * 3)
fmt.Println(m.infos)
}
输出结果:
read start 0
read end 0
write start 1
write start end 1
read start 4
read end 4
read start 9
read end 9
read start 3
read start 1
read start 7
read end 7
read start 2
read start 8
read end 8
read end 1
read start 6
read end 6
read start 5
read end 5
read end 3
read end 2
write start 0
write start end 0
write start 2
write start end 2
write start 3
write start end 3
write start 4
write start end 4
write start 5
write start end 5
write start 6
write start end 6
write start 7
write start end 7
write start 8
write start end 8
write start 9
write start end 9
[1 0 2 3 4 5 6 7 8 9]
从结果我们可以看出,开始的时候读线程占用读锁,并且多个线程可以同时开始读操作,但是写操作只能单个进行。
3)使用条件变量实现线程同步
go语言提供了条件变量sync.Cond,sync.Cond方法如下:
Wait,Signal,Broadcast。
Wait添加一个计数,也就是添加一个阻塞的goroutine。
Signal解除一个goroutine的阻塞,计数减一。
Broadcast接触所有wait goroutine的阻塞。
代码如下:
func printIntValue(value int, cond *sync.Cond) {
cond.L.Lock()
if value < 5 {
//value小于5时,进入等待状态
cond.Wait()
}
//大于5的正常输出
fmt.Println(value)
cond.L.Unlock()
}
func main() {
//条件等待
mutex := sync.Mutex{}
//使用锁创建一个条件等待
cond := sync.NewCond(&mutex)
for i := 0; i < 10; i++ {
go printIntValue(i, cond)
}
time.Sleep(time.Second * 1)
cond.Signal()//解除一个阻塞
time.Sleep(time.Second * 1)
cond.Broadcast()//解除全部阻塞
time.Sleep(time.Second * 1)
}
运行后先输出满足条件的值:5 6 7 8 9
解除一个阻塞,输出0,解除全部阻塞,输出1 2 3 4
go语言多线程支持全局唯一性操作,即一个只允许goruntine调用一次,重复调用无效。
go语言还支持sync.WaitGroup
实现多个线程同步的操作。