自帶的 print 函式居然會報錯?

crossoverJie發表於2021-05-18

前言

最近用 Python 寫了幾個簡單的指令碼來處理一些資料,因為只是簡單功能所以我就直接使用 print 來列印日誌。

任務執行時偶爾會出現一些異常:

因為我在不同地方都有列印日誌,導致每次報錯的地方都不太一樣,從而導致程式執行結果非常詭異;有時候是這段程式碼沒有執行,下一次就可能是另外一段程式碼沒有觸發。

雖說當時有注意到 Broken pipe 這個關鍵異常,但沒有特別在意,因為程式碼中也有一些傳送 http 請求的地方,一直以為是網路 IO 出現了問題,壓根沒往 print 這個最基本的列印函式上思考?。

直到這個問題反覆出現我才認真看了這個異常,定睛一看 print 不也是 IO 操作嘛,難道真的是自帶的 print 函式都出問題了?


但在本地、測試環境我執行無數次也沒能發現異常;於是我找運維拿到了線上的執行方式。

原來為了方便維護大家提交上來的指令碼任務,運維自己有維護一個統一的指令碼,在這個指令碼中使用:

cmd = 'python /xxx/test.py'
os.popen(cmd)

來觸發任務,這也是與我在本地、開發環境的唯一區別。

popen 原理

為此我在開發環境模擬出了異常:

test.py:

import time
if __name__ == '__main__':
    time.sleep(20)
    print '1000'*1024

task.py:

import os
import time
if __name__ == '__main__':
    start = int(time.time())
    cmd = 'python test.py'
    os.popen(cmd)
    end = int(time.time())
    print 'end****{}s'.format(end-start)

執行:

python task.py

等待 20s 必然會復現這個異常:

Traceback (most recent call last):
  File "test.py", line 4, in <module>
    print '1000'*1024
IOError: [Errno 32] Broken pipe

為什麼會出現這個異常呢?

首先得了解 os.popen(command[, mode[, bufsize]]) 這個函式的執行原理。

根據官方文件的解釋,該函式會執行 fork 一個子程式執行 command 這個命令,同時將子程式的標準輸出通過管道連線到父程式;

也就該方法返回的檔案描述符。

這裡畫個圖能更好地理解其中的原理:

在這裡的使用場景中並沒有獲取 popen() 的返回值,所以 command 的執行本質上是非同步的;

也就是說當 task.py 執行完畢後會自動關閉讀取端的管道。

如圖所示,關閉之後子程式會向 pipe 中輸出 print '1000'*1024,由於這裡輸出的內容較多會一下子填滿管道的緩衝區;

於是寫入端會收到 SIGPIPE 訊號,從而導致 Broken pipe 的異常。

從維基百科中我們也可以看出這個異常產生的一些條件:

其中也提到了 SIGPIPE 訊號。

解決辦法

既然知道了問題原因,那解決起來就比較簡單了,主要有以下幾個方案:

  1. 使用 read() 函式讀取管道中的資料,全部讀取之後再關閉。
  2. 如果不需要子程式中的輸出時,也可以將 command 的標準輸出重定向到 /dev/null
  3. 也可以使用 Python3subprocess.Popen 模組來執行。

這裡使用第一種方案進行演示:

import os
import time
if __name__ == '__main__':
    start = int(time.time())
    cmd = 'python test.py'
    with os.popen(cmd) as p:
        print p.read()
    end = int(time.time())
    print 'end****{}s'.format(end-start)

執行 task.py 之後不會再拋異常,同時也將 command 的輸出列印出來。

線上修復時我沒有采用這個方案,為了方便檢視日誌,還是使用標準的日誌框架將日誌輸出到了 es 中,方便統一在 kibana 中進行檢視。

由於日誌框架並沒有使用到管道,所以自然也不會有這個問題。

更多內容

問題雖然是解決了,其中還是涉及到了一些我們們平時不太注意的知識點,這次我們就來一起回顧一下。

首先是父子程式的內容,這個在 c/c++/python 中比較常見,在 Java/golang 中直接使用多執行緒、協程會更多一些。

比如這次提到的 Python 中的 os.popen() 就是建立了一個子程式,既然是子程式那肯定是需要和父程式進行通訊才能達到協同工作的目的。

很容易想到,父子程式之間可以通過上文提到的管道(匿名管道)來進行通訊。

還是以剛才的 Python 程式為例,當執行 task.py 後會生成兩個程式:

分別進入這兩個程式的 /proc/pid/fd 目錄可以看到這兩個程式所開啟的檔案描述符。

父程式:

子程式:

可以看到子程式的標準輸出與父程式關聯,也就是 popen() 所返回的那個檔案描述符。

這裡的 0 1 2 分別對應一個程式的stdin(標準輸入)/stdout(標準輸出)/stderr(標準錯誤)。

還有一點需要注意的是,當我們在父程式中開啟的檔案描述符,子程式也會繼承過去;

比如在 task.py 中新增一段程式碼:

x = open("1.txt", "w")

之後檢視檔案描述符時會發現父子程式都會有這個檔案:

但相反的,子程式中開啟的檔案父程式是不會有的,這個應該很容易理解。

總結

一些基礎知識在排查一些詭異問題時顯得尤為重要,比如本次涉及到的父子程式的管道通訊,最後來總結一下:

  1. os.popen() 函式是非同步執行的,如果需要拿到子程式的輸出,需要自行呼叫 read() 函式。
  2. 父子程式是通過匿名管道進行通訊的,當讀取端關閉時,寫入端輸出到達管道最大快取時會收到 SIGPIPE 訊號,從而丟擲 Broken pipe 異常。
  3. 子程式會繼承父程式的檔案描述符。

你的點贊與分享是對我最大的支援

相關文章