【青训营】Go的并发编程
本文章整理自——字节跳动青年训练营(第五届)后端组
1.线程和协程
操作系统中有三个重要的概念,分别是进程、线程和协程。其中进程和线程的区别请移步操作系统专栏,现在主要叙述线程和协程的区别。
简单来说,协程又称为用户态线程(以下的线程均指的是内核级线程),它比线程更加轻量化,使用起来更灵活,具有更高的性能。具体来说,协程的各种操作所需要的开销要比线程少,因此具有更高的性能。协程线程是内核态的,栈是MB级别的;协程是用户态的,其栈是KB级别的。一个线程可以控制多个协程,Go语言自动完成协程的创建,Go一次可以创建上万条协程,因此Go在针对高并发场景上有优势
Go使用如下方法创建协程
go func(形式参数){
函数体
}(实际参数)
简单来说,就是在函数func前加go关键字以创建协程
协程的简单例子如下:
import (
"fmt"
"time"
)
// 协程的简单例子
func hello(i int) {
println("Hello goroutine" + fmt.Sprint(i))
}
func main() {
for i := 0; i < 5; i++ {
// 在函数func前加go关键字以创建协程
go func(j int) {
hello(j)
}(i) //此处填入协程的实际参数
}
time.Sleep(time.Second)
}
其输入如下:
Hello goroutine1
Hello goroutine4
Hello goroutine2
Hello goroutine0
Hello goroutine3
其数字i是完全随机的,可以看出协程的并发性
2.协程的通信CSP
GO提倡通过通信共享内存而不是使用共享内存通信,两者区别如下
3.通道Channel
通道是Go进行通信的重要手段,通道的创建如下
make(chan 元素类型, [缓冲区大小])
// example
make(chan int) //无缓冲通道
make(chan char, 2)
无缓冲通道中1发送的信息回立即传送到2中,会出现同步问题。而有缓冲的通道则会先放置到缓冲区中再送入2,带缓冲通道的可以解决一些速度不匹配问题
下面使用一个例子实现生产者消费者问题:
生产者:负责生成数字,并且将数字放入到缓冲区src中
消费者:从src中取出数字,并将其取平方
生产者消费者问题详解可以看这个:https://blog.csdn.net/weixin_45434953/article/details/127044788
package main
func main() {
src := make(chan int) // 无缓冲通道
dest := make(chan int, 3) // 缓冲区为3的通道
// 生产者协程,用于生产数字
go func() {
defer close(src)
for i := 0; i < 10; i++ {
src <- i //将数据i冲入通道src中
}
}()
// 消费者进程,取出src中的数字并且将其平方后放入dist通道
go func() {
defer close(dest)
for i := range src {
dest <- i * i //将i的平方冲入dist通道中
}
}()
for i := range dest {
println(i)
}
}
通过结果可以看出,通道可以保证输出的顺序
4.并发安全:锁Lock
使用锁可以确保对临界资源的互斥访问,从而避免同步互斥发生的数据不匹配问题,以下是一个简单的同步互斥问题:
我们需要启动5个协程,每个协程对x进行2000次+1的操作,x=x+1的操作可以分解为:1.运算器取得x的值 2.运算器执行x=x+1,并将结果写回寄存器 3.将运算器的值写回内存
import (
"sync"
"time"
)
var (
x int64
lock sync.Mutex
)
// 加锁版本
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 main() {
x = 0
// 启动5个协程,他们需要互斥地访问x
for i := 0; i < 5; i++ {
go addWithLock()
}
time.Sleep(time.Second)
println("WithLock:", x)
x = 0
// 启动5个协程,他们不需要互斥地访问x
for i := 0; i < 5; i++ {
go addWithoutLock()
}
time.Sleep(time.Second)
println("WithoutLock:", x)
}
其输出的结果是:
WithLock: 10000
WithoutLock: 7050
分析以上代码片段:
如果使用addWithoutLock()函数,假设某时刻x=10,然后协程a执行了一次x=x+1,协程b执行了一次x=x+1,此时x的值应该是12。但是假设如下情况:协程a开始运行,根据分解操作,在执行到第二步的时候,cpu切换到执行协程b,此时协程a的寄存器中x=11,但是该值尚未写回内存,因此协程b从内存中取得的x的值还是10,进行x=x+1后,x=11并且将其写回内存,协程b执行结束,切换到未执行结束的协程a执行,此时a将寄存器中的x=11写回内存,则最终内存中x=11,显然这不是我们想要的结果,这就是同步导致的数据冲突,因此WithoutLock的结果最终是小于10000的
如果使用addWithLock函数,主要区别是,x只有在取得锁后才能对其进行操作,在进行x=x+1的之前,需要取得锁lock,执行结束后,才释放锁。这使得x=x+1的操作是一气呵成的,不会被中断的,这种又叫做原子操作。这避免了上述的状况。
5.WaitGroup
Go提供了WaitGroup来实现并发任务的同步,其中主要的三个方法:
Add(delta int) //有多少个并发的协程
Done() // 表示协程已完成,会将计数器的值-1
Wait() //在计数器为0之前,一直阻塞不向下执行
这类似于一个计数器,刚开始使用Add表示有n个协程,而Done方法表示协程已完成,会将n–,当n!=0的时候,会一直触发Wait()方法,使得程序阻塞在Wait处;当n=0的时候表示所有协程均已完成,程序会继续执行Wait之后的语句
示例如下:
import (
"fmt"
"sync"
)
func hello(i int) {
println("hello goroutine:" + fmt.Sprint(i))
}
func main() {
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()
}
这是对本文第一个例子的改写,没有使用time.Sleep(time.Second),因此性能更好