You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

26 KiB

34并发如何使用共享变量

你好我是Tony Bai。

在前面的讲解中我们学习了Go的并发实现方案知道了Go基于Tony Hoare的CSP并发模型理论实现了Goroutine、channel等并发原语。

并且Go语言之父Rob Pike还有一句经典名言“不要通过共享内存来通信应该通过通信来共享内存Dont communicate by sharing memory, share memory by communicating这就奠定了Go应用并发设计的主流风格使用channel进行不同Goroutine间的通信

不过Go也并没有彻底放弃基于共享内存的并发模型而是在提供CSP并发模型原语的同时还通过标准库的sync包提供了针对传统的、基于共享内存并发模型的低级同步原语包括互斥锁sync.Mutex、读写锁sync.RWMutex、条件变量sync.Cond并通过atomic包提供了原子操作原语等等。显然基于共享内存的并发模型在Go语言中依然有它的“用武之地”。

所以在并发的最后一讲我们就围绕sync包中的几个同步结构与对应的方法聊聊基于共享内存的并发模型在Go中的应用。

我们先来看看在哪些场景下我们需要用到sync包提供的低级同步原语。

sync包低级同步原语可以用在哪

这里我要先强调一句一般情况下我建议你优先使用CSP并发模型进行并发程序设计。但是在下面一些场景中我们依然需要sync包提供的低级同步原语。

首先是需要高性能的临界区critical section同步机制场景。

在Go中channel并发原语也可以用于对数据对象访问的同步我们可以把channel看成是一种高级的同步原语它自身的实现也是建构在低级同步原语之上的。也正因为如此channel自身的性能与低级同步原语相比要略微逊色开销要更大。

这里关于sync.Mutex和channel各自实现的临界区同步机制我做了一个简单的性能基准测试对比通过对比结果我们可以很容易看出两者的性能差异

var cs = 0 // 模拟临界区要保护的数据
var mu sync.Mutex
var c = make(chan struct{}, 1)

func criticalSectionSyncByMutex() {
    mu.Lock()
    cs++
    mu.Unlock()
}

func criticalSectionSyncByChan() {
    c <- struct{}{}
    cs++
    <-c
}

func BenchmarkCriticalSectionSyncByMutex(b *testing.B) {
    for n := 0; n < b.N; n++ {
        criticalSectionSyncByMutex()
    }
}

func BenchmarkCriticalSectionSyncByMutexInParallel(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            criticalSectionSyncByMutex()
        }
    })
}

func BenchmarkCriticalSectionSyncByChan(b *testing.B) {
    for n := 0; n < b.N; n++ {
        criticalSectionSyncByChan()
    }
}

func BenchmarkCriticalSectionSyncByChanInParallel(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            criticalSectionSyncByChan()
        }
    })
}

运行这个对比测试Go 1.17),我们得到:

$go test -bench .
goos: darwin
goarch: amd64
... ...
BenchmarkCriticalSectionSyncByMutex-8             	88083549	        13.64 ns/op
BenchmarkCriticalSectionSyncByMutexInParallel-8   	22337848	        55.29 ns/op
BenchmarkCriticalSectionSyncByChan-8              	28172056	        42.48 ns/op
BenchmarkCriticalSectionSyncByChanInParallel-8    	 5722972	       208.1 ns/op
PASS

通过这个对比实验我们可以看到无论是在单Goroutine情况下还是在并发测试情况下sync.Mutex实现的同步机制的性能都要比channel实现的高出三倍多。

因此通常在需要高性能的临界区critical section同步机制的情况下sync包提供的低级同步原语更为适合。

第二种就是在不想转移结构体对象所有权,但又要保证结构体内部状态数据的同步访问的场景。

基于channel的并发设计有一个特点在Goroutine间通过channel转移数据对象的所有权。所以只有拥有数据对象所有权从channel接收到该数据的Goroutine才可以对该数据对象进行状态变更。

如果你的设计中没有转移结构体对象所有权但又要保证结构体内部状态数据在多个Goroutine之间同步访问那么你可以使用sync包提供的低级同步原语来实现比如最常用的sync.Mutex

了解了这些应用场景之后接着我们就来看看如何使用sync包中的各个同步结构不过在使用之前我们需要先看看一个sync包中同步原语使用的注意事项。

sync包中同步原语使用的注意事项

在sync包的注释中$GOROOT/src/sync/mutex.go文件的头部注释),我们看到这样一行说明:

// Values containing the types defined in this package should not be copied.

翻译过来就是:“不应复制那些包含了此包中类型的值”。

在sync包的其他源文件中我们同样看到类似的一些注释


// $GOROOT/src/sync/mutex.go
// A Mutex must not be copied after first use. 禁止复制首次使用后的Mutex

// $GOROOT/src/sync/rwmutex.go
// A RWMutex must not be copied after first use.禁止复制首次使用后的RWMutex

// $GOROOT/src/sync/cond.go
// A Cond must not be copied after first use.禁止复制首次使用后的Cond
... ...

那么为什么首次使用Mutex等sync包中定义的结构类型后我们不应该再对它们进行复制操作呢我们以Mutex这个同步原语为例看看它的实现是怎样的。

Go标准库中sync.Mutex的定义是这样的

// $GOROOT/src/sync/mutex.go
type Mutex struct {
    state int32
    sema  uint32
}

我们看到Mutex的定义非常简单由两个整型字段state和sema组成

  • state表示当前互斥锁的状态
  • sema用于控制锁状态的信号量。

初始情况下Mutex的实例处于Unlocked状态state和sema均为0。对Mutex实例的复制也就是两个整型字段的复制。一旦发生复制原变量与副本就是两个单独的内存块各自发挥同步作用互相就没有了关联。

如果发生复制后,你仍然认为原变量与副本保护的是同一个数据对象,那可就大错特错了。我们来看一个例子:

 func main() {
     var wg sync.WaitGroup
     i := 0
     var mu sync.Mutex // 负责对i的同步访问
 
     wg.Add(1)
     // g1
     go func(mu1 sync.Mutex) {
         mu1.Lock()
         i = 10
         time.Sleep(10 * time.Second)
         fmt.Printf("g1: i = %d\n", i)
         mu1.Unlock()
         wg.Done()
     }(mu)
 
     time.Sleep(time.Second)
 
     mu.Lock()
     i = 1
     fmt.Printf("g0: i = %d\n", i)
     mu.Unlock()
 
     wg.Wait()
 }

在这个例子中我们使用一个sync.Mutex类型变量mu来同步对整型变量i的访问。我们创建一个新Goroutineg1g1通过函数参数得到mu的一份拷贝mu1然后g1会通过mu1来同步对整型变量i的访问。

那么g0通过mu和g1通过mu的拷贝mu1是否能实现对同一个变量i的同步访问呢我们来看看运行这个示例的运行结果

g0: i = 1
g1: i = 1

从结果来看这个程序并没有实现对i的同步访问第9行g1对mu1的加锁操作并没能阻塞第19行g0对mu的加锁。于是g1刚刚将i赋值为10后g0就又将i赋值为1了。

出现这种结果的原因就是我们前面分析的情况一旦Mutex类型变量被拷贝原变量与副本就各自发挥作用互相没有关联了。甚至如果拷贝的时机不对比如在一个mutex处于locked的状态时对它进行了拷贝就会对副本进行加锁操作将导致加锁的Goroutine永远阻塞下去。

通过前面这个例子我们可以很直观地看到如果对使用过的、sync包中的类型的示例进行复制并使用了复制后得到的副本将导致不可预期的结果。所以在使用sync包中的类型的时候我们推荐通过闭包方式,或者是**传递类型实例(或包裹该类型的类型实例)的地址(指针)**的方式进行。这就是使用sync包时最值得我们注意的事项。

接下来我们就来逐个分析日常使用较多的sync包中同步原语。我们先来看看互斥锁与读写锁。

互斥锁Mutex还是读写锁RWMutex

sync包提供了两种用于临界区同步的原语互斥锁Mutex和读写锁RWMutex。它们都是零值可用的数据类型也就是不需要显式初始化就可以使用并且使用方法都比较简单。在上面的示例中我们已经看到了Mutex的应用方法这里再总结一下

var mu sync.Mutex
mu.Lock()   // 加锁
doSomething()
mu.Unlock() // 解锁

一旦某个Goroutine调用的Mutex执行Lock操作成功它将成功持有这把互斥锁。这个时候如果有其他Goroutine执行Lock操作就会阻塞在这把互斥锁上直到持有这把锁的Goroutine调用Unlock释放掉这把锁后才会抢到这把锁的持有权并进入临界区。

由此,我们也可以得到使用互斥锁的两个原则:

  • 尽量减少在锁中的操作。这可以减少其他因Goroutine阻塞而带来的损耗与延迟。
  • 一定要记得调用Unlock解锁。忘记解锁会导致程序局部死锁甚至是整个程序死锁会导致严重的后果。同时我们也可以结合第23讲学习到的defer优雅地执行解锁操作。

读写锁与互斥锁用法大致相同,只不过多了一组加读锁和解读锁的方法:

var rwmu sync.RWMutex
rwmu.RLock()   //加读锁
readSomething()
rwmu.RUnlock() //解读锁
rwmu.Lock()    //加写锁
changeSomething()
rwmu.Unlock()  //解写锁

写锁与Mutex的行为十分类似一旦某Goroutine持有写锁其他Goroutine无论是尝试加读锁还是加写锁都会被阻塞在写锁上。

但读锁就宽松多了一旦某个Goroutine持有读锁它不会阻塞其他尝试加读锁的Goroutine但加写锁的Goroutine依然会被阻塞住。

通常,互斥锁Mutex是临时区同步原语的首选,它常被用来对结构体对象的内部状态、缓存等进行保护,是使用最为广泛的临界区同步原语。相比之下,读写锁的应用就没那么广泛了,只活跃于它擅长的场景下。

那读写锁RWMutex究竟擅长在哪种场景下呢我们先来看一组基准测试

var cs1 = 0 // 模拟临界区要保护的数据
var mu1 sync.Mutex

var cs2 = 0 // 模拟临界区要保护的数据
var mu2 sync.RWMutex

func BenchmarkWriteSyncByMutex(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            mu1.Lock()
            cs1++
            mu1.Unlock()
        }
    })
}

func BenchmarkReadSyncByMutex(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            mu1.Lock()
            _ = cs1
            mu1.Unlock()
        }
    })
}

func BenchmarkReadSyncByRWMutex(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            mu2.RLock()
            _ = cs2
            mu2.RUnlock()
        }
    })
}

func BenchmarkWriteSyncByRWMutex(b *testing.B) {
    b.RunParallel(func(pb *testing.PB) {
        for pb.Next() {
            mu2.Lock()
            cs2++
            mu2.Unlock()
        }
    })
}

这些基准测试都是并发测试度量的是Mutex、RWMutex在并发下的读写性能。我们分别在cpu=2、8、16、32的情况下运行这个并发性能测试测试结果如下

goos: darwin
goarch: amd64
... ...
BenchmarkWriteSyncByMutex-2     	73423770	        16.12 ns/op
BenchmarkReadSyncByMutex-2      	84031135	        15.08 ns/op
BenchmarkReadSyncByRWMutex-2    	37182219	        31.87 ns/op
BenchmarkWriteSyncByRWMutex-2   	40727782	        29.08 ns/op

BenchmarkWriteSyncByMutex-8     	22153354	        56.39 ns/op
BenchmarkReadSyncByMutex-8      	24164278	        51.12 ns/op
BenchmarkReadSyncByRWMutex-8    	38589122	        31.17 ns/op
BenchmarkWriteSyncByRWMutex-8   	18482208	        65.27 ns/op

BenchmarkWriteSyncByMutex-16      	20672842	        62.94 ns/op
BenchmarkReadSyncByMutex-16       	19247158	        62.94 ns/op
BenchmarkReadSyncByRWMutex-16     	29978614	        39.98 ns/op
BenchmarkWriteSyncByRWMutex-16    	16095952	        78.19 ns/op

BenchmarkWriteSyncByMutex-32      	20539290	        60.20 ns/op
BenchmarkReadSyncByMutex-32       	18807060	        72.61 ns/op
BenchmarkReadSyncByRWMutex-32     	29772936	        40.45 ns/op
BenchmarkWriteSyncByRWMutex-32    	13320544	        86.53 ns/op

通过测试结果对比,我们得到了一些结论:

  • 并发量较小的情况下Mutex性能最好随着并发量增大Mutex的竞争激烈导致加锁和解锁性能下降
  • RWMutex的读锁性能并没有随着并发量的增大而发生较大变化性能始终恒定在40ns左右
  • 在并发量较大的情况下RWMutex的写锁性能和Mutex、RWMutex读锁相比是最差的并且随着并发量增大RWMutex写锁性能有继续下降趋势。

由此,我们就可以看出,读写锁适合应用在具有一定并发量且读多写少的场合。在大量并发读的情况下多个Goroutine可以同时持有读锁从而减少在锁竞争中等待的时间。

