并发安全与锁
总述
这篇文章,我想谈一谈自己对于并发变成的理解与学习。主要涉及以下三个部分:goroutine,channel以及lock
临界区
首先,要明确下面两组概念
并发和并行
并行:指几个程序每时每刻都同时进行
并发:指在单位时间内同时运行
go的并发模型在内存共享方面有点类型SMP
模型:
相似之处:
- 多处理器利用:
- SMP:SMP 系统利用多个处理器(或核心)来并行处理任务,系统中的所有处理器共享同一内存空间。
- Go: Go 通过 goroutines 和调度器来利用多个处理器,能够在多核处理器上并行执行多个 goroutine。Go 的调度器会将 goroutine 分配到可用的处理器上,从而实现并行计算。
- 共享内存:
- SMP:所有处理器访问同一内存空间,可以直接共享数据。
- Go: goroutines 可以通过共享内存进行通信,虽然 Go 提供了通道(channels)作为主要的同步和通信机制,但共享内存仍然是可能的。Go 的同步机制,如互斥锁(mutex)和原子操作,帮助避免数据竞争和保持一致性。
不同之处:
- 并发模型:
- SMP:SMP 更多地关注于物理硬件层面的多处理器架构和资源共享,不直接涉及编程模型。
- Go: Go 语言提供了一种高层次的并发编程模型,通过 goroutines 和 channels 来简化并发编程。Go 的调度器负责将 goroutines 映射到系统线程和处理器上,程序员可以更高效地编写并发程序,而不需要直接管理线程。
- 调度和管理:
- SMP:在 SMP 系统中,操作系统负责调度和管理线程,确保线程能够在多个处理器上运行。
- Go: Go 运行时提供了一个轻量级的调度器,称为 GOMAXPROCS,管理 goroutines 的执行。Go 的调度器将 goroutines 调度到系统线程上,而不是直接由操作系统的线程调度机制来管理。
- 编程模型:
- SMP:编程模型通常需要考虑线程同步、数据竞争和缓存一致性等底层细节。
- Go: Go 的并发模型通过 goroutines 和通道提供了较高的抽象,程序员不需要直接处理线程和锁的细节,使并发编程更为简洁和安全。
总的来说,虽然 Go 的并发模型与 SMP 在利用多处理器的并行能力上有相似之处,但 Go 的模型更专注于简化并发编程,而 SMP 更加关注底层的处理器和内存管理。
协程 进程 线程
1. 进程(Process)
定义:
进程是计算机中正在运行的程序的实例,具有独立的地址空间、资源和执行上下文。每个进程都有自己的内存空间、文件描述符和其他系统资源。
特点:
- 资源独立:每个进程有独立的内存空间,进程间的通信需要使用 IPC(进程间通信)机制,如管道、共享内存等。
- 开销大:创建和销毁进程的开销相对较大,因为涉及内存分配和资源管理。
2. 线程(Thread)
定义:
线程是进程中的一个执行单元,是程序执行的最小单位。一个进程可以包含多个线程,它们共享进程的地址空间和资源。
特点:
- 共享资源:同一进程中的线程共享进程的内存和资源,因此线程间的通信比进程间的通信更为高效。
- 开销小:线程的创建和销毁比进程要轻量,因为不需要为每个线程分配独立的地址空间。
3. 协程(Coroutine)
定义:
协程是一种用户级的轻量级线程,可以在程序中暂停和恢复执行。协程通常在同一线程中切换,由程序控制,而不是由操作系统调度。
特点:
- 高效:由于协程是由程序员控制的,切换开销相对较小,适合处理高并发场景。
- 共享同一线程:协程通常在同一线程内运行,之间的切换非常快速,适合执行 I/O 密集型任务。
区别总结
特性 | 进程 | 线程 | 协程 |
---|---|---|---|
定义 | 程序的独立实例 | 进程内的执行单元 | 用户级的轻量级线程 |
内存 | 独立的内存空间 | 共享进程内的内存 | 共享线程内的内存 |
开销 | 高 | 较低 | 低 |
调度 | 由操作系统调度 | 由操作系统调度 | 由程序控制 |
通信 | 复杂(需要 IPC) | 简单(共享内存) | 更简单(通过函数调用) |
应用场景 | 适合 CPU 密集型任务 | 适合多任务并发处理 | 适合高并发的 I/O 密集型任务 |
适用场景
- 进程:适用于需要高度隔离的任务,比如服务器、桌面应用等。
- 线程:适合需要共享资源的任务,如 GUI 应用程序的事件处理、网络服务等。
- 协程:适合 I/O 密集型的应用,如网络爬虫、异步处理等,能够有效管理大量并发任务。
Go程的使用
go语言使用的是共享内存(与传统的共享模型不同的是,go语言与大多编程语言一样,允许加锁来保证线程安全)的并发模式,使用go关键字可以启动一个go程(先后顺序是随机的、可互换的),不同的go程之间可以通过通道来传输数据,使用锁或者sync
包中的方法可以控制进程的顺序(有点类似于阶段内随机、总体分阶段进行)。
Goroutine
在go语言中,并发性是一种语言天然支持,简洁而容易实现的。
两种调用
实现一个“go程”,最常见的方法在正常的函数调用之前加上“go”关键字即可:
for i := 0; i < 5; i++ {wg.Add(1)//暂且忽略这一行go work(&wg)}
在go中,无法控制go程执行的先后顺序,也就是说这些go程的先后顺序是随机的。
此外还有一种方法是使用闭包函数的直接调用:
go func() { //使用go 直接将这个函数作为一个goroutine来运行defer fmt.Println("A defer")func() {defer fmt.Println("B defer")runtime.Goexit() //退出这个goroutine//return 退出内层匿名函数fmt.Println("A")}()}()
Go程的常见配套方法
前置defer()
标记结束
func Run() {defer fmt.Println("子进程结束了")for i := 0; i < 10; i++ {fmt.Println("这是子进程的第", i, "个循环")time.Sleep(1 * time.Second)}
}
利用defer的特性,我们可以在go触发进程的函数结束之后,达到某种效果(比如sync.WaitGroup.Done()
)
time.Sleep(?* time.Second)
收尾
goroutine可能的切换点
- I/O,select
- channel
- 等待锁
- 函数调用(有时)
runtime.Gosched()
Channel
channel是一个通道,是用来实现两个进程之间的通信的,本质上是一个队列的数据结构。
channel实现了goroutine两个进程之间的通信
chan2 := make(chan int, 10) make(chan Type, capacity)有缓冲通道
chan1 := make(chan int) //make(chan Type)无缓冲通道
向通道中读写数据:
//向管道中写入数据
channel <- value 发送//从管道中读取数据
<- channel 接收并将其丢弃
x := <- channel 接收并赋值
x , ok := <- channel 接收并赋值,ok为false表示channel已关闭
两种通道的解释
下面两个是刘丹冰老师对于无缓冲通道和有缓冲通道的形象解释
无缓冲通道:
有缓冲通道:
实例:
func test1() {defer fmt.Println("主进程已经结束")//创建一个无缓冲channelc := make(chan int)go func() {defer fmt.Println("子进程已经结束")fmt.Println("正在进行")num := 666fmt.Println("子进程中数据的值为:", num)c <- num}()num := <-c //通过通道将子进程的数据捕捉到主进程fmt.Println("主进程捕捉到的子进程数据: ", num)
}
Lock
锁的使用,包含在sync
包里面,分为互斥锁(Mutex)、读写锁(RWMutex)、等待组(WaitGroup)、一次性锁(Once)和条件变量(Cond)。这是为了解决在Go代码中可能会存在多个goroutine同时操作一个资源(临界区)以及这种情况下发生的竞态问题(数据竞态)。这篇文章只涉及前面两个,后续的在sync包中解释。
互斥锁(Mutex类型)
互斥锁只能被一个goroutine同时持有。如果另一个goroutine试图获取一个已被持有的互斥锁,它将被阻塞,直到持有锁的goroutine释放锁。使用互斥锁能够保证同一时间有且只有一个goroutine进入临界区,其他的goroutine则在等待锁;当互斥锁释放后,等待的goroutine才可以获取锁进入临界区,多个goroutine同时等待一个锁时,唤醒的策略是随机的。
案例:
package mainimport ("fmt""sync""time"
)var (sharedValue intmu sync.Mutex // 创建一个互斥锁
)func increment(wg *sync.WaitGroup, id int) {defer wg.Done() // 完成任务时调用 Done()for i := 0; i < 5; i++ { // 为了简化输出,将循环次数减少到5次mu.Lock() // 获取锁fmt.Printf("Go程编号: %d: 将共享变量 sharedValue 的值从 %d 增加\n", id, sharedValue)sharedValue++ // 访问和修改共享变量time.Sleep(100 * time.Millisecond) // 模拟其他工作,增加延迟以便观察输出fmt.Printf("Go程编号: %d: 将共享变量 sharedValue 的值增加到 %d \n", id, sharedValue)mu.Unlock() // 释放锁}
}func main() {var wg sync.WaitGroupwg.Add(2) // 添加两个 goroutine 的等待go increment(&wg, 1) // 创建第一个 goroutine,并传递ID 1go increment(&wg, 2) // 创建第二个 goroutine,并传递ID 2wg.Wait() // 等待所有 goroutine 完成fmt.Printf("最终的共享变量值: %d\n", sharedValue)
}
运行结果:
Go程编号: 2: 将共享变量 sharedValue 的值从 0 增加
Go程编号: 2: 将共享变量 sharedValue 的值增加到 1
Go程编号: 2: 将共享变量 sharedValue 的值从 1 增加
Go程编号: 2: 将共享变量 sharedValue 的值增加到 2
Go程编号: 1: 将共享变量 sharedValue 的值从 2 增加
Go程编号: 1: 将共享变量 sharedValue 的值增加到 3
Go程编号: 1: 将共享变量 sharedValue 的值从 3 增加
Go程编号: 1: 将共享变量 sharedValue 的值增加到 4
Go程编号: 2: 将共享变量 sharedValue 的值从 4 增加
Go程编号: 2: 将共享变量 sharedValue 的值增加到 5
Go程编号: 2: 将共享变量 sharedValue 的值从 5 增加
Go程编号: 2: 将共享变量 sharedValue 的值增加到 6
Go程编号: 1: 将共享变量 sharedValue 的值从 6 增加
Go程编号: 1: 将共享变量 sharedValue 的值增加到 7
Go程编号: 1: 将共享变量 sharedValue 的值从 7 增加
Go程编号: 1: 将共享变量 sharedValue 的值增加到 8
Go程编号: 2: 将共享变量 sharedValue 的值从 8 增加
Go程编号: 2: 将共享变量 sharedValue 的值增加到 9
Go程编号: 1: 将共享变量 sharedValue 的值从 9 增加
Go程编号: 1: 将共享变量 sharedValue 的值增加到 10
最终的共享变量值: 10
这个程序说明了,Lock()
将go程锁住,这时候只有一个进程可以更改变量的值,直到这个进程结束后,才能有其他进程一起竞争这个变量值的使用。
锁的影响范围:
- 在
mu.Lock()
和mu.Unlock()
之间的代码:这些代码块中的所有操作都被保护。只有持有锁的 goroutine 可以执行这些操作。 - 在
mu.Unlock()
之后的代码:锁的释放意味着其他等待的 goroutine 现在可以获取锁并继续执行它们的操作。锁不再影响mu.Unlock()
之后的代码块。
读写互斥锁(RWMutex类型
)
读写锁允许多个goroutine同时读取受保护的数据,但只允许一个goroutine同时写入受保护的数据。
每个进程都可以获得读锁,拿到之后都可以读。但是写锁只有一把,谁拿到谁写。所以很明显,读写锁非常适合读多写少的场景,如果读和写的操作差别不大,读写锁的优势就发挥不出来。
案例如下:
package mainimport ("fmt""sync""time"
)var (sharedValue intrwMutex sync.RWMutex
)// 读操作
func read(id int) {rwMutex.RLock() // 获取读锁fmt.Printf("Goroutine %d: Reading sharedValue: %d\n", id, sharedValue)time.Sleep(100 * time.Millisecond) // 模拟读取操作rwMutex.RUnlock() // 释放读锁
}// 写操作
func write(id int, value int) {rwMutex.Lock() // 获取写锁fmt.Printf("Goroutine %d: Writing sharedValue from %d to %d\n", id, sharedValue, value)sharedValue = valuetime.Sleep(200 * time.Millisecond) // 模拟写操作rwMutex.Unlock() // 释放写锁
}func main() {var wg sync.WaitGroup// 启动多个读操作for i := 1; i <= 5; i++ {wg.Add(1)go func(id int) {defer wg.Done()read(id)}(i)}// 启动多个写操作for i := 1; i <= 3; i++ {wg.Add(1)go func(id int) {defer wg.Done()write(id, id*10)}(i)}// 再启动一些读操作for i := 6; i <= 10; i++ {wg.Add(1)go func(id int) {defer wg.Done()read(id)}(i)}wg.Wait()fmt.Printf("最终共享值为: %d\n", sharedValue)
}
运行结果:
Goroutine 1: Reading sharedValue: 0
Goroutine 2: Reading sharedValue: 0
Goroutine 2: Writing sharedValue from 0 to 20
Goroutine 8: Reading sharedValue: 20
Goroutine 9: Reading sharedValue: 20
Goroutine 4: Reading sharedValue: 20
Goroutine 3: Reading sharedValue: 20
Goroutine 6: Reading sharedValue: 20
Goroutine 7: Reading sharedValue: 20
Goroutine 5: Reading sharedValue: 20
Goroutine 10: Reading sharedValue: 20
Goroutine 1: Writing sharedValue from 20 to 10
Goroutine 3: Writing sharedValue from 10 to 30
最终共享值为: 30
从这里面我们可以看出来,读操作的进程是没有什么先后顺序的,完全随机的(比如89436这几个进程,完全是竞争的关系),而写操作之间也是相互竞争的。但是,我们不难发现,对于读取操作,他们使用的是读锁,因此这个竞争是随机的;但是写锁,很明显是有着先后顺序的(这点从前后值的变化就可以看出),即前面一个写进程结束之后,后面一个写进程才能继续。
后续的三个锁在sync包中有详解。