最佳实践之“全身而退”
全身而退,是一种境界,更是一种智慧!编程,特别是框架设计,需要的就是哲学思维。“全身而退”这四个字,还在当年的内存泄露的踩坑过程中就悟出来了,有图有真相:
有人会问,内存泄露和这个“全身而退”好像搭不上边?从某些角度看也是,要不我不会单独放在这里讲。但是也有一点关系,内存泄露不就是内存有分配没有释放,何时释放,一般是程序退出之时。当年作为踩坑专业户,踩过的坑远远不止前面有关“踩坑系列”系列文章提到的,可谓五花八门,也许可以套用一句话:“你们看到过的官场小说远没有我经历过的精彩”:)大家可以看前面一系列踩坑经历的总结。
当时踩过很多崩溃的坑,时不时的崩溃,我形容我当时是救火队长。而当时经过不少的踩坑分析有一个共同的现象,竟然都是退出的时候,就莫名其妙崩溃了。然后这个问题其实也容易重现,你不停的启动和退出就行了,至少百分之五十的概率。只是因为运行的时间不同,退出崩溃的堆栈还不一样。
正是这样子,我们总结开玩笑说,就是经不起折腾。也造就了后面,做项目时,写完一个类,一定会测试退出。整个系统分不同的时间点反复重启、反复插拔网线。基本达到着魔的程度,不是?原来我控制欲这么强:)事实也证明了,经过这样反复重启的折腾,新产品落地投入市场,基本没有这些乱七八糟的问题了!踩坑经验(二)有截图作证:
很久才会出现一个Bug,而且也可以轻松搞定。一般都是新加的功能,或者很特别的情况没考虑到。
所以,也通过真实的项目验证了,全身而退的重要性。当然现在和当年的心境也完全不一样了!事实上,后面经过分布式洗礼,我的这种方法是正确的。分布式就是解决这种单点故障。当然分布式单点故障和单机故障,是不一样的概念或者说属性。分布式中是考虑不可抗拒的原因,集群中单节点的稳定性肯定是集群稳定性的基础。只是这种测试方法可以用来测试分布式的故障切换,甚至是故障恢复!集群考虑的是集群的全身而退,事实上,我们在测试过程中也出现过集群整体失效的时候。
那么我们可以从哪些方面去做到全身而退呢?下面我从实践经验中总结以下几点。
一、单实例的使用
1.方式一
说到单实例,估计绝大部分人都说很简单。如上的例子,一般的人会说没问题。从逻辑来说确实没问题,很简单,不是吗?实现类更简单:
就一句话,静态变量再申明一下。你是不是这样实现的呢? 这是我们重构的新平台上,依然有这样使用的。以前的平台那就更多了,基本上是全部。导致的结果就是上面描述的崩溃就是家常便饭。当时在部门范围内做培训的时候,我把这个作为考题,结果能指出问题点的少之又少。
2.方式二
后面的修改:
这是定义,我们再看看具体实现:
平时我对代码是追求简洁的,但是在这里我反而增加了函数,是不是看上去复杂了?No!简洁必须建立在遵循原理的基础之上。那为什么要这么改,遵循的原理是什么?
最基本的原理就是单实例首先是一个对象,而且是类的对象,不是简单变量。那么类就可能涉及深拷贝,涉及到new和delete,比基础变量要复杂得多。如果像第一种方式,那么就可能出现编译期间开始进行内存分配等操作,而退出的时候也在等着编译器系统的自动回收。在这种启动和退出之时非常容易操作已经不存在的对象和内存,而出现了崩溃。
其实,第一种方式使用的是静态变量,可返回的是引用。从内存的布局而言,属于系统(内核)管理,无法做到应用层的控制,面对复杂情况的内存使用很难做到全身而退。关于程序的内存布局和分配可以查阅虚拟技术和内存泄露两篇文档。
所以基于以上两点,将单实例定义为类的私有变量,指针形式。那么只有在程序启动后要获取的时候才开始构建类对象,完全可以掌控在自己(程序的用户态)的手中,而且可以非常灵活的加入调试代码,比如捕获异常等。在程序退出之时,亦可以很方便很合理的选择何时释放和销毁对象。
当然,一般编码规范的公司就会直接用第二种方式,其他人使用的时候直接copy改改就行。同样的如果是“先辈们”使用了第一种,错误也会传承下去。。。而且大多时候错误的东西还很难纠正过来,就说第一种方式竟然被发现在代码重构后!
所以,编程的时候我们需要有怀疑的精神、反思的精神和专业的引导!
二、架构设计
1.模块与接口
模块和层级清晰,接口简单易用。都有利于全身而退。我们新版的存储系统中,核心业务有三块,IO+rewrite,而仅仅只有一个写模块I和rewrite模块之间的接口!读模块O和其他都是完全分开。
2.类的五大函数
你听说的都是类的三大函数吧,我这里咋变成了五大函数,而且我的五大不是包括原来的三大。
2.1 构造函数和析构函数
只是单纯的对成员变量进行赋值。
2.2 初始化函数和反初始化函数
例如内存的分配和释放,多线程的创建启动和停止。都应该放在这里面,而不是上面提到的构造和析构中。在内存泄露中也提到过。其实只有一个目的,好控制!
2.3 复位函数
有异常情况下,不是被动退出,而是能够主动控制,最坏的情况是复位。将各种变量、状态、内存和多线程恢复到初始状态。有的人可能会说,那怎么不直接用初始化函数呢?这两种功能还是不一样的。初始化是系统启动的时候用的。而复位可能承载更多的判断,例如打印一些重要变量的信息,以利于开发人员跟踪异常情况。
这五大函数是我建立一个类最基本的模板,也是做到全身而退基本的保障。
一个类要尽量做到职责单一,其实一个函数也是类似的。例如说初始化函数不能省,很多人将初始化和构造放一起,或者复位和初始化放一起。
析构函数只是简单的初始化变量而已,而例如内存的分配、线程和定时器的启动最好都到初始化函数中;
假如采用单例模式,则只需用到构造函数,像上面提到的单实例模式用的不好,如果构造函数中包括太多内存和多线程的使用,对退出将是一个灾难。
而从另一个角度,虚拟内存的原理,内存只有分配和使用的时候才开始产生缺页中断即才开始寻找物理内存。所以何时初始化,何时使用内存可以掌握在开发人员的手中,而不是依赖系统来控制。
所以我认为这也是C++相比JAVA来说更好的优势,可能有的人认为C++就是很难控制,像内存、指针、继承,而且还没有GC机制等等。而我认为刚刚相反,例如指针就是一把双刃剑,你懂它,它就威力无穷!你看很多开源库,比如经典的MySQL、高效的Redis、一直很火的Nginx,是吧,指针发挥了太大太大的作用,你能说这些库不稳定吗?虽然说我也一直关注和学习Java和Spring,主要学习它们的抽象思想!
3.锁的设计
细心的你可以发现,上面单实例使用的第二种方式,加了一把锁。我一般的类设计中,锁的数量是2~3,大多情况下是2。单实例模式使用一把锁,状态机用一把锁。在死锁的踩坑经验总结中曾经提到过,锁的使用个数少,范围少。所以你可以回头看看状态机和单实例模式锁的使用。该加的时候一定加锁,该不要加的时候一定不能加,不能模糊。
锁或者说多线程同步使用不当,是很容易出问题的,不仅是运行过程中可能出现死锁,退出的时候也可能出现,可能的情况就是退出失败,同样需要手动进行杀进程。
4.时序
前面的函数、接口、类、内存、多线程与锁都设计好了,对于全身而退,或许已经成功了一半。还有一半在哪里,时序。就是将这些对象和接口如何串起来,如果做到有序。最常见的是退出和启动的顺序即时序是相反的,这是基本的要求,而对于复杂的情况需要分两方面来讨论。
一方面,每个类、每个模块都要做到全身而退,即每个类和模块都需要有序的退出。所以上面类的设计,五大函数最终是为了全身而退,为了有序的释放内存、停止线程等。我们上文提到的有限状态机最终也是为了更好的控制线程、为了线程的退出,锁的设计同样可以减少干扰提高效率。所以系统的全身而退主要依赖于每个类、每个模块的全身而退。
另一方面,程序最后的退出其实也是要封装和集中在一两个函数中,不能太散。即层级关系要非常明确,类似于树的结构,最后集中到根节点,也就是一个函数。所以层级设计的目的也应该是,越到外层越好控制!
以上提到的几点,包括时序,包括单实例模式,也包括最近一系列踩坑经历,我最后都会归纳到框架或设计,所以也可以说这一切都是为了最终的全身而退。
总之,能随时随地的全身而退的代码是一种境界,经得起各种折腾的代码才是好代码,经得起各种折腾的设计才是好的设计!
最后,万事万物的发展都离不开一个简单的道理,例如读一本书:
从简单,到复杂,最后到简单。但这两个简单的心境完全不一样,前者我认为更多的是无知者无畏,后者则是一切了然于心。
计算机最终归结于二进制的运算,最终就是01的组合,组合成各种数据结构和协议,组合成各种调度和算法,等等。
而对应于中国智慧,就是道,抓住事物的本质,以不变应万变,阴阳论,太极八卦论;对应于中国的万事开头难,和全身而退!
也可以对应软件两大难点,就是原理的把握和框架的设计。
有了对原理的理解,能抓住事物的本质,就会下笔如有神助,也能最终全身而退!
当然你要达到这一点,得不停的"悟"!
悟的速度依赖"实践+思考"!
肯定不能只依赖CRUD,也不能仅仅依赖改Bug的多少、仅仅依赖参与项目的多少。
有的改10个Bug比不上别人一个Bug,有的参与了10个项目不如比别人开发一个项目。
还是那句话,如果不读书,行万里路,也只是一个邮差。
所以,我认为多去“实践+思考”。