由淺入深 | 如何一步步地搭建一個Web服務器

魚相信要成爲一個更好的開發人員,你必須更好地理解你天天使用的底層軟件系統,包括編程語言、編譯器和解釋器、數據庫和操做系統、web服務器和web框架。

引言

有一天,一個女人出去散步到一個建築工地,看見三個男人在工做。python

她問第一個男人:「你在幹什麼?」第一個男人被這個問題惹惱了,叫道:「你沒看見我在砌磚嗎?」web

她對回答不滿意,問第二我的在作什麼。第二個男人回答說:「我在建一堵磚牆。」而後,他把注意力轉向第一個男人,說:「嘿,你剛砌過了牆的盡頭。你須要把最後一塊磚頭摘下來。」shell

她又一次對答案不滿意,問第三我的在幹什麼。那人擡頭望着天空對她說:「我正在建造世界上最大的大教堂。」當他站在那裏仰望天空時,另外兩我的開始爲那塊亂七八糟的磚頭爭吵起來。男人轉身對前兩我的說:「嘿,夥計們,別擔憂那塊磚頭。這是一個內牆,它會被粉刷過,沒有人會看到那塊磚。換一層吧。」數據庫

這個故事的寓意是,當你瞭解整個系統,瞭解不一樣部分(磚塊、牆壁、大教堂)如何組合在一塊兒時,你能夠更快地識別和解決問題(錯誤的磚塊)。django

文章這樣的開頭,與《建立一個簡單的Web服務器》有什麼關係?編程

魚相信要成爲一個更好的開發人員,你必須更好地理解你天天使用的底層軟件系統,包括編程語言、編譯器和解釋器、數據庫和操做系統、web服務器和web框架。並且,爲了更好更深刻地瞭解這些系統,你必須從頭開始,一塊一塊地,一堵牆一堵牆地從新構建它們。flask

孔子這樣說:segmentfault

聽而易忘

見而易記

作而易懂

魚但願你能夠相信,對不一樣的軟件系統進行造輪子,來學習它們的工做方式,是一個好辦法。瀏覽器

在這個由三部分組成的系列中,魚將向你展現如何構建本身的基本Web服務器。Here we go!bash

初識Web服務器

首先,什麼是Web服務器?

簡單滴說,它是一個網絡服務器,位於物理服務器之上(沒看錯,就是服務器上的服務器),而後它等待客戶端發送請求。當它接收到請求時,它生成一個響應並將其發送回客戶端。客戶端和服務器之間的通訊使用HTTP協議進行。客戶端能夠是你的瀏覽器,也能夠是任何其餘講HTTP的軟件。

最簡單的Web服務器

一個很是簡單的Web服務器實現是什麼樣子的?

能夠看下魚的這個例子,這個例子是用Python編寫的(在Python3.7+上進行了測試),可是即便你不瞭解Python(這是一種很容易掌握的語言,請嘗試!)你仍然應該可以從下面的代碼和解釋中理解概念:

# Python3.7+
import socket

HOST, PORT = '', 8888

listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
listen_socket.bind((HOST, PORT))
listen_socket.listen(1)
print(f'Serving HTTP on port {PORT} ...')
while True:
    client_connection, client_address = listen_socket.accept()
    request_data = client_connection.recv(1024)
    print(request_data.decode('utf-8'))

    http_response = b"""\
HTTP/1.1 200 OK

Hello, World!
"""
    client_connection.sendall(http_response)
    client_connection.close()

將以上代碼保存爲webserver1.py,而後使用如下命令運行它:

$ python webserver1.py
Serving HTTP on port 8888 …

如今,在Web瀏覽器的地址欄輸入http://localhost:8888/hello,按Enter,而後你應該看到「Hello World!」顯示在瀏覽器中,以下所示:

快去實踐一遍吧,簡單地一匹!

分析Web服務器工做原理

魚們來分析一下它究竟是如何工做的。

首先讓魚們從你輸入的網址開始。它被稱爲URL,其基本結構以下

這個就是你要告訴瀏覽器的地址,至關於你要求瀏覽器查找和鏈接到的Web服務器的地址,以及服務器上要爲你獲取的頁面(路徑)。

可是,在瀏覽器發送HTTP請求以前,它首先須要與Web服務器創建TCP鏈接。而後瀏覽器經過TCP鏈接向服務器發送HTTP請求,並等待服務器發送HTTP響應。

當你的瀏覽器收到來自服務器的應答時,它會顯示出來,在這種狀況下,它會顯示「Hello, World!」

如今,讓魚們來更詳細地探討,客戶端和服務器在發送HTTP請求和響應以前如何創建TCP鏈接。爲此,魚使用所謂的套接字進行模擬。你將經過在命令行上使用telnet手動模擬瀏覽器,而不是直接使用瀏覽器。

在運行Web服務器的同一臺計算機上,在命令行上啓動telnet會話,指定要鏈接到本地主機和8888的端口,而後按Enter鍵:

$ telnet localhost 8888
Trying 127.0.0.1 …
Connected to localhost.

