如何从有界缓冲区中按最初在C ++中生成的顺序检索项目?

时间:2018-11-26 12:52:26

标签: c++ multithreading concurrency

我有一个非常典型的生产者/消费者问题,已经用bounded buffer解决了。单个进程生成项目并将其交给N个工作线程。辅助线程处理这些项目并将结果放置在有界缓冲区中。最终消费者流程从缓冲区中检索成品。以下数据流程图说明:

enter image description here

每个工作人员需要花费可变的时间来处理其项目,因此工作人员会以基本上随机的顺序将完成的项目插入边界缓冲区。这已经足够好了,但是有时有必要按照它们最初生成的 same 顺序检索完成的项目。所以问题是:

如何修改现有实现以按顺序检索完成的商品?

一个重要的附加约束是,我们必须尊重有界缓冲区的大小。如果缓冲区的大小为M,那么在任何给定时间等待消费者使用的成品不得超过M。

绑定缓冲区

有界缓冲区具有直接接口:

template <class T> class bounded_buffer
{
public:
  // initializes a new buffer
  explicit bounded_buffer(size_t capacity);
  // pushes an item into the buffer, blocks if full
  void push(T item);
  // pops an item from the buffer, blocks if empty
  T pop();
};

处理项目

工作线程使用以下代码来处理项目:

std::unique_lock guard{ source_lock };
auto item = GetNextItem();
guard.unlock();

buffer.push(ProcessItem(std::move(item)));

(实际代码要复杂得多,因为它必须处理输入数据的结尾,取消和处理错误。但是这些细节与问题无关。)

用于检索完成项的代码只是弹出有界缓冲区:

auto processed_item = buffer.pop();

2 个答案:

答案 0 :(得分:1)

我将提出两种解决方案。首先是快速和简单。第二种方法建立在第一种方法后面的思想之上,可以产生更高的效率。

第一种方法:std :: future

基本思想是,当我们第一次获取一个值并在完成该项目的处理时将其填充时,我们将在边界缓冲区中“保留”一个空间。 std::future提供了一种现成的机制来完成此任务。代替使用bounded_buffer<T>,我们将使用bounded_buffer<std::future<T>>。我们按如下方式调整工作程序代码:

std::unique_lock guard{ source_lock };
auto item = GetNextItem();    
std::promise<T> processed_item;
buffer.push(processed_item.get_future());
guard.unlock();

processed_item.set_value(ProcessItem(std::move(item)));

然后,我们只需轻轻调整一下消费者代码即可从未来获取价值:

auto processed_item = buffer.pop().get();

如果消费者进程在工作人员完成某件商品之前就对其进行了检索,那么std::future<T>::get将确保消费者阻塞直到该物品准备就绪。

优点:

  • 相对简单,它可以解决问题。我们在保持源锁定的同时将期货放入有界缓冲区中,因此可以保证最终结果以与从源中检索它们相同的顺序进入缓冲区。
  • 不需要对有界缓冲区本身进行任何更改,从而保留了该抽象的纯度。

缺点:

  • std::future相对较重,需要额外的内存分配和内部同步。
  • 现在我们在推入缓冲区时保持了源锁(并且推可能会阻塞);这可能很好,但是如果GetNextItem()昂贵,则可能会出现问题。

第二种方法:建立更好的缓冲区

要解决第一种方法中的性能问题,我们可以调整有界缓冲区的实现,以建立在其中保留空间的想法。我们将对其界面进行三处更改:

  1. 更改构造函数以接受谓词。
  2. 更改push方法以返回定位器。
  3. 添加一个新的replace方法,该方法接受一个定位符和一个值。

修改后的界面如下:

template <class T, class P> class bounded_buffer
{
public:
  using locator_type = /* unspecified */;
  // initializes a new buffer; an item is "available" if and only if it
  // satisfies this predicate
  explicit bounded_buffer(size_t capacity, P predicate);
  // pushes an item into the buffer, blocks if full; the buffer's count of
  // available items will increase by one if and only if all items in the
  // buffer (including the new one) are available
  locator_type push(T item);
  // pops an item from the buffer, blocks if empty
  T pop();
  // replaces an existing item in the buffer; if the item is the first in the
  // buffer, then we set the count of available items as follows: 0 if the
  // item is unavailable, or X if it is available where X is the number of 
  // available items at the front of the buffer
  void replace(locator_type location, T item);
};

然后,将存储在有界缓冲区中的类型从T更改为std::variant<std::monostate, T>。如果谓词包含T,则谓词将认为它“可用”。我们按如下方式更改工作程序代码:

std::unique_lock guard{ source_lock };
auto item = GetNextItem();      
auto location = buffer.push(std::monostate{});
guard.unlock();

buffer.replace(location, ProcessItem(std::move(item));

使用者中的检索代码也必须更改,以便从变体中检索值:

auto processed_item = std::get<1>(buffer.pop());

优点:

  • std::future方法更轻巧,因此性能更高。 (存储std::variant索引只需要比原始版本稍多的内存。)
  • 以与future版本基本相同的方式解决问题。

缺点:

  • 需要更改有界缓冲区的实现,并且其基本操作已不再是您对这种抽象的期望。
  • 不解决上面确定的源锁定问题。

错误处理

为简单起见,我省略了错误处理。然而,两种方法都需要适当的异常处理。如果在处理带有已编写代码的项目时发生异常,则消费者将挂起,因为它将等待永远不会到达的保留项目。

答案 1 :(得分:0)

这是我的建议:

  1. WorkItem类中添加一个“ requires”字段。该字段的类型为shared_ptr<WorkItem>(或类似名称)。如果为非NULL,则此字段指示两个WorkItem之间的依赖关系-例如,如果WorkItem B的require字段设置为指向WorkItem A,则意味着使用者进程需要在A之前消耗B

  2. 还向每个WorkItem添加一个condition variable(及其关联的mutex

  3. 还向每个WorkItem添加一个布尔“已消耗”字段。该字段默认为false,但是,当使用者进程使用WorkItem时,它将锁定WorkItem的{​​{1}},并将此字段设置为{{1} },在mutex的{​​{1}}上调用true,然后解锁notify_all()

  4. 当工作进程完成对WorkItem的处理时,它必须检查condition variable的“ requires”字段。如果“ requires”字段为mutex,则WorkItem可能会立即添加到绑定队列中,并完成工作进程的工作。

  5. 否则,工作进程必须锁定“ requires”字段的WorkItem的{​​{1}}并检查其“ consumed”变量-如果将其设置为NULL,工作进程应该解锁WorkItem并加入引用mutex,工作就完成了。

  6. 如果我们到了这里,那么工作进程就无法加入其WorkItem队列,因为它的true具有阻止它的排序依赖性。因此,在这种情况下,工作进程应在其依赖项的mutex上调用WorkItem。这将使工作进程进入睡眠状态,直到消耗掉它的“ requires”-WorkItem为止。此时,工作进程将被唤醒(通过步骤3中的WorkItem调用),并且可以照常将自己的wait()排入队列。

该逻辑应足以确保在指定时正确排序,同时仍允许工作流程在condition variable上没有消耗排序要求的情况下尽可能高效地工作。