自己動手開發一個 Web 伺服器(三)

codingpy發表於2016-01-03

第二部分中,你開發了一個能夠處理HTTPGET請求的簡易WSGI伺服器。在上一篇的最後,我問了你一個問題:“怎樣讓伺服器一次處理多個請求?”讀完本文,你就能夠完美地回答這個問題。接下來,請你做好準備,因為本文的內容非常多,節奏也很快。文中的所有程式碼都可以在Github倉庫下載。

自己動手開發一個 Web 伺服器(三)

首先,我們簡單回憶一下簡易網路伺服器是如何實現的,伺服器要處理客戶端的請求需要哪些條件。你在前面兩部分文章中開發的伺服器,是一個迭代式伺服器(iterative server),還只能一次處理一個客戶端請求。只有在處理完當前客戶端請求之後,它才能接收新的客戶端連線。這樣,有些客戶端就必須要等待自己的請求被處理了,而對於流量大的伺服器來說,等待的時間就會特別長。

自己動手開發一個 Web 伺服器(三)

下面是迭代式伺服器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秒。

自己動手開發一個 Web 伺服器(三)

下面就是修改之後的伺服器程式碼:

#########################################################################
# 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命令掛死):

自己動手開發一個 Web 伺服器(三)

當然,你等了足夠長時間之後(超過60秒),你會看到第一個curl命令結束,然後第二個curl命令會在螢幕上列印出“Hello, World!”,之後再掛死60秒,最後才結束:

自己動手開發一個 Web 伺服器(三)

這背後的實現方式是,伺服器處理完第一個curl客戶端請求後睡眠60秒,才開始處理第二個請求。這些步驟是線性執行的,或者說迭代式一步一步執行的。在我們這個例項中,則是一次一個請求這樣處理。

接下來,我們簡單談談客戶端與伺服器之間的通訊。為了讓兩個程式通過網路進行通訊,二者均必須使用套接字。你在前兩章中也看到過套接字,但到底什麼是套接字?

自己動手開發一個 Web 伺服器(三)

套接字是通訊端點(communication endpoint)的抽象形式,可以讓一個程式通過檔案描述符(file descriptor)與另一個程式進行通訊。在本文中,我只討論Linux/Mac OS X平臺上的TCP/IP套接字。其中,尤為重要的一個概念就是TCP套接字對(socket pair)。

TCP連線所使用的套接字對是一個4元組(4-tuple),包括本地IP地址、本地埠、外部IP地址和外部埠。一個網路中的每一個TCP連線,都擁有獨特的套接字對。IP地址和埠號通常被稱為一個套接字,二者一起標識了一個網路端點。

自己動手開發一個 Web 伺服器(三)

因此,{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,它們在這裡被稱為一個套接字(同理,客戶端端點的兩個值也是一個套接字)。

伺服器建立套接字並開始接受客戶端連線的標準流程如下:

自己動手開發一個 Web 伺服器(三)

  1. 伺服器建立一個TCP/IP套接字。通過下面的Python語句實現:

    listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)

  2. 伺服器可以設定部分套接字選項(這是可選項,但你會發現上面那行伺服器程式碼就可以確保你重啟伺服器之後,伺服器會繼續使用相同的地址)。

    listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)

  3. 然後,伺服器繫結地址。繫結函式為套接字指定一個本地協議地址。呼叫繫結函式時,你可以單獨指定埠號或IP地址,也可以同時指定兩個引數,甚至不提供任何引數也沒問題。

    listen_socket.bind(SERVER_ADDRESS)

  4. 接著,伺服器將該套接字變成一個偵聽套接字:

    listen_socket.listen(REQUEST_QUEUE_SIZE)

listen方法只能由伺服器呼叫,執行後會告知伺服器應該接收針對該套接字的連線請求。

完成上面四步之後,伺服器會開啟一個迴圈,開始接收客戶端連線,不過一次只接收一個連線。當有連線請求時,accept方法會返回已連線的客戶端套接字。然後,伺服器從客戶端套接字讀取請求資料,在標準輸出中列印資料,並向客戶端返回訊息。最後,伺服器會關閉當前的客戶端連線,這時伺服器又可以接收新的客戶端連線了。

要通過TCP/IP協議與伺服器進行通訊,客戶端需要作如下操作:

自己動手開發一個 Web 伺服器(三)

下面這段示例程式碼,實現了客戶端連線至伺服器,傳送請求,並列印響應內容的過程:

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)。

自己動手開發一個 Web 伺服器(三)

