C++ 线程安全对象缓存的设计选项

Posted

技术标签:

【中文标题】C++ 线程安全对象缓存的设计选项【英文标题】:Design options for a C++ thread-safe object cache 【发布时间】:2010-01-26 13:46:41 【问题描述】:

我正在为 C++ 中的数据缓存编写模板库,其中可以进行并发读取和并发写入,但不能使用相同的键。该模式可以用以下环境来解释:

    用于缓存写入的互斥锁。 缓存中每个键的互斥锁。

这样,如果一个线程从缓存中请求一个键并且不存在,则可以为该唯一键启动锁定计算。与此同时,其他线程可以检索或计算其他键的数据,但尝试访问第一个键的线程会被锁定等待。

主要的约束是:

    切勿同时计算键的值。 可以同时计算 2 个不同键的值。 数据检索不能锁定其他线程从其他键检索数据。

我的其他限制但已经解决的是:

    固定(在编译时已知)最大缓存大小以及基于 MRU(最近使用)的抖动。 按引用检索(隐含互斥共享计数)

我不确定为每个键使用 1 个互斥锁是实现此目的的正确方法,但我没有找到任何其他实质上不同的方法。

您是否知道实现此功能的其他模式,或者您认为这是一个合适的解决方案?我不喜欢拥有大约 100 个互斥锁的想法。 (缓存大小约为 100 个键)

【问题讨论】:

也许第一句话说明了这一点,但我不清楚并发限制是否适用于对同一对象的读取。换句话说,两个线程可以同时使用一个现有的键吗? 是的,如果缓存中已经存在,2 个线程应该同时读取同一个键。否则,如果键不存在,则一个线程必须获得键上的锁,而第二个线程必须等待另一个线程完成计算并将对象添加到缓存中。同时允许其他线程从其他键中读取。这可以通过缓存的多读/单写互斥锁来完成;当锁定写入时,一个新条目被添加到缓存中。你可以认为是一个元组。键互斥锁被锁定,然后缓存互斥锁被释放。 【参考方案1】:

您想要锁定并且想要等待。因此,某处应该有“条件”(如类 Unix 系统上的pthread_cond_t)。

我建议如下:

有一个全局互斥锁,仅用于添加或删除映射中的键。 映射将键映射到值,其中值是包装器。每个包装器都包含一个条件和一个可能的值。设置值时会发出条件信号。

当一个线程希望从缓存中获取一个值时,它首先获取全局互斥锁。然后它在地图中查找:

    如果该键有一个包装器,并且该包装器包含一个值,则线程有它的值并可能释放全局互斥锁。 如果该键有一个包装器但还没有值,那么这意味着某个其他线程当前正忙于计算该值。然后线程在条件下阻塞,当它完成时被另一个线程唤醒。 如果没有包装器,则线程在映射中注册一个新包装器,然后继续计算值。计算值时,它会设置值并发出条件信号。

在伪代码中是这样的:

mutex_t global_mutex hashmap_t 映射 锁(global_mutex) w = map.get(key) 如果(w == NULL) w = 新包装器 map.put(键,w) 解锁(global_mutex) v = 计算值() 锁(global_mutex) w.set(v) 信号(w.cond) 解锁(global_mutex) 返回 v 别的 v = w.get() 而(v == NULL) 解锁并等待(global_mutex,w.cond) v = w.get() 解锁(global_mutex) 返回 v

pthreads 术语中,lockpthread_mutex_lock()unlockpthread_mutex_unlock()unlock-and-waitpthread_cond_wait()signalpthread_cond_signal()unlock-and-wait 原子地释放互斥体并将线程标记为等待条件;当线程被唤醒时,互斥锁会自动重新获取。

这意味着每个包装器都必须包含一个条件。这体现了您的各种要求:

没有线程长时间持有互斥锁(阻塞或计算值)。 当要计算一个值时,只有一个线程执行,其他希望访问该值的线程只是等待它可用。

请注意,当一个线程希望获得一个值并发现其他线程已经在忙于计算它时,线程最终会锁定全局互斥锁两次:一次是在开始时,一次是在值可用时。一个更复杂的解决方案,每个包装器使用一个互斥锁,可以避免第二次锁定,但除非争用非常高,否则我怀疑它是否值得。

关于有许多互斥锁:互斥锁很便宜。互斥体基本上是int,它的成本只不过是用于存储它的四个左右字节的 RAM。注意 Windows 术语:在 Win32 中,我在这里所说的互斥锁被视为“互锁区域”; Win32 在调用CreateMutex() 时创建的东西完全不同,它可以从几个不同的进程中访问,而且由于涉及到内核的往返,所以成本要高得多。请注意,在 Java 中,每个对象实例都包含一个互斥体,Java 开发人员似乎并没有对这个主题过于暴躁。

【讨论】:

【参考方案2】:

您可以使用互斥池,而不是为每个资源分配一个互斥锁。当请求读取时,首先检查有问题的插槽。如果它已经标记了一个互斥锁,则阻止该互斥锁。如果不是,则为该插槽分配一个互斥锁并发出信号,将互斥锁从池中取出。一旦互斥体未发出信号,清除插槽并将互斥体返回到池中。

【讨论】:

所有这些仍然必须是线程安全的。如果两个线程同时尝试将互斥锁分配给一个插槽会发生什么? :) 完全正确。我的回答实际上只是一个指向一个基本想法的围栏。与任何多线程应用程序一样,魔鬼在细节中...... 其实这不是什么大问题,我喜欢这个主意。假设我使用多读取器/单写入器互斥锁来访问缓存,当 1 个线程想要向缓存添加密钥时,请求独占访问,添加密钥,从池中为其分配互斥锁并锁定它,释放缓存互斥体并开始计算。当没有人锁定它时,我仍然无法确定如何将互斥锁“返回”到池中。任何的想法? :)【参考方案3】:

一种更简单的解决方案是在整个缓存上使用单个读取器/写入器锁。鉴于您知道条目的最大数量(并且相对较小),听起来向缓存添加新键是一个“罕见”事件。一般的逻辑是:

acquire read lock
search for key
if found
    use the key
else
    release read lock
    acquire write lock
    add key
    release write lock
    // acquire the read lock again and use it (probably encapsulate in a method)
endif

不了解使用模式的更多信息,我不能确定这是否是一个好的解决方案。不过,它非常简单,如果主要用于读取,则在锁定方面非常便宜。

【讨论】:

我的假设(可能不正确)是需要某种独占访问才能将新条目添加到缓存中。读锁(在读/写锁上)将确保在持有写锁的同时添加新项目时的安全性。 我虽然有这样的想法,但不太适合我的环境。我有一个热身阶段,其中大部分所需的键是由各种线程选择的,以未知的方式或顺序(取决于前一个键的结果)。在密钥被重用一段时间后,另一组计算开始。我不能在计算阶段失去并行性;因此不能使用单个写入互斥锁。

以上是关于C++ 线程安全对象缓存的设计选项的主要内容,如果未能解决你的问题,请参考以下文章

场景应用:自己设计一个本地缓存(代码实现)

场景应用:自己设计一个本地缓存(代码实现)

c++ 当在一个线程中写入并在第二个线程中读取同一个对象时会发生啥? (它安全吗?)[重复]

设计线程安全的类--对象的组合

线程安全的 C++ 堆栈

java多线程3.设计线程安全类