自己寫一個Web伺服器(3)

2016-04-15    分類:作業系統、程式設計開發、首頁精華2人評論發表於2016-04-15

本文由碼農網 – 王堅原創翻譯,轉載請看清文末的轉載要求,歡迎參與我們的付費投稿計劃

自己寫一個Web伺服器(1)

自己寫一個Web伺服器(2)

自己寫一個Web伺服器(3)

必須發明時我們學的最好——Piaget

第二篇你建了一個極簡的WSGI伺服器,可以出來基本的HTTP GET請求。結束時我問了個問題,你怎麼保證你的伺服器能同時處理多個請求?在這篇文章中你會找到答案。所以,繫好安全帶,換高檔位,你將會超高速行駛。準備好你的Linux,Mac OS X(或其他*nix系統)和python。這篇文章的所有程式碼都在GitHub

首先讓我們回憶一個基本的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秒。

這是可休眠伺服器的程式碼 webserver3b.py:

#########################################################################
# 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不會立即有任何顯示而只停在那裡。伺服器也不會列印出一個新請求的標準輸出。在我的Mac上是這個樣子的(在右下方高亮的視窗顯示了第二個curl命令掛起,等待連線被伺服器接受):

等待時間足夠長之後(多餘60秒)你應該看到第一個curl終止,第二個curl的視窗列印出“Hello, World!”,然後掛起60秒,然後終止:

它的工作方式是這樣的,伺服器處理完第一個curl客戶端請求後休眠60秒然後開始處理第二個請求。這些都是按順序一步步來,或者在這個例子中一個時刻,一個客戶端請求。

我們討論一下客戶端和伺服器之間的通訊。要讓兩個程式通過網路彼此通訊,他們需要用到socket。你在第一篇和第二篇都看到了socket,但socket是什麼呢?

socket是一個通訊終端的抽象,它允許你的程式通過描述檔案與另一個程式通訊。在這篇文章中我會談到Linux/Mac OS X上典型的TCP/IP socket一個重要的概念是TCP socket對。

TCP連線的socket對是有4個值的tuple用來標識TCP連線的兩個端點:本地IP地址,本地埠,外部IP地址,外部埠。socket對唯一標識網路上的每個TCP連線。這兩個成對的值標識各自端點,一個IP地址和一個埠號,通常被稱為一個socket。

tuple {10.10.10.2:49152, 12.12.12.3:8888} 是客戶端上一個唯一標識兩個TCP連線終端的socket, {12.12.12.3:8888, 10.10.10.2:49152} 是客戶端上一個唯一標識相同的兩個TCP連線終端的socket。IP地址12.12.12.3和埠8888在TCP連線中用來識別伺服器端點(同樣適用於客戶端)。

標準的伺服器建立一個socket然後接受客戶端連線的流程如下圖所示:

伺服器建立一個TCP/IP socket。用下面的python語句:

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

伺服器可能會設定一些socket選項(這是可選的,單絲你看到上面的程式碼多次使用相同的地址,如果你想停止它那就馬上重啟伺服器)。

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

3. 然後,伺服器繫結地址。bind函式給socket分配一個本地地址。在TCP中,呼叫bind允許你指定埠號,IP地址,要麼兩個要麼就沒有。

listen_socket.bind(SERVER_ADDRESS)

接著伺服器讓這個socket成為監聽socket

listen_socket.listen(REQUEST_QUEUE_SIZE)

listen方法只供伺服器呼叫。它告訴核心應該接受給這個socket傳入的連線請求

這些完成後,伺服器開始逐個接受客戶端連線。當一個連線可用accept返回要連線的客戶端socket。然後伺服器讀從客戶端socket取請求資料,列印出響應標準輸出然後給客戶端socket傳回訊息。然後伺服器關閉客戶端連線,準備接受一個新的客戶端連線。

下圖就是在TCP/IP中客戶端與伺服器通訊需要做的:

這裡有同樣的程式碼用來連線客戶端和伺服器,發出一個請求然後列印出響應:

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

建立socket之後,客戶端需要連線伺服器。這是通過connect呼叫來完成的:

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

客戶端只需提供伺服器的遠端地址或是主機名和遠端埠號來連線。

你可能已經注意到客戶端沒有呼叫bind和accept。其原因是客戶端不關心本地IP地址和埠號。客戶端呼叫connect時核心中的TCP/IP socket會自動分配本地IP地址和埠號。本地埠被稱為臨時埠,一個短命的埠。

