MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

操作系统互斥锁的实现细节

2022-04-271.9k 阅读

操作系统互斥锁的基本概念

在操作系统的并发编程场景中,多个进程或线程可能会同时访问共享资源。如果不对这种访问进行适当控制,就可能导致数据不一致、竞态条件等问题。互斥锁(Mutex,即 Mutual Exclusion 的缩写)是一种常用的同步机制,用于确保在同一时刻只有一个进程或线程能够访问共享资源,从而避免竞态条件的发生。

从本质上讲,互斥锁是一个二元信号量,它的值只能是 0 或 1。当互斥锁的值为 1 时,表示共享资源可用,进程或线程可以获取(acquire)该互斥锁,获取成功后互斥锁的值变为 0,其他进程或线程就无法再获取,直到持有互斥锁的进程或线程释放(release)它,此时互斥锁的值又变为 1。

互斥锁的实现基础:原子操作

原子操作的概念

要实现互斥锁,关键在于使用原子操作。原子操作是指不会被线程调度机制打断的操作,一旦开始执行,就会一直执行到结束,不会被其他线程干扰。在硬件层面,现代处理器提供了一些指令来支持原子操作,例如比较并交换(Compare - And - Swap,CAS)指令、测试并设置(Test - And - Set,TAS)指令等。

比较并交换(CAS)指令

  1. 指令原理 CAS 指令通常有三个操作数:内存位置(V)、预期原值(A)和新值(B)。它的操作过程是:首先读取内存位置 V 的值,然后将这个值与预期原值 A 进行比较。如果相等,就将内存位置 V 的值更新为新值 B;如果不相等,则不进行任何操作。整个过程是原子的,不会被中断。

  2. 代码示例(以 C 语言为例,假设使用 GCC 内置的原子操作函数)

#include <stdio.h>
#include <stdatomic.h>

atomic_int value = ATOMIC_VAR_INIT(0);

int cas_example() {
    int expected = 0;
    int new_value = 1;
    int result = atomic_compare_exchange_strong(&value, &expected, new_value);
    if (result) {
        printf("CAS 操作成功,值已更新为 %d\n", new_value);
    } else {
        printf("CAS 操作失败,当前值为 %d\n", expected);
    }
    return result;
}

测试并设置(TAS)指令

  1. 指令原理 TAS 指令用于将一个内存位置的值设置为 1,并返回该位置原来的值。这个操作也是原子的。例如,在互斥锁的实现中,可以用一个值为 0 的内存位置表示互斥锁未被占用,当一个线程执行 TAS 指令时,如果返回值为 0,说明互斥锁未被占用,该线程成功获取了互斥锁;如果返回值为 1,说明互斥锁已被占用,该线程获取失败。

  2. 代码示例(简单模拟 TAS 操作)

#include <stdio.h>

// 简单模拟 TAS 操作
int tas(int *lock) {
    int old_value = *lock;
    *lock = 1;
    return old_value;
}

int main() {
    int mutex = 0;
    int result1 = tas(&mutex);
    if (result1 == 0) {
        printf("线程 1 获取互斥锁成功\n");
    } else {
        printf("线程 1 获取互斥锁失败\n");
    }
    int result2 = tas(&mutex);
    if (result2 == 0) {
        printf("线程 2 获取互斥锁成功\n");
    } else {
        printf("线程 2 获取互斥锁失败\n");
    }
    return 0;
}

基于原子操作实现互斥锁

基于 TAS 指令的互斥锁实现

  1. 实现思路 使用一个整型变量作为互斥锁,初始值为 0 表示未锁定。当一个线程想要获取互斥锁时,执行 TAS 操作,如果返回值为 0,说明成功获取锁;如果返回值为 1,则表示锁已被占用,线程需要等待。等待的方式可以是循环重试,直到成功获取锁。

  2. 代码实现(以 C 语言为例)

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

// 简单模拟 TAS 操作
int tas(int *lock) {
    int old_value = *lock;
    *lock = 1;
    return old_value;
}

// 互斥锁结构体
typedef struct {
    int lock;
} Mutex;

// 初始化互斥锁
void mutex_init(Mutex *mutex) {
    mutex->lock = 0;
}

// 获取互斥锁
void mutex_lock(Mutex *mutex) {
    while (tas(&mutex->lock) != 0);
}

// 释放互斥锁
void mutex_unlock(Mutex *mutex) {
    mutex->lock = 0;
}

// 共享资源
int shared_resource = 0;

// 线程函数
void *thread_function(void *arg) {
    Mutex *mutex = (Mutex *)arg;
    mutex_lock(mutex);
    shared_resource++;
    printf("线程 %ld 增加共享资源,值为 %d\n", pthread_self(), shared_resource);
    mutex_unlock(mutex);
    return NULL;
}

