封面


文章目录


引言

刚开始学线程的时候,我其实一直有一个比较简单的理解:线程就是“多个执行流一起跑”,只要 pthread_create 创建出来,大家各干各的就行了。但是学到线程同步和互斥之后,我才发现这个理解太浅了。
多线程真正麻烦的地方,不是“怎么创建线程”,而是多个线程同时访问同一份资源时,怎么保证数据不会乱。比如一个全局变量 ticket,看起来只是简单地 ticket--,但只要放到多线程环境下,就可能卖出 0 号票、-1 号票,甚至出现更离谱的数据。
这个地方一开始我挺容易想错:我以为 ticket-- 就是一句代码,应该天然是一个整体。后来往底层看才发现,它在 CPU 层面可能对应多条指令:先从内存读出来,再在寄存器里减一,最后再写回内存。只要中间发生线程切换,数据就可能出问题。
所以这篇博客主要是我学习线程互斥、线程同步、生产者消费者模型、信号量、线程池、线程安全与可重入时整理出来的理解。相比单纯记接口,我更想把几个问题想清楚:

  • 线程为什么会不安全?
  • 互斥锁到底保护了什么?
  • 条件变量为什么必须配合互斥锁?
  • 生产者消费者模型为什么这么经典?
  • 线程池为什么能提升服务器效率?
  • 什么叫线程安全,什么叫可重入?

学完之后我最大的感受是:多线程不是简单地“让程序跑得更快”,而是在共享资源、执行顺序和安全性之间做平衡。


1. 线程互斥

1.1 互斥相关的几个基础概念

学习线程互斥之前,先要把几个词分清楚:
共享资源:多个线程都能访问到的资源,比如全局变量、堆区对象、文件描述符、日志文件等。
临界资源:被多个执行流共享,并且需要保护的资源。
临界区:线程内部访问临界资源的那段代码。
互斥:任意时刻,只允许一个执行流进入临界区访问临界资源。
原子性:一个操作不会被任何调度机制打断,要么全部完成,要么完全没做。

💡临界资源可以理解成宿舍公共洗衣机,临界区就是“你正在使用洗衣机”的那段时间。互斥就是同一时间只能一个人用,不能两个人同时把衣服塞进去。

线程互斥基本结构图

1.2 为什么多个线程操作共享变量会出问题

先看一个最经典的抢票代码。

#include <stdio.h>      // printf
#include <stdlib.h>     // 标准库函数
#include <string.h>     // 字符串相关接口
#include <unistd.h>     // usleep
#include <pthread.h>    // pthread_create / pthread_join

// 全局变量 ticket 属于共享资源
// 所有线程都可以访问它
int ticket = 100;

void *route(void *arg)
{
    // arg 是 pthread_create 传进来的线程名字
    // 这里强转成 char*,方便打印当前是哪一个线程在卖票
    char *id = (char*)arg;

    while (1)
    {
        // 判断票数是否大于 0
        // 注意:这个判断本身不受保护
        if (ticket > 0)
        {
            // 模拟业务处理耗时
            // 在 sleep 的过程中,线程很可能被切走
            usleep(1000);

            // 打印当前线程卖出的票号
            printf("%s sells ticket:%d\n", id, ticket);

            // 对共享变量 ticket 做自减
            // 这个操作不是原子操作
            ticket--;
        }
        else
        {
            // 没票了,退出循环
            break;
        }
    }

    return nullptr;
}

int main(void)
{
    pthread_t t1, t2, t3, t4;

    // 创建 4 个线程,同时执行 route 函数
    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");

    // 主线程等待 4 个新线程结束
    pthread_join(t1, NULL);
    pthread_join(t2, NULL);
    pthread_join(t3, NULL);
    pthread_join(t4, NULL);

    return 0;
}

可能出现的结果:

thread 4 sells ticket:100
...
thread 4 sells ticket:1
thread 2 sells ticket:0
thread 1 sells ticket:-1
thread 3 sells ticket:-2

这段代码最容易误解的地方在于:我一开始会觉得 if (ticket > 0) 已经判断过了,为什么还能卖出负数票?

关键原因有两个:

  • 第一,if 判断完成之后,线程可能被切换走。比如线程 A 判断 ticket > 0 成立,此时票数是 1,但它还没来得及 ticket--,线程 B、线程 C、线程 D 也可能进来判断,大家都认为还有票,于是最后就会卖出 0、-1、-2。

  • 第二,ticket-- 不是原子操作。

它看起来是一句 C/C++ 代码,但在底层可能被拆成类似下面几步:

# 查看可执行程序反汇编
objdump -d a.out > test.objdump
# 取出 ticket-- 对应的部分汇编代码

40064b: 8b 05 e3 04 20 00    mov 0x2004e3(%rip), %eax    # load:从内存读取 ticket 到寄存器 eax
400651: 83 e8 01             sub $0x1, %eax              # update:寄存器中的值减 1
400654: 89 05 da 04 20 00    mov %eax, 0x2004da(%rip)    # store:把结果写回 ticket 的内存地址

也就是说,ticket-- 实际上可以拆成:

load   :把 ticket 从内存加载到寄存器
update :在寄存器中执行 -1
store  :把新值写回内存

只要这三步中间发生线程切换,就可能出现数据不一致。

💡这就像几个人同时看手机上的库存,大家看到还剩 1 件,于是都点了购买。问题不是“大家不会点按钮”,而是查看库存和扣减库存之间没有被保护起来。


2. 互斥量 mutex

2.1 为什么需要互斥量

想要解决上面的抢票问题,本质上要做到三点:

  1. 当一个线程进入临界区时,其他线程不能进入同一个临界区。
  2. 如果多个线程同时想进入临界区,只能让其中一个线程进去。
  3. 如果线程没有在临界区里执行,它就不能阻止其他线程进入临界区。

这三点总结起来,其实就是一句话:需要一把锁。

在 Linux 线程库中,这把锁通常就是互斥量 mutex

互斥量的作用不是让所有线程都停下来,而是让多个线程在访问同一个临界资源时排队。

💡mutex 可以理解成厕所门上的锁。厕所本身是共享资源,进去上厕所的过程就是临界区。谁先锁门谁先用,其他人只能等门打开。

线程互斥示意图

2.2 互斥量的初始化

互斥量有两种初始化方式。

2.2.1 静态初始化

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

这种方式一般用于 全局变量 或者 静态变量。它的特点是简单,不需要手动调用初始化函数。

2.2.2 动态初始化

#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t *restrict mutex,
                       const pthread_mutexattr_t *restrict attr);

参数解释:

  • mutex:要初始化的互斥量地址。
  • attr:互斥量属性,一般传 NULL,表示使用默认属性。

