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

Linux C语言条件变量实现线程同步

2021-04-064.9k 阅读

1. 线程同步基础概念

在多线程编程中,线程同步是一个至关重要的环节。多个线程共享相同的地址空间,它们可能同时访问和修改共享资源。如果不对线程的访问进行合理的控制,就会出现数据竞争(Data Race)问题,导致程序出现难以调试的错误。

1.1 数据竞争问题

假设有两个线程 thread1thread2 同时对一个共享变量 count 进行自增操作,代码可能如下:

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

int count = 0;

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

int main() {
    pthread_t tid1, tid2;
    pthread_create(&tid1, NULL, increment, NULL);
    pthread_create(&tid2, NULL, increment, NULL);

    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);

    printf("Final count: %d\n", count);
    return 0;
}

理论上,如果没有并发问题,count 的最终值应该是 2000000。但实际上,由于 count++ 操作不是原子的,它包含读取、自增和写入三个步骤,不同线程在这三个步骤之间可能会发生切换,导致数据竞争,使得最终的 count 值往往小于 2000000

1.2 同步机制的作用

为了解决数据竞争问题,我们需要引入同步机制。同步机制能够确保在同一时刻只有一个线程可以访问共享资源,或者按照特定的顺序访问共享资源。常见的同步机制有互斥锁(Mutex)、信号量(Semaphore)和条件变量(Condition Variable)等。

2. 互斥锁简介

在深入了解条件变量之前,先简单回顾一下互斥锁。互斥锁是一种最基本的同步工具,它就像一把锁,一次只能被一个线程持有。

2.1 互斥锁的操作

在 Linux C 语言中,使用 pthread_mutex_t 类型来表示互斥锁,相关操作函数有:

  • pthread_mutex_init:初始化互斥锁。
  • pthread_mutex_lock:加锁,如果锁已经被其他线程持有,则调用线程会阻塞等待。
  • pthread_mutex_unlock:解锁,释放锁资源,允许其他等待的线程获取锁。
  • pthread_mutex_destroy:销毁互斥锁,释放相关资源。

以下是使用互斥锁解决上述数据竞争问题的示例代码:

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

int count = 0;
pthread_mutex_t mutex;

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

int main() {
    pthread_t tid1, tid2;
    pthread_mutex_init(&mutex, NULL);

    pthread_create(&tid1, NULL, increment, NULL);
    pthread_create(&tid2, NULL, increment, NULL);

    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);

    pthread_mutex_destroy(&mutex);
    printf("Final count: %d\n", count);
    return 0;
}

通过在对 count 操作前后加锁和解锁,确保了同一时刻只有一个线程可以对 count 进行自增操作,从而避免了数据竞争。

3. 条件变量概述

虽然互斥锁可以解决数据竞争问题,但在某些场景下,仅仅使用互斥锁是不够的。例如,一个线程需要等待某个条件满足后才能继续执行,这时就需要条件变量。

3.1 条件变量的定义

条件变量是一种线程同步机制,它允许线程在某个条件满足时被唤醒。在 Linux C 语言中,使用 pthread_cond_t 类型来表示条件变量。

3.2 条件变量与互斥锁的关系

条件变量通常和互斥锁一起使用。互斥锁用于保护共享资源,而条件变量用于线程之间的通信,以等待某个条件的满足。一个线程在等待条件变量时,会先获取互斥锁,然后在条件变量上等待,等待过程中会自动释放互斥锁,这样其他线程就可以修改共享资源。当条件满足时,等待的线程会被唤醒,重新获取互斥锁,然后继续执行。

4. 条件变量的操作函数

4.1 pthread_cond_init

函数原型:

int pthread_cond_init(pthread_cond_t *restrict cond, const pthread_condattr_t *restrict attr);

作用:初始化一个条件变量。cond 是指向要初始化的条件变量的指针,attr 是条件变量的属性,通常可以设置为 NULL,表示使用默认属性。成功时返回 0,失败时返回错误码。

4.2 pthread_cond_destroy

函数原型:

int pthread_cond_destroy(pthread_cond_t *cond);

作用:销毁一个条件变量。在销毁之前,需要确保没有线程在该条件变量上等待。成功时返回 0,失败时返回错误码。

4.3 pthread_cond_wait

函数原型:

int pthread_cond_wait(pthread_cond_t *restrict cond, pthread_mutex_t *restrict mutex);

作用:该函数用于使调用线程等待在指定的条件变量上。调用此函数前,线程必须已经获取了 mutex 锁。函数执行时,线程会自动释放 mutex 锁,并进入等待状态。当条件变量被其他线程通过 pthread_cond_signalpthread_cond_broadcast 唤醒时,该线程会重新获取 mutex 锁,然后继续执行。成功时返回 0,失败时返回错误码。

4.4 pthread_cond_signal

函数原型:

int pthread_cond_signal(pthread_cond_t *cond);

作用:唤醒一个等待在指定条件变量上的线程。如果有多个线程在等待,通常是唤醒等待时间最长的那个线程。成功时返回 0,失败时返回错误码。

4.5 pthread_cond_broadcast

函数原型:

int pthread_cond_broadcast(pthread_cond_t *cond);

作用:唤醒所有等待在指定条件变量上的线程。成功时返回 0,失败时返回错误码。

5. 条件变量实现线程同步的示例

5.1 生产者 - 消费者模型

生产者 - 消费者模型是一个经典的多线程同步问题。生产者线程生成数据并放入缓冲区,消费者线程从缓冲区取出数据进行处理。在这个模型中,需要使用条件变量来协调生产者和消费者的工作。

以下是一个简单的生产者 - 消费者模型示例代码:

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

#define BUFFER_SIZE 5
int buffer[BUFFER_SIZE];
int in = 0;
int out = 0;
int count = 0;

pthread_mutex_t mutex;
pthread_cond_t cond_full;
pthread_cond_t cond_empty;