伺服器端有部分埠用於連線熟知的服務,這種埠被叫做“熟知埠”(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時,你也就開啟了一個程式。

自己動手開發一個 Web 伺服器(三)

我們在終端啟動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。

自己動手開發一個 Web 伺服器(三)

接下來請自己嘗試操作一下。再次開啟你的Python shell程式,這會建立一個新程式,然後我們通過os.gepid()和os.getppid()這兩個方法,分別獲得Python shell程式的PID及它的父程式PID(即BASH shell程式的PID)。接著,我們開啟另一個終端視窗,執行ps命令,grep檢索剛才所得到的PPID(父程式ID,本操作時的結果是3148)。在下面的截圖中,你可以看到我在Mac OS X上的操作結果:

自己動手開發一個 Web 伺服器(三)

另一個需要掌握的重要概念就是檔案描述符(file descriptor)。那麼,到底什麼是檔案描述符?檔案描述符指的就是當系統開啟一個現有檔案、建立一個新檔案或是建立一個新的套接字之後,返回給程式的那個正整型數。系統核心通過檔案描述符來追蹤一個程式所開啟的檔案。當你需要讀寫檔案時,你也通過檔案描述符說明。Python語言中提供了用於處理檔案(和套接字)的高層級物件,所以你不必直接使用檔案描述符來指定檔案,但是從底層實現來看,UNIX系統中就是通過它們的檔案描述符來確定檔案和套接字的。

自己動手開發一個 Web 伺服器(三)

一般來說,UNIX shell會將檔案描述符0指定給程式的標準輸出,檔案描述富1指定給程式的標準輸出,檔案描述符2指定給標準錯誤。

自己動手開發一個 Web 伺服器(三)

正如我前面提到的那樣,即使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引數的意義

現在,我可以開始回答第二部分留下的問題了:如何讓伺服器一次處理多個請求?換句話說,如何開發一個併發伺服器?

自己動手開發一個 Web 伺服器(三)

在Unix系統中開發一個併發伺服器的最簡單方法,就是呼叫系統函式fork()。

自己動手開發一個 Web 伺服器(三)

下面就是嶄新的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。

自己動手開發一個 Web 伺服器(三)

我還記得,第一次接觸並使用fork函式時,自己感到非常不可思議。我覺得這就好像一個魔法。之前還是一個線性的程式碼,突然一下子克隆了自己,出現了並行執行的相同程式碼的兩個例項。我當時真的覺得這和魔法也差不多了。

當父程式fork一個新的子程式時,子程式會得到父程式檔案描述符的副本:

自己動手開發一個 Web 伺服器(三)

你可能也注意到了,上面程式碼中的父程式關閉了客戶端連線:

else:  # parent
    client_connection.close()  # close parent copy and loop over

那為什麼父程式關閉了套接字之後,子程式卻仍然能夠從客戶端套接字中讀取資料呢?答案就在上面的圖片裡。系統核心根據檔案描述符計數(descriptor reference counts)來決定是否關閉套接字。系統只有在描述符計數變為0時,才會關閉套接字。當你的伺服器建立一個子程式時,子程式就會獲得父程式檔案描述符的副本,系統核心則會增加這些檔案描述符的計數。在一個父程式和一個子程式的情況下,客戶端套接字的檔案描述符計數為2。當上面程式碼中的父程式關閉客戶端連線套接字時,只是讓套接字的計數減為1,還不夠讓系統關閉套接字。子程式同樣關閉了父程式偵聽套接字的副本,因為子程式不關心要不要接收新的客戶端連線,只關心如何處理連線成功的客戶端所發出的請求。

listen_socket.close()  # close child copy

稍後,我會給大家介紹如果不關閉重複的描述符的後果。

從上面並行伺服器的原始碼可以看出,伺服器父程式現在唯一的作用,就是接受客戶端連線,fork一個新的子程式來處理該客戶端連線,然後回到迴圈的起點,準備接受其他的客戶端連線,僅此而已。伺服器父程式並不會處理客戶端請求,而是由它的子程式來處理。

談得稍遠一點。我們說兩個事件是並行時,到底是什麼意思?

自己動手開發一個 Web 伺服器(三)

我們說兩個事件是並行的,通常指的是二者同時發生。這是簡單的定義,但是你應該牢記它的嚴格定義:

如果你不能分辨出哪個程式會先執行,那麼二者就是並行的。

現在又到了回顧目前已經介紹的主要觀點和概念。

自己動手開發一個 Web 伺服器(三)

  • 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命令卻不會終止。

自己動手開發一個 Web 伺服器(三)

那麼為什麼curl命令會沒有結束執行呢?原因在於重複的檔案描述符(duplicate file descriptor)。當子程式關閉客戶端連線時,系統核心會減少客戶端套接字的計數,變成了1。伺服器子程式結束了,但是客戶端套接字並沒有關閉,因為那個套接字的描述符計數並沒有變成0,導致系統沒有向客戶端傳送終止包(termination packet)(用TCP/IP的術語來說叫做FIN),也就是說客戶端仍然線上。但是還有另一個問題。如果你一直執行的伺服器不去關閉重複的檔案描述符,伺服器最終就會耗光可用的檔案伺服器:

自己動手開發一個 Web 伺服器(三)

按下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

很快你的伺服器就會崩潰。下面是我的虛擬機器上丟擲的異常情況:

自己動手開發一個 Web 伺服器(三)

問題很明顯——伺服器應該關閉重複的描述符。但即使你關閉了這些重複的描述符,你還沒有徹底解決問題,因為你的伺服器還存在另一個問題,那就是殭屍程式!

自己動手開發一個 Web 伺服器(三)

沒錯,你的伺服器程式碼確實會產生殭屍程式。我們來看看這是怎麼回事。再次執行伺服器:

$ 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>。這就是我們要找的殭屍程式。殭屍程式的問題在於你無法殺死它們。

自己動手開發一個 Web 伺服器(三)

即使你試圖通過$ 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而崩潰:這個異常指的是暫時沒有足夠的資源。伺服器試圖建立新的子程式時,由於已經達到了系統所允許的最大可建立子程式數,所以丟擲這個異常。下面是我的虛擬機器上的報錯截圖。

自己動手開發一個 Web 伺服器(三)

你也看到了,如果長期執行的伺服器不處理好殭屍程式,將會出現重大問題。稍後我會介紹如何處理殭屍程式。

我們先回顧一下目前已經學習的知識點:

  • 如果你不關閉重複的檔案描述符,由於客戶端連線沒有中斷,客戶端程式就不會結束。
  • 如果你不關閉重複的檔案描述符,你的伺服器最終會消耗完可用的檔案描述符(最大開啟檔案數)
  • 當你fork一個子程式後,如果子程式在父程式之前退出,而父程式又沒有等待程式,並獲取它的結束狀態,那麼子程式就會變成殭屍程式。
  • 殭屍程式也需要消耗資源,也就是記憶體。如果不處理好殭屍程式,你的伺服器最終會消耗完可用的程式數(最大使用者程式數)。
  • 你無法殺死殭屍程式,你需要等待子程式結束。

那麼,你要怎麼做才能處理掉殭屍程式呢?你需要修改伺服器程式碼,等待殭屍程式返回其結束狀態(termination status)。要實現這點,你只需要在程式碼中呼叫wait系統函式即可。不過,這種方法並不是最理想的方案,因為如果你呼叫wait後,卻沒有結束了的子程式,那麼wait呼叫將會阻塞伺服器,相當於阻止了伺服器處理新的客戶端請求。那麼還有其他的辦法嗎?答案是肯定的,其中一種辦法就是將wait函式呼叫與訊號處理函式(signal handler)結合使用。

自己動手開發一個 Web 伺服器(三)

這種方法的具體原理如下。當子程式退出時,系統核心會傳送一個SIGCHLD訊號。父程式可以設定一個訊號處理函式,用於非同步監測SIGCHLD事件,然後再呼叫wait,等待子程式結束並獲取其結束狀態,這樣就可以避免產生殭屍程式。

自己動手開發一個 Web 伺服器(三)

順便說明一下,非同步事件意味著父程式實現並不知道該事件是否會發生。

接下來我們修改伺服器程式碼,新增一個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

我們來看伺服器的反應:

自己動手開發一個 Web 伺服器(三)

發生了什麼事?accept函式呼叫報錯了。

自己動手開發一個 Web 伺服器(三)

子程式退出時,父程式被阻塞在accept函式呼叫的地方,但是子程式的退出導致了SIGCHLD事件,這也啟用了訊號處理函式。訊號函式執行完畢之後,就導致了accept系統函式呼叫被中斷:

自己動手開發一個 Web 伺服器(三)

別擔心,這是個非常容易解決的問題。你只需要重新呼叫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+了。太棒了!沒有殭屍程式搗亂真是太好了。

自己動手開發一個 Web 伺服器(三)

  • 如果你fork一個子程式,卻不等待程式結束,該程式就會變成殭屍程式。
  • 使用SIGCHLD時間處理函式來非同步等待程式結束,獲取其結束狀態。
  • 使用事件處理函式時,你需要牢記系統函式呼叫可能會被中斷,要做好這類情況發生得準備。

好了,目前一切正常。沒有其他問題了,對嗎?呃,基本上是了。再次執行webserver3f.py,然後通過client3.py建立128個並行連線:

$ python client3.py --max-clients 128

現在再次執行ps命令:

$ ps auxw | grep -i python | grep -v grep

噢,糟糕!殭屍程式又出現了!

自己動手開發一個 Web 伺服器(三)

這次又是哪裡出了問題?當你執行128個並行客戶端,建立128個連線時,伺服器的子程式處理完請求,幾乎是同一時間退出的,這就觸發了一大波的SIGCHLD訊號傳送至父程式。但問題是這些訊號並沒有進入佇列,所以有幾個訊號漏網,沒有被伺服器處理,這就導致出現了幾個殭屍程式。

自己動手開發一個 Web 伺服器(三)

這個問題的解決方法,就是在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

現在請確認不會再出現殭屍程式了。

自己動手開發一個 Web 伺服器(三)

恭喜大家!現在已經自己開發了一個簡易的併發伺服器,這個程式碼可以作為你以後開發生產級別的網路伺服器的基礎。

最後給大家留一個練習題,把第二部分中的WSGI修改為併發伺服器。最終的程式碼可以在這裡檢視。不過請你在自己實現了之後再檢視。

接下來該怎麼辦?借用喬希·比林斯(19世紀著名幽默大師)的一句話:

要像一張郵票,堅持一件事情直到你到達目的地。

自己動手開發一個 Web 伺服器(三)

相關文章