而互斥锁即便是读请求的场合同一时刻也只能有一个Goroutine持有锁其他Goroutine只能阻塞在加锁操作上等待被调度。

接下来我们继续看条件变量sync.Cond。

条件变量

sync.Cond是传统的条件变量原语概念在Go语言中的实现。我们可以把一个条件变量理解为一个容器这个容器中存放着一个或一组等待着某个条件成立的Goroutine。当条件成立后这些处于等待状态的Goroutine将得到通知并被唤醒继续进行后续的工作。这与百米飞人大战赛场上各位运动员等待裁判员的发令枪声的情形十分类似。

条件变量是同步原语的一种如果没有条件变量开发人员可能需要在Goroutine中通过连续轮询的方式检查某条件是否为真这种连续轮询非常消耗资源因为Goroutine在这个过程中是处于活动状态的但它的工作又没有进展。

这里我们先看一个用sync.Mutex 实现对条件轮询等待的例子:

type signal struct{}

var ready bool

func worker(i int) {
	fmt.Printf("worker %d: is working...\n", i)
	time.Sleep(1 * time.Second)
	fmt.Printf("worker %d: works done\n", i)
}

func spawnGroup(f func(i int), num int, mu *sync.Mutex) <-chan signal {
	c := make(chan signal)
	var wg sync.WaitGroup

	for i := 0; i < num; i++ {
		wg.Add(1)
		go func(i int) {
			for {
				mu.Lock()
				if !ready {
					mu.Unlock()
					time.Sleep(100 * time.Millisecond)
					continue
				}
				mu.Unlock()
				fmt.Printf("worker %d: start to work...\n", i)
				f(i)
				wg.Done()
				return
			}
		}(i + 1)
	}

	go func() {
		wg.Wait()
		c <- signal(struct{}{})
	}()
	return c
}

func main() {
	fmt.Println("start a group of workers...")
	mu := &sync.Mutex{}
	c := spawnGroup(worker, 5, mu)

	time.Sleep(5 * time.Second) // 模拟ready前的准备工作
	fmt.Println("the group of workers start to work...")

	mu.Lock()
	ready = true
	mu.Unlock()

	<-c
	fmt.Println("the group of workers work done!")
}

就像前面提到的,轮询的方式开销大,轮询间隔设置的不同,条件检查的及时性也会受到影响。
sync.Cond为Goroutine在这个场景下提供了另一种可选的、资源消耗更小、使用体验更佳的同步方式。使用条件变量原语我们可以在实现相同目标的同时避免对条件的轮询。

我们用sync.Cond对上面的例子进行改造,改造后的代码如下:

type signal struct{}

var ready bool

func worker(i int) {
	fmt.Printf("worker %d: is working...\n", i)
	time.Sleep(1 * time.Second)
	fmt.Printf("worker %d: works done\n", i)
}

func spawnGroup(f func(i int), num int, groupSignal *sync.Cond) <-chan signal {
	c := make(chan signal)
	var wg sync.WaitGroup

	for i := 0; i < num; i++ {
		wg.Add(1)
		go func(i int) {
			groupSignal.L.Lock()
			for !ready {
				groupSignal.Wait()
			}
			groupSignal.L.Unlock()
			fmt.Printf("worker %d: start to work...\n", i)
			f(i)
			wg.Done()
		}(i + 1)
	}

	go func() {
		wg.Wait()
		c <- signal(struct{}{})
	}()
	return c
}

func main() {
	fmt.Println("start a group of workers...")
	groupSignal := sync.NewCond(&sync.Mutex{})
	c := spawnGroup(worker, 5, groupSignal)

	time.Sleep(5 * time.Second) // 模拟ready前的准备工作
	fmt.Println("the group of workers start to work...")

	groupSignal.L.Lock()
	ready = true
	groupSignal.Broadcast()
	groupSignal.L.Unlock()

	<-c
	fmt.Println("the group of workers work done!")
}

我们运行这个示例程序,得到:

start a group of workers...
the group of workers start to work...
worker 2: start to work...
worker 2: is working...
worker 3: start to work...
worker 3: is working...
worker 1: start to work...
worker 1: is working...
worker 4: start to work...
worker 5: start to work...
worker 5: is working...
worker 4: is working...
worker 4: works done
worker 2: works done
worker 3: works done
worker 1: works done
worker 5: works done
the group of workers work done!