int main() {
    Mutex mutex;
    mutex_init(&mutex);

    pthread_t threads[10];
    for (int i = 0; i < 10; i++) {
        pthread_create(&threads[i], NULL, thread_function, &mutex);
    }

    for (int i = 0; i < 10; i++) {
        pthread_join(threads[i], NULL);
    }

    return 0;
}

基于 CAS 指令的互斥锁实现

  1. 实现思路 同样使用一个整型变量作为互斥锁,初始值为 0。当线程获取互斥锁时,使用 CAS 指令尝试将互斥锁的值从 0 改为 1。如果 CAS 操作成功,说明获取锁成功;如果失败,说明锁已被占用,线程需要等待并重新尝试。

  2. 代码实现(以 C 语言为例,使用 GCC 内置的原子操作函数)

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

// 互斥锁结构体
typedef struct {
    atomic_int lock;
} Mutex;

// 初始化互斥锁
void mutex_init(Mutex *mutex) {
    atomic_store(&mutex->lock, 0);
}

// 获取互斥锁
void mutex_lock(Mutex *mutex) {
    while (1) {
        int expected = 0;
        if (atomic_compare_exchange_strong(&mutex->lock, &expected, 1)) {
            break;
        }
    }
}

// 释放互斥锁
void mutex_unlock(Mutex *mutex) {
    atomic_store(&mutex->lock, 0);
}

// 共享资源
int shared_resource = 0;

// 线程函数
void *thread_function(void *arg) {
    Mutex *mutex = (Mutex *)arg;
    mutex_lock(mutex);
    shared_resource++;
    printf("线程 %ld 增加共享资源,值为 %d\n", pthread_self(), shared_resource);
    mutex_unlock(mutex);
    return NULL;
}

int main() {
    Mutex mutex;
    mutex_init(&mutex);

    pthread_t threads[10];
    for (int i = 0; i < 10; i++) {
        pthread_create(&threads[i], NULL, thread_function, &mutex);
    }

    for (int i = 0; i < 10; i++) {
        pthread_join(threads[i], NULL);
    }

    return 0;
}

互斥锁实现中的性能优化

忙等待的问题与解决

  1. 忙等待的问题 在基于 TAS 或 CAS 实现的互斥锁中,当线程获取锁失败时,采用的是循环重试的方式,即忙等待(busy - waiting)。这种方式会浪费大量的 CPU 时间,因为线程在等待锁的过程中一直在占用 CPU 资源进行无效的循环。

  2. 解决方法 - 引入睡眠机制 一种解决忙等待问题的方法是引入睡眠机制。当线程获取锁失败时,不再进行忙等待,而是将自己挂起(睡眠),操作系统会将 CPU 资源分配给其他可运行的线程。当持有锁的线程释放锁后,可以通过某种方式唤醒等待的线程,让其重新尝试获取锁。在 Linux 系统中,可以使用 pthread_cond_waitpthread_cond_signal 函数来实现这种机制。

公平性问题与解决

  1. 公平性问题 在一些互斥锁实现中,可能会出现不公平的情况。例如,当多个线程同时等待锁时,先等待的线程不一定能先获取到锁,后到的线程可能因为运气好而先获取到锁,这可能导致某些线程长时间等待。

  2. 解决方法 - 使用队列 为了解决公平性问题,可以引入一个等待队列。当线程获取锁失败时,将其加入等待队列。当锁被释放时,从队列头部取出一个线程并唤醒它,让其获取锁。这样可以保证按照线程等待的先后顺序获取锁,提高公平性。

操作系统内核中的互斥锁实现

Linux 内核中的互斥锁实现

  1. 数据结构 在 Linux 内核中,互斥锁使用 struct mutex 结构体来表示。这个结构体包含了一些字段,如表示锁状态的 count 字段,用于记录等待线程的等待队列等。

  2. 获取与释放操作 获取锁的函数是 mutex_lock,它会首先检查锁的状态。如果锁可用(count 为 1),则将 count 减为 0 并获取锁;如果锁不可用,则将当前线程加入等待队列并进入睡眠状态。释放锁的函数是 mutex_unlock,它会将 count 加为 1,并唤醒等待队列中的一个线程。

Windows 内核中的互斥锁实现

  1. 数据结构 Windows 内核中的互斥对象使用 KMUTANT 结构体表示。它包含了一些成员,如当前拥有互斥对象的线程的句柄,等待线程的列表等。

  2. 获取与释放操作 获取互斥锁的函数如 KeWaitForSingleObject,当线程调用这个函数来获取互斥锁时,如果互斥锁可用,线程会获取锁并标记自己为锁的拥有者;如果不可用,线程会被放入等待队列并进入等待状态。释放互斥锁的函数如 KeReleaseMutant,它会将互斥锁标记为可用,并唤醒等待队列中的一个线程。

