《Linux系统编程》19.线程同步与互斥

💡Yupureki:个人主页

✨个人专栏:《C++》 《算法》《Linux系统编程》《高并发内存池》《MySQL数据库》

《个人在线OJ平台》


🌸Yupureki🌸的简介:


目录

[1. 线程互斥](#1. 线程互斥)

[1.1 前置知识](#1.1 前置知识)

[1.2 为什么需要线程互斥?](#1.2 为什么需要线程互斥?)

[1.3 互斥锁](#1.3 互斥锁)

[1.4 常用接口](#1.4 常用接口)

[1.4.1 初始化与销毁](#1.4.1 初始化与销毁)

[1.4.2 加锁与解锁](#1.4.2 加锁与解锁)

[1.4.3 互斥锁属性对象](#1.4.3 互斥锁属性对象)

[1.5 测试](#1.5 测试)

[1.6 互斥锁的封装](#1.6 互斥锁的封装)

[2. 线程同步](#2. 线程同步)

[2.1 条件变量](#2.1 条件变量)

[2.2 常见接口](#2.2 常见接口)

[2.2.1 初始化与销毁](#2.2.1 初始化与销毁)

[2.2.2 等待条件](#2.2.2 等待条件)

[2.2.3 唤醒等待线程](#2.2.3 唤醒等待线程)

[2.2.4 条件变量属性对象](#2.2.4 条件变量属性对象)

[2.3 条件变量和互斥锁的使用顺序](#2.3 条件变量和互斥锁的使用顺序)

[2.4 测试](#2.4 测试)

[2.5 条件变量的封装](#2.5 条件变量的封装)

[3. 线程安全问题](#3. 线程安全问题)

[3.1 概念](#3.1 概念)

[3.2 常见导致非线程安全/非重入的原因](#3.2 常见导致非线程安全/非重入的原因)


1. 线程互斥

1.1 前置知识

  • 共享资源
  • 临界资源:多线程执行流被保护的共享的资源就叫做临界资源
  • 临界区:每个线程内部,访问临界资源的代码就叫做临界区
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用
  • 原子性(后面讨论如何实现):不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成

1.2 为什么需要线程互斥?

线程之间共享进程的内存空间 ,多个线程可以同时访问同一个变量或资源。如果不对访问进行控制,可能出现以下情况:

  • 线程 A 读取变量 x = 10

  • 线程 B 读取变量 x = 10

  • 线程 A 将 x 加 1,写回 x = 11

  • 线程 B 将 x 加 1,写回 x = 11

原本希望 x 变成 12,结果却变成了 11。这就是竞态条件

在这里,变量x就是临界资源 ,我们需要保护该资源,使得每次访问该资源的时候最多只有一个线程 。线程之间无法同时访问,就是互斥

1.3 互斥锁

Linux 中最常用的线程互斥工具是互斥锁(mutex,mutual exclusion)。互斥锁有两种状态:

  • 锁定(locked):某线程持有锁

  • 未锁定(unlocked):没有线程持有锁

基本使用流程:

  1. 在访问共享资源前,加锁pthread_mutex_lock

  2. 访问共享资源(临界区)

  3. 访问结束后,解锁pthread_mutex_unlock

如果某个线程尝试加锁但锁已被其他线程占用,该线程会阻塞,直到锁被释放。

1.4 常用接口

1.4.1 初始化与销毁

接口 描述
pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr) 动态初始化互斥锁,可指定属性(attrNULL 时使用默认属性)。
pthread_mutex_destroy(pthread_mutex_t *mutex) 销毁互斥锁,释放相关资源。销毁前锁必须处于未锁定状态。
PTHREAD_MUTEX_INITIALIZER 静态初始化宏,用于编译时初始化具有默认属性的互斥锁。例如: pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

1.4.2 加锁与解锁

接口 描述
pthread_mutex_lock(pthread_mutex_t *mutex) 加锁。如果锁已被其他线程持有,调用线程会阻塞直到锁可用。
pthread_mutex_trylock(pthread_mutex_t *mutex) 尝试加锁。如果锁可用,立即加锁并返回 0;如果锁已被占用,立即返回 EBUSY,不阻塞。
pthread_mutex_timedlock(pthread_mutex_t *mutex, const struct timespec *abs_timeout) 限时加锁。若在指定绝对时间前无法获得锁,则返回 ETIMEDOUT
pthread_mutex_unlock(pthread_mutex_t *mutex) 解锁。由持有锁的线程调用,释放锁。

1.4.3 互斥锁属性对象

接口 描述
pthread_mutexattr_init(pthread_mutexattr_t *attr) 初始化互斥锁属性对象。
pthread_mutexattr_destroy(pthread_mutexattr_t *attr) 销毁互斥锁属性对象。
pthread_mutexattr_settype(pthread_mutexattr_t *attr, int type) 设置互斥锁类型(如 PTHREAD_MUTEX_NORMALPTHREAD_MUTEX_ERRORCHECKPTHREAD_MUTEX_RECURSIVE 等)。
pthread_mutexattr_gettype(...) 获取互斥锁类型。
pthread_mutexattr_setpshared(...) 设置互斥锁的共享范围(进程内或进程间)。

1.5 测试

不加锁

cpp 复制代码
#include <pthread.h>
#include <stdio.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int shared_counter = 0;

void* increment(void* arg) {
    for (int i = 0; i < 100000; i++) {
        //pthread_mutex_lock(&mutex);   // 加锁
        shared_counter++;             // 临界区
        //pthread_mutex_unlock(&mutex); // 解锁
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, increment, NULL);
    pthread_create(&t2, NULL, increment, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("Final counter: %d\n", shared_counter); // 200000
    pthread_mutex_destroy(&mutex);
    return 0;
}

我们期望的结果是200000,但由于资源竞争的激烈,结果只有101596。能看出加锁的重要性

加锁:

cpp 复制代码
#include <pthread.h>
#include <stdio.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int shared_counter = 0;

void* increment(void* arg) {
    for (int i = 0; i < 100000; i++) {
        pthread_mutex_lock(&mutex);   // 加锁
        shared_counter++;             // 临界区
        pthread_mutex_unlock(&mutex); // 解锁
    }
    return NULL;
}

int main() {
    pthread_t t1, t2;
    pthread_create(&t1, NULL, increment, NULL);
    pthread_create(&t2, NULL, increment, NULL);
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    printf("Final counter: %d\n", shared_counter); // 200000
    pthread_mutex_destroy(&mutex);
    return 0;
}

加锁后结果正常,避免了资源竞争。但加锁的时候也破坏了线程的同步性,其他的线程由于没拿到锁,只能在临界区外干瞪眼,什么也干不了。因此加锁会导致效率的下降

1.6 互斥锁的封装

cpp 复制代码
#pragma once
#include <iostream>
#include <pthread.h>

#define ERR_EXIT(m)         \
    do                      \
    {                       \
        perror(m);          \
        exit(EXIT_FAILURE); \
    } while (0)

class mylock{
public:
    mylock()
    {
        int n = pthread_mutex_init(&_lock,nullptr);
        if(n != 0)
        {
            ERR_EXIT("mutex init");
            return;
        }
        //std::cout<<"mutex init success!"<<std::endl;
    }
    int lock()
    {
        return pthread_mutex_lock(&_lock);
    }
    int unlock()
    {
        return pthread_mutex_unlock(&_lock);
    }
    pthread_mutex_t* get_lock()
    {
        return &_lock;
    }
    ~mylock()
    {
        pthread_mutex_destroy(&_lock);
    }
private:
    pthread_mutex_t _lock;
};

2. 线程同步

2.1 条件变量

没有拿到锁的线程只能在外面一直等待,当锁归还的时候怎么办,是不是也得抢?

想象一下,一堆线程竞争同一把锁,是否会造成某些线程一直拿不到锁的情况?这就造成了效率低下的问题。

不信?测试一下

cpp 复制代码
#include <pthread.h>
#include <stdio.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
int shared_counter = 0;

void* increment(void* arg) {
    for (int i = 0; i < 100000; i++) {
        pthread_mutex_lock(&mutex);   // 加锁
        printf("%s拿到了锁\n",(char*)arg);
        shared_counter++;             // 临界区
        pthread_mutex_unlock(&mutex); // 解锁
    }
    return NULL;
}

int main() {
    pthread_t t1, t2,t3,t4;
    pthread_create(&t1, NULL, increment, (void*)"1");
    pthread_create(&t2, NULL, increment, (void*)"2");
    pthread_create(&t3, NULL, increment, (void*)"3");
    pthread_create(&t4, NULL, increment, (void*)"4");
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
    printf("Final counter: %d\n", shared_counter); // 200000
    pthread_mutex_destroy(&mutex);
    return 0;
}

我们可以发现在某段时间内,一直都是线程1拿到的锁,刚还又给拿回来了,相当于左手倒右手

因此为了解决这个问题,我们期望线程们拿锁应该是有顺序的,即像一个队列一样,先来先到。这就是条件变量

  • 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步
  • 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解

2.2 常见接口

2.2.1 初始化与销毁

接口 描述
pthread_cond_init(pthread_cond_t *cond, const pthread_condattr_t *attr) 动态初始化条件变量,可指定属性(attrNULL 时使用默认属性)。
pthread_cond_destroy(pthread_cond_t *cond) 销毁条件变量,释放相关资源。销毁前不应有线程在等待。
PTHREAD_COND_INITIALIZER 静态初始化宏,用于编译时初始化具有默认属性的条件变量。例如: pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

2.2.2 等待条件

接口 描述
pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex) 阻塞等待条件变量。调用前必须已锁定 mutex。该函数会原子地释放 mutex 并阻塞线程,直到被唤醒。被唤醒后,函数返回前会重新获取 mutex
pthread_cond_timedwait(pthread_cond_t *cond, pthread_mutex_t *mutex, const struct timespec *abstime) 限时等待。若在指定的绝对时间前未被唤醒,则返回 ETIMEDOUT。用法与 pthread_cond_wait 类似,但增加了超时机制。

2.2.3 唤醒等待线程

接口 描述
pthread_cond_signal(pthread_cond_t *cond) 唤醒至少一个等待该条件变量的线程。如果没有线程在等待,则无效果。
pthread_cond_broadcast(pthread_cond_t *cond) 唤醒所有等待该条件变量的线程。

2.2.4 条件变量属性对象

接口 描述
pthread_condattr_init(pthread_condattr_t *attr) 初始化条件变量属性对象。
pthread_condattr_destroy(pthread_condattr_t *attr) 销毁条件变量属性对象。
pthread_condattr_setpshared(...) 设置条件变量的共享范围(进程内或进程间)。
pthread_condattr_getpshared(...) 获取条件变量的共享范围。
pthread_condattr_setclock(...) 设置 pthread_cond_timedwait 使用的时钟(如 CLOCK_MONOTONIC)。

2.3 条件变量和互斥锁的使用顺序

条件变量与互斥锁的配合使用有明确的顺序要求,这直接影响程序的正确性。通常,等待线程必须先加锁,再调用 pthread_cond_wait;唤醒线程则应先加锁、改变条件,然后调用 pthread_cond_signal,最后解锁。 这个顺序并非任意,而是基于条件变量的工作机理和避免竞态的需要。

等待线程的正确顺序

cpp 复制代码
pthread_mutex_lock(&mutex);
while (!condition) {
    pthread_cond_wait(&cond, &mutex);   // 原子地释放锁并阻塞
}
// 条件满足,访问共享资源
pthread_mutex_unlock(&mutex);

为什么必须先加锁?

  • pthread_cond_wait 要求调用前互斥锁已被锁定,这是函数的硬性规定。

  • 更关键的是,条件变量必须与互斥锁配合使用,以保护条件的检查。如果先 waitlock,则无法原子地检查条件和进入等待,可能导致错过唤醒。

为什么 while 而不是 if

  • 因为存在虚假唤醒,while 循环可以重新检查条件,确保只有条件真正成立时才继续执行。

唤醒线程的正确顺序

cpp 复制代码
pthread_mutex_lock(&mutex);
// 修改共享条件(如将 ready 置为 1)
condition = 1;
pthread_cond_signal(&cond);   // 或 broadcast
pthread_mutex_unlock(&mutex);

为什么先加锁,再 signal,最后解锁?

  1. 避免条件丢失 :如果在修改条件前就调用 signal,那么等待线程可能尚未进入 wait 状态,从而错过唤醒。先加锁修改条件,保证条件的改变和信号的发送是原子的,等待线程要么在条件改变前进入等待(随后被唤醒),要么在条件改变后直接看到条件成立而不进入等待。

  2. 防止竞态 :如果先 signal 再解锁,在解锁之前等待线程可能已经醒来并尝试加锁,但由于锁仍被持有,等待线程会短暂阻塞,但这是无害的;反之,如果先解锁再 signal,可能在解锁和 signal 之间插入另一个等待线程的加锁,造成不必要的调度。

是否可以解锁后再 signal?

  • 从 POSIX 规范看,pthread_cond_signal 可以在不持有锁的情况下调用,此时不会丢失唤醒,因为等待线程在 wait 中会检查条件(受锁保护)。但为了代码简洁性和避免优先级反转等问题,推荐在持有锁的情况下调用 signal。这样能确保条件变量与互斥锁的协同工作最可靠。

两种常见误用及其后果

误用 后果
等待线程先 wait 再加锁 编译错误或未定义行为,因为 pthread_cond_wait 要求锁已被锁定。
唤醒线程先 signal 再修改条件 等待线程可能在条件被修改前就收到信号,导致条件仍不成立时被唤醒,再次进入等待,可能错过真正的唤醒(即丢失唤醒)。

省流

  • 等待线程 :加锁 → pthread_cond_wait(内部释放锁)→ 醒来后重新获得锁 → 解锁。

  • 唤醒线程 :加锁 → 修改条件 → pthread_cond_signal → 解锁。

2.4 测试

cpp 复制代码
#include <pthread.h>
#include <unistd.h>
#include <stdio.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int shared_counter = 0;
bool done = false;

void* increment(void* arg) {
    while(!done)
    {
        pthread_mutex_lock(&mutex);
        if(shared_counter >= 100)
        {
            done = true;
            pthread_cond_broadcast(&cond);
            pthread_mutex_unlock(&mutex);
            break;
        }
        shared_counter++;
        printf("%s拿到了锁,当前计数器: %d\n",(char*)arg, shared_counter);
        pthread_mutex_unlock(&mutex);
        usleep(100); // 稍微延迟,让其他线程有机会执行
    }
    printf("%s退出\n",(char*)arg);
    return NULL;
}

int main() {
    pthread_t t1, t2,t3,t4;
    pthread_create(&t1, NULL, increment, (void*)"1");
    pthread_create(&t2, NULL, increment, (void*)"2");
    pthread_create(&t3, NULL, increment, (void*)"3");
    pthread_create(&t4, NULL, increment, (void*)"4");
    while(shared_counter < 100)
    {
        pthread_cond_signal(&cond);
    }
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);
    printf("Final counter: %d\n", shared_counter);
    pthread_mutex_destroy(&mutex);
    pthread_cond_destroy(&cond);
    return 0;
}

保证了线程按照顺序拿锁

2.5 条件变量的封装

cpp 复制代码
#pragma once
#include <iostream>
#include <pthread.h>

#define ERR_EXIT(m)         \
    do                      \
    {                       \
        perror(m);          \
        exit(EXIT_FAILURE); \
    } while (0)
    
class mycond{
public:
    mycond()
    {
        int n = pthread_cond_init(&_cond,nullptr);
        if(n != 0)
        {
            ERR_EXIT("cond init");
            return;
        }
        //std::cout<<"cond init success!"<<std::endl;
    }
    void signal()
    {
        pthread_cond_signal(&_cond);
    }
    void wait(pthread_mutex_t* lock)
    {
        pthread_cond_wait(&_cond,lock);
    }
    void broadcast()
    {
        pthread_cond_broadcast(&_cond);
    }
    pthread_cond_t* get_cond()
    {
        return &_cond;
    }
    ~mycond()
    {
        pthread_cond_destroy(&_cond);
    }
private:
    pthread_cond_t _cond;
};

3. 线程安全问题

3.1 概念

线程安全(Thread Safety)

定义

如果一个函数或数据结构在多线程环境中被多个线程同时调用,仍然能正确工作(即共享数据保持一致性,不会出现竞态条件),则称它是线程安全的。

可重入性(Reentrancy)

定义

一个函数在执行过程中可以被中断 (比如被信号处理函数或另一个线程调用),并且中断后再次进入该函数时,仍然能正确运行,不会破坏之前调用的状态,则称它是可重入的。

3.2 常见导致非线程安全/非重入的原因

非线程安全

  • 使用未保护的全局或静态变量

  • 返回指向内部静态缓冲区的指针(如 asctimectime

  • 多个线程同时修改同一文件描述符未加锁

非重入

  • 使用静态或全局变量来保存状态(如 strtok

  • 调用非可重入函数(如 mallocprintf 在信号处理中通常不安全)

  • 使用锁(因为同一线程再次进入会死锁)

3.3 如何实现线程安全和可重入

实现线程安全

  • 加锁 :保护临界区,如 pthread_mutex_lock/unlock

  • 原子操作 :对简单计数器用 __sync_fetch_and_add 或 C11 的 atomic_*

  • 线程局部存储 :使用 __threadpthread_key_t

实现可重入

  • 避免使用全局/静态数据 :将状态作为参数由调用者传入(如 strtok_r

  • 只使用局部变量(存储在栈上)

  • 不调用任何不可重入的函数

  • 不操作共享资源(如文件、锁、信号量)

相关推荐
又来敲代码了2 小时前
Zrlog博客的系统部署
java·linux·运维·mysql·apache·tornado
砍光二叉树2 小时前
【设计模式】行为型-责任链模式
java·设计模式·责任链模式
96772 小时前
C++ Lambda 表达式 匿名函数 sort
数据结构·c++·算法
feng_you_ying_li2 小时前
linux开发工具的介绍(5)
linux·运维·centos
Lugas Luo2 小时前
Kernel 5.10 SD卡专属探测、上电与注册流程分析 (Detect -> Power Up -> Add)
linux·嵌入式硬件
艾莉丝努力练剑2 小时前
【Linux信号】Linux进程信号(下):可重入函数、Volatile关键字、SIGCHLD信号
linux·运维·服务器·c++·人工智能·后端·学习
kiki_24112 小时前
用IntelliJ IDEA编写Java程序,从0到1完整教程
java·ide·intellij-idea
FL16238631292 小时前
基于C#winform部署RealESRGAN的onnx模型实现超分辨率图片无损放大模糊图片变清晰
开发语言·c#
liuyao_xianhui2 小时前
优选算法_锯齿形层序遍历二叉树_队列_C++
java·开发语言·数据结构·c++·算法·链表