【命令】Python执行命令超时控制【原创】

目录

参考

概要

方案

方案一:os.system

方案二:os.popen

方案三:subprocess.check_output

方案四:subprocess.Popen

方案五:subprocess.Popen

方案六:subprocess.Popen


参考

官方手册

python3 subprocess.check_output的使用

subprocess之preexec_fn

安全开发 | Python Subprocess库在使用中可能存在的安全风险总结 

 

概要

这几天在做一个发布系统,里面进行前端Node发布的时候,需要执行npm install以及npm run build命令,这两个命令执行的时间很长,尤其是npm run build,经常需要超过4、5分钟,一开始我是使用最简单的os.system命令的话,前期没什么大问题,后期随着前端加载的包越来越多,发布系统经常性的会造成莫名的堵塞,卡死在npm run build之间,npm会一直处于epoll_pwait状态,原因是因为超时导致的,所以用了好几种方案来进行超时的控制

【命令】Python执行命令超时控制【原创】_第1张图片

 

建议先了解一下Python执行命令的相关:【命令】Python中的执行命令【原创】

 

方案

方案一:os.system

os.system用来执行cmd指令,在cmd输出的内容会直接在控制台输出,返回结果为0表示执行成功

system()函数在执行过程中进行了以下三步操作:

  • fork一个子进程
  • 在子进程中调用exec函数去执行命令
  • 在父进程调用wait(阻塞)去等待子进程结束

对于fork失败,system函数会返回-1

 

注意: 由于使用该函数经常会莫名其妙地出现错误,但是直接执行命令并没有问题,所以一般建议不要使用

注意:os.system是简单粗暴的执行cmd指令,如果想获取在cmd输出的内容,是没办法获到的

注意:在Unix,Windows都有效

 

优点:简单易理解,能够返回执行命令的成功失败,返回0是成功,非0是失败

缺点:会造成堵塞,无法设置超时,不能输出控制台的结果

 

代码如下:

import os


def run_cmd(cmd_string):
    print("命令为:" + cmd_string)
    return os.system(cmd_string)

 

方案二:os.popen

同样是用来执行cmd指令,如果想获取控制台输出的内容,那就用os.popen的方法了,popen返回的是一个file对象,跟open打开文件一样操作了,r是以读的方式打开

 

popen() 创建一个管道,通过fork一个子进程,然后该子进程执行命令。返回值在标准IO流中,该管道用于父子进程间通信。父进程要么从管道读信息,要么向管道写信息,至于是读还是写取决于父进程调用popen时传递的参数(w或r)

 

注意:能获取到命令的执行内容,可以打印出来,但是获取不到命令是否执行成功,只是单纯输出了命令的执行结果而已

注意:os.popen() 方法用于从一个命令打开一个管道。在Unix,Windows中有效

 

优点:简单易理解,能够输出控制台的结果

缺点:无法获取命令是否执行成功,不清楚是否能够设置超时

 

代码如下:

import os

def run_cmd(cmd_string):
    print("命令为:" + cmd_string)
    p = os.popen(cmd_string)
    x = p.read()
    p.close()
    return x

 

 

方案三:subprocess.check_output

subprocess模块是在2.4版本中新增的,官方文档中描述为可以用来替换以下函数:os.system、os.spawn、os.popen、popen2

 

参数既可以是string字符串,也可以是list列表

比如:

subprocess.Popen([“cat”,”test.txt”])
subprocess.Popen(“cat test.txt”, shell=True) 

对于参数是字符串,需要指定shell=True,官方建议使用list列表

 

参数有:

【命令】Python执行命令超时控制【原创】_第2张图片

 

比如:subprocess.call代替os.system

执行命令,返回命令的结果和执行状态,0或者非0

import subprocess

retcode = subprocess.call('ls -l', shell=True)
print(retcode)

 

比如:subprocess.getstatusoutput()

接受字符串形式的命令,返回 一个元组形式的结果,第一个元素是命令执行状态,第二个为执行结果

比如:

#执行正确
>>> subprocess.getstatusoutput('pwd')
(0, '/root')
#执行错误
>>> subprocess.getstatusoutput('pd')
(127, '/bin/sh: pd: command not found')

 

比如:subprocess.getoutput()

