深入分析在Python模块顶层运行的代码引起的一个Bug
然后我们在InteractivePythonprompt中测试了一下:
>>>importsubprocess >>>subprocess.check_call("false") 0
而在其他机器运行相同的代码时,却正确的抛出了错误:
>>>subprocess.check_call("false") Traceback(mostrecentcalllast): File"",line1,in File"/System/Library/Frameworks/Python.framework/Versions/2.7/lib/python2.7/subprocess.py",line542,incheck_call raiseCalledProcessError(retcode,cmd) subprocess.CalledProcessError:Command'false'returnednon-zeroexitstatus1
看来是subprecess误以为子进程成功的退出了导致的原因.
深入分析
第一眼看上去,这一问题应该是Python自身或操作系统引起的.这到底是怎么发生的?于是我的同事查看了subprocess的wait()方法:
defwait(self): """Waitforchildprocesstoterminate.Returnsreturncodeattribute.""" whileself.returncodeisNone: try: pid,sts=_eintr_retry_call(os.waitpid,self.pid,0) exceptOSErrorase: ife.errno!=errno.ECHILD: raise #ThishappensifSIGCLDissettobeignoredorwaiting #forchildprocesseshasotherwisebeendisabledforour #process.Thischildisdead,wecan'tgetthestatus. pid=self.pid sts=0 #Checkthepidandloopaswaitpidhasbeenknowntoreturn #0evenwithoutWNOHANGinoddsituations.issue14396. ifpid==self.pid: self._handle_exitstatus(sts) returnself.returncode
可见,如果os.waitpid的ECHILD检测失败,那么错误就不会被抛出.通常,当一个进程结束后,系统会继续记录其信息,直到母进程调用wait()方法.在此期间,这一进程就叫"zombie".如果子进程不存在,那么我们就无法得知其是否成功还是失败了.
以上代码还能解决另外一个问题:Python默认认为子进程成功退出.大多数情况下,这一假设是没问题的.但当一个进程明确表明忽略子进程的SIGCHLD时,waitpid()将永远是成功的.
回到原来的代码中
我们是不是在我们的程序中明确设置忽略SIGCHLD?不太可能,因为我们使用了大量的子进程,但只有极少数情况下才出现同样的问题.再使用gitgrep后,我们发现只有在一段独立代码中,我们忽略了SIGCHLD.但这一代吗根本就不是程序的一部分,只是引用了一下.
一星期后
一星期后,这一错误又再一次发生.并且通过简单的调试,在debugger中重现了该错误.
经过一些测试,我们确定了正是由于程序忽略了SIGCHLD才引起的这一bug.但这是怎么发生的呢?
我们查看了那段独立代码,其中有一段:
signal.signal(signal.SIGCHLD,signal.SIG_IGN)
我们是不是无意间import了这段代码到程序中?结果显示我们的猜测是正确的.当import了这段代码后,由于以上语句是在这一module的顶层,而不是在一个function中,导致了它的运行,忽略了SIGCHLD,从而导致了子进程错误没有被抛出!
总结
这一bug的发生,给了我们两个教训.第一是,在debug检查时,应该从新的代码到老的代码,再到PythonLibrary.因为新代码发生错误的几率大于老代码,而pythonlibrary中发生错误的几率更小.
第二是,不要将可能会引起副作用的代码写在module顶层,而应当写到functuon中.因为如果该module被import,那么在顶层的代码就会运行,导致各种不可知的事件发生.