【探索Linux】—— 强大的命令行工具 P.21(多线程 | 线程同步 | 条件变量 | 线程安全)

首页 linux 【探索Linux】—— 强大的命令行工具 P.21(多线程 | 线程同步 | 条件变量 | 线程安全)

引言

在上一篇文章中,我们详细探讨了多线程编程的基础概念,包括线程互斥、互斥锁以及死锁和资源饥饿等问题。我们了解到,在多线程环境下,为了防止数据竞争和保证程序的正确性,需要采用一定的同步机制来协调线程之间的执行顺序。本篇文章将继续深入探讨多线程编程中的另一组关键概念:线程同步、条件变量和线程安全。

在这篇文章中,我们将具体介绍线程同步的技术和模式,探讨条件变量的工作原理以及如何在实际编程中正确使用它们来避免竞态条件和提高程序效率。同时,我们还将分析线程安全的概念,并通过示例展示如何编写线程安全的代码,以确保多线程程序的可靠性和稳定性。随着对这些概念的深入理解,我们将能够更加熟练地掌握多线程编程,打造出更加健壮和高效的软件系统。
一、线程同步
1. 竞态条件的概念

竞态条件(Race Condition)是并发编程中的一个重要概念,它指的是程序的输出或行为依赖于事件或线程的时序。在多线程环境中,如果多个线程共享某些数据,并且它们试图同时读写这些数据而没有适当的同步机制来协调这些操作,就可能出现竞态条件。

简单来说,当两个或更多的线程访问共享数据,并且至少有一个线程在修改这些数据时,如果线程之间的执行顺序会影响最终的结果,那么就存在竞态条件。由于线程调度通常由操作系统进行,而且具有一定的随机性,因此竞态条件可能导致程序行为不可预测,有时候甚至非常难以复现和调试。

竞态条件的一个典型例子是“检查后行动”(check-then-act)操作,其中线程检查某个条件(如资源是否可用),然后基于这个条件采取行动。如果在检查和行动之间的时间窗口内,另一个线程改变了条件(如抢占了资源),那么第一个线程的行动可能基于错误的假设。

另一个常见的竞态条件是“读-改-写”(read-modify-write)操作,这涉及到读取一个变量的值,对其进行修改,然后写回新值。如果两个线程同时执行这样的操作,而且它们的读取和写入操作是交织在一起的,那么最终写回的值可能只反映了其中一个线程的修改,而另一个线程的修改则丢失了。

为了避免竞态条件,我们需要使用线程同步机制,如互斥锁、信号量、条件变量等,来确保在任何时刻只有一个线程能够访问临界区的代码。通过这种方式,可以序列化对共享资源的访问,从而避免不确定的时序和数据冲突,保证程序的正确性和稳定性。
2. 线程同步的概念

线程同步是指在多线程环境中,控制不同线程之间的执行顺序,确保它们能够有序地共享资源和协调工作的一系列机制和方法。当多个线程访问共享资源时,如果没有适当的同步,就可能发生竞态条件(Race Condition),导致数据不一致、程序错误甚至崩溃。

为了防止这些问题,线程同步提供了一种方式,使得在任何时刻只有一个线程可以访问到临界区(Critical Section)。临界区是指那些访问共享资源的代码段,这些资源可能是内存、文件或者其他外部状态。通过线程同步,我们可以确保每次只有一个线程可以操作临界区内的共享资源,从而避免非预期的交互和数据冲突。
二、条件变量

条件变量是一种同步原语,它用于线程间的通信,使得一个线程能够在某个特定条件不满足时挂起(等待),直到另一个线程更新了这个条件并通知等待的线程。条件变量通常与互斥锁(mutex)一起使用,以避免竞态条件,并确保数据的一致性。
1. 条件变量函数
⭕使用前提

在Linux环境下,使用条件变量相关的函数需要包含<pthread.h>头文件:

#include <pthread.h>

    1

<pthread.h> 头文件中定义了所有与POSIX线程相关的数据类型、函数原型和宏。这包括了条件变量的操作函数、互斥锁的操作函数以及线程创建和控制的函数。

当编译使用了 <pthread.h> 的程序时,通常需要链接线程库,这可以通过在编译命令中添加 -lpthread 选项来实现。例如:

gcc program.c -o program -lpthread

    1

这条命令会编译 program.c 文件,并将POSIX线程库链接到生成的可执行文件 program 中。
(1)初始化条件变量

