本文从实战出发,以线程间竞争、线程间协作两个角度,介绍GO语言并发实践,并在最后着重介绍了GO中的channel使用方法,此为GO语言并发的精华。让我们开始吧!
1 线程间竞争
当多线程执行同一条高级语言中对共享数据的竞争操作时,会导致数据不一致。使用“锁”可以避免同时使用共享数据,可以解决竞争问题。但“锁”的错误使用,比如,死锁,又是新的问题。GO语言中,支持“锁”的存在,主要有互斥锁和读写锁。GO还支持其他避免竞争的方法,主流的有原子性操作、MAP锁等等。
互斥锁
互斥锁保证同一时间内只有一个goroutine
可以访问共享数据。Go语言中使用sync
包来实现互斥锁,一个经典用例如下:
package main
import (
"fmt"
"sync"
"time"
)
type test struct {
x int
mutex sync.Mutex
}
func (t *test) add(){
//对t.x++操作进行加锁,避免竞争。
//如果不加锁,会出现错误的结果
t.mutex.Lock()
t.x++
t.mutex.Unlock()
}
func (t *test) get()int{
return t.x
}
func main() {
t:=test{x: 0}
for i := 0; i < 1000; i++ {
//并发的运行添加操作
go t.add()
}
//这里之所以加休止,是因为main函数也是一个特殊的go程。所以很有可能前面开启的go程还没运行完,main程已经选择输出了,也会出现错误的结果。
//此处也可以使用WaitGroup方法,后面会介绍。
time.Sleep(100)
fmt.Println(t.get())
}
读写锁
在读多写少的情况下,可以优先使用读写锁。读写锁和互斥锁用法非常相似,其读锁可以让共享资源被任意读但是不能写,其写锁就是一个互斥锁。GO语言中使用sync 库提供读写锁,一个经典用例如下:
package main
import (
"fmt"
"sync"
"time"
)
// 声明读写锁
var rwlock sync.RWMutex
var x int
// 写数据
func write() {
rwlock.Lock()
x += 1
rwlock.Unlock()
}
func read(i int) {
rwlock.RLock()
fmt.Println(x)
rwlock.RUnlock()
}
func main() {
go write()
for i := 0; i < 1000; i++ {
go read(i)
}
time.Sleep(100)
}
map锁
GO中的map支持并发读,但是并不能支持并发写。但是可以使用sync
中的支持并发的map来实现并发,示例代码如下:
package main
import (
"fmt"
"sync"
)
func main() {
wg := sync.WaitGroup{}
var m = sync.Map{}
// 并发写
for i := 0; i < 100; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
m.Store(i,i*i)
}(i)
}
wg.Wait()
fmt.Println(m.Load(1))
}
原子性操作
也可以使用sync/atomic
库完成原子操作来保证线程安全。原子操作在用户态就可以完成,性能比加锁更高,主要有以下几个原子方法:
//加减方法
AddXxx()
//读取方法
LoadXxx()
//写入方法
StoreXxx()
//交换方法
SwapXxx()
2 线程间协作
线程之间不仅存在竞争关系,还存在协作关系。比如消费者/生产者模型,需要不同的线程之间进行配合协作。GO语言中实现协作的方法不仅可以使用传统的如条件变量等方法,还可以通过特有的channel机制来实现协作。接下来将会依次介绍WaitGroup、条件变量、channel。
WaitGroup
GO中可以使用WaitGroup实现多个GO程之间的同步。在 sync.WaitGroup
类型中,每个 sync.WaitGroup 值在内部维护着一个计数,此计数的初始默认值为零。可以通过调用以下几个方法来改变此计数器。
方法名 | 功能 |
---|---|
Add(x int) | 等待组的计数器 +1 |
Done() | 等待组的计数器 -1 |
Wait() | 当等待组计数器不等于 0 时,阻塞直到变 0。 |
通常用作主函数GO程等待其他GO程完成的情况,简单使用如下:
package main
import (
"fmt"
"sync"
)
//声明一个计数器
var wg sync.WaitGroup
type test struct {
x int
mutex sync.Mutex
}
func (t *test) add(){
//运行完毕 计数器减一
defer wg.Done()
//对t.x++操作进行加锁,避免竞争。
//如果不加锁,会出现错误的结果
t.mutex.Lock()
t.x++
t.mutex.Unlock()
}
func (t *test) get()int{
return t.x
}
func main() {
t:=test{x: 0}
for i := 0; i < 1000; i++ {
//运行一个新GO程,计数器加一
wg.Add(1)
//并发的运行添加操作
go t.add()
}
//直到计数器为0,不然会一直阻塞
wg.Wait()
fmt.Println(t.get())
}
条件变量
条件变量通常和互斥锁一起,用于实现等待/唤醒模型,是协作的重要一环。其可以将某个线程阻塞,直到得到一个外部唤醒信号再重新运行。GO中的条件变量是基于锁的,在sync
库中实现,以下为GO中条件变量一个简单例子。
package main
import (
"fmt"
"sync"
"time"
)
var wg sync.WaitGroup
var cond *sync.Cond
func test(x int) {
defer wg.Done()
cond.L.Lock() // 获取锁
cond.Wait() // 等待通知 暂时阻塞
fmt.Println(x)
time.Sleep(time.Second * 1)
cond.L.Unlock()
}
func main() {
cond = sync.NewCond(&sync.Mutex{})
fmt.Println("start all")
for i := 0; i < 40; i++ {
wg.Add(1)
go test(i)
}
time.Sleep(time.Second * 3)
fmt.Println("one")
cond.Signal() // 下发一个通知给已经阻塞的goroutine
time.Sleep(time.Second * 3)
fmt.Println("one")
cond.Signal() // 3秒之后 下发一个通知给已经阻塞的goroutine
time.Sleep(time.Second * 3)
fmt.Println("broadcast")
cond.Broadcast() //3秒之后 下发广播给所有等待的goroutine
wg.Wait()
}
3 channel
不要通过共享内存来通信,要通过通信来共享内存
扫描二维码关注公众号,回复: 16669988 查看本文章
以上介绍的锁、原子性操作是大部分编程语言都支持的,接下来,将会介绍GO语言的并发核心组件channel(管道)。
channel和Goroutine是相辅相成的。GO程作为GO特有的并发体,是用户态的协程,非常的轻便,可以轻而易举的在一个性能一般的电脑上跑起上千个GO程。而Channel主要作用就是作为通信管道为GO程之间提供通信桥梁,具有接受数据和发送数据的功能,类似一个全局的并发安全的队列。GO语言设计者强调,不要通过共享内存来通信,要通过通信来共享内存,这是GO语言的设计思想,接下来将会介绍channel的使用。
定义及创建一个channel
channel的定义和map很像。首先,channel内的数据结构是可以自己定义的,比如如下代码就定义了一个可以存储Int类型的channel。
var ch chan int
注意看以上代码,注意一下几点:
- 定义ch为int类型的chan后,ch现在还是nil值,不能直接使用!就像map的使用一样,后续还需要用make来进行创建。
- 不仅可以定义int类型的chan,还可以定义其他类型的chan
channel的创建也和map很像,可使用如下代码进行创建:
ch=make(chan int,10)
注意:
- 创建的时候指定的类型,必须跟定义的时候类型一致,只有创建后chan才能正常使用。
- 创建时,可以指定通道的大小,上面的例子是10。当大小为0时,创建的通道为无缓冲通道,大于0时,为有缓冲通道。
当然,也可以把定义和创建放在一起:
ch:=make(chan int,10)
channel的基本操作
channel具有发送数据和接受数据的功能,写法分别如下:
x := 0
//创建通道ch
ch:= make(chan int,1)
// 向ch发送数据
ch <- 1
// 从ch接收数据
x = <- ch
注意:
- 每个通道都有缓冲区大小,当缓冲区满了后,发送会被阻塞,当缓冲区为空时,接受会被阻塞。
- 无缓冲通道单个发送和单个接受都会被阻塞,只有发送和接受成对存在的时候才能继续往下运行。
而根据channel的操作分类,管道还可以分为普通通道以及单向通道,定义分别如下:
//只能向通道发送数据
var ch chan-< int
//只能从通道接受数据
var ch <-chan int
- 一般来说,把通道作为函数值传给一个函数时,会使用单向通道,这样可以限制函数对通道的操作。
接下来,编写一个双人打乒乓的例子,来展示channel的用法:
package main
import (
"fmt"
"math/rand"
"sync"
"time"
)
var wg sync.WaitGroup
func A(ch chan int) {
defer wg.Done()
for {
time.Sleep(time.Second)
//得到击球机会
num := <-ch
//如果是有效的回球
if num >= 1 {
//产生随机数回球,如果数小于300,代表没打到球,输掉比赛。
temp := rand.Int63() % 1000
fmt.Println("A击出第", num, "球,得到", temp, "分!")
if temp < 300 {
fmt.Println("A输掉比赛!")
ch <- 0
break
}
ch <- num + 1
} else {
//如果是无效的球,退出循环
break
}
}
fmt.Println("A结束比赛~")
}
//B和A类似
func B(ch chan int) {
defer wg.Done()
for {
time.Sleep(time.Second)
//得到击球机会
num := <-ch
if num >= 1 {
temp := rand.Int63() % 1000
fmt.Println("B击出第", num, "球,得到", temp, "分!")
if temp < 300 {
fmt.Println("B输掉比赛!")
ch <- 0
break
}
ch <- num + 1
} else {
break
}
}
fmt.Println("B结束比赛~")
}
func main() {
ch := make(chan int)
wg.Add(2)
//启动双方
go A(ch)
go B(ch)
//发球
ch <- 1
//等待比赛结束
wg.Wait()
}
select
学会了chan的基本操作后,还需要学习一个很重要的东西——select。select跟switch的语法非常相似,但是使用方法却完全不同。可以理解为专用于通信的switch,其每一个case必须是一个通道通信操作(接受或者发送)。
注:Go 语言的
select
语句借鉴自 Unix 的select()
函数,在 Unix 中,可以通过调用select()
函数来监控一系列的文件句柄,一旦其中一个文件句柄发生了 IO 动作,该select()
调用就会被返回(C 语言中就是这么做的),后来该机制也被用于实现高并发的 Socket 服务器程序。Go 语言直接在语言级别支持select
关键字,用于处理并发编程中通道之间异步 IO 通信问题。 ——《虎贲GO(21世纪之C语言)》
以下为基本语法:
select {
case <-ch:
// 如果从 ch 信道成功接收数据,则执行该分支代码
case ch1 <- 1:
// 如果成功向 ch1 信道成功发送数据,则执行该分支代码
// 你可以定义任意数量的 case
//default为可选项
default :
//如果没有case可运行,则运行此分支
}
注意一下几点:
-
每一个case后面都应该接一个关于chan的通信操作。
-
所有case后面的表达式并不是都会被运行。如果存在多个可运行的case,select 会随机地执行一个可运行的 case执行,并执行其下的分支代码。
-
如果没有 case 可运行,会运行default分支,如果没有default分支,它将阻塞,直到有 case 可运行。
select和定时器
select经常和定时器一起使用,可以实现定时执行某些操作,通常用于监控某个服务是否在指定时间内完成,以下有个简单代码,可以尝试修改一些:
package main
import (
"fmt"
"time"
)
func A(ch chan int) {
time.Sleep(time.Second * 2)
ch <- 1
}
func main() {
ch := make(chan int)
go A(ch)
select {
//接受来自ch的消息
case <-ch:
fmt.Println("接收到消息!")
//定时器设置为1s,超过1s后case被激活
case <-time.After(1 * time.Second):
fmt.Println("没接收到消息!")
}
}
总结
本文中,主要介绍了GO语言并发的基本语法并给出了一些小Demo。笔者GO语言专栏还有一些进阶版的实践的例子,有兴趣的朋友可以进入主页搜索“GO并发”查看。