冰羚杂谈(三)找到那只空着的箱子,用完放回去

ABA的故事背景

作为集团的关键角色之一,太上老君是具备根据云盘地址获取藏经阁中对应地址的捅破天能力的。江山都是老君打造的,难道还不清楚江山在哪吗?具备如此法力确实非常合乎逻辑。悟空在云盘上为各种尺寸的箱子选好位置后,老君要把箱子君们放到藏经阁实际对应的位置,当然这些体力活不用他老人家亲自下场。作为集团的核心狠角色,手下有一帮得力的打手也很合乎预期。老君召集爱将们听命,将各个箱子的放置位置如实相告。得令,诺!一片山呼声整齐划一、振聋发聩,连集团一号位的办公室也清晰可闻。第二天,箱子君们就出现在了藏经阁各自家的位置,久经磨砺团队的执行力就是如此出色!

箱子君们到位了,矿工和搬运工们美好的岗前培训也要结束了。摸鱼的日子虽然妙不可言,职场上的历练提升更加掷地有声令人神往。矿工们挖完一桶金后,需要找到一个空闲的箱子将金子放进去。搬运工将金子运走后,也需要将箱子还回去。设计顶层打法的活被悟空接了下来。他通过多方打听总结出,4.0的绩效可不是仅仅在云盘上选个址就可以拿到的,需要识别业务关键痛点,摸索提炼颠覆性疏通方案,引爆价值转化核子能力,助力相关方体感达到量级提升。

如果要找到那只空着的箱子,并且,箱子用完后能够顺利返还,需要解决以下两个问题:

  1. 箱子数量众多,且都是连续放置的,难不成悟空要腾云驾雾沿途搜寻直到发现那只空着的箱子?矿工们数量众多,每个人挖完金子都要悟空交出一个空闲的箱子。悟空只能疲于奔命,在藏经阁中来回奔波。不是在寻找箱子,就是在寻找箱子的路上。腾云驾雾容易,为了4.0的绩效身体累也能忍忍,但如此愚笨之法肯定会被评审们判定技术壁垒有限,引领性不足以支撑起4.0的绩效。

  2. 箱子是矿工们和搬运工们共用的,如果不控制访问秩序,将会产生非预期的结果。比如,矿工甲找到了一个空闲的箱子,矿工乙也找到了同样那只箱子。但在甲开始装金子前,乙眼疾手快抢先将自己的金子装了进去。此刻,甲仍然认为这个箱子是空闲可用的,结果金子装不进去。再比如,搬运工小甲和小乙同时将箱子返还,要将哪只箱子首先交给矿工使用呢?或者有没有可能因为统计失误,将小乙还回的箱子遗漏导致这只箱子再也发挥不了价值?

原来要找到一个空闲的箱子并非易事,除了要高效快速,还要处理多人同时寻找箱子、返还箱子的场景,防止可用空闲箱子的查找和返还产生错乱。经过一个白天的思考,悟空终于找到了答案,答案就是他没有找到答案。当晚,他翻着筋斗云火速赶往灵台方寸山,和另一位大神菩提老祖一夜畅谈。第二天早上,悟空驾着祥云归来,面带微笑,cosplay着和老君驾驭祥云时一样的神情。这一次,他确实找到了答案。

菩提老祖是这样,哦,不对,悟空是这样做的:

统计顺序摆放着的特定尺寸箱子总的数目(不同尺寸的箱子采用一样的设计),假设数目是N,然后制作N + 1张令牌。将箱子从0到N-1进行编号,令牌也从0到N-1进行编号,与箱子的索引一一对应。不过,多了一个耗尽指示令牌,编号为N。耗尽指示令牌标识已无空闲令牌可用,也意味着无空闲箱子可用。可用令牌君们手拉手形成一个队列,为了记录令牌君们拉的是谁的手,令牌的存放内容为下一个可用令牌的编号。最后一个耗尽指示令牌存放的是无效索引N+1。找到了空闲的令牌索引,就找了对应索引的空闲可用的箱子。为了提高查找效率,悟空又设置了一个头部指示令牌,用来指示空闲队列的头部是哪个索引,方便快速找到带头大哥。令牌们的初始状态设置如下图所示。

假设矿工甲需要一个空闲箱子,悟空只需拿到头部指示令牌,令牌存放的索引是0。于是悟空告诉矿工甲,你可以使用第0号箱子。因为第0号令牌放的是索引1,表示下一个可用的箱子是1,悟空将头部指示令牌的存放索引变为1,如下图所示:

