
来源:程序员老廖
日志是所有线上系统的“黑匣子”,但日志写入如果阻塞业务线程,会把 I/O 延迟 直接放大到业务请求上。
高并发下,同步写日志常见问题:
视频讲解与源码领取:C++高性能日志库开发实践
线程局部存储(Thread Local Storage, TLS)是一种机制,用于为每个线程提供独立的变量副本。这些变量存储在每个线程的局部存储区中,而不是全局存储区,从而避免线程之间的数据竞争和共享问题。

同步日志通常意味着:业务线程在写日志时完成:
缺点:任何 I/O 抖动会直接影响业务线程;并发时锁竞争严重。
核心思想:业务线程只负责“产生日志并写入内存缓冲”,后台线程负责“批量写入磁盘”。
数据流(简化):
业务线程: Logger -> g_output(asyncOutput) -> AsyncLogging::append()
|
v
内存缓冲(currentBuffer_/TLS buffer)
|
v
后台线程: AsyncLogging::threadFunc() -> LogFile -> FileUtil::AppendFile -> fwrite_unlocked -> OS page cache -> 磁盘收益:

关键点:
代码逻辑(Logging.cc::Logger::Impl::formatTime()):
__thread char t_time[64]; // 缓存 "YYYYMMDD HH:MM:SS" 部分
__thread time_t t_lastSecond; // 上次格式化的秒数
void Logger::Impl::formatTime() {
int64_t microSecondsSinceEpoch = time_.microSecondsSinceEpoch();
time_t seconds = microSecondsSinceEpoch / 1000000;
int microseconds = microSecondsSinceEpoch % 1000000;
if (seconds != t_lastSecond) { // 只在"秒变化"时才调用 gmtime_r + snprintf
t_lastSecond = seconds;
struct tm tm_time;
::gmtime_r(&seconds, &tm_time);
snprintf(t_time, sizeof(t_time), "%4d%02d%02d %02d:%02d:%02d", ...);
}
// 微秒部分:每条日志都要拼,但我们已优化为手写 6 位数字(不再 snprintf)
formatMicroseconds(microseconds, ...); // 固定 6 位数字拼接
stream_ << T(t_time, 17) << ".XXXXXX " << ...;
}收益量化:
AsyncLogging 的核心是"缓冲 + 线程同步 + 批量写":
后台大 buffer(4MB):kLargeBuffer = 4000*1000
TLS staging buffer(64KB):
flush 语义修正:后台线程不再"每批写完都 flush",改为按 flushInterval 时间间隔 flush。
thread-local 前端缓冲(降锁):业务线程先写入 64KB TLS staging buffer,满了/到时间再一次性提交,减少每条日志的 mutex 开销。
注意:TLS 方案保持"同线程内顺序",不保证跨线程严格全局顺序(并发下本就难保证)。
TLS buffer 生命周期:
LogFile 负责:
FileUtil::AppendFile 负责实际写入:
setbuffer(用户态缓冲):
fwrite_unlocked(无锁 stdio):
注意:fwrite_unlocked 只能在"确定单线程写"时用,否则会数据竞争。
目的:减少内存分配。
正常写入:业务线程向 currentBuffer_ append
写满:把 currentBuffer_ move 到 buffers_
目的:让"写磁盘"阶段不持锁。
后台线程拿锁做很短的事情:
释放锁后:
如果不用双队列 swap(伪代码):
lock(mutex);
for (auto& buf : buffers_) {
output.append(buf->data(), buf->length()); // 持锁写文件,阻塞前端 append
}
buffers_.clear();
unlock(mutex);持锁时间 = 遍历 + 写文件 + 清理,可能数百 ms(取决于磁盘 I/O)
用双队列 swap(实际代码):
lock(mutex);
buffers_.swap(buffersToWrite); // O(1),只交换两个 vector 的内部指针
unlock(mutex); // 持锁时间降到 < 1 µs
// 释放锁后批量写(不影响前端 append)
for (auto& buf : buffersToWrite) {
output.append(buf->data(), buf->length());
}持锁时间 = swap(几条指令)+ 其他准备,通常 < 10 µs
收益量化:
目的:减少系统调用次数,提高吞吐。


append(logline):
lock(mutex)
if currentBuffer 有空间:
currentBuffer.append(logline)
else:
buffers.push_back(move(currentBuffer))
currentBuffer = nextBuffer ? move(nextBuffer) : new Buffer
currentBuffer.append(logline)
notify(cond)
unlock(mutex)
threadFunc():
while running:
lock(mutex)
if buffers empty:
wait(cond, flushInterval)
buffers.push_back(move(currentBuffer)) // 把当前 buffer 也交给后台写
currentBuffer = move(newBuffer1) // 立刻补一个空 buffer 给前端
buffersToWrite.swap(buffers) // 关键:缩短临界区
if nextBuffer empty:
nextBuffer = move(newBuffer2)
unlock(mutex)
for b in buffersToWrite:
output.append(b.data, b.len) // 无锁批量写
if 到了 flushInterval:
output.flush()
回收 buffersToWrite 的部分 buffer 复用为 newBuffer1/newBuffer2
buffersToWrite.clear()

