繁体   English   中英

读者/作者锁……没有读者锁?

[英]A readers/writer lock… without having a lock for the readers?

我觉得这可能是一种非常普遍和常见的情况,其中存在众所周知的无锁解决方案。

简而言之,我希望有像读者/作者锁这样的方法,但这并不要求读者获得锁,因此可以获得更好的平均性能。

相反,读者会使用一些原子操作(128 位 CAS),而编写者会使用互斥锁。 我将拥有数据结构的两个副本,一个用于正常成功查询的只读副本,以及一个在互斥锁保护下更新的相同副本。 将数据插入可写副本后,我们将其设为新的可读副本。 旧的可读副本然后依次插入,一旦所有待处理的读者都读完它,写者旋转剩余的读者数量直到其为零,然后依次修改它,最后释放互斥体。

或类似的东西。

沿着这些思路存在吗?

如果您的数据适合 64 位值,则大多数系统都可以以原子方式廉价地读取/写入,因此只需使用std::atomic<my_struct>

对于小的和/或不经常写入的数据,有几种方法可以使读取器对共享数据真正只读,而不必对共享计数器或任何东西执行任何原子 RMW 操作。 这允许读取端扩展到多个线程,而无需读取器相互竞争(与 x86 上使用lock cmpxchg16b或采用 RWlock 的 128 位原子读取不同)。

理想情况下,只是通过atomic<T*>指针 (RCU) 的额外间接级别,或者只是额外的负载 + 比较和分支 (SeqLock); 没有原子 RMW 或 memory 屏障比读取端的 acq/rel 或其他任何东西都强。

这可能适用于许多线程非常频繁地读取的数据,例如由计时器中断更新但在整个地方读取的时间戳。 或者一个通常永远不会改变的配置设置。

如果您的数据更大和/或更频繁地更改,则其他答案中建议的策略之一要求读者仍然对某事采取 RWlock 或原子地增加计数器将更合适。 这不会完美地扩展,因为每个读者仍然需要获得包含锁或计数器的共享缓存行的独占所有权,以便它可以修改它,但是没有免费午餐这样的东西。

控制单元

听起来您正在发明 RCU (读取复制更新),您可以在其中更新指向新版本的指针。

但请记住,无锁读取器可能会在加载指针后停止,因此您遇到了释放问题。 这是 RCU 的难点。 在 kernel 中,可以通过设置同步点来解决,在该同步点您知道没有超过某个时间 t 的阅读器,因此可以释放旧版本。 有一些用户空间实现。 https://en.wikipedia.org/wiki/Read-copy-updatehttps://lwn.net/Articles/262464/

对于 RCU,更改的频率越低,您可以证明复制的数据结构越大。 例如,即使是一个中等大小的树,如果它只由管理员交互更改,而读者在数十个内核上运行,所有这些内核都在并行检查某些东西,那么它也是可行的。 例如 kernel 配置设置是 RCU 在 Linux 中很棒的一件事。


序列锁

如果您的数据很小(例如,32 位机器上的 64 位时间戳),另一个不错的选择是 SeqLock。 读取器在将数据非原子复制到私有缓冲区之前/之后检查序列计数器。 如果序列计数器匹配,我们就知道没有撕裂。 (编写器使用单独的互斥体相互排除每个)。 使用 32 位原子实现 64 位原子计数器/ 如何使用 c++11 原子库实现 seqlock 锁

在 C++ 中编写一些可以有效编译为可能会撕裂的非原子副本的东西有点像 hack,因为这不可避免地是数据竞争 UB。 (除非您对每个块分别使用带有mo_relaxedstd::atomic<long> ,但是您正在使编译器无法使用movdqu或其他东西一次复制 16 个字节。)

SeqLock 使读取器在每次读取时都复制整个内容(或者理想情况下只是将其加载到寄存器中),因此它仅适用于小型结构或 128 位 integer 或其他东西。 但是对于少于 64 字节的数据,它可能非常好,如果您有很多读取器和不频繁的写入,则比让读取器对 128 位数据使用lock cmpxchg16b更好。

但是,它不是无锁的:在修改 SeqLock 时休眠的编写器可能会让读者无限期地重试。 对于小型 SeqLock,window 很小,显然您希望在执行第一次序列计数器更新之前准备好所有数据,以最大限度地减少在更新过程中中断暂停写入器的机会。

最好的情况是只有 1 个写者,所以它不必做任何锁定; 它知道没有其他东西会修改序列计数器。

您所描述的与双实例锁定左右并发控制非常相似。

在进度保证方面,两者的区别在于前者对读者是无锁的,而后者是无等待的。 两者都在阻止作家。

事实证明,我正在考虑的双结构解决方案与http://concurrencyfreaks.blogspot.com/2013/12/left-right-concurrency-control.html有相似之处

这是我想到的具体数据结构和伪代码。

我们分配了一些名为 MyMap 的任意数据结构的两个副本,并且一组三个指针中的两个指针指向这两个。 最初,一个由 achReadOnly[0].pmap 指向,另一个由 pmapMutable 指向。

