自己動手開發一個 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秒。

讓服務器睡眠60秒

讓服務器睡眠60秒

下面就是修改之後的服務器代碼:

  1. #########################################################################
  2. # Iterative server - webserver3b.py #
  3. # #
  4. # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X #
  5. # #
  6. # - Server sleeps for 60 seconds after sending a response to a client #
  7. #########################################################################
  8. import socket
  9. import time
  10. SERVER_ADDRESS = (HOST, PORT) = '', 8888
  11. REQUEST_QUEUE_SIZE = 5
  12. def handle_request(client_connection):
  13. request = client_connection.recv(1024)
  14. print(request.decode())
  15. http_response = b"""\
  16. HTTP/1.1 200 OK
  17. Hello, World!
  18. """
  19. client_connection.sendall(http_response)
  20. time.sleep(60) # sleep and block the process for 60 seconds
  21. def serve_forever():
  22. listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  23. listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  24. listen_socket.bind(SERVER_ADDRESS)
  25. listen_socket.listen(REQUEST_QUEUE_SIZE)
  26. print('Serving HTTP on port {port} ...'.format(port=PORT))
  27. while True:
  28. client_connection, client_address = listen_socket.accept()
  29. handle_request(client_connection)
  30. client_connection.close()
  31. if __name__ == '__main__':
  32. serve_forever()

接下來,我們啓動服務器:

  1. $ python webserver3b.py

現在,我們打開一個新的終端窗口,並運行curl命令。你會立刻看到屏幕上打印出了“Hello, World!”這句話:

  1. $ curl http://localhost:8888/hello
  2. Hello, World!

接着我們立刻再打開一個終端窗口,並運行curl命令:

  1. $ curl http://localhost:8888/hello

如果你在60秒了完成了上面的操作,那麼第二個curl命令應該不會立刻產生任何輸出結果,而是處於掛死(hang)狀態。服務器也不會在標準輸出中打印這個新請求的正文。下面這張圖就是我在自己的Mac上操作時的結果(右下角那個邊緣高亮爲黃色的窗口,顯示的就是第二個curl命令掛死):

Mac上操作時的結果

Mac上操作時的結果

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

curl命令演示

curl命令演示

這背後的實現方式是,服務器處理完第一個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,它們在這裏被稱爲一個套接字(同理,客戶端端點的兩個值也是一個套接字)。

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

服務器創建套接字並開始接受客戶端連接的標準流程

服務器創建套接字並開始接受客戶端連接的標準流程

  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協議與服務器進行通信,客戶端需要作如下操作:

客戶端與服務器進行通信所需要的操作

客戶端與服務器進行通信所需要的操作

下面這段示例代碼,實現了客戶端連接至服務器,發送請求,並打印響應內容的過程:

  1. import socket
  2. # create a socket and connect to a server
  3. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  4. sock.connect(('localhost', 8888))
  5. # send and receive some data
  6. sock.sendall(b'test')
  7. data = sock.recv(1024)
  8. print(data.decode())

在創建套接字之後,客戶端需要與服務器進行連接,這可以通過調用connect方法實現:

  1. sock.connect(('localhost', 8888))

客戶端只需要提供遠程IP地址或主機名,以及服務器的遠程連接端口號即可。

你可能已經注意到,客戶端不會調用bindaccept方法。不需要調用bind方法,是因爲客戶端不關心本地IP地址和本地端口號。客戶端調用connect方法時,系統內核中的TCP/IP棧會自動指定本地IP地址和本地端口。本地端口也被稱爲臨時端口(ephemeral port)。

本地端口——臨時端口號

本地端口——臨時端口號

服務器端有部分端口用於連接熟知的服務,這種端口被叫做“熟知端口”(well-known port),例如,80用於HTTP傳輸服務,22用於SSH協議傳輸。接下來,我們打開Python shell,向在本地運行的服務器發起一個客戶端連接,然後查看系統內核爲你創建的客戶端套接字指定了哪個臨時端口(在進行下面的操作之前,請先運行webserver3a.pywebserver3b.py文件,啓動服務器):

  1. >>> import socket
  2. >>> sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  3. >>> sock.connect(('localhost', 8888))
  4. >>> host, port = sock.getsockname()[:2]
  5. >>> host, port
  6. ('127.0.0.1', 60589)