此時,你已經與本地主機上運行的服務器創建了TCP鏈接,並準備發送和接收HTTP消息。在下面的圖片中,你能夠看到一個服務器必須通過的標準過程,才能接受新的TCP鏈接。

更多地關注造殼

同時,魚們繼續試驗,在同一個telnet會話中,輸入「GET /hello HTTP/1.1」,而後按Enter:

$ telnet localhost 8888
Trying 127.0.0.1 …
Connected to localhost.
GET /hello HTTP/1.1

HTTP/1.1 200 OK
Hello, World!

就在此時,你手動模擬了你的瀏覽器!你發送了一個HTTP請求並獲得了一個HTTP響應。這是HTTP請求的基本結構:

HTTP請求由這些元素組成:

  1. HTTP方法(GET)
  2. 表示所需服務器上的「頁面」的路徑(/hello)
  3. 協議版本(HTTP/1.1)

爲了簡單起見,魚們的Web服務器此時徹底忽略了上面的請求數據。你也能夠輸入任何垃圾而不是「GET/hello HTTP/1.1」,而後你仍然會獲得一個「hello,World!」迴應。

輸入請求行並按Enter鍵後,客戶機將請求發送到服務器,服務器讀取請求行,打印請求行並返回正確的HTTP響應。

如下是服務器發送回客戶端的HTTP響應(在本例中爲telnet):

讓魚們解剖一下。HTTP響應由這幾個元素組成:

  1. 響應狀態行(協議版本+狀態碼/返回碼),HTTP/1.1 200 OK,
  2. 一個必需的空行,
  3. HTTP響應體。

響應狀態行HTTP/1.1 200 OK由HTTP協議版本、HTTP狀態碼和HTTP狀態碼緣由短語OK組成。當瀏覽器獲得響應時,它會顯示響應的主體,這就是爲何你會看到「Hello,World!」在你的瀏覽器中。

這就是Web服務器工做的基本模式。總而言之:

  1. Web服務器建立一個監聽套接字,並開始在循環中接受新鏈接。
  2. 客戶端啓動一個TCP鏈接,並在成功創建它以後,客戶端向服務器發送一個HTTP請求,服務器返回HTTP響應結果,其中包含了展現給用戶看的響應內容。

要創建TCP鏈接,客戶端和服務器都使用套接字。

如今你有了一個很是基本的工做Web服務器,你可使用瀏覽器或其餘HTTP客戶端進行測試。正如你已經看到並但願嘗試過的那樣,經過使用telnet並手動鍵入HTTP請求,你也能夠成爲一我的工HTTP客服端。

接下來魚們要提出一個問題:

「在不對服務器進行任何更改的前提下,你如何在新開發的Web服務器下,運行/適配不一樣的Django應用程序、Flask應用程序和pyrampid應用程序」

解耦Web服務器和Python應用程序 —— WSGI

在過去,你對Python Web框架的選擇會限制你對可用Web服務器的選擇,反之亦然。若是框架和服務器設計爲協同工做,那麼通常是可行的:

可是,當你嘗試將服務器和非設計爲協同工做的框架結合在一塊兒時,可能會遇到不match的問題:

基本上,你必須使用協同工做的東西,但有可能不是你想要使用的東西。好比你但願用ServerA的某個特性和FrameworkB的某個功能,可是FrameworkA不能知足你。

那麼,如何確保可使用多個Web框架運行Web服務器,而沒必要對Web服務器或Web框架進行代碼更改呢?這個問題的答案就是Python Web服務器網關接口(簡稱WSGI,發音爲wizgy)。

WSGI容許開發人員將Web框架與Web服務器解耦。如今,你能夠混合和匹配Web服務器和Web框架,並選擇適合你須要的配對。例如,可使用Gunicorn、Nginx/uWSGI或Waitress運行Django、Flask或Pyramid。這樣的解耦,得益於服務器和框架中的WSGI支持:

所以,WSGI就是問題的答案。你的Web服務器必須實現WSGI接口的服務器部分,而且要求全部的python web框架都已經實現了WSGI接口的框架端。這樣,就能夠將它們混合使用,而無需修改服務器代碼以適應特定的Web框架。

如今你知道,Web服務器和Web框架對WSGI的支持容許你選擇適合你的配對,但這也有利於服務器和框架開發人員,由於他們能夠專一於本身喜歡的專業領域,而不是相互干涉。其餘語言也有相似的接口:例如,Java有Servlet API,Ruby有Rack。

編寫本身的WSGI服務器

理想很美好,但凡事都得「Show me the code」。魚們來看看這個很是簡單的WSGI服務器實現:

# Tested with Python 3.7+ (Mac OS X)
import io
import socket
import sys