不同编程语言中的互斥锁实现

C++ 中的互斥锁

  1. C++ 标准库中的互斥锁 C++11 引入了 <mutex> 头文件,提供了多种互斥锁类型,如 std::mutexstd::recursive_mutex 等。std::mutex 是最基本的互斥锁,使用起来非常简单。例如:
#include <iostream>
#include <mutex>
#include <thread>

std::mutex mtx;
int shared_variable = 0;

void increment() {
    mtx.lock();
    shared_variable++;
    std::cout << "线程 " << std::this_thread::get_id() << " 增加共享变量,值为 " << shared_variable << std::endl;
    mtx.unlock();
}

int main() {
    std::thread threads[10];
    for (int i = 0; i < 10; i++) {
        threads[i] = std::thread(increment);
    }
    for (auto& th : threads) {
        th.join();
    }
    return 0;
}
  1. 高级互斥锁类型 std::recursive_mutex 允许同一个线程多次获取锁,不会造成死锁。例如,在递归函数中可能会用到这种互斥锁。

Java 中的互斥锁

  1. synchronized 关键字 在 Java 中,synchronized 关键字可以用来实现互斥锁的功能。它可以修饰方法或代码块。当一个线程进入被 synchronized 修饰的方法或代码块时,它会自动获取对象的锁,执行完后自动释放锁。例如:
public class SynchronizedExample {
    private static int sharedVariable = 0;

    public static synchronized void increment() {
        sharedVariable++;
        System.out.println("线程 " + Thread.currentThread().getName() + " 增加共享变量,值为 " + sharedVariable);
    }

    public static void main(String[] args) {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < 10; i++) {
            threads[i] = new Thread(() -> increment());
            threads[i].start();
        }
        for (Thread th : threads) {
            try {
                th.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
  1. ReentrantLock Java 还提供了 ReentrantLock 类,它提供了更灵活的锁操作,例如可以实现公平锁,并且可以中断等待锁的线程等。

互斥锁在实际应用中的场景

多线程数据库访问

在多线程应用程序访问数据库时,可能会有多个线程同时尝试对数据库进行读写操作。为了保证数据的一致性,需要使用互斥锁来控制对数据库连接或特定数据记录的访问。例如,在一个银行转账的操作中,多个线程可能同时尝试更新账户余额,使用互斥锁可以确保每次只有一个线程能够执行更新操作,避免出现数据不一致的情况。

共享内存管理

在操作系统中,多个进程可能共享一块内存区域。如果这些进程同时对共享内存进行读写操作,就需要使用互斥锁来保护共享内存。例如,在一个进程间通信的场景中,多个进程通过共享内存来交换数据,使用互斥锁可以防止数据冲突,保证数据的完整性。

互斥锁使用中的常见问题与避免方法

死锁问题

  1. 死锁的产生原因 死锁是指两个或多个进程或线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。例如,线程 A 获取了锁 1,然后尝试获取锁 2;而线程 B 获取了锁 2,然后尝试获取锁 1,这样就形成了死锁。

  2. 避免死锁的方法

  • 资源分配图算法:通过构建资源分配图,并使用算法(如死锁检测算法)来检测和避免死锁。
  • 破坏死锁的必要条件:死锁的产生需要四个必要条件,即互斥、占有并等待、不可剥夺和循环等待。可以通过破坏其中一个或多个条件来避免死锁。例如,采用资源分配的顺序规则,避免循环等待的情况。

锁粒度问题

  1. 锁粒度的概念 锁粒度指的是互斥锁所保护的资源范围大小。如果锁粒度太大,即保护的资源范围很广,可能会导致很多线程因为等待这个大锁而无法并发执行,降低系统的并发性能;如果锁粒度太小,虽然可以提高并发性能,但会增加锁的管理开销。

  2. 合理选择锁粒度的方法 需要根据具体的应用场景来合理选择锁粒度。例如,在对大量数据进行操作时,可以将数据进行分块,每个块使用一个单独的互斥锁,这样可以在保证数据一致性的前提下提高并发性能。同时,要综合考虑锁的创建、获取和释放的开销,以及对系统整体性能的影响。

在操作系统的并发编程中,互斥锁是一种非常重要的同步机制。深入理解其实现细节,能够帮助开发者编写出更高效、更健壮的并发程序。无论是在操作系统内核层面,还是在应用程序开发中,合理使用互斥锁并避免相关问题,对于提升系统性能和稳定性都具有关键意义。