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

Linux C语言互斥锁实现线程同步的策略

2021-10-114.6k 阅读

1. 线程同步问题概述

在多线程编程中,线程同步是一个至关重要的问题。当多个线程同时访问和修改共享资源时,如果没有适当的同步机制,就可能导致数据竞争(Data Race)问题,进而引发程序出现不可预测的行为,比如数据损坏、程序崩溃等。

想象一下,多个线程同时对一个全局变量进行累加操作。如果没有同步机制,一个线程可能在读取该变量的值之后,还未来得及更新它,另一个线程又读取了相同的值,这样就会导致累加操作丢失,最终得到的结果并非预期值。

2. 互斥锁简介

互斥锁(Mutex,即 Mutual Exclusion 的缩写)是一种最基本的线程同步机制,用于保证在同一时刻只有一个线程能够访问共享资源,从而避免数据竞争。

从概念上讲,互斥锁就像是一把钥匙,当一个线程获取了这把钥匙(锁定互斥锁),它就可以进入临界区(访问共享资源的代码段)。在该线程释放钥匙(解锁互斥锁)之前,其他线程无法获取钥匙进入临界区。

在 Linux C 语言编程中,互斥锁相关的操作主要由 POSIX 线程库(pthread 库)提供支持。

3. 互斥锁的创建与初始化

在使用互斥锁之前,需要先创建并初始化它。在 POSIX 线程库中,可以使用 pthread_mutex_init 函数来完成这一操作。

#include <pthread.h>

// 定义一个互斥锁变量
pthread_mutex_t mutex;

int main() {
    // 初始化互斥锁
    int ret = pthread_mutex_init(&mutex, NULL);
    if (ret != 0) {
        perror("pthread_mutex_init");
        return 1;
    }

    // 后续代码...

    // 销毁互斥锁
    pthread_mutex_destroy(&mutex);
    return 0;
}

上述代码中,pthread_mutex_init 函数的第一个参数是指向互斥锁变量的指针,第二个参数是一个可选的属性指针,通常设置为 NULL,表示使用默认属性。

如果初始化成功,函数返回 0;否则返回一个非零错误码,通过 perror 函数可以打印出具体的错误信息。

4. 互斥锁的锁定与解锁

一旦互斥锁初始化完成,线程就可以通过 pthread_mutex_lock 函数来锁定互斥锁,进入临界区;通过 pthread_mutex_unlock 函数来解锁互斥锁,离开临界区。

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

// 定义共享资源
int shared_variable = 0;
// 定义一个互斥锁变量
pthread_mutex_t mutex;

void* increment(void* arg) {
    // 锁定互斥锁
    pthread_mutex_lock(&mutex);
    for (int i = 0; i < 1000000; ++i) {
        shared_variable++;
    }
    // 解锁互斥锁
    pthread_mutex_unlock(&mutex);
    return NULL;
}

int main() {
    // 初始化互斥锁
    pthread_mutex_init(&mutex, NULL);

    pthread_t thread1, thread2;
    // 创建线程1
    pthread_create(&thread1, NULL, increment, NULL);
    // 创建线程2
    pthread_create(&thread2, NULL, increment, NULL);

    // 等待线程1结束
    pthread_join(thread1, NULL);
    // 等待线程2结束
    pthread_join(thread2, NULL);

    printf("Final value of shared_variable: %d\n", shared_variable);

    // 销毁互斥锁
    pthread_mutex_destroy(&mutex);
    return 0;
}

在上述代码中,increment 函数是线程执行的函数体。在函数开始处,通过 pthread_mutex_lock 锁定互斥锁,这样其他线程就无法同时进入该临界区。在对共享变量 shared_variable 进行累加操作完成后,通过 pthread_mutex_unlock 解锁互斥锁,允许其他线程获取锁并访问共享资源。

main 函数中,创建了两个线程,它们都调用 increment 函数。如果没有互斥锁,两个线程同时对 shared_variable 进行累加操作,很可能会导致数据竞争,最终得到的结果是不确定的。而使用互斥锁后,就可以保证 shared_variable 的累加操作是正确的。

5. 互斥锁的错误处理

在使用互斥锁的过程中,错误处理是必不可少的。pthread_mutex_lockpthread_mutex_unlock 函数都可能返回错误码。

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

// 定义共享资源
int shared_variable = 0;
// 定义一个互斥锁变量
pthread_mutex_t mutex;