在POSIX线程(pthreads)库中,条件变量可以通过两种方式进行初始化:

    静态初始化:使用预定义的宏 PTHREAD_COND_INITIALIZER 来初始化条件变量。这是在程序开始执行之前,即编译时期就已经完成的初始化。

    示例代码:

    pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
        1

    动态初始化:使用函数 pthread_cond_init() 在运行时动态地初始化条件变量。这种方式允许你指定条件变量的属性。

    示例代码:

    pthread_cond_t cond;
    int ret = pthread_cond_init(&cond, NULL); // 使用NULL作为属性参数,表示默认属性
    if (ret != 0)
    {
        // 错误处理
    }
        1
        2
        3
        4
        5
        6

在动态初始化的情况下,如果你想要设置特定的条件变量属性,可以创建一个 pthread_condattr_t 类型的变量,并使用 pthread_condattr_init() 和相关函数来设置所需的属性。之后,将这个属性变量传递给 pthread_cond_init() 函数。

不论是静态还是动态初始化,初始化后的条件变量都处于未信号化的状态,等待被 pthread_cond_signal() 或 pthread_cond_broadcast() 函数唤醒。
(2)等待条件满足

pthread_cond_wait 函数是POSIX线程库中用于等待条件变量的函数。它的作用是阻塞调用线程直到指定的条件变量被信号化。在等待期间,pthread_cond_wait 会自动释放与条件变量相关联的互斥锁,并且在条件变量被信号化后重新获取互斥锁。

pthread_cond_wait函数的原型:

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

    1

参数解释:

    cond:指向需要等待的条件变量的指针。
    mutex:指向当前线程已锁定的互斥锁的指针,必须在调用pthread_cond_wait之前由线程锁定。

返回值:

    如果函数成功,返回0。
    如果失败,将返回一个错误码(非零值)。

使用说明

    线程在调用pthread_cond_wait之前,必须确保已经锁定了mutex互斥锁。
    调用pthread_cond_wait后,线程会阻塞,并且mutex互斥锁被自动释放,以允许其他线程操作条件和互斥锁。
    当其他线程对条件变量调用pthread_cond_signal或pthread_cond_broadcast时,等待的线程会被唤醒。
    被唤醒的线程在返回前将重新获取mutex互斥锁。这意味着当pthread_cond_wait返回时,线程已经再次锁定了mutex。
    因为可能有多个线程在等待同一个条件变量,所以即使线程被唤醒,也不能假设条件已经满足。通常需要在循环中调用pthread_cond_wait来重新检查条件。

示例代码

// 假设已经声明并初始化了cond和mutex
pthread_mutex_lock(&mutex);
while (condition_is_not_met)
{
    pthread_cond_wait(&cond, &mutex);
}
// 此时condition_is_met为真,可以执行依赖于该条件的代码
pthread_mutex_unlock(&mutex);


在这个示例中,线程首先锁定互斥锁mutex,然后在一个循环中检查条件是否满足。如果条件不满足,线程调用pthread_cond_wait等待条件变量cond。当条件变量被其他线程信号化时,线程将被唤醒,并在重新获得互斥锁后继续执行。
(3)唤醒等待

pthread_cond_broadcast 和 pthread_cond_signal 函数都是用来唤醒等待特定条件变量的线程。它们的区别在于唤醒等待线程的数量。
pthread_cond_broadcast()

pthread_cond_broadcast 函数唤醒所有等待特定条件变量的线程。如果没有线程在等待,调用此函数不会有任何效果。

函数原型如下:

int pthread_cond_broadcast(pthread_cond_t *cond);

    1

参数解释:

    cond:指向需要广播信号的条件变量的指针。

返回值:

    如果函数成功,返回0。
    如果失败,将返回一个错误码(非零值)。

pthread_cond_signal()

与pthread_cond_broadcast不同,pthread_cond_signal函数只唤醒一个正在等待特定条件变量的线程。如果有多个线程在等待,系统选择一个线程唤醒。选择哪个线程通常取决于线程调度策略,程序员无法控制。

函数原型如下:

int pthread_cond_signal(pthread_cond_t *cond);

    1

参数解释:

    cond:指向需要发送信号的条件变量的指针。

返回值:

    如果函数成功,返回0。
    如果失败,将返回一个错误码(非零值)。

