第5章 高效的多线程日志

"日志(logging)"有两个意思:

诊断日志(diagnostic log)即log4j、logback、slf4j、glog、g2log、log4cxx、log4cpp、log4cplus、Pantheios、ezlogger等常用日志库提供的日志功能。

交易日志 (transaction log)即数据库的write-ahead log [注1:http://en.wikipedia.org/wiki/Write-ahead_logging 不同的数据库有不同的称呼,如binary log、redo log等。]、文件系统的jour-naling [注2:http://en.wikipedia.org/wiki/Journaling_file_system]等,用于记录状态变更,通过回放日志可以逐步恢复每一次修改之后的状态。

本章的"日志"是前一个意思,即文本的、供人阅读的日志,通常用于故障诊断和追踪(trace)[注3:http://en.wikipedia.org/wiki/Traceability],也可用于性能分析。日志通常是分布式系统中事故调查时的唯一线索,用来追寻蛛丝马迹,查出元凶。在服务端编程中,日志是必不可少的,在生产环境中应该做到"Log Everything All The Time"[注4:http://highscalability.com/log-everything-all-time]。对于关键进程,日志通常要记录

1.收到的每条内部消息的id(还可以包括关键字段、长度、hash等);

2.收到的每条外部消息的全文[注5:第2、3两条或许不适用于分布式存储系统的bulk data,但适用于meta data。];

3.发出的每条消息的全文,每条消息都有全局唯一的id [注6:可用5 9.4的办法生成。];

4.关键内部状态的变更,等等。

每条日志都有时间戳,这样就能完整追踪分布式系统中一个事件的来龙去脉。也只有这样才能查清楚发生故障时究竟发生了什么,比如业务处理流程卡在了哪一步。

诊断日志不光是给程序员看的,更多的时候是给运维人员看的,因此日志的内容应避免造成误解,不要误导调查故障的主攻方向,拖延故障解决的时间。

一个日志库大体可分为前端[注7:muduo/base/Logging.{h, cc}](frontend)和后端[注8:muduo/base/LogFile.{h,cc}](backend)两部分。前端是供应用程序使用的接口(API),并生成日志消息(log message);后端则负责把日志消息写到目的地(destination)。这两部分的接口有可能简单到只有一个回调函数:

void output(const char*message,int len);

其中的message字符串是一条完整的日志消息,包含日志级别、时间戳、源文件位置、线程id等基本字段,以及程序输出的具体消息内容。

在多线程程序中,前端和后端都与单线程程序无甚区别,无非是每个线程有自己的前端,整个程序共用一个后端。但难点在于将日志数据从多个前端高效地传输到后端[注9:muduo/base/AsyncLogging.{h,cc}]。这是一个典型的多生产者-单消费者问题,对生产者(前端)而言,要尽量做到低延迟、低CPU开销、无阻塞;对消费者(后端)而言,要做到足够大的吞吐量,并占用较少资源。

对C++程序而言,最好整个程序(包括主程序和程序库)都使用相同的日志库,程序有一个整体的日志输出,而不要各个组件有各自的日志输出。从这个意义上讲,日志库是个singleton。

C++日志库的前端大体上有两种API风格:

● C/Java的printf(fmt,...)风格,例如

log_info("Received%d bytes from%s",len,getClientName().c_str());

● C++的stream<<风格,例如

LOG_INFO<<"Received "<<len<<"bytes from "<<getClientName();

muduo日志库是C++stream风格,这样用起来更自然,不必费心保持格式字符串与参数类型的一致性,可以随用随写,而且是类型安全[注10:printf(fmt,..)风格在C++中也可以做到类型安全,但是在C++11引入variadic template之前很费劲。因为C++不允许把non-POD对象通过可变参数(...)传入函数。Pantheios日志库用的是重载函数模板的办法(http://www.pantheios.org).]的。

stream风格的另一个好处是当输出的日志级别高于语句的日志级别时,打印日志是个空操作[注11:http://www.drdobbs.com/cpp/201804215],运行时开销接近零。比方说当日志级别为WARNING时,LOG_INFO<<是空操作,这个语句根本不会调用std::string getClientName()函数,减小了开销。而printf风格不易做到这一点。

muduo没有用标准库中的iostream,而是自己写的LogStream class [注12:muduo/base/Logstream.{h,cc}],这主要是出于性能原因(§ 11.6.6)。

5.1 功能需求

常规的通用日志库如log4j [注13:http://logging.apache.org/log4j/1.2/manual.html]/logback [注14:http://logback.qos.ch/manual/index.html]通常会提供丰富的功能,但这些功能不一定全都是必需的。

1.日志消息有多种级别(level),如TRACE、DEBUG、INFO、WARN、ERROR、FATAL等。

2.日志消息可能有多个目的地(appender),如文件、socket、SMTP等。

3.日志消息的格式可配置(layout),例如org.apache.log4j.PatternLayout。

4.可以设置运行时过滤器(filter),控制不同组件的日志消息的级别和目的地。在上面这几项中,我认为除了第一项之外,其余三项都是非必需的功能。

日志的输出级别在运行时可调,这样同一个可执行文件可以分别在QA测试环境的时候输出DEBUG级别的日志,在生产环境输出INFO级别的日志[注15:muduo默认输出INFO级别的日志,可以通过环境变量控制输出DEBUG或TRACE级别的日志。]。在必要的时候也可以临时在线调整日志的输出级别。例如某台机器的消息量过大、日志文件太多、磁盘空间紧张,那么可以临时调整为WARNING级别输出,减少日志数目。又比如某个新上线的进程的行为略显古怪,则可以临时调整为DEBUG级别输出,打印更细节的日志消息以便分析查错。调整日志的输出级别不需要重新编译,也不需要重启进程,只要调用muduo::Logger::setLogLevel()就能即时生效。

对于分布式系统中的服务进程而言,日志的目的地(destination)只有一个:本地文件。往网络写日志消息是不靠谱的,因为诊断日志的功能之一正是诊断网络故障,比如连接断开(网卡或交换机故障)、网络暂时不通(若干秒之内没有收到心跳消息)、网络拥塞(消息延迟明显加大)等等。如果日志消息也是通过网络发到另一台机器上的,那岂不是一损俱损?如果接收网络日志消息的服务器(日志服务器)发生故障或者出现进程死锁(阻塞),通常会导致发送日志的多个服务进程阻塞,或者内存暴涨(用户态和内核的TCP缓存),这无异于放大了单机故障。往网络写日志消息的另一个坏处是增加网络带宽消耗。试想收到一条业务消息、发出一条业务消息时都会写日志,如果写到网络上岂不是让网络带宽消耗翻倍,加剧trashing?同理,应该避免往网络文件系统(例如NFS)上写日志,这等于掩耳盗铃。

以本地文件为日志的destination,那么日志文件的滚动(rolling)是必需的,这样可以简化日志归档(archive)的实现。rolling的条件通常有两个:文件大小(例如每写满1GB就换下一个文件)和时间(例如每天零点新建一个日志文件,不论前一个文件有没有写满)。muduo日志库的LogFile会自动根据文件大小和时间来主动滚动日志文件。既然能主动rolling,自然也就不必支持SIGUSR1了,毕竟多线程程序处理signal很麻烦(§4.10)。

一个典型的日志文件的文件名如下:

logfile_test.++20120603++-144022.hostname.3605.log

文件名由以下几部分组成:

● 第1部分logfile_test是进程的名字。通常是main()函数参数中argv[0]的basename(3),这样容易区分究竟是哪个服务程序的日志。必要时还可以把程序版本加进去。

● 第2部分是文件的创建时间(GMT时区)。这样很容易通过文件名来选择某一时间范围内的日志,例如用通配符*.20120603-14*表示2012年6月3日下午2点(GMT)左右的日志文件(s)。

● 第3部分是机器名称。这样即便把日志文件拷贝到别的机器上也能追溯其来源。

● 第4部分是进程id。如果一个程序一秒之内反复重启,那么每次都会生成不同的日志文件,参考§ 9.4。

● 第5部分是统一的后缀名.log。同样是为了便于周边配套脚本的编写。

muduo的日志文件滚动没有采用文件改名的办法,即dmesg.log是最新日志,dmesg.log.1是前一个日志,dmesg.log.2.gz是更早的日志等。这种做法的一个好处是dmesg.log始终是最新日志,便于编写某些及时解析日志的脚本。将来可以增加一个功能,每次滚动日志文件之后立刻创建(更新)一个symlink,logfile_test.log始终指向当前最新的日志文件,这样达到相同的效果。

日志文件压缩与归档[注16:例如在非繁忙时段把压缩后的日志文件拷贝到某个NFS位置,以便集中保存和分析。](archive)不是日志库应有的功能,而应该交给专门的脚本去做,这样C++和Java的服务程序可以共享这一基础设施。如果想更换日志压缩算法或归档策略也不必动业务程序,改改周边配套脚本就行了。磁盘空间监控也不是日志库的必备功能。有人或许曾经遇到日志文件把磁盘占满的情况,因此希望日志库能限制空间使用,例如只分配10GB磁盘空间,用满之后就冲掉旧日志,重复利用空间,就像循环磁带一样。殊不知如果出现程序死循环拼命写日志的异常情况,那么往往是开头的几条日志最关键,它往往反映了引发异常(busy-loop)的原因(例如收到某条非法消息),后面都是无用的垃圾日志。如果日志库具备重复利用空间的"功能",只会帮倒忙。磁盘写入的带宽按100MB/s计算,写满一个100GB的磁盘分区需要16分钟,这足够监控系统报警并人工干预了(§ 9.2.1)。

往文件写日志的一个常见问题是,万一程序崩溃,那么最后若干条日志往往就丢失了,因为日志库不能每条消息都flush硬盘,更不能每条日志都open/close文件,这样性能开销太大。muduo日志库用两个办法来应对这一点,其一是定期(默认3秒)将缓冲区内的日志消息flush到硬盘;其二是每条内存中的日志消息都带有cookie(或者叫哨兵值/sentry),其值为某个函数的地址,这样通过在core dump文件中查找cookie [注17:可以用gdb的find命令。用strings(1)命令也能从core文件里找到不少有用的信息。]就能找到尚未来得及写入磁盘的消息。

日志消息的格式是固定的,不需要运行时配置,这样可节省每条日志解析格式字符串的开销。我认为日志的格式在项目的整个生命周期几乎不会改变,因为我们经常会为不同目的编写parse日志的脚本,既要解析最近几天的日志文件,也要和几个月之前,甚至一年之前的日志文件的同类数据做对比。如果在此期间日志格式变了,势必会增加很多无谓的工作量。如果真的需要调整消息格式,直接修改代码并重新编译即可。以下是muduo日志库的默认消息格式:

日志消息格式有几个要点:

● 尽量每条日志占一行。这样很容易用awk、sed、grep等命令行工具来快速联机分析日志,比方说要查看"2012-06-0308:02:00"至"2012-06-0308:02:59"这1分钟内每秒打印日志的条数(直方图),可以运行

$grep-o'^2012060308:02:..'|sort|uniq-c

● 时间戳精确到微秒。每条消息都通过gettimeofday(2)获得当前时间,这么做不会有什么性能损失。因为在x86-64 Linux上,gettimeofday(2)不是系统调用,不会陷入内核[注18:http://lwn.net/Articles/446528/](可用strace(1)验证muduo/base/tests/Timestamp_unittest.cc)。

● 始终使用GMT时区(Z)。对于跨洲的分布式系统而言,可省去本地时区转换的麻烦(别忘了主要西方国家大多实行夏令时),更易于追查事件的顺序。

● 打印线程id。便于分析多线程程序的时序,也可以检测死锁[注19:例如某个繁忙的线程在某一时刻之后突然不再log任何消息,往往意味着发生了死锁或阻塞(僵死)]。这里的线程id是指调用LOG_INFO<<的线程,线程id的获取见§ 4.3。

● 打印日志级别。在线查错的时候先看看有无ERROR日志,通常可加速定位问题。

● 打印源文件名和行号。修复bug的时候不至于搞错对象。

每行日志的前4个字段的宽度是固定的,以空格分隔,便于用脚本解析。另外,应该避免在日志格式(特别是消息id [注20:对于Base64编码的消息id,可以将其中的'+'替换为'-',见RFC 4648第5节。])中出现正则表达式的元字符(meta character),例如'['和']'等等,这样在用less(1)查看日志文件的时候查找字符串更加便捷。

运行时的日志过滤器(filter)或许是有用的,例如控制不同部件(程序库)的输出日志级别,但我认为这应该放到编译期去做,整个程序有一个整体的输出级别就足够好了。同时我认为一个程序同时写多个日志文件[注21:例如不同的日志级别或不同的组件写到不同的文件。]是非常罕见的需求,这可以事后留给log archiver来分流,不必做到日志库中。不实现filter自然也能减小生成每条日志的运行时开销,可以提高日志库的性能。

-------分隔线------------------------------------

笔记:

cpp 复制代码
//Timestamp_unittest.cpp   时间戳日志使用的示列

#include "muduo/base/Timestamp.h"
#include <vector>
#include <stdio.h>

using muduo::Timestamp;

void passByConstReference(const Timestamp& x)
{
    printf("%s\n", x.toString().c_str());
}

void passByValue(Timestamp x)
{
    printf("%s\n", x.toString().c_str());
}

void benchmark()
{
    const int kNumber = 1000 * 1000;
    std::vector<Timestamp> stamps;
    stamps.reserve(kNumber);
    for (int i = 0; i < kNumber; ++i)
        {
            stamps.push_back(Timestamp::now());
        }
    printf("%s\n", stamps.front().toString().c_str());
    printf("%s\n", stamps.back().toString().c_str());
    printf("%f\n", timeDifference(stamps.back(), stamps.front()));

    int increments[100] = { 0 };
    int64_t start = stamps.front().microSecondsSinceEpoch();
    for (int i = 1; i < kNumber; ++i)
        {
            int64_t next = stamps[i].microSecondsSinceEpoch();
            int64_t inc = next - start;
            start = next;
            if (inc < 0)
            {
                printf("reverse!\n");
            }
            else if (inc < 100)
            {
                ++increments[inc];
            }
            else
            {
                printf("big gap %d\n", static_cast<int>(inc));
            }
        }

    for (int i = 0; i < 100; ++i)
        {
            printf("%2d: %d\n", i, increments[i]);
        }
}

int main()
{
    Timestamp now(Timestamp::now());
    printf("%s\n", now.toString().c_str());
    passByValue(now);
    passByConstReference(now);
    benchmark();
    return 0;
}

//编译命令:
// g++ -o Timestamp_unittest Timestamp_unittest.cpp \
//    -I/usr/local/include \
//    -L/usr/local/lib \
//    -lmuduo_net -lmuduo_base -lpthread

编译运行:

-------分隔线------------------------------------

5.2 性能需求

编写Linux服务端程序的时候,我们需要一个高效的日志库。只有日志库足够高效,程序员才敢在代码中输出足够多的诊断信息,减小运维难度,提升效率。高效性体现在几方面:

● 每秒写几千上万条日志的时候没有明显的性能损失。

● 能应对一个进程产生大量日志数据的场景,例如1GB/min。

● 不阻塞正常的执行流程。

● 在多线程程序中,不造成争用(contention)。

这里列举一些具体的性能指标,考虑往普通7200rpm SATA硬盘写日志文件的情况:

● 磁盘带宽约是110MB/s,日志库应该能瞬时写满这个带宽(不必持续太久)。

● 假如每条日志消息的平均长度是110字节,这意味着1秒要写100万条日志。

以上是"高性能"日志库的最低指标。如果磁盘带宽更高,那么日志库的预期性能指标也会相应提高。反过来说,在磁盘带宽确定的情况下,日志库的性能只要"足够好"就行了。假如某个神奇的日志库1秒能往/dev/null写1000MB数据,那么到哪里去找这么快的磁盘来让程序写诊断日志呢?

这些指标初看起来有些异想天开,什么程序需要1秒写100万条日志消息呢?换一个角度其实很容易想明白,如果一个程序耗尽全部CPU资源和磁盘带宽可以做到1秒写100万条日志消息,那么当只需要1秒写10万条日志的时候[注22:比方说一秒处理两三万条消息,每条消息写三条日志:从哪里收到、计算结果如何、发到哪里。],立刻就能腾出90%的资源来干正事(处理业务)。相反,如果一个日志库在满负荷的情况下只能1秒写10万条日志,真正用到生产环境,恐怕就只能1秒写1万条日志才不会影响正常业务处理,这其实钳制了服务器的吞吐量。

以下是muduo日志库在两台机器上的实测性能数据[注23:muduo/base/tests/Logging_test.cc]。

可见muduo日志库在现在的PC上能写到每秒200万条消息,带宽足够撑满两个千兆网连接或4个SATA组成的RAID10,性能是达标的[注24:日志文件是顺序写入,是对磁盘最友好的一种负载,对IOPS要求不高。]。

为了实现这样的性能指标,muduo日志库的实现有几点优化措施值得一提:

● 时间戳字符串中的日期和时间两部分是缓存的,一秒之内的多条日志只需重新格式化微秒部分[注25:见muduo/base/Logging.cc中的Logger::lmpl::formatTime()函数。]。例如p.111出现的3条日志消息中,"20120603 08:02:46"是复用的,每条日志只需要格式化微秒部分(".125770Z")。

● 日志消息的前4个字段是定长的,因此可以避免在运行期求字符串长度(不会反复调用strlen [注26:见muduo/base/Logging.cc中的class T和operator<<(LogStream&s,T v).])。因为编译器认识memcpy()函数,对于定长的内存复制,会在编译期把它inline展开为高效的目标代码。

● 线程id是预先格式化为字符串,在输出日志消息时只需简单拷贝几个字节。见CurrentThread::tidString()。

● 每行日志消息的源文件名部分采用了编译期计算来获得basename,避免运行期strrchr(3)开销。见SourceFile class,这里利用了gcc的内置函数。

-------分隔线------------------------------------

笔记:

前端日志示例:

cpp 复制代码
//Logging_test.cpp   前端日志使用的示列

#include "muduo/base/Logging.h"
#include "muduo/base/LogFile.h"
#include "muduo/base/ThreadPool.h"
#include "muduo/base/TimeZone.h"

#include <stdio.h>
#include <unistd.h>

int g_total;
FILE* g_file;
std::unique_ptr<muduo::LogFile> g_logFile;

void dummyOutput(const char* msg, int len)
{
    g_total += len;
    if (g_file)
    {
        fwrite(msg, 1, len, g_file);
    }
    else if (g_logFile)
    {
        g_logFile->append(msg, len);
    }
}

void bench(const char* type)
{
    muduo::Logger::setOutput(dummyOutput);
    muduo::Timestamp start(muduo::Timestamp::now());
    g_total = 0;

    int n = 1000 * 1000;
    const bool kLongLog = false;
    muduo::string empty = " ";
    muduo::string longStr(3000, 'X');
    longStr += " ";
    for (int i = 0; i < n; ++i)
    {
        LOG_INFO << "Hello 0123456789" << " abcdefghijklmnopqrstuvwxyz"
            << (kLongLog ? longStr : empty)
            << i;
    }
    muduo::Timestamp end(muduo::Timestamp::now());
    double seconds = timeDifference(end, start);
    printf("%12s: %f seconds, %d bytes, %10.2f msg/s, %.2f MiB/s\n",
        type, seconds, g_total, n / seconds, g_total / seconds / (1024 * 1024));
}

void logInThread()
{
    LOG_INFO << "logInThread";
    usleep(1000);
}

int main()
{
    getppid(); // for ltrace and strace

    muduo::ThreadPool pool("pool");
    pool.start(5);
    pool.run(logInThread);
    pool.run(logInThread);
    pool.run(logInThread);
    pool.run(logInThread);
    pool.run(logInThread);

    LOG_TRACE << "trace";
    LOG_DEBUG << "debug";
    LOG_INFO << "Hello";
    LOG_WARN << "World";
    LOG_ERROR << "Error";
    LOG_INFO << sizeof(muduo::Logger);
    LOG_INFO << sizeof(muduo::LogStream);
    LOG_INFO << sizeof(muduo::Fmt);
    LOG_INFO << sizeof(muduo::LogStream::Buffer);

    sleep(1);
    bench("nop");

    char buffer[64 * 1024];

    g_file = fopen("/dev/null", "w");
    setbuffer(g_file, buffer, sizeof buffer);
    bench("/dev/null");
    fclose(g_file);

    g_file = fopen("/tmp/log", "w");
    setbuffer(g_file, buffer, sizeof buffer);
    bench("/tmp/log");
    fclose(g_file);

    g_file = NULL;
    g_logFile.reset(new muduo::LogFile("test_log_st", 500 * 1000 * 1000, false));
    bench("test_log_st");

    g_logFile.reset(new muduo::LogFile("test_log_mt", 500 * 1000 * 1000, true));
    bench("test_log_mt");
    g_logFile.reset();

    {
        g_file = stdout;
        sleep(1);
        muduo::TimeZone beijing(8 * 3600, "CST");
        muduo::Logger::setTimeZone(beijing);
        LOG_TRACE << "trace CST";
        LOG_DEBUG << "debug CST";
        LOG_INFO << "Hello CST";
        LOG_WARN << "World CST";
        LOG_ERROR << "Error CST";

        sleep(1);
        muduo::TimeZone newyork = muduo::TimeZone::loadZoneFile("/usr/share/zoneinfo/America/New_York");
        muduo::Logger::setTimeZone(newyork);
        LOG_TRACE << "trace NYT";
        LOG_DEBUG << "debug NYT";
        LOG_INFO << "Hello NYT";
        LOG_WARN << "World NYT";
        LOG_ERROR << "Error NYT";
        g_file = NULL;
    }
    bench("timezone nop");
}

//编译命令:
// g++ -o Logging_test Logging_test.cpp \
//    -I/usr/local/include \
//    -L/usr/local/lib \
//    -lmuduo_net -lmuduo_base -lpthread

编译运行:

复制代码
20251009 07:36:50.809778Z 13527 INFO  Hello - Logging_test.cpp:71
20251009 07:36:50.809790Z 13611 INFO  logInThread - Logging_test.cpp:53
20251009 07:36:50.809796Z 13614 INFO  logInThread - Logging_test.cpp:53
20251009 07:36:50.809788Z 13612 INFO  logInThread - Logging_test.cpp:53
20251009 07:36:50.809805Z 13613 INFO  logInThread - Logging_test.cpp:53
20251009 07:36:50.809793Z 13527 WARN  World - Logging_test.cpp:72
20251009 07:36:50.809808Z 13527 ERROR Error - Logging_test.cpp:73
20251009 07:36:50.809810Z 13527 INFO  4056 - Logging_test.cpp:74
20251009 07:36:50.809810Z 13527 INFO  4024 - Logging_test.cpp:75
20251009 07:36:50.809811Z 13527 INFO  36 - Logging_test.cpp:76
20251009 07:36:50.809812Z 13527 INFO  4016 - Logging_test.cpp:77
20251009 07:36:50.809832Z 13615 INFO  logInThread - Logging_test.cpp:53
         nop: 0.130737 seconds, 110888890 bytes, 7648944.06 msg/s, 808.89 MiB/s
   /dev/null: 0.134992 seconds, 110888890 bytes, 7407846.39 msg/s, 783.39 MiB/s
    /tmp/log: 0.222957 seconds, 110888890 bytes, 4485169.79 msg/s, 474.32 MiB/s
 test_log_st: 0.273861 seconds, 110888890 bytes, 3651487.43 msg/s, 386.15 MiB/s
 test_log_mt: 0.294080 seconds, 110888890 bytes, 3400435.26 msg/s, 359.60 MiB/s
20251009 15:36:53.867068 13527 INFO  Hello CST - Logging_test.cpp:109
20251009 15:36:53.867091 13527 WARN  World CST - Logging_test.cpp:110
20251009 15:36:53.867093 13527 ERROR Error CST - Logging_test.cpp:111
20251009 03:36:54.867178 13527 INFO  Hello NYT - Logging_test.cpp:118
20251009 03:36:54.867198 13527 WARN  World NYT - Logging_test.cpp:119
20251009 03:36:54.867200 13527 ERROR Error NYT - Logging_test.cpp:120
timezone nop: 0.131758 seconds, 109888890 bytes, 7589671.97 msg/s, 795.38 MiB/s

后端日志示例:

cpp 复制代码
//LogFile_test.cpp   后端日志使用的示列
#include "muduo/base/LogFile.h"
#include "muduo/base/Logging.h"

#include <unistd.h>

std::unique_ptr<muduo::LogFile> g_logFile;

void outputFunc(const char* msg, int len)
{
	g_logFile->append(msg, len);
}

void flushFunc()
{
	g_logFile->flush();
}

int main(int argc, char* argv[])
{
	char name[256] = { '\0' };
	strncpy(name, argv[0], sizeof name - 1);
	g_logFile.reset(new muduo::LogFile(::basename(name), 200 * 1000));
	muduo::Logger::setOutput(outputFunc);
	muduo::Logger::setFlush(flushFunc);

	muduo::string line = "1234567890 abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ ";
	printf("main====================start\n");
	for (int i = 0; i < 10000; ++i)
	{
		LOG_INFO << line << i;
		usleep(1000);
	}
	printf("main====================end\n");
	return 0;
}

//编译命令:
// g++ -o LogFile_test LogFile_test.cpp \
//    -I/usr/local/include \
//    -L/usr/local/lib \
//    -lmuduo_net -lmuduo_base -lpthread

编译运行:

-------分隔线------------------------------------

5.3 多线程异步日志

多线程程序对日志库提出了新的需求:线程安全,即多个线程可以并发写日志,两个线程的日志消息不会出现交织。线程安全不难办到,简单的办法是用一个全局mutex保护IO,或者每个线程单独写一个日志文件[注27:Google C++日志库的默认多线程实现即如此。],但这两种做法的高效性就堪忧了。前者会造成全部线程抢一个锁,后者有可能让业务线程阻塞在写磁盘操作上。

我认为一个多线程程序的每个进程最好只写一个日志文件,这样分析日志更容易,不必在多个文件中跳来跳去。再说多线程写多个文件也不一定能提速,见p.99的分析。解决办法不难想到,用一个背景线程负责收集日志消息,并写入日志文件,其他业务线程只管往这个"日志线程"发送日志消息,这称为"异步日志"。

在多线程服务程序中,异步日志(叫"非阻塞日志"似乎更准确)是必需的,因为如果在网络IO线程或业务线程中直接往磁盘写数据的话,写操作偶尔可能阻塞长达数秒之久(原因很复杂,可能是磁盘或磁盘控制器复位)。这可能导致请求方超时,或者耽误发送心跳消息,在分布式系统中更可能造成多米诺骨牌效应,例如误报死锁引发自动failover等。因此,在正常的实时业务处理流程中应该彻底避免磁盘IO,这在使用one loop per thread模型的非阻塞服务端程序中尤为重要,因为线程是复用的,阻塞线程意味着影响多个客户连接。

我们需要一个"队列"来将日志前端的数据传送到后端(日志线程),但这个"队列"不必是现成的BlockingQueue<std::string>,因为不用每次产生一条日志消息都通知(notify())接收方。

muduo日志库采用的是双缓冲(double buffering)技术[注28:http://en.wikipedia.org/wiki/Multiplebuffering],基本思路是准备两块buffer:A和B,前端负责往buffer A填数据(日志消息),后端负责将buffer B的数据写入文件。当buffer A写满之后,交换A和B,让后端将buffer A的数据写入文件,而前端则往buffer B填入新的日志消息,如此往复。用两个buffer的好处是在新建日志消息的时候不必等待磁盘文件操作,也避免每条新日志消息都触发(唤醒)后端日志线程。换言之,前端不是将一条条日志消息分别传送给后端,而是将多条日志消息拼成一个大的buffer传送给后端,相当于批处理,减少了线程唤醒的频度,降低开销。另外,为了及时将日志消息写入文件,即便buffer A未满,日志库也会每3秒执行一次上述交换写入操作。

muduo异步日志的性能开销大约是前端每写一条日志消息耗时1.0µs~1.6µs。

关键代码

实际实现采用了四个缓冲区,这样可以进一步减少或避免日志前端的等待。数据结构如下(muduo/base/AsyncLogging.h):

cpp 复制代码
typedef boost::ptr_vector<LargeBuffer> BufferVector;
typedef BufferVector::auto_type BufferPtr;
muduo::MutexLock mutex_;
muduo::Condition cond_;
BufferPtr currentBuffer_;			//当前缓冲
BufferPtr nextBuffer_;				//预备缓冲
BufferVector buffers_;				//待写入文件的已填满的缓冲

其中,LargeBuffer类型是FixedBuffer class template的一份具体实现(instantia-tion),其大小为4MB,可以存至少1000条日志消息。boost::ptr_vector<T>::auto_type类型类似C++11中的std::unique_ptr,具备移动语义(move semantics),而且能自动管理对象生命期。mutex_用于保护后面的四个数据成员。buffers_存放的是供后端写入的buffer。

先来看发送方代码,即p.108回调函数output()的实现。

cpp 复制代码
28 void AsyncLogging::append(const char* logline, int len)
29 {
30     muduo::MutexLockGuard lock(mutex_);
31     if(currentBuffer_->avail() > len)
32     {	//most common case: buffer is not full, copy data here
33         currentBuffer_->append(logline, len);
34     }
35     else  // buffer is full, push it, and find next spare buffer 
36     {
37         buffers_.push_back(currentBuffer_.release());
38 
39         if(nextBuffer_)  // is there is one already, use it 
40         {
41             currentBuffer_ = boost::ptr_container::move(nextBuffer_); //移动,而非复制
42         }
43         else   // allocate a new one 
44         {
45             currentBuffer_.reset(new LargeBuffer);  //Rarely happens
46         }
47         currentBuffer_->append(logline, len);
48         cond_.notify();
49     }
50 }     

前端在生成一条日志消息的时候会调用AsyncLogging::append()。在这个函数中,如果当前缓冲(currentBuffer_)剩余的空间足够大(L31),则会直接把日志消息拷贝(追加)到当前缓冲中(L33),这是最常见的情况。这里拷贝一条日志消息并不会带来多大开销(p.120)。前后端代码的其余部分都没有拷贝,而是简单的指针交换。

否则,说明当前缓冲已经写满,就把它送入(移入)buffers_(L37),并试图把预备好的另一块缓冲(nextBuffer_)移用(move)为当前缓冲(L39~L42),然后追加日志消息并通知(唤醒)后端开始写入日志数据(L47~L48)。以上两种情况在临界区之内都没有耗时的操作,运行时间为常数。

如果前端写入速度太快,一下子把两块缓冲都用完了,那么只好分配一块新的buffer,作为当前缓冲(L43~L46),这是极少发生的情况。

再来看接收方(后端)实现,这里只给出了最关键的临界区内的代码(L59~L72),其他琐事请见源文件。

cpp 复制代码
51 void AsyncLogging::threadFunc()
52 {
53     BufferPtr newBuffer1(new LargeBuffer);
54     BufferPtr newBuffer2(new LargeBuffer);
55     BufferVector buffersToWrite;  // reserve()  从略
56     while(running_)
57     {
58         //  swap out what need to be written, keep CS short 
59         {
60             muduo::MutexLockGuard lock(mutex_);
61             if(buffers_.empty())  // unusual usage!
62             {
63                 cond_.waitForSeconds(flushInterval_);
64             }
65             buffers_push_back(currentBuffer_.release()); //移动,而非复制
66             currentBuffer_ = boost::ptr_container::move(newBuffer1); //移动,而非复制
67             buffersToWrite.swap(buffers_); //内部指针交换,而非复制
68             if(!nextBuffer_)
69             {
70                 nextBuffer_ = boost::ptr_container::move(newBuffer2); //移动,而非复制
71             }
72         }
73         // output buffersToWrite to file
74         // re-fill newBuffer1 and new Buffer2
75     }
76     // flush output
77}

首先准备好两块空闲的buffer,以备在临界区内交换(L53、L54)。在临界区内,等待条件触发(L61~L64),这里的条件有两个:其一是超时,其二是前端写满了一个或多个buffer。注意这里是非常规的condition variable用法,它没有使用while循环,而且等待时间有上限。

当"条件"满足时,先将当前缓冲(currentBuffer_)移入buffers_(L65),并立刻将空闲的newBuffer1移为当前缓冲(L66)。注意这整段代码位于临界区之内,因此不会有任何race condition。接下来将buffers_与buffersToWrite交换(L67),后面的代码可以在临界区之外安全地访问buffersToWrite,将其中的日志数据写入文件(L73)。临界区里最后干的一件事情是用newBuffer2替换nextBuffer_(L68~L71),这样前端始终有一个预备buffer可供调配。nextBuffer_可以减少前端临界区分配内存的概率,缩短前端临界区长度。注意到后端临界区内也没有耗时的操作,运行时间为常数。

L74会将buffersToWrite内的buffer重新填充newBuffer1和newBuffer2,这样下一次执行的时候还有两个空闲buffer可用于替换前端的当前缓冲和预备缓冲。最后,这四个缓冲在程序启动的时候会全部填充为0,这样可以避免程序热身时page fault引发性能不稳定。

运行图示

以下再用图表展示前端和后端的具体交互情况。一开始先分配好四个缓冲区A、B、C、D,前端和后端各持有其中两个。前端和后端各有一个缓冲区数组,初始时都是空的。

第一种情况是前端写日志的频度不高,后端3秒超时后将"当前缓冲current-Buffer_"写入文件,见图5-1(图中变量名为简写,下同)。

图5-1

在第2.9秒的时候,currentBuffer_使用了80%,在第3秒的时候后端线程醒过来,先把currentBuffer_送入buffers_(L65),再把newBuffer1移用为currentBuffer_(L66)。随后第3+秒,交换buffers_和buffersToWrite(L67),离开临界区,后端开始将buffer A写入文件。写完(write done)之后再把newBuffer1重新填上,等待下一次cond_.waitForSeconds()返回。

后面在画图时将有所简化,不再画出buffers_和buffersToWrite交换的步骤。

第二种情况,在3秒超时之前已经写满了当前缓冲,于是唤醒后端线程开始写入文件,见图5-2。

图5-2

在第1.5秒的时候,currentBuffer_使用了80%;第1.8秒,currentBuffer_写满,于是将当前缓冲送入buffers_(L37),并将nextBuffer_移用为当前缓冲(L39~L42),然后唤醒后端线程开始写入。当后端线程唤醒之后(第1.8+秒),先将currentBuffer_送入buffers_(L65),再把newBuffer1移用为currentBuffer_(L66),然后交换buffers_和buffersToWrite(L67),最后用newBuffer2替换nextBuffer_(L68~L71),即保证前端有两个空缓冲可用。离开临界区之后,将buffersToWrite中的缓冲区A和B写入文件,写完之后重新填充newBuffer1和newBuffer2,完成一次循环。

上面这两种情况都是最常见的,再来看一看前端需要分配新buffer的两种情况。

第三种情况,前端在短时间内密集写入日志消息,用完了两个缓冲,并重新分配了一块新的缓冲,见图5-3。

图5-3

在第1.8秒的时候,缓冲A已经写满,缓冲B也接近写满,并且已经notify()了后端线程,但是出于种种原因,后端线程并没有立刻开始工作。到了第1.9秒,缓冲B也已经写满,前端线程新分配了缓冲E。到了第1.8+秒,后端线程终于获得控制权,将C、D两块缓冲交给前端,并开始将A、B、E依次写入文件。一段时间之后,完成写入操作,用A、B重新填充那两块空闲缓冲。注意这里有意用A和B来填充newBuffer1/2,而释放了缓冲E,这是因为使用A和B不会造成page fault。

思考题:阅读代码并回答,缓冲E是何时在哪个线程释放的?

第四种情况,文件写入速度较慢,导致前端耗尽了两个缓冲,并分配了新缓冲,见图5-4。

图5-4

前1.8+秒的场景和前面"第二种情况"相同,前端写满了一个缓冲,唤醒后端线程开始写入文件。之后,后端花了较长时间(大半秒)才将数据写完。这期间前端又用完了两个缓冲,并分配了一个新的缓冲,这期间前端的notify()已经丢失。当后端写完(write done)后,发现buffers_不为空(L61),立刻进入下一循环。即替换前端的两个缓冲,并开始一次写入C、D、E。假定前端在此期间产生的日志较少,请读者补全后续的情况。

改进措施

前面我们一共准备了四块缓冲,应该足以应付日常的需求。如果需要进一步增加buffer数目,可以改用下面的数据结构。

cpp 复制代码
BufferPtr		currentBuffer_;		//当前缓冲
BufferVector	emptyBuffers_;		//空闲缓冲
BufferVertor 	fullBuffers_;		//已写满的缓冲

初始化时在emptyBuffers_中放入足够多空闲buffer,这样前端几乎不会遇到需要在临界区内新分配buffer的情况,这是一种空间换时间的做法。为了避免短时突发写大量日志造成新分配的buffer占用过多内存,后端代码应该保证emptyBuffers_和fullBuffers_的长度之和不超过某个定值。buffer在前端和后端之间流动,形成一个循环,如图5-5所示。

以上改进留作练习。

图5-5

如果日志消息堆积怎么办万一前端陷入死循环,拼命发送日志消息,超过后端的处理(输出)能力,会导致什么后果?对于同步日志来说,这不是问题,因为阻塞IO自然就限制了前端的写入速度,起到了节流阀(throttling)的作用。但是对于异步日志来说,这就是典型的生产速度高于消费速度问题,会造成数据在内存中堆积,严重时引发性能问题(可用内存不足)或程序崩溃(分配内存失败)。

muduo日志库处理日志堆积的方法很简单:直接丢掉多余的日志buffer,以腾出内存,见muduo/base/AsyncLogging.cc第87~96行代码。这样可以防止日志库本身引起程序故障,是一种自我保护措施。将来或许可以加上网络报警功能,通知人工介入,以尽快修复故障。

-------分隔线------------------------------------

笔记:

cpp 复制代码
//AsyncLogging_test.cpp   前端日志传到后端日志使用示列

#include "muduo/base/AsyncLogging.h"
#include "muduo/base/Logging.h"
#include "muduo/base/Timestamp.h"

#include <stdio.h>
#include <sys/resource.h>
#include <unistd.h>

off_t kRollSize = 500 * 1000 * 1000;

muduo::AsyncLogging* g_asyncLog = NULL;

void asyncOutput(const char* msg, int len)
{
    g_asyncLog->append(msg, len);
}

void bench(bool longLog)
{
    muduo::Logger::setOutput(asyncOutput);
    int cnt = 0;
    const int kBatch = 1000;
    muduo::string empty = " ";
    muduo::string longStr(3000, 'X');
    longStr += " ";

    for (int t = 0; t < 30; ++t)
        {
            muduo::Timestamp start = muduo::Timestamp::now();
            for (int i = 0; i < kBatch; ++i)
                {
                    LOG_INFO << "Hello 0123456789" << " abcdefghijklmnopqrstuvwxyz "
                        << (longLog ? longStr : empty)
                        << cnt;
                    ++cnt;
                }
            muduo::Timestamp end = muduo::Timestamp::now();
            printf("%f\n", timeDifference(end, start) * 1000000 / kBatch);
            struct timespec ts = { 0, 500 * 1000 * 1000 };
            nanosleep(&ts, NULL);
        }
}

int main(int argc, char* argv[])
{
    {
        // set max virtual memory to 2GB.
        size_t kOneGB = 1000 * 1024 * 1024;
        rlimit rl = { 2 * kOneGB, 2 * kOneGB };
        setrlimit(RLIMIT_AS, &rl);
    }

    printf("pid = %d\n", getpid());

    char name[250] = { '\0' };
    strncpy(name, argv[0], sizeof name - 1);
    muduo::AsyncLogging log(::basename(name), kRollSize);
    log.start();
    g_asyncLog = &log;

    bool longLog = argc > 1;
    bench(longLog);
    return 0;
}

//编译命令:
// g++ -o AsyncLogging_test AsyncLogging_test.cpp \
//    -I/usr/local/include \
//    -L/usr/local/lib \
//    -lmuduo_net -lmuduo_base -lpthread

编译运行:

-------分隔线------------------------------------

5.4 其他方案

当然在前端和后端之间高效传递日志消息的办法不止这一种,比方说使用常规的muduo::BlockingQueue<std::string>或muduo::BoundedBlockingQueue<std::string>在前后端之间传递日志消息,其中每个std::string是一条消息。这种做法每条日志消息都要分配内存,特别是在前端线程分配的内存要由后端线程释放,因此对malloc的实现要求较高,需要针对多线程特别优化。另外,如果用这种方案,那么需要修改LogStream的Buffer,使之直接将日志写到std::string中,可节省一次内存拷贝。

相比前面展示的直接拷贝日志消息的做法,这个传递指针的方案似乎会更高效,但是据我测试[注29:代码见recipes/logging/AsyncLogging*],直接拷贝日志数据的做法比传递指针快3倍(在每条日志消息不大于4kB的时候),估计是内存分配的开销所致。因此muduo日志库只提供了§ 5.3介绍的这一种异步日志机制。这再次说明"性能"不能凭感觉说了算,一定要有典型场景的测试数据作为支撑。

muduo现在的异步日志实现用了一个全局锁。尽管临界区很小,但是如果线程数目较多,锁争用(lock contention)也可能影响性能。一种解决办法是像Java的ConcurrentHashMap那样用多个桶子(bucket),前端写日志的时候再按线程id哈希到不同的bucket中,以减少contention。这种方案的后端实现较为复杂,有兴趣的读者可以试一试。

为了简化实现,目前muduo日志库只允许指定日志文件的名字,不允许指定其路径。日志库会把日志文件写到当前路径,因此可以在启动脚本(shell脚本)里改变当前路径,以达到相同的目的。

Linux默认会把core dump写到当前目录,而且文件名是固定的core。为了不让新的core dump文件冲掉旧的,我们可以通过sysctl设置kernel.core_pattern参数(也可以修改/proc/sys/kernel/core_pattern),让每次core dump都产生不同的文件。例如设为%e.%t.%p.%u.core,其中各个参数的意义见man 5 core。另外也可以使用Apport来收集有用的诊断信息,见https://wiki.ubuntu.com/Apport。

相关推荐
班公湖里洗过脚2 天前
第3章 多线程服务器的适用场合与常用编程模型
linux多线程
班公湖里洗过脚4 天前
第2章 线程同步精要
linux多线程