【探索Linux】P.20(多线程 | 线程互斥 | 互斥锁 | 死锁 | 资源饥饿)

简介: 【探索Linux】P.20(多线程 | 线程互斥 | 互斥锁 | 死锁 | 资源饥饿)

引言

在上一篇文章中,我们对多线程编程的基础知识进行了深入的探讨,包括了线程的概念、线程控制以及分离线程等关键点。通过这些内容的学习,我们已经能够理解并实现简单的多线程程序。然而,随着程序复杂度的提升,仅仅掌握这些基础是远远不够的。在多线程环境下,数据的共享和访问管理变得尤为重要,否则就可能会遇到数据竞争和一致性问题,导致程序运行出错甚至崩溃。

因此,在本篇文章中,我们将继续深入探讨多线程编程中至关重要的几个概念:线程互斥、互斥锁、死锁以及资源饥饿等问题。这些概念是保证多线程程序正确运行的基石。通过本篇文章的学习,你将能够更加深入地理解多线程编程中的高级话题,从而编写出更加健壮和高效的多线程应用程序。

一、进程线程间互斥的相关概念

1. 线程互斥

线程互斥(Thread Mutex)是多线程编程中的一个核心概念,它指的是在任何时刻只允许一个线程访问某个共享资源或执行某段代码,以此来防止多个线程同时访问同一资源时可能引发的冲突和数据不一致问题。

在没有适当同步机制的情况下,当多个线程并发读写同一块内存区域(比如共享变量、数据结构等)时,就可能发生数据竞争(Race Condition)。数据竞争会导致程序的运行结果不可预测,甚至引起程序崩溃。为了避免这种情况,需要引入线程互斥机制来确保线程对共享资源的独占访问。

正确理解和应用线程互斥对于开发安全、可靠的多线程程序至关重要。在接下来的内容中,我们将进一步深入探讨互斥锁的具体使用方法以及如何在实际编程中有效地管理线程间的同步问题。

2. 临界资源 & 临界区

在多线程编程中,临界资源(Critical Resource)和临界区(Critical Section)是两个密切相关的概念,它们都与线程同步和互斥有关。

(1)临界资源

临界资源是指在多线程环境中可以被多个线程共享访问的资源,但是在任何时刻只能由一个线程使用的资源。这些资源通常包括内存、文件、数据库连接以及任何形式的数据结构等。如果不对这些资源的访问进行适当的管理和同步,就可能导致数据竞争问题,从而使程序的行为变得不可预测甚至错误。

(2)临界区

临界区是指一段访问临界资源的代码,这段代码必须被互斥执行,以确保同一时间内只有一个线程能够执行这段代码。换句话说,临界区是一段实现对临界资源访问控制的代码。当一个线程进入临界区时,它会对所需的临界资源进行操作,此时其他线程必须等待,直到该线程离开临界区并释放了对资源的控制。

⭕为了保护临界区,避免多个线程同时执行临界区内的代码,通常会使用锁(如互斥锁)或其他同步机制。当线程尝试进入临界区时,它必须首先获得锁,这样可以保证在它持有锁的期间内没有其他线程可以进入临界区。完成对临界资源的操作后,线程会释放锁,这样其他线程就有机会获取锁并进入临界区。

3. 原子性

在Linux和其他操作系统中,原子性(Atomicity)是指一个操作或一组操作要么全部执行并完成,要么完全不执行,不会出现部分执行的情况。这些操作在执行过程中不会被其他线程或进程打断,即使在多线程或多进程的环境下也是如此

原子操作的重要性在于它们可以在无需使用锁或其他同步机制的情况下安全地更新数据。因为原子操作不会被线程调度器打断,所以它们不会引发数据竞争问题。这使得原子操作成为实现多线程编程中某些类型的线程安全的关键工具。

二、互斥锁

1. 互斥量 mutex

互斥量(Mutex,是 Mutual Exclusion 的缩写)是一种用于多线程编程中保证多个线程不会同时访问共享资源的同步机制。互斥量用来保护临界区,以确保在任何时刻只有一个线程能进入临界区执行代码或操作共享资源。