使用说明

    在调用pthread_cond_signal或pthread_cond_broadcast之前,通常需要锁定与条件变量相关联的互斥锁。
    调用这些函数后,互斥锁可以被释放,以便唤醒的线程可以继续执行。
    唤醒的线程将尝试重新获取互斥锁,一旦获取成功,它们就可以检查条件是否满足并继续执行。

示例代码

// 假设已经声明并初始化了cond和mutex
pthread_mutex_lock(&mutex);
// 更新条件并可能修改共享资源
condition_met = 1;
// 唤醒所有等待cond的线程
pthread_cond_broadcast(&cond);
// 或者,只唤醒至少一个等待cond的线程
// pthread_cond_signal(&cond);
pthread_mutex_unlock(&mutex);


在这个示例中,线程首先锁定互斥锁mutex,然后更新条件变量相关的条件。之后,使用pthread_cond_broadcast或pthread_cond_signal来唤醒等待该条件变量的线程。最后,线程解锁互斥锁。
(4)销毁条件变量

销毁条件变量是指在条件变量不再需要时,释放它所占用的资源。在POSIX线程(pthreads)库中,可以使用 pthread_cond_destroy 函数来销毁一个条件变量。

pthread_cond_destroy 函数的原型:

int pthread_cond_destroy(pthread_cond_t *cond);

    1

参数解释:

    cond:指向需要销毁的条件变量的指针。

返回值:

    如果函数成功,返回0。
    如果失败,将返回一个错误码(非零值)。

使用说明

    在调用 pthread_cond_destroy 之前,必须确保没有线程正在等待或即将等待条件变量。否则,行为是未定义的,并且可能会导致程序崩溃或其他错误。
    通常,在动态初始化的条件变量不再需要时调用 pthread_cond_destroy。对于静态初始化的条件变量,如果没有分配额外的资源,则可以不调用 pthread_cond_destroy。
    一旦条件变量被销毁,你应该避免再次使用它,除非它被重新初始化。

示例代码

// 假设 cond 是一个之前已经初始化的条件变量
int ret = pthread_cond_destroy(&cond);
if (ret != 0)
{
    // 错误处理
}


在这个示例中,cond 是一个先前已经初始化并且现在不再需要的条件变量。通过调用 pthread_cond_destroy 来销毁它,从而释放可能分配的资源。如果销毁过程中出现错误,可以根据返回的错误码进行相应的错误处理。
2. 条件变量使用规范

条件变量的运行机制基于两个主要操作:等待(wait)和通知(signal/broadcast)。
(1)条件变量的使用流程

    等待条件(Waiting for a Condition):
        线程首先获取与条件变量关联的互斥锁。
        线程检查某个条件是否满足。如果条件不满足,线程将进入等待状态,并且原子地释放互斥锁,这样其他线程就可以获取互斥锁来更改条件。
        当条件变量收到通知后,线程被唤醒,重新尝试获取互斥锁。一旦获取到锁,线程将再次检查条件是否满足,以防在等待期间条件发生了变化。

    通知等待线程(Notifying Waiting Threads):
        另一个线程在更改了条件后,会获取相同的互斥锁。
        在保持互斥锁的情况下,该线程更新条件。
        更新完毕后,线程通过条件变量发送通知,表示条件已经改变。通知操作有两种形式:
            signal:唤醒至少一个等待该条件变量的线程。
            broadcast:唤醒所有等待该条件变量的线程。

    重新检查条件(Rechecking the Condition):
        被唤醒的线程在从等待状态返回时需要重新获得之前释放的互斥锁。
        一旦锁被重新获得,线程应该再次检查条件,因为在多个线程等待相同条件的情况下,条件可能已经再次变为假。

(2)条件变量的使用注意事项

    使用条件变量时,应当始终与互斥锁配合使用,以防止竞态条件。
    必须在修改条件之前获取互斥锁,并在修改完毕后释放互斥锁。
    在等待条件变量时,程序应该处于循环中检查条件,即使被signal或broadcast唤醒,也应重新检查条件是否真正满足。
    条件变量的等待和通知操作必须在同一个互斥锁保护下进行,以确保数据的一致性。

3. 使用条件变量的示例

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

// 定义全局的条件变量和互斥锁
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

void *thread_function(void *arg)
{
    // 获取互斥锁
    pthread_mutex_lock(&mutex);
    // 等待条件变量
    pthread_cond_wait(&cond, &mutex);
    // 做一些工作...
    printf("Received signal\n");
    // 释放互斥锁
    pthread_mutex_unlock(&mutex);
    return NULL;
}