客戶端連線用以獲取已知服務的伺服器埠成為已知埠(例如80是HTTP,22是SSH)。開啟python shell在本地主機開啟一個客戶端連線,看看核心給你的socket分配了哪個臨時埠(先啟動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)

上面的例子核心分配給socket的臨時埠是60589.

還有一些重要概念我需要在回答第二篇的問題前先做說明。你很快就會看到為什麼這是非常重要的。這兩個概念是一個是程式,一個是檔案描述符。

什麼事程式?程式是執行程式的例項。當伺服器程式碼開始執行,比如,它要載入記憶體執行程式就會呼叫一個程式。核心記錄一系列關於程式的資訊——比如程式ID——用來追蹤它。當你執行webserver3a.py 或 webserver3b.py你只執行了一個程式。

在一個終端中執行webserver3b.py:

$ python webserver3b.py

在另一個終端中用ps命令獲取有關程式的資訊:

$ ps | grep webserver3b | grep -v grep
7182 ttys003    0:00.04 python webserver3b.py

ps命令表明你卻是隻執行了一個python程式webserver3b。當一個程式產生核心就會給他分配程式ID,PID。在UNIX中每個使用者程式還有一個父程式,當然也有自己的ID叫做父程式ID,或者簡寫成PPID。

我當你是在用預設BASH,那麼啟動伺服器一個程式被建立同時一個PID被設定,同時一個PPID在BASH中被設定。

你自己試試看它是怎麼做的。再次開啟python shell,它就產生了一個新程式,然後用ow.getpid()和os.getppid()這戀歌系統呼叫檢視PID和PPID。接著在另一個終端視窗執行ps命令同時grep搜尋這個PPID(我這裡是3148).在下面的截圖中你看到一個關於我Moc OS X系統上BASH程式和python shell程式的父子關係:

另一個必須知道的的概念是檔案描述符。那什麼是檔案描述符呢?是當一個程式開啟現有的檔案,建立一個新檔案,或者當它建立一個新的socket時,核心返回給它的一個非負整數。你應該知道在UNIX中所有東西都是檔案。核心通過檔案描述符指向一個開啟的檔案。當你需要讀寫檔案是就用檔案描述符來識別。python給你跟高階別的物件來處理檔案,你不需要直接用檔案描述符來識別檔案,但在底層,UNIX中檔案和socket的識別是用他們的整數檔案描述符。

UNIX shell預設分配檔案描述符0給標準輸入程式,1是標準輸出,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中處理檔案和socket時,你通常會使用一個高層次的檔案/ Socket物件,但也有可能,你需要直接使用檔案描述符。這裡給出一個例子,你用write系統呼叫給標準輸出寫入一個字串,它將檔案描述符作為一個引數:

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

這是一個有趣的部分——你不會在感到驚訝,因為你已經知道在UNIX中所有的都是檔案——你的socket也有一個與其關聯的檔案描述符。繼續,我前面說到那樣建立一個socket你得到一個物件和一個非負整數,你總是可以直接通過fileno()方法獲取檔案描述符。

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

還有一件事情:你有沒有注意到注意到在第二個迭代伺服器webserver3b.py的例子中,伺服器在60秒休眠中你還可以通過第二個curl命令連線到伺服器。當然curl沒有立即有任何輸出,它掛起了,但是為什麼伺服器沒有在那時就接受連線,也沒有立即拒絕客戶端,而是允許它連線到伺服器呢?答案是socket物件的listen方法和它的BACKLOG 引數,在程式碼中是REQUEST_QUEUE_SIZE。BACKLOG 引數決定核心處理進來連線請求佇列的長度。伺服器 webserver3b.py休眠時,第二個curl能夠連線到伺服器是因為核心有足夠的空間給進來的連線請求。

增加BACKLOG 引數不能讓你的伺服器理解神奇到可以同時處理多個客戶端請求。要繁忙的伺服器不必等待繼而接受一個新的連線,而是立即從訊息佇列中抓取新的連線同時沒有延遲的開始一個客戶端響應程式,一個相當大的BACKLOG引數是非常重要的。

你已經瞭解夠多了。來快速回顧一下你目前為止所學的(或者複習一下你的基礎)。

  • 迭代伺服器
  • 伺服器socket建立過程(socket,bind,listen,accept)
  • 客戶端socket建立過程(socket,connect)
  • socket對
  • socket
  • 臨時埠和已知埠
  • 程式
  • 程式ID(PID),父程式ID(PPID),和父子關係
  • 檔案描述符
  • 監聽socket的BSCKLOG引數的意義

