【问题标题】:How to implement Copy-on-Write?如何实现写时复制?
【发布时间】:2010-12-11 13:34:11
【问题描述】:

我想在我的自定义 C++ String 类上实现写入时复制,我想知道如何...

我尝试实现一些选项,但结果都非常低效。

谢谢你们 :-)

【问题讨论】:

  • 你的内存分配策略是什么?您可能希望依靠池分配来获得更好的性能。
  • 我希望这只是为了学习。要让它在所有情况下都能正常工作,有很多陷阱。
  • 仅供学习之用..

标签: c++ string copy-on-write


【解决方案1】:

在多线程环境中(现在大多数情况下),CoW 通常对性能造成巨大影响,而不是获得收益。并且仔细使用 const 引用,即使在单线程环境中也不会带来太大的性能提升。

这篇旧 DDJ 文章解释了just how bad CoW can be in a multithreaded environment, even if there's only one thread

另外,正如其他人所指出的,CoW 字符串实现起来确实很棘手,而且很容易出错。再加上它们在线程情况下的糟糕表现让我真的怀疑它们的总体用途。一旦您开始使用 C++11 移动构造和移动赋值,这将变得更加真实。

但是,回答你的问题....

这里有一些可能有助于提高性能的实现技术。

首先,将长度存储在字符串本身中。长度被非常频繁地访问,并且消除指针取消引用可能会有所帮助。我会,只是为了保持一致性,把分配的长度也放在那里。就你的字符串对象而言,这会让你付出更大的代价,但空间和复制时间的开销非常小,特别是因为这些值将变得更容易让编译器发挥有趣的优化技巧。

这会给你一个字符串类,如下所示:

class MyString {
   ...
 private:
   class Buf {
      ...
    private:
      ::std::size_t refct_;
      char *data_;
   };

   ::std::size_t len_;
   ::std::size_t alloclen_;
   Buf *data_;
};

现在,您可以执行进一步的优化。那里的 Buf 类看起来并没有真正包含或做很多事情,这是真的。此外,它需要同时分配一个 Buf 实例和一个缓冲区来保存字符。这似乎相当浪费。因此,我们将转向一种常见的 C 实现技术,即弹性缓冲区:

class MyString {
   ...
 private:
   struct Buf {
      ::std::size_t refct_;
      char data_[1];
   };

   void resizeBufTo(::std::size_t newsize);
   void dereferenceBuf();

   ::std::size_t len_;
   ::std::size_t alloclen_;
   Buf *data_;
};

void MyString::resizeBufTo(::std::size_t newsize)
{
   assert((data_ == 0) || (data_->refct_ == 1));
   if (newsize != 0) {
      // Yes, I'm using C's allocation functions on purpose.
      // C++'s new is a poor match for stretchy buffers.
      Buf *newbuf = ::std::realloc(data_, sizeof(*newbuf) + (newsize - 1));
      if (newbuf == 0) {
         throw ::std::bad_alloc();
      } else {
         data_ = newbuf_;
      }
   } else { // newsize is 0
      if (data_ != 0) {
         ::std::free(data_);
         data_ = 0;
      }
   }
   alloclen_ = newsize;
}

当您这样做时,您可以将 data_->data_ 视为包含 alloclen_ 字节,而不仅仅是 1。

请记住,在所有这些情况下,您都必须确保永远不会在多线程环境中使用它,或者确保 refct_ 是一种您同时拥有原子的类型增量,以及原子减量和测试指令。

还有一种更高级的优化技术,它涉及使用联合将短字符串存储在用于描述较长字符串的数据位中。但这更复杂,我不认为我会倾向于编辑它以便稍后在此处放置一个简化的示例,但你永远无法判断。

【讨论】:

  • 有该分析的链接吗?我听说过类似的说法,我一直想知道细节。
  • 我认为 CoW 对任何平台上的程序员生产力都有巨大的好处。您不再需要处理显式共享、处理引用、确保在需要时复制。几乎所有现代平台都支持快速原子操作,而 CoW 比每次都进行深度复制便宜得多(正如 Herb 所愿)。您对性能不佳的论点在 IMO 中没有实际意义。
  • @iconiK,给我看数字,我们会谈谈。我的论点是基于经验测试得出的实际数字,而不是对原子操作的速度和深拷贝更昂贵的大胆断言。原子操作需要内存屏障来实现,而这些可能非常昂贵。在我改变立场之前,我希望看到数据表明深度复制比原子引用计数更昂贵。
  • 据我所知,提到的写时复制实现通过检查当前是否存在对字符串的确切引用来工作,这是一项具有许多错综复杂的操作。如果包装器不必是线程安全的,但包装的字符串可以,那么让每个包装器保存一个指向字符串的可变版本的指针和一个指向不可变版本的指针会产生什么影响,前提是要么完全一个指针是非空的,还是指向相同的字符串?可变指针,如果非空,将是任何地方对字符串的唯一引用...
  • ...而不可变指针永远不会以允许写入的方式使用。为了克隆一个 Immutable 指针为 null 的字符串,AtomicExchange 将 Mutable 指针与 null 并将其存储在 Immutable 指针中。否则只需复制不可变指针。要改变 Mutable 指针为 null 的字符串,请在 MutablePointer 中存储对 ImmutablePointer 中字符串副本的引用。然后,为了改变 Mutable 指针(可能是新的)非 null 的字符串,AtomicExchange 将 Mutable 指针更改为 null,进行更改,将指针设置回去,并使 Immutable 指针无效。
【解决方案2】:

我建议,如果想要有效地实现写时复制(对于字符串或其他),应该定义一个包装器类型,该类型将表现为可变字符串,并且将包含对可变字符串的可空引用字符串(永远不会存在对该项目的其他引用)和对“不可变”字符串的可为空引用(在不会尝试改变它的事物之外永远不会存在的引用)。将始终使用至少一个非空引用创建包装器;一旦可变项引用被设置为非空值(在构造期间或之后),它将永远引用相同的目标。任何时候两个引用都是非空的,不可变项引用将指向在最近完成的突变之后某个时间创建的项目的副本(在突变期间,不可变项引用可能持有也可能不持有引用到突变前的值)。

要读取对象,请检查“可变项”引用是否为非空。如果是这样,请使用它。否则,检查“immutable-item”引用是否为非空。如果是这样,请使用它。否则,请使用“可变项”引用(现在它将是非空的)。

要改变一个对象,检查“mutable-item”引用是否非空。如果不是,则将“不可变项”引用的目标和 CompareExchange 对新对象的引用复制到“可变项”引用中。然后对“可变项”引用的目标进行变异,并使“不可变项”引用无效。

要克隆一个对象,如果期望克隆在它发生变异之前再次被克隆,则检索“immutable-item”引用的值。如果它为 null,则复制“可变项”目标并将 CompareExchange 对该新对象的引用复制到不可变项引用中。然后创建一个新包装器,其“可变项”引用为空,其“不可变项”引用为检索到的值(如果它不为空)或新项(如果它是)。

要克隆一个对象,如果预期克隆在被克隆之前会发生变异,则检索“immutable-item”引用的值。如果为 null,则检索“可变项”引用。复制检索到的任何引用的目标,并创建一个新包装器,其“可变项”引用指向新副本,其“不可变项”引用为空。

这两种克隆方法在语义上是相同的,但在特定情况下选择错误的方法会导致额外的复制操作。如果始终选择正确的复制操作,则将获得“积极的”写时复制方法的大部分好处,但线程开销要少得多。每个数据保存对象(例如字符串)要么是非共享可变的,要么是共享的不可变的,并且没有对象会在这些状态之间切换。因此,如果需要,可以消除所有“线程/同步开销”(用直接存储替换 CompareExchange 操作),前提是在多个线程中同时使用没有包装器对象。两个包装器对象可能持有对同一个不可变数据持有者的引用,但它们可能不知道彼此的存在。

