Go进阶-Go并发Goroutine

TOC

了解程序、进程、线程、协程

  • 程序(Program)
    • 概念: 某种指令集合,用某种变成语言写好的代码文件,本身只是一个静态的东西。
    • 特点: 存在于硬盘或存储介质里。不占用 CPU,只占用一点存储空间。
    • 举例: 就像一本菜谱,写着做菜的步骤,但它不会自己做菜。
  • 进程(Process)
    • 概念: 运行中的一个独立程序实例,有自己独立的内存空间、系统资源(文件句柄、网络端口等)。
    • 特点: 相互之间是隔离的,必须通过门(进程间通信 IPC)。
    • 举例: 你开了两个微信,一个是进程 A,一个是进程 B,它们互不影响。如果一个微信崩了,不会直接导致另一个崩。
  • 线程(Thread)
    • 概念: 进程进一步细化为线程,也就是进程里的执行单元,是一个程序内部的一条执行路径,若一个进程同一时间并行执行多个线程,就是支持多线程,共享进程的内存空间和资源。
    • 特点: 线程之间切换快,通信容易(直接访问同一块内存),但容易出错(比如多线程同时修改一个变量会出竞态问题)。
    • 举例: 微信是一个进程,里面可能有:线程 1:负责 UI 界面、线程 2:负责收发消息、线程 3:负责文件下载、如果 UI 线程卡住了,界面就会卡死,但后台下载线程可能还在工作。
  • 协程(Coroutine)
    • 概念: 比线程更轻量的用户级调度单元,通常由编程语言自己调度,不依赖操作系统的线程管理。
    • 特点:
      • 1.创建和切换成本比线程低得多(Go 里几 KB 栈空间就能开一个协程,线程一般要 MB 级)。
      • 2.可以有成千上万个协程跑在少量线程上(Go 调度器会负责把协程分配到线程)。
      • 3.遇到等待(如 I/O)会主动让出 CPU,不会阻塞其他协程。
    • 举例: 你开一个 Go 程序(1 个进程),里面有 4 个 OS 线程,但是你可以启动 1 万个 goroutine(Go 协程),用来同时处理 1 万个网络请求。- - 虽然协程数量巨大,但它们不会像线程一样给系统造成很大压力。

Go高并发的核心理念

传统语言里(C/C++/Java),如果你想并发执行,就要手动创建线程,线程比较重,一个线程可能占 1MB 以上的栈内存,创建/销毁开销大,调度靠操作系统。
Go 的协程很轻量,一个协程初始栈大小只有几 KB,可以创建成千上万个,Go 运行时会帮你做协程调度(M:N 模型,多个协程在少量线程上调度)。
所以,我们使用Go的时候不需要关心线程的创建、销毁和切换,代码更简单,性能更好。但是协程是Go运行时管理的执行单元,轻量级,最终还是跑在线程上

使用协程示例

第一个协程应用

【示例】代码如下:

package main

import (
    "fmt"
    "time"
)

func test() {
    for i := 1; i <= 5; i++ {
        fmt.Println("I am the main thread --", i)
        time.Sleep(time.Second)  // 模拟耗时1s
    }
}

func subtest() {
    for i := 1; i <= 5; i++ {
        fmt.Println("I am a sub thread --", i)
        time.Sleep(time.Second)  // 模拟耗时1s
    }
}

func main() {
    go subtest() // 使用协程执行subtest()函数任务
    test()       // 执行主线程任务
}
/* 运行结果如下
I am the main thread -- 1
I am a sub thread -- 1
I am the main thread -- 2
I am a sub thread -- 2
I am a sub thread -- 3
I am the main thread -- 3
I am the main thread -- 4
I am a sub thread -- 4
I am the main thread -- 5
I am a sub thread -- 5
*/

协程的意义,计算有无协程运行程序的耗时,代码如下:

func main() {  
    first_start_time := time.Now()  
    subtest() // 使用协程执行subtest()函数任务  
    test()    // 执行主线程任务  
    first_run_time := time.Since(first_start_time)  
    last_start_time := time.Now()  
    go subtest() // 使用协程执行subtest()函数任务  
    test()       // 执行主线程任务  
    last_run_time := time.Since(last_start_time)  
    fmt.Println("第一次总共耗时: ", first_run_time, ",第二次总共耗时: ", last_run_time)  
}
/*
......
第一次总共耗时:  10.010840625s ,第二次总共耗时:  5.00542725s
*/

可以发现在没有使用协程和使用了协程的情况下我们节约了一半的时间,提高了一倍的效率。

启动多个协程

我们可以使用for循环来启动多个协程。
【示例】代码如下:

package main

import (
	"fmt"
	"time"
)

func main() {
	for i := 1; i <= 5; i++ {
		go func(num int) {
			fmt.Println("I am running --", num)
		}(i)
	}
	time.Sleep(time.Second * 2)
}
/* 运行结果如下
I am running -- 5
I am running -- 2
I am running -- 3
I am running -- 4
I am running -- 1
/*

WaitGroup

上面的协程示例中,我们都用了time.Sleep()方法来模拟耗时,如果没有这个步骤,程序就会因为没有主线程直接终止运行。现实运用中我们也不能确定任务的具体执行时间,因此我们需要模拟更长的时间来确保协程的执行完成,所以几乎不会用这种方案来保证程序正常运行。

主死从随现象

主死从随: 当主进程(或主线程)结束、退出、崩溃的时候,它所管理、派生、依附的子进程(或从线程、从协程)也会随之结束。也就是说从属任务的生命周期依赖主任务,一旦主任务挂了,从任务也就活不下去了。
下面我们用示例来演示主死从随的现象。只启动协程运行任务,主线程不做任何事情。
【示例】代码如下:

package main

import (
	"fmt"
	"time"
)

func master() {
	for i := 1; i <= 5; i++ {
		fmt.Println("I am the main thread --", i)
		// 模拟耗时1s
		time.Sleep(time.Second)
	}
}

func main() {
	go master() // 使用协程执行master()函数任务
}
/* 运行结果如下
进程 已完成,退出代码为 0
*/

下面我们再用示例来演示另外一种主死从随的现象。协程运行的任务时长周期比主线程的任务运行时长周期更长。
【示例】代码如下:

package main

import (
	"fmt"
	"time"
)

func test() {
	for i := 1; i <= 5; i++ {
		fmt.Println("I am the main thread --", i)
		// 模拟耗时1s
		time.Sleep(time.Second)
	}
}

func subtest() {
	for i := 1; i <= 10; i++ {
		fmt.Println("I am a sub thread --", i)
		// 模拟耗时1s
		time.Sleep(time.Second)
	}
}

func main() {
	go subtest() // 使用协程执行subtest()函数任务
	test()       // 执行主线程任务
}
/* 运行结果如下
I am the main thread -- 1
I am a sub thread -- 1
I am a sub thread -- 2
I am the main thread -- 2
I am the main thread -- 3
I am a sub thread -- 3
I am a sub thread -- 4
I am the main thread -- 4
I am the main thread -- 5
I am a sub thread -- 5
I am a sub thread -- 6
*/

从上面两个示例可以看出协程运行的任务都在主线程停止结束的时候也跟着停止了。

使用WaitGroup

我们可以使用sync模块下的WaitGroup来对协程进行管理操作。
使用方法,如下所示:

// 导入模块
import "sync"

//初始化对象
var wg sync.WaitGroup

// 使用方法
wg.Add(1) // 新增协程任务,数量+1
wg.Done() // 协程执行完成,数量-1
wg.Wait() // 等待所有协程完成

我们根据上面启动多个协程的示例来使用WaitGroup来优化确保协程完整执行。
【示例】代码如下:

package main

import (
	"fmt"
	"sync"
)

var wg sync.WaitGroup

func main() {
	for i := 1; i <= 5; i++ {
		wg.Add(1) // 新增协程任务,数量+1
		go func(num int) {
			fmt.Println("I am running --", num)
			wg.Done() // 协程执行完成,数量-1
		}(i)
	}
	wg.Wait() // 等待所有协程完成
}
/* 运行结果如下
I am running -- 2
I am running -- 1
I am running -- 5
I am running -- 3
I am running -- 4
*/

