写 C++ 好多年了,也研究过不少开源代码,发现一个共性:输出换行时,大家基本都用 \n,而不是 std::endl。就像下面这样:
std::cout << "hello\n";
原因很简单,std::endl 不只是输出一个换行符,它还会调用 flush() 刷新输出缓冲区;而 \n 只是输出换行,并不会主动要求刷新。
于是,一个很自然的结论出现了:std::endl 会频繁触发系统调用,而系统调用很昂贵,所以代码中应该尽量换成 \n。
这个结论我一度也觉得没什么问题。不过真去测一下,会发现一个奇怪的现象:把程序里的 std::endl 全部换成 \n,系统调用次数可能一次都没少。
示例
用一段示例代码验证下这个结论。代码很简单,循环输出 30 万行数据,每行 16 字节,总共大约 4.8 MB。
if (use_endl) {
for (long i = 0; i < 300000; ++i)
std::cout << PAYLOAD << std::endl;
} else {
for (long i = 0; i < 300000; ++i)
std::cout << PAYLOAD << '\n';
}
std::cout.flush();
这里分别测试两种情况:第一种直接输出到终端,第二种把 stdout 重定向到文件。然后通过 strace 统计两种情况下 write() 系统调用的次数。
# 输出到终端
strace -c ./test
# 输出重定向到文件
strace -c ./test > output.txt
按照直觉,结果应该很明显:std::endl 每行都刷新一次,所以会产生大量 write();而 \n 不会主动 Flush,可以先把数据留在 Buffer 里,等 Buffer 满了再批量写入内核。
但实际结果却是:
输出位置 write 次数
std::endl terminal 300001
\n terminal 300001
std::endl file 300001
\n file 1173
输出重定向到文件时,一切符合预期:把 std::endl 换成 \n 后,write() 从 30 万次降到了 1173 次。但输出到终端时,两者竟然都是 300001 次。
也就是说,同样的代码、同样的数据,仅仅改变 stdout 的去向,结果就完全不同:
Terminal → std::endl 300001 次,\n 300001 次
File → std::endl 300001 次,\n 1173 次
同样的代码,仅仅改变 stdout 的输出目标,结果就完全不同。 要解释这个现象,还得继续往下看 std::cout 的输出过程。
内部 Buffer
前面的现象有点奇怪:std::endl 会主动 Flush,\n 不会,但输出到终端时,两者产生的 write() 次数却完全一样。
要解释这个问题,首先得搞清楚一件事:std::cout 输出的数据,在进入内核之前到底缓存在什么地方?
很多人可能会下意识认为,std::cout 自己维护着一块 Buffer,数据先写进这里,等 Buffer 满了或者调用 flush(),再通过 write() 交给内核:
std::cout → C++ Buffer → write() → Kernel
但在 libstdc++ 的默认配置下,实际情况并没有这么简单。默认情况下:
std::ios::sync_with_stdio(true);
也就是 C++ iostream 与 C stdio 保持同步。此时 std::cout 底层使用的是 stdio_sync_filebuf,写入数据最终会走到类似:
std::fwrite(...);
而:
std::cout.flush();
最终会走到类似:
std::fflush(stdout);
所以这时候更接近真实的数据路径其实是:
std::cout → C stdio → stdout Buffer → write() → Kernel
也就是说,我们真正应该关注的 Buffer,不是在 std::cout 这一层,而是在下面的 stdio。
这就能解释前面的怪现象了:既然最终的数据缓冲由 stdio 负责,那么什么时候真正调用 write(),就不只取决于 C++ 有没有调用 flush(),还取决于 stdio 自己采用了什么缓冲策略。
而这个策略,又和 stdout 最终连接的是终端还是文件有关。
终端和文件
在 Linux 上,stdout 本质上就是文件描述符 1,glibc 会根据 stdout 最终连接的对象选择不同的缓冲策略。
如果 stdout 指向普通文件,就像下面这样:
./test > output.txt
通常采用全缓冲(Full Buffering)。数据先进入 Buffer,Buffer 满了以后再调用 write():
程序 → Buffer → Buffer 满 → write() → Kernel
所以大量:
std::cout << "hello\n";
不会每次都触发 write()。示例代码里一次大约写 4096 字节,因此 4.8 MB 数据最终只有 1173 次左右系统调用。
但终端不一样。如果 stdout 指向 Terminal,stdio 通常采用行缓冲(Line Buffering),遇到换行就会把当前行送出去。
这时候最开始的谜团就解开了。
我们一直认为 std::endl 会 Flush,而 \n 不会 Flush。从 C++ 接口语义上说没有问题,但当 stdout 指向终端时,底层行缓冲机制本身就会因为换行而把数据刷新出去。
于是两条路径最终变成:
std::endl → 换行 → 显式 flush → write()
\n → 换行 → 行缓冲刷新 → write()
所以:
std::endl terminal 300001
\n terminal 300001
不是 std::endl 没有 Flush,而是 \n 在这个环境下最终也导致了输出刷新。
差距
现在再看:
./test > output.txt
就很好理解了。stdout 不再指向终端,而是普通文件,stdio 从行缓冲变成全缓冲。于是 \n 只是继续往 Buffer 里写,只有 Buffer 满了才批量调用 write()。
但 std::endl 不一样,因为它明确要求 flush(),所以无论底层是什么缓冲模式,都必须刷新。
最终才出现:
std::endl file 300001
\n file 1173
30 万次和 1173 次,相差大约 256 倍。
所以“把 std::endl 换成 \n 可以减少系统调用”这句话不能说错,但它少了一个非常重要的前提:
你的输出最终去了哪里?
sync_with_stdio(false)
很多 C++ 性能优化文章除了告诉你把 std::endl 换成 \n,通常还会顺手加一句:
std::ios::sync_with_stdio(false);
这行代码经常被描述成“关闭 C/C++ IO 同步,可以让 cout 更快”。但如果继续往下看,会发现它改变的远不只是一个同步开关。
执行以后,std::cout 底层从 stdio_sync_filebuf 变成 stdio_filebuf,这时候 C++ Stream 开始使用自己的 Buffer。实验中观察到的 Buffer 大约是 8191 字节,数据路径也从:
std::cout → stdio/glibc Buffer → write()
变成更接近:
std::cout → C++ Stream Buffer → write()/writev()
然后最有意思的事情发生了。重新测试:
系统调用次数
endl + terminal 300001
endl + file 300001
\n + terminal 587
\n + file 587
这一次,Terminal 和 File 的区别基本消失了。因为 std::cout 不再依赖 stdout 原来的行缓冲策略,C++ 自己维护 Buffer,于是 \n 终于真的只是写入一个换行符,不会因为终端行缓冲立即刷新。
反而是在执行:
std::ios::sync_with_stdio(false);
以后,std::endl 和 \n 在终端上的性能差距才真正显现出来。
很多时候我们把 sync_with_stdio(false) 和 std::endl → \n 当成两个独立的优化建议,但从底层看,它们并不是完全独立的:前者改变了 Buffer 和 IO 路径,后者的实际性能影响也因此发生了变化。
性能不一定提升
还有一个容易被忽略的问题。关闭同步以后,实验中的系统调用从 1173 次进一步下降到了 587 次,差不多少了一半,但执行时间只是从大约 9.86 ms 下降到 8.11 ms,并没有快一倍。
原因很简单:系统调用次数只是性能的一部分。最终仍然要搬运相同的 4.8 MB 数据,区别只是从“很多次小 write()”变成“更少次数的大 write()/writev()”。
所以:
syscall 减少 50% ≠ 性能提升 50%
结语
回到最开始的问题,std::endl 和 \n 到底哪个快?
如果只回答:
\n 更快,因为 std::endl 会 Flush。
不能说错,但离真正理解这个问题还差了好几层。因为程序真正执行的路径可能是:
C++ Stream → streambuf → C stdio/glibc → Buffering Policy → write/writev → Kernel → Terminal/File/Pipe
任何一层发生变化,最终测出来的结果都可能不同。
输出到 Terminal 时,std::endl 和 \n 的系统调用次数可能一样,因为底层本身就在做行缓冲;重定向到文件后,两者差距可能突然放大,因为 stdout 变成了全缓冲;关闭 sync_with_stdio(false) 后,Terminal 和 File 的差别又可能消失,因为 C++ 开始使用自己的 Buffer。