返回值:

  • 成功返回 0
  • 失败返回错误码。

这里的 restrict 可以简单理解成一种编译器层面的提示,表示这个指针是访问该对象的主要方式。初学阶段不用被它卡住,重点理解函数作用即可。

2.3 互斥量的销毁

int pthread_mutex_destroy(pthread_mutex_t *mutex);

销毁互斥量时需要注意:

  • 使用 PTHREAD_MUTEX_INITIALIZER 静态初始化的互斥量,一般不需要手动销毁。
  • 不要销毁一个已经加锁的互斥量。
  • 已经销毁的互斥量,后续不能再被任何线程加锁。

这个地方很容易写错:如果某个线程还在使用锁,另一个线程就把锁销毁了,程序行为就不可控了。

2.4 互斥量加锁和解锁

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

pthread_mutex_lock 的含义是申请锁。

如果锁当前没有被线程持有,当前线程加锁成功,继续向下执行。
如果锁已经被其他线程持有,当前线程会阻塞等待。

pthread_mutex_unlock 的含义是释放锁。

当当前线程访问完临界区后,必须释放锁,否则其他线程会一直等待。


3. 使用 mutex 改进抢票系统

3.1 加锁版本代码

#include <stdio.h>      // printf
#include <stdlib.h>     // 标准库函数
#include <string.h>     // 字符串处理
#include <unistd.h>     // usleep
#include <pthread.h>    // pthread_create / pthread_join / pthread_mutex_xxx
#include <sched.h>      // 调度相关接口

// 共享资源:票数
int ticket = 100;

// 全局互斥量,用来保护 ticket
pthread_mutex_t mutex;

void *route(void *arg)
{
    char *id = (char*)arg;

    while (1)
    {
        // 进入临界区之前先加锁
        // 如果锁已经被其他线程拿走,当前线程会阻塞等待
        pthread_mutex_lock(&mutex);

        if (ticket > 0)
        {
            // 模拟业务处理耗时
            // 注意:这里虽然 sleep,但因为锁没有释放,其他线程不能进入临界区
            usleep(1000);

            printf("%s sells ticket:%d\n", id, ticket);

            // ticket-- 被锁保护起来之后,
            // 同一时刻只有一个线程能执行这段逻辑
            ticket--;

            // 临界区执行完毕,释放锁
            pthread_mutex_unlock(&mutex);
        }
        else
        {
            // 即使没票了,也必须先释放锁
            // 否则其他线程可能永远阻塞
            pthread_mutex_unlock(&mutex);
            break;
        }
    }

    return nullptr;
}

int main(void)
{
    pthread_t t1, t2, t3, t4;

    // 动态初始化互斥量
    pthread_mutex_init(&mutex, NULL);

    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_mutex_destroy(&mutex);

    return 0;
}

3.2 代码理解

这段代码和前面最大的区别就是:

pthread_mutex_lock(&mutex);

pthread_mutex_unlock(&mutex);

把访问 ticket 的代码包起来了。

这里要注意一个非常重要的点:锁保护的是临界区,不是变量本身。

也就是说,ticket 这个变量还是那个变量,内存里没有发生什么神奇变化。真正变化的是:访问 ticket 的线程必须先抢到锁。

这个地方我一开始挺容易想错,总觉得“给变量加锁”像是变量自己被锁住了。后来才明白,锁本质上是保护访问这个变量的代码路径。

3.3 不能忘记解锁

这段代码里 else 分支也要解锁:

else
{
    pthread_mutex_unlock(&mutex);
    break;
}

如果这里忘记 pthread_mutex_unlock,当前线程在没票后直接 break,但锁还在它手里,其他线程就会永远卡在 pthread_mutex_lock

这就是典型的死锁问题。

💡这就像你用完自习室钥匙后直接回宿舍了,但钥匙还在你口袋里。后面所有想进自习室的人都会被卡住。


4. 互斥量实现原理探究

4.1 i++ 和 ++i 为什么不是原子的

经过抢票问题之后,可以发现单纯的 i++++ii----i 都不是原子操作。

它们都可能被拆成:

读取内存数据
修改寄存器中的值
写回内存

如果多个线程同时执行,就可能发生覆盖写。

4.2 lock 和 unlock 底层为什么需要硬件支持

为了实现互斥锁,大多数 CPU 体系结构都会提供类似 swapexchange 的指令。

这种指令可以把寄存器和内存单元的数据交换,并且这个交换过程是一条原子指令。

即使在多核 CPU 上,访问内存总线也有先后顺序,一个处理器执行交换指令时,另一个处理器必须等待。

可以用下面的伪代码理解:

lock:
    movb $0, %al
    xchgb %al, mutex
    if al 的内容 > 0:
        return 0
    else:
        挂起等待
        goto lock

unlock:
    movb $1, mutex
    唤醒等待 mutex 的线程
    return 0

这段伪代码想说明:加锁动作本身也必须是原子的。

如果加锁动作不是原子的,那就可能两个线程同时认为自己拿到了锁,这样互斥就失效了。


5. RAII 风格封装互斥锁

5.1 为什么要封装锁

直接使用 pthread_mutex_lockpthread_mutex_unlock 有一个问题:非常容易忘记解锁。

尤其是在代码中出现 returnbreak、异常或者多个分支时,很可能某条路径没有释放锁。

C++ 中可以用 RAII 思想解决这个问题。

RAII 的核心是:

对象构造时获取资源。
对象析构时释放资源。

用在锁上就是:

构造函数加锁。
析构函数解锁。

💡RAII 就像离开教室自动关灯。你不用每次提醒自己“记得关灯”,因为这个动作已经和“离开教室”绑定在一起了。

5.2 Lock.hpp

#pragma once

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

namespace LockModule
{
    // 对锁进行封装,可以独立使用
    class Mutex
    {
    public:
        // 删除拷贝构造
        // 锁对象不应该被拷贝,否则多个 Mutex 对象可能管理同一把底层锁,容易出问题
        Mutex(const Mutex &) = delete;

        // 删除赋值运算符
        const Mutex &operator=(const Mutex &) = delete;

        Mutex()
        {
            // 初始化 pthread 互斥量
            // nullptr 表示使用默认属性
            int n = pthread_mutex_init(&_mutex, nullptr);
            (void)n;
        }

        void Lock()
        {
            // 加锁
            // 如果锁已经被其他线程持有,当前线程会阻塞
            int n = pthread_mutex_lock(&_mutex);
            (void)n;
        }

        void Unlock()
        {
            // 解锁
            // 当前线程访问完临界区后调用
            int n = pthread_mutex_unlock(&_mutex);
            (void)n;
        }

