背景
写测试用例时,发现自己开发的系统服务程序执行systemctl stop时要等很久,进一步调查发现实际上程序没有正常退出的功能。代码大致如下:
class A {
A();
~A();
virtual bool Init();
virtual void Shutdown();
};
int main() {
A a;
a.Init();
WaitSigTerm();
return 0;
}
A类实例中在Init时会启动一个工作线程。在通过 kill -s SIGTERM pid 命令想要正常/优雅地(gracefully)退出进程时,发现进程在收到消息后,执行了退出步骤,但是卡在某处迟迟无法退出。经调查是在~A()中调用Shutdown,而Shutdown卡死。
这是当我想要支持正常退出遇到的第一个问题。有时,更糟糕,程序看似正常退出了,实则工作线程没有正常退出。比如,当~A()中没有调用Shutdown,而是期待用户自己调用Shutdown,但用户并没有调Shutdown时。
问题1: 是否应规定,用户应该显式调用Shutdown,还是我们应该在析构函数中自动调用它?
这里,面临一个设计决策,即,是否应规定,用户应该显式调用Shutdown,还是我们应该在析构函数中自动调用它。
显式调用的话,在用户没有忘记调用时,有个好处,就是比较容易定位卡死在哪里,比如哪个Shutdown卡住了,很容易通过在其前后打日志进行分析。而通过析构函数调用Shutdown,则需要更多经验,比如首先要在较大范围中定位是哪些类实例的造成卡死,定位方法主要是增加额外的scope,如原来代码:
int main() {
A a;
a.Init();
B b;
b.Init();
WaitSigTerm();
return 0;
}
改为
int main() {
{
A a;
a.Init();
{
B b;
b.Init();
WaitSigTerm();
Log("Before destroy b");
}
Log("Before destroy a");
}
Log("Before return");
return 0;
}
如过,输出为:
Before destroy b
Before destroy a
则说明卡在~A中。
进一步在~A中添加额外日志:
~A() {
Log("Before Shutdown");
Shutdown();
Log("After Shutdown");
}
输出为:
Before Shutdown
说明卡在Shutdown。
还有一种情况,如果~A实现如下:
class A {
~A() {
}
C c;
D d;
};
则考虑时C、D的析构函数卡死。进一步到C、D代码中分析。
以上是在析构函数中调用Shutdown时分析卡死原因的过程,对比在用户显式的调用Shutdown时,仅仅在Shutdown函数前后添加日志即可分析出卡死的原因,上面的过程显得复杂的多。
但是,仔细看上面的叙述,里面提到了,如果用户显示的调用了Shutdown的话,会比较容易定位卡死问题,但是实际上,用户可能会忘记调用,从而出现你以为正常退出了,实际上没有正常退出的情况。因此,把Shutdown放到析构函数中,能够帮助我们暴露卡死问题。
继续分析用户显式调用Shutdown的方案,用户在何时调用Shutdown是很难确定的,而且也可能已经失去了对于要Shutdown的实例的引用,比如:
int main() {
std::vector<std::shared_ptr<A>> modules;
{
auto c = std::make_shared<C>();
c->Init();
auto a = std::make_shared<A>(c);
a->Init();
modules.push_back(a);
}
WaitSigTerm();
for (auto& a : modules) {
a->Shutdown();
}
return 0;
}
显然在Shutdown a时,还应该Shutdown c。也许你会说,为什么不在A::Shutdown中,添加 c->Shutdown()?这是个好问题。但是作为A的实现者,你怎么知道这个C实例是A独享的?没有其他的实例也在使用它?也许可以增加一个标志位多次调用Shutdown只实际关闭一次呢?但是即便如此你还要有引用计数的能力,确保没有别人用它时,在真正关闭它。这一步一步的会把你引向类似COM的AddRef/Release Pattern 和类似C中的谁申请谁释放原则:谁Init实例,谁负责Shutdown实例。最后,你会发现你实现了引用计数,额外要求用护遵循谁Init谁Shutdown原则,引用计数尚算好实现,但用户会觉得自己用了假的C++,进而抛弃你。
综上,对比两个方案:
方案 | 优点 | 缺点 |
---|---|---|
A:用户显示调用Shutdown | 在用户已经调用Shutdown并出现卡死时,容易定位原因。 | 容易忘记调用,隐藏异常退出问题; 实现复杂,没有智能指针等工具帮助管理Shutdown时机,难以使用。 |
B:在析构函数中调用Shutdown | 对用户而言容易使用; 使用后,容易暴露卡死问题; | 卡死后,不容易定位原因。 |
有意思的是,用户显式调用Shutdown的优点依赖于用户正确使用了它,而这有时是很难的。实践中,实际上是由于我们已经在析构函数中调用了Shutdown,才发现了程序有退出时卡死的问题,即实践中发现方案A有好处,是因为我们已经使用了方案B。
综上,采用方案B:
在析构函数中调用Shutdown。
问题2:是否应该提供public 方法让用户自己调用Shutdown?
采用方案B后,让用户自己调用Shutdown没有什么价值,可能用户想要手动Shutdown来提前诊断卡死问题?一旦让用户能够访问Shutdown方法,那就需要防止Shutdown多次,于是就要添加flag,表示实例已经Shutdown过,这次Shutdown咱就直接退出吧。当然,你可能会说Init已经时publilc了,要防止Init多次,不是已经要用flag了吗?为了省flag你可以用状态啊。一个状态表示New,Started,Stopped,不挺好吗?好,你增加了状态,那状态是不是要考虑线程安全?另外Shutdown时你是不是要调用父类的Shutdown?父类是否还有自己的状态(集)?同样,对于Init,应该也要调用父类的Init,父类Init成功,子类Init失败,怎么处理?后续会有很多设计决策需要选择。
不妨应用奥卡姆剃刀原理,尽量简单。即:
不要public Shutdown方法和Init方法,在构造函数中调用Init,在析构函数中调用Shutdown,不维护状态。
问题3:构造函数中执行Init步骤,如果发生错误,如何上报?
先上一个 quora上的讨论链接做下参考。
难点在于,我们不喜欢在构造函数中抛出异常。如果不抛出异常,如何上报错误?基本上方案是类似的,构造函数已经执行,由于没有异常产生,技术上说对象已经构造完成,即使有错误,也属于业务范畴。可以通过错误码(不管是额外传入的参数,还是对象中的成员)来反应错误。用户在构造完成后,需要检察该错误码,确定是否发生了构造错误。
比如quora中有人给出的方案:
class T {
T(const Foo& arg, std::error_code& errc) {
auto filename = parse_filename(arg);
if (!filename) {
errc = MyErrorEnum::INVALID_ARGUMENT;
return;
}
auto file = open_file(*filename);
if (!file) {
errc = MyErrorEnum::FILE_NOT_FOUND;
return;
}
this->data = parse_data(file);
if (this->data) {
errc = MyErrorEnum::OK;
} else {
errc = MyErrorEnum::INVALID_DATA;
}
}
public:
static std::expected<T, std::error_code> create(const Foo& arg) {
std::error_code errc;
std::expected<T, std::error_code> result(std::in_place_t, arg, errc);
if (errc) {
// delete the failed object and store the error code
result = std::unexpected<std::error_code>(errc);
}
return result;
}
};
上面的 create 不是必须的,在没有std::expected的C++版本上,自己实现一个expected,或者开放构造函数直接裸用也未尝不可。
总结
综合上面三个问题的结论,修改代码如下(增加有子类的情况):
class A {
public:
A(std::error_code& errc) {
errc = init();
if (errc) {
return;
}
}
virtual ~A() {
shutdown();
}
private:
...
std::error_code init();
void shutdown();
};
class SubA: public A {
public:
SubA(std::error_code& errc): A(errc) {
if (errc) {
return;
}
errc = init();
if (errc) {
return;
}
}
virtual ~SubA() {
shutdown();
}
private:
...
std::error_code init();
void shutdown();
};
int main() {
std::error_code errc;
SubA a(errc);
if (errc) {
return -1;
}
WaitSigTerm();
return 0;
}