线程安全

多协程操作同一数据

下面我们用一个示例来演示多协程的情况下操作同一数据。功能如下:
1.定义一个全局变量 count,初始值为 0。
2.启动两个 goroutine:add():循环 10 万次,每次 count++,sub():循环 10 万次,每次 count–
3.使用 sync.WaitGroup 等待两个 goroutine 都执行完。
4.输出最终的 count 值。
【示例】代码如下:

package main

import (
	"fmt"
	"sync"
)

var (
	count int = 0
	wg    sync.WaitGroup
)

func add() {
	defer wg.Done()
	for i := 0; i < 100000; i++ {
		count++
	}
}

func sub() {
	defer wg.Done()
	for i := 0; i < 100000; i++ {
		count--
	}
}

func main() {
	wg.Add(2)
	go add()
	go sub()
	wg.Wait()
	fmt.Println("最后的结果:", count)
}
/* 运行结果如下
最后的结果: 97782
*/

根据这个代码逻辑,加10万次,减10万次,结果理论上应该为0。
为什么结果不为0?
在多线程(多协程)并发下,add() 和 sub() 可能会交叉执行,比如:

count = 0

step1:
  goroutine A 读取 0 然后执行 count++
  count = 1
  goroutine B 读取 0 然后执行 count++
  count = -1

step2: 
  goroutine A 1 + 1 → 2
  goroutine B 1 - 1 → 0

step3:
  goroutine A 写回 2
  goroutine B 写回 0

结果一次加一次减,本来应该回到0,结果却成了2,这种情况会不断发生,最终导致结果不稳定。

互斥锁

互斥锁的概念:
互斥锁(Mutex,Mutual Exclusion)是一种并发控制机制,用来保证在同一时间只有一个协程(goroutine)能访问共享资源。防止多个协程同时读写同一份数据,避免数据竞争(race condition)。

互斥锁的原理:
可以把互斥锁想象成一把"门锁"。

  • 加锁(Lock):一个协程进入临界区(访问共享资源的代码区)时,先锁上门,不让其他协程进来。
  • 解锁(Unlock):任务完成后开门,让其他协程进来。
  • 如果一个协程在门外碰到门锁,就会阻塞等待,直到锁被释放。

使用方法:

// 互斥锁由sync.Mutex提供,先导入模块
import "sync"

// 初始化对象
var mu sync.Mutex
// 使用方法
mu.Lock()  // 加锁
mu.UnLock()  //解锁

【示例】代码如下:

package main

import (
	"fmt"
	"sync"
)

var (
	count int            = 0
	wg    sync.WaitGroup // 初始化WaitGroup对象
	mu    sync.Mutex     // 初始化互斥锁对象
)

func add() {
	defer wg.Done()
	for i := 0; i < 100000; i++ {
		mu.Lock()  // 加锁count变量操作
		count++
		mu.Unlock() // 解锁count变量操作
	}
}

func sub() {
	defer wg.Done()
	for i := 0; i < 100000; i++ {
		mu.Lock()  // 加锁count变量操作
		count--
		mu.Unlock()  // 解锁count变量操作
	}
}

func main() {
	for i := 1; i <= 5; i++ {
		wg.Add(2)
		go add()
		go sub()
		wg.Wait()
		fmt.Println("第", i, "次运行的的结果:", count)
	}
}
/* 运行结果如下
第 1 次运行的的结果: 0
第 2 次运行的的结果: 0
第 3 次运行的的结果: 0
第 4 次运行的的结果: 0
第 5 次运行的的结果: 0
*/

读写锁

读写锁的概念:
相比普通互斥锁,读写锁更快,普通互斥锁(sync.Mutex)在加锁后,无论是读还是写,其他 goroutine 都必须等待。而读写锁(sync.RWMutex)优化了这个场景。

读写锁的原理:
假设有一个共享资源(比如 count)。

  • 多个 goroutine 只读它:可以同时持有读锁,互不阻塞。
  • 有 goroutine 需要写它:必须获取 写锁,并且要等所有读锁释放后才能执行。

