前言
最近用 Python
寫了幾個(gè)簡(jiǎn)單的腳本來處理一些數(shù)據(jù),因?yàn)橹皇呛?jiǎn)單功能所以我就直接使用 print
來打印日志凳兵。
任務(wù)運(yùn)行時(shí)偶爾會(huì)出現(xiàn)一些異常:
因?yàn)槲以诓煌胤蕉加写蛴∪罩韭拱裕瑢?dǎo)致每次報(bào)錯(cuò)的地方都不太一樣柄延,從而導(dǎo)致程序運(yùn)行結(jié)果非常詭異肃廓;有時(shí)候是這段代碼沒有運(yùn)行翻屈,下一次就可能是另外一段代碼沒有觸發(fā)陈哑。
雖說當(dāng)時(shí)有注意到 Broken pipe
這個(gè)關(guān)鍵異常,但沒有特別在意伸眶,因?yàn)榇a中也有一些發(fā)送 http
請(qǐng)求的地方惊窖,一直以為是網(wǎng)絡(luò) IO
出現(xiàn)了問題,壓根沒往 print
這個(gè)最基本的打印函數(shù)上思考??厘贼。
直到這個(gè)問題反復(fù)出現(xiàn)我才認(rèn)真看了這個(gè)異常界酒,定睛一看 print
不也是 IO
操作嘛,難道真的是自帶的 print
函數(shù)都出問題了嘴秸?
但在本地毁欣、測(cè)試環(huán)境我運(yùn)行無數(shù)次也沒能發(fā)現(xiàn)異常;于是我找運(yùn)維拿到了線上的運(yùn)行方式岳掐。
原來為了方便維護(hù)大家提交上來的腳本任務(wù)凭疮,運(yùn)維自己有維護(hù)一個(gè)統(tǒng)一的腳本,在這個(gè)腳本中使用:
cmd = 'python /xxx/test.py'
os.popen(cmd)
來觸發(fā)任務(wù)串述,這也是與我在本地执解、開發(fā)環(huán)境的唯一區(qū)別。
popen 原理
為此我在開發(fā)環(huán)境模擬出了異常:
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)
運(yùn)行:
python task.py
等待 20s 必然會(huì)復(fù)現(xiàn)這個(gè)異常:
Traceback (most recent call last):
File "test.py", line 4, in <module>
print '1000'*1024
IOError: [Errno 32] Broken pipe
為什么會(huì)出現(xiàn)這個(gè)異常呢纲酗?
首先得了解 os.popen(command[, mode[, bufsize]])
這個(gè)函數(shù)的運(yùn)行原理衰腌。
根據(jù)官方文檔的解釋,該函數(shù)會(huì)執(zhí)行 fork
一個(gè)子進(jìn)程執(zhí)行 command
這個(gè)命令觅赊,同時(shí)將子進(jìn)程的標(biāo)準(zhǔn)輸出通過管道連接到父進(jìn)程右蕊;
也就該方法返回的文件描述符。
這里畫個(gè)圖能更好地理解其中的原理:
在這里的使用場(chǎng)景中并沒有獲取 popen()
的返回值茉兰,所以 command
的執(zhí)行本質(zhì)上是異步的尤泽;
也就是說當(dāng) task.py
執(zhí)行完畢后會(huì)自動(dòng)關(guān)閉讀取端的管道。
如圖所示,關(guān)閉之后子進(jìn)程會(huì)向
pipe
中輸出 print '1000'*1024
坯约,由于這里輸出的內(nèi)容較多會(huì)一下子填滿管道的緩沖區(qū)熊咽;
于是寫入端會(huì)收到 SIGPIPE
信號(hào),從而導(dǎo)致 Broken pipe
的異常闹丐。
從維基百科中我們也可以看出這個(gè)異常產(chǎn)生的一些條件:
其中也提到了 SIGPIPE
信號(hào)横殴。
解決辦法
既然知道了問題原因,那解決起來就比較簡(jiǎn)單了卿拴,主要有以下幾個(gè)方案:
- 使用
read()
函數(shù)讀取管道中的數(shù)據(jù)衫仑,全部讀取之后再關(guān)閉。 - 如果不需要子進(jìn)程中的輸出時(shí)堕花,也可以將
command
的標(biāo)準(zhǔn)輸出重定向到/dev/null
文狱。 - 也可以使用
Python3
的subprocess.Popen
模塊來運(yùn)行。
這里使用第一種方案進(jìn)行演示:
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)
運(yùn)行 task.py
之后不會(huì)再拋異常缘挽,同時(shí)也將 command
的輸出打印出來湿酸。
線上修復(fù)時(shí)我沒有采用這個(gè)方案井赌,為了方便查看日志杠茬,還是使用標(biāo)準(zhǔn)的日志框架將日志輸出到了 es 中寸齐,方便統(tǒng)一在 kibana
中進(jìn)行查看。
由于日志框架并沒有使用到管道腮郊,所以自然也不會(huì)有這個(gè)問題摹蘑。
更多內(nèi)容
問題雖然是解決了,其中還是涉及到了一些咱們平時(shí)不太注意的知識(shí)點(diǎn)轧飞,這次我們就來一起回顧一下衅鹿。
首先是父子進(jìn)程的內(nèi)容,這個(gè)在 c/c++/python
中比較常見踪少,在 Java/golang
中直接使用多線程塘安、協(xié)程會(huì)更多一些。
比如這次提到的 Python
中的 os.popen()
就是創(chuàng)建了一個(gè)子進(jìn)程援奢,既然是子進(jìn)程那肯定是需要和父進(jìn)程進(jìn)行通信才能達(dá)到協(xié)同工作的目的兼犯。
很容易想到,父子進(jìn)程之間可以通過上文提到的管道(匿名管道)來進(jìn)行通信集漾。
還是以剛才的 Python 程序?yàn)槔星?dāng)運(yùn)行 task.py 后會(huì)生成兩個(gè)進(jìn)程:
分別進(jìn)入這兩個(gè)程序的/proc/pid/fd
目錄可以看到這兩個(gè)進(jìn)程所打開的文件描述符。
父進(jìn)程:
子進(jìn)程:
可以看到子進(jìn)程的標(biāo)準(zhǔn)輸出與父進(jìn)程關(guān)聯(lián)具篇,也就是 popen()
所返回的那個(gè)文件描述符纬霞。
這里的
0 1 2
分別對(duì)應(yīng)一個(gè)進(jìn)程的stdin
(標(biāo)準(zhǔn)輸入)/stdout
(標(biāo)準(zhǔn)輸出)/stderr
(標(biāo)準(zhǔn)錯(cuò)誤)。
還有一點(diǎn)需要注意的是驱显,當(dāng)我們?cè)诟高M(jìn)程中打開的文件描述符诗芜,子進(jìn)程也會(huì)繼承過去瞳抓;
比如在 task.py
中新增一段代碼:
x = open("1.txt", "w")
之后查看文件描述符時(shí)會(huì)發(fā)現(xiàn)父子進(jìn)程都會(huì)有這個(gè)文件:
但相反的,子進(jìn)程中打開的文件父進(jìn)程是不會(huì)有的伏恐,這個(gè)應(yīng)該很容易理解孩哑。
總結(jié)
一些基礎(chǔ)知識(shí)在排查一些詭異問題時(shí)顯得尤為重要,比如本次涉及到的父子進(jìn)程的管道通信翠桦,最后來總結(jié)一下:
-
os.popen()
函數(shù)是異步執(zhí)行的横蜒,如果需要拿到子進(jìn)程的輸出,需要自行調(diào)用read()
函數(shù)销凑。 - 父子進(jìn)程是通過匿名管道進(jìn)行通信的丛晌,當(dāng)讀取端關(guān)閉時(shí),寫入端輸出到達(dá)管道最大緩存時(shí)會(huì)收到
SIGPIPE
信號(hào)斗幼,從而拋出Broken pipe
異常澎蛛。 - 子進(jìn)程會(huì)繼承父進(jìn)程的文件描述符。
你的點(diǎn)贊與分享是對(duì)我最大的支持