python subprocess 模塊使用(以及詳解管道阻塞的坑)

本文內容均基於 python 2.7 版本,整理一下這個模塊的一些平常套路和 一些要注意的坑,省去你們讀官方文檔的時間。但仍是推薦認真讀下 官方文檔,並不長html

功能和用途

subprocess 是 python 標準庫中的一個模塊,用於建立子進程和與子進程交互python

該模塊替換了一些過期的模塊和函數linux

os.system
os.spawn*
os.popen*
popen2.*
commands.*

常見使用姿式

共計四個函數,包括:shell

  • 三個知足不一樣需求的簡化函數(call, check_call, check_output)
  • 以及一個類 Popen,是上面三個簡化函數的基礎,也就是說上面三個簡化版函數是封裝了這個方式的一些特定用法而已
  • 三個 Popen 類的成員函數,用於父子進程間的交互

如下爲詳細說明api


先引入包,爲了讓下面函數看起來清晰一些,包名臨時簡化一下爲 's'緩存

import subprocess as s

s.call()
建立一個子進程並等待其結束,返回一個 returncode(即 exit code,詳見 Linux 基礎安全

# 函數定義
s.call(args, *, stdin=None, stdout=None, stderr=None, shell=False)

# 舉例
>>> subprocess.call(["ls", "-l"])
0
>>> subprocess.call("exit 1", shell=True)
1

s.check_call()
跟上面的 s.call() 相似,只不過變成了 returncode 若是不是 0 的話就拋出異常 subprocess.CalledProcessError,這個對象包含 returncode 屬性,能夠用 try...except... 來處理(參考 Python 異常處理app

# 函數定義
s.check_call(args, *, stdin=None, stdout=None, stderr=None, shell=False)

# 舉例
>>> subprocess.check_call(["ls", "-l"])
0
>>> subprocess.check_call("exit 1", shell=True)
Traceback (most recent call last):
   ...
subprocess.CalledProcessError: Command 'exit 1' returned non-zero exit status 1

s.check_output()
建立一個子進程並等待其結束,返回子進程向 stdout 的輸出結果。若是 returncode 不爲 0,則拋出異常 subprocess.CalledProcessError函數

# 函數定義
s.check_output(args, *, stdin=None, stderr=None, shell=False, universal_newlines=False)

# 舉例
>>> subprocess.check_output(["echo", "Hello World!"])
'Hello World!\n'
>>> subprocess.check_output("exit 1", shell=True)
Traceback (most recent call last):
   ...
subprocess.CalledProcessError: Command 'exit 1' returned non-zero exit status 1

s.Popen()
這是整個 subprocess 模塊最重要也是最基礎的類,以上三個簡化方法均是基於這個類。具體每一個參數什麼含義請看 官方文檔性能

# 類定義
class subprocess.Popen(args, bufsize=0, executable=None, stdin=None, stdout=None, stderr=None, preexec_fn=None, close_fds=False, shell=False, cwd=None, env=None, universal_newlines=False, startupinfo=None, creationflags=0)

Popen.poll()
檢查子進程是否已經結束,返回 returncode

Popen.wait()
阻塞父進程,直到子進程結束,返回 returncode

Popen.communicate(input=None)
阻塞父進程,直到子進程結束。並能夠與子進程交互:發送數據(即參數 input,類型必須爲 string)到 stdin,以及從 stdout、stderr 讀取信息


重點(坑)介紹


先來個開胃菜
以上函數(類)的首個參數 args,有兩種使用方式

  1. 一個 list,如 ['ls', '-al'],通常 推薦 這種方式
  2. 一個字符串,如 'ls -al',這種方式以上函數中的 shell 參數必須爲 True。這種方式存在安全風險,具體請看官方文檔

再來一盤
要使父進程和子進程能夠通訊,必須將對應的流的管道(PIPE,自行搜索 Linux 管道)打開,如 stdout=subprocess.PIPE。其中 subprocess.PIPE 是一個特定的值(源碼中 hardcode PIPE = -1),當參數列表中某個流被置爲了這個值,則將會在建立子進程的同時爲這種流建立一個管道對象(調用系統 api,如 p2cread, _ = _winapi.CreatePipe(None, 0)),用於父子進程間的通訊
而管道這個東西,其實就是一段內存區間,每一個系統內核對其的大小都有所限制,如 linux 上默認最大爲 64KB,能夠在終端用 ulimit -a 來查看

可選類型還有:

  1. subprocess.PIPE = -1
  2. subprocess.STDOUT = -2 重定向到 stdout
  3. subprocess.DEVNULL = -3 重定向到 /dev/null,即丟棄信息

開始正餐

咱們先來看這樣一個需求:
腳本 A 需調用一個外部程序 B 來作一些工做,並獲取 B 的 stdout 輸出

實現方式一:

import subprocess

child = subprocess.Popen(['./B'], stdout=subprocess.PIPE)

child.wait()

print child.stdout

這種方式官方文檔中再三警告,會致使死鎖問題,不怕坑本身的話就用吧,good luck

致使死鎖的緣由:
如上文提到,管道的大小是有所限制的,當子進程一直向 stdout 管道寫數據且寫滿的時候,子進程將發生 I/O 阻塞;
而此時父進程只是乾等子進程退出,也處於阻塞狀態;
因而,GG。

實現方式二(加粗:推薦):

import subprocess

child = subprocess.Popen(['./B'], stdout=subprocess.PIPE)

stdout, stderr = child.communicate()

這是官方推薦的方式,可是也有坑:
看 communicate 的實現

for key, events in ready:
    if key.fileobj is self.stdin:
        chunk = input_view[self._input_offset :
                           self._input_offset + _PIPE_BUF]
        try:
            self._input_offset += os.write(key.fd, chunk)
        except BrokenPipeError:
            selector.unregister(key.fileobj)
            key.fileobj.close()
        else:
            if self._input_offset >= len(self._input):
                selector.unregister(key.fileobj)
                key.fileobj.close()
    elif key.fileobj in (self.stdout, self.stderr):
        data = os.read(key.fd, 32768)
        if not data:
            selector.unregister(key.fileobj)
            key.fileobj.close()
        self._fileobj2output[key.fileobj].append(data)

其實 communicate 就是幫咱們作了循環讀取管道的操做,保證管道不會被塞滿;因而子進程能夠很爽地寫完本身要輸出的數據,正常退出,避免了死鎖

這裏有注意點:
communicate 實際上是循環讀取管道中的數據(每次 32768 字節)並將其存在一個 list 裏面,到最後將 list 中的全部數據鏈接起來(b''.join(list)) 返回給用戶。因而就出現了一個坑:若是子進程輸出內容很是多甚至無限輸出,則機器內存會被撐爆,再次 GG

第三種實現
第三種的思路其實很簡單,就是當子進程的可能輸出很是大的時候,直接將 stdout 或 stderr 重定向到文件,避免了撐爆內存的問題。不過這種情形不多見,不經常使用


Tips:
communicate 的實現中能夠看到一些有趣的東西,寫一下想法,歡迎討論

  1. 每次讀取管道中的數據爲 32768bytes,即 32KB。選用這個數據的緣由猜測爲

    • 取一個折中的值 16KB~64KB,適應不一樣系統對 PIPE SIZE 最大值的限制(MAC OS 初始默認 16KB,當管道滿後自動擴大爲 64KB,Linux 默認爲 64KB)
    • 32KB 是 512bytes 的整數倍,適應內核對內存的分頁機制,提升效率。相似的思路比較常見,如對以太網幀的尾部封裝(現已被廢棄)
  2. 從管道中讀取數據並緩存到內存中的操做,並不是循環用字符串拼接的方式,而是先將數據分段放進一個 list,最後再鏈接起來,緣由

    • python 中的每次字符串拼接操做都會生成新對象,當數據量大的時候會有嚴重的性能問題
    • 而使用 list 合併的方式,僅在每次 list 須要擴容的時候須要將 list 總體搬遷到內存中的另外一個空間;以及最後將全部 list 中的元素合併爲一個字符串對象的時候生成新對象有性能消耗,相對來講代價很是低
  3. 在觀察 linux 系統默認管道大小的時候,ulimit -a 的輸出中,能夠看到 PIPE BUF 4KBPIPE SIZE 64KB 兩個值,查了一下了解到,前者是對管道單次寫入大小的限制,然後者是管道總大小的限制。前者恰好對應了內核分頁單頁的大小。
相關文章
相關標籤/搜索