先有个前置知识:
- CPU 速度差不多是 I/O 设备的 \(10^6\) 倍这样
- CPU 访问内存的顺序:L1 缓存 → L2 缓存 → L3 缓存 → 主内存 → I/O设备
- 系统调用发生在「主内存 → I/O 设备」这一步中
- 缓冲区通常设置在主内存中
为什么要有缓冲区?
一个现实问题:
很显然的,CPU 速度相对于 I/O 设备来说太快了,CPU 常常需要等待 I/O 设备完成输入输出,从而导致 CPU 利用率低下。
一个传统解法:
既然每次都要跑一遍「L1 缓存 → L2 缓存 → L3 缓存 → 主内存 → I/O设备」这个流程,中间还要有系统调用这个特别慢的流程,那么不如多堆一点数据等会一起调出得了
就像送快递,把快递一个一个从转运中心送到客户家门口效率低得吓人,但把快递装成一车,就省得跑这么多路,从而提高了效率(虽然这个样子效率依旧感人)
这个时候,「缓冲区」这个概念应运而生。
它是在内存中开辟的一小块空间,用于暂时存放 CPU 计算完毕的数据,并在缓冲区满后通过系统调用把存放的数据一起给 I/O 设备。
按照「生产者-消费者问题」来看的话,我们可以把 CPU 比做生产者,把 I/O 设备比做消费者,数据比做商品。
那么缓冲区的意义,就在于把生产者和消费者解耦,让它们可以以各自的速度运行下去,避免一方被另一方的速度卡着的情况发生。
交互题的诡异函数:
我们知道,在交互题中,我们经常可以看到如下说法:
每次输出后不要忘记换行并刷新输出缓冲区,否则会收到 Idleness limit exceeded 的判罚。具体做法如下:
C++:fflush(stdout) 或 cout.flush()
Java:System.out.flush()
Pascal:flush(output)
Python:stdout.flush()
其它语言请查阅相关文档。
我们知其然,却不知其所以然。为什么我一定要清空缓冲区?我不清空怎么你了?
但如果我们知道了以上做法有什么用处,或许上面的问题就可以迎刃而解了。
让我们以 c++ 为例:
在默认情况下,c++ 标准输出(cout
)通常是行缓冲(在终端)或者是全缓冲(在重定向到文件时)的。行缓冲,指的是遇到换行符(\n
)或者是缓冲区满的时候清空一次缓冲区;全缓冲,指的是只在缓冲区满的时候清空一次缓冲区。
在 CSP、NOIP 一类需要提交文件的的比赛中或者是在洛谷、OpenJudge 等在线 OJ 中,由于程序运行时的输出被重定向到文件中,所以使用的缓冲方式是全缓冲,即在缓冲区满的时候清空才清空一次缓冲区。
众所周知,交互题具有非常强的实时性,几乎要求一个输入对应一个实时的输出。但是,从上文我们得知,由于缓冲区的存在以及全缓冲的使用,导致一大堆输出要等到缓冲区满之后才能被调到 I/O 设备进行操作。而这又违背了交互题的本质——交互:
程序等待评测机给出输入从而得以继续处理数据并最终输出,评测机等待程序的输出从而给出下一个输入。
这是什么?这是死锁!而这也是 Idleness limit exceeded 判罚的原因。
于是出题人处心积虑(?),从 iostream
库里翻出来这么一个函数—— cout.flush()
。
cout.flush()
的用处是清空缓冲区,此处的清空指的是强制进行系统调用把数据从缓冲区调出到 I/O 设备进行 I/O 操作。
这么一来,我们得以实现与评测机的实时交互,也从此出现了一种新题型:交互题。
不同输出方式的比较:
我们还是以 c++ 为例展开说明:
c++标准库提供了流操作符endl、flush和ends来刷新输出缓冲区。endl操作符输出换行符并刷新缓冲区,flush仅刷新缓冲区而不输出任何内容,而ends输出一个空字符后刷新缓冲区。
—— OI Wiki
由于 ends 在竞赛语境下不实用且不使用,本文略去。
同时,我们有两种主流的换行方式:其一,使用 endl
换行;其二,使用换行符 "\n"
换行;
由上可知,使用 endl
不仅换行,并且会清空缓冲区。而使用 "\n"
只会换行,并不会清空缓冲区。使用 cout.flush()
只会清空缓冲区,不会换行。
这里重点讲一下这三种方法的时间开销:
使用 endl
需要清空缓冲区,由上文可知,这个操作也可以看成要进行一次系统调换。但众所周知,进行系统调换,就意味着有巨大的时间开销,就意味着在有大量输出的情况下程序有 TLE 的风险。
使用 "\n"
不需要清空缓冲区,可以减少 I/O 负担,程序效率得到有效提高。
(关于它们效率的比较数据见 Q & As)
在竞赛里这似乎不用犹豫了直接无脑选择 "\n"
就行了。
顺带一提,由上文我们可知,清空缓冲区意味着程序运行到这一行的时候一定会进行 I/O 操作。那就意味着,要是我在之后的程序中有危险操作(比如说爆栈),我仍然可以得到这一行之前的输出。
意即,要是我们使用 endl
进行换行(或是直接使用 cout.flush()
),我们可以得到危险操作之前的输出;而要是使用 "\n"
进行换行的话,我们就不能得到之。
具体测试就不放出来了,有心的读者可以自行测试。
关于 ios::syuc_with_stdio(false) (本段需要结合计组知识食用)
使用 std::ios::sync_with_stdio(false) 函数来关闭与 C 流的同步。
对于基于流的 I/O(如 std::cin 与 std::cout),最常用的优化方法为关闭与 C 流的同步与解除输入输出流的关联。
——OI Wiki
还是众所周知,关闭同步流,即 ios::sync_with_stdio(false)
,可以极大地优化 c++ 标注输入(cin
)输出(cout
)的时间开销。
但是,我们还是知其然不知其所以然。本段将探讨其个中原理。
我们都知道,在 c++ 语境下, c 的标准输出(printf()
)和 c++ 的标准输出 (cout
)是共用一个缓冲区的。
c 和 c++ 的混合输出,实质上是在竞争使用 I/O 设备。由于 I/O 设备是处于临界区的资源,所以需要一个锁对其进行保护。我们使用一个实例进行讲解:
cout << "Hello World!\n";
printf("Hello World\n");
如上的代码块混合使用 c 和 c++ 的 I/O。 当执行到 cout << "Hello World!\n";
时,其需要获取 I/O 设备的锁;当执行到 printf("Hello World\n");
时,其也需要获取 I/O 设备的锁。程序执行的底层时间线如下:
[cout
获取锁] → [cout
输出] → [释放锁] → [printf
获取锁] →
[printf
输出] → [释放锁]
(在 [cout 输出],[释放锁],[printf 获取锁] 这三个步骤中,这段时间printf
在等待锁)
上述步骤中,时间开销的主要来源一般有两个:
第一,锁竞争的开销。在每一次 I/O 操作中,都需要进行「获取锁 → 检查 c/c++ 流的状态 → 协调缓冲区 → 实际输出操作 → 更新双方状态 → 释放锁」这一大串操作,时间开销自然增加。
第二,状态协调的开销。在每一次 I/O 操作中,也都需要进行「检查 c/c++ 流的状态 → 更新 c++/c 流的状态 → 维护缓冲区的一致性 → 处理错误状态同步」等操作,时间开销也会增加。
如果使用 ios::sync_with_stdio(false)
关闭同步流,把缓冲区一分为二,以上的操作通通都不用管—— c 和 c++ 流各自管理自己的缓冲区,无锁的竞争,无状态的协调,从而减少时间开销。
但,好处说完了,代价呢?
代价就是不能保证 c/c++ 流的同步。 由于 c/c++ 流的异步,会导致输入输出的乱序。
由于在终端中使用的是行缓冲,所以你的输出看上去没有什么问题(当然也有可能乱序)。但是,当我们把代码提交的时候,由于输出被重定向到文件,使用的是全缓冲,有时可能会捕获不到另一个流的输出,导致有些本应有的输出没有了(是的,本人就是因为这一点被卡过一个晚上)
Q & As:
- Q :那既然系统调用的速度比 I/O 设备还要慢,那为什么还要设计缓冲区呢?下一个数据从 CPU 调到 I/O 设备的时间已经足够 I/O 设备完成上一个数据的 I/O 操作了啊?设计缓冲区岂不是降低了 I/O 设备的利用率?
A:从整体上考量,如果没有缓冲区的话,那么 CPU 要等待系统调用完毕才能进行下一次计算。而这,又降低了 CPU 的利用率。我们引进缓冲区,是希望 CPU 和 I/O 设备能够并行处理。这样虽然看似降低了 I/O 设备的利用率,实则不然,只是把原先串行的 I/O 操作转化为了并行的 I/O 操作,从整体上考量的话,这种方案是更优的。 - Q:你说的数据比较呢我怎么没见到?
A:由于我一上来就搞了四千万行的输出,加诸我的电脑太烂了QWQ,最后只找到一组不完整的数据QWQ。源码如下,有心的读者可以自行测试。(感谢 A1234467提供的代码,本人稍作修改)
#include<stdio.h>
#include<time.h>
#include<conio.h>
#include<windows.h>
#include<iostream> int main() {
const int times = 10000000;
system("cls");
freopen("test.txt", "w", stdout);
clock_t c1 = clock();
for (int i = 0; i < times; i++) std::cout << std::endl;
clock_t c2 = clock();
for (int i = 0; i < times; i++) std::cout << "\n";
clock_t c3 = clock();
for (int i = 0; i < times; i++) printf("\n");
clock_t c4 = clock();
//for (int i = 0; i < times; i++)
//clock_t c5 = clock();
system("cls");
std::cout << "write times:%d\n" << times;
std::cout << double(c2 - c1) / CLOCKS_PER_SEC << double(c3 - c2) / CLOCKS_PER_SEC << double(c4 - c3) / CLOCKS_PER_SEC;
std::ios::sync_with_stdio(false);
std::cin.tie(0);
clock_t c5 = clock();
for (int i = 0; i < times; i++) std::cout << std::endl;
clock_t c6 = clock();
std::cout << double(c6 - c5) / CLOCKS_PER_SEC << "s" << std::endl;
return 0;
}