关于 achReadOnly 的简要说明:它有一个正常的 state 和两个临时状态。 正常的 state 将是(单元格 0/1 的 WLOG):

achReadOnly = { { pointer to one data structure, number of current readers },
                { nullptr, 0 } }
pmapMutable = pointer to the other data structure

当我们完成对“另一个”的变异后,我们将它存储在数组的未使用槽中,因为它是下一代只读的,读者可以开始访问它。

achReadOnly = { { pointer to one data structure, number of old readers },
                { pointer to the other data structure, number of new readers } }
pmapMutable = pointer to the other data structure

然后,作者清除指向“the one”的指针,即上一代只读,迫使读者将 go 指向下一代。 我们将其移至 pmapMutable。

achReadOnly = { { nullptr, number of old readers },
                { pointer to the other data structure, number of new readers } }
pmapMutable = pointer to the one data structure

然后编写器旋转一些老读者来命中一个(本身),此时它可以接收相同的更新。 该 1 被 0 覆盖以清理以准备继续前进。 虽然实际上它可能会被弄脏,因为它在被覆盖之前不会被引用。

struct CountedHandle {
    MyMap*   pmap;
    int      iReaders;
};

// Data Structure:
atomic<CountedHandle> achReadOnly[2];
MyMap* pmapMutable;
mutex_t muxMutable;

data Read( key ) {
    int iWhich = 0;
    CountedHandle chNow, chUpdate;

    // Spin if necessary to update the reader counter on a pmap, and/or
    // to find a pmap (as the pointer will be overwritten with nullptr once
    // a writer has finished updating the mutable copy and made it the next-
    // generation read-only in the other slot of achReadOnly[].

    do {
        chNow = achReadOnly[ iWhich ];
        if ( !chNow .pmap ) {
            iWhich = 1 - iWhich;
            continue;
        }
        chUpdate = chNow;
        chNow.iReaders++;
    } while ( CAS( ach[ iWhich ], chNow, chUpdate ) fails );

    // Now we've found a map, AND registered ourselves as a reader of it atomicly.
    // Importantly, it is impossible any reader has this pointer but isn't
    // represented in that count.

    if ( data = chnow.pmap->Find( key ) ) {
        // Deregister ourselves as a reader.
        do {
            chNow = achReadOnly[ iWhich ];
            chUpdate = chNow;
            chNow.iReaders--;
        } while ( CAS( ach[ iWhich ], chNow, chUpdate ) fails );

        return data;
    }

    // OK, we have to add it to the structure.

    lock muxMutable;
    figure out data for this key
    pmapMutable->Add( key, data );

    // It's now the next-generation read-only.  Put it where readers can find it.
    achReadOnly[ 1 - iWhich ].pmap = pmapMutable;

    // Prev-generation readonly is our Mutable now, though we can't change it
    // until the readers are gone.
    pmapMutable = achReadOnly[ iWhich ].pmap;

    // Force readers to look for the next-generation readonly.
    achReadOnly[ iWhich ].pmap = nullptr;

    // Spin until all readers finish with previous-generation readonly.
    // Remember we added ourselves as reader so wait for 1, not 0.

    while ( achReadOnly[ iWhich ].iReaders > 1 }
        ;

    // Remove our reader count.
    achReadOnly[ iWhich ].iReaders = 0;

    // No more readers for previous-generation readonly, so we can now write to it.
    pmapMutable->Add( key, data );

    unlock muxMutable;

    return data;

}

我遇到的解决方案:

每个线程都有一个thread_local的数据结构副本,可以随意查询,无需加锁。 任何时候你找到你的数据,很好,你已经完成了。

如果您没有找到您的数据,那么您将获得主副本的互斥锁。

这可能会有许多来自其他线程的新插入(可能包括您需要的数据。)。 检查它是否有您的数据,如果没有插入它。

最后,将所有最近的更新(包括您需要的数据条目)复制到您自己的thread_local副本中。 释放互斥锁并完成。

读者可以整天并行阅读,即使正在更新,也无需加锁 仅在写入时(或有时在赶上时)才需要锁。 这种通用方法适用于广泛的底层数据结构。 量子点


如果您有很多线程使用此结构,那么拥有许多thread_local索引听起来内存效率低下。

但是,索引找到的数据,如果是只读的,只需要一个副本,被许多索引引用。 (幸运的是,这就是我的情况。)

此外,许多线程可能不会随机访问所有条目; 也许有些人只需要几个条目,很快就会达到最终的 state ,他们的结构的本地副本可以在它增长很多之前找到所有需要的数据。 然而,许多其他线程可能根本没有提到这一点。 (幸运的是,这就是我的情况。)

最后,为了“复制所有最近的更新”,如果添加到结构中的所有新数据都被推到向量的末尾,那么假设你的本地副本中有 4000 个条目,主副本有4020,您可以用几个机器周期定位您需要添加的 20 个对象。 (幸运的是,这就是我的情况。)

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM