搭個 Web 伺服器(三)

贊 回覆發表於2016-10-09

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

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

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

首先,我們來回顧一下 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 秒才能開始處理第二個請求。這個處理請求的過程按順序進行(也可以說,迭代進行),一步一步進行,在我們剛剛給出的例子中,在同一時間內只能處理一個請求。

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

套接字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 語句來建立:

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

    listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    
  3. 然後,伺服器繫結一個地址。繫結函式 bind 可以將一個本地協議地址賦給套接字。若使用 TCP 協議,呼叫繫結函式 bind 時,需要指定一個埠號,一個 IP 地址,或兩者兼有,或兩者全無。(引自《UNIX網路程式設計 卷1:套接字聯網 API (第3版)》

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

    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 地址(或主機名),及埠號,即可連線至遠端伺服器。

你可能已經注意到了,客戶端不需要呼叫 bindaccept 函式,就可以與伺服器建立連線。客戶端不需要呼叫 bind 函式是因為客戶端不需要關注本地 IP 地址及埠號。作業系統核心中的 TCP/IP 協議棧會在客戶端呼叫 connect 函式時,自動為套接字分配本地 IP 地址及本地埠號。這個本地埠被稱為臨時埠ephemeral port,即一個短暫開放的埠。

伺服器中有一些埠被用於承載一些眾所周知的服務,它們被稱作通用well-known埠:如 80 埠用於 HTTP 服務,22 埠用於 SSH 服務。開啟你的 Python shell,與你在本地執行的伺服器建立一個連線,來看看核心給你的客戶端套接字分配了哪個臨時埠(在嘗試這個例子之前,你需要執行伺服器程式 webserver3a.pywebserver3b.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.pywebserver3b.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(), '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_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.pywebserver3b.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

過一會,你的伺服器程式就該爆了。這是我的環境中出現的異常截圖:

這個例子很明顯——你的伺服器應該關閉描述符副本。

殭屍程式

但是,即使你關閉了描述符副本,你依然沒有擺脫險境,因為你的伺服器還有一個問題,這個問題在於“殭屍zombies”!

沒錯,這個伺服器程式碼確實在製造殭屍程式。我們來看看怎麼回事。重新執行你的伺服器:

$ 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 來殺死殭屍程式,它們仍舊會存活。自己試試看,看看結果。

這個殭屍到底是什麼,為什麼我們的伺服器會造出它們呢?一個殭屍程式zombie是一個已經結束的程式,但它的父程式並沒有等待(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 譯註

下面是一份書單,我從這些書中提煉出了這篇文章所需的素材。他們能助你在我剛剛所述的幾個方面中發掘出兼具深度和廣度的知識。我極力推薦你們去搞到這幾本書看看:從你的朋友那裡借,在當地的圖書館中閱讀,或者直接在亞馬遜上把它買回來。下面是我的典藏祕籍:

  1. 《UNIX 網路程式設計 卷1:套接字聯網 API (第3版)》
  2. 《UNIX 環境高階程式設計(第3版)》
  3. 《Linux/UNIX 系統程式設計手冊》
  4. 《TCP/IP 詳解 卷1:協議(第2版)
  5. 《訊號系統簡明手冊 (第二版): 併發控制深入淺出及常見錯誤》,這本書也可以從作者的個人網站中免費下載到。

順便,我在撰寫一本名為《搭個 Web 伺服器:從頭開始》的書。這本書講解了如何從頭開始編寫一個基本的 Web 伺服器,裡面包含本文中沒有的更多細節。訂閱原文下方的郵件列表,你就可以獲取到這本書的最新進展,以及釋出日期。


via: https://ruslanspivak.com/lsbaws-part3/

作者:Ruslan 譯者:StdioA 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出

相關文章