搭個 Web 伺服器(三)
伺服器的基本結構及如何處理請求
首先,我們來回顧一下 Web 伺服器的基本結構,以及伺服器處理來自客戶端的請求時,所需的必要步驟。你在第一部分及第二部分中創建的輪詢伺服器只能夠一次處理一個請求。在處理完當前請求之前,它不能夠接受新的客戶端連接。所有請求為了等待服務都需要排隊,在服務繁忙時,這個隊伍可能會排的很長,一些客戶端可能會感到不開心。
這是輪詢伺服器 webserver3a.py 的代碼:
#####################################################################
# 輪詢伺服器 - webserver3a.py #
# #
# 使用 Python 2.7.9 或 3.4 #
# 在 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:
######################################################################
# 輪詢伺服器 - webserver3b.py #
# #
# 使用 Python 2.7.9 或 3.4 #
# 在 Ubuntu 14.04 及 Mac OS X 環境下測試通過 #
# #
# - 伺服器向客戶端發送響應之後,會阻塞 60 秒 #
######################################################################
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) ### 睡眠語句,阻塞該進程 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))
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
指令不會立刻產生任何輸出,而只是掛在了哪裡。同樣,伺服器也不會在標準輸出流中輸出新的請求內容。這是這個過程在我的 Mac 電腦上的運行結果(在右下角用黃色框標註出來的窗口中,我們能看到第二個 curl
指令被掛起,正在等待連接被伺服器接受):
當你等待足夠長的時間(60 秒以上)後,你會看到第一個 curl
程序完成,而第二個 curl
在屏幕上輸出了「Hello, World!」,然後休眠 60 秒,進而終止。
這樣運行的原因是因為在伺服器在處理完第一個來自 curl
的請求之後,只有等待 60 秒才能開始處理第二個請求。這個處理請求的過程按順序進行(也可以說,迭代進行),一步一步進行,在我們剛剛給出的例子中,在同一時間內只能處理一個請求。
現在,我們來簡單討論一下客戶端與伺服器的交流過程。為了讓兩個程序在網路中互相交流,它們必須使用套接字。你應當在本系列的前兩部分中見過它幾次了。但是,套接字是什麼?
套接字 是一個通訊通道 端點 的抽象描述,它可以讓你的程序通過文件描述符來與其它程序進行交流。在這篇文章中,我只會單獨討論 Linux 或 Mac OS X 中的 TCP/IP 套接字。這裡有一個重點概念需要你去理解:TCP 套接字對 。
TCP 連接使用的套接字對是一個由 4 個元素組成的元組,它確定了 TCP 連接的兩端:本地 IP 地址、本地埠、遠端 IP 地址及遠端埠。一個套接字對唯一地確定了網路中的每一個 TCP 連接。在連接一端的兩個值:一個 IP 地址和一個埠,通常被稱作一個套接字。(引自《UNIX 網路編程 卷1:套接字聯網 API (第3版)》)
所以,元組 {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)
- 然後,伺服器綁定一個地址。綁定函數
bind
可以將一個本地協議地址賦給套接字。若使用 TCP 協議,調用綁定函數bind
時,需要指定一個埠號,一個 IP 地址,或兩者兼有,或兩者全無。(引自《UNIX網路編程 卷1:套接字聯網 API (第3版)》)
listen_socket.bind(SERVER_ADDRESS)
- 然後,伺服器開啟套接字的監聽模式。
listen_socket.listen(REQUEST_QUEUE_SIZE)
監聽函數 listen
只應在服務端調用。它會通知操作系統內核,表明它會接受所有向該套接字發送的入站連接請求。
以上四步完成後,伺服器將循環接收來自客戶端的連接,一次循環處理一條。當有連接可用時,接受請求函數 accept
將會返回一個已連接的客戶端套接字。然後,伺服器從這個已連接的客戶端套接字中讀取請求數據,將數據在其標準輸出流中輸出出來,並向客戶端回送一條消息。然後,伺服器會關閉這個客戶端連接,並準備接收一個新的客戶端連接。
這是客戶端使用 TCP/IP 協議與伺服器通信的必要步驟:
下面是一段示例代碼,使用這段代碼,客戶端可以連接你的伺服器,發送一個請求,並輸出響應內容:
import socket
### 創建一個套接字,並連接值伺服器
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect(('localhost', 8888))
### 發送一段數據,並接收響應數據
sock.sendall(b'test')
data = sock.recv(1024)
print(data.decode())
在創建套接字後,客戶端需要連接至伺服器。我們可以調用連接函數 connect
來完成這個操作:
sock.connect(('localhost', 8888))
客戶端只需提供待連接的遠程伺服器的 IP 地址(或主機名),及埠號,即可連接至遠端伺服器。
你可能已經注意到了,客戶端不需要調用 bind
及 accept
函數,就可以與伺服器建立連接。客戶端不需要調用 bind
函數是因為客戶端不需要關注本地 IP 地址及埠號。操作系統內核中的 TCP/IP 協議棧會在客戶端調用 connect
函數時,自動為套接字分配本地 IP 地址及本地埠號。這個本地埠被稱為 臨時埠 ,即一個短暫開放的埠。
伺服器中有一些埠被用於承載一些眾所周知的服務,它們被稱作 通用 埠:如 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 分配給了你的套接字。
在我開始回答我在第二部分中提出的問題之前,我還需要快速講解一些概念。你很快就會明白這些概念為什麼非常重要。這兩個概念,一個是進程,另外一個是文件描述符。
什麼是進程?進程就是一個程序執行的實體。舉個例子:當你的伺服器代碼被執行時,它會被載入內存,而內存中表現此次程序運行的實體就叫做進程。內核記錄了進程的一系列有關信息——比如進程 ID——來追蹤它的運行情況。當你在執行輪詢伺服器 webserver3a.py
或 webserver3b.py
時,你其實只是啟動了一個進程。
我們在終端窗口中運行 webserver3b.py
:
$ python webserver3b.py
在另一個終端窗口中,我們可以使用 ps
命令獲取該進程的相關信息:
$ ps | grep webserver3b | grep -v grep
7182 ttys003 0:00.04 python webserver3b.py
ps
命令顯示,我們剛剛只運行了一個 Python 進程 webserver3b.py
。當一個進程被創建時,內核會為其分配一個進程 ID,也就是 PID。在 UNIX 中,所有用戶進程都有一個父進程;當然,這個父進程也有進程 ID,叫做父進程 ID,縮寫為 PPID。假設你默認使用 BASH shell,那當你啟動伺服器時,就會啟動一個新的進程,同時被賦予一個 PID,而它的父進程 PID 會被設為 BASH shell 的 PID。
自己嘗試一下,看看這一切都是如何工作的。重新開啟你的 Python shell,它會創建一個新進程,然後在其中使用系統調用 os.getpid()
及 os.getppid()
來獲取 Python shell 進程的 PID 及其父進程 PID(也就是你的 BASH shell 的 PID)。然後,在另一個終端窗口中運行 ps
命令,然後用 grep
來查找 PPID(父進程 ID,在我的例子中是 3148)。在下面的屏幕截圖中,你可以看到一個我的 Mac OS X 系統中關於進程父子關係的例子,在這個例子中,子進程是我的 Python shell 進程,而父進程是 BASH shell 進程:
另外一個需要了解的概念,就是文件描述符。什麼是文件描述符?文件描述符是一個非負整數,當進程打開一個現有文件、創建新文件或創建一個新的套接字時,內核會將這個數返回給進程。你以前可能聽說過,在 UNIX 中,一切皆是文件。內核會按文件描述符來找到一個進程所打開的文件。當你需要讀取文件或向文件寫入時,我們同樣通過文件描述符來定位這個文件。Python 提供了高層次的操作文件(或套接字)的對象,所以你不需要直接通過文件描述符來定位文件。但是,在高層對象之下,我們就是用它來在 UNIX 中定位文件及套接字,通過這個整數的文件描述符。
一般情況下,UNIX shell 會將一個進程的標準輸入流(STDIN)的文件描述符設為 0,標準輸出流(STDOUT)設為 1,而標準錯誤列印(STDERR)的文件描述符會被設為 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 中操作文件及套接字時,你可能會使用高層次的文件/套接字對象,但是你仍然有可能會直接使用文件描述符。下面有一個例子,來演示如何用文件描述符做參數來進行一次寫入的系統調用:
>>> import sys
>>> import os
>>> res = os.write(sys.stdout.fileno(), 'hellon')
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_QUEUE_SIZE
常量來表示。BACKLOG
參數決定了在內核中為存放即將到來的連接請求所創建的隊列的大小。當伺服器 webserver3b.py
在睡眠的時候,你運行的第二個 curl
命令依然能夠連接至伺服器,因為內核中用來存放即將接收的連接請求的隊列依然擁有足夠大的可用空間。
儘管增大 BACKLOG
參數並不能神奇地使你的伺服器同時處理多個請求,但當你的伺服器很繁忙時,將它設置為一個較大的值還是相當重要的。這樣,在你的伺服器調用 accept
方法時,不需要再等待一個新的連接建立,而可以立刻直接抓取隊列中的第一個客戶端連接,並不加停頓地立刻處理它。
歐耶!現在你已經了解了一大塊內容。我們來快速回顧一下我們剛剛講解的知識(當然,如果這些對你來說都是基礎知識的話,那我們就當複習好啦)。
- 輪詢伺服器
- 服務端套接字創建流程(創建套接字,綁定,監聽及接受)
- 客戶端連接創建流程(創建套接字,連接)
- 套接字對
- 套接字
- 臨時埠及通用埠
- 進程
- 進程 ID(PID),父進程 ID(PPID),以及進程父子關係
- 文件描述符
- 套接字的
listen
方法中,BACKLOG
參數的含義
如何並發處理多個請求
現在,我可以開始回答第二部分中的那個問題了:「你該如何讓你的伺服器在同一時間處理多個請求呢?」或者換一種說法:「如何編寫一個並發伺服器?」
在 UNIX 系統中編寫一個並發伺服器最簡單的方法,就是使用系統調用 fork()
。
下面是全新出爐的並發伺服器 webserver3c.py 的代碼,它可以同時處理多個請求(和我們之前的例子 webserver3b.py 一樣,每個子進程都會休眠 60 秒):
#######################################################
# 並發伺服器 - webserver3c.py #
# #
# 使用 Python 2.7.9 或 3.4 #
# 在 Ubuntu 14.04 及 Mac OS X 環境下測試通過 #
# #
# - 完成客戶端請求處理之後,子進程會休眠 60 秒 #
# - 父子進程會關閉重複的描述符 #
# #
#######################################################
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: ### 子進程
listen_socket.close() ### 關閉子進程中複製的套接字對象
handle_request(client_connection)
client_connection.close()
os._exit(0) ### 子進程在這裡退出
else: ### 父進程
client_connection.close() ### 關閉父進程中的客戶端連接對象,並循環執行
if __name__ == '__main__':
serve_forever()
在深入研究代碼、討論 fork
如何工作之前,先嘗試運行它,自己看一看這個伺服器是否真的可以同時處理多個客戶端請求,而不是像輪詢伺服器 webserver3a.py
和 webserver3b.py
一樣。在命令行中使用如下命令啟動伺服器:
$ python webserver3c.py
然後,像我們之前測試輪詢伺服器那樣,運行兩個 curl
命令,來看看這次的效果。現在你可以看到,即使子進程在處理客戶端請求後會休眠 60 秒,但它並不會影響其它客戶端連接,因為他們都是由完全獨立的進程來處理的。你應該看到你的 curl
命令立即輸出了「Hello, World!」然後掛起 60 秒。你可以按照你的想法運行儘可能多的 curl
命令(好吧,並不能運行特別特別多 ^_^
),所有的命令都會立刻輸出來自伺服器的響應 「Hello, World!」,並不會出現任何可被察覺到的延遲行為。試試看吧。
如果你要理解 fork()
,那最重要的一點是:你調用了它一次,但是它會返回兩次 —— 一次在父進程中,另一次是在子進程中。當你創建了一個新進程,那麼 fork()
在子進程中的返回值是 0。如果是在父進程中,那 fork()
函數會返回子進程的 PID。
我依然記得在第一次看到它並嘗試使用 fork()
的時候,我是多麼的入迷。它在我眼裡就像是魔法一樣。這就好像我在讀一段順序執行的代碼,然後「砰!」地一聲,代碼變成了兩份,然後出現了兩個實體,同時並行地運行相同的代碼。講真,那個時候我覺得它真的跟魔法一樣神奇。
當父進程創建出一個新的子進程時,子進程會複製從父進程中複製一份文件描述符:
你可能注意到,在上面的代碼中,父進程關閉了客戶端連接:
else: ### 父進程
client_connection.close() # 關閉父進程的副本並循環
不過,既然父進程關閉了這個套接字,那為什麼子進程仍然能夠從來自客戶端的套接字中讀取數據呢?答案就在上面的圖片中。內核會使用描述符引用計數器來決定是否要關閉一個套接字。當你的伺服器創建一個子進程時,子進程會複製父進程的所有文件描述符,內核中該描述符的引用計數也會增加。如果只有一個父進程及一個子進程,那客戶端套接字的文件描述符引用數應為 2;當父進程關閉客戶端連接的套接字時,內核只會減少它的引用計數,將其變為 1,但這仍然不會使內核關閉該套接字。子進程也關閉了父進程中 listen_socket
的複製實體,因為子進程不需要關注新的客戶端連接,而只需要處理已建立的客戶端連接中的請求。
listen_socket.close() ### 關閉子進程中的複製實體
我們將會在後文中討論,如果你不關閉那些重複的描述符,會發生什麼。
你可以從你的並發伺服器源碼中看到,父進程的主要職責為:接受一個新的客戶端連接,複製出一個子進程來處理這個連接,然後繼續循環來接受另外的客戶端連接,僅此而已。伺服器父進程並不會處理客戶端連接——子進程才會做這件事。
打個岔:當我們說兩個事件並發執行時,我們所要表達的意思是什麼?
當我們說「兩個事件並發執行」時,它通常意味著這兩個事件同時發生。簡單來講,這個定義沒問題,但你應該記住它的嚴格定義:
如果你不能在代碼中判斷兩個事件的發生順序,那這兩個事件就是並發執行的。(引自《信號系統簡明手冊 (第二版): 並發控制深入淺出及常見錯誤》)
好的,現在你又該回顧一下你剛剛學過的知識點了。
- 在 Unix 中,編寫一個並發伺服器的最簡單的方式——使用
fork()
系統調用; - 當一個進程分叉(
fork
)出另一個進程時,它會變成剛剛分叉出的進程的父進程; - 在進行
fork
調用後,父進程和子進程共享相同的文件描述符; - 系統內核通過描述符的引用計數來決定是否要關閉該描述符對應的文件或套接字;
- 伺服器父進程的主要職責:現在它做的只是從客戶端接受一個新的連接,分叉出子進程來處理這個客戶端連接,然後開始下一輪循環,去接收新的客戶端連接。
進程分叉後不關閉重複的套接字會發生什麼?
我們來看看,如果我們不在父進程與子進程中關閉重複的套接字描述符會發生什麼。下面是剛才的並發伺服器代碼的修改版本,這段代碼(webserver3d.py 中,伺服器不會關閉重複的描述符):
#######################################################
# 並發伺服器 - webserver3d.py #
# #
# 使用 Python 2.7.9 或 3.4 #
# 在 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()
### 將引用存儲起來,否則在下一輪循環時,他們會被垃圾回收機制銷毀
clients.append(client_connection)
pid = os.fork()
if pid == 0: ### 子進程
listen_socket.close() ### 關閉子進程中多餘的套接字
handle_request(client_connection)
client_connection.close()
os._exit(0) ### 子進程在這裡結束
else: ### 父進程
# 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
不終止呢?原因就在於文件描述符的副本。當子進程關閉客戶端連接時,系統內核會減少客戶端套接字的引用計數,將其變為 1。伺服器子進程退出了,但客戶端套接字並沒有被內核關閉,因為該套接字的描述符引用計數並沒有變為 0,所以,這就導致了連接終止包(在 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 來測試你的伺服器。
#######################################################
# 測試客戶端 - client3.py #
# #
# 使用 Python 2.7.9 或 3.4 #
# 在 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 進程。下面是我的環境中 ps
的運行結果:
$ 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
來殺死殭屍進程,它們仍舊會存活。自己試試看,看看結果。
這個殭屍到底是什麼,為什麼我們的伺服器會造出它們呢?一個 殭屍進程 是一個已經結束的進程,但它的父進程並沒有等待(waited
)它結束,並且也沒有收到它的終結狀態。如果一個進程在父進程退出之前退出,系統內核會把它變為一個殭屍進程,存儲它的部分信息,以便父進程讀取。內核保存的進程信息通常包括進程 ID、進程終止狀態,以及進程的資源佔用情況。OK,所以殭屍進程確實有存在的意義,但如果伺服器不管這些殭屍進程,你的系統將會被壅塞。我們來看看這個會如何發生。首先,關閉你運行的伺服器;然後,在一個新的終端窗口中,使用 ulimit
命令將最大用戶進程數設為 400(同時,要確保你的最大可用描述符數大於這個數字,我們在這裡設為 500):
$ ulimit -u 400
$ ulimit -n 500
在你剛剛運行 ulimit -u 400
命令的終端中,運行伺服器 webserver3d.py
:
$ python webserver3d.py
在一個新的終端窗口中,運行 client3.py
,並且讓它與伺服器同時創建 500 個連接:
$ python client3.py --max-clients=500
然後,過一會,你的伺服器進程應該會再次爆了,它會在創建新進程時拋出一個 OSError: 資源暫時不可用
的異常。但它並沒有達到系統允許的最大進程數。這是我的環境中輸出的異常信息截圖:
你可以看到,如果伺服器不管殭屍進程,它們會引發問題。接下來我會簡單探討一下殭屍進程問題的解決方案。
我們來回顧一下你剛剛掌握的知識點:
- 如果你不關閉文件描述符副本,客戶端就不會在請求處理完成後終止,因為客戶端連接沒有被關閉;
- 如果你不關閉文件描述符副本,長久運行的伺服器最終會把可用的文件描述符(最大文件打開數)消耗殆盡;
- 當你創建一個新進程,而父進程不等待(
wait
)子進程,也不在子進程結束後收集它的終止狀態,它會變為一個殭屍進程; - 殭屍通常都會吃東西,在我們的例子中,殭屍進程會吃掉資源。如果你的伺服器不管殭屍進程,它最終會消耗掉所有的可用進程(最大用戶進程數);
- 你不能殺死(
kill
)殭屍進程,你需要等待(wait
)它。
如何處理殭屍進程?
所以,你需要做什麼來處理殭屍進程呢?你需要修改你的伺服器代碼,來等待(wait
)殭屍進程,並收集它們的終止信息。你可以在代碼中使用系統調用 wait
來完成這個任務。不幸的是,這個方法離理想目標還很遠,因為在沒有終止的子進程存在的情況下調用 wait
會導致伺服器進程阻塞,這會阻礙你的伺服器處理新的客戶端連接請求。那麼,我們有其他選擇嗎?嗯,有的,其中一個解決方案需要結合信號處理以及 wait
系統調用。
這是它的工作流程。當一個子進程退出時,內核會發送 SIGCHLD
信號。父進程可以設置一個信號處理器,它可以非同步響應 SIGCHLD
信號,並在信號響應函數中等待(wait
)子進程收集終止信息,從而阻止了殭屍進程的存在。
順便說一下,非同步事件意味著父進程無法提前知道事件的發生時間。
修改你的伺服器代碼,設置一個 SIGCHLD
信號處理器,在信號處理器中等待(wait
)終止的子進程。修改後的代碼如下(webserver3e.py):
#######################################################
# 並發伺服器 - webserver3e.py #
# #
# 使用 Python 2.7.9 或 3.4 #
# 在 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)
### 掛起進程,來允許父進程完成循環,並在 "accept" 處阻塞
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: ### 子進程
listen_socket.close() ### 關閉子進程中多餘的套接字
handle_request(client_connection)
client_connection.close()
os._exit(0)
else: ### 父進程
client_connection.close()
if __name__ == '__main__':
serve_forever()
運行伺服器:
$ python webserver3e.py
使用你的老朋友——curl
命令來向修改後的並發伺服器發送一個請求:
$ curl http://localhost:8888/hello
再來看看伺服器:
剛剛發生了什麼?accept
調用失敗了,錯誤信息為 EINTR
。
當子進程退出並觸發 SIGCHLD
事件時,父進程的 accept
調用被阻塞了,系統轉去運行信號處理器,當信號處理函數完成時,accept
系統調用被打斷:
別擔心,這個問題很好解決。你只需要重新運行 accept
系統調用即可。這是修改後的伺服器代碼 webserver3f.py,它可以解決這個問題:
#######################################################
# 並發伺服器 - webserver3f.py #
# #
# 使用 Python 2.7.9 或 3.4 #
# 在 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
### 若 'accept' 被打斷,那麼重啟它
if code == errno.EINTR:
continue
else:
raise
pid = os.fork()
if pid == 0: ### 子進程
listen_socket.close() ### 關閉子進程中多餘的描述符
handle_request(client_connection)
client_connection.close()
os._exit(0)
else: ### 父進程
client_connection.close() ### 關閉父進程中多餘的描述符,繼續下一輪循環
if __name__ == '__main__':
serve_forever()
運行更新後的伺服器 webserver3f.py
:
$ python webserver3f.py
用 curl
來向更新後的並發伺服器發送一個請求:
$ curl http://localhost:8888/hello
看到了嗎?沒有 EINTR 異常出現了。現在檢查一下,確保沒有殭屍進程存活,調用 wait
函數的 SIGCHLD
信號處理器能夠正常處理被終止的子進程。我們只需使用 ps
命令,然後看看現在沒有處於 Z+
狀態(或名字包含 <defunct>
)的 Python 進程就好了。很棒!殭屍進程沒有了,我們很安心。
- 如果你創建了一個子進程,但是不等待它,它就會變成一個殭屍進程;
- 使用
SIGCHLD
信號處理器可以非同步地等待子進程終止,並收集其終止狀態; - 當使用事件處理器時,你需要牢記,系統調用可能會被打斷,所以你需要處理這種情況發生時帶來的異常。
正確處理 SIGCHLD 信號
好的,一切順利。是不是沒問題了?額,幾乎是。重新嘗試運行 webserver3f.py
但我們這次不會只發送一個請求,而是同步創建 128 個連接:
$ python client3.py --max-clients 128
現在再次運行 ps
命令:
$ ps auxw | grep -i python | grep -v grep
看到了嗎?天啊,殭屍進程又出來了!
這回怎麼回事?當你同時運行 128 個客戶端,建立 128 個連接時,伺服器的子進程幾乎會在同一時間處理好你的請求,然後退出。這會導致非常多的 SIGCHLD
信號被發送到父進程。問題在於,這些信號不會存儲在隊列中,所以你的伺服器進程會錯過很多信號,這也就導致了幾個殭屍進程處於無主狀態:
這個問題的解決方案依然是設置 SIGCHLD
事件處理器。但我們這次將會用 WNOHANG
參數循環調用 waitpid
來替代 wait
,以保證所有處於終止狀態的子進程都會被處理。下面是修改後的代碼,webserver3g.py:
#######################################################
# 並發伺服器 - webserver3g.py #
# #
# 使用 Python 2.7.9 或 3.4 #
# 在 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, ### 等待所有子進程
os.WNOHANG ### 無終止進程時,不阻塞進程,並拋出 EWOULDBLOCK 錯誤
)
except OSError:
return
if pid == 0: ### 沒有殭屍進程存在了
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
### 若 'accept' 被打斷,那麼重啟它
if code == errno.EINTR:
continue
else:
raise
pid = os.fork()
if pid == 0: ### 子進程
listen_socket.close() ### 關閉子進程中多餘的描述符
handle_request(client_connection)
client_connection.close()
os._exit(0)
else: ### 父進程
client_connection.close() ### 關閉父進程中多餘的描述符,繼續下一輪循環
if __name__ == '__main__':
serve_forever()
運行伺服器:
$ python webserver3g.py
使用測試客戶端 client3.py
:
$ python client3.py --max-clients 128
現在來查看一下,確保沒有殭屍進程存在。耶!沒有殭屍的生活真美好 ^_^
。
大功告成
恭喜!你剛剛經歷了一段很長的旅程,我希望你能夠喜歡它。現在你擁有了自己的簡易並發伺服器,並且這段代碼能夠為你在繼續研究生產級 Web 伺服器的路上奠定基礎。
我將會留一個作業:你需要將第二部分中的 WSGI 伺服器升級,將它改造為一個並發伺服器。你可以在這裡找到更改後的代碼。但是,當你實現了自己的版本之後,你才應該來看我的代碼。你已經擁有了實現這個伺服器所需的所有信息。所以,快去實現它吧 ^_^
。
然後要做什麼呢?喬希·比林斯說過:
「就像一枚郵票一樣——專註於一件事,不達目的不罷休。」
開始學習基本知識。回顧你已經學過的知識。然後一步一步深入。
「如果你只學會了方法,你將會被這些方法所困。但如果你學會了原理,那你就能發明出新的方法。」——拉爾夫·沃爾多·愛默生
「有道無術,術尚可求也,有術無道,止於術」——中國古代也有這樣的話,LCTT 譯註
下面是一份書單,我從這些書中提煉出了這篇文章所需的素材。他們能助你在我剛剛所述的幾個方面中發掘出兼具深度和廣度的知識。我極力推薦你們去搞到這幾本書看看:從你的朋友那裡借,在當地的圖書館中閱讀,或者直接在亞馬遜上把它買回來。下面是我的典藏秘籍:
- 《UNIX 網路編程 卷1:套接字聯網 API (第3版)》
- 《UNIX 環境高級編程(第3版)》
- 《Linux/UNIX 系統編程手冊》
- 《TCP/IP 詳解 卷1:協議(第2版)
- 《信號系統簡明手冊 (第二版): 並發控制深入淺出及常見錯誤》,這本書也可以從作者的個人網站中免費下載到。
順便,我在撰寫一本名為《搭個 Web 伺服器:從頭開始》的書。這本書講解了如何從頭開始編寫一個基本的 Web 伺服器,裡面包含本文中沒有的更多細節。訂閱原文下方的郵件列表,你就可以獲取到這本書的最新進展,以及發布日期。
via: https://ruslanspivak.com/lsbaws-part3/
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive