前言

刷题刷得头疼,水篇blog。这个BUG是我大约一个月前,在做15445实现lock_manager的时候遇到的一个很恶劣但很愚蠢的BUG,排查 + 摸鱼大概花了我三天的时间,根本原因是我在使用std::unordered_map做并发的时候考虑不周。但由于这个BUG无法在我的本地复现,只能提交代码后再gradescope上看到执行日志,而且打印的日志还不能太多,因为gradescope的执行比较慢,打印日志如果稍微多加一点就会报TIMEOUT,所以着实让我抓狂了一段时间。最后的解决也很突然,非常有戏剧性,可以考虑拿来水点东西。感觉自己写blog很拖刷leetcode和背八股的节奏,所以找到实习前可能写不了太多了。

功能描述

15445 Project4要求我们为bustub添加并发访问控制,实现tuple粒度的strict 2PL。简单来说,一个RID表示着一个tuple在磁盘上的位置,因此可以唯一标识tuple;一个事务就是一个线程。事务会并发的对这些tuple进行读写访问,因此必须要引入lock来对访问进行同步,而这个Project要求我们将lock的粒度设定为tuple。为了实现这一点,bustub设定了一个单例LockManager(但其实现方法并不是单例的),要求任何事务在访问某个RID之前,都要向这个事务申请锁。如果事务进行的是读访问操作,要调用LockManagerlock_shared()方法申请S-LOCK(共享锁,或者说读锁),否则操作就是写操作,要调用lock_exclusive()方法申请X-LOCK(独占锁,或者说是写锁);如果事务已经获取了S-LOCK,希望在不释放S-LOCK的情况下将锁升级为X-LOCK,则要调用lock_upgrade方法。此外,由于strict 2PL只能保证serializable Schedule,但无法保证不存在死锁,因此LockManager还需要实现死锁检测功能(但这不是本篇blog的重点)。LockManager的声明如下:

class LockManager {
enum class LockMode { SHARED, EXCLUSIVE, UPGRADE };
enum class RIDLockState { UNLOCKED, S_LOCKED, X_LOCKED }; class LockRequest {
public:
LockRequest(txn_id_t txn_id, LockMode lock_mode)
: txn_id_(txn_id), lock_mode_(lock_mode), granted_(false), aborted_(false) {} txn_id_t txn_id_;
LockMode lock_mode_;
bool granted_;
bool aborted_;
}; class LockRequestQueue {
public:
LockRequestQueue() = default;
LockRequestQueue(const LockRequestQueue &rhs) = delete;
LockRequestQueue operator=(const LockRequestQueue &rhs) = delete;
// DISALLOW_COPY(LockRequestQueue);
RIDLockState state_;
std::mutex queue_latch_;
std::list<LockRequest> request_queue_;
std::condition_variable cv_; // for notifying blocked transactions on this rid
bool upgrading_ = false;
}; public:
/**
* Creates a new lock manager configured for the deadlock detection policy.
*/
LockManager() {
enable_cycle_detection_ = true;
cycle_detection_thread_ = new std::thread(&LockManager::RunCycleDetection, this);
LOG_INFO("Cycle detection thread launched");
} ~LockManager() {
enable_cycle_detection_ = false;
cycle_detection_thread_->join();
delete cycle_detection_thread_;
LOG_INFO("Cycle detection thread stopped");
} /*
* [LOCK_NOTE]: For all locking functions, we:
* 1. return false if the transaction is aborted; and
* 2. block on wait, return true when the lock request is granted; and
* 3. it is undefined behavior to try locking an already locked RID in the same transaction, i.e. the transaction
* is responsible for keeping track of its current locks.
*/ /**
* Acquire a lock on RID in shared mode. See [LOCK_NOTE] in header file.
* @param txn the transaction requesting the shared lock
* @param rid the RID to be locked in shared mode
* @return true if the lock is granted, false otherwise
*/
bool LockShared(Transaction *txn, const RID &rid); /**
* Acquire a lock on RID in exclusive mode. See [LOCK_NOTE] in header file.
* @param txn the transaction requesting the exclusive lock
* @param rid the RID to be locked in exclusive mode
* @return true if the lock is granted, false otherwise
*/
bool LockExclusive(Transaction *txn, const RID &rid); /**
* Upgrade a lock from a shared lock to an exclusive lock.
* @param txn the transaction requesting the lock upgrade
* @param rid the RID that should already be locked in shared mode by the requesting transaction
* @return true if the upgrade is successful, false otherwise
*/
bool LockUpgrade(Transaction *txn, const RID &rid); /**
* Release the lock held by the transaction.
* @param txn the transaction releasing the lock, it should actually hold the lock
* @param rid the RID that is locked by the transaction
* @return true if the unlock is successful, false otherwise
*/
bool Unlock(Transaction *txn, const RID &rid); /**
* Checks if the graph has a cycle, returning the newest transaction ID in the cycle if so.
* @param[out] txn_id if the graph has a cycle, will contain the newest transaction ID
* @return false if the graph has no cycle, otherwise stores the newest transaction ID in the cycle to txn_id
*/
bool HasCycle(txn_id_t *txn_id); /** @return the set of all edges in the graph, used for testing only! */
std::vector<std::pair<txn_id_t, txn_id_t>> GetEdgeList(); /** Runs cycle detection in the background. */
void RunCycleDetection(); private:
std::unordered_map<txn_id_t, std::vector<txn_id_t>> BuildTxnRequestGraph();
std::mutex latch_;
std::atomic<bool> enable_cycle_detection_;
std::thread *cycle_detection_thread_; /** Lock table for lock requests. */
std::unordered_map<RID, LockRequestQueue> lock_table_;
/** Waits-for graph representation. */
std::unordered_map<txn_id_t, std::vector<txn_id_t>> waits_for_;
};

