全网注释第二全的GO教程-并发(Goroutine)

持续更新: github.com/Zhouchaowen... 感谢 star

系列文章-包(Package)
系列文章-变量与常量(Var)
系列文章-函数(Function)
系列文章-流程控制(For,If,Switch,defer)
系列文章-结构体(Struct)
系列文章-数组与切片(Array&Slice)
系列文章-映射(Map)
系列文章-接口(Interface)

Goroutine

GoroutineGo 语言中提供并发处理 的方式。在学习Goroutine之前,我们先了解下什么是并发?什么是并行。

  • 并发:一段时间内 执行多个任务,这意味着仅需一个物理核心就能实现。

并发本质是我们的感知问题,就像电影闪电侠一样,普通人1s只能做一件事,而闪电侠1s可以做很多件事情,这导致我们产生了错觉,觉得闪电侠在1s内同时做这些事;

其实闪电侠只是在0.1s做了A事,在0.2s做了B事情,在0.3s做了C事,只是闪电侠做的太快了,普通人类没法在短短的0.1s感知到而已。当1s后感知到时,发现A,B,C都做完了,这就产生了闪电侠在1s内同时做这些事的错觉

在现实中CPU的执行是非常快的,我们通过将CPU的执行时间划分为不同的时间片来执行任务。下图中 CPU在1 s内并发的运行了3个任务(A,B,C),这里将CPU的1s执行时间划分为了5个时间片,用来分别执行这些任务。

图中在时间片1CPU加载任务A并执行,当时间片1 执行完后,CPU会将任务A移出然后开始时间片2 ,在时间片2CPU会加载任务B并执行,接下来会按照这种方式依次执行任务C,任务B,任务A;为什么还会执行任务B和任务A?因为任务AB可能在时间片1和2没有执行完就被移出了,所以在后面的时间片又被加载执行。

CUP执行时间片中,无论任务A,B,C本身是否执行完,当时间片耗尽后都会被移出CPU,这个加载和移出的过程叫做上下文切换

  • 并行:同一时刻 执行多个任务,这意味着至少需要2个物理核心才能实现。

并行指严格意义上的同一时刻 执行2个或2个以上的任务,这取决于你的CPU核心数。

上图中一个CPU至少有两个物理核心Core,每个核心上都运行着不同的任务。任务A和任务B分别运行在时间片1时间片2 上并且都运行在Core-1的核心上,这意味着任务A和任务B并发运行的;

而任务A和任务F都运行在时间片1 上(代表同一时刻),并且他们分别运行在Core-1Core-2的核心上,所以他们是并行运行的。

为什么说一个CPU至少有两个物理核心Core

因为现代的一颗CPU上可能会有多个核心 ,又因为超线程技术 ,一个物理核心可以当做两个虚拟核心使用,所以理论上一个核心在同一时刻可以并行执行2个任务 。而一般一个CPU具有4个核心,意味着这个CPU同一时刻可以并行运行8个任务(4*2=8)。

上文中的任务具体代表什么喃?

上文的一个任务代表一个线程,这些线程在不同的CPU时间片上运行,这些线程都是由内核进行调度。而我们在Go中的Goroutine 也可以看作是一个轻量级线程 ,它是由GoGoroutine 调度器来调度的,具体的细节可以看这两篇文章来了解:Golang 调度器 GMP 原理与调度全分析GMP 并发调度器深度解析

我们的一个Go程序可以启动成百上千GoroutineGoroutine 的启动非常快,只需要几纳秒的时间, 而且不需要开发者手动进行线程调度,都由 GoGoroutine调度器自动完成,接下来我们就来了解一下Goroutine

目录

  • Goroutine 基础用法
  • Goroutinesync.WaitGroup 的使用
  • Goroutine 小实验: 并发的下载图片
  • Goroutine 的并发安全问题
    • 原子操作
    • 加锁保护

Goroutine基础

Golang中想要并发的执行一段逻辑 可以通过go关键字+匿名函数或有名函数实现, 代码如下:

go 复制代码
// 匿名函数
go func() {
    // goroutine 执行的代码
}()


// 有名函数
func test(){
  fmt.Printf("golang tutorial\n")
}
go test()

一个go func()会启动一个后台并发任务, 大概流程是通过go关键字将这个func()打包成一个任务, 然后提交给Golang的并发调度器, 并发调度器会根据一定策略来执行这些任务。具体的细节可以看这两篇文章来了解:Golang 调度器 GMP 原理与调度全分析GMP 并发调度器深度解析

如下实现并发打印两个数组内的数据:

go 复制代码
package main

import (
	"fmt"
	"time"
)

// 并发与并行:https://gfw.go101.org/article/control-flows-more.html

// 使用 goroutine 打印数据
func main() {
	language := []string{"golang", "java", "c++", "python", "rust", "js"}
	tutorial := []string{"入门", "初级", "中级", "高级", "专家"}

	// Go 程(goroutine)是由 Go 运行时管理的轻量级线程
	// 在函数调⽤语句前添加 go 关键字,就可创建一个 goroutine
	go listLanguage(language) // 通过goroutine启动该函数
	go listTutorial(tutorial)

	<-time.After(time.Second * 10) // 10s后执行下一行
	fmt.Println("return")
}

func listLanguage(items []string) {
	for i := range items {
		fmt.Printf("language: %s\n", items[i])
		time.Sleep(time.Second)
	}
}

func listTutorial(items []string) {
	for i := range items {
		fmt.Printf("tutorial: %s\n", items[i])
		time.Sleep(time.Second)
	}
}
/* output
tutorial: 入门
language: golang
tutorial: 初级
language: java
language: c++
tutorial: 中级
tutorial: 高级
language: python
tutorial: 专家
language: rust
language: js
return
*/

如上代码在 main 函数中,首先定义了两个切片,分别存储语言language和教程tutorial的信息。然后通过 go 关键字,将 listLanguagelistTutorial 这两个函数同时启动,提交给Golang的并发调度器,并发地执行。

listLanguagelistTutorial 函数中,通过 for 循环遍历切片中的元素,使用 fmt.Printf 函数打印每个元素的信息,并通过 time.Sleep 函数暂停一秒钟,模拟执行一些耗时的操作。

main 函数的最后,通过 <-time.After(time.Second * 10) 等待 10 秒钟,保证两个 goroutine 都有足够的时间执行。然后通过 fmt.Println 函数输出 "return",程序结束。

WaitGroup使用

再上一小节中通过<-time.After(time.Second * 10)来等待Goroutine执行完成, 这非常难以控制Goroutine的结束时刻。

在真实的场景中我们并不是那么容易知道一个Goroutine什么时候执行完成, 我们需要一种更简单的方式来等待Goroutine的结束。

sync.WaitGroup 可以用来完成这个需求, 它是 Go 语言中用于并发控制的一个结构体, 它可以用于等待一组 Goroutine 的完成

WaitGroup 包含三个方法:

  1. Add(n int):向 WaitGroup 中添加 n 个等待的 Goroutine
  2. Done():表示一个等待的 Goroutine 已经完成了,向 WaitGroup 中减少一个等待的 Goroutine
  3. Wait():等待所有添加到 WaitGroup 中的 Goroutine 都完成。

使用 WaitGroup 进行并发控制的基本流程如下:

  1. 创建 WaitGroup 对象 wg
  2. 启动多个 Goroutine,在每个 Goroutine 的开始处调用 wg.Add(1) 将等待的 Goroutine 数量加 1。
  3. 在每个 Goroutine 中进行任务处理,当任务处理完毕后,在 Goroutine 的结束处调用 wg.Done() 将已完成的 Goroutine 数量减 1。
  4. 在主函数中调用 wg.Wait() 等待所有的 Goroutine 完成任务。

通过sync.WaitGroup改造上一小节代码。

go 复制代码
package main

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

func listLanguage(items []string, wg *sync.WaitGroup) { // 一般不建议这样使用
	defer wg.Done()

	for i := range items {
		fmt.Printf("language: %s\n", items[i])
		time.Sleep(time.Second)
	}
}

func listTutorial(items []string) {
	for i := range items {
		fmt.Printf("tutorial: %s\n", items[i])
		time.Sleep(time.Second)
	}
}

// 使用 WaitGroup等待goroutine执行完成
func main() {
	language := []string{"golang", "java", "c++", "python", "rust", "js"}
	tutorial := []string{"入门", "初级", "中级", "高级", "专家"}

	var wg sync.WaitGroup

	wg.Add(2) // 设置需要等待 goroutine 的数量,目前为2

	go listLanguage(language, &wg) // 通过 goroutine 启动该函数

	go func() { // 建议使用方式
		defer wg.Done() // 程序运行完毕, 将等待数量减1
		listTutorial(tutorial)
	}()

	wg.Wait() // 当等待数量为0后执行下一行
	//<-time.After(time.Second * 10) // 10s后执行下一行。 通过 wg.Wait() 代替
	fmt.Println("return")
}

如上代码, 在 main 函数的最后, 通过 wg.Wait() 等待两个 goroutine 都执行完成。然后通过 fmt.Println 函数输出 "return"程序结束。

并发下载图片小练习

go 复制代码
package main

import (
	"bytes"
	"fmt"
	"io/ioutil"
	"net/http"
	"os"
	"path"
	"sync"
)

func getImageData(url, name string) {
	resp, _ := http.Get(url) // 通过 http.get 请求读取 url 的数据

	// 创建一个缓存读取返回的 response 数据
	buf := new(bytes.Buffer)
	buf.ReadFrom(resp.Body)
	resp.Body.Close()
  
	dir, _ := os.Getwd()             // 获取当前执行程序目录
	fileName := path.Join(dir, name) // 拼接保存图片的文件地址

	// 将数据写到指定文件地址,权限为0666
	err := ioutil.WriteFile(fileName, buf.Bytes(), 0666)
	if err != nil {
		fmt.Printf("Save to file failed! %v", err)
	}
}

// 并发下载图片
func main() {
	var wg sync.WaitGroup
  defer wg.Wait() // 通过defer来调用Wait()

	wg.Add(3)

	go func() {
		defer wg.Done()
		getImageData("https://img2.baidu.com/it/u=3125736368,3712453346&fm=253&fmt=auto&app=138&f=JPEG?w=800&h=500", "1.jpg")
	}()

	go func() {
		defer wg.Done()
		getImageData("https://img2.baidu.com/it/u=4284966505,4095784909&fm=253&fmt=auto&app=138&f=JPEG?w=640&h=400", "2.jpg")
	}()

	go func() {
		defer wg.Done()
		getImageData("https://img1.baidu.com/it/u=3580024761,2271795904&fm=253&fmt=auto&app=138&f=JPEG?w=500&h=667", "3.jpg")
	}()
}

Goroutine并发安全

Goroutine 的出现使得 Go 语言可以更加方便地进行并发编程 。但是在使用 Goroutine 时需要注意避免资源竞争和死锁等问题。

当多个Goroutine并发修改同一个变量 时有可能会产生并发安全问题 导致结果不一致, 因为修改操作可能是非原子的 。这种情况可以将修改变成原子操作 (atomic)或通过加锁保护 (sync.Mutex, sync.RWMutex), 让修改的步骤串行执行防止并发安全问题。

如上图片展示了两个Goroutine在两个CPU-Core上同时加载Count变量,并进行Count++后写入主内存,这可能会产生数据覆盖,导致变量Count不是预期结果。

原子操作

什么是原子操作(atomic)?

原子操作是指一个不可中断的操作(一条CPU指令),它要么完全执行并且它的所有副作用(修改)对其它线程都是可见的或者要么根本不执行,所以执行一个原子操作后当前线程和其他所有线程都可以看到操作之前或之后的状态。

什么是非原子操作?

非原子操作是指需要多条CPU指令来完成的操作,高级语言中一条语句往往需要多条CPU指令完成。例如count++,至少需要三条CPU指令:

go 复制代码
package main

func main() {
	count++
}
  • 指令1:把变量count从内存加载到CPU的寄存器;
  • 指令2:在寄存器中执行+1操作;
  • 指令3:将结果写入内存(缓存机制导致可能写入的是CPU缓存而不是内存)。

上面三个步骤每一步都是原子操作 ,但是组合在一起就不是原子操作

非原子操作为什么导致并发安全问题?

在多核下,多个Goroutine同时以非原子的方式 修改一个共享变量时(count++操作), 如果 Goroutine-A 读取了变量count的值,并且在它修改count的值之前, Goroutine-B 修改了这个count的值并写回内存,那么 Goroutine-B的修改操作将会Goroutine-A覆盖,从而导致数据不一致,下图展示了这个过程:

上图展示了 Goroutine-A所在的CPU-Core将变量Count从内存加载到自己的寄存器中,然后此时发生了CPU中断

CPU中断会导致当前执行的Goroutine停止运行,从而加载其它等待运行的Goroutine; 所以Goroutine-B被执行了,然后Goroutine-B所在的CPU-Core将变量Count从内存加载到自己的寄存器中,此时内存中的Count=0,加载后对Count进行+1后写回内存, Goroutine-B结束。

然后CPU切换回Goroutine-A继续执行,这时Goroutine-A所在的CPU-Core核心上的Count还是当初从内存加载值0,然后对他进行+1后写回内存, Goroutine-A结束。

两个GoroutineCount都进行了+1,但内存中的Count却还是1, 这就是非原子操作被CPU中断导致数据不一致的原因。

如下示例将展示这种现象:

go 复制代码
package main

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

// NoConcurrence 并发操作一个变量是不安全的,需要加锁
func NoConcurrence() {
	sum := 0

	var wg sync.WaitGroup

	wg.Add(2)

	go func() {
		defer wg.Done()
		for i := 0; i < 10000000; i++ { // sum做累加
			sum++
		}
	}()

	go func() {
		defer wg.Done()
		for i := 0; i < 10000000; i++ { // sum做累加
			sum++
		}
	}()

	wg.Wait()

	fmt.Println(sum) // 结果应该等于20000000
}

func Concurrence() {
	var sum int64 = 0 

	var wg sync.WaitGroup

	wg.Add(2) // 设置需要等待 goroutine 的数量,目前为2

	go func() {
		defer wg.Done() // 程序运行完毕, 将 goroutine 等待数量减1
		for i := 0; i < 10000000; i++ {
			atomic.AddInt64(&sum, 1) // 原子操作 +1
		}
	}()

	go func() {
		defer wg.Done()
		for i := 0; i < 10000000; i++ {
			atomic.AddInt64(&sum, 1) // 原子操作 +1
		}
	}()

	wg.Wait()

	fmt.Println(sum) // 结果应该等于20000000
}

// goroutine 的并发安全问题
func main() {
	NoConcurrence()
	Concurrence()
}

如上代码演示了使用原子操作和非原子操作对变量进行并发累加时,对程序结果正确性的影响。

NoConcurrence() 函数中,使用非原子操作 sum++ 进行累加,会出现并发安全问题,因为多个Goroutine同时对 sum 进行写操作,会导致结果不正确。执行该函数后,累加结果不等于20000000。

Concurrence() 函数中,使用原子操作 atomic.AddInt64 进行累加,保证了在多个Goroutine同时对 sum 进行写操作时,每次只有一个Goroutine能够成功操作,其余Goroutine则需要等待。这样可以保证 sum 的值的正确性。执行该函数后,累加结果等于20000000。

加锁保护

加锁保护(sync.Mutex, sync.RWMutex)

在多个Goroutine并发执行的情况下,加锁 也可以保证同一时刻只有一个Goroutine能够进入临界区操作(简单理解为上一小节的变量count的这块内存) ,其他Goroutine需要等待锁被释放后才能进入临界区进行操作。这种机制可以保证不会出现多个Goroutine同时对同一共享资源进行修改的情况,从而避免了并发安全问题。

就像如下,多个人排队上WC一样:每个人进WC前必须要获得门上的钥匙**(加锁), 获取到钥匙后才能进WC释放 (执行临界区代码), 释放完后需要将钥匙放回门上(解锁)**让下一个人使用。

Go中互斥锁(sync.Mutex)是一种实现方式,只有拥有锁Goroutine 才能访问临界区,其他的 Goroutine 必须等待。当一个 Goroutine 获得了锁,其他的 Goroutine 就无法再获得锁,只有等到这个 Goroutine 释放锁后才能继续访问。如下示例通过Mutex保护临界区:

go 复制代码
package main

import (
	"fmt"
	"sync"
)

// NoConcurrence 并发操作一个变量是不安全的,需要加锁
func NoConcurrence() {
  sum := 0 // 临界区或叫做共享变量

	var wg sync.WaitGroup

	wg.Add(2)

	go func() {
		defer wg.Done()
		for i := 0; i < 10000000; i++ { // sum做累加
			sum++
		}
	}()

	go func() {
		defer wg.Done()
		for i := 0; i < 10000000; i++ { // sum做累加
			sum++
		}
	}()

	wg.Wait()

	fmt.Println(sum) // 结果应该等于20000000
}

func Concurrence() {
	sum := 0

	var wg sync.WaitGroup
	var mu sync.Mutex // 互斥锁(保护临界区,同一时刻只能有一个 goroutine 可以操作临界区)
  // var rmu sync.RWMutex

	wg.Add(2) // 设置需要等待 goroutine 的数量,目前为2

	go func() {
		defer wg.Done() // 程序运行完毕, 将 goroutine 等待数量减1
		for i := 0; i < 10000000; i++ {
			mu.Lock() // 加锁保护临界区
			sum++
			mu.Unlock() // 操作完成解锁,临界区
		}
	}()

	go func() {
		defer wg.Done()
		for i := 0; i < 10000000; i++ {
			mu.Lock() // 加锁保护临界区
			sum++
			mu.Unlock() // 操作完成解锁,临界区
		}
	}()

	wg.Wait()

	fmt.Println(sum) // 结果应该等于20000000
}

// goroutine 的并发安全问题
func main() {
	NoConcurrence()
	Concurrence()
}

如上代码展示了 Go 语言中使用互斥锁(Mutex)保证并发安全的方法。

首先,定义了一个 NoConcurrence() 函数和一个 Concurrence() 函数,分别演示了并发操作变量时的不安全场景和加锁保护的安全场景。

NoConcurrence() 函数中,定义了一个变量 sum,然后启动了两个 Goroutine 并发执行相同的累加操作,最终将结果打印在控制台。由于两个 Goroutine 同时访问了同一个变量 sum , 并且没有使用任何锁机制,所以会出现并发安全问题,累加结果不等于20000000。

Concurrence() 函数中,首先定义了一个互斥锁 mu,然后在每个 Goroutine 中在访问共享变量 sum 之前加锁 ,操作完成之后解锁 ,从而保证同一时间只有一个 Goroutine 能够访问 sum。这样就可以避免并发安全问题,累加结果等于20000000。

需要注意的是,互斥锁Mutex只能保证在同一时间只有一个 Goroutine 能够访问临界区,这会牺牲一定的性能 ,因为在一个 Goroutine 访问临界区时,其他 Goroutine 无法执行,就算是只读取不修改也需要等待锁释放之后才能继续执行。