在上面的示例中,我們看到內核爲套接字指定的臨時端口是60589。

在開始回答第二部分最後提的問題之前,我需要快速介紹一些其他的重要概念。稍後你就會明白我爲什麼要這樣做。我要介紹的重要概念就是進程(process)和文件描述符(file descriptor)。

什麼是進程?進程就是正在執行的程序的一個實例。舉個例子,當服務器代碼執行的時候,這些代碼就被加載至內存中,而這個正在被執行的服務器的實例就叫做進程。系統內核會記錄下有關進程的信息——包括進程ID,以便進行管理。所以,當你運行迭代式服務器webserver3a.pywebserver3b.py時,你也就開啓了一個進程。

服務器進程

服務器進程

我們在終端啓動webserver3a.py服務器:

  1. $ python webserver3b.py

然後,我們在另一個終端窗口中,使用ps命令來獲取上面那個服務器進程的信息:

  1. $ ps | grep webserver3b | grep -v grep
  2. 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。

進程ID與父進程ID

進程ID與父進程ID

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

Mac OS X系統下進程ID與父進程ID演示

Mac OS X系統下進程ID與父進程ID演示

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

文件描述符

文件描述符

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

標準輸入的文件描述符

標準輸入的文件描述符

正如我前面提到的那樣,即使Python語言提供了高層及的文件或類文件對象,你仍然可以對文件對象使用fileno()方法,來獲取該文件相應的文件描述符。我們回到Python shell中來試驗一下。

  1. >>> import sys
  2. >>> sys.stdin
  3. <open file '<stdin>', mode 'r' at 0x102beb0c0>
  4. >>> sys.stdin.fileno()
  5. 0
  6. >>> sys.stdout.fileno()
  7. 1
  8. >>> sys.stderr.fileno()
  9. 2

在Python語言中處理文件和套接字時,你通常只需要使用高層及的文件/套接字對象即可,但是有些時候你也可能需要直接使用文件描述符。下面這個示例演示了你如何通過write()方法向標準輸出中寫入一個字符串,而這個write方法就接受文件描述符作爲自己的參數:

  1. >>> import sys
  2. >>> import os
  3. >>> res = os.write(sys.stdout.fileno(), 'hello\n')
  4. hello

還有一點挺有意思——如果你知道Unix系統下一切都是文件,那麼你就不會覺得奇怪了。當你在Python中創建一個套接字後,你獲得的是一個套接字對象,而不是一個正整型數,但是你還是可以和上面演示的一樣,通過fileno()方法直接訪問這個套接字的文件描述符。

  1. >>> import socket
  2. >>> sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  3. >>> sock.fileno()
  4. 3

我還想再說一點:不知道大家有沒有注意到,在迭代式服務器webserver3b.py的第二個示例中,我們的服務器在處理完請求後睡眠60秒,但是在睡眠期間,我們仍然可以通過curl命令與服務器建立連接?當然,curl命令並沒有立刻輸出結果,只是出於掛死狀態,但是爲什麼服務器既然沒有接受新的連接,客戶端也沒有立刻被拒絕,而是仍然繼續連接至服務器呢?這個問題的答案在於套接字對象的listen方法,以及它使用的BACKLOG參數。在示例代碼中,這個參數的值被我設置爲REQUEST_QUEQUE_SIZEBACKLOG參數決定了內核中外部連接請求的隊列大小。當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()

fork()系統函數調用

fork()系統函數調用

