Nathanaël

纳塔纳埃尔,切莫再想去尝试旧日的清水

0%

并发B+树根节点重复解锁问题

今年年初寒假的时候,尝试做了一下CMU-15445的lab2,并发B+树的实现。可惜能力实在有限,如果不是西部小笼包大佬的博客,我都完全不知道从哪里开始。最后也是7分抄3分自己写,对着课件PPT的B+树分裂合并流程一点点啃完了。不过还有一个一直困扰我的问题,就是大佬使用了一个thread_local变量来记录根节点锁的上锁次数,我一直不明白为什么要这样做。然后我决定自己单步调试一下如果不使用thread_local变量记录根节点锁加锁次数会怎么样。

首先我将唯一用到root_locked_cnt的代码注释掉:

为了能在更小的B+树和更少的数据下观察到BUG,将B+树叶子节点的maxsize设置为3,内部节点maxsize设置为4,插入数据个数设置为10,执行插入读取的子线程个数设为1

这里把线程个数设为1是因为想先看看单线程下是否有问题,其实我最开始也不确定,幸运的是最终在单线程下就找到了问题。

在线程函数里打上断点,这个函数的逻辑就是先插入再读取:

在多次调试并观察日志之后,我发现每次在插入三个元素后,再读取就会出现重复的解锁,此时的根节点page_id为3。

前面提到我把叶子节点的maxsize设置为了3,插入三个元素随即开始分裂。再次读取叶子节点的值时会由于螃蟹协议提前释放根节点锁。但由于GetValue函数在最后返回时仍然会调用一次FreePageInTransaction,导致重复解锁:

先来看一下FindLeafPage的调用过程。首先是加锁,随后从根节点开始使用螃蟹协议逐层向下加锁:

由上图可以看到,CrabingFetch有一个previous参数,previous在获取根节点时为-1,在CrabingFetch里会根据previous值来处理父节点的释放:

当前遍历节点为根节点时,previous为-1,不会调用FreePageInTransaction来释放根节点锁。继续根据螃蟹协议向下获取Page:

可以看到previous的值为3,即根节点的page_id,根据螃蟹协议,读是安全的,父节点的锁可以被释放,因此调用了FreePageInTransaction。而当整个GetValue的流程结束时,会再次调用FreePageInTransaction,这样就发生了死锁。

解决思路

由于FreePageInTransaction可能会调用多次,但解锁必须恰好是加锁的次数,所以可以使用一个thread_local变量来记录加锁的次数,只有当加锁次数大于0时才真正的解锁:

所以在CrabingFetch里不管调用了多少次FreePageInTransaction,root_locked_cnt能保证恰好解锁加锁的次数。

大佬的博客里其实写了为什么要使用thread_local变量,说如果没有这个变量可能会错误的释放其他线程加的保护root_page_id的锁,但经过单步调试我发现实际上是因为在螃蟹协议里会提前解锁,而整个调用链路的收尾位置还会解锁一遍,如果不引入一个加锁计数的话,就会重复解锁,随即引发死锁的问题。