锁-随笔笔记

什么是临界区

临界区(Critical Section)是指一段代码或资源的访问区域,这段代码在多线程或多进程环境下被多个线程或进程同时访问时,会导致数据不一致或不可预期的问题。因此,临界区需要保护,保证同一时间只有一个线程或进程能够进入执行。

通俗易懂的例子:

银行柜台取钱

  • 假设你和你的朋友同时去银行取钱,账户里有100元。
  • 你想取50元,朋友也想取50元。
  • 如果没有临界区保护,银行系统可能会发生如下情形:
    1. 系统查看账户余额:100元(你和朋友同时看到这个余额)。
    2. 你取走50元,账户余额变成50元。
    3. 你的朋友也取走50元,但此时系统可能还没有更新余额信息,所以认为还有100元。
    4. 最后,账户余额变成**-50元**,出现了逻辑错误。

临界区保护:

在这种情况下,可以用(如互斥锁)来保护临界区,确保只有一个人能同时操作账户余额:

  1. 你去操作时,加锁,进入临界区,完成取钱,更新余额后解锁。
  2. 你的朋友尝试进入时发现临界区被锁住,只能等你完成后才能进入。

这样,账户余额始终是正确且一致的。

关键点:

  1. 临界区的本质需要被独占访问的共享资源或代码
  2. 保护方式:通常使用互斥锁(Mutex)、信号量(Semaphore)等同步机制。

我们需要保护临界区的原因是为了避免竞争条件(Race Condition)的发生,确保程序中的数据一致性操作正确性

为什么需要保护?

在多线程或多进程环境下,多个线程或进程可能会同时访问共享资源(如变量、文件、数据库)。如果这些访问是并发进行的,而没有同步机制保护,可能导致以下问题:


1. 数据损坏或不一致

例子:银行账户转账
  • 账户余额初始为100元,两个线程分别进行以下操作:
    1. 线程A:转账50元(余额减50)。
    2. 线程B:存入30元(余额加30)。
  • 如果两线程同时执行,操作可能是这样的:
    • 线程A读取余额100。
    • 线程B读取余额100。
    • 线程A更新余额为50。
    • 线程B更新余额为130。
  • 最终,实际余额应该是80元,但由于竞争条件,结果变成了130元50元

2. 程序崩溃或未定义行为

例子:链表操作
  • 假设两个线程在操作一个链表:
    1. 线程A正在删除链表中的一个节点。
    2. 线程B同时尝试遍历链表。
  • 如果没有保护机制:
    • 线程A可能刚把节点删除,线程B就访问了这个已经被释放的内存
    • 导致程序崩溃或产生未定义行为。

3. 意外的业务逻辑错误

例子:订单库存管理
  • 假设有一个库存计数器初始值为1,两个线程分别执行“用户下单”和“库存减少”的操作:
    1. 用户A下单,库存减1。
    2. 用户B也下单,同时库存减1。
  • 如果没有保护,两个线程可能同时看到库存为1,各自完成操作后,库存变成**-1**。

4. 安全性和完整性

例子:文件写入
  • 多个线程同时往同一个文件中写数据,没有同步控制时:
    • 数据可能互相覆盖或拼接错误,导致文件内容混乱。
    • 例如:线程A写“Hello”,线程B写“World”,可能的结果是“HWoerllldo”这种混杂内容。

总结:保护临界区的目的

  1. 防止数据竞争:保证同一时间只有一个线程访问共享资源。
  2. 确保操作原子性:让共享资源的读写操作完整而不可分割。
  3. 提高程序的可靠性和安全性:避免崩溃和未定义行为。
  4. 保证业务逻辑正确:让程序行为符合预期。

在工作场景中,比如网络通信驱动开发,经常会用到全局变量、文件描述符、链表等共享资源,这些地方的临界区保护尤为重要。

并发执行(Concurrency)的原因