下面就是嶄新的webserver3c.py併發服務器,能夠同時處理多個客戶端請求:

  1. ###########################################################################
  2. # Concurrent server - webserver3c.py #
  3. # #
  4. # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X #
  5. # #
  6. # - Child process sleeps for 60 seconds after handling a client's request #
  7. # - Parent and child processes close duplicate descriptors #
  8. # #
  9. ###########################################################################
  10. import os
  11. import socket
  12. import time
  13. SERVER_ADDRESS = (HOST, PORT) = '', 8888
  14. REQUEST_QUEUE_SIZE = 5
  15. def handle_request(client_connection):
  16. request = client_connection.recv(1024)
  17. print(
  18. 'Child PID: {pid}. Parent PID {ppid}'.format(
  19. pid=os.getpid(),
  20. ppid=os.getppid(),
  21. )
  22. )
  23. print(request.decode())
  24. http_response = b"""\
  25. HTTP/1.1 200 OK
  26. Hello, World!
  27. """
  28. client_connection.sendall(http_response)
  29. time.sleep(60)
  30. def serve_forever():
  31. listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  32. listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  33. listen_socket.bind(SERVER_ADDRESS)
  34. listen_socket.listen(REQUEST_QUEUE_SIZE)
  35. print('Serving HTTP on port {port} ...'.format(port=PORT))
  36. print('Parent PID (PPID): {pid}\n'.format(pid=os.getpid()))
  37. while True:
  38. client_connection, client_address = listen_socket.accept()
  39. pid = os.fork()
  40. if pid == 0: # child
  41. listen_socket.close() # close child copy
  42. handle_request(client_connection)
  43. client_connection.close()
  44. os._exit(0) # child exits here
  45. else: # parent
  46. client_connection.close() # close parent copy and loop over
  47. if __name__ == '__main__':
  48. serve_forever()

在討論fork的工作原理之前,請測試一下上面的代碼,親自確認一下服務器是否能夠同時處理多個客戶端請求。我們通過命令行啓動上面這個服務器:

  1. $ python webserver3c.py

然後輸入之前迭代式服務器示例中的兩個curl命令。現在,即使服務器子進程在處理完一個客戶端請求之後會睡眠60秒,但是並不會影響其他客戶端,因爲它們由不同的、完全獨立的進程處理。你應該可以立刻看見curl命令輸出“Hello, World”,然後掛死60秒。你可以繼續運行更多的curl命令,所有的命令都會輸出服務器的響應結果——“Hello, World”,不會有任何延遲。你可以試試。

關於fork()函數有一點最爲重要,就是你調用fork一次,但是函數卻會返回兩次:一次是在父進程裏返回,另一次是在子進程中返回。當你fork一個進程時,返回給子進程的PID是0,而fork返回給父進程的則是子進程的PID。

fork函數

fork函數

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

當父進程fork一個新的子進程時,子進程會得到父進程文件描述符的副本:

當父進程fork一個新的子進程時,子進程會得到父進程文件描述符的副本

當父進程fork一個新的子進程時,子進程會得到父進程文件描述符的副本

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

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

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

  1. listen_socket.close() # close child copy

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

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

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

並行事件

並行事件

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

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

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

checkpoint

checkpoint

  • Unix系統中開發並行服務器最簡單的方法,就是調用fork()函數
  • 當一個進程fork新進程時,它就成了新創建進程的父進程
  • 在調用fork之後,父進程和子進程共用相同的文件描述符
  • 系統內核通過描述符計數來決定是否關閉文件/套接字
  • 服務器父進程的角色:它現在所做的只是接收來自客戶端的新連接,fork一個子進程來處理該客戶端的請求,然後回到循環的起點,準備接受新的客戶端連接

接下來,我們看看如果不關閉父進程和子進程中的重複套接字描述符,會發生什麼情況。下面的並行服務器(webserver3d.py)作了一些修改,確保服務器不關閉重複的:

  1. ###########################################################################
  2. # Concurrent server - webserver3d.py #
  3. # #
  4. # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X #
  5. ###########################################################################
  6. import os
  7. import socket
  8. SERVER_ADDRESS = (HOST, PORT) = '', 8888
  9. REQUEST_QUEUE_SIZE = 5
  10. def handle_request(client_connection):
  11. request = client_connection.recv(1024)
  12. http_response = b"""\
  13. HTTP/1.1 200 OK
  14. Hello, World!
  15. """
  16. client_connection.sendall(http_response)
  17. def serve_forever():
  18. listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  19. listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  20. listen_socket.bind(SERVER_ADDRESS)
  21. listen_socket.listen(REQUEST_QUEUE_SIZE)
  22. print('Serving HTTP on port {port} ...'.format(port=PORT))
  23. clients = []
  24. while True:
  25. client_connection, client_address = listen_socket.accept()
  26. # store the reference otherwise it's garbage collected
  27. # on the next loop run
  28. clients.append(client_connection)
  29. pid = os.fork()
  30. if pid == 0: # child
  31. listen_socket.close() # close child copy
  32. handle_request(client_connection)
  33. client_connection.close()
  34. os._exit(0) # child exits here
  35. else: # parent
  36. # client_connection.close()
  37. print(len(clients))
  38. if __name__ == '__main__':
  39. serve_forever()