int main()
{
    pthread_t thread_id;
    // 创建新线程
    pthread_create(&thread_id, NULL, thread_function, NULL);
    // 做一些工作...
    sleep(1); // 等待一段时间,模拟工作
    // 发送信号给等待的线程
    pthread_cond_signal(&cond);
    // 等待线程结束
    pthread_join(thread_id, NULL);
    // 清理资源
    pthread_cond_destroy(&cond);
    pthread_mutex_destroy(&mutex);
    return 0;
}


这个示例中,主线程创建了一个新线程,并通过条件变量发送信号。新线程在接收到信号后开始执行打印操作。
三、线程安全
1. 概念

线程安全指的是当多个线程同时访问某个功能、对象或变量时,系统能够确保这个功能、对象或变量仍然能够如预期般正常工作。具体来说,一个线程安全的程序对于并发访问没有任何副作用,不会出现数据竞争、死锁等问题,可以正确地处理多线程同时访问的情况。
2. 常见的线程不安全的情况

线程不安全的情况通常发生在多个线程并发访问共享资源时,由于缺乏适当的同步或互斥机制,导致出现意外的结果。以下是一些常见的线程不安全的情况:

    竞态条件(Race Conditions):当多个线程试图同时访问和修改共享的数据,而没有足够的同步保护时,会导致竞态条件。这可能导致数据损坏或不一致的结果。

    数据竞争(Data Races):当至少两个线程同时访问相同的内存位置,其中至少一个是写操作时,且没有适当的同步时,就会发生数据竞争。这可能导致未定义的行为和程序崩溃。

    死锁(Deadlock):当两个或多个线程互相持有对方所需的资源,并且在等待对方释放资源时都不释放自己的资源时,就会产生死锁。这将导致多个线程永远无法继续执行。

    活锁(Livelock):类似于死锁,但线程们不断重试某个操作,却始终无法取得进展,导致系统无法正常工作。

    非原子操作:当一个操作需要多个步骤完成,而这些步骤中间被其他线程打断,可能导致数据状态处于不一致的状态。

    资源泄露:当线程在使用完资源后没有正确释放,导致资源泄露,可能最终耗尽系统资源。

    不一致的状态:当多个线程并发修改共享状态时,由于缺乏同步机制,可能导致状态变得不一致,违反程序的预期行为。

以上情况都代表了典型的线程不安全问题,编写多线程程序时需要格外注意避免这些问题的发生。为了解决这些问题,可以使用锁、原子操作、条件变量等同步机制来确保线程安全,以及遵循良好的并发编程实践。
3. 常见的线程安全的情况

    不可变对象(Immutable Objects):不可变对象在创建后无法被修改,因此多个线程同时访问不会引发线程安全问题。例如,Java中的String类就是不可变对象。

    线程本地存储(Thread-Local Storage):每个线程都有自己独立的变量副本,不会被其他线程共享,从而避免了线程安全问题。可以使用ThreadLocal类来实现线程本地存储。

    局部变量(Local Variables):局部变量是在每个线程的栈帧中创建的,每个线程拥有自己的副本,不存在线程安全问题。

    同步容器(Synchronized Containers):某些容器类(如Vector、Hashtable)提供了内部同步机制,可以安全地在多线程环境下使用。这些容器会确保对它们的操作是原子的,并且提供了线程安全的迭代器。

    并发容器(Concurrent Containers):Java中的ConcurrentHashMap、ConcurrentLinkedQueue等并发容器提供了高效的线程安全操作。它们使用了复杂的算法和数据结构来实现高性能的并发访问。

    使用互斥锁(Mutex)或同步机制:通过在多个线程访问共享资源时使用互斥锁、读写锁等同步机制,可以保证线程安全。这样在任意时刻只有一个线程能够访问共享资源。

    原子操作(Atomic Operations):某些编程语言提供了原子操作,这些操作是不可中断的,可以保证在多线程环境下的原子性。例如,Java中的AtomicInteger类提供了原子操作的整型变量。

    使用并发编程库和框架:一些现代编程语言和框架提供了丰富的并发编程工具和库,如Java中的java.util.concurrent包,可以更方便地实现线程安全。

4. 可重入与线程安全的关系(八股文)
(1)可重入与线程安全的联系

    函数是可重入的,那就是线程安全的。
    函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
    如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

(2)可重入与线程安全的区别

    可重入函数是线程安全函数的一种。
    线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
    如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。


125    2024-01-15 16:52:04