class WSGIServer(object):

    address_family = socket.AF_INET
    socket_type = socket.SOCK_STREAM
    request_queue_size = 1

    def __init__(self, server_address):
        # Create a listening socket
        self.listen_socket = listen_socket = socket.socket(
            self.address_family,
            self.socket_type
        )
        # Allow to reuse the same address
        listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        # Bind
        listen_socket.bind(server_address)
        # Activate
        listen_socket.listen(self.request_queue_size)
        # Get server host name and port
        host, port = self.listen_socket.getsockname()[:2]
        self.server_name = socket.getfqdn(host)
        self.server_port = port
        # Return headers set by Web framework/Web application
        self.headers_set = []

    def set_app(self, application):
        self.application = application

    def serve_forever(self):
        listen_socket = self.listen_socket
        while True:
            # New client connection
            self.client_connection, client_address = listen_socket.accept()
            # Handle one request and close the client connection. Then
            # loop over to wait for another client connection
            self.handle_one_request()

    def handle_one_request(self):
        request_data = self.client_connection.recv(1024)
        self.request_data = request_data = request_data.decode('utf-8')
        # Print formatted request data a la 'curl -v'
        print(''.join(
            f'< {line}\n' for line in request_data.splitlines()
        ))

        self.parse_request(request_data)

        # Construct environment dictionary using request data
        env = self.get_environ()

        # It's time to call our application callable and get
        # back a result that will become HTTP response body
        result = self.application(env, self.start_response)

        # Construct a response and send it back to the client
        self.finish_response(result)

    def parse_request(self, text):
        request_line = text.splitlines()[0]
        request_line = request_line.rstrip('\r\n')
        # Break down the request line into components
        (self.request_method,  # GET
         self.path,            # /hello
         self.request_version  # HTTP/1.1
         ) = request_line.split()

    def get_environ(self):
        env = {}
        # The following code snippet does not follow PEP8 conventions
        # but it's formatted the way it is for demonstration purposes
        # to emphasize the required variables and their values
        #
        # Required WSGI variables
        env['wsgi.version']      = (1, 0)
        env['wsgi.url_scheme']   = 'http'
        env['wsgi.input']        = io.StringIO(self.request_data)
        env['wsgi.errors']       = sys.stderr
        env['wsgi.multithread']  = False
        env['wsgi.multiprocess'] = False
        env['wsgi.run_once']     = False
        # Required CGI variables
        env['REQUEST_METHOD']    = self.request_method    # GET
        env['PATH_INFO']         = self.path              # /hello
        env['SERVER_NAME']       = self.server_name       # localhost
        env['SERVER_PORT']       = str(self.server_port)  # 8888
        return env

    def start_response(self, status, response_headers, exc_info=None):
        # Add necessary server headers
        server_headers = [
            ('Date', 'Mon, 15 Jul 2019 5:54:48 GMT'),
            ('Server', 'WSGIServer 0.2'),
        ]
        self.headers_set = [status, response_headers + server_headers]
        # To adhere to WSGI specification the start_response must return
        # a 'write' callable. We simplicity's sake we'll ignore that detail
        # for now.
        # return self.finish_response

    def finish_response(self, result):
        try:
            status, response_headers = self.headers_set
            response = f'HTTP/1.1 {status}\r\n'
            for header in response_headers:
                response += '{0}: {1}\r\n'.format(*header)
            response += '\r\n'
            for data in result:
                response += data.decode('utf-8')
            # Print formatted response data a la 'curl -v'
            print(''.join(
                f'> {line}\n' for line in response.splitlines()
            ))
            response_bytes = response.encode()
            self.client_connection.sendall(response_bytes)
        finally:
            self.client_connection.close()


SERVER_ADDRESS = (HOST, PORT) = '', 8888


def make_server(server_address, application):
    server = WSGIServer(server_address)
    server.set_app(application)
    return server


if __name__ == '__main__':
    if len(sys.argv) < 2:
        sys.exit('Provide a WSGI application object as module:callable')
    app_path = sys.argv[1]
    module, application = app_path.split(':')
    module = __import__(module)
    application = getattr(module, application)
    httpd = make_server(SERVER_ADDRESS, application)
    print(f'WSGIServer: Serving HTTP on port {PORT} ...\n')
    httpd.serve_forever()

代碼仍是比較簡單的(不到150行),你們均可以理解,應該不會出現陷入細節泥潭的狀況。上面的服務器還能夠作更多的事情——它能夠運行用你喜好的Web框架編寫的基本Web應用程序,不管是Pyramid、Flask、Django仍是其餘Python WSGI框架。

讓魚們來運行試試看。將上述代碼保存爲webserver2.py。若是你試圖在沒有任何參數的狀況下運行它,它會提醒錯誤並退出。

$ python webserver2.py
Provide a WSGI application object as module:callable

它真的須要你的Web應用服務。要運行服務器,只須要安裝Python。可是要運行使用Pyramid、Flask和Django編寫的應用程序,你須要先安裝這些框架,讓魚們把這三個都安裝好。魚首選的方法是使用venv建立一個虛擬環境,以避免影響現有環境(venv在Python3.3版本以及以上默認自帶)。只需按照下面的步驟建立並激活一個虛擬環境,而後安裝全部三個Web框架。

$ python3 -m venv lsbaws
$ ls lsbaws
bin   include   lib   pyvenv.cfg
$ source lsbaws/bin/activate
(lsbaws) $ pip install -U pip
(lsbaws) $ pip install pyramid
(lsbaws) $ pip install flask
(lsbaws) $ pip install django