之后,矿工乙也需要同样尺寸大小的箱子,于是他也向悟空求助。悟空又一次拿出了头部指示令牌。这次,存放的索引是1。矿工乙于是可以用1号箱子来装金子。当然,悟空作为规则的制定者,他不会忘了操作流程规范,头部指示令牌也随之更新了,如下图所示:

最后,当所有可用的箱子都耗尽后,头部指示令牌存放的索引变为N,也即指向了耗尽指示令牌。此时,如果矿工张三也需要同样尺寸的箱子,悟空拿出头部指示令牌一看,索引是N,只能无奈告诉张三,三哥,这个尺寸的箱子用完了,寻找比这个尺寸更大的箱子的流程比较繁复,要不再等等看,不会等太久的。如下图所示:

果然,矿工张三确实没等太久。先前拿到1号箱子的搬运工小乙运走金子后,告诉悟空第1号箱子现在可用了。悟空迅速更新了1号箱子存放的索引和头部指示令牌存放的索引,如下图所示:

张三休息了一会后,再次向悟空请求可用的箱子。悟空又检查了头部指示令牌,发现存放的索引是1。终于能够让顾客满意了。张三得到了1号空闲箱子,悟空也根据1号箱子存放的索引,更新了头部指示令牌存放的索引,如下图所示:

可以看出,此时又没有空闲箱子可用了。只要头部指示令牌存放的索引指向了N号令牌,即表示当前无空闲箱子可用,这正是额外设置一个耗尽指示令牌的作用!

为了培养矿工和搬运工们的狼性,悟空允许矿工们同时向自己请求空闲的箱子,也允许搬运工们同时返还用完的箱子。但悟空一人分身乏术,即使矿工们和搬运工们可以同时发送请求,自己也只能一个接一个的按照到来的顺序处理请求。为了同时处理这些请求,悟空又招聘了几个小弟来服务矿工和搬运工们找箱子和清空箱子的操作,但小弟们都只能依靠同一个头部指示令牌输出答案以防止出现数据不一致。问题又来了,小弟甲拿到头部指示令牌的索引后,准备将该索引给某个矿工的时候,在这个间隙头部指示令牌抢先一步被小弟乙更新了,如下图所示:

在小弟甲正准备将拿到的空闲箱子索引序号0告诉矿工和更新头部指示令牌前,头部指示令牌被小弟乙更新了。但小弟甲不知道头部指示令牌已更新,仍然将索引0告诉了矿工,指示0号箱子可用,但此时0号箱子已经被占用了。即使头部指示令牌存储索引被正确更新为1,但结果不符合预期。

吸取了上述教训,悟空决定改变一下工作流程,即小弟在将空闲索引告诉矿工之前,再二次确认下头部指示令牌是否已经被别的小弟更新。如果更新了,就重复以上步骤,直到确认头部指示令牌没有被别的小弟更新,如下图所示:

搬运工将箱子设置为空闲状态的操作和矿工遇到的问题一样,本质都是竞争头部指示令牌的使用,确保不出现非一致的状态,操作类似。

采取了上述步骤后,仍然可能将已经被占用的箱子识别为空闲箱子的情况,如下图所示:

如上图所示,当下一次再寻找空闲箱子时,会将头部指示令牌中存放的索引1当作空闲箱子的可用编号,但此时箱子1是不可用的。

上面出现的问题为头部指示令牌的状态从A变为B,又从B变回了A。小弟甲没有意识到,认为头部指示令牌的状态没有发生过改变,造成了错误的判断。针对这种问题,聪明的悟空又想出了一个绝妙的办法,除了在头部指示令牌中存储当前空闲箱子队列的头部索引,还存储了一个计数器。头部指示令牌每更新一次,计数器加1。如下图所示:

这次找到的2号箱子确实没有在使用。

看看源码

找到可用空闲箱子及将箱子重新设置为空闲状态的类为MpmcLoFFLi。MpmcLoFFLi其实是一个无锁队列,在介绍其具体实现之前,先看一个函数簇:

bool compare_exchange_weak( T& expected, T desired,
                            std::memory_order success,
                            std::memory_order failure ) noexcept;           (1)(since C++11)
bool compare_exchange_weak( T& expected, T desired,
                            std::memory_order success,
                            std::memory_order failure ) volatile noexcept;  (2)(since C++11)
bool compare_exchange_weak( T& expected, T desired,
                            std::memory_order order =
                            std::memory_order_seq_cst ) noexcept;           (3)(since C++11)