接受字符串形式的命令,返回执行结果

#执行正确
>>> res = subprocess.getoutput('pwd')
>>> res
'/d/software/laragon/www/test/AutoRelease'
#执行错误
>>> res = subprocess.getoutput('ip addr')
>>> res
"'ip' 不是内部或外部命令,也不是可运行的程序\n或批处理文件。"

 

以及还有check_output()、check_call()、run()等

 

以上subprocess使用的方法,都是对subprocess.Popen的封装

 

注意:官方建议使用run函数,run函数是在Python 3.5增加的(https://docs.python.org/zh-cn/3.7/library/subprocess.html#module-subprocess)

>>> import subprocess
# python 解析则传入命令的每个参数的列表
>>> subprocess.run(["df","-h"])
Filesystem      Size Used Avail Use% Mounted on
/dev/mapper/VolGroup-LogVol00
           289G  70G 204G 26% /
tmpfs         64G   0  64G  0% /dev/shm
/dev/sda1       283M  27M 241M 11% /boot
CompletedProcess(args=['df', '-h'], returncode=0)
# 需要交给Linux shell自己解析,则:传入命令字符串,shell=True
>>> subprocess.run("df -h|grep /dev/sda1",shell=True)
/dev/sda1       283M  27M 241M 11% /boot
CompletedProcess(args='df -h|grep /dev/sda1', returncode=0)


查询了subprocess的官方文档,发现是有超时的异常处理(https://docs.python.org/zh-cn/3/library/subprocess.html)

 

代码如下:

import subprocess

def run_cmd_old(cmd_string, timeout=20):
    print("命令为:" + cmd_string)
    try:
        out_bytes = subprocess.check_output(cmd_string, stderr=subprocess.STDOUT, timeout=timeout, shell=True)
        res_code = 0
        msg = out_bytes.decode('utf-8')
    except subprocess.CalledProcessError as e:
        out_bytes = e.output
        msg = "[ERROR]CallError :" + out_bytes.decode('utf-8')
        res_code = e.returncode
    except subprocess.TimeoutExpired as e:
        res_code = 100
        msg = "[ERROR]Timeout : " + str(e)
    except Exception as e:
        res_code = 200
        msg = "[ERROR]Unknown Error : " + str(e)

    return res_code, msg

check_output返回的是子程序的执行结果,是unicode编码,如果程序执行报错的话,会直接抛出异常CalledProcessError,并且异常当中会有output属性,该属性为unicode编码的,要当字符串使用的时候需要转码,如e.output.decode(encoding="utf-8")

 

但实际发现,是有问题的,比如超时时间设置为20秒,那么该命令会等到命令执行完毕才会抛出TimeoutExpired异常,并不是说一旦命令执行了20秒,立马就停止命令抛出异常的(参考:python3 subprocess.check_output的使用,timeout参数不能和shell=True一起使用,不然就算是时间到了,还是会继续执行,等执行结束以后才会抛出subprocess.TimeoutExpired异常)

官方文档有这么一段话:

timeout 参数将被传递给 Popen.communicate()。如果发生超时,子进程将被杀死并等待。 TimeoutExpired 异常将在子进程中断后被抛出。

 

优点:简单易理解,能够输出控制台的结果,能够知道是否超时

缺点:需要在命令执行完成才会抛出超时异常

 

方案四:subprocess.Popen

该方案是基于上面的方案进行改造的,上面的方案既然无法进行超时的控制,那么就手动来进行超时的判断和控制,即在循环中判断是否达到超时时间,如果已经达到的话,那么手动杀掉进程来停止命令的继续执行

代码如下:

import subprocess
import time

def run_cmd(cmd_string, timeout=20):
    print("命令为:" + cmd_string)
    try:
        p = subprocess.Popen(cmd_string, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, shell=True)
        t_beginning = time.time()
        res_code = 0
        while True:
            if p.poll() is not None:
                break
            seconds_passed = time.time() - t_beginning
            if timeout and seconds_passed > timeout:
                p.terminate()  # 等同于p.kill()
                msg = "Timeout :Command '" + cmd_string + "' timed out after " + str(timeout) + " seconds"
                raise Exception(msg)
            time.sleep(0.1)

        msg = str(p.stdout.read().decode('utf-8'))
    except Exception as e:
        res_code = 200
        msg = "[ERROR]Unknown Error : " + str(e)

    return res_code, msg
  • poll:判断是否执行完成,如果完毕返回returncode,未完成返回None
  • terminate:终止进程发送SIGTERM信号

 

这个方案虽然能够在达到设置的超时时间后输出超时错误,但实际上还是会存在一些问题

在生产环境下(LInux)中,我们发现虽然主进程不在了,但子进程npm还在执行,但由于父进程被杀死了导致这个npm子进程处于僵尸进程,杀不掉

【命令】Python执行命令超时控制【原创】_第3张图片

 

优点:能够输出控制台的结果,能够进行超时的控制

缺点:无法杀干净所有的子进程,可能会导致子进程变成僵尸进程,超时之后无法输出命令的执行过程信息

 

方案五:subprocess.Popen

那既然使用subprocess的kill方法或者是terminate方法,无法杀干净所有的子进程,那么可以使用os.killpg方法来进行杀掉子进程,只需要知道子进程的进程号即可

 

另外,查阅了subprocess的官方文档(https://docs.python.org/zh-cn/3.7/library/subprocess.html#popen-objects),里面有提到一个communicate方法,可以实现超时控制,另外为什么要用communicate方法,可以参考:安全开发 | Python Subprocess库在使用中可能存在的安全风险总结 

 

注意:如果要使用os.killpg方法的话,需要通过preexec_fn 参数让popen成立自己的进程组,然后向进程组发送SIGTERM 或 SIGKILL,中止 subprocess.Popen 所启动进程的子子孙孙,可参考:subprocess之preexec_fn

Popen在Linux/Unix平台下的实现方式是,先fork一个子进程,然后让这个子进程去exec载入外部可执行程序。被执行程序可能会再fork一些子进程来进行工作,从被执行程序被fork之后,产生的子进程都有独立的进程空间和pid,超出了Popen可控制的范围(如果不设置preexec_fn 和 start_new_session的话)。而Popen的preexec_fn 参数,接受一个回调函数,在fork子进程之后并且exec之前会执行这个回调函数,可以利用这个特性对被运行的子进程做出一些修改,比如执行setsid函数来成立一个独立的进程组

Linux 的进程组是一个进程的集合,任何进程用系统调用 setsid 可以创建一个新的进程组,并让自己成为首领进程。首领进程的子子孙孙只要没有再调用 setsid 成立自己的独立进程组,那么它都将成为这个进程组的成员。 之后进程组内只要还有一个存活的进程,那么这个进程组就还是存在的,即使首领进程已经死亡也不例外。 而这个存在的意义在于,我们只要知道了首领进程的 pid (同时也是进程组的 pgid), 那么可以给整个进程组发送 signal,组内的所有进程都会收到。

因此利用这个特性,就可以通过 preexec_fn 参数让 Popen 成立自己的进程组, 然后再向进程组发送 SIGTERM 或 SIGKILL,中止 subprocess.Popen 所启动进程的子子孙孙。当然,前提是这些子子孙孙中没有进程再调用 setsid 分裂自立门户。

 

所以,改进的方案如下:

import subprocess
import os
import signal

def run_cmd(cmd_string, timeout=20):
    print("命令为:" + cmd_string)
    p = subprocess.Popen(cmd_string, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, shell=True, close_fds=True,
                         preexec_fn=os.setsid)
    try:
        (msg, errs) = p.communicate(timeout=timeout)
        ret_code = p.poll()
        if ret_code:
            code = 1
            msg = "[Error]Called Error : " + str(msg.decode('utf-8'))
        else:
            code = 0
            msg = str(msg.decode('utf-8'))
    except subprocess.TimeoutExpired:
        # 注意:不能只使用p.kill和p.terminate,无法杀干净所有的子进程,需要使用os.killpg
        p.kill()
        p.terminate()
        os.killpg(p.pid, signal.SIGTERM)

        # 注意:如果开启下面这两行的话,会等到执行完成才报超时错误,但是可以输出执行结果
        # (outs, errs) = p.communicate()
        # print(outs.decode('utf-8'))

        code = 1
        msg = "[ERROR]Timeout Error : Command '" + cmd_string + "' timed out after " + str(timeout) + " seconds"
    except Exception as e:
        code = 1
        msg = "[ERROR]Unknown Error : " + str(e)

    return code, msg
  • close_fds=True,此时除了文件描述符为0 , 1 and 2,其他子进程都要被杀掉。(Linux中所有的进程都是进程0的子进程)
  • pid=1的是init,内核完成之后启动的第一个进程,然后init根据/etc/inittab的内容再去启动其它进程。)
  • os.setsid(): 使独立于终端的进程(不响应sigint,sighup等),使脱离终端。
  • SIGTERM: 终止信号
  • os.killpg( p.pid,signal.SIGTERM): 发送终止信号到组进程p.pid

 

我们以为这个方案是最后的方案了,但其实还是有问题的,首先是在超时时候,无法获取执行的结果,如果想要获取命令执行的结果,可以注释掉中间的这两行,但是呢,超时的控制就会失效,一定会等到命令执行完成才会输出超时异常同时输出命令的执行结果,无法实现到达超时时间之后,立即杀掉进程同时输出命令执行的结果

# 注意:如果开启下面这两行的话,会等到执行完成才报超时错误,但是可以输出执行结果
(outs, errs) = p.communicate()
print(outs.decode('utf-8'))

另外呢,该方案在Windows不能运行,因为Windows下的os是没有setsid的

 

优点:能够进行超时的控制,能够杀掉所有的子进程

缺点:超时之后无法输出命令执行结果,无法在Windows下运行

 

另外由于一些未知的原因,导致超时的概率比之前的更高,手动执行倒是没有任何的问题,至今找不到任何的原因

所以只能换其他的超时控制方案了

 

方案六:subprocess.Popen

为了兼容Windows系统以及为了更安全,官方建议使用start_new_session=True来代替了上面的方案里面的preexec_fn=os.setsid

官方文档里面描述:

警告
preexec_fn 形参在应用程序中存在多线程时是不安全的。子进程在调用前可能死锁。如果你必须使用它,保持警惕!最小化你调用的库的数量。 

注解
如果你需要修改子进程环境,使用 env 形参而非在 preexec_fn 中进行。 start_new_session 形参可以代替之前常用的 preexec_fn 来在子进程中调用 os.setsid()。 

如果start_new_session设置为True,那么将在子进程执行之前进行setid(仅Posix系统)

 

注意:不管是设置preexec_fn=os.setsid 或者是 设置 start_new_session=True,都不能保证百分百清理掉子进程,一旦被执行的子进程的子进程调用使用了setsid的话,那么也同样是无法清理掉的

 

代码如下:

import os
import signal
import subprocess
import platform

def run_cmd(cmd_string, timeout=20):
    print("命令为:" + cmd_string)
    p = subprocess.Popen(cmd_string, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, shell=True, close_fds=True,
                         start_new_session=True)

    format = 'utf-8'
    if platform.system() == "Windows":
        format = 'gbk'

    try:
        (msg, errs) = p.communicate(timeout=timeout)
        ret_code = p.poll()
        if ret_code:
            code = 1
            msg = "[Error]Called Error : " + str(msg.decode(format))
        else:
            code = 0
            msg = str(msg.decode(format))
    except subprocess.TimeoutExpired:
        # 注意:不能只使用p.kill和p.terminate,无法杀干净所有的子进程,需要使用os.killpg
        p.kill()
        p.terminate()
        os.killpg(p.pid, signal.SIGTERM)

        # 注意:如果开启下面这两行的话,会等到执行完成才报超时错误,但是可以输出执行结果
        # (outs, errs) = p.communicate()
        # print(outs.decode('utf-8'))

        code = 1
        msg = "[ERROR]Timeout Error : Command '" + cmd_string + "' timed out after " + str(timeout) + " seconds"
    except Exception as e:
        code = 1
        msg = "[ERROR]Unknown Error : " + str(e)

    return code, msg

 

优点:能够进行超时的控制,能够杀掉所有的子进程,Windows下也可以运行

缺点:超时之后无法输出命令执行结果

 

你可能感兴趣的:(Python,Python,subprocess,Popen,超时,命令)