        pthread_mutex_t *GetMutexOriginal()
        {
            // 获取原始 pthread_mutex_t 指针
            // 后面条件变量 pthread_cond_wait 需要用到原生互斥量
            return &_mutex;
        }

        ~Mutex()
        {
            // 销毁互斥量
            int n = pthread_mutex_destroy(&_mutex);
            (void)n;
        }

    private:
        pthread_mutex_t _mutex;
    };

    // 采用 RAII 风格进行锁管理
    class LockGuard
    {
    public:
        LockGuard(Mutex &mutex)
            : _mutex(mutex)
        {
            // 构造对象时自动加锁
            _mutex.Lock();
        }

        ~LockGuard()
        {
            // 对象析构时自动解锁
            _mutex.Unlock();
        }

    private:
        Mutex &_mutex;
    };
}

5.3 使用 RAII 改写抢票代码

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include "Lock.hpp"

using namespace LockModule;

int ticket = 1000;

// 使用自己封装的 Mutex
Mutex mutex;

void *route(void *arg)
{
    char *id = (char *)arg;

    while (1)
    {
        // lockguard 是局部对象
        // 构造时自动加锁,离开作用域时自动解锁
        LockGuard lockguard(mutex);

        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;
}

5.4 RAII 代码理解

这个版本里没有显式写 Unlock,但是锁仍然会被释放。

原因是:

LockGuard lockguard(mutex);

是一个局部对象。

当程序执行完当前作用域时,lockguard 会自动析构,析构函数中会调用:

_mutex.Unlock();

这就是 RAII 的价值:把释放资源的动作交给对象生命周期管理。

C++11 标准库也有类似写法:

#include <mutex>

std::mutex mtx;
std::lock_guard<std::mutex> guard(mtx);

RAII 锁生命周期示意图


6. 线程同步

6.1 为什么有了互斥还需要同步

互斥解决的是同一时刻只能一个线程访问临界资源的问题。

但是同步解决的是线程之间按照某种顺序访问资源的问题。

比如一个线程访问队列,发现队列为空。这时它就算拿到了锁,也没有数据可以取。它不应该一直占着锁死等,而应该释放锁,然后等待生产者放入数据。

这就是条件变量要解决的问题。

6.2 条件变量

条件变量适合处理这种情况:

一个线程发现当前条件不满足,于是进入等待。

另一个线程修改了条件,然后通知等待线程继续执行。

例如:

某个线程想从队列中获取数据,但发现队列为空,于是进入等待状态。

后来另一个线程向队列中放入数据,并发送通知。

等待线程被唤醒后继续执行。

后面学习生产者消费者模型时,我们会看到条件变量最经典的应用场景。

💡条件变量就像外卖取餐叫号。你发现餐还没好,就坐着等。等店员叫到你的号,你再过去取餐。


7. 条件变量接口

7.1 初始化条件变量

#include <pthread.h>

int pthread_cond_init(pthread_cond_t *restrict cond,
                      const pthread_condattr_t *restrict attr);

参数说明:

  • cond:要初始化的条件变量。
  • attr:条件变量属性,一般传 NULL

返回值:

  • 成功返回 0
  • 失败返回错误码。

7.2 销毁条件变量

int pthread_cond_destroy(pthread_cond_t *cond);

销毁条件变量时要确保没有线程还在这个条件变量上等待。

7.3 等待条件满足

int pthread_cond_wait(pthread_cond_t *restrict cond,
                      pthread_mutex_t *restrict mutex);

参数说明:

  • cond:当前线程要等待的条件变量。
  • mutex:配合条件变量使用的互斥锁。

这个函数非常关键,它会做三件事:

  1. 释放当前线程持有的互斥锁。
  2. 让当前线程在条件变量上等待。
  3. 被唤醒后,重新竞争互斥锁,成功后才返回。

7.4 唤醒等待线程

int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);

pthread_cond_signal:唤醒一个等待线程。
pthread_cond_broadcast:唤醒所有等待线程。


8. 条件变量简单案例

8.1 测试代码

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

// 静态初始化条件变量
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;

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

void *active(void *arg)
{
    std::string name = static_cast<const char*>(arg);

    while (true)
    {
        // 等待条件变量之前先加锁
        pthread_mutex_lock(&mutex);

        // 当前线程在 cond 条件变量上等待
        // pthread_cond_wait 会自动释放 mutex
        // 被唤醒后会重新竞争 mutex
        pthread_cond_wait(&cond, &mutex);

        std::cout << name << " 活动..." << std::endl;

        // 执行完临界区逻辑后解锁
        pthread_mutex_unlock(&mutex);
    }

    return nullptr;
}

int main(void)
{
    pthread_t t1, t2;

    pthread_create(&t1, NULL, active, (void*)"thread-1");
    pthread_create(&t2, NULL, active, (void*)"thread-2");

    // 确保两个线程已经运行起来并进入等待状态
    sleep(3);

    while (true)
    {
        // 唤醒一个等待线程
        // pthread_cond_signal(&cond);

        // 唤醒所有等待线程
        pthread_cond_broadcast(&cond);

        sleep(1);
    }

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

    return 0;
}

8.2 运行结果

$ ./cond
thread-1 活动...
thread-2 活动...
thread-1 活动...
thread-1 活动...
thread-2 活动...

这个结果不一定完全固定,因为线程调度顺序由操作系统决定。

pthread_cond_signal 每次只唤醒一个线程。
pthread_cond_broadcast 会唤醒所有等待线程。

8.3 条件变量不是保存信号的容器

这里要注意一个容易踩坑的点:

条件变量不会保存历史信号。

如果没有线程正在等待,提前发送的 signal 可能会丢失。

所以条件变量一般要配合某个具体条件使用,而不是单纯把它当成“消息队列”。


9. 为什么 pthread_cond_wait 需要互斥量

9.1 条件变化一定涉及共享数据

条件变量等待的“条件”通常不是凭空出现的,而是由共享数据决定的。

比如:

队列是否为空。
队列是否满。
任务是否到来。
资源是否可用。

这些状态都属于共享数据,所以必须用互斥锁保护。

9.2 错误设计

一开始我也容易想成下面这样:先加锁,发现条件不满足,就解锁,然后等待。

// 错误设计:解锁和等待不是原子操作
pthread_mutex_lock(&mutex);

while (condition_is_false)
{
    pthread_mutex_unlock(&mutex);

    // 解锁之后、真正等待之前,
    // 如果其他线程已经修改条件并发送信号,
    // 当前线程就可能错过这次通知
    pthread_cond_wait(&cond, &mutex);

    pthread_mutex_lock(&mutex);
}

pthread_mutex_unlock(&mutex);

问题就在于:

pthread_mutex_unlock(&mutex);
pthread_cond_wait(&cond, &mutex);

这两步之间不是原子的。

如果当前线程刚解锁,还没来得及进入等待,另一个线程就修改条件并发送信号,那么当前线程可能错过通知,后面就会一直阻塞。

9.3 正确理解 pthread_cond_wait

pthread_cond_wait 帮我们把“释放锁 + 挂起等待”做成一个原子过程。

也就是说,它不是简单地等待条件变量,而是带着锁一起配合完成同步。

条件变量的标准等待写法:

pthread_mutex_lock(&mutex);

while (条件为假)
{
    // 必须用 while,不能简单用 if
    pthread_cond_wait(&cond, &mutex);
}

// 条件满足后,访问共享资源

pthread_mutex_unlock(&mutex);

发送通知方的标准写法:

pthread_mutex_lock(&mutex);

// 修改条件
// 例如:往队列中放入数据

pthread_cond_signal(&cond);

pthread_mutex_unlock(&mutex);

9.4 为什么要用 while 而不是 if

pthread_cond_wait 可能出现伪唤醒。

也就是说,线程被唤醒了,但条件不一定真的满足。

另外,如果多个线程被唤醒,资源可能已经被其他线程先拿走了。
所以被唤醒后必须重新判断条件。

💡这就像很多人在等奶茶,店员喊了一声“好了”,你不能不看单号就直接拿。你被喊醒了,不代表那杯一定是你的,还得重新确认条件。

pthread_cond_wait 原子释放锁与等待示意图


10. 条件变量封装

10.1 Cond.hpp

#pragma once

#include <iostream>
#include <string>
#include <pthread.h>
#include "Lock.hpp"

namespace CondModule
{
    using namespace LockModule;

    class Cond
    {
    public:
        Cond()
        {
            // 初始化条件变量
            int n = pthread_cond_init(&_cond, nullptr);
            (void)n;
        }

        void Wait(Mutex &mutex)
        {
            // 等待条件变量
            // 这里必须传入原始 pthread_mutex_t*
            int n = pthread_cond_wait(&_cond, mutex.GetMutexOriginal());
            (void)n;
        }

        void Notify()
        {
            // 唤醒一个等待线程
            int n = pthread_cond_signal(&_cond);
            (void)n;
        }

        void NotifyAll()
        {
            // 唤醒所有等待线程
            int n = pthread_cond_broadcast(&_cond);
            (void)n;
        }

        ~Cond()
        {
            // 销毁条件变量
            int n = pthread_cond_destroy(&_cond);
            (void)n;
        }

    private:
        pthread_cond_t _cond;
    };
}

10.2 封装时为什么不在 Cond 内部持有 Mutex

这里有一个设计细节:不要让 Cond 类内部强行持有某个 Mutex

因为条件变量和互斥锁通常是一起被某个更大的数据结构管理的,比如阻塞队列、线程池。
如果 Cond 内部也绑定一把锁,后面组合时会让代码耦合变高。

所以更合理的方式是:Cond 只负责封装条件变量本身,等待时由外部传入对应的锁。


11. 生产者消费者模型

11.1 为什么要使用生产者消费者模型

生产者消费者模型是多线程里非常经典的模型。

它的核心思想是:通过一个容器解决生产者和消费者之间的强耦合问题。

生产者生产完数据后,不直接交给消费者,而是放到阻塞队列中。
消费者不直接找生产者要数据,而是从阻塞队列里取。

这个阻塞队列就像缓冲区,平衡生产者和消费者的处理速度。

11.2 生产者消费者模型的优点

主要有三个:

第一,解耦。
生产者和消费者不直接通信,中间通过队列交互。

第二,支持并发。
多个生产者和多个消费者可以同时工作。

第三,支持忙闲不均。
如果生产者一段时间生产得快,队列可以暂时存放数据。
如果消费者一段时间处理得快,也可以快速消耗队列中的数据。

💡这就像食堂后厨和学生之间隔着取餐台。厨师只负责把饭放到取餐台,学生只负责从取餐台拿饭。双方不用一对一沟通,整体效率反而更高。

生产者消费者模型


12. 基于 BlockingQueue 的生产者消费者模型

12.1 BlockingQueue 的特点

阻塞队列和普通队列的区别在于:

队列为空时,消费者取数据会阻塞。
队列满时,生产者放数据会阻塞。

这刚好可以用互斥锁和条件变量实现。

12.2 BlockQueue.hpp

#ifndef __BLOCK_QUEUE_HPP__
#define __BLOCK_QUEUE_HPP__

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

template <typename T>
class BlockQueue
{
private:
    bool IsFull()
    {
        // 判断队列是否满
        return _block_queue.size() == _cap;
    }

    bool IsEmpty()
    {
        // 判断队列是否为空
        return _block_queue.empty();
    }

public:
    BlockQueue(int cap)
        : _cap(cap)
    {
        _productor_wait_num = 0;
        _consumer_wait_num = 0;

        // 初始化保护队列的互斥锁
        pthread_mutex_init(&_mutex, nullptr);

        // 初始化生产者等待的条件变量
        pthread_cond_init(&_product_cond, nullptr);

        // 初始化消费者等待的条件变量
        pthread_cond_init(&_consum_cond, nullptr);
    }

    void Enqueue(T &in)
    {
        // 生产者进入临界区前加锁
        pthread_mutex_lock(&_mutex);

        while (IsFull())
        {
            // 队列满了,生产者不能继续生产
            // pthread_cond_wait 会自动释放 _mutex
            // 被唤醒后,会重新竞争 _mutex
            _productor_wait_num++;
            pthread_cond_wait(&_product_cond, &_mutex);
            _productor_wait_num--;
        }

        // 进行生产
        _block_queue.push(in);

        // 如果有消费者正在等待,就唤醒一个消费者
        if (_consumer_wait_num > 0)
        {
            pthread_cond_signal(&_consum_cond);
        }

        // 释放锁
        pthread_mutex_unlock(&_mutex);
    }

    void Pop(T *out)
    {
        // 消费者进入临界区前加锁
        pthread_mutex_lock(&_mutex);

        while (IsEmpty())
        {
            // 队列为空,消费者不能消费
            // 使用 while 是为了防止伪唤醒
            _consumer_wait_num++;
            pthread_cond_wait(&_consum_cond, &_mutex);
            _consumer_wait_num--;
        }

        // 进行消费
        *out = _block_queue.front();
        _block_queue.pop();

        // 如果有生产者正在等待,就唤醒一个生产者
        if (_productor_wait_num > 0)
        {
            pthread_cond_signal(&_product_cond);
        }

        pthread_mutex_unlock(&_mutex);
    }