⭕互斥量的基本操作包括锁定(locking)和解锁(unlocking):

  1. 锁定(Lock):当一个线程需要访问共享资源时,它会尝试锁定与该资源关联的互斥量。如果互斥量已被其他线程锁定,那么尝试锁定的线程将被阻塞,直到互斥量被解锁。如果互斥量未被锁定,则当前线程锁定它,并继续执行。
  2. 解锁(Unlock):当线程完成对共享资源的操作后,它应该解锁之前锁定的互斥量,允许其他线程有机会锁定互斥量并访问共享资源。

2. 互斥量的接口

在Linux中,互斥量通常通过POSIX线程库(pthread)来实现。下面是一些基本的pthread互斥量操作函数

(1)初始化互斥量

在多线程编程中,初始化互斥量是创建和使用互斥量的第一步。它涉及设置互斥量对象的初始状态,使其准备好被线程锁定和解锁。在POSIX线程(pthread)库中,互斥量的初始化可以通过两种方式进行:静态初始化和动态初始化

⭕静态初始化

静态初始化是指在编译时期为变量分配初始值的过程。对于互斥量而言,静态初始化意味着在程序编译时就给互斥量赋予了一个已知的初始状态,这个状态表明互斥量是未锁定的,并且准备好被线程使用。

在POSIX线程(pthread)库中,可以使用宏PTHREAD_MUTEX_INITIALIZER对互斥量进行静态初始化。这个宏为互斥量提供了默认的属性值,确保了互斥量在使用前已经处于一个有效的状态。使用静态初始化时,不需要调用初始化函数,也就是说,不需要在运行时显式地调用pthread_mutex_init。

下面是一个互斥量静态初始化的例子:

#include <pthread.h>

// 静态初始化一个互斥量
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

在上述代码中,mutex是一个全局变量,它在声明的同时被初始化。由于它是静态初始化的,因此在程序开始执行之前,mutex就已经是可用的了。

静态初始化的优点是简单和易于使用,特别是当你有全局互斥量或者在模块范围内的互斥量时。它避免了动态初始化可能引入的额外运行时开销,并且不需要担心忘记初始化互斥量。

然而,静态初始化也有其局限性。它只能用于默认属性的互斥量。如果你需要设置特定的互斥量属性,如调整互斥量的类型(例如使其成为递归互斥量或错误检查互斥量),那么你必须使用动态初始化。

此外,静态初始化的互斥量没有销毁的概念,因为它们通常与程序的生命周期一样长。但是,如果互斥量是以动态方式分配的(例如,通过malloc函数),即使它是静态初始化的,你仍然需要在不再需要时释放它所占用的内存。

⭕动态初始化

动态初始化是指在程序运行时(而非编译时)初始化变量或对象的过程。对于互斥量而言,动态初始化通常是通过调用特定的初始化函数来完成的,这允许程序员为互斥量设置特定的属性。

在POSIX线程(pthread)库中,动态初始化互斥量是通过pthread_mutex_init函数实现的。这个函数可以让你指定互斥量的属性,并在运行时初始化互斥量。如果你不需要设置特殊的属性,也可以传递NULL作为属性参数,这样互斥量将被初始化为默认属性。

✅pthread_mutex_init() 函数
🍁头文件

pthread_mutex_init() 函数的头文件是 <pthread.h>。该头文件是 POSIX 线程库的头文件,其中包含了多线程编程所需的函数、类型和常量的声明和定义。

要在程序中使用 pthread_mutex_init() 函数,需要在源代码文件的开头添加以下代码:

#include <pthread.h>

<pthread.h> 头文件提供了 POSIX 线程库的函数原型、宏定义和相关数据类型的声明。通过包含该头文件,可以在程序中使用各种多线程编程相关的函数、常量和类型,包括 pthread_mutex_t 类型和 pthread_mutex_init() 函数。

在使用 GCC 编译器时,可以通过添加 -pthread 参数来链接线程库,例如:

gcc myprogram.c -pthread -o myprogram

上述命令中,-pthread 参数告诉编译器链接 POSIX 线程库。

🍁函数原型

pthread_mutex_init() 函数是 POSIX 线程库中用于动态初始化互斥量的函数。它的原型如下:

int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
🍁参数解释