void* increment(void* arg) {
    int ret;
    // 锁定互斥锁
    ret = pthread_mutex_lock(&mutex);
    if (ret != 0) {
        errno = ret;
        perror("pthread_mutex_lock");
        return NULL;
    }
    for (int i = 0; i < 1000000; ++i) {
        shared_variable++;
    }
    // 解锁互斥锁
    ret = pthread_mutex_unlock(&mutex);
    if (ret != 0) {
        errno = ret;
        perror("pthread_mutex_unlock");
        return NULL;
    }
    return NULL;
}

int main() {
    int ret;
    // 初始化互斥锁
    ret = pthread_mutex_init(&mutex, NULL);
    if (ret != 0) {
        errno = ret;
        perror("pthread_mutex_init");
        return 1;
    }

    pthread_t thread1, thread2;
    // 创建线程1
    ret = pthread_create(&thread1, NULL, increment, NULL);
    if (ret != 0) {
        errno = ret;
        perror("pthread_create thread1");
        return 1;
    }
    // 创建线程2
    ret = pthread_create(&thread2, NULL, increment, NULL);
    if (ret != 0) {
        errno = ret;
        perror("pthread_create thread2");
        return 1;
    }

    // 等待线程1结束
    ret = pthread_join(thread1, NULL);
    if (ret != 0) {
        errno = ret;
        perror("pthread_join thread1");
        return 1;
    }
    // 等待线程2结束
    ret = pthread_join(thread2, NULL);
    if (ret != 0) {
        errno = ret;
        perror("pthread_join thread2");
        return 1;
    }

    printf("Final value of shared_variable: %d\n", shared_variable);

    // 销毁互斥锁
    ret = pthread_mutex_destroy(&mutex);
    if (ret != 0) {
        errno = ret;
        perror("pthread_mutex_destroy");
        return 1;
    }
    return 0;
}

在上述代码中,对 pthread_mutex_initpthread_mutex_lockpthread_mutex_unlockpthread_createpthread_join 等函数的返回值都进行了检查。如果函数返回非零错误码,通过 errnoperror 函数可以打印出具体的错误信息,方便调试和定位问题。

6. 死锁问题与避免

死锁是多线程编程中一个棘手的问题,它发生在两个或多个线程相互等待对方释放资源,从而导致所有线程都无法继续执行的情况。

考虑以下场景:线程 A 持有互斥锁 mutex1 并试图获取互斥锁 mutex2,而线程 B 持有互斥锁 mutex2 并试图获取互斥锁 mutex1,此时就会发生死锁。

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

// 定义两个互斥锁变量
pthread_mutex_t mutex1, mutex2;

void* thread_function1(void* arg) {
    // 锁定互斥锁1
    pthread_mutex_lock(&mutex1);
    printf("Thread 1 has locked mutex1\n");

    // 尝试锁定互斥锁2
    pthread_mutex_lock(&mutex2);
    printf("Thread 1 has locked mutex2\n");

    // 解锁互斥锁2
    pthread_mutex_unlock(&mutex2);
    // 解锁互斥锁1
    pthread_mutex_unlock(&mutex1);
    return NULL;
}

void* thread_function2(void* arg) {
    // 锁定互斥锁2
    pthread_mutex_lock(&mutex2);
    printf("Thread 2 has locked mutex2\n");

    // 尝试锁定互斥锁1
    pthread_mutex_lock(&mutex1);
    printf("Thread 2 has locked mutex1\n");

    // 解锁互斥锁1
    pthread_mutex_unlock(&mutex1);
    // 解锁互斥锁2
    pthread_mutex_unlock(&mutex2);
    return NULL;
}

int main() {
    // 初始化互斥锁1
    pthread_mutex_init(&mutex1, NULL);
    // 初始化互斥锁2
    pthread_mutex_init(&mutex2, NULL);

    pthread_t thread1, thread2;
    // 创建线程1
    pthread_create(&thread1, NULL, thread_function1, NULL);
    // 创建线程2
    pthread_create(&thread2, NULL, thread_function2, NULL);

    // 等待线程1结束
    pthread_join(thread1, NULL);
    // 等待线程2结束
    pthread_join(thread2, NULL);

    // 销毁互斥锁1
    pthread_mutex_destroy(&mutex1);
    // 销毁互斥锁2
    pthread_mutex_destroy(&mutex2);
    return 0;
}

