C++ constructor/destructor vs Init/Shutdown Method

背景

写测试用例时,发现自己开发的系统服务程序执行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;
}
  • 19
    点赞
  • 40
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值