背景

众所周知,基于X86架构的CPU瓜分了服务器领域90%领域以上的市场,而基于ARM架构的CPU则占据了移动芯片领域绝大部份的市场。MySQL作为流行的通用数据库,可能运行在任何架构的CPU上。然而,与X86不同,ARM架构的CPU往往是弱内存序模型,这对于基于原子操作+内存屏障实现锁机制的InnoDB而言,可能引入新的bug。

image.png

image.png

如上图所示,X86属于强序模型,仅会发生“写-读”乱序:即写操作后的读操作被乱序到写操作前执行。引入乱序机制的根本原因在于片上缓存/同步机制的设计机制(为了提高CPU流水线的执行效率)。介绍这方面资料的相关文章很多,读者可以自行搜索阅读,本文在此不表。而ARM架构的处理器核数往往更多,因此它采用了更加激进的弱序模型,除了依赖读操作,所有读/写操作都可能出现乱序的问题。基于这一背景,我们发现了MySQL 8.0.13代码在ARM上出现的死锁问题(目前官方已修复:link)。

问题分析

如上文所述,InnoDB基于原子操作+内存屏障实现了自己的一套锁机制。为了便于读者阅读和问题理解,我们简化了相关代码。对于读写锁rw_lock_t类型,我们主要介绍writer_thread和recursive这两个变量:writer_thread表示持有锁的写线程,recursive表示这个锁是否是递归锁和writer_thread值的合法性。我们假设两个线程A和B按照以下顺序执行锁操作:step1. A成功申请了写锁,并调用rw_lock_set_writer_id_and_recursion_flag()函数,修改了writer_thread=A和recursive=true这两个变量,recursive=true表示writer_thread的值是合法的;step2. A释放了写锁,将recursive变量修改为false,表示writer_thread是非法的;step3. B申请了写锁,并调用rw_lock_set_writer_id_and_recursion_flag()函数,修改了writer_thread=B和recursive=true这两个变量;step4. A申请写锁,发现写锁已经被某线程持有。然而因为rw_lock_t是递归锁,A需要检查持有该写锁的线程是否是自己,如果是就成功获得锁。如果多线程执行无法保证step3和step4两组操作之间的执行顺序,这里的判断逻辑就会在ARM架构上引入严重bug。

首先我们说明rw_lock_set_writer_id_and_recursion_flag()函数。由于os_compare_and_swap_thread_id原子操作包含了wmb屏障,这里的写操作逻辑在ARM上没有问题。writer_thread会先被设置,然后lock->recursive才被设置成true表示writer_thread是合法的。

  1. /* rw_lock_set_writer_id_and_recursion_flag()函数 */
  2. 1. local_thread = lock->writer_thread;
  3. 2. /* 原子操作包含wmb,这块的顺序没问题 */
  4. 3. success = os_compare_and_swap_thread_id( &lock->writer_thread, local_thread, curr_thread);
  5. 4. lock->recursive = recursive;

其次,我们说明step4中的判断逻辑。首先,line-3的os_rmb对本问题毫无作用,我们来看line-5的问题。line-5主要包含了lock->recursive和os_thread_eq(lock->writer_thread, thread_id)的判断,包含lock->recursive和lock->writer_thread两个读操作。在X86上,我们保证先读lock->recursive,再读lock->writer_thread的顺序。如果lock->recursive为true,我们才会访问lock->writer_thread的值,这就和上面的rw_lock_set_writer_id_and_recursion_flag()函数相呼应,保证看到的lock->writer_thread一定是最新的。

  1. /* 判断是否是本线程持有了这个rw_lock_t锁 */
  2. 1. os_thread_id_t thread_id = os_thread_get_curr_id();
  3. 2. if (!pass) {
  4. 3. os_rmb; /* 这个rmb有什么问题吗? */
  5. 4. }
  6. 5. if (!pass && lock->recursive && os_thread_eq(lock->writer_thread, thread_id)) {
  7. 6. /* 判断是本线程持有了锁,开始执行后续逻辑 */

然而,ARM这类弱序模型可能打乱了lock->recursive和lock->writer_thread两个读操作的顺序。以step4为例,A先访问了lock->writer_thread,然后才访问lock->recursive。这时候如果step4和step3是交叉执行的,就会引入bug。例如A访问lock->writer_thread是在step3之前,这时候它获取到的lock->writer_thread=A(这时候lock->recursive=false,表明这个值是无效的)。然而如果这时候step3执行完成,A然后才访问了lock->recursive=true,这就导致A以为自己持有了这个写锁,就进入了后面的递归锁逻辑。这导致了临界区的混乱,两个线程可能进入了同一个临界区。这个问题导致的后果,轻则死锁,重则mysqld崩溃甚至数据写坏。

问题修复

基于上述分析,修复这个问题仅需要保证lock->recursive和lock->writer_thread两个读操作的顺序,因此我们的修复方案如下:

  1. /* 判断是否是本线程持有了这个rw_lock_t锁 */
  2. 1. bool recursive;
  3. 2. os_thread_id_t writer_thread;
  4. 3. if (!pass) {
  5. 4. recursive = lock->recursive;
  6. 5. os_rmb;
  7. 6. writer_thread = lock->writer_thread;
  8. 7. }
  9. 8. if (!pass && recursive && os_thread_eq(writer_thread, thread_id)) {
  10. 9. /* 判断是本线程持有了锁,开始执行后续逻辑 */

通过这个bug,我们了解到:在ARM这类弱序模型上编写多线程程序的时候(尤其是lock-free算法),要特别注意内存屏障的使用,避免出现临界区混乱等问题