閱讀356 返回首頁    go 阿裏雲 go 技術社區[雲棲]


搭個 Web 服務器(三)

“隻有在創造中才能夠學到更多。” ——皮亞傑

在本係列的第二部分中,你創造了一個可以處理基本 HTTP GET 請求的、樸素的 WSGI 服務器。當時我問了一個問題:“你該如何讓你的服務器在同一時間處理多個請求呢?”在這篇文章中,你會找到答案。係好安全帶,我們要認真起來,全速前進了!你將會體驗到一段非常快速的旅程。準備好你的 Linux、Mac OS X(或者其他 *nix 係統),還有你的 Python。本文中所有源代碼均可在 GitHub 上找到。

服務器的基本結構及如何處理請求

首先,我們來回顧一下 Web 服務器的基本結構,以及服務器處理來自客戶端的請求時,所需的必要步驟。你在第一部分第二部分中創建的輪詢服務器隻能夠一次處理一個請求。在處理完當前請求之前,它不能夠接受新的客戶端連接。所有請求為了等待服務都需要排隊,在服務繁忙時,這個隊伍可能會排的很長,一些客戶端可能會感到不開心。

這是輪詢服務器 webserver3a.py 的代碼:


  1. #####################################################################
  2. # 輪詢服務器 - webserver3a.py #
  3. # #
  4. # 使用 Python 2.7.9 3.4 #
  5. # Ubuntu 14.04 Mac OS X 環境下測試通過 #
  6. #####################################################################
  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. print(request.decode())
  13. http_response = b"""\
  14. HTTP/1.1 200 OK
  15. Hello, World!
  16. """
  17. client_connection.sendall(http_response)
  18. def serve_forever():
  19. listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  20. listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  21. listen_socket.bind(SERVER_ADDRESS)
  22. listen_socket.listen(REQUEST_QUEUE_SIZE)
  23. print('Serving HTTP on port {port} ...'.format(port=PORT))
  24. while True:
  25. client_connection, client_address = listen_socket.accept()
  26. handle_request(client_connection)
  27. client_connection.close()
  28. if __name__ == '__main__':
  29. serve_forever()

為了觀察到你的服務器在同一時間隻能處理一個請求的行為,我們對服務器的代碼做一點點修改:在將響應發送至客戶端之後,將程序阻塞 60 秒。這個修改隻需要一行代碼,來告訴服務器進程暫停 60 秒鍾。

這是我們更改後的代碼,包含暫停語句的服務器 webserver3b.py


  1. ######################################################################
  2. # 輪詢服務器 - webserver3b.py #
  3. # #
  4. # 使用 Python 2.7.9 3.4 #
  5. # Ubuntu 14.04 Mac OS X 環境下測試通過 #
  6. # #
  7. # - 服務器向客戶端發送響應之後,會阻塞 60 #
  8. ######################################################################
  9. import socket
  10. import time
  11. SERVER_ADDRESS = (HOST, PORT) = '', 8888
  12. REQUEST_QUEUE_SIZE = 5
  13. def handle_request(client_connection):
  14. request = client_connection.recv(1024)
  15. print(request.decode())
  16. http_response = b"""\
  17. HTTP/1.1 200 OK
  18. Hello, World!
  19. """
  20. client_connection.sendall(http_response)
  21. time.sleep(60) ### 睡眠語句,阻塞該進程 60 秒
  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. while True:
  29. client_connection, client_address = listen_socket.accept()
  30. handle_request(client_connection)
  31. client_connection.close()
  32. if __name__ == '__main__':
  33. 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 指令不會立刻產生任何輸出,而隻是掛在了哪裏。同樣,服務器也不會在標準輸出流中輸出新的請求內容。這是這個過程在我的 Mac 電腦上的運行結果(在右下角用黃色框標注出來的窗口中,我們能看到第二個 curl 指令被掛起,正在等待連接被服務器接受):

當你等待足夠長的時間(60 秒以上)後,你會看到第一個 curl 程序完成,而第二個 curl 在屏幕上輸出了“Hello, World!”,然後休眠 60 秒,進而終止。

