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

Linux C语言线程同步机制探讨

2022-10-286.2k 阅读

一、线程同步的概念与必要性

在多线程编程的环境中,线程同步是至关重要的。多个线程可能会同时访问共享资源,例如内存中的数据结构、文件描述符等。如果没有适当的同步机制,就可能会导致数据竞争(data race)和竞态条件(race condition)问题。

数据竞争指的是当多个线程同时访问和修改共享资源,并且这些访问没有通过合适的同步机制进行协调时,程序的行为将变得不可预测。例如,考虑两个线程同时对一个全局变量进行加1操作:

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

int global_variable = 0;

void* increment(void* arg) {
    for (int i = 0; i < 10000; i++) {
        global_variable++;
    }
    return NULL;
}

int main() {
    pthread_t thread1, thread2;

    pthread_create(&thread1, NULL, increment, NULL);
    pthread_create(&thread2, NULL, increment, NULL);

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    printf("Expected value: 20000, Actual value: %d\n", global_variable);

    return 0;
}

在上述代码中,理论上两个线程各对global_variable加10000次,最终结果应该是20000。但由于没有同步机制,实际运行结果往往小于20000,因为两个线程可能同时读取global_variable的值,然后各自进行加1操作,导致部分加1操作被覆盖。

竞态条件则是指程序的行为依赖于多个线程执行的相对时间顺序。如果这种顺序不可控,程序的结果就会出现不确定性。为了避免这些问题,我们需要线程同步机制来确保线程对共享资源的访问是有序和安全的。

二、互斥锁(Mutex)

2.1 互斥锁的原理

互斥锁(Mutual Exclusion,缩写为Mutex)是一种最基本的线程同步机制。它的作用类似于一把锁,一次只能有一个线程持有这把锁,其他线程若想访问共享资源,必须先获取这把锁。当线程使用完共享资源后,需要释放锁,以便其他线程可以获取。

在Linux C语言中,使用pthread_mutex_t类型来表示互斥锁。可以通过pthread_mutex_init函数来初始化互斥锁,pthread_mutex_lock函数来获取锁,pthread_mutex_unlock函数来释放锁,pthread_mutex_destroy函数来销毁互斥锁。

2.2 互斥锁的代码示例

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

int global_variable = 0;
pthread_mutex_t mutex;

