1. 简介
Metadata Lock,顾名思义,是对元数据的保护。MDL 是在 5.5 中引入的,之前版本对于元数据也有保护,但实现为语句级别的,当语句结束后元数据相关的锁就会被释放掉。这种实现会导致两个主要的问题:
- 无法实现 RR 隔离级别,比如以下场景:
tx1: BEGIN;tx1: SELECT * FROM tbl; -- 获取元数据锁,返回(c1,c2),释放元数据锁tx2: ALTER TABLE tbl DROP COLUMN c2; -- 获取元数据锁,成功,释放元数据锁tx1: SELECT * FROM tbl; -- 返回(c1),不可重复读tx1: END;
- 备库重放经常出错然后停止,比如以下场景:
tx1: BEGIN;tx1: INSERT INTO tbl(c1, c2) VALUES(v1, v2);tx2: ALTER TABLE tbl DROP COLUMN c2; -- commit并写入binlog 1tx1: END; -- commit,写入binlog 2,备库先重放binlog 1,重放binlog 2时出错
于是 5.5 版本引入了 MDL,实现为事务级别,事务中获取的 MDL 锁在事务提交或回滚时释放,并在后续版本中针对最初实现做了很多优化,包括:
- MDL lock hash 函数的修改
- 把保存 MDL lock 的 hash 表分区提升并发性能
- Lock-free MDL hash table (5.7)
- 对常用锁模式的 Lock-free 加锁 (5.7)
本文将介绍 5.7 中的实现。
2. MDL 使用
内核开发过程中有时候需要使用 MDL 子系统,关于 MDL 使用有几个重要的概念:
- MDLkey, 用来标识要加锁的目标对象,是一个三元组,namespace:dbname:objectname;dbname 和 objectname 很好理解,namespace 是一个枚举,表示目标对象的分类,根据 namespace 判定后续的 dbname 和 objectname 的含义以及是否有效。目标对象分为两大类,范围锁 (scoped lock) 和目标锁(object lock),常见的范围锁类型包括 GLOBAL, SCHEMA, TABLESPACE 和 COMMIT,常见的目标锁类型主要为 TABLE。MDLkey 在代码中的定义为:
struct MDL_key { enum enum_mdl_namespace { GLOBAL = 0, // global范围锁 TABLESPACE, // 表空间范围锁 SCHEMA, // 数据库范围锁 TABLE, // 表目标锁 ... COMMIT, // commit范围锁 }; char m_ptr[]; }
三元组以字符串的方式连续存储在 m_ptr 中。目标锁理解为当要访问某些具体对象时需要对该目标加元数据锁,范围锁理解为当要进入某些范围内并且会在该范围内造成数据库改动时,需要加范围锁,其中 GLOBAL 范围锁几乎所有的会造成改动的语句都需要获取,COMMIT 锁是在做事务提交之前获取,通过 GLOBAL 和 COMMIT 范围锁可以控制 SQL 语句的进入以及提交 (FTWRL 实现方式)。
- enummdltype,加锁的模式,主要包括:
MDL_INTENTION_EXCLUSIVE IX // 只用于范围锁 MDL_SHARED S // 只能读元数据,不能读写表数据,如检查表是否存在时用这个锁 MDL_SHARED_HIGH_PRIO SH // 只能读元数据,不能读写表数据,用于填充INFORMATION_SCHEMA,或者SHOW CREATE TABLE时,和S模式区别在于若其他线程持有S锁,且等待队列中有X锁时,可以直接获取锁(S锁需要等待X模式先被授予, SH可以优先于等待队列中的X) MDL_SHARED_READ SR // 可以读表数据,SELECT语句使用 MDL_SHARED_WRITE SW // 可以写表数据,DML语句和SELECT FOR UPDATE使用 ... MDL_SHARED_UPGRADABLE SU // 可升级锁,类似SR,可以读表数据,但可以升级为SNW或者X锁,ALTER TABLE用 ... MDL_SHARED_NO_WRITE SNW // 其它线程能读元数据和表数据,但不能写表数据,持锁线程可以读写表数据,可以升级成X锁,ALTER TABLE用 MDL_SHARED_NO_READ_WRITE SNRW // 其它线程能读元数据,但不能读写表数据,持锁者可以读写表数据,可以升级成X锁,LOCK TABLES WRITE用 MDL_EXCLUSIVE X // 排它锁,可以写元数据,禁止其它线程读元数据,CREATE/DROP/RENAME/ALTER TABLE用
值得注意的两点:
- 这里的模式指的是对元数据的锁模式,如果要读写表数据,需要先获取相应的 SR 模式 MDL 锁,然后获取相应的 table lock 才能安全访问数据,比如 TL_READ;
- IX 模式只能用于范围锁,范围锁上能获取的模式只包括 IX,S 和 X;
这几种模式中,SU 比较特别,其他的模式都很容易理解。SU 特别之处在于它的语义类似 SR,但 SR 和 SR 之间是兼容的,而 SU 和 SU 之间是冲突的。SU 的存在是为了解决一类死锁问题。对于某些 DDL 语句的实现 (如 ALTER TABLE),为了不长时间阻塞其他数据库操作,需要先对表获取一个低级别 MDL 锁以读取数据,然后做一些很耗时的操作,最后关键步骤(如 rename) 将之前的低级别 MDL 锁升级为 X 锁以修改元数据。如果没有 SU 锁模式的存在,最合适的低级别 MDL 锁模式即为 SR,那么就会存在如下场景:
tx1: acquire SR lock mode tx2: acquire SR lock mode tx1: acquire X lock mode // 阻塞,因为tx2拿着SR,X和SR冲突 tx2: acquire X lock mode // 阻塞,因为tx1拿着SR,死锁
将上述场景中的 SR 替换为 SU 就能避免死锁,tx2 在获取 SU 时就会阻塞,因为和 tx1 持有的 SU 冲突;5.7 中的 ALTER TABLE ... ALGORITHM = COPY 就是这么实现的,先在打开表时获取 SR 锁,然后升级为 SNW 并读取表数据(其他线程不能写入表数据了),构建临时表,在最后 rename 表阶段升级为 X 模式。
另一个涉及锁升级的 DDL 语句是 CREATE TABLE,首先获取 S 模式的 MDL 锁,用于检查表是否存在,然后升级为 X 模式并真正建表;它是直接从 S 升级到 X,所以会存在上述的死锁问题,解决方法是代码中加了一个重试逻辑;
各种表模式的核心含义在于它们相互之间的兼容与互斥关系,这种关系可以划分到两类里,范围锁模式的互斥关系和目标锁模式的互斥关系,每一类下又包括两种互斥关系,一种是与已经被授予的锁模式的互斥关系,另一种是与等待队列中锁模式的互斥关系,获取锁时需要先判断是否和已经授予的锁模式冲突,如果不冲突再判断和等待队列中的请求是否冲突,都不冲突才能获取锁。第二个冲突检测的目的是给各种锁模式赋予了优先级,后来的锁只有优先级高于等待队列中的锁才能进行抢占。冲突矩阵从语义中已经可以推演出来了,具体为:
- 范围锁互斥矩阵:
| Type of active | Request | scoped lock | type | IS(*) IX S X | ---------+------------------+ IS | + + + + | IX | + + - - | S | + - + - | X | + - - - | (*) IS和所有范围锁模式兼容,所以并没有相应的实现 | Pending | Request | scoped lock | type | IS(*) IX S X | ---------+------------------+ IS | + + + + | IX | + + - - | S | + + + - | X | + + + + |
- 目标锁互斥矩阵:
Request | Granted requests for lock | type | S SH SR SW SWLP SU SRO SNW SNRW X | ----------+---------------------------------------------+ S | + + + + + + + + + - | SH | + + + + + + + + + - | SR | + + + + + + + + - - | SW | + + + + + + - - - - | SWLP | + + + + + + - - - - | SU | + + + + + - + - - - | SRO | + + + - - + + + - - | SNW | + + + - - - + - - - | SNRW | + + - - - - - - - - | X | - - - - - - - - - - | Request | Pending requests for lock | type | S SH SR SW SWLP SU SRO SNW SNRW X | ----------+---------------------------------------------+ S | + + + + + + + + + - | SH | + + + + + + + + + + | SR | + + + + + + + + - - | SW | + + + + + + + - - - | SWLP | + + + + + + - - - - | SU | + + + + + + + + + - | SRO | + + + - + + + + - - | SNW | + + + + + + + + + - | SNRW | + + + + + + + + + - | X | + + + + + + + + + + |
- enummdlduration,锁获得后持续的时间范围
enum enum_mdl_duration { MDL_STATEMENT // 语句结束释放 MDL_TRANSACTION // 事务结束释放 MDL_EXPLICIT // 需显式释放 }
- MDL_request,一次锁请求的封装,包括上述提到的锁的各种属性,通过填充 type, duration 和 key 字段再调用锁获取接口;
class MDL_request { enum enum_mdl_type type; enum enum_mdl_duration duration; MDL_ticket *ticket; // 返回的锁获取结果 MDL_key key; }
3. MDL 实现
3.1 实现锁的基本要素
在介绍 MDL 锁实现之前,有一些关于锁的背景先介绍一下。在各种不同的锁的实现中,个人认为有两个非常关键的基本要素:
- 基本的原子操作
锁的目的是为了保护一块临界区,该临界区会被并发访问到,要么是进程并发访问,要么是线程并发访问。锁最本质的核心是一个布尔变量,为 0 或者 1,为 0 时表示临界区现在没有被访问,为 1 时表示临界区正在被访问。各种锁的实现都是在这个 0/1 版本基础上的扩展。当一个并发要访问临界区时,先检查锁变量,若为 0 则将锁变量置为 1,否则暂时不能访问临界区,这个过程就是获取锁。但有个关键问题是,锁变量本身是被并发访问的,它自身就是一块临界区,所以必须保证 0/1 这块临界区是被保护读写的才能保证锁逻辑的正确。一般的实现方式是用一个机器支持的原子数据类型存储这个 0/1 变量,并通过 TAS 或 CAS 这类原语来原子地读取并改写该变量,大部分机器会提供原子读写指令来实现 TAS 或 CAS 原语,比如 xchg 和 cmpxchg,对于不提供这类指令的机器,可以使用汇编语法中的 LOCK 前缀,通过锁住 CPU 和内存之间总线的方式实现原语的逻辑。 - 等待队列,睡眠和唤醒
通过原子操作可以实现最简单的自旋锁,在代码逻辑中,除非是持锁时间非常短的场景,大多数场景下,为了更好利用 CPU,当锁获取失败时不会选择循环重试,而是将自己挂起到一个等待队列上并让出 CPU,等持锁的进程或者线程释放锁的时候会检查等待队列,并从上面摘取一个进程 / 线程,授予该锁,并唤醒它。等待队列一般通过自旋锁保护。这个过程的关键是等待队列的维护,以及进程 / 线程的睡眠和唤醒。 - PostgreSQL 通过上述的原子操作实现了 spin lock,并用 spin lock 来保护一个自己维护的等待队列,进而实现了轻量级锁 LWLock,进程的睡眠和唤醒用的是 Posix semaphore 或者 System V semaphore,Posix semaphore 是通过系统调用 futex() 实现的,System V semaphore 是通过系统调用 semop() 实现的,都是利用内核提供的接口实现调度;
- MySQL 对于上述两个基本要素的实现都是依赖于 libpthread 提供的接口,包括 pthreadmutext, pthreadcondt, pthreadrwlockt 等,通过查看 glibc 中 nptl 模块的代码可以看到,libpthread 也是通过上述的汇编调用实现基本原子操作,并将等待队列,线程睡眠和唤醒都交给内核实现,通过系统调用 futex() 的方式。主要的调用栈为:
__pthread_mutex_lock --> LLL_MUTEX_LOCK(macro) --> lll_lock(mutex->__data.__lock) --> assembly checking contension |__ __lll_lock_wait --> lll_futex_wait(macro) --> lll_futex_timed_wait(macro) --> syscall futex __pthread_cond_wait --> lll_lock(cond->__data.__lock) |__ __pthread_mutex_unlock_usercnt(mutex) |__ cond->__data.__futex++ // integer |__ cond->__data.__mutex = mutex // save the mutex for re-acquiring later |__ futex_val = cond->__data.__futex |__ lll_unlock(cond->__data.__lock) |__ lll_futex_wait(&cond->__data.__futex, futex_val)(macro) --> lll_futex_timed_wait --> syscall futex
除此之外,MySQL 在 MDL 中还自己维护了一个等待队列,具体在 MDLlock::mwaiting 中;
3.2 MDL 具体实现
代码中的重要数据结构和类定义包括:
class MDL_ticket: public MDL_wait_for_subgraph // 一次锁请求的结果,可能是获取成功,可能是获取不成功需要等待{ enum enum_mdl_type m_type; // 锁模式 enum enum_mdl_duration m_duration; // 持续时间 MDL_context *m_ctx; // 对应的线程 MDL_lock *m_lock; // 对应的锁}class MDL_wait // 等待行为的抽象,实现等待逻辑{ enum enum_wait_status m_wait_status; // 等待状态 mysql_mutex_t m_LOCK_wait_status; // 保护m_wait_status的mutex mysql_cond_t m_COND_wait_status; // 当m_wait_status发生变化时线程唤醒用的pthread_cond_t,睡眠唤醒逻辑的封装 timed_wait() // pthread_cond_t的经典用法,实现睡眠等待 { mysql_mutex_lock(); while(!m_wait_status) { mysql_cond_timedwait(); } mysql_mutex_unlock(); }}class MDL_context // 代表每个请求锁的线程{ /* * ... 线程对于MDL锁子系统的各种接口函数声明 */ MDL_wait m_wait; // 用于实现等待行为 Ticket_list m_tickets; // 线程获得的所有锁 MDL_wait_for_subgraph *m_waiting_for; // 正在等待的锁,同一时间只会有一个}class MDL_map // 全局变量,用于存储所有的锁{ LF_HASH m_locks; // Lock-free hash table,存储除COMMIT和GLOBAL外所有锁 MDL_lock *m_global_lock; // GLOBAL范围锁,全局只一个 MDL_LOCK *m_commit_lock; // COMMIT范围锁,全局只一个}class MDL_lock // 标识每个锁,和unique MDL_key一对一, 不管duration和锁模式{ MDL_key key; mysql_prlock_t m_rwlock; // 保护内部成员的rwlock, prefer reader, self-implemented using pthread_mutex_t, and pthread_cond_t; Ticket_list m_granted; // 已经授予的请求 Ticket_list m_waiting; // 等待这个锁的请求 // fast path releated volatile fast_path_state_t m_fast_path_state; // flag和counter的组合, for atomic operations, use fast_path_state_cas()/add()/reset() wrappers struct MDL_lock_strategy { bitmap_t m_granted_incompatible; // 互斥矩阵 bitmap_t m_waiting_incompatible; // 互斥矩阵 fast_path_state_t m_unobtrusive_lock_increment[MDL_TYPE_END]; // longlong, base_increment, 0 means obtrusive mode } static const MDL_lock_strategy m_scoped_lock_strategy; // static, members hard-coded, IMPORTANT static const MDL_lock_strategy m_object_lock_strategy; // static, members hard-coded, IMPORTANT}
MDL 子系统的初始化调用栈为:
#0 MDL_map::init#1 mdl_init#2 init_server_components#3 mysqld_main#4 main
锁获取的流程为:
- 首先调用 tryacquirelockimpl 尝试获取锁,如果获取成功则返回;如果不能获取,则将代表这次锁获取的 MDLticket 加入到对应 MDLlock 的等待队列中,然后调用 deadlock detector 的接口 finddeadlock() 检查是否存在死锁,如果不存在则调用 MDL_wait 的成员函数睡眠,等待别的线程唤醒;唤醒后检查是否被授予了锁(有可能是等待超时或者被其他线程检测出死锁并标记为 victim,或者收到了 kill),并做相关的清理工作。
- 在 tryacquirelockimpl 中,首先通过 findticket() 判断目标锁是否之前已经被获取过,如果是,直接返回,否则构造一个 MDL_ticket 用于保存获取结果;
- 然后从全局的 lock-free 哈希表中查找或插入对应的 MDL_lock 对象,并判断要获取的锁模式是否可以走 fast path 的代码路径,这里将锁模式划分为两类:
- obstrusive mode: 重量级锁,不能走 lock-free 代码路径
- nonobtrusive mode: 轻量级锁,可以走 lock-free 代码路径,只包括 IX, S, SH, SR 和 SW,被 DML 使用,互相之间不冲突
代码中用一个原子数据类型变量 mfastpathstate 的各个比特位段记录各种 nonobtrusive 锁的授予计数,以及当前锁上是否授予过 obtrusive mode 锁,由于 obtrusive mode 锁之间不冲突,如果之前被授予的锁模式全部为 nonobtrusive mode,则可以直接原子地将相应模式的授予计数加一即可成功获取锁,在这个代码路径中,整个锁获取过程只原子地操作 mfastpathstate, 是 lock free 的,即为 fast path 优化; - 对于不能走 fast path 的情况,会首先获取 MDLlock 中的读写锁,记录相关的状态,然后调用 cangrantlock() 分别检查 MDLlock 中的授予队列和等待队列的冲突矩阵,判断是否可以获取锁;如果不能,将当前 ticket 保存在 outticket 中,并保持 MDLrequest.ticket 为空;注意获取失败情况下从 tryacquirelockimpl() 中返回时是持有 MDLlock 中的读写锁的。
- 整个过程中不管加锁成功失败都会记录很多的状态信息,比如上述数据结构和类中的 MDLlock.mgranted, MDLlock.mwaiting, MDLcontext.mwaitingfor, MDLcontext.mtickets,MDLticket.mctx, MDLticket.m_lock, 三者之间通过这些成员变量形成关联。记录这些信息的目的是为死锁检测提供线索,后面章节会具体介绍死锁检测机制。
- 调用栈为:
acquire_lock --> try_acquire_lock_impl --> find_ticket // check if we have already hold this lock, MDL_contex::m_tickets, similar to PG locallock | |__ clone_ticket // if duration is not same | |__ fix_pins // get LF_PINS for lock-free hash access | |__ MDL_ticket::create // m_lock is null now | |__ get_unobtrusive_lock_increment | |__ retry label | |__ mdl_locks.find_or_insert(m_pins, key, &pinned) // pinned must be true for non-singleton locks, i.e, "locked" | |__ fast path branch --> old_state = lock->m_fast_path_state // save instant one | | |__ do{old_state flag checks} while(!lock->fast_path_state_cas(&old_state, old_state + unobtrusive_lock_increment)) // cas would update old_state if not equal, after get out from this loop, lock is granted | | |__ cleanups: lf_hash_search_unpin, save lock in ticket->m_lock, append the ticket to MDL_context::m_tickets, set mdl_request->ticket | |__ slow path branch --> mysql_prlock_wrlock(&lock->m_rwlock) // protecting list and counters | |__ lf_hash_search_unpin // m_rwlock can protect the lock object now | |__ do{} while(!lock->fast_path_state_cas()) // to mark HAS_SLOW_PATH and optional HAS_OBTRUSIVE | |__ ticket->m_lock = lock | |__ if lock->can_grant_lock --> lock->m_granted.add_ticket(ticket) // can_grant_lock checks granted, fast-path granted, and waiting bitmaps | | |__ mysql_prlock_unlock | | |__ append the ticket to MDL_context::m_tickets | | |__ set mdl_request->ticket = ticket | |__ else *out_ticket = ticket // not successful, HOLDING the m_rwlock |__ check mdl_request->ticket and return if granted |__ lock->m_waiting.add_ticket(ticket) // add itself to waiting list |__ m_wait.reset_status() // under protection of m_rwlock |__ mysql_prlock_unlock |__ MDL_context::m_waiting_for = ticket // inform deadlock detector, will_wait_for() |__ find_deadlock |__ m_wait.timed_wait() // two classes, one(with high prio) would notify other threads to release locks, then wait; the other would just wait |__ MDL_context::m_waiting_for = NULL // done_waiting_for() |__ check wait_status and clean up
释放锁的逻辑相较于获取简单:
- 首先判断锁获取时是否通过 fast path,如果是则原子地将 mfastpathstate 中相应比特位的授予计数减一,并调用 reschedulewaiters() 唤醒一个线程(将锁授予该线程);
- 如果是 slow path,则获取 MDLlock 中的 rwlock,将自己从授予链表 mgranted 上移除,并更新 mfastpathstate 中的相应标记位,然后调用 reschedulewaiters() 唤醒一个线程(将锁授予该线程);
- reschedulewaiters() 进入时是一定持有 MDLlock 的 rwlock 的,该函数遍历 MDLlock.mwaiting 链表,对每个元素调用 cangrantlock() 检查互斥矩阵判定是否可以授予该锁,如果可以则将该线程的 mwaitstatus 修改并调用 pthreadcondsignal() 唤醒线程(将锁授予该线程),并帮助该线程更新 MDL_lock 中的状态信息;
- 调用栈为:
release_lock --> if ticket->m_is_fast_path --> get_unobtrusive_lock_increment | |__ old_state = lock->m_m_fast_path_state | |__ do{mysql_prlock_wrlock();reschedule_waiters();mysql_prlock_unlock()} while(!lock->fast_path_state_cas()) |__ slow path --> lock->remove_ticket --> mysql_prlock_wrlock() | |__ remove_ticket | |__ update flags, fast_path_state_cas() | |__ reschedule_waiters() | |__ mysql_prlock_unlock() |__ MDL_context::m_tickets.remove() |__ MDL_ticket::destroy() reschedule_waiters(rwlock held) --> iterate m_waiting --> can_grant_lock --> yes --> m_wait.set_status(GRANTED) --> mysql_mutex_lock() | |__ m_wait_status = status_arg | |__ mysql_cond_signal() | |__ mysql_mutex_unlock() |__ cleanups, m_waiting.remove_ticket, m_granted.add_ticket
其他的一些 MDL 接口都是基于这两个基本操作实现的,比如 MDLcontext::upgradesharedlock() 是调用 MDLcontext::acquirelock() 获取新的 ticket,并移除老的 ticket;MDLcontext::releaselocksstoredbefore() 是循环调用 MDLcontext::release_lock(),实现锁获取的回滚。
4. 死锁检测
上面章节中提到了锁获取过程中,线程在进入睡眠状态前会调用 find_deadlock() 检测是否存在死锁。死锁产生的根本原因是线程在持有锁的同时去等待另一个锁,所以就会存在线程之间的依赖关系,如果依赖关系中出现了环,则产生了死锁。
上面章节还提到,MDLcontext, MDLticket 和 MDL_lock 中存了很多状态信息,这些状态信息是死锁检测的依据。死锁检测过程具体为:
- 每一个 MDLcontext 只能等待在一个锁上,记录在 MDLcontext.mwaitingfor(MDLticket 类型) 中;该 MDLticket 记录了所等待的 MDLlock,记录在 MDLticket.mlock 中;而 MDLlock 中记录了该锁授予的线程和等待在该锁上的线程,分别在 mgranted 和 mwaiting 中;
- 所以我们可以推算出当前 MDLcontext(线程)所依赖的那些 MDLcontext,将这个依赖关系形成一个有向图,并从图中一点出发遍历这个图,如果遍历过程中再次遇到了当前 MDL_context,则表示图中有环,即存在死锁;
- 在这个环里,可以找出 "权重" 最低的 MDLcontext(权重通过 MDLticket::getdeadlockweight() 获得),将它选为 victim,即让他放弃正在获取的和已经持有的锁,至少移出依赖图中的一个节点;
- 循环这个选择 victim 并移出的过程,直到依赖图中不存在环;
- 在遍历依赖图时,会首先检查距离为 1 的所有直接相邻 MDLcontext,如果没有环则用深度优先搜索的方式递归地检查相邻 MDLcontext,并用一个 Deadlockdetectionvisitor 类做 context 类记录整个遍历过程的中间变量,以及起始 MDL_context;整个遍历过程可以看作是 DFS 加一个步长为 1 的 BFS 优化;
- 值得注意的是,每个 MDL_lock 上不止授予链表中的元素需要检测是否存在依赖边,等待链表中的元素也需要,因为 MySQL 通过等待互斥矩阵实现了锁授予的优先级;这点和 PostgreSQL 不一样,PostgreSQL 只有一个互斥矩阵 (存储在静态结构 LockConflicts 中),优先级是通过 FIFO 的方式体现的;相应的,MySQL 也不存在 PostgreSQL 因为 FIFO 带来的 soft edge 和 hard edge 的概念,进而不存在死锁检测中通过调整等待队列中元素顺序去除 soft edge 来试图消除死锁的实现;
- 死锁检测不止检查 MDL 带来的等待关系,还检查其他如 TABLESHARE 带来的等待,都被抽象在 MDLwaitforsubgraph 父类中;
调用栈为:
MDL_context::find_deadlock --> while(1) --> Deadlock_detection_visitor // each round would spot one victim, remove one node from graph |__ MDL_context::visit_subgraph --> MDL_ticket::accept_visitor --> MDL_lock::visit_subgraph --> mysql_prlock_rdlock(&m_rwlock) | |__ enter_node // depth++, if overflow, terimate and say deadlock found | |__ BFS direct neibors, including granted and waiting, inspect_edge() --> check if it is starting MDL_context | |__ DFS neibors, including granted and waiting, recursive MDL_context::visit_subgraph | |__ leave_node // depth-- | |__ mysql_prlock_unlock |__ set victim m_waiting_status VICTIM
5. Lock-free Hash Table
上面章节也提到了,所有的锁对象 (除了 COMMIT 和 GLOBAL) 都存放在一个全局的 hash 表中(LFHASH),这个 hash 表是实现为 lock-free 的。在 5.7 之前的版本中,hash 表被实现为带分区的形式,但访问需要加锁。LFHASH 的实现是很经典的 lock-free 数据结构实现方式:用 Copy-on-Write 的方式,每次需要修改共享对象时都重新构造一个新的对象,然后原子地将指向对象的全局指针替换掉。这种方式有个问题是旧对象内存什么时候释放,如果在指针替换后立即释放,之前线程可能会在内存 free 掉之后还会通过之前存在 local 的指针访问该对象,那么就可能会出现 segment fault。
解决这个问题的方法有很多:
- 一个类别是用引用计数,但引用计数的问题是访问全局指针和通过全局指针访问对象中的引用计数不能是原子的,那么就会存在如下 race condition:
thd1: save object pointer to register(old one) //1 thd2: save object pointer to register(old one) //2 thd2: override global pointer to new object //3 thd2: read and check reference counter of old object //4 thd2: free old object memory //5 thd1: atomic increment reference counter, SIGSEGV //6 key here is 1 and 6 are not atomic
还是没能避免 segment fault;进一步解决这个问题的方式是用一个独立的读写锁保护引用计数,或者引入一个中间层结构体,给每个结构体加一个版本号,将引用计数和版本号一起存放在一个原子数据类型中,并通过原子读写指令在写之前判断版本和读时是否一致,通过这种方式将保护引用计数的读写锁去掉。后者的关键在于中间层结构体需要在并发周期内一直可访问,所以中间层结构体需要专门的分配器。
- 另一个类别的方法被称作 harzard pointer,也是 LF_HASH 所采用的方式。不同于引用计数,每个线程在访问全局指针 (这个共享的全局指针即为 harzard pointer) 前,先将该指针存放在一个全局变量中(每个线程有一个全局变量),访问结束后将该变量置为 NULL,当发生全局指针替换时,遍历所有的线程,看被覆盖版本的指针是否还在被其他线程访问,如果没有任何线程访问,则 free 该指针指向的内存。工程实现上,为了提升性能,会批量释放内存,判断一批指针是否可被 free。
- 在 MySQL 中,定义了 LFPINS 来记录线程所访问的指针,用 LFPINBOX 来管理所有的 pin,当替换一个全局指针时,将其添加到名为 purgatory 的链表中,当 purgatory 中指针超过一定数量时批量遍历 LFPINBOX 中每个线程的记录,决定是否释放指针指向的内存。内存管理抽象为 LFALLOCATOR 类,是一个内存池,当要分配新的对象时,从 LFALLOCATOR 中获取,如果 LFALLOCATOR 的 free stack 不为空,则从 stack 上返回一个对象的内存,否则从 LFDYNARRAY 中获取新的对象内存。当没有任何线程访问某个对象时,会将内存归还到 LFALLOCATOR 的 free stack 上。在此之上,实现了 LFHASH,每个元素的最新版本指针被保存在 LFDYNARRAY 中 (在 MDL 子系统中每个元素为指向 MDLlock 对象的指针),所有元素内存,包括旧的但还在被某些线程引用的版本,都由 LFALLOCATOR 管理。每个元素有最多 4 个 pin,pin[0] 和 pin[1] 一般用作临时用途,pin[2] 是一般意义上的 harzard pointer 中的本地版本。每次通过 lfhashsearch() 读取一个 hash 表中的元素时,将该元素指针保存在 pin[2] 中,访问结束,通过调用 lfhashsearchunpin() 将 pin[2] 置为 NULL;简单来说,LFHASH 的实现,一是通过每个元素指针的原子赋值操作保证并发下的正确性,二是通过 LF_PINBOX 来安全回收内存。值得一提的是,harzard pointer 实现中在保存全局指针到本地时有一个经典的 double check 逻辑:
void *g_hp_local_array[MAX_THREAD_ID]; // global void *ref = NULL; while(1) { ref = g_ptr; // save global pointer locally //1 g_hp_local_array[thread_id] = ref; //2 if (ref == g_ptr) // not overriden, safe now on //3 break; } // the double check is very important, if thd1 executes line 1, then is scheduled before line 2, then thd 2 // find no reference for the old g_ptr in g_hp_local_array, so frees the object, then thd1 gets back to line 2
这个逻辑在 MySQL 中也是存在的,可以在 my_lfind() 函数中看到;
6. 总结
本文介绍了 MDL 子系统的使用和实现细节,包括锁获取与释放,死锁检测和实现中用到的相关 lock free 优化。