规则
1.同时可以拥有多个读锁。
2.同时只能有一个写锁。
3.写锁期间,禁止获取读锁(防止读到不一致数据)。

使用方法:

// 读写锁由sync.RWMutex提供,先导入模块
import "sync"

// 初始化对象
var rwMu sync.RWMutex
// 使用方法
rwMu.Lock()  // 写加锁
rwMu.UnLock()  // 写解锁
rwMu.RLock()  // 读加锁
rwMu.RUnLock()  // 读解锁

【示例】代码如下:

package main

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

var (
	count int            = 0
	wg    sync.WaitGroup // 初始化WaitGroup对象
	rwMu  sync.RWMutex   // 初始化读写锁对象
)

func add() {
	defer wg.Done()
	for i := 0; i < 100000; i++ {
		rwMu.Lock() // 写加锁
		count++
		rwMu.Unlock() // 写解锁
	}
}

func sub() {
	defer wg.Done()
	for i := 0; i < 100000; i++ {
		rwMu.Lock() // 写加锁
		count--
		rwMu.Unlock() // 写解锁
	}
}

func readValue(id int) {
	defer wg.Done()
	rwMu.RLock() // 读加锁
	fmt.Printf("Goroutine %d count的值: %d\n", id, count)
	rwMu.RUnlock() // 读解锁
}

func main() {
	wg.Add(2)
	go add()          // 写操作1
	go sub()          // 写操作2
	readCount := 3    // 设置读几次操作
	wg.Add(readCount) // 添加读的协程任务数量
	for i := 1; i <= readCount; i++ {
		go readValue(i)              // 读操作
		time.Sleep(time.Millisecond) // 模拟间隔耗时读
	}
	wg.Wait() // 等待所有协程完成
	fmt.Println("最后的结果:", count)
}
/* 运行结果如下
Goroutine 1 count的值: 303
Goroutine 2 count的值: 9633
Goroutine 3 count的值: 6605
最后的结果: 0
*/

原子操作

在并发环境下,对某个变量的一次读、写或“读-改-写”在底层以不可分割的方式完成,不会被打断,也不会与其他并发访问交叉,并且同时提供内存可见性(建立 happens-before 关系)。sync/atomic库提供原子操作(atomic operations)

主要作用:

  • 超高频、极短临界区的场景(计数器、状态位、只读指针热更新等)。
  • 想避免互斥锁开销或锁竞争的热点路径。

注意事项:原子操作适合单个变量的安全访问;一旦牵涉到多个变量的一致性/不变式,请优先用 sync.Mutex/RWMutex。

函数列表:
以 int64 为例(其他类型同名):

  • atomic.AddInt64(&v, delta):原子加(返回新值)
  • atomic.LoadInt64(&v):原子读
  • atomic.StoreInt64(&v, x):原子写
  • atomic.SwapInt64(&v, x):原子交换(返回旧值)
  • atomic.CompareAndSwapInt64(&v, old, new):CAS(比较后交换,成功返回 true)

使用方法:

// 原子操作方法由sync.atomic提供,先导入模块
import "sync/atomic"

// 初始化对象
var count int64 = 0

// 使用方法
atomic.AddInt64(&count, 1)  // 原子+1
atomic.AddInt64(&count, -1)  // 原子-1
atomic.LoadInt64(&count)  // 原子读

【示例】代码如下:

package main

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

var (
	count int64 = 0
	wg    sync.WaitGroup
)

func add() {
	defer wg.Done()
	for i := 0; i < 100000; i++ {
		atomic.AddInt64(&count, 1) // 使用原子操作+1
	}
}

func sub() {
	defer wg.Done()
	for i := 0; i < 100000; i++ {
		atomic.AddInt64(&count, -1) // 使用原子操作-1
	}
}

func main() {
	wg.Add(2)
	go add()
	go sub()
	wg.Wait()
	fmt.Println("最后的结果:", count)
}
/* 运行结果如下
最后的结果: 0
*/