啓動服務器:

  1. $ python webserver3d.py

然後通過curl命令連接至服務器:

  1. $ curl http://localhost:8888/hello
  2. Hello, World!

我們看到,curl命令打印了並行服務器的響應內容,但是並沒有結束,而是繼續掛死。服務器出現了什麼不同情況嗎?服務器不再繼續睡眠60秒:它的子進程會積極處理客戶端請求,處理完成後就關閉客戶端連接,然後結束運行,但是客戶端的curl命令卻不會終止。

服務器不再睡眠,其子進程積極處理客戶端請求

服務器不再睡眠,其子進程積極處理客戶端請求

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

文件描述符

文件描述符

按下Control-C,關閉webserver3d.py服務器,然後通過shell自帶的ulimit命令查看服務器進程可以使用的默認資源:

  1. $ ulimit -a
  2. core file size (blocks, -c) 0
  3. data seg size (kbytes, -d) unlimited
  4. scheduling priority (-e) 0
  5. file size (blocks, -f) unlimited
  6. pending signals (-i) 3842
  7. max locked memory (kbytes, -l) 64
  8. max memory size (kbytes, -m) unlimited
  9. open files (-n) 1024
  10. pipe size (512 bytes, -p) 8
  11. POSIX message queues (bytes, -q) 819200
  12. real-time priority (-r) 0
  13. stack size (kbytes, -s) 8192
  14. cpu time (seconds, -t) unlimited
  15. max user processes (-u) 3842
  16. virtual memory (kbytes, -v) unlimited
  17. file locks (-x) unlimited

從上面的結果中,我們可以看到:在我這臺Ubuntu電腦上,服務器進程可以使用的文件描述符(打開的文件)最大數量爲1024。

現在,我們來看看如果服務器不關閉重複的文件描述符,服務器會不會耗盡可用的文件描述符。我們在現有的或新開的終端窗口裏,將服務器可以使用的最大文件描述符數量設置爲256:

  1. $ ulimit -n 256

在剛剛運行了$ ulimit -n 256命令的終端裏,我們開啓webserver3d.py服務器:

  1. $ python webserver3d.py

然後通過下面的client3.py客戶端來測試服務器。

  1. #####################################################################
  2. # Test client - client3.py #
  3. # #
  4. # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X #
  5. #####################################################################
  6. import argparse
  7. import errno
  8. import os
  9. import socket
  10. SERVER_ADDRESS = 'localhost', 8888
  11. REQUEST = b"""\
  12. GET /hello HTTP/1.1
  13. Host: localhost:8888
  14. """
  15. def main(max_clients, max_conns):
  16. socks = []
  17. for client_num in range(max_clients):
  18. pid = os.fork()
  19. if pid == 0:
  20. for connection_num in range(max_conns):
  21. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  22. sock.connect(SERVER_ADDRESS)
  23. sock.sendall(REQUEST)
  24. socks.append(sock)
  25. print(connection_num)
  26. os._exit(0)
  27. if __name__ == '__main__':
  28. parser = argparse.ArgumentParser(
  29. description='Test client for LSBAWS.',
  30. formatter_class=argparse.ArgumentDefaultsHelpFormatter,
  31. )
  32. parser.add_argument(
  33. '--max-conns',
  34. type=int,
  35. default=1024,
  36. help='Maximum number of connections per client.'
  37. )
  38. parser.add_argument(
  39. '--max-clients',
  40. type=int,
  41. default=1,
  42. help='Maximum number of clients.'
  43. )
  44. args = parser.parse_args()
  45. 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虛擬機中的結果:

  1. $ ps auxw | grep -i python | grep -v grep
  2. vagrant 9099 0.0 1.2 31804 6256 pts/0 S+ 16:33 0:00 python webserver3d.py
  3. 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)。

  1. $ ulimit -u 400
  2. $ ulimit -n 500

然後在同一個窗口中啓動webserver3d.py服務器:

  1. $ python webserver3d.py

在新終端窗口中,啓動客戶端client3.py,讓客戶端創建500個服務器並行連接:

  1. $ python client3.py --max-clients=500

