Linux 多线程进阶:线程互斥、同步、线程池、死锁与线程安全、读写锁、自旋锁

前言:

在 Linux 多线程编程中,同一进程内所有线程共享地址空间、全局变量、堆内存 ,多个线程同时争抢、修改共享资源时,会引发数据混乱、结果异常、程序崩溃等竞态问题。线程互斥保证「同一临界区同一时间只有一个线程进入」,线程同步保证「多线程按照预定有序逻辑协作执行」,二者是 Linux 后端开发、高并发服务器、线程池架构的核心基石。本文基于 POSIX pthread 线程库,完整梳理 Linux 线程互斥、同步机制、生产者消费者模型、线程池设计、线程安全、死锁问题全套知识点,配套原理 + 底层实现 + C++ 代码实战,覆盖 Linux 后台开发面试 90% 高频考点。

一、线程互斥

1.1进程线程间的互斥相关背景概念

临界资源:多线程执行流被保护的共享的资源就叫做临界资源

临界区:每个线程内部,访问临界资源的代码,就叫做临界区

互斥:任何时刻,互斥保证有且只有⼀个执行流进入临界区,访问临界资源,通常对临界资源起 保护作用

原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成, 要么未完成

1.2互斥量mutex

大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量 归属单个线程,其他线程⽆法获得这种变量。但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。多个线程并发的操作共享变量,会带来⼀些问题。

模拟抢票代码:

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


int ticket = 100;
void *route(void *arg)
{
    char *id = (char*)arg;
    while ( 1 ) 
    {
        if ( ticket > 0 ) 
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
        } else 
        {
            break;
        }
    }
    return nullptr;
}
int main( void )
{
    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, NULL, route, (void*)"thread 1");
    pthread_create(&t2, NULL, route, (void*)"thread 2");
    pthread_create(&t3, NULL, route, (void*)"thread 3");
    pthread_create(&t4, NULL, route, (void*)"thread 4");
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);

    return 0;
}

为什么会减到负数???

1.ticket--不是原子的

2.if 语句判断条件为真以后,代码可以并发的切换到其他线程

3.usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码 段

解决需要:

代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。

如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许⼀个线程 进入该临界区。

如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。


做到这些就需要一把锁,Linux上提供的这把锁叫做互斥量

初始化互斥量:

销毁互斥量:

互斥量加锁和解锁:

改进的抢票代码:

cpp 复制代码
int ticket = 100;

class ThreadData
{
public:
    ThreadData(const std::string &n, Mutex &lock)
        : name(n),
          lockp(&lock)
    {
    }
    ~ThreadData() {}
    std::string name;
    Mutex *lockp;
};

void *route(void *arg)
{
    ThreadData *td = static_cast<ThreadData *>(arg);
    while (1)
    {
        pthread_mutex_lock(td->lockp);
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", td->name.c_str(), ticket);
            ticket--;
            pthread_mutex_unlock(td->lockp);
        }
        else
        {
            pthread_mutex_unlock(td->lockp);
            break;
        }
    }

    return nullptr;
}

int main( void )
{
    pthread_mutex_t lock;
    pthread_mutex_init(&lock, NULL);

    pthread_t t1, t2, t3, t4;
    ThreadData *td1 = new ThreadData("thread 1", lock);
    pthread_create(&t1, NULL, route, td1);

    ThreadData *td2 = new ThreadData("thread 2", lock);
    pthread_create(&t2, NULL, route, td2);

    ThreadData *td3 = new ThreadData("thread 3", lock);
    pthread_create(&t3, NULL, route, td3);

    ThreadData *td4 = new ThreadData("thread 4", lock);
    pthread_create(&t4, NULL, route, td4);

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);

    pthread_mutex_destroy(&lock);

    return 0;
}

1.3互斥量实现原理探究

为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和 内存单元的数据相交换,由于只有⼀条指令,保证了原字性,即使是多处理器平台,访问内存的总线周 期也有先后,⼀个处理器上的交换指令执行时另⼀个处理器的交换指令只能等待总线周期。

1.4互斥量的封装

cpp 复制代码
//Mutex.hpp

#pragma once
#include <iostream>
#include <pthread.h>

namespace MutexModule
{
    class Mutex
    {
    public:
        Mutex()
        {
            pthread_mutex_init(&_mutex, nullptr);
        }
        void Lock()
        {
            int n = pthread_mutex_lock(&_mutex);
            (void)n;
        }
        void Unlock()
        {
            int n = pthread_mutex_unlock(&_mutex);
            (void)n;
        }
        ~Mutex()
        {
            pthread_mutex_destroy(&_mutex);
        }

    private:
        pthread_mutex_t _mutex;
    };

    class LockGuard
    {
    public:
        LockGuard(Mutex &mutex):_mutex(mutex)
        {
            _mutex.Lock();
        }
        ~LockGuard()
        {
            _mutex.Unlock();
        }
    private:
        Mutex &_mutex;
    };
}
cpp 复制代码
//TestMutex.cpp

#include <iostream>
#include <mutex>
#include <string>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include "Mutex.hpp"

using namespace MutexModule;

int ticket = 1000;
// pthread_mutex_t glock = PTHREAD_MUTEX_INITIALIZER;
// std::mutex cpp_lock;

class ThreadData
{
public:
    ThreadData(const std::string &n, Mutex &lock)
        : name(n),
          lockp(&lock)
    {
    }
    ~ThreadData() {}
    std::string name;
    Mutex *lockp;
};

// 加锁:尽量加锁的范围粒度要比较细,尽可能的不要包含太多的非临界区代码
void *route(void *arg)
{
    ThreadData *td = static_cast<ThreadData *>(arg);
    while (1)
    {
        LockGuard guard(*td->lockp); // 加锁完成, RAII风格的互斥锁的实现
        if (ticket > 0)
        {
            usleep(1000);
            printf("%s sells ticket:%d\n", td->name.c_str(), ticket);
            ticket--;
        }
        else
        {
            break;
        }

        usleep(123);
    }

    return nullptr;
}


int main(void)
{
    // pthread_mutex_t lock;
    // pthread_mutex_init(&lock, nullptr); // 初始化锁

    int a = 20;

    Mutex lock;
    pthread_t t1, t2, t3, t4;
    ThreadData *td1 = new ThreadData("thread 1", lock);
    pthread_create(&t1, NULL, route, td1);

    ThreadData *td2 = new ThreadData("thread 2", lock);
    pthread_create(&t2, NULL, route, td2);

    ThreadData *td3 = new ThreadData("thread 3", lock);
    pthread_create(&t3, NULL, route, td3);

    ThreadData *td4 = new ThreadData("thread 4", lock);
    pthread_create(&t4, NULL, route, td4);

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);

    // pthread_mutex_destroy(&lock);
    return 0;
}

二、线程的同步

如果我们就是单纯的使用互斥的话,会有一个场景,就我们按超级自习室的例子来说,如果一个人进来之后,他就是中午想吃饭了,想出去,他刚把钥匙放在那他就想不行,好不容易申请的这个钥匙,然后他又立即又申请过来了,然后如果还有很多猪此类的情况,那如果他高频的申请钥匙,没有做有效动作,这样的话,其他人得不到钥匙就是会造成其他线程的饥饿问题。

这种做法并没有错,但就是不高效也不太公平,所以这时我们要做出一个管理,第一,不能立即申请。第二,外面的人进行排队,退出的人必须跑到队尾进行二次申请,这样就可以保证自习室安全的情况下让所有执行访问临界资源,按照一定的顺序去访问资源这就叫线程同步

2.1条件变量

当⼀个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。

例如⼀个线程访问队列时,发现队列为空,它只能等待,只到其它线程将⼀个节点添加到队列 中。这种情况就需要用到条件变量。

2.2同步概念与竞争条件

同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免 饥饿问题,叫做同步

竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。