目前你只需要知道这个,为了同步多个线程(事务)的并发访问,线程会调用单例的LockManager中的lock_sharedlock_exclusivelock_upgrade方法来获取锁,而锁的粒度是RID级别的就可以了。Project4 Part1的要求就是让我们实现上述三个方法。

设计与实现

具体的实现思路不难。我们先看一下LockManager的核心数据结构lock_table_,它实现了从RIDLockRequestQueue的映射,所有的事务在为某个RID申请lock时,必须将自己的请求构建一个LockRequest结构,插入到这个RID对应的LockRequestQueue里面,等待自己的请求被执行。图例如下:

本图中共有5个RID,理所应当有5个LockRequestQueue,这里为了简便起见我只画了其中的两个。上图中有四个事务(1,2,7,4)申请RID 1的锁,三个事务(3,2,1)申请RID 4的锁,请求到来的顺序、授予锁的顺序都是FIFO。由于RID 1的前三个请求都是S-LOCK,因此这把S-LOCK可以被授予给三个事务,而第四个事务(txn 4)的X-LOCK请求则被阻塞;

能画出这个图之后,相应的代码也不难实现了,这里我以伪代码的形式给出lock_shared的逻辑,lock_exclusivelock_upgrade的逻辑都很类似,故不再给出,我所想讨论的BUG也在下面的代码里面:

bool LockManager::LockShared(Transaction *txn, const RID &rid) {
// Assertions for transaction state, isolation level and so on.
...... // BUG IS HERE!!!!!!
latch_.lock();
if (lock_table_.find(rid) == lock_table_.end()) {
// INSERTION MAY HAPPENDS HERE!
lock_table_[rid].state_ = RIDLockState::UNLOCKED;
} // block other txns' visit on this rid,then we could saftly release latch_
std::unique_lock lk(lock_table_[rid].queue_latch_);
latch_.unlock(); // BUG IS HERE!!!!
LockRequest req = MakeRequest(txn->id, LOCK_SHARED);
lock_table_[rid].queue_.emplace_back(req);
auto req_iter = --lock_table[rid].queue_.end();
// waiting on condition_variable
while (wait(lock_table_[rid].queue_.cv_, lk)) .... // Success, return
lk.unlock();
return true; // unlock the queue
}

