CppCon 2015 学习:How to Make Your Data Structures Wait-Free for Reads
以下是你提到的内容的中文解释,涉及到 并发编程 和 进度条件,这些都是设计高效、高性能和可扩展并发系统时的关键概念。让我们逐一解析每个主题。
1. 进度条件 (Progress Conditions):
进度条件描述了系统在处理并发时的行为,特别是它如何处理共享资源或锁。常见的进度条件包括:
- 阻塞 (Blocking):线程必须等待直到某些条件被满足,或者直到其他线程完成任务。例如,线程在等待获取锁或资源时,处于阻塞状态。
- 无饥饿 (Starvation-Free):确保没有线程会永久阻塞。每个线程都必须最终能够执行其临界区操作。无饥饿系统保证每个线程最终都会有机会执行。
- 无锁 (Lock-Free):无锁保证至少有一个线程在并发环境中能够继续执行,即使其他线程可能被阻塞。相比于等待-free,这是一个较弱的条件,但它仍然能够保证系统的某些线程能够继续运行。
- 等待-Free (Wait-Free):这是最强的条件。等待-free算法保证每个线程在有限的步骤内完成其操作,无论系统中有多少线程。它确保没有线程会被无限期阻塞。
2. 延迟分布和进度条件 (Latency Distributions and Progress Conditions):
在并发系统中,延迟是指操作完成所需的时间。进度条件会影响延迟分布:
- 延迟分布描述了操作执行所需时间的变化。当系统采用不同的进度条件(如无锁、等待-free等),它们会呈现出不同的延迟特征。
- 了解延迟分布对于选择合适的进度条件至关重要,尤其是在某些场景下,可能更需要低延迟而不是确保公平性。
3. 读写锁 (Reader-Writer Locks):
- 读写锁允许多个线程并发读取共享数据,但写操作时只允许一个线程进行。它非常适合于读多写少的情况,可以提高并发性并减少竞争。
4. 写时复制 (Copy-On-Write, COW):
- 写时复制 (COW) 是一种优化技术,在并发编程中,当数据需要被修改时,只有在需要写入时才会进行复制,而在读时不会创建副本。这样,多个线程可以并发地读取同一份数据,只有一个线程修改时才会生成新副本。
5. 左右模式 (Left-Right Pattern):
- 左右模式是一种并发模式,通常用于链表结构,其中数据被分为两部分:左部分和右部分。对于左右部分的操作是并行的,处理左、右部分的线程不会互相阻塞。
- 这种模式常常与 并发链表 和 树结构 算法相关,其中数据的左、右部分可以并行处理。
6. 左右模式的并发控制算法:
- 左右模式背后的并发控制算法确保并发操作时,数据结构的左部分和右部分能够安全地进行操作而不发生冲突。这种算法可能使用 锁、原子操作,或者 CAS (比较与交换) 来确保更新的一致性。
7. 微基准 (Microbenchmarks):
- 微基准是一些小型的性能测试,旨在测量某些组件或操作的效率。例如,测试一个锁/解锁操作的时间,或测试遍历链表的时间。这些基准帮助开发人员了解并发操作对系统性能的影响。
8. 读取指示器 (Read Indicators):
- 读取指示器是用来跟踪数据是否正在被某个线程读取。在并发系统中,它们帮助优化数据访问,特别是在使用 读写锁 或 写时复制 等策略时。
9. 左右模式与Lambda表达式:
- 使用 Lambda表达式 与左右模式结合可以简化代码,使其更具可读性。Lambda表达式是用于定义小的匿名函数,可以方便地传递给并发操作。在此,Lambda表达式用于定义对数据结构左右部分需要执行的操作。
10. 左右单实例链表 (Left-Right-Single-Instance Linked List):
- 左右单实例链表是对左右模式在链表上的应用,其中链表的每个元素只有一个实例。这可以确保在并发操作时不会发生数据的重复或竞态条件。
11. 与RCU (Read-Copy-Update)的比较:
- RCU 是一种同步机制,用于处理读取密集型的并发工作负载。它允许线程并发读取共享数据,而不需要获取锁,只有在更新时才需要同步。
- 左右模式与 RCU 进行比较时,二者的目标相似:都旨在提高并发性并减少数据访问时的竞争。然而,RCU通常专注于读密集型的操作并尽量减少同步成本,而左右模式则更适用于需要精心管理左、右数据结构部分并发操作的复杂算法。
总结:
这些概念都涉及到如何设计和管理 并发数据结构 和 并发算法,以有效地处理多个线程。不同的进度条件,如 无锁 和 等待-free,决定了线程如何访问共享数据,而像 读写锁 和 写时复制 等技术则帮助减少阻塞和竞争。左右模式 是一种高阶的并发控制模式,技术如 微基准 和 RCU 提供了对并发系统性能的深入分析。
这些进度条件用来描述并发操作中的不同保证和约束。
1. 阻塞 (Blocking)
- 阻塞条件意味着,某些线程在某些情况下会被迫等待,直到其他线程完成它们的工作或释放某些资源。
- 比如,在传统的多线程程序中,一个线程可能因为获取不到锁而阻塞,直到锁被释放。
2. 死锁自由 (Deadlock-free)
- 死锁是指一组线程相互等待,导致程序中的这些线程无法继续执行。
- 死锁自由意味着在多线程程序中,系统能够避免死锁的发生。
- 在死锁自由的程序中,即使线程之间存在依赖关系,系统仍能确保不会进入一种永远互相等待的状态。
3. 无饥饿 (Starvation-free)
- 饥饿是指某些线程永远得不到资源,从而无法执行。
- 无饥饿保证了每个线程都有机会执行,并且没有线程会因为其他线程的无休止执行而永远得不到资源。
- 例如,如果线程 A 和线程 B 需要获取相同的资源,但线程 B 总是获取到了资源而线程 A 一直等待,那么线程 A 就发生了饥饿。无饥饿条件可以防止这种情况。
4. 阻塞自由 (Obstruction-Free)
- 阻塞自由意味着系统中的线程不会因其他线程的操作而被永久阻塞。
- 在这个条件下,至少有一个线程总是能够继续执行,不会因竞争等原因导致永久的阻塞。
- 即使其他线程处于阻塞状态,系统仍能确保有线程能够继续运行。
5. 无锁 (Lock-Free)
- 无锁表示至少有一个线程能够在有限的时间内完成其操作,而不必等待其他线程的操作或资源。
- 无锁保证了系统中至少有一个线程能够执行成功,而不是所有线程都可能被无限期地阻塞。
- 注意,无锁不意味着没有锁,而是系统会避免出现永远阻塞的情况,能够保证系统的进度。
6. 等待自由 (Wait-Free)
- 等待自由是一种比无锁更强的进度保证,它保证每个线程都能在有限的时间内完成其操作。
- 等待自由意味着在系统中没有任何线程会被无限期地阻塞,所有线程都会在有限的步骤内完成它们的操作。
- 这是一种非常强的进度条件,它比无锁提供了更强的保证。
7. 等待自由有界 (Wait-Free Bounded)
- 等待自由有界是等待自由的一种变种,指的是在每个线程进行操作时,其最大执行步骤是有限的。
- 虽然等待自由有界保证了没有线程会阻塞,但它还对每个线程完成任务所需的最大步骤数做出了限制。
- 也就是说,虽然每个线程都能在有限的时间内完成操作,但不同的线程可能有不同的最大步骤数。
8. 等待自由与群体无关 (Wait-Free Population Oblivious)
- 等待自由群体无关表示系统在保证等待自由的同时,不受其他线程数量或线程之间的竞争的影响。
- 这个条件特别强调,不管系统中有多少个线程,系统的表现都不应该受到线程数量的影响,所有线程都应该在有限的步骤内完成它们的操作。
总结
这些进度条件从阻塞到等待自由,表示了并发操作中不同的线程进度保证,从较弱的进度条件到较强的进度条件,逐步提高了线程间交互的公平性、效率和响应性。
- 阻塞和死锁自由是最基本的条件。
- 无饥饿和阻塞自由确保了线程能够获得执行机会。
- 无锁和等待自由则提供了更强的执行保证。
- 等待自由有界和等待自由群体无关提供了在高负载或高并发环境下的进一步保证。
这些进度条件的定义描述了在并发程序中,各线程如何相互影响以及它们能否独立或并行地完成操作。以下是对每个进度条件的详细解释:
1. 阻塞 (Blocking)
- 定义:阻塞发生时,一个线程的意外延迟可能会阻止其他线程继续执行进程。也就是说,当某个线程被阻塞时,可能会影响到其他线程的执行。
- 特点:线程可能因为等待某些资源或锁而进入阻塞状态,导致整体系统的进度受阻。
2. 非阻塞 (Non-Blocking)
- 定义:非阻塞条件是一种更广泛的条件,它意味着即使某个线程暂时无法完成它的任务,它也不会使系统整体进度停滞。具体来说,这里通常会使用"无阻塞"来描述,常见的包括 无锁 和 等待自由。
- 特点:系统能够避免因某个线程的延迟或阻塞而导致其他线程无法继续执行。
3. 阻塞自由 (Obstruction-Free)
- 定义:一个方法是阻塞自由的,意味着从某个时间点开始,如果它在隔离的环境中执行,它会在有限的步骤内完成。换句话说,线程不需要等待其他线程,可以独立完成任务。
- 特点:线程只要不与其他线程发生竞争,它就能在有限的步骤内完成工作,但可能在并发环境中仍然会受到影响。
4. 无锁 (Lock-Free)
- 定义:一个方法是无锁的,意味着在多线程环境下,至少有一个线程在有限步骤内能够完成任务,即使其他线程由于竞争或等待某些资源而延迟执行。
- 特点:无锁保证了系统中总有一个线程能够完成任务,但不一定是每个线程都能及时完成。
5. 等待自由 (Wait-Free)
- 定义:一个方法是等待自由的,意味着每个调用都能在有限步骤内完成,不会因其他线程的操作而阻塞或延迟。
- 特点:等待自由保证了所有线程都能在有限的步骤内完成操作,解决了“线程等待其他线程完成操作”的问题。
6. 等待自由有界 (Wait-Free Bounded)
- 定义:等待自由有界的方法保证了每个调用能够在有限并且有界的步骤内完成执行。这个“有界的步骤”可能依赖于系统中的线程数。
- 特点:相较于普通的等待自由,等待自由有界增加了对执行时间的限制。尽管每个线程都能够在有限时间内完成任务,但执行的最大步骤数可能会随线程数的增加而改变。
7. 等待自由群体无关 (Wait-Free Population Oblivious)
- 定义:等待自由群体无关的方法保证了每个调用在有限时间内完成,同时它的执行时间与线程数量无关。换句话说,线程的数量不会影响每个线程执行任务的进度。
- 特点:即使线程数不断增加,系统的性能也不会受到影响。每个线程都有相同的进度保证。
总结
这些进度条件从较弱到较强的顺序排列,定义了在并发环境中如何保证线程能够在合理的时间内完成任务,避免阻塞、等待或性能受限:
- 阻塞 (Blocking):线程之间的依赖可能导致某些线程无法继续执行。
- 阻塞自由 (Obstruction-Free):线程独立时能够完成任务,避免了外部阻塞。
- 无锁 (Lock-Free):至少有一个线程能在有限步骤内完成任务。
- 等待自由 (Wait-Free):所有线程都能够在有限步骤内完成任务。
- 等待自由有界 (Wait-Free Bounded):每个线程都在有限且有界的步骤内完成任务,但执行时间可能与线程数有关。
- 等待自由群体无关 (Wait-Free Population Oblivious):线程数不影响每个线程的执行进度,保证了更强的性能一致性。
进度条件和时间的关系,特别是如何误解“等待自由(Wait-Free)”的方法以及它在多线程系统中的实际表现。以下是详细解读:
进度条件与时间
- 进度条件通常是用步骤数来定义的,而不是用时间来衡量的。也就是说,它们描述的是在某个方法中完成操作所需的步骤数量,而不是方法实际执行所需的实际时间。
- 这可能会让人误解,特别是在**等待自由(Wait-Free)**的情况下。
等待自由 (Wait-Free) 可能被误解
- “等待自由”常常被误解为意味着没有等待时间,也就是说,调用的线程总能在常量时间内完成操作。但实际上,这种理解并不准确。
- 在多线程系统中,无法保证线程总是能在固定的时间内完成任务,因为线程调度本身就有很多变数。例如,操作系统的调度器可能会决定将执行权从一个线程切换到另一个线程,导致当前线程无法继续执行。这种调度行为可能会导致某些线程在等待其他线程执行时,延迟了它自己的执行。
等待自由方法与调度器的关系
- 即使是“等待自由”的方法,仍然有可能需要不确定的时间来完成。原因是,操作系统的线程调度器可能在不考虑该线程的执行需要时,决定不给该线程分配 CPU 时间。比如,当其他线程的优先级更高时,某个“等待自由”的线程可能无法立即执行。
- 这意味着,尽管该方法在步骤数上是保证有限的,但在实际的执行时间上,它不能保证每次都能立即完成。
进度条件对延迟分布的影响
- 不同类型的进度条件确实会影响延迟分布,即系统响应的时间和任务完成的时间分布。不同的进度条件(如无锁、等待自由等)对延迟分布有不同的影响,尤其是在高并发的环境下,这种差异可能会变得更加明显。
- 不过,延迟分布的详细分析本身是一个非常复杂的话题,通常需要专门的演讲或研究来详细探讨。
总结
- 进度条件的定义通常基于步骤数而非时间,这可能会让人误解为“等待自由”方法意味着没有任何等待时间。
- 然而,等待自由并不能完全消除时间延迟,特别是在多线程环境中,调度器的行为可能会引起线程执行的延迟。
- 不同的进度条件影响着系统中任务完成的延迟分布,这一点非常关键,但需要专门的深入分析才能全面理解。
进度条件中的阻塞(Blocking)方法,并提供了一个代码示例来说明即使方法是阻塞的,也并不意味着它就不能扩展或提高效率。具体来说,讨论了在多线程情况下,尽管方法是阻塞的,仍然可能通过其他方式提高效率。以下是详细解释:
Blocking 方法的解释
- 阻塞(Blocking)方法意味着,某些操作或代码会导致调用线程被阻塞,直到某个操作完成。这通常与锁(lock)、等待等行为相关。
- 阻塞方法的缺点是,调用线程在执行某些操作时可能需要等待,例如在使用互斥锁(
std::mutex
)时,线程必须等待直到其他线程释放锁。
示例代码
void someMethod(auto& data) { auto result = aVeryComplexComputation(data); // Takes 10 µs to complete // The next two lines take 2 µs to complete std::lock_guard<std::mutex> lock(amutex); // Lock the mutex to ensure exclusive access to shared resourceglobalResult += result; // Update the global result
}
aVeryComplexComputation(data)
:假设这个计算过程非常复杂,需要 10 微秒(µs) 才能完成。它是计算密集型的,且每次执行时需要固定时间。std::lock_guard<std::mutex> lock(amutex)
:这里使用了一个互斥锁(mutex)来保证对共享资源(globalResult
)的访问是线程安全的。锁会阻塞当前线程,直到它能够获得锁。globalResult += result
:更新全局结果的操作也是一个非常短的操作,假设它只需要 2 微秒(µs)。
讨论:阻塞不一定意味着不能扩展
- 即使方法是阻塞的,它依然可以在多线程环境中扩展,具体取决于计算密集型任务的分配和锁的管理。在这个例子中,虽然
aVeryComplexComputation
需要 10 微秒来完成,但整个方法的执行过程并不是完全由锁阻塞的。 - 多线程的效果:如果你使用 10 个线程 来调用这个方法,每个线程执行
aVeryComplexComputation
,每个线程的执行时间是 10 微秒。这部分是并行的,10 个线程能并行处理 10 个数据项,每个线程各自执行计算,而互斥锁只会影响globalResult
更新的操作。锁的操作只占用了 2 微秒,而且它是串行执行的——即只有一个线程能成功执行更新globalResult
的操作。
具体的:- 如果你用 10 个线程来并行执行
aVeryComplexComputation
,每个线程的计算任务会并行进行,所以下来的总时间应该接近 10 微秒,而不是 100 微秒(即 10 个线程总共需要的时间),因为计算是并行的。 - 锁仅影响全局结果的更新部分,但因为它只需要 2 微秒,并且这部分操作是串行的,所以不会显著影响整个程序的运行时间。
- 如果你用 10 个线程来并行执行
结论
- 阻塞(Blocking)方法并不意味着不能提高效率或扩展。如果操作中的阻塞部分只是少数几行代码(如锁的部分),而且主要计算工作是可以并行化的,那么增加线程数(并行化计算)可以显著减少整体执行时间。
- 但锁部分依然是串行的,因此如果涉及共享资源的更新,还是会有一定的性能瓶颈。因此,为了进一步提高并行度,锁优化(如减少锁的持有时间、使用锁粒度较小的锁等)是提高并发性能的关键。
你有两个方法 methodA()
和 methodB()
,并且我们需要判断在拥有足够核心的情况下,哪一个方法能够在更多线程下扩展(即具有更好的并行性和性能)。
方法分析:
方法 A:
void methodA(auto& data) { auto result = aNotSoComplexComputation(data); // Takes ~10 µs std::lock_guard<std::mutex> lock(amutex); globalResult += result; // Takes ~2 µs
}
aNotSoComplexComputation(data)
:该操作大约需要 10 微秒(µs) 来完成。这是一个相对较简单的计算任务,可以在多线程环境中并行化。std::lock_guard<std::mutex> lock(amutex)
:这里使用了互斥锁(mutex)来确保对globalResult
的更新是线程安全的。锁的操作需要大约 2 微秒(µs)。globalResult += result
:对全局结果进行更新的部分是串行的,所有线程都必须等待获取锁后才能修改全局结果。
方法 B:
void methodB(auto& data) { auto result = aVeryComplexComputation(data); // Takes ~20 µs std::lock_guard<std::mutex> lock(amutex); globalResult += result; // Takes ~2 µs
}
aVeryComplexComputation(data)
:该操作需要大约 20 微秒(µs) 来完成,比方法A的计算更复杂。std::lock_guard<std::mutex> lock(amutex)
:与方法A相同,锁操作需要大约 2 微秒(µs)。globalResult += result
:与方法A相同,更新全局结果是串行的,所有线程都必须通过锁。
对比分析:
- 计算复杂度:
- 方法A的计算任务大约需要 10 微秒,而方法B的计算任务需要 20 微秒。从计算上看,方法A的任务更加轻量,意味着更多线程可以同时并行执行这些计算任务,而不会被计算本身拖慢速度。
- 方法B由于其较复杂的计算(20 微秒),每个线程在执行时会被计算部分拖慢速度,因此不如方法A能有效地扩展。
- 锁操作:
- 两个方法中的锁操作都是 2 微秒,但锁部分是串行的,意味着多个线程不能同时更新
globalResult
,它们必须排队等待获取锁。因此,无论是方法A还是方法B,都面临着同样的瓶颈:全局结果的更新是串行的。 - 但计算部分的差异使得方法A相较于方法B能够在更多线程下扩展,因为方法A的计算任务更轻,线程可以花费更多时间在计算上,而不是在锁的争用上。
- 两个方法中的锁操作都是 2 微秒,但锁部分是串行的,意味着多个线程不能同时更新
- 多线程扩展性:
- 方法A:由于计算任务较轻,每个线程能更快地完成自己的工作。因此,线程可以更快速地释放,允许更多的线程执行。这使得方法A能够更好地扩展到多个线程。
- 方法B:由于计算任务较复杂,单个线程需要更多的时间来完成计算。因此,虽然有多个线程,但它们会被计算任务拖慢,整体并行度无法充分利用。
结论:
方法A能够更好地扩展到更多线程,因为它的计算任务较简单,线程可以更快完成计算并释放锁。而方法B的计算任务较复杂,导致每个线程的工作时间较长,无法充分利用多核处理器的并行能力。
因此,答案是 a) methodA()。
Starvation-Free:
- Starvation-Free 是一个锁相关的属性,意味着每个试图获取锁的线程最终都会成功。换句话说,每个线程的
lock()
调用最终会返回。这种属性有时被称为 Lockout Freedom。 - 在 阻塞方法(Blocking Methods) 中,无饿死(Starvation-Free) 属性是非常理想的,因为它有助于提高公平性并降低延迟。
SpinLock 示例:
SpinLock
是一个常见的锁实现,它并不具备无饿死(Starvation-Free)属性。
SpinLock 实现分析:
#include <atomic>
class SpinLock { int UNLOCKED = 0; int LOCKED = 1; std::atomic<int> mlock = { 0 };
public: void lock() { int tmp = UNLOCKED; while (!mlock.compare_exchange_strong(tmp, LOCKED)) { std::this_thread::yield(); // 如果锁被占用,当前线程让出CPU时间片tmp = UNLOCKED; } } void unlock() { mlock.store(UNLOCKED, std::memory_order_release); }
};
锁机制解释:
- 锁状态(UNLOCKED / LOCKED):
UNLOCKED
(0)和LOCKED
(1)分别表示锁的两种状态。
lock()
方法:- 线程尝试通过
compare_exchange_strong()
方法将mlock
从UNLOCKED
状态改变为LOCKED
。 - 如果
compare_exchange_strong()
返回false
(即锁已被其他线程持有),当前线程将调用std::this_thread::yield()
,让出 CPU 时间片,让其他线程有机会执行。 - 然后,当前线程再次尝试获取锁。这种方式称为 自旋锁(SpinLock),即线程在等待锁的过程中不断地循环检查锁的状态。
- 线程尝试通过
unlock()
方法:- 释放锁,将
mlock
设置为UNLOCKED
,使其他线程能够获取锁。
- 释放锁,将
SpinLock 的问题:
- Starvation-Free:
- SpinLock 并不具备无饿死的属性。在高并发的情况下,如果一个线程持续不断地获取到 CPU 时间片,它就可能永远占用锁,导致其他线程无法获取锁,造成饿死(Starvation)。例如,如果一个线程一直占用 CPU,其他线程会因为无法获得锁而永远等待。
- 公平性:
- SpinLock 没有任何形式的公平性保障。即使多个线程依次请求锁,也有可能某个线程因为自旋方式一直未能成功获取锁。
- 延迟:
- 在某些场景下,SpinLock 会导致较高的延迟,因为线程会一直自旋等待锁的释放,尤其是在高负载下。
总结:
- Starvation-Free 属性保证了每个请求锁的线程最终都会成功,而 SpinLock 由于自旋等待的特性,容易导致饿死(starvation),从而不满足无饿死属性。
Quiz 2: lock()
方法的进度条件是什么?
给定代码:
#include <atomic>
class TicketLock { std::atomic<long long> ticket = { 0 }; std::atomic<long long> grant = { 0 };
public: void lock() { long long lticket = ticket.fetch_add(1); // 获取一个新的票号while (lticket != grant.load()) { // 等待直到自己的票号与 grant 匹配std::this_thread::yield(); // 放弃处理器} } void unlock() { long long lgrant = grant.load(std::memory_order_relaxed);grant.store(lgrant + 1, std::memory_order_release); // 增加 grant 值}
};
进度条件分析:
这个锁实现被称为 票据锁(Ticket Lock),其工作原理如下:
- 票据机制:
- 每个线程通过
fetch_add(1)
获取一个“票号”,确保每个线程有一个唯一的编号。 - 拥有最小票号的线程最终可以获得锁,因为它会等待其票号与
grant
值匹配。
- 每个线程通过
- 等待机制:
- 如果线程的票号与
grant
值不相同,它会进入一个while
循环,反复调用std::this_thread::yield()
来让出 CPU 的控制权。 - 这意味着线程不断检查是否能够获取锁,如果不能,它就会主动让出 CPU 让其他线程执行。
- 如果线程的票号与
- 解锁:
unlock()
方法会增加grant
值,从而允许下一个排队的线程获取锁。
进度条件解释:
- 阻塞(Blocking):
- 这个锁是 阻塞 的,因为线程会在获取不到锁时阻塞自己,它会反复检查并让出 CPU 时间。它不能保证线程会立刻完成操作,而且其他线程也可能会在等待这个锁时阻塞。
- 免饥饿(Starvation-Free):
- 该锁机制确保拥有最小票号的线程最终可以获得锁,因此不会有线程被饿死(永远得不到锁)。因此,它保证了 免饥饿。
- 非阻塞(Non-Blocking) 或 无锁(Lock-Free):
- 这个锁 不是非阻塞 或 无锁 的。非阻塞或无锁意味着至少有一个线程总能成功执行其操作,而在这里,线程通过让出 CPU 来等待,必须等到票号匹配才能获得锁。
结论:
lock()
方法的正确 进度条件 是:- b) 阻塞,但免饥饿
进度条件 - 免饥饿(Starvation-Free)
免饥饿解释:
- 免饥饿(Starvation-Free) 是指每个线程在竞争锁时,最终都能够成功地获取到锁,而不会被永久性地阻塞或忽视。
- 这意味着,尽管有多个线程可能在等待同一个资源(例如锁),每个线程都能在有限的时间内完成它的操作。
Ticket Lock 示例:
在这个 Ticket Lock 锁的实现中,使用的是 std::atomic::fetch_add()
来获取票号,该方法是通过单一指令(XADD
)实现的,而不是循环比较交换(CAS)。这种设计确保了线程能够按顺序获得锁,而不会因某个线程长时间无法获得锁而导致饥饿。
#include <atomic>
class TicketLock { std::atomic<long long> ticket = { 0 }; std::atomic<long long> grant = { 0 }; void lock() { long long lticket = ticket.fetch_add(1); // 获取票号while (lticket != grant.atomic_load()) { // 等待直到票号匹配std::this_thread::yield(); // 主动让出 CPU} } void unlock() { long long lgrant = grant.load(std::memory_order_relaxed); grant.store(lgrant + 1, std::memory_order_release); // 释放锁,增加 grant}
};
分析:
ticket.fetch_add(1)
:- 每个线程通过
fetch_add(1)
获取一个票号。由于这是一个原子操作,多个线程可以并发地安全地递增票号。 fetch_add(1)
是通过硬件的单指令(例如XADD
)来实现的,性能较高且不依赖循环和条件判断,因此不会导致活锁或饥饿问题。
- 每个线程通过
while (lticket != grant.load())
:- 每个线程在获取锁时都会与
grant
比较票号。如果ticket
的值(即当前线程的票号)不等于grant
(即已经被允许访问锁的票号),那么线程就会调用std::this_thread::yield()
,主动让出 CPU 给其他线程。
- 每个线程在获取锁时都会与
std::this_thread::yield()
:- 让出 CPU 给其他线程,避免占用过多的时间片,这虽然让线程处于等待状态,但也确保了每个线程都会在一定时间内有机会执行,并且最终都会完成其任务。
unlock()
:unlock()
方法增加grant
值,允许下一个排队的线程获得锁。
免饥饿性:
由于 Ticket Lock
总是按票号顺序授予锁,且每个线程的票号唯一,最终每个线程都能获得锁,而不会因为竞争而被永久阻塞。因此,Ticket Lock
是免饥饿的。
总结:
- 免饥饿(Starvation-Free): Ticket Lock 是免饥饿的,因为其核心是 等待-free 技术。每个线程最终都会获得锁,不会被永久阻塞。
- 规则:通常,使用等待-free 技术的锁(如 Ticket Lock、CLH Lock、Tidex Lock)是免饥饿的,而使用无锁技术的锁(如 CAS Spinlock)则可能不会保证免饥饿。
进度条件 - 无锁(Lock-Free)
无锁定义:
根据《Multiprocessor Programming(多处理器编程的艺术)》一书,无锁(Lock-Free) 的定义是:
一个方法是 无锁 的,如果它保证:某个线程调用此方法时,能够在有限的步骤内完成。
换句话说,无论其他线程是否在运行,至少有一个线程能够完成它的操作,而不会陷入无休止的等待。
简单判断无锁的方法:
我们可以通过以下简单的方法来判断某个方法是否无锁:
- 如果能够证明在某个实例的方法调用中,至少有一个线程会在有限步骤内完成,那么这个方法至少有一个 无锁 进度条件。
无锁方法和饥饿:
- 无锁并不意味着没有饥饿。也就是说,无锁方法 可以存在饥饿现象。
- 无锁方法可能会导致饥饿:虽然总有线程能够完成任务,但并不保证所有线程都会被公平地调度,某些线程可能会长期得不到执行。
无锁方法示例:
下面是一个典型的无锁方法的示例,使用了 Maged Michael 和 Michael Scott 在其论文《Simple, Fast, and Practical Non-Blocking and Blocking Concurrent Queue Algorithms》(1996年)中提出的算法,来实现一个无锁队列的插入操作。
bool add(T *item) { Node *newNode = new Node(item); while (true) { Node *t = tail.load(); // 获取队列尾节点Node *q = t->next.load(); // 获取尾节点的下一个节点if (q == nullptr) { // 如果队列为空,尝试将新节点插入到尾节点,并更新尾指针if (t->casNext(nullptr, newNode)) { // casNext为原子操作casTail(t, newNode); // 更新尾指针return true; } } else { // 如果尾节点指向下一个节点,尝试移动尾指针casTail(t, q); } }
}
代码解释:
Node *newNode = new Node(item);
- 创建一个新节点
newNode
,用于插入队列。
- 创建一个新节点
Node *t = tail.load();
- 获取队列的当前尾节点
t
,它是一个原子操作。
- 获取队列的当前尾节点
Node *q = t->next.load();
- 获取尾节点
t
的下一个节点q
。
- 获取尾节点
if (q == nullptr)
- 如果队列尾节点没有指向任何下一个节点(即
q == nullptr
),说明队列为空或是尾节点刚刚插入的节点,此时可以将新节点插入到尾节点的next
上,并尝试更新尾指针。
- 如果队列尾节点没有指向任何下一个节点(即
if (t->casNext(nullptr, newNode))
casNext
是一个原子操作,它尝试将tail->next
设置为newNode
,如果成功则继续执行,否则尝试更新尾指针。
casTail(t, newNode);
- 更新尾指针
tail
,指向新的尾节点newNode
。
- 更新尾指针
else { casTail(t, q); }
- 如果尾节点的
next
已经不为空(说明可能有其他线程插入了元素),则更新尾指针,指向下一个有效节点q
。
- 如果尾节点的
无锁性质:
- 这个方法是 无锁 的,因为它使用了原子操作(如
casNext
和casTail
),每个线程都可以在有限的时间内进行自己的操作,无需阻塞其他线程。 - 然而,它并不保证没有饥饿,某些线程可能因为不断地被其他线程插入而永远无法更新尾指针,导致无法完成它的任务。
总结:
- 无锁(Lock-Free) 方法保证至少有一个线程会在有限步骤内完成,但它不能保证避免所有线程的饥饿现象。
- 例如,上面的
add
方法是一个典型的无锁方法,虽然它不会导致死锁或无限阻塞,但可能会出现某些线程的饥饿现象。
进度条件 - 等待自由(Wait-Free)
定义:
一个方法是 等待自由(Wait-Free) 的,如果它保证每个调用都能在有限的步骤内完成其执行。
与锁自由(Lock-Free)的比较:
- 锁自由(Lock-Free) 保证至少有一个线程能够在有限步骤内完成任务,而 等待自由(Wait-Free) 保证每个线程都能在有限步骤内完成任务。
- 性能比较:
- 目前尚未证明 等待自由方法(Wait-Free) 始终比 锁自由方法(Lock-Free) 提供更好的性能。
- 在某些情况下,等待自由 方法可能会导致额外的开销,因为它必须为所有线程提供保证,而这在实现上可能比锁自由方法更复杂。
进度和饥饿:
- 不会发生饥饿: 和 锁自由(Lock-Free) 方法不同,等待自由(Wait-Free) 方法绝不会导致任何线程被饿死,即使其他线程的执行时间非常长。
- 等待自由 方法保证了每个线程都能完成任务,如果该线程获得了 CPU 时间,它会在有限步骤内完成,不会因为其他线程的操作而永远无法执行。
总结:
- 等待自由方法(Wait-Free) 提供了更强的进度保证,每个线程都能够在有限的步骤内完成其任务。
- 与 锁自由方法(Lock-Free) 相比,等待自由方法有时更复杂,因为它必须为所有线程提供进度保证,可能会引入更多的开销。
- 等待自由方法 永远不会导致饥饿,每个线程都能在有限的时间内完成它的操作。
等待自由 (Wait-Free) — 误解澄清
尽管 等待自由(Wait-Free)方法的名字可能让人认为它没有等待,但实际上它并不是完全没有等待。以下是一些澄清:
1. 不代表完全没有等待:
- 等待自由 方法并不是指它完全没有等待时间。它指的是每个线程都会在有限的步骤内完成任务,但完成的时间仍然取决于线程调度器给定的时间片。
- 如果线程的时间片不足以完成等待自由方法,那么可能会发生 上下文切换,从而增加完成方法的绝对时间。
2. 线程调度的影响:
- 即便方法本身是等待自由的,如果线程调度器给予的时间片非常小,线程可能无法在一次调度中完成方法的执行。
- 如果线程调度器不公平,某个线程可能会一直被抢占,导致它无法继续执行,从而无法取得进展——即使方法本身是等待自由的。
3. 线程抢占与进度:
- 等待自由方法保证的是:每个线程都能在有限步骤内完成任务,但它并没有消除等待的可能性。如果线程调度器选择不公平地抢占线程,可能会导致线程无法取得进展。
总结:
- 等待自由(Wait-Free) 方法并不意味着没有等待。它只是保证每个线程最终能够在有限步骤内完成任务,但它的执行仍然受限于线程调度器的时间片和调度策略。
- 如果线程调度器不公平,某个线程可能会在等待自由方法下没有任何进展。
Progress Conditions: Wait-Free Population Oblivious(WFPO)
** 定义:**
- WFPO(等待自由与线程数无关) 是一种 最强的进度条件。
- 如果一个 Wait-Free(等待自由) 方法的性能 不依赖于活跃线程数量,那么它就被称为 Wait-Free Population Oblivious(WFPO)。
** 更通俗地理解:** - 一般的 Wait-Free 方法虽然可以保证每个线程都能在有限步数内完成任务,但其性能可能会随着线程数量增加而变差。
- WFPO 方法更进一步,它即使在系统中有很多线程同时运行,其执行效率和完成时间也不会受到影响。
- 换句话说:不管有几个线程,它都一样快!
** 例子(x86 上的典型 WFPO 操作):**
以下这些原子操作在 x86 架构上属于 WFPO(注意:在其他平台可能不是):
std::atomic<int>::load(); // 加载一个原子变量的值
std::atomic<int>::store(); // 设置一个原子变量的值
std::atomic<int>::exchange(); // 交换一个原子变量的值
std::atomic<int>::fetch_add(); // 原子加
std::atomic<int>::compare_exchange_strong(); // 强比较并交换(假设没有重试)
这些操作在硬件层面都能在 固定时间内完成,不管系统中有多少线程。
** 与其他进度条件对比:**
类型 | 是否所有线程都能完成? | 执行时间是否确定? | 是否受线程数影响? |
---|---|---|---|
Lock-Free | (可能有线程饿死) | 可能变差 | |
Wait-Free | 有限步数 | 可受线程数影响 | |
WFPO(最强) | 有限且固定步数 | 不受线程数影响 | |
** 总结一句话:** |
WFPO 是 Wait-Free 中最强的一种,线程再多也不会影响执行效率,是多线程并发中的理想目标。
Quiz(测验)关于类 Cookie
中每个方法的进度条件(Progress Condition):
问题:
类 Cookie
的三个方法分别属于哪种进度条件?
选项包括:
- a) Lock-Free(无锁)
- b) Wait-Free Unbounded(等待自由但步骤不确定)
- c) Wait-Free Bounded(等待自由且步骤有界)
- d) Wait-Free Population Oblivious(等待自由与线程数无关)
类 Cookie 分析:
class Cookie {std::atomic<enum cookie_type> type;std::atomic<int> chocolateChips[MAX_THREADS];cookie_type getCookieType() {return type.load();}int getTotalChocolateChips() {int sum = 0;for (int i = 0; i < MAX_THREADS; i++) {sum += chocolateChips[i].load();}}void addChocolateChips(int numChips) {while (true) {int tmp = chocolateChips[0].load();if (chocolateChips[0].compare_exchange_strong(tmp, tmp+1)) {return;}}}
};
每个方法的进度条件分析:
1. getCookieType()
return type.load();
- 这是一个对原子变量的 load() 操作,在 x86 架构中属于 单条指令。
- 所以是:Wait-Free Population Oblivious(与线程数无关的等待自由)
2. getTotalChocolateChips()
for (int i = 0; i < MAX_THREADS; i++) {sum += chocolateChips[i].load();
}
- 进行了 MAX_THREADS 次原子加载。
- 每一次
load()
是 WFPO,但整体循环次数取决于线程数。 - 所以是:Wait-Free Bounded(等待自由且步数受线程数上限限制)
3. addChocolateChips()
while (true) {int tmp = chocolateChips[0].load();if (chocolateChips[0].compare_exchange_strong(tmp, tmp+1)) {return;}
}
- 使用了 CAS 循环(compare-and-swap)。
- 在竞争激烈时,可能会反复重试,但至少有一个线程会成功。
- 所以是:Lock-Free(无锁但不保证每个线程都完成)
总结答案:
方法名 | 进度条件 |
---|---|
getCookieType() | Wait-Free Population Oblivious |
getTotalChocolateChips() | Wait-Free Bounded |
addChocolateChips() | Lock-Free |
全面理解「进度条件(Progress Conditions)」和它们与 延迟(Latency) 的关系。
内容总结:Progress Conditions & Latency(进度条件与延迟)
本段内容主要讲的是:
使用具备不同进度条件的算法,在多线程并发环境下分析它们的 延迟分布(Latency Distribution),看看在线程数增加时,这些算法的表现如何变化。
实验目的:
探索在高并发(高线程数)冲突场景下,不同进度条件算法的性能表现(具体是延迟)——
这并不是严格的性能评估,而是一个「玩具实验」,用来给出一个直观的比较。
分析的 5 种算法类型(对应不同进度条件):
类型 | 说明 |
---|---|
Blocking | 使用一个带有 CAS 循环的 SpinLock(即忙等锁) 会阻塞,延迟可堆积 |
Starvation-Free | 使用 Ticket Lock(票据锁),每个线程公平等待 延迟稳定但线性增长 |
Lock-Free | 用 CAS 循环,操作时间有 20% 的抖动(不确定性) 有可能某个线程一直失败但总体有进展 |
Wait-Free Bounded | 每个线程更新一个长度为 NUM_THREADS 的数组(对每个线程位都写入) 执行步骤数量是有上限的,但受线程数影响 |
Wait-Free Population Oblivious(WFPO) | 每个线程只操作自己独占(cache-line 对齐)的数组元素 不受其他线程影响,延迟极低且稳定 |
最后提醒:
「以下几页图表仅供参考,仅用于帮助你直观理解在高并发情况下,5 种进度条件所带来的不同延迟表现。」
中文小结:
- 不同的 Progress Condition(进度条件) 会影响线程在执行操作时的 响应延迟(Latency),尤其是在多线程竞争严重的场景下。
- 延迟分布是否可预测、是否有最坏情况、是否受其他线程影响,是衡量系统可扩展性的重要指标。
- 特别注意:Wait-Free ≠ 没有延迟,它只是保证不会无限等待,并不保证你马上完成。
Wait-Free 与 Lock-Free 方法在延迟(Latency)方面的差异。
中文理解:
Lock-Free 方法的延迟特性:
- Lock-Free(无锁) 方法的特点是:保证「系统整体」不断前进,但不保证每个线程都能在有限时间内完成。
- 因此,某个线程可能会无限次失败,被其他线程抢占资源,导致一直得不到执行机会(发生“饥饿”)。
- 这就意味着:
Lock-Free 方法的延迟分布曲线会有「无限尾巴」(infinite tail),也就是说——在理论上,延迟可能无限大。
Wait-Free 方法的延迟特性:
- Wait-Free(等候无关) 方法则保证每个线程在有限的步骤内完成操作。
- 所以它的延迟不会无限延长,即:
Wait-Free 方法的延迟是有上限的(cut-off),每个线程的执行时间最多就是某个最大值。
举个简单例子:
假设有 10 个线程在执行一个共享队列的入队操作:
- 如果这个队列的实现是 Lock-Free,那么某个倒霉的线程可能一直 CAS 失败,被其他线程抢占——理论上永远卡着。
- 如果这个队列是 Wait-Free 的设计,每个线程一定能在规定的步骤内完成 —— 不会卡死也不会无限等待。
小结:
特性 | Lock-Free | Wait-Free |
---|---|---|
保证系统进展 | ||
保证每个线程完成 | (可能饿死) | |
最坏延迟(理论上) | 无穷大(无限尾巴) | 有上限(cut-off) |
如果你要构建一个高实时性或强公平性的系统,Wait-Free 会更有保障,但代价是更高的实现复杂度和资源开销。 |
“进度条件(Progress Conditions)”这一概念的总结。以下是详细的中文理解和归纳:
中文理解(逐条解释)
1. 进度条件是按“操作步骤数的上限”定义的
- 即,一个方法完成所需的最大操作步骤数(steps)是多少 —— 它不关注实际时间,只关注理论上的执行步骤数量是否有限。
- 举例:一个方法如果最多只需 100 步就能完成,那它可以被认为是“有界的 wait-free”。
2. 进度条件 ≠ 执行时间
-
“wait-free” 并不是“没有等待时间”,只是保证:
“只要线程被调度到执行,它一定能在有限步骤内完成。”
-
线程调度的不确定性(如被操作系统抢占)依然会影响真实执行时间。
3. 同一个类的不同方法可以具有不同的进度条件
- 例如:
get()
方法可能是 Wait-Free Population Obliviousadd()
方法可能是 Lock-Free
- 因此,要分析具体方法的行为,而不是整个类或结构的行为。
4. 内存的分配和释放通常是 Blocking 的
- 大多数内存分配器(如
malloc()
、new
)在多线程下使用锁,因此是 Blocking(阻塞型)。 - 尤其在高并发下,内存管理常常成为瓶颈。
5. Lock-Free = 有可能发生线程饿死
- Lock-Free 虽然避免了死锁,但不保证每个线程都能顺利完成任务。
- 某个线程可能一直被抢占,导致永远完成不了它的操作。
6. 最理想的 Blocking 是 Starvation-Free(无饥饿)
- 例如:Ticket Lock 可以保证公平性,每个线程最终都会拿到锁。
- 相比之下,普通的自旋锁(Spinlock)可能让某些线程永远得不到机会。
7. 最理想的进度条件是 Wait-Free Population Oblivious(等候无关 + 与线程数无关)
-
不仅每个线程都能完成操作(Wait-Free),而且:
无论有多少线程,性能都不会下降。
-
这是并发编程中的“黄金标准”,但实现非常复杂。
8. 线程只要获得时间片,调用 wait-free 方法就一定能完成操作
- 不会浪费资源。
- 这是构建高实时性系统(如音视频处理、交易系统)时非常重要的属性。
小结对比表:
进度条件 | 特点 | 是否可饿死 | 是否阻塞 | 是否公平 | 实现难度 |
---|---|---|---|---|---|
Blocking | 会等待锁 | 可能 | 是 | 可能不公平 | 简单 |
Starvation-Free | 每个线程最终都能成功 | 否 | 是 | 公平 | 较复杂 |
Lock-Free | 总体进展保证 | 可饿死 | 否 | 不保证 | 中等 |
Wait-Free | 每个线程保证完成 | 否 | 否 | 公平 | 较复杂 |
WFPO | 强 wait-free,线程数无关 | 否 | 否 | 极公平 | 最难 |
Reader-Writer Locks(读写锁)
1. 什么是读写锁?
- **读写锁(Reader-Writer Lock)**是一种允许:
- 多个线程并发读取(共享访问),
- 但写入时必须独占(独占访问)的同步机制。
- 与传统的互斥锁(Mutex)不同,读写锁允许多个 Reader 同时持锁,只要没有 Writer。
2. 读写锁仍然是阻塞型(Blocking)
- 即使读者可以并发访问,只要有写者存在,读者仍需等待写者释放锁;
- 写者也要等待所有读者释放锁后才能获得独占访问权;
- 所以从“进度条件”角度来看,读写锁依旧属于Blocking。
3. 各种风格的 RW-Lock(读写锁种类)
读写锁的具体实现有很多种,差异主要体现在以下几个维度:
调度顺序风格:
- 严格 FIFO 顺序:
- 如:MCS、CLH、Ticket Lock、Tidex Lock。
- 所有线程严格按照进入顺序获得锁,公平性高,但性能可能受限。
- Phase-Fair(阶段公平):
- 某一阶段允许多个读者,下一阶段允许一个写者,交替进行;
- 提供较好的公平性与性能平衡。
- Unfair(不公平):
- 通常通过 CAS 自旋循环实现;
- 性能高但可能会造成线程饥饿,即某些线程长时间拿不到锁。
优先级策略:
- 写者优先(Writer-Preference):
- 若写者在等待,则不再允许新的读者进来;
- 优点:避免写者饿死;
- 缺点:高读并发下写者依然可能等待较久。
- 读者优先(Reader-Preference):
- 写者必须等待当前所有读者完成;
- 高读多写场景下容易写者饥饿。
- 无偏向(公平):
- 不偏向读者或写者;
- 通常使用排队或标志位实现,保证请求先来先处理。
饥饿问题(Starvation):
- 有些实现提供Starvation-Freedom(无线程饥饿);
- 有些则可能让写者或读者长时间得不到锁(特别是在偏向策略下)。
是否支持乐观读取:
- StampedLock、Seqlock 等锁提供:
- 乐观读取(Optimistic Reads);
- 允许在不加锁的前提下读取数据,并在修改期间验证版本是否变化;
- 极大提高读取性能,适用于写少读多的场景。
4. 常见的 RW-Lock API
- C语言:
- 提供
pthread_rwlock_t
(POSIX 线程库);
- 提供
- C++ Boost库:
- 提供
boost::shared_mutex
;
- 提供
- C++17 标准库:
- 提供
std::shared_mutex
和std::shared_timed_mutex
; - 可用于现代多线程 C++ 开发,具备超时功能。
- 提供
总结一句话:
读写锁是一种“在多个读者之间共享,在写者面前独占”的并发控制机制,虽然读性能好,但从进度条件角度仍属阻塞型,有多种策略与实现方式。
读写锁(Reader-Writer Locks)在最近算法研究中的一些新进展,下面是详细的中文理解与解释:
Reader-Writer Locks:近期研究进展(Recent Work)中文理解
1. 最近的研究成果
有两项较新的研究工作在读写锁算法方面做出了改进:
论文 1:
《Design, Verification and Applications of a New Read-Write Lock Algorithm》
作者:Jun Shirako 和 Nick Vrvilo
- 专注于读写锁的新设计和形式验证,提升正确性和适用性。
论文 2:
《NUMA-Aware Reader-Writer Locks》
作者:Irina Calciu、Dave Dice、Yossi Lev、Victor Luchangco、Virendra Marathe 和 Nir Shavit
- 这篇论文中提出了三个 RW 锁算法,其中之一是重点介绍的:
C-RW-WP(Core-based Reader-Writer with Writer Preference)
2. C-RW-WP 算法的优势
- 高可扩展性(Scalability):
在读操作多、写操作极少的场景下,能显著提高并发性能。 - 无需 NUMA API:
尽管论文标题中提到了“NUMA-Aware”,但实现这个算法不需要使用任何 NUMA 专用 API。它通过简单的技术实现线程隔离,提高并发性能。
3. 实现方式简述
- 核心技术:
- 使用一个按 CPU 核心数量划分的原子计数器数组;
- 每个线程通过其线程 ID 进行哈希,映射到某个计数器;
- 这样可以避免线程之间竞争相同的锁,从而提升读取性能。
- 开源实现地址(C++):
- 头文件:https://github.com/pramalhe/ConcurrencyFreaks/blob/master/CPP/locks/DCLCRWLock.h
- 源文件:https://github.com/pramalhe/ConcurrencyFreaks/blob/master/CPP/locks/DCLCRWLock.cpp
4. C-RW-WP 的主要缺点
尽管这个算法非常适合高并发读取场景,但也存在一些不足:
内存消耗较高:
- 每个读写锁实例,每个 CPU 核心至少消耗一个缓存行(通常是 64 字节);
- 在核心数多的系统中,内存占用不可忽视。
写者优先(Writer-Preference):
- 如果持续有写者,理论上可能会导致读者饥饿(Starvation);
- 不过在大多数实际场景下,这种饥饿不太可能真正发生,因此是一个理论问题。
总结一句话:
C-RW-WP 是一种适用于“多读少写”场景的高性能读写锁算法,具有很强的可扩展性,但以更高的内存代价和写者优先策略为代价。
这段内容展示了使用 pthread_rwlock_t
实现线程安全的读写操作的一个简单示例:
使用 pthread_rwlock_t 的代码说明 中文理解
背景
pthread_rwlock_t
是 POSIX 线程库中的 读写锁 类型;- 它支持:
- 多个线程同时读(共享锁)
- 单个线程写(独占锁)
- 非常适合 读多写少 的数据结构保护场景。
示例结构说明
这段代码实现了一个线程安全的集合操作类,通过 pthread_rwlock_t
对内部集合 _set
的操作进行保护。
类定义:
template<typename T>
class RWLockLinkedListPT {
private: LinkedListSet<T> _set; pthread_rwlock_t _rwlock = PTHREAD_RWLOCK_INITIALIZER;
_set
是一个链表实现的集合(LinkedListSet
);_rwlock
是用于保护集合的读写锁。
写操作:add
和 remove
bool add(T key) {pthread_rwlock_wrlock(&_rwlock); // 获取写锁bool retValue = _set.add(key); // 修改集合pthread_rwlock_unlock(&_rwlock); // 释放锁return retValue;
}
- 获取写锁(
pthread_rwlock_wrlock
):阻塞其它读写操作; - 修改集合内容;
- 最后释放锁。
remove
方法与add
几乎相同,也是修改操作,需要写锁。
读操作:contains
bool contains(T key) {pthread_rwlock_rdlock(&_rwlock); // 获取读锁bool retValue = _set.contains(key); // 读取集合pthread_rwlock_unlock(&_rwlock); // 释放锁return retValue;
}
- 获取读锁(
pthread_rwlock_rdlock
):允许多个线程同时读取; - 调用
_set.contains()
检查元素是否存在; - 释放锁。
中文总结:
这段代码展示了如何用 pthread 的读写锁机制 来保护链表集合,使其支持:
- 多线程安全读;
- 写操作互斥进行;
- 写时阻止其它读写;
- 适用于读多写少的场景。
Reader-Writer Locks 总结(Takeaway)
主要观点:
- C++17 新增了读写锁:
std::shared_mutex
- 是 C++17 标准库中提供的一种读写锁类型;
- 支持多个线程同时进行读操作(共享锁);
- 支持一个线程进行写操作(独占锁);
- 非常适合“读多写少”的并发场景;
- 替代 Boost 中的
boost::shared_mutex
,成为现代 C++ 的官方推荐方式。
- 近期的研究发现:新的 Reader-Writer 锁算法可以在读为主的场景下实现良好的可扩展性
- 示例算法:C-RW-WP(出自论文《NUMA-Aware Reader-Writer Locks》):
- 能在大多数是读操作时保持高性能、高并发;
- 通过将线程 ID 映射(hash)到各自的缓存行,减少线程间的写冲突和缓存失效(cache line bouncing);
- 每个核心至少使用 一条缓存行(cache line),以实现最大限度的可扩展性。
- 示例算法:C-RW-WP(出自论文《NUMA-Aware Reader-Writer Locks》):
总结:
- 如果你在使用 C++17,可以直接使用
std::shared_mutex
来实现读写锁; - 如果你追求高并发读性能,特别是在多核系统下,可以考虑使用像 C-RW-WP 这样的高性能读写锁算法;
- 这类高性能算法虽然消耗更多内存(每核一个缓存行),但在读为主的负载中会显著提升扩展性和性能。
这段内容讲的是 Copy-On-Write(COW,写时复制) 技术:
Copy-On-Write(COW)中文理解
什么是 COW(写时复制)?
- COW(Copy-On-Write,写时复制)是一种并发编程技术,有时也被称为 Read-Copy-Update(RCU)。
- 但注意:它 不能和 Linux 内核中的 RCU 机制混为一谈,它们实现细节不同。
在 C++ 中的应用
- 在 C++ 里,可以将 COW 和轻量级的 RCU 结合使用,将一个 原本只能在单线程中运行的数据结构,改造成具有并发能力的数据结构。
- 读操作是 wait-free 的(永远不会阻塞);
- 写操作是 blocking 的(需要加锁)。
COW 的原理
- COW 的核心思想是:通过逻辑上的不可变性(immutability)来最小化数据的共享。
- 所有读线程共享同一个指针指向的数据,不修改原始数据本身。
- 当需要写入数据时:
- 创建一个原始数据的副本;
- 修改这个副本;
- 将共享指针原子地更新为指向这个新副本;
- 写操作完成后,读操作会自动读到新数据。
COW 的优势
- 适合 读操作远多于写操作 的场景;
- 结合不可变数据结构(immutable data structures),COW 方案既优雅又高效;
- 在高并发读取、极少修改的应用中,性能非常可观(例如配置管理、缓存系统等)。
总结
特性 | 描述 |
---|---|
读操作 | wait-free,速度快,不阻塞其他线程 |
写操作 | blocking,但不影响读取操作 |
适用场景 | 高读低写,比如:配置读取、缓存、白名单系统等 |
实现方式 | 使用共享指针 + 拷贝副本 + 原子更新 |
性能表现 | 在以读为主的系统中,可达到非常好的扩展性和吞吐率 |
Copy-On-Write(COW)结合互斥锁实现的一个示例:
Copy-On-Write + 互斥锁(COWLock)中文理解
1. 机制介绍
- 写操作需要加锁(使用互斥锁
std::mutex mut
)保护对共享指针mapRef
的写访问。 - 每次写操作(插入)都会 创建一个当前映射(
std::map
)的副本,在副本上进行修改,然后将原子指针指向这个新的副本。 - 读取时则直接访问当前原子指针指向的
map
,配合 RCU(读-复制-更新)读锁rcu_read_lock()
,确保读取期间数据不会被回收。
2. 详细过程
- 读操作:
- 调用
find()
时,先获取 RCU 读锁; - 通过原子指针访问当前
map
; - 读完后释放 RCU 读锁。
- 这样读操作不会阻塞写操作,并且是线程安全的。
- 调用
- 写操作:
- 加互斥锁
mut.lock()
; - 从
mapRef
加载当前映射currMap
; - 拷贝当前映射,创建
newMap
,对newMap
执行插入; - 用原子操作更新
mapRef
指向newMap
; - 调用
synchronize_rcu()
等待所有旧读者完成,确保旧映射不再被访问; - 解锁
mut.unlock()
; - 清空并删除旧映射
currMap
。
- 加互斥锁
3. 特点与影响
- 多版本存在:内存中会存在多个映射版本,读者在读取时都访问不同版本的映射(读副本),写者生成新的副本。
- 内存回收:旧版本映射在所有读者结束后才回收,保证了数据安全。
- 写阻塞读不阻塞:写操作是阻塞的(加锁),读操作是无锁的(RCU 保护)。
- 性能适合:读多写少的场景,读操作几乎无锁,写操作代价较大。
4. 总结
特性 | 描述 |
---|---|
写操作 | 互斥锁保护,写时复制,阻塞 |
读操作 | RCU保护,非阻塞,wait-free-like |
多版本 | 内存中存在多个副本,保证读写安全 |
内存回收 | 写完成后等待所有读者结束再回收 |
适用场景 | 读远多于写的场景 |
Copy-On-Write (COW) 加互斥锁的并发特性:
Copy-On-Write + 互斥锁(COWLock)进阶理解
1. 读写操作的不同表现
- 写操作(Writers):
- 多个写线程(尝试修改数据的线程)之间是 互斥的,即写线程必须排队等待其他写线程完成(因为写操作要加互斥锁)。
- 写线程在修改数据时会创建数据的一个新副本,然后原子替换指针指向这个新副本。
- 写线程在删除旧副本之前,必须等待所有正在访问旧副本的读线程完成,这个等待是通过
synchronize_rcu()
实现的,所以写操作是 阻塞的。
- 读操作(Readers):
- 读线程可以直接访问当前的共享指针(
ref
),访问当前最新的实例。 - 读操作不需要加锁,因此是 非阻塞的,也称为 wait-free population oblivious,意思是读操作的性能和活跃线程数无关,也不会被写操作阻塞。
- 多个读线程可以同时访问同一个实例。
- 读线程可以直接访问当前的共享指针(
2. 多个实例共存的状态示意
- 在任意时间点,内存中可能存在多个实例,比如:
instance1
、instance2
、instance3
、instance4
等。
- 读线程正在访问的实例不能被删除,必须等待这些读线程结束后,写线程才能安全删除旧实例。
- 指针
ref
始终指向最新创建的实例。
3. 总结
方面 | 行为描述 |
---|---|
写线程 | 互斥阻塞,等待写锁和RCU同步 |
读线程 | 直接访问,不阻塞,wait-free |
实例管理 | 多版本实例共存,读线程访问实例保护其不被删除 |
性能优势 | 读多写少场景下性能优越 |
Left-Right 模式是什么?:
Left-Right 模式简介
- 基本思想
Left-Right 技术和“双实例锁定”(Double Instance Locking)有些相似,都是使用两个实例,并且用一个互斥锁(mutex)来串行化写操作。它类似于计算机图形学中用到的“双缓冲”(Double Buffering)技术。 - 读操作非阻塞
与普通的锁不同,Left-Right 对于只读操作是非阻塞且高速的,读者不需要等待写锁释放,可以并行访问。 - 内存回收
Left-Right 自己管理内存回收,这意味着在 C++ 这类语言中可以安全使用,不会有悬挂指针等内存安全问题。 - 乐观读
如果需要,Left-Right 支持乐观读(optimistic reads),类似于 Linux 的 SeqLocks 或 Java 的 StampedLock,可以在读时不加锁,但需要后续验证读数据是否一致。 - 通用性和线性化
Left-Right 是一个通用且线性化(linearizable)的技术,适用于任何内存中的对象或数据结构,实现写操作的互斥保证,同时读操作高速。
简言之,Left-Right 模式是一种通过维护两个版本的对象来让读操作完全非阻塞,同时保证写操作串行的高效并发方案。
Classic Left-Right 模式的组成部分:
Classic Left-Right 组成
- 两个 ReadIndicator(读指标)
- 每个版本对应一个 ReadIndicator,用来跟踪当前有多少读者正在访问该版本的数据。
- 读指标帮助管理并发读操作,确保写操作时不会破坏正在读的数据。
- versionIndex(版本索引)
- 一个原子变量(atomic int),指示当前活跃的版本(比如 0 或 1)。
- 用来切换读写所针对的具体实例。
- leftRight 原子变量
- 也是一个原子变量,用于指示读操作是针对“左边”还是“右边”的实例。
- 配合 ReadIndicator 工作,帮助协调读者和写者的访问。
- writersMutex(写者互斥锁)
- 用于串行化写操作,保证一次只有一个写者能修改数据。
- 两个指针,指向要提供并发访问的实例
leftInst
和rightInst
是两个指针,分别指向“左边”和“右边”的实例。- 读者和写者根据
leftRight
和versionIndex
访问对应实例。
优势
- 灵活性强
Classic Left-Right 允许使用不同类型的 ReadIndicator,可以根据具体需求权衡内存占用、可扩展性和延迟。 - 读写分离
通过两个实例和读指标的协作,实现了读操作的非阻塞和写操作的互斥。
结构示意(简化版)
ReadIndicator readIndic[2];
std::atomic<int> versionIndex{0}; // 版本 0 或 1
std::atomic<int> leftRight{0}; // 读左边(0)或右边(1)
std::mutex writersMutex; // 写者锁
T* const leftInst; // 左实例指针
T* const rightInst; // 右实例指针
读者根据 leftRight
指向的版本读数据,并在对应的 ReadIndicator 中登记自己;写者持有 writersMutex
,切换版本索引并等待所有读者完成后进行写操作。
你这段是描述 Classic Left-Right 模式中写者(Writer)的算法流程:
写者算法(针对两个 std::map
实例的例子)
- 获取写者互斥锁(
writersMutex
)- 保证同一时间只有一个写者执行写操作,避免写冲突。
- 在当前不被读者访问的实例上执行写操作
leftRight
指示当前读者访问的是哪个实例,写者会在“另一个”实例上执行修改(mutation)。- 这样可以避免直接修改被读者使用的实例,保证读操作不会被阻塞或读取不一致数据。
- 切换
leftRight
标记- 把读者的访问指向刚刚修改好的那个实例。
- 等待之前的读者(对另一个实例的访问)全部完成,即等待对应的
ReadIndicator
计数器变为0(isEmpty()
)。 - 然后切换版本索引(
versionIndex
),同时等待当前版本对应的读者访问计数也变为0。
- 对另一个实例进行写操作
- 在之前的那个实例上执行相同的修改。
- 这样两个实例最终都会保持一致。
- 释放写者互斥锁
- 写操作结束,允许下一个写者进入。
整体理解
- 写者操作是交替更新两个实例(双实例),并通过原子变量协调读者访问。
- 这样读者总是读到一个稳定且不变的版本,实现了非阻塞读取。
- 写者通过切换标记和等待确保读者不会读取到正在被修改的实例,避免数据竞态。
经典 Left-Right 模式的关键部分,用于协调读者的“到达”和“离开”操作,以及写者切换版本号并等待读者完成的过程。
1. arrive()
方法
int arrive(void) {const int localVI = versionIndex.load();readIndic[localVI].arrive();return localVI;
}
- 读者调用这个方法,表示它要开始读取数据。
- 它先读取当前的
versionIndex
(版本号),然后调用对应版本的ReadIndicator
的arrive()
表示“我开始读这个版本的数据了”。 - 返回当前版本号,后续读者调用
depart()
需要用到。
2. depart(int localVI)
方法
void depart(int localVI) {readIndic[localVI].depart();
}
- 读者调用这个方法,表示它完成了对某个版本的读取。
- 传入它之前在
arrive()
返回的版本号,通知对应的ReadIndicator
计数器减少,表示离开。
3. toggleVersionAndWait()
方法
void toggleVersionAndWait(void) {const int localVI = versionIndex.load();const int prevVI = (int)(localVI & 0x1);const int nextVI = (int)((localVI + 1) & 0x1);// 等待切换后版本的读者都完成while (!readIndic[nextVI].isEmpty()) {std::this_thread::yield();}// 切换版本号versionIndex.store(nextVI);// 等待切换前版本的读者都完成while (!readIndic[prevVI].isEmpty()) {std::this_thread::yield();}
}
- 这是写者调用的关键方法。
- 它先拿到当前版本号
localVI
,计算出前一个版本号prevVI
和下一个版本号nextVI
(版本号只在 0 和 1 之间切换)。 - 先等待即将被切换成新版本号的读者都读完了,调用
readIndic[nextVI].isEmpty()
确保无活跃读者。 - 然后更新
versionIndex
,切换读者访问的新版本。 - 最后等待切换前版本的读者全部完成后,才能保证写者安全进行修改。
总结
- 读者通过
arrive()
和depart()
来标记自己的读取开始和结束。 - 写者通过
toggleVersionAndWait()
安全切换版本,确保没有活跃读者时才修改数据。 readIndic
是用来计数活跃读者的辅助结构,保证读写协调。
这段代码展示了经典 Left-Right 模式中,如何用 C++ lambda 和 std::function
实现“读操作”和“写操作”的执行接口。
1. applyRead()
方法(读操作)
template<typename R, typename A>
R applyRead(A& arg1, std::function<R(T*, A)>& readOnlyFunc) {const int lvi = lrc.arrive(); // 标记读者到来,记录版本号T* inst = leftRight.load() == READS_LEFT ? leftInst : rightInst; // 选择当前读的实例(左或右)R ret = readOnlyFunc(inst, arg1); // 执行读操作(通过传入的 lambda)lrc.depart(lvi); // 标记读者离开return ret; // 返回读操作结果
}
R
是返回类型,A
是参数类型。- 传入一个读操作的函数对象
readOnlyFunc
,它接受实例指针和参数。 - 先调用
arrive()
表示读者进入,确定当前版本。 - 根据当前
leftRight
标志,选择左实例或右实例执行读操作。 - 调用传入的读函数 lambda。
- 读完后调用
depart()
离开。 - 返回读操作的结果。
2. applyMutation()
方法(写操作)
template<typename R, typename A>
R applyMutation(A& arg1, std::function<R(T*, A)>& mutativeFunc) {std::lock_guard<std::mutex> lock(lrc.writersMutex); // 独占写锁,确保只有一个写者if (leftRight.load(std::memory_order_relaxed) == READS_LEFT) {mutativeFunc(rightInst, arg1); // 修改不被读者访问的实例leftRight.store(READS_RIGHT); // 切换读者到另一个实例lrc.toggleVersionAndWait(); // 等待读者完成return mutativeFunc(leftInst, arg1); // 修改旧实例,保持两个实例同步} else {mutativeFunc(leftInst, arg1);leftRight.store(READS_LEFT);lrc.toggleVersionAndWait();return mutativeFunc(rightInst, arg1);}
}
- 写操作通过
writersMutex
互斥锁确保写者互斥。 - 检查当前读者读的是哪个实例,写者先修改另一边的实例(未被读者访问的那个)。
- 切换读者访问实例(通过改变
leftRight
标志)。 - 等待读者完成之前版本的访问。
- 再修改旧实例,使两个实例保持同步(保证下次读时数据是最新的)。
- 返回最后一次写操作的结果。
总结
- 读操作无锁,且是非阻塞的,调用
applyRead
传入读操作 lambda,即可安全读取当前版本数据。 - 写操作是阻塞的,且互斥的,调用
applyMutation
传入写操作 lambda,写者会确保数据一致并安全切换版本。 - 这种设计实现了高效且线性的读写同步,读操作不会阻塞写操作,且写操作保证数据一致。
这段代码展示了经典 Left-Right 模式用法示例,操作的对象是 std::map<int, UserData>
,用 lambda 表达式封装了读写操作。
关键点解析
LeftRightClassicLambda<std::map<int,UserData>> lrcLambda;
定义了一个 Left-Right 对象,管理两个std::map<int, UserData>
实例,实现并发访问。- 定义三个 lambda 表达式,分别对应查找(读)、删除和插入(写)操作:
std::function<bool(std::map<int,UserData>*, int)> findLambda =[](auto _map, auto _key) { return _map->find(_key) != _map->end(); };
std::function<bool(std::map<int,UserData>*, int)> eraseLambda =[](auto _map, auto _key) { return _map->erase(_key); };
std::function<bool(std::map<int,UserData>*, std::pair<int,UserData>)> insertLambda =[](auto _map, auto _pair) { _map->insert(_pair); return true; };
- 调用读接口,传入要查找的 key 和查找 lambda:
lrcLambda.applyRead(i1, findLambda);
- 调用写接口,传入 key 和删除 lambda:
lrcLambda.applyMutation(i1, eraseLambda);
- 调用写接口,传入 key-value 对和插入 lambda:
lrcLambda.applyMutation(i1pair, insertLambda);
总结
applyRead()
和applyMutation()
这两个函数分别用于执行读和写操作。- 你通过 lambda 把具体的业务操作传给 Left-Right 框架,它内部负责版本切换和同步,保证线程安全。
- 读操作是非阻塞的,写操作会互斥执行且做版本切换,保证数据一致性。
- 这样就能把一个非线程安全的
std::map
变成线程安全的共享数据结构,同时读性能很好。
“读指示器”(Read Indicators),总结如下:
- 读指示器的别名:有时也叫“零指示器”或“非零指示器”。
- 作用:用于标识并跟踪有多少读线程正在访问某个共享资源,方便实现并发控制。
- API接口:
arrive()
:表示一个读线程开始访问,做“到达”标记。depart()
:表示读线程结束访问,做“离开”标记。isEmpty()
:检查当前是否没有读线程在访问(即是否所有到达的线程都已离开)。返回false
表示有线程还在访问。
- 一致性要求:这三个方法调用之间至少需要保证顺序一致性(sequential consistency),保证状态更新正确且线程间可见。
- 应用场景:
- 多种读写锁(Reader-Writer Locks)实现中会用到读指示器。
- Left-Right 机制中也用读指示器来检测读线程的状态,辅助版本切换和同步。
简单来说,读指示器就是一个轻量的机制,用来记录当前有多少读线程正在操作,方便写线程知道什么时候安全地切换或修改数据,避免数据竞争。
“单计数器”实现的读指示器(ReadIndicator),重点理解如下:
- 功能:用一个原子计数器
counter
来跟踪有多少读者正在访问(arrive增加,depart减少)。 - arrive():调用时,计数器加1,表示有一个读线程开始访问。
- depart():调用时,计数器减1,表示有一个读线程结束访问。
- isEmpty():当计数器为0时,表示当前没有任何读线程正在访问。
- 进度条件:
arrive()
和depart()
调用的是atomic_fetch_add()
,在x86架构上,这个操作是Wait-Free,因为x86提供了硬件级别的原子操作(XADD或者LOCK ADD)。- 在其他架构上,可能只保证Lock-Free,即至少有一个线程会成功,但可能存在线程饿死的情况。
isEmpty()
是一个简单的load()
操作,通常是Wait-Free。
总结:
- 这个实现简单且高效,适合x86架构。
- 它保证了读者计数的正确性和进度条件,但在非x86平台,
arrive()
和depart()
可能不完全是Wait-Free。
class RIAtomicCounter : public ReadIndicator {
private:std::atomic<long long> counter { 0 }; // 原子计数器,记录当前活跃读者数量
public:void arrive(void) override {counter.fetch_add(1); // 读者到达时计数器加1}void depart(void) override {counter.fetch_add(-1); // 读者离开时计数器减1}bool isEmpty(void) override {return counter.load() == 0; // 判断当前是否无活跃读者}
};
各方法的进度条件分析
方法 | 作用 | 进度条件 | 说明 |
---|---|---|---|
arrive() | 读者到达,计数器加1 | Wait-Free(x86) | 在x86架构上,fetch_add 实现为XADD/LOCK ADD指令,保证操作不会阻塞,有限步完成。 |
Lock-Free(其他架构) | 其他架构上fetch_add 可能不是严格Wait-Free,但至少保证某线程能完成,不会死锁。 | ||
depart() | 读者离开,计数器减1 | 同上 | 与arrive() 相同,都是原子加减操作,进度条件一致。 |
isEmpty() | 判断计数器是否为0,无活跃读者 | Wait-Free | 单纯的load 操作,读取计数器的值,通常是非阻塞且快速完成。 |
总结
arrive()
和depart()
通过原子加减计数器实现,保证了线程安全。- 在x86平台下,这两个操作是Wait-Free的,确保每个线程在有限步内完成操作。
isEmpty()
是一个简单的原子读操作,也是Wait-Free的。- 在非x86架构上,
arrive()
和depart()
可能只保证Lock-Free,即不会死锁,但可能存在线程饿死(starvation)。
“分布式读者计数器(Array of Counters)”代码及其机制:
代码结构说明
class RIDistributedCacheLineCounter : public ReadIndicator {
private:int numCounters; // 计数器数组大小std::atomic<long long>* counters; // 指向计数器数组(每个计数器对应一个缓存行,避免伪共享)std::atomic<long long> acquireLoad { 0 }; // 用于读取计数器数组时的同步操作std::hash<std::thread::id> hashFunc; // 将线程ID哈希映射到计数器索引// 根据当前线程ID计算对应的计数器索引int thread_2_idx(void) {std::size_t idx = hashFunc(std::this_thread::get_id());return (int)((idx % numCounters) * CACHE_LINE_WORDS); // 每计数器跨CACHE_LINE_WORDS位置}
public:// 构造函数,显式指定计数器数目RIDistributedCacheLineCounter(int numCounters) {this->numCounters = numCounters;counters = new std::atomic<long long>[numCounters * CACHE_LINE_WORDS];}// 默认构造,计数器数量为CPU核数或默认值RIDistributedCacheLineCounter() {numCounters = std::thread::hardware_concurrency();if (numCounters == 0) numCounters = DEFAULT_NUM_COUNTERS;counters = new std::atomic<long long>[numCounters * CACHE_LINE_WORDS];}~RIDistributedCacheLineCounter() {delete[] counters;}// arrive() 调用线程对应计数器 +1void arrive(void) override {counters[thread_2_idx()].fetch_add(1);}// depart() 调用线程对应计数器 -1void depart(void) override {counters[thread_2_idx()].fetch_add(-1);}// 判断所有计数器是否全为0,无活跃读者bool isEmpty(void) override {int idx = acquireLoad.load();for (; idx < numCounters * CACHE_LINE_WORDS; idx += CACHE_LINE_WORDS) {if (counters[idx].load(std::memory_order_relaxed) > 0) {atomic_thread_fence(std::memory_order_acquire);return false;}}atomic_thread_fence(std::memory_order_acquire);return true;}
};
代码关键点解释
- 分布式计数器设计
通过为每个线程映射到一个单独的计数器,减少了多个线程对同一个原子计数器的竞争,从而降低缓存行伪共享和总线流量。 - 缓存行填充 (Cache Line Padding)
CACHE_LINE_WORDS
是每个计数器间的间隔,用来确保计数器各自占据不同缓存行,避免伪共享。 - 线程索引计算
通过哈希当前线程ID定位计数器,保证不同线程尽可能分散在不同计数器上。 arrive()
和depart()
只是对线程对应的计数器进行简单的原子加减操作,操作延迟较低且冲突少。isEmpty()
的实现细节- 读取当前
acquireLoad
的值作为起始点(可能用于分散检测负载,但未见修改操作)。 - 遍历所有计数器对应缓存行位置索引,判断是否有计数器值大于0。
- 使用
atomic_thread_fence(memory_order_acquire)
确保读取顺序和内存可见性,避免读操作重排序,确保读取计数器状态的一致性。
- 读取当前
进度条件(Progress Conditions)
方法 | 进度条件 | 说明 |
---|---|---|
arrive() | Wait-Free | 只执行单个原子加法操作,无阻塞,保证有限步内完成 |
depart() | Wait-Free | 同arrive() ,单个原子减法操作,无阻塞 |
isEmpty() | Wait-Free (大多数情况下) | 遍历有限大小数组,读取计数器值;可能稍慢,但没有锁阻塞,理论上有限步完成。只有计数器特别大时耗时增多 |
总结
- 分布式计数器通过减少热点(即热点计数器)的竞争,提升了并发读写的性能,特别适合多线程高并发场景。
- 缓存行填充防止了伪共享,显著提升性能。
- 该实现的
arrive()
和depart()
在大部分主流CPU架构上是wait-free,isEmpty()
则可能因为遍历计数器而较慢,但仍是非阻塞的。 - 该ReadIndicator适合用于需要高并发读的同步机制,如Left-Right读写模式、某些Reader-Writer锁等。
“Read Indicators — Per Thread”代码及其机制:
代码结构说明
class RIStaticPerThread : public ReadIndicator {
private:enum State { NOT_READING = 0, READING = 1 }; // 两种状态:不读、正在读int numThreads; // 线程数std::atomic<long>* states; // 每个线程一个状态,带缓存行填充std::atomic<long> acquireLoad { 0 }; // 读取状态时的同步辅助变量
public:// 构造函数,初始化状态数组大小(线程数 * 缓存行大小)RIStaticPerThread(int numThreads) {this->numThreads = numThreads;states = new std::atomic<long>[numThreads * CACHE_LINE_WORDS];// 注意这里未初始化states数组元素为NOT_READING,实际使用时应初始化}~RIStaticPerThread() {delete[] states;}// 当前线程标记自己为正在读取void arrive(void) override {states[(int)std::this_thread::get_id()].store(READING, std::memory_order_release);}// 当前线程标记自己结束读取void depart(void) override {states[(int)std::this_thread::get_id()].store(NOT_READING, std::memory_order_release);}// 判断所有线程是否都处于非读取状态bool isEmpty(void) override {int tid = acquireLoad.load(std::memory_order_acquire);for (; tid < numThreads * CACHE_LINE_WORDS; tid += CACHE_LINE_WORDS) {if (states[tid].load(std::memory_order_relaxed) == READING) {atomic_thread_fence(std::memory_order_acquire);return false;}}atomic_thread_fence(std::memory_order_acquire);return true;}
};
代码关键点解释
- 每线程一个状态变量
预先分配固定大小的states
数组,每个线程对应一个唯一位置,用于存放该线程的读取状态。通过这种静态映射方式避免了线程竞争。 - 缓存行填充 (Cache Line Padding)
每个状态占用一个缓存行的大小,防止伪共享,提升性能。 arrive()
和depart()
使用store
操作将当前线程状态设置为“读取”或“非读取”,其中memory_order_release
保证写操作对其他线程可见。isEmpty()
遍历所有线程的状态,检测是否有线程处于“读取”状态。若有则返回false
。遍历时使用memory_order_relaxed
加载状态,但在判断后加入atomic_thread_fence(memory_order_acquire)
保证内存同步,防止重排序。- 线程索引映射的潜在问题
代码中直接用(int)std::this_thread::get_id()
作为索引是不安全的,因为std::thread::id
不是整型且不可直接转为数组索引,实际应用时需要映射线程ID到一个连续索引(如线程本地存储或管理线程ID映射)。
进度条件(Progress Conditions)
方法 | 进度条件 | 说明 |
---|---|---|
arrive() | Wait-Free | 只对线程唯一的原子变量进行store ,无阻塞 |
depart() | Wait-Free | 同arrive() |
isEmpty() | Wait-Free 有界 | 遍历固定大小数组,操作不阻塞但复杂度随线程数线性增长 |
优缺点总结
优点 | 缺点 |
---|---|
- 读操作只写入自己线程专属的原子变量 | - 需要事先知道线程数,且线程ID到索引的映射必须正确 |
- 无伪共享,提高并发性能 | - 适合线程数固定且较少的场景,线程动态变化难支持 |
- arrive() 和depart() 操作非常快速 | - isEmpty() 复杂度随线程数增长,可能较慢 |
- 适合读操作频繁、写操作少的环境 | - 线程ID转换成索引需要额外机制 |
总结
这个“Per Thread”Read Indicator实现方案为每个线程维护一个状态变量,避免了多个线程在一个计数器上竞争,是一种针对固定数量线程的高效设计。它保证了读者标记操作的wait-free(无阻塞)特性,同时检测是否存在读者的isEmpty()
方法的复杂度是固定的(线程数乘以缓存行间距),且也是非阻塞的。
这段关于不同 Read Indicator 实现的对比总结:
Read Indicator | arrive()/depart() 进度 | isEmpty() 进度 | 需不需要 GC(垃圾回收) | 可扩展性 | 内存使用 | 灵活性 | 备注 |
---|---|---|---|---|---|---|---|
Single Counter | WFPO(x86上),Lock-free(PPC/ARM) | WFPO | 否 | 差 | 低 | 好 | 实现简单,低开销但可扩展性差 |
Array of Counters | WFPO(x86上),Lock-free(PPC/ARM) | Wait-free | 否 | 好 | 较高(按线程数) | 很好 | 每线程一个计数器,扩展性强 |
One entry per thread | Wait-free | Wait-free | 否 | 好 | 按线程数(固定) | 很好 | 线程数固定时表现优异 |
SNZI | Lock-free | Wait-free | 需要 | 好 | 较低 | 一般 | 支持锁自由,非常适合高并发环境 |
术语解释:
- WFPO (Wait-Free Population Oblivious): 读者线程不受其他线程数量影响,无阻塞。
- Wait-free: 每个操作都有有限步骤完成,无阻塞。
- Lock-free: 至少有一个线程能完成操作,无死锁。
关键理解点:
- Single Counter简单,适合低线程数和简单场景,但在高并发时成为瓶颈。
- Array of Counters(分布式计数器)通过分散线程写入,显著提升可扩展性,但占用更多内存。
- One entry per thread是分配给每个线程固定的计数器,适用于线程数已知且固定的场景,读写操作都是wait-free。
- SNZI(Scalable NonZero Indicator)实现锁自由,更适合极端高并发环境,但需要垃圾回收机制管理状态。
总结建议:
- 低线程数或简单场景用Single Counter。
- 多线程、高并发且线程数固定,One entry per thread最佳。
- 线程数多且动态变化,推荐Array of Counters。
- 极高并发和复杂需求下,考虑SNZI(需GC)。
这段代码是 Left-Right 技术中写操作(mutative operation)的实现,结合进度条件(progress condition)的问题。
代码解析
template<typename R, typename A>
R applyMutation(A& arg1, std::function<R(T*,A)>& mutativeFunc) { std::lock_guard<std::mutex> lock(lrc.writersMutex); // 1. 加锁保证只有一个写线程执行if (leftRight.load(std::memory_order_relaxed) == READS_LEFT) {leftRight.store(READS_RIGHT); // 2. 切换活跃版本到右侧实例mutativeFunc(rightInst, arg1); // 3. 在右侧实例执行变更lrc.toggleVersionAndWait(); // 4. 等待读者完成后切换版本号return mutativeFunc(leftInst, arg1); // 5. 在左侧实例执行变更并返回结果} else {mutativeFunc(leftInst, arg1); // 类似地,先在左侧实例变更leftRight.store(READS_LEFT); // 切换回左侧lrc.toggleVersionAndWait(); // 等待读者完成后切换版本号return mutativeFunc(rightInst, arg1); // 最后在右侧实例变更并返回结果}
}
toggleVersionAndWait()
实现:
void toggleVersionAndWait(void) { // 读当前版本号const int localVI = versionIndex.load(); const int prevVI = (int)(localVI & 0x1); const int nextVI = (int)((localVI+1) & 0x1); // 等待读取下一个版本的读者完成while (!readIndic[nextVI].isEmpty()) { std::this_thread::yield(); }// 切换版本号versionIndex.store(nextVI); // 等待读取上一个版本的读者完成while (!readIndic[prevVI].isEmpty()) { std::this_thread::yield(); }
}
关键点与进度条件分析
- 写者加锁(std::lock_guardstd::mutex):
- 写者间是互斥的(互斥锁),意味着写操作是 阻塞的,不能并发执行。
- 其他写线程如果想执行,必须等待当前写线程完成。
- 读者无阻塞:
- 读者在执行时不需要获取锁,可以并发读取当前活跃的实例。
- 写操作的等待:
- 写者在切换版本时必须等待旧版本的读者全部完成访问,即
toggleVersionAndWait()
中的循环等待。 - 这种等待可能会导致写操作被阻塞,直到所有读者都完成。
- 写者在切换版本时必须等待旧版本的读者全部完成访问,即
选项分析
- a) Blocking — 写操作有阻塞,因为有互斥锁和等待读者完成,满足这个条件。
- b) Blocking – Starvation-Free — 也成立,因为写者锁是公平互斥锁,避免写者饿死。
- c) Lock-Free — 不成立,写者用了互斥锁。
- d) Wait-Free Unbounded — 不成立,写者可能阻塞等待。
- e) Wait-Free Bounded — 不成立,等待时间未必有限。
- f) Wait-Free Population Oblivious — 不成立,写操作等待读者完成,受读者数量影响。
结论
最佳答案是 b) Blocking – Starvation-Free
我们来看这段代码和题目,帮你理解并分析读操作的进度条件。
题目
Quiz 5
Left-Right 技术中,读操作的最佳进度条件是什么?
选项有:
a) Blocking
b) Blocking – Starvation-Free
c) Lock-Free
d) Wait-Free Unbounded
e) Wait-Free Bounded
f) Wait-Free Population Oblivious
代码(简化版)
template<typename R, typename A>
R applyRead(A& arg1, std::function<R(T*,A)>& readOnlyFunc) {const int lvi = lrc.arrive(); // 1. 标记读者到来,获得当前版本索引T* inst = leftRight.load() == READS_LEFT ? leftInst : rightInst; // 2. 根据 leftRight 读取当前有效实例R ret = readOnlyFunc(inst, arg1); // 3. 在当前实例执行读操作lrc.depart(lvi); // 4. 标记读者离开return ret;
}
arrive() 与 depart() 负责标记读者状态:
int arrive(void) {const int localVI = versionIndex.load(); // 读取当前版本号readIndic[localVI].arrive(); // 在对应的读指标(ReadIndicator)上登记读者return localVI;
}
void depart(int localVI) {readIndic[localVI].depart(); // 读者完成,更新状态
}
代码分析
- 读操作执行流程:
arrive()
会立即在对应的读指标上登记该读者。通常,这个操作是原子性的,执行非常快。- 读操作直接读取当前活跃实例的数据,不阻塞。
- 读完成后调用
depart()
,快速更新状态,表示离开。
- 关键点:
arrive()
和depart()
都是原子操作,通常是单个原子加减操作(如 atomic_fetch_add)。- 读操作不加锁,不阻塞其他读者或写者(写者会等待读者完成,但读者不等待写者)。
- 读操作不会因为其他线程的操作而被阻塞,且执行时间有界。
进度条件推断
- 读操作不阻塞任何线程:排除 a) 和 b)。
- 是否是 Lock-Free?
Lock-Free 意味着至少有一个线程能持续前进,通常是读者不被阻塞且进度被保证,这里读操作是无锁的,符合 Lock-Free,但我们能否说更强? - 是否是 Wait-Free?
Wait-Free 要求每个线程的操作能在有限步内完成,无论其他线程状态如何。这里读操作不依赖写者或其他读者,且原子操作本身是有界的,所以满足 Wait-Free。 - 是否是 Population Oblivious?
Wait-Free Population Oblivious(WFPO)意味着读操作的执行时间不依赖于其他活跃线程数量。
由于读者只是单个原子操作,没有循环等待,读操作是 人口无关的。
结论
最佳答案是:
f) Wait-Free Population Oblivious
总结
步骤 | 说明 | 进度条件 |
---|---|---|
arrive() | 快速原子增加计数 | Wait-Free, WFPO |
读操作执行 | 无锁直接访问对应实例 | Wait-Free, WFPO |
depart() | 快速原子减少计数 | Wait-Free, WFPO |
Left-Right 技术的进度条件总结
读者(Readers):
- 读者的状态机没有循环(无阻塞等待或重试)
- 读者操作步骤固定,不依赖于线程数
- 因此,读者操作是 Wait-Free Population Oblivious
— 即读操作在有限步骤内完成,且执行时间不受线程数影响
写者(Writers): - 写者操作在切换版本索引后,需要等待旧版本的读者完成
- 只要读者不断前进,写者就能完成切换
- 因此,写者是 Blocking Starvation-Free
— 写操作可能阻塞,但不会永远被饿死,最终能进展
这种设计通过牺牲写者的部分阻塞性,换取读者的超低延迟和高并发,非常适合读多写少的场景。
Linearizability(线性化)在 Classic Left-Right 中的含义:
- 线性化是一种强一致性保证,比顺序一致性(Sequential Consistency)更严格。
- 它保证所有操作看起来像是按某个全局顺序瞬间发生的。
Classic Left-Right 是如何实现线性化的?
- 写者互斥保证
- 使用
writersMutex
确保同一时间只有一个写者在操作。 - 持有互斥锁后写者的关键步骤(写操作)可以作为写者之间的线性化点(linearization point)。
- 使用
- 版本切换决定可见性
- 写者在修改完“备用版本”后,会切换
leftRight
变量,切换时刻就是写入对新读者可见的线性化点。 - 这意味着从此刻起,新的读者看到的是包含写者最新修改的版本。
- 写者在修改完“备用版本”后,会切换
- 读者的线性化点
- 读者读取
leftRight
变量的那一刻,就是它的线性化点。 - 读者看到的版本要么是旧版本(写者切换之前),要么是新版本(切换之后),不会出现读到“半更新”的状态。
- 读者读取
- 无竞态保证
- 在任何时候,写者都不会修改被当前读者访问的版本实例。
- 这样读写之间不会产生竞态条件,保证数据访问安全。
总结:
- 写者的写操作在线性化点前后都串行执行。
- 读者看到的状态也是瞬时一致的。
- 这种设计使 Left-Right 既高效又保证了强一致性。
Linearizable Iterators 的背景与问题
- Left-Right 和 Copy-On-Write(COW)技术的优势
- 读操作是 wait-free(无阻塞)且 线性化(linearizable) 的。
- 这意味着读者在遍历数据结构时看到的是一个“瞬时一致”的快照。
- 锁自由(Lock-Free)或无阻塞(Wait-Free)数据结构在迭代器支持上的缺陷
- 它们通常只能保证单项操作的原子性。
- 但是,对于遍历(迭代器)这种需要多个元素保持一致视图的操作,它们不能保证线性化。
- 例子:Michael & Scott的无锁队列,添加元素到尾部,删除元素从头部。
- 多写线程不断修改,队列中元素动态变化,读线程遍历计数时,读到的元素可能是部分修改状态,导致计数不正确。
举例说明
- 队列起初有3个元素(key: 1,2,3)
- 多个写线程不断插入和删除元素,保证队列中任意时刻不超过3个元素。
- 读线程在遍历队列计数时,可能会看到不一致的状态(元素数目不对),因为写线程同时在修改。
- 这说明无锁数据结构虽然单个操作是原子且无阻塞,但不支持多个元素的一致视图(迭代器不线性化)。
Left-Right的优势
- 由于它维护了两个实例版本(leftInst和rightInst),写线程只修改一个版本,读线程只读当前版本。
- 版本切换时写线程保证没有读线程正在访问那个版本。
- 因此,读线程遍历时看到的是版本的一致快照,不会出现遍历过程中的中间不一致状态。
- 这使得 Left-Right 可以提供 线性化迭代器,保证读操作对多个元素的一致视图。
Linearizable Iterators
- 出现“11个元素”计数的原因:
- 虽然队列中从未有过超过3个元素的时刻,
- 但是读取线程看到11个元素,说明它读取的是“跨越多个版本”或“多次部分修改叠加的结果”。
- 这是由于 无锁或弱一致性结构的迭代器遍历,它可能看到数据结构在多个不同时间点的“拼凑快照”,导致数据不一致。
- 无锁/无阻塞数据结构迭代器的弱一致性:
- 迭代器遍历过程中,写操作可能在修改结构,读操作看到了不连贯的元素状态,结果出现计数错误。
- 这种弱一致性意味着:
- 迭代不保证线性化(linearizability),
- 甚至不保证顺序一致性(sequential consistency)。
- Copy-On-Write(COW)和 Left-Right 的优势:
- COW:写者操作创建数据的一个完整快照,读者读这个快照,保证一致性。
- Left-Right:写者必须等待所有读者结束旧版本访问,才切换版本并执行修改,确保读者始终看到一个一致的版本。
- 因此,这两种技术都能保证迭代器的 线性化,即读者看到的是一致的快照。
- 实际效果:
- 对于计数操作,COW和Left-Right都能正确返回2或3(实际存在的元素数目),而不会出现11这种错误计数。
总结:
- 无锁结构适合快速单项操作,但不适合需要跨多个元素一致视图的操作(如迭代、计数)。
- COW 和 Left-Right 机制通过维护快照或版本切换,实现了迭代器的线性化,保证读操作的强一致性。
Benchmark数据和说明的分析:
说明
- 测试场景:
- 使用
std::map
存储 10,000 个元素 - 测量持续 1000 秒(16分钟)
- 测量的是操作延迟,单位是微秒(μs)
- 使用
- 数据解读:
- 以 “99% find()” 为例,代表 99% 的
find
操作延迟小于表中对应数值。 - 举例:使用
pthread rwlock
时,99% 的find
操作在 51μs 内完成。 - 对比中可以看到,Left-Right 机制的
find
延迟明显更低(2μs),远优于传统读写锁。
- 以 “99% find()” 为例,代表 99% 的
- Copy-On-Write 缺点明显:
- 因为每次写操作都需要复制整个
std::map
,写操作耗时极高,超过1000μs,难以测量。 - 这使 COW 在写多读少或大数据结构场景下表现很差。
- 因为每次写操作都需要复制整个
- Left-Right 机制优势:
- 虽然 Left-Right 在写操作时需要在两个实例上执行插入和删除,
- 但它仍然比单实例加读写锁更快,体现出高效的并发性能。
- 这得益于读操作几乎不加锁,以及写操作的分离与等待机制。
- Atomic Counter vs Array of Counters:
- Left-Right 使用“原子计数器”和“计数器数组”两种不同的读者指标,
- 两者延迟表现接近,Array of Counters在更严格的尾部延迟表现稍优。
- Userspace RCU:
- 作为另一种无锁读写机制,延迟与 Left-Right 相似,尤其是读操作。
总结
操作类型 | Left-Right 读延迟 | 读写锁延迟 | COW 写延迟 |
---|---|---|---|
读 (find) | 约 2 μs | 约 51 μs | 极快 |
写 (insert/erase) | 7-120 μs | 53-179 μs | > 1000 μs (极慢) |
- Left-Right 在读性能方面大幅领先,
- 写性能也优于传统锁,远优于 Copy-On-Write。
- 因此,Left-Right 是一种兼顾读写性能且延迟低的并发策略。
Left-Right 技术在纯写入场景下的非直观性能优势,具体分析如下:
主要观点
- 写操作虽需双倍应用:
Left-Right 需要在两个数据实例(left 和 right)上各执行一次写操作,乍一看写操作时间应该是单实例写操作的两倍。 - 实际上吞吐量并非减半:
但测试中发现 Left-Right 写吞吐量并没有减半,反而表现相当甚至更好。 - 原因:树遍历成本低
std::map 底层是红黑树,查找/定位节点需要遍历大约log N
个节点。
这个遍历非常快,执行两次遍历(两次写操作)和锁的加解锁开销相比,影响不大。 - 大规模数据结构也适用
即使有百万级节点,遍历20个节点也足够快,类似哈希表O(1)的遍历。
所以双写带来的性能损失仍小于写锁的加锁/解锁延迟。 - 但不适用于遍历全结构的写操作
如果写操作是对整个数据结构做遍历和修改(比如对每个节点都改),则写操作时间会明显增加,Left-Right 的性能优势减弱。
对比说明
操作步骤 | RW-Lock | Left-Right |
---|---|---|
获取写锁 | lock() | lock() |
进行写操作 | mutation | mutation + mutation (两遍) |
释放写锁 | unlock() | unlock() |
等待读者完成(阻塞写者) | wait for readers | wait for readers (写完才等待) |
- RW-Lock 写操作中,写锁会阻塞所有读者直到写完成,导致等待时间显著。
- Left-Right 写操作能先完成写,随后等待所有旧读者结束,减少锁竞争和等待时间。
总结
- Left-Right 写操作表面上做了双倍写入,
- 但由于红黑树(或哈希表)结构遍历快,
- 写锁的加解锁成本占用更多时间,导致 Left-Right 并不减速那么多,
- 在高并发写场景下依然具有较好性能。
这段内容主要比较了 Left-Right机制与经典无锁链表(如Harris链表、Michael & Scott队列)以及其它同步技术在内存屏障(fences)使用上的差异,并指出其带来的性能影响和额外需求。
主要内容解析
1. 内存屏障(Fences)数量对比
数据结构 / 技术 | x86 架构 | PowerPC 架构 | ARMv7 架构 | ARMv8 架构 |
---|---|---|---|---|
Harris链表 | 无 | N次 isync | N次 isb | N次 LDAR + N次 isb |
Michael & Scott队列 | 无 | N次 isync | N次 isb | N次 LDAR + N次 isb |
Left-Right | 2次 MFENCE | 3次 hwsync | 1次 hwsync | 3次 hwsync |
Copy-On-Write (COW) | 无 | 具体未提及 | 具体未提及 | 具体未提及 |
读写锁(RW-Lock) | 最少1次 CAS | 3次 dmb | 2次 dmb | 最少1次 CAS + 1次 dmb |
- Harris链表和Michael & Scott链表在PowerPC和ARM架构上,每访问一个节点都需要一个全屏障,意味着访问N个节点就要做N次昂贵的同步屏障操作。
- Left-Right机制无论访问多少节点,屏障数量固定(3次hwsync),显著减少了开销。
- 在x86架构上,Left-Right需要的MFENCE次数也远远少于链表结构访问的全屏障需求。
2. 额外开销
- 对于链表数据结构,如使用 Hazard Pointers 进行内存回收,则每访问一个节点都需添加MFENCE,进一步增加开销。
- 如果使用 Reference Counting,每访问节点则需额外两次XADD(原子加操作),额外消耗CPU资源。
- Left-Right和传统的读写锁(RW-Lock)不需要GC或复杂的内存回收机制,这降低了系统复杂性和运行时开销。
3. 总结
- 无锁链表(Harris,Michael & Scott):
- 优点:无锁、并发性高
- 缺点:每节点访问带来大量内存屏障(尤其在非x86架构),需要复杂内存回收机制(GC、Hazard Pointers等)
- Left-Right机制:
- 优点:屏障数量极少,性能优越
- 不依赖GC或复杂内存回收,减少系统复杂度
- 在多架构表现均衡(尤其对ARM、PowerPC)
- 读写锁:
- 需要一定的同步屏障,但比无锁链表更少
- 无需复杂GC机制
为什么这很重要?
- 屏障指令(Fence)是昂贵的同步操作,过多会严重影响多核处理器的性能,尤其是高并发数据结构访问时。
- Left-Right技术通过减少屏障数量,实现了更低延迟和更高吞吐量,且简化了内存管理。
这个部分的内容主要是讨论 Acquire-Loads基准测试,通过比较不同数据结构(特别是 Left-Right 和 Harris链表)在进行 只读操作(lookups) 时的性能,针对不同的架构(x86 和 PowerPC)以及不同大小的链表(100个节点和1000个节点)。
1. 基准测试内容
- Harris链表:这是一种无锁链表数据结构,在进行查找操作时非常依赖于屏障指令(fence),特别是如果没有使用内存回收机制(如Hazard Pointers、RCU等),它会导致更大的性能开销。没有内存回收技术的实现会影响性能。
- Left-Right数据结构:相较于传统的链表结构,Left-Right具有更低的延迟和更高的吞吐量,尤其在没有写操作时,它的性能更为突出。
- 不使用内存回收技术的Harris链表:在x86和PowerPC架构下,Harris链表的性能会更低,尤其是在没有内存回收的情况下。
2. 比较架构
- x86 和 PowerPC 是两种不同架构,尽管都支持多线程,但是它们在内存模型和同步操作上的实现有所不同。这会直接影响到数据结构的性能表现。
3. memory_order_seq_cst
和 memory_order_acquire
memory_order_seq_cst
(顺序一致性):这是最强的内存顺序约束,所有线程的操作必须按顺序一致地执行。这个模式会导致比memory_order_acquire
更高的性能开销,因为它需要保证更强的内存一致性。memory_order_acquire
(获取模式):这是一个较轻的内存顺序约束,要求加载操作只确保获取锁之后的内存一致性,而不要求全局顺序一致。这个模式的性能较好,特别是当只做读取操作时,性能优势显著。
4. 图表分析
- 红线(使用
memory_order_seq_cst
):表示加载操作使用顺序一致性内存顺序的实现。这个实现保证了强一致性,但会带来性能损失,特别是在有大量节点时。 - 绿线(使用
memory_order_acquire
):表示加载操作使用获取模式内存顺序的实现。这个实现相比红线会有更少的性能损失,因此可以更高效地进行查找操作。
5. 性能结果
- 在 PowerPC架构上,当链表有 100个节点 时,性能差异并不明显,但当链表扩展到 1000个节点 时,性能差异显现出来。使用
memory_order_acquire
会显著提升每秒查找次数,特别是在处理更大链表时。
6. 内存回收技术对比
- Harris链表如果没有使用内存回收机制,如 Hazard Pointers 或 RCU,则会更慢,特别是在多线程环境下对内存进行管理时,性能差异尤为明显。
- Left-Right数据结构相比之下不需要额外的内存回收机制,它本身通过锁的设计来保证安全,避免了因回收操作产生的额外开销。
总结
- Harris链表的性能与 内存回收技术密切相关。没有内存回收时,其性能明显较差,特别在 PowerPC 和 x86 架构上。
- Left-Right机制 在 只读操作 中表现出色,尤其是在
memory_order_acquire
模式下,能够进行更高效的查找操作。 - 内存顺序选择(
memory_order_seq_cst
vsmemory_order_acquire
)会直接影响到性能,后者提供更优的性能,尤其是在多节点查找时。
为什么使用 Left-Right,而不是 Lock-Free 链表?
在选择 Left-Right 模式和 Lock-Free 数据结构(如 Harris 链表)之间时,理解每种方法的 优缺点 至关重要。下面是 Left-Right 和 Lock-Free 链表 之间的对比,特别是在 垃圾回收、内存管理 和 性能 方面的考虑。
1. Lock-Free 链表及其挑战
- Harris 链表:
- Lock-Free 数据结构的典型代表,其中每个节点使用一个 引用位 来标记当前节点是否被逻辑删除。
- 内存管理:为了安全地使用 Lock-Free 链表,需要一种自动的 垃圾回收(GC) 或 Lock-Free 内存管理技术。
- Hazard Pointers(HP) 和 引用计数 是常见的方式,但它们都有一些 缺点:
- Hazard Pointers 实现起来相对容易,但在处理更复杂的数据结构时,可能会变得 非常复杂(对于更复杂的数据结构,可能几乎无法使用)。而且,Hazard Pointers 的性能较差,并且由 IBM 拥有专利。
- 引用计数 更加缓慢,而且 C++1x 并不直接支持
atomic<unique_ptr>
,这意味着需要 手动内存管理。
- Hazard Pointers(HP) 和 引用计数 是常见的方式,但它们都有一些 缺点:
- GC 开销:即便使用了 GC,Harris 链表 仍然需要进行大量修改以有效地工作,尤其是在 多线程环境 中。
- 链表的性能:即便经过优化,链表 通常比其他数据结构(如数组)要 慢,因为遍历链表需要访问多个节点,相比之下,数组的随机访问速度更快。
2. Left-Right 模式的优势
- 实现简单:
- Left-Right 模式 可以用于任何 数据结构 或 用户自定义类,为各种应用提供 灵活性。
- 它的 语义 与 读写锁 非常接近,因此非常容易理解和使用。写线程可以安全地进行数据变更,而读线程可以在不加锁的情况下访问数据,从而实现 高并发。
- 无需复杂的内存管理:
- 与 Lock-Free 链表 不同,Left-Right 模式 不需要复杂的内存管理技术,如 Hazard Pointers 或 引用计数。
- 内存管理问题相对简单,而且该模式 避免了 GC 或内存回收 的开销。
- 性能:
- Left-Right 模式对于 只读操作(例如查找)通常比链表更快,因为它基于 数据快照,而无需像链表那样遍历多个节点。
- 使用 数组 或类似的结构可以大大提高性能,因为 数组提供了更快的随机访问,避免了链表中的指针遍历。
- 可扩展性:
- Left-Right 模式 在处理大数据集或高并发时表现得更好,因为它 低冲突,读线程之间不会发生争用,而读线程在访问时无需加锁。
3. Lock-Free 链表与 Left-Right 模式的对比
方面 | Lock-Free 链表(如 Harris) | Left-Right 模式 |
---|---|---|
内存管理 | 需要 Hazard Pointers 或 引用计数 | 不需要复杂的内存管理 |
复杂性 | 复杂(涉及内存回收和指针操作) | 实现简单,易于理解 |
性能 | 因指针遍历和内存管理开销而较慢 | 较快,尤其是对于只读操作 |
可扩展性 | 对复杂数据结构可扩展性差 | 对各种数据结构具有较好的可扩展性 |
使用场景 | 适合简单的链表场景 | 更适合广泛的数据结构(如数组、哈希表) |
4. 为什么不使用 Lock-Free 链表?
- 内存复杂性:Lock-Free 链表需要高级的内存管理策略(如 Hazard Pointers、引用计数 或 RCU),这些方法都增加了 复杂性,并且可能会导致 性能瓶颈。
- 性能较慢:即便 Lock-Free 链表优化良好,它们 本质上较慢,因为它们需要逐个节点地进行 遍历。
- 使用数组更好:在许多情况下,数组 或其他连续的数据结构会更加高效,尤其是当你能够利用 Left-Right 模式 来提高性能时。
5. 总结
虽然 Lock-Free 链表(如 Harris 链表)在一些简单场景下非常有效,但它们涉及到复杂的 内存管理 和 性能开销。相比之下,Left-Right 模式 提供了一个更加 简单、可扩展 和 高效 的选择,尤其是在处理 非链式 数据结构时(如数组、哈希表等)。
Left-Right 模式 特别吸引人,因为:
- 它 不需要复杂的内存管理 技术。
- 它 灵活,适用于各种数据结构。
- 它 易于实现,并且在 读并发 和 写同步 上提供了很强的保证。
因此,Left-Right 模式 是寻求 简单性、性能 和 可扩展性 的并发系统的一个强大选择。
左-右链表(Left-Right Linked List) 是一种用于实现**无阻塞查找(wait-free lookup)和原子修改(atomic modification)**的链表技术。它常用于并发系统中,多个线程同时进行读写操作时,希望减少线程之间的阻塞或竞争。
左-右链表(Left-Right Linked List)理解
左-右链表(Left-Right Linked List) 是一种无阻塞(wait-free)查找的链表结构,常用于并发系统中,其中多个线程可以同时对链表进行读和写操作,而不需要使用锁(lock)。这种结构通过采用**左-右模式(Left-Right pattern)**来保护两个物理链表,允许一个链表进行读操作,另一个链表进行写操作,从而达到高效的并发性能。
基本思路:
- 两个链表:
- 一个链表用于读操作,另一个链表用于写操作。通常,读操作和写操作会在不同的链表上进行。
- 操作流程:
- 读操作: 在其中一个链表(比如
leftInst
)上进行,多个线程可以并行地进行查找。 - 写操作: 只有一个线程可以对另一个链表(比如
rightInst
)进行修改,执行插入或删除等操作。
- 读操作: 在其中一个链表(比如
- 切换链表:
- 一旦修改操作完成,所有新的查找操作会被重定向到已修改的链表(比如
rightInst
)。然后,系统会等待尚未完成的查找操作完成,确保数据一致性,才会在另一链表(leftInst
)上应用相同的修改。
- 一旦修改操作完成,所有新的查找操作会被重定向到已修改的链表(比如
- 避免哨兵节点:
与其他链表(例如 Harris 链表)不同,左-右链表不需要哨兵节点。它的头指针指向第一个节点,而尾节点的next
指针指向null
,不需要额外的头尾哨兵节点来进行操作。
左-右链表的优势:
- 无阻塞查找(Wait-free Lookups): 读操作不会被阻塞,读取链表的线程不需要等待正在进行的写操作。
- 高效的并发支持: 通过两个链表的并行操作,读操作和写操作可以独立进行,避免了线程之间的竞争。
- 避免锁的使用: 由于没有使用锁机制,因此能够提供高效的并发性能。
示例:
- 两个物理链表:
假设我们有两个链表:leftInst
(读操作链表):用来进行查找操作。rightInst
(写操作链表):用来执行插入、删除等修改操作。
- 操作流程:
- 在
leftInst
上进行查找操作,这时读线程会访问这个链表。 - 同时,在
rightInst
上进行写操作,这时写线程会执行插入、删除等修改操作。
- 在
- 切换操作:
- 一旦
rightInst
的修改操作完成,所有新的查找操作会被重定向到rightInst
。 - 然后,系统等待所有正在进行的查找操作(指向
leftInst
的查找)完成后,才会对leftInst
进行相同的修改。
- 一旦
- 尾节点:
- 在这个模式下,链表的尾节点
next
指向null
,不需要额外的哨兵节点。
- 在这个模式下,链表的尾节点
总结:
左-右链表的核心思路是通过使用两个物理链表并在它们之间切换来实现无阻塞查找和高效的并发写入。通过这种设计,不同线程可以在不同的链表上并行工作,读操作和写操作互不干扰,极大地提高了系统的效率。
左-右链表(Left-Right Linked List)两个实例的理解
在左-右链表模式中,我们通过使用两个物理链表来管理相同的键(keys)。这两个链表各自具有独立的结构,但它们指向相同的键,从而允许一个链表进行读取操作,另一个链表进行写入操作。这个模式主要用于高并发环境中,帮助实现无阻塞查找(wait-free lookups)并有效地处理并发写入。
核心概念:
- 两个物理链表实例:
- 两个链表实例:在左-右链表模式中,链表有两个物理实例,通常称为
leftInst
和rightInst
。 - 共享键:这两个链表实例(
leftInst
和rightInst
)虽然是物理上独立的链表,但它们指向相同的键(key)。这意味着,两个链表的节点中存储的数据(如Key A
、Key B
等)是相同的。
- 两个链表实例:在左-右链表模式中,链表有两个物理实例,通常称为
- 每个节点的结构:
- 指针到下一个节点(
next
):每个节点都有一个指针,指向链表中的下一个节点。 - 指针到键(
key
):每个节点还包含一个指针,指向节点中的实际数据或键(key
)。这指向链表中存储的元素。
- 指针到下一个节点(
具体结构示例:
假设我们有两个链表实例 leftInst
和 rightInst
,每个链表包含相同的键(例如 Key A
、Key B
、Key C
),但是这两个链表的节点链接(即 next
指针)可能会有所不同。
结构表示:
leftInst
:用于执行读操作,节点会根据某种顺序组织,例如:- Node A → Node B → Node C
rightInst
:用于执行写操作,可能包含与leftInst
相同的键(但顺序可能不同):- Node A → Node C → Node B
节点结构:
- 每个节点包含:
next
:指向下一个节点。key
:指向实际的键或数据(例如Key A
、Key B
)。
例子:
假设我们有以下两个链表实例:
leftInst
(读操作链表):Node A → Node B → Node C
rightInst
(写操作链表):Node A → Node C → Node B
每个节点都包含两个部分:
key
:存储实际的数据,如Key A
、Key B
等。next
:指向链表中的下一个节点。
详细的节点结构:
- Node A:
next
→ 指向 Node B(leftInst
或rightInst
中的下一个节点)。key
→ 存储Key A
。
- Node B:
next
→ 指向 Node C(leftInst
或rightInst
中的下一个节点)。key
→ 存储Key B
。
- Node C:
next
→ 指向null
(链表的末尾)。key
→ 存储Key C
。
指针示意图:
leftInst: rightInst:
Head → Node A → Node B → Node C Head → Node A → Node C → Node B
在这种结构下,尽管两个链表都包含相同的键(例如 Key A
、Key B
、Key C
),它们的节点排列顺序不同。
操作说明:
- 读操作:
- 读操作会访问一个链表(例如
leftInst
),并通过节点的next
指针按顺序遍历链表。这个链表用于执行查找操作。
- 读操作会访问一个链表(例如
- 写操作:
- 写操作会在另一个链表(例如
rightInst
)上进行。写操作包括插入、删除等修改操作,且仅由一个线程执行。
- 写操作会在另一个链表(例如
- 切换:
- 一旦写操作完成,所有新的读操作会被重定向到刚刚修改过的链表(例如
rightInst
)。待所有的旧读操作完成后,相同的修改会应用到另一个链表(例如leftInst
)。
- 一旦写操作完成,所有新的读操作会被重定向到刚刚修改过的链表(例如
总结:
- 在左-右链表模式中,使用两个链表实例,它们共享相同的键(key)。每个节点通过两个指针(
next
和key
)指向下一个节点和存储的数据。 - 这种设计支持高效的并发操作,一个链表进行读操作,另一个链表进行写操作。通过切换链表,能够实现无阻塞查找和高效并发写入。
- 这种方式避免了传统链表中对头尾哨兵节点的依赖,简化了链表的结构。
左-右链表单一实例(Left-Right Linked List Single)理解
在“左-右链表单一实例”模式中,我们不再使用两个物理链表来分别表示左逻辑链表(leftInst
)和右逻辑链表(rightInst
)。而是使用一个物理链表,并通过在每个节点中添加两个 next
指针来分别维护两个逻辑链表。这两个指针分别指向:
nextL
:指向左实例链表(leftInst
)中的下一个节点。nextR
:指向右实例链表(rightInst
)中的下一个节点。
基本思路:
- 单一物理链表:所有节点只在一个链表中存储,节省了内存空间。
- 两个逻辑链表:通过
nextL
和nextR
分别管理两个链表的结构,一个链表用于读取操作(leftInst
),另一个链表用于写入操作(rightInst
)。 - 两个头节点:分别有两个头指针,
headL
和headR
,分别指向左链表和右链表的头节点。
具体结构:
- 物理链表:所有节点在一个链表中存在,节点有两个指针:
nextL
和nextR
。nextL
:指向左实例链表(leftInst
)的下一个节点。nextR
:指向右实例链表(rightInst
)的下一个节点。
- 逻辑链表头节点:
headL
:指向**左逻辑链表(leftInst
)**的头节点。headR
:指向**右逻辑链表(rightInst
)**的头节点。
节点结构:
每个节点都包含以下内容:
key
:存储节点的数据或键(例如:Key A
、Key B
、Key C
)。nextL
:指向左实例链表的下一个节点。nextR
:指向右实例链表的下一个节点。
示例:
假设我们有以下节点结构和逻辑链表:
leftInst
(读操作链表):Node A → Node B → Node C
rightInst
(写操作链表):Node A → Node C → Node B
我们只使用一个物理链表,并通过 nextL
和 nextR
分别维护两个链表的链接:
节点细节:
- Node A:
nextL
→ 指向 Node B(在leftInst
中)。nextR
→ 指向 Node A(在rightInst
中)。key
→ 存储Key A
。
- Node B:
nextL
→ 指向 Node C(在leftInst
中)。nextR
→ 指向 Node B(在rightInst
中)。key
→ 存储Key B
。
- Node C:
nextL
→ 指向null
(在leftInst
中)。nextR
→ 指向 Node Y(在rightInst
中,若有插入)。key
→ 存储Key C
。
结构示意图:
leftInst: rightInst:
HeadL → Node A → Node B → Node C HeadR → Node A → Node C → Node B
如何操作:
- 读操作:
- 通过
headL
遍历leftInst
,节点通过nextL
进行连接。
- 通过
- 写操作:
- 写操作通过
headR
遍历rightInst
,节点通过nextR
进行连接。 - 对
rightInst
的插入、删除等操作会修改nextR
指针。
- 写操作通过
- 修改操作切换:
- 当写操作完成后,新的查找操作会被重定向到刚刚修改过的链表(例如
rightInst
)。随后,所有尚未完成的读操作会继续在leftInst
上完成,直到它们完成后才会将修改同步到另一个链表。
- 当写操作完成后,新的查找操作会被重定向到刚刚修改过的链表(例如
总结:
在“左-右链表单一实例”模式下,只有一个物理链表,但通过每个节点的两个指针(nextL
和 nextR
),可以分别管理两个逻辑链表。一个链表用于读取操作(leftInst
),另一个链表用于写入操作(rightInst
)。这种设计不仅节省了内存空间,还能够高效地支持并发读写操作,适用于高并发、无阻塞的场景。
左-右链表单一实例(Left-Right Linked List Single)理解
在“左-右链表单一实例”模式中,我们放弃了使用两个物理链表的方式,而是采用一个物理链表,并在每个节点中引入两个指针来维护两个逻辑链表:一个用于左逻辑链表(leftInst
),另一个用于右逻辑链表(rightInst
)。通过这种方式,我们依然能够实现无阻塞读写操作,同时节省内存开销。
核心思路:
- 单一物理链表:
所有节点存储在一个物理链表中,而不是两个不同的链表。每个节点包含两个指针:nextL
:指向左逻辑链表(leftInst
)中的下一个节点。nextR
:指向右逻辑链表(rightInst
)中的下一个节点。
- 两个头节点:
为了分别表示两个逻辑链表,我们需要两个头指针:headL
:指向左逻辑链表(leftInst
)的头节点。headR
:指向右逻辑链表(rightInst
)的头节点。
- 每个节点结构:
每个节点包含以下内容:key
:存储该节点的键值。nextL
:指向左逻辑链表中的下一个节点。nextR
:指向右逻辑链表中的下一个节点。
通过这种方式,两个逻辑链表的内容是共享相同的节点,但每个链表的遍历和修改操作通过各自的nextL
和nextR
指针来分别实现。
具体实现细节:
假设我们有以下链表结构和节点:
leftInst
(用于读操作):Node A → Node B → Node C
rightInst
(用于写操作):Node A → Node C → Node B
实际存储方式是一个物理链表,每个节点会有两个指针,分别指向**leftInst
** 和 rightInst
的下一个节点。
节点结构:
每个节点包含:
key
:节点的数据(例如Key A
、Key B
等)。nextL
:指向左逻辑链表中下一个节点。nextR
:指向右逻辑链表中下一个节点。
示意图:
物理链表: 左实例链表(leftInst): 右实例链表(rightInst):
HeadL → Node A → Node B → Node C HeadL → Node A → Node B → Node C HeadR → Node A → Node C → Node B
- 在这个物理链表中:
- Node A 的
nextL
指向Node B
(在左实例链表中的下一个节点),而nextR
指向Node A
(在右实例链表中的下一个节点)。 - Node B 的
nextL
指向Node C
(在左实例链表中的下一个节点),而nextR
指向Node C
(在右实例链表中的下一个节点)。
- Node A 的
操作流程:
- 读操作:
- 读操作使用
headL
作为左链表的头节点,通过nextL
指针遍历左逻辑链表(leftInst
)。
- 读操作使用
- 写操作:
- 写操作使用
headR
作为右链表的头节点,通过nextR
指针遍历右逻辑链表(rightInst
)。
- 写操作使用
- 节点插入与删除:
- 插入节点: 插入新节点时,修改
nextL
和nextR
的指向,确保插入操作不会破坏两个逻辑链表的结构。 - 删除节点: 删除操作会修改相应的
nextL
或nextR
指针,使链表保持一致性。
- 插入节点: 插入新节点时,修改
- 修改同步:
- 当写操作完成后,新的查找操作会被重定向到修改过的链表(例如
rightInst
)。 - 系统会等待正在进行的读操作完成,然后将修改同步到另一个链表(例如
leftInst
)。
- 当写操作完成后,新的查找操作会被重定向到修改过的链表(例如
优点:
- 节省内存: 只使用一个物理链表,避免了同时维护两个物理链表所需的内存。
- 并发支持: 左右逻辑链表可以并行地执行读写操作,一个链表用于读取,另一个用于写入,互不干扰。
- 无阻塞查找: 读操作和写操作可以独立进行,提高了系统的响应能力和并发性能。
总结:
左-右链表单一实例通过一个物理链表和两个逻辑链表的方式来实现并发的无阻塞读写操作。每个节点包含两个指针(nextL
和 nextR
),分别用于维护左右逻辑链表的结构。这种设计不仅节省了内存,还能高效地处理并发操作,特别适合在高并发环境中使用。
理解 Left-Right 链表的删除操作(remove(Y)
)
在 Left-Right Linked List 中,删除一个节点(例如 remove(Y)
)是一个需要协调多个线程操作的过程,特别是读写操作的并发执行。这个过程确保在删除节点时,不阻塞读操作,并保证一致性和无阻塞。
下面是关于如何从左-右链表中删除一个节点(例如 Y
)的详细步骤:
删除步骤(remove(Y)
)
- 步骤 1:写操作线程(Writer)开始操作:
- 写操作线程首先会从**与读线程不使用的逻辑链表(
leftInst
或rightInst
)**的头开始进行操作。 - 写线程会遍历该链表,并找到要删除节点的前一个节点。
- 找到对应的节点后,写线程会解除该节点的
next
指针的连接,也就是说,把前一个节点的next
指针指向该节点的下一个节点,从而“跳过”节点Y
,实现节点的逻辑删除。
- 写操作线程首先会从**与读线程不使用的逻辑链表(
- 步骤 2:重新定向新的读操作:
- 一旦写操作完成,新进入的读操作会被重定向到修改过的逻辑链表,即指向已经删除了
Y
的链表(即此时的链表结构没有节点Y
)。 - 写操作线程会等待正在执行的读操作完成后,确保旧的读操作不再访问已删除节点(
Y
)。这就需要协调并确保没有未完成的读操作在访问被删除节点。
- 一旦写操作完成,新进入的读操作会被重定向到修改过的逻辑链表,即指向已经删除了
- 步骤 3:节点删除:
- 一旦旧的读操作完成,写操作线程会再进一步解除另一个链表中的
next
指针(即在另一个链表中删除Y
)。 - 最后,节点 Y 可以被释放(即从内存中删除或“free”掉该节点),确保节点不再占用任何资源。
- 一旦旧的读操作完成,写操作线程会再进一步解除另一个链表中的
总结操作流程:
- 第1步:写操作线程从另一个逻辑链表的头节点开始,找到节点
Y
,并解除next
指针的连接,跳过该节点。 - 第2步:将新的读操作指向已经删除
Y
的链表,同时等待旧的读操作完成。 - 第3步:删除操作完成后,释放节点
Y
所占用的内存。
理解与特点:
- 并发支持:
- 写操作(删除节点)是在与读操作不同的逻辑链表上进行的,避免了对读操作的阻塞。
- 读操作始终会使用一个已经修改过的链表(通过重定向
headL
或headR
)。
- 一致性:
- 通过先解除
next
指针,避免了在删除节点的过程中出现并发访问的问题。 - 确保在删除节点之后,旧的读操作不会访问被删除的节点,直到所有读操作都完成,才能最终执行删除操作。
- 通过先解除
- 无阻塞:
- 通过分离逻辑链表的读写操作,允许多个读操作并行进行,而写操作仅影响未被读操作使用的链表。
- 内存管理:
- 删除操作最后通过
free()
来回收内存资源,防止内存泄漏。
- 删除操作最后通过
流程图概述:
- 写操作线程从
leftInst
或rightInst
中找到节点Y
并解除连接。 - 新的读操作被指向更新后的链表。
- 旧的读操作完成后,删除操作继续,最后释放节点
Y
。
通过这样的步骤,左-右链表实现了高效的并发操作,保证了无阻塞查找和线程安全的同时,能高效地进行节点删除操作。
理解 Left-Right 链表单一实例(Single)
在“左-右链表单一实例(Left-Right Linked List Single)”模式中,我们采用了一个物理链表来同时管理两个逻辑链表,分别是左逻辑链表(leftInst
)和右逻辑链表(rightInst
)。这种设计通过在每个节点中添加两个 next
指针来实现,每个指针指向不同的逻辑链表中的下一个节点。
基本思想
- 物理链表:
- 所有节点存储在一个物理链表中,而不是两个物理链表。
- 每个节点包含两个指针:
nextL
:指向**左逻辑链表(leftInst
)**中的下一个节点。nextR
:指向**右逻辑链表(rightInst
)**中的下一个节点。
- 逻辑链表头指针:
- 我们需要两个头节点:
headL
:指向左逻辑链表(leftInst
)的头节点。headR
:指向右逻辑链表(rightInst
)的头节点。
- 我们需要两个头节点:
节点结构:
每个节点包含以下字段:
key
:节点存储的实际数据或键(例如:Key A
、Key B
、Key C
)。nextL
:指向左逻辑链表中的下一个节点。nextR
:指向右逻辑链表中的下一个节点。
通过这种方式,尽管我们只有一个物理链表,但是逻辑链表(左链表和右链表)可以通过不同的nextL
和nextR
指针分别管理。
如何操作:
1. 插入操作:
- 当需要插入一个新节点时,系统会插入到左链表或右链表,这取决于插入位置的逻辑。每个插入操作会修改对应逻辑链表中的
nextL
或nextR
指针。 - 新节点的
nextL
和nextR
将指向适当的节点,确保两个逻辑链表的结构正确。
2. 删除操作:
- 当需要删除节点时,操作线程会遍历一个链表,解除其对应的
nextL
或nextR
指针,跳过要删除的节点。 - 删除后,两个链表中的指针都需要进行适当的更新,以保持链表的一致性。
3. 并发读写:
- 读操作和写操作在两个不同的逻辑链表上并行进行。读操作只会访问一个链表(
leftInst
或rightInst
),而写操作则可能同时修改另一个链表。 - 通过重定向新的读操作到修改后的链表,确保系统在进行写操作时不阻塞读操作。
示意图:
假设我们有如下结构:
leftInst
(读操作链表):Node A → Node B → Node C
rightInst
(写操作链表):Node A → Node C → Node B
在物理链表中,所有节点共享同一个链表,并且通过 nextL
和 nextR
分别指向两个链表中的下一个节点。
物理链表示意:
物理链表: 左逻辑链表(leftInst): 右逻辑链表(rightInst):
HeadL → Node A → Node B → Node C HeadL → Node A → Node B → Node C HeadR → Node A → Node C → Node B
- Node A:
nextL
→ 指向 Node B(在左逻辑链表中)。nextR
→ 指向 Node A(在右逻辑链表中)。
- Node B:
nextL
→ 指向 Node C(在左逻辑链表中)。nextR
→ 指向 Node C(在右逻辑链表中)。
- Node C:
nextL
→ 指向null
(在左逻辑链表中)。nextR
→ 指向 Node Y(如果有写操作时)。
总结操作流程:
- 读操作:通过
headL
来遍历左逻辑链表,使用nextL
。 - 写操作:通过
headR
来遍历右逻辑链表,使用nextR
。 - 两个链表是共享的,但是通过不同的指针来分别维护它们的结构。
优势:
- 节省内存:
- 使用一个物理链表来表示两个逻辑链表,减少内存消耗。
- 并发支持:
- 左右逻辑链表可以独立执行读和写操作,一个链表用于读取,另一个用于写入,实现了无阻塞并发。
- 一致性和安全:
- 两个逻辑链表通过不同的指针(
nextL
和nextR
)分别维护,确保修改操作不会影响正在进行的读取操作。
- 两个逻辑链表通过不同的指针(
- 灵活性:
- 通过动态切换读操作的目标链表,实现灵活的同步和数据一致性。
总结:
“左-右链表单一实例(Left-Right Linked List Single)”模式通过一个物理链表和两个逻辑链表的方式,实现了高效的内存使用和并发读写操作。每个节点包含两个指针(nextL
和 nextR
),分别指向左右逻辑链表的下一个节点。通过这样的设计,系统可以在保证一致性和线程安全的前提下,实现高效的操作。
理解 Left-Right 链表的删除操作(remove(Y)
)
在 Left-Right 链表单一实例 的模型中,删除节点(例如 remove(Y)
)是一个确保并发操作一致性和无阻塞的过程,尤其是在处理读写操作的并行执行时。以下是删除节点的具体步骤:
删除步骤 (remove(Y)
)
- 步骤 1:写操作线程(Writer)开始从相对链表头开始操作
- 写操作线程(Writer)首先从与读线程不使用的逻辑链表(
leftInst
或rightInst
)的头节点开始。 - 这个操作的目标是从与读线程不使用的链表中删除节点,因此写操作线程会在另一个链表上进行操作。
- 写操作线程找到要删除的节点(Y),然后解除它的
next
指针,即更新当前节点的next
指向跳过节点Y
的下一个节点。
- 写操作线程(Writer)首先从与读线程不使用的逻辑链表(
- 步骤 2:写操作线程重定向新的读操作到当前已修改的链表,并等待旧的读操作完成
- 一旦删除操作完成,写操作线程将新的读操作重定向到当前已修改的链表,即更新读操作所指向的逻辑链表(例如,修改为已删除
Y
的链表)。 - 同时,写操作线程会等待正在执行的旧读操作完成,确保它们不会访问已经删除的节点
Y
。 - 在这个等待期间,正在进行的读操作(如果在删除过程中仍在进行)会继续访问尚未修改的逻辑链表。
- 一旦删除操作完成,写操作线程将新的读操作重定向到当前已修改的链表,即更新读操作所指向的逻辑链表(例如,修改为已删除
- 步骤 3:删除节点 Y
- 一旦所有旧的读操作完成,写操作线程就会在另一个逻辑链表(
leftInst
或rightInst
)中解除节点Y
的连接(解除对应的next
指针),确保节点Y
被从链表中完全移除。 - 最后,节点
Y
会被释放内存(free()
),使得它不再占用系统资源。
- 一旦所有旧的读操作完成,写操作线程就会在另一个逻辑链表(
总结操作流程:
- 从相对链表开始删除:写操作线程从未被读操作使用的链表的头开始,解除节点
Y
的next
指针连接。 - 重定向读操作并等待旧读操作完成:写操作线程将新读操作指向已修改的链表,并等待旧的读操作完成。
- 解除另一链表的连接并删除节点:写操作线程会在另一个逻辑链表中解除节点
Y
的连接,并最终释放节点Y
。
过程解析:
- 并发控制:
- 读操作和写操作并行:读操作可以同时进行,但写操作需要确保在操作过程中不会影响到读操作。写操作完成后,新的读操作会被重定向到更新后的链表。
- 一致性保障:
- 在删除节点时,写操作确保不会影响到仍在运行的读操作。通过重定向新的读操作和等待旧读操作完成,避免了在节点删除时出现不一致的情况。
- 内存管理:
- 删除节点后,系统会释放节点
Y
所占用的内存,防止内存泄漏。
- 删除节点后,系统会释放节点
优势:
- 无阻塞读操作:由于读操作和写操作分别在不同的链表上进行,写操作不会阻塞正在进行的读操作。
- 高效并发支持:通过将读写操作分配到不同的逻辑链表,能够高效地支持并发操作,避免了锁竞争。
- 一致性和可靠性:通过等待旧的读操作完成,确保系统在修改链表结构时不会破坏数据一致性。
总结:
在 Left-Right 链表单一实例 模式中,删除节点 Y
的操作通过以下步骤保证了高效的并发性和一致性:
- 从未被读操作使用的链表开始删除节点。
- 重定向新的读操作并等待旧读操作完成。
- 完全删除节点并释放内存。
这种设计能够实现无阻塞的并发读写操作,并通过精细的操作步骤来保障数据一致性和系统稳定性。
Left-Right vs RCU
理解 Left-Right 模式与 RCPU(RCU)的区别
Left-Right 模式与 RCU(Read-Copy-Update) 的一些根本区别,虽然这两者看起来有一些相似之处,但它们的实现和应用方式是不同的。
Left-Right 模式与 RCPU 的区别:
1. RCPU(Read-Copy-Update)概述:
- RCU 是一种高效的 无锁并发技术,它允许多个线程并发读取数据而无需加锁,同时通过 复制更新 的方式来保证写操作的一致性。RCU 结合了 Copy-On-Write(COW) 模式,用于在写操作时复制数据。
- RCU 适用于内核编程,它需要依赖内核的支持(例如内核中的原子操作、内存屏障等)。RCU 是一种 内存回收机制,能有效处理并发读写,并且确保在没有读线程访问某个数据项时可以安全地回收内存。
- RCPU 可以与 Copy-On-Write (COW) 技术一起工作,确保数据在修改时不影响正在读取的线程。
2. Left-Right 模式概述:
- Left-Right 模式 是一种基于 两个逻辑链表 的结构,通过交替修改两个链表来避免读写冲突,特别适用于 无锁并发操作。
- Left-Right 模式 不依赖于原子操作,它的实现方式通常在 单线程数据结构 中运行,因此不需要在操作中使用原子指令或内存屏障。
- 在 Left-Right 模式中,读操作和写操作分别在两个链表中进行,读线程可以访问其中一个链表,而写线程则在另一个链表中进行操作,完成修改后再切换链表供读操作访问。
关键区别总结:
特性 | RCU | Left-Right |
---|---|---|
核心技术 | 结合 Read-Copy-Update 和 COW 模式。 | 通过 两个逻辑链表 交替处理读写操作。 |
内存回收机制 | 是一种 内存回收技术,适用于大规模并发。 | 不涉及内存回收,侧重于读写交替的实现。 |
原子操作 | 使用 原子操作 和内存屏障来确保一致性。 | 不使用 原子操作,通常适用于单线程。 |
是否需要内核支持 | 需要 内核支持(如 Linux 内核中的 RCPU)。 | 可以在任何语言中实现,无需内核支持。 |
使用的实例 | 没有实例,通常是全局数据结构。 | 使用 两个实例(两个链表)。 |
数据结构线程安全 | 适用于 多线程/多核环境,通过原子操作确保一致性。 | 适用于 单线程数据结构,不依赖于原子操作。 |
内存模型 | 需要 内存模型 和内核级支持。 | 可以在任何编程语言中实现,适合用户空间。 |
其他关键点:
- RCU 是一种内存回收机制,并且依赖于 Copy-On-Write (COW) 模式来高效地管理内存,特别是在进行并发读取和写入时。
- RCU 需要内核支持,并且通常与 原子操作 一起工作。它主要用于内核中的并发编程,以确保在高并发场景下的高效访问和内存管理。
- Left-Right 模式 适用于更广泛的编程语言环境,且不依赖于 原子操作,更适合于 单线程数据结构。它的设计目标是通过交替修改两个链表来避免并发访问的冲突,适用于不需要高复杂度内存管理的场景。
总结:
- RCU 是一个内存回收机制和并发访问控制技术,适用于需要高效并发处理的系统(尤其是内核级别的应用)。它依赖于原子操作和内存屏障来保证数据一致性,支持 无锁读 操作。
- Left-Right 模式 是一种通过交替使用两个链表来实现无锁读写的方式,它并不依赖于原子操作或内核支持,适合于多种编程语言的实现,通常用于较为简单的单线程数据结构。
尽管这两者有些相似之处,尤其是在支持并发读写操作方面,它们的应用场景和实现方式有所不同。
Left-Right vs RCPU(RCU)核心方法对比
在这一部分,描述了 Left-Right 模式与 RCU 模式的核心 API 对比以及它们的相互关系。虽然它们使用不同的名称和结构,但它们的功能和设计目标是相似的,尤其是在支持并发和避免冲突的方面。
RCU 与 Left-Right 的 API 对比:
RCU API:
- rcu_read_lock()
- 锁定 RCPU 读操作的临界区,保证读线程能够读取一致的数据。
- rcu_read_unlock()
- 解锁读操作的临界区,标志着读操作结束。
- synchronize_rcu()
- 确保所有 RCPU 读操作完成后再进行更新。它通常会阻塞直到所有读取完成,确保不会发生数据竞争。
- rcu_assign_pointer()
- 更新指针时使用,确保在并发环境下更新指针时的同步。
- rcu_dereference()
- 获取指针的值并确保读取的指针值是同步的。
Left-Right API:
- arrive()
- 这是 Left-Right 模式的读操作的开始,标志着读线程进入临界区。该方法通常会指定读线程使用哪个链表。
- depart()
- 这是读操作的结束,标志着读线程完成了操作,并释放对链表的访问。
- toggleVersionAndWait()
- 这是一个核心的同步方法,写线程调用该方法来切换到新的逻辑链表,并等待当前正在执行的读线程完成它们的操作。这个方法确保了读写的同步,避免数据竞争。
RCU 与 Left-Right 模式的等价性:
- 等价性与互换性:
- 从 API 的角度 来看,RCU 和 Left-Right 的核心算法是语义上可以互换的。即,你可以在 Left-Right 模式中使用 RCPU 的某些算法,反之亦然。这意味着它们的操作方法在实现读写同步时是可以互换的,尤其是在并发环境中使用锁和内存屏障时。
- 同步调用:
- 在 RCU 中,
synchronize_rcu()
作用是等待所有读线程完成,然后才能继续写操作。 - 在 Left-Right 中,为了实现类似的同步效果,可以通过一系列方法来实现,特别是:
- 使用
writersMutex.lock()
来保护写操作。 - 然后调用
toggleVersionAndWait()
来等待当前读线程完成。 - 最后通过
writersMutex.unlock()
解锁,完成同步操作。
这种方法保证了读操作完成后再进行修改,类似于synchronize_rcu()
的作用。
- 使用
- 在 RCU 中,
Kernel/OS 支持与通用性:
- RCU 的大多数算法(特别是内核空间中的 RCPU)需要 内核/操作系统支持。它们依赖于 原子操作 和 内存屏障,因此并不完全是通用的。
- 但有一些 用户空间实现(Userspace-RCPU) 可以不依赖内核支持:
- Userspace-RCPU Bullet Proof:这种实现适用于用户空间,确保在没有内核支持的情况下仍能高效地执行。
- Userspace-RCPU Memory Barriers:这种实现依赖于内存屏障,确保多个线程能够正确同步,而无需内核的原子操作。
- Left-Right 模式 则不依赖内核支持,并且可以在任何支持 内存模型 的编程语言中实现。它提供了更为简单的 API,且不涉及复杂的内核级别的同步机制。
总结:
- RCU 和 Left-Right 模式 在 API 层面上非常相似,都提供了对并发读写操作的支持。
- RCU 主要用于内核中,依赖原子操作和内存屏障,而 Left-Right 模式 可以在任何编程语言中实现,适用于更为简化的场景。
- 核心方法对比:RCU 使用的
rcu_read_lock()
和rcu_read_unlock()
与 Left-Right 的arrive()
和depart()
相似,而synchronize_rcu()
对应于 Left-Right 中的toggleVersionAndWait()
。
两者的主要区别在于,RCU 通常需要内核的支持,而 Left-Right 模式则更加灵活,适用于没有内核支持的用户空间应用。
RCU 与 Left-Right 算法对比与作用
在这一部分,我们对 RCU(Read-Copy-Update) 和 Left-Right 模式中的算法进行了对比,重点说明了它们的适用场景以及这些算法如何保证并发环境中的一致性和内存回收安全。
RCU 算法:
RCU 算法的关键在于允许多个线程并发读取数据而无需加锁,更新则通过复制机制来避免干扰读取线程。RCU 主要应用于 多线程/多核环境,特别是在 内核 或 需要高效并发访问的系统中。
RCU 算法和变种:
- RCU(需要内核支持):RCU 是一种专为高效并发设计的同步机制,它依赖内核支持来确保多线程环境中的一致性。
- Userspace RCPU (用户空间 RCPU):
- Quiescent State:用户空间中 RCPU 的 稳定状态,确保线程停止操作后,可以安全地回收内存。
- Signal:使用信号机制来通知线程完成某个操作,可以实现类似同步的效果。
- Memory Barriers:通过内存屏障来确保线程间的同步,避免因重排序导致的问题。
- Bullet Proof:用于提供更强的错误保护,确保即使在极端情况下也能安全地操作数据。
RCU 算法适用于需要 内存回收 和 并发读写 的场景,尤其是内核和其他高并发系统中。然而,并非所有 RCPU 算法都能在 C++1x 等用户空间编程语言中实现,因为它们依赖于 内核支持 或特定的操作系统机制。
Left-Right 算法:
Left-Right 模式 是一种基于 两个实例(逻辑链表)的并发数据结构模式,旨在通过交替修改两个链表来避免读写冲突,特别适用于不依赖内核的用户空间应用。
Left-Right 算法和变种:
- LR Classical:经典的 Left-Right 算法,用于传统的链表操作,确保读操作和写操作不会干扰。
- LR Readers Version:为每个读线程分配版本号,确保并发读操作的一致性。
- LR No Version:不使用版本号,简化实现,但可能会降低并发性能。
- LR Atomic Long:使用原子操作(Atomic Long)来确保在高并发情况下对链表的更新是安全的。
- LR GT:一种专为处理高并发读写设计的变种,通过全局时间戳(Global Time)来同步读写操作。
- LR David GoldBlatt’s variant:David GoldBlatt 提出的变种,改进了传统 Left-Right 算法的效率。
Left-Right 模式 适用于 单线程数据结构,并且不依赖于内核支持,因此更加灵活,适用于各种编程语言(例如 C++)。此外,Left-Right 模式具有多种变种,适用于不同的并发场景。
RCU 与 Left-Right 模式的主要差异:
特点 | RCU | Left-Right |
---|---|---|
并发读写 | 允许并发读取和写入,但写入需要复制。 | 使用两个实例,通过交替更新来避免读写冲突。 |
内存回收 | 依赖内存回收机制(Copy-On-Write)。 | 通过修改链表避免数据冲突,但不专门管理内存回收。 |
实现依赖 | 需要内核支持。 | 不需要内核支持,可以在任何编程语言中实现。 |
适用数据结构 | 高效的并发数据结构,适用于多核环境。 | 适用于单线程数据结构或轻量级的多线程环境。 |
实现复杂性 | 较复杂,涉及原子操作、内存屏障等。 | 较简单,通过切换链表来保证一致性。 |
内存管理 | 适合内存回收安全的高并发应用。 | 主要侧重于避免读写冲突,不涉及复杂的内存回收。 |
总结:
- RCU 算法 适用于 高并发、内核级别的并发控制,可以确保多线程环境中的 并发读写 操作一致性,并且通过 Copy-On-Write (COW) 和内存屏障等技术处理内存回收。但它依赖于 内核支持 和一些 操作系统级的机制,因此并不完全通用。
- Left-Right 模式 适用于 单线程或轻量级多线程 环境,它通过 两个实例 交替修改数据结构来避免读写冲突。它不依赖于内核支持,适用于任何编程语言,特别是 不需要原子操作或复杂内存管理 的场景。
最终,RCU 和 Left-Right 都可以用于并发数据结构的设计,选择哪种模式取决于具体的应用需求:是否需要内核支持、高并发的读写需求,还是需要在不依赖内核的环境中实现简单的并发结构。
一些通用技术的比较表
以下是关于不同并发控制技术的比较,重点描述了 只读操作、变更操作、内存使用 和它们的 优缺点。
技术 | 只读操作进度 | 变更操作进度 | 内存使用 | 观察与限制 |
---|---|---|---|---|
互斥锁(Mutual Exclusion) | 阻塞 | 阻塞 | 1 个实例 | 无并发,适用于基本的保护,但会阻塞所有操作。 |
读写锁(Reader-Writer Lock) | 阻塞 | 阻塞 | 1 个实例 | 允许多个并发读操作,但写操作需要等待。 |
双实例锁(Double Instance Locking) | 无锁 | 阻塞 | 2 个实例 | 变更操作需要做额外的工作,增加复杂性。 |
演员模型(Actors) | 阻塞 | 阻塞 | 1 个实例 | 可以为读操作实现无阻塞,但失去顺序一致性(seq-cst),不是线性化的。 |
写时复制(Copy-On-Write)+ RCPU | 无阻塞(WFPO) | 阻塞 | 1 或 2 个实例(对于 RCPU),N 个实例(对于 HPs) | 需要浅拷贝整个数据结构,内存分配和缓存系统可能发生变化。 |
Left-Right(左右链表模式) | 无阻塞(WFPO) | 阻塞 | 2 个实例 | 变更操作需要做额外的工作(2 倍的操作量),但读操作具有高扩展性。 |
关键观察与结论:
- 互斥锁(Mutual Exclusion):
- 只读和变更操作进度:两者都阻塞,因此无法实现高并发。
- 内存使用:使用1个实例,限制了并发性。
- 限制:适用于简单的并发控制,但会在高并发场景中成为瓶颈。
- 读写锁(Reader-Writer Lock):
- 只读和变更操作进度:允许多个并发读取,但写操作需要等待。
- 内存使用:使用1个实例,适合较低并发的场景。
- 限制:在高写负载的场景下效率较低,写操作的性能瓶颈可能会影响整体并发。
- 双实例锁(Double Instance Locking):
- 只读和变更操作进度:读取操作无锁,但变更操作被阻塞。
- 内存使用:使用2个实例,为读写操作提供分离的链表,减少冲突。
- 限制:变更操作需要额外的工作,这增加了内存使用和操作复杂性。
- 演员模型(Actors):
- 只读和变更操作进度:对读操作可以进行无阻塞处理,但变更操作依然阻塞。
- 内存使用:使用1个实例,对于无阻塞的读操作,也存在一定的内存开销。
- 限制:尽管可以为读操作实现无阻塞,但失去顺序一致性(seq-cst),并且不是线性化的,这在某些应用场景下可能不适用。
- 写时复制(Copy-On-Write)+ RCPU(Read-Copy-Update):
- 只读和变更操作进度:只读操作无阻塞(WFPO),但变更操作会阻塞。
- 内存使用:通常使用1或2个实例,如果需要更复杂的结构(如层次指针HPs),则使用更多实例。由于需要频繁地进行浅拷贝,可能会导致内存分配频繁。
- 限制:写时复制带来高内存消耗和频繁的内存分配,尤其在变更操作频繁的情况下,可能对系统性能造成负担。
- Left-Right(左右链表模式):
- 只读和变更操作进度:只读操作无阻塞(WFPO),但变更操作会阻塞。
- 内存使用:需要2个实例(分别对应左链表和右链表)。尽管变更操作需要额外工作,但它能保证高效的并发读操作。
- 限制:变更操作需要额外的2倍工作量,但对于高并发读操作,提供了较好的扩展性和较低的延迟。
关键结论:
- Left-Right 是唯一提供 线性一致性 的 通用技术,它能在 C/C++ 中容易实现,同时为 只读操作提供高扩展性和低延迟,非常适用于高并发读操作的场景。
- RCU(Read-Copy-Update) 提供了 只读操作的无阻塞进度(WFPO),但是由于其 写时复制 特性,变更操作可能会导致高内存消耗和内存分配开销。
- 读写锁 和 双实例锁 适用于 低并发读写场景,但它们在 高写操作 或 高并发环境 下可能不够高效。
- 总的来说,如果你需要一个 高并发读 的环境,Left-Right 模式 提供了 高效、易实现的解决方案,而 RCU 则更适用于 内核级或高并发读操作 的需求,但其内存开销需要注意。
Takeaways
- 互斥性与无阻塞读访问:
- Left-Right模式提供了对任意对象或数据结构的互斥访问,并且无阻塞的只读访问,这意味着多个线程可以同时读取数据而不会阻塞。唯一的代价是需要两倍的内存使用,因为它需要使用两个实例(左右链表)。
- 与RCU算法的兼容性:
- Left-Right模式中的任何算法都可以与RCU算法互换,虽然可能存在一些限制。这意味着,Left-Right算法和RCU算法都可以用于线程安全的内存管理,且它们在内存回收和并发控制方面的作用是类似的。
- 扩展性与低延迟:
- 使用Left-Right模式时,你可以使任何单线程数据结构在只读操作下几乎达到线性扩展,并且能够低延迟地处理大量并发的读操作。
- 简洁的实现:
- Left-Right模式的完整实现代码量少于50行,这使得它非常简洁且易于实现,适用于开发者需要快速部署并发安全数据结构的场景。
- 无阻塞、与人群无关(Wait-free Population Oblivious):
- 重要的一点是,Left-Right模式是无阻塞的,并且它对线程数量无关,意味着随着线程数量的增加,性能不会显著下降,读操作依然能够保持高效。
总结:
- Left-Right模式为并发编程提供了一种高效、简单的解决方案,适用于需要高并发只读操作并保证线程安全的数据结构。
- 尽管它的内存使用是RCU的两倍,但由于其简洁性(小于50行代码)和低延迟的读操作,它非常适合高并发、低延迟的场景。
- 最后,无阻塞且与线程数量无关(Wait-free Population Oblivious)的特性,让它在复杂的并发程序中非常有价值。
如果你正在设计高性能并发数据结构或需要优化内存访问模式,Left-Right模式是一个值得考虑的解决方案。