2.3条件变量函数

2.4生产者消费者模型

想象一个极端场景:工厂(生产者)直接把商品送到用户(消费者)手里,没有任何中间环节。工厂生产一件商品,就得专门联系用户,用户不在线、忙别的事,商品就送不出去,工厂只能等着,效率极低;反过来,用户想买东西,也得等工厂生产完才能拿到,工厂忙的时候,用户只能干等,体验极差。(这就像多线程里,生产者线程和消费者线程直接通信,不仅耦合度高,还会互相阻塞,谁也没法高效工作。)超市 的出现,完美解决了这个问题。工厂把商品批量送到超市,不用管谁来买;用户直接去超市买东西,不用管商品是谁生产的。超市就是一个「缓冲带」,让工厂和用户都能并行工作,整体效率直接拉满。(放到多线程里,这个「超市」就是一块共享的内存缓冲区,而这就是生产者消费者模型的核心。)

2.4.1为何要使用生产者消费者模型

生产者消费者模式就是通过⼀个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于⼀个缓冲区, 平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。

2.4.2生产者消费者模型优点

缓冲区的意义

  1. 解耦:生产者和消费者不用直接通信,通过缓冲区交互,谁的变化都不会直接影响另一方;
  2. 支持忙闲不均:工厂旺季疯狂生产,用户不一定同时买;用户突然抢购,工厂也不用临时加班,缓冲区可以暂时存储数据;
  3. 提高效率:生产者只管生产、消费者只管消费,两者可以并行执行,避免互相阻塞。

模型里的 3 种关键关系

  1. 生产者和生产者:竞争互斥关系多个工厂(生产者线程)不能同时往超市的同一个货架放商品,不然会出现商品混乱、数据覆盖的问题。对应多线程里,多个生产者线程操作共享缓冲区时,需要互斥锁保证同一时间只有一个生产者能写入数据。

  2. 消费者和消费者:竞争互斥关系多个用户(消费者线程)不能同时抢同一个商品,不然会出现「超卖」,两个人买到同一件商品的情况。对应多线程里,多个消费者线程操作共享缓冲区时,同样需要互斥锁保证同一时间只有一个消费者能读取数据。

  3. 生产者和消费者:同步 + 互斥关系

    • 同步:超市货架满了,工厂就不能再放商品,得等着用户买走;超市货架空了,用户就买不到东西,得等着工厂补货。这就是「生产者不能在缓冲区满时生产,消费者不能在缓冲区空时消费」的同步逻辑。
    • 互斥:工厂放商品和用户拿商品,不能同时操作同一个货架,不然会出现数据错乱。

这 3 种关系,也对应了模型的「321 原则」:

  • 3 种关系:生产者间互斥、消费者间互斥、生产者与消费者同步 + 互斥
  • 2 种角色:生产者线程、消费者线程(同一个线程也可以同时承担两种角色)
  • 1 个交易场所:共享的内存缓冲区

2.5基于BlockingQueue的生产者消费者模型

2.5.1BlockingQueue

在多线程编程中阻塞队列(Blocking Queue)是⼀种常用于实现生产者和消费者模型的数据结构。其与普通的队列区别在于,当队列为空时,从队列获取元素的操作将会被阻塞,直到队列中被放入了元素;当队列满时,往队列里存放元素的操作也会被阻塞,直到有元素被从队列中取出(以上的操作都是基于不同的线程来说的,线程在对阻塞队列进程操作时会被阻塞)

一句话总结:阻塞队列是一个容量具有上限的队列,不满足读写条件的时候就要进行阻塞对于线程。

代码演示(我们这里写的是单生产,单消费;其实改成多生产,多消费代码没有变化。生产者和生产者还有消费者和消费者的关系都是互斥,我们中心都在生产者和消费者

复制代码
//BlockQueue.hpp


// 阻塞队列的实现
#pragma once

#include <iostream>
#include <string>
#include <queue>
#include <pthread.h>

const int defaultcap = 5; // for test

template <typename T>
class BlockQueue
{
private:
    bool IsFull() { return _q.size() >= _cap; }
    bool IsEmpty() { return _q.empty(); }

public:
    BlockQueue(int cap = defaultcap)
        : _cap(cap), _csleep_num(0), _psleep_num(0)
    {
        pthread_mutex_init(&_mutex, nullptr);
        pthread_cond_init(&_full_cond, nullptr);
        pthread_cond_init(&_empty_cond, nullptr);
    }
    void Equeue(const T &in)
    {
        pthread_mutex_lock(&_mutex);
        // 生产者调用
        while (IsFull())
        {
            _psleep_num++;
            std::cout << "生产者,进入休眠了: _psleep_num" <<  _psleep_num << std::endl;
            pthread_cond_wait(&_full_cond, &_mutex);
            _psleep_num--;
        }
        // 100%确定:队列有空间
        _q.push(in);

        if(_csleep_num>0)
        {
            pthread_cond_signal(&_empty_cond);
            std::cout << "唤醒消费者..." << std::endl;
        }

        // pthread_cond_signal(&_empty_cond); // 可以
        pthread_mutex_unlock(&_mutex); // TODO
        // pthread_cond_signal(&_empty_cond); // 可以
    }
    T Pop()
    {
        // 消费者调用
        pthread_mutex_lock(&_mutex);
        while (IsEmpty())
        {
            _csleep_num++;
            pthread_cond_wait(&_empty_cond, &_mutex);
            _csleep_num--;
        }
        T data = _q.front();
        _q.pop();

        if(_psleep_num > 0)
        {
            pthread_cond_signal(&_full_cond);
            std::cout << "唤醒生产者" << std::endl;
        }

        // pthread_cond_signal(&_full_cond);
        pthread_mutex_unlock(&_mutex);
        return data;
    }
    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_full_cond);
        pthread_cond_destroy(&_empty_cond);
    }

private:
    std::queue<T> _q; // 临界资源!!!
    int _cap;         // 容量大小

    pthread_mutex_t _mutex;
    pthread_cond_t _full_cond;
    pthread_cond_t _empty_cond;

    int _csleep_num; // 消费者休眠的个数
    int _psleep_num; // 生产者休眠的个数
};

补充细节:

1.唤醒的位置

唤醒放在解锁前后都是正确的。解锁前唤醒:被唤醒的线程会等当前线程释放锁后,再竞争锁;解锁后唤醒:被唤醒的线程可能立刻拿到锁,减少一次上下文切换。两种写法都符合 POSIX 线程规范,没有对错之分。

2.为什么用if判断睡眠数量

pthread_cond_signal在没有线程等待的时候调用,不会报错,但会产生无效的系统调用。加这个判断,只有当真的有线程在睡眠时才执行唤醒,是性能优化的写法。

pthread_cond_wait 三大核心设计重点

这是理解条件变量的核心,也是面试高频考点,每一点都讲清**「是什么 + 为什么这么设计 + 不遵守的后果」。**

重点 1:wait 调用成功、挂起线程前,会自动原子性释放互斥锁
  • 核心机制:pthread_cond_wait 不是单纯的 "让线程睡觉",它会在线程真正进入阻塞休眠前,原子性地释放绑定的互斥锁
  • 为什么必须这么设计?互斥锁是用来保护队列这个临界资源的,如果生产者线程进入休眠时,不释放锁,会直接触发死锁:生产者拿着锁休眠了,消费者线程根本拿不到锁,无法从队列里取走数据,队列永远不会有空位,生产者也就永远没有被唤醒的机会。
  • 关键补充:这里的 "原子性" 是核心,保证「释放锁 + 进入休眠」是一个不可分割的操作,不会出现 "刚释放锁、还没休眠,就被消费者抢了锁改了条件" 的竞态问题。