現在我準備回答第二篇的問題:你怎麼保證你的伺服器能同時處理多個請求?或者換個方式,如何編寫併發伺服器?

在UNIX下最簡單的方法是用一個fork()系統呼叫。

這是你新的兵法伺服器的程式碼[webserver3c.py](https://github.com/rspivak/lsbaws/blob/master/part3/webserver3c.py),它可以同時處理多個客戶端請求(跟在迭代伺服器webserver3b.py一樣,每個子程式休眠60秒):

###########################################################################  
# 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” 。

理解fork()最重要的一點是你呼叫forl一次但是他返回兩次:一次是父程式,一次是子程式。你fork你個新的程式返回子程式的ID是0,返回父程式的是子程式的PID。

我還記得第一次看到並嘗試fork時是有多著迷。我正在看循序程式碼突然一聲響:程式碼複製了自己成為兩個同時執行的例項。我覺得這就是魔法,真的。

父程式fork出一個新子程式,這個子程式得到一個父程式檔案描述符:

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

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

那麼子程式怎麼能繼續讀取客戶端socket資料,如果父程式已經關閉了和它的連線?答案就在上面的圖片中。核心根據檔案描述符的值來決定是否關閉連線socket,只有其值為0才會關閉。伺服器產生一個子程式,子程式拷貝父程式檔案描述符,核心增加引用描述符的值。在一個父程式一個子程式的例子中,描述符引用值就是2,當父程式關閉連線socket,它只會把引用值減為1,不會小島讓核心關閉socket。子程式也關閉了父程式監聽socket的重複拷貝,是因為它不關心接受新的客戶端連線,而只在乎處理已連線客戶端的響應:

listen_socket.close()  # close child copy

我會在這篇文章的後面談到你不取消重複描述符會發生什麼。

如你從當前伺服器程式碼中看到的,父程式的唯一職責是接受客戶端連線,fork一個子程式去處理客戶端請求,然後繼續接受另一個客戶端請求,沒別的。父程式不對客戶端請求做處理——子程式來做。

我們說兩個事件併發是什麼意思呢?

說兩事件併發通常是指他們在同一時間發生。作為一個簡短的定義是好的,但是你應該記住嚴格的定義:

如果你不能通過看這個程式來告訴你,這兩個事件是併發的。

又到了回顧概念和理念的時間了:

  • 在UNIX下寫併發伺服器最簡單的方法是用fork()系統呼叫。
  • 一個程式fork出一個新程式,它就變成新程式的父程式
  • 呼叫fork後,父程式和子程式公用同樣的檔案描述符
  • 核心用檔案描述符應用值來決定關閉或開啟檔案/socket
  • 伺服器父程式的角色:從客戶端接受新的連線,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仍然不終止。

為什麼curl不終止?答案是重複的檔案描述符。子程式關閉了客戶端連線,核心將socket引用值減為1。子程式退出,客戶端socket還不關閉是因為socket的引用值還不是0,結果就是終止包(在TCP/IP中叫FIN)沒有被髮送到客戶端,客戶端就持續連線。還有一個問題,你一直執行伺服器而不關閉重複的檔案描述符,最終會用完可用的檔案描述符。

用Control-C停止你的伺服器,在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上open files檔案描述符(開啟多少檔案)的最大可用數值是1024.

來看看不關閉重複描述符伺服器怎樣用完可用檔案描述符。在終端視窗設定open files描述符為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

很快你的伺服器就會爆掉。這是我的異常報告截圖:

教訓是明顯的——伺服器應該關閉重複描述符。但即使關閉重複描述符你也沒有走出困境,你這伺服器還有一個問題,這個問題是殭屍!

真的,你的程式碼創造了殭屍程式。看下怎麼回事,再次啟動伺服器:

$ 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+,而且程式名是了嗎?這就是我們的殭屍程式。殭屍程式的問題是你不能殺死他們。

即使你用’$ kill -9’來殺殭屍程式,他們還會復活,你自己試試看。

什麼是殭屍程式而我們的伺服器為什麼會產生他們?殭屍程式是一個已經終止程式,但是他的父程式沒有等待並收到它的終止狀態。

一個子程式先於它的父程式退出,核心將其轉為殭屍程式並儲存器父程式的一些資訊用來以後恢復。通常儲存程式ID,終止狀態,程式使用的資源。所以殭屍程式是有用的,但你的伺服器不處理好這些殭屍程式就會造成系統阻塞。看看吧,先停止執行的伺服器,然後再新的終端視窗用ulimit命令設定你的最大使用者程式為400(確定open files是更大的數,就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: Resource temporarily unavailable異常,因為它已經達到了允許子程式數的上限。下面是我的異常截圖:

我會簡要說明伺服器該怎樣對待殭屍程式問題。

再回顧一下主要內容:

  • 你不關閉重複描述符,客戶端就不終止因為客戶端連線沒有關閉
  • 你不關閉重複描述符,長時間執行的伺服器最終會用完可用檔案描述符
  • 你fork的子程式退出了但是其父程式沒等待和回收它的終止狀態,那它就成了殭屍程式
  • 你不能殺死殭屍程式,你需要等待它

那麼你要做什麼來對付殭屍程式?你要修改伺服器程式碼來等待殭屍程式回收他們的終止狀態。你可以用系統呼叫wait來修改伺服器。不幸的是這太不理想,因為呼叫wait而沒有終止的子程式的話,wait呼叫會鎖住伺服器,從而阻止伺服器從處理新的客戶端連線請求。有別的辦法嗎?有,其中一個是將一個訊號處理器和wait呼叫結合。

它的工作原理是,一個子程式退出,核心發出一個SIGCHLD訊號。父程式可以建一個訊號處理器非同步接收SIGCHLD訊號,然後它就等待並回收子程式終止狀態,從而防止留下殭屍程式。

順便說下,非同步事件意味著父程式不會提前知道該事件將要發生。

修改伺服器程式碼,設定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

看看伺服器怎麼樣:

發生了什麼?因為錯誤EINTR呼叫accept失敗。

子程式退出出發SIGCHLD事件然後父程式在呼叫accept時被鎖住,父程式啟用訊號處理器完成工作後導致了系統呼叫accept中斷:

別擔心,這是個很好解決的問題。你需要的只是重啟系統呼叫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()

啟動webserver3f.py:

$ python webserver3f.py

用curl給伺服器傳送請求:

$ curl http://localhost:8888/hello

看到了吧?沒有EINTR異常了。現在,確定沒有殭屍程式同時SIGCHLD事件處理器等待並處理子程式終止。執行ps命令不會在意python程式是Z+狀態(沒有程式)。太好了,沒有殭屍程式就安全了。

  • 如果你fork一個子程式卻沒有等待它,它會變殭屍程式
  • 用SIGCHLD事件處理器非同步等待終止的子程式回收它的終止狀態
  • 用事件處理器時你要記住系統呼叫可能終止,您需要為此做好準備方案

目前為止沒什麼問題,對嗎?嗯,基本上是。在試試看webserver3f.py 但是不要用curl只發一個請求,用client3.py 發出128個同時連線:

$ python client3.py --max-clients 128

再次執行ps命令:

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

看到了吧,天吶,殭屍程式又回來了!

這次是哪出錯了?當執行128個連結且連線成功,伺服器子程式處理請求並推出基本在同一時間,造成了SIGCHLD訊號的洪流傳向父程式。問題在於這些訊號不排隊,你的伺服器就漏掉了一些訊號,留下幾個殭屍程式亂跑沒人管:

解決方法是設一個SIGCHLD事件處理器但用WNOHANG來代替系統呼叫waitpid來排一個隊,以確保所有終止程式都被處理。修改後程式碼 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伺服器升級成併發伺服器留給你當練習。你在這裡可以找到修改的版本.但是隻能在自己實現了之後看。你用完成它的所有資訊,那就做吧:  )

下來時什麼呢?就像Josh Billings說的

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

從掌握的基楚開始,質疑你已經知道的,始終深入。

如果你僅僅學習方法,你將被被你的方法束縛。但是如果你學習原則,你可以設計自己的方法。—— Ralph Waldo Emerson

下面是我在這篇文章引用素材的書單。他們會幫你擴大並深入我在文章中提到的知識。

譯文連結:http://www.codeceo.com/article/make-web-server-3.html
英文原文:Let’s Build A Web Server. Part 3
翻譯作者:碼農網 – 王堅
轉載必須在正文中標註並保留原文連結、譯文連結和譯者等資訊。]

相關文章