apache / brpc

brpc is an Industrial-grade RPC framework using C++ Language, which is often used in high performance system such as Search, Storage, Machine learning, Advertisement, Recommendation etc. "brpc" means "better RPC".
https://brpc.apache.org
Apache License 2.0
16.56k stars 3.98k forks source link

疑似keytable内存泄漏 #2756

Closed lsdh-fei closed 2 weeks ago

lsdh-fei commented 2 months ago

Describe the bug (描述bug) 升级1.10之后,内存会不断增长,在vars界面查看,发现bthread_keytable_count在不断增长,我理解keytable是thread local的,thread销毁后也会跟着销毁,不应该无限增长

To Reproduce (复现方法) 使用1.10之后就会出现,但是用1.8就不存在泄漏

Expected behavior (期望行为) keytable对象数量维持动态平衡

Versions (各种版本) OS: Ubuntu 20.04.3 LTS Compiler: clang8 brpc: 1.10 protobuf: 3.15.8

Additional context/screenshots (更多上下文/截图)

image

lsdh-fei commented 2 months ago

@wwbmmm @MJY-HUST 看了下提交记录,不知道是不是 #2645 引入的,辛苦看下哈

chenBright commented 2 months ago

thread销毁后也会跟着销毁,不应该无限增长

自己起的线程中使用bthread_keytable吗?

lsdh-fei commented 2 months ago

@chenBright 不是,就是rpc处理的bthread中使用的

MJY-HUST commented 2 months ago

使用bthread-local的场景是什么样的呢@lsdh-fei 在使用bthread-local变量时,如果create的bthread的addr中没有初始化bthread_keytable_pool_t的话,那么每次return_keytable都会直接析构keytable;另外如果初始化了bthread_keytable_pool_t的话,使用bthread-local变量时需要先调用bthread_getspecific尝试borrow_keytable进行复用,如果每次都是直接bthread_setspecific赋值的话,如果当前bthread不存在keytable的话,就会new一个新的keytable,内存就会不断增长,这个是之前就有的问题

lsdh-fei commented 2 months ago

@MJY-HUST 没有单独使用bthread-local,就是task_runner中的,我门最近把brpc从1.8升级到了1.10,然后就发现新版有内存泄漏,LogStream占用了大量内存,看了下brpc源代码,发现log stream的buf是存在keytable中,然后就怀疑是keytable的问题,然后看vars监控,发现keytable的数量一直在不断增长,所以才怀疑是新版的改动导致的

lsdh-fei commented 2 months ago

现在观察到的现象是经常会出现 LogStream** get_or_new_tls_stream_array() => bthread_getspecific 无法获取LogStream,然后new了LogStream之后通过 bthread_setspecific 设置到tls_bls.keytable,但是同时在这里new一个KeyTable。所以新增的LogStream和KeyTable的数量是相同的。 image image 应该就是new log stream时创建的keytable泄漏了

fausturs commented 2 months ago

我提供一个个人的角度,可以讨论一下。

因为新的keytable的策略,是return到一个pthread 的thread local的list中。但没办法保证的是,同一个keytable被borrow后,会被return到同一个pthread里。

本来其实也没什么问题,只要等待足够时间,每个pthread中,就会create足够的keytable。但是可能,目前的一些task的调度策略,会使得pthread中的keytable数量像波浪一样震荡,使得keytable稳定的总数大大增加。

可以看这么一个例子

void* print_log(void *) {
    std::random_device rd;
    std::mt19937 mt{rd()};
    std::uniform_int_distribution<uint64_t> uid(0, 100000);

    bthread_usleep(50000 + uid(mt));
    LOG(INFO) << "some log here xxx";
    return NULL;
}

class GreeterImpl final : public Greeter {
    void SayHello(google::protobuf::RpcController* controller, const HelloRequest* request, HelloReply* response, google::protobuf::Closure* done) override {
        brpc::ClosureGuard done_guard(done);
        brpc::Controller* cntl = static_cast<brpc::Controller*>(controller);

        (void)print_log(NULL);

        std::string message = "Hello " + request->name() + "!\n";
        response->set_message(message);
    }
};

image

先做sleep,再进行LOG(相当于使用keytable),可以看到keytable的数量非常稳定。因为这里,每个pthread里,有一个keytable就可以了。

但我们换一下位置

void* print_log(void *) {
    std::random_device rd;
    std::mt19937 mt{rd()};
    std::uniform_int_distribution<uint64_t> uid(0, 100000);

    LOG(INFO) << "some log here xxx";
    bthread_usleep(50000 + uid(mt));
    return NULL;
}

class GreeterImpl final : public Greeter {
    void SayHello(google::protobuf::RpcController* controller, const HelloRequest* request, HelloReply* response, google::protobuf::Closure* done) override {
        brpc::ClosureGuard done_guard(done);
        brpc::Controller* cntl = static_cast<brpc::Controller*>(controller);

        (void)print_log(NULL);

        std::string message = "Hello " + request->name() + "!\n";
        response->set_message(message);
    }
};

image 可以看到keytable有一个持续的增长。

这里从统计意义上来说,增长可能会一直存在,因为当前worker的task sleep了(或者是等待某个锁),之后它好像会尝试从其他队列steal一个pendding的任务。随着这些任务都sleep(或者都block在某个锁上),这个work所在的pthread的存量keyTable会被持续消耗。而且这些keytable大概率不会被return到这个pthread了。

所以,pthread中的keytables,不是维持在一个较为稳定的状态,而是有着潮水一样的涨落。随着borrow的失败,只能不停的new新的keytable

MJY-HUST commented 2 months ago

是的,假设每个bthread都持有keytable的前提下,如果pendding的bthread数量过多(持续增长),那么内存消耗是无法避免的。 如果是因为task调度的不均衡(极端情况下一个task group只执行需要创建/获取keytable的任务,然后yield,这些任务最终在其他的task group中执行完成),那么也会出现内存持续增长的情况。这种情况下的一种解决方法是为每个task group的 tls keytable list的长度设置一个阈值,一旦超过之后加锁批量返回给一个全局链表;而当borrow_keytable失败需要new keytable时,先查看全局链表中是否有keytable,再加锁批量取一批,不直接new keytable。

fausturs commented 2 months ago

这个解决方案看上去很棒。 因为现有的borrow_keytable中,已经包含了对thread local 的keytable为空时,落到另一个全局的list(pool->free_keytables)中的逻辑。

看上去,只需要对return_keytable逻辑进行一些调整。

期待大佬们给出修复。

chenBright commented 2 months ago

@MJY-HUST 10月节后会发新版本,能赶上新版本修复这个问题吗?

MJY-HUST commented 2 months ago

@MJY-HUST 10月节后会发新版本,能赶上新版本修复这个问题吗? 周末修复一下,提个pr

chenBright commented 2 weeks ago

Closed this as completed in #2768.