好好活就是有意义的事,有意义的事就是好好活
记一次Bug 调试 —— 多线程
记一次Bug 调试 —— 多线程

记一次Bug 调试 —— 多线程

前言

Pistache,不愧是一个开源软件,其组件真的存在各种问题,今天又被其PollableQueue给坑了,之前修复了其中存在的一个内存泄漏Bug,见《记一次Bug 调试 —— 内存泄漏&&内存越界》。今天又发现了一个关于多线程的Bug。

PollableQueue是基于父类Pistache::Queue实现的,Pistache::Queue是一种无锁的MPSC设计,即多生产者单消费者模型。因此,其设计已经保证了多线程安全,而然PollableQueue为了实现 Pollable 属性,添加了一个eventfd,从而为多线程埋下了隐患。

关于eventfd可以参考我翻译的linux手册《linux手册翻译——eventfd(2)》,手册告诉我们,eventfd也是线程安全的,因此PollableQueue在大部分场景下都没有太大问题,但是偏偏被我遇到了有问题的场景。

源代码

#include <pistache/mailbox.h>
#include <thread>
#include <atomic>

using namespace Pistache;
using namespace std;

int main() {
    Polling::Epoll poller;
    PollableQueue<int> queue;
    queue.bind(poller);
    atomic<int> count = 0;
    thread t([&] {
        for (;;) {
            vector<Pistache::Polling::Event> events;
            int ready_fds = poller.poll(events);
            for (const auto &e:events)
                if (e.tag == queue.tag())
                    for (;;) {
                        auto t = queue.popSafe();
                        if (!t)
                            break;
                        printf("%d ", *t);
                        count--;
                    }

        }
    });
    sleep(1);
    for (int i = 0;;) {
        if (count == 0) {
            count++;
            queue.push(i++);
        }
    }
}

代码逻辑

逻辑非常简单,就一个生产者,一个消费者,并且使用了一个atomic<int> count = 0;用于描述队列的最大容量,在这里最大容量为1。即生产者产生一个数据,只有在消费者消费之后,才会继续生产。
理论上来说,这个模型没有什么线程安全问题,程序会一直运行。

运行结果

程序运行一段时间就会停止(停止的位置不确定),查看函数栈会发现,消费者者停留在poller.poll(events),且此时count的值为1,生产者一直空循环。

也就是说,生产者产生了数据(因为count值为1),但是消费者却没有感知到,因为消费者是通过eventfd感知数据到达的,为什么生产者执行了push操作,消费者却感知不到呢?

源码分析

我们需要查看pop的源码(稍微简化):

Entry* pop() override
        {
            auto ret = Queue<T>::pop();
            if (isBound())
            {
                uint64_t val;
                ssize_t bytes = read(event_fd, &val, sizeof val);
            }
            return ret;
        }

我们可以看到,每次执行pop,无论是否有结果返回,都会清空eventfd,而问题正是出现在这里:
如果在执行了auto ret = Queue<T>::pop();之后,发现ret为空,但是此时刚好有新数据到达,那么这个时候清空eventfd,就会导致新数据的eventfd也被清除,从而导致epoll无法检测到新的数据到达了
我们结合我们自己的测试用例分析上述过程:

  1. 生产者判断count为0,使count++,并执行push放入数据,此后在count被消费者置为0之前都不会继续push
  2. 消费者的epoll检测到eventfd事件,报告queue中产生了新的数据
  3. 消费者将循环处理数据(见源码)
  4. 第一次循环,消费了生产者的数据,count–,此时生产者已经具备了可执行的条件
  5. 然后消费者再次执行pop操作,当执行完auto ret = Queue<T>::pop();后生产者被调度,产生了数据并将count++,注意此时ret的值是null
  6. 消费者继续执行,清除eventfd,在这里将抹除生产者刚刚产生的eventfd记录
  7. 消费因为pop返回null导致for循环推出
  8. 消费者,继续执行int ready_fds = poller.poll(events);等待事件到达,但是我们在第6步清除了eventfd,导致此时无法检测到新数据到达
  9. 如果此时生产者还能继续产生消息,那么这个问题将被解决,但是生产者此时由于消费者没有将count–,也无法产生数据,从而导致程序停止输出!!!

Bug解决

解决方案也很简单:

Entry* pop() override
        {
            auto ret = Queue<T>::pop();
            if (isBound() && ret != nullptr)
            // if (isBound())
            {
                uint64_t val;
                ssize_t bytes = read(event_fd, &val, sizeof val);
            }
            return ret;
        }

加一个判断即可,如果ret返回是null,那么就不要再清除eventfd了!

思考

其实如果生产者可以源源不断的产生数据,那么其实程序就不会产生这种类似于死锁的问题,在Pistache中使用PollableQueue的确不会像我这样限制queue的大小,因此不会导致严重的后果,但是也会导致请求无法被及时处理,因此这个改进是应该的!

标题给的是多线程,但这好像不是严格意义上的多线程访问共享资源导致的安全问题,反而更类似与死锁问题!

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注