lock_table_是一个std::unordered_map<RID, LockRequestQueue>,最开始的时候是空的,当出现一个新的RID时,会向这个unordered_map中添加一对新的 (RID, LockRequestQueue)的Pair。这是一个对unordered_map的写操作,而unordered_map并不是线程安全的,如果多个线程同时调用lock_shared去获取同一个RID的锁,而这个RID之前并不在lock_table_里,那么lock_table可能就会并发的插入同一个RID,这是非常危险的操作!!!我们必须避免这一情景,通常的方法是在可能引入插入的操作之前,获取latch_来保证单线程对lock_table_的访问操作

// BUG IS HERE!!!!!!
latch_.lock();
if (lock_table_.find(rid) == lock_table_.end()) {
// INSERTION MAY HAPPENDS HERE!
lock_table_[rid].state_ = RIDLockState::UNLOCKED;
} // block other txns' visit on this rid,then we could saftly release latch_
std::unique_lock lk(lock_table_[rid].queue_latch_);
latch_.unlock();

正如前文所述,由于operator []的使用可能会引入插入操作,因此这里我用latch_.lock()来隔绝其他线程对lock_table_的访问操作,这样可以避免多个线程对同一个RID同时进行插入操作的情景,当lock_table_[rid].state_ = RIDLockState::UNLOCKED;这行代码执行完毕后,这个RID的KV Pair已经存放在了这个unordered_map里面,然后我们拿到更细粒度的锁lock_table_[rid].queue_latch_,就可以释放掉latch_了,因为虽然后续的代码会访问lock_table_里的LockRequestQueue,但由于对应的RID均已经存在,因此这些操作应该看做是对lock_table_读操作;虽然这个过程中会有新的RID插入,但不应该会对这些已经存在的RID产生影响(这里是我的第一个疏忽),因此无需关注。而那些针对LockRequestQueue的读写操作,由更加细粒度的锁LockRequestQueue.queue_latch_来提供同步,而其在latch_释放之前就已经被获取了,因此也不存在并发问题。

可以看到,这段代码是非常stupid的,因为当初为了写的快一点,我大量的使用了lock_table_[rid]来获取LockRequestQueue的引用,而operator []的操作并不是常量级的,这会引入非常多的开销(本意是测试通过之后再修改)。更重要的是这里是我的第二个疏忽,正是前后这两个疏忽造成了BUG。

BUG产生情景

在完成编码后我在本地跑了上千次测试,都可以完美PASS测试样例,但当我把代码提交到gradescope时却失败了,提示某些请求一直没有得到调度导致超时了。于是我在代码里加了点小trick,把调度失败时lock_table_的状态打印出来,于是产生了下面的日志:

先解释一下日志的输出。一个RID可以由一个二元组(page_num, slot_num)唯一表示,一个请求可以由三元组(txn_id, is_granted, lock_type)表示。我希望通过前文所述的二元组和三元组展示一下LockManager中锁调度的状态。上图中(txn 5, granted, S-LOCKED)表示事务5申请了RID(9, 9)的S-LOCK,且lock_manager已经将S-LOCK授权给了它。

注意到这个日志里有两个 RID(1, 1),且它们的hashval是一模一样的,这说明std::unordered_map中出现了两个同样的键!。我后面又多次提交代码查看lock_table_的状态,但都获得了类似的结果,即std::unordered_map中总是会有两个相同的键,或者说,拥有同样的hasval的键被分到了两个bucket中,而我们通过下标访问lock_table_[rid]时,至多只能访问到其中的一个bucket,因此也只有这个bucket中的LockRequest可能被调度,而另一个bucket由于无法被访问到,因此其中的请求就可能永远都不会被调度了,这就导致了测试代码的超时。