append(logline):
写入 thread_local tlsBuffer
如果 tlsBuffer 满了 / 到时间:
lock(mutex)
appendLocked(tlsBuffer.data, tlsBuffer.len) // 一次提交一大块
tlsBuffer.reset()
notify(cond)
unlock(mutex)
性能对比量化:
指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
每条日志加锁次数 | 1 次 | 约 1/256 次 | 降低 98.6% |
单次 lock/unlock 开销 | 20-30 ns | 20-30 ns | 不变 |
平均每条锁开销 | 20-30 ns | ~0.1 ns | 降低 99%+ |
锁竞争概率 | 高(每条都竞争) | 极低(批量提交) | 显著降低 |
单线程 500 万条日志锁开销 | ~100-150 ms | ~0.5 ms | 节省约 100ms |

优化效果:
gmtime_r + snprintf:约 100-200 ns(仅在秒变化时调用)
缓存命中(同一秒内):约 2-5 ns(直接读 t_time)
微秒拼接(每条必做):从 30-50 ns(snprintf)降到 5-10 ns(手写)
单秒百万日志场景:

参数选择依据:
rollSize = 100MB ~ 1GB:
flushInterval = 1 ~ 3 秒:
把优化分层是面试的关键:能说明你不是“乱改”,而是按热路径定位与成本模型做工程取舍。
--roll=100M、--flush=1、--num=... 优化前(使用 Fmt 内部 snprintf):
Fmt us(".%06dZ ", microseconds); // 内部:snprintf(buf, 32, ".%06dZ ", microseconds);
stream_ << T(us.data(), us.length());优化后(手写固定 6 位拼接):
char buf[10];
buf[0] = '.';
buf[1] = '0' + (microseconds / 100000) % 10;
buf[2] = '0' + (microseconds / 10000) % 10;
buf[3] = '0' + (microseconds / 1000) % 10;
buf[4] = '0' + (microseconds / 100) % 10;
buf[5] = '0' + (microseconds / 10) % 10;
buf[6] = '0' + microseconds % 10;
buf[7] = 'Z';
buf[8] = ' ';
stream_.append(buf, 9);收益量化:每条日志节省 ~20~40 ns;100 万条日志累计节省 ~20~40 ms CPU 时间。
优化前:
LogStream& operator<<(const char* str) {
buffer_.append(str, strlen(str)); // strlen 需要遍历到 '\0'
}优化后:
template <int N>
LogStream& operator<<(const char (&arr)[N]) {
buffer_.append(arr, N - 1); // N 编译期已知,不需要 strlen
}收益量化:典型一条日志有 3~5 个字面量片段,累计节省 15~50 ns/条。

性能对比:
操作 | 拷贝方式 | 移动方式 | 差异 |
|---|---|---|---|
转移4MB buffer | memcpy 4MB~1-2 ms | 交换指针~5 ns | 快 20万倍 |
内存分配 | new + delete~100-500 ns | 复用~0 ns | 节省分配开销 |
异常安全 | 可能泄漏 | unique_ptr自动释放 | RAII保证 |

访问过程:
为什么用 atomic:
特性 | 代码位置(示例) | 解决的问题 | 面试一句话 |
|---|---|---|---|
std::atomic<bool> | AsyncLogging.h:running_ | 状态跨线程可见,避免数据竞争 | “用 atomic 管理线程生命周期状态,保证可见性与无数据竞争。” |
std::unique_ptr | AsyncLogging.h:BufferPtr;LogFile.h:file_ | 自动资源管理,避免泄漏 | “用 unique_ptr 做 RAII,异常/早返回也不会泄漏资源。” |
std::move | AsyncLogging.cc:buffer 转移/复用 | 避免深拷贝,降低开销 | “高吞吐路径只移动所有权,不做拷贝。” |
std::function/std::bind | AsyncLogging.cc:线程入口绑定成员函数 | 更易组合与复用 | “线程函数用 bind 绑定成员函数,结构更清晰。” |
thread_local | AsyncLogging.cc:TLS staging buffer | 降锁、缓存热数据 | “把高频写先落到线程本地,批量提交减少锁竞争。” |
static_assert | TimeStamp.cc:类型/大小校验 | 编译期约束 | “用 static_assert 把假设写进编译期,防止平台差异。” |
range-for | AsyncLogging.cc:遍历 buffersToWrite | 少写错、更简洁 | “遍历容器用 range-for,减少手写迭代器错误。” |
nullptr | AsyncLogging.cc:TLS owner 等 | 安全空指针 | “用 nullptr 代替 NULL,避免重载歧义。” |
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。