|
| 1 | +--- |
| 2 | +layout: single |
| 3 | +title: Differences between SMR mechanisms |
| 4 | +date: 2023-01-14 00:00:00 +0800 |
| 5 | +categories: 论文 实践 |
| 6 | +tags: C++ Concurrency |
| 7 | +--- |
| 8 | + |
| 9 | +### Hazard pointer vs shared_ptr |
| 10 | + |
| 11 | +相同之处:管理对象的生命周期 |
| 12 | + |
| 13 | +不同之处:一言以蔽之,Hazard pointer是线程安全的,但`shared_ptr`不是 |
| 14 | + |
| 15 | +- 为什么`hazard pointer`是线程安全的? |
| 16 | + |
| 17 | +首先读线程不会修改任何指针所指向内容,并且会将指针记录进全局列表中,其他线程是不能对全局列表中的指针所指向的内容进行修改的(包括释放指针所指向的内存)。然后任何写线程,只会释放同时满足以下两个条件的指针所指向内存: |
| 18 | + |
| 19 | +1. 该指针是在当前写线程所替换下来的旧版本指针 |
| 20 | +2. 该指针没有其他读线程在使用(即没有出现在全局列表中) |
| 21 | + |
| 22 | +读写线程通过这些约束,保证了`hazard pointer`是线程安全的。 |
| 23 | + |
| 24 | +- 为什么智能指针不是线程安全的? |
| 25 | + |
| 26 | +不管是`unique_ptr`还是`shared_ptr`都只能管理智能指针中的那个对象的生命周期(而不是智能指针本身),也就是通过引用计数的方式发现如果智能指针中的对象不再被使用,就会回收对应内存。但如果有多个线程同时修改智能指针,将这个指针指向不同对象,又或者其中夹杂若干读取智能指针的操作,这显然不是线程安全的,只能通过外部的锁等机制进行同步。 |
| 27 | + |
| 28 | +### Hazard pointers vs Epoch-based reclamation |
| 29 | + |
| 30 | +| | EBR | Hazard pointers | |
| 31 | +| --- | --- | --- | |
| 32 | +| 核心原理 | 只有全部线程同意,才能推进epoch,过期epoch的指针一定可以安全回收。 | 读线程告知其他线程我正在使用特定指针,其他线程不可以修改或释放这个指针指向的对象,但可以将指针指向其他对象。 | |
| 33 | +| 粒度 | 粗 | 细 | |
| 34 | +| 速度 | 快 只需要获取全局epoch即可 | 慢 需要CAS操作一个全局链表 | |
| 35 | +| 内存释放内存时机 | 特定epoch没有被任何线程所引用 | thread local列表达到一定长度 | |
| 36 | +| 内存释放是否会被阻塞 | 会 | 不会 | |
| 37 | + |
| 38 | +这里解释几个方面 |
| 39 | + |
| 40 | +- 粒度:EBR一旦进入资源临界区之后,可以操作任意数量和类型的对象指针,只要在退出资源临界区之前,将指针放到epoch对应的列表中即可。但Hazard pointers每次进入资源临界区之前都需要先获取特定类型对象的指针,之后的操作也限定于这个指针,退出资源临界区之前,将指针放到thread_local的列表中。所以从这个角度上说,EBR是粗粒度,而Hazard pointers是细粒度。 |
| 41 | +- 速度:这里主要对比进入和退出资源临界区的速度 |
| 42 | + - EBR只需要获取一次全局epoch |
| 43 | + |
| 44 | + ```cpp |
| 45 | + void acquire() { |
| 46 | + active_flags_[get_thread_id()].active_ = true; |
| 47 | + CPU_BARRIER(); |
| 48 | + local_epoches_[get_thread_id()].epoch_ = global_epoch_.epoch_; |
| 49 | + } |
| 50 | + |
| 51 | + void release() { |
| 52 | + active_flags_[get_thread_id()].active_ = false; |
| 53 | + } |
| 54 | + ``` |
| 55 | + |
| 56 | + - Hazard pointers则需要通过CAS操作来在全局链表中获取一个节点 |
| 57 | + |
| 58 | + ```cpp |
| 59 | + HazptrNode* acquire() { |
| 60 | + auto p = head_; |
| 61 | + while (p != nullptr) { |
| 62 | + if (p->active_ || !CAS(&(p->active_), false, true)) { |
| 63 | + p = p->next_; |
| 64 | + continue; |
| 65 | + } |
| 66 | + return p; |
| 67 | + } |
| 68 | + |
| 69 | + HazptrNode* node = new HazptrNode(); |
| 70 | + VLOG(2) << "[Node] new node " << node; |
| 71 | + node->active_ = true; |
| 72 | + |
| 73 | + HazptrNode* oldHead; |
| 74 | + do { |
| 75 | + oldHead = head_; |
| 76 | + node->next_ = oldHead; |
| 77 | + } while (!CAS(&head_, oldHead, node)); |
| 78 | + return node; |
| 79 | + } |
| 80 | + |
| 81 | + void release(HazptrNode* node) { |
| 82 | + (node->pHazard_).store(nullptr, std::memory_order_release); |
| 83 | + node->active_ = false; |
| 84 | + } |
| 85 | + ``` |
| 86 | + |
| 87 | +- 内存释放是否会被阻塞 |
| 88 | + |
| 89 | +除了粒度和速度之外,二者最关键的不同之处就在于能否及时回收内存。EBR由于某个线程可能在特定epoch长时间不退出资源临界区,导致内存回收不及时。另外如果大量线程在不断尝试进入和退出资源临界区,也可能导致全局epoch较难推进。所以EBR通常的使用场景在于内存对象的生命周期非常短的情况下。而Hazard pointers则不存在阻塞的问题,任何一个线程所使用的对象,不会阻塞其他线程的内存回收。 |
| 90 | + |
| 91 | +### Hazard pointers vs RCU |
| 92 | + |
| 93 | +二者的主要区别同样是在于粒度和是否阻塞。 |
| 94 | + |
| 95 | +- 粒度:Hazard pointers如之前所说,只保护单个指针。但RCU是保护可以保护资源临界区中的所有指针指向内存不被释放。 |
| 96 | +- RCU和EBR一样,如果某个线程在资源临界区的时间过长,会阻止资源临界区中的对象回收。所以RCU对于同样也是期望读临界区的时间足够短。 |
| 97 | +- RCU可以在读操作时做到wait-free,而Hazard pointers只能做到lock-free |
| 98 | + |
| 99 | +> Quoted from folly: |
| 100 | +So roughly: RCU is simple, but an all-or-nothing affair. A single rcu_reader can block all reclamation. Hazptrs will reclaim exactly as much as possible, at the cost of extra work writing traversal code |
| 101 | +> |
| 102 | +
|
| 103 | +[p0461r1.pdf (open-std.org)](https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/p0461r1.pdf) 这个论文比较了几种延时内存回收的机制 |
| 104 | + |
| 105 | + |
0 commit comments