欢迎光临
我们一直在努力

Linux 线程同步与互斥——日志系统与线程池

互斥锁、条件变量和信号量单独使用时,解决的是比较具体的问题。把这些机制组合起来,就能够实现更完整的多线程组件,比如日志系统和线程池。

这两个例子很适合用来理解同步原语的实际用途:日志系统需要避免多个线程同时写日志造成混乱,线程池则需要让一组工作线程共享任务队列,并在有新任务时及时开始工作。

一、日志系统为什么也需要线程同步?

日志的作用很直接:记录程序运行过程中发生的事件,帮助我们了解程序的执行状态,并在出错后查找问题。

一个比较常见的日志格式是:

[2026-10-22 09:00:00] [INFO] [12345] [main.cpp] [20] – program started

其中可以包含时间、日志等级、进程 ID、源文件名、行号和具体消息。

如果程序只有一个线程,输出日志通常比较简单。但在多线程程序中,不同线程可能在同一时刻写日志。如果没有做好保护,日志输出就可能出现混杂,日志对象内部的数据也可能因为并发访问而被破坏。

日志系统本身同样是需要考虑线程安全的共享组件。

二、用策略模式切换日志输出方式

日志既可以输出到终端,也可以写入文件。为了避免把两种输出方式混在同一个类的各种分支判断中,可以把真正的输出行为抽象成一个策略接口。

例如:

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
{
// 将日志输出到终端
}
};

class FileLogStrategy : public LogStrategy
{
public:
void SyncLog(
const std::string& message
) override
{
// 将日志追加写入文件
}
};

日志管理器只需要持有一个 LogStrategy 对象,就可以通过更换策略切换实际输出方式,而不必改变业务代码中每一处日志调用。

比如在调试时选择终端输出,程序运行时选择文件输出。日志格式和日志调用方式可以保持不变,变化的只是底层输出策略。

这就是策略模式在这里的作用:把容易发生变化的部分独立出来,让调用者与具体实现解耦。

三、日志输出中的互斥保护

策略模式解决的是输出方式的组织问题,但它并不会自动解决线程安全问题。

假如多个线程同时调用文件日志策略,如果它们共同操作同一个输出对象或共享状态,就需要合理地保护这些资源。

一种简单方式是在日志写入过程中使用互斥锁:

pthread_mutex_lock(&mutex);

// 组织日志内容并写入输出目标

pthread_mutex_unlock(&mutex);

通过互斥锁,可以避免多个线程同时进入受保护的日志输出区域。

如果把锁封装成前面学过的 RAII 风格对象,就可以在日志函数结束时自动解锁,减少遗漏解锁的风险。

当然,也不能认为整个程序中所有数据都必须由一把大锁保护。应该明确哪些对象是共享的、哪些步骤必须保持完整,并尽量缩小临界区。


四、日志模拟实现

#ifndef __LOG_HPP__
#define __LOG_HPP__

#include <iostream>
#include <cstdio>
#include <string>
#include <filesystem> // C++17
#include <sstream>
#include <fstream>
#include <memory>
#include <ctime>
#include <unistd.h>
#include "Mutex.hpp"