我们知道,std::unordered_map通过Key获取对应的Value的规则是首先计算这个Key对应的hashval % bucket_num获取得到K-V对所在的bucket,虽然不同的Key会有不同的hashval,但他们可能会有相同的hashval % bucket_num,因此可能会被放入到同一个bucket中;为了从bucket中找到唯一的K-V对,又需要调用operator ==来找到唯一的目标Key;因此发现这个BUG后,我第一个想法就是RID的实现可能存在问题,于是去仔细查看了RIDoperator ()方法和operator ==的实现,然后打消了这个念头。其实前文中我也提到了,我在日志中打印了RID对应的hashval,两个键的hashval都是一样的,却在不同的bucket中,这种情景基本不可能是operator ()方法和operator ==实现错误所能触发的、



【世界名画之:我代码错了,肯定是XXX的问题】

然后我又考虑是不是因为我前面试图降低锁粒度的方法存在问题,但用纸笔模拟了多种情景、又拿状态机之类的理论推导了一下,最终也宣告了我的怀疑破产。

这样一来我的思路就完全断掉了,于是我希望能获取到更多的这个BUG产生时的程序上下文信息。由于测试代码只涉及到了10个RID,而这种情况出现时,lock_table_的size会膨胀到11,因此这个时机可以作为一个排查BUG、获取当前lock_table_状态的切入点,因此我又往自己的代码里添加了一系列的逻辑,边打印日志边准备捕获这个瞬间,但测试代码又被TIMEOUT了,因为gradescope的执行速度比较慢,打印太多日志会导致超时,拿不到我想要的东西。

这样一时间我的调试就陷入了僵局,我猜不到这个BUG产生的可能原因,无法在本地复现这个BUG,甚至无法通过日志的方式获取到更多的信息。

BUG的解决

这个BUG的解决也很富有戏剧性,大概有两天我的思路没有进展,直到第二天晚上偶然打开cppreference时注意到了std::unordered_map的一个之前没注意到的细节:rehash。最初始时,std::unordered_map最初一般只有7个bucket,但随着插入量的增长,同一个bucket中的元素越来越多,越来越多的时间会被花费在bucket内部的线性查找上,因此std::unordered_map会在适当时机进行扩容操作,增添bucket的数量,并将之前的k-v pair重新分配到其对应的桶中。

https://en.cppreference.com/w/cpp/container/unordered_map

我自己写了一点测试代码了解rehash的行为后,猜测可能是并发访问下rehash造成了std::unordered_map的undefined行为,但这种想法一旦成立,也就意味着我前文中降低锁粒度所思考的逻辑存在着严重的问题。验证方法也很简单,在lock_table_创建时,把桶的数量开到足够大,这样就不会出现rehash的情景了:

LockManager() {
enable_cycle_detection_ = true;
cycle_detection_thread_ = new std::thread(&LockManager::RunCycleDetection, this); // reserve enough buckets to avoid rehash
lock_table_.reserve(100); LOG_INFO("Cycle detection thread launched");
}

修改后再次提交到gradescope,顺利通过。这样基本石锤了时rehash导致lock_table中出现了两个相同的key;

BUG的分析

当然,我不能使用这种投机取巧的方法去过测试,我又重看了前面的lock_shared方法,很快发现了问题,这里我换一批注释:

bool LockManager::LockShared(Transaction *txn, const RID &rid) {
...... latch_.lock();
if (lock_table_.find(rid) == lock_table_.end()) {
// INSERTION AND REHASH MAY HAPPENDS HERE!
lock_table_[rid].state_ = RIDLockState::UNLOCKED;
} std::unique_lock lk(lock_table_[rid].queue_latch_);
latch_.unlock(); LockRequest req = MakeRequest(txn->id, LOCK_SHARED);
// IF REHASH AND THIS LINE IS RUNNING AT THE SAME TIME, WHAT WILL HAPPEN ?
lock_table_[rid].queue_.emplace_back(req);
auto req_iter = --lock_table[rid].queue_.end();
// AND WHAT MAY HAPPEND HERE ???
while (wait (lock_table_[rid].queue_.cv_, lk)) .... lk.unlock();
return true; // unlock the queue
}

错误已经很明显了,我在插入LockRequest前,使用了operator []来获取对应的LockRequestQueue的引用,本意是认为新的RID的插入并不会影响这一过程,但如果在operator []执行的过程中发生了rehash,那么 bucket_num的值就会发生改变(应该是从7变为11),而这个过程中,同一个hashval就可能被送到不同的bucket中,因此就产生了undefined behaviour。