搭建WSGI + Pyramid應用程序

此時,你須要建立一個Web應用程序。魚們先從Pyramid開始。將如下代碼另存爲金字塔app.py保存到同一目錄webserver2.py

from pyramid.config import Configurator
from pyramid.response import Response


def hello_world(request):
    return Response(
        'Hello world from Pyramid!\n',
        content_type='text/plain',
    )

config = Configurator()
config.add_route('hello', '/hello')
config.add_view(hello_world, route_name='hello')
app = config.make_wsgi_app()

如今,你可使用本身的Web服務器,來啓動你的Pyramid應用程序了:

(lsbaws) $ python webserver2.py pyramidapp:app
WSGIServer: Serving HTTP on port 8888 ...

你剛纔告訴服務器從python模塊「Pyramid app」加載可調用的「app」,你的服務器如今能夠接收請求並將它們轉發到你的pyramid中叫app的應用程序。

同時,從webserver2.py代碼中能夠看出,應用程序如今只處理一個路由:/hello路由。在瀏覽器網站上輸入http://localhost:8888/hello地址,按回車鍵,而後觀察結果:


固然,你也能夠在命令行中使用curl工具,也能達到一樣的效果:

$ curl -v http://localhost:8888/hello
...

你能夠看下服務器和curl都輸出了些什麼。

搭建WSGI + Flask應用程序

如今魚們移步到Flask,跟着魚一塊兒作:

from flask import Flask
from flask import Response
flask_app = Flask('flaskapp')


@flask_app.route('/hello')
def hello_world():
    return Response(
        'Hello world from Flask!\n',
        mimetype='text/plain'
    )

app = flask_app.wsgi_app

將上述代碼保存爲flaskapp.py,而後容許它:

(lsbaws) $ python webserver2.py flaskapp:app
WSGIServer: Serving HTTP on port 8888 ...

在瀏覽器網站上輸入http://localhost:8888/hello地址,按回車鍵:


同樣的,你也能夠在命令行中使用curl工具:

$ curl -v http://localhost:8888/hello
...

搭建WSGI + Django應用程序

服務器還能夠處理Django應用程序嗎?固然能夠,試試看!不過,它涉及的內容稍微多一些,魚建議複製整個repo並使用djangoapp.py

下面的源代碼基本上將Django helloworld項目(使用Django的Django-admin.py startproject命令預先建立)添加到當前Python路徑,而後導入項目的WSGI應用程序。

import sys
sys.path.insert(0, './helloworld')
from helloworld import wsgi


app = wsgi.application

將這段代碼保存爲djangoapp.py,而後運行起來:

(lsbaws) $ python webserver2.py djangoapp:app
WSGIServer: Serving HTTP on port 8888 ...

在瀏覽器網站上輸入地址,按回車鍵:

雖然已經運行過不少次,可是你依然能夠在命令行中使用curl工具,爲了驗證Django:

$ curl -v http://localhost:8888/hello
...

ok,到目前爲止,魚們把三個服務器都輪了一遍,若是你還沒親手試過,最好動下手,看是沒啥用的。

WSGI程序分析

好吧,你已經體驗過WSGI的強大功能:它容許你混合匹配你的Web服務器和Web框架。

WSGI在Python Web服務器和pythonweb框架之間提供了一個最小的接口。WSGI很簡單,並且很容易在服務器端和框架端實現。

魚們來分析下魚們以前實現的WSGI的代碼。

如下代碼段顯示了服務器和接口的框架端:

def run_application(application):
    """Server code."""
    # This is where an application/framework stores
    # an HTTP status and HTTP response headers for the server
    # to transmit to the client
    headers_set = []
    # Environment dictionary with WSGI/CGI variables
    environ = {}

    def start_response(status, response_headers, exc_info=None):
        headers_set[:] = [status, response_headers]

    # Server invokes the ‘application' callable and gets back the
    # response body
    result = application(environ, start_response)
    # Server builds an HTTP response and transmits it to the client
    ...

def app(environ, start_response):
    """A barebones WSGI app."""
    start_response('200 OK', [('Content-Type', 'text/plain')])
    return [b'Hello world!']

run_application(app)

它的工做原理以下:

  1. 框架提供了一個可調用的「應用程序」(WSGI規範沒有規定應該如何實現),指的就是魚們的業務代碼
  2. 服務器爲從HTTP客戶端接收的每一個請求調用可調用的「應用程序」。它將包含WSGI/CGI變量的字典「environ」和可做爲參數調用的「start_response」傳遞給可調用的「application」。
  3. 框架/應用程序生成一個HTTP狀態和HTTP響應頭,並將它們傳遞給服務器可調用的「start_response」來存儲它們。框架/應用程序還返回一個響應體。
  4. 服務器將狀態、響應頭和響應體組合成一個HTTP響應並將其傳輸到客戶端(此步驟不是規範的一部分,但它是流中的下一個邏輯步驟,通常都是須要的)

下面是界面的可視化表示:

到目前爲止,魚們已經輪過了Pyramid、Flask和Django Web應用程序,還看到了實現WSGI規範的服務器端的服務器代碼。