该函数用于初始化一个互斥量,并将其属性设置为 attr 所指定的属性。参数说明如下:

  • mutex:一个指向要初始化的互斥量的指针,初始化后的互斥量将存储在这个指针指向的内存位置。
  • attr:一个指向互斥量属性的指针,可以设置互斥量的特定属性。通常传递 NULL表示互斥量将使用默认属性进行初始化。
🍁返回值

pthread_mutex_init() 函数返回一个整数值,表示函数执行的结果。如果初始化成功,返回值为 0;如果出现错误,返回值将是一个非零的错误码

🍁使用示例

以下是一个示例代码,展示了如何使用 pthread_mutex_init() 函数动态初始化互斥量:

#include <pthread.h>

// 声明一个互斥量
pthread_mutex_t mutex;

// 定义一个互斥量属性变量
pthread_mutexattr_t attr;

// 初始化互斥量属性
pthread_mutexattr_init(&attr);

// (可选)设置互斥量属性,例如将互斥量类型设置为PTHREAD_MUTEX_RECURSIVE
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE);

// 使用指定的属性初始化互斥量
int ret = pthread_mutex_init(&mutex, &attr);

// 检查是否成功初始化互斥量
if (ret != 0) {
    // 初始化失败,处理错误
}

// 使用完毕后,销毁互斥量属性对象
pthread_mutexattr_destroy(&attr);

在上面的代码中,我们首先声明了一个pthread_mutex_t类型的变量mutex。然后,我们创建并初始化一个互斥量属性对象attr。我们可以设置这个属性对象,以便配置互斥量的行为,例如,使它成为一个递归互斥量。接着,我们调用pthread_mutex_init,传入互斥量变量的地址和属性对象的地址,以初始化互斥量。最后,我们检查pthread_mutex_init的返回值,以确保互斥量已成功初始化。

🚨注意:互斥量的初始化只需进行一次,之后便可重复使用。在程序结束前,务必销毁互斥量,并释放与之相关的资源,以避免内存泄漏。

(2)锁定互斥量

在Linux下,我们可以使用互斥量(Mutex)来实现线程同步和对临界区的保护。在Linux系统中,有两种常见的方法可以用来锁定互斥量:pthread_mutex_lock() 和 pthread_mutex_trylock()。下面我将详细介绍这两种方法的使用。

✅pthread_mutex_lock() 函数
🍟头文件

pthread_mutex_lock() 函数位于 <pthread.h> 头文件中,因此在使用该函数之前,需要包含这个头文件。

🍟函数原型

pthread_mutex_lock() 函数的原型如下:

int pthread_mutex_lock(pthread_mutex_t *mutex);
🍟参数解释
  • mutex:指向互斥量对象的指针。调用该函数时,需要传入一个已经初始化的互斥量对象。
🍟返回值
  • 若函数执行成功,返回值为 0。
  • 若函数执行失败,返回的是一个非零错误码,表示发生了错误。
🍟使用示例

下面是一个简单的使用示例,以展示如何使用 pthread_mutex_lock() 函数来对临界区进行加锁和解锁:

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

pthread_mutex_t mutex;  // 定义互斥量对象

void* thread_func(void* arg) {
    pthread_mutex_lock(&mutex);  // 加锁
    
    // 访问共享资源,临界区操作
    printf("Critical section protected by mutex\n");
    
    pthread_mutex_unlock(&mutex);  // 解锁

    return NULL;
}

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

    pthread_create(&thread, NULL, thread_func, NULL);  // 创建线程

    pthread_mutex_lock(&mutex);  // 加锁

    // 访问共享资源,临界区操作
    printf("Critical section protected by mutex\n");

    pthread_mutex_unlock(&mutex);  // 解锁

    pthread_join(thread, NULL);  // 等待线程结束
    pthread_mutex_destroy(&mutex);  // 销毁互斥量

    return 0;
}

在上述示例中,首先创建了一个互斥量对象 mutex,然后在主线程和子线程中分别使用 pthread_mutex_lock() 进行加锁,保护了临界区的访问。在访问完成后,使用 pthread_mutex_unlock() 进行解锁。最后,通过 pthread_join() 等待子线程结束,并使用 pthread_mutex_destroy() 销毁互斥量。

