什么是锁

引入锁的概念是为了解决多线程/多进程并发访问共享资源时可能出现的数据竞争和不一致性的问题。

  1. 数据竞争:在多线程环境中,当多个线程同时访问和修改共享数据时,由于执行顺序的不确定性,可能导致数据竞争。数据竞争可能会导致不可预测的结果和程序错误,如读取脏数据、数据丢失、计算错误等。
  2. 数据一致性:共享资源的一致性是指在并发访问下,多个线程或进程之间对共享资源的访问和修改应该满足某种逻辑约束或约定。通过引入锁机制,可以确保在任意时刻只有一个线程或进程能够访问共享资源,从而维护数据的一致性。
  3. 线程安全性:引入锁可以实现线程安全性,即在多线程环境中,对共享资源的访问操作不会导致不正确或不一致的结果。使用锁可以保护临界区(critical section)的代码片段,确保在同一时间只有一个线程可以进入临界区执行代码,从而避免了并发访问带来的问题。
  4. 同步机制:锁作为一种同步机制,可以协调多个线程或进程之间的执行顺序,以确保线程间操作的有序性。通过锁,可以实现线程的互斥访问和顺序执行,从而控制和协调多个线程之间的交互。

临界区(Critical Section)是指在多线程/多进程程序中,对共享资源进行访问和修改的代码段或区域。在临界区内,每个线程或进程对共享资源的操作必须是原子的,即在同一时间只允许一个线程或进程进入临界区执行。

以下是一个简单的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
var counter = 0
func incrementCounter() {
for i := 0; i < 1000000; i++ {
counter++
}
}

func main() {
counter = 0

go incrementCounter()
go incrementCounter()
fmt.Println("Counter:", counter)
}

预期的结果是 Counter: 2000000,即每个线程各增加了100万次,最后得到的总计数为200万。然而,由于两个线程并发执行,没有同步机制的情况下,可能会出现数据竞争和不一致的问题。

在没有锁的情况下,可能会发生以下情况之一:

  1. 线程交错问题:线程1和线程2交替执行,每个线程执行100万次的循环。由于计数器的自增操作不是原子的,可能会导致计数器的值丢失或增加不正确,最后得到的总计数不是预期的200万。
  2. 竞争条件:由于两个线程同时读取并修改计数器,可能会导致数据竞争。例如,线程1读取计数器的值为100,然后线程2也读取同样的值为100。接着,线程1增加计数器的值为101,然后线程2也增加计数器的值为101。最终,两个线程都以相同的值更新计数器,导致计数器的增加次数减少。

通过引入锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package main

import (
"fmt"
"sync"
)

var counter int
var mutex sync.Mutex

func incrementCounter(wg *sync.WaitGroup) {
defer wg.Done()

for i := 0; i < 1000000; i++ {
mutex.Lock()
counter++
mutex.Unlock()
}
}

func main() {
counter = 0
var wg sync.WaitGroup
wg.Add(2)

go incrementCounter(&wg)
go incrementCounter(&wg)

wg.Wait()

fmt.Println("Counter:", counter)
}

在上述代码中,我们使用了 sync.Mutex 类型的互斥锁来保护对计数器的访问。在 incrementCounter 函数中,我们使用 mutex.Lock() 来获取锁,并在操作完成后使用 mutex.Unlock() 来释放锁。这样可以确保在同一时间只有一个 goroutine 可以访问临界区(对计数器的操作),避免数据竞争和不一致性。

各种各样的锁 - Too many locks

互斥锁

互斥锁的主要特点如下:

  • 互斥性:互斥锁提供了互斥性,即同一时间只允许一个线程或进程持有锁并进入临界区执行。其他线程或进程如果希望进入临界区,必须等待当前持有锁的线程或进程释放锁。
  • 阻塞与非阻塞:当一个线程或进程尝试获取互斥锁时,如果锁已被其他线程或进程持有,那么它可以选择阻塞等待锁的释放,或者以非阻塞方式立即返回获取锁的结果。

