Linux线程同步与互斥(二):从日志系统与线程池设计,到线程安全单例、可重入函数和死锁原理

Linux线程同步与互斥(二):从日志系统与线程池设计,到线程安全单例、可重入函数和死锁原理


🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《指针合集》 | 《C语言基础》 | 《数据结构》 | 《机器学习导论》 | 《前端基础》 | 《python基础》 | 《C++从入门到入土》 | 《Linux的学习之旅》 ✨ 数据即知识,压缩即智能


文章目录

  • Linux线程同步与互斥(二):从日志系统与线程池设计,到线程安全单例、可重入函数和死锁原理
  • 一、为什么设计线程池之前要先设计日志?
    • [1.1 多线程程序有什么特点?](#1.1 多线程程序有什么特点?)
    • [1.2 什么是日志?](#1.2 什么是日志?)
    • [1.3 一条日志至少需要哪些信息?](#1.3 一条日志至少需要哪些信息?)
  • 二、日志等级
    • [2.1 DEBUG](#2.1 DEBUG)
    • [2.2 INFO](#2.2 INFO)
    • [2.3 WARNING](#2.3 WARNING)
    • [2.4 ERROR](#2.4 ERROR)
    • [2.5 FATAL](#2.5 FATAL)
  • 三、设计模式:为什么日志需要不同的输出策略?
    • [3.1 同一条日志可能有不同的输出位置](#3.1 同一条日志可能有不同的输出位置)
    • [3.2 什么是策略模式?](#3.2 什么是策略模式?)
    • [3.3 定义策略接口](#3.3 定义策略接口)
    • [3.4 控制台日志策略](#3.4 控制台日志策略)
    • [3.5 文件日志策略](#3.5 文件日志策略)
  • 四、实现一个支持策略模式的日志系统
    • [4.1 完整Log.hpp](#4.1 完整Log.hpp)
  • 五、为什么LogMessage要使用RAII?
    • [5.1 希望日志能够这样使用](#5.1 希望日志能够这样使用)
    • [5.2 operator<<的作用](#5.2 operator<<的作用)
    • [5.3 临时对象什么时候析构?](#5.3 临时对象什么时候析构?)
    • [5.4 为什么这样有助于线程安全?](#5.4 为什么这样有助于线程安全?)
  • 六、测试日志系统
    • [6.1 为什么这里需要C++17?](#6.1 为什么这里需要C++17?)
  • 七、正式开始设计线程池
    • [7.1 什么是线程池?](#7.1 什么是线程池?)
    • [7.2 为什么不为每个任务都创建新线程?](#7.2 为什么不为每个任务都创建新线程?)
    • [7.3 线程池适合什么场景?](#7.3 线程池适合什么场景?)
    • [7.4 固定线程池与动态线程池](#7.4 固定线程池与动态线程池)
  • 八、线程池需要哪些成员?
    • [8.1 工作线程集合](#8.1 工作线程集合)
    • [8.2 任务队列](#8.2 任务队列)
    • [8.3 互斥锁](#8.3 互斥锁)
    • [8.4 条件变量](#8.4 条件变量)
    • [8.5 等待线程数量](#8.5 等待线程数量)
    • [8.6 运行状态](#8.6 运行状态)
  • 九、线程池工作线程怎样执行任务?
    • [9.1 第一步:申请互斥锁](#9.1 第一步:申请互斥锁)
    • [9.2 第二步:判断任务队列是否为空](#9.2 第二步:判断任务队列是否为空)
    • [9.3 第三步:判断是否应该退出](#9.3 第三步:判断是否应该退出)
    • [9.4 第四步:取得任务](#9.4 第四步:取得任务)
    • [9.5 第五步:在线程私有执行过程中处理任务](#9.5 第五步:在线程私有执行过程中处理任务)
  • 十、实现固定数量线程池
    • [10.1 ThreadPool.hpp](#10.1 ThreadPool.hpp)
    • [10.2 为什么wait_count_要在锁内维护?](#10.2 为什么wait_count_要在锁内维护?)
    • [10.3 为什么需要while?](#10.3 为什么需要while?)
  • 十一、测试线程池
    • [11.1 任务类型](#11.1 任务类型)
    • [11.2 完整测试代码](#11.2 完整测试代码)
    • [11.3 为什么有4个线程,却能执行20个任务?](#11.3 为什么有4个线程,却能执行20个任务?)
    • [11.4 为什么Stop不应该直接取消所有工作线程?](#11.4 为什么Stop不应该直接取消所有工作线程?)
  • 十二、为什么线程池退出时必须broadcast?
    • [12.1 Stop与Wait的关系](#12.1 Stop与Wait的关系)
    • [12.2 为什么不能先Wait再Stop?](#12.2 为什么不能先Wait再Stop?)
  • 十三、线程安全的单例模式
    • [13.1 什么是单例模式?](#13.1 什么是单例模式?)
    • [13.2 单例模式的特点](#13.2 单例模式的特点)
  • 十四、饿汉模式与懒汉模式
    • [14.1 饿汉模式](#14.1 饿汉模式)
    • [14.2 懒汉模式](#14.2 懒汉模式)
    • [14.3 为什么懒汉模式可能创建两个对象?](#14.3 为什么懒汉模式可能创建两个对象?)
  • 十五、双重检查加锁
    • [15.1 为什么进行两次判断?](#15.1 为什么进行两次判断?)
    • [15.2 volatile能够保证双重检查线程安全吗?](#15.2 volatile能够保证双重检查线程安全吗?)
    • [15.3 C++11的简单安全写法](#15.3 C++11的简单安全写法)
    • [15.4 将单例用于线程池](#15.4 将单例用于线程池)
  • 十六、线程安全:什么叫做一个函数是线程安全的?
    • [16.1 线程安全的基本含义](#16.1 线程安全的基本含义)
    • [16.2 什么样的函数可能不安全?](#16.2 什么样的函数可能不安全?)
    • [16.3 怎样让函数变得线程安全?](#16.3 怎样让函数变得线程安全?)
    • [16.4 只读取全局变量是否安全?](#16.4 只读取全局变量是否安全?)
  • 十七、可重入函数:它和线程安全有什么区别?
    • [17.1 什么是重入?](#17.1 什么是重入?)
    • [17.2 什么是可重入函数?](#17.2 什么是可重入函数?)
    • [17.3 什么样的函数可能不可重入?](#17.3 什么样的函数可能不可重入?)
    • [17.4 加锁以后是否可重入?](#17.4 加锁以后是否可重入?)
    • [17.5 二者的区别](#17.5 二者的区别)
  • 十八、常见锁问题:死锁
    • [18.1 什么是死锁?](#18.1 什么是死锁?)
    • [18.2 为什么申请一把锁安全,申请两把锁可能出问题?](#18.2 为什么申请一把锁安全,申请两把锁可能出问题?)
    • [18.3 死锁示例](#18.3 死锁示例)
  • 十九、死锁的四个必要条件
    • [19.1 互斥条件](#19.1 互斥条件)
    • [19.2 请求与保持条件](#19.2 请求与保持条件)
    • [19.3 不可剥夺条件](#19.3 不可剥夺条件)
    • [19.4 循环等待条件](#19.4 循环等待条件)
  • 二十、如何避免死锁?
    • [20.1 统一加锁顺序](#20.1 统一加锁顺序)
    • [20.2 使用std::lock()](#20.2 使用std::lock())
    • [20.3 避免遗漏解锁](#20.3 避免遗漏解锁)
    • [20.4 设置适当的等待策略](#20.4 设置适当的等待策略)
    • [20.5 死锁检测算法](#20.5 死锁检测算法)
  • 二十一、STL容器是否线程安全?
    • [21.1 std::vector是否自动加锁?](#21.1 std::vector是否自动加锁?)
    • [21.2 为什么STL不统一为容器内部加锁?](#21.2 为什么STL不统一为容器内部加锁?)
    • [21.3 STL完全不能在多线程中使用吗?](#21.3 STL完全不能在多线程中使用吗?)
  • 二十二、智能指针是否线程安全?
    • [22.1 unique_ptr](#22.1 unique_ptr)
    • [22.2 shared_ptr](#22.2 shared_ptr)
    • [22.3 shared_ptr为什么需要处理引用计数?](#22.3 shared_ptr为什么需要处理引用计数?)
    • [22.4 shared_ptr保证被管理对象线程安全吗?](#22.4 shared_ptr保证被管理对象线程安全吗?)
    • [22.5 总结](#22.5 总结)
  • 二十三、其他常见锁概念
    • [23.1 悲观锁](#23.1 悲观锁)
    • [23.2 乐观锁](#23.2 乐观锁)
    • [23.3 CAS](#23.3 CAS)
    • [23.4 自旋锁](#23.4 自旋锁)
    • [23.5 读写锁](#23.5 读写锁)
  • 二十四、常见问题总结
    • [24.1 为什么日志输出需要加锁?](#24.1 为什么日志输出需要加锁?)
    • [24.2 RAII为什么适合日志设计?](#24.2 RAII为什么适合日志设计?)
    • [24.3 为什么线程池不能让所有工作线程一起处理任务队列?](#24.3 为什么线程池不能让所有工作线程一起处理任务队列?)
    • [24.4 为什么线程池没有任务时不应该一直空循环?](#24.4 为什么线程池没有任务时不应该一直空循环?)
    • [24.5 为什么工作线程取出任务后要释放队列锁?](#24.5 为什么工作线程取出任务后要释放队列锁?)
    • [24.6 为什么Stop需要NotifyAll?](#24.6 为什么Stop需要NotifyAll?)
    • [24.7 线程池关闭以后是否必须丢弃所有剩余任务?](#24.7 线程池关闭以后是否必须丢弃所有剩余任务?)
    • [24.8 单例模式为什么存在多线程问题?](#24.8 单例模式为什么存在多线程问题?)
    • [24.9 volatile可以保证懒汉单例线程安全吗?](#24.9 volatile可以保证懒汉单例线程安全吗?)
    • [24.10 线程安全与可重入相同吗?](#24.10 线程安全与可重入相同吗?)
    • [24.11 为什么线程安全函数仍然可能不可重入?](#24.11 为什么线程安全函数仍然可能不可重入?)
    • [24.12 申请一把锁和申请两把锁有什么区别?](#24.12 申请一把锁和申请两把锁有什么区别?)
    • [24.13 std::vector是否会自动保护并发push_back?](#24.13 std::vector是否会自动保护并发push_back?)
    • [24.14 shared_ptr能够保证对象内部数据安全吗?](#24.14 shared_ptr能够保证对象内部数据安全吗?)
  • 总结

前言:学会加锁以后,怎样设计真正的多线程程序?

上一篇已经掌握了线程互斥和同步的基本方法。

通过:

cpp 复制代码
pthread_mutex_lock()
pthread_mutex_unlock()

保护临界资源。

通过:

cpp 复制代码
pthread_cond_wait()
pthread_cond_signal()

协调线程之间的执行条件。

又通过:

cpp 复制代码
sem_wait()
sem_post()

实现了基于固定容量环形队列的生产者---消费者模型。

但是,到目前为止,我们的大部分程序仍然只是简单实验。

例如:

text 复制代码
生产者产生整数
        |
        v
阻塞队列
        |
        v
消费者打印整数

如果进一步将队列中的整数替换成任务对象,再提前创建多个工作线程,就能够逐步实现一个线程池。

不过,一个可用的线程池还需要解决很多具体问题:

  • 多个工作线程怎样安全取得任务?
  • 没有任务时,怎样让工作线程等待?
  • 有新任务到来时,怎样唤醒工作线程?
  • 程序运行时产生的日志怎样保存?
  • 多个线程同时打印日志会不会混乱?
  • 线程池退出时,怎样确保所有工作线程结束?
  • 如果线程池只允许创建一个实例,怎样保证单例模式的线程安全?

本篇按照这些问题的自然顺序,首先设计日志系统,再实现固定数量线程池,然后研究线程安全单例、可重入函数、死锁,以及 STL 和智能指针的线程安全问题。


一、为什么设计线程池之前要先设计日志?

1.1 多线程程序有什么特点?

对于单线程程序,通常可以按照一条执行路线分析:

text 复制代码
main
  |
  v
FunctionA
  |
  v
FunctionB

但多线程程序中,可能同时存在:

text 复制代码
Thread-1
Thread-2
Thread-3
Thread-4

它们的执行顺序由操作系统调度决定。

例如:

text 复制代码
线程1开始处理任务
线程3取得新任务
线程2进入等待
线程1完成任务

如果程序出现异常,我们需要知道:

哪个任务什么时候开始执行?由哪个线程执行?在哪一行代码发生了错误?

因此,日志对于多线程程序尤其重要。

1.2 什么是日志?

日志(Log)用于记录程序运行期间发生的事件。

它可以帮助开发者:

  • 观察程序运行状态。
  • 追踪任务执行过程。
  • 记录异常信息。
  • 定位发生问题的代码。
  • 分析多线程程序的执行情况。

1.3 一条日志至少需要哪些信息?

例如:

text 复制代码
[2026-10-20 12:00:00] [DEBUG] [12345] [main.cpp] [25] - hello world

可以拆分为:

字段 示例 作用
时间戳 2026-10-20 12:00:00 记录事件发生时间
日志等级 DEBUG 区分事件重要程度
进程PID 12345 说明来自哪个进程
文件名 main.cpp 定位源码文件
行号 25 定位源码行
日志内容 hello world 记录事件信息

其中:

text 复制代码
时间
等级
内容

是日志最基本的信息。

文件名、行号、PID、线程ID等可以进一步帮助定位问题。


二、日志等级

为了区分不同事件,可以定义:

cpp 复制代码
enum class LogLevel
{
    DEBUG,
    INFO,
    WARNING,
    ERROR,
    FATAL
};

2.1 DEBUG

用于调试信息。

例如:

text 复制代码
线程开始等待任务
线程从队列中取得任务

2.2 INFO

用于记录正常的重要运行事件。

例如:

text 复制代码
线程池启动完成
线程池退出

2.3 WARNING

用于记录需要关注、但不一定导致程序失败的情况。

例如:

text 复制代码
任务队列积压较多

2.4 ERROR

用于记录错误。

例如:

text 复制代码
文件打开失败
线程创建失败

2.5 FATAL

用于记录严重错误。

例如:

text 复制代码
重要资源初始化失败,程序无法继续运行

这里的日志等级只是对事件进行分类。

定义一个名为 FATAL 的等级,并不意味着输出该日志后,程序就必然自动退出。

是否终止程序,还需要由具体业务逻辑决定。


三、设计模式:为什么日志需要不同的输出策略?

3.1 同一条日志可能有不同的输出位置

假设我们已经生成一条日志:

text 复制代码
[INFO] Thread-1 is running

开发阶段可能希望直接输出到终端:

cpp 复制代码
std::cerr

而程序正式运行时,可能希望保存到文件:

text 复制代码
./log/log.txt

如果在所有日志调用位置都写:

cpp 复制代码
if (use_file)
{
    // 输出到文件
}
else
{
    // 输出到终端
}

会导致大量重复的选择逻辑。

因此,可以考虑把:

text 复制代码
日志内容的生成

和:

text 复制代码
日志的最终输出

分离。

3.2 什么是策略模式?

策略模式(Strategy Pattern)的基本思想是:

将一组可替换的操作封装成不同策略,并通过统一接口进行调用。

例如:

text 复制代码
Logger
   |
   v
LogStrategy
   |
   +-- ConsoleLogStrategy
   |
   +-- FileLogStrategy

其中:

text 复制代码
Logger

负责生成和组织日志信息。

text 复制代码
LogStrategy

提供统一输出接口。

而两个具体策略分别决定:

text 复制代码
写入终端

或者:

text 复制代码
写入文件

3.3 定义策略接口

cpp 复制代码
class LogStrategy
{
public:
    virtual ~LogStrategy() = default;

    virtual void SyncLog(
        const std::string& message
    ) = 0;
};

这里:

cpp 复制代码
virtual

用于实现运行时多态。

其中:

cpp 复制代码
SyncLog()

是一个纯虚函数。

不同的策略需要提供自己的实现。

3.4 控制台日志策略

cpp 复制代码
class ConsoleLogStrategy : public LogStrategy
{
public:
    void SyncLog(
        const std::string& message
    ) override
    {
        std::lock_guard<std::mutex> guard(mutex_);

        std::cerr << message << std::endl;
    }

private:
    std::mutex mutex_;
};

这里出现:

cpp 复制代码
std::mutex

和:

cpp 复制代码
std::lock_guard

它们分别对应我们之前封装的:

cpp 复制代码
Mutex
LockGuard

主要作用仍然是:

保证多个线程的完整日志输出不会不受保护地同时修改同一输出流的相关状态。

3.5 文件日志策略

可以将日志写入:

text 复制代码
./log/log.txt

例如:

cpp 复制代码
class FileLogStrategy : public LogStrategy
{
public:
    void SyncLog(
        const std::string& message
    ) override
    {
        std::lock_guard<std::mutex> guard(mutex_);

        std::ofstream out(
            "./log/log.txt",
            std::ios::app
        );

        if (out.is_open())
        {
            out << message << '\n';
        }
    }

private:
    std::mutex mutex_;
};

这里:

cpp 复制代码
std::ios::app

表示追加写入。

但还需要确保:

text 复制代码
./log/

目录已经存在。

后面的完整实现会处理目录创建问题。


四、实现一个支持策略模式的日志系统

为了便于和后面的线程池组合,我们把日志模块封装为:

text 复制代码
Log.hpp

主要包括:

text 复制代码
LogLevel
LogStrategy
ConsoleLogStrategy
FileLogStrategy
Logger
LogMessage

4.1 完整Log.hpp

下面采用 C++17 编写,以便使用:

cpp 复制代码
std::filesystem
std::make_unique
inline变量

等功能。

cpp 复制代码
#pragma once

#include <ctime>
#include <cstdio>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <memory>
#include <mutex>
#include <sstream>
#include <string>
#include <unistd.h>

namespace LogModule
{

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

// 日志等级转换为字符串
inline const char* 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";
    }
}

// 获取可读时间字符串
inline std::string GetCurrTime()
{
    std::time_t now = std::time(nullptr);

    std::tm current = {};

    localtime_r(&now, &current);

    char buffer[64];

    std::snprintf(
        buffer,
        sizeof(buffer),
        "%04d-%02d-%02d %02d:%02d:%02d",
        current.tm_year + 1900,
        current.tm_mon + 1,
        current.tm_mday,
        current.tm_hour,
        current.tm_min,
        current.tm_sec
    );

    return buffer;
}

// 日志策略接口
class LogStrategy
{
public:
    virtual ~LogStrategy() = default;

    virtual void SyncLog(
        const std::string& message
    ) = 0;
};

// 控制台输出策略
class ConsoleLogStrategy : public LogStrategy
{
public:
    void SyncLog(
        const std::string& message
    ) override
    {
        std::lock_guard<std::mutex> guard(mutex_);

        std::cerr << message << std::endl;
    }

private:
    std::mutex mutex_;
};

// 文件输出策略
class FileLogStrategy : public LogStrategy
{
public:
    FileLogStrategy()
    {
        std::filesystem::create_directories("./log");
    }

    void SyncLog(
        const std::string& message
    ) override
    {
        std::lock_guard<std::mutex> guard(mutex_);

        std::ofstream out(
            "./log/log.txt",
            std::ios::app
        );

        if (out.is_open())
        {
            out << message << '\n';
        }
    }

private:
    std::mutex mutex_;
};

// 日志管理器
class Logger
{
public:
    Logger()
        : strategy_(
            std::make_unique<ConsoleLogStrategy>()
          )
    {
    }

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

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

    // 一条完整日志对应一个LogMessage对象
    class LogMessage
    {
    public:
        LogMessage(
            LogLevel level,
            const std::string& filename,
            int line,
            Logger& logger
        )
            : logger_(logger)
        {
            stream_
                << "[" << GetCurrTime() << "] "
                << "[" << LogLevelToString(level) << "] "
                << "[" << getpid() << "] "
                << "[" << filename << "] "
                << "[" << line << "]"
                << " - ";
        }

        template <typename T>
        LogMessage& operator<<(const T& value)
        {
            stream_ << value;

            return *this;
        }

        ~LogMessage()
        {
            logger_.Emit(stream_.str());
        }

    private:
        Logger& logger_;
        std::ostringstream stream_;
    };

    LogMessage operator()(
        LogLevel level,
        const std::string& filename,
        int line
    )
    {
        return LogMessage(
            level,
            filename,
            line,
            *this
        );
    }

private:
    void Emit(const std::string& message)
    {
        if (strategy_)
        {
            strategy_->SyncLog(message);
        }
    }

    std::unique_ptr<LogStrategy> strategy_;
};

// C++17 inline全局变量
inline Logger logger;

} // namespace LogModule

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

#define ENABLE_CONSOLE_LOG_STRATEGY() \
    ::LogModule::logger.UseConsoleStrategy()

#define ENABLE_FILE_LOG_STRATEGY() \
    ::LogModule::logger.UseFileStrategy()

这个版本假设在启动工作线程之前选定日志策略,运行中不并发切换策略。

因为:

cpp 复制代码
UseFileStrategy()

会替换当前策略对象。

如果其他线程正在通过旧策略写日志,缺少额外同步就可能产生数据竞争或对象生命周期问题。

如果需要支持运行时动态切换策略,还需要为策略切换设计统一的同步方案。


五、为什么LogMessage要使用RAII?

5.1 希望日志能够这样使用

例如:

cpp 复制代码
LOG(LogLevel::INFO)
    << "Thread-1"
    << " is running";

我们希望它最终能够形成一条完整日志:

text 复制代码
[2026-10-20 12:00:00] [INFO] [12345] [main.cpp] [20] - Thread-1 is running

这里有两个要求:

  1. 支持连续使用 << 添加信息。
  2. 整条日志构建完成后再统一输出。

5.2 operator<<的作用

例如:

cpp 复制代码
template <typename T>
LogMessage& operator<<(const T& value)
{
    stream_ << value;
    return *this;
}

这样就可以写:

cpp 复制代码
LOG(LogLevel::INFO)
    << "value = "
    << 100
    << ", result = "
    << 200;

不同类型的信息会通过流式接口追加到当前日志内容中。

5.3 临时对象什么时候析构?

例如:

cpp 复制代码
LOG(LogLevel::INFO) << "hello world";

会形成一个用于保存当前日志内容的临时 LogMessage 对象。

当这一整条表达式执行完成以后,临时对象结束生命周期。

随后执行:

cpp 复制代码
~LogMessage()

因此可以在析构函数中:

cpp 复制代码
logger_.Emit(stream_.str());

把已经组合好的整条日志输出。

5.4 为什么这样有助于线程安全?

假设两个线程分别执行:

cpp 复制代码
LOG(LogLevel::INFO)
    << "Thread1"
    << " running";

和:

cpp 复制代码
LOG(LogLevel::INFO)
    << "Thread2"
    << " running";

每次日志表达式都会维护自己的 LogMessage 数据。

最终完整日志才通过受互斥量保护的策略接口写入。

因此可以避免不同线程在生成日志时直接向同一个共享输出目标零散写入大量片段。

这正是:

RAII + 策略模式 + 互斥锁

在日志系统中的结合。


六、测试日志系统

main.cpp:

cpp 复制代码
#include "Log.hpp"

using namespace LogModule;

void Test()
{
    int value = 100;

    LOG(LogLevel::DEBUG)
        << "value = " << value;

    LOG(LogLevel::WARNING)
        << "this is a warning";

    LOG(LogLevel::ERROR)
        << "this is an error";
}

int main()
{
    // 控制台日志
    ENABLE_CONSOLE_LOG_STRATEGY();

    Test();

    // 切换到文件日志
    ENABLE_FILE_LOG_STRATEGY();

    LOG(LogLevel::INFO)
        << "write message to file";

    return 0;
}

编译:

bash 复制代码
g++ main.cpp -std=c++17 -pthread -o log_test

运行:

bash 复制代码
./log_test

终端输出类似:

text 复制代码
[2026-10-20 12:00:00] [DEBUG] [12345] [main.cpp] [9] - value = 100
[2026-10-20 12:00:00] [WARNING] [12345] [main.cpp] [12] - this is a warning
[2026-10-20 12:00:00] [ERROR] [12345] [main.cpp] [15] - this is an error

同时生成:

text 复制代码
log/
└── log.txt

可以执行:

bash 复制代码
cat log/log.txt

查看文件中的日志。

6.1 为什么这里需要C++17?

主要因为:

cpp 复制代码
#include <filesystem>

使用了:

cpp 复制代码
std::filesystem::create_directories()

以及 C++17 的:

cpp 复制代码
inline Logger logger;

如果使用较旧的 C++ 标准,需要调整这些实现方式。


七、正式开始设计线程池

日志系统完成后,就可以把它用于线程池。

7.1 什么是线程池?

线程池(Thread Pool)是一种线程复用机制。

它提前创建一定数量的工作线程。

这些线程反复从任务队列中获取任务并执行,而不是每次处理一个任务都创建新的线程。

例如:

text 复制代码
ThreadPool
    |
    +-- Worker-1
    +-- Worker-2
    +-- Worker-3
    +-- Worker-4

中间维护:

text 复制代码
TaskQueue

有任务时,由工作线程取出执行。

没有任务时,工作线程等待。

7.2 为什么不为每个任务都创建新线程?

假设服务器需要处理很多短任务。

如果每个任务都执行:

text 复制代码
创建线程
    |
    v
执行任务
    |
    v
等待线程退出
    |
    v
回收资源

那么大量任务会导致频繁的线程创建和回收。

此外,如果瞬间创建过多线程,还可能增加:

text 复制代码
线程调度开销
内存使用
上下文切换

因此,线程池通过复用工作线程减少这些开销。

7.3 线程池适合什么场景?

典型应用包括:

  • 大量执行时间相对较短的任务。
  • 需要快速响应的任务处理服务。
  • 具有突发任务量的服务器。
  • 需要限制并发线程数量的程序。

但线程池不一定适合所有场景。

如果任务很少、执行时间非常长,或者任务本身不适合并行,线程池未必能带来明显收益。

7.4 固定线程池与动态线程池

常见类型包括:

固定线程池

创建固定数量的工作线程。

例如:

text 复制代码
10个Worker

程序运行期间,线程数量基本保持不变。

动态线程池

可以根据任务负载调整工作线程数量。

本篇先实现:

固定数量线程池。


八、线程池需要哪些成员?

可以设计:

cpp 复制代码
template <typename T>
class ThreadPool;

其中 T 表示任务类型。

例如:

cpp 复制代码
using Task = std::function<void()>;

于是:

cpp 复制代码
ThreadPool<Task>

就是一个保存并执行可调用任务对象的线程池。

8.1 工作线程集合

需要一个容器保存线程 ID:

cpp 复制代码
std::vector<pthread_t> threads_;

8.2 任务队列

cpp 复制代码
std::queue<T> task_queue_;

用于保存待执行的任务。

8.3 互斥锁

cpp 复制代码
pthread_mutex_t mutex_;

用于保护:

text 复制代码
任务队列
线程池运行状态

等共享信息。

8.4 条件变量

cpp 复制代码
pthread_cond_t cond_;

用于让没有任务的工作线程等待。

当新任务进入队列时,生产者通知工作线程。

8.5 等待线程数量

cpp 复制代码
int wait_count_;

用于统计正在条件变量上等待的工作线程数量。

8.6 运行状态

cpp 复制代码
bool running_;

用于判断线程池是否仍然接受任务。

还可以增加:

cpp 复制代码
bool started_;

区分线程池尚未启动和已经启动的状态。


九、线程池工作线程怎样执行任务?

先不急着写整个类。

重点分析:

cpp 复制代码
HandleTask()

的核心逻辑。

9.1 第一步:申请互斥锁

cpp 复制代码
pthread_mutex_lock(&mutex_);

因为任务队列由多个线程共同访问。

所以必须在获取任务之前先进入临界区。

9.2 第二步:判断任务队列是否为空

cpp 复制代码
while (task_queue_.empty() && running_)
{
    pthread_cond_wait(&cond_, &mutex_);
}

这里的条件很重要。

只有:

text 复制代码
队列为空

并且:

text 复制代码
线程池仍然运行

时,工作线程才应该继续等待。

9.3 第三步:判断是否应该退出

如果:

text 复制代码
线程池已经停止接收任务

并且

任务队列已经为空

就说明当前没有需要处理的剩余任务。

因此:

cpp 复制代码
if (task_queue_.empty() && !running_)
{
    pthread_mutex_unlock(&mutex_);
    break;
}

工作线程可以退出。

9.4 第四步:取得任务

cpp 复制代码
T task = task_queue_.front();

task_queue_.pop();

取出任务以后,应该及时释放互斥锁。

9.5 第五步:在线程私有执行过程中处理任务

cpp 复制代码
pthread_mutex_unlock(&mutex_);

task();

为什么任务执行要放在锁外?

因为:

保护任务队列所需的临界区,只包含安全地取出任务这一过程。

任务本身可能耗时较长。

如果执行任务时一直持有任务队列锁,其他线程就无法及时访问队列。

因此线程池最重要的设计原则之一是:

取任务时加锁,执行任务时释放队列锁。


十、实现固定数量线程池

下面实现一个可以运行的固定线程池。

为了突出线程池本身的互斥与同步机制,线程集合直接使用 pthread_t,核心结构仍然包括:

text 复制代码
InitThreadPool
Start
HandleTask
Enqueue
Stop
Wait

10.1 ThreadPool.hpp

cpp 复制代码
#pragma once

#include <pthread.h>

#include <cstdio>
#include <queue>
#include <stdexcept>
#include <string>
#include <vector>

#include "Log.hpp"

template <typename T>
class ThreadPool
{
private:
    // pthread要求的C风格线程入口
    static void* ThreadRoutine(void* arg)
    {
        ThreadPool* self =
            static_cast<ThreadPool*>(arg);

        self->HandleTask();

        return nullptr;
    }

    // 工作线程反复执行的函数
    void HandleTask()
    {
        while (true)
        {
            pthread_mutex_lock(&mutex_);

            // 没有任务且线程池仍运行时等待
            while (task_queue_.empty() && running_)
            {
                ++wait_count_;

                pthread_cond_wait(&cond_, &mutex_);

                --wait_count_;
            }

            // 停止后,剩余任务已全部处理
            if (task_queue_.empty() && !running_)
            {
                pthread_mutex_unlock(&mutex_);
                break;
            }

            // 获取任务
            T task = task_queue_.front();
            task_queue_.pop();

            pthread_mutex_unlock(&mutex_);

            // 执行任务,不占用任务队列锁
            try
            {
                task();
            }
            catch (const std::exception& e)
            {
                LOG(LogModule::LogLevel::ERROR)
                    << "task exception: "
                    << e.what();
            }
            catch (...)
            {
                LOG(LogModule::LogLevel::ERROR)
                    << "unknown task exception";
            }
        }
    }

public:
    explicit ThreadPool(int thread_num = 10)
        : thread_num_(thread_num),
          wait_count_(0),
          running_(false),
          started_(false)
    {
        if (thread_num_ <= 0)
        {
            throw std::invalid_argument(
                "thread_num must be positive"
            );
        }

        pthread_mutex_init(&mutex_, nullptr);
        pthread_cond_init(&cond_, nullptr);
    }

    // 准备线程容器
    void InitThreadPool()
    {
        threads_.reserve(thread_num_);
    }

    // 启动工作线程
    bool Start()
    {
        if (started_)
        {
            return false;
        }

        running_ = true;

        for (int i = 0; i < thread_num_; ++i)
        {
            pthread_t tid;

            int ret = pthread_create(
                &tid,
                nullptr,
                ThreadRoutine,
                this
            );

            if (ret != 0)
            {
                // 停止并回收已经创建的线程
                Stop();
                Wait();

                return false;
            }

            threads_.push_back(tid);
        }

        started_ = true;

        LOG(LogModule::LogLevel::INFO)
            << "thread pool started";

        return true;
    }

    // 添加任务
    bool Enqueue(const T& task)
    {
        pthread_mutex_lock(&mutex_);

        if (!running_)
        {
            pthread_mutex_unlock(&mutex_);
            return false;
        }

        task_queue_.push(task);

        // 如果有工作线程正在等待,就通知一个
        if (wait_count_ > 0)
        {
            pthread_cond_signal(&cond_);
        }

        pthread_mutex_unlock(&mutex_);

        return true;
    }

    // 停止接收任务,唤醒等待线程
    void Stop()
    {
        pthread_mutex_lock(&mutex_);

        running_ = false;

        pthread_cond_broadcast(&cond_);

        pthread_mutex_unlock(&mutex_);
    }

    // 等待工作线程退出
    void Wait()
    {
        for (pthread_t tid : threads_)
        {
            pthread_join(tid, nullptr);
        }

        threads_.clear();

        started_ = false;
    }

    ~ThreadPool()
    {
        Stop();
        Wait();

        pthread_cond_destroy(&cond_);
        pthread_mutex_destroy(&mutex_);
    }

    ThreadPool(const ThreadPool&) = delete;
    ThreadPool& operator=(const ThreadPool&) = delete;

private:
    int thread_num_;

    std::vector<pthread_t> threads_;

    std::queue<T> task_queue_;

    pthread_mutex_t mutex_;
    pthread_cond_t cond_;

    int wait_count_;

    bool running_;
    bool started_;
};

这是一个用于展示线程池协作机制的简化实现。

它约定 Start()、Stop()、Wait() 由负责管理线程池的线程依次调用,且销毁线程池时不再有其他线程调用 Enqueue()。

如果要设计完整的生产级线程池,还需要处理更复杂的生命周期并发、任务入队异常、重复启动以及工作线程主动销毁线程池等问题。

10.2 为什么wait_count_要在锁内维护?

例如:

cpp 复制代码
++wait_count_;
pthread_cond_wait(&cond_, &mutex_);
--wait_count_;

这些操作位于互斥锁保护范围内。

因为:

text 复制代码
等待线程数量

同样是共享数据。

如果多个线程不受保护地修改它,也可能产生竞争。

10.3 为什么需要while?

cpp 复制代码
while (task_queue_.empty() && running_)

这个条件与上一节阻塞队列的等待方式一致。

被唤醒以后,线程必须重新检查:

text 复制代码
队列是否仍然为空?

线程池是否仍然运行?

不能假设唤醒意味着任务一定存在。


十一、测试线程池

11.1 任务类型

使用:

cpp 复制代码
std::function<void()>

表示任务。

例如:

cpp 复制代码
using Task = std::function<void()>;

11.2 完整测试代码

main.cpp:

cpp 复制代码
#include <functional>
#include <iostream>
#include <unistd.h>

#include "Log.hpp"
#include "ThreadPool.hpp"

using Task = std::function<void()>;

int main()
{
    // 启动工作线程之前选好日志策略
    ENABLE_CONSOLE_LOG_STRATEGY();

    ThreadPool<Task> pool(4);

    pool.InitThreadPool();

    if (!pool.Start())
    {
        std::cerr << "start thread pool failed\n";
        return 1;
    }

    for (int i = 0; i < 20; ++i)
    {
        pool.Enqueue([i]() {

            LOG(LogModule::LogLevel::INFO)
                << "task " << i
                << " is running";

            usleep(100000);

        });
    }

    // 不再接受新任务
    pool.Stop();

    // 等待所有剩余任务完成
    pool.Wait();

    std::cout << "all tasks finished\n";

    return 0;
}

编译:

bash 复制代码
g++ main.cpp -std=c++17 -pthread -o thread_pool

运行:

bash 复制代码
./thread_pool

可能得到类似输出:

text 复制代码
[2026-10-20 12:00:00] [INFO] [12345] [ThreadPool.hpp] [112] - thread pool started

[2026-10-20 12:00:00] [INFO] [12345] [main.cpp] [25] - task 0 is running
[2026-10-20 12:00:00] [INFO] [12345] [main.cpp] [25] - task 1 is running
[2026-10-20 12:00:00] [INFO] [12345] [main.cpp] [25] - task 2 is running
...
all tasks finished

实际时间、PID、行号以及任务执行顺序以运行结果为准。

11.3 为什么有4个线程,却能执行20个任务?

因为线程池不是:

text 复制代码
一个线程只执行一个任务

而是:

text 复制代码
工作线程取得一个任务
        |
        v
执行任务
        |
        v
继续获取下一个任务

因此,同一个工作线程可以重复执行多个任务。

11.4 为什么Stop不应该直接取消所有工作线程?

我们希望:

text 复制代码
停止接受新任务

之后,已经进入任务队列中的任务能够继续被处理。

所以设计:

cpp 复制代码
running_ = false;

并不是强制让工作线程立刻停止当前任务。

而是让它们继续处理队列中剩余的任务。

当:

text 复制代码
running == false

并且:

text 复制代码
task_queue.empty() == true

时,工作线程才退出。

这就是一种简单的:

平滑退出(Graceful Shutdown)。


十二、为什么线程池退出时必须broadcast?

假设线程池有:

text 复制代码
10个工作线程

但是当前队列为空。

于是10个工作线程都在:

cpp 复制代码
pthread_cond_wait()

中等待。

现在主线程调用:

cpp 复制代码
Stop();

如果只修改:

cpp 复制代码
running_ = false;

工作线程并不一定会自动从条件变量等待中返回。

因此需要:

cpp 复制代码
pthread_cond_broadcast(&cond_);

通知所有等待线程。

这些线程被唤醒并重新取得互斥锁以后,会检查:

cpp 复制代码
while (task_queue_.empty() && running_)

以及:

cpp 复制代码
if (task_queue_.empty() && !running_)

然后决定是否退出。

12.1 Stop与Wait的关系

可以理解为:

text 复制代码
Stop()
    |
    v
停止接受新任务
    |
    v
唤醒全部等待线程
    |
    v
工作线程处理剩余任务
    |
    v
工作线程退出
    |
    v
Wait()
    |
    v
主线程回收工作线程

12.2 为什么不能先Wait再Stop?

因为如果工作线程正在等待:

cpp 复制代码
pthread_cond_wait()

而线程池仍然标记为运行中,那么直接等待工作线程结束,可能永远无法完成。

所以对于这种线程池设计:

先Stop通知退出,再Wait回收线程。


十三、线程安全的单例模式

现在已经实现了线程池。

但还有一个问题:

如果整个程序只希望存在一个全局线程池对象,该怎么办?

这就可以使用:

单例模式(Singleton Pattern)。

13.1 什么是单例模式?

单例模式要求:

一个类在规定的使用范围内只存在一个实例,并提供统一访问入口。

例如:

text 复制代码
Program
   |
   +-- ModuleA
   |
   +-- ModuleB
   |
   +-- ModuleC
           |
           v
      ThreadPool

不同模块都可以取得同一个线程池对象。

13.2 单例模式的特点

通常包括:

  • 限制对象的创建。
  • 维护唯一实例。
  • 提供统一访问接口。

例如:

cpp 复制代码
ThreadPool::GetInstance()

十四、饿汉模式与懒汉模式

14.1 饿汉模式

饿汉模式可以理解为:

提前创建对象,后续直接使用。

例如:

text 复制代码
程序初始化
    |
    v
创建单例对象
    |
    v
后续取得对象

可以用一个简单的类表示:

cpp 复制代码
class Singleton
{
private:
    Singleton() = default;

    static Singleton instance_;

public:
    static Singleton& GetInstance()
    {
        return instance_;
    }
};

实际定义静态成员还需要相应的类外定义,或者使用适当的现代 C++ 静态成员写法。

它的特点是:

对象提前创建,后续不需要再判断是否首次创建。

14.2 懒汉模式

懒汉模式可以理解为:

第一次真正需要对象时,才创建它。

例如:

cpp 复制代码
if (instance == nullptr)
{
    instance = new Singleton;
}

但在多线程环境中,这段代码存在问题。

14.3 为什么懒汉模式可能创建两个对象?

假设线程A执行:

cpp 复制代码
if (instance == nullptr)

发现成立。

但还没有执行:

cpp 复制代码
instance = new Singleton;

线程B也执行相同判断。

同样发现:

text 复制代码
instance == nullptr

于是两个线程都有可能创建对象。

这样就破坏了单例约束。


十五、双重检查加锁

为解决问题,可以考虑:

text 复制代码
第一次判断是否为空
        |
        v
加锁
        |
        v
第二次判断是否为空
        |
        v
创建实例
        |
        v
解锁

这一思路通常称为:

双重检查加锁(Double-Checked Locking,DCL)。

15.1 为什么进行两次判断?

第一次判断的目的:

text 复制代码
对象已经创建以后,尽量避免每次访问都加锁

第二次判断的目的:

text 复制代码
线程取得锁后,重新确认对象是否已经由其他线程创建

例如:

线程A和线程B都通过第一次空指针检查。

线程A先获得锁并创建单例。

线程B随后获得锁时,第二次检查发现实例已经存在。

因此不需要再次创建。

15.2 volatile能够保证双重检查线程安全吗?

不能简单这样认为。

一种早期常见写法是:

cpp 复制代码
volatile static T* instance;

但这里的:

cpp 复制代码
volatile

并不能替代 C++ 多线程内存模型所需要的原子性和同步保证。

如果不同线程对普通共享指针进行未同步的读取和写入,即使给指针添加 volatile,也不能因此认定整个实现已经线程安全。

所以这里保留双重检查的设计思想,但不推荐直接使用未经正确同步的普通指针版本。

15.3 C++11的简单安全写法

C++11 保证函数局部静态变量初始化具有线程安全语义。

因此可以写:

cpp 复制代码
class Singleton
{
public:
    static Singleton& GetInstance()
    {
        static Singleton instance;

        return instance;
    }

    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;

private:
    Singleton() = default;
};

第一次调用:

cpp 复制代码
Singleton::GetInstance();

会完成对象初始化。

之后调用返回已经存在的对象。

这样不需要自己实现复杂的双重检查。

15.4 将单例用于线程池

例如:

cpp 复制代码
using Task = std::function<void()>;

ThreadPool<Task>& GetThreadPool()
{
    static ThreadPool<Task> pool(10);

    return pool;
}

调用:

cpp 复制代码
auto& pool = GetThreadPool();

不同模块取得的是同一个线程池对象。

不过:

取得单例对象,并不意味着线程池已经自动启动。

还需要按照线程池设计,适当调用:

cpp 复制代码
InitThreadPool();
Start();

并在结束阶段执行:

cpp 复制代码
Stop();
Wait();

同时需要统一管理启动和停止的时机,避免多个调用者重复启动或过早关闭线程池。


十六、线程安全:什么叫做一个函数是线程安全的?

现在进入多线程程序设计中另一个重要问题:

线程安全(Thread Safety)。

16.1 线程安全的基本含义

如果多个线程按照函数规定的使用方式并发调用某个函数,仍然能够满足其正确性要求,就可以认为这个函数在相应使用条件下是线程安全的。

例如:

cpp 复制代码
int Add(int a, int b)
{
    return a + b;
}

该函数只使用参数与局部计算,不修改共享状态。

这种函数通常比较容易满足线程安全要求。

16.2 什么样的函数可能不安全?

例如:

cpp 复制代码
int counter = 0;

void Increase()
{
    ++counter;
}

多个线程同时调用:

cpp 复制代码
Increase();

可能产生数据竞争。

因此不能认为它天然线程安全。

16.3 怎样让函数变得线程安全?

一种常见办法是使用互斥锁:

cpp 复制代码
#include <mutex>

int counter = 0;

std::mutex mutex;

void Increase()
{
    std::lock_guard<std::mutex> guard(mutex);

    ++counter;
}

这样多个线程执行:

cpp 复制代码
Increase();

时,相关共享状态访问受到互斥保护。

但还需要保证程序中其他访问 counter 的位置也遵守相同的同步规则。

16.4 只读取全局变量是否安全?

如果全局对象在并发期间不发生不安全修改,多个线程通常可以同时读取它。

例如:

cpp 复制代码
const int value = 100;

多个线程读取它没有问题。

所以不能仅仅因为一个函数使用了全局变量,就断定它一定不是线程安全的。

真正要关注的是:

text 复制代码
是否访问共享可变状态?

并发访问有没有正确同步?

十七、可重入函数:它和线程安全有什么区别?

17.1 什么是重入?

前面学习进程信号时,已经介绍过一个现象:

函数执行到一半时,可能被另一个执行路径打断。

随后同一个函数又被调用。

例如:

text 复制代码
第一次调用Function
        |
        v
Function执行一半
        |
        v
信号处理函数再次调用Function
        |
        v
第二次Function执行
        |
        v
返回第一次Function继续执行

这就是重入场景。

17.2 什么是可重入函数?

可重入函数要求:

当先前一次调用尚未结束,又发生新的调用时,函数仍然能够保持正确行为。

例如:

cpp 复制代码
int Add(int a, int b)
{
    int result = a + b;

    return result;
}

每次调用使用各自的局部变量,不依赖共享可变状态。

因此很容易实现为可重入函数。

17.3 什么样的函数可能不可重入?

例如:

cpp 复制代码
int global_result = 0;

int Add(int a, int b)
{
    global_result = a + b;

    return global_result;
}

如果函数调用在修改:

cpp 复制代码
global_result

的过程中发生重入,就可能破坏原来的计算结果。

因此这个函数不具备普通意义下的可重入保证。

17.4 加锁以后是否可重入?

假设:

cpp 复制代码
std::mutex mutex;

void Function()
{
    std::lock_guard<std::mutex> guard(mutex);

    // 操作共享资源
}

多个线程调用这个函数时,互斥锁可以保护共享状态。

因此它可能是线程安全的。

但如果同一线程在尚未释放这把普通互斥锁时,再次进入该函数:

text 复制代码
第一次Function取得锁
        |
        v
发生重入
        |
        v
第二次Function尝试取得同一把锁

就可能造成自我死锁。

因此:

线程安全不等于可重入。

17.5 二者的区别

对比 线程安全 可重入
关注重点 多个线程并发调用的正确性 函数尚未返回时再次进入的正确性
典型风险 数据竞争、共享状态破坏 重入过程中共享状态破坏或死锁
是否可以利用普通互斥锁 可以 普通锁未必适合异步重入
是否强调局部数据 是 是

在相同的使用契约下,可重入性通常比一般的线程安全具有更严格的要求。

但这里还需要区分普通函数重入与异步信号处理函数调用的情况;后者还受到异步信号安全接口规则约束。


十八、常见锁问题:死锁

18.1 什么是死锁?

死锁(Deadlock)是指多个执行流分别占有某些资源,同时又等待其他执行流释放资源,形成无法继续推进的相互等待状态。

例如:

text 复制代码
线程A持有锁1
线程B持有锁2

此时:

text 复制代码
线程A继续申请锁2

线程B继续申请锁1

于是:

text 复制代码
线程A等待线程B

线程B等待线程A

形成循环等待。

18.2 为什么申请一把锁安全,申请两把锁可能出问题?

单次互斥锁获取可以保证对该互斥量的互斥语义。

但是:

cpp 复制代码
mutex1.lock();
mutex2.lock();

并不是一个天然不可分割的组合操作。

线程可能在成功取得第一把锁以后,被切换到其他线程。

因此不同线程如果按相反顺序取得锁,就可能出现死锁。

18.3 死锁示例

下面展示错误的锁顺序。

cpp 复制代码
#include <mutex>
#include <thread>
#include <unistd.h>

std::mutex mutex1;
std::mutex mutex2;

void Thread1()
{
    mutex1.lock();

    usleep(1000);

    mutex2.lock();

    mutex2.unlock();
    mutex1.unlock();
}

void Thread2()
{
    mutex2.lock();

    usleep(1000);

    mutex1.lock();

    mutex1.unlock();
    mutex2.unlock();
}

int main()
{
    std::thread t1(Thread1);
    std::thread t2(Thread2);

    t1.join();
    t2.join();

    return 0;
}

这个程序可能出现:

text 复制代码
Thread1取得mutex1

Thread2取得mutex2

Thread1等待mutex2

Thread2等待mutex1

于是双方都无法继续执行。

这段代码仅用于演示错误,不建议作为正常程序运行。如果实验中发生死锁,可从其他终端终止对应实验进程。


十九、死锁的四个必要条件

经典死锁理论将死锁发生所需的条件概括为四类。

19.1 互斥条件

某种资源同一时刻只能由一个执行流排他使用。

例如:

text 复制代码
同一把普通互斥锁

只能被一个线程持有。

19.2 请求与保持条件

执行流已经持有部分资源,但在等待其他资源时,不释放原有资源。

例如:

text 复制代码
线程A持有mutex1

继续等待mutex2

19.3 不可剥夺条件

执行流已经获得的资源,不能被其他执行流任意强制剥夺。

例如,正常互斥锁应由符合其所有权规则的线程负责释放。

19.4 循环等待条件

多个执行流形成一个首尾相接的资源等待环。

例如:

text 复制代码
线程A等待线程B

线程B等待线程A

这正是前面的两个锁示例。


二十、如何避免死锁?

可以从前面的四个必要条件出发,尽量破坏死锁形成条件。

20.1 统一加锁顺序

例如规定:

text 复制代码
任何线程都先申请mutex1

再申请mutex2

这样就能够避免这两把锁之间因相反加锁顺序形成循环等待。

例如:

cpp 复制代码
void Function()
{
    mutex1.lock();
    mutex2.lock();

    // 访问资源

    mutex2.unlock();
    mutex1.unlock();
}

这只是一个简单示例,实际代码还应考虑异常退出等情况下的资源释放。

20.2 使用std::lock()

C++提供:

cpp 复制代码
std::lock()

可以协调多个互斥量的获取,避免普通的相反顺序加锁导致的死锁。

例如:

cpp 复制代码
#include <mutex>

std::mutex mutex1;
std::mutex mutex2;

void Function()
{
    std::unique_lock<std::mutex> lock1(
        mutex1,
        std::defer_lock
    );

    std::unique_lock<std::mutex> lock2(
        mutex2,
        std::defer_lock
    );

    std::lock(lock1, lock2);

    // 此时两把锁都已经成功获得
}

这里:

cpp 复制代码
std::defer_lock

表示构造 unique_lock 时暂时不立即加锁。

随后:

cpp 复制代码
std::lock(lock1, lock2);

协调获得两把锁。

当函数离开作用域时,两个 unique_lock 对象负责释放各自持有的互斥量。

20.3 避免遗漏解锁

之前已经学习:

cpp 复制代码
std::lock_guard

以及自定义:

cpp 复制代码
LockGuard

通过 RAII 管理锁。

这样可以减少由于正常函数返回、分支退出等原因遗漏解锁的问题。

20.4 设置适当的等待策略

对于一些场景,可以考虑使用超时或非阻塞的锁获取机制。

但是否适合,需要根据具体资源和业务要求判断。

本节重点掌握:

统一锁顺序、正确管理锁生命周期,以及避免持有一把锁长期等待另一把锁。

20.5 死锁检测算法

操作系统理论中还存在:

text 复制代码
死锁检测
死锁避免
银行家算法

等概念。

这里先了解它们的名称与大致目的:

  • 死锁检测:分析系统是否已经进入死锁状态。
  • 死锁避免:通过资源分配策略尽量避免进入不安全状态。
  • 银行家算法:一种经典的资源分配与死锁避免算法。

暂时不展开具体算法推导。


二十一、STL容器是否线程安全?

21.1 std::vector是否自动加锁?

例如:

cpp 复制代码
std::vector<int> values;

如果两个线程同时执行:

cpp 复制代码
values.push_back(100);

和:

cpp 复制代码
values.push_back(200);

不能认为这是天然安全的。

因为 std::vector 的内部状态可能包括:

text 复制代码
数据指针
元素数量
容量

插入操作可能修改这些共享状态。

因此如果没有适当同步,就可能发生数据竞争。

21.2 为什么STL不统一为容器内部加锁?

因为不同程序的并发访问方式不同。

例如:

text 复制代码
有的程序只有一个线程使用容器

如果容器内部所有操作都无条件加锁,可能增加不必要的开销。

另外,不同容器适合采用的锁粒度也不同。

例如:

text 复制代码
哈希表可以考虑锁整个表

也可以考虑对桶进行更细粒度保护

但这些具体策略取决于使用场景。

因此,STL 通常不会将所有容器成员函数设计成在任意并发访问情况下都自动安全。

21.3 STL完全不能在多线程中使用吗?

不是。

例如:

text 复制代码
多个线程只读一个不被修改的容器

通常可以安全进行。

而如果多个线程需要同时修改同一个容器,就需要遵守容器相关的并发访问规则,并进行适当同步。

例如:

cpp 复制代码
#include <vector>
#include <mutex>

std::vector<int> values;

std::mutex mutex;

void AddValue(int value)
{
    std::lock_guard<std::mutex> guard(mutex);

    values.push_back(value);
}

这样就可以通过外部锁保护共享容器的插入操作。


二十二、智能指针是否线程安全?

22.1 unique_ptr

std::unique_ptr 表示独占所有权。

例如:

cpp 复制代码
std::unique_ptr<int> ptr =
    std::make_unique<int>(100);

通常不能直接复制一个 unique_ptr。

但需要注意:

独占所有权不等于对象永远不会被多个线程访问。

如果多个线程通过其他指针或引用访问同一个被管理对象,仍然需要考虑线程安全问题。

所以 unique_ptr 解决的主要是所有权管理,而不是一般意义上的线程同步。

22.2 shared_ptr

std::shared_ptr 使用共享所有权机制。

例如:

cpp 复制代码
auto ptr = std::make_shared<int>(100);

可以复制:

cpp 复制代码
auto ptr2 = ptr;

两个 shared_ptr 对象共同管理同一个对象。

22.3 shared_ptr为什么需要处理引用计数?

因为每次复制或销毁共享所有权对象,都会影响相应控制块中的引用计数。

例如:

text 复制代码
shared_ptr A
    |
    v
控制块引用计数
    ^
    |
shared_ptr B

多个线程可能操作不同的 shared_ptr 实例,但它们共享同一控制块。

因此标准库需要使相关引用计数管理满足必要的并发安全要求。

通常可以使用原子操作等机制实现。

22.4 shared_ptr保证被管理对象线程安全吗?

不保证。

例如:

cpp 复制代码
auto ptr = std::make_shared<int>(0);

如果多个线程同时执行:

cpp 复制代码
++(*ptr);

仍然可能发生数据竞争。

因为:

控制块引用计数的线程安全,不等于被指向对象的读写线程安全。

另外,如果多个线程并发修改同一个 shared_ptr 变量本身,也需要遵守对应的同步规则。

22.5 总结

类型 主要保证 需要注意
unique_ptr 独占所有权 不自动保护被管理对象的并发访问
shared_ptr 共享所有权及控制块引用计数管理 不自动保护被管理对象的并发读写
STL容器 提供通用数据结构 并发修改通常需要外部同步

二十三、其他常见锁概念

最后简单认识几组与锁有关的概念。

这些概念不需要在当前阶段深入实现,但应该知道它们的基本含义。

23.1 悲观锁

悲观锁可以理解为:

假定共享数据有可能被其他执行流并发修改,因此在访问前先进行相应的同步保护。

例如互斥锁是常见的悲观式并发控制工具。

23.2 乐观锁

乐观锁的基本思想是:

先尝试完成操作,在提交修改时检查数据是否仍满足预期条件。

如果发现冲突,则通过失败处理或者重试机制进行协调。

例如可以使用:

text 复制代码
版本号
CAS

等方法。

23.3 CAS

CAS 全称:

text 复制代码
Compare And Swap

即比较并交换。

其基本思想是:

text 复制代码
检查当前值是否仍等于预期值
        |
        +-- 相等:原子更新为新值
        |
        +-- 不相等:本次更新失败

例如:

text 复制代码
预期值 = 10

实际值 = 10

则可以尝试执行原子更新。

如果:

text 复制代码
实际值已经变成11

就说明其他执行流可能已经改变了共享数据。

这时 CAS 操作失败,调用者可以根据业务选择是否重试。

23.4 自旋锁

自旋锁是一类在获取锁失败时,可以通过反复尝试等待锁可用的同步机制。

其典型特点是:

text 复制代码
没有立即获得锁
        |
        v
不断尝试获取

与可能将线程挂起等待的互斥量相比,自旋锁适用于某些持锁时间很短的场景,但如果等待时间较长,就可能浪费 CPU 时间。

23.5 读写锁

读写锁用于区分:

text 复制代码
读取操作
写入操作

例如:

text 复制代码
多个读取者可以同时读取

写入者需要满足排他访问要求

因此读写锁适合某些读多写少的共享资源场景。

这里先掌握概念,不进一步展开具体接口。


二十四、常见问题总结

24.1 为什么日志输出需要加锁?

因为多个线程可能同时写入同一个输出目标,需要保证日志内容不会因不安全的并发操作而被破坏。

24.2 RAII为什么适合日志设计?

因为可以利用临时对象析构时机,在日志内容构建完成后统一输出。

24.3 为什么线程池不能让所有工作线程一起处理任务队列?

因为共享队列内部状态需要互斥保护,必须安全地取得和删除任务。

24.4 为什么线程池没有任务时不应该一直空循环?

因为忙等待会消耗 CPU 时间,可以使用条件变量让工作线程进入等待状态。

24.5 为什么工作线程取出任务后要释放队列锁?

因为任务执行可能耗时较长,不应该长期阻止其他线程访问任务队列。

24.6 为什么Stop需要NotifyAll?

因为所有等待线程都需要获得重新检查退出条件的机会。

24.7 线程池关闭以后是否必须丢弃所有剩余任务?

不一定。

可以采用平滑退出策略,让工作线程处理完剩余任务后再结束。

24.8 单例模式为什么存在多线程问题?

如果多个线程同时发现实例尚未创建,就可能重复创建对象。

24.9 volatile可以保证懒汉单例线程安全吗?

不能。

C++中的线程同步不能简单通过普通共享指针加 volatile 保证。

24.10 线程安全与可重入相同吗?

不同。

线程安全关注并发调用;可重入关注函数尚未完成前又被再次进入时的正确性。

24.11 为什么线程安全函数仍然可能不可重入?

因为普通互斥锁可以保护多线程访问,却可能在同一执行流重入时造成自我死锁。

24.12 申请一把锁和申请两把锁有什么区别?

两次独立加锁操作不构成一个天然原子的整体,如果加锁顺序不一致,可能形成循环等待。

24.13 std::vector是否会自动保护并发push_back?

不会。

对同一个容器对象的不安全并发修改仍然需要同步。

24.14 shared_ptr能够保证对象内部数据安全吗?

不能。

它的共享所有权管理不等于对所指向对象提供互斥保护。


总结

本文从日志设计开始,逐步将互斥锁与条件变量应用到实际多线程程序中。

首先,利用:

text 复制代码
LogStrategy
ConsoleLogStrategy
FileLogStrategy

实现日志策略模式。

再通过:

text 复制代码
LogMessage
operator<<
RAII

让日志信息能够分段构建、整体输出。

接下来,我们结合:

text 复制代码
线程集合
任务队列
互斥锁
条件变量

实现固定数量线程池。

线程池的核心工作过程是:

text 复制代码
预先创建工作线程
        |
        v
任务进入共享队列
        |
        v
条件变量唤醒工作线程
        |
        v
线程加锁取任务
        |
        v
线程解锁并执行任务
        |
        v
继续等待下一次任务

然后进一步学习:

text 复制代码
Stop
Wait

理解线程池如何平滑退出。

之后,我们研究线程安全单例模式,认识:

text 复制代码
饿汉模式
懒汉模式
双重检查加锁

并理解 C++11 局部静态变量初始化在多线程环境中的作用。

最后,进一步分析:

text 复制代码
线程安全
可重入函数
死锁
死锁四个必要条件
STL容器的线程安全
智能指针的线程安全
悲观锁与乐观锁
CAS

等重要概念。

把两篇文章联系起来,可以形成完整的知识路径:

text 复制代码
共享资源
    |
    v
临界区与互斥
    |
    v
Mutex
    |
    v
条件变量与线程同步
    |
    v
生产者---消费者模型
    |
    v
BlockingQueue / RingQueue
    |
    v
日志系统与线程池
    |
    v
线程安全单例
    |
    v
可重入性与死锁
    |
    v
常见并发容器与锁概念

因此,线程同步与互斥的最终目的,不仅是学会调用几组 API。

更重要的是:

能够识别共享资源、正确划分临界区、协调线程之间的执行关系,并将这些机制用于构建可靠的多线程程序。


相关推荐
mengge.cloud42 分钟前
1022小白入门教程(番外筑基)— nerdctl 容器基本功 + K8s 资源编写
linux·网络·云原生·容器·kubernetes·云计算·k8s
张小姐的猫1 小时前
【MySQL】表的约束
android·linux·服务器·数据库·c++·mysql·adb
whyutianict_vv1 小时前
HCIE-Datacom 考纲技术拆解:从 OSPF/BGP 到 VXLAN/Netconf 的知识地图
运维·网络
无足鸟ICT1 小时前
【RHCA+】yum命令(查询、安装、升级和卸载软件包)
linux
kyle~1 小时前
数据结构 --- 无锁队列(Lock-Free Queue)
linux·数据结构·c++·多线程·并发
邪修king1 小时前
Re:Linux系统篇(三十六):进程间通信Chapter3:从匿名管道到命名管道:Linux 进程间通信实战(FIFO 深度解析)
linux·运维·服务器
Rabitebla1 小时前
【Linux】 权限篇(三):深入理解 umask 位运算与粘滞位的设计思想
java·linux·服务器·c++·笔记·学习
Tech探索者2 小时前
重装飞牛 NAS,几 KB 的 .bin 够吗?我的备份与恢复全过程
运维·服务器
java_nnnn2 小时前
Linux 学习第一篇:从零认识 Linux,使用 WSL + Ubuntu 搭建学习环境
linux·运维·学习