Go语言:协程与并发控制|青训营笔记

362 阅读6分钟

这是我参与「第五届青训营 」伴学笔记创作活动的第 2 天

内容梗概

本文梳理了以下内容:

  1. 并发编程常用基本概念介绍。
  2. 协程及其通信机制。
  3. Go并发控制:子协程的三种控制机制(Channel控制、WaitGroup信号量控制、Context上下文控制)。
  4. 对传统互斥锁与读写锁的原理与实现。

1 基本概念

  1. 进程:进程是应用程序的启动实例,有独立的内存空间,不同进程间通过进程间通信方式来通信。
  2. 线程:线程从属于进程,每个进程至少包含一个线程,线程是CPU调度的基本单位,多个线程之间可共享进程的资源并通过共享内存等线程间通信方式来通信。
  3. 协程:用户态、轻量级的线程。与线程相比,协程不受操作系统调度,而是用户应用程序提供协程调度器,并按照调度策略将协程调度到线程中运行。
  4. 并发:多个线程在同一个CPU上运行,主要是通过时间片的交替切换(不同时)
  5. 并行:多个线程在多个CPU上运行,多个线程在同一时间同时运行而不是时间片的切换。
  6. 条件竞争:在并发编程中,多个线程同时执行操作,结果取决于各个线程执行的相对次序。
  7. 数据竞争:多个线程同时对同一个对象进行操作。(当一个线程在进行写入操作时,其他线程进行读操作或写操作。)
  8. 互斥:在操作共享数据前加锁,访问结束后解锁。(一旦一个线程锁住了互斥,则其他线程想要上锁时需要等待。)
  9. 死锁:多个进程在执行过程中,由于竞争资源或者彼此通信而造成的一种阻塞的现象,使程序运行无法推进。(常见死锁:加锁后没有解锁(或被跳过)、ABBA死锁等)

2 协程及其通信机制

2.1 协程

使用Go语言创建协程,只需在调用的函数前使用go关键字。

func func1(a int){}
//若函数已定义,可直接调用创建协程
go func1(a)

//若函数未定义,可使用“定义+创建协程”合并操作
go func2(a int){
    //函数定义
}(a)
    

示例代码

//go:build ignore
// +build ignore

package main

import (
	"fmt"
	"time"
)

func hello(i int) {
	println("hello goroutine : " + fmt.Sprint(i))
}

func HelloGoRoutine() {
	for i := 0; i < 5; i++ {
		// go关键字作为创建协程的关键字
		go func(j int) {
			hello(j)
		}(i) //函数的定义与协程创建的合并写法
	}
	// 保证子协程运行完成前主线程不退出
	time.Sleep(time.Second)
}

func main() {
	HelloGoRoutine()
}

输出结果

hello goroutine : 1
hello goroutine : 4
hello goroutine : 3
hello goroutine : 0
hello goroutine : 2

2.2 协程通信机制

CSP模型(Communicating Sequential Process):通信顺序进程,一种用来描述并发行系统之间进行通信交互的模型。

Go提倡通过通信共享内存而不是通过共享内存实现通信

image.png 通过通信共享内存:(左图)借助channel通道,实现协程之间的连接和通信,能保证收发数据的顺序。 通过共享内存实现通信:(右图)使用共享内存进行数据交换。过程中需要使用互斥量对内存加锁(获取临界区权限),不同Goruntine之间有可能出现数据竞争,影响性能。

3 Go并发控制(三种子协程控制机制)

3.1 Channel控制

channel的创建

make(chan 元素类型, [缓冲大小])
make(chan int)//无缓冲通道
make(chan int2)//有缓冲通道

image.png 无缓冲通道(同步通道):会使发送和接收方之间的传输过程同步化(不会在通道中逗留)。

有缓冲通道:通道中能暂存信息,属于一种生产-消费模型

示例代码

//+bulid ignore

package main

func CalSquare() {
	src := make(chan int)
	dest := make(chan int, 3) //带缓存的channel,有助于生产-消费的均衡
	go func() {               //子协程1:生产数字(发送0~9数字)
		defer close(src) //defer实现延迟的资源关闭
		for i := 0; i < 10; i++ {
			src <- i
		}
	}()
	go func() { //子协程2:计算数字的平方
		defer close(dest)
		for i := range src {
			dest <- i * i
		}
	}()
	for i := range dest { //主协程输出最后的结果
		//复杂操作
		println(i)
	}
}

func main() {
	CalSquare()
}

观察上述代码,我们发现:channel其实就相当于一个队列,输入数据经过channel能保持有序性,以此可实现数据的并发安全。

3.2 WaitGroup信号量控制

在2.1所示的hello程序中,使用的是 time.Sleep(time.Second)以保证子协程运行完成前主线程不退出,这种阻塞方法比较粗暴且不优雅,而我们可以使用WaitGroup方法进行阻塞优化。

func HelloWaitGroup() {
	//WaitGroup方法实现进程同步
	/*
            Add(delta int)	//计数器+delta
            Done()		//计数器-1
            Wait()		//阻塞直到计数器为0
	*/
	var wg sync.WaitGroup
	wg.Add(5)
	for i := 0; i < 5; i++ {
		go func(j int) {
			defer wg.Done()
			hello(j)
		}(i)
	}
	wg.Wait()
}

3.3 Context上下文控制

4 传统互斥锁与读写锁

互斥锁是一种常用的控制共享资源访问的方法,它能够保证同时只有一个goroutine可以访问共享资源。Go语言中使用sync包的Mutex类型来实现互斥锁。

//通过共享内存实现通信,需要Lock避免数据竞争,保证并发安全

//go:build ignore
// +build ignore

package main

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

var (
	x    int64
	lock sync.Mutex
)

func hello(i int) {
	println("hello goroutine : " + fmt.Sprint(i))
}

func addWithLock() { //加锁-解锁,实现对临时区的保护
	for i := 0; i < 2000; i++ {
		lock.Lock() //加锁(获取临界区的权限)
		x += 1
		lock.Unlock() //解锁(释放临界区权限)
	}
}
func addWithoutLock() {
	for i := 0; i < 2000; i++ {
		x += 1
	}
}

func Add() {
	x = 0
	for i := 0; i < 5; i++ {
		go addWithoutLock()
	}
	time.Sleep(time.Second)
	println("WithoutLock:", x)

	x = 0
	for i := 0; i < 5; i++ {
		go addWithLock()
	}
	time.Sleep(time.Second)
	println("WithLock:", x)
}

func main() {
	Add()
}

使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁;

当互斥锁释放后,等待的goroutine才可以获取锁进入临界区,多个goroutine同时等待一个锁时,唤醒的策略是随机的。

5 总结收获

通过本次课程的学习和总结,我们进一步的加深了对Go语言的协程、并发控制的理解。

学会了:

  • 协程的创建
    • 直接创建
    • 含函数定义创建
  • 并发编程的两种通信形式
    • 通过通信共享内存(channel实现:相当于队列)
    • 通过共享内存实现通信(需要lock、阻塞)