Goroutine与并发编程的关系
什么是并发
是指多个任务在同一时间段内进行处理,但不一定是在同一时刻执行。并发强调的是“结构上的并行性”,也就是说,程序能够在一个时间端内同时处理多个任务,但是这些任务可能是交替进行的。例如:一个cpu可以迅速切换任务的上下文,给人一种多个任务在“并行”执行的感觉。
并发的主要特点是能够处理多个任务,而这些任务并不需要同时执行,但在某种情况下它们可以并行执行。并发的关键在于如何有效的切换任务和处理共享资源。
并发的核心就是让多个任务有机会被处理,而不需要等待其他任务完全结束。
goroutine与并发的关系
在Go语言中,goruoutine是并发的基本单位,它是一种轻量级的线程,由Go运行时管理。与传统的线程相比,goroutine占用的资源非常小,因此在程序中启动成千上万个goroutine,不会造成过高的开销。
goroutine通过Go的调度器进行管理,调度器负责讲goroutine映射到操作系统的线程上。goroutine在并发模型中的作用是允许程序处理多个任务,它的本质是轻量级的“线程”,但由于Go提供了对goroutine的高效调度,goroutine的使用非常灵活和高效。
goroutine 如何实现并发?
- 轻量级任务:goroutine 被设计为非常轻量级的任务。启动一个 goroutine 需要的内存比传统线程要少很多,因此你可以并发执行成千上万的 goroutine。
- 自动调度:Go 运行时内置的调度器会自动将 goroutine 映射到 CPU 上的可用线程上,从而实现并发执行。Go 程序不需要手动管理线程池或调度机制。
- 非阻塞执行:在执行 goroutine 时,即使某个 goroutine 因为 I/O 操作或其他原因阻塞了,Go 的调度器仍然可以将其他 goroutine 调度到 CPU 上继续执行,从而保持并发运行。
Go语言中的锁
互斥锁,读写锁
锁的思想:乐观锁,悲观锁
互斥锁()
1.1定义
互斥锁是一个用于保护临界区的同步用语,它的所用是确保统一时刻只有一个goroutine能访问被保护的共享资源。通过互斥锁,可以避免并发访问带来的数据竞争问题。
1.2示例代码:
package mainimport ("fmt""sync"
)var (mutex sync.Mutexcount int
)func increment() {mutex.Lock() // 获取锁count++mutex.Unlock() // 释放锁
}func main() {var wg sync.WaitGroup// 启动多个 goroutine 并发执行for i := 0; i < 10; i++ {wg.Add(1)go func() {defer wg.Done()increment()}()}wg.Wait() // 等待所有 goroutine 完成fmt.Println("Final count:", count)
}
在这个示例中,mutex
被用来确保同一时刻只有一个 goroutine 能够访问 count
变量。由于有多个 goroutine 在并发地执行 increment
函数,通过 mutex.Lock()
和 mutex.Unlock()
确保了线程安全。
1.3注意事项
- 死锁:如果一个 goroutine 在持有锁时没有及时释放锁,或者两个 goroutine 相互等待对方释放锁,可能会导致死锁。为了避免死锁,确保每个
Lock()
都有对应的Unlock()
,并且尽量减少锁持有的时间。 - 锁的嵌套:避免在持有锁时再次请求锁,除非非常必要,否则会增加死锁的风险。
读写锁
2.1 定义与原理
读写锁(RWMutex)是为了优化读操作频繁的场景设计的锁。与互斥锁不同,读写锁允许多个 goroutine 同时进行读操作,但写操作是互斥的,只有在没有任何读操作或写操作进行时,才能进行写操作。
- RLock():用于加读锁,多个 goroutine 可以同时持有读锁,只要没有其他 goroutine 持有写锁。
- RUnlock():释放读锁。
- Lock():用于加写锁,写锁是独占的,只有一个 goroutine 可以持有写锁。
- Unlock():释放写锁。
2.2 使用示例
package mainimport ("fmt""sync" )var (rwMutex sync.RWMutexdata int )func read() {rwMutex.RLock() // 获取读锁defer rwMutex.RUnlock()fmt.Println("Reading data:", data) }func write(value int) {rwMutex.Lock() // 获取写锁defer rwMutex.Unlock()data = valuefmt.Println("Writing data:", value) }func main() {var wg sync.WaitGroup// 启动多个 goroutine 执行读操作for i := 0; i < 5; i++ {wg.Add(1)go func() {defer wg.Done()read()}()}// 启动一个 goroutine 执行写操作wg.Add(1)go func() {defer wg.Done()write(42)}()wg.Wait() }
在这个示例中,
read
函数通过rwMutex.RLock()
获取读锁,允许多个 goroutine 同时执行读取操作。而write
函数则通过rwMutex.Lock()
获取写锁,保证写操作是独占的,不会与读操作同时进行。
2.3 适用场景
- 读多写少:读写锁特别适合读多写少的场景。当读操作远远多于写操作时,使用读写锁可以显著提高性能,因为多个读操作可以并发执行,而不必等待锁的释放。
- 写操作必须独占:写操作会阻塞所有读操作和其他写操作,确保写操作的安全性。
2.4 与互斥锁的区别
- 互斥锁是严格的互斥机制,即同一时刻只能有一个 goroutine 执行临界区代码(无论是读还是写)。
- 读写锁允许多个 goroutine 同时读共享资源,但在写共享资源时,写操作需要独占资源。
当程序中存在大量读操作时,读写锁可以提供更高的并发性,因为它允许多个读操作同时进行,而不会像互斥锁那样相互阻塞。
3. 何时使用互斥锁,何时使用读写锁?
- 使用互斥锁:
- 如果操作是读写混合的,或者读写的比例相当,使用互斥锁会更简单直接。
- 如果共享资源的访问量很小,读写锁可能带来的复杂性不值得使用。
- 使用读写锁:
- 如果你的应用场景下,读操作远多于写操作,使用读写锁可以提高性能,允许多个 goroutine 并发读数据,同时保证写操作的独占性。
- 读写锁比互斥锁更复杂,因此如果读写比不明显,使用互斥锁可能更为简单
两种锁的思想:悲观锁和乐观锁
悲观锁
2.1 定义与原理
悲观锁的核心思想是假设并发操作会引发冲突,因此在访问共享资源时,采用加锁的方式防止其他 goroutine 同时访问该资源。只有在获取锁之后,才能对共享资源进行操作,其他 goroutine 必须等待锁被释放。
- 特点:总是先加锁,再访问资源,保证在同一时刻只有一个 goroutine 能够访问资源。
- 锁的粒度:锁的粒度较大(例如,一次获取整个资源的锁),因此会影响系统的并发性能。
在 Go 中,sync.Mutex
和 sync.RWMutex
就是典型的悲观锁实现:
sync.Mutex
:互斥锁,整个资源加锁,防止其他 goroutine 访问。sync.RWMutex
:读写锁,写操作是互斥的,而读操作可以并发执行,但在写操作时,所有读操作和其他写操作会被阻塞。
2.2 示例
假设我们有一个共享变量,需要通过悲观锁来保护:
package mainimport ("fmt""sync"
)var (mutex sync.Mutexcount int
)func increment() {mutex.Lock() // 获取锁defer mutex.Unlock()count++
}func main() {var wg sync.WaitGroupfor i := 0; i < 10; i++ {wg.Add(1)go func() {defer wg.Done()increment()}()}wg.Wait()fmt.Println("Final count:", count)
}
在这个示例中,mutex.Lock()
和 mutex.Unlock()
确保了只有一个 goroutine 能访问 count
变量,这就是一种典型的悲观锁机制。
2.3 使用场景
悲观锁通常用于以下场景:
- 资源竞争严重:当多个 goroutine 频繁争抢资源时,悲观锁能确保对资源的访问是互斥的,避免并发问题。
- 数据一致性要求高:当你无法接受并发操作对数据产生不一致时,使用悲观锁可以避免这类问题。
然而,悲观锁可能导致性能瓶颈,因为锁住了共享资源的访问,其他 goroutine 必须等待锁释放,导致并发性降低。
乐观锁
3.1 定义与原理
与悲观锁相对,乐观锁的核心思想是假设并发操作不会引发冲突,因此它不主动加锁,而是乐观地认为不会有并发冲突。在操作共享资源之前,乐观锁并不会加锁,而是在操作后检测是否发生了冲突。如果冲突发生,则回滚操作,重试或者放弃。
乐观锁通常通过一些“版本控制”机制来实现,例如通过“版本号”或者“时间戳”来判断数据是否发生变化。
- 特点:乐观锁不会阻塞其他 goroutine,只有在数据写入时才检测冲突。通常适用于读多写少的场景。
- 回滚重试机制:如果在提交操作时发现冲突,乐观锁通常会选择回滚并重试。
3.2 示例
Go 本身并没有直接实现乐观锁,但你可以通过一些简单的机制来模拟乐观锁。一个常见的方法是通过版本号(或时间戳)来判断资源是否被修改。
package mainimport ("fmt""sync"
)type Counter struct {value intversion int // 版本号,用于乐观锁mu sync.Mutex
}func (c *Counter) increment() bool {c.mu.Lock()defer c.mu.Unlock()// 记录当前版本号currentVersion := c.version// 执行增量操作c.value++// 模拟乐观锁:如果版本号没有变化,认为没有并发冲突if c.version == currentVersion {c.version++return true // 操作成功}// 如果版本号发生变化,表示有其他操作修改了数据,返回失败return false
}func main() {var wg sync.WaitGroupcounter := &Counter{}for i := 0; i < 10; i++ {wg.Add(1)go func() {defer wg.Done()// 模拟乐观锁重试for !counter.increment() {fmt.Println("Retrying...")}}()}wg.Wait()fmt.Println("Final count:", counter.value)
}
3.3 使用场景
乐观锁适用于以下场景:
- 读操作远多于写操作:在大多数情况下,数据不会发生冲突,乐观锁能够减少不必要的加锁,提高性能。
- 冲突发生概率低:如果你知道并发冲突发生的概率非常低,使用乐观锁可以提高效率。
- 需要高并发读操作:乐观锁不需要加锁,允许多个 goroutine 同时读取数据,适用于高并发读场景。
然而,乐观锁的缺点在于需要处理冲突时的回滚和重试,尤其是在高并发写操作的场景中,重试的开销可能非常大。
乐观锁与悲观锁的对比
特性 | 悲观锁 | 乐观锁 |
---|---|---|
假设 | 假设并发冲突会发生,采取措施避免冲突 | 假设并发冲突不会发生,事后检查冲突 |
加锁方式 | 总是加锁,互斥访问 | 不加锁,事后检查冲突 |
性能 | 可能影响并发性能,锁竞争时会阻塞 | 高并发读取时性能较好,但有回滚重试开销 |
适用场景 | 资源竞争严重,数据一致性要求高 | 读多写少,冲突概率低的场景 |
总结
- 悲观锁:通过加锁保证数据一致性,适用于资源竞争严重的场景,但会影响系统的并发性能。
- 乐观锁:假设没有冲突,允许多个 goroutine 并发访问资源,适用于读多写少、冲突概率低的场景,通常需要进行冲突检测和回滚。
在 Go 语言中,虽然我们大多数情况下使用的是悲观锁(例如 sync.Mutex
和 sync.RWMutex
),但在某些场景下,理解并应用乐观锁的思想可以帮助我们提升并发性能,尤其是在读操作远多于写操作的情况下。