我们看到,sync.Cond实例的初始化,需要一个满足实现了sync.Locker接口的类型实例,通常我们使用sync.Mutex

条件变量需要这个互斥锁来同步临界区保护用作条件的数据。加锁后各个等待条件成立的Goroutine判断条件是否成立如果不成立则调用sync.Cond的Wait方法进入等待状态。Wait方法在Goroutine挂起前会进行Unlock操作。

当main goroutine将ready置为true并调用sync.Cond的Broadcast方法后各个阻塞的Goroutine将被唤醒并从Wait方法中返回。Wait方法返回前Wait方法会再次加锁让Goroutine进入临界区。接下来Goroutine会再次对条件数据进行判定如果条件成立就会解锁并进入下一个工作阶段如果条件依旧不成立那么会再次进入循环体并调用Wait方法挂起等待。

sync.Mutexsync.RWMutex等相比,sync.Cond 应用的场景更为有限只有在需要“等待某个条件成立”的场景下Cond才有用武之地。

其实面向CSP并发模型的channel原语和面向传统共享内存并发模型的sync包提供的原语已经能够满足Go语言应用并发设计中99.9%的并发同步需求了。而剩余那0.1%的需求我们可以使用Go标准库提供的atomic包来实现。

原子操作atomic operations

atomic包是Go语言给用户提供的原子操作原语的相关接口。原子操作atomic operations是相对于普通指令操作而言的。

我们以一个整型变量自增的语句为例说明一下:

var a int
a++

a++这行语句需要3条普通机器指令来完成变量a的自增

  • LOAD将变量从内存加载到CPU寄存器
  • ADD执行加法指令
  • STORE将结果存储回原内存地址中。

这3条普通指令在执行过程中是可以被中断的。而原子操作的指令是不可中断的它就好比一个事务要么不执行一旦执行就一次性全部执行完毕中间不可分割。也正因为如此原子操作也可以被用于共享数据的并发同步。

原子操作由底层硬件直接提供支持是一种硬件实现的指令级的“事务”因此相对于操作系统层面和Go运行时层面提供的同步技术而言它更为原始。

atomic包封装了CPU实现的部分原子操作指令为用户层提供体验良好的原子操作函数因此atomic包中提供的原语更接近硬件底层也更为低级它也常被用于实现更为高级的并发同步技术比如channel和sync包中的同步原语。

我们以atomic.SwapInt64函数在x86_64平台上的实现为例看看这个函数的实现方法

// $GOROOT/src/sync/atomic/doc.go
func SwapInt64(addr *int64, new int64) (old int64)

// $GOROOT/src/sync/atomic/asm.s

TEXT ·SwapInt64(SB),NOSPLIT,$0
        JMP     runtimeinternalatomic·Xchg64(SB)

// $GOROOT/src/runtime/internal/atomic/asm_amd64.s
TEXT runtimeinternalatomic·Xchg64(SB), NOSPLIT, $0-24
        MOVQ    ptr+0(FP), BX
        MOVQ    new+8(FP), AX
        XCHGQ   AX, 0(BX)
        MOVQ    AX, ret+16(FP)
        RET


从函数SwapInt64的实现中我们可以看到它基本就是对x86_64 CPU实现的原子操作指令XCHGQ的直接封装。

原子操作的特性让atomic包也可以被用作对共享数据的并发同步那么和更为高级的channel以及sync包中原语相比我们究竟该怎么选择呢

我们先来看看atomic包提供了哪些能力。

atomic包提供了两大类原子操作接口一类是针对整型变量的包括有符号整型、无符号整型以及对应的指针类型另外一类是针对自定义类型的。因此第一类原子操作接口的存在让atomic包天然适合去实现某一个共享整型变量的并发同步。

我们再看一个例子:

var n1 int64

func addSyncByAtomic(delta int64) int64 {
	return atomic.AddInt64(&n1, delta)
}

func readSyncByAtomic() int64 {
	return atomic.LoadInt64(&n1)
}

var n2 int64
var rwmu sync.RWMutex

func addSyncByRWMutex(delta int64) {
	rwmu.Lock()
	n2 += delta
	rwmu.Unlock()
}

func readSyncByRWMutex() int64 {
	var n int64
	rwmu.RLock()
	n = n2
	rwmu.RUnlock()
	return n
}

func BenchmarkAddSyncByAtomic(b *testing.B) {
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			addSyncByAtomic(1)
		}
	})
}

