被NTFS的bug困扰了一个多星期

  这事情还得从上上星期说起。

 

  最近在跑一个自己写的数据挖掘程序,这程序要跑上两三天,期间要同时向磁盘输出一个220多GB的二进制文件(以下称文件1)和一个450多GB的二进制文件(以下称文件2)。我上上周三就把程序写好了,用的是std::ofstream。

 

  程序跑了两天,到了上上周五早上,我欢欢喜喜地去上班,准备收结果。谁知到了办公楼下,在配电箱上卖力捣腾的电工扭头看见我,对我说:“停电了。”——这段时间单位在进行电路的整改,不过停电还真是我没料到的事。于是茫然地跑进办公室,发现除了我自己写的程序外,电脑上的所有程序所有窗口居然还在运行着!根据运行日志,我的程序是在64%进度因为对文件1的ofstream::write操作出错而停下的

 

  大惑不解——我的办公室到底有没有停电?莫非刚才只停电了一秒不到,而我的外星人台式机强劲到竟能在短暂瞬间的停电下继续工作,只是密集的磁盘操作会受影响?

 

  正准备再花两三天时间跑一次程序。谁知朱老板实验室那边传来消息,说是我写的另一个软件——GMDR-GPU在分析一个大数据时崩溃,而重现错误似乎也要一两天时间。无奈,只有先暂停这边的程序,放下这边的工作,双休日加班,处理朱老板那边遇到的bug。

 

  还好到了上周一,朱老板那边的bug也修复了。于是重新开始跑程序。这回我跟电工打了招呼,要他别停我房间的电。谁知到了周三,在电脑前吃早餐的时候,听见机箱风扇高速飞转的声音突然消失。一颗心马上提起来,立即给屏幕解锁进入系统。果然,我的程序又出错了——这回是在63%进度对文件2的ofstream::write操作出错。

 

  怎么回事?我再次困惑了。几秒钟之后,我听见中央空调“嘀”地一声响——原来电工又在对配电箱进行开闸合闸的操作。这时一个念头闪过:会不会是电压不稳对密集的磁盘操作有影响?于是问电工:刚才的电压会不会不稳?电工说:中央空调的启动和关闭可能会使电压稍微有些波动,但一般情况下电压应该是稳的。

 

  文件大小有限制?我之前查过的,NTFS支持的文件大小远远超过4GB,而且我这是64位程序;况且如果是文件大小限制在4GB的话,应该1%不到就出错了,程序运行到64%的话都已经写了一两百GB了。不过为了确认,我还是写了个小程序,将700GB的double输出到一个文件中。结果证实了我的想法:程序运行了约两个小时,成功输出了700GB的文件。我也写了另外一个小程序,读这700GB的文件,确认了里面的内容是正确的。

 

  于是只好准备又将程序重新运行一遍——没办法的办法。这时已经是上周三。不过在此之前得对程序做些改动,想办法让它在出错的情况下能输出更详细的错误信息。记得C++的流对象有几个成员,好像是bad()和fail()什么的,可以用来判断流对象当前的有效状态。于是查到了basic_ios类。ofstream继承ostream,ostream是basic_ostream模板的特化,basic_ostream继承basic_ios。basic_ios有三个成员函数可以判断流对象当前的有效状态:

    • bad():Indicates a loss of integrity of the stream buffer.
    • fail(): Indicates failure to extract a valid field from a stream.

    • good(): Indicates the stream is in good condition.

  看来fail()只会在输入流上返回真,而good就是!(bad || fail)。我的程序出错时ofstream肯定是处于bad状态,可这并不能告诉我多少有用的信息。我需要知道更具体的出错原因。

 

  于是上网搜索,看看有没有其他人用ofstream::write时同样出错,看看有没有办法输出更详细的出错原因。然后得知可以用C语言的errno。于是赶紧在程序中加入对errno的输出,重新跑。

 

  我心里面一直嘀咕着:如果是程序本身的错误,那会是什么错误呢?单元测试都通过了;内存访问异常也不会这么报错;磁盘空间也够的。会不会是程序的其它地方出现了内存访问越界,正好访问破坏了ofstream对象?真要是那样的话,这bug可就太难定位了。这是我第一次碰到写文件错误,还是这么棘手的错误。

 

  趁着跑程序的空档,我在CSDN上发帖求助:http://topic.csdn.net/u/20120425/18/10717c3f-f787-4e65-8429-65c1aa1c96d5.html。在Stack Overflow上也发帖求助:http://stackoverflow.com/questions/10314949/ofstreamwrite-fails-in-the-middle-when-writing-large-binary-files。两个帖子逐渐得到回复,然而大家也不太清楚是怎么回事。有人建议用本地API,这倒是我早就想到的。但我的程序有跨平台要求,过一段时间还要在Linux下运行,可能的话还是想尽量用标准C++。不过CSDN网友ri_aje说:“既然你说写单个文件没问题,那要是把两个文件分开写呢,我是说不要同时写。”这倒是我没想到的。唔,如果这第三遍跑还出错的话,那就采纳ri_aje的建议。不过我想写完一个文件再写另一个文件肯定是没问题的,而且由于算法和计算资源的限制,我的实际程序必须要同时写两个文件。所以届时我打算单独写一个小程序,同时写两个文件。如果这个小程序出现同样错误的话,那就可以排除实际程序中其它地方出错的可能性了。

 

  结果这回正在跑第三遍的程序真的又出错了!而且这回才运行到55%就出错,并且仍然是在文件2上调用ofstream::write出错。errno给出的错误状态码是EINVAL: Invalid argument。

 

  于是马上写了一个类似下面的小程序,同时写两个文件——一个220GB左右,另一个450GB左右:

 

