19

Facebook Folly源代码分析

作者: baiyuzhong 分类:架构实践, 选题策划   阅读:24,295 次 添加评论

Folly是Facebook的一个开源C++11组件库,它提供了类似Boost库和STL的功能,包括散列、字符串、向量、内存分配、位处理等,用于满足大规模高性能的需求。

6月初,Facebook宣布将其内部使用的底层C++组件库Folly开源,本文尝试对Folly库中的几个重要的数据结构代码进行分析,包括一些实现细节的讨论、特点和不足的分析,以及在工程上的应用。本文将首先分析RWSpinlock.h和ThreadLocal.h的源代码。

RWSpinlock.h

顾名思义,RWSpinlock就是使用自旋方式进行临界区资源等待的读写锁,它与pthread_rwlock_t有三个比较重要的区别。

  • 通常情况下等待锁的线程不会主动让出CPU,而是循环中不断地尝试获取锁。
  • 使用原子操作处理读者计数或写者状态,避免pthread_rwlock_t无论读写的加锁解锁都要在互斥锁的保护下进行。
  • 提供类似数据库中”更新锁”的机制,在尽量提供更高并发的情况下,避免死锁。

    表1 读写锁状态相容矩阵

RWSpinlock实现中几处值得关注的地方如下。

自旋锁在锁粒度较小的情况下,使用自选等待方式等锁,可以避免较高的上下文切换代价。而为了自适应多次获取锁失败的情况,可以主动让出CPU。Folly的 实现比较简单,硬编码为在自旋1000次后仍无法获取锁的情况下,以后的每次循环都调用sched_yield主动让出CPU以调度其他线程上来运行(要 研究更复杂的自适应锁机制,可以参考Solaris内部实现的Adaptive locks或注1提到的论文)。但在获取读锁次数远大于写锁次数的情况下,RWSpinlock的读优先机制可能造成写者的饥饿,而主动让出CPU的机制 则可能加重写者的饥饿程度。因此Folly中同时实现了可配置为写者优先的RWTicketSpinLockT锁。

与通常对读计数器加1的 思路不同,RWSpinlock使用int32_t的高30位保存读计数,而使用最低两位保存upgrade和write标记。在加解读锁时直接对 int32_t的锁状态原子加减0×4,直接避开了对最低两位的修改,执行原子加0×4后再根据原子操作前的最低两位是否有效,来决定是否需要回滚(减 0×4)。在多数只获取读锁的情况下,不需要回滚,一次ATOMIC_ADD即完成读锁的加解锁。

图1 使用比ATOMIC_CAS更高效的ATOMIC_ADD处理读锁的加锁和解锁

Folly中的RWSpinlock还提供了类似数据库中“更新锁”的upgrade锁,用于对锁保护对象先读后改时避免死锁的需求,它与读写锁的状态相容矩阵如表1所示。

Write/Read/Upgrade三种锁状态除了可以和初始状态进行加解锁的双向转换外,也可以在某些锁状态之间进行转换,即原子的释放原来的锁并获取到新的锁,锁状态转换如图2所示。

图2 锁状态转换

可以看出,除读写状态外,其他任意两个状态之间都是双向的转换,只有读写之间是单向转换,即在持有写锁的情况下,可以降级为读锁,而在持有读锁的情 况下却不能升级为写锁。原因很简单,在两个及以上线程都持有读锁,并尝试获取写锁的情况下,由于释放读锁和获取写锁必须原子性的完成,而要获取写锁就要等 待其他线程释放读锁,在这种情况下线程将进入死锁状态。

因此某些对锁保护对象需要先读取再决定是否修改的情况,只能在读取之前就加上写锁, 而在读取后需要修改的情况很少时,这种方式代价就比较大,因为它阻塞了其他线程获取读锁。Upgrade就应运而生,从相容性矩阵可以看到,Upgrade锁与读锁相容,而与其他upgrade和写锁不相容,线程在读取数据之前可以先获取upgrade,读取数据之后,如果决定需要修改, 就升级为写锁。

一个具体的使用示例如下,如图3所示,考虑实现一个在结点上加锁的B+tree。

图3 结点加锁B+tree查询