當你使用這些框架之一編寫Web應用程序時,你能夠在更高的級別上工做,而不直接使用WSGI。

但魚知道你對WSGI接口的框架方面也很好奇,由於你正在閱讀本文。

更多地關注造殼

所以,讓魚們建立一個極簡的WSGI Web應用程序/Web框架,而不使用Pyramid、Flask或Django,並在服務器上運行它:

def app(environ, start_response):
    """A barebones WSGI application.

    This is a starting point for your own Web framework :)
    """
    status = '200 OK'
    response_headers = [('Content-Type', 'text/plain')]
    start_response(status, response_headers)
    return [b'Hello world from a simple WSGI application!\n']

將上述代碼保存爲wsgiapp.py,運行它:

(lsbaws) $ python webserver2.py wsgiapp:app
WSGIServer: Serving HTTP on port 8888 ...

在瀏覽器網站上輸入地址,按回車鍵:

這樣,魚們就實現了一個極簡的WSGI Web「框架」,沒錯,就是這麼簡單。

如今,讓魚們回到服務器向客戶端傳輸的內容。

下面是服務器在使用HTTP客戶端調用金字塔應用程序時生成的HTTP響應:

這個響應有一些是你在前文中能看到的熟悉部分,但它也有一些新的內容。例如,它有四個你之前從未見過的HTTP頭:內容類型、內容長度、日期和服務器。其實這些是Web服務器響應一般應該具備的頭。不過,這些都不是嚴格要求的。報頭的目的是傳輸關於HTTP請求/響應的附加信息。

如今你已經瞭解了更多關於WSGI接口的信息,一樣的,下面HTTP響應,其中包含了有關生成它的部分的更多信息:

魚尚未提到「environ」字典,但基本上它是一個Python字典,必須包含WSGI規範指定的某些WSGI和CGI變量。服務器在分析請求後從HTTP請求中獲取字典的值。這就是字典的內容:

Web框架使用該字典中的信息,根據指定的路由、請求方法等信息,決定使用哪一個視圖、從何處讀取請求體以及在何處寫入錯誤(若是有的話)。

如今,你已經建立了本身的WSGI Web服務器,並使用不一樣的Web框架編寫了Web應用程序。並且,你還建立了一個簡單的Web應用程序/Web框架。

讓魚們回顧一下你的WSGI Web服務器必須作些什麼來服務針對WSGI應用程序的請求:

首先,服務器啓動並加載Web框架/應用程序提供的可調用的「應用程序」;
而後,服務器讀取一個請求;
而後,服務器解析它;
而後,它使用請求數據構建一個「environ」字典;
而後,它用「environ」字典調用「application」,用「start_response」做爲參數調用「start_response」,並返回一個響應體;
而後,服務器使用對「application」對象的調用返回的數據以及可調用的「start_response」設置的狀態和響應頭來構造HTTP響應;
最後,服務器將HTTP響應發送回客戶端。

就是這些步驟,貫穿了魚們的整個服務流程。

如今你有了一個能夠工做的WSGI服務器,它能夠爲使用符合WSGI的Web框架(如Django、Flask、Pyramid或你本身的WSGI框架)編寫的基本Web應用程序提供服務。最理想的,是服務器能夠與多個Web框架一塊兒使用,而不須要對服務器代碼庫進行任何更改。

但這還不夠完美,甚至還有明顯的缺點。

魚們來思考一下:「爲了提升你的程序的性能,你如何讓你的服務器一次處理多個請求?」

建立併發服務器

在前面,魚們建立了一個極簡的WSGI服務器,它能夠處理基本的HTTP GET請求。可是,它是一個「迭代服務器」,一次處理一個客戶機請求。在處理完當前客戶端請求以前,它沒法接受新鏈接。有些客戶端可能不滿意,由於他們將不得不排隊等候,而對於繁忙的服務器,排隊現象尤爲嚴重。

串行的「迭代服務器」

魚們來看一眼魚們的「迭代服務器」,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秒延遲。

「下降性能」的代碼保存爲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

在命令行中請求一下,你立刻就能看到「Hello, World!」:

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

而後,緊接着,魚們趕忙發起第二個請求:

$ curl http://localhost:8888/hello

若是你足夠地「趕忙」,在60秒內完成了發起了這兩個請求,那麼第二個請求應該不會立刻產生任何輸出,而應該只是hung在那裏。服務器也不該該在其標準輸出上打印新的請求體。下面是在魚的Mac上的狀況(右下角以黃色突出顯示的窗口顯示第二個curl命令掛起,等待服務器接受鏈接):

當你等了60秒到了,你就會看到第二個「Hello, World!」出現了,而後服務器繼續hung住60秒。

魚們看到服務器完成對第一個curl客戶機請求的服務,而後僅在第二個請求休眠60秒後纔開始處理它。這一切都是由於「迭代服務器」是按順序或迭代地進行的,一步一個步驟,或者在魚們的狀況下,一次處理一個請求。

Socket、進程、文件描述符是什麼

爲了更好地分析魚們怎麼解決這個性能問題,魚們來談談客戶端和服務器之間的通訊。