重点 2:线程被唤醒时,wait 成功返回前,会自动重新申请并持有互斥锁
  • 核心机制:当其他线程通过pthread_cond_signal/pthread_cond_broadcast唤醒等待的线程时,被唤醒的线程不会直接从wait返回,而是会先重新抢到绑定的互斥锁,拿到锁之后,才会从 wait 函数返回
  • 为什么必须这么设计?线程被唤醒后,接下来要操作队列这个临界资源,而临界资源的访问必须持有互斥锁。这个设计保证了:只要 pthread_cond_wait 成功返回,当前线程一定持有互斥锁,一定在临界区内,可以安全操作临界资源
重点 3:被唤醒后抢锁失败,会在互斥锁上阻塞等待
  • 核心机制:很多新手会误以为 "线程被唤醒就会立刻执行",这是错误的。唤醒只是把线程从「条件变量的等待队列」,转移到了「互斥锁的竞争队列」。如果此时有其他线程已经持有了这把互斥锁,被唤醒的线程就会在锁上阻塞等待,直到锁被释放、自己成功抢到锁,才会从 wait 返回。
  • 这也是为什么,线程被唤醒后,必须重新判断队列是否已满 ------ 在你抢锁的这段时间里,可能有其他生产者已经往队列里放了数据,队列又满了。

核心避坑:伪唤醒(Spurious Wakeup)与 while 循环的必要性

代码里标注了两个问题,本质上都指向了 POSIX 多线程编程里最经典的坑:伪唤醒

什么是伪唤醒?

POSIX 标准中,明确允许pthread_cond_wait出现「没有线程调用 signal/broadcast,函数却提前返回」的情况,也允许「多个线程被 signal 唤醒后,条件被其他线程修改,导致当前线程的等待条件不再满足」的情况。这种 "条件不满足,wait 却返回了" 的现象,就叫伪唤醒。

为什么必须用 while,绝对不能用 if?
  • 如果你用if(IsFull())判断:线程被伪唤醒后,if 判断已经执行过了,会直接往下执行_q.push(in),此时队列其实还是满的,直接导致队列越界、数据污染、程序崩溃。
  • 如果你用while(IsFull())判断:无论线程是正常唤醒还是伪唤醒,都会重新执行一次 IsFull () 判断
    • 如果队列还是满的,就会再次进入 wait 继续休眠;
    • 只有队列真的有空位,才会跳出循环,执行入队操作。这是 100% 安全的写法,也是 POSIX 标准强制推荐的条件变量使用范式。
补充:pthread_cond_wait 会调用失败吗?

会的。pthread_cond_wait是有返回值的,当参数非法、被信号中断等情况发生时,会返回非 0 的错误码。但哪怕是正常返回,也必须通过 while 循环重新判断条件,这是双保险。

补充细节:_psleep_num 计数的作用

代码里的_psleep_num是「当前正在休眠等待的生产者线程数量」,它的核心作用是性能优化 :后续生产者入队完成后,唤醒消费者线程时,会先判断「有没有消费者在休眠」,只有真的有线程在等待,才会执行pthread_cond_signal,避免无意义的系统调用开销。

2.6信号量

POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。但POSIX可以用于线程间同步。

信号量的使用有两种场景:

场景 1:目标资源整体使用(互斥场景)

当需要对资源进行 "整体互斥访问" 时,我们可以用 mutex + 二元信号量 实现。

  • 原理:将信号量初始值设为 1,此时信号量就相当于一个互斥锁。
    • 线程执行前执行 P 操作:信号量从 1 变为 0,其他线程执行 P 操作时会被阻塞,保证同一时间只有一个线程访问临界资源。
    • 线程执行后执行 V 操作:信号量从 0 变回 1,唤醒等待的线程。
  • 本质:二元信号量实现的互斥,和互斥锁的效果类似,但信号量的适用场景比互斥锁更灵活。

场景 2:目标资源分块使用(同步 / 资源池场景)

当需要将资源分成不同的 "块",允许多个线程同时访问不同块时,我们直接使用计数信号量即可。

  • 原理:将信号量的初始值设为 "资源块的数量",比如资源有 5 个可用块,就将信号量初始化为 5。
    • 线程每次申请一个块,执行 P 操作,信号量减 1;用完后执行 V 操作,信号量加 1。
    • 这样最多可以有 5 个线程同时访问资源,实现 "有限并发",避免资源竞争。
  • 常见案例:生产者消费者模型(环形队列)、线程池的任务队列控制。

之前生产者-消费者的例子是基于queue的,其空间可以动态分配,现在基于固定大小的环形队列重写这个程序(POSIX信号量):

基于环形队列的生产消费模型

模型的四大 "君子协定":

环形队列的生产者 - 消费者模型,为了避免数据竞争、越界覆盖等问题,约定了 4 条核心规则,这是整个模型安全运行的基础

  • **约定 1:队列为空时,生产者优先运行。**当队列无数据时,消费者无法读取有效数据,必须阻塞等待,让生产者先写入数据。
  • **约定 2:队列为满时,消费者优先运行。**当队列被写满时,生产者无法写入新数据,必须阻塞等待,让消费者先读取数据释放空间。
  • **约定 3:生产者不能比消费者 "快一圈"(套圈)。**环形队列是循环结构,如果生产者写入速度远超消费者,会出现生产者指针追上消费者指针,覆盖未被读取的数据,造成数据丢失。因此生产者的进度最多只能和消费者差一圈。
  • **约定 4:消费者不能超过生产者。**消费者读取数据的进度,永远不能超过生产者写入的进度,否则会读取到未被初始化的无效数据。

什么时候可以并行?什么时候需要同步互斥?

环形队列的核心优势,在于它能在大部分场景下支持生产者和消费者并行执行,只有特定场景下才需要同步互斥。我们可以从 "是否访问同一个队列位置" 这个核心点来拆解:

1. 什么时候可以同时执行?

只要生产者和消费者不访问同一个队列位置 ,两者的读写操作就不会互相干扰,可以完全并行执行!举个例子:环形队列容量为 8,生产者指针tail指向位置 3,消费者指针head指向位置 1。此时生产者写入位置 3,消费者读取位置 1,两者操作的是不同的内存地址,完全不会产生数据竞争,因此可以同时运行。

2. 什么时候会访问同一个位置?

只有两种极端场景,生产者和消费者的指针会指向同一个位置:

  • 场景 1:队列为空(head == tail,队列无数据)
  • 场景 2:队列为满(head == tail,队列已满)也就是说,只有队列为空 或 队列为满的时候,生产者和消费者才会访问同一个位置,此时必须进行同步互斥

3. 两种场景下的同步规则

  • 队列为空时:只能让生产者先运行,消费者必须等待(否则会读取无效数据),即生产者优先同步运行。
  • 队列为满时:只能让消费者先运行,生产者必须等待(否则会覆盖未读数据),即消费者优先同步运行。

总结:

  • 结论 1:当环形队列「不为空 且 不为满」时,生产者和消费者可以同时进行
  • 结论 2:当环形队列「为空 或 为满」时,生产者和消费者需要同步互斥

信号量如何保证四大约定?(核心实现思路)

要实现上面的同步规则和 4 个约定,我们可以用两个信号量来实现,无需对整个队列加锁,就能保证线程安全:

生产者每次写入数据前,需要先申请空位置:

  • P(sem_blank):申请空位置,如果队列已满(无空位置),生产者会阻塞等待。
  • 写入数据到队列,更新生产者指针tailtail = (tail + 1) % N)。
  • V(sem_data):写入完成后,数据数量 + 1,通知消费者有新数据可读。

消费者每次读取数据前,需要先申请数据:

  • P(sem_data):申请数据,如果队列为空(无数据),消费者会阻塞等待。
  • 从队列读取数据,更新消费者指针headhead = (head + 1) % N)。
  • V(sem_blank):读取完成后,空位置数量 + 1,通知生产者有空位置可写。

三、线程池

3.1池化技术