那么这个BUG该如何解决呢?再次阅读cpp reference我们可以得知,rehash不会导致引用或者指针失效,因此我们可以在持有latch_的时候,直接获取到对应的LockRequestQueue的引用,以后只通过这个引用来访问LockRequestQueue,伪代码如下:

bool LockManager::LockShared(Transaction *txn, const RID &rid) {
...... latch_.lock();
if (lock_table_.find(rid) == lock_table_.end()) {
// INSERTION AND REHASH MAY HAPPENDS HERE!
lock_table_[rid].state_ = RIDLockState::UNLOCKED;
}
// acquire the reference of LockRequestQueue;
LockRequestQueue &queue = lock_table_[rid];
latch_.unlock(); // rehash does not invalid reference queue, so it's safe here.
std::unique_lock lk(queue.queue_latch_);
LockRequest req = MakeRequest(txn->id, LOCK_SHARED);
queue.queue_.emplace_back(req);
auto req_iter = --queue.queue_.end();
while (wait (queue.cv_, lk)) ....
lk.unlock();
return true; // unlock the queue
}

还剩下一个问题:在rehash的过程中,引用是否会失效?我个人查了查相关资料,没有找到对应的讨论情况。虽然我个人认为引用不会失效,但很明显我们不应该依靠这种侥幸心理来写代码,后续重构的时候我会像一个更好地方法来解决这个问题。

总结

std::unordered_map<Key, Value>是一个无法保证线程安全的数据结构,我们必须自己来处理它的并发访问。并发访问可以支持单个进程的写操作,或者多个进程的并发读操作。一般情况下我们可以把对Value的写操作,看做是一个对std::unordered_map<Key, Value>的读操作,因为这个操作并不改变Key与Value的映射关系。operator[]是一个十分需要小心使用的方法,因为它既可能对应一个读操作,也可能对应一个写操作,如果这个方法触发了插入行为,那么其中的元数据就会被修改,如果装载引子接近了上限值时还可能触发rehash,因此operator []不应该并发的调用。