    ~BlockQueue()
    {
        pthread_mutex_destroy(&_mutex);
        pthread_cond_destroy(&_product_cond);
        pthread_cond_destroy(&_consum_cond);
    }

private:
    std::queue<T> _block_queue;       // 阻塞队列,属于共享资源
    int _cap;                         // 队列容量上限
    pthread_mutex_t _mutex;           // 保护队列的锁
    pthread_cond_t _product_cond;     // 生产者等待条件变量
    pthread_cond_t _consum_cond;      // 消费者等待条件变量
    int _productor_wait_num;          // 正在等待的生产者数量
    int _consumer_wait_num;           // 正在等待的消费者数量
};

#endif

12.3 代码理解

这段代码里有几个关键点。

第一,判断队列满或空时必须在锁内完成。因为队列本身是共享资源。

第二,等待条件变量必须使用 while,不能只用 if。因为被唤醒不代表条件一定满足。

第三,生产者生产完数据后,要通知消费者。
消费者消费完数据后,要通知生产者。

第四,条件变量不是锁。
真正保护队列的是 _mutex,条件变量只是负责让线程在条件不满足时等待。


13. Task 任务类型

13.1 队列中不一定只能放 int

阻塞队列使用模板实现,是为了让队列中不仅可以放内置类型,也可以放对象或任务。

比如可以自定义任务类:

#pragma once

#include <iostream>
#include <string>
#include <functional>

// 任务类型 1:可以自己定义 Task 类
// class Task
// {
// public:
//     Task() {}
//
//     Task(int a, int b)
//         : _a(a), _b(b), _result(0)
//     {
//     }
//
//     void Excute()
//     {
//         // 执行任务
//         _result = _a + _b;
//     }
//
//     std::string ResultToString()
//     {
//         return std::to_string(_a) + "+" + std::to_string(_b) + "=" +
//                std::to_string(_result);
//     }
//
//     std::string DebugToString()
//     {
//         return std::to_string(_a) + "+" + std::to_string(_b) + "=?";
//     }
//
// private:
//     int _a;
//     int _b;
//     int _result;
// };

// 任务类型 2:使用函数包装器
// 只要能被调用,就可以作为任务放入队列
using Task = std::function<void()>;

std::function<void()> 很适合线程池,因为线程池不需要关心任务内部到底干什么,只需要取出来执行即可。


14. POSIX 信号量

14.1 POSIX 信号量的作用

POSIX 信号量和 System V 信号量的作用类似,都是用于同步操作,避免多个执行流冲突访问共享资源。

不过 POSIX 信号量可以用于线程间同步。

14.2 初始化信号量

#include <semaphore.h>

int sem_init(sem_t *sem, int pshared, unsigned int value);

参数说明:

  • sem:要初始化的信号量。
  • pshared:为 0 表示线程间共享,非 0 表示进程间共享。
  • value:信号量初始值。

14.3 销毁信号量

int sem_destroy(sem_t *sem);

14.4 等待信号量

int sem_wait(sem_t *sem);

sem_wait 对应 P 操作(拿资源,可以理解成“Pick”)。
它会让信号量的值减 1。
如果信号量为 0,线程会阻塞等待。

14.5 发布信号量

int sem_post(sem_t *sem);

sem_post 对应 V 操作(还资源,“Return”)。
它会让信号量的值加 1,并可能唤醒等待线程。


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

15.1 环形队列的基本理解

环形队列可以用数组模拟,通过模运算让下标回绕。

普通队列判断空和满比较麻烦,因为起始状态和结束状态可能看起来一样。
常见做法有:

  • 加计数器。
  • 加标记位。
  • 预留一个空位置。
  • 使用信号量计数。

这里用信号量就非常合适。

15.2 Sem.hpp

#pragma once

#include <iostream>
#include <semaphore.h>

// 随手做一下封装
class Sem
{
public:
    Sem(int n)
    {
        // pshared 为 0,表示线程间共享
        // n 是信号量初始值
        sem_init(&_sem, 0, n);
    }

    void P()
    {
        // 申请资源
        // 如果信号量为 0,会阻塞
        sem_wait(&_sem);
    }

    void V()
    {
        // 释放资源
        // 信号量加 1
        sem_post(&_sem);
    }

    ~Sem()
    {
        sem_destroy(&_sem);
    }

private:
    sem_t _sem;
};

15.3 RingQueue.hpp

#pragma once

#include <iostream>
#include <string>
#include <vector>
#include <semaphore.h>
#include <pthread.h>
#include "Sem.hpp"

// 单生产,单消费
// 多生产,多消费
// "321":
// 3: 三种关系
// a: 生产和消费互斥和同步
// b: 生产者之间互斥
// c: 消费者之间互斥
// 2: 两类角色,生产者和消费者
// 1: 一个交易场所,环形队列

template <typename T>
class RingQueue
{
private:
    void Lock(pthread_mutex_t &mutex)
    {
        pthread_mutex_lock(&mutex);
    }

    void Unlock(pthread_mutex_t &mutex)
    {
        pthread_mutex_unlock(&mutex);
    }

public:
    RingQueue(int cap)
        : _ring_queue(cap),
          _cap(cap),
          _room_sem(cap),       // 初始时所有位置都是空位
          _data_sem(0),         // 初始时没有数据
          _productor_step(0),
          _consumer_step(0)
    {
        // 多生产者之间需要互斥
        pthread_mutex_init(&_productor_mutex, nullptr);

        // 多消费者之间需要互斥
        pthread_mutex_init(&_consumer_mutex, nullptr);
    }

    void Enqueue(const T &in)
    {
        // 生产者先申请空位
        // 如果没有空位,说明队列满了,生产者阻塞
        _room_sem.P();

        // 多个生产者之间要保护生产下标
        Lock(_productor_mutex);

        // 一定有空间可以写入
        _ring_queue[_productor_step++] = in;

        // 下标回绕
        _productor_step %= _cap;

        Unlock(_productor_mutex);

        // 写入数据后,通知消费者数据数量增加
        _data_sem.V();
    }

    void Pop(T *out)
    {
        // 消费者先申请数据
        // 如果没有数据,说明队列为空,消费者阻塞
        _data_sem.P();

        // 多个消费者之间要保护消费下标
        Lock(_consumer_mutex);

        *out = _ring_queue[_consumer_step++];

        // 下标回绕
        _consumer_step %= _cap;

        Unlock(_consumer_mutex);

        // 取走数据后,通知生产者空位数量增加
        _room_sem.V();
    }

    ~RingQueue()
    {
        pthread_mutex_destroy(&_productor_mutex);
        pthread_mutex_destroy(&_consumer_mutex);
    }

private:
    // 1. 环形队列
    std::vector<T> _ring_queue;
    int _cap;