✅pthread_mutex_trylock() 函数

🔴pthread_mutex_trylock() 函数与 pthread_mutex_lock() 函数类似,但是它尝试获取互斥锁而不会阻塞线程

🚩头文件

pthread_mutex_trylock() 函数同样位于 <pthread.h> 头文件中,因此在使用该函数之前,需要包含这个头文件。

🚩函数原型

pthread_mutex_trylock() 函数的原型如下:

int pthread_mutex_trylock(pthread_mutex_t *mutex);
🚩参数解释
  • mutex:指向互斥量对象的指针。调用该函数时,需要传入一个已经初始化的互斥量对象。
🚩返回值
  • 若函数执行成功,返回值为 0,表示成功获取了互斥锁。
  • 若函数执行失败,返回的是一个非零错误码,表示未能获取互斥锁
🚩使用示例

下面是一个简单的使用示例,以展示如何使用 pthread_mutex_trylock() 函数来尝试获取互斥锁:

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

pthread_mutex_t mutex;  // 定义互斥量对象

void* thread_func(void* arg) {
    int result = pthread_mutex_trylock(&mutex);  // 尝试获取锁
    if (result == 0) {
        // 成功获取锁,访问共享资源,临界区操作
        printf("Critical section protected by mutex\n");
        pthread_mutex_unlock(&mutex);  // 解锁
    } else {
        // 未能获取锁,进行其他处理
        printf("Failed to acquire lock\n");
    }

    return NULL;
}

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

    pthread_create(&thread, NULL, thread_func, NULL);  // 创建线程

    pthread_mutex_lock(&mutex);  // 主线程直接使用pthread_mutex_lock加锁
    
    // 访问共享资源,临界区操作
    printf("Critical section protected by mutex\n");
    
    pthread_mutex_unlock(&mutex);  // 解锁

    pthread_join(thread, NULL);  // 等待线程结束
    pthread_mutex_destroy(&mutex);  // 销毁互斥量

    return 0;
}

在上述示例中,我们首先创建了一个互斥量对象 mutex,然后在主线程和子线程中分别使用 pthread_mutex_trylock() 和 pthread_mutex_lock() 进行对临界区的加锁。通过检查返回值,我们可以确定是否成功获取了互斥锁,并分别进行相应的处理。


(3)解锁互斥量

解锁互斥量函数原型

int pthread_mutex_unlock(pthread_mutex_t *mutex);

该函数的作用是解锁一个已经上锁的互斥量对象。函数参数 mutex 是一个指向互斥量对象的指针,表示需要解锁的互斥量。

(4)销毁互斥量

销毁互斥量函数原型

int pthread_mutex_destroy(pthread_mutex_t *mutex);

该函数的作用是销毁一个初始化过的互斥量对象。函数参数 mutex 是一个指向互斥量对象的指针,表示需要销毁的互斥量。

🚨注意:在调用 pthread_mutex_destroy() 函数前,必须确保该互斥量没有被其他线程所持有,否则会导致未定义行为

三、死锁、资源饥饿问题

死锁和资源饥饿是多线程编程中常见的两种并发问题,它们都会影响程序的正确性和性能。下面分别对死锁和资源饥饿问题进行详细介绍:

1. 死锁(Deadlock)

死锁是指两个或多个进程在执行过程中,因争夺资源而造成的一种僵局(Deadly Embrace),彼此占有对方需要的资源,又都在等待对方释放资源,导致所有参与的进程无法继续执行的状态。

死锁产生的条件包括

  1. 互斥条件:一个资源每次只能被一个进程使用。
  2. 请求与保持条件:一个进程因请求资源而阻塞时,对已获得的资源保持不放。
  3. 不剥夺条件:进程已获得的资源,在未使用完之前,不能被其他进程强行剥夺。
  4. 循环等待条件:若干进程之间形成一种头尾相接的循环等待资源关系。

解决死锁的方法包括

  • 预防死锁:通过破坏死锁产生的条件来预防死锁,例如破坏循环等待条件、破坏不剥夺条件等。
  • 避免死锁:使用银行家算法等避免死锁的算法,动态分配资源,避免系统进入不安全状态。
  • 检测与解除死锁:当死锁发生时,通过检测死锁来采取措施解除死锁,例如撤销进程、回滚操作等。