namespace LogModule
{
using namespace MutexModule;

const std::string gsep = "\\r\\n";
// 2. 刷新策略 a: 显示器打印 b: 向指定的文件写入
// 刷新策略基类
class LogStrategy
{
public:
~LogStrategy() = default;
virtual void SyncLog(const std::string &message) = 0;
};

// 显示器打印日志的策略 : 子类
class ConsoleLogStrategy : public LogStrategy
{
public:
ConsoleLogStrategy()
{
}
void SyncLog(const std::string &message) override
{
LockGuard lockguard(_mutex);
std::cout << message << gsep;
}
~ConsoleLogStrategy()
{
}

private:
Mutex _mutex;
};

// 文件打印日志的策略 : 子类
const std::string defaultpath = "./log";
const std::string defaultfile = "my.log";
class FileLogStrategy : public LogStrategy
{
public:
FileLogStrategy(const std::string &path = defaultpath, const std::string &file = defaultfile)
: _path(path),
_file(file)
{
LockGuard lockguard(_mutex);
if (std::filesystem::exists(_path))
{
return;
}
try
{
std::filesystem::create_directories(_path);
}
catch (const std::filesystem::filesystem_error &e)
{
std::cerr << e.what() << '\\n';
}
}
void SyncLog(const std::string &message) override
{
LockGuard lockguard(_mutex);
std::string filename = _path + (_path.back() == '/' ? "" : "/") + _file;
std::ofstream out(filename, std::ios::app); // 追加写入的方式打开
if (!out.is_open())
{
return;
}
out << message << gsep;
out.close();
}
~FileLogStrategy() {}

private:
std::string _path; // 日志文件所在路径
std::string _file; // 日志文件本身
Mutex _mutex;
};

// 形成一条完整的日志&&根据上面的策略, 选择不同的刷新方式
// 1. 形成日志等级
enum class LogLevel
{
DEBUG,
INFO,
WARNING,
ERROR,
FATAL
};
std::string LevelToStr(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";
}
}
std::string GetTimeStamp()
{
time_t curr = time(nullptr);
struct tm curr_tm;
localtime_r(&curr, &curr_tm);
char timebuffer[128];
snprintf(timebuffer, sizeof(timebuffer), "%4d-%02d-%02d %02d:%02d:%02d",
curr_tm.tm_year + 1900,
curr_tm.tm_mon + 1,
curr_tm.tm_mday,
curr_tm.tm_hour,
curr_tm.tm_min,
curr_tm.tm_sec);
return timebuffer;
}

// 1. 形成日志 2. 根据不同的策略, 完成刷新
class Logger
{
public:
Logger()
{
EnableConsoleLogStrategy();
}
void EnableFileLogStrategy()
{
_fflush_strategy = std::make_unique<FileLogStrategy>();
}
void EnableConsoleLogStrategy()
{
_fflush_strategy = std::make_unique<ConsoleLogStrategy>();
}
// 表示的是一条日志
class LogMessage
{
public:
LogMessage(LogLevel &level, std::string &src_name, int line_number, Logger &logger)
: _curr_time(GetTimeStamp()),
_level(level),
_pid(getpid()),
_src_name(src_name),
_line_number(line_number),
_logger(logger)
{
std::stringstream ss;
ss << "[" << _curr_time << "] "
<< "[" << LevelToStr(_level) << "] "
<< "[" << _pid << "] "
<< "[" << _src_name << "] "
<< "[" << _line_number << "] "
<< "- ";
_loginfo = ss.str();
}
template <typename T>
LogMessage &operator<<(const T &info)
{
std::stringstream ss;
ss << info;
_loginfo += ss.str();
return *this;
}
~LogMessage()
{
if (_logger._fflush_strategy)
{
_logger._fflush_strategy->SyncLog(_loginfo);
}
}

private:
std::string _curr_time;
LogLevel _level;
pid_t _pid;
std::string _src_name;
int _line_number;
std::string _loginfo; // 合并之后, 一条完整的信息
Logger &_logger;
};
LogMessage operator()(LogLevel level, std::string name, int line)
{
return LogMessage(level, name, line, *this);
}
~Logger() {}

private:
std::unique_ptr<LogStrategy> _fflush_strategy;
};

// 全局日志对象
Logger logger;

// 使用宏, 简化用户操作, 获取文件名和行号
#define LOG(level) logger(level, __FILE__, __LINE__)
#define Enable_Console_Log_Strategy() logger.EnableConsoleLogStrategy()
#define Enable_File_Log_Strategy() logger.EnableFileLogStrategy()
}

#endif


五、为什么服务器经常使用线程池?

假设一个服务器每收到一个请求,就创建一个新线程来处理;请求处理完成后,再让这个线程退出。

如果请求数量很大,程序就会频繁创建和销毁线程。线程的创建、销毁与调度都存在开销,而且短时间内出现大量请求时,线程数量也可能迅速增加。

对于执行时间较短、数量很多的任务,这种处理方式未必合适。

线程池的思路是:提前创建一组工作线程,让它们反复从任务队列中获取任务,完成后继续等待下一项任务。

这样就能复用线程,减少频繁创建和销毁线程的成本。线程池中的工作线程数量可以固定下来,通过任务队列调节任务进入系统的速度。

典型的线程池包括三个部分:

  • 工作线程:负责从任务队列中获取并执行任务。
  • 任务队列:保存尚未处理的任务。
  • 同步机制:保护任务队列,并在有新任务时唤醒等待线程。

六、工作线程怎样取任务?

线程池中的工作线程可以按照下面的流程工作:

while (true)
{
mutex.Lock();

while (task_queue.empty() && is_running)
{
cond.Wait(mutex);
}

if (task_queue.empty() && !is_running)
{
mutex.Unlock();
break;
}

auto task = task_queue.front();
task_queue.pop();

mutex.Unlock();

task();
}

这段代码展示的是核心逻辑,mutex 和 cond 分别代表互斥锁和条件变量的封装,task_queue 代表任务队列。

先看等待部分:

while (task_queue.empty() && is_running)
{
cond.Wait(mutex);
}

如果任务队列为空,而且线程池仍处于运行状态,那么当前工作线程没有任务可做,就可以等待条件变量,而不是一直循环检查队列。

当其他线程把任务放入队列时,就可以发出通知,唤醒某个正在等待的工作线程。

再看取任务部分:

auto task = task_queue.front();
task_queue.pop();

mutex.Unlock();

task();

工作线程先在锁的保护下从队列中取出任务,然后释放锁,再真正执行任务。

这里很重要的一点是,不要在执行整个业务任务时一直持有任务队列的锁。否则,一个线程正在执行耗时任务,其他线程就可能无法及时向队列加入新任务,也可能无法取出任务。

取出任务后尽快解锁,可以让多个工作线程并发执行各自的任务。

七、任务是怎么放进线程池的?

线程池一般会提供一个提交任务的接口。例如:

bool Enqueue(const Task& task)
{
mutex.Lock();

if (!is_running)
{
mutex.Unlock();
return false;
}

task_queue.push(task);
cond.Notify();

mutex.Unlock();
return true;
}

这里同样需要保护任务队列。提交任务时,先获取锁,检查线程池状态,再把任务放入队列,并通知可能处于等待状态的工作线程。

线程池已经停止时,就不应该继续接受新任务。因此,任务入队操作需要检查运行状态,并明确提交失败时的处理方式。

在实际实现中,还需要正确处理锁的释放、异常路径以及任务对象本身的生命周期。

八、线程池应该怎样退出?

线程池不能仅仅设置一个标志就结束,因为可能还有工作线程正在等待条件变量。

一种常见做法是提供 Stop() 和 Wait() 两个接口。

Stop() 负责改变线程池状态,并唤醒所有等待中的工作线程:

mutex.Lock();
is_running = false;
cond.NotifyAll();
mutex.Unlock();

Wait() 则负责逐个等待工作线程结束。

工作线程被唤醒后,会重新检查任务队列和运行状态。若线程池已经停止,但队列中还有任务,通常可以继续处理剩余任务;如果队列为空且线程池已经停止,就可以退出循环。

这种关闭方式能够让线程池有机会处理完已有任务,而不是只把运行标志改掉就直接结束所有工作线程。

线程池的退出逻辑需要和任务提交逻辑配合好,否则就可能出现一边已经准备退出,另一边还在提交任务的情况。


九、线程池示例代码

#pragma once
#include <iostream>
#include <string>
#include <vector>
#include <queue>
#include "Log.hpp"
#include "Thread.hpp"
#include "Cond.hpp"
#include "Mutex.hpp"