    // 2. 生产和消费的下标
    int _productor_step;
    int _consumer_step;

    // 3. 定义信号量
    Sem _room_sem; // 生产者关心空位数量
    Sem _data_sem; // 消费者关心数据数量

    // 4. 定义锁,维护多生产多消费之间的互斥关系
    pthread_mutex_t _productor_mutex;
    pthread_mutex_t _consumer_mutex;
};

15.4 环形队列代码理解

这段代码里最关键的是两个信号量:

_room_sem 表示空位资源。
_data_sem 表示数据资源。

生产者执行顺序是:

申请空位
加生产者锁
写入数据
更新生产下标
解锁
释放数据资源

消费者执行顺序是:

申请数据
加消费者锁
取出数据
更新消费下标
解锁
释放空位资源

这里生产者之间需要互斥,因为多个生产者可能同时修改 _productor_step
消费者之间也需要互斥,因为多个消费者可能同时修改 _consumer_step

但是生产者和消费者之间主要通过信号量同步。

💡环形队列就像一个固定数量的快递柜。生产者放包裹前要先确认有空格,消费者取包裹前要先确认有包裹。空格数量和包裹数量,就是两个信号量。

环形队列生产消费模型


16. 线程池

16.1 为什么需要线程池

服务器中如果每来一个任务就创建一个线程,开销会很大。

线程池的思路是:

提前创建一批线程。
任务来了之后放入任务队列。
线程不断从队列中取任务执行。
没有任务时线程等待,有任务时被唤醒。

线程池适合:

大量短任务。
突发请求比较多的服务器。
不希望频繁创建和销毁线程的场景。

💡线程池就像快递驿站提前安排好的工作人员。包裹来了直接分配给工作人员处理,而不是每来一个包裹临时招聘一个人。

线程池整体结构图

16.2 线程池和生产者消费者模型的关系

线程池本质上就是生产者消费者模型的一种工程化应用。

提交任务的线程是生产者。
工作线程是消费者。
任务队列是交易场所。
条件变量负责没有任务时等待和有任务时唤醒。

所以理解了生产者消费者模型,再看线程池就会清楚很多。


17. 日志与策略模式

17.1 为什么线程池需要日志

多线程程序最难调试的地方是执行顺序不固定。

有些问题可能只在特定线程切换顺序下出现。
如果没有日志,就很难知道程序当时发生了什么。

一个比较完整的日志通常包含:

  • 时间戳
  • 日志等级
  • 进程 pid
  • 文件名
  • 行号
  • 日志内容

示例格式:

[2026-08-04 12:27:03] [DEBUG] [202938] [main.cc] [16] - hello world
[2026-08-04 12:27:03] [WARNING] [202938] [main.cc] [23] - hello world

💡日志就像实验记录本。程序正常跑的时候你可能觉得它没用,但一旦出问题,它就是你回溯现场的唯一线索。

17.2 日志等级

enum class LogLevel
{
    DEBUG,
    INFO,
    WARNING,
    ERROR,
    FATAL
};

不同等级表示不同严重程度。

DEBUG:调试信息。
INFO:普通运行信息。
WARNING:警告信息。
ERROR:错误信息。
FATAL:严重错误信息。

17.3 日志等级转字符串

std::string LogLevelToString(LogLevel level)
{
    switch (level)
    {
	    case LogLevel::DEBUG:
	        return "DEBUG";
	    case LogLevel::INFO:
	        return "INFO";
	    case LogLevel::WARNING:
	        return "WARNING";
	    case LogLevel::ERROR:
	        return "ERROR";
	    case LogLevel::FATAL:
	        return "FATAL";
	    default:
	        return "UNKNOWN";
    }
}

这段代码的作用是把枚举值转成字符串,方便最终打印到日志中。

17.4 获取当前时间

std::string GetCurrTime()
{
    time_t tm = time(nullptr);
    struct tm curr;

    // localtime_r 是线程安全版本
    // localtime 返回的是静态区地址,多线程环境下不安全
    localtime_r(&tm, &curr);

    char timebuffer[64];

    snprintf(timebuffer,
             sizeof(timebuffer),
             "%4d-%02d-%02d %02d:%02d:%02d",
             curr.tm_year + 1900,
             curr.tm_mon + 1,
             curr.tm_mday,
             curr.tm_hour,
             curr.tm_min,
             curr.tm_sec);

    return timebuffer;
}

这里有一个细节:多线程环境下建议使用 localtime_r,而不是 localtime

因为 localtime 可能使用静态缓冲区,多线程同时调用时可能造成数据覆盖。


18. 策略模式实现日志输出

18.1 策略模式的理解

策略模式简单来说就是:

把“会变化的行为”抽象成接口,然后让不同策略类去实现。

日志这里变化的部分是:日志输出到哪里。

可以输出到控制台。
也可以输出到文件。
后续还可以输出到网络、数据库等。

18.2 LogStrategy 接口

class LogStrategy
{
public:
    virtual ~LogStrategy() = default;

    // 不同策略的核心区别就是 SyncLog 的实现不同
    virtual void SyncLog(const std::string &message) = 0;
};

这是一个抽象基类,定义了统一的日志输出接口。

18.3 控制台日志策略

class ConsoleLogStrategy : public LogStrategy
{
public:
    void SyncLog(const std::string &message) override
    {
        // 显示器也是共享资源
        // 多线程同时输出可能会交叉,所以也需要加锁
        LockGuard lockguard(_mutex);
        std::cerr << message << std::endl;
    }

private:
    Mutex _mutex;
};

这里显示器也被看成临界资源。

如果多个线程同时向终端打印,输出内容可能交叉在一起,所以也需要加锁。

18.4 文件日志策略

class FileLogStrategy : public LogStrategy
{
public:
    FileLogStrategy(const std::string logpath = defaultpath,
                    std::string logfilename = defaultname)
        : _logpath(logpath),
          _logfilename(logfilename)
    {
        LockGuard lockguard(_mutex);

        if (std::filesystem::exists(_logpath))
        {
            return;
        }

        try
        {
            std::filesystem::create_directories(_logpath);
        }
        catch (const std::filesystem::filesystem_error &e)
        {
            std::cerr << e.what() << '\n';
        }
    }

    void SyncLog(const std::string &message) override
    {
        LockGuard lockguard(_mutex);

        std::string log = _logpath + _logfilename;

        // 追加方式打开日志文件
        std::ofstream out(log.c_str(), std::ios::app);

        if (!out.is_open())
        {
            return;
        }

        out << message << "\n";
        out.close();
    }

public:
    std::string _logpath;
    std::string _logfilename;
    Mutex _mutex;
};