void* producer(void* arg) {
    int num = 0;
    while (1) {
        pthread_mutex_lock(&mutex);
        while (count == BUFFER_SIZE) {
            pthread_cond_wait(&cond_empty, &mutex);
        }
        buffer[in] = num++;
        printf("Produced: %d\n", buffer[in]);
        in = (in + 1) % BUFFER_SIZE;
        count++;
        pthread_cond_signal(&cond_full);
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

void* consumer(void* arg) {
    while (1) {
        pthread_mutex_lock(&mutex);
        while (count == 0) {
            pthread_cond_wait(&cond_full, &mutex);
        }
        int data = buffer[out];
        printf("Consumed: %d\n", data);
        out = (out + 1) % BUFFER_SIZE;
        count--;
        pthread_cond_signal(&cond_empty);
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

int main() {
    pthread_t tid1, tid2;
    pthread_mutex_init(&mutex, NULL);
    pthread_cond_init(&cond_full, NULL);
    pthread_cond_init(&cond_empty, NULL);

    pthread_create(&tid1, NULL, producer, NULL);
    pthread_create(&tid2, NULL, consumer, NULL);

    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);

    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond_full);
    pthread_cond_destroy(&cond_empty);
    return 0;
}

在上述代码中:

  • 生产者线程在缓冲区满时,通过 pthread_cond_wait 等待 cond_empty 条件变量,当缓冲区有空间时被唤醒继续生产。
  • 消费者线程在缓冲区空时,通过 pthread_cond_wait 等待 cond_full 条件变量,当缓冲区有数据时被唤醒继续消费。
  • 每次生产或消费后,通过 pthread_cond_signal 唤醒等待的线程。

5.2 更复杂的场景:多个生产者和多个消费者

下面是一个更复杂的示例,包含多个生产者和多个消费者:

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

#define BUFFER_SIZE 5
int buffer[BUFFER_SIZE];
int in = 0;
int out = 0;
int count = 0;

pthread_mutex_t mutex;
pthread_cond_t cond_full;
pthread_cond_t cond_empty;

void* producer(void* arg) {
    int id = *((int*)arg);
    int num = id * 100;
    while (1) {
        pthread_mutex_lock(&mutex);
        while (count == BUFFER_SIZE) {
            pthread_cond_wait(&cond_empty, &mutex);
        }
        buffer[in] = num++;
        printf("Producer %d produced: %d\n", id, buffer[in]);
        in = (in + 1) % BUFFER_SIZE;
        count++;
        pthread_cond_broadcast(&cond_full);
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

void* consumer(void* arg) {
    int id = *((int*)arg);
    while (1) {
        pthread_mutex_lock(&mutex);
        while (count == 0) {
            pthread_cond_wait(&cond_full, &mutex);
        }
        int data = buffer[out];
        printf("Consumer %d consumed: %d\n", id, data);
        out = (out + 1) % BUFFER_SIZE;
        count--;
        pthread_cond_broadcast(&cond_empty);
        pthread_mutex_unlock(&mutex);
    }
    return NULL;
}

int main() {
    pthread_t producers[3];
    pthread_t consumers[2];
    int producer_ids[3] = {1, 2, 3};
    int consumer_ids[2] = {1, 2};

    pthread_mutex_init(&mutex, NULL);
    pthread_cond_init(&cond_full, NULL);
    pthread_cond_init(&cond_empty, NULL);

    for (int i = 0; i < 3; ++i) {
        pthread_create(&producers[i], NULL, producer, &producer_ids[i]);
    }
    for (int i = 0; i < 2; ++i) {
        pthread_create(&consumers[i], NULL, consumer, &consumer_ids[i]);
    }

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

    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond_full);
    pthread_cond_destroy(&cond_empty);
    return 0;
}

在这个示例中:

  • 有多个生产者线程和多个消费者线程。
  • 生产者线程在生产数据后使用 pthread_cond_broadcast 唤醒所有等待的消费者线程,因为不知道哪个消费者线程需要数据。
  • 消费者线程在消费数据后使用 pthread_cond_broadcast 唤醒所有等待的生产者线程,因为不知道哪个生产者线程可以继续生产。

6. 条件变量使用中的注意事项

6.1 虚假唤醒问题

在使用 pthread_cond_wait 时,可能会出现虚假唤醒(Spurious Wakeup)的情况。即线程可能在没有调用 pthread_cond_signalpthread_cond_broadcast 的情况下被唤醒。为了避免虚假唤醒带来的问题,通常在 pthread_cond_wait 调用处使用循环检查条件,例如:

while (condition_not_met) {
    pthread_cond_wait(&cond, &mutex);
}

这样即使出现虚假唤醒,线程也会再次检查条件,只有当条件真正满足时才会继续执行。

6.2 死锁问题

在使用条件变量和互斥锁时,如果使用不当,容易出现死锁。例如,一个线程在等待条件变量时没有释放互斥锁,而其他线程又需要获取该互斥锁来修改条件变量,就会导致死锁。为了避免死锁,需要确保在调用 pthread_cond_wait 之前已经获取了互斥锁,并且在等待过程中会自动释放互斥锁。同时,在唤醒线程后,要确保正确地重新获取互斥锁。

6.3 条件变量的属性

在初始化条件变量时,可以通过 pthread_condattr_t 结构体设置条件变量的属性。例如,可以设置条件变量的时钟属性,决定使用哪个时钟源来测量等待时间。通常情况下,使用默认属性(即 attr 参数为 NULL)就可以满足大多数需求。但在一些特定场景下,如对时间精度要求较高的应用中,可能需要自定义条件变量的属性。

7. 总结条件变量在实际项目中的应用场景

7.1 服务器编程

在服务器端编程中,经常会遇到多个客户端连接到服务器的情况。服务器可能需要等待客户端发送请求,然后处理请求并返回响应。条件变量可以用于协调服务器线程和客户端请求处理线程之间的同步。例如,当有新的客户端连接请求到达时,服务器可以通过条件变量唤醒一个等待的处理线程来处理该请求。

7.2 资源管理

在一些资源有限的系统中,如内存管理、文件描述符管理等,条件变量可以用于协调线程对资源的获取和释放。例如,当所有的内存块都被占用时,申请内存的线程可以等待在条件变量上,直到有其他线程释放内存块,然后被唤醒并获取内存。

7.3 分布式系统

在分布式系统中,不同节点之间的通信和同步也可以使用条件变量的思想。虽然分布式系统通常使用更复杂的协议和机制,但在单个节点内部的多线程处理中,条件变量同样可以用于协调线程之间的操作,确保数据的一致性和系统的正确性。

通过合理使用条件变量,我们可以有效地解决多线程编程中的同步问题,提高程序的稳定性和性能。在实际应用中,需要根据具体的需求和场景,谨慎地设计和使用条件变量,避免出现各种潜在的问题。