基础知识
日志
,由服务器自动创建,并记录运行状态,错误信息,访问数据的文件。
同步日志
,日志写入函数与工作线程串行执行,由于涉及到I/O操作,当单条日志比较大的时候,同步模式会阻塞整个处理流程,服务器所能处理的并发能力将有所下降,尤其是在峰值的时候,写日志可能成为系统的瓶颈。
生产者-消费者模型
,并发编程中的经典模型。以多线程为例,为了实现线程间数据同步,生产者线程与消费者线程共享一个缓冲区,其中生产者线程往缓冲区中push消息,消费者线程从缓冲区中pop消息。
阻塞队列
,将生产者-消费者模型进行封装,使用循环数组实现队列,作为两者共享的缓冲区。
异步日志
,将所写的日志内容先存入阻塞队列,写线程从阻塞队列中取出内容,写入日志。
单例模式
,最简单也是被问到最多的设计模式之一,保证一个类只创建一个实例,同时提供全局访问的方法。
整体概述
本项目中,使用单例模式创建日志系统,对服务器运行状态、错误信息和访问数据进行记录,该系统可以实现按天分类,超行分类功能,可以根据实际情况分别使用同步和异步写入两种方式。
其中异步写入方式,将生产者-消费者模型封装为阻塞队列,创建一个写线程,工作线程将要写的内容push进队列,写线程从队列中取出内容,写入日志文件。
日志系统大致可以分成两部分,其一是单例模式与阻塞队列的定义,其二是日志类的定义与使用。
本文内容
本篇将介绍单例模式与阻塞队列的定义,具体的涉及到单例模式、生产者-消费者模型,阻塞队列的代码实现。
单例模式,描述懒汉与饿汉两种单例模式,并结合线程安全进行讨论。
生产者-消费者模型,描述条件变量,基于该同步机制实现简单的生产者-消费者模型。
代码实现,结合代码对阻塞队列的设计进行详解。
单例模式
单例模式作为最常用的设计模式之一,保证一个类仅有一个实例,并提供一个访问它的全局访问点,该实例被所有程序模块共享。
实现思路:私有化它的构造函数,以防止外界创建单例类的对象;使用类的私有静态指针变量指向类的唯一实例,并用一个公有的静态方法获取该实例。
单例模式有两种实现方法,分别是懒汉和饿汉模式。顾名思义,懒汉模式,即非常懒,不用的时候不去初始化,所以在第一次被使用时才进行初始化;饿汉模式,即迫不及待,在程序运行时立即初始化。
#include <pthread.h>
// 单例类,确保整个程序只有一个实例
class single {
private:
// 私有静态指针变量指向唯一的实例
static single* p;
// 静态互斥锁,用于线程安全的实例创建
static pthread_mutex_t lock;
// 私有化构造函数,防止外部创建实例
single() {
// 初始化互斥锁
pthread_mutex_init(&lock, nullptr);
}
// 私有化析构函数,避免外部删除实例
~single() {}
public:
// 公有静态方法,用于获取单例实例
static single* getinstance();
};
// 定义静态互斥锁变量
pthread_mutex_t single::lock;
// 静态实例指针初始化为nullptr
single* single::p = nullptr;
/**
* 获取单例实例的方法
* 该方法确保在多线程环境下安全地创建单例对象
* 返回值:单例对象指针
*/
single* single::getinstance() {
// 如果实例尚未创建
if (nullptr == p) {
// 加锁,防止多线程同时创建实例
pthread_mutex_lock(&lock);
// 再次检查实例是否创建,避免不必要的创建
if (nullptr == p) {
// 实例化单例对象
p = new single;
}
// 解锁
pthread_mutex_unlock(&lock);
}
// 返回单例实例
return p;
}
为什么要用双检测,只检测一次不行吗?
如果只检测一次,在每次调用获取实例的方法时,都需要加锁,这将严重影响程序性能。双层检测可以有效避免这种情况,仅在第一次创建单例的时候加锁,其他时候都不再符合NULL == p的情况,直接返回已创建好的实例。
局部静态变量之线程安全懒汉模式
前面的双检测锁模式,写起来不太优雅,《Effective C++》(Item 04)中的提出另一种更优雅的单例模式实现,使用函数内的局部静态对象,这种方法不用加锁和解锁操作。
// 单例类,防止外部实例化和删除
class single {
private:
// 私有构造函数,防止外部实例化
single() {}
// 私有析构函数,防止外部delete
~single() {}
public:
// 获取单例对象的静态方法
static single* getinstance();
};
// 获取单例对象的实现
single* single::getinstance() {
// 静态局部变量,保证其只被实例化一次
static single obj;
// 返回单例对象的地址
return &obj;
}
这时候有人说了,这种方法不加锁会不会造成线程安全问题?
其实,C++0X以后,要求编译器保证内部静态变量的线程安全性,故C++0x之后该实现是线程安全的,C++0x之前仍需加锁,其中C++0x是C++11标准成为正式标准之前的草案临时名字。
所以,如果使用C++11之前的标准,还是需要加锁,这里同样给出加锁的版本。
// 单例类single,确保整个程序中只有一个实例
class single {
private:
// 静态互斥锁,用于线程安全的实例化
static pthread_mutex_t lock;
// 私有构造函数,防止外部直接构造对象
single() {
// 初始化互斥锁
pthread_mutex_init(&lock, nullptr);
}
// 私有析构函数,避免对象被删除
~single(){}
public:
// 获取单例对象的静态方法
static single* getinstance();
};
// 静态互斥锁变量
pthread_mutex_t single::lock;
// 单例模式的线程安全实例化方法
single* single::getinstance() {
// 加锁,保证线程安全
pthread_mutex_lock(&lock);
// 静态局部变量,保证只在第一次调用时初始化
static single obj;
// 解锁
pthread_mutex_unlock(&lock);
// 返回单例对象的地址
return &obj;
}
饿汉模式
饿汉模式不需要用锁,就可以实现线程安全。原因在于,在程序运行时就定义了对象,并对其初始化。之后,不管哪个线程调用成员函数getinstance(),都只不过是返回一个对象的指针而已。所以是线程安全的,不需要在获取实例的成员函数中加锁。
#include <iostream>
using namespace std;
// 定义一个单例类
class single{
private:
// 静态成员指针,用于存储单例对象的唯一实例
static single* p;
// 私有构造函数,防止外部直接创建对象
single() {}
// 私有析构函数,防止外部删除对象
~single() {}
public:
// 静态方法,返回单例对象的指针
static single* getinstance();
};
// 静态成员变量初始化,创建并初始化单例对象
single* single::p = new single();
// 获取单例对象的指针方法
single* single::getinstance() {
return p;
}
int main() {
// 获取单例对象的指针
single* p1 = single::getinstance();
// 再次获取单例对象的指针
single* p2 = single::getinstance();
// 检查两次获取的指针是否相同,验证单例特性
if (p1 == p2) {
cout << "same" << endl;
}
return 0;
}
饿汉模式虽好,但其存在隐藏的问题,在于非静态对象(函数外的static对象)在不同编译单元中的初始化顺序是未定义的。如果在初始化完成之前调用 getInstance() 方法会返回一个未定义的实例。
- 懒汉模式 在第一次调用
getInstance()
时才会实例化对象,适用于对资源敏感的场景,但需要处理线程安全问题。 - 饿汉模式 在类加载时就创建对象,无需考虑线程安全问题,适用于资源不敏感且确保对象一定会被使用的场景。
条件变量与生产者-消费者模型
条件变量API与陷阱
条件变量提供了一种线程间的通知机制,当某个共享数据达到某个值时,唤醒等待这个共享数据的线程。
基础API
- pthread_cond_init函数,用于初始化条件变量
- pthread_cond_destory函数,销毁条件变量
- pthread_cond_broadcast函数,以广播的方式唤醒所有等待目标条件变量的线程
- pthread_cond_wait函数,用于等待目标条件变量。该函数调用时需要传入 mutex参数(加锁的互斥锁) ,函数执行时,先把调用线程放入条件变量的请求队列,然后将互斥锁mutex解锁,当函数成功返回为0时,表示重新抢到了互斥锁,互斥锁会再次被锁上, 也就是说函数内部会有一次解锁和加锁操作.
使用pthread_cond_wait方式如下:
pthread_mutex_lock(&mutex)
while(线程执行条件是否成立) {
pthread_cond_wait(&cond, &mutex);
}
pthread_mutex_unlock(&mutex);
pthread_cond_wait执行后的内部操作分为以下几步:
- 将线程放在条件变量的请求队列后,内部解锁
- 线程等待被pthread_cond_broadcast信号唤醒或者pthread_cond_signal信号唤醒,唤醒后去竞争锁
- 若竞争到互斥锁,内部再次加锁
陷阱一
使用前要加锁,为什么要加锁?
多线程访问,为了避免资源竞争,所以要加锁,使得每个线程互斥的访问公有资源。
pthread_cond_wait内部为什么要解锁?
如果while或者if判断的时候,满足执行条件,线程便会调用pthread_cond_wait阻塞自己,此时它还在持有锁,如果他不解锁,那么其他线程将会无法访问公有资源。
具体到pthread_cond_wait的内部实现,当pthread_cond_wait被调用线程阻塞的时候,pthread_cond_wait会自动释放互斥锁。
为什么要把调用线程放入条件变量的请求队列后再解锁?
线程是并发执行的,如果在把调用线程A放在等待队列之前,就释放了互斥锁,这就意味着其他线程比如线程B可以获得互斥锁去访问公有资源,这时候线程A所等待的条件改变了,但是它没有被放在等待队列上,导致A忽略了等待条件被满足的信号。
倘若在线程A调用pthread_cond_wait开始,到把A放在等待队列的过程中,都持有互斥锁,其他线程无法得到互斥锁,就不能改变公有资源。
为什么最后还要加锁?
将线程放在条件变量的请求队列后,将其解锁,此时等待被唤醒,若成功竞争到互斥锁,再次加锁。
陷阱二
为什么判断线程执行的条件用while而不是if?
一般来说,在多线程资源竞争的时候,在一个使用资源的线程里面(消费者)判断资源是否可用,不可用,便调用pthread_cond_wait,在另一个线程里面(生产者)如果判断资源可用的话,则调用pthread_cond_signal发送一个资源可用信号。
在wait成功之后,资源就一定可以被使用么?答案是否定的,如果同时有两个或者两个以上的线程正在等待此资源,wait返回后,资源可能已经被使用了。
再具体点,有可能多个线程都在等待这个资源可用的信号,信号发出后只有一个资源可用,但是有A,B两个线程都在等待,B比较速度快,获得互斥锁,然后加锁,消耗资源,然后解锁,之后A获得互斥锁,但A回去发现资源已经被使用了,它便有两个选择,一个是去访问不存在的资源,另一个就是继续等待,那么继续等待下去的条件就是使用while,要不然使用if的话pthread_cond_wait返回后,就会顺序执行下去。
所以,在这种情况下,应该使用while而不是if:
while(resource == FALSE) {
pthread_cond_wait(&cond, &mutex);
}
如果只有一个消费者,那么使用if是可以的
生产者-消费者模型
这里摘抄《Unix 环境高级编程》中第11章线程关于pthread_cond_wait的介绍中有一个生产者-消费者的例子P311,其中,process_msg相当于消费者,enqueue_msg相当于生产者,struct msg* workq作为缓冲队列。
生产者和消费者是互斥关系,两者对缓冲区访问互斥,同时生产者和消费者又是一个相互协作与同步的关系,只有生产者生产之后,消费者才能消费。
生产者-消费者模型的优点
1)解耦合:将生产者类和消费者类进行解耦,消除代码之间的依赖性,简化工作负载的管理。
2)复用:通过将生产者类和消费者类独立开来,那么可以对生产者类和消费者类进行独立的复用与扩展。
3)调整并发数:由于生产者和消费者的处理速度是不一样的,可以调整并发数,给予慢的一方多的并发数,来提高任务的处理速度。
4)异步:对于生产者和消费者来说能够各司其职,生产者只需要关心缓冲区是否还有数据,不需要等待消费者处理完;同样的对于消费者来说,也只需要关注缓冲区的内容,不需要关注生产者,通过异步的方式支持高并发,将一个耗时的流程拆成生产和消费两个阶段,这样生产者因为执行 put() 的时间比较短,而支持高并发。
5)支持分布式:生产者和消费者通过队列进行通讯,所以不需要运行在同一台机器上,在分布式环境中可以通过 redis 的 list 作为队列,而消费者只需要轮询队列中是否有数据。同时还能支持集群的伸缩性,当某台机器宕掉的时候,不会导致整个集群宕掉。
#include <pthread.h>
// 消息结构体定义,用于在队列中传递消息
struct msg
{
struct msg* m_next; // 指向下一个消息的指针
// value.. // 这里可以存储消息的具体内容
};
// 工作队列的全局变量,头指针指向队列中的第一个消息
struct msg* workq;
// 初始化条件变量,用于在队列有消息时通知消费者线程
pthread_cond_t qread = PTHREAD_COND_INITIALIZER;
// 初始化互斥锁,用于保护工作队列的线程安全
pthread_mutex_t qlock = PTHREAD_MUTEX_INITIALIZER;
/**
* 处理消息的函数
*
* 本函数旨在从一个消息队列中不断取出消息并处理之
* 它通过线程互斥量(qlock)来保证消息队列的访问是线程安全的
* 同时,它使用条件变量(qread)来等待新消息的到达
*/
void process_msg() {
// 指向当前正在处理的消息
struct msg* mp;
// 持续等待并处理消息
for (;;) {
// 加锁以保护共享资源
pthread_mutex_lock(&qlock);
// 如果队列为空,则解锁并等待新消息
// 这里需要用while,而不是if,以防止虚假唤醒
while (workq == nullptr) {
pthread_cond_wait(&qread, &qlock);
}
// 取出队列头部的消息
mp = workq;
// 从队列中移除已取出的消息
workq = mp->m_next;
// 解锁以允许其他线程访问队列
pthread_mutex_unlock(&qlock);
// 此处应处理消息mp
// 注意:具体的消息处理逻辑根据实际需求实现,此处省略
}
}
/**
* 将消息加入队列的函数
* @param mp 指向要加入队列的消息的指针
*/
void enqueue_msg(struct msg* mp) {
// 加锁保护工作队列
pthread_mutex_lock(&qlock);
// 将新消息加入队列头部
mp->m_next = workq;
workq = mp;
// 解锁以允许其他线程操作队列
pthread_mutex_unlock(&qlock);
// 此时另一个线程可能在signal之前拿走了mp元素
// 通过signal操作唤醒等待在qread上的线程
pthread_cond_signal(&qread);
// 如果在signal之前mp元素被拿走,workq仍然是NULL,需要继续等待
}
阻塞队列代码分析
阻塞队列类中封装了生产者-消费者模型,其中push成员是生产者,pop成员是消费者。
阻塞队列中,使用了循环数组实现了队列,作为两者共享缓冲区,当然了,队列也可以使用STL中的queue。
自定义队列
当队列为空时,从队列中获取元素的线程将会被挂起;当队列是满时,往队列里添加元素的线程将会挂起。
阻塞队列类中,有些代码比较简单,这里仅对push和pop成员进行详解。
/*************************************************************
*循环数组实现的阻塞队列,m_back = (m_back + 1) % m_max_size;
*线程安全,每个操作前都要先加互斥锁,操作完后,再解锁
**************************************************************/
#ifndef BLOCK_QUEUE_H
#define BLOCK_QUEUE_H
#include "../lock/locker.h"
#include <stdlib.h>
#include <sys/time.h>
template<class T>
class block_queue {
public:
/**
* 构造函数,初始化队列
* @param max_size 队列的最大容量,默认为1000
*/
block_queue(int max_size = 1000) {
// 检查最大容量是否合法
if (max_size <= 0) {
exit(-1);
}
m_max_size = max_size;
// 分配存储空间
m_array = new T[max_size];
// 初始化队列大小为0
m_size = 0;
// 初始化队列前后指针
m_front = -1;
m_back = -1;
}
// 析构函数,负责释放队列所占用的资源
~block_queue() {
// 加锁以确保线程安全
m_mutex.lock();
// 检查数组是否被分配,如果被分配则释放内存
if (m_array != nullptr) {
delete[] m_array;
}
// 解锁以释放互斥锁
m_mutex.unlock();
}
// 清空队列,重置队列状态
void clear() {
// 加锁以确保线程安全
m_mutex.lock();
// 重置队列大小、前后指针,表示队列为空
m_size = 0;
m_front = -1;
m_back = -1;
// 解锁以释放互斥锁
m_mutex.unlock();
}
// 检查队列是否已满
bool full() {
// 加锁以确保线程安全
m_mutex.lock();
// 如果队列大小等于最大容量,则队列已满
if (m_size >= m_max_size) {
// 解锁并返回队列已满的状态
m_mutex.unlock();
return true;
}
// 解锁并返回队列未满的状态
m_mutex.unlock();
return false;
}
// 检查队列是否为空
bool empty() {
// 加锁以确保线程安全
m_mutex.lock();
// 如果队列大小为0,则队列为空
if(m_size == 0) {
// 解锁并返回队列为空的状态
m_mutex.unlock();
return true;
}
// 解锁并返回队列不为空的状态
m_mutex.unlock();
return false;
}
// 获取队列的前端元素
bool front(T &value) {
// 加锁以确保线程安全
m_mutex.lock();
// 如果队列为空,则不能获取元素
if (0 == m_size) {
m_mutex.unlock();
return false;
}
// 将前端元素的值复制到传入的引用
value = m_array[m_front];
// 解锁以释放互斥锁
m_mutex.unlock();
return true;
}
/**
* 获取队列的尾部元素值,并解锁
*
* 该函数尝试从队列中获取尾部元素的值。在获取元素值前后,会加锁和解锁以确保线程安全。
* 如果队列为空,则返回false,并且不会修改传入的引用参数。
*
* @param value 将被设置为队列尾部元素值的引用,如果队列不为空。
* @return 如果成功获取队列尾部元素值,则返回true;如果队列为空,则返回false。
*/
bool back(T& value) {
m_mutex.lock(); // 加锁以保护对共享资源的访问
if (0 == m_size) { // 检查队列是否为空
m_mutex.unlock(); // 如果队列为空,解锁并返回false
return false;
}
value = m_array[back]; // 将队列尾部元素值赋给value
m_mutex.unlock(); // 解锁以释放对共享资源的访问
return true; // 返回true表示成功获取队列尾部元素值
}
/**
* 获取队列当前的元素数量,并解锁
*
* 该函数通过加锁保护对共享资源的访问,以获取当前队列中的元素数量,
* 然后解锁并返回这个数量。这用于获取队列的大小,而不改变队列的状态。
*
* @return 队列当前的元素数量。
*/
int size() {
int tmp = 0; // 用于存储队列当前的元素数量
m_mutex.lock(); // 加锁以保护对共享资源的访问
tmp = m_size; // 将队列当前的元素数量赋给tmp
m_mutex.unlock(); // 解锁以释放对共享资源的访问
return tmp; // 返回队列当前的元素数量
}
/**
* 获取队列最大的元素数量,并解锁
*
* 该函数通过加锁保护对共享资源的访问,以获取队列被创建时所允许的最大元素数量,
* 然后解锁并返回这个最大数量。这用于确定队列的最大容量,而不改变队列的状态。
*
* @return 队列最大的元素数量。
*/
int max_size() {
int tmp = 0; // 用于存储队列最大的元素数量
m_mutex.lock(); // 加锁以保护对共享资源的访问
tmp = m_max_size; // 将队列最大的元素数量赋给tmp
m_mutex.unlock(); // 解锁以释放对共享资源的访问
return tmp; // 返回队列最大的元素数量
}
/**
* 向队列中推送一个新元素
*
* @param item 要推送的元素
* @return bool 推送成功返回true,失败返回false
*/
bool push(const T &item) {
// 加锁以确保线程安全
m_mutex.lock();
// 如果队列已满,则解锁并返回false
if (m_size >= m_max_size) {
// 唤醒所有等待的线程,以便它们可以检查队列状态
m_cond.broadcast();
m_mutex.unlock();
return false;
}
// 计算新元素应插入的位置,并存储元素
m_back = (m_back + 1) % m_max_size;
m_array[m_back] = item;
m_size ++;
// 通知所有等待的线程队列状态已改变
m_cond.broadcast();
// 解锁以释放资源
m_mutex.unlock();
return true;
}
/**
* 从队列中弹出一个元素
*
* @param item 弹出的元素将存储在此处
* @param ms_timeout 等待队列非空的超时时间(以毫秒为单位)
* @return bool 弹出成功返回true,失败返回false
*/
bool pop(T &item, int ms_timeout) {
// 初始化绝对超时时间
struct timespec t = {0, 0};
// 获取当前时间
struct timeval now = {0, 0};
gettimeofday(&now, nullptr);
// 加锁以确保线程安全
m_mutex.lock();
// 如果队列为空且在超时时间内未变为非空,则解锁并返回false
if (m_size <= 0) {
// 设置超时的绝对时间
t.tv_sec = now.tv_sec + ms_timeout / 1000;
t.tv_nsec = (ms_timeout % 1000) * 1000;
// 在超时时间内等待队列变为非空
if (!m_cond.timewait(m_mutex.get(), t)) {
m_mutex.unlock();
return false;
}
}
// 如果队列仍然为空,则解锁并返回false
if(m_size <= 0) {
m_mutex.unlock();
return false;
}
// 计算下一个要弹出元素的位置,并获取该元素
m_front = (m_front + 1) % m_max_size;
item = m_array[m_front];
m_size --;
// 解锁以释放资源
m_mutex.unlock();
return true;
}
private:
// 互斥锁,用于线程安全
locker m_mutex;
// 条件变量,用于线程间同步
cond m_cond;
// 队列存储数组
T* m_array;
// 当前队列大小
int m_size;
// 队列最大容量
int m_max_size;
// 队列首元素索引
int m_front;
// 队列末元素索引
int m_back;
};
#endif
日志系统分为两部分,其一是单例模式与阻塞队列的定义,其二是日志类的定义与使用。
本篇将介绍日志类的定义与使用,具体的涉及到基础API,流程图与日志类定义,功能实现。
基础API,描述fputs,可变参数宏__VA_ARGS__,fflush
流程图与日志类定义,描述日志系统整体运行流程,介绍日志类的具体定义
功能实现,结合代码分析同步、异步写文件逻辑,分析超行、按天分文件和日志分级的具体实现
基础API
为了更好的源码阅读体验,这里对一些API用法进行介绍。
fputs
#include <stdio.h>
int fputs(const char* str, FILE* stream);
- str,一个数组,包含了要写入的以空字符终止的字符序列。
- stream,指向FILE对象的指针,该FILE对象标识了要被写入字符串的流。
可变参数宏__VA_ARGS__
__VA_ARGS__是一个可变参数的宏,定义时宏定义中参数列表的最后一个参数为省略号,在实际使用时会发现有时会加##,有时又不加。
//最简单的定义
#define my_print1(...) printf(__VA_ARGS__)
//搭配va_list的format使用
#define my_print2(format, ...) printf(format, __VA_ARGS__)
#define my_print3(format, ...) printf(format, ##__VA_ARGS__)
__VA_ARGS__宏前面加上##的作用在于,当可变参数的个数为0时,这里printf参数列表中的的##会把前面多余的","去掉,否则会编译出错,建议使用后面这种,使得程序更加健壮。
fflush
#include <stdio.h>
int fflush(FILE *stream);
fflush()会强迫将缓冲区内的数据写回参数stream 指定的文件中,如果参数stream 为NULL,fflush()会将所有打开的文件数据更新。
在使用多个输出函数连续进行多次输出到控制台时,有可能下一个数据再上一个数据还没输出完毕,还在输出缓冲区中时,下一个printf就把另一个数据加入输出缓冲区,结果冲掉了原来的数据,出现输出错误。
在prinf()后加上fflush(stdout); 强制马上输出到控制台,可以避免出现上述错误。
流程图与日志类定义
流程图
- 日志文件
-
- 局部变量的懒汉模式获取实例
- 生成日志文件,并判断同步和异步写入方式
- 同步
-
- 判断是否分文件
- 直接格式化输出内容,将信息写入日志文件
- 异步
-
- 判断是否分文件
- 格式化输出内容,将内容写入阻塞队列,创建一个写线程,从阻塞队列取出内容写入日志文件
日志类定义
通过局部变量的懒汉单例模式创建日志实例,对其进行初始化生成日志文件后,格式化输出内容,并根据不同的写入方式,完成对应逻辑,写入日志文件。
日志类包括但不限于如下方法,
- 公有的实例获取方法
- 初始化日志文件方法
- 异步日志写入方法,内部调用私有异步方法
- 内容格式化方法
- 刷新缓冲区
- …
#ifndef LOG_H
#define LOG_H
#include <iostream>
#include <string>
#include "block_queue.h"
using namespace std;
/**
* 日志类,用于记录程序运行时的日志信息
*/
class Log{
public:
/**
* 获取日志类的单例实例
* @return 返回日志类的单例指针
*/
static Log* get_instance() {
static Log instance;
return &instance;
}
/**
* 日志刷新线程入口函数
* @param args 线程参数,未使用
* @return 返回nullptr
*/
static void* flush_log_thread(void* args) {
Log::get_instance()->async_write_log();
return nullptr;
}
/**
* 初始化日志类
* @param file_name 日志文件名
* @param close_log 是否关闭日志
* @param log_buf_size 日志缓冲区大小,默认为8192
* @param split_lines 日志文件分割行数,默认为5000000
* @param max_queue_size 日志队列最大大小,默认为0表示不限制
* @return 返回初始化是否成功
*/
bool init(const char* file_name, int close_log, int log_buf_size = 8192, int split_lines = 5000000, int max_queue_size = 0);
/**
* 写入日志
* @param level 日志级别
* @param format 日志信息的格式字符串
* @param ... 格式字符串中的参数
*/
void write_log(int level, const char* format, ...);
/**
* 刷新日志,将缓冲区内容写入文件
*/
void flush(void);
private:
Log();
virtual ~Log();
/**
* 异步写入日志线程函数
* @return 返回nullptr
*/
void* async_write_log() {
string single_log;
//从阻塞队列中取出一个日志string,写入文件
while(m_log_queue->pop(single_log)) {
m_mutex.lock();
fputs(single_log.c_str(), m_fp);
m_mutex.unlock();
}
}
private:
char dir_name[128]; //日志目录名
char log_name[128]; //日志文件名
int m_split_lines; //日志文件分割行数
int m_log_buf_size; //日志缓冲区大小
long long m_count; //日志计数
int m_today; //当日志
FILE* m_fp; //文件指针
char* m_buf; //缓冲区
block_queue<string> *m_log_queue; //阻塞队列,用于异步写日志
bool m_is_async; //是否异步写日志
locker m_mutex; //线程锁
int m_close_log; //是否关闭日志
};
// 定义DEBUG级别日志宏,当m_close_log为0时,记录DEBUG级别日志
#define LOG_DEBUG(format, ...) if(0 == m_close_log) {Log::get_instance()->write_log(0, format, ##__VA_ARGS__); Log::get_instance()->flush();}
// 定义INFO级别日志宏,当m_close_log为0时,记录INFO级别日志
#define LOG_INFO(format, ...) if(0 == m_close_log) {Log::get_instance()->write_log(1, format, ##__VA_ARGS__); Log::get_instance()->flush();}
// 定义WARN级别日志宏,当m_close_log为0时,记录WARN级别日志
#define LOG_WARN(format, ...) if(0 == m_close_log) {Log::get_instance()->write_log(2, format, ##__VA_ARGS__); Log::get_instance()->flush();}
// 定义ERROR级别日志宏,当m_close_log为0时,记录ERROR级别日志
#define LOG_ERROR(format, ...) if(0 == m_close_log) {Log::get_instance()->write_log(3, format, ##__VA_ARGS__); Log::get_instance()->flush();}
#endif
日志类中的方法都不会被其他程序直接调用,末尾的四个可变参数宏提供了其他程序的调用方法。
前述方法对日志等级进行分类,包括DEBUG,INFO,WARN和ERROR四种级别的日志。
功能实现
init函数实现日志创建、写入方式的判断。
write_log函数完成写入日志文件中的具体内容,主要实现日志分级、分文件、格式化输出内容。
生成日志文件 && 判断写入方式
通过单例模式获取唯一的日志类,调用init方法,初始化生成日志文件,服务器启动按当前时刻创建日志,前缀为时间,后缀为自定义log文件名,并记录创建日志的时间day和行数count。
写入方式通过初始化时是否设置队列大小(表示在队列中可以放几条数据)来判断,若队列大小为0,则为同步,否则为异步。
//异步需要设置阻塞队列长度,同步不需要设置
bool Log::init(const char* file_name, int close_log, int log_buf_size, int split_lines, int max_queue_size) {
// 如果设置了 max_queue_size,则将日志设置为异步模式
if (max_queue_size >= 1) {
m_is_async = true; // 设置异步标志为 true
m_log_queue = new block_queue<string>(max_queue_size); // 创建日志队列,大小为 max_queue_size
pthread_t tid;
// 创建一个异步线程,用于异步写日志,flush_log_thread 是回调函数
pthread_create(&tid, nullptr, flush_log_thread, nullptr);
}
m_close_log = close_log; // 设置日志关闭标志
m_log_buf_size = log_buf_size; // 设置日志缓冲区大小
m_buf = new char[m_log_buf_size]; // 分配日志缓冲区
memset(m_buf, '\0', m_log_buf_size); // 将缓冲区初始化为全零
m_split_lines = split_lines; // 设置日志分割行数
time_t t = time(nullptr); // 获取当前时间
struct tm* sys_tm = localtime(&t); // 将时间转换为本地时间
struct tm my_tm = *sys_tm; // 复制本地时间结构体
const char* p = strrchr(file_name, '/'); // 查找文件名中最后一个 '/' 的位置
char log_full_name[256] = {0}; // 日志文件的完整路径和文件名
if (p == nullptr) {
// 如果文件名中没有 '/',直接使用文件名创建日志文件名
snprintf(log_full_name, 255, "%d_%02d_%02d_%s", my_tm.tm_year + 1900, my_tm.tm_mon + 1, my_tm.tm_mday, file_name);
} else {
// 如果文件名中包含 '/',将目录和文件名分开
strcpy(log_name, p + 1); // 提取文件名
strncpy(dir_name, file_name, p - file_name + 1); // 提取目录名
// 创建包含目录和日期的完整日志文件名
snprintf(log_full_name, 255, "%s%d_%02d_%02d_%s", dir_name, my_tm.tm_year + 1900, my_tm.tm_mon + 1, my_tm.tm_mday, log_name);
}
m_today = my_tm.tm_mday; // 记录日志创建的当天日期
m_fp = fopen(log_full_name, "a"); // 以追加模式打开日志文件
if (m_fp == nullptr) {
return false; // 如果文件打开失败,返回 false
}
return true; // 文件打开成功,返回 true
}
日志分级与分文件
日志分级的实现大同小异,一般的会提供五种级别,具体的,
- Debug,调试代码时的输出,在系统实际运行时,一般不使用。
- Warn,这种警告与调试时终端的warning类似,同样是调试代码时使用。
- Info,报告系统当前的状态,当前执行的流程或接收的信息等。
- Error和Fatal,输出系统的错误信息。
上述的使用方法仅仅是个人理解,在开发中具体如何选择等级因人而异。项目中给出了除Fatal外的四种分级,实际使用了Debug,Info和Error三种。
超行、按天分文件逻辑,具体的,
- 日志写入前会判断当前day是否为创建日志的时间,行数是否超过最大行限制
-
- 若为创建日志时间,写入日志,否则按当前时间创建新log,更新创建时间和行数
- 若行数超过最大行限制,在当前日志的末尾加count/max_lines为后缀创建新log
将系统信息格式化后输出,具体为:格式化时间 + 格式化内容
void Log::write_log(int level, const char* format, ...) {
struct timeval now = {0, 0}; // 定义一个时间结构体,用于存储当前时间
gettimeofday(&now, nullptr); // 获取当前时间,精确到微秒
time_t t = now.tv_sec; // 提取当前时间的秒部分
struct tm *sys_tm = localtime(&t); // 将秒部分转换为本地时间的tm结构
struct tm my_tm = *sys_tm; // 将sys_tm赋值给my_tm
char s[16] = {0}; // 定义一个字符数组s,用于存储日志级别字符串
switch (level) {
case 0:
strcpy(s, "[debug]"); // 如果level为0,表示debug级别日志
break;
case 1:
strcpy(s, "[info]:"); // 如果level为1,表示info级别日志
break;
case 2:
strcpy(s, "[warn]:"); // 如果level为2,表示warn级别日志
break;
case 3:
strcpy(s, "[erro]:"); // 如果level为3,表示error级别日志
break;
default:
strcpy(s, "[info]:"); // 其他情况下,默认使用info级别日志
break;
}
m_mutex.lock(); // 加锁,确保多线程环境下对共享资源的安全访问
m_count++; // 日志计数器加1
// 检查是否需要新建日志文件
if(m_today != my_tm.tm_mday || m_count & m_split_lines == 0) {
char new_log[256] = {0}; // 定义一个字符数组,用于存储新日志文件名
fflush(m_fp); // 刷新文件缓冲区,将数据写入文件
fclose(m_fp); // 关闭当前日志文件
char tail[16] = {0}; // 定义一个字符数组,用于存储日期信息
// 格式化日期信息
snprintf(tail, 16, "%d_%02d_%02d_", my_tm.tm_year + 1900, my_tm.tm_mon + 1, my_tm.tm_mday);
if (m_today != my_tm.tm_mday) {
// 如果是新的一天,则新建一个新的日志文件
snprintf(new_log, 255, "%s%s%s", dir_name, tail, log_name);
m_today = my_tm.tm_mday; // 更新当前日期
m_count = 0; // 重置日志计数器
} else {
// 如果是同一天,但日志数量达到了分割行数,则创建一个新文件
snprintf(new_log, 255, "%s%s%s.%lld", dir_name, tail, log_name, m_count / m_split_lines);
}
m_fp = fopen(new_log, "a"); // 打开新日志文件,追加模式
}
m_mutex.unlock(); // 解锁
va_list valst; // 定义一个可变参数列表
va_start(valst, format); // 初始化可变参数列表
string log_str; // 定义一个字符串用于存储最终的日志信息
m_mutex.lock(); // 再次加锁,确保安全写入日志内容
// 写入具体的时间和日志级别内容
int n = snprintf(m_buf, 48, "%d-%02d-%02d %02d:%02d:%02d.%06ld %s ",
my_tm.tm_year + 1900, my_tm.tm_mon + 1, my_tm.tm_mday,
my_tm.tm_hour, my_tm.tm_min, my_tm.tm_sec, now.tv_usec, s);
// 将可变参数列表格式化并写入缓冲区
int m = vsnprintf(m_buf + n, m_log_buf_size - n - 1, format, valst);
m_buf[n + m] = '\n'; // 添加换行符
m_buf[n + m + 1] = '\0'; // 以空字符结束字符串
log_str = m_buf; // 将缓冲区内容转换为字符串
m_mutex.unlock(); // 解锁
if (m_is_async && !m_log_queue->full()) {
// 如果是异步模式且日志队列未满,则将日志推入队列
m_log_queue->push(log_str);
} else {
// 如果是同步模式或日志队列已满,则直接写入日志文件
m_mutex.lock();
fputs(log_str.c_str(), m_fp); // 将日志字符串写入文件
m_mutex.unlock();
}
va_end(valst); // 结束可变参数列表
}
void Log::flush(void) {
m_mutex.lock(); // 加锁
fflush(m_fp); // 刷新文件缓冲区,将数据写入文件
m_mutex.unlock(); // 解锁
}