魚們爲了讓兩個程序經過網絡相互通訊,必須使用套接字/Socket。前面的代碼魚們使用了Socker,那麼什麼是Socket?

Socket是通訊端點的抽象,它容許你的程序使用文件描述符與另外一個程序通訊。在本文中,魚將特別討論Linux/Mac OS X上的TCP/IP socket。

其中,魚們須要理解,什麼是Socket Pair(套接字對)?

TCP鏈接的Socket Pair是一個4元組,用於標識TCP鏈接的兩個端點:本地IP地址、本地端口、外部IP地址和外部端口。Socket Pair惟一標識網絡上的每一個TCP鏈接。標識每一個鏈接點的兩個值(IP地址和端口號)一般稱爲Socket。

因此,元組{10.10.10.2:49152, 12.12.12.3:8888}是一個Socket Pair,它惟一地標識出客戶端上兩個終端的TCP鏈接;而元組 {12.12.12.3:8888, 10.10.10.2:49152}也是一個Socket Pair,標識出服務器上兩個終端的TCP鏈接。地址12.12.12.3和端口8888兩個值,能標識TCP鏈接的服務器端點,在這裏魚們稱之爲Socket(客戶端依然)。

服務器建立Socket並開始接受客戶端鏈接的標準順序以下:

  1. 服務器建立TCP/IP Socket。這是經過Python語句完成的:
listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  1. 服務器可能會設置一些Socket參數選項(這是可選的參數。魚們能夠看到上面實現過的服務器代碼,使用的是REUSEADDR,正是爲了可以反覆使用同一地址)。
listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
  1. 而後,服務器綁定地址。bind函數爲Socket分配一個本地協議地址。對於TCP,調用bind容許你指定端口號、IP地址,或者二者都指定,或者都不指定。
listen_socket.bind(SERVER_ADDRESS)
  1. 而後,監聽。
listen_socket.listen(REQUEST_QUEUE_SIZE)

listen方法僅由服務器調用,客戶端沒有。它告訴內核應該接受這個Socket的傳入鏈接請求。

完成後,服務器開始在一個循環中一次接受一個客戶端鏈接。當有可用的鏈接時,accept調用返回已鏈接的客戶端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))

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

你可能已經注意到客戶端沒有調用bind和accept,是的,客戶端不須要調用bind,由於客戶端不關心本地IP地址和本地端口號。

當客戶端調用connect時,內核中的TCP/IP堆棧會自動分配本地IP地址和本地端口。本地端口稱爲臨時端口,通常來講很快就釋放了。

通常,經常使用服務的端口稱爲經常使用端口,如HTTP服務的80端口,SSH服務的22端口。

若是你想知道你的客服端的本地端口是什麼,可一啓動Python shell並與在本地主機上運行的服務器創建客戶端鏈接,而後查看內核爲你建立的套接字分配的臨時端口(在嘗試如下示例以前啓動服務器webserver3a.py或webserver3b.py):

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

在上述狀況下,內核將臨時端口60589分配給Socket。

除了Socket以外,魚須要快速介紹一些其餘重要的概念,分別是進程和文件描述符。你很快就會明白這些概念爲何很重要。

什麼是進程?進程只是執行程序的一個實例。例如,當服務器代碼被執行時,它被加載到內存中,執行程序的一個實例稱爲進程。內核記錄了一堆關於進程的信息,它的進程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 shell,當你啓動服務器時,會建立一個帶有PID的新進程,其父PID設置爲bashshell的PID。

再次啓動Python shell,它將建立一個新進程,而後魚們使用os.getpid() 和os.getppid() 系統調用獲取pythonshell進程的PID和父PID(bashshell的PID)。

而後,在另外一個終端窗口中爲PPID(父進程ID,在魚的例子中是3148)運行ps命令和grep。在下面的屏幕截圖中,你能夠看到魚的Mac OS X上的子Python shell進程和父BASH shell進程之間的父子關係示例:

另外一個須要知道的重要概念是文件描述符。那麼什麼是文件描述符?是一個非負整數。

?什麼鬼非負整數?

內核在打開現有文件、建立新文件或建立新Socket時返回給進程一個非負整數,這個非負整數就是文件描述符。

你可能據說過,在UNIX中,一切都是文件。內核經過文件描述符引用進程的打開文件。當你須要讀或寫一個文件時,你能夠用文件描述符來識別它。

Python爲你提供了處理文件(和socket)的高級對象,你沒必要直接使用文件描述符來標識文件,但實際上,在UNIX中,文件和socket是經過它們的整數文件描述符來標識的。

默認狀況下,unixshell將文件描述符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也有一個與之相關的文件描述符。一樣,在Python中建立一個socket時,會返回一個對象,而不是一個非負整數,但你始終可使用前面提到的fileno() 方法直接訪問socket的整數文件描述符。

>>> 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參數是很重要的,這樣accept調用就沒必要等待創建新鏈接,而是能夠當即從隊列中獲取新鏈接,並當即開始處理客戶端請求。