#include <fstream>
#include <iostream>

using namespace std;

int _tmain(int argc, _TCHAR *argv[])
{
    const TCHAR *fileName1 = _T("file1.bin"),
                 *fileName2 = _T("file2.bin");

    const unsigned __int64 numElems = 61250000000;

    ofstream file1(fileName1, ios::binary);
    if (!file1)
    {
        cerr << "Failed to create file " << fileName1 << endl;
        exit(1);
    }

     ofstream file2(fileName2, ios::binary);
     if (!file2)
     {
         cerr << "Failed to create file " << fileName2 << endl;
         exit(1);
     }

     for (unsigned __int64 cnt = 0; numElems > cnt; ++cnt)
     {
         const void *pV = 0;

         const float valueFloat = static_cast<float>(cnt);
         pV = &valueFloat;
         if(!file1.write(static_cast<const char *>(pV), sizeof(valueFloat)))
         {
             cerr << "errno: " << errno << ": " << strerror(errno) << endl;
            cerr << "Write error on file1!" << endl;
            exit(1);
        }

        const double valueDouble = static_cast<double>(cnt);
        pV = &valueDouble;
        if(!file2.write(static_cast<const char *>(pV), sizeof(valueDouble)))
        {
            cerr << "errno: " << errno << ": " << strerror(errno) << endl;
            cerr << "Write error on file2!" << endl;
            exit(1);
        }
    }

    cout << "Done." << endl;
    return 0;
}

  

 

   结果经过5个多小时,这个程序运行到64%进度的时候,出现了同样的错误!(题外话,这里我注意到一个细节:同样是用ofstream向磁盘输出700GB的文件,输出成单个文件只需要2小时;而输出成两个文件,运行到64%却经过了5个多小时,也就是说如果运行成功的话需要10个多小时。)

 

  这么说肯定是程序本身的bug了,不过幸运的是,可以排除是程序其它地方出错了。

 

  Invalid argument,无效的参数。可是我盯着程序中调用ofstream::write的地方,盯了很久,非常确定传给ofstream::write的参数是合法的。不过errno是C语言的机制,只有当C语言库函数出错时,errno才会被设置。ostream::write很可能是调用了C标准库的fwrite,可能是调用fwrite时传入了非法的参数。这么说这很可能是微软的STL实现中的bug。

 

  于是跑到Visual Studio的安装目录里,将fstream、ostream等各个头文件打开来研究。C++模板的一个额外好处在这里便体现出来了——要想将模板给人用的话,必须给源代码大笑。果然,ofstream::write在多处调用了fwrite,这也是ofstream::write唯一会调用的C标准库函数。不过我也盯着那些fwrite看了很久,觉得传入那些fwrite的参数也不太可能是非法的。为此我甚至还修改了fstream文件,在其中一处唯一让我有一点点怀疑的fwrite的出错处理语句中加入了自己的打印语句,然后将小程序又运行了一遍。结果小程序并没有执行我的打印语句,这证明ofstream::write对fwrite的各个调用也没有传入非法参数。

 

  那到底是咋回事呢?先不管三七二十一,我觉得八成应该是微软STL实现的bug,于是向微软提交了一份bug报告:https://connect.microsoft.com/VisualStudio/feedback/details/739637/ofstream-write-succeeds-when-writing-a-huge-file-but-fails-when-writing-two-smaller-files#details,然后继续研究,心想微软对标准的实现果然是不给力么。

 

  现在只有改用Win32本地API——CreateFile和WriteFile了。于是花了一些时间改动那个小程序。不过改好之后,刚一运行,就发现程序的速度慢了很多很多:运行到1%就需要半小时,这么说全部运行完还得2天多!我想是因为我没有在程序中设计缓存,每次调用WriteFile的时候只是写一个float或一个double,所以调用了很多次WriteFile;而每次调用WriteFile时,程序都要在用户态和内核态之间切换;另外磁盘的数据传输能力也没有被充分发挥,这当然慢了。于是再次改动小程序,设计缓存,每次写16KB。这下小程序快多了,而且用2小时就运行到了50%,也就是说比用ofstream的版本快上了一倍不止。

 

  这次,小程序成功运行到了100%,将两个文件输出完毕。

 

  

  唔——看来应该就是微软的ofstream::write实现有bug了!不过我读ofstream::write的代码读了半天也没发现bug,还是让微软的专家们自己找去吧。

 

  那么要不要在实际的程序中用CreateFile和WriteFile呢?我衡量了一下:不如试验一下直接用FILE指针、fopen和fwrite。如果C标准库函数没问题的话,还是用C标准库函数的好,这样可移植性好。我想C标准库函数应该没问题。于是又花了点时间,把小程序改成用FILE *、fopen和fwrite。由于之前用CreateFile和WriteFile的过程也让我想起了Win32的GetLastError()函数,因此这回我在小程序里也加入了出错时对GetLastError()函数的调用——毕竟Win32的错误代码比errno给出的信息要详细多了。另外我还加入了对ferror()的调用。

 

  谁知结果大大出乎我的意料:这回程序又在64%出错了!errno仍然是EINVAL: Invalid argument;ferror()返回的是EPIPE:Broken pipe;GetLastError()返回的是665:ERROR_FILE_SYSTEM_LIMITATION:The requested operation could not be completed due to a file system limitation。

 

  ferror()的信息没什么用处,估计意思是FILE结构体损坏了。还是将GetLastError()的返回值ERROR_FILE_SYSTEM_LIMITATION作为线索,在网上搜索吧。

 

  结果正是这个线索让我找到了错误的真正原因!原来这bug既不是藏在我的程序上,也不是藏在微软的C标准库或C++标准库实现中,而是藏在了NTFS中:http://support.microsoft.com/default.aspx?scid=kb;EN-US;967351。如果程序同时向磁盘写两个大文件,那这两个文件就会被分割成大量的碎片;而在NTFS中,如果一个文件被分割成大量碎片,这个文件的大小就会受到限制。以下是微软帮助和支持给出的技术解释(见刚才给出的网址):

 

