使用双缓冲技术进行并发读写?

Posted

技术标签:

【中文标题】使用双缓冲技术进行并发读写?【英文标题】:Using a double buffer technique for concurrent reading and writing? 【发布时间】:2021-08-25 19:27:52 【问题描述】:

我有一个相对简单的案例:

    我的程序将通过 Websockets 接收更新,并将使用这些更新来更新它的本地状态。这些更新将非常小(通常 同时,程序将从该本地状态读取/评估并输出其结果。 这两个任务应该并行运行,并将在程序的持续时间内运行,即永不停止。 本地状态大小相对较小,因此内存使用不是一个大问题。

棘手的部分是更新需要“原子地”发生,这样它就不会从本地状态中读取,例如,只写入了一半的更新。状态不限于使用原语,并且可以包含任意类 AFAICT atm,因此我无法通过使用 Interlocked 原子操作之类的简单方法来解决它。我计划在自己的线程上运行每个任务,所以在这种情况下总共有两个线程。

为了实现这个目标,我想使用双缓冲技术,其中:

    它保留两份状态副本,以便在写入另一份的同时读取一份。 线程可以通过使用锁来传达它们正在使用的副本。即写入器线程在写入时锁定副本;阅读器线程在完成当前副本后请求访问锁定; writer 线程看到 reader 线程正在使用它,因此它切换到其他副本。 编写线程跟踪它在当前副本上完成的状态更新,因此当它切换到另一个副本时,它可以“赶上”。

这是这个想法的一般要点,但实际的实现当然会有点不同。

我试图查找这是否是一个常见的解决方案,但实际上找不到太多信息,所以这让我想知道类似的事情:

    它是否可行,还是我遗漏了什么? 有更好的方法吗? 这是一个通用的解决方案吗?如果有,通常称为什么? (奖励)我可以阅读与此相关的主题的好资源吗?

几乎我觉得我遇到了一个死胡同,我找不到(因为我不知道要搜索什么)更多的资源和信息来看看这种方法是否“好”。我计划用 .NET C# 编写这个,但我认为这些技术和解决方案可以翻译成任何语言。对所有见解表示赞赏。

【问题讨论】:

本地状态是mutable or immutable吗? 【参考方案1】:

您实际上需要四个缓冲区/对象。阅读器拥有两个缓冲区/对象,作者拥有一个,邮箱拥有一个。

读者——每次他完成对新对象的一组原子操作时,他使用互锁交换将旧对象句柄(指针或索引无关紧要)与邮箱句柄交换。然后他查看新获得的对象并将序列号与他刚刚读取(并且仍然持有)的对象进行比较,以找出哪个更新。

作者——将最新数据的完整副本写入他的对象,然后使用互锁交换将他新写入的对象与邮箱对象交换。

如您所见,写入者可以随时窃取邮箱对象,但绝不会窃取读取者正在使用的对象,因此读取操作保持原子性。读者可以随时窃取邮箱对象,但永远不会窃取作者正在使用的邮箱对象,因此写入操作保持原子性。

只要 interlocked-exchange 函数产生正确的内存栅栏(为写入线程中的交换释放,为读取线程获取),对象本身就可以任意复杂。

【讨论】:

嗯,我还没有理解为什么两个对象/缓冲区用于阅读器,不能只使用一个并与邮箱比较,看看是否有任何更新,然后交换?我觉得在技术上我可以只使用两个缓冲区(使用我在帖子中概述的想法),但它可能会更加混乱和复杂。我真的很喜欢邮箱的想法,它看起来简单直观,因为内存对我来说不是问题,使用 3-4 个缓冲区没什么大不了的。我一定会调查的。谢谢。 @Bureto:“与邮箱比较”是个问题。当对象在邮箱中时,您无法访问它,因为写入者可以随时交换邮箱并在您阅读对象时开始写入对象。在检查之前,您必须将对象从邮箱中换出。 如果我错了,请纠正我,这是我对它如何工作的理解: 1. 交换已完成,将邮箱对象读入临时占位符对象(通过引用 ofc)。 2. 对于 writer,它可以通过简单地通过增加对象中的序列号并使用Interlock.Exchange 用更新的对象更新邮箱 ref 来完成交换来完成其工作。 3. 对于阅读器,它会检查占位符对象的序列号与其当前“主”对象的序列号,如果占位符的序列号较高,则占位符成为主要对象,并且它.. @Bureto:避免锁定确实是我的解决方案的一个功能,但是您消除第四个对象的尝试引入了竞争条件(在 C++ 中这是即时未定义的行为,在 C# 中您仍然可以获得类型安全保证但不能保证您实际找到的价值)。阅读器需要先从邮箱中认领该对象(通过Interlocked.Exchange),然后才能读取存储在该对象中的序列号。仅仅为邮箱仍然拥有的同一个对象创建另一个“占位符”句柄(指针/索引/其他)是不安全的。 @Bureto:但是,除了您必须停止尝试从邮箱中读取对象之外,您对在发布前增加写入器中的序列号的理解,并检查它在读者中决定它是否替换“主对象”是完全正确的。【参考方案2】:

如果我理解正确,写入本身是同步的。如果是这样,那么也许没有必要保留两份甚至使用锁。

也许这样的东西可以工作?

State state = populateInitialState();

...

// Reader thread
public State doRead() 
    return makeCopyOfState(state);


...

// Writer thread
public void updateState() 
    State newState = makeCopyOfState(state);

    // make changes in newState

    state = newState;

【讨论】:

不幸的是,在我的情况下,更新可能会影响状态的大范围(即不是孤立的一小部分),所以我最终不得不复制任意大块状态 AFAICT。嗯..也许这暗示我的状态结构很差.. idk。我可以看到这个想法是如何工作的,但如果使用互锁/原子交换功能,我一定会记住它。感谢您的洞察力。【参考方案3】:

您似乎在多线程pipeline 中使用input-process-output 模式。有时,当问题很简单时,输入和处理阶段(或处理和输出阶段)会合并。

您已经添加了一个 C# 标记,因此使用 BlockingCollection 之类的东西可能是在输入和输出线程之间进行通信的有用方式。由于本地状态相对较小(您的话),因此将包含本地状态副本的数据对象从输入线程发布到输出线程可能是一个简单的解决方案。这遵循满足原子要求的无共享理念,因为当前状态的快照已排队。 “追赶”能力得到满足,因为队列包含状态变化的积压。

一般来说,Messaging Patterns 和 Conversation Patterns 在尝试确定在 2 个或多个线程(或进程、服务、服务器等)之间通信什么以及如何通信时是有用的资源。

【讨论】:

以上是关于使用双缓冲技术进行并发读写?的主要内容,如果未能解决你的问题,请参考以下文章

基于c#的双缓冲技术画图

OpenGL中实现双缓冲技术

单缓冲和双缓冲 有啥区别

OpenGL单缓冲与双缓冲的区别(追加10分)

IO缓冲区

改进版——使用了双缓冲技术