bool compare_exchange_weak( T& expected, T desired,
                            std::memory_order order =
                            std::memory_order_seq_cst ) volatile noexcept;  (4)(since C++11)

上述函数簇有三个关键值,即expected、desired及返回值,官方对这些函数簇的作用描述为:

当前值与期望值(expect)相等时,修改当前值为设定值(desired),返回true

当前值与期望值(expect)不等时,将期望值(expect)修改为当前值,返回false

看到上述描述时,你可能已经云里雾里了,不仅念起来拗口,而且真的需要这样的操作吗?让我们先用一个游戏来领会一下它们的奥义。

游戏机室为了让彩票迷们提前对自己预测好运的能力有一个摸底,增加了一个游戏项目取名叫做猜数宝(也是这款游戏的机器名称)。这台机器设置了一个数字,你的任务就是改变它的数字,将它的数字变成你想要的数字,就是如此简单。但是,为了让你增加一点征服欲,让你觉得游戏币花的值,让你觉得你在博弈领域天赋禀异,游戏增加了一个规则,在将机器里的数字变成你想要的数字之前,必须首先猜对机器里面原先那个数字。只有猜对了那个数字,你才能把原先那个数字变成你想要的数字。游戏机制造商根据产品规格书加班加点把猜数宝赶制了出来,它大概长这副模样:

假设猜数宝本来放置的数字是10,矿工甲想把它变成100。第一轮游戏开始,矿工甲认为里面的数字是8的可能性比较大。于是,他在猜数宝中输入了以下数字:

很遗憾,出师不利,首轮铩羽而归,但游戏机的设置还是很人性化的,不仅告诉你输了,还告诉你输在哪,原来之前的数字是10(通过expected对你反向输出)。矿工甲不甘心,他又打算赌上第二把(猜数宝里面的数字被重置成12了):

这一轮,他成功了!

compare_exchange_weak常用于并发无锁编程,但需要提防著名的ABA问题,即上述给头部指示令牌增加计数器要解决的问题。

还是用实际生活场景来具象化。矿工甲因为工作太卷产生了过劳肥,到医院让医生开了一副减肥药,医生告诉他一个月后过来复查。在前半个月药效明显,矿工甲的体重以肉眼可见的速度下降。眼看要重新变回帅小伙了。优秀的人常常自驱,月中矿工甲打算冲冲产量,争取在月底的挖矿排行榜上变成榜一大哥。为了完成自我超越,矿工甲常常光着膀子带头冲锋,每天挖矿挖的兴起,索性将衣服脱光,忘我的工作,也忘了减肥药放在了哪。半个月后榜一大哥如愿以偿,但体重巧了,反弹到和之前一模一样。于是他又去医院找到医生,医生又测了矿工甲的体重,竟然和上次一模一样。如果矿工甲没有将他中途停药的事情告诉医生,医生会误认为之前开的药没有效果或效果不大,准备再给矿工甲开一副新开发的、正在小范围患者身上小规模实验的猛药。这就是ABA问题,具体的定义为:In multithreaded computing, the ABA problem occurs during synchronization, when a location is read twice, has the same value for both reads, and the read value being the same twice is used to conclude that nothing has happened in the interim; however, another thread can execute between the two reads and change the value, do other work, then change the value back, thus fooling the first thread into thinking nothing has changed even though the second thread did work that violates that assumption.

现在来看下MpmcLoFFLi的实现代码:

inline constexpr uint64_t MpmcLoFFLi::requiredIndexMemorySize(const uint64_t capacity) noexcept
{
    return (capacity + 1U) * sizeof(MpmcLoFFLi::Index_t);
}

using Index_t = uint32_t;

根据箱子的个数计算所需令牌的数目,计算所需的存储内存大小。因为额外需要一个耗尽指示令牌,所以是(capacity + 1U)个。

class MpmcLoFFLi
{
  private:
    uint32_t m_size{0U};                            (1)
    Index_t m_invalidIndex{0U};                     (2)
    Atomic<Node> m_head{
  
  {0U, 1U}};                  (3)
    iox::RelativePointer<Index_t> m_nextFreeIndex;  (4)
};

(1)令牌的总数目(不包括头部指示令牌,耗尽指示令牌)

(2)无效索引,供耗尽指示令牌使用

(3)头部指示令牌

(4)令牌的起始内存地址(不是头部指示令牌的起始内存地址)。因为需要在矿工、搬运工及悟空之间传递,所以是RelativePointer