这算是我个人遇到的最难受的BUG之一了,既没法在本地复现,又没法得到更多的信息。个人非常大的失误是不了解std::unordered_map的行为,并且在BUG定位的时候没能跳出逻辑圈。实际上,试图降低锁粒度对我这种经验很少的鶸来说,是非常危险的行为,因此我个人十分不建议在缺少全局分析、缺乏经验、没有足量的测试的情况下写并发代码,能单机解决它不香嘛(

记一个关于std::unordered_map并发访问的BUG的更多相关文章

  1. 记一个非常诡异的关于 shared_ptr 的 bug

    问题描述 今天写项目的时候遇见一个特别诡异的 bug,体现在在执行某条语句时,程序会莫名崩溃,并且给出的错误信息也非常难懂,只有一个malloc(): invalid size (unsorted)错 ...

  2. 并发访问sqlite数据库出现databse is locked的错误的一个解决办法

    作者:朱金灿 来源:http://blog.csdn.net/clever101 在并发访问sqlite数据库会出现这样一个错误:databseis locked,这是sqlite数据库对并发支持不太 ...

  3. 使用multi curl进行http并发访问

    curl是一款利用URL语法进行文件传输的工具,它支持多种协议,包括FTP, FTPS, HTTP, HTTPS, GOPHER, TELNET等,我们既可以在命令行上使用它,也可以利用 libcur ...

  4. 记一个社交APP的开发过程——基础架构选型(转自一位大哥)

    记一个社交APP的开发过程——基础架构选型 目录[-] 基本产品形态 技术选型 最近两周在忙于开发一个社交App,因为之前做过一点儿社交方面的东西,就被拉去做API后端了,一个人头一次完整的去搭这么一 ...

  5. Cocos2d-x优化中多线程并发访问

    多线程并发访问在Cocos2d-x引擎中用的不是很多,这主要是因为中整个结构设计没有采用多线程.源自于Objective-C的Ref对象,需要使用AutoreleasePool进行内存管理,Autor ...

  6. C++ std::unordered_map使用std::string和char *作key对比

    最近在给自己的服务器框架加上统计信息,其中一项就是统计创建的对象数,以及当前还存在的对象数,那么自然以对象名字作key.但写着写着,忽然纠结是用std::string还是const char *作ke ...

  7. 基于c++11新标准开发一个支持多线程高并发的网络库

    背景 新的c++11标准出后,c++语法得到了非常多的扩展,比起以往不论什么时候都要灵活和高效,提高了程序编码的效率,为软件开发者节省了不少的时间. 之前我也写过基于ACE的网络server框架,但A ...

  8. hashmap C++实现分析及std::unordered_map拓展

    今天想到哈希函数,好像解决冲突的只了解了一种链地址法而且也很模糊,就查了些资料复习一下 1.哈希Hash 就是把任意长度的输入,通过哈希算法,变换成固定长度的输出(通常是整型),该输出就是哈希值. 这 ...

  9. Java多线程基础——对象及变量并发访问

    在开发多线程程序时,如果每个多线程处理的事情都不一样,每个线程都互不相关,这样开发的过程就非常轻松.但是很多时候,多线程程序是需要同时访问同一个对象,或者变量的.这样,一个对象同时被多个线程访问,会出 ...

随机推荐

  1. Mybatis(二)实例练习

    文章目录 实例练习Mybatis,实现一个简单的登录功能. 增.删.改:操作返回Int类型. 查询操作返回实体对象. 首先需要导入相关的包. #导包: #建表 在数据库中新建一个用户mybatis,然 ...

  2. HBase原理 – 分布式系统中snapshot是怎么玩的?(转载)

    snapshot(快照)基础原理 snapshot是很多存储系统和数据库系统都支持的功能.一个snapshot是一个全部文件系统.或者某个目录在某一时刻的镜像.实现数据文件镜像最简单粗暴的方式是加锁拷 ...

  3. jmespath(1)基础语法

    前言 JMESPath是JSON的查询语言.您可以从JSON文档中提取和转换元素 官方文档:https://jmespath.org/tutorial.html 基本表达式 JMESPath用的最多的 ...

  4. AcWing 216 Rainbow 的信号

    题意 给定一个长度为n的序列,然后从\(1\sim N\) 这 N 个数中选取两个数\(l,r\) , 如果\(l>r\),则交换\(l,r\).把第\(l\) 个数到第\(r\)个数取出来构成 ...

  5. Codeforces Round #625 (Div. 2, based on Technocup 2020 Final Round) A. Contest for Robots(数学)

    题意: n 道题,2 个答题者,已知二者的做题情况,你是受贿裁判,可以给每题指定分值(≥1),求甲乙分数(甲>乙)相差最小时最大分值的最小值. 思路: 统计只有甲或乙做出的题目数. 加一取下整判 ...

  6. poj2443Set Operation (bitset)

    Description You are given N sets, the i-th set (represent by S(i)) have C(i) element (Here "set ...

  7. hdu4325 Flowers

    Problem Description As is known to all, the blooming time and duration varies between different kind ...

  8. 洛谷P1628合并序列【模板】(Trie+dfs)

    很久之前写的题了,当时不知道怎么dfs所以卡了一段时间,^_^ 题解:由于题目给了一大堆字符串,所以首先考虑应该可以建树,之后找到T所在的位置,对T所在的位置dfs就行了 代码: 1 #include ...

  9. Python 装包与拆包

    装包就是把未命名的参数放到元组中,把命名参数放到字典中 a = 1, 2 print(a) (1, 2) 拆包将一个结构中的数据拆分为多个单独变量中 *args **kwargs def run1(* ...

  10. Spring web之restTemplate超时问题处理

    问题 项目中有个远程服务因为某些原因会访问不通,于是就在调用的那一步挂起无法结束了. 查看代码 代码大概如下 CloseableHttpClient closeableHttpClient = Htt ...