3.1.1 池化的核心逻辑:从 "预制菜" 理解效率优化

用 "预制菜" 类比内存池,这个例子非常形象,我们先拆解底层逻辑:

程序向操作系统申请内存(比如 C 语言的malloc、C++ 的new),本质上最终都会触发系统调用 (比如 Linux 下的brk/mmap)。而系统调用会涉及 "用户态→内核态→用户态" 的切换,这个过程是有时间成本的。

如果程序频繁申请、释放小块内存,就会导致大量的系统调用,带来明显的性能损耗。而内存池的思路,就像提前做好的预制菜:

  • 一次性向操作系统申请一大块连续内存,作为 "内存池";
  • 程序后续需要内存时,直接从池子里分配,用完再放回池子,而不是每次都向 OS 申请;
  • 把 "多次小系统调用" 变成 "一次大系统调用",大幅减少开销,提升效率。

3.1.2 线程池:池化思想的延伸应用

线程池也是一种 "池化" 技术,它和内存池的核心逻辑完全一致:

线程的创建和销毁同样依赖系统调用,频繁创建 / 销毁线程会带来内核态开销,还会增加上下文切换的成本。而线程池的优化思路是:

  • 提前创建好一批线程,放在 "池子" 里;
  • 任务来了直接分配给池子里的线程执行,任务完成后线程不销毁,回到池子里等待下一个任务;
  • 本质还是通过复用已创建的线程资源,减少频繁创建 / 销毁的系统调用开销,提升并发处理效率。

池化技术的核心总结:提前申请资源 → 重复利用资源 → 减少系统调用次数 → 最终实现性能提升。除了内存池、线程池,连接池(数据库连接池)、对象池也都是同样的思想。

3.2日志与策略模式

cpp 复制代码
[可读性很好的时间] [⽇志等级] [进程pid] [打印对应⽇志的⽂件名][⾏号] - 消息内容,⽀持可变参数
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [16] - hello world
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [17] - hello world
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [18] - hello world
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [20] - hello world
[2024-08-04 12:27:03] [DEBUG] [202938] [main.cc] [21] - hello world
[2024-08-04 12:27:03] [WARNING] [202938] [main.cc] [23] - hello world

一条完整的日志,通常包含以下核心组成部分:

  1. 可读性时间:精确到秒 / 毫秒的时间戳,快速定位问题发生的时间点;
  2. 日志等级:标记日志的严重程度,方便过滤和排查;
  3. 进程 PID:记录输出日志的进程 ID,多进程场景下区分来源;
  4. 文件名 + 行号:标记日志所在的代码文件和行号,快速定位代码位置;
  5. 消息内容:日志的主体信息,支持拼接变量、格式化字符串等可变参数。

有5 种常见的日志级别,从上到下严重程度递增:

3.3线程池设计

3.3.1什么是线程池?

线程池是一种线程复用的线程使用模式,它的核心逻辑可以概括为:提前创建一批线程并放入 "池子" 中统一管理,当任务到来时,直接分配池中的空闲线程执行任务;任务执行完成后,线程不会被销毁,而是回归池中等待下一次任务分配。

核心设计初衷

我们可以先理解 "频繁创建 / 销毁线程" 的代价,这也是线程池诞生的根本原因:

  • 系统调用开销:创建线程需要操作系统分配栈内存、初始化线程控制块(TCB),销毁线程则需要回收资源,这些都是昂贵的系统调用操作;
  • 调度与缓存损耗:大量线程会导致操作系统频繁进行上下文切换,破坏 CPU 缓存的局部性,降低整体执行效率;
  • 资源失控风险:无限制创建线程可能耗尽系统内存、CPU、文件描述符等资源,最终导致服务崩溃。

而线程池通过复用线程,实现了三大核心价值:

  1. 避免短任务场景下线程创建 / 销毁的重复开销;
  2. 控制并发线程数量,防止系统资源被耗尽;
  3. 保证 CPU 内核的充分利用,同时避免过度调度带来的性能损耗。

补充:线程池的可用线程数量并非越大越好,需要根据业务场景合理设置,一般取决于可用的 CPU 核心数、内存、网络 socket 数量等资源。

3.3.2线程池的典型应用场景

线程池的优势在特定场景下才能最大化发挥,以下是其最适配的三类场景:

1. 大量短时间并发任务场景

这是线程池最经典的应用场景,比如 Web 服务器处理 HTTP 请求、接口服务处理用户请求等。这类场景的特点是:单个任务耗时短,但任务数量极大(例如热门网站的高并发请求)。此时线程池的复用机制能完美规避线程创建 / 销毁的开销,大幅提升服务的响应效率。

⚠️ 反例说明:长时间任务(如 Telnet 长连接请求、超长时间计算任务)不适合使用线程池。这类任务的执行时间远大于线程创建时间,线程池的复用优势无法体现,甚至可能导致线程池被长任务占满,阻塞后续短任务的执行。

2. 对响应速度要求苛刻的场景

比如电商秒杀、即时通讯消息推送等业务,要求服务器迅速响应客户请求。线程池中的线程提前处于就绪状态,任务到来时可直接分配执行,无需等待线程创建,能有效降低请求的响应延迟。

3. 应对突发性大量请求的场景

当业务面临突发性流量高峰(如促销活动、热点事件引发的流量洪峰)时,没有线程池的情况下,系统会为每个请求创建新线程,短时间内大量线程可能耗尽内存资源,导致服务崩溃。线程池可以限制并发线程的最大数量,将超出处理能力的任务放入队列中缓冲,既保证了系统的稳定性,又能平稳处理突发流量。

3.3.3线程池的常见种类与对比

常见的线程池主要分为两类:固定数量线程池浮动(动态 / 缓存)线程池,两者的核心区别在于线程数量是否可变,具体对比如下:

类型 核心特点 工作逻辑 优点 缺点 适用场景
固定线程池 线程数量固定不变 初始化时创建固定数量的线程,循环从任务队列中获取任务执行,任务完成后线程回归池中等候 实现简单,线程数量可控,不会过度消耗资源;任务执行稳定,无额外线程创建开销 线程数量固定,无法应对流量波动;若任务队列过长,可能导致任务堆积,响应延迟增加 CPU 密集型任务、任务数量可控的场景、对稳定性要求高的服务
浮动线程池 线程数量动态伸缩 核心线程数基础上,根据任务量动态创建临时线程,空闲超时后自动回收临时线程 可灵活应对流量波动,任务少时自动回收资源,任务多时扩容处理,资源利用率高 线程动态创建 / 回收仍有一定开销;伸缩策略复杂,若配置不当可能引发性能问题 短时间突发流量场景、IO 密集型任务、任务量波动较大的服务

3.4线程安全的单例模式

3.4.1什么是单例模式?

单例模式的核心定义是:某个类在整个程序生命周期中,只允许存在一个实例对象,所有对该类的访问,最终都指向这同一个实例。

举个通俗的例子:就像一个国家只能有一个中央政府,单例类也只能有一个 "对象实例",避免多个实例带来的状态混乱和资源浪费。

在服务器开发场景中,单例模式的应用场景非常典型:

  • 当服务器需要加载上百 G 的全局数据到内存中时,通常会用一个单例类来统一管理这些数据,避免多个实例重复加载,造成内存浪费;
  • 数据库连接池、线程池、全局配置管理类,也普遍采用单例模式,保证全局状态的统一和资源的有序使用。

3.4.2单例模式的核心特点

结合定义和应用场景,单例模式的核心特点可以总结为三点:

  1. 全局唯一性:整个程序运行期间,类的实例只能存在一个;
  2. 私有构造权限 :通常将类的构造函数设为私有,禁止外部通过new关键字直接创建实例;
  3. 统一访问入口:提供一个静态公共方法,供外部获取这个唯一的实例对象。

3.4.3饿汉式 vs 懒汉式:两种单例实现逻辑