查询操作,按照如下操作顺序从根节点开始加锁和查询:

1. 对父节点加读锁;

2. 获取子节点的读锁;

3. 释放父节点读锁。

继续在当前节点上执行第二步,直到查询到叶子节点。

更新操作,按照如下操作顺序从根节点开始加锁和查询:

1. 对父节点加upgrade锁;

2. 获取子节点的upgrade锁;

3. 判断子节点如果可能需要分裂或合并,升级父子节点为写锁;

4. 执行子节点分裂或合并,并修改父节点内容;

5. 释放父节点锁;

6. 继续在当前节点上执行第二步,直到叶子节点,对叶子节点执行插入/删除/修改。

因此在B+tree的应用中,由于索引节点的分裂合并操作比较少见,使用upgrade锁,避免与读锁的竞争,只有在必要时才升级为写锁。

关 于C++0x中atomic对象中的memory_order,RWSpinlock使用std::atomic保存上面提到的读锁计数、写锁标记、 upgrade锁标记,使用了fetch_add、fetch_and、fetch_or、compare_exchange_strong这几个原子操 作函数来修改锁状态。作者在不同的场景下使用了三种不同的memory_order,与作者沟通,他的解释如下:

For example, unlock_shared() can be delayed to other memory_order_release (or memory_order_relaxed), but not memory_order_acquire, which means it ok for the compiler (and machine) to reordering unlock_shared() from different threads.

但从gcc4.6.3中std::atomic的实现和生成的汇编代码来看,上面提到的几个原子操 作函数,直接使用了gcc提供的几个以__sync开头的内置原子操作,忽略掉了传入的memory_order参数。而只有store函数的行为针对不 同的memory_order只有是否增加mfence指令的差别。最后,笔者的建议是在性能影响不大的的情况下,直接使用std::atomic默认的 高级别的memory_order,因为通过分析复杂的原子操作指令优化时序,来决定memory_order,收益可能不及它带来的风险。

写 者优先的RWTicketSpinLockT锁,提供写锁优先的调度机制,在有线程等待获取写锁的情况下,不再授予读锁,避免在大量加读锁的场景下,写锁 很难获取的问题。使用了gcc内置的原子操作__sync_fetch_and_add和__sync_bool_compare_and_swap替代 std::atomic,并且也没有用到其他C++0x特性,使用旧版本gcc的项目可以使用这个锁。

Folly注重效率,因此RWTicketSpinLockT中也有几处值得关注的细节。

  • 在等待获取锁的自旋中使用pause指令,一方面可以降低CPU的功耗,另一方面还可以帮助CPU优化指令流效率,具体可以参考注2的Intel白皮书。 而在写锁不优先的情况下,由于pause带来的延迟可能导致写锁更不容易被获取,因此获取非优先的写锁不使用pause指令。
  • 使用SSE并行指令,对多个地址连续的整数一个指令完成++操作。
  •  减少分支判断。见源码try_lock_shared()的old.users = old.read。将users与read是否相等的逻辑延迟到CAS操作时顺便判断,尽管在加不上读锁的情况下,要多执行两个自加和一个CAS操作,但 在加读锁成功的多数情况下,省去了一次分支判断。
  • 使用__sync_fetch_and_add代替 __sync_bool_compare_and_swap。RWTicketSpinLockT使用了名为Write、Read、User的三个计数器 用来保存读锁计数和写锁标记,方法比较巧妙。读锁需要在user等于read的情况下才可以加上,而写锁则需要满足user等于write,加解锁逻辑如下。

1. 通过对read和user原子加一,获取读锁,同时封锁了加写锁的条件。

2. 通过对read原子加一,获取写锁,同时也就封锁了加读锁的条件;这里通过先对read加一,封锁了后续读锁的条件,然后再等待写锁的条件被满足,实现了写锁优先的逻辑。

3. 通过对write原子加一,释放读锁,同时恢复写锁的加锁条件。

4. 通过对read和write原子加一,释放写锁,同时恢复读锁的加锁条件。

可以看出写锁的获取和读锁的释放可以避免使用CAS,而用一个原子加即可实现。

ThreadLocal.h

