我们都知道 Golang 并发优选 channel, 但 channel 不是万能的, Golang 为我们提供了另一种选择: sync. 通过这篇文章, 你会了解 sync 包最基础, 最常用的方法, 至于 sync 和 channel 之争留给下一篇文章.
sync 包 https://golang.org/pkg/sync/ 提供了基础的异步操作方法, 比如互斥锁 (Mutex), 单次执行(Once) 和等待组(WaitGroup), 这些异步操作主要是为低级库提供, 上层的异步 / 并发操作最好选用通道和通信.
sync 包提供了:
Mutex: 互斥锁
RWMutex: 读写锁
WaitGroup: 等待组
Once: 单次执行
Cond: 信号量
Pool: 临时对象池
Map: 自带锁的 map
这篇文章是 sync 包的入门文章, 所以只介绍常用的结构和方法: Mutex , RWMutex , WaitGroup , Once , 而 Cond , Pool 和 Map 留给大家自行探索, 或有需求再介绍.
互斥锁
常做并发工作的朋友对互斥锁应该不陌生, Golang 里 互斥锁需要确保的是某段时间内, 不能有多个协程同时访问一段代码(临界区) .
互斥锁被称为 Mutex , 它有 2 个函数, Lock() 和 Unlock() 分别是获取锁和释放锁, 如下:
- type Mutex
- func (m *Mutex) Lock(){
- }
- func (m *Mutex) Unlock(){
- }
Mutex 的初始值为未锁的状态, 并且 Mutex 通常作为结构体的匿名成员存在.
经过了上面这么 "官方" 的介绍, 举个例子: 你在工商银行有 100 元存款, 这张卡绑定了支付宝和微信, 在中午 12 点你用支付宝支付外卖 30 元, 你在微信发红包, 抢到 10 块. 银行需要按顺序执行上面两件事, 先减 30 再加 10 或者先加 10 再减 30, 结果都是 80, 但如果同时执行, 结果可能是, 只减了 30 或者只加了 10, 即你有 70 元或者你有 110 元. 前一个结果是你赔了, 后一个结果是银行赔了, 银行可不希望把这种事算错.
看看实际使用吧: 创建一个银行, 银行里存每个账户的钱, 存储查询都加了锁操作, 这样银行就不会算错账了.
银行的定义:
- type Bank struct {
- sync.Mutex
- saving map[string]int // 每账户的存款金额
- }
- func NewBank() *Bank {
- b := &Bank{
- saving: make(map[string]int),
- }
- return b
- }
银行的存取钱:
- // Deposit 存款
- func (b *Bank) Deposit(name string, amount int) {
- b.Lock()
- defer b.Unlock()
- if _, ok := b.saving[name]; !ok {
- b.saving[name] = 0
- }
- b.saving[name] += amount
- }
- // Withdraw 取款, 返回实际取到的金额
- func (b *Bank) Withdraw(name string, amount int) int {
- b.Lock()
- defer b.Unlock()
- if _, ok := b.saving[name]; !ok {
- return 0
- }
- if b.saving[name] < amount {
- amount = b.saving[name]
- }
- b.saving[name] -= amount
- return amount
- }
- // Query 查询余额
- func (b *Bank) Query(name string) int {
- b.Lock()
- defer b.Unlock()
- if _, ok := b.saving[name]; !ok {
- return 0
- }
- return b.saving[name]
- }
模拟操作: 小米支付宝存了 100, 并且同时花了 20.
- func main() {
- b := NewBank()
- go b.Deposit("xiaoming", 100)
- go b.Withdraw("xiaoming", 20)
- go b.Deposit("xiaogang", 2000)
- time.Sleep(time.Second)
- fmt.Printf("xiaoming has: %d\n", b.Query("xiaoming"))
- fmt.Printf("xiaogang has: %d\n", b.Query("xiaogang"))
- }
结果: 先存后花.
- sync_pkg Git:(master) go run mutex.go
- xiaoming has: 80
- xiaogang has: 2000
也可能是: 先花后存, 因为先花 20, 因为小明没钱, 所以没花出去.
- sync_pkg Git:(master) go run mutex.go
- xiaoming has: 100
- xiaogang has: 2000
这个例子只是介绍了 mutex 的基本使用, 如果你想多研究下 mutex, 那就去我的 GitHub(阅读原文)下载下来代码, 自己修改测试. GitHub 中还提供了没有锁的例子, 运行多次总能碰到错误:
fatal error: concurrent map writes
读写锁
读写锁是互斥锁的特殊变种, 如果是计算机基本知识扎实的朋友会知道, 读写锁来自于读者和写者的问题, 这个问题就不介绍了, 介绍下我们的重点: 读写锁要达到的效果是同一时间可以允许多个协程读数据, 但只能有且只有 1 个协程写数据 .
也就是说, 读和写是互斥的, 写和写也是互斥的, 但读和读并不互斥. 具体讲, 当有至少 1 个协程读时, 如果需要进行写, 就必须等待所有已经在读的协程结束读操作, 写操作的协程才获得锁进行写数据. 当写数据的协程已经在进行时, 有其他协程需要进行读或者写, 就必须等待已经在写的协程结束写操作.
读写锁是 RWMutex , 它有 5 个函数, 它需要为读操作和写操作分别提供锁操作, 这样就 4 个了:
Lock() 和 Unlock() 是给写操作用的.
RLock() 和 RUnlock() 是给读操作用的.
RLocker()能获取读锁, 然后传递给其他协程使用. 使用较少.
- type RWMutex
- func (rw *RWMutex) Lock(){
- }
- func (rw *RWMutex) RLock(){
- }
- func (rw *RWMutex) RLocker() Locker{
- }
- func (rw *RWMutex) RUnlock(){
- }
- func (rw *RWMutex) Unlock(){
- }
上面的银行 实现不合理 : 大家都是拿手机 App 查余额, 可以同时几个人一起查呀, 这根本不影响, 银行的锁可以换成读写锁. 存, 取钱是写操作, 查询金额是读操作, 代码修改如下, 其他不变:
- type Bank struct {
- sync.RWMutex
- saving map[string]int // 每账户的存款金额
- }
- // Query 查询余额
- func (b *Bank) Query(name string) int {
- b.RLock()
- defer b.RUnlock()
- if _, ok := b.saving[name]; !ok {
- return 0
- }
- return b.saving[name]
- }
- func main() {
- b := NewBank()
- go b.Deposit("xiaoming", 100)
- go b.Withdraw("xiaoming", 20)
- go b.Deposit("xiaogang", 2000)
- time.Sleep(time.Second)
- print := func(name string) {
- fmt.Printf("%s has: %d\n", name, b.Query(name))
- }
- nameList := []string{"xiaoming", "xiaogang", "xiaohong", "xiaozhang"}
- for _, name := range nameList {
- go print(name)
- }
- time.Sleep(time.Second)
- }
结果, 可能不一样, 因为协程都是并发执行的, 执行顺序不固定:
- sync_pkg Git:(master) go run rwmutex.go
- xiaohong has: 0
- xiaozhang has: 0
- xiaogang has: 2000
- xiaoming has: 100
等待组
互斥锁和读写锁大多数人可能比较熟悉, 而对等待组 ( WaitGroup ) 可能就不那么熟悉, 甚至有点陌生, 所以先来介绍下等待组在现实中的例子.
你们团队有 5 个人, 你作为队长要带领大家打开藏有宝藏的箱子, 但这个箱子需要 4 把钥匙才能同时打开, 你把寻找 4 把钥匙的任务, 分配给 4 个队员, 让他们分别去寻找, 而你则守着宝箱, 在这 等待 , 等他们都找到回来后, 一起插进钥匙打开宝箱.
这其中有个很重要的过程叫等待: 等待一些工作完成后, 再进行下一步的工作 . 如果使用 Golang 实现, 就得使用等待组.
等待组是 WaitGroup , 它有 3 个函数:
- Add() Done() Wait()
- type WaitGroup
- func (wg *WaitGroup) Add(delta int){
- }
- func (wg *WaitGroup) Done(){
- }
- func (wg *WaitGroup) Wait(){
- }
来, 一起看下怎么用 WaitGroup 实现上面的问题.
队长先创建一个 WaitGroup 对象 wg, 每个队员都是 1 个协程, 队长让队员出发前, 使用 wg.Add() , 队员出发寻找钥匙, 队长使用 wg.Wait() 等待 (阻塞) 所有队员完成, 某个队员完成时执行 wg.Done() , 等所有队员找到钥匙, wg.Wait() 则返回, 完成了等待的过程, 接下来就是开箱.
结合之前的协程池的例子, 修改成 WG 等待协程池协程退出, 实例代码:
- func leader() {
- var wg sync.WaitGroup
- wg.Add(4)
- for i := 0; i < 4; i++ {
- go follower(&wg, i)
- }
- wg.Wait()
- fmt.Println("open the box together")
- }
- func follower(wg *sync.WaitGroup, id int) {
- fmt.Printf("follwer %d find key\n", id)
- wg.Done()
- }
结果:
- sync_pkg Git:(master) go run waitgroup.go
- follwer 3 find key
- follwer 1 find key
- follwer 0 find key
- follwer 2 find key
- open the box together
WaitGroup 也常用在协程池的处理上, 协程池等待所有协程退出, 把上篇文章 《Golang 并发模型: 轻松入门协程池》 的例子改下:
- package main
- import (
- "fmt"
- "sync"
- )
- func main() {
- var once sync.Once
- onceBody := func() {
- fmt.Println("Only once")
- }
- done := make(chan bool)
- for i := 0; i < 10; i++ {
- go func() {
- once.Do(onceBody)
- done <- true
- }()
- }
- for i := 0; i < 10; i++ {
- <-done
- }
- }
单次执行
在程序执行前, 通常需要做一些初始化操作, 但触发初始化操作的地方是有多处的, 但是这个初始化又只能执行 1 次, 怎么办呢?
使用 Once 就能轻松解决, once 对象是用来存放 1 个无入参无返回值的函数, once 可以确保这个函数只被执行 1 次 .
- type Once
- func (o *Once) Do(f func()){
- }
直接把官方代码给大家搬过来看下, once 在 10 个协程中调用, 但 once 中的函数 onceBody() 只执行了 1 次:
- package main
- import (
- "fmt"
- "sync"
- )
- func main() {
- var once sync.Once
- onceBody := func() {
- fmt.Println("Only once")
- }
- done := make(chan bool)
- for i := 0; i < 10; i++ {
- go func() {
- once.Do(onceBody)
- done <- true
- }()
- }
- for i := 0; i < 10; i++ {
- <-done
- }
- }
结果:
- sync_pkg Git:(master) go run once.go
- Only once
示例源码
本文所有示例源码, 及历史文章, 代码都存储在 GitHub:
下期预告
这次先介绍入门的知识, 下次再介绍一些深入思考, 最佳实践, 不能一口吃个胖子, 咱们慢慢来, 顺序渐进.
下一篇我以这些主题进行介绍, 欢迎关注:
哪个协程先获取锁
一定要用锁吗
锁与通道的选择
来源: http://www.tuicool.com/articles/iIzYfiZ