单例模式最常见的两种实现方式是饿汉式懒汉式,我们可以用 "洗碗" 的生活化例子,快速理解两者的核心区别:

实现方式 通俗类比(洗碗场景) 核心逻辑 核心特点
饿汉式 吃完饭立刻洗碗,下一顿直接拿干净的碗就能吃饭 程序启动时,就提前创建好单例对象,不管后续是否会使用 提前加载,启动即实例化
懒汉式 吃完饭先把碗放着,下一顿要用到碗的时候再洗碗 第一次调用获取实例的方法时,才创建单例对象 延时加载,用到再实例化

1. 饿汉式:提前加载,天生线程安全

饿汉式的核心逻辑是 "不管用不用,先创建好再说":

  • 程序启动阶段,单例对象就已经被实例化完成,后续所有调用都直接返回这个已存在的对象;
  • 优点:实现简单,天生线程安全(实例在程序初始化阶段就创建完成,多线程运行时不会再触发创建逻辑,不存在竞争问题);
  • 缺点:如果单例对象体积较大(比如管理上百 G 的全局数据),会拖慢服务器启动速度;如果整个程序运行中都没用到这个单例,还会造成内存浪费。
cs 复制代码
template <typename T>
class Singleton {
private:
    // 1. 私有构造函数:禁止外部通过new直接创建实例
    Singleton() = default;
    // 2. 静态成员变量:程序启动时自动初始化,全局唯一
    static T instance;

public:
    // 3. 全局访问入口:直接返回已创建的实例
    static T& GetInstance() {
        return instance;
    }

    // 4. 禁用拷贝/赋值:避免通过拷贝创建新实例,破坏单例唯一性
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
};

// 静态成员变量的初始化:程序启动时自动实例化
template <typename T>
T Singleton<T>::instance;

2. 懒汉式:延时加载,优化启动性能

懒汉式的核心思想是 **"延时加载"**:只有第一次使用单例对象时,才会创建实例,从而避免启动时加载大量不必要的对象,优化服务器的启动速度。

  • 优点:按需创建,启动速度快,不会浪费内存资源;
  • 缺点:原生实现存在线程安全问题 ------ 如果多个线程同时第一次调用获取实例的方法,可能会同时进入实例创建逻辑,导致创建出多个实例,破坏单例的唯一性。
cpp 复制代码
template <typename T>
class Singleton {
public:
    // 获取单例实例(唯一入口)
    static T& GetInstance() {
        // C++11 保证:静态局部变量 初始化是线程安全的!
        static T instance; 
        return instance;
    }

    // = delete:禁用拷贝构造、赋值运算符,保证单例唯一性
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

    // 禁用析构(可选,防止误删)
    ~Singleton() = delete;

private:
    // 私有构造:外部无法 new 创建对象
    Singleton() = default;
};

四、线程安全和重入问题

4.1先搞懂两个核心概念

1. 什么是线程安全?

定义:多个线程在访问共享资源时,能正确执行、互不干扰,不会破坏彼此的执行结果,就是线程安全的。

举个生活化的例子:把共享资源比作公共水杯,多个线程(人)同时使用它:

  • 如果大家只是 "喝水(读操作)",不会改变水杯里的水量,那怎么用都不会出错,这就是安全的;
  • 如果有人 "倒水(写操作)",但没做任何保护,就会出现 "刚倒的水被别人喝光""喝到错误水量" 的混乱情况,这就是线程不安全的。

核心问题:多线程并发访问共享资源时,会不会出现 "竞争冲突"。

2. 什么是可重入?

定义 :同一个函数被不同执行流调用,前一个流程还没执行完,就有其他执行流再次进入该函数,运行结果依然正确、没有任何问题,这个函数就是可重入函数;反之则是不可重入函数。

重入分为两种场景:

  • 多线程重入:多个线程同时进入同一个函数(和线程安全的场景有重叠);
  • 信号导致的重入:单个线程里,函数执行过程中被信号打断,信号处理函数又再次调用了这个函数(这是可重入特有的场景,也是它和线程安全的核心区别点)。

举个生活化的例子:把函数比作单人厕所

  • 你进去了(调用函数),还没出来(没执行完),突然被打断(信号来了),又进去一次(再次调用函数);
  • 如果厕所里的卫生纸、水不会因为两次进入而乱掉,两次使用都能正常完成,就是可重入的;
  • 如果第一次进去没锁门,第二次进去把东西搞乱了,导致两次使用都出错,就是不可重入的。

核心问题:函数被 "中途打断并再次调用" 时,自身状态会不会被破坏。

4.2常见场景拆解:安全 / 不安全、可重入 / 不可重入

  1. 线程不安全 vs 线程安全
场景 具体情况 为什么会这样?
线程不安全 不保护共享变量的函数 多个线程同时修改全局 / 静态变量,比如count++,会出现 "丢失更新" 的问题
线程不安全 函数状态随调用发生变化 比如用static变量记录调用次数,多个线程调用会导致计数混乱
线程不安全 返回静态变量指针的函数 多个线程同时访问同一个静态变量,会互相覆盖数据,导致结果错误
线程不安全 调用了线程不安全的函数 比如调用了早期实现的malloc(用全局链表管理内存,多线程调用会破坏链表结构)
--- --- ---
线程安全 共享变量只有读权限,无写权限 多个线程同时读同一个变量,不会修改数据,自然不会冲突
线程安全 类 / 接口的操作是原子操作 比如用std::atomic封装的变量,操作不可被打断,不会出现中间状态
线程安全 线程切换不会导致结果二义性 比如接口的返回值不会因为线程调度顺序而变化,结果始终一致
  1. 不可重入 vs 可重入