void* increment(void* arg) {
    for (int i = 0; i < 10000; i++) {
        pthread_mutex_lock(&mutex);
        global_variable++;
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

int main() {
    pthread_t thread1, thread2;

    pthread_mutex_init(&mutex, NULL);

    pthread_create(&thread1, NULL, increment, NULL);
    pthread_create(&thread2, NULL, increment, NULL);

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    pthread_mutex_destroy(&mutex);

    printf("Expected value: 20000, Actual value: %d\n", global_variable);

    return 0;
}

在这个改进后的代码中,每个线程在访问global_variable之前,先通过pthread_mutex_lock获取互斥锁,访问结束后通过pthread_mutex_unlock释放锁。这样就保证了同一时间只有一个线程能够修改global_variable,从而避免了数据竞争问题,最终结果将是20000。

2.3 互斥锁的注意事项

  1. 死锁问题:如果一个线程获取了互斥锁但没有释放,或者多个线程以不同顺序获取多个互斥锁,就可能导致死锁。例如:
#include <stdio.h>
#include <pthread.h>

pthread_mutex_t mutex1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutex2 = PTHREAD_MUTEX_INITIALIZER;

void* thread_function1(void* arg) {
    pthread_mutex_lock(&mutex1);
    pthread_mutex_lock(&mutex2);
    // 执行一些操作
    pthread_mutex_unlock(&mutex2);
    pthread_mutex_unlock(&mutex1);
    return NULL;
}

void* thread_function2(void* arg) {
    pthread_mutex_lock(&mutex2);
    pthread_mutex_lock(&mutex1);
    // 执行一些操作
    pthread_mutex_unlock(&mutex1);
    pthread_mutex_unlock(&mutex2);
    return NULL;
}

int main() {
    pthread_t thread1, thread2;

    pthread_create(&thread1, NULL, thread_function1, NULL);
    pthread_create(&thread2, NULL, thread_function2, NULL);

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    pthread_mutex_destroy(&mutex1);
    pthread_mutex_destroy(&mutex2);

    return 0;
}

在上述代码中,thread_function1先获取mutex1,然后尝试获取mutex2,而thread_function2先获取mutex2,然后尝试获取mutex1。如果thread_function1获取了mutex1,同时thread_function2获取了mutex2,两个线程就会互相等待对方释放锁,从而导致死锁。

  1. 性能问题:虽然互斥锁可以保证数据的一致性,但过多地使用互斥锁会降低程序的性能,因为线程获取锁和释放锁都需要一定的开销。在一些高并发场景下,频繁的锁竞争可能会成为性能瓶颈。

三、读写锁(Read - Write Lock)

3.1 读写锁的原理

读写锁是一种特殊的同步机制,它区分了读操作和写操作。允许多个线程同时进行读操作,因为读操作不会修改共享资源,所以不会产生数据竞争。但是,当有一个线程进行写操作时,其他线程既不能进行读操作也不能进行写操作,以保证数据的一致性。

在Linux C语言中,使用pthread_rwlock_t类型来表示读写锁。通过pthread_rwlock_init函数初始化读写锁,pthread_rwlock_rdlock函数获取读锁,pthread_rwlock_wrlock函数获取写锁,pthread_rwlock_unlock函数释放锁,pthread_rwlock_destroy函数销毁读写锁。

3.2 读写锁的代码示例

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

int shared_data = 0;
pthread_rwlock_t rwlock;

void* reader(void* arg) {
    pthread_rwlock_rdlock(&rwlock);
    printf("Reader read data: %d\n", shared_data);
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}

void* writer(void* arg) {
    pthread_rwlock_wrlock(&rwlock);
    shared_data++;
    printf("Writer incremented data to: %d\n", shared_data);
    pthread_rwlock_unlock(&rwlock);
    return NULL;
}

int main() {
    pthread_t reader_thread1, reader_thread2, writer_thread;

    pthread_rwlock_init(&rwlock, NULL);

    pthread_create(&reader_thread1, NULL, reader, NULL);
    pthread_create(&reader_thread2, NULL, reader, NULL);
    pthread_create(&writer_thread, NULL, writer, NULL);

    pthread_join(reader_thread1, NULL);
    pthread_join(reader_thread2, NULL);
    pthread_join(writer_thread, NULL);

    pthread_rwlock_destroy(&rwlock);

    return 0;
}

在上述代码中,reader线程通过pthread_rwlock_rdlock获取读锁,可以同时有多个reader线程读取shared_data。而writer线程通过pthread_rwlock_wrlock获取写锁,在写操作期间,其他线程无法进行读写操作。

3.3 读写锁的适用场景

读写锁适用于读操作频繁而写操作相对较少的场景。例如,在数据库系统中,大量的查询操作(读操作)可以同时进行,而数据更新操作(写操作)则需要保证原子性,以避免数据不一致。

四、条件变量(Condition Variable)

4.1 条件变量的原理

条件变量是一种线程同步机制,它允许线程在某个条件满足时被唤醒。条件变量通常与互斥锁一起使用。一个线程可以在某个条件不满足时,通过条件变量进入等待状态,并释放它持有的互斥锁。当另一个线程改变了相关条件后,通过条件变量唤醒等待的线程,等待的线程被唤醒后重新获取互斥锁,然后检查条件是否满足。

在Linux C语言中,使用pthread_cond_t类型来表示条件变量。通过pthread_cond_init函数初始化条件变量,pthread_cond_wait函数使线程等待条件变量,pthread_cond_signal函数唤醒一个等待的线程,pthread_cond_broadcast函数唤醒所有等待的线程,pthread_cond_destroy函数销毁条件变量。

4.2 条件变量的代码示例

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

int data_ready = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

void* producer(void* arg) {
    sleep(2);
    pthread_mutex_lock(&mutex);
    data_ready = 1;
    printf("Producer set data ready\n");
    pthread_cond_signal(&cond);
    pthread_mutex_unlock(&mutex);
    return NULL;
}

void* consumer(void* arg) {
    pthread_mutex_lock(&mutex);
    while (!data_ready) {
        printf("Consumer waiting for data...\n");
        pthread_cond_wait(&cond, &mutex);
    }
    printf("Consumer got data\n");
    pthread_mutex_unlock(&mutex);
    return NULL;
}

int main() {
    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);

    return 0;
}

在上述代码中,consumer线程在data_ready为0时,通过pthread_cond_wait等待条件变量,并释放mutexproducer线程在设置data_ready为1后,通过pthread_cond_signal唤醒consumer线程。consumer线程被唤醒后重新获取mutex,检查data_ready是否满足条件。

4.3 条件变量的注意事项

  1. 虚假唤醒:在某些操作系统中,pthread_cond_wait可能会出现虚假唤醒的情况,即没有调用pthread_cond_signalpthread_cond_broadcastpthread_cond_wait也可能返回。因此,在使用pthread_cond_wait时,应该在循环中检查条件,如上述代码中的while (!data_ready)
  2. 条件变量与互斥锁的配合:条件变量必须与互斥锁一起使用,且在调用pthread_cond_wait之前必须先获取互斥锁,在pthread_cond_wait内部会自动释放互斥锁并进入等待状态,被唤醒后又会自动获取互斥锁。

五、信号量(Semaphore)

5.1 信号量的原理

信号量是一个整型变量,它可以用来控制对共享资源的访问数量。信号量的值表示可用资源的数量。当一个线程想要访问共享资源时,它需要先获取信号量(将信号量的值减1)。如果信号量的值为0,表示没有可用资源,线程将被阻塞,直到信号量的值大于0。当线程使用完共享资源后,需要释放信号量(将信号量的值加1)。

在Linux C语言中,可以使用sem_t类型来表示信号量。通过sem_init函数初始化信号量,sem_wait函数获取信号量,sem_post函数释放信号量,sem_destroy函数销毁信号量。

5.2 信号量的代码示例

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

sem_t semaphore;
int shared_counter = 0;

void* increment(void* arg) {
    sem_wait(&semaphore);
    shared_counter++;
    printf("Incremented counter: %d\n", shared_counter);
    sem_post(&semaphore);
    return NULL;
}

int main() {
    pthread_t thread1, thread2;

    sem_init(&semaphore, 0, 1);

    pthread_create(&thread1, NULL, increment, NULL);
    pthread_create(&thread2, NULL, increment, NULL);

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    sem_destroy(&semaphore);

    return 0;
}

在上述代码中,信号量初始值为1,表示只有一个共享资源可用。increment线程在访问shared_counter之前,先通过sem_wait获取信号量,访问结束后通过sem_post释放信号量,从而保证同一时间只有一个线程能够修改shared_counter

5.3 信号量的适用场景

信号量不仅可以用于实现互斥(将信号量初始值设为1),还可以用于控制对多个共享资源的访问。例如,在一个线程池中有多个线程,而任务队列的长度有限,此时可以使用信号量来控制任务的提交数量,避免任务队列溢出。

六、屏障(Barrier)

6.1 屏障的原理

屏障是一种线程同步机制,它允许一组线程在某个点上同步。所有线程到达屏障点后,必须等待其他所有线程也到达该点,然后才能继续执行。这在需要多个线程完成各自的部分工作,然后再一起进行下一步操作的场景中非常有用。

在Linux C语言中,可以使用pthread_barrier_t类型来表示屏障。通过pthread_barrier_init函数初始化屏障,pthread_barrier_wait函数使线程等待屏障,pthread_barrier_destroy函数销毁屏障。

6.2 屏障的代码示例

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

pthread_barrier_t barrier;

void* thread_function(void* arg) {
    int thread_id = *((int*)arg);
    printf("Thread %d started\n", thread_id);
    sleep(thread_id);
    printf("Thread %d reached barrier\n", thread_id);
    pthread_barrier_wait(&barrier);
    printf("Thread %d passed barrier\n", thread_id);
    return NULL;
}

int main() {
    pthread_t threads[3];
    int thread_ids[3] = {1, 2, 3};

    pthread_barrier_init(&barrier, NULL, 3);

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

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

    pthread_barrier_destroy(&barrier);

    return 0;
}

在上述代码中,三个线程分别模拟不同的工作,它们到达pthread_barrier_wait处时会等待其他线程也到达。当所有三个线程都到达后,它们会一起通过屏障并继续执行后续代码。

6.3 屏障的适用场景

屏障常用于并行计算中,例如在矩阵乘法中,每个线程负责计算矩阵的一部分,当所有线程完成自己的计算部分后,需要通过屏障同步,然后才能进行下一步的结果合并操作。

七、线程同步机制的选择与优化

7.1 选择合适的同步机制

  1. 互斥锁:适用于需要保证共享资源每次只有一个线程访问的场景,简单直接,但性能开销在高并发时可能较大。
  2. 读写锁:当读操作远多于写操作时,读写锁可以显著提高性能,因为它允许多个读操作同时进行。
  3. 条件变量:适用于线程需要等待某个条件满足才能继续执行的场景,通常与互斥锁配合使用。
  4. 信号量:可用于控制对多个共享资源的访问数量,比互斥锁更灵活,也可用于实现简单的互斥。
  5. 屏障:适用于多个线程需要在某个点同步,然后一起进行下一步操作的场景。

7.2 优化线程同步

  1. 减少锁的粒度:尽量缩小持有锁的代码范围,只在访问共享资源的关键部分加锁,这样可以减少锁竞争的时间。
  2. 锁的层次化管理:对于复杂的多线程程序,采用层次化的锁管理方式,避免死锁的发生。例如,可以按照一定的顺序获取多个锁,释放锁时也按照相反的顺序。
  3. 使用无锁数据结构:在某些场景下,无锁数据结构可以避免锁带来的性能开销。例如,使用原子操作实现的无锁队列,适用于高并发的生产者 - 消费者场景。

通过合理选择和优化线程同步机制,可以提高多线程程序的性能和稳定性,充分发挥多核处理器的优势。在实际编程中,需要根据具体的应用场景和性能需求,灵活运用各种线程同步机制。