文件日志策略需要注意两个点:

第一,如果日志目录不存在,需要先创建目录。
第二,多线程写文件也需要保护,否则日志内容可能乱序或交叉。


19. Logger 和 LogMessage

19.1 Logger 类

class Logger
{
public:
    Logger()
    {
        // 默认使用控制台日志策略
        UseConsoleStrategy();
    }

    void UseConsoleStrategy()
    {
        _strategy = std::make_unique<ConsoleLogStrategy>();
    }

    void UseFileStrategy()
    {
        _strategy = std::make_unique<FileLogStrategy>();
    }

private:
    std::unique_ptr<LogStrategy> _strategy;

    friend class LogMessage;
};

Logger 内部使用 std::unique_ptr<LogStrategy> 管理策略对象。

如果调用 UseConsoleStrategy,日志输出到控制台。
如果调用 UseFileStrategy,日志输出到文件。

19.2 LogMessage 类

class LogMessage
{
private:
    LogLevel _type;           // 日志等级
    std::string _curr_time;   // 当前时间
    pid_t _pid;               // 进程 ID
    std::string _filename;    // 文件名
    int _line;                // 行号
    Logger &_logger;          // 引用 Logger,用于调用具体策略
    std::string _loginfo;     // 拼接后的完整日志信息

public:
    LogMessage(LogLevel type,
               std::string filename,
               int line,
               Logger &logger)
        : _type(type),
          _curr_time(GetCurrTime()),
          _pid(getpid()),
          _filename(filename),
          _line(line),
          _logger(logger)
    {
        std::stringstream ssbuffer;

        ssbuffer << "[" << _curr_time << "] "
                 << "[" << LogLevelToString(type) << "] "
                 << "[" << _pid << "] "
                 << "[" << _filename << "] "
                 << "[" << _line << "]"
                 << " - ";

        _loginfo = ssbuffer.str();
    }

    template <typename T>
    LogMessage &operator<<(const T &info)
    {
        std::stringstream ssbuffer;
        ssbuffer << info;
        _loginfo += ssbuffer.str();

        return *this;
    }

    ~LogMessage()
    {
        if (_logger._strategy)
        {
            _logger._strategy->SyncLog(_loginfo);
        }
    }
};

这个类的设计也有 RAII 的味道。

构造时生成日志头部。
使用 << 拼接日志内容。
析构时自动把日志刷新出去。

19.3 宏封装

#define LOG(level) LogModule::Logger::LogMessage(level, __FILE__, __LINE__, logger)

#define ENABLE_CONSOLE_LOG_STRATEGY() logger.UseConsoleStrategy()
#define ENABLE_FILE_LOG_STRATEGY() logger.UseFileStrategy()

宏的好处是可以自动带上 __FILE____LINE__

这样打印日志时不需要手动写文件名和行号。


20. 线程安全的单例模式

20.1 为什么需要单例模式

有些对象在一个进程中只应该存在一份,比如:线程池、日志器、配置管理器。

  • 如果创建出多个线程池,可能会造成线程数量失控。
  • 如果创建出多个日志器,可能会导致日志输出混乱。

20.2 饿汉模式

template <typename T>
class Singleton
{
private:
    // 程序启动时就创建对象
    static T data;

public:
    static T *GetInstance()
    {
        return &data;
    }
};

饿汉模式的特点是:程序启动时就创建对象。

优点:简单、天然线程安全。

缺点:如果对象很大,会影响启动速度。即使后续不用,也已经创建了。

💡 饿汉模式就像吃完饭立刻洗碗。下一顿吃饭时可以直接用,但是你必须提前付出洗碗成本。

20.3 懒汉模式

template <typename T>
class Singleton
{
private:
    static T *inst;

public:
    static T *GetInstance()
    {
        if (inst == NULL)
        {
            inst = new T();
        }

        return inst;
    }
};

懒汉模式的特点是:第一次使用时才创建对象。

优点:延时加载,不用就不创建。

缺点:普通写法线程不安全。如果多个线程第一次同时调用 GetInstance,它们可能都看到 inst == NULL,于是创建出多个对象。

💡 懒汉模式就像吃完饭不洗碗,下次吃饭前在洗碗,在每次吃饭前会多一个洗碗的过程。

20.4 线程安全的懒汉模式

#include <mutex>

template <typename T>
class Singleton
{
private:
    // volatile 防止编译器过度优化
    volatile static T *inst;

    // 互斥锁保护第一次创建对象的过程
    static std::mutex lock;

public:
    static T *GetInstance()
    {
        // 第一层判断:减少不必要的加锁
        if (inst == NULL)
        {
            lock.lock();

            // 第二层判断:保证只有第一个抢到锁的线程真正创建对象
            if (inst == NULL)
            {
                inst = new T();
            }

            lock.unlock();
        }

        return (T*)inst;
    }
};

这里的双重判断很重要。

第一层 if 是为了性能:对象创建好之后,就不用每次都加锁。
第二层 if 是为了安全:防止多个线程排队进入锁后重复创建对象。


21. 线程安全与可重入

21.1 什么是线程安全

线程安全指的是:多个线程并发执行同一段代码时,不会因为访问共享资源而产生异常结果。

常见线程不安全原因:

  • 访问共享变量没有加锁;
  • 函数内部使用了静态变量;
  • 多个线程同时操作同一个容器;
  • 多个线程同时写同一个文件或显示器;
  • 使用了不可重入函数。

21.2 什么是可重入函数

可重入函数指的是:函数在执行过程中被打断后,再次进入执行也不会出问题。

如果一个函数只使用局部变量,不访问共享资源,一般更容易是可重入的。

如果一个函数使用了全局变量、静态变量、malloc/free、标准 IO 等,就可能不可重入。

21.3 线程安全和可重入的关系

可重入函数通常是线程安全的。
线程安全函数不一定是可重入的。

比如一个函数内部加锁访问共享资源,它可能是线程安全的。
但是如果它在信号处理函数中被重入,可能因为重复加锁导致死锁。

21.4 常见不可重入情况

常见不可重入情况包括:

  • 函数内部使用静态变量;
  • 函数返回静态区地址;
  • 函数调用了不可重入函数;
  • 函数内部使用全局数据结构;
  • 函数内部进行了加锁操作,但可能被信号打断后再次进入。

22. STL、智能指针与线程安全

22.1 STL 容器是否线程安全

STL 容器默认不是线程安全的。

原因很简单:标准库更偏向性能。如果每个容器操作都默认加锁,会带来很大性能损耗。

而且不同容器适合的加锁粒度也不同,比如:

  • 有些场景需要锁整个容器;
  • 有些场景只需要锁某个桶;
  • 有些场景读多写少,适合读写锁。

