導語
業務模塊為實現高並發時的更快的處理速度,經常會采用多進程的方式去處理業務。多進程模式下常見的三種bug:for循環下fork子進程導致產生無數孫子進程,僵屍進程,接口竄包。本章主要介紹第一種常見的bug:for循環下fork子進程導致產生無數孫子進程。通過分析開發線上出現的問題,理解問題出現的原因以及如何避免,如何有效的測試出這類缺陷。
目錄
一:缺陷引入
二:多進程概念理解
2.0 fork基本概念理解
2.1 “寫時復制”的fork
2.2 for循環下fork子進程問題分析
2.3 缺陷分析
三:測試方法
一: 缺陷引入
某日下午,測試組突然炸鍋了,“為什么這台機器一下這么卡?”“為什么機器的cpu占用這么高?”“為啥這台機器的這個進程ps這么多?”“這么多進程未被主進程回收,這是僵屍進程啊”,后面該進程的相關測試人員一看,趕緊停了被測程序,機器恢復。
測試同學:主進程在wait釋放子進程的“空殼”時,出現了大量的失敗返回值為-1(日志有打印主進程調用wait的返回值),這就導致子進程的“空殼”未被釋放,出現大量僵屍進程,直接占用耗盡用戶資源,導致整個系統被拖垮。
開發同學:直接上多進程3件套看看能不能修復bug:
1: 創建進程前,查看子進程個數,超過最大進程數,不再fork
2: 子進程運行前,先sleep 1s
3: 子進程運行完或者發生異常時,不拋異常,直接exit退出
測試同學:修復了,現在正常運行子進程和子進程異常退出都不會產生僵屍進程了,問題解決。
------------------------------------------------------------------------------------------------------------------------------------------
那問題出現了:ps查看進程的時候,有看進程的狀態嗎?為什么確定就是僵屍進程? 也有可能是創建了大量的子進程且子進程一直未退出。用python還原下當時的代碼,如下所示:
#!/usr/bin/python
# 模擬缺陷問題產生
import os,time,sys
def test(is_ok):
if is_ok == 0:
return 0
else:
return -1
def CreateVerifyTask():
try:
pid = os.fork()
if pid == 0:
print("子進程執行的代碼,子進程的pid為{0},主進程pid為{1}".format(os.getpid(),os.getppid()))
ret = test(-1)
if ret<0:
print("創建任務失敗")
# 改進方法1:子進程運行異常直接退出(推薦)
# os._exit(0)
raise RuntimeError('error1')
else:
pid= os.wait()
print(pid)
print("主進程執行的代碼,當前pid為{0},我真實的pid為{1}".format(pid,os.getpid()))
except:
print('throw 1 CreateVerifyTask')
raise RuntimeError('error1')
def hand_test():
try:
CreateVerifyTask()
except:
print('throw 2 get_test')
# 改進方法2:將異常直接拋到for循環或者while循環
# raise RuntimeError('error1 hand_test')
# 不往外繼續拋異常,子進程執行CreateVerifyTask產生異常在hand_test內被"消化",導致子進程在excute繼續執行for循環,產生更多的孫子進程。
def excute():
try:
for i in range(10):
print(i)
hand_test()
except:
print('throw 2 get_test')
if __name__=='__main__':
excute()
為什么會產生大量的進程呢?如果認真看了這段代碼仍然不明白為何產生問題,強烈建議從第二章節開始看,else謎底在2.3 缺陷分析揭曉。
二:多進程概念理解
2.0 fork基本概念
計算機程序設計中的分叉函數。返回值: 若成功調用一次則返回兩個值,子進程返回0,父進程返回子進程標記;否則,出錯返回-1。
一個現有進程可以調用fork函數創建一個新進程。由fork創建的新進程被稱為子進程(child process)。子進程是父進程的副本,它將獲得父進程數據空間、堆、棧等資源的副本,這意味着父子進程間不共享這些地址空間。UNIX將復制父進程的地址空間內容給子進程,因此,子進程有了獨立的地址空間。在不同的UNIX (Like)系統下,我們無法確定fork之后是子進程先運行還是父進程先運行,這依賴於系統的實現。
如下所示的代碼段:
Fork語句執行后:(第二步,第三步順序不確定)
第一步: 父進程fork子進程成功,子進程獲得父進程數據空間、堆、棧等資源的副本,擁有跟父進程一樣的代碼段,此時父子進程都從fork的下一句開始並發執行。
第二步: 父進程執行:內核向父進程1869返回子進程的進程號pid=1870,父進程執行else內代碼段。
第三步: 子進程執行:內核向子進程返回0.子進程執行elif pid ==0 內代碼段。
2.1 “寫時復制”的fork
進程(Process)是計算機中已運行程序的實體,是系統的基本運作單位,是資源分配的最小單位,fork子進程后,子進程會復制父進程的狀態(內存空間數據等)。fork 出來的進程和父進程擁有同樣的上下文信息、內存數據、與進程關聯的文件描述符。這句話是否理解,可以通過下面2個demo驗證下:
問題1 :全局變量list1,fork子進程后,在子進程內:打印list1的虛擬地址,修改list1[0]的值,打印list1值,打印list1虛擬地址。 主進程內:打印list1的虛擬地址,待子進程修改后,打印list1值。
子進程和主進程打印的虛擬地址值是一樣的嗎?打印的list1的值是一樣的嗎?
import os
import time
list1 =[1,2,3,4]
print("list1的地址為{0}".format(id(list1)))
mainpid = os.getpid()
print(os.getpid())
pid = os.fork()
if pid<0:
print('創建進程失敗')
elif pid == 0:
print("子進程執行的代碼,子進程的pid為{0},主進程pid為{1}".format(os.getpid(),os.getppid()))
print("子進程修改前list1的地址為{0}".format(id(list1)))
list1[0]=10
print("子進程修改后list1的地址為{0}".format(id(list1)))
print("子進程list1為{0}".format(list1))
else:
print("主進程執行的代碼,當前pid為{0},我真實的pid為{1}".format(pid,os.getpid()))
print("主進程list1的地址為{0}".format(id(list1)))
time.sleep(1)
print("主進程list1為{0}".format(list1))
print("主進程最后打印list1的地址為{0}".format(id(list1)))
print(list1)
print('end')
運行結果:
list1的地址為4349698528 10157 主進程執行的代碼,當前pid為10158,我真實的pid為10157 主進程list1的地址為4349698528 子進程執行的代碼,子進程的pid為10158,主進程pid為10157 子進程修改前list1的地址為4349698528 子進程修改后list1的地址為4349698528 子進程list1為[10, 2, 3, 4] [10, 2, 3, 4] end 主進程list1為[1, 2, 3, 4] 主進程最后打印list1的地址為4349698528 [1, 2, 3, 4] end Process finished with exit code 0
結果:
全局變量在子進程的虛擬地址值 = 主進程的虛擬地址值;子進程內的list1的值 不等於主進程list1的值。
分析:
fork創建一個新進程。系統調用復制當前進程,在進程表中新建一個新的表項,新表項中的許多屬性與當前進程是相同的。新進程幾乎與主進程一模一樣,執行的代碼也完全相同,但是新進程有自己的數據空間、環境和文件描述符。但是新的進程只是擁有自己的虛擬內存空間,而沒有自己的物理內存空間。新進程共享源進程的物理內存空間。而且新內存的虛擬內存空間幾乎就是源進程虛擬內存空間的一個復制。所以父子進程都打印list1的虛擬地址時,都是同一個地址值。
進程空間可以簡單地分為程序段(正文段)、數據段、堆和棧四部分(簡單這樣理解)。fork函數,當執行完fork后的一定時間內,新的進程(p2)和主進程(p1)的進程空間關系如下圖:
fork執行時,Linux內核會為新的進程P2創建一個虛擬內存空間,而新的虛擬空間中的內容是對P1虛擬內存空間中的內容的一個拷貝。而P2和P1共享原來P1的物理內存空間。但是當父子兩個進程中任意一個進程對數據段、棧區、堆區進行寫操作時,上圖中的狀態就會被打破,這個時候就會發生物理內存的復制,這也就是叫“寫時復制”的原因。發生的狀態轉變如下:
P2有了屬於自己的物理內存空間。如果只有數據段發生了寫操作那么就只有數據段進行寫時復制,而堆、棧區域依然是父子進程共享。這就解釋了為啥修改子進程的全局變量,不影響父進程list1值的情況。還有一個需要注意的是,正文段(程序段)不會發生寫時復制,這是因為通常情況下程序段是只讀的。子進程和父進程從fork之后,基本上就是獨立運行,互不影響了。
此外需要特別注意的是,父子進程的文件描述符表也會發生寫時復制。
#!/usr/bin/python # -*- coding: UTF-8 -*- import pymysql import os import time def init_db(): # 打開數據庫連接 db_conn = pymysql.connect("localhost","root","root1234","mysql" ) # 使用 cursor() 方法創建一個游標對象 cursor cursor = db_conn.cursor() return db_conn,cursor db_conn,db_curdsor = init_db() pid = os.fork() if pid<0: print('創建進程失敗') elif pid == 0: print('子進程db_curdsor的地址是:{0}'.format(id(db_curdsor))) for i in range(1000): time.sleep(1) db_curdsor.execute("SELECT VERSION()") version_data = db_curdsor.fetchone() print(version_data) else: print("主進程執行的代碼,當前pid為{0},我真實的pid為{1}".format(pid,os.getpid())) print('主進程db_curdsor的地址是:{0}'.format(id(db_curdsor))) time.sleep(2) db_conn.close()
運行結果:
主進程執行的代碼,當前pid為13237,我真實的pid為13224 主進程db_curdsor的地址是:4672554320 子進程db_curdsor的地址是:4672554320 ('8.0.18',) Traceback (most recent call last): File "/Users/leiliao/Downloads/loleina_excise/process/test2.py", line 30, in <module> db_curdsor.execute("SELECT VERSION()") File "/opt/anaconda3/lib/python3.7/site-packages/pymysql/cursors.py", line 170, in execute result = self._query(query) File "/opt/anaconda3/lib/python3.7/site-packages/pymysql/cursors.py", line 328, in _query conn.query(q) File "/opt/anaconda3/lib/python3.7/site-packages/pymysql/connections.py", line 517, in query self._affected_rows = self._read_query_result(unbuffered=unbuffered) File "/opt/anaconda3/lib/python3.7/site-packages/pymysql/connections.py", line 732, in _read_query_result result.read() File "/opt/anaconda3/lib/python3.7/site-packages/pymysql/connections.py", line 1075, in read first_packet = self.connection._read_packet() File "/opt/anaconda3/lib/python3.7/site-packages/pymysql/connections.py", line 657, in _read_packet packet_header = self._read_bytes(4) File "/opt/anaconda3/lib/python3.7/site-packages/pymysql/connections.py", line 707, in _read_bytes CR.CR_SERVER_LOST, "Lost connection to MySQL server during query") pymysql.err.OperationalError: (2013, 'Lost connection to MySQL server during query') Process finished with exit code 0
此時如果子進程運行中,發現斷連執行重連操作,則重連后的句柄屬於子進程獨有資源。
2.2 for循環下fork子進程問題分析
下面代碼會創建幾個子進程呢?
import os import time n = 3 # 期望設置3個進程 for i in range(n): pid = os.fork() if pid<0: print('創建進程失敗') elif pid == 0: print("子進程執行的代碼,子進程的pid為{0},主進程pid為{1}".format(os.getpid(),os.getppid()))
# break
# 增加break,創建的線程數就是n
else: pass #主進程什么都不做
運行結果:
子進程執行的代碼,子進程的pid為14787,主進程pid為14786
子進程執行的代碼,子進程的pid為14788,主進程pid為14786
子進程執行的代碼,子進程的pid為14789,主進程pid為14786
子進程執行的代碼,子進程的pid為14790,主進程pid為14787
子進程執行的代碼,子進程的pid為14791,主進程pid為14788
子進程執行的代碼,子進程的pid為14792,主進程pid為14787
子進程執行的代碼,子進程的pid為14793,主進程pid為14790
結果:7個
分析:
fork是UNIX或類UNIX中的分叉函數,fork函數將運行着的程序分成2個(幾乎)完全一樣的進程,每個進程都啟動一個從代碼的同一位置開始執行的線程。這兩個進程中的線程繼續執行,就像是兩個用戶同時啟動了該應用程序的兩個副本。如果把n設置成3,則實際會產生7個子進程。
1.i=0時,父進程進入for循環,此時由於fork的作用,產生父子兩個進程(分別記為F0/S0),分別輸出father和child,然后,二者分別執行后續的代碼,子進程由於for循環的存在,沒有退出當前循環,因此,父子進程都將進入i=1的情況;
2.i=1時,父進程繼續分成父子兩個進程(分別記為F1/S1),而i=0時fork出的子進程也將分成兩個進程(分別記為FS01/SS01),然后所有這些進程進入i=2;
3.....過程於上面類似,已經不用多說了,相信一切都已經明了了,依照上面的標記方法,i=2時將產生
如下圖所示:
對應的數學公式如下:1 + 2 + 4 + ... + 2^(n - 1) = 2^n - 1
2.3 缺陷分析
缺陷產生的根本原因就在於:在創建子進程時異常,raise拋異常之后被外層函數hand_test捕獲到之后沒有繼續往外拋,導致子進程在excute函數種的for循環未結束,子進程繼續執行excute函數,創建孫子進程,導致創建n多進程。
三件套還是有點用,雖然那會開發不知道是啥原因。。。。。
這個問題的解決,應該有2種方法:
1. 子進程運行的代碼段,子進程執行的函數正常運行完,尤其是異常的時候,使用exit退出當前子進程,從根本上解決子進程fork孫子進程的問題。(最推薦方法)
2. 子進程運行異常拋異常,異常需要一直拋直到for循環處理的函數去處理。
三:測試方法
測試點:for循環內fork子進程,是否產生孫子進程
測試方法:子進程代碼執行區正常執行,子進程能正常退出。
子進程代碼執行區異常執行,子進程直接exit退出。(拋異常關注處理異常函數)