如果需要在读多写少的场景中提高性能,可以使用读写锁(RWMutex)来代替互斥锁。接下来详细看看**MutexRWMutex:**

MutexRWMutex 都是 Go 语言中的并发控制机制,它们都可以用于保护临界区(共享资源),避免并发访问导致的数据竞争和不一致性。

Mutex 是最简单的并发控制机制,它提供了两个方法:

  1. Lock():获取互斥锁,如果互斥锁已经被其他 Goroutine 获取,则当前 Goroutine阻塞等待
  2. Unlock():释放互斥锁,如果当前 Goroutine 没有获取互斥锁,则会引发运行时 panic。(必须先Lock, 在Unlock)

Mutex 适用于对共享资源的互斥访问,即同一时间只能有一个 Goroutine 访问共享资源的情况。

RWMutex 是在 Mutex 的基础上进行了扩展,它允许多个 Goroutine 同时读取共享资源 ,但只允许一个 Goroutine 写共享资源 ,当写共享资源时其余读操作一样会被阻塞。RWMutex 提供了三个方法:

  1. RLock():获取读锁,允许多个 Goroutine 同时获取读锁。
  2. RUnlock():释放读锁。
  3. Lock():获取互斥锁(写锁),只允许一个 Goroutine 获取互斥锁。
  4. Unlock():释放互斥锁。

RWMutex 适用于读多写少的场景,可以提高共享资源的并发读取性能。

思考题

  1. 通过RWMutex实现读取Count变量100000次,修改Count变量1000次。

自检

  • Goroutine的定义和启动 ?
  • Goroutine的同步方式 ?
  • Goroutine的调度器 ?
  • Goroutine的并发安全 ?
  • WaitGroup的定义和使用 ?

参考

strikefreedom.top/archives/hi... [精]

learnku.com/articles/41... [精]

taoshu.in/go/memory-m... [精]

github.com/LeoYang90/G...

colobu.com/2021/07/13/...

strikefreedom.top/archives/hi...

ssup2.github.io/theory_anal...

dev.to/ahmedash95/...

learnku.com/articles/62...

blog.boot.dev/golang/gos-...

gfw.go101.org/article/con...

larrylu.blog/race-condit...

zhuanlan.zhihu.com/p/431422464

segmentfault.com/a/119000001...

www.ruanyifeng.com/blog/2013/0...

www.cnblogs.com/javaleon/p/...

stackoverflow.com/questions/3...

www.modb.pro/db/78813

www.eet-china.com/mp/a125392....

持续更新: github.com/Zhouchaowen... 感谢 star

系列文章-包(Package)
系列文章-变量与常量(Var)
系列文章-函数(Function)
系列文章-流程控制(For,If,Switch,defer)
系列文章-结构体(Struct)
系列文章-数组与切片(Array&Slice)
系列文章-映射(Map)
系列文章-接口(Interface)

相关推荐
码上一元3 小时前
SpringBoot自动装配原理解析
java·spring boot·后端
枫叶_v5 小时前
【SpringBoot】22 Txt、Csv文件的读取和写入
java·spring boot·后端
杜杜的man5 小时前
【go从零单排】Closing Channels通道关闭、Range over Channels
开发语言·后端·golang
java小吕布6 小时前
Java中Properties的使用详解
java·开发语言·后端
2401_857610037 小时前
Spring Boot框架:电商系统的技术优势
java·spring boot·后端
用户3157476081358 小时前
成为程序员的必经之路” Git “,你学会了吗?
面试·github·全栈
杨哥带你写代码8 小时前
网上商城系统:Spring Boot框架的实现
java·spring boot·后端
camellias_8 小时前
SpringBoot(二十一)SpringBoot自定义CURL请求类
java·spring boot·后端
布川ku子9 小时前
[2024最新] java八股文实用版(附带原理)---Mysql篇
java·mysql·面试
背水9 小时前
初识Spring
java·后端·spring