Go 学习笔记(23)— 并发(02)[竞争,锁资源,原子函数sync/atomic、互斥锁sync.Mutex]

本文参考 《Go 语言实战》

1. 竞争状态简述

如果两个或者多个 goroutine 在没有互相同步的情况下,访问某个共享的资源,并试图同时读和写这个资源,就处于相互竞争的状态,这种情况被称作竞争状态(race candition)。

对一个共享资源的读和写操作必须是原子化的,换句话说,同一时刻只能有一个 goroutine 对共享资源进行读和写操作。

当某些东西被认为是原子的,或者具有原子性的时候,这意味着在它运行的环境中,它是不可分割的或不可中断的。

// 这个示例程序展示如何在程序里造成竞争状态
// 实际上不希望出现这种情况
package main

import (
	"fmt"
	"runtime"
	"sync"
)

var (
	// counter是所有goroutine都要增加其值的变量
	counter int

	// wg用来等待程序结束
	wg sync.WaitGroup
)

// main是所有Go程序的入口
func main() {
	// 计数加2,表示要等待两个goroutine
	wg.Add(2)

	// 创建两个goroutine
	go incCounter(1)
	go incCounter(2)

	// 等待goroutine结束
	wg.Wait()
	fmt.Println("Final Counter:", counter)
}

// incCounter增加包里counter变量的值
func incCounter(id int) {
	// 在函数退出时调用Done来通知main函数工作已经完成
	defer wg.Done()

	for count := 0; count < 2; count++ {
		// 捕获counter的值
		value := counter

		// 当前goroutine从线程退出,并放回到队列
		/*
			用于将 goroutine 从当前线程退出,给其他 goroutine 运行的机会。
			在两次操作中间这样做的目的是强制调度器切换两个 goroutine,以便让竞争状态的效果变得更明显。
		*/
		runtime.Gosched()

		// 增加本地value变量的值
		value++

		// 将该值保存回counter
		counter = value
	}
}

输出:

Final Counter: 2

变量 counter 会进行 4 次读和写操作,每个 goroutine 执行两次。但是,程序终止时, counter 变量的值为2。

每个 goroutine 都会覆盖另一个 goroutine 的工作。这种覆盖发生在 goroutine 切换的时候。每个 goroutine 创造了一个 counter 变量的副本,之后就切换到另一个 goroutine

当这个 goroutine 再次运行的时候, counter 变量的值已经改变了,但是 goroutine 并没有更新自己的那个副本的值,而是继续使用这个副本的值,用这个值递增,并存回 counter 变量,结果覆盖了另一个 goroutine 完成的工作。

竞争状态下程序行为的图像表达.png

图: 竞争状态下程序行为的图像表达

2. 锁住共享资源

Go 语言提供了传统的同步 goroutine 的机制,就是对共享资源加锁。如果需要顺序访问一个整型变量或者一段代码, atomicsync 包里的函数提供了很好的解决方案。

下面我们了解一下 atomic 包里的几个函数以及 sync 包里的 mutex 类型。

2.1 原子函数

原子函数能够以很底层的加锁机制来同步访问整型变量和指针.

示例 1:

package main

import (
	"fmt"
	"sync/atomic"
)

var (
	// 序列号
	seq int64
)

// 序列号生成器
func GenID() int64 {
	// 尝试原子的增加序列号
	// 这里故意没有使用 atomic .Addlnt64()的返回值作 为 GenID () 函数的返// 回值,因此会造成一个竞态问题
	atomic.AddInt64(&seq, 1)
	return seq
}

func main() {
	// 10个并发序列号生成
	for i := 0; i < 10; i++ {
		go GenID()
	}

	fmt.Println(GenID())
}

在运行程序时,为运行参数加入 -race 参数,开启运行时( runtime )对竞态问题的分析,命令如下:

wohu@wohu-dev:~/gocode/src$ go run -race temp.go 
==================
WARNING: DATA RACE
Write at 0x0000005f8178 by goroutine 8:
  sync/atomic.AddInt64()
      /usr/local/go/src/runtime/race_amd64.s:276 +0xb
  main.GenID()
      /home/wohu/gocode/src/temp.go:16 +0x43

