自己動手開發一個 Web 伺服器(三)
在第二部分中,你開發了一個能夠處理HTTPGET請求的簡易WSGI伺服器。在上一篇的最後,我問了你一個問題:“怎樣讓伺服器一次處理多個請求?”讀完本文,你就能夠完美地回答這個問題。接下來,請你做好準備,因為本文的內容非常多,節奏也很快。文中的所有程式碼都可以在Github倉庫下載。
首先,我們簡單回憶一下簡易網路伺服器是如何實現的,伺服器要處理客戶端的請求需要哪些條件。你在前面兩部分文章中開發的伺服器,是一個迭代式伺服器(iterative server),還只能一次處理一個客戶端請求。只有在處理完當前客戶端請求之後,它才能接收新的客戶端連線。這樣,有些客戶端就必須要等待自己的請求被處理了,而對於流量大的伺服器來說,等待的時間就會特別長。
下面是迭代式伺服器webserver3a.py的程式碼:
##################################################################### # Iterative server - webserver3a.py # # # # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X # ##################################################################### import socket SERVER_ADDRESS = (HOST, PORT) = '', 8888 REQUEST_QUEUE_SIZE = 5 def handle_request(client_connection): request = client_connection.recv(1024) print(request.decode()) http_response = b"""/ HTTP/1.1 200 OK Hello, World! """ client_connection.sendall(http_response) def serve_forever(): listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind(SERVER_ADDRESS) listen_socket.listen(REQUEST_QUEUE_SIZE) print('Serving HTTP on port {port} ...'.format(port=PORT)) while True: client_connection, client_address = listen_socket.accept() handle_request(client_connection) client_connection.close() if __name__ == '__main__': serve_forever()
如果想確認這個伺服器每次只能處理一個客戶端的請求,我們對上述程式碼作簡單修改,在向客戶端返回響應之後,增加60秒的延遲處理時間。這個修改只有一行程式碼,即告訴伺服器在返回響應之後睡眠60秒。
下面就是修改之後的伺服器程式碼:
######################################################################### # Iterative server - webserver3b.py # # # # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X # # # # - Server sleeps for 60 seconds after sending a response to a client # ######################################################################### import socket import time SERVER_ADDRESS = (HOST, PORT) = '', 8888 REQUEST_QUEUE_SIZE = 5 def handle_request(client_connection): request = client_connection.recv(1024) print(request.decode()) http_response = b"""/ HTTP/1.1 200 OK Hello, World! """ client_connection.sendall(http_response) time.sleep(60) # sleep and block the process for 60 seconds def serve_forever(): listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind(SERVER_ADDRESS) listen_socket.listen(REQUEST_QUEUE_SIZE) print('Serving HTTP on port {port} ...'.format(port=PORT)) while True: client_connection, client_address = listen_socket.accept() handle_request(client_connection) client_connection.close() if __name__ == '__main__': serve_forever()
接下來,我們啟動伺服器:
$ python webserver3b.py
現在,我們開啟一個新的終端視窗,並執行curl命令。你會立刻看到螢幕上列印出了“Hello, World!”這句話:
$ curl http://localhost:8888/hello Hello, World!
接著我們立刻再開啟一個終端視窗,並執行curl命令:
$ curl http://localhost:8888/hello
如果你在60秒了完成了上面的操作,那麼第二個curl命令應該不會立刻產生任何輸出結果,而是處於掛死(hang)狀態。伺服器也不會在標準輸出中列印這個新請求的正文。下面這張圖就是我在自己的Mac上操作時的結果(右下角那個邊緣高亮為黃色的視窗,顯示的就是第二個curl命令掛死):
當然,你等了足夠長時間之後(超過60秒),你會看到第一個curl命令結束,然後第二個curl命令會在螢幕上列印出“Hello, World!”,之後再掛死60秒,最後才結束:
這背後的實現方式是,伺服器處理完第一個curl客戶端請求後睡眠60秒,才開始處理第二個請求。這些步驟是線性執行的,或者說迭代式一步一步執行的。在我們這個例項中,則是一次一個請求這樣處理。
接下來,我們簡單談談客戶端與伺服器之間的通訊。為了讓兩個程式通過網路進行通訊,二者均必須使用套接字。你在前兩章中也看到過套接字,但到底什麼是套接字?
套接字是通訊端點(communication endpoint)的抽象形式,可以讓一個程式通過檔案描述符(file descriptor)與另一個程式進行通訊。在本文中,我只討論Linux/Mac OS X平臺上的TCP/IP套接字。其中,尤為重要的一個概念就是TCP套接字對(socket pair)。
TCP連線所使用的套接字對是一個4元組(4-tuple),包括本地IP地址、本地埠、外部IP地址和外部埠。一個網路中的每一個TCP連線,都擁有獨特的套接字對。IP地址和埠號通常被稱為一個套接字,二者一起標識了一個網路端點。
因此,{10.10.10.2:49152, 12.12.12.3:8888}元組組成了一個套接字對,代表客戶端側TCP連線的兩個唯一端點,{12.12.12.3:8888, 10.10.10.2:49152}元組組成另一個套接字對,代表伺服器側TCP連線的兩個同樣端點。構成TCP連線中伺服器端點的兩個值分別是IP地址12.12.12.3和埠號8888,它們在這裡被稱為一個套接字(同理,客戶端端點的兩個值也是一個套接字)。
伺服器建立套接字並開始接受客戶端連線的標準流程如下:
- 伺服器建立一個TCP/IP套接字。通過下面的Python語句實現:
listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
- 伺服器可以設定部分套接字選項(這是可選項,但你會發現上面那行伺服器程式碼就可以確保你重啟伺服器之後,伺服器會繼續使用相同的地址)。
listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
- 然後,伺服器繫結地址。繫結函式為套接字指定一個本地協議地址。呼叫繫結函式時,你可以單獨指定埠號或IP地址,也可以同時指定兩個引數,甚至不提供任何引數也沒問題。
listen_socket.bind(SERVER_ADDRESS)
- 接著,伺服器將該套接字變成一個偵聽套接字:
listen_socket.listen(REQUEST_QUEUE_SIZE)
listen方法只能由伺服器呼叫,執行後會告知伺服器應該接收針對該套接字的連線請求。
完成上面四步之後,伺服器會開啟一個迴圈,開始接收客戶端連線,不過一次只接收一個連線。當有連線請求時,accept方法會返回已連線的客戶端套接字。然後,伺服器從客戶端套接字讀取請求資料,在標準輸出中列印資料,並向客戶端返回訊息。最後,伺服器會關閉當前的客戶端連線,這時伺服器又可以接收新的客戶端連線了。
要通過TCP/IP協議與伺服器進行通訊,客戶端需要作如下操作:
下面這段示例程式碼,實現了客戶端連線至伺服器,傳送請求,並列印響應內容的過程:
import socket # create a socket and connect to a server sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(('localhost', 8888)) # send and receive some data sock.sendall(b'test') data = sock.recv(1024) print(data.decode())
在建立套接字之後,客戶端需要與伺服器進行連線,這可以通過呼叫connect方法實現:
sock.connect(('localhost', 8888))
客戶端只需要提供遠端IP地址或主機名,以及伺服器的遠端連線埠號即可。
你可能已經注意到,客戶端不會呼叫bind和accept方法。不需要呼叫bind方法,是因為客戶端不關心本地IP地址和本地埠號。客戶端呼叫connect方法時,系統核心中的TCP/IP棧會自動指定本地IP地址和本地埠。本地埠也被稱為臨時埠(ephemeral port)。
伺服器端有部分埠用於連線熟知的服務,這種埠被叫做“熟知埠”(well-known port),例如,80用於HTTP傳輸服務,22用於SSH協議傳輸。接下來,我們開啟Python shell,向在本地執行的伺服器發起一個客戶端連線,然後檢視系統核心為你建立的客戶端套接字指定了哪個臨時埠(在進行下面的操作之前,請先執行webserver3a.py或webserver3b.py檔案,啟動伺服器):
>>> import socket >>> sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) >>> sock.connect(('localhost', 8888)) >>> host, port = sock.getsockname()[:2] >>> host, port ('127.0.0.1', 60589)
在上面的示例中,我們看到核心為套接字指定的臨時埠是60589。
在開始回答第二部分最後提的問題之前,我需要快速介紹一些其他的重要概念。稍後你就會明白我為什麼要這樣做。我要介紹的重要概念就是程式(process)和檔案描述符(file descriptor)。
什麼是程式?程式就是正在執行的程式的一個例項。舉個例子,當伺服器程式碼執行的時候,這些程式碼就被載入至記憶體中,而這個正在被執行的伺服器的例項就叫做程式。系統核心會記錄下有關程式的資訊——包括程式ID,以便進行管理。所以,當你執行迭代式伺服器webserver3a.py或webserver3b.py時,你也就開啟了一個程式。
我們在終端啟動webserver3a.py伺服器:
$ python webserver3b.py
然後,我們在另一個終端視窗中,使用ps命令來獲取上面那個伺服器程式的資訊:
$ ps | grep webserver3b | grep -v grep 7182 ttys003 0:00.04 python webserver3b.py
從ps命令的結果,我們可以看出你的確只執行了一個Python程式webserver3b。程式建立的時候,核心會給它指定一個程式ID——PID。在UNIX系統下,每個使用者程式都會有一個父程式(parent process),而這個父程式也有自己的程式ID,叫做父程式ID,簡稱PPID。在本文中,我預設大家使用的是BASH,因此當你啟動伺服器的時候,系統會建立伺服器程式,指定一個PID,而伺服器程式的父程式PID則是BASH shell程式的PID。
接下來請自己嘗試操作一下。再次開啟你的Python shell程式,這會建立一個新程式,然後我們通過os.gepid()和os.getppid()這兩個方法,分別獲得Python shell程式的PID及它的父程式PID(即BASH shell程式的PID)。接著,我們開啟另一個終端視窗,執行ps命令,grep檢索剛才所得到的PPID(父程式ID,本操作時的結果是3148)。在下面的截圖中,你可以看到我在Mac OS X上的操作結果:
另一個需要掌握的重要概念就是檔案描述符(file descriptor)。那麼,到底什麼是檔案描述符?檔案描述符指的就是當系統開啟一個現有檔案、建立一個新檔案或是建立一個新的套接字之後,返回給程式的那個正整型數。系統核心通過檔案描述符來追蹤一個程式所開啟的檔案。當你需要讀寫檔案時,你也通過檔案描述符說明。Python語言中提供了用於處理檔案(和套接字)的高層級物件,所以你不必直接使用檔案描述符來指定檔案,但是從底層實現來看,UNIX系統中就是通過它們的檔案描述符來確定檔案和套接字的。
一般來說,UNIX shell會將檔案描述符0指定給程式的標準輸出,檔案描述富1指定給程式的標準輸出,檔案描述符2指定給標準錯誤。
正如我前面提到的那樣,即使Python語言提供了高層及的檔案或類檔案物件,你仍然可以對檔案物件使用fileno()方法,來獲取該檔案相應的檔案描述符。我們回到Python shell中來試驗一下。
>>> import sys >>> sys.stdin <open file '<stdin>', mode 'r' at 0x102beb0c0> >>> sys.stdin.fileno() 0 >>> sys.stdout.fileno() 1 >>> sys.stderr.fileno() 2
在Python語言中處理檔案和套接字時,你通常只需要使用高層及的檔案/套接字物件即可,但是有些時候你也可能需要直接使用檔案描述符。下面這個示例演示了你如何通過write()方法向標準輸出中寫入一個字串,而這個write方法就接受檔案描述符作為自己的引數:
>>> import sys >>> import os >>> res = os.write(sys.stdout.fileno(), 'hello/n') hello
還有一點挺有意思——如果你知道Unix系統下一切都是檔案,那麼你就不會覺得奇怪了。當你在Python中建立一個套接字後,你獲得的是一個套接字物件,而不是一個正整型數,但是你還是可以和上面演示的一樣,通過fileno()方法直接訪問這個套接字的檔案描述符。
>>> import socket >>> sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) >>> sock.fileno() 3
我還想再說一點:不知道大家有沒有注意到,在迭代式伺服器webserver3b.py的第二個示例中,我們的伺服器在處理完請求後睡眠60秒,但是在睡眠期間,我們仍然可以通過curl命令與伺服器建立連線?當然,curl命令並沒有立刻輸出結果,只是出於掛死狀態,但是為什麼伺服器既然沒有接受新的連線,客戶端也沒有立刻被拒絕,而是仍然繼續連線至伺服器呢?這個問題的答案在於套接字物件的listen方法,以及它使用的BACKLOG引數。在示例程式碼中,這個引數的值被我設定為REQUEST_QUEQUE_SIZE。BACKLOG引數決定了核心中外部連線請求的佇列大小。當webserver3b.py伺服器睡眠時,你執行的第二個curl命令之所以能夠連線伺服器,是因為連線請求佇列仍有足夠的位置。
雖然提高BACKLOG引數的值並不會讓你的伺服器一次處理多個客戶端請求,但是業務繁忙的伺服器也應該設定一個較大的BACKLOG引數值,這樣accept函式就可以直接從佇列中獲取新連線,立刻開始處理客戶端請求,而不是還要花時間等待連線建立。
嗚呼!到目前為止,已經給大家介紹了很多知識。我們現在快速回顧一下之前的內容。
- 迭代式伺服器
- 伺服器套接字建立流程(socket, bind, listen, accept)
- 客戶端套接字建立流程(socket, connect)
- 套接字對(Socket pair)
- 套接字
- 臨時埠(Ephemeral port)與熟知埠(well-known port)
- 程式
- 程式ID(PID),父程式ID(PPID)以及父子關係
- 檔案描述符(File descriptors)
- 套接字物件的listen方法中BACKLOG引數的意義
現在,我可以開始回答第二部分留下的問題了:如何讓伺服器一次處理多個請求?換句話說,如何開發一個併發伺服器?
在Unix系統中開發一個併發伺服器的最簡單方法,就是呼叫系統函式fork()。
下面就是嶄新的webserver3c.py併發伺服器,能夠同時處理多個客戶端請求:
########################################################################### # Concurrent server - webserver3c.py # # # # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X # # # # - Child process sleeps for 60 seconds after handling a client's request # # - Parent and child processes close duplicate descriptors # # # ########################################################################### import os import socket import time SERVER_ADDRESS = (HOST, PORT) = '', 8888 REQUEST_QUEUE_SIZE = 5 def handle_request(client_connection): request = client_connection.recv(1024) print( 'Child PID: {pid}. Parent PID {ppid}'.format( pid=os.getpid(), ppid=os.getppid(), ) ) print(request.decode()) http_response = b"""/ HTTP/1.1 200 OK Hello, World! """ client_connection.sendall(http_response) time.sleep(60) def serve_forever(): listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind(SERVER_ADDRESS) listen_socket.listen(REQUEST_QUEUE_SIZE) print('Serving HTTP on port {port} ...'.format(port=PORT)) print('Parent PID (PPID): {pid}/n'.format(pid=os.getpid())) while True: client_connection, client_address = listen_socket.accept() pid = os.fork() if pid == 0: # child listen_socket.close() # close child copy handle_request(client_connection) client_connection.close() os._exit(0) # child exits here else: # parent client_connection.close() # close parent copy and loop over if __name__ == '__main__': serve_forever()
在討論fork的工作原理之前,請測試一下上面的程式碼,親自確認一下伺服器是否能夠同時處理多個客戶端請求。我們通過命令列啟動上面這個伺服器:
$ python webserver3c.py
然後輸入之前迭代式伺服器示例中的兩個curl命令。現在,即使伺服器子程式在處理完一個客戶端請求之後會睡眠60秒,但是並不會影響其他客戶端,因為它們由不同的、完全獨立的程式處理。你應該可以立刻看見curl命令輸出“Hello, World”,然後掛死60秒。你可以繼續執行更多的curl命令,所有的命令都會輸出伺服器的響應結果——“Hello, World”,不會有任何延遲。你可以試試。
關於fork()函式有一點最為重要,就是你呼叫fork一次,但是函式卻會返回兩次:一次是在父程式裡返回,另一次是在子程式中返回。當你fork一個程式時,返回給子程式的PID是0,而fork返回給父程式的則是子程式的PID。
我還記得,第一次接觸並使用fork函式時,自己感到非常不可思議。我覺得這就好像一個魔法。之前還是一個線性的程式碼,突然一下子克隆了自己,出現了並行執行的相同程式碼的兩個例項。我當時真的覺得這和魔法也差不多了。
當父程式fork一個新的子程式時,子程式會得到父程式檔案描述符的副本:
你可能也注意到了,上面程式碼中的父程式關閉了客戶端連線:
else: # parent client_connection.close() # close parent copy and loop over
那為什麼父程式關閉了套接字之後,子程式卻仍然能夠從客戶端套接字中讀取資料呢?答案就在上面的圖片裡。系統核心根據檔案描述符計數(descriptor reference counts)來決定是否關閉套接字。系統只有在描述符計數變為0時,才會關閉套接字。當你的伺服器建立一個子程式時,子程式就會獲得父程式檔案描述符的副本,系統核心則會增加這些檔案描述符的計數。在一個父程式和一個子程式的情況下,客戶端套接字的檔案描述符計數為2。當上面程式碼中的父程式關閉客戶端連線套接字時,只是讓套接字的計數減為1,還不夠讓系統關閉套接字。子程式同樣關閉了父程式偵聽套接字的副本,因為子程式不關心要不要接收新的客戶端連線,只關心如何處理連線成功的客戶端所發出的請求。
listen_socket.close() # close child copy
稍後,我會給大家介紹如果不關閉重複的描述符的後果。
從上面並行伺服器的原始碼可以看出,伺服器父程式現在唯一的作用,就是接受客戶端連線,fork一個新的子程式來處理該客戶端連線,然後回到迴圈的起點,準備接受其他的客戶端連線,僅此而已。伺服器父程式並不會處理客戶端請求,而是由它的子程式來處理。
談得稍遠一點。我們說兩個事件是並行時,到底是什麼意思?
我們說兩個事件是並行的,通常指的是二者同時發生。這是簡單的定義,但是你應該牢記它的嚴格定義:
如果你不能分辨出哪個程式會先執行,那麼二者就是並行的。
現在又到了回顧目前已經介紹的主要觀點和概念。
- Unix系統中開發並行伺服器最簡單的方法,就是呼叫fork()函式
- 當一個程式fork新程式時,它就成了新建立程式的父程式
- 在呼叫fork之後,父程式和子程式共用相同的檔案描述符
- 系統核心通過描述符計數來決定是否關閉檔案/套接字
- 伺服器父程式的角色:它現在所做的只是接收來自客戶端的新連線,fork一個子程式來處理該客戶端的請求,然後回到迴圈的起點,準備接受新的客戶端連線
接下來,我們看看如果不關閉父程式和子程式中的重複套接字描述符,會發生什麼情況。下面的並行伺服器(webserver3d.py)作了一些修改,確保伺服器不關閉重複的:
########################################################################### # Concurrent server - webserver3d.py # # # # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X # ########################################################################### import os import socket SERVER_ADDRESS = (HOST, PORT) = '', 8888 REQUEST_QUEUE_SIZE = 5 def handle_request(client_connection): request = client_connection.recv(1024) http_response = b"""/ HTTP/1.1 200 OK Hello, World! """ client_connection.sendall(http_response) def serve_forever(): listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind(SERVER_ADDRESS) listen_socket.listen(REQUEST_QUEUE_SIZE) print('Serving HTTP on port {port} ...'.format(port=PORT)) clients = [] while True: client_connection, client_address = listen_socket.accept() # store the reference otherwise it's garbage collected # on the next loop run clients.append(client_connection) pid = os.fork() if pid == 0: # child listen_socket.close() # close child copy handle_request(client_connection) client_connection.close() os._exit(0) # child exits here else: # parent # client_connection.close() print(len(clients)) if __name__ == '__main__': serve_forever()
啟動伺服器:
$ python webserver3d.py
然後通過curl命令連線至伺服器:
$ curl http://localhost:8888/hello Hello, World!
我們看到,curl命令列印了並行伺服器的響應內容,但是並沒有結束,而是繼續掛死。伺服器出現了什麼不同情況嗎?伺服器不再繼續睡眠60秒:它的子程式會積極處理客戶端請求,處理完成後就關閉客戶端連線,然後結束執行,但是客戶端的curl命令卻不會終止。
那麼為什麼curl命令會沒有結束執行呢?原因在於重複的檔案描述符(duplicate file descriptor)。當子程式關閉客戶端連線時,系統核心會減少客戶端套接字的計數,變成了1。伺服器子程式結束了,但是客戶端套接字並沒有關閉,因為那個套接字的描述符計數並沒有變成0,導致系統沒有向客戶端傳送終止包(termination packet)(用TCP/IP的術語來說叫做FIN),也就是說客戶端仍然線上。但是還有另一個問題。如果你一直執行的伺服器不去關閉重複的檔案描述符,伺服器最終就會耗光可用的檔案伺服器:
按下Control-C,關閉webserver3d.py伺服器,然後通過shell自帶的ulimit命令檢視伺服器程式可以使用的預設資源:
$ ulimit -a core file size (blocks, -c) 0 data seg size (kbytes, -d) unlimited scheduling priority (-e) 0 file size (blocks, -f) unlimited pending signals (-i) 3842 max locked memory (kbytes, -l) 64 max memory size (kbytes, -m) unlimited open files (-n) 1024 pipe size (512 bytes, -p) 8 POSIX message queues (bytes, -q) 819200 real-time priority (-r) 0 stack size (kbytes, -s) 8192 cpu time (seconds, -t) unlimited max user processes (-u) 3842 virtual memory (kbytes, -v) unlimited file locks (-x) unlimited
從上面的結果中,我們可以看到:在我這臺Ubuntu電腦上,伺服器程式可以使用的檔案描述符(開啟的檔案)最大數量為1024。
現在,我們來看看如果伺服器不關閉重複的檔案描述符,伺服器會不會耗盡可用的檔案描述符。我們在現有的或新開的終端視窗裡,將伺服器可以使用的最大檔案描述符數量設定為256:
$ ulimit -n 256
在剛剛執行了$ ulimit -n 256命令的終端裡,我們開啟webserver3d.py伺服器:
$ python webserver3d.py
然後通過下面的client3.py客戶端來測試伺服器。
##################################################################### # Test client - client3.py # # # # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X # ##################################################################### import argparse import errno import os import socket SERVER_ADDRESS = 'localhost', 8888 REQUEST = b"""/ GET /hello HTTP/1.1 Host: localhost:8888 """ def main(max_clients, max_conns): socks = [] for client_num in range(max_clients): pid = os.fork() if pid == 0: for connection_num in range(max_conns): sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.connect(SERVER_ADDRESS) sock.sendall(REQUEST) socks.append(sock) print(connection_num) os._exit(0) if __name__ == '__main__': parser = argparse.ArgumentParser( description='Test client for LSBAWS.', formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) parser.add_argument( '--max-conns', type=int, default=1024, help='Maximum number of connections per client.' ) parser.add_argument( '--max-clients', type=int, default=1, help='Maximum number of clients.' ) args = parser.parse_args() main(args.max_clients, args.max_conns)
開啟一個新終端視窗,執行client3.py,並讓客戶端建立300個與伺服器的並行連線:
$ python client3.py --max-clients=300
很快你的伺服器就會崩潰。下面是我的虛擬機器上丟擲的異常情況:
問題很明顯——伺服器應該關閉重複的描述符。但即使你關閉了這些重複的描述符,你還沒有徹底解決問題,因為你的伺服器還存在另一個問題,那就是殭屍程式!
沒錯,你的伺服器程式碼確實會產生殭屍程式。我們來看看這是怎麼回事。再次執行伺服器:
$ python webserver3d.py
在另一個終端視窗中執行下面的curl命令:
$ curl http://localhost:8888/hello
現在,我們執行ps命令,看看都有哪些正在執行的Python程式。下面是我的Ubuntu虛擬機器中的結果:
$ ps auxw | grep -i python | grep -v grep vagrant 9099 0.0 1.2 31804 6256 pts/0 S+ 16:33 0:00 python webserver3d.py vagrant 9102 0.0 0.0 0 0 pts/0 Z+ 16:33 0:00 [python] <defunct>
我們發現,第二行中顯示的這個程式的PID為9102,狀態是Z+,而程式的名稱叫做<defunct>。這就是我們要找的殭屍程式。殭屍程式的問題在於你無法殺死它們。
即使你試圖通過$ kill -9命令殺死殭屍程式,它們還是會存活下來。你可以試試看。
到底什麼是殭屍程式,伺服器又為什麼會建立這些程式?殭屍程式其實是已經結束了的程式,但是它的父程式並沒有等待程式結束,所以沒有接收到程式結束的狀態資訊。當子程式在父程式之前退出,系統就會將子程式變成一個殭屍程式,保留原子程式的部分資訊,方便父程式之後獲取。系統所保留的資訊通常包括程式ID、程式結束狀態和程式的資源使用情況。好吧,這樣說殭屍程式也有自己存在的理由,但是如果伺服器不處理好這些殭屍程式,系統就會堵塞。我們來看看是否如此。首先,停止正在執行的伺服器,然後在新終端視窗中,使用ulimit命令將最大使用者程式設定為400(還要確保將開啟檔案數量限制設定到一個較高的值,這裡我們設定為500)。
$ ulimit -u 400 $ ulimit -n 500
然後在同一個視窗中啟動webserver3d.py伺服器:
$ python webserver3d.py
在新終端視窗中,啟動客戶端client3.py,讓客戶端建立500個伺服器並行連線:
$ python client3.py --max-clients=500
結果,我們發現很快伺服器就因為OSError而崩潰:這個異常指的是暫時沒有足夠的資源。伺服器試圖建立新的子程式時,由於已經達到了系統所允許的最大可建立子程式數,所以丟擲這個異常。下面是我的虛擬機器上的報錯截圖。
你也看到了,如果長期執行的伺服器不處理好殭屍程式,將會出現重大問題。稍後我會介紹如何處理殭屍程式。
我們先回顧一下目前已經學習的知識點:
- 如果你不關閉重複的檔案描述符,由於客戶端連線沒有中斷,客戶端程式就不會結束。
- 如果你不關閉重複的檔案描述符,你的伺服器最終會消耗完可用的檔案描述符(最大開啟檔案數)
- 當你fork一個子程式後,如果子程式在父程式之前退出,而父程式又沒有等待程式,並獲取它的結束狀態,那麼子程式就會變成殭屍程式。
- 殭屍程式也需要消耗資源,也就是記憶體。如果不處理好殭屍程式,你的伺服器最終會消耗完可用的程式數(最大使用者程式數)。
- 你無法殺死殭屍程式,你需要等待子程式結束。
那麼,你要怎麼做才能處理掉殭屍程式呢?你需要修改伺服器程式碼,等待殭屍程式返回其結束狀態(termination status)。要實現這點,你只需要在程式碼中呼叫wait系統函式即可。不過,這種方法並不是最理想的方案,因為如果你呼叫wait後,卻沒有結束了的子程式,那麼wait呼叫將會阻塞伺服器,相當於阻止了伺服器處理新的客戶端請求。那麼還有其他的辦法嗎?答案是肯定的,其中一種辦法就是將wait函式呼叫與訊號處理函式(signal handler)結合使用。
這種方法的具體原理如下。當子程式退出時,系統核心會傳送一個SIGCHLD訊號。父程式可以設定一個訊號處理函式,用於非同步監測SIGCHLD事件,然後再呼叫wait,等待子程式結束並獲取其結束狀態,這樣就可以避免產生殭屍程式。
順便說明一下,非同步事件意味著父程式實現並不知道該事件是否會發生。
接下來我們修改伺服器程式碼,新增一個SIGCHLD事件處理函式,並在該函式中等待子程式結束。具體的程式碼見webserver3e.py檔案:
########################################################################### # Concurrent server - webserver3e.py # # # # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X # ########################################################################### import os import signal import socket import time SERVER_ADDRESS = (HOST, PORT) = '', 8888 REQUEST_QUEUE_SIZE = 5 def grim_reaper(signum, frame): pid, status = os.wait() print( 'Child {pid} terminated with status {status}' '/n'.format(pid=pid, status=status) ) def handle_request(client_connection): request = client_connection.recv(1024) print(request.decode()) http_response = b"""/ HTTP/1.1 200 OK Hello, World! """ client_connection.sendall(http_response) # sleep to allow the parent to loop over to 'accept' and block there time.sleep(3) def serve_forever(): listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind(SERVER_ADDRESS) listen_socket.listen(REQUEST_QUEUE_SIZE) print('Serving HTTP on port {port} ...'.format(port=PORT)) signal.signal(signal.SIGCHLD, grim_reaper) while True: client_connection, client_address = listen_socket.accept() pid = os.fork() if pid == 0: # child listen_socket.close() # close child copy handle_request(client_connection) client_connection.close() os._exit(0) else: # parent client_connection.close() if __name__ == '__main__': serve_forever()
啟動伺服器:
$ python webserver3e.py
再次使用curl命令,向修改後的併發伺服器傳送一個請求:
$ curl http://localhost:8888/hello
我們來看伺服器的反應:
發生了什麼事?accept函式呼叫報錯了。
子程式退出時,父程式被阻塞在accept函式呼叫的地方,但是子程式的退出導致了SIGCHLD事件,這也啟用了訊號處理函式。訊號函式執行完畢之後,就導致了accept系統函式呼叫被中斷:
別擔心,這是個非常容易解決的問題。你只需要重新呼叫accept即可。下面我們再修改一下伺服器程式碼(webserver3f.py),就可以解決這個問題:
########################################################################### # Concurrent server - webserver3f.py # # # # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X # ########################################################################### import errno import os import signal import socket SERVER_ADDRESS = (HOST, PORT) = '', 8888 REQUEST_QUEUE_SIZE = 1024 def grim_reaper(signum, frame): pid, status = os.wait() def handle_request(client_connection): request = client_connection.recv(1024) print(request.decode()) http_response = b"""/ HTTP/1.1 200 OK Hello, World! """ client_connection.sendall(http_response) def serve_forever(): listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind(SERVER_ADDRESS) listen_socket.listen(REQUEST_QUEUE_SIZE) print('Serving HTTP on port {port} ...'.format(port=PORT)) signal.signal(signal.SIGCHLD, grim_reaper) while True: try: client_connection, client_address = listen_socket.accept() except IOError as e: code, msg = e.args # restart 'accept' if it was interrupted if code == errno.EINTR: continue else: raise pid = os.fork() if pid == 0: # child listen_socket.close() # close child copy handle_request(client_connection) client_connection.close() os._exit(0) else: # parent client_connection.close() # close parent copy and loop over if __name__ == '__main__': serve_forever()
啟動修改後的伺服器:
$ python webserver3f.py
通過curl命令向伺服器傳送一個請求:
$ curl http://localhost:8888/hello
看到了嗎?沒有再報錯了。現在,我們來確認下伺服器沒有再產生殭屍程式。只需要執行ps命令,你就會發現沒有Python程式的狀態是Z+了。太棒了!沒有殭屍程式搗亂真是太好了。
- 如果你fork一個子程式,卻不等待程式結束,該程式就會變成殭屍程式。
- 使用SIGCHLD時間處理函式來非同步等待程式結束,獲取其結束狀態。
- 使用事件處理函式時,你需要牢記系統函式呼叫可能會被中斷,要做好這類情況發生得準備。
好了,目前一切正常。沒有其他問題了,對嗎?呃,基本上是了。再次執行webserver3f.py,然後通過client3.py建立128個並行連線:
$ python client3.py --max-clients 128
現在再次執行ps命令:
$ ps auxw | grep -i python | grep -v grep
噢,糟糕!殭屍程式又出現了!
這次又是哪裡出了問題?當你執行128個並行客戶端,建立128個連線時,伺服器的子程式處理完請求,幾乎是同一時間退出的,這就觸發了一大波的SIGCHLD訊號傳送至父程式。但問題是這些訊號並沒有進入佇列,所以有幾個訊號漏網,沒有被伺服器處理,這就導致出現了幾個殭屍程式。
這個問題的解決方法,就是在SIGCHLD事件處理函式使用waitpid,而不是wait,再呼叫waitpid時增加WNOHANG選項,確保所有退出的子程式都會被處理。下面就是修改後的程式碼,webserver3g.py:
########################################################################### # Concurrent server - webserver3g.py # # # # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X # ########################################################################### import errno import os import signal import socket SERVER_ADDRESS = (HOST, PORT) = '', 8888 REQUEST_QUEUE_SIZE = 1024 def grim_reaper(signum, frame): while True: try: pid, status = os.waitpid( -1, # Wait for any child process os.WNOHANG # Do not block and return EWOULDBLOCK error ) except OSError: return if pid == 0: # no more zombies return def handle_request(client_connection): request = client_connection.recv(1024) print(request.decode()) http_response = b"""/ HTTP/1.1 200 OK Hello, World! """ client_connection.sendall(http_response) def serve_forever(): listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) listen_socket.bind(SERVER_ADDRESS) listen_socket.listen(REQUEST_QUEUE_SIZE) print('Serving HTTP on port {port} ...'.format(port=PORT)) signal.signal(signal.SIGCHLD, grim_reaper) while True: try: client_connection, client_address = listen_socket.accept() except IOError as e: code, msg = e.args # restart 'accept' if it was interrupted if code == errno.EINTR: continue else: raise pid = os.fork() if pid == 0: # child listen_socket.close() # close child copy handle_request(client_connection) client_connection.close() os._exit(0) else: # parent client_connection.close() # close parent copy and loop over if __name__ == '__main__': serve_forever()
啟動伺服器:
$ python webserver3g.py
使用客戶端client3.py進行測試:
$ python client3.py --max-clients 128
現在請確認不會再出現殭屍程式了。
恭喜大家!現在已經自己開發了一個簡易的併發伺服器,這個程式碼可以作為你以後開發生產級別的網路伺服器的基礎。
最後給大家留一個練習題,把第二部分中的WSGI修改為併發伺服器。最終的程式碼可以在這裡檢視。不過請你在自己實現了之後再檢視。
接下來該怎麼辦?借用喬希·比林斯(19世紀著名幽默大師)的一句話:
要像一張郵票,堅持一件事情直到你到達目的地。
相關文章
- 自己動手開發一個 Web 伺服器(一)Web伺服器
- 自己動手開發一個 Web 伺服器(二)Web伺服器
- 自己動手開發網路伺服器(一)伺服器
- 自己寫一個Web伺服器(1)Web伺服器
- 自己寫一個Web伺服器(2)Web伺服器
- 自己寫一個Web伺服器(3)Web伺服器
- 如何自己開發一個腳手架工具
- 自己動手開發一個Android持續整合工具-簡介Android
- 自己動手寫一個 SimpleVueVue
- SAP成都研究院安德魯:自己動手開發一個Chrome ExtensionChrome
- 自己動手開發一個Android持續整合工具-關於TaskAndroid
- 自己動手開發一個Android持續整合工具-準備工作Android
- C#中自己動手建立一個Web Server(非Socket實現)C#WebServer
- RxJava:自己動手擼一個RxBinding(一)。RxJava
- 手動開發一個日曆元件元件
- 自己動手實現一個前端路由前端路由
- 自己動手寫一個持久層框架框架
- 自己動手搞一個tip 外掛
- 自己動手實現一個EventBus框架框架
- 自己動手實現一個Unix Shell
- 自己動手實現一個阻塞佇列佇列
- RxJava:自己動手擼一個RxBinding(二)。RxJava
- 手寫一個最迷你的Web伺服器Web伺服器
- 開發一個自己的 CSS 框架(一)CSS框架
- 如何開發一個自己的 RubyGem?
- 搭個 Web 伺服器(三)Web伺服器
- 自己動手作PPPOE伺服器(二)伺服器
- 自己動手寫Web自動化測試框架Web框架
- 使用 Nginx 自己實現一個 Web 除錯代理伺服器NginxWeb除錯伺服器
- 自己動手實現一個簡單的 IOC
- 自己動手寫一個Bug管理工具
- 自己動手寫一個簡單的MVC框架MVC框架
- 三分鐘搭建一個自己的 ChatGPT (從開發到上線)ChatGPT
- 測試同學動手搭個簡易web開發專案Web
- 動手開發第一個 Cypress 測試應用
- 自己動手擼一個cron表示式解析器
- 自己動手實現一個 Java Class 解析器Java
- 自己動手做一個迷你 Linux 系統(轉)Linux