所以 STL 通常把线程安全控制交给使用者。

22.2 智能指针是否线程安全

unique_ptr 通常不涉及多线程共享所有权,因为它强调独占。

shared_ptr 的引用计数操作通常是线程安全的。
因为引用计数可能被多个 shared_ptr 对象共享,标准库实现一般会使用原子操作保证引用计数正确。

但是注意:

shared_ptr 管理的对象本身不一定线程安全。

也就是说,多个线程拷贝 shared_ptr 通常没问题。
但是多个线程通过 shared_ptr 同时修改对象内容,仍然需要加锁。

💡shared_ptr 的线程安全更像“借书登记表是安全的”,但书本内容本身不是自动安全的。多个人同时在书上写字,还是会乱。


23. 常见锁概念补充

23.1 悲观锁

悲观锁的思想是:每次访问数据时,都认为别人可能会修改,所以先加锁。

互斥锁就是典型的悲观锁。

适合场景:

写操作较多。
冲突概率较高。
数据一致性要求高。

23.2 乐观锁

乐观锁的思想是:先不加锁,更新时再检查数据有没有被别人改过。

常见实现方式:

  • 版本号机制;
  • CAS 操作。

适合场景:

读多写少。
冲突概率较低。
希望减少加锁开销。

23.3 CAS 操作

CAS 全称 Compare And Swap

它的逻辑是:

如果当前内存值等于预期值,就把它更新成新值。
如果不相等,说明数据被别人改过,更新失败,可以重试。

CAS 常常会配合自旋使用。

23.4 自旋锁

自旋锁的特点是:拿不到锁时不阻塞,而是一直循环等待。

优点:

避免线程挂起和唤醒开销。

缺点:

如果锁持有时间长,会浪费 CPU。

适合锁持有时间非常短的场景。

23.5 读写锁

读写锁适合读多写少的场景。

多个读者可以同时读。
写者写的时候需要独占。
读写之间互斥。

这样可以提高读多写少场景下的并发能力。


24. 高频技术题 / 面试题

24.1 为什么 ticket-- 不是原子操作?

ticket-- 在 C/C++ 代码中看起来是一句,但底层通常会拆成三步:

先从内存读取 ticket 到寄存器。
再在寄存器中减一。
最后把结果写回内存。

多线程环境下,如果线程在这三步中间被切换,就可能导致多个线程基于同一个旧值进行修改,最终造成数据不一致。

24.2 互斥锁保护的到底是什么?

互斥锁保护的不是变量本身,而是访问临界资源的代码段,也就是临界区。

如果一个共享变量有多处访问,那么这些访问位置都应该使用同一把锁保护。否则只保护一处是不够的。

24.3 pthread_mutex_lock 加锁失败会发生什么?

如果互斥量当前没有被占用,加锁成功,函数返回 0。
如果互斥量已经被其他线程占用,当前线程通常会阻塞等待。
如果使用方式错误,比如锁对象非法,可能返回错误码。

24.4 为什么 pthread_cond_wait 要放在 while 里,而不是 if 里?

因为可能存在伪唤醒。

即使线程被唤醒,也不能保证条件一定满足。尤其是多个线程同时等待时,某个线程被唤醒后,资源可能已经被其他线程抢走。

所以被唤醒后必须重新判断条件。

24.5 为什么 pthread_cond_wait 需要互斥锁?

因为条件变量等待的“条件”通常依赖共享数据,而共享数据必须被互斥锁保护。

同时,pthread_cond_wait 需要原子地完成释放锁和挂起等待。如果手动先解锁再等待,中间可能错过其他线程发送的通知,导致永久阻塞。

24.6 生产者消费者模型有什么优点?

主要有三个:

第一,解耦。生产者和消费者不直接通信,而是通过队列交互。

第二,支持并发。多个生产者和多个消费者可以同时工作。

第三,支持忙闲不均。队列可以作为缓冲区,平衡生产速度和消费速度。

24.7 条件变量和信号量有什么区别?

条件变量更强调“等待某个条件发生变化”,通常需要配合互斥锁使用。

信号量本身带计数能力,可以表示资源数量。比如环形队列中,空位数量和数据数量就很适合用信号量表示。

24.8 线程池为什么能提高效率?

线程池提前创建固定数量的线程,避免每来一个任务就创建和销毁线程。

它减少了线程创建销毁开销,也能限制线程数量,避免突发请求导致系统资源被耗尽。

24.9 饿汉模式和懒汉模式有什么区别?

饿汉模式是程序启动时就创建对象。优点是简单且线程安全,缺点是可能影响启动速度。

懒汉模式是第一次使用时再创建对象。优点是延时加载,缺点是多线程环境下需要加锁保护。

24.10 STL 容器为什么默认不是线程安全的?

因为 STL 设计时更注重性能。如果每个容器操作都默认加锁,会带来额外开销。

而且不同容器适合的加锁粒度不同,有的适合锁整个表,有的适合锁桶。因此标准库通常把线程安全控制交给使用者。

24.11 什么是可重入函数?

可重入函数指的是:函数在执行过程中被打断后,再次进入执行也不会破坏原来的执行结果。

可重入函数一般不依赖全局变量、静态变量,也不会使用不可重入资源。

24.12 线程安全函数一定可重入吗?

不一定。

线程安全函数可能通过加锁实现安全。
但是如果它在执行过程中被信号打断,然后在信号处理函数中再次调用,就可能重复加锁导致死锁。

所以可重入函数通常线程安全,但线程安全函数不一定可重入。


结语

学完线程同步与互斥之后,我对多线程的理解确实变了很多。

一开始我只是觉得线程就是“多个函数同时跑”,后来才发现,多线程真正难的不是创建,而是资源共享之后带来的不确定性。一个简单的 ticket--,如果放在单线程里完全没问题,但放在多线程里就可能出现负数票。这个现象背后其实牵扯到 CPU 指令、线程调度、临界区、原子性和锁。

互斥锁解决的是安全问题:同一时刻只允许一个线程进入临界区。
条件变量解决的是顺序问题:线程在条件不满足时应该等待,条件满足后再继续。
生产者消费者模型解决的是解耦问题:让生产和消费通过队列连接起来。
线程池解决的是工程效率问题:避免频繁创建销毁线程,提高服务器处理任务的能力。

这一部分让我感觉操作系统很多知识其实是连在一起的。进程线程、调度、锁、信号量、条件变量、生产者消费者、线程池,看起来是不同概念,但本质都围绕一个问题:多个执行流如何安全、高效、有序地协作。

后面再看服务器、网络编程、任务队列、日志系统时,这些内容就不是孤立的 API 了,而是能真正理解它们为什么这样设计。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