結果,我們發現很快服務器就因爲OSError而崩潰:這個異常指的是暫時沒有足夠的資源。服務器試圖創建新的子進程時,由於已經達到了系統所允許的最大可創建子進程數,所以拋出這個異常。下面是我的虛擬機上的報錯截圖。

OSError異常

OSError異常

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

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

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

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

信號處理函數

信號處理函數

這種方法的具體原理如下。當子進程退出時,系統內核會發送一個SIGCHLD信號。父進程可以設置一個信號處理函數,用於異步監測SIGCHLD事件,然後再調用wait,等待子進程結束並獲取其結束狀態,這樣就可以避免產生殭屍進程。

SIGCHLD信號與wait函數結合使用

SIGCHLD信號與wait函數結合使用

順便說明一下,異步事件意味着父進程實現並不知道該事件是否會發生。

接下來我們修改服務器代碼,添加一個SIGCHLD事件處理函數,並在該函數中等待子進程結束。具體的代碼見webserver3e.py文件:

  1. ###########################################################################
  2. # Concurrent server - webserver3e.py #
  3. # #
  4. # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X #
  5. ###########################################################################
  6. import os
  7. import signal
  8. import socket
  9. import time
  10. SERVER_ADDRESS = (HOST, PORT) = '', 8888
  11. REQUEST_QUEUE_SIZE = 5
  12. def grim_reaper(signum, frame):
  13. pid, status = os.wait()
  14. print(
  15. 'Child {pid} terminated with status {status}'
  16. '\n'.format(pid=pid, status=status)
  17. )
  18. def handle_request(client_connection):
  19. request = client_connection.recv(1024)
  20. print(request.decode())
  21. http_response = b"""\
  22. HTTP/1.1 200 OK
  23. Hello, World!
  24. """
  25. client_connection.sendall(http_response)
  26. # sleep to allow the parent to loop over to 'accept' and block there
  27. time.sleep(3)
  28. def serve_forever():
  29. listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  30. listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  31. listen_socket.bind(SERVER_ADDRESS)
  32. listen_socket.listen(REQUEST_QUEUE_SIZE)
  33. print('Serving HTTP on port {port} ...'.format(port=PORT))
  34. signal.signal(signal.SIGCHLD, grim_reaper)
  35. while True:
  36. client_connection, client_address = listen_socket.accept()
  37. pid = os.fork()
  38. if pid == 0: # child
  39. listen_socket.close() # close child copy
  40. handle_request(client_connection)
  41. client_connection.close()
  42. os._exit(0)
  43. else: # parent
  44. client_connection.close()
  45. if __name__ == '__main__':
  46. serve_forever()

啓動服務器:

  1. $ python webserver3e.py

再次使用curl命令,向修改後的併發服務器發送一個請求:

  1. $ curl http://localhost:8888/hello

我們來看服務器的反應:

修改後的併發服務器處理請求

修改後的併發服務器處理請求

發生了什麼事?accept函數調用報錯了。

accept函數調用失敗

accept函數調用失敗

子進程退出時,父進程被阻塞在accept函數調用的地方,但是子進程的退出導致了SIGCHLD事件,這也激活了信號處理函數。信號函數執行完畢之後,就導致了accept系統函數調用被中斷:

accept調用被中斷

accept調用被中斷

別擔心,這是個非常容易解決的問題。你只需要重新調用accept即可。下面我們再修改一下服務器代碼(webserver3f.py),就可以解決這個問題:

  1. ###########################################################################
  2. # Concurrent server - webserver3f.py #
  3. # #
  4. # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X #
  5. ###########################################################################
  6. import errno
  7. import os
  8. import signal
  9. import socket
  10. SERVER_ADDRESS = (HOST, PORT) = '', 8888
  11. REQUEST_QUEUE_SIZE = 1024
  12. def grim_reaper(signum, frame):
  13. pid, status = os.wait()
  14. def handle_request(client_connection):
  15. request = client_connection.recv(1024)
  16. print(request.decode())
  17. http_response = b"""\
  18. HTTP/1.1 200 OK
  19. Hello, World!
  20. """
  21. client_connection.sendall(http_response)
  22. def serve_forever():
  23. listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  24. listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  25. listen_socket.bind(SERVER_ADDRESS)
  26. listen_socket.listen(REQUEST_QUEUE_SIZE)
  27. print('Serving HTTP on port {port} ...'.format(port=PORT))
  28. signal.signal(signal.SIGCHLD, grim_reaper)
  29. while True:
  30. try:
  31. client_connection, client_address = listen_socket.accept()
  32. except IOError as e:
  33. code, msg = e.args
  34. # restart 'accept' if it was interrupted
  35. if code == errno.EINTR:
  36. continue
  37. else:
  38. raise
  39. pid = os.fork()
  40. if pid == 0: # child
  41. listen_socket.close() # close child copy
  42. handle_request(client_connection)
  43. client_connection.close()
  44. os._exit(0)
  45. else: # parent
  46. client_connection.close() # close parent copy and loop over
  47. if __name__ == '__main__':
  48. serve_forever()

