文章 · 2025-04-28

多线程锁实现:原理剖析与工程实践指南

操作系统原语是操作系统提供的一组用于同步和互斥的基本功能,例如信号量(Semaphore)、互斥量(Mutex)和条件变量(Condition Variable)等。这类原语通常由操作系统内核实现。使用基于操作系统原语的锁时,线程可能需要在用户态和内核态之间切换,以借助内核来完成同步操作。换句话说,当一个线程尝试获取锁而该锁已被其它线程持有时,操作系统会将该线程阻塞挂起,等待锁可用后再将其唤醒继续执行。通过内核调度,操作系统原语能够保证互斥访问的同时,也避免了线程在锁被占用期间白白浪费CPU时间。

基于原子指令

原子指令是处理器提供的一类特殊指令,它们能够在一个指令周期内完成指定操作,并且执行过程中不会被中断。典型的原子指令包括原子加、原子减以及原子"比较并交换"(Compare-and-Swap,简称 CAS)等。利用这些底层指令,程序员可以在用户态实现锁机制,例如自旋锁(spinlock)就是一种典型基于原子操作的锁。

与操作系统原语实现的锁不同,基于原子指令的锁通常不会主动放弃CPU。线程在尝试获取这类锁时,如果锁目前被其他线程持有,调用原子指令的线程不会被阻塞,而是会反复尝试——这种行为称为忙等待(busy wait)。因此,自旋锁适用于锁占用时间很短的场景,因为等待锁的线程一直在消耗CPU运行。而对于占用时间较长的临界区,忙等待可能并不划算。

性能与适用场景

两种实现方式各有优劣,在性能表现上差异明显。

操作系统原语由于需要进入内核,在加锁和解锁时会产生系统调用和线程切换的开销。如果锁竞争不激烈(很少有线程因为获取不到锁而阻塞等待),这种开销通常可以接受;但在高度并发、频繁抢锁的情况下,不断的用户态/内核态切换可能成为性能瓶颈。操作系统原语的另一个优点是,其阻塞等待机制可以避免无谓的CPU占用:当线程A持有锁时,线程B如果请求相同的锁会被挂起,这样B不会占用CPU,等待A释放锁后再唤醒B。

原子指令实现的锁由于完全在用户态操作,省去了进入内核的成本,因此在低竞争的情况下性能非常高。然而,当许多线程竞争同一把锁时,问题也会出现:由于线程忙等待不会阻塞,它们会持续占用CPU反复尝试获取锁,导致CPU时间被大量消耗。同时,多核环境下频繁的原子操作会带来缓存一致性的开销——每次原子更新都需要在各个CPU核心的缓存之间同步数据。这会让原本很快的操作变得缓慢。例如,如果多个核心不停地对同一个共享变量执行原子加锁/解锁操作,缓存总线上的争用将使每次操作都可能耗费数百纳秒甚至更长时间。可见,过度使用基于原子指令的锁会引发处理器资源竞争,进而影响整体性能。

基于上述特性,在不同场景下应选择合适的锁实现:

CPU缓存一致性与内存屏障

无论使用哪种方式实现锁,都必须考虑内存可见性和指令有序性的问题。现代CPU为了提高性能,允许对内存操作进行乱序执行(Out-of-Order Execution),并采用多级缓存来加速内存访问。这意味着,一个线程对共享变量的写入操作,不一定能立刻被其他CPU核心上的线程看见;同时,处理器和编译器也可能在不影响单线程语义的前提下调整指令顺序。然而,这些优化在多线程场景下可能会引发意想不到的情况——例如线程A在释放锁之前更新了某个共享变量,而线程B在获取同一把锁进入临界区后却读不到线程A更新后的值。这可能是因为线程A的写入仍停留在它自己的CPU缓存中尚未对其他核心可见,或者因为指令重排导致写入操作的生效被延后。

为保证多线程程序的正确性,锁的实现中需要引入内存屏障(Memory Barrier)机制。内存屏障可以阻止特定的内存操作重排序,并确保缓存中的数据被及时刷新,使得在屏障之前发生的内存更新对于其他处理器核心是可见的。大多数高级语言的锁原语已经隐含了适当的内存屏障语义:例如,在 Java 中进入和退出同步块(synchronized)会建立"Happens-Before"(先行发生)关系,保证释放锁之前的修改对随后获得同一锁的线程可见;C++ 中的 std::atomic 默认使用顺序一致性(memory_order_seq_cst),确保原子操作在程序执行顺序上不会乱序。这些机制使开发者无需手动插入内存屏障指令。对于更底层的实现者来说,如果直接使用汇编级的原子指令来自行构建锁,则需要了解目标架构的内存模型,并在必要的地方显式加入屏障指令。例如,在某些弱内存序模型的CPU上,自行实现锁可能需要在解锁操作前后插入 mfence、sfence 等指令以保证内存可见性。而在x86这样内存序较强的架构上,大多数带lock前缀的原子指令(如lock cmpxchg)已经隐含执行了内存屏障的功能。