這樣運行的原因是因為在服務器在處理完第一個來自 curl 的請求之後,隻有等待 60 秒才能開始處理第二個請求。這個處理請求的過程按順序進行(也可以說,迭代進行),一步一步進行,在我們剛剛給出的例子中,在同一時間內隻能處理一個請求。

現在,我們來簡單討論一下客戶端與服務器的交流過程。為了讓兩個程序在網絡中互相交流,它們必須使用套接字。你應當在本係列的前兩部分中見過它幾次了。但是,套接字是什麼?

套接字socket是一個通訊通道端點endpoint的抽象描述,它可以讓你的程序通過文件描述符來與其它程序進行交流。在這篇文章中,我隻會單獨討論 Linux 或 Mac OS X 中的 TCP/IP 套接字。這裏有一個重點概念需要你去理解:TCP套接字對socket pair。

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),代表一個套接字;另外兩個值則代表客戶端的套接字。

一個服務器創建一個套接字並開始建立連接的基本工作流程如下:

  1. 服務器創建一個 TCP/IP 套接字。我們可以用這條 Python 語句來創建:

    
    
    1. listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  2. 服務器可能會設定一些套接字選項(這個步驟是可選的,但是你可以看到上麵的服務器代碼做了設定,這樣才能夠在重啟服務器時多次複用同一地址):

    
    
    1. listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  3. 然後,服務器綁定一個地址。綁定函數 bind 可以將一個本地協議地址賦給套接字。若使用 TCP 協議,調用綁定函數 bind 時,需要指定一個端口號,一個 IP 地址,或兩者兼有,或兩者全無。(引自《UNIX網絡編程 卷1:套接字聯網 API (第3版)》

    
    
    1. listen_socket.bind(SERVER_ADDRESS)
  4. 然後,服務器開啟套接字的監聽模式。

    
    
    1. listen_socket.listen(REQUEST_QUEUE_SIZE)

監聽函數 listen 隻應在服務端調用。它會通知操作係統內核,表明它會接受所有向該套接字發送的入站連接請求。

以上四步完成後,服務器將循環接收來自客戶端的連接,一次循環處理一條。當有連接可用時,接受請求函數accept 將會返回一個已連接的客戶端套接字。然後,服務器從這個已連接的客戶端套接字中讀取請求數據,將數據在其標準輸出流中輸出出來,並向客戶端回送一條消息。然後,服務器會關閉這個客戶端連接,並準備接收一個新的客戶端連接。

這是客戶端使用 TCP/IP 協議與服務器通信的必要步驟:

下麵是一段示例代碼,使用這段代碼,客戶端可以連接你的服務器,發送一個請求,並輸出響應內容:


  1. import socket
  2. ### 創建一個套接字,並連接值服務器
  3. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  4. sock.connect(('localhost', 8888))
  5. ### 發送一段數據,並接收響應數據
  6. sock.sendall(b'test')
  7. data = sock.recv(1024)
  8. print(data.decode())

在創建套接字後,客戶端需要連接至服務器。我們可以調用連接函數 connect 來完成這個操作:


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

客戶端隻需提供待連接的遠程服務器的 IP 地址(或主機名),及端口號,即可連接至遠端服務器。

你可能已經注意到了,客戶端不需要調用 bind 及 accept 函數,就可以與服務器建立連接。客戶端不需要調用 bind 函數是因為客戶端不需要關注本地 IP 地址及端口號。操作係統內核中的 TCP/IP 協議棧會在客戶端調用 connect 函數時,自動為套接字分配本地 IP 地址及本地端口號。這個本地端口被稱為臨時端口ephemeral port,即一個短暫開放的端口。

服務器中有一些端口被用於承載一些眾所周知的服務,它們被稱作通用well-known端口:如 80 端口用於 HTTP 服務,22 端口用於 SSH 服務。打開你的 Python shell,與你在本地運行的服務器建立一個連接,來看看內核給你的客戶端套接字分配了哪個臨時端口(在嚐試這個例子之前,你需要運行服務器程序 webserver3a.py 或webserver3b.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 分配給了你的套接字。

在我開始回答我在第二部分中提出的問題之前,我還需要快速講解一些概念。你很快就會明白這些概念為什麼非常重要。這兩個概念,一個是進程,另外一個是文件描述符。

什麼是進程?進程就是一個程序執行的實體。舉個例子:當你的服務器代碼被執行時,它會被載入內存,而內存中表現此次程序運行的實體就叫做進程。內核記錄了進程的一係列有關信息——比如進程 ID——來追蹤它的運行情況。當你在執行輪詢服務器 webserver3a.py 或 webserver3b.py 時,你其實隻是啟動了一個進程。

我們在終端窗口中運行 webserver3b.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.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 中,我們來看看你該怎麼做到這一點:


  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 中操作文件及套接字時,你可能會使用高層次的文件/套接字對象,但是你仍然有可能會直接使用文件描述符。下麵有一個例子,來演示如何用文件描述符做參數來進行一次寫入的係統調用:


  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_QUEUE_SIZE 常量來表示。BACKLOG 參數決定了在內核中為存放即將到來的連接請求所創建的隊列的大小。當服務器 webserver3b.py 在睡眠的時候,你運行的第二個 curl命令依然能夠連接至服務器,因為內核中用來存放即將接收的連接請求的隊列依然擁有足夠大的可用空間。

盡管增大 BACKLOG 參數並不能神奇地使你的服務器同時處理多個請求,但當你的服務器很繁忙時,將它設置為一個較大的值還是相當重要的。這樣,在你的服務器調用 accept 方法時,不需要再等待一個新的連接建立,而可以立刻直接抓取隊列中的第一個客戶端連接,並不加停頓地立刻處理它。

歐耶!現在你已經了解了一大塊內容。我們來快速回顧一下我們剛剛講解的知識(當然,如果這些對你來說都是基礎知識的話,那我們就當複習好啦)。

  • 輪詢服務器
  • 服務端套接字創建流程(創建套接字,綁定,監聽及接受)
  • 客戶端連接創建流程(創建套接字,連接)
  • 套接字對
  • 套接字
  • 臨時端口及通用端口
  • 進程
  • 進程 ID(PID),父進程 ID(PPID),以及進程父子關係
  • 文件描述符
  • 套接字的 listen 方法中,BACKLOG 參數的含義

如何並發處理多個請求

現在,我可以開始回答第二部分中的那個問題了:“你該如何讓你的服務器在同一時間處理多個請求呢?”或者換一種說法:“如何編寫一個並發服務器?”

在 UNIX 係統中編寫一個並發服務器最簡單的方法,就是使用係統調用 fork()

下麵是全新出爐的並發服務器 webserver3c.py 的代碼,它可以同時處理多個請求(和我們之前的例子webserver3b.py 一樣,每個子進程都會休眠 60 秒):


  1. #######################################################
  2. # 並發服務器 - webserver3c.py #
  3. # #
  4. # 使用 Python 2.7.9 3.4 #
  5. # Ubuntu 14.04 Mac OS X 環境下測試通過 #
  6. # #
  7. # - 完成客戶端請求處理之後,子進程會休眠 60 #
  8. # - 父子進程會關閉重複的描述符 #
  9. # #
  10. #######################################################
  11. import os
  12. import socket
  13. import time
  14. SERVER_ADDRESS = (HOST, PORT) = '', 8888
  15. REQUEST_QUEUE_SIZE = 5
  16. def handle_request(client_connection):
  17. request = client_connection.recv(1024)
  18. print(
  19. 'Child PID: {pid}. Parent PID {ppid}'.format(
  20. pid=os.getpid(),
  21. ppid=os.getppid(),
  22. )
  23. )
  24. print(request.decode())
  25. http_response = b"""\
  26. HTTP/1.1 200 OK
  27. Hello, World!
  28. """
  29. client_connection.sendall(http_response)
  30. time.sleep(60)
  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. print('Parent PID (PPID): {pid}\n'.format(pid=os.getpid()))
  38. while True:
  39. client_connection, client_address = listen_socket.accept()
  40. pid = os.fork()
  41. if pid == 0: ### 子進程
  42. listen_socket.close() ### 關閉子進程中複製的套接字對象
  43. handle_request(client_connection)
  44. client_connection.close()
  45. os._exit(0) ### 子進程在這裏退出
  46. else: ### 父進程
  47. client_connection.close() ### 關閉父進程中的客戶端連接對象,並循環執行
  48. if __name__ == '__main__':
  49. serve_forever()

在深入研究代碼、討論 fork 如何工作之前,先嚐試運行它,自己看一看這個服務器是否真的可以同時處理多個客戶端請求,而不是像輪詢服務器 webserver3a.py 和 webserver3b.py 一樣。在命令行中使用如下命令啟動服務器:


  1. $ python webserver3c.py

然後,像我們之前測試輪詢服務器那樣,運行兩個 curl 命令,來看看這次的效果。現在你可以看到,即使子進程在處理客戶端請求後會休眠 60 秒,但它並不會影響其它客戶端連接,因為他們都是由完全獨立的進程來處理的。你應該看到你的 curl 命令立即輸出了“Hello, World!”然後掛起 60 秒。你可以按照你的想法運行盡可能多的 curl 命令(好吧,並不能運行特別特別多 ^_^),所有的命令都會立刻輸出來自服務器的響應 “Hello, World!”,並不會出現任何可被察覺到的延遲行為。試試看吧。

如果你要理解 fork(),那最重要的一點是:你調用了它一次,但是它會返回兩次 —— 一次在父進程中,另一次是在子進程中。當你創建了一個新進程,那麼 fork() 在子進程中的返回值是 0。如果是在父進程中,那fork() 函數會返回子進程的 PID。

我依然記得在第一次看到它並嚐試使用 fork() 的時候,我是多麼的入迷。它在我眼裏就像是魔法一樣。這就好像我在讀一段順序執行的代碼,然後“砰!”地一聲,代碼變成了兩份,然後出現了兩個實體,同時並行地運行相同的代碼。講真,那個時候我覺得它真的跟魔法一樣神奇。

當父進程創建出一個新的子進程時,子進程會複製從父進程中複製一份文件描述符:

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


  1. else: ### 父進程
  2. client_connection.close() # 關閉父進程的副本並循環

不過,既然父進程關閉了這個套接字,那為什麼子進程仍然能夠從來自客戶端的套接字中讀取數據呢?答案就在上麵的圖片中。內核會使用描述符引用計數器來決定是否要關閉一個套接字。當你的服務器創建一個子進程時,子進程會複製父進程的所有文件描述符,內核中該描述符的引用計數也會增加。如果隻有一個父進程及一個子進程,那客戶端套接字的文件描述符引用數應為 2;當父進程關閉客戶端連接的套接字時,內核隻會減少它的引用計數,將其變為 1,但這仍然不會使內核關閉該套接字。子進程也關閉了父進程中 listen_socket 的複製實體,因為子進程不需要關注新的客戶端連接,而隻需要處理已建立的客戶端連接中的請求。


  1. listen_socket.close() ### 關閉子進程中的複製實體

我們將會在後文中討論,如果你不關閉那些重複的描述符,會發生什麼。

你可以從你的並發服務器源碼中看到,父進程的主要職責為:接受一個新的客戶端連接,複製出一個子進程來處理這個連接,然後繼續循環來接受另外的客戶端連接,僅此而已。服務器父進程並不會處理客戶端連接——子進程才會做這件事。

打個岔:當我們說兩個事件並發執行時,我們所要表達的意思是什麼?

當我們說“兩個事件並發執行”時,它通常意味著這兩個事件同時發生。簡單來講,這個定義沒問題,但你應該記住它的嚴格定義:

如果你不能在代碼中判斷兩個事件的發生順序,那這兩個事件就是並發執行的。(引自《信號係統簡明手冊 (第二版): 並發控製深入淺出及常見錯誤》

好的,現在你又該回顧一下你剛剛學過的知識點了。

  • 在 Unix 中,編寫一個並發服務器的最簡單的方式——使用 fork() 係統調用;
  • 當一個進程分叉(fork)出另一個進程時,它會變成剛剛分叉出的進程的父進程;
  • 在進行 fork 調用後,父進程和子進程共享相同的文件描述符;
  • 係統內核通過描述符的引用計數來決定是否要關閉該描述符對應的文件或套接字;
  • 服務器父進程的主要職責:現在它做的隻是從客戶端接受一個新的連接,分叉出子進程來處理這個客戶端連接,然後開始下一輪循環,去接收新的客戶端連接。

進程分叉後不關閉重複的套接字會發生什麼?

我們來看看,如果我們不在父進程與子進程中關閉重複的套接字描述符會發生什麼。下麵是剛才的並發服務器代碼的修改版本,這段代碼(webserver3d.py 中,服務器不會關閉重複的描述符):


  1. #######################################################
  2. # 並發服務器 - webserver3d.py #
  3. # #
  4. # 使用 Python 2.7.9 3.4 #
  5. # Ubuntu 14.04 Mac OS X 環境下測試通過 #
  6. #######################################################
  7. import os
  8. import socket
  9. SERVER_ADDRESS = (HOST, PORT) = '', 8888
  10. REQUEST_QUEUE_SIZE = 5
  11. def handle_request(client_connection):
  12. request = client_connection.recv(1024)
  13. http_response = b"""\
  14. HTTP/1.1 200 OK
  15. Hello, World!
  16. """
  17. client_connection.sendall(http_response)
  18. def serve_forever():
  19. listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  20. listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  21. listen_socket.bind(SERVER_ADDRESS)
  22. listen_socket.listen(REQUEST_QUEUE_SIZE)
  23. print('Serving HTTP on port {port} ...'.format(port=PORT))
  24. clients = []
  25. while True:
  26. client_connection, client_address = listen_socket.accept()
  27. ### 將引用存儲起來,否則在下一輪循環時,他們會被垃圾回收機製銷毀
  28. clients.append(client_connection)
  29. pid = os.fork()
  30. if pid == 0: ### 子進程
  31. listen_socket.close() ### 關閉子進程中多餘的套接字
  32. handle_request(client_connection)
  33. client_connection.close()
  34. os._exit(0) ### 子進程在這裏結束
  35. else: ### 父進程
  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 不終止呢?原因就在於文件描述符的副本。當子進程關閉客戶端連接時,係統內核會減少客戶端套接字的引用計數,將其變為 1。服務器子進程退出了,但客戶端套接字並沒有被內核關閉,因為該套接字的描述符引用計數並沒有變為 0,所以,這就導致了連接終止包(在 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. # 測試客戶端 - client3.py #
  3. # #
  4. # 使用 Python 2.7.9 3.4 #
  5. # Ubuntu 14.04 Mac OS X 環境下測試通過 #
  6. #######################################################
  7. import argparse
  8. import errno
  9. import os
  10. import socket
  11. SERVER_ADDRESS = 'localhost', 8888
  12. REQUEST = b"""\
  13. GET /hello HTTP/1.1
  14. Host: localhost:8888
  15. """
  16. def main(max_clients, max_conns):
  17. socks = []
  18. for client_num in range(max_clients):
  19. pid = os.fork()
  20. if pid == 0:
  21. for connection_num in range(max_conns):
  22. sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  23. sock.connect(SERVER_ADDRESS)
  24. sock.sendall(REQUEST)
  25. socks.append(sock)
  26. print(connection_num)
  27. os._exit(0)
  28. if __name__ == '__main__':
  29. parser = argparse.ArgumentParser(
  30. description='Test client for LSBAWS.',
  31. formatter_class=argparse.ArgumentDefaultsHelpFormatter,
  32. )
  33. parser.add_argument最後更新:2017-06-06 16:02:26

      上一篇:go  函數式 TypeScript
      下一篇:go  內容安全策略(CSP),防禦 XSS 攻擊的好助手