func BenchmarkReadSyncByAtomic(b *testing.B) {
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			readSyncByAtomic()
		}
	})
}

func BenchmarkAddSyncByRWMutex(b *testing.B) {
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			addSyncByRWMutex(1)
		}
	})
}

func BenchmarkReadSyncByRWMutex(b *testing.B) {
	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			readSyncByRWMutex()
		}
	})
}


我们分别在cpu=2、 8、16、32的情况下运行上述性能基准测试得到结果如下

goos: darwin
goarch: amd64
... ...
BenchmarkAddSyncByAtomic-2     	75426774	        17.69 ns/op
BenchmarkReadSyncByAtomic-2    	1000000000	         0.7437 ns/op
BenchmarkAddSyncByRWMutex-2    	39041671	        30.16 ns/op
BenchmarkReadSyncByRWMutex-2   	41325093	        28.48 ns/op

BenchmarkAddSyncByAtomic-8     	77497987	        15.25 ns/op
BenchmarkReadSyncByAtomic-8    	1000000000	         0.2395 ns/op
BenchmarkAddSyncByRWMutex-8    	17702034	        67.16 ns/op
BenchmarkReadSyncByRWMutex-8   	29966182	        40.37 ns/op

BenchmarkAddSyncByAtomic-16      	57727968	        20.39 ns/op
BenchmarkReadSyncByAtomic-16     	1000000000	         0.2536 ns/op
BenchmarkAddSyncByRWMutex-16     	15029635	        78.61 ns/op
BenchmarkReadSyncByRWMutex-16    	29722464	        40.28 ns/op

BenchmarkAddSyncByAtomic-32      	58010497	        20.40 ns/op
BenchmarkReadSyncByAtomic-32     	1000000000	         0.2402 ns/op
BenchmarkAddSyncByRWMutex-32     	11748312	        93.15 ns/op
BenchmarkReadSyncByRWMutex-32    	29845912	        40.54 ns/op

通过这个运行结果,我们可以得出一些结论:

  • 读写锁的性能随着并发量增大的情况与前面讲解的sync.RWMutex一致
  • 利用原子操作的无锁并发写的性能,随着并发量增大几乎保持恒定;
  • 利用原子操作的无锁并发读的性能随着并发量增大有持续提升的趋势并且性能是读锁的约200倍。

通过这些结论我们大致可以看到atomic原子操作的特性随着并发量提升使用atomic实现的共享变量的并发读写性能表现更为稳定尤其是原子读操作和sync包中的读写锁原语比起来atomic表现出了更好的伸缩性和高性能。

由此我们也可以看出atomic包更适合一些对性能十分敏感、并发量较大且读多写少的场合

不过atomic原子操作可用来同步的范围有比较大限制只能同步一个整型变量或自定义类型变量。如果我们要对一个复杂的临界区数据进行同步那么首选的依旧是sync包中的原语。

小结

好了,今天的课讲到这里就结束了,现在我们一起来回顾一下吧。

虽然Go推荐基于通信来共享内存的并发设计风格但Go并没有彻底抛弃对基于共享内存并发模型的支持Go通过标准库的sync包以及atomic包提供了低级同步原语。这些原语有着它们自己的应用场景。

如果我们考虑使用低级同步原语,一般都是因为低级同步原语可以提供更佳的性能表现性能基准测试结果告诉我们使用低级同步原语的性能可以高出channel许多倍。在性能敏感的场景下我们依然离不开这些低级同步原语。

在使用sync包提供的同步原语之前我们一定要牢记这些原语使用的注意事项不要复制首次使用后的Mutex/RWMutex/Cond等。一旦复制,你将很大可能得到意料之外的运行结果。

sync包中的低级同步原语各有各的擅长领域你可以记住

  • 在具有一定并发量且读多写少的场合使用RWMutex
  • 在需要“等待某个条件成立”的场景下使用Cond
  • 当你不确定使用什么原语时那就使用Mutex吧。

如果你对同步的性能有极致要求且并发量较大读多写少那么可以考虑一下atomic包提供的原子操作函数。

思考题

使用基于共享内存的并发模型时,最令人头疼的可能就是“死锁”问题的存在了。你了解死锁的产生条件么?能编写一个程序模拟一下死锁的发生么?

欢迎你把这节课分享给更多对Go并发感兴趣的朋友。我是Tony Bai我们下节课见。