Linux的多线程

目录

1、线程互斥

[1.1 互斥的相关概念](#1.1 互斥的相关概念)

[1.2 互斥的实现方式](#1.2 互斥的实现方式)

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

[1. 为什么ticket会<=0](#1. 为什么ticket会<=0)

[2. 互斥锁的相关操作](#2. 互斥锁的相关操作)

[2.1 创建锁及初始化](#2.1 创建锁及初始化)

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

[3. 保证ticket不会<=0](#3. 保证ticket不会<=0)

[4. 互斥锁的原理](#4. 互斥锁的原理)

[5. 互斥锁的封装](#5. 互斥锁的封装)

2、线程同步

[2.1 线程同步的概念](#2.1 线程同步的概念)

[2.2 同步的实现方式](#2.2 同步的实现方式)

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

[1. 条件变量的相关操作](#1. 条件变量的相关操作)

[1.1 创建条件变量及初始化](#1.1 创建条件变量及初始化)

[1.2 等待与唤醒](#1.2 等待与唤醒)

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

[2.2.2 POSIX信号量](#2.2.2 POSIX信号量)

[1. 信号量的相关操作](#1. 信号量的相关操作)

[1.1 创建信号量及初始化](#1.1 创建信号量及初始化)

[1.2 P/V操作](#1.2 P/V操作)

[2. 信号量的封装](#2. 信号量的封装)

3、死锁

[3.1 死锁的定义](#3.1 死锁的定义)

[3.2 死锁产生的四个必要条件](#3.2 死锁产生的四个必要条件)

[3.3 避免死锁](#3.3 避免死锁)

4、线程安全


1、线程互斥

1.1 互斥的相关概念

  • 临界资源需要被保护共享资源
  • 临界区访问临界资源代码
  • 原子性 (不可分割):一个或多个操作要么全部执行成功要么全部不执行中间不会被打断
  • 互斥 解决的是 **"多个线程 / 进程不能同时访问共享资源 "** 的问题,核心是 "排他性"------ 确保同一时间只有一个执行者 (线程 / 进程)能进入访问共享资源的 "临界区"

1.2 互斥的实现方式

1.2.1 互斥锁
  • pthread库提供了互斥锁(互斥量),对于一种整块临界资源,只有一把锁 (唯一),所以只有一个线程会执行临界区的代码 ,保证了执行临界区代码原子性。 即临界区的代码要么全部执行成功 (一个持有锁的线程),要么全部不执行 (其他没有申请到锁的线程),中间不会被打断(其他没有申请到锁的线程执行不了临界区的代码)。
  • 例如多个线程修改同一计数器(读取->修改->写回)时,互斥锁能避免 "两个线程同时读取旧值、各自修改后覆盖" 的错误,从根本上防止数据竞争,实现线程间的基础协调。
  • 注意:互斥锁保护的是代码段(临界区),而非数据本身。任何访问共享资源的代码都必须配合同一个互斥锁使用。
1. 为什么ticket会<=0
cpp 复制代码
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

int ticket = 100; // 1-100号的票

void* buyTicket(void* arg)
{
    char* name = static_cast<char*>(arg);
    // 临界区begin
    while(ticket > 0)
    {
        usleep(100); // 睡眠0.1ms
        printf("%s buy ticket:%d\n",name,ticket);
        --ticket;
    }
    // 临界区end

    return nullptr;
}

int main()
{
    pthread_t tid1,tid2,tid3;
    pthread_create(&tid1,nullptr,buyTicket,(void*)"tid1");
    pthread_create(&tid2,nullptr,buyTicket,(void*)"tid2");
    pthread_create(&tid3,nullptr,buyTicket,(void*)"tid3");

    pthread_join(tid1,nullptr);
    pthread_join(tid2,nullptr);
    pthread_join(tid3,nullptr);

    return 0;
}

  • 最后抢1号票的过程如下:
  • 注意
  1. 如果不写usleep,可能一个线程就直接抢完了。
  2. 实际上,这是抢票混乱其中的一种情况。
  3. 在这个场景中,判断也属于临界区,正是因为判断,所以tid2和tid3都能进入循环,抢最后的1号票。
  4. 因为临界区的代码操作不是原子性 的,所以出现问题 。其实是线程并发而产生的问题 (本质是线程切换),称为线程安全问题
2. 互斥锁的相关操作
2.1 创建锁及初始化
cpp 复制代码
#include <pthread.h>

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

int pthread_mutex_init(pthread_mutex_t *restrict mutex,
    const pthread_mutexattr_t *restrict attr);
int pthread_mutex_destroy(pthread_mutex_t *mutex);
  • 锁的类型pthread_mutex_t。锁有全局锁和局部锁。
  1. 全局锁 ,要在编译 时完成初始化 ,所以使用PTHREAD_MUTEX_INITIALIZER****初始化 。会自动销毁
  2. 局部锁 ,要在运行 时完成初始化 ,所以使用pthread_mutex_init()初始化第二个参数 用于配置锁的属性,一般传nullptr (默认属性)。**必须手动**pthread_mutex_destroy(pthread_mutex_t *mutex);进行销毁
2.2 加锁与解锁
cpp 复制代码
#include <pthread.h>

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);

int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • pthread_mutex_lock(pthread_mutex_t *mutex),申请加锁成功继续向后执行失败阻塞并挂起 ,等待持有锁的线程释放锁。pthread_mutex_trylock(pthread_mutex_t *mutex),同上,失败 ,就是非阻塞(本文不考虑)。
  • pthread_mutex_unlock(pthread_mutex_t *mutex),解锁。
  • 注意:
  1. 所有的线程都要遵循锁的规则(加锁,解锁)。
  2. 一般对临界区进行加锁,不要包含太多的非临界区,会影响效率。
  3. 加锁之后,在临界区内部,即使线程被切换了,但是其他线程申请加锁,会失败,就会被阻塞并挂起。
3. 保证ticket不会<=0
cpp 复制代码
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

int ticket = 100; // 1-100号的票
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

void *buyTicket(void *arg)
{
    char *name = static_cast<char *>(arg);
    while (true)
    {
        pthread_mutex_lock(&lock);
        if (ticket > 0)
        {
            usleep(100); // 模拟抢票耗时,睡眠0.1ms
            printf("%s buy ticket:%d\n", name, ticket);
            --ticket;
            pthread_mutex_unlock(&lock);
        }
        else
        {
            pthread_mutex_unlock(&lock);
            break;
        }
    }

    return nullptr;
}

// 如果这样写,抢到锁后,就一直循环抢票了,其他线程都没有锁,抢不到票
// void* buyTicket(void* arg)
// {
//     char* name = static_cast<char*>(arg);
//     pthread_mutex_lock(&lock);
//     while(ticket > 0)
//     {
//         usleep(100); // 睡眠0.1ms
//         printf("%s buy ticket:%d\n",name,ticket);
//         --ticket;
//     }
//     pthread_mutex_unlock(&lock);

//     return nullptr;
// }

// 如果这样写,还是会抢票混乱,关键是抢到锁后要再判断
// void* buyTicket(void* arg)
// {
//     char* name = static_cast<char*>(arg);
//     while(ticket > 0)
//     {
//         pthread_mutex_lock(&lock);

//         usleep(100); // 睡眠0.1ms
//         printf("%s buy ticket:%d\n",name,ticket);
//         --ticket;

//         pthread_mutex_unlock(&lock);
//     }

//     return nullptr;
// }

int main()
{
    pthread_t tid1, tid2, tid3;
    pthread_create(&tid1, nullptr, buyTicket, (void *)"tid1");
    pthread_create(&tid2, nullptr, buyTicket, (void *)"tid2");
    pthread_create(&tid3, nullptr, buyTicket, (void *)"tid3");

    pthread_join(tid1, nullptr);
    pthread_join(tid2, nullptr);
    pthread_join(tid3, nullptr);

    return 0;
}
  • 注意:关键是抢到锁后要再判断
  • 这里发现1-100号的票都是tid1抢的 ,这并不代表代码有问题,只是多线程调度的正常现象。加锁的目的是保证数据安全,而不是保证线程执行的公平性。如果需要严格的公平性,可以考虑使用条件变量或其他同步机制来实现。
4. 互斥锁的原理
  • 疑问:线程要竞争锁,那么锁就是共享资源,而锁是为了保护共享资源的,那么谁来保护锁?由申请加锁的原子性操作保证锁的设计原理保证申请加锁 的过程是原子的。
  • 为了实现互斥锁操作,大多数体系结构都提供了swapexchange指令,该指令的作用是把寄存器和内存单元的数据相交换 ,于只有一条指令保证了原子性,即使是多处理器平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。如下是lock和unlock的伪代码:
  • **流程:**第一个线程lock,%a1 = 0,原子操作swap(%a1,mutex),%a1 = 1,mutex = 0,因为%a1>0,return,执行临界区的代码;后面的线程lock,%a1 = 0,原子操作swap(%a1,mutex),%a1 = 0,mutex = 0,因为%a1<=0,挂起等待;第一个线程unlock,mutex = 1。
  • 注意:
  • %a1每次lock都会置为0,unlock不需要swap(%a1,mutex),直接mutex = 1。
  • 始终只有一个线程的%a1为1(%a1放到线程私有堆栈中 )。1本质就是唯一的一把锁
5. 互斥锁的封装
cpp 复制代码
#include <pthread.h>

namespace MutexModule
{
    class Mutex
    {
    public:
        Mutex()
        {
            pthread_mutex_init(&_mutex, nullptr);
        }
        void Lock()
        {
            pthread_mutex_lock(&_mutex);
        }
        void Unlock()
        {
            pthread_mutex_unlock(&_mutex);
        }
        pthread_mutex_t *Get()
        {
            return &_mutex;
        }
        ~Mutex()
        {
            pthread_mutex_destroy(&_mutex);
        }

    private:
        pthread_mutex_t _mutex;
    };

    // RAII,资源的初始化与释放与对象的生命周期绑定
    class LockGuard
    {
    public:
        LockGuard(Mutex &mutex)
            : _mutex(mutex)
        {
            _mutex.Lock();
        }
        ~LockGuard()
        {
            _mutex.Unlock();
        }

    private:
        Mutex &_mutex;
    };
}

2、线程同步

2.1 线程同步的概念

  • 同步 的核心是解决****"多个进程协作时的'有序性'问题"**,本质** 是**"协调顺序"。**

2.2 同步的实现方式

2.2.1 条件变量
  • pthread库提供了条件变量,用于解决 "线程需等待特定条件满足后再执行 " 的复杂场景常与互斥锁配合使用 。其核心逻辑是:当线程检查到目标条件不满足 时(如 "队列已空"),会释放持有的互斥锁主动进入等待状态 ;当其他线程修改资源后使条件可能满足 时(如向队列添加数据),可通过条件变量唤醒等待的线程 ,让其重新获取锁并继续执行
  • 例如生产者 - 消费者模型中,消费者线程可通过条件变量等待 "队列有数据",生产者线程在生产数据后唤醒消费者。
1. 条件变量的相关操作
1.1 创建条件变量及初始化
cpp 复制代码
#include <pthread.h>

pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

int pthread_cond_init(pthread_cond_t *restrict cond,
    const pthread_condattr_t *restrict attr);
int pthread_cond_destroy(pthread_cond_t *cond);
  • 条件变量的类型pthread_cond_t。条件变量有全局条件变量和局部条件变量。
  1. 全局条件变量 ,要在编译 时完成初始化 ,所以使用PTHREAD_COND_INITIALIZER****初始化 。会自动销毁
  2. 局部条件变量 ,要在运行 时完成初始化 ,所以使用pthread_cond_init()初始化第二个参数 用于配置条件变量的属性,一般传nullptr (默认属性)。**必须手动**pthread_cond_destroy(pthread_cond_t *cond);进行销毁
1.2 等待与唤醒
cpp 复制代码
#include <pthread.h>

int pthread_cond_wait(pthread_cond_t *restrict cond,
    pthread_mutex_t *restrict mutex);
int pthread_cond_timedwait(pthread_cond_t *restrict cond,
    pthread_mutex_t *restrict mutex,
    const struct timespec *restrict abstime);

int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);
  • pthread_cond_wait(cond, mutex),线程原子释放互斥锁 mutex等待条件变量 cond 被触发 (即阻塞)。当线程被唤醒时需重新竞争互斥锁再返回

  • pthread_cond_timedwait(cond, mutex, abstime) ,同上,阻塞 ,但带有超时时间,如果在指定时间内条件未触发 ,则返回ETIMEDOUT

  • pthread_cond_signal(cond)唤醒 至少一个 等待在该条件变量上线程

  • pthread_cond_broadcast(cond)唤醒 所有 等待在该条件变量上线程

  • 注意:

  1. 所有的线程都要遵循条件变量的规则(等待,被唤醒)。
  2. 流程:是先申请到加锁,然后判断,条件不满足,原子释放锁等待,当条件满足,被唤醒(可能会错误唤醒 或 唤醒时线程切换,条件再次不满足,所以要while判断)。
2. 条件变量的封装
cpp 复制代码
#include <pthread.h>
#incude "Mutex.hpp"

namespace CondModule
{
    class Cond
    {
    public:
        // RAII,资源的初始化与释放与对象的生命周期绑定
        Cond()
        {
            pthread_cond_init(_cond,nullptr);
        }
        void Wait(MutexModule::Mutex& mutex)
        {
            pthread_cond_wait(_cond,mutex.Get());
        }
        void Signal()
        {
            pthread_cond_signal(_cond);
        }
        void Broadcast()
        {
            pthread_cond_broadcast(_cond);
        }
        ~Cond()
        {
            pthread_cond_destroy(_cond);
        }
    private:
        pthread_cond_t _cond;
    };
}
2.2.2 POSIX信号量
  • 信号量 本质是一个计数器 ,是对一种多块临界资源的预定机制。如果只有一块资源,就称二元信号量,满足互斥,锁的底层就是使用二元信号量实现的。
  • 既可以实现互斥(一个计数器初始值设为 1,确保同一时间只有一个线程获取资源),也可实现简单的计数器同步(如初始值为0的信号量用于线程间通知)。
  • 同步的例子:当信号量值(商品数)为 0,消费者要等待生产者生产,当信号量值(空位数)为 0,生产者要等待消费者消费。
1. 信号量的相关操作
1.1 创建信号量及初始化
cpp 复制代码
#include <semaphore.h>

int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_destroy(sem_t *sem);
  • 信号量的类型sem_t
  • int sem_init(sem_t *sem, int pshared, unsigned int value)pshared:0 表示线程间共享非零 表示进程间共享 value信号量初始值
  • int sem_destroy(sem_t *sem),进行销毁
1.2 P/V操作
cpp 复制代码
#include <semaphore.h>

int sem_wait(sem_t *sem); // P()
int sem_trywait(sem_t *sem);
int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout);

int sem_post(sem_t *sem); // V()
  • int sem_wait(sem_t *sem),等待信号量,预定资源。如果sem-1 >=0成功预定 了一块临界资源,如果sem-1 < 0失败阻塞并挂起
  • int sem_trywait(sem_t *sem),同上,如果失败就不阻塞
  • int sem_timedwait(sem_t *sem, const struct timespec *abs_timeout),同上,阻塞 ,但带有超时时间,如果在指定时间内条件未触发 ,则返回ETIMEDOUT
  • int sem_post(sem_t *sem),增加信号量,释放资源。++sem
  • 注意:
  1. 为什么是P(--),V(++)操作。发明者,使用荷兰语,Prolaag尝试减少,Verhoog增加
  2. P/V操作是原子的,所以信号量这个共享资源是线程安全的。
2. 信号量的封装
cpp 复制代码
#include <semaphore.h>

namespace SemModule
{
    const unsigned int default_value = 1;
    class Sem
    {
    public:
        // RAII,资源的初始化与释放与对象的生命周期绑定
        Sem(unsigned int sem_value = default_value)
            : _sem_value(sem_value)
        {
            sem_init(&_sem, 0, _sem_value);
        }
        void P()
        {
            sem_wait(&_sem);
        }
        void V()
        {
            sem_post(&_sem);
        }
        ~Sem()
        {
            sem_destroy(&_sem);
        }

    private:
        unsigned int _sem_value;
        sem_t _sem;
    };
}

画外音:const unsigned int传给unsigned int 没关系吗?没关系,是数值拷贝,指针和引用才需要担心权限的问题。

3、死锁

3.1 死锁的定义

  • 死锁 是指两个或两个以上并发执行单元 (线程或进程),在争夺系统资源 时,陷入一种相互等待对方释放资源的状态 ,若无外力干涉 ,这些单元都将陷入一种永久等待状态。

简单比喻:两个人在一座独木桥上迎面相遇。

person A 需要从左边去右边,但被 person B 挡住。

person B 需要从右边去左边,但被 person A 挡住。

两人都坚持不让,于是谁也过不去,形成了僵局。这就是死锁。

3.2 死锁产生的四个必要条件

  • 四个条件 必须同时满足死锁才可能发生 。只要破坏其中任意一个 ,就能预防死锁
  • 互斥 条件 (Mutual Exclusion)
    • 含义:资源一次只能被一个执行单元独占使用。如果另一个单元请求该资源,请求者必须等待,直到资源被释放。
    • 例子:打印机、一个共享变量的写操作、一个互斥锁(Mutex)。
  • 占有并等待 条件 (Hold and Wait)
    • 含义:一个执行单元已经持有了至少一个资源 ,但又提出了新的资源请求 ,而该新资源恰好被其他单元占有 ,此时该单元进入等待状态 ,但并不释放自己已持有的资源
    • 例子:线程A锁住了锁L1,然后试图去锁L2;与此同时,线程B锁住了L2,然后试图去锁L1。
  • 不可抢占 条件 (No Preemption)
    • 含义:资源不能被 强制地从占有它的执行单元手中抢走只能占有者在使用完毕后主动释放
    • 例子:对于互斥锁,操作系统不能强行从一个线程那里把锁夺过来给另一个线程。线程必须自己调用 unlock()。
  • 循环等待 条件 (Circular Wait)
    • 含义:存在一个等待资源的循环链每个执行单元 都在等待下一个单元所占有的资源
    • 例子:线程A等待线程B占有的资源R2,线程B又在等待线程A占有的资源R1。这样就形成了一个A->B->A的循环等待。

3.3 避免死锁

  • 核心思想破坏死锁的四个必要条件
  • 我们无法破坏"互斥"(因为锁的本质就是互斥),但可以破坏其他三个条件。
避免方法 破坏的条件 核心思想 优点 缺点
固定顺序加锁 循环等待 定义全局统一的锁获取顺序 简单有效,最常用 需要全局规划,有时顺序难定
超时与回退 占有并等待 尝试获取锁,失败则释放已有锁 灵活性高,可应对未知顺序 实现复杂,可能活锁,性能开销
一次性申请 占有并等待 原子性地获取所有需要的锁 从根本上杜绝"持有等待" 资源利用率低,可能提前占锁
锁粒度粗化 循环等待 减少所需锁的数量 简化编程模型,提升性能 并发度降低

4、线程安全

  • STL中的容器 是否是线程安全的? 不是
  • 原因是,STL的设计初衷是将性能挖掘到极致,而一旦涉及到加锁保证线程安全,会对性能造成巨大的影响。而且对于不同的容器,加锁方式的不同,性能可能也不同(例如hash表的锁表和锁桶)。因此STL默认不是线程安全 ,如果需要在多线程环境下使用,往往需要调用者自行保证线程安全
  • 智能指针 是否是线程安全的? 对于unique_ptr所有权独占 ,有且只有一个 unique_ptr实例拥有对一个对象的所有权和控制权,通常不跨线程(跨线程用std::move()),无并发问题 。对于shared_ptr多个对象需要共用一个引用计数变量 ,所以会存在线程安全问题 。但是标准库实现的时候考虑到了这个问题,保证了引用计数的线程安全,但是指向的原始数据默认不是线程安全的
  • 注意:
  • 函数可重入一定线程安全 的,但是线程安全函数不一定可重入的,如:发送信号造成死锁。
cpp 复制代码
#include <pthread.h>
#include <signal.h>
#include <unistd.h>

pthread_mutex_t global_lock = PTHREAD_MUTEX_INITIALIZER;

// 一个线程安全的函数(但不可重入)
void process_data() {
    pthread_mutex_lock(&global_lock); // (1) 加锁
    // ... 执行一些操作,访问全局数据 ...
    sleep(5); // 模拟一个耗时操作,在此期间信号很可能发生
    // ... 执行更多操作 ...
    pthread_mutex_unlock(&global_lock); // (3) 解锁
}

// 信号处理函数
void signal_handler(int sig) {
    process_data(); // (2) 在信号处理中再次调用!
}

int main() {
    signal(SIGINT, signal_handler); // 注册信号处理函数

    process_data(); // 主调用
    return 0;
}
  • 死锁的发生流程:主线程 进入 process_data(),成功获取互斥锁 global_lock(执行点(1);然后开始sleep(5);在 sleep 期间,用户按下了 Ctrl+C(发送 SIGINT 信号);主线程被中断,转而执行 信号处理函数 signal_handler;信号处理函数 调用 process_data();process_data() 试图再次获取 global_lock(执行点(2));然而,这把锁正被主线程自己持有(它在被中断前已经拿到了锁);于是,信号处理函数(在主线程的上下文中执行),等待一把永远不可能被释放的锁(因为主线程被阻塞了,无法继续执行到解锁操作(3))会永远阻塞;死锁发生!
相关推荐
清风笑烟语2 小时前
Ubuntu 24.04 搭建k8s 1.33.4
linux·ubuntu·kubernetes
Dovis(誓平步青云)3 小时前
《Linux 基础指令实战:新手入门的命令行操作核心教程(第一篇)》
linux·运维·服务器
好名字更能让你们记住我3 小时前
MYSQL数据库初阶 之 MYSQL用户管理
linux·数据库·sql·mysql·adb·数据库开发·数据库架构
半桔4 小时前
【网络编程】TCP 服务器并发编程:多进程、线程池与守护进程实践
linux·服务器·网络·c++·tcp/ip
维尔切4 小时前
Shell 脚本编程:函数
linux·运维·自动化
穷人小水滴4 小时前
胖喵必快 (pmbs): btrfs 自动快照工具 (每分钟快照)
linux·rust
云泽8085 小时前
从ENIAC到Linux:计算机技术与商业模式的协同演进
linux·运维·服务器
wheeldown5 小时前
【Linux】【实战向】Linux 进程替换避坑指南:从理解 bash 阻塞等待,到亲手实现能执行 ls/cd 的 Shell
linux·运维·bash
zyt05025 小时前
四、计算机网络与分布式系统(中)
linux·计算机网络·程序人生