在上述代码中,如果线程 1 先获取了 mutex1,线程 2 先获取了 mutex2,然后它们各自尝试获取对方持有的锁,就会陷入死锁。

为了避免死锁,可以遵循以下原则:

  • 按顺序加锁:所有线程都按照相同的顺序获取锁。例如,在上述例子中,如果所有线程都先获取 mutex1,再获取 mutex2,就可以避免死锁。
  • 使用超时机制:使用 pthread_mutex_timedlock 函数替代 pthread_mutex_lock 函数,设置一个超时时间。如果在规定时间内无法获取锁,线程可以放弃获取并采取其他措施,而不是一直等待。

7. 递归互斥锁

普通互斥锁如果被同一个线程多次锁定,会导致死锁。因为第二次锁定时,该线程已经持有锁,再次获取锁会被阻塞,从而导致自身死锁。

递归互斥锁(Recursive Mutex)则允许同一个线程对其进行多次锁定,每锁定一次,内部的引用计数就加 1,解锁时引用计数减 1,只有当引用计数为 0 时,互斥锁才真正被释放。

在 POSIX 线程库中,可以通过设置互斥锁的属性来创建递归互斥锁。

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

// 定义共享资源
int shared_variable = 0;
// 定义一个互斥锁变量
pthread_mutex_t mutex;
// 定义互斥锁属性变量
pthread_mutexattr_t attr;

void recursive_function(int count) {
    if (count <= 0) return;

    // 锁定互斥锁
    pthread_mutex_lock(&mutex);
    printf("Entering recursive_function with count %d\n", count);
    shared_variable++;
    recursive_function(count - 1);
    // 解锁互斥锁
    pthread_mutex_unlock(&mutex);
}

int main() {
    // 初始化互斥锁属性
    pthread_mutexattr_init(&attr);
    // 设置互斥锁属性为递归
    pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);

    // 初始化互斥锁
    pthread_mutex_init(&mutex, &attr);

    recursive_function(5);

    printf("Final value of shared_variable: %d\n", shared_variable);

    // 销毁互斥锁
    pthread_mutex_destroy(&mutex);
    // 销毁互斥锁属性
    pthread_mutexattr_destroy(&attr);
    return 0;
}

在上述代码中,recursive_function 是一个递归函数,它在每次递归调用时都会锁定互斥锁。由于使用的是递归互斥锁,不会发生死锁。

8. 读写锁与互斥锁的对比

读写锁(Read - Write Lock)是一种特殊的同步机制,它区分了读操作和写操作。允许多个线程同时进行读操作,因为读操作不会修改共享资源,不会导致数据竞争。但是,当有一个线程进行写操作时,其他线程无论是读操作还是写操作都必须等待,直到写操作完成。

相比之下,互斥锁则更为简单粗暴,无论是读操作还是写操作,都只允许一个线程进入临界区。

在一些读操作频繁而写操作较少的场景下,使用读写锁可以提高程序的并发性能。

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

// 定义共享资源
int shared_variable = 0;
// 定义读写锁变量
pthread_rwlock_t rwlock;

void* read_function(void* arg) {
    // 锁定读锁
    pthread_rwlock_rdlock(&rwlock);
    printf("Thread %ld is reading: %d\n", (long)pthread_self(), shared_variable);
    // 解锁读锁
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}

void* write_function(void* arg) {
    // 锁定写锁
    pthread_rwlock_wrlock(&rwlock);
    shared_variable++;
    printf("Thread %ld is writing: %d\n", (long)pthread_self(), shared_variable);
    // 解锁写锁
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}

int main() {
    // 初始化读写锁
    pthread_rwlock_init(&rwlock, NULL);

    pthread_t read_thread1, read_thread2, write_thread;
    // 创建读线程1
    pthread_create(&read_thread1, NULL, read_function, NULL);
    // 创建读线程2
    pthread_create(&read_thread2, NULL, read_function, NULL);
    // 创建写线程
    pthread_create(&write_thread, NULL, write_function, NULL);

    // 等待读线程1结束
    pthread_join(read_thread1, NULL);
    // 等待读线程2结束
    pthread_join(read_thread2, NULL);
    // 等待写线程结束
    pthread_join(write_thread, NULL);

    // 销毁读写锁
    pthread_rwlock_destroy(&rwlock);
    return 0;
}