场景 具体情况 为什么会这样?
不可重入 调用malloc/free函数 早期malloc用全局链表管理内存,重入时会破坏链表结构,导致内存泄漏或崩溃
不可重入 调用标准 I/O 库函数(如printf 标准 I/O 库用了全局缓冲区,重入时会导致输出混乱(比如两次printf的内容混在一起)
不可重入 函数内使用静态 / 全局数据结构 比如static数组、链表,重入时会修改数据结构的状态,导致后续调用出错
不可重入 调用了其他不可重入函数 函数依赖的其他函数不可重入,自身也会变成不可重入的
--- --- ---
可重入 不使用全局 / 静态变量 所有变量都是局部变量,每次调用函数都会在栈上创建新副本,互不干扰
可重入 不使用malloc/new开辟的空间 不依赖堆上的共享资源,不会出现内存状态被破坏的问题
可重入 不返回静态 / 全局数据,所有数据由调用者提供 比如让调用者传入缓冲区,而不是返回静态缓冲区,避免数据被覆盖
可重入 不调用不可重入函数 比如不用printf,改用write直接输出,避免依赖标准 I/O 的全局状态
可重入 使用本地数据,或制作全局数据的本地拷贝 比如把全局变量复制一份到局部变量,只修改局部变量,不改变全局状态

4.3线程安全与可重入的联系 & 区别

1. 核心联系

  • 可重入函数一定是线程安全的:可重入函数没有共享状态,多个线程调用时不会互相干扰,天然满足线程安全的要求;
  • **函数不可重入,大概率也无法在多线程中安全使用:**除非额外加锁保护,但加锁只能解决多线程竞争问题,无法解决信号重入的问题。

2. 关键区别(一句话总结)

可重入函数是线程安全函数的一种;线程安全的函数,不一定是可重入的。

举个最经典的例子:加锁的函数
cpp 复制代码
std::mutex mtx;
int count = 0;

// 这个函数是线程安全的,但不是可重入的
void add_count() {
    // 加锁保证同一时间只有一个线程能进入
    std::lock_guard<std::mutex> lock(mtx);
    count++;
}
  • **为什么是线程安全的?**多个线程调用add_count()时,锁会保证同一时间只有一个线程能拿到锁并修改count,不会出现竞争冲突;
  • **为什么不是可重入的?**如果线程 A 在add_count()里拿到锁之后,被信号打断,信号处理函数又调用了add_count(),此时锁已经被线程 A 持有,再次拿锁会阻塞,导致死锁
维度 线程安全 可重入
核心场景 多线程并发访问共享资源 函数被中途打断(信号 / 多线程)并再次调用
解决的问题 多线程之间的资源竞争 单次执行流中被打断后的状态一致性
实现方式 依赖锁、原子操作等同步机制 依赖 "无共享状态" 的设计(不用全局 / 静态变量、不用堆共享资源)
  • 如果不考虑 "信号导致的重入" 场景,很多时候线程安全和可重入可以近似理解,但严格来说两者并不等价;
  • 可重入函数的要求比线程安全更严格:它不仅要解决多线程竞争问题,还要解决信号打断后的状态一致性问题;
  • 实际开发中,优先实现可重入函数(比如避免全局变量、用调用者传入的缓冲区),能同时满足线程安全和可重入的要求,是最稳妥的设计方式。

"无共享则可重入,可重入必线程安全;加锁能保线程安全,未必能保可重入。"

五、常见锁的概念

5.1死锁

一组进程(或线程)中的每个进程 / 线程都持有至少一个资源且不会主动释放,同时又在申请被组内其他进程 / 线程持有的、且不会被释放的资源,最终导致所有进程 / 线程陷入永久等待的状态,程序无法继续推进。

如图:

5.2死锁四个必要条件

  • 互斥条件:一个资源每次只能被一个执行流(线程 / 进程)使用,其他执行流必须等待,直到资源被释放。
  • 请求与保持条件:一个执行流因请求新资源而阻塞时,对自己已经获得的资源保持不释放。
  • 不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能被其他执行流强行剥夺,只能由持有资源的执行流主动释放。
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系,每个执行流都在等待下一个执行流持有的资源。

5.3避免死锁

  • 破坏死锁的四个必要条件:
  1. 可通过设计无锁数据结构、使用原子操作,或减少需要互斥访问的资源,降低对互斥锁的依赖。
  2. 一次性申请所有资源:线程在执行前,一次性申请所有需要的锁 / 资源,只有全部申请成功才开始执行,否则不持有任何资源;
  3. 设置超时机制:申请资源时设置超时时间,如果超时未获取到资源,主动释放已持有的所有资源,重新尝试;
  4. 打破等待链:如果线程无法按顺序获取资源,主动释放已持有的资源,打破循环依赖。
  • 避免锁未释放的场景
  • 死锁检测算法
  • 银行家算法

六、STL,智能指针和线程安全

6.1STL 容器

核心结论

STL 中的所有容器**(vectorlistmapunordered_map等)** ,默认都不是线程安全的

为什么不做默认线程安全?

STL 的设计哲学是 **"极致性能优先"**:

  1. 线程安全的实现必然依赖锁或原子操作,这会带来额外的性能开销,对于单线程场景来说是不必要的负担;
  2. 不同容器的访问模式差异极大,统一的锁策略无法适配所有场景:比如vector的随机访问、unordered_map的桶式结构,锁表、锁桶的性能差异巨大,标准库无法提供一个 "通用且高效" 的实现;
  3. 把线程安全的选择权交给用户,用户可以根据自己的业务场景选择最合适的同步方式,而不是被标准库强制绑定一个低效的方案。
场景 是否安全 说明
多个线程同时只读同一个容器 ✅ 安全 读操作不会修改容器的内部状态,不会产生数据竞争
一个线程修改容器,其他线程读取 / 修改容器 ❌ 不安全 会导致迭代器失效、数据损坏、程序崩溃
多个线程同时修改容器(插入 / 删除 / 修改) ❌ 不安全 容器内部状态(如链表指针、哈希桶结构)会被破坏

6.2智能指针

智能指针的线程安全特性,需要分unique_ptrshared_ptr两种情况讨论,很多人会在这里踩坑。

1. unique_ptr:天生不涉及线程安全问题

unique_ptr的核心特性是独占所有权,它不支持拷贝,只支持移动语义:

  • 一个unique_ptr只能被一个线程持有,无法被多个线程同时访问;
  • 当你把unique_ptr移动给另一个线程后,原线程的指针会失效,不会出现 "多个线程同时操作同一个unique_ptr" 的场景。

⚠️ 注意:unique_ptr本身不涉及线程安全问题,但它指向的对象如果被多个线程同时访问,仍然需要手动加锁保护 ,不要误以为用了unique_ptr就万事大吉。

2. shared_ptr:引用计数线程安全,但对象访问不保证

shared_ptr的线程安全需要分两层理解,这是最容易被误解的点:

✅ 安全的部分:引用计数的增减

shared_ptr通过引用计数实现多线程共享所有权,标准库使用 ** 原子操作(CAS)** 实现引用计数的增减:

  • 多个线程同时拷贝shared_ptr(引用计数 + 1)或析构shared_ptr(引用计数 - 1)时,计数操作是原子的,不会出现数据竞争;
  • 这保证了引用计数不会被破坏,不会出现内存泄漏或重复释放的问题。
❌ 不安全的部分:这两个场景仍然会出问题

很多人误以为 "shared_ptr是线程安全的",就直接在多线程里用,结果踩了这两个坑:

  1. 多个线程同时修改shared_ptr指向的对象shared_ptr只保护引用计数,不保护它指向的对象本身。如果多个线程同时读写同一个对象,和普通裸指针一样会产生数据竞争,需要用户自己加锁保护。
  2. 多个线程同时修改同一个shared_ptr变量本身 :给shared_ptr本身赋值、reset()等操作不是原子的,多个线程同时修改会导致数据竞争,可能引发内存泄漏或重复释放。

3. 多线程下shared_ptr的正确用法

  • 当多个线程共享同一个对象时,对象的读写操作必须用锁或原子操作保护
  • 当需要多个线程同时修改同一个shared_ptr变量时,用std::mutex包裹赋值 /reset操作,或者使用 C++20 的std::atomic<std::shared_ptr<T>>实现原子访问;
  • 优先通过拷贝shared_ptr的方式持有对象所有权,再对对象本身加锁,避免直接修改同一个shared_ptr变量

七、读者写者问题与读写锁

在多线程并发编程中,有一类非常经典的同步问题:读者写者问题。它描述了多个线程对共享资源的访问场景 ------ 部分线程(读者)只读取数据,部分线程(写者)修改数据,我们需要在保证数据一致性的同时,最大化读操作的并发性能。

7.1什么是读者写者问题?

7.1.1核心场景与约束

读者写者问题的核心是对共享资源的访问控制,需要满足三个关键约束:

  1. 读共享:多个读者可以同时访问共享资源,互不干扰;
  2. 写独占:写者必须独占访问共享资源,任何其他读者或写者都不能同时访问;
  3. 数据一致性:写者修改数据时,读者不能读取到 "中间状态" 的脏数据。

这类场景在实际开发中非常常见:比如数据库的查询与修改、配置中心的读取与更新、缓存服务的访问与刷新,都是典型的 "读多写少" 场景 ------ 读操作的数量远多于写操作,且读操作耗时较长,如果用普通互斥锁,每次读都要加锁,会极大降低并发性能,读写锁就是为这类场景而生的。

7.1.2读者写者 vs 生产者消费者

对比维度 读者写者问题 生产者消费者问题
核心目标 优化读多写少场景的并发性能,实现 "读共享、写独占" 解决生产者与消费者的同步问题,保证缓冲区 "满 / 空" 状态下的线程阻塞与唤醒
访问关系 读者之间可以并发;写者与所有线程互斥 生产者之间、消费者之间、生产者与消费者之间均互斥
典型场景 数据库查询、配置读取、缓存访问 消息队列、任务队列、数据缓冲处理

重点:消费者会把数据取走,读者不会!

7.2伪代码理解:读者优先的实现逻辑

下面是典型的读者优先策略的伪代码实现,也是理解读者写者问题的核心逻辑:

公共变量定义

cpp 复制代码
uint32_t reader_count = 0; // 当前正在读的读者数量
lock_t count_lock;         // 保护reader_count的互斥锁
lock_t writer_lock;        // 写者独占锁,保证写操作的互斥性

读者线程逻辑

cpp 复制代码
// 1. 进入读操作前:
lock(count_lock);          // 锁住读者计数,防止多个读者同时修改count
if(reader_count == 0) {     // 如果是第一个读者
    lock(writer_lock);      // 申请写者锁,阻止写者进入
}
++reader_count;             // 读者计数+1
unlock(count_lock);        // 释放计数锁,其他读者可以继续进入

// 2. 读操作(多个读者可并发执行)
// read;

// 3. 退出读操作后:
lock(count_lock);          // 再次锁住读者计数
--reader_count;            // 读者计数-1
if(reader_count == 0) {    // 如果是最后一个读者
    unlock(writer_lock);   // 释放写者锁,允许写者进入
}
unlock(count_lock);        // 释放计数锁

写者线程逻辑

cpp 复制代码
lock(writer_lock);         // 申请写者锁(独占)
// 写操作
// write;
unlock(writer_lock);       // 释放写者锁

这个伪代码的核心是reader_count和双锁的配合:

  1. count_lock:保护读者计数的互斥锁,保证多个读者修改reader_count时不会出现数据竞争;
  2. writer_lock:写者的独占锁,只要有读者持有它,写者就无法进入;
  3. 只有第一个读者 会申请writer_lock,阻止写者进入;只有最后一个读者 会释放writer_lock,让写者有机会执行;
  4. 多个读者可以同时通过计数检查,进入读操作,实现 "读共享";写者必须等所有读者退出后,才能拿到writer_lock,实现 "写独占"。

这就是典型的读者优先 策略:只要有读者在,后续的读者都可以直接进入,写者会被一直阻塞,直到没有读者为止。

7.3读写锁:pthread 原生实现的读写同步

伪代码实现的读者优先策略逻辑清晰,但在实际开发中,我们更常用 pthread 提供的读写锁(pthread_rwlock_t,它是专门为读写场景设计的同步原语,支持读锁、写锁的区分。

读写锁的核心特性

读写锁的状态转换遵循以下规则:

当前锁状态 读锁请求 写锁请求
无锁 可以 可以
读锁 可以 阻塞
写锁 阻塞 阻塞

简单来说就是:写独占,读共享,读锁优先级更高(默认策略)。

读写锁的核心接口

cpp 复制代码
// 1. 初始化读写锁
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock, 
                        const pthread_rwlockattr_t *restrict attr);

// 2. 销毁读写锁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);

// 3. 加锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); // 加读锁
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); // 加写锁