啓動修改後的服務器:

  1. $ python webserver3f.py

通過curl命令向服務器發送一個請求:

  1. $ curl http://localhost:8888/hello

看到了嗎?沒有再報錯了。現在,我們來確認下服務器沒有再產生殭屍進程。只需要運行ps命令,你就會發現沒有Python進程的狀態是Z+了。太棒了!沒有殭屍進程搗亂真是太好了。

checkpoint

checkpoint

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

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

  1. $ python client3.py --max-clients 128

現在再次運行ps命令:

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

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

殭屍進程又出現了

殭屍進程又出現了

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

部分信號沒有被處理,導致出現殭屍進程

部分信號沒有被處理,導致出現殭屍進程

這個問題的解決方法,就是在SIGCHLD事件處理函數使用waitpid,而不是wait,再調用waitpid時增加WNOHANG選項,確保所有退出的子進程都會被處理。下面就是修改後的代碼,webserver3g.py:

  1. ###########################################################################
  2. # Concurrent server - webserver3g.py #
  3. # #
  4. # Tested with Python 2.7.9 & Python 3.4 on Ubuntu 14.04 & Mac OS X #
  5. ###########################################################################
  6. import errno
  7. import os
  8. import signal
  9. import socket
  10. SERVER_ADDRESS = (HOST, PORT) = '', 8888
  11. REQUEST_QUEUE_SIZE = 1024
  12. def grim_reaper(signum, frame):
  13. while True:
  14. try:
  15. pid, status = os.waitpid(
  16. -1, # Wait for any child process
  17. os.WNOHANG # Do not block and return EWOULDBLOCK error
  18. )
  19. except OSError:
  20. return
  21. if pid == 0: # no more zombies
  22. return
  23. def handle_request(client_connection):
  24. request = client_connection.recv(1024)
  25. print(request.decode())
  26. http_response = b"""\
  27. HTTP/1.1 200 OK
  28. Hello, World!
  29. """
  30. client_connection.sendall(http_response)
  31. def serve_forever():
  32. listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  33. listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  34. listen_socket.bind(SERVER_ADDRESS)
  35. listen_socket.listen(REQUEST_QUEUE_SIZE)
  36. print('Serving HTTP on port {port} ...'.format(port=PORT))
  37. signal.signal(signal.SIGCHLD, grim_reaper)
  38. while True:
  39. try:
  40. client_connection, client_address = listen_socket.accept()
  41. except IOError as e:
  42. code, msg = e.args
  43. # restart 'accept' if it was interrupted
  44. if code == errno.EINTR:
  45. continue
  46. else:
  47. raise
  48. pid = os.fork()
  49. if pid == 0: # child
  50. listen_socket.close() # close child copy
  51. handle_request(client_connection)
  52. client_connection.close()
  53. os._exit(0)
  54. else: # parent
  55. client_connection.close() # close parent copy and loop over
  56. if __name__ == '__main__':
  57. serve_forever()

啓動服務器:

  1. $ python webserver3g.py

使用客戶端client3.py進行測試:

  1. $ python client3.py --max-clients 128

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

不會再出現殭屍進程了

不會再出現殭屍進程了

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

最後給大家留一個練習題,把第二部分中的WSGI修改爲併發服務器。最終的代碼可以在這裏查看。不過請你在自己實現了之後再查看。

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

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

堅持就是勝利

堅持就是勝利

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章