在上述代码中,read_function 函数通过 pthread_rwlock_rdlock 锁定读锁,允许多个读线程同时执行。write_function 函数通过 pthread_rwlock_wrlock 锁定写锁,在写操作时会阻止其他读线程和写线程进入。

9. 条件变量与互斥锁的结合使用

条件变量(Condition Variable)通常与互斥锁一起使用,用于线程间的同步。它可以让线程在满足特定条件时才继续执行。

例如,假设有一个生产者 - 消费者模型,生产者线程生产数据并放入共享缓冲区,消费者线程从共享缓冲区取出数据。当缓冲区为空时,消费者线程需要等待生产者线程生产数据;当缓冲区满时,生产者线程需要等待消费者线程取出数据。

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

#define BUFFER_SIZE 5
// 定义共享缓冲区
int buffer[BUFFER_SIZE];
// 定义缓冲区索引
int in = 0;
int out = 0;
// 定义互斥锁变量
pthread_mutex_t mutex;
// 定义条件变量变量
pthread_cond_t cond_full;
pthread_cond_t cond_empty;

void* producer(void* arg) {
    for (int i = 0; i < 10; ++i) {
        // 锁定互斥锁
        pthread_mutex_lock(&mutex);
        while ((in + 1) % BUFFER_SIZE == out) {
            // 缓冲区满,等待
            pthread_cond_wait(&cond_empty, &mutex);
        }
        buffer[in] = i;
        printf("Produced: %d\n", buffer[in]);
        in = (in + 1) % BUFFER_SIZE;
        // 通知缓冲区非空
        pthread_cond_signal(&cond_full);
        // 解锁互斥锁
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

void* consumer(void* arg) {
    for (int i = 0; i < 10; ++i) {
        // 锁定互斥锁
        pthread_mutex_lock(&mutex);
        while (in == out) {
            // 缓冲区空,等待
            pthread_cond_wait(&cond_full, &mutex);
        }
        int data = buffer[out];
        printf("Consumed: %d\n", data);
        out = (out + 1) % BUFFER_SIZE;
        // 通知缓冲区非满
        pthread_cond_signal(&cond_empty);
        // 解锁互斥锁
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

int main() {
    // 初始化互斥锁
    pthread_mutex_init(&mutex, NULL);
    // 初始化条件变量
    pthread_cond_init(&cond_full, NULL);
    pthread_cond_init(&cond_empty, NULL);

    pthread_t producer_thread, consumer_thread;
    // 创建生产者线程
    pthread_create(&producer_thread, NULL, producer, NULL);
    // 创建消费者线程
    pthread_create(&consumer_thread, NULL, consumer, NULL);

    // 等待生产者线程结束
    pthread_join(producer_thread, NULL);
    // 等待消费者线程结束
    pthread_join(consumer_thread, NULL);

    // 销毁互斥锁
    pthread_mutex_destroy(&mutex);
    // 销毁条件变量
    pthread_cond_destroy(&cond_full);
    pthread_cond_destroy(&cond_empty);
    return 0;
}

在上述代码中,producer 函数和 consumer 函数通过 pthread_cond_wait 函数等待特定条件(缓冲区空或满)的改变。pthread_cond_wait 函数会自动解锁互斥锁,并将线程置于等待状态,当条件变量被 pthread_cond_signalpthread_cond_broadcast 唤醒时,线程会重新获取互斥锁并继续执行。

10. 互斥锁在实际项目中的应用场景

  • 数据库访问:在多线程应用程序中访问数据库时,多个线程可能同时请求对数据库进行读写操作。使用互斥锁可以保证在同一时刻只有一个线程能够执行数据库操作,避免数据不一致问题。
  • 文件操作:当多个线程需要同时对同一个文件进行读写时,互斥锁可以防止文件内容被破坏。例如,在日志记录场景中,多个线程可能需要向同一个日志文件中写入日志,通过互斥锁可以确保日志的完整性。
  • 共享内存访问:在使用共享内存进行进程间通信(IPC)时,多个进程(或线程)可能会访问共享内存区域。互斥锁可以用于同步对共享内存的访问,保证数据的一致性。

通过合理使用互斥锁以及其他同步机制,开发人员可以编写出高效、稳定的多线程程序,充分发挥多核处理器的性能优势,提升软件的整体性能和用户体验。同时,对于复杂的多线程场景,还需要综合考虑死锁避免、性能优化等多方面因素,确保程序的健壮性和可靠性。