“發明創造時,我們學得最多” —— Piaget
在本系列第二部分,你已經創造了一個可以處理基本的 HTTP GET 請求的 WSGI 伺服器。我還問了你一個問題,“怎麼讓伺服器在同一時間處理多個請求?”在本文中你將找到答案。那麼,繫好安全帶加大馬力。你馬上就乘上快車啦。準備好Linux、Mac OS X(或任何類unix系統)和 Python。本文的所有原始碼都能在GitHub上找到。
首先我們們回憶下一個基本的Web伺服器長什麼樣,要處理客戶端請求它得做什麼。你在第一部分和第二部分建立的是一個迭代的伺服器,每次處理一個客戶端請求。除非已經處理了當前的客戶端請求,否則它不能接受新的連線。有些客戶端對此就不開心了,因為它們必須要排隊等待,而且如果伺服器繁忙的話,這個隊伍會很長。
以下是迭代伺服器webserver3a.py的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
##################################################################### # 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秒。
以下是睡眠版的伺服器webserver3b.py程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
######################################################################### # 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() |
啟動伺服器:
1 |
$ python webserver3b.py |
現在開啟一個新的控制檯視窗,執行以下curl命令。你應該立即就會看到螢幕上列印出了“Hello, World!”字串:
1 2 3 4 |
$ curl http://localhost:8888/hello Hello, World! And without delay open up a second terminal window and run the same curl command: |
立刻再開啟一個控制檯視窗,然後執行相同的curl命令:
1 |
$ curl http://localhost:8888/hello |
如果你是在60秒內做的,那麼第二個curl應該不會立刻產生任何輸出,而是掛起。而且伺服器也不會在標準輸出列印出新請求體。在我的Mac上看起來像這樣(在右下角的黃色高亮視窗表示第二個curl命令正掛起,等待伺服器接受這個連線):
當你等待足夠長時間(大於60秒)後,你會看到第一個curl終止了,第二個curl在螢幕上列印出“Hello, World!”,然後掛起60秒,然後再終止:
它是這麼工作的,伺服器完成處理第一個curl客戶端請求,然後睡眠60秒後開始處理第二個請求。這些都是順序地,或者迭代地,一步一步地,或者,在我們例子中是一次一個客戶端請求地,發生。
我們們討論點客戶端和伺服器的通訊吧。為了讓兩個程式能夠網路通訊,它們必須使用socket。你在第一部分和第二部分已經見過socket了,但是,socket是什麼呢?
socket就是通訊終端的一種抽象,它允許你的程式使用檔案描述符和別的程式通訊。本文我將詳細談談在Linux/Mac OS X上的TCP/IP socket。理解socket的一個重要的概念是TCP socket對。
TCP的socket對是一個4元組,標識著TCP連線的兩個終端:本地IP地址、本地埠、遠端IP地址、遠端埠。一個socket對唯一地標識著網路上的TCP連線。標識著每個終端的兩個值,IP地址和埠號,通常被稱為socket。
所以,元組{10.10.10.2:49152, 12.12.12.3:8888}是客戶端TCP連線的唯一標識著兩個終端的socket對。元組{12.12.12.3:8888, 10.10.10.2:49152}是伺服器TCP連線的唯一標識著兩個終端的socket對。標識TCP連線中伺服器終端的兩個值,IP地址12.12.12.3和埠8888,在這裡就是指socket(同樣適用於客戶端終端)。
伺服器建立一個socket並開始接受客戶端連線的標準流程經歷通常如下:
- 伺服器建立一個TCP/IP socket。在Python裡使用下面的語句即可:
1listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - 伺服器可能會設定一些socket選項(這是可選的,上面的程式碼就設定了,為了在殺死或重啟伺服器後,立馬就能再次重用相同的地址)。
1listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - 然後,伺服器繫結指定地址,bind函式分配一個本地地址給socket。在TCP中,呼叫bind可以指定一個埠號,一個IP地址,兩者都,或者兩者都不指定。
1listen_socket.bind(SERVER_ADDRESS) - 然後,伺服器讓這個socket成為監聽socket。
1listen_socket.listen(REQUEST_QUEUE_SIZE)
listen方法只會被伺服器呼叫。它告訴核心它要接受這個socket上的到來的連線請求了。
做完這些後,伺服器開始迴圈地一次接受一個客戶端連線。當有連線到達時,aceept呼叫返回已連線的客戶端socket。然後,伺服器從這個socket讀取請求資料,在標準輸出上把資料列印出來,並回發一個訊息給客戶端。然後,伺服器關閉客戶端連線,準備好再次接受新的客戶端連線。
下面是客戶端使用TCP/IP和伺服器通訊要做的:
以下是客戶端連線伺服器,傳送請求並列印響應的示例程式碼:
1 2 3 4 5 6 7 8 9 10 |
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()) |
建立socket後,客戶端需要連線伺服器。這是通過connect呼叫做到的:
1 |
sock.connect(('localhost', 8888)) |
客戶端僅需提供要連線的遠端IP地址或主機名和遠端埠號即可。
可能你注意到了,客戶端不用呼叫bind和accept。客戶端沒必要呼叫bind,是因為客戶端不關心本地IP地址和本地埠號。當客戶端呼叫connect時核心的TCP/IP棧自動分配一個本地IP址地和本地埠。本地埠被稱為暫時埠( ephemeral port),也就是,short-lived 埠。
伺服器上標識著一個客戶端連線的眾所周知的服務的埠被稱為well-known埠(舉例來說,80就是HTTP,22就是SSH)。操起Python shell,建立個連線到本地伺服器的客戶端連線,看看核心分配給你建立的socket的暫時的埠是多少(在這之前啟動webserver3a.py或webserver3b.py):
1 2 3 4 5 6 |
>>> 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這個暫時埠。
在我開始回答第二部分提出的問題前,我需要快速講一下幾個重要的概念。你很快就知道為什麼重要了。兩個概念是程式和檔案描述符。
什麼是程式?程式就是一個正在執行的程式的例項。比如,當伺服器程式碼執行時,它被載入進記憶體,執行起來的程式例項被稱為程式。核心記錄了程式的一堆資訊用於跟蹤,程式ID就是一個例子。當你執行伺服器 webserver3a.py 或 webserver3b.py 時,你就在執行一個程式了。
在控制檯視窗執行webserver3b.py:
1 |
$ python webserver3b.py |
在別的控制檯視窗使用ps命令獲取這個程式的資訊:
1 2 |
$ ps | grep webserver3b | grep -v grep 7182 ttys003 0:00.04 python webserver3b.py |
ps命令表示你確實執行了一個Python程式webserver3b。程式建立時,核心分配給它一個程式ID,也就是 PID。在UNIX裡,每個使用者程式都有個父程式,父程式也有它自己的程式ID,叫做父程式ID,或者簡稱PPID。假設預設你是在BASH shell裡執行的伺服器,那新程式的父程式ID就是BASH shell的程式ID。
自己試試,看看它是怎麼工作的。再啟動Python shell,這將建立一個新程式,使用 os.getpid() 和 os.getppid() 系統呼叫獲取Python shell程式的ID和父程式ID(BASH shell的PID)。然後,在另一個控制檯視窗執行ps命令,使用grep查詢PPID(父程式ID,我的是3148)。在下面的截圖你可以看到在我的Mac OS X上,子Python shell程式和父BASH shell程式的關係:
另一個要了解的重要概念是檔案描述符。那麼什麼是檔案描述符呢?檔案描述符是當開啟一個存在的檔案,建立一個檔案,或者建立一個socket時,核心返回的非負整數。你可能已經聽過啦,在UNIX裡一切皆檔案。核心使用檔案描述符來追蹤程式開啟的檔案。當你需要讀或寫檔案時,你就用檔案描述符標識它好啦。Python給你包裝成更高階別的物件來處理檔案(和socket),你不必直接使用檔案描述符來標識一個檔案,但是,在底層,UNIX中是這樣標識檔案和socket的:通過它們的整數檔案描述符。
預設情況下,UNIX shell分配檔案描述符0給程式的標準輸入,檔案描述符1給程式的標準輸出,檔案描述符2給標準錯誤。
就像我前面說的,雖然Python給了你更高階別的檔案或者類檔案的物件,你仍然可以使用物件的fileno()方法來獲取對應的檔案描述符。回到Python shell來看看怎麼做:
1 2 3 4 5 6 7 8 9 |
>>> import sys >>> sys.stdin <open file '<stdin>', mode 'r' at 0x102beb0c0> >>> sys.stdin.fileno() 0 >>> sys.stdout.fileno() 1 >>> sys.stderr.fileno() 2 |
雖然在Python中處理檔案和socket,通常使用高階的檔案/socket物件,但有時候你需要直接使用檔案描述符。下面這個例子告訴你如何使用write系統呼叫寫一個字串到標準輸出,write使用整數檔案描述符做為引數:
1 2 3 4 |
>>> import sys >>> import os >>> res = os.write(sys.stdout.fileno(), 'hellon') hello |
有趣的是——應該不會驚訝到你啦,因為你已經知道在UNIX裡一切皆檔案——socket也有一個分配給它的檔案描述符。再說一遍,當你建立一個socket時,你得到的是一個物件而不是非負整數,但你也可以使用我前面提到的fileno()方法直接訪問socket的檔案描述符。
還有一件事我想說下:你注意到了嗎?在第二個例子webserver3b.py中,當伺服器程式在60秒的睡眠時你仍然可以用curl命令來連線。當然啦,curl沒有立刻輸出什麼,它只是在那掛起。但為什麼伺服器不接受連線,客戶端也不立刻被拒絕,而是能連線伺服器呢?答案就是socket物件的listen方法和它的BACKLOG引數,我稱它為 REQUEST_QUEUE_SIZE(請求佇列長度)。BACKLOG引數決定了核心為進入的連線請求準備的佇列長度。當伺服器webser3b.py睡眠時,第二個curl命令可以連線到伺服器,因為核心在伺服器socket的進入連線請求佇列上有足夠的可用空間。
然而增加BACKLOG引數不會神奇地讓伺服器同時處理多個客戶端請求,設定一個合理大點的backlog引數挺重要的,這樣accept呼叫就不用等新連線建立起來,立刻就能從佇列裡獲取新的連線,然後開始處理客戶端請求啦。
吼吼!你已經瞭解了非常多的背景知識啦。我們們快速簡要重述到目前為止你都學了什麼(如果你都知道啦就溫習一下吧)。
- 迭代伺服器
- 伺服器socket建立流程(socket, bind, listen, accept)
- 客戶端連線建立流程(socket, connect)
- socket對
- socket
- 臨時埠和眾所周知埠
- 程式
- 程式ID(PID),父程式ID(PPID),父子關係。
- 檔案描述符
- listen方法的BACKLOG引數的意義
現在我準備回答第二部分問題的答案了:“怎樣才能讓伺服器同時處理多個請求?”或者換句話說,“怎樣寫一個併發伺服器?”
在Unix上寫一個併發伺服器最簡單的方法是使用fork()系統呼叫。
下面就是新的牛逼閃閃的併發伺服器webserver3c.py的程式碼,它能同時處理多個客戶端請求(和我們們迭代伺服器例子webserver3b.py一樣,每個子程式睡眠60秒):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
########################################################################### # 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() |
在深入討論for如何工作之前,先自己試試,看看伺服器確實可以同時處理多個請求,不像webserver3a.py和webserver3b.py。用下面命令啟動伺服器:
1 |
$ python webserver3c.py |
像你以前那樣試試用兩個curl命令,自己看看,現在雖然伺服器子程式在處理客戶端請求時睡眠60秒,但不影響別的客戶端,因為它們是被不同的完全獨立的程式處理的。你應該能看到curl命令立刻就輸出了“Hello, World!”,然後掛起60秒。你可以接著想執行多少curl命令就執行多少(嗯,幾乎是任意多),它們都會立刻輸出伺服器的響應“Hello, Wrold”,而且不會有明顯的延遲。試試看。
理解fork()的最重要的點是,你fork了一次,但它返回了兩次:一個是在父程式裡,一個是在子程式裡。當你fork了一個新程式,子程式返回的程式ID是0。父程式裡fork返回的是子程式的PID。
我仍然記得當我第一次知道它使用它時我對fork是有多著迷。它就像魔法一樣。我正讀著一段連續的程式碼,然後“duang”的一聲:程式碼克隆了自己,然後就有兩個相同程式碼的例項同時執行。我想除了魔法無法做到,我是認真噠。
當父程式fork了一個新的子程式,子程式就獲取了父程式檔案描述符的拷貝:
你可能已經注意到啦,上面程式碼裡的父程式關閉了客戶端連線:
1 2 |
else: # parent client_connection.close() # close parent copy and loop over |
那麼,如果它的父程式關閉了同一個socket,子程式為什麼還能從客戶端socket讀取資料呢?答案就在上圖。核心使用描述符引用計數來決定是否關閉socket。只有當描述符引用計數為0時才關閉socket。當伺服器建立一個子程式,子程式獲取了父程式的檔案描述符拷貝,核心增加了這些描述符的引用計數。在一個父程式和一個子程式的場景中,客戶端socket的描述符引用計數就成了2,當父程式關閉了客戶端連線socket,它僅僅把引用計數減為1,不會引發核心關閉這個socket。子程式也把父程式的listen_socket拷貝給關閉了,因為子程式不用管接受新連線,它只關心處理已經連線的客戶端的請求:
1 |
listen_socket.close() # close child copy |
本文後面我會講下如果不關閉複製的描述符會發生什麼。
你從併發伺服器原始碼看到啦,現在伺服器父程式唯一的角色就是接受一個新的客戶端連線,fork一個新的子程式來處理客戶端請求,然後重複接受另一個客戶端連線,就沒有別的事做啦。伺服器父程式不處理客戶端請求——它的小弟(子程式)幹這事。
跑個題,我們說兩個事件併發到底是什麼意思呢?
當我們說兩個事件併發時,我們通常表達的是它們同時發生。簡單來說,這也不錯,但你要知道嚴格定義是這樣的:
1 |
如果你不能通過觀察程式來知道哪個先發生的,那麼這兩個事件就是併發的。 |
又到了簡要重述目前為止已經學習的知識點和概念的時間啦.
- 在Unix下寫一個併發伺服器最簡單的方法是使用fork()系統呼叫
- 當一個程式fork了一個新程式時,它就變成了那個新fork產生的子程式的父程式。
- 在呼叫fork後,父程式和子程式共享相同的檔案描述符。
- 核心使用描述符引用計數來決定是否關閉檔案/socket。
- 伺服器父程式的角色是:現在它乾的所有活就是接受一個新連線,fork一個子進來來處理這個請求,然後迴圈接受新連線。
我們們來看看,如果在父程式和子程式中你不關閉複製的socket描述符會發生什麼吧。以下是個修改後的版本,伺服器不關閉複製的描述符,webserver3d.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 |
########################################################################### # 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() |
啟動伺服器:
1 |
$ python webserver3d.py |
使用curl去連線伺服器:
1 2 |
$ curl http://localhost:8888/hello Hello, World! |
好的,curl列印出來併發伺服器的響應,但是它不終止,一直掛起。發生了什麼?伺服器不再睡眠60秒了:它的子程式開心地處理了客戶端請求,關閉了客戶端連線然後退出啦,但是客戶端curl仍然不終止。
那麼,為什麼curl不終止呢?原因就在於複製的檔案描述符。當子程式關閉了客戶端連線,核心減少引用計數,值變成了1。伺服器子程式退出,但是客戶端socket沒有被核心關閉掉,因為引用計數不是0啊,所以,結果就是,終止資料包(在TCP/IP說法中叫做FIN)沒有傳送給客戶端,所以客戶端就保持線上啦。這裡還有個問題,如果伺服器不關閉複製的檔案描述符然後長時間執行,最終會耗盡可用檔案描述符。
使用Control-C停止webserver3d.py,使用shell內建的命令ulimit檢查一下shell預設設定的程式可用資源:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
$ 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:
1 |
$ ulimit -n 256 |
在同一個控制檯上啟動webserver3d.py:
1 |
$ python webserver3d.py |
使用下面的client3.py客戶端來測試伺服器。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
##################################################################### # 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個連線同時連線伺服器。
1 |
$ python client3.py --max-clients=300 |
很快伺服器就崩了。下面是我電腦上拋異常的截圖:
教訓非常明顯啦——伺服器應該關閉複製的描述符。但即使關閉了複製的描述符,你還沒有接觸到底層,因為你的伺服器還有個問題,殭屍!
是噠,伺服器程式碼就是產生了殭屍。我們們看下是怎麼產生的。再次執行伺服器:
1 |
$ python webserver3d.py |
在另一個控制檯視窗執行下面的curl命令:
1 |
$ curl http://localhost:8888/hello |
現在執行ps命令,顯示執行著的Python程式。以下是我的Ubuntu電腦上的ps輸出:
1 2 3 |
$ 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+,程式的名稱是。這個就是殭屍啦。殭屍的問題在於,你殺死不了他們啊。
即使你試著用 $ kill -9 來殺死殭屍,它們還是會倖存下來噠,自己試試看看。
殭屍到底是什麼呢?為什麼我們們的伺服器會產生它們呢?殭屍就是一個程式終止了,但是它的父程式沒有等它,還沒有接收到它的終止狀態。當一個子程式比父程式先終止,核心把子程式轉成殭屍,儲存程式的一些資訊,等著它的父程式以後獲取。儲存的資訊通常就是程式ID,程式終止狀態,程式使用的資源。嗯,殭屍還是有用的,但如果伺服器不好好處理這些殭屍,系統就會越來越堵塞。我們們看看怎麼做到的。首先停止伺服器,然後新開一個控制檯視窗,使用ulimit命令設定最大使用者程式為400(確保設定開啟檔案更高,比如500吧):
1 2 |
$ ulimit -u 400 $ ulimit -n 500 |
在同一個控制檯視窗執行webserver3d.py:
1 |
$ python webserver3d.py |
新開一個控制檯視窗,啟動client3.py,讓它建立500個連線同時連線到伺服器:
1 |
$ python client3.py --max-clients=500 |
然後,伺服器又一次崩了,是OSError的錯誤:拋了資源臨時不可用的異常,當試圖建立新的子程式時但建立不了時,因為達到了最大子程式數限制。以下是我的電腦的截圖:
看到了吧,如果你不處理好殭屍,伺服器長時間執行就會出問題。我會簡短討論下伺服器應該怎樣處理殭屍問題。
我們們簡要重述下目前為止你已經學習到主要知識點:
- 如果不關閉複製描述符,客戶端不會終止,因為客戶端連線不會關閉。
- 如果不關閉複製描述符,長時間執行的伺服器最終會耗盡可用檔案描述符(最大開啟檔案)。
- 當fork了一個子程式,然後子程式退出了,父程式沒有等它,而且沒有收集它的終止狀態,它就變成殭屍了。
- 殭屍要吃東西,我們的場景中,就是記憶體。伺服器最終會耗盡可用程式(最大使用者程式),如果不處理好殭屍的話。
- 殭屍殺不死的,你需要等它們。
那麼,處理好殭屍的話,要做什麼呢?要修改伺服器程式碼去等殭屍,獲取它們的終止狀態。通過呼叫wait系統呼叫就好啦。不幸的是,這不完美,因為如果呼叫wait,然而沒有終止的子程式,wait就會阻塞伺服器,實際上就是阻止了伺服器處理新的客戶端連線請求。有其他辦法嗎?當然有啦,其中之一就是使用資訊處理器和wait系統呼叫組合。
以下是如何工作的。當一個子程式終止了,核心傳送SIGCHLD訊號。父程式可以設定一個訊號處理器來非同步地被通知,然後就能wait子程式獲取它的終止狀態,因此阻止了殭屍程式出現。
順便說下,非同步事件意味著父程式不會提前知道事件發生的時間。
修改伺服器程式碼,設定一個SIGCHLD事件處理器,然後在事件處理器裡wait終止的子程式。webserver3e.py程式碼如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 |
########################################################################### # 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() |
啟動伺服器:
1 |
$ python webserver3e.py |
使用老朋友curl給修改後的併發伺服器傳送請求:
1 |
$ curl http://localhost:8888/hello |
觀察伺服器:
剛才發生了什麼?accept呼叫失敗了,錯誤是EINTR。
當子程式退出,引發SIGCHLD事件時,父程式阻塞在accept呼叫,這啟用了事件處理器,然後當事件處理器完成時,accept系統呼叫就中斷了:
彆著急,這個問題很好解決。你要做的就是重新呼叫accept。以下是修改後的程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
########################################################################### # 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() |
啟動修改後的webserver3f.py:
1 |
$ python webserver3f.py |
使用curl給修改後的伺服器傳送請求:
1 |
$ curl http://localhost:8888/hello |
看到了嗎?沒有EINTR異常啦。現在,驗證一下吧,沒有殭屍了,帶wait的SIGCHLD事件處理器也能處理好子程式了。怎麼驗證呢?只要執行ps命令,看看沒有Z+狀態的程式(沒有程式)。太棒啦!沒有殭屍在四周跳的感覺真安全呢!
- 如果fork了子程式並不wait它,它就成殭屍了。
- 使用SIGCHLD事件處理器來非同步的wait終止了的子程式來獲取它的終止狀態
- 使用事件處理器時,你要明白,系統呼叫會被中斷的,你要做好準備對付這種情況
嗯,目前為止,一次都好。沒有問題,對吧?好吧,幾乎滑。再次跑下webserver3f.py,這次不用curl請求一次了,改用client3.py來建立128個併發連線:
1 |
$ python client3.py --max-clients 128 |
現在再執行ps命令
1 |
$ ps auxw | grep -i python | grep -v grep |
看到了吧,少年,殭屍又回來了!
這次又出什麼錯了呢?當你執行128個併發客戶端時,建立了128個連線,子程式處理了請求然後幾乎同時終止了,這就引發了SIGCHLD訊號洪水般的發給父程式。問題在於,訊號沒有排隊,父程式錯過了一些訊號,導致了一些殭屍到處跑沒人管:
解決方案就是設定一個SIGCHLD事件處理器,但不用wait了,改用waitpid系統呼叫,帶上WNOHANG引數,迴圈處理,確保所有的終止的子程式都被處理掉。以下是修改後的webserver3g.py:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 |
########################################################################### # 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() |
啟動伺服器:
1 |
$ python webserver3g.py |
使用測試客戶端client3.py:
1 |
$ python client3.py --max-clients 128 |
現在驗證一下沒有殭屍了吧。哈!沒有殭屍的日子真好!
恭喜!這真是段很長的旅程啊,希望你喜歡。現在你已經擁有了自己的簡單併發伺服器,而且這個程式碼有助於你在將來的工作中開發一個產品級的Web伺服器。
我要把它留作練習,你來修改第二部分的WSGI伺服器,讓它達到併發。你在這裡可以找到修改後的版本。但是你要自己實現後再看我的程式碼喲。你已經擁有了所有必要的資訊,所以,去實現它吧!
接下來做什麼呢?就像Josh Billings說的那樣,
像郵票那樣——用心做一件事,直到完成。
去打好基礎吧。質疑你已經知道的,保持深入研究。
如果你只學方法,你就依賴方法。但如果你學會原理,你可以發明自己的方法。—— 愛默生
以下是我挑出來對本文最重要的幾本書。它們會幫你拓寬加深我提到的知識。我強烈建議你想言設法弄到這些書:從朋友那借也好,從本地圖書館借,或者從亞馬遜買也行。它們是守護者:
- Unix網路程式設計,卷1:socket網路API(第三版)
- UNIX環境高階程式設計,第三版
- Linux程式設計介面:Linux和UNIX系統編輯手冊
- TCP/IP詳解,卷1:協議(第二版)
- The Little Book of SEMAPHORES (2nd Edition): The Ins and Outs of Concurrency Control and Common Mistakes. Also available for free on the author’s site here.