到目前爲止,文章以上的內容覆蓋了不少知識點,魚們複習下:

  • 迭代服務器
  • 服務器Socket建立序列 (socket, bind, listen, accept)
  • 客戶端Socket建立序列 (socket, connect)
  • Socket Pair(套接字對)
  • Socket
  • 臨時端口和經常使用端口
  • 進程
  • 進程id(PID),父進程id(PPID),父子進程關係
  • 文件描述符
  • BACKLOG參數的含義

使用fork編寫併發服務器

如今魚們已經準備好回答那一個問題:「爲了提升你的程序的性能,你如何讓你的服務器一次處理多個請求?」

或者魚們換一個問法:「你怎麼寫一個併發的服務器呢?」

寫一個併發服務器最簡單的方法,是在Unix系統下使用fork()系統調用。

下面是新的閃亮登場的併發服務器的代碼,命名爲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是如何工做的以前,魚們先嚐試一下並親眼確認服務器確實能夠同時處理多個客戶機請求。

不一樣於webserver3a.pywebserver3b.py,使用如下命令行啓動服務器:

$ python webserver3c.py

在迭代服務器上嘗試一樣的兩個curl命令,而後魚們能夠看到,即便服務器子進程在服務客戶機請求後睡眠60秒,它也不會影響其餘客戶端的請求,由於它們由不一樣的徹底獨立的進程提供服務。你應該看到curl命令輸出「Hello,World!」立刻就hung住60秒。你能夠繼續運行任意數量的curl命令(固然不能大於fd的上限),全部這些命令都將當即輸出服務器的響應「Hello,World」,不會有任何明顯的延遲。

關於fork()要注意一點,一個代碼裏面調用fork一次,它會返回兩次:一次在父進程中,一次在子進程中。派生新進程時,返回給子進程的進程ID爲0。當fork在父進程中返回時,它返回子進程的PID。

魚依稀記得當魚第一次讀到並嘗試fork的時候魚是多麼的迷,如今這在魚看來依然很神奇。

更多地關注造殼

當父進程派生新的子進程時,子進程將獲取父進程的文件描述符的副本:

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

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

那麼,當一個子進程的父進程關閉了同一個套接字,它爲何還能從客戶端socket裏面讀取數據呢?

答案如上圖所示。內核使用描述符引用計數來決定是否關閉socket。它只在其描述符引用計數變爲0時,關閉套接字。

當服務器建立子進程時,子進程獲取父進程文件描述符的副本,內核增長這些描述符的引用計數。在一個父進程和一個子進程的狀況下,客戶端socket的描述符引用計數爲2,當上面代碼中的父進程關閉客戶端鏈接套接字時,它只會減小其引用計數,該計數將變爲1,不足以致使內核關閉socket。

另外,子進程還關閉父進程偵聽套接字的副本,由於子進程不關心接受新的客戶端鏈接,它只關心處理來自已創建的客戶端鏈接的請求:

listen_socket.close()  # close child copy

魚將在本文後面討論若是不關閉重複的描述符會發生什麼。

從併發服務器的源代碼中能夠看到,服務器父進程如今的惟一角色是接受一個新的客戶端鏈接,派生一個新的子進程來處理該客戶端請求,並循環接受另外一個客戶端鏈接,僅此而已。服務器父進程不處理客戶端請求,而是讓其子進程處理。

魚們先討論另外一個問題,魚們所說的兩個事件同時發生是什麼意思?

當魚們說兩個事件同時發生時,一般是指它們同時發生。這個定義很好,但你應該記住嚴格的定義:

若是你看不出哪一個程序會先發生,那麼兩個事件是併發的。

再次重申一下,如今是時候回顧一下你迄今爲止所涉及的主要思想和概念了。

  • 在Unix中編寫併發服務器的最簡單方法是使用fork()系統調用
  • 當進程分叉新進程時,它將成爲該新分叉子進程的父進程。
  • 在調用fork以後,父級和子級共享相同的文件描述符。
  • 內核使用描述符引用計數來決定是否關閉文件/socket
  • 服務器父進程的角色:它如今所作的只是接受來自客戶端的新鏈接,派生子進程來處理客戶端請求,而後循環接受新的客戶端鏈接。

回收文件描述符

讓魚們看看若是不關閉父進程和子進程中的重複套接字描述符,將會發生什麼。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不終止?緣由是文件描述符還有餘。

當子進程關閉客戶端鏈接時,內核減小了該客戶端套接字的引用計數,計數變爲1。服務器子進程已退出,但客戶端套接字未被內核關閉,由於該套接字描述符的引用計數不是0。所以,終止數據包(在TCP/IP術語中稱爲FIN)未發送到客戶端,客戶端保持在線。

若是長時間運行的服務器沒有關閉重複的文件描述符,它最終將耗盡可用的文件描述符:

使用Control-C終止你webserver3d.py程序,檢查下你所在服務器上的默承認用資源,可使用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

在同一個終端中啓動服務:

$ 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輸出的例子:

$ 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+,進程的名稱是<deflunct>?那是魚們的殭屍進程。甚至魚們還不能殺死他們。

即便魚們使用kill -9,他們依然會存在。

什麼是殭屍進程?爲何魚們的服務器要建立殭屍進程?

