异步定时任务的使用哲学

最近大概间隔1个月,我在使用定时任务类时遇到了两次析构过程中的 bug

这两个 bug 出现在我用别人写的定时任务类时,这两个bug的区别是一个是组合使用的,另一个是继承使用的

由于coredump的堆栈都不在正常的位置,因此这两个bug都花费了我平均一整天的大量时间去定位

另外有个基于协程的定时任务类,这个类不仅只是使用,甚至都是我实现的,尽管 bug 还未发生,我仔细看了下居然也存在类似的问题

因此,我觉得我有必要总结这个问题,避免再次在相同的问题上耗费太多时间,毕竟多线程(协程)问题的定位非常困难

定时任务类实现

首先需要定义线程或者协程的抽象类,可以认为是一个可执行的任务单元,如下

1
2
3
4
5
6
7
class TaskUnit { 
public:
void start() {} //启动任务
void join() {} //等待任务退出
private:
virtual void run() = 0; //执行任务
};

本篇博文不会有detach等需求,因此一个最简实现是这样的,具体细节就不实现了

定时任务需要重载这个run接口,循环执行待执行的定时任务

考虑到某些定时任务还有一些初始化需求,因此需要一个init接口

并且需要一个start和stop的接口来控制定时任务的启停

跑开线程安全的细节不谈,大致是这样的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class ScheduledTask : protected TaskUnit {
public:
virtual ~ScheduledTask() {
stop();
}
void start() {
init();
TaskUnit::start();
}
void stop() {
stop_ = true;
TaskUnit::join();
}
private:
virtual bool onScheduled() = 0;
virtual void init() {}
void run() override {
do {
if (!onScheduled()) {
break;
}
//sleep interval ms
}while(!stop_);
}
bool stop_ = false;
};

定时任务类的错误使用

一个理所当然的使用方式如下

1
2
3
4
5
6
7
8
9
10
11
12
13
class FooTask : public ScheduledTask {
bool onScheduled() override {
memberV = 1;
return true;
}
int memberV = 0;
};
int main() {
FooTask task;
task.start();
for (;;) {
}
}

在大部分场景下,这个case都能运行的很好,这是因为主流场景下,定时任务可以作为单例类永不析构

服务的停止可以通过quick_exit绕过析构,防止一些很难缠的生命周期问题(例如单例类的相互调用引起的生命周期依赖问题)

一旦需要析构场景下,这个FooTask就会真的“foo”了

这是因为派生类成员变量的析构,早于基类的析构

考虑如下的时序

时序 主线程 子线程
1 ~FooTask()
2 派生类成员变量析构:FooTask::memberV
3 onScheduled()
4 FooTask::memberV = 1
5 ~ScheduledTask()
6 ScheduledTask::stop()

在这样的时序下,4会引起core,也有可能不会,从而core在奇奇怪怪的地方

定时任务类的正确使用

智能指针用法(不推荐)

多线程安全的生命周期问题,总是可以用智能指针来解决

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class FooTask2 : public ScheduledTask , 
public std::enable_shared_from_this<FooTask2> {
private:
bool onScheduled() override {
auto task = wPtr.lock();
if (!task) {
return false;
}
memberV = 1;
return true;
}
int memberV = 0;
weak_ptr<FooTask2> wPtr = shared_from_this();
};

在FooTask析构的时候,再去调用onScheduled就一定会直接退出了

但这种方式的缺陷是需要自己能完全控制这个对象的构造和析构方式

  • 构造

    需要使用智能指针来创建这个类

  • 析构

    能够接受这个类的owner析构的时候,这个子类还未析构

    对定时任务类来说,就是定时任务的拥有者的析构函数不再有join子线程的能力

    1
    2
    3
    4
    5
    6
    int main() { 
    shared_ptr<FooTask2> task_ = make_shared<FooTask2>();
    //sleep for long time
    task_ = nullptr;
    //do something
    }
    时序 主线程 子线程
    1 //sleep for long time
    2 onScheduled()
    3 auto task = wPtr.lock():成功task延长生命周期
    4 task_ = nullptr:还有引用计数,不触发析构,直接退出
    5 //do something:期望收尾的代码提前被执行
    6 task = nullptr:局部变量智能指针析构,引用计数清零
    7 ~FooTask()

    主线程需要主动调用子线程的stop方法

    1
    2
    3
    4
    5
    6
    7
    int main() { 
    shared_ptr<FooTask2> task_ = make_shared<FooTask2>();
    //sleep for long time
    task_->stop();
    task_ = nullptr;
    //do something
    }

非智能指针用法

1
2
3
4
5
6
7
8
9
10
11
12
class FooTask : public ScheduledTask { 
public:
~FooTask() {
stop();
}
private:
bool onScheduled() override {
memberV = 1;
return true;
}
int memberV = 0;
};

一定要显式的在定时任务需要访问的成员变量析构前,调用stop

上面一直讨论的是继承的情况,对于组合来说,也需要考虑一样的问题

组合的正确使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class FooTask1 { 
public:
void start() {
task_.start();
}
void stop() {
task_.stop();
}
private:
bool onScheduled() {
memberV = 1;
return true;
}
struct TaskImp : public ScheduledTask {
TaskImp(FooTask1 *owner): owner_(owner) {}
bool onScheduled() override {
return owner_->onScheduled();
}
FooTask1 *owner_;
};
int memberV = 0;
TaskImp task_ = {this};
};

相对于继承,无法提前基类的析构函数,组合时可以通过将定时任务类成员放置到最后一个成员变量位置

来保证他析构时的stop一定先于其他成员变量

当然最好的还是加一行,因为这种隐式的规则,很容易不小心调错顺序,在超大的代码规模下,即使加上注释,也不容易同步给其他开发者

1
2
3
~FooTask1() {
task_.stop();
}

结论

自己控制的stop才是好stop,依赖定时任务类的析构函数来进行stop总是会出大错的

我最后封装了一个析构时检查是否有没有调用过stop方法的定时任务类,来提前发现问题,而不是core在莫名其妙的地方

1
2
3
4
5
6
7
8
9
10
11
12
13
class StopCheckedScheduledTask : public ScheduledTask {
public:
~StopCheckedScheduledTask() {
assert(isStop_ &&
"stop() must be called before destructing");
}
void stop() {
isStop_ = true;
ScheduledTask::stop();
}
private:
bool isStop_ = false;
};