互斥锁的底层实现可以有多种方式,具体取决于操作系统和编程语言的实现细节。下面介绍一种常见的互斥锁实现方式:

  1. 原子操作与标志位:互斥锁可以通过硬件提供的原子操作指令来实现。通常使用一个标志位(flag)表示锁的状态,比如0表示未被持有,1表示已被持有。获取锁时,线程或进程会尝试将标志位从0改为1,如果成功获取到锁,则进入临界区执行操作。释放锁时,线程或进程将标志位重新设置为0,表示锁已释放,其他线程或进程可以尝试获取锁。
  2. 禁用中断:某些操作系统或编程语言的实现中,互斥锁可以通过禁用中断来实现。当一个线程或进程获取锁时,它会禁用中断,阻止其他线程或进程的干扰。只有当持有锁的线程或进程释放锁时,中断才会重新启用,其他线程或进程才有机会获取锁。

读写锁

在读写锁(也称为读者-写者锁)中,允许多个读进程同时访问共享资源,但只允许一个写进程独占地访问共享资源。当一个写进程正在等待获取写锁时,后续的读请求的处理方式取决于读写锁的实现策略。

  1. 读请求等待:某些读写锁实现可能选择将后续的读请求阻塞,直到写进程释放写锁。这意味着写进程具有更高的优先级,读请求必须等待写请求完成。
  2. 读请求不受阻塞:其他实现可能允许后续的读请求继续执行,即使有一个写进程正在等待获取写锁。这种情况下,读请求和写请求可以并发执行,但写请求可能需要等待之前的读请求完成后才能获取写锁。

信号量

信号量(Semaphore)是一种用于控制并发访问的同步机制,用于管理对共享资源的访问权限。信号量主要有两种类型:二进制信号量和计数信号量。

  1. 二进制信号量:二进制信号量也被称为互斥锁(Mutex)。它只能有两个状态:0和1。通常用于实现互斥访问,即同一时间只允许一个线程或进程访问共享资源。在二进制信号量中,当一个线程或进程获得了信号量(值为1)时,其他线程或进程将被阻塞,直到该线程或进程释放信号量(值为0)。
  2. 计数信号量:计数信号量允许一个资源被多个线程或进程同时访问,但限制同时访问的数量。计数信号量的值可以大于1,表示可以同时有多个线程或进程访问共享资源。当一个线程或进程获得信号量时,信号量的值会减少。如果信号量的值为0,表示所有资源都已经被占用,其他线程或进程将被阻塞,直到有一个线程或进程释放信号量,使得信号量的值变为正数。

下面是使用信号量的一般步骤:

  1. 创建信号量:根据需要创建一个二进制信号量或计数信号量,并初始化其初始值。
  2. 获取信号量:一个线程或进程想要访问共享资源时,首先尝试获取信号量。如果信号量的值大于0,则线程或进程可以继续执行,同时将信号量的值减少。如果信号量的值为0,则线程或进程将被阻塞,等待其他线程或进程释放信号量。
  3. 释放信号量:当一个线程或进程完成对共享资源的访问时,它需要释放信号量,使得信号量的值增加。这样可以通知其他等待的线程或进程可以继续执行。

乐观锁与悲观锁

乐观锁和悲观锁并不是一种特定的锁,而是一种设计锁的策略。

  1. 乐观锁:
    乐观锁假设多个事务之间很少发生冲突,因此允许它们同时访问共享资源,只在更新时检查冲突。它不会立即阻塞其他事务的操作,而是在提交时进行冲突检测。一般来说,乐观锁使用版本号(Versioning)或时间戳(Timestamping)机制来标识资源的状态。当两个事务冲突时,乐观锁会回滚其中一个事务,让其重新执行,或者抛出异常让应用程序处理冲突。
    乐观锁适用于并发冲突较少的场景,可以提高并发性能,因为它不会直接阻塞其他事务。但在高并发环境下,冲突的概率增加,可能需要多次重试,这可能会引入额外的开销。

  2. 悲观锁:
    悲观锁假设多个事务之间可能频繁发生冲突,因此在访问共享资源之前就对其进行加锁,阻塞其他事务的访问。悲观锁使用锁机制(如互斥锁)来保证资源的独占性,一旦事务获得了锁,其他事务就无法同时访问该资源。
    悲观锁适用于并发冲突较多的场景,可以确保数据的一致性,因为它阻塞了其他事务的操作。但是,在高并发环境下,悲观锁的竞争可能会导致大量的阻塞和等待,从而降低了系统的并发性能。

选择乐观锁还是悲观锁取决于具体的应用场景和并发冲突的频率。如果并发冲突较少且希望提高并发性能,可以选择乐观锁。如果并发冲突较多且需要确保数据一致性,可以选择悲观锁。同时,还可以结合两种锁策略,根据具体情况进行灵活选择和调整。