锁的优化策略

锁的实现和使用可以通过多种策略来优化性能。下面介绍几种常见的优化手段:

工程实践案例

下面通过不同语言的实际代码示例,来直观展示锁的实现方式和效果。

示例1:POSIX 互斥锁(C语言)

POSIX线程库(Pthreads)是 C 语言中用于多线程编程的标准库,提供了互斥锁、条件变量等一系列操作系统原语。下面的代码展示了如何使用 Pthreads 提供的互斥锁来保护临界区:

#include <pthread.h>
#include <stdio.h>

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
int counter = 0;

void *increment(void *arg) {
    for (int i = 0; i < 1000000; i++) {
        pthread_mutex_lock(&lock);
        counter++;
        pthread_mutex_unlock(&lock);
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;

    pthread_create(&t1, NULL, increment, NULL);
    pthread_create(&t2, NULL, increment, NULL);

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);

    printf("Counter: %d\n", counter);

    return 0;
}

在这个例子中,两个线程分别对全局变量 counter 进行一百万次递增操作。我们使用 pthread_mutex_lock(&lock) 和 pthread_mutex_unlock(&lock) 将 counter++ 的操作包裹起来,确保任意时刻只有一个线程可以修改 counter。如果一个线程已经持有锁,另一个线程在调用 pthread_mutex_lock 时会被阻塞挂起,直到锁被释放后才被唤醒继续执行。通过这种互斥锁机制,我们避免了竞争条件,使最终的计数结果正确。

示例2:C++11 原子自旋锁

C++11 引入了 头文件,提供了一组基于原子指令的操作接口。利用这些接口,我们可以很方便地实现自旋锁等同步结构。下面是一个使用 C++11 原子操作实现简易自旋锁的示例:

#include <atomic>
#include <iostream>
#include <thread>

std::atomic_flag lock = ATOMIC_FLAG_INIT;
int counter = 0;

void increment() {
    for (int i = 0; i < 1000000; i++) {
        while (lock.test_and_set(std::memory_order_acquire)) {
            // 自旋等待锁释放
        }
        counter++;
        lock.clear(std::memory_order_release);
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);

    t1.join();
    t2.join();

    std::cout << "Counter: " << counter << std::endl;
    return 0;
}

上述代码中,两个线程各自对全局变量 counter 执行一百万次加一操作。这里我们使用 std::atomic_flag 实现了一把简单的自旋锁:每次循环中,线程调用 lock.test_and_set(std::memory_order_acquire) 来尝试获取锁。如果锁已经被占用,则 test_and_set 会一直返回 true,导致 while 循环反复空转等待;一旦 test_and_set 返回 false,表示成功获得锁,线程便退出循环进入临界区执行 counter++,随后通过 lock.clear(std::memory_order_release) 释放锁。这样保证了任何时候只有一个线程在执行 counter++ 操作。需要注意,在锁被其他线程持有期间,当前线程会在 while 循环中忙等待。这种实现避免了线程切换的开销,但如果锁迟迟不释放,就会浪费大量CPU时间。

示例3:Java synchronized 关键字

Java 语言内置了关键字 synchronized 来支持同步,它可以用来修饰方法或代码块,确保同一时间至多有一个线程执行被保护的代码。下面的示例展示了使用 synchronized 方法实现线程安全的计数器:

public class Counter {
    private int count = 0;

    public synchronized void increment() {
        count++;
    }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000000; i++) {
                counter.increment();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000000; i++) {
                counter.increment();
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("Counter: " + counter.count);
    }
}

在这个例子中,我们将 increment() 方法声明为 synchronized。这样保证了同一时刻只能有一个线程进入该方法,其他线程若调用 increment() 将在入口处阻塞等待。因此,两个线程对 counter.count 的所有操作都会被串行化,确保最终结果正确无误。

当线程进入 increment() 方法时,Java 虚拟机(JVM)会尝试获取与 Counter 实例(即 this 对象)关联的内部锁。这个锁的实现机制依赖于操作系统原语和底层的原子操作相结合。在 HotSpot JVM 中,锁有不同的状态:首先会尝试使用轻量级锁(Thin Lock)通过CAS操作来竞争锁;如果检测到锁存在竞争,则升级为重量级锁(Fat Lock),此时线程会进入内核阻塞等待。下面摘自 HotSpot JVM 的源码片段,可以观察锁竞争时 JVM 的策略:

void ObjectSynchronizer::enter(Handle obj, BasicLock* lock, TRAPS) {
    if (UseBiasedLocking) {
        BiasedLocking::revoke_and_rebias(obj, false, THREAD);
        assert(!obj->mark()->has_bias_pattern(), "biases should be revoked by now");
    }
    slow_enter(obj, lock, THREAD);
}

void ObjectSynchronizer::slow_enter(Handle obj, BasicLock* lock, TRAPS) {
    // ... 省略其他代码

    if (mark->is_neutral()) {
        // 尝试使用轻量级锁
    } else if (mark->has_locker()) {
        // ... 省略其他代码
    } else {
        // 如果轻量级锁失败,则转为使用重量级锁(基于操作系统原语实现)
        ObjectSynchronizer::inflate(THREAD, obj())->enter(THREAD);
    }
}

从上述源码可以看出,JVM 在进入 synchronized 块时会先尝试偏向锁(Biased Locking,如果启用的话),接着尝试轻量级锁(针对"mark word"为 neutral 的情况),如果发现锁已经被其他线程持有(即存在 locker),则调用 inflate() 将锁膨胀为重量级锁并让线程进入阻塞等待。通过这种分层的锁实现,Java 在大多数无争用的情况下能够利用原子指令快速获得锁,一旦出现竞争则退化为操作系统调度线程,从而在性能和功能之间取得平衡。

示例4:Java AtomicInteger 原子类

Java 提供了 java.util.concurrent.atomic 包,其中包含了若干原子变量类,例如 AtomicInteger、AtomicLong 等。它们通过底层原子指令提供了一种 lock-free(无锁)方式来实现线程安全操作。下面的代码使用 AtomicInteger 实现了一个线程安全的计数器递增:

import java.util.concurrent.atomic.AtomicInteger;

public class Counter {
    private AtomicInteger count = new AtomicInteger(0);

    public void increment() {
        count.incrementAndGet();
    }

    public static void main(String[] args) throws InterruptedException {
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 1000000; i++) {
                counter.increment();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 1000000; i++) {
                counter.increment();
            }
        });

        t1.start();
        t2.start();
        t1.join();
        t2.join();

        System.out.println("Counter: " + counter.count);
    }
}

在这个例子中,两个线程分别对同一个 Counter 对象的 count 变量进行递增操作。通过调用 AtomicInteger 的 incrementAndGet() 方法,我们能够保证对 count 的加一操作是原子的(即线程安全的)。相比使用传统锁,原子类的操作完全在用户态完成,没有线程切换和上下文切换的开销,非常适合像这种简单的计数场景。

其实,AtomicInteger 的内部实现仍然依赖CAS等原子指令。下面是 AtomicInteger.incrementAndGet() 方法的源码简化:

public final int incrementAndGet() {
    for (;;) {
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next))
            return next;
    }
}

可以看到,incrementAndGet() 方法采用了一个自旋循环,不断尝试,直到通过 CAS 成功更新 count 值才返回。每次循环中,它先读取当前值,计算新值,然后使用 compareAndSet(current, next) 来尝试原子地更新,如果返回 false 表示期间有其他线程修改了值,那么循环会重试直到成功为止。compareAndSet 方法底层调用了处理器的原子指令,例如在 x86 架构上对应的是 lock cmpxchg 指令,从而保证比较和交换操作的原子性。

常见问题与注意事项

并发编程中,锁的使用还涉及一些常见的问题和需要注意的地方:

总结

多线程锁的实现有操作系统原语和原子指令两种主要途径,各有其适用场景和注意事项。操作系统原语提供了完善的功能(例如阻塞等待和各种同步原语),在处理复杂的线程同步问题时更为方便可靠,但由于涉及内核调度,其性能在高并发情况下可能受到限制。基于原子指令的锁在无锁争用时性能极佳,能够最大程度发挥硬件潜力,但对于复杂同步场景支持有限,而且如果使用不当(比如缺少必要的内存屏障或在不适合的场合忙等)也会带来问题。

在实际开发中,并没有"一招鲜"的锁方案,需要针对具体情况选择最合适的工具。如果只是实现简单的线程计数等,可直接使用原子变量来避免传统锁的开销;如果需要更复杂的互斥/同步控制,操作系统提供的 mutex、信号量或更高级的并发库(如 Java java.util.concurrent 包、C++ 等)通常是更稳妥的选择。很多现代编程语言和框架已经在内部结合使用了多种优化策略(如自旋+阻塞、偏向锁等)来提供开箱即用的高性能锁,我们大可遵循其默认实现。

理解锁的底层原理可以帮助我们写出更高效、更健壮的并发程序,但更重要的是遵循良好的并发编程实践:尽量减少不必要的锁、避免长时间持有锁、避免死锁和资源饥饿等问题。

参考资料

© 2026 Yuxu Ge ·