在上一节中我们实现了同步日志,并输出到stdout。这节我们来实现异步。
在上一节中,添加了AppendFile类,这是对文件操作的一个底层的类,我们需要一个更加上层的,使用更便捷的接口来操控磁盘文件。比如说每输出了1000条日志消息,就需要冲刷AppendFile类中的缓冲区buffer_;还有日志文件大小若达到了预定的大小,就要新开一个日志文件等等。所以我们新创一个类,更加方便我们使用。
1.LogFile类
class LogFile
{
public:
LogFile(const std::string& file_name, int flushEveryN);
//添加日志消息到file_的缓冲区
void Append(const char* str, int len);
//冲刷file_的缓冲区
void Flush(){ file_.Flush(); }
private:
//日志文件名
const std::string filename_;
const int flushEveryN_; //每写flushEveryN_条日志消息就强制冲刷file_的缓冲区内容到磁盘中
int count_; //计算写了多少条日志消息(即是调用Append()的次数)
std::unique_ptr<AppendFile> file_;
};
LogFile类中有个AppendFile的智能指针成员变量file_,LogFile类通过操控file_就可以更加方便实现我们上述的需求。
该构造函数中创建一个AppendFile类对象的智能指针赋给成员变量file_。
在LogFile::Append()中调用AppendFile::Append()函数,并记录Append()的使用次数,(即也是记录添加了多少条日志消息)。达到次数后,就进行fflush()冲刷,即把日志消息写到磁盘文件中。
LogFile::LogFile(const std::string& file_name, int flushEveryN)
:filename_(filename_)
,flushEveryN_(flushEveryN)
,count_(0)
{
//file_.reset(new AppendFile(filename_));
file_ = std::make_unique<AppendFile>(filename_);
}
//添加日志消息到file_的缓冲区
void LogFile::Append(const char* logline, int len)
{
file_->Append(logline, int len);
if (++count_ >= flushEveryN_) {
count_ = 0;
file_->Flush();
}
}
这里可能又有疑惑了:为什么不把这些操作直接放到AppendFile类中呢,为什么就非要创建多一个类呢。
这是为了方便我们的使用,AppendFile类封装了对磁盘文件的操作,LogFile类就只需要操控AppendFile的智能指针file_就行,这样就能很大便利我们去编写代码。
封装起来方便我们编写代码,这也是为了让类的责任分工明确。
或者你也可以去试试把这两个类写成一个类看看是什么效果。
接着来看看重头戏,实现异步的类。
2.AsyncLogger类
//异步日志,单独开一个线程来负责输出日志
class AsyncLogger
{
public:
// 参数1为日志文件名,参数2默认每隔3秒冲刷缓冲区到磁盘文件中
AsyncLogger(const std::string fileName, int flushInterval = 3);
~AsyncLogger();
void Append(const char* logline, int len);
//开始异步日志线程
void start()
{
is_running_ = true;
thread_ = std::thread([this]() {ThreadFunc(); });
}
void stop();
public:
void ThreadFunc(); //后端日志线程函数
//固定大小的缓冲区
using Buffer = FixedBuffer<KLargeBuffer>;
using BufferPtr = std::unique_ptr<Buffer>;
using BufferVector = std::vector<BufferPtr>;
const int flushInterval_; //需要冲刷的时间间隔
bool is_running_;
std::string fileName_; //日志文件名
std::thread thread_; //log后端线程
std::mutex mutex_; //用于保护下面的四个成员变量
std::condition_variable cond_;
BufferPtr currentBuffer_; //当前的缓冲区指针
BufferPtr nextBuffer_; //预备可用的缓冲区指针
BufferVector buffers_; //待写入文件的已填完的缓冲vector
};
这里的异步主要是用空间换时间。该类里面的成员变量很多。
muduo中的日志使用的是双缓冲技术,这里讲解的也主要是参考陈硕书籍《Linux多线程服务端编程》。基本思路是先准备好两块buffer:currentBuffer_和nextBuffer_。前端负责往currentBuffer_中填写日志消息,后端负责将nextBuffer_的数据写入文件中(磁盘)。
当currentBuffer_写满后,交换currentBuffer_和nextBuffer_,(交换之后,那么前端就是往nextBuffer_中填写日志消息了),让后端将currentBuffer_的数据写入文件,而前端就往nextBuffer_中填入新的日志消息,如此往复。
先来看看发送方的代码,即是前端代码。
void AsyncLogger::Append(const char* logline, int len)
{
std::unique_lock<std::mutex> lock(mutex_);
if (currentBuffer_->Available() > len) {
//当前缓冲区有足够的空间,就直接往缓冲区中添加
currentBuffer_->Append(logline, len);
}
else {
// 把已满的缓冲区放入预备给后端log线程的容器中
buffers_.emplace_back(std::move(currentBuffer_));
if (nextBuffer_) { // 若下一个可用缓冲区不为空,则通过move给cur
currentBuffer_ = std::move(nextBuffer_);
}
else { //否则就新建一个
currentBuffer_.reset(new Buffer);
}
currentBuffer_->Append(logline, len);
cond_.notify_one(); // 这时提醒后端log线程可以读取缓冲区了
}
}
该函数中主要有3种情况,逻辑也清晰,要说的也在注释中了哈。
而cond_.notify_one()就会唤醒后端log线程,把日志消息真正写入到磁盘文件中了。
来看看后端log线程函数。
//后端日志线程函数 代码有省略,只写了比较关键的部分
void AsyncLogger::ThreadFunc()
{
//========================步骤1 ============================
//打开磁盘日志文件,即是创建LogFile对象
LogFile output(fileName_);
//准备好后端备用的缓冲区1、2
auto newBuffer1 = std::make_unique<Buffer>();
auto newBuffer2 = std::make_unique<Buffer>();
//备好读取的缓冲区vector
BufferVector buffersToWrite;
buffersToWrite.reserve(16); //预留16个元素空间
while (is_running_) {
{
//============================= 步骤2 =========================
std::unique_lock<std::mutex> lock(mutex_);
if (buffers_.empty()) { //注意这里是用 if ,因为够这么多秒时间后也会唤醒的
cond_.wait_for(lock, std::chrono::seconds(flushInterval_));
}
buffers_.emplace_back(std::move(currentBuffer_)); //把currentBuffer_放入到容器中
currentBuffer_ = std::move(newBuffer1); //将后端log线程备好的缓冲区1交给cur
buffersToWrite.swap(buffers_); //把buffers跟后端log线程空的容器交换
// 如果下一个可用缓冲区nextBuffer_为空,就把后端log线程的缓冲区2交给next
if (!nextBuffer_) {
nextBuffer_ = std::move(newBuffer2);
}
} //这里出了锁的作用域
//=======================步骤3 =======================
// 这里代码省略,若缓冲区过多,说明前端产生log的速度远大于后端消费的速度,这里只是简单的将它们丢弃
// 将缓冲区内容写入文件
for (size_t i = 0; i < buffersToWrite.size(); ++i){
output.Append(buffersToWrite[i]->GetData(), buffersToWrite[i]->GetLength());
}
//======================== 步骤4 =====================
// 将过多的缓冲区丢弃
if (buffersToWrite.size() > 2){
buffersToWrite.resize(2);
}
// 恢复后端备用缓冲区
if (!newBuffer1){
newBuffer1 = buffersToWrite.back();
buffersToWrite.pop_back();
newBuffer1->Reset(); //将缓冲区的数据指针归零
}
//newBuffer1的操作和1的操作也是一样的,所以在这里就省略,不写了
// 丢弃无用的缓冲区
buffersToWrite.clear();
output.Flush();
}
output.Flush();
}
步骤1:
先打开保存日志的磁盘文件,准备好后端备用的空闲缓冲区1、2,用来交换已写好日志消息的缓冲区。也备好备好读取的缓冲区vector,用来交换已写入文件的填完完毕的缓冲vector。
这里都是为了在临界区内交换而不用阻塞的。
步骤2:到了while(1)循环里面了。也到了需要加锁的临界区。
首先等待条件是否触发,等待触发的条件有两个:其一是超时(超过默认的3s),其二是前端写满了一个或多个buffer。注意这里使用的是if(),没有使用while()循环的。
当条件满足时,将当前缓冲(currentBuffer_)移入buffers_,并立刻将空闲的newBuffer1移为当前缓冲。要注意的是,这整段代码是位于临界区内的,所以不会有任何race condition。
接下来将已填写完毕的缓冲vector(buffers_)与备好的缓冲区buffersToWrite交换(和第12节中的函数doPendingFunctors()中也是要进行交换后,才好执行任务回调函数的),后面的代码就可以在临界区外安全地访问buffersToWrite,将其中的日志数据写入到磁盘文件中。
接着用newBuffer2替换nextBuffer_,这样前端就始终有一个预备buffer 可以调配。
nextBuffer_可以减少前端临界区分配内存的概率,缩短前端临界区长度。(因为前端Append()函数中有一种情况会currentBuffer_.reset(new Buffer);)
步骤3:这时已经出了临界区
若缓冲区过多,说明前端产生log的速度远大于后端消费的速度,这里只是简单的将它们丢弃(调用erase()函数)。之后把日志消息写入到磁盘(调用LogFile::Append函数),
步骤4:
调用vector的resize()函数重置该缓冲区vector大小,并将该缓冲区vector内的buffer重新填充给newBuffer1和newBuffer2,这样下一次执行的时候就还有两个空闲buffer可用于替换前端的当前缓冲和预备缓冲。
3.如何使用AsyncLogger类
在AsyncLogger类中我们使用的是LogFile类,不使用AppendFile类。AppendFile类是供LogFile对象使用的,我们基本不会直接去操作AppendFile类。
记住是通过LogFile类去调用AppendFile类。
那关键的异步日志的AsyncLogger类已实现。那该怎么使用该类呢。
对外界可见,可以直接使用的是Logger类,那怎么把这两个类关联起来呢。
AsyncLogger类中是使用AsyncLogger::start()开始后端线程,前端收集日志消息是AsyncLogger::Append()函数。我们就要从这两个函数下手。
在Logger类中添加
public:
static std::string LogFileName() { return logFileName_; }
using OutputFunc=void (*)(const char* msg, int len); //输出回调函数
using FlushFunc=void (*)(); //冲刷回调函数
private:
//日志文件名字
static std::string logFileName_;
前一节我们输出到stdout是通过Logger类的析构函数来输出的。
//这是上一节的实现
Logger::~Logger()
{
//其他的省略
DefaultOutput(buf.Data(), buf.Length()); //是调用了fwrite()输出到stdout的
}
那我们可以修改析构函数中的DefaultOutput()函数就行,我们可以调用AsyncLogger::Append()函数来让消息日志写到磁盘中。AsyncLogger::Append()就是收集消息日志,写到缓冲中,等到缓存满了,条件符合了,就会notify_one(),唤醒后端log线程,把日志消息真正写到磁盘中去。
再来看看AsyncLogger类对象在哪生成呢。
static std::unique_ptr<AsyncLogger> asyncLogger;
static std::once_flag g_once_flag;
std::string Logger::logFileName_ = "../li22.log";
void OnceInit()
{
asyncLogger = std::make_unique<AsyncLogger>(Logger::LogFileName());
asyncLogger->start(); //开启后端线程
}
void AsyncOutput(const char* logline, int len)
{
std::call_once(g_once_flag, OnceInit); //保证只调用OnceInit函数一次
asyncLogger->Append(logline, len); //把日志消息写到缓冲区中
}
// 全局变量:输出函数
Logger::OutputFunc g_output = AsyncOutput;
Logger::~Logger()
{
//省略其他的..............
g_output(buf.Data(), buf.Length());
}
logFileName_ 就是我们的日志文件名。
那我们从该析构函数Logger::~Logger()开始,g_output()是全局输出函数,这里设置成了AsyncOutput()函数,AsyncOutput()函数使用了std::call_once,这是c++11新增的,可以保证不管是开多少个线程,该函数(OnceInit())只会执行一次。这就可以保证了后端log线程只有一个,这就不会有抢占写日志到磁盘的情况发生。asyncLogger的后端log线程就开始了,那就接着调用AsyncLogger::Append()函数就可以了。下面是其主要的流程图。
异步日志的主体就完成了,还有一些功能,小细节没有实现,下一节再讲解了。
这个双缓冲的设计真不错。
这一节我们就可以把日志输出到磁盘文件了。
完整源代码:https://github.com/liwook/CPPServer/tree/main/code/18_log2