2. 资源饥饿(Resource Starvation)

资源饥饿指的是某个或某些线程由于无法获取所需的资源而无法执行的状态。资源饥饿可能会导致进程无法继续执行,影响系统的性能和效率。

资源饥饿产生的原因可能包括

  • 优先级反转:低优先级任务占用了高优先级任务所需的资源,导致高优先级任务无法执行。
  • 资源竞争:多个线程竞争同一资源,导致某些线程无法获得所需资源。

解决资源饥饿的方法包括

  • 公平性调度:使用公平的调度算法,确保每个线程都有机会获得所需资源。
  • 优先级继承:当低优先级任务占用了高优先级任务所需的资源时,将低优先级任务提升到与高优先级任务相同的优先级,以避免优先级反转。
  • 资源分配策略:设计合理的资源分配策略,避免资源过度集中导致资源竞争。

温馨提示

感谢您对博主文章的关注与支持!如果您喜欢这篇文章,可以点赞、评论和分享给您的同学,这将对我提供巨大的鼓励和支持。另外,我计划在未来的更新中持续探讨与本文相关的内容。我会为您带来更多关于Linux以及C++编程技术问题的深入解析、应用案例和趣味玩法等。如果感兴趣的话可以关注博主的更新,不要错过任何精彩内容!


再次感谢您的支持和关注。我们期待与您建立更紧密的互动,共同探索Linux、C++、算法和编程的奥秘。祝您生活愉快,排便顺畅!

目录
相关文章
|
4天前
|
Java 数据库
【Java多线程】对线程池的理解并模拟实现线程池
【Java多线程】对线程池的理解并模拟实现线程池
16 1
|
1天前
|
NoSQL Redis 缓存
【后端面经】【缓存】36|Redis 单线程:为什么 Redis 用单线程而 Memcached 用多线程?
【5月更文挑战第17天】Redis常被称为单线程,但实际上其在处理命令时采用单线程,但在6.0后IO变为多线程。持久化和数据同步等任务由额外线程处理,因此严格来说Redis是多线程的。面试时需理解Redis的IO模型,如epoll和Reactor模式,以及其内存操作带来的高性能。Redis使用epoll进行高效文件描述符管理,实现高性能的网络IO。在讨论Redis与Memcached的线程模型差异时,应强调Redis的单线程模型如何通过内存操作和高效IO实现高性能。
22 7
【后端面经】【缓存】36|Redis 单线程:为什么 Redis 用单线程而 Memcached 用多线程?
|
2天前
|
Python
|
3天前
|
监控 Java 测试技术
在多线程开发中,线程死循环可能导致系统资源耗尽,影响应用性能和稳定性
【5月更文挑战第16天】在多线程开发中,线程死循环可能导致系统资源耗尽,影响应用性能和稳定性。为解决这一问题,建议通过日志记录、线程监控工具和堆栈跟踪来定位死循环;处理时,及时终止线程、清理资源并添加错误处理机制;编码阶段要避免无限循环,正确使用同步互斥,进行代码审查和测试,以降低风险。
18 3
|
4天前
|
Linux C语言 调度
|
4天前
|
存储 安全 Linux
【Linux】详解进程通信中信号量的本质&&同步和互斥的概念&&临界资源和临界区的概念
【Linux】详解进程通信中信号量的本质&&同步和互斥的概念&&临界资源和临界区的概念
|
4天前
|
设计模式 消息中间件 安全
【Java多线程】关于多线程的一些案例 —— 单例模式中的饿汉模式和懒汉模式以及阻塞队列
【Java多线程】关于多线程的一些案例 —— 单例模式中的饿汉模式和懒汉模式以及阻塞队列
12 0
|
4天前
|
Java
【Java多线程】分析线程加锁导致的死锁问题以及解决方案
【Java多线程】分析线程加锁导致的死锁问题以及解决方案
26 1
|
4天前
|
存储 缓存 安全
【Java多线程】线程安全问题与解决方案
【Java多线程】线程安全问题与解决方案
22 1