namespace ThreadPoolModule
{
using namespace ThreadModule;
using namespace LogModule;
using namespace CondModule;
using namespace MutexModule;

static const int gnum = 5;
template <typename T>
class ThreadPool
{
private:
void WakeUpAllThread()
{
LockGuard lockguard(_mutex);
if (_sleepernum)
_cond.Broadcast();
LOG(LogLevel::INFO) << "唤醒所有的休眠线程";
}
void WakeUpOne()
{
_cond.Signal();
LOG(LogLevel::INFO) << "唤醒一个休眠线程";
}
void Start()
{
if (_isrunning)
return;
_isrunning = true;
for (auto &thread : _threads)
{
thread.Start();
LOG(LogLevel::INFO) << "start new thread success: " << thread.Name();
}
}
ThreadPool(int num = gnum) : _num(num), _isrunning(false), _sleepernum(0)
{
for (int i = 0; i < num; ++i)
{
_threads.emplace_back(
[this]()
{
HandlerTask();
});
}
}
ThreadPool(const ThreadPool<T> &) = delete;
ThreadPool<T> &operator=(const ThreadPool<T> &) = delete;

public:
static ThreadPool<T> *GetInstance()
{
if (inc == nullptr)
{
LockGuard lockguard(_lock);
LOG(LogLevel::DEBUG) << "获取单例…";
if (inc == nullptr)
{
LOG(LogLevel::DEBUG) << "首次使用单例, 创建之…";
inc = new ThreadPool<T>();
inc->Start();
}
}
return inc;
}
void Stop()
{
if (!_isrunning)
return;
_isrunning = false;

// 唤醒所有的线程
WakeUpAllThread();
}
void Join()
{
for (auto &thread : _threads)
{
thread.Join();
}
}
void HandlerTask()
{
char name[128];
pthread_getname_np(pthread_self(), name, sizeof(name));
while (true)
{
T t;
{
LockGuard lockguard(_mutex);
// 1. a.队列为空 b.线程池没有退出
while (_taskq.empty() && _isrunning)
{
_sleepernum++;
_cond.Wait(_mutex);
_sleepernum—;
}
// 2. 内部的线程被唤醒
if (!_isrunning && _taskq.empty())
{
LOG(LogLevel::INFO) << name << " 退出了, 线程池退出&&任务队列为空";
break;
}
// 一定有任务
t = _taskq.front();
_taskq.pop();
}
t(); // 处理任务
}
}
bool Enqueue(const T &in)
{
if (_isrunning)
{
LockGuard lockguard(_mutex);
_taskq.push(in);
if (_threads.size() == _sleepernum)
WakeUpOne();
return true;
}
return false;
}
~ThreadPool()
{
}

private:
std::vector<Thread> _threads;
int _num; // 线程池中, 线程的个数
std::queue<T> _taskq;
Cond _cond;
Mutex _mutex;
bool _isrunning;
int _sleepernum;

static ThreadPool<T> *inc; // 单例指针
static Mutex _lock;
};

template <typename T>
ThreadPool<T> *ThreadPool<T>::inc = nullptr;

template <typename T>
Mutex ThreadPool<T>::_lock;
}


十、为什么任务队列需要互斥锁,工作线程需要条件变量?

这两个机制各自负责不同的工作。

互斥锁用于保护任务队列及线程池状态,确保多个工作线程不会同时以不受保护的方式修改队列。

条件变量用于处理等待和通知。队列暂时没有任务时,工作线程可以等待;有任务进入队列时,提交任务的线程可以发出通知。

因此,线程池不仅是一个创建了多个线程的程序。它实际上是把互斥、同步和任务调度组合在了一起。

如果没有互斥锁,多个线程同时操作任务队列可能产生冲突;如果没有合理的等待机制,工作线程就可能不断轮询一个空队列,白白消耗 CPU 时间。

十一、线程安全与可重入

在多线程程序中,经常会提到线程安全和可重入。它们有关联,但不是同一个概念。

线程安全,关注多个线程同时使用某段代码或同一个对象时,是否会因共享状态而造成错误。

例如,一个函数在修改全局变量时没有任何保护,那么多个线程同时调用它,就可能发生数据竞争。

可重入,关注一个函数在前一次调用尚未完成时,是否能被另一个执行流程再次进入,并且依然保持正确行为。

例如,某个函数正在修改全局链表,中途被信号处理函数打断,而信号处理函数又调用同一个函数修改同一条链表,就可能导致链表状态损坏。

有些函数会操作内部共享状态,因此使用时需要格外小心。例如,内存分配函数可能维护内部数据结构;一些标准 I/O 库函数也可能使用内部状态。不能简单地认为任意函数都适合在信号处理函数或多线程共享场景下随意调用。

判断代码是否线程安全或可重入,不能只看函数本身有没有局部变量,还要看它访问了什么共享数据、调用了哪些函数,以及这些操作是否受到适当保护。

十二、总结

日志系统和线程池让互斥锁、条件变量的用途变得更加直观。

日志系统需要保护共享输出状态,同时可以通过策略模式切换输出方式;线程池则通过固定数量的工作线程和一个任务队列,减少频繁创建线程的开销,并用条件变量在没有任务时让线程等待。

从售票程序到线程池,可以看出多线程编程一直围绕几个问题展开:共享数据怎么保护、线程在什么条件下工作、任务怎样分配,以及程序退出时如何妥善结束线程。

真正理解这些机制后,写多线程程序就不再只是调用几个 Pthreads 函数,而是能够根据实际场景设计出合理的同步结构。

赞(0)
未经允许不得转载:171主机测评 » Linux 线程同步与互斥——日志系统与线程池
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址