殭屍進程是已終止的進程,但其父進程還沒有等待它,也還沒有收到其終止狀態。當子進程在其父進程以前退出時,內核會將子進程變成殭屍進程,並存儲一些有關該進程的信息,供其父進程之後檢索。存儲的信息一般是進程ID、進程終止狀態和進程的資源使用狀況。

因此說,殭屍進程是有目的的,可是若是你的服務器不處理這些殭屍進程,你的系統最終就會被阻塞。

讓魚們試驗看看不清理殭屍進程會怎麼樣。

首先中止正在運行的服務器,並在新的終端窗口中,使用ulimit命令將max user processess設置爲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 temporary unavailable異常,當它試圖建立一個新的子進程時,建立失敗,由於它已經達到了容許建立的最大子進程數的限制。這是魚機器上異常的截圖:

如你所見,若是不處理殭屍進程,就會給長期運行的服務器帶來問題。魚將後面會討論服務器應該如何處理殭屍問題。

魚們再回顧一下知識點:

  • 若是不關閉重複的描述符,則客戶端不會終止,由於客戶端鏈接不會關閉。
  • 若是不關閉重複的描述符,長期運行的服務器最終將耗盡可用的文件描述符(最大打開文件數)。
  • 當派生子進程並退出,而父進程不等待它,也不收集其終止狀態時,它將成爲一個殭屍進程。
  • 殭屍須要吃點東西,在魚們的例子中,這是記憶。若是服務器不處理殭屍進程,那麼它最終將耗盡可用進程(最大用戶進程)。
  • 你不能殺殭屍,你須要等待。

解決殭屍進程:信號處理程序 + 等待系統調用

那麼你須要作什麼來處理掉殭屍進程呢?你須要修改服務器代碼,以等待殭屍進程,得到其終止狀態。而後能夠經過修改服務器來調用等待系統調用來完成此操做。

不幸的是,這遠不是理想的,由於若是調用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 http://localhost:8888/hello

看服務器終端:

發生了什麼呢?accept調用失敗了,返回了EINTR錯誤。

當子進程退出致使SIGCHLD事件後,父進程accept調用會被阻塞,而後致使激活了信號處理程序,當信號處理程序完成時,accept系統調用被中斷:

Don’t worry, it’s a pretty simple problem to solve, though. All you need to do is to re-start the accept system call. Here is the modified version of the server webserver3f.py that handles that problem:
不過這是個很簡單的問題,只須要從新啓動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 http://localhost:8888/hello

如今再也不有EINTR異常了。

接下來魚們再驗證是否再也不有殭屍進程,以及帶有wait調用的SIGCHLD事件處理程序是否處理已終止的子進程。

要作到這一點,只需運行ps命令,並親自查看再也不有Z+狀態的Python進程(再也不有<definct>進程)就ok了。

  • 若是你用fork建立一個子進程而不wait它,它就會變成殭屍進程;
  • 處理殭屍進程的方法:使用SIGCHLD事件處理程序,異步等待已終止的子進程獲取其終止狀態;
  • 當使用事件處理程序時,你須要記住系統調用可能會被中斷,而且你須要爲該場景作好準備。

好吧,到目前爲止還不錯。沒問題吧?好吧,差很少了。再次嘗試webserver3f.py,但不要使用curl發出一個請求,而是使用client3.py建立128個同時鏈接:

$ python client3.py --max-clients 128

運行下ps命令看下:

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

然而,殭屍進程還在。

此次是什麼問題呢?當你同時運行128個客戶端並創建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

發起請求:

$ python client3.py --max-clients 128

如今能夠確認沒有殭屍進程了。

總結

恭喜!代碼的旅途很漫長,但總算結束了。

如今你擁有了本身的簡單併發服務器,代碼能夠做爲你進一步面向生產級Web服務器的基礎。

接下來是什麼?正如喬希·比林斯所說,

「就像一張郵票,堅持一件事,直到你到達那裏。」

開始掌握基本知識,質疑你已經知道的,而後老是深刻挖掘。

「若是你只學方法,你就會被方法束縛住。但若是你學會了原則,你就能夠設計出本身的方法。」 —— 愛默生。

下面是魚爲這篇文章中的大部份內容而繪製的書籍列表。它們將幫助你拓寬和加深你對魚所涉及主題的知識。魚強烈建議你以某種方式去買那些書:從你的朋友那裏借,從你當地的圖書館裏看,或者在亞馬遜上買:

  1. Unix Network Programming, Volume 1: The Sockets Networking API (3rd Edition)
  2. Advanced Programming in the UNIX Environment, 3rd Edition
  3. The Linux Programming Interface: A Linux and UNIX System Programming Handbook
  4. TCP/IP Illustrated, Volume 1: The Protocols (2nd Edition) (Addison-Wesley Professional Computing Series)
  5. The Little Book of SEMAPHORES (2nd Edition): The Ins and Outs of Concurrency Control and Common Mistakes. Also available for free on the author’s site here.

先這樣吧

原文,如有錯誤之處請指出,更多地關注造殼

相關文章
相關標籤/搜索