Previous read at 0x0000005f8178 by goroutine 7:
  main.GenID()
      /home/wohu/gocode/src/temp.go:17 +0x53

Goroutine 8 (running) created at:
  main.main()
      /home/wohu/gocode/src/temp.go:23 +0x4f

Goroutine 7 (finished) created at:
  main.main()
      /home/wohu/gocode/src/temp.go:23 +0x4f
==================
4
Found 1 data race(s)
exit status 66

修改该函数为下面即可正常。

// 序列号生成器
func GenID() int64 {
	// 尝试原子的增加序列号
	return atomic.AddInt64(&seq, 1)
}

示例代码 2:

// 这个示例程序展示如何使用atomic包来提供
// 对数值类型的安全访问
package main

import (
	"fmt"
	"runtime"
	"sync"
	"sync/atomic"
)

var (
	// counter是所有goroutine都要增加其值的变量
	counter int32

	// wg用来等待程序结束
	wg sync.WaitGroup
)

// main是所有Go程序的入口
func main() {
	// 计数加2,表示要等待两个goroutine
	wg.Add(2)

	// 创建两个goroutine
	go incCounter(1)
	go incCounter(2)

	// 等待goroutine结束
	wg.Wait()
	fmt.Println("Final Counter:", counter)
}

// incCounter增加包里counter变量的值
func incCounter(id int) {
	// 在函数退出时调用Done来通知main函数工作已经完成
	defer wg.Done()

	for count := 0; count < 2; count++ {
		// 安全地对counter加1
		atomic.AddInt32(&counter, 1)

		// 当前goroutine从线程退出,并放回到队列
		/*
			用于将 goroutine 从当前线程退出,给其他 goroutine 运行的机会。
			在两次操作中间这样做的目的是强制调度器切换两个 goroutine,以便让竞争状态的效果变得更明显。
		*/
		runtime.Gosched()

	}
}

输出:

Final Counter: 4

程序的第43行使用了 atmoic 包的 AddInt64 函数。这个函数会同步整型值的加法,方法是强制同一时刻只能有一个 goroutine 运行并完成这个加法操作。

goroutine 试图去调用任何原子函数时,这些 goroutine 都会自动根据所引用的变量做同步处理。

另外两个有用的原子函数是 LoadInt64StoreInt64 。这两个函数提供了一种安全地读和写一个整型值的方式。

如下代码示例程序使用 LoadInt64StoreInt64 来创建一个同步标志,这个标志可以向程序里多个 goroutine 通知某个特殊状态。

// 这个示例程序展示如何使用atomic包里的
// Store和Load类函数来提供对数值类型
// 的安全访问
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
	"time"
)

var (
	// shutdown是通知正在执行的goroutine停止工作的标志
	shutdown int64

	// wg用来等待程序结束
	wg sync.WaitGroup
)

// main是所有Go程序的入口
func main() {
	// 计数加2,表示要等待两个goroutine
	wg.Add(2)

	// 创建两个goroutine
	go doWork("A")
	go doWork("B")

	// 给定goroutine执行的时间
	time.Sleep(1 * time.Second)

	// 该停止工作了,安全地设置shutdown标志
	fmt.Println("Shutdown Now")
	atomic.StoreInt64(&shutdown, 1)

	// 等待goroutine结束
	wg.Wait()
}

// doWork用来模拟执行工作的goroutine,
// 检测之前的shutdown标志来决定是否提前终止
func doWork(name string) {
	// 在函数退出时调用Done来通知main函数工作已经完成
	defer wg.Done()

	for {
		fmt.Printf("Doing %s Work\n", name)
		time.Sleep(250 * time.Millisecond)

		// 要停止工作了吗?
		if atomic.LoadInt64(&shutdown) == 1 {
			fmt.Printf("Shutting %s Down\n", name)
			break
		}
	}
}

2.2 互斥锁

另一种同步访问共享资源的方式是使用互斥锁( mutex )。互斥锁这个名字来自互斥(mutual exclusion)的概念。

互斥锁用于在代码上创建一个临界区,保证同一时间只有一个 goroutine 可以执行这个临界区代码。

// 这个示例程序展示如何使用互斥锁来
// 定义一段需要同步访问的代码临界区
// 资源的同步访问
package main

import (
	"fmt"
	"runtime"
	"sync"
)

var (
	// counter是所有goroutine都要增加其值的变量
	counter int

	// wg用来等待程序结束
	wg sync.WaitGroup

	// mutex 用来定义一段代码临界区
	mutex sync.Mutex
)

// main是所有Go程序的入口
func main() {
	// 计数加2,表示要等待两个goroutine
	wg.Add(2)

	// 创建两个goroutine
	go incCounter(1)
	go incCounter(2)

	// 等待goroutine结束
	wg.Wait()
	fmt.Printf("Final Counter: %d\n", counter)
}

// incCounter使用互斥锁来同步并保证安全访问,
// 增加包里counter变量的值
func incCounter(id int) {
	// 在函数退出时调用Done来通知main函数工作已经完成
	defer wg.Done()

	for count := 0; count < 2; count++ {
		// 同一时刻只允许一个goroutine进入
		// 这个临界区
		mutex.Lock()
		{	 // 使用大括号只是为了让临界区看起来更清晰,并不是必需的。
			// 捕获counter的值
			value := counter

			// 当前goroutine从线程退出,并放回到队列
			runtime.Gosched()

			// 增加本地value变量的值
			value++

			// 将该值保存回counter
			counter = value
		}
		mutex.Unlock()
		// 释放锁,允许其他正在等待的goroutine
		// 进入临界区
	}
}

counter 变量的操作在第 46 行和第 60 行的 Lock()Unlock() 函数调用定义的临界区里被保护起来。

同一时刻只有一个 goroutine 可以进入临界区。之后,直到调用 Unlock() 函数之后,其他 goroutine 才能进入临界区。当第 52 行强制将当前 goroutine 退出当前线程后,调度器会再次分配这个 goroutine 继续运行。当程序结束时,我们得到正确的值 4,竞争状态不再存在。

2.3 读写互斥锁 sync.RWMutex

当然,互斥锁也并不总是最好的。由于在同一时间内只能有一个协程获取互斥锁并执行操作,那么在多读少写的情况下,如果长时间没有写操作,读取到的会是完全相同的值,使用互斥锁就显得没有必要了。这个时候,使用读写锁则更加恰当。

读写锁通过两种锁来实现,一种为读锁,另一种为写锁。当进行读取操作时,需要加读锁,而进行写入操作时则需要加写锁。多个协程可以同时获得读锁并执行。如果此时有协程申请了写锁,那么该协程会等待所有的读锁都释放后,才能获取写锁并执行。如果当前的协程申请读锁时已经存在写锁,那么读锁会等待写锁释放后再获取读锁并执行。

总之,读锁必须能观察到上一次写锁写入的值,写锁则要在之前的读锁释放后才能写入。可以有多个协程获得读锁,但只有一个协程可以获得写锁。

在读多写少的环境中,可以优先使用读写互斥锁, sync 包中的 RWMutex 提供了读写互斥锁的封装。

package main

import (
	"fmt"
	"sync"
	"time"
)

var (
	count int
	// 变量对应的读写互斥锁
	countGuard sync.RWMutex
)

func GetCount() int {
	countGuard.RLock()
	defer countGuard.RUnlock()
	return count
}

func SetCount(c int) {
	countGuard.Lock()
	{
		count += c
	}
	countGuard.Unlock()
}

func main() {
	// 可以进行并发安全的设置
	for i := 0; i < 10; i++ {
		go SetCount(2)
	}
	time.Sleep(2 * time.Second)
	// 可以进行并发安全的读取
	fmt.Println(GetCount())
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值