并发执行(Concurrency)是指在同一时间段内,多个任务或进程(线程)能够交替执行或共享计算资源。并发执行的原因可以简单理解为:当多个任务或者进程需要在同一时间段内执行时,操作系统会通过分配计算资源,使它们看似同时进行。以下是一些简化后的原因:

  1. 多核CPU:就像一台机器有多个“工人”一起工作,多个任务可以分配给不同的核心,让它们同时进行。

  2. 操作系统的“调度员”:操作系统就像一个忙碌的调度员,它把时间分给每个任务,虽然它们轮流使用CPU,但给人感觉是同时进行。

  3. 等待I/O:当程序需要等待文件读取或网络请求时,操作系统让其他任务“顶替”使用CPU,直到原任务完成。

  4. 设计上的选择:有些程序设计时就决定同时做多件事,比如一个网站可以同时处理多个用户请求。

  5. 事件驱动:有些应用(如聊天软件)会等待不同的事件发生,它们不会阻塞,而是同时处理多个请求。

  6. 资源争夺:多个任务抢同一个资源时,操作系统让它们轮流使用,这样每个任务看起来都在同时执行。

通过这些方式,多个任务能够在同一时间段内并发执行,从而提高效率。

哪些数据需要加锁保护?

以下是一个表格,列举了哪些数据需要加锁保护,哪些数据不需要加锁保护:

数据类型 是否需要加锁保护 说明
局部自动变量 不需要加锁保护 这些变量仅在单个线程的栈中,其他线程无法访问。
堆栈上分配的动态数据 不需要加锁保护 动态分配的内存地址存储在堆栈中,只有当前线程访问。
全局变量(多个线程共享) 需要加锁保护 如果多个线程同时访问全局变量,可能会引起数据竞态问题,需加锁保护。
静态变量(多个线程共享) 需要加锁保护 静态变量通常在程序运行期间存在,多个线程访问时需加锁保护以避免竞态。
堆内存分配的数据(多个线程共享) 需要加锁保护 如果多个线程访问同一块堆内存,需要加锁防止数据竞争。
线程局部存储(TLS) 不需要加锁保护 每个线程有独立的TLS,不与其他线程共享数据,线程间不会干扰。
互斥量(锁)本身 需要加锁保护 互斥量在多线程环境中用于保护共享资源,因此本身需要加锁。
文件句柄(多个线程共享) 需要加锁保护 如果多个线程同时读写同一个文件句柄,可能会造成资源冲突,需加锁保护。

总结:

  • 不需要加锁保护的数据:局部变量、线程局部存储(TLS)、堆栈上动态分配的内存等。
  • 需要加锁保护的数据:全局变量、静态变量、堆内存中的共享数据、互斥量、文件句柄等,因它们通常在多个线程间共享,容易出现数据冲突。

死锁(Deadlock)概念 

死锁 是指多个线程在执行过程中,由于竞争资源或通信问题,导致互相等待的情况,从而造成程序无法继续执行。具体来说,死锁会让程序中的线程陷入一种 无休止的等待状态,因为每个线程都在等待其他线程释放它们需要的资源,而这些资源永远得不到释放。

死锁的发生一般需要满足以下四个条件:

死锁的四个必要条件(经典死锁模型)

  1. 互斥条件(Mutual Exclusion):至少有一个资源是以 非共享 的方式被分配给某个线程,即一个资源一次只能被一个线程占用。

  2. 占有并等待条件(Hold and Wait):一个线程在持有至少一个资源的同时,等待获取其他线程占用的资源。

  3. 非抢占条件(No Preemption):已分配给线程的资源,不能被其他线程抢占,线程必须在完成后释放资源。

  4. 循环等待条件(Circular Wait):存在一种线程资源的循环等待关系,假设线程 T1 等待 T2 占有的资源,T2 等待 T3 占有的资源,…,最后 Tn 等待 T1 占有的资源。

如果这四个条件同时成立,就可能发生死锁。

死锁示意图

假设有两个线程 T1T2,以及两个资源 R1R2

  • T1 获取了 R1,等待 R2
  • T2 获取了 R2,等待 R1

这时 T1T2 形成了循环等待的关系,导致死锁。


死锁的示例

下面是一个简单的 C 语言死锁示例:

#include 
#include 
#include 

pthread_mutex_t mutex1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutex2 = PTHREAD_MUTEX_INITIALIZER;

void *thread1_func(void *arg) {
    pthread_mutex_lock(&mutex1);
    printf("Thread 1: Locked mutex1\n");

    // 模拟某些处理
    sleep(1);

    pthread_mutex_lock(&mutex2);
    printf("Thread 1: Locked mutex2\n");

    pthread_mutex_unlock(&mutex2);
    pthread_mutex_unlock(&mutex1);

    return NULL;
}

void *thread2_func(void *arg) {
    pthread_mutex_lock(&mutex2);
    printf("Thread 2: Locked mutex2\n");

    // 模拟某些处理
    sleep(1);

    pthread_mutex_lock(&mutex1);
    printf("Thread 2: Locked mutex1\n");

    pthread_mutex_unlock(&mutex1);
    pthread_mutex_unlock(&mutex2);

    return NULL;
}

int main() {
    pthread_t thread1, thread2;

    // 创建两个线程
    pthread_create(&thread1, NULL, thread1_func, NULL);
    pthread_create(&thread2, NULL, thread2_func, NULL);

    // 等待两个线程完成
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    return 0;
}

运行说明
  • thread1_func 线程首先锁定了 mutex1,然后等待锁定 mutex2
  • thread2_func 线程首先锁定了 mutex2,然后等待锁定 mutex1
  • 两个线程形成了循环等待的关系,导致死锁。thread1thread2 都在等待对方释放锁,程序无法继续执行。
输出(死锁发生时)
Thread 1: Locked mutex1
Thread 2: Locked mutex2

此时程序会停止,因为两个线程互相等待对方释放锁,无法继续执行下去。


避免和解决死锁的方法

  1. 避免死锁

    • 资源请求顺序的统一:所有线程请求资源时都按照相同的顺序进行,例如,始终先申请 mutex1,然后申请 mutex2,这样就避免了交叉等待。
    • 资源超时机制:在请求资源时设置超时时间,如果无法在规定时间内获得锁,线程就放弃,重新尝试或者退出,从而避免无限等待。
  2. 死锁检测与恢复

    • 资源分配图:通过图论模型(如资源分配图),监控资源的分配和请求状态,检测是否存在循环等待。
    • 撤销操作:当死锁发生时,可以通过撤销某些线程的操作来打破循环等待关系。
    • 资源回收:通过回收部分资源,重新调度线程来避免死锁。
  3. 使用死锁避免算法

    • 银行家算法:银行家算法是一种用于资源分配的算法,能够在分配资源之前预测是否会导致死锁。如果可能发生死锁,则不会分配资源。
  4. 使用锁的时限或尝试获取锁

    • pthread_mutex_trylock:尝试获取锁,如果获取不到则返回,而不会阻塞线程。

死锁的检测与避免方法示例

1. 统一请求顺序
// 假设我们有两个资源 mutex1 和 mutex2
pthread_mutex_t mutex1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutex2 = PTHREAD_MUTEX_INITIALIZER;

// 按照相同顺序申请锁,可以避免死锁
void *thread1_func(void *arg) {
    pthread_mutex_lock(&mutex1);  // 先锁 mutex1
    printf("Thread 1: Locked mutex1\n");

    sleep(1);

    pthread_mutex_lock(&mutex2);  // 再锁 mutex2
    printf("Thread 1: Locked mutex2\n");

    pthread_mutex_unlock(&mutex2);
    pthread_mutex_unlock(&mutex1);

    return NULL;
}

void *thread2_func(void *arg) {
    pthread_mutex_lock(&mutex1);  // 先锁 mutex1
    printf("Thread 2: Locked mutex1\n");

    sleep(1);

    pthread_mutex_lock(&mutex2);  // 再锁 mutex2
    printf("Thread 2: Locked mutex2\n");

    pthread_mutex_unlock(&mutex2);
    pthread_mutex_unlock(&mutex1);

    return NULL;
}

2. 使用 pthread_mutex_trylock 避免死锁
void *thread_func(void *arg) {
    if (pthread_mutex_trylock(&mutex1) != 0) {
        printf("Failed to lock mutex1\n");
        return NULL;
    }
    printf("Locked mutex1\n");

    sleep(1);

    if (pthread_mutex_trylock(&mutex2) != 0) {
        printf("Failed to lock mutex2\n");
        pthread_mutex_unlock(&mutex1);
        return NULL;
    }
    printf("Locked mutex2\n");

    pthread_mutex_unlock(&mutex2);
    pthread_mutex_unlock(&mutex1);

    return NULL;
}

通过 pthread_mutex_trylock,如果无法获取锁,则立即返回,而不会造成死锁。


总结

  • 死锁 是多线程编程中常见的问题,它会导致程序中的线程陷入等待状态,无法继续执行。
  • 死锁的发生需要满足四个条件:互斥、占有并等待、非抢占、循环等待。
  • 避免死锁的方法包括统一资源请求顺序、使用超时机制、死锁检测和恢复等。
  • 在设计并发程序时,合理的锁策略和资源分配能够有效减少死锁的风险。

 通信导致的死锁

通信导致的死锁通常出现在 并发通信系统 中,其中多个线程或进程通过共享资源(如消息队列、共享内存、套接字等)进行通信。当这些线程或进程之间的通信流程不当时,可能会因为资源竞争或通信等待的交叉关系导致死锁。以下是几个常见的通信死锁场景,并通过代码示例说明。

通信导致死锁的示例

在以下的示例中,我们考虑两个线程使用 消息队列信号量 进行通信。如果线程之间的同步不当,可能会发生死锁。

1. 使用消息队列导致的死锁

在这个例子中,线程 A 向消息队列发送消息,线程 B 从消息队列接收消息,但是线程 A 需要等待线程 B 执行一些操作来继续,它们之间形成了循环等待。

#include 
#include 
#include 
#include 
#include 

#define MAX_MSG 1

sem_t semA, semB;  // 信号量,用来同步两个线程
int message = 0;  // 模拟消息队列

void *threadA(void *arg) {
    while (1) {
        // 等待线程B完成
        sem_wait(&semB);

        // 发送消息
        message = 1;
        printf("Thread A: Message sent to thread B\n");

        // 等待线程B确认
        sem_post(&semA);  // 释放给线程B的信号
    }
    return NULL;
}

void *threadB(void *arg) {
    while (1) {
        // 等待线程A发送消息
        sem_wait(&semA);

        if (message == 1) {
            printf("Thread B: Received message from A\n");

            // 模拟处理消息
            message = 0;
            sleep(1);  // 模拟处理时间

            // 发送回信号通知线程A
            sem_post(&semB);  // 释放给线程A的信号
        }
    }
    return NULL;
}

int main() {
    pthread_t tA, tB;

    // 初始化信号量
    sem_init(&semA, 0, 0);
    sem_init(&semB, 0, 0);

    // 创建线程
    pthread_create(&tA, NULL, threadA, NULL);
    pthread_create(&tB, NULL, threadB, NULL);

    // 激活通信,开始线程A的工作
    sem_post(&semB);

    // 等待线程完成
    pthread_join(tA, NULL);
    pthread_join(tB, NULL);

    // 清理资源
    sem_destroy(&semA);
    sem_destroy(&semB);

    return 0;
}

死锁发生原因:
  • 线程 A 向线程 B 发送消息,然后等待线程 B 进行处理后发送回信号。
  • 线程 B 先等待线程 A 的消息,再处理后发送回信号。
  • 如果两个线程在同一时刻彼此等待对方释放资源,就会导致 死锁
运行结果(死锁时)
Thread A: Message sent to thread B

然后程序就会停止,两个线程永远互相等待对方的信号,无法继续执行。


2. 使用信号量通信导致的死锁

另一个常见的情况是使用 信号量 进行同步。如果两个线程同时等待对方的信号量,而没有先释放自己占用的资源,也会造成死锁。

#include 
#include 
#include 

sem_t sem1, sem2;

void* thread1_func(void* arg) {
    sem_wait(&sem1);  // 获取信号量 sem1
    printf("Thread 1 acquired sem1\n");

    // 模拟一些工作
    sleep(1);

    sem_wait(&sem2);  // 等待信号量 sem2
    printf("Thread 1 acquired sem2\n");

    sem_post(&sem2);  // 释放信号量 sem2
    sem_post(&sem1);  // 释放信号量 sem1

    return NULL;
}

void* thread2_func(void* arg) {
    sem_wait(&sem2);  // 获取信号量 sem2
    printf("Thread 2 acquired sem2\n");

    // 模拟一些工作
    sleep(1);

    sem_wait(&sem1);  // 等待信号量 sem1
    printf("Thread 2 acquired sem1\n");

    sem_post(&sem1);  // 释放信号量 sem1
    sem_post(&sem2);  // 释放信号量 sem2

    return NULL;
}

int main() {
    pthread_t thread1, thread2;

    // 初始化信号量
    sem_init(&sem1, 0, 1);
    sem_init(&sem2, 0, 1);

    // 创建线程
    pthread_create(&thread1, NULL, thread1_func, NULL);
    pthread_create(&thread2, NULL, thread2_func, NULL);

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

    // 销毁信号量
    sem_destroy(&sem1);
    sem_destroy(&sem2);

    return 0;
}

死锁发生原因:
  • 线程 1 在等待 sem1 后,接着等待 sem2
  • 线程 2 在等待 sem2 后,接着等待 sem1
  • 这两个线程互相等待对方释放信号量,导致 死锁
解决方法:
  1. 资源分配顺序一致

    • 所有线程都应按照相同的顺序获取信号量。例如,始终先获取 sem1,再获取 sem2
  2. 避免嵌套信号量操作

    • 尽量减少嵌套的信号量操作,降低死锁的可能性。
  3. 超时机制

    • 使用带超时的信号量操作(如 sem_timedwait),避免线程无限期阻塞。
  4. 死锁检测

    • 在复杂系统中,可以设计死锁检测机制,检测到死锁时采取恢复措施。
  5. 哲学家进餐问题解决方案

    • 改变资源分配策略,例如使用奇偶编号规则或允许线程最多只获取一个资源,避免循环等待。
  6. 优先级继承

    • 在多线程系统中,采用优先级继承机制,防止优先级反转问题。
改进示例

以下是解决上述代码死锁问题的改进版本:

void* thread1_func(void* arg) {
    sem_wait(&sem1);  // 按顺序获取 sem1
    printf("Thread 1 acquired sem1\n");

    sleep(1);

    sem_wait(&sem2);  // 然后获取 sem2
    printf("Thread 1 acquired sem2\n");

    sem_post(&sem2);  // 按顺序释放 sem2
    sem_post(&sem1);  // 再释放 sem1

    return NULL;
}

void* thread2_func(void* arg) {
    sem_wait(&sem1);  // 按顺序获取 sem1
    printf("Thread 2 acquired sem1\n");

    sleep(1);

    sem_wait(&sem2);  // 然后获取 sem2
    printf("Thread 2 acquired sem2\n");

    sem_post(&sem2);  // 按顺序释放 sem2
    sem_post(&sem1);  // 再释放 sem1

    return NULL;
}


通信导致死锁的发生条件

  1. 通信资源的竞争:多个线程或进程在进行通信时,必须争夺有限的资源(例如,消息队列、信号量、共享内存等)。如果资源的请求顺序不合理,或者线程之间的同步机制设计不当,就可能引发死锁。

  2. 等待通信的交叉关系:死锁通常发生在线程之间由于同步不当,导致它们互相等待对方的操作完成。例如,一个线程等待从另一个线程接收消息,而另一个线程又等待第一个线程发送消息。

  3. 没有超时机制或回退机制:如果通信没有设置超时或回退机制,线程可能一直在等待对方的响应,导致死锁。


如何避免通信中的死锁?

  1. 统一资源请求顺序:确保所有线程或进程在请求共享资源时遵循一致的顺序。这样可以避免产生循环等待的情况。

  2. 使用超时机制:在进行通信时,设定超时条件,如果无法获取资源或者没有收到消息,可以中止等待并采取其他措施(例如,重试、放弃或退出)。

  3. 死锁检测:使用死锁检测算法定期检查系统状态,确保没有线程进入死锁状态。

  4. 设计无死锁的通信协议:可以通过设计避免死锁的通信协议。例如,在发送消息前,确认接收方已经准备好接收消息,或者使用 非阻塞通信 来避免线程阻塞等待。

  5. 简化同步机制:尽量避免复杂的锁和同步机制,例如信号量或多个互斥锁的组合使用。可以采用简单的同步机制来减少死锁的风险。

通过这些方式,可以有效减少由于通信而导致的死锁问题。

你可能感兴趣的:(工作随笔,linux,c语言)