When a file is very fragmented, NTFS uses more space to save the description of the allocations that is associated with the fragments. The allocation information is stored in one or more file records. When the allocation information is stored in multiple file records, another structure, known as the ATTRIBUTE_LIST, stores information about those file records. The number of ATTRIBUTE_LIST_ENTRY structures that the file can have is limited.

 

  翻译(微软帮助和支持的机器翻译真没法看):

 

  当一个文件被分割成大量碎片时,NTFS就需要用更多的空间来存储这些碎片在硬盘中的分配信息。这些分配信息被存储为一个或多个文件记录。当这些分配信息被存储为多个文件记录时,NTFS就会用另一个数据结构——ATTRIBUTE_LIST来存储这些文件记录的相关信息,而一个文件能拥有的ATTRIBUTE_LIST_ENTRY结构体的数量是有限的。

 

  我下载了Contig工具来查看那两个才写了一半的文件。果然,文件中的碎片数量已经达到了几百万之多!

 

  于是按照微软帮助和支持的说明,我下载安装了补丁,还下载安装了商业软件Diskeeper的试用版。打了补丁后还得将800多GB的E盘格式化,我勒了个去……格式化后,我先把Diskeeper开了起来,然后运行用fwrite的小程序。哇塞,速度比原来快了一倍多,Diskeeper真是给力——我后来试了一下不开Diskeeper,速度跟一开头没打补丁时是一样的。当然,重要的是——经过5个小时,小程序成功运行完毕了!再用Contig查看一下两个文件,碎片数量才几万。

 

  既然fwrite成功了,那么ofstream应该也能行;而且既然微软已经发步了解决这个问题的补丁,那么不开Diskeeper应该也行。于是我又换回了用ofstream的版本。结果没想到不开Diskeeper的ofstream比开了Diskeeper的fwrite还快——3小时就成功运行完毕了。看来ofstream对缓存的管理比fwrite要好么。再用Contig查看,碎片数量果然又恢复成几百万了。

 

  于是,终于解决了这个困扰我一个多星期的问题。这时已经是5月1日,也就是昨天了。

 

总结

 

  1. 写程序的时候,如果要对调用错误进行检查并输出错误信息,那么要利用库和系统自身设计的错误信息机制,如C标准库的errno和Windows系统的GetLastError()。这些信息会对查错提供很大的帮助。如果我一开始就用了errno和GetLastError(),那就能早几天解决这个问题了。

  2. 如果要往磁盘写大量的数据,尽量将这些数据合并成一个文件写,而不是分开多个文件写。这样一来可以减少文件碎片,二来可以加快写文件的速度。

你可能感兴趣的:(File,微软,磁盘,Allocation)