在服务器编程中,通常会遇到需要为每个线程都分配不同对象的情况,如线程处理一个请求需要使用的临时内存、远程调用需要临时构造的参数等等。在需要的时候临 时构造,不仅要付出构造成本,还会有内存申请释放的代价,而使用线程主函数的栈对象,每一层都要传递参数也让代码很不便维护。

Folly中实现的ThreadLocal.h提供了对象的线程局部存储和访问,其功能与pthread_getspecific相似,提供了更方便友好的调用方式, 线程退出后自动析构本线程内所欲的私有对象,并且提供遍历所有线程私有对象的接口。实现上使用了GCC内置特性,实现比pthread库更快的线程私有对 象访问。

ThreadLocal内存布局如图4所示,主要由StaticMeta、ThreadEntry和ElementWrapper三者组成。

图4 ThreadLocal内存布局

  • StaticMeta为全局唯一结构,主要包括各个线程管理结构组成的链表的头指针和对象ID生成器,用于全局析构和遍历所有线程的私有对象。
  • ThreadEntry为线程私有结构,每线程对应一个,主要包括线程线程私有对象的指针数组,管理所有线程私有对象的指针,通过ID获取指定对象的指针。
  • ElementWrapper是线程私有对象管理器,每个对象实例对应一个,主要包括指向对象实例的指针和对象的析构方法。

假设要管理的线程私有类型为T,初始化和访问线程私有对象的流程如下。

1. ThreadLocalPtr对象构造时即从StaticMeta为它管理的类型申请了唯一ID。

2. 使用TheadLocalPtr::get方法通过ID从ThreadEntry管理的ElementWrapper数组中获取一个ElementWrapper对象。

3. 如果ElementWrapper中T的指针为空,则构造一个T的对象,指针和析构方法保存在ElementWrapper对象中。

4. 如果ElementWrapper中的T指针不为空,则直接返回。

在 Folly的实现中值得注意的是:ThreadEntry对象在每个线程中有一个,使用gcc内置的static __thread方式声明ThreadEntry,即可实现同一个名字在不同线程访问到的是不同对象,需要注意的是这种方式仅适用于POD类型。由于直接 访问对象,这种方式比调用pthread库的pthread_getspecific函数调用方式效率要更高。

但由于 ThreadEntry是POD类型,在线程退出时不能自动析构释放它管理的线程私有对象,因此在StaticMeta构造时会申请一个 pthread_key_t注册线程退出时的回调函数,在回调函数中遍历当前线程ThreadEntry管理的所有私有对象,依次调用它们的析构方法。因 此在Folly的实现中,对pthread库函数仅仅使用了它在线程退出时调用回调函数的功能。

此外还有两处细节值得借鉴。

●ThreadLocal还区分了单个线程退出和整体析构情况下,传给析构方法不同的参数,以便用户在必要的情况下区别这两种情况下析构方法的实现。

●提供了指定立即析构释放当前线程私有对象的方法,而不必等待线程退出时才释放,这一点在单元测试多个case的情况下可能会使测试变得比较方便。

注释

注1: Adaptive Locks: Combining Transactions and Locks for Efficient Concurrency

注2: Using Spin-Loops on Intel® Pentium® 4 Processor and Intel® XeonTM Processor

注3: A Provably Correct Scalable Concurrent Skip

注4: Hazard Pointers: Safe Memory Reclamation for Lock-Free Objects

注5: Doug Lea. ConcurrentSkipListMap. In java.util.concurrent

 

作者李凯,现任淘宝核心系统部存储组技术专家,花名郁白。2007-2010年曾参与百度分布式文件系统研发,2010年至今参与淘宝Oceanbase项目研发。

 

转播到腾讯微博

----->立刻申请加入《程序员》杂志读者俱乐部,与杂志编辑直接交流,参与选题,优先投稿

2 Responses to “Facebook Folly源代码分析”

  1. zach 说道:

    4. 执行子节点分裂或合并,并修改父节点内容;
    应该将子节点的update lock升级为write lock吧

  2. TureCode_PHP 说道:

    如果随便升级其索的权限,可能会使下级的update lock变得无法控制。

请评论

preload preload preload
京ICP备06065162