C/C++中非线程安全的文件I/O

6
在调试我们应用程序的一些性能问题时,我发现 C 的 stdio.h 函数(至少对于我们的供应商来说,C++ 的 fstream 类)是线程安全的。因此,每次执行像 fgetc 这样简单的操作时,RTL 都必须获取锁、读取一个字节并释放锁。

这对性能不利。

如何在 C 和 C++ 中获得非线程安全的文件 I/O,以便我可以自己管理锁并获得更好的性能?

  • MSVC提供_fputc_nolock,而GCC提供unlocked_stdioflockfile,但我在我的编译器(CodeGear C++Builder)中找不到任何类似的函数。
  • 我可以使用原始的Windows API,但那不具备可移植性,而且我认为对于逐个字符的I/O来说会比未锁定的fgetc慢。
  • 我可以切换到类似于Apache Portable Runtime的东西,但那可能需要很多工作。

其他人如何解决这个问题?

编辑:由于有些人想知道,我在发布之前已经测试过这个。如果fgetc可以从其缓冲区中满足读取请求,则不会进行系统调用,但它仍然会进行锁定,因此锁定最终占用了大量时间(为了从磁盘读取单个数据块,需要获取和释放数百个锁)。不进行逐字符I/O将是一种解决方案,但是C++Builder的fstream类不幸使用了fgetc(因此,如果我要使用iostream类,我就必须使用它),而且我有很多使用fgetc等函数从记录式文件中读取字段的旧代码(如果没有锁定问题,这将是合理的)。

C的stdio.h函数不是线程安全的;这也是你的供应商的问题。 - MSalters
不仅是我的供应商;例如,POSIX也需要它。 - Josh Kelley
6个回答

4

如果在性能上合理的话,我不会逐个字符地进行IO操作。


所有与流相关的操作都会导致字符IO。您必须使用流缓冲区进行操作。我已经在下面发布了如何... - ovanes

1

多平台方法非常简单。避免使用标准规定应该使用sentry的函数或运算符。sentry是iostream类中的内部类,它确保每个输出字符的流一致性,并在多线程环境中为每个输出字符锁定与流相关的互斥量。这避免了低级别的竞争条件,但仍使输出不可读,因为来自两个线程的字符串可能会同时输出,如下面的示例所示:

线程1应写入:abc
线程2应写入:def

输出可能看起来像:adebcf而不是abcdef或defabc。这是因为sentry被实现为每个字符锁定和解锁。

标准将其定义为所有处理istream或ostream的函数和运算符。避免这种情况的唯一方法是使用流缓冲区和自己的锁定(例如每个字符串)。

我编写了一个应用程序,将一些数据输出到文件并测量速度。如果您在此处添加一个直接使用fstream输出而不使用缓冲区和刷新的函数,则会看到速度差异。它使用boost,但我希望这对您不是问题。尝试删除所有流缓冲区,并查看有无它们的差异。在我的情况下,性能损失约为2-3倍。

以下文章由N. Myers撰写,将解释C++ IOStreams中的本地化和sentry的工作原理。当然,您应该查阅ISO C++标准文档,了解哪些函数使用sentry。

祝好运,
Ovanes

#include <vector>
#include <fstream>
#include <iterator>
#include <algorithm>
#include <iostream>
#include <cassert>
#include <cstdlib>

#include <boost/progress.hpp>
#include <boost/shared_ptr.hpp>

double do_copy_via_streambuf()
{
  const size_t len = 1024*2048;
  const size_t factor = 5;
  ::std::vector<char> data(len, 1);

  std::vector<char> buffer(len*factor, 0);

  ::std::ofstream
    ofs("test.dat", ::std::ios_base::binary|::std::ios_base::out);
  noskipws(ofs);

  std::streambuf* rdbuf = ofs.rdbuf()->pubsetbuf(&buffer[0], buffer.size());

  ::std::ostreambuf_iterator<char> oi(rdbuf);

  boost::progress_timer pt;

  for(size_t i=1; i<=250; ++i)
  {
    ::std::copy(data.begin(), data.end(), oi);
    if(0==i%factor)
      rdbuf->pubsync();
  }

  ofs.flush();
  double rate = 500 / pt.elapsed();
  std::cout << rate << std::endl;
  return rate;
}

void count_avarage(const char* op_name, double (*fct)())
{
    double av_rate=0;
    const size_t repeat = 1;
    std::cout << "doing " << op_name << std::endl;
    for(size_t i=0; i<repeat; ++i)
        av_rate+=fct();

    std::cout << "average rate for " << op_name << ": " << av_rate/repeat 
            << "\n\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\n\n"
            << std::endl;
}


int main()
{
    count_avarage("copy via streambuf iterator", do_copy_via_streambuf);
    return 0;
}

1

fgetc 函数每次调用几乎肯定不会读取一个字节(所谓“读取”,是指调用系统调用执行 I/O 操作)。请在其他地方寻找您的性能瓶颈,因为这可能不是问题,使用不安全的函数肯定不是解决方案。您进行的任何锁处理可能都比标准例程处理的效率低。


1
它不是每次读取一个字节,但每次都可以很好地获取锁。顺便说一下,在POSIX下获取锁是强制性的,有getc_unlocked()(以及char by char IO函数的_unlocked变体和锁定函数,以便它们受到保护)。 - AProgrammer

1
最简单的方法是将整个文件读入内存,然后提供自己的类似 fgetc 的接口来访问该缓冲区。

1
为什么不只是内存映射文件呢?内存映射是可移植的(除了在 Windows Vista 中,现在需要你跳过一些东西来使用它,蠢货)。无论如何,将文件映射到内存中,并在生成的内存位置上执行自己的锁定/非锁定操作。
操作系统处理所有必要的锁定以实际从磁盘中读取-你永远不会能够消除这种开销。但是,另一方面,你的处理开销不会受到额外的锁定的影响,除了你自己执行的锁定之外。

1

考虑构建自定义运行时是一件值得考虑的事情。大多数编译器都提供了运行时库的源代码(如果C++ Builder包中没有,我会感到惊讶)。

这可能需要很多工作,但也许他们已经将线程支持本地化,使得像这样的事情变得容易。例如,我正在使用的嵌入式系统编译器就是为此而设计的 - 他们记录了添加锁例程的钩子。然而,即使最初相对容易,这可能会成为维护上的头疼。

另一个类似的方法是与Dinkumware这样的人交谈,了解使用提供所需功能的第三方运行时的情况。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接