Go语言并发编程入门 | 青训营

175 阅读9分钟

1、Go语言并发基础知识

在学习Go语言并发编程之前,我们需要对进程、线程、协程、并发、并行等概念有所了解。

平常,在我们编译代码后就会产生可执行的二进制文件,当我们运行这个文件时,其会被装载到内存中,这个运行中的程序就称为进程。然而,当运行到读写磁盘的指令时,磁盘的读写速度非常慢,如果CPU选择等待IO完成再继续,那么CPU利用率会非常低。因此,当执行到这样的指令时,CPU不会阻塞等待,而是去执行其他进程,等到数据返回时再执行该进程。

由此我们引入了两个概念:并发并行

一言以蔽之,并发就是多线程程序在单核上运行,并行就是多线程程序在多核上运行。并发通过多个程序快速交替执行,使得看起来像是并行一样,然而实际上一瞬间只能运行一个进程。如下图所示:

无标题.png

进程是资源分配的单位,但是单进程的程序并没有并发执行,效率低;而多进程的程序又面临着通信和共享数据、进程上下文切换开销大的问题,因此线程就呼之欲出了。线程是进程当中的一条执行流程。 同一个进程的多个线程可以共享资源,而每个线程拥有自己的寄存器和栈。因此线程的上下文切换比进程的上下文切换开销要小得多。

线程包括用户级线程、内核级线程、轻量级线程。而在Go语言中,其描述略有不同,main函数被称为主线程,在一个Go线程上可以跑多个协程,协程拥有独立的栈空间和共享程序的堆空间,调度由用户控制,属用户态,可以理解为轻量级线程。比较协程和内核态线程而言,栈空间的量级分别是KBMB的级别。

2、goroutine

(1) 创建协程

我们要怎么在主线程中创建协程呢?在Go语言中使用go关键字为一个函数创建一个goroutine,这意味着一个函数可以被创建多个协程。

package main

import (
	"fmt"
	"time"
)

func hello(i int) {
	fmt.Println("hello I am", i)
}

func main() {
	for i := 1; i <= 10; i++ {
		go hello(i)//使用go关键字为函数创建协程
	}
	time.Sleep(time.Second)
}

当主线程结束时,无论子协程是否运行完成,都会直接结束。所以我们可以利用这一点写一个无限循环的计数器,当接收到用户输入回车时程序结束。

package main

import (
	"fmt"
	"time"
)

func run() {
	counter := 0
	for {
		fmt.Println(counter)
		counter++
		time.Sleep(time.Second)
	}
}

func main() {
	go run()
	var s string
	fmt.Scanln(&s)
}

同理我们可以为匿名函数创建协程,这样可以将上面的代码改写为:

package main

import (
	"fmt"
	"time"
)

func main() {
	go func() {
		counter := 0
		for {
			fmt.Println(counter)
			counter++
			time.Sleep(time.Second)
		}
	}()
	var s string
	fmt.Scanln(&s)
}

(2) 调整并发的运行性能

runtime包中,提供了NumCPU()函数查询本机的核数,同时提供GOMAXPROCS()函数,即设置运行主线程的最大核数。我们可以通过下列代码将其设置为本机的核数:

runtime.GOMAXPROCS(runtime.NumCPU())

笔者测试了一份用并发实现30000000个数做质因数分解的代码的时间,如果设置为单核(即runtime.GOMAXPROCS(1)),运行了58s;如果设置为满核(笔者的笔记本是16核,即runtime.GOMAXPROCS(runtime.NumCPU()),运行了7s

package main

import (
	"fmt"
	"runtime"
	"time"
)

var ch chan int

func cal(l, r int) {
	//对每个数质因数分解.
	for i := l; i <= r; i++ {
		cur := []int{}
		x := i
		for j := 2; j*j <= x; j++ {
			if x%j == 0 {
				cur = append(cur, j)
				for x%j == 0 {
					x /= j
				}
			}
		}
		if x > 1 {
			cur = append(cur, x)
		}
	}
	ch <- 1
}

func main() {
	ch = make(chan int)
	runtime.GOMAXPROCS(runtime.NumCPU()) //这里可以测试不同数字来看时耗差距
	start := time.Now().Unix()
	for i := 1; i <= 30000000; i += 10000 {
		go cal(i, i+9999)
	}
	for i := 1; i <= 30000000; i += 10000 {
		<-ch
	}
	end := time.Now().Unix()
	fmt.Println("耗时为", end-start, "秒")
}

3、Go语言竞争状态

(1) 并发竞争

并发竞争,指并发的多个协程同时对某个共享资源进行读写操作,使其处于相互竞争的状态。我们可以看一个简单的例子:并发地调用100000次加1的函数,看看会出现什么?

package main

import (
	"fmt"
	"sync"
)

var (
	a  int64
	wg sync.WaitGroup
)

func main() {
	a = 0
	for i := 1; i <= 100000; i++ {
		wg.Add(1)
		go func() {
			a++
			wg.Done()
		}()
	}
	wg.Wait() //使用等待组让主线程等到子协程运行完成再进入后面的代码
	fmt.Println(a)
}

我们发现,输出的结果不是我们预期的100000,而是会比100000少,而且每次都很有可能不同。这是因为可能在某一瞬间,多个goroutine同时对a进行写,使得已经+1的结果被覆盖掉了,导致结果错误,学过数据库的小伙伴都知道这种错误称为丢失更新。

为了检测是否存在并发竞争,我们可以用go build -race指令编译代码生成可执行文件,再运行可执行文件查看是否存在并发竞争。

QQ图片20230816034418.png

为了解决这种并发竞争,Go语言为我们提供了许多方法,我们先介绍原子函数互斥锁读写互斥锁

(2) 原子函数

原子函数在内置包sync/atomic中,是通过CPU指令实现的,可以保证在读写变量时不会被其他的协程所影响。其提供的原子操作主要为AddLoadStoreSwap。分别表示对给定变量(指针传递)加上一个值、从给定变量读取存储值到给定变量、将值交换给指定变量,返回其原值。上述操作都是并发安全的。因此我们可以把源代码改写为:

package main

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

var (
	a  int64
	wg sync.WaitGroup
)

func main() {
	a = 0
	for i := 1; i <= 100000; i++ {
		wg.Add(1)
		go func() {
			atomic.AddInt64(&a, 1)
			atomic.LoadInt64(&a) //原子函数的读也是并发安全的
			wg.Done()
		}()
	}
	wg.Wait() //使用等待组让主线程等到子协程运行完成再进入后面的代码
	fmt.Println(a)
}

这样输出的结果就是100000了。

(3) 互斥锁

互斥锁位于包sync里,即sync.Mutex,当一个goroutine要读写数据时,会加锁,而其他协程加锁被阻塞,只能等待该协程释放锁来唤醒。加锁和解锁的函数分别为lock()unlock()

这样我们可以将上面的代码中匿名函数的部分改写为:

go func() {
		lock.Lock()
		a++
		lock.Unlock()
		wg.Done()
}()

运行发现这样也是并发安全的。

死锁

当两个协程互相等待对方释放锁以加锁获取资源而阻塞时,会永远等待不到进入死锁。在Go语言中,编译运行时就会报fatal error: all goroutines are asleep - deadlock!错误。但是注意这种错误只有在主线程阻塞时才会报,即就算子协程直接产生死锁,只要主线程可以正常结束,就不会报错。

for i := 1; i <= 10; i++ {
		wg.Add(1)
		go func() {
			lock.Lock()
			a++
			//lock.Unlock() //协程互相等待对方释放锁而陷入阻塞状态,导致永远阻塞
			wg.Done()
		}()
	}

我们将上面代码中的lock.Unlock()去掉,发现代码报了死锁的错误。但是如果将wg.Wait()去掉,让主线程不等待子协程结束,改成time.Sleep(..)之类的,就不会报错,可正常运行。

注意,在Go语言里,这样写也会报死锁:

//...在某个协程里:
		lock.Lock()
		lock.Lock()

称为锁重入导致的死锁。这是因为Go的互斥锁是不可重入锁

(4) 读写互斥锁

其实就是将互斥锁拆成了读锁写锁,当我们需要读取数据时,其实不需要直接上互斥锁,我们上读锁即可。这是因为读之间是不会互相影响的,也不会产生并发竞争,所以我们允许多个协程给共享资源上读锁。

读写互斥锁即RWMutex,上读锁函数为Rlock(),解读锁函数为RUnlock(),写锁的函数与互斥锁相同。 使用方法如下:

for i := 1; i <= 10; i++ {
		wg.Add(1)
		go func() {
			rwlock.Lock() //要修改a,加写锁
			a++
			rwlock.Unlock()
			rwlock.RLock() //需要读取a,加读锁
			fmt.Println(a)
			rwlock.RUnlock()
			wg.Done()
		}()
	}

注意读写互斥锁的写锁重入也会导致死锁。

4、Channel

Go语言实现并发安全的通信机制就是channel,任意时刻,同时只能有一个goroutine访问通道发送或接收数据。Go语言采用的并发模型是:通信顺序进程,提倡通过通信共享内存而不是通过共享内存而实现通信。channel本身是先进先出的队列结构,创建时需要声明存储的变量类型。

QQ图片20230816225019.png

(1) Channel初始化和基本操作

声明通道的类型是chan typetype表示通道内存储的数据类型。需要注意的是,channel是引用类型,声明完后其空值为nil,仍然不可使用,必须配合make才可以使用。

创建通道

通道分为无缓冲通道有缓冲通道,创建时的区别如下:

make(chan type) //无缓冲通道

make(chan type,size) //有缓冲通道

可以参考如下代码:

package main

import (
	"fmt"
)

func main() {
	ch1 := make(chan int) //无缓冲通道.
	ch2 := make(chan int, 3) //有缓冲通道,在第二个参数声明,声明的数字即通道的最大容量
	go func() {
		ch1 <- 2
	}()
	x := <-ch1
	fmt.Println(x)
	ch2 <- 1
	x = <-ch2
	fmt.Println(x)
}

输出结果为2 1

发送、接收数据

向通道中发送数据时,格式为通道变量 <- 值,如上面代码所示。

从通道中接收数据时,格式为<- 通道变量,如果要接收并赋值前面加上变量名 = ,如上面代码所示。

关于这一块接收、发送数据的细节和死锁处理、等待队列等内容,我们放在后面深入讲解。

关闭通道

关闭通道的语法为close(通道变量),不能向关闭后的通道发送数据,但可以从通道中接收数据:

close(ch2)
ch2 <- 3

这样写是会报错的!报错:panic: send on closed channel

判断channel是否关闭

使用多变量返回值:val,ok:= <- ch,这与mapfind的方式和类型断言的语法类似。若已关闭,获取的val就是该类型的零值,ok则为false。这种方式称为非阻塞接收数据,可能造成高CPU占用

package main

import "fmt"

var ch chan int

func test(i int) {
	ch <- i
}
func main() {
	ch = make(chan int)
	go test(1)
	val, ok := <-ch
	fmt.Println(val, ok)
	close(ch)
	val, ok = <-ch
	fmt.Println(val, ok)
}

输出结果为1 true0 false

(2) 等待队列

我们从通道的底层入手,它的底层是hchan结构体:

type hchan struct {
  //channel分为无缓冲和有缓冲两种。
  //对于有缓冲的channel存储数据,借助的是如下循环数组的结构
	qcount   uint           // 循环数组中的元素数量
	dataqsiz uint           // 循环数组的长度
	buf      unsafe.Pointer // 指向底层循环数组的指针
	elemsize uint16 //能够收发元素的大小
  

	closed   uint32   //channel是否关闭的标志
	elemtype *_type //channel中的元素类型
  
  //有缓冲channel内的缓冲数组会被作为一个“环型”来使用。
  //当下标超过数组容量后会回到第一个位置,所以需要有两个字段记录当前读和写的下标位置
	sendx    uint   // 下一次发送数据的下标位置
	recvx    uint   // 下一次读取数据的下标位置
  
  //当循环数组中没有数据时,收到了接收请求,那么接收数据的变量地址将会写入读等待队列
  //当循环数组中数据已满时,收到了发送请求,那么发送数据的变量地址将写入写等待队列
	recvq    waitq  // 读等待队列
	sendq    waitq  // 写等待队列


	lock mutex //互斥锁,保证读写channel时不存在并发竞争问题
}

总结而言,底层存储数据使用的是循环队列(链表)。

等待队列指的是:

goroutinechannel中读取数据时,若缓冲区为空/没有缓冲区(无缓冲区的通道),当前goroutine被阻塞,进入读等待队列。

goroutinechannel中写数据时,若缓冲区满/没有缓冲区,当前goroutine被阻塞,进入写缓冲队列。

recvqgoroutine由写入数据的goroutine唤醒,反之亦然。

因此,使用通道时,只要主线程被永久阻塞,就会形成死锁:

func main() {
	ch1 := make(chan int)
	ch1 <- 1
}

在上面的代码中,主线程向管道写入1时没有缓冲区,进入阻塞状态,等待读数据的goroutine唤醒,但是根本没有协程来读,所以形成死锁。

func main() {
	ch1 := make(chan int)
	go func() {
		ch1 <- 1
	}()
}

但这样是不会报错的,因为虽然goroutine永久阻塞了,但是主线程可以正常结束。

在经过了前面对等待队列的讲解后,我们考虑从channel读写数据会发送什么?

channel中写数据:

  • 若读等待队列recvq不为空,则将等待的协程G取出并写入数据,最后唤醒G,结束。

  • recvq为空,且缓冲区有空位,那么将数据写入缓冲区。

  • 否则将当前goroutine阻塞,加入写等待队列sendq,等待被唤醒。

channel中读数据:

  • 若写等待队列sendq不为空,且没有缓冲区,则将等待的协程G取出并读出数据,最后唤醒G,结束。

  • 若写等待队列sendq不为空,且存在缓冲区,说明缓冲区已满,从缓冲区头部读出数据,并将sendq头部等待的协程G取出并将其数据写入缓冲区尾部,最后唤醒G,结束。

  • sendq为空,且缓冲区内有数据,那么从缓冲区头部读出数据。

  • 否则将当前goroutine阻塞,加入读等待队列recvq,等待被唤醒。

(3) 单向通道和通道遍历

顾名思义,单向通道就是只能读入或写。 声明方式为var 变量名 <-chan typevar 变量名 chan<- type,分别表示只读和只写的通道。

单向通道更多时候是对channel的一种使用限制。

package main

import (
	"fmt"
)

var ch chan int

func main() {
	ch = make(chan int)
	go func() {
		var writeonly chan<- int = ch //只能写的管道,写入到ch中
		writeonly <- 1
		writeonly <- 2
		close(writeonly)
	}()
	var readonly <-chan int = ch //将ch赋给只读的管道,将数据读出.
	x := <-readonly
	fmt.Println(x)
	x = <-readonly
	fmt.Println(x)
}

除了前面介绍的通道遍历方式外,我们还可以使用range for来进行遍历。需要注意的是,使用range for遍历时通道必须关闭,否则会报死锁。

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func main() {

	ch := make(chan int, 10)
	wg.Add(1)
	go func() {
		for i := 1; i <= 10; i++ {
			ch <- i
		}
		close(ch) //不关闭通道就无法使用range for。读者可以试试删掉这句会发生什么
		wg.Done()
	}()
	wg.Wait()
	for v := range ch {
		fmt.Println(v)
	}
}

5、其他

(1) 等待组

在前面的代码中,我们虽然没有介绍,但是已经多次使用了WaitGroup,主要的场景是我们无法预估子协程会进行多久,但我们又希望主线程等待协程运行完毕再关闭,而使用sleep让主线程等待我们可能难以估计时间,因此WaitGroup呼之欲出。

等待组位于包sync中,拥有Add(delta int)Done()Wait()三种方法。在WaitGroup中维护着一个计数,初始时为0。当调用Add(1)Done()时,将使得计数+1-1;调用Wait()方法会使得此协程/主线程阻塞,直到WaitGroup维护的计数变成0。当某次操作使得计数变为负数时,会产生panic

一般的使用方式就是在开启子协程时Add(),子协程结束时Done(),这样可以保证主线程会等待各协程结束后再继续后面的部分。

package main

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

var (
	a  int64
	wg sync.WaitGroup
)

func main() {
	a = 0
	for i := 1; i <= 100000; i++ {
		wg.Add(1)
		go func() {
                        defer wg.Done()
			atomic.AddInt64(&a, 1)
			atomic.LoadInt64(&a) 
		}()
	}
	wg.Wait() 
	fmt.Println(a)
}

(2) Select

select语句一般用来在主线程中监听和channel有关的IO操作。和Switch-Case的语法类似。

package main

import (
	"fmt"
)

var ch chan int

func main() {
	ch = make(chan int)
	go func() {
		ch <- 1
	}()
	select {
	//可写
	case ch <- 1:
		fmt.Println("Write!")
	//可读
	case <-ch:
		fmt.Println("Read!")
	default:
		fmt.Println("No")

	}
}

上述代码可能输出Read!也可能输出No,这是因为可能协程没来得及写入1主线程就执行完成了。此外,若有多个Case同时被满足,Select语句并非顺序执行的,而是先到先执行,同时到则随机执行的原则。如果没有Case满足且不存在Default,那么主线程/协程将堵塞。

注意Select语句中Case的读不像普通的读可能会堵塞进入等待队列,因为case中如果读不到channel那么不会进入等待队列而是会直接返回。