WebServer 日志系统

文章讨论了同步日志和异步日志的区别,以及如何在服务端程序中通过多线程和缓冲区优化日志写入性能。介绍了日志库的前后端结构,使用宏定义简化日志记录,并强调了文件滚动策略以防止单个日志文件过大。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一、同步异步日志

同步日志:

        同步日志是指产生日志的同时就将其写入至文件中,这种方式优点是设计简单、能够及时的将日志输出到文件中,缺点是会频繁的触发 IO 操作,且对写日志的线程阻塞。在客户端程序中,这种方式很常见,写入几条日志的时间相比于用户的反应时间来说可以忽略不计,用户很难察觉这点性能损失。即使是在 UI 线程中,只要日志量不大的情况下也不会造成什么影响。

异步日志:

        对于服务端程序而言,使用同步写日志的方式会影响性能,服务器线程阻塞时间越长,其为客户端提供服务的时间就越短,同时为多个客户端提供服务的能力也越差。现代计算机的 CPU 一般都是具有多个核心的,因此服务端程序常采用多线程程序来尽量发挥每一个核心的能力,异步日志为了减少写日志对线程的阻塞,采用一个单独的线程来向磁盘写入日志,其它线程只是产生日志并将其交给写磁盘线程。

        服务器的⽇志系统是⼀个多⽣产者,单消费者的任务场景:多⽣产者负责把⽇志写⼊缓冲区,单消费者负责把缓冲 区中数据写⼊⽂件。如果只⽤⼀个缓冲区,不光要同步各个⽣产者,还要同步⽣产者和消费者。⽽且最重要的是需要 保证⽣产者与消费者的并发,也就是前端不断写⽇志到缓冲区的同时,后端可以把缓冲区写⼊⽂件。

二、结构

日志库分为前端和后端两个部分,前端用于生成日志消息并传送到后端,后端则负责将日志消息写入本地日志文件。日志系统的前后端之间只有一个简单的回调函数作为接口:

日志模块前端部分的调用时序为:Logger => Impl => LogStream => operator<<FixBuffer => g_output => AsyncLogging:: append。

三 、日志前端

        通过宏定义 #define LOG Logger(__FILE__, __LINE__).stream() 使得我们在使⽤⽇志写⼊语句 LOG << info; 之类的内容时,先构建⼀个临时的 Logger 对象,在构造时⽣成详细的⽇志信息,由于临时对象会在语句结束后析构,我们可以在析构的时候再将⽇志真正地 写⼊⽂件,来保证实现的简洁性。

#define LOG_TRACE if (Logger::logLevel() <= Logger::TRACE) \
	Logger(__FILE__, __LINE__, Logger::TRACE, __func__).stream()
#define LOG_DEBUG if (Logger::logLevel() <= Logger::DEBUG) \
	Logger(__FILE__, __LINE__, Logger::DEBUG, __func__).stream()
#define LOG_INFO if (Logger::logLevel() <= Logger::INFO) \
	Logger(__FILE__, __LINE__).stream()
#define LOG_WARN Logger(__FILE__, __LINE__, Logger::WARN).stream()
#define LOG_ERROR Logger(__FILE__, __LINE__, Logger::ERROR).stream()
#define LOG_FATAL Logger(__FILE__, __LINE__, Logger::FATAL).stream()
#define LOG_SYSERR Logger(__FILE__, __LINE__, false).stream()
#define LOG_SYSFATAL Logger(__FILE__, __LINE__, true).stream()
#define LOG_DEBUG_BIN(x,l) if (Logger::logLevel() <= Logger::DEBUG) \
	Logger(__FILE__, __LINE__, Logger::DEBUG, __func__).WriteLog((x), (l))

        使用LOG宏时会创建一个匿名Logger对象(其中包含一个Impl类型的成员变量),并调用stream()函数得到一个LogStream对象的引用,而LogStream重载了<<操作符,可以将日志信息存入LogStream的buffer中。这样LOG语句执行结束时,匿名Logger对象被销毁,在Logger的析构函数中,会在日志消息的末尾添加LOG语句的位置信息(文件名和行号),最后调用g_output()函数将日志信息传输到后端,由后端日志线程将日志消息写入日志文件。

LOG << XXXX    //  调用格式
#define LOG Logger(__FILE__, __LINE__).stream()
 
LogStream &stream() { return impl_.stream_; }  //  stream() 会返回一个logstream 对象, logstream 重载了各种 << 写到 缓冲区 FixedBuffer 中, 在LOG 调用析构函数进行销毁时,调用output() 缓冲区内容写到标准输出,或则后端中,再由后端写到硬盘,从这里开始就进入 AsyncLogger 类中