请注意,与使用“激进”方法相比,使用这种方法可能需要更多的复制操作。例如,如果使用新字符串创建了一个新包装器,并且该包装器发生了变异,并被复制了六次,那么原始包装器将保存对原始字符串持有者的引用和一个持有数据副本的不可变引用。六个复制的包装器将只保存对不可变字符串的引用(总共两个字符串,尽管如果在复制后原始字符串从未发生过变异,那么激进的实现可能会得到一个)。如果原始包装器以及六个副本中的五个发生了变异,那么对不可变字符串的引用除了一个之外的所有引用都将失效。那时,如果第六个包装副本发生了变异,一个激进的写时复制实现可能会意识到它持有对其字符串的唯一引用,因此决定副本是不必要的。然而,我描述的实现将创建一个新的可变副本并放弃不可变副本。然而,尽管存在一些额外的复制操作,但在大多数情况下,线程开销的减少应该足以抵消成本。如果生成的大多数逻辑副本从未发生变异,那么这种方法可能比总是复制字符串更有效。

【讨论】:

    【解决方案3】:

    对 CoW 来说没什么。基本上,您在想要更改它时复制它,并让不想更改它的任何人保留对旧实例的引用。您需要引用计数来跟踪谁仍在引用该对象,并且由于您正在创建新副本,因此您需要减少“旧”实例的计数。一个捷径是在计数为 1 时不制作副本(您是唯一的参考)。

    除此之外,没有什么可以说的,除非你面临一个特定的问题。

    【讨论】:

    • 魔鬼在细节中:你如何处理运算符 []?你是否返回一个 char& 并总是复制,假设它会改变?您是否返回 char 并且从不复制,禁止修改单独的字符?您是否返回代理对象并在分配时复制?这么多问题,没有一个正确答案:)
    • @sbk:简单的回答?不要使用它。 :) 例如,您可以拥有用于单个字符操作的 get/set 方法。
    • @roe:但那将是一个残缺的字符串类......我记得当我看到 Java 的字符串上的 charAt 方法时我是多么厌恶。呸
    • @sbk: 可能是,但是给某人一个内部的参考/指针会伤害你。即使您现在复制,其他人也可能在稍后阶段获得对您对象的读取引用。不允许任何人与字符串对象以外的任何东西进行交互。您可以通过实现一个字符指针对象(由 [] 运算符返回)来解决它,该对象将在赋值运算符中进行复制。这实际上是一个非常有趣的想法,谢谢!
    • 感谢大家的反对,现在我得到了一个很好的甚至是代表.. ;) 认真,但要详细说明吗?
    【解决方案4】:

    您可能想要模拟其他语言具有的“不可变”字符串(据我所知是 Python、C#)。

    这个想法是每个字符串都是不可变的,因此对字符串的任何工作都会创建一个新的不可变字符串...或者这是基本想法,为了避免爆炸,如果有类似的字符串,则无需创建另一个字符串。

    【讨论】:

      【解决方案5】:
      template <class T> struct cow {
        typedef boost::shared_ptr<T> ptr_t;
        ptr_t _data;
      
        ptr_t get()
        {
          return boost::atomic_load(&_data);
        }
      
        void set(ptr_t const& data)
        {
          boost::atomic_store(&_data, data);
        }
      }
      

      【讨论】:

      • @daramarak cow::set() 释放对旧数据的引用而不接触它,如果没有其他人通过之前调用过 cow::get() 来引用旧数据,数据被删除。想想 cow<:string const> 是如何工作的。
      • 通常对于牛,您希望一个不共享的牛对象被重复修改以不创建新对象或进行分配。
      • @Yakk - 这是一种不同的模式,称为读取时复制或简单地合并
      猜你喜欢
      • 1970-01-01
      • 2013-03-09
      • 1970-01-01
      • 2011-05-06
      • 2020-06-25
      • 1970-01-01
      • 2011-05-17
      • 2021-01-25
      • 1970-01-01
      相关资源
      最近更新 更多