void MpmcLoFFLi::init(not_null<Index_t*> freeIndicesMemory, const uint32_t capacity) noexcept
{
    IOX_ENFORCE(capacity > 0, "A capacity of 0 is not supported!");
    constexpr uint32_t INTERNALLY_RESERVED_INDICES{1U};
    IOX_ENFORCE(capacity < (std::numeric_limits<Index_t>::max() - INTERNALLY_RESERVED_INDICES),
                "Requested capacity exceeds limits!");

    m_nextFreeIndex = freeIndicesMemory;
    m_size = capacity;
    m_invalidIndex = m_size + 1;

    if (m_nextFreeIndex != nullptr)
    {
        for (uint32_t i = 0; i < m_size + 1; i++)
        {
            // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) upper limit of index is set by m_size
            m_nextFreeIndex.get()[i] = i + 1;            (1)
        }
    }
}

(1)初始化令牌的存储索引,指向下一个相邻的令牌,因为初始时所有的箱子都是可用的。

bool MpmcLoFFLi::pop(Index_t& index) noexcept
{
    Node oldHead = m_head.load(std::memory_order_acquire);
    Node newHead = oldHead;

    do
    {
        // we are empty if next points to an element with index of Size
        if (oldHead.indexToNextFreeIndex >= m_size || !m_nextFreeIndex)       (1)
        {
            return false;
        }

        // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) upper limit of index set by m_size
        newHead.indexToNextFreeIndex = m_nextFreeIndex.get()[oldHead.indexToNextFreeIndex];    (2)
        newHead.abaCounter = oldHead.abaCounter + 1;                                           (3)
    } while (!m_head.compare_exchange_weak(oldHead, newHead, std::memory_order_acq_rel, std::memory_order_acquire));   (4)

    /// comes from outside, is not shared and therefore no synchronization is needed
    index = oldHead.indexToNextFreeIndex;
    /// What if interrupted here an another thread guesses the index and calls push?
    /// @brief murphy case: m_nextFreeIndex does not require any synchronization since it
    ///         either is used by the same thread in push or it is given to another
    ///         thread which performs the cleanup and during this process a synchronization
    ///         is required
    // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic)
    m_nextFreeIndex.get()[index] = m_invalidIndex;

    /// we need to synchronize m_nextFreeIndex with push so that we can perform a validation
    /// check right before push to avoid double free's;
    /// a simple fence without explicit atomic store should be sufficient since the index needs to be transferred to
    /// another thread and the most simple method would be a relaxed store of the index in the 'pop' thread and a
    /// relaxed load of the same atomic in the 'push' thread which would be the atomic in the fence to fence
    /// synchronization; other mechanism would involve stronger synchronizations and implicitly also synchronize
    /// m_nextFreeIndex
    std::atomic_thread_fence(std::memory_order_release);

    return true;
}

该函数用来找到一个空闲箱子索引

(1)前一个判断条件表示所有箱子都被占用,暂无空闲箱子

(2)(3)头部指示令牌的更新候选值

(3)增加计数,解决ABA问题

(4)反复比较确认头部指示令牌没有被其它线程更新,如果没有被更新,则用(2)(3)计算的值更新头部指示令牌

bool MpmcLoFFLi::push(const Index_t index) noexcept
{
    /// we synchronize with m_nextFreeIndex in pop to perform the validity check
    std::atomic_thread_fence(std::memory_order_acquire);

    /// we want to avoid double free's therefore we check if the index was acquired
    /// in pop and the push argument "index" is valid
    // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) index is limited by capacity
    if (index >= m_size || !m_nextFreeIndex || m_nextFreeIndex.get()[index] != m_invalidIndex)
    {
        return false;
    }

    Node oldHead = m_head.load(std::memory_order_acquire);
    Node newHead = oldHead;

    do
    {
        // NOLINTNEXTLINE(cppcoreguidelines-pro-bounds-pointer-arithmetic) index is limited by capacity
        m_nextFreeIndex.get()[index] = oldHead.indexToNextFreeIndex;
        newHead.indexToNextFreeIndex = index;
        newHead.abaCounter = oldHead.abaCounter + 1;
    } while (!m_head.compare_exchange_weak(oldHead, newHead, std::memory_order_acq_rel, std::memory_order_acquire));

    return true;
}

该函数用来将用完的箱子索引返还,设置成可用状态,实现逻辑和MpmcLoFFLi::pop类似。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值