void output(const char* msg, int len)
{
    pthread_once(&once_control_, once_init);
    AsyncLogger_->append(msg, len);  ///  在析构函数中调用AsyncLogger 写回文件
}

        然后是 AsynLogging 类,这个类的作⽤则是将从前端获得的 Buffer A 放⼊ 后端的 Buffer B中,并且将 Buffer B 的内容最终写⼊到磁盘中(也就是整个后端所作的内容)

四、日志后端

        日志后端有自己的缓冲区,使用的是双缓冲区,实际实现上使用了 4 个缓冲区。缓冲区使用FixedBuffer<kLargeBuffer> 对象(大小为4000*1000字节),缓冲区使用std::unique_ptr管理,通过移动语义来实现缓冲区之间的交换,避免了缓冲区内容的复制成本。

class AsyncLogging : noncopyable{
public:
    AsyncLogging(std::string base, size_t rollSize, int flushInterval=3);
    ~AsyncLogging() {
        if (running_) {
            stop();
        }
    }

    void append(const char* logLine, int len);  // 日志前端(生产者)调用该函数写入
    void start() {
        running_ = true;
        thread_ = std::make_unique<std::thread>(&AsyncLogging::theadFunc, this);
    }
    void stop() {
        if (running_) {
            running_ = false;
            cond_.notify_one();
            thread_->join();
        }
    }

private:
    void theadFunc();    // 消费缓冲区中的日志

    typedef detail::FixedBuffer<detail::kLargeBuffer> Buffer;
    typedef std::vector<std::unique_ptr<Buffer>> BufferVector;
    typedef BufferVector::value_type BufferPtr;

    const int flushInterval_;
    bool running_;
    std::string basename_;
    size_t rollSize_;

    std::unique_ptr<std::thread> thread_;
    std::mutex mutex_;
    std::condition_variable cond_;

    BufferPtr currentBuffer_;   // 前端使用的第一个缓冲区
    BufferPtr nextBuffer_;      // 如果日志写入速度很快,短时间写满第一个缓冲区,则使用第二个缓冲区
    BufferVector buffers_;      // 存储已经写满的缓冲区
};

        currentBuffer 和 nextBuffer 主要用于防止前端写得过快,一下子写满缓冲区,所以设置两个,写满一个,继续写第二个。

首先看前端(生产者)调用的写日志函数:

       可能同时多个线程向日志后端提交日志,因此 append 函数加锁,其逻辑如下:首先写 currentBuffer_ 缓冲区,写满之后使用 nextBuffer_ 缓冲区,两个都写满时再重新分配新的缓冲区。所有写满的缓冲区都移入buffers_ 中保存,等待消费者写入磁盘。当写满一个缓冲区后就立即唤醒条件变量,从而让消费者线程将缓冲区写入磁盘。

void AsyncLogging::append(const char *logLine, int len) {
    std::lock_guard<std::mutex> lockGuard(mutex_);
    // 当前缓冲区空间足够
    if (currentBuffer_->avail() > len) {
        currentBuffer_->append(logLine, len);
    } else {
        buffers_.emplace_back(currentBuffer_.release());
        if (nextBuffer_) {
            // 使用另一个缓冲区
            currentBuffer_ = std::move(nextBuffer_);   // now nextBuffer_ == nullptr
        } else {
            // 两个缓冲区都满了,重新分配一个缓冲区,很少发生
            currentBuffer_ = std::make_unique<Buffer>();
        }
        currentBuffer_->append(logLine, len);
        cond_.notify_one();
    }
}

        其中 LogFile 是对写入磁盘文件的一个封装,它能在文件大小超过 rollSize_ 后自动写一个新的文件,这样防止单个日志文件过大。调用其 append 函数 flush 函数能够写入磁盘和刷新磁盘缓冲区,其详细设计可以参考源码。

线程函数每次在条件变量上休眠,唤醒条件有两个:(1) 生产者唤醒;(2) 超时。超时时间为 3 s,因此最长每 3 s就会将缓冲区的日志写入磁盘一次。这里注意临界区里将当前在写的缓冲区加入了 buffers_swapbuffersToWritebuffers_,而不是将整个写入磁盘的操作也放进临界区里,提高效率。

之后调用output.append()来将日志写入磁盘,并调用output.flush()来刷新 IO 的内核缓冲区,确保日志写入磁盘中。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值