本文针对的对象是CPer(competitive programmer),对于熟悉C++的同学来说以下内容是非常基础的。
算法竞赛选手在使用cin
、cout
进行输入输出的时候,为了防止读写被卡常,往往会加上以下两行优化:
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
大家都知道这两行代码写完以后使用cin
和cout
进行读写,效率就跟使用scanf
和printf
相差无几。但,为什么呢?
- 以及,既然可以通过这种方式几乎抹平
cin
、cout
和scanf
、printf
的效率差距,那么为什么C++要默认一种开销更大的方式呢? - 在没有写这两行代码的时候,使用
cin
和cout
进行输入输出时进行了什么额外的工作? - 这两行代码又具体实现了什么呢?
以下我们逐步解释这些问题。
01 缓冲区
首先要理解的是缓冲区的概念。缓冲区对输入输出非常重要。
首先我们要知道,输入输出操作相比于算术或逻辑运算,时间开销是非常大的。原因在于此类操作并不是CPU的局部可以完成的,无论是向打印机、屏幕还是硬盘输出文字,都需要通过对应的总线访问去实现。这就好像是邻居之间串门和跨省旅游之间的区别。
例如以下这段代码:
cout<<1+2;
cout<<2+3;
cout<<3+4;
它最大的时间开销完全不在算术运算上,而是在通过cout
流将运算结果输出到屏幕上。
因此,我们就要想办法优化这种情况。
硬件上如何加速不归我们管,我们只考虑代码层面是否有可以优化的地方。这里就要用到一个性质——有很多输入输出操作对实时性的要求是很低的。原因在于这些操作的交互对象是人类,例如屏幕的输出、文件的输出等。此类操作具有一定的延迟,人类是无法察觉的,也并不在乎。(当然,输出信息到供其他程序使用的文件或共享池中的情形,就不在此列了)
那么,我们就可以引入一个思想:
既然向外设写入开销很大,那么我们就减少这种写入的次数。
具体方法来说,我们可以设立一个内存池,将要输出的东西全部存起来,等到合适的时机再输出。
例如,上述代码改写成
a=1+2; write_to_device(a);
a=2+3; write_to_device(a);
a=3+4; write_to_device(a);
的话,需要3次总线访问,那么设立一个缓冲区:
代码就变成了:
int buffer[n];
a=1+2; buffer[++len] = a;
a=2+3; buffer[++len] = a;
a=3+4; buffer[++len] = a;
write_to_device(buffer);
只需要一次总线访问。单次write_to_device()
开销对于操作数大小是不敏感的,所以这段代码可以几乎将时间缩减为原先的
对于C++输出流cout
来说,实际上使用的是flush()
来进行write_to_device()
这种将缓冲区内容输出至对应设备的操作——并称之为刷新。对于C标准输出流stdout
来说,对应的做法是fflush(stdout)
。
02 刷新的时机
我们知道,将越多次输出内容暂存到缓冲区内,就可以减少越多不必要的总线访问,提升程序效率。那么这种行为是可以无止境做下去的么?显然不行。接下来我们就需要探讨,什么时候必须进行缓冲区刷新。
0201 缓冲区满
显然,缓冲区的大小不能是无限的,否则一方面会增大不必要的内存开销,另外会导致一些情况下的风险增大。
例如,当我们有大量数据要输出时:
for (int i = 1; i <= 1000000; i++)
cout<<a[i];
此时,如果程序意外中断,则有大量原本应被记录的数据丢失。而即使不出现这种情况,内存负担也是很大的。
综合以上考量,我们应当设计一个不过分大的缓冲区大小,然后在缓冲区满时输出。
(大概这样.jpg
(没什么卵用的)新黑科技——缓冲区大小
那么,缓冲区大小这个参数,在使用stdin
和stdout
时(它的含义我们会在后面提到),也会影响到程序输入输出的效率——这很显然。如果我们知道一种更合适的大小,那么可以通过以下方式手动指定它,以提高输入输出效率:
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IOFBF, 100000);
以上代码的含义是,使stdin
无缓冲(不经过缓冲区),且设定其缓冲区大小为0;使stdout
全缓冲(缓冲区满或手动操作时再刷新),缓冲区大小为100000——具体情形下的具体数值,有待实验。
其中,_IONBF
、_IOLBF
、_IOFBF
分别指代无缓冲、行缓冲(遇到换行则刷新)、全缓冲。
0202 互动
另一个,也是最重要的刷新缓冲区的动机就是互动需求。“缓冲区”之所以能够成立,就是因为有些输出在当时不会被立刻使用。例如输出到硬盘的数据,显示在屏幕上的数据(人眼无法分辨若干条指令之间的间隙)。
但当输出的数据立刻就要被其他主体使用的时候,这种及时性需求就使得程序输出不能再依赖缓冲,而是要尽最快速度输出到对应设备。例如交互题中总有这样的说法:
就是因为交互器需要得到你的输出之后才能做出对应反应。此时我们是无法利用缓冲区的。
接下来,我们看一段正常的人机交互代码:
int main()
{
string username, password;
cout<<"Please enter your name: "; //24 chars
cin>>username;
cout<<"Please enter your password: "; //28 chars
cin>>password;
......
}
此时我们假设缓冲区大小为30byte,也就是可以容纳30个char,没有额外的输出缓冲刷新机制,那么会发生什么呢?
- 空命令行等待用户输入,用户一脸懵逼
- 用户随意输入什么,回车
- 界面显示“Please enter your name:”,但username已经被赋值为刚才用户随意输入的内容。
- 同时,下面紧接着显示“Please enter your password:”
- 用户一脸懵逼。
这说明了一个重要的事实——在用户应该意识到当前程序的期望进展时,输出应当和它被期待的表现一致。
之前我们可以利用缓冲区延迟输出,是凭借着程序运行有着远大于人眼所能识别的速度这一条件。那么,当程序以中断的形式将时间交给用户的时候,所有缓冲下来的输出必须送到它们应有的位置。
那么,我们应该能想到,像是cin
这种将程序中断给用户的行为,应当在进行前刷新输出缓冲区——这是非常自然的做法。
事实上,C++的实现也的确是如此做的——方法是:将cin
流与cout
流(的缓冲区)绑定,在调用前者前刷新后者:
所以,我们就知道了
cin.tie(nullptr);
的含义了——解除cin
和cout
的绑定。它所消除掉的,是使用cin
读入前刷新输出缓冲区的开销。
0203 适宜的时机
其他规定刷新缓冲区的时机,主要是一些约定俗成的原理了,主要是为了贴合人类的理解。这类刷新更类似于程序员在认为合适的时候手动调用了flush()
。这里,主要指的是endl
。
endl
和'\n'
同样是输出换行,但endl
会刷新缓冲,而'\n'
只是单纯的换行,这主要是人为原因:
- 如字面所见,
'\n'
只是一个单纯的符号而已
注意,无论任何标准与实现中,均未规定'\n'
与刷新缓冲区有任何特定联系
endl
包含了“本行已经结束”的语义,提示“应该把本行内容给用户看了”
这也就是为什么有这行火车头的原因:
#define endl '\n'
另一种典型的情况是,错误流cerr
是无缓冲的,因为错误信息应当在发生错误时立刻准确反馈给用户,不适用缓冲。
03 C流与C++流
还有一点需要知道的是,C和C++输入输出所使用的环境是不同的,虽然同样是向屏幕输出,但C环境使用的是stdin
和stdout
两个流,而C++使用的是cin
和cout
两个流(当然,这两个环境都还有其他流)。
而这两个环境中的流是什么关系呢?见下图:
可以看到,默认情况下,C流和C++流是直接关联的:
这样,通过C流的scanf
和printf
和通过C++流的cin
和cout
,虽然使用不同的对象进行操作,但是由于路径是相同的,所以不必担心输出混乱。
而我们注意到,如果我们只使用C++流的话,再通过C流的buffer去“桥接”一遍,开销很大。那么我们能否断开这种连接,让C++流直接与对应ABI相连呢?
答案就是我们的优化头的第一句:
ios_base::sync_with_stdio(false);
现在再来考虑它为何能够提速?重新画图一目了然:
此时C++流“饶了近路”。
那么,关闭流同步后再同时使用两种输入输出方式,因为它们各自有自己的缓冲机制,输入输出错乱也就可以理解了。
- 如果既要同时使用,又要关闭同步,也是可以的——但那势必要设置二者同时无缓冲,那就与我们的初衷背道而驰了。
为加深理解,读者可以自行推理一个问题,以下两行代码,是否有依赖关系?
ios_base::sync_with_stdio(false);
cin.tie(nullptr);
答案是:有。
第一行代码单独存在有用,第二行代码单独存在没用。
原因通过上图很好理解:C++流与C流同步时,不自主管理缓冲区,缓冲区是由stdin
和stdout
管理的。只有关闭同步时,C++的流自行管理其缓冲区,此时才会有“cin
先刷新cout
缓冲区再读取”的现象,此时取消这种行为才有意义。
04 一些小细节
- 虽然没有标准没有约束,但有时
'\n'
确实会导致刷新缓冲区。这是由于stdout
存在一种“行缓冲” 模式导致的,而存在这样一件事:image.png ,所以不同环境下会出现针对这一情景的不同行为。但因为它不是标准约定,所以不要依赖这种实现! - C++流是如何保证状态和缓冲区终末刷新的?
#include<iostream>
会初始化其中的静态对象cin
和cout
,它们可以保有状态。它们析构于程序终止时,此时会最后清空缓冲区。
sync_with_stdio()
调用前,IO流上不能有任何操作。- ……想起来再说
05 总结
看完以上内容我们可以知道,C++中所有开销巨大的设计(除了unordered_map
和unordered_set
!)都有它存在的道理。而在CP中我们采取一些手段消除这些开销也是有道理的:因为我们的代码面向的是judger和checker,而并非人类,所以不需要考虑可读性和鲁棒性。因此,我们可以通过去除那些提高程序鲁棒性的代码来提高效率。这是CP火车头(包括快读在内)的核心原理。
此时回顾文首的几个问题,相信大家可以自行回答了。
参考文献
[1] https://stackoverflow.com/questions/213907/stdendl-vs-n
以及各种来自cppreference的资料、现行C、C++标准。