// 4. 解锁
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock);

模拟代码:

cpp 复制代码
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include <vector>
#include <cstdlib>
#include <ctime>

// 共享资源
int shared_data = 0;

// 读写锁
pthread_rwlock_t rwlock;

// 读者线程函数
void *Reader(void *arg)
{
    //sleep(1); //读者优先,一旦读者进入&&读者很多,写者基本就很难进入了
    int number = *(int *)arg;
    while (true)
    {
        pthread_rwlock_rdlock(&rwlock); // 读者加锁
        std::cout << "读者-" << number << " 正在读取数据, 数据是: " << shared_data << std::endl;
        sleep(1);                       // 模拟读取操作
        pthread_rwlock_unlock(&rwlock); // 解锁
    }
    delete (int*)arg;
    return NULL;
}

// 写者线程函数
void *Writer(void *arg)
{
    int number = *(int *)arg;
    while (true)
    {
        pthread_rwlock_wrlock(&rwlock); // 写者加锁
        shared_data = rand() % 100;     // 修改共享数据
        std::cout << "写者- " << number << " 正在写入. 新的数据是: " << shared_data << std::endl;
        sleep(2);                       // 模拟写入操作
        pthread_rwlock_unlock(&rwlock); // 解锁
    }
    delete (int*)arg;
    return NULL;
}

int main()
{
    srand(time(nullptr)^getpid());
    pthread_rwlock_init(&rwlock, NULL); // 初始化读写锁

    // 可以更高读写数量配比,观察现象
    const int reader_num = 2;
    const int writer_num = 2;
    const int total = reader_num + writer_num;
    pthread_t threads[total]; // 假设读者和写者数量相等

    // 创建读者线程
    for (int i = 0; i < reader_num; ++i)
    {
        int *id = new int(i);
        pthread_create(&threads[i], NULL, Reader, id);
    }

    // 创建写者线程
    for (int i = reader_num; i < total; ++i)
    {
        int *id = new int(i - reader_num);
        pthread_create(&threads[i], NULL, Writer, id);
    }

    // 等待所有线程完成
    for (int i = 0; i < total; ++i)
    {
        pthread_join(threads[i], NULL);
    }

    pthread_rwlock_destroy(&rwlock); // 销毁读写锁

    return 0;
}

运行时会看到:

  • 多个读者可以同时读取数据,输出连续的读者日志;
  • 写者会被频繁阻塞,很久才能执行一次;

7.4优先级策略:读者优先 vs 写者优先

pthread 读写锁支持通过属性设置优先级策略,核心接口如下:

cpp 复制代码
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);

pref支持三种选项:

  • PTHREAD_RWLOCK_PREFER_READER_NP:默认设置,读者优先,可能导致写者饥饿;
  • PTHREAD_RWLOCK_PREFER_WRITER_NP:写者优先(部分系统存在兼容性问题,表现与读者优先一致);
  • PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP:写者优先,且写者不能递归加锁。

7.4.1读者优先(Reader-Preference)

  • 逻辑:只要有读者正在读,后续的读者请求会被直接允许进入,写者必须等待所有读者退出才能执行;
  • 优点:最大化读操作的并发性能,适合读多写少、写不频繁的场景;
  • 缺点:写者可能饥饿 ------ 如果读者持续不断地到来,写者会一直被阻塞,永远无法获得锁;

7.4.2写者优先(Writer-Preference)

  • 逻辑:当有写者申请锁时,后续的读者请求会被阻塞,直到写者完成操作;
  • 优点:避免写者饥饿,写者可以优先获得锁,适合写操作优先级高的场景;
  • 缺点:读者可能饥饿 ------ 如果写者持续不断地到来,读者会一直被阻塞,影响读性能;

八、自旋锁

在多线程并发编程中,我们最常用的同步机制是互斥锁(pthread_mutex_t),它通过让线程进入休眠来等待锁释放,解决了资源竞争问题,但也带来了上下文切换的开销。而今天我们要聊的自旋锁(Spinlock) ,则是一种完全不同的同步思路 ------ 它让线程在循环中 "忙等待" 锁的释放,避免了线程休眠的开销,专门为短时间锁竞争场景而生。

8.1什么是自旋锁?

自旋锁是一种多线程同步机制,用于保护共享资源免受并发访问的影响。它的核心逻辑和普通互斥锁完全不同:

  • 普通互斥锁:线程尝试获取锁失败时,会进入休眠状态,主动让出 CPU,等待锁释放后被唤醒,期间会发生上下文切换;
  • 自旋锁:线程尝试获取锁失败时,不会休眠,也不会让出 CPU,而是在一个循环中持续检查锁的状态(也就是 "自旋"),直到锁被释放。

这种设计的核心目的是减少线程切换的开销,适合锁竞争时间极短的场景 ------ 毕竟,相比 "休眠 + 唤醒" 的上下文切换开销,几轮循环检查的成本要低得多。但如果使用不当,也会造成 CPU 资源的浪费。

8.2自旋锁的核心原理

自旋锁的实现依赖一个共享的标志位(比如布尔值),来表示锁的状态:

  • 标志位为false:锁处于可用状态;
  • 标志位为true:锁已被占用,其他线程无法获取。

线程尝试获取自旋锁时,会执行以下逻辑:

  1. 检查标志位状态:
    • 如果标志位为false(锁可用):将标志位设置为true,表示自己占用了锁,进入临界区执行;
    • 如果标志位为true(锁被占用):线程会进入循环,持续检查标志位,直到锁被释放(标志位变回false),再重复第一步的操作。
  2. 线程执行完临界区代码后,将标志位设置为false,释放锁,让其他等待的线程可以获取。

⚠️ 关键:这个标志位的读取和修改操作,必须是原子操作,否则会出现数据竞争,导致锁失效,这也是自旋锁实现的核心基础。

8.3自旋锁的优缺点与适用场景

维度 优点 缺点
性能开销 低延迟:无上下文切换,避免了线程休眠和唤醒的开销 CPU 浪费:如果锁持有时间长,自旋等待的线程会一直占用 CPU 循环,造成资源浪费
调度影响 减少系统调度开销:等待锁的线程不会被阻塞,不需要内核态的调度操作 活锁风险:多个线程同时自旋等待同一把锁时,如果没有退避策略,可能所有线程都无法获取锁,形成活锁

什么时候该用自旋锁?

自旋锁不是 "万能锁",它的优势只在特定场景下才能发挥:

  • 短时间锁竞争场景:锁被占用的时间极短(比如临界区只有几行简单的读写操作),此时上下文切换的开销远大于自旋等待的开销;
  • 多 CPU / 多核环境:通常用于系统底层,同步多个 CPU 对共享资源的访问,避免跨 CPU 的线程切换开销;
  • ❌ 不适合长时间持有锁的场景:如果临界区代码执行时间较长,自旋等待会导致 CPU 空转,反而降低整体性能。

8.4自旋锁的两种实现方式

8.4.1 纯软件实现:基于 CAS 的自旋锁

自旋锁的软件实现依赖CAS(Compare-And-Swap,比较并交换)原子操作,它可以保证对标志位的读取和修改是原子的,不会出现数据竞争。

下面是基于 C11 标准**stdatomic.h**的自旋锁伪代码实现:

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

// 使用原子标志位模拟自旋锁,初始状态为未占用(false)
atomic_flag spinlock = ATOMIC_FLAG_INIT; 

// 自旋锁加锁:循环检查并尝试获取锁
void spinlock_lock() {
    // atomic_flag_test_and_set:原子地检查标志位并设置为true
    // 如果标志位之前为false(未占用),则设置为true并返回false,退出循环
    // 如果标志位之前为true(已占用),则返回true,继续循环自旋
    while (atomic_flag_test_and_set(&spinlock)) {
        // 可添加__builtin_pause指令,减少CPU空转损耗
    }
}

// 自旋锁解锁:将标志位重置为false,释放锁
void spinlock_unlock() {
    atomic_flag_clear(&spinlock);
}

atomic_flag_test_and_set:

  • 功能:原子地检查标志位的当前状态:
    • 如果标志位为false(未设置),则将其设置为true(已设置),并返回false
    • 如果标志位为true(已设置),则不修改状态,直接返回true
  • 原子性:整个操作是不可分割的,多线程环境下不会出现 "读到中间状态" 的情况,保证了锁的线程安全性。

8.4.2Linux pthread 自旋锁系统调用

Linux 提供了原生的pthread_spinlock_t自旋锁,封装了底层的原子操作,直接调用即可使用,接口如下:

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

// 初始化自旋锁
int pthread_spin_init(pthread_spinlock_t *lock, int pshared);

// 销毁自旋锁
int pthread_spin_destroy(pthread_spinlock_t *lock);

// 加锁:循环自旋等待,直到获取锁
int pthread_spin_lock(pthread_spinlock_t *lock);

// 尝试加锁:非阻塞,立即返回是否获取成功
int pthread_spin_trylock(pthread_spinlock_t *lock);

// 解锁
int pthread_spin_unlock(pthread_spinlock_t *lock);

pshared参数:

  • 设置为PTHREAD_PROCESS_PRIVATE表示锁仅在本进程内的线程间共享;
  • 设置为PTHREAD_PROCESS_SHARED可用于多进程间的同步(需共享内存支持)。

8.5样例

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

int ticket = 1000; // 总票数
pthread_spinlock_t lock; // 自旋锁

void *route(void *arg) {
    char *id = (char *)arg;
    while (1) {
        // 加自旋锁,进入临界区
        pthread_spin_lock(&lock);

        if (ticket > 0) {
            usleep(1000); // 模拟售票操作耗时(注意:耗时过长会导致其他线程空转)
            printf("%s sells ticket:%d\n", id, ticket);
            ticket--;
            pthread_spin_unlock(&lock); // 解锁
        } else {
            pthread_spin_unlock(&lock); // 解锁,避免死锁
            break;
        }
    }
    return NULL;
}

int main(void) {
    // 初始化自旋锁
    pthread_spin_init(&lock, PTHREAD_PROCESS_PRIVATE);

    pthread_t t1, t2, t3, t4;
    pthread_create(&t1, NULL, route, (void *)"thread 1");
    pthread_create(&t2, NULL, route, (void *)"thread 2");
    pthread_create(&t3, NULL, route, (void *)"thread 3");
    pthread_create(&t4, NULL, route, (void *)"thread 4");

    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);

    // 销毁自旋锁
    pthread_spin_destroy(&lock);
    return 0;
}

8.6自旋锁使用的注意事项与避坑指南

  • 临界区代码必须尽可能短:这是自旋锁使用的核心原则。如果锁持有时间过长,其他线程会一直循环自旋,浪费 CPU 资源,甚至导致系统性能下降;
  • 避免在自旋锁保护的临界区中执行休眠 / 阻塞操作:如果持有自旋锁的线程进入休眠,其他线程会一直自旋等待,导致 CPU 空转,且无法释放锁,造成死锁;
  • 多 CPU 环境下的性能问题:在多 CPU 环境中,不同 CPU 上的线程自旋等待同一把锁时,可能会导致跨 CPU 的缓存一致性流量增加,反而降低性能;
  • 警惕活锁问题 :多个线程同时自旋等待同一把锁时,如果没有退避策略(比如随机延时、yield),可能所有线程都无法获取锁,形成活锁。实际实现中,可以添加__builtin_pause指令或sched_yield(),减少 CPU 空转的损耗;
  • 和互斥锁的选择:短锁用自旋,长锁用互斥
    • 临界区执行时间短(微秒级):自旋锁性能更好;
    • 临界区执行时间长(毫秒级及以上):互斥锁的休眠 + 唤醒开销远小于自旋空转,更适合使用。
相关推荐
lingran__2 小时前
C++入门基础
开发语言·c++
Hello_wshuo2 小时前
v3s镜像从零开始构建
linux·嵌入式
Felven2 小时前
国产ZYNQ multiboot功能介绍与实现
linux·fpga开发·multiboot·国产zynq
吃好睡好便好2 小时前
Matlab中三种三维图的对比
开发语言·人工智能·学习·算法·matlab·信息可视化
代码改善世界2 小时前
【C++进阶】二叉搜索树
java·数据结构·c++
Highcharts.js2 小时前
无需搭建数据管道,如何快速上线投资基金筛选器?
开发语言·javascript·react.js·前端框架·highcharts
雨落在了我的手上2 小时前
初识java(六):方法的使用
java·开发语言
脆皮炸鸡7552 小时前
进程通信----命名管道
linux·经验分享·笔记·算法·学习方法
春蕾夏荷_7282977252 小时前
c++ 编译abseil-cpp
c++·abseil-cpp