有天一個女士出門散步,路過一個建築工地,看到三個男人在干活。她問第一個男人,“你在干什么呢?”,第一個男人被問得很煩,咆哮道,“你沒看到我在碼磚嗎?”。她對回答不滿意,然后問第二個男人他在干什么。第二個男人回答,“我正在砌牆”,然后轉移注意力到第一個男人,他說,“嘿,你碼過頭了,你要把最后一塊磚拿掉。”。她還是對回答不滿意,然后問第三個男人在干什么。第三個男人仰望着天空對她說,“我正在建造世界上最大的教堂。”。當他站在那里仰望天空的時候,另外兩個男人開始爭論磚位置不對的問題。第三個男人轉向前兩個男人說,“嘿,伙計們,別擔心那塊磚了,那是里面的牆,它會被灰泥堵塞起來,然后沒人會看到那塊磚。去另一層干活吧。“
故事的寓意是說,當你了解整個系統,理解不同的部分如何組織到一起的(磚、牆、教堂),你就能找出問題並快速解決之(磚位置不對)。
這跟從零開始搭建你的WEB服務器有什么關系呢?
我相信,要成為優秀的開發者,你必須對你每天都用的底層的軟件系統有進一步的理解,包括編程語言、編譯器和解釋器、數據庫和操作系統、WEB服務器和WEB框架。為了更好更深入的理解這些系統,你可以從零開始一塊磚地,一面牆地,重建它們。
子曰:聞之我也野,視之我也饒,行之我也明
“我看過的,我還記得。”
“我做過的,我都理解了。”
(子曰:聞之我也野,視之我也饒,行之我也明)
此時我希望你能夠相信,從重建不同的軟件系統來開始來學習它們是如何工作的,是一個好主意。
在這個由3部分組成的系列文章中,我會向你展示怎樣搭建一個基本的WEB服務器。咱們開始吧。
重中之重,什么是WEB服務器?
簡而言之,它是一個位於一個物理服務器上的網絡服務器(呀,服務器上的服務器),它等待客戶端發送請求。當它接收到一個請求,就會生成一個響應並回發給客戶端。客戶端和服務器使用HTTP協議通信。客戶端可以是瀏覽器或者別的使用HTTP協議的軟件。
一個非常簡單的WEB服務器實現長什么樣呢?以下是我寫的一個。例子是用Python語言寫的,但是即使你不會Python(它是一個非常易學的語言,試試!),你仍然可以通過代碼和下面的解釋理解相關概念:
1
2
|
$ python webserver1.py
Serving HTTP on port 8888 …
|
現在在你的WEB瀏覽器地址欄里輸入以下URL http://localhost:8888/hello,敲回車,見證奇跡的時刻。你會看到瀏覽器顯示”Hello, World!“,像這樣:
認真做一下吧,我會等你的。
做完了?很好。現在我們討論一下它到底怎么工作的。
首先我們從你剛才鍵入的WEB地址開始。它叫URL,這是它的基本結構:
這個就表示怎樣告訴瀏覽器要查找和連接的WEB服務器地址,和你要獲取的服務器上的頁面(路徑)。但是在瀏覽器發送HTTP請求前,瀏覽器需要先和WEB服務器建立TCP連接。然后瀏覽器在TCP連接上發送HTTP請求,然后等待服務器回發HTTP響應。當瀏覽器接收到響應后,顯示響應,在本次例子中,瀏覽器顯示“Hello, World!”。
我們再詳細探索一下客戶端和服務器在發送HTTP請求和響應前如何建立TCP連接的。在建立連接,它們必須使用所謂的sockets。用你命令行下的telnet手動模擬瀏覽器吧,而不是直接使用瀏覽器。
在運行WEB服務器的同一台電腦上,在命令行啟動一個telnet會話,指定連接到localhost主機,連接端口為8888,然后按回車:
$ telnet localhost 8888 Trying 127.0.0.1 … Connected to localhost.
此時,你已經和運行在你本地主機的服務器建立了TCP連接,已經准備好發送並接收HTTP消息了。下圖中你可以看到一個服務器要經過的標准步驟,然后才能接受新的TCP連接。
在同一個telnet會話中,輸入 GET /hello HTTP/1.1然后敲回車:
你完成了手動模擬瀏覽器!你發送了一個HTTP請求並得到了一個HTTP響應。這是HTTP請求的基本結構:
HTTP請求由行組成。行指示了HTTP方法(GET,因為我們請求我們的服務器返回給我們一些東西)、代表我們想要的服務器上的“頁面”的路徑 /hello和協議版本。
為了簡單起見,此時我們的WEB服務器完全忽略了上面的請求行。你也可以輸入任何垃圾字符取代“GET /hello HTTP/1.1”,你仍然會得到“Hello, World!”響應。
一旦你輸入了請求行,敲了回車,客戶端就發送請求給服務器,服務器讀取請求行,打印出來然后返回相應的HTTP響應。
以下是服務器回發給客戶端(這個例子中是telnet)的HTTP響應:
咱們分析一下它,響應包含了狀態行HTTP/1.1 200 OK,隨后一個必須的空行,和HTTP響應body。
響應狀態行TTP/1.1 200 OK包含了HTTP版本,HTTP狀態碼和HTTP狀態碼理由短語OK。瀏覽器得到響應時,它就顯示響應的body,所以你就看到了“Hello, World!”
這就是WEB瀏覽器怎么工作的基本模型。總結來說:WEB服務器創建一個監聽socket然后開始循環接受新連接。客戶端初始化一個TCP連接,在連接成功后,客戶端發送HTTP請求到服務器,服務器響應一個顯示給用戶的HTTP響應。客戶端和服務器都使用socket建立TCP連接。
你現在你擁有了一個非常基礎的WEB服務器,你可以用瀏覽器或其他的HTTP客戶端測試它。正如你看到的,使用telnet手動輸入HTTP請求,你也就成了一個人肉 HTTP 客戶端。
對你來說有一個問題:“怎樣在你的剛完成的WEB服務器下運行 Django 應用、Flask 應用和 Pyramid 應用?在不單獨修改服務器來適應這些不同的 WEB 框架的情況下。”
還記得嗎?在本系列第一部分我問過你:“怎樣在你的剛完成的WEB服務器下運行 Django 應用、Flask 應用和 Pyramid 應用?在不單獨修改服務器來適應這些不同的WEB框架的情況下。”往下看,來找出答案。
過去,你所選擇的一個Python Web框架會限制你選擇可用的Web服務器,反之亦然。如果框架和服務器設計的是可以一起工作的,那就很好:
但是,當你試着結合沒有設計成可以一起工作的服務器和框架時,你可能要面對(可能你已經面對了)下面這種問題:
基本上,你只能用可以在一起工作的部分,而不是你想用的部分。
那么,怎樣確保在不修改Web服務器和Web框架下,用你的Web服務器運行不同的Web框架?答案就是Python Web服務器網關接口(或者縮寫為WSGI,讀作“wizgy”)。
WSGI允許開發者把框架的選擇和服務器的選擇分開。現在你可以真正地混合、匹配Web服務器和Web框架了。例如,你可以在Gunicorn或者Nginx/uWSGI或者Waitress上面運行Django,Flask,或Pyramid。真正的混合和匹配喲,感謝WSGI服務器和框架兩者都支持:
就這樣,WSGI成了我在本系列第一部分和本文開頭重復問的問題的答案。你的Web服務器必須實現WSGI接口的服務器端,所有的現代Python Web框架已經實現 了WSGI接口的框架端了,這就讓你可以不用修改服務器代碼,適應某個框架。
現在你了解了Web服務器和WEb框架支持的WSGI允許你選擇一對兒合適的(服務器和框架),它對服務器和框架的開發者也有益,因為他們可以專注於他們特定的領域,而不是越俎代庖。其他語言也有相似的接口:例如,Java有Servlet API,Ruby有Rack。
一切都還不錯,但我打賭你會說:“秀代碼給我看!” 好吧,看看這個漂亮且簡約的WSGI服務器實現:
它明顯比本系列第一部分中的服務器代碼大,但為了方便你理解,而不陷入具體細節,它也足夠小了(只有150行不到)。上面的服務器還做了別的事 – 它可以運行你喜歡的Web框架寫的基本的Web應用,可以是Pyramid,Flask,Django,或者其他的Python WSGI框架。
不信?自己試試看。把上面的代碼保存成webserver2.py或者直接從Github上下載。如果你不帶參數地直接運行它,它就會報怨然后退出。
1
2
|
$ python webserver2.py
Provide a WSGI application object as module:callable
|
它真的想給Web框架提供服務,從這開始有趣起來。要運行服務器你唯一需要做的是安裝Python。但是要運行使用Pyramid,Flask,和Django寫的應用,你得先安裝這些框架。一起安裝這三個吧。我比較喜歡使用virtualenv。跟着以下步驟來創建和激活一個虛擬環境,然后安裝這三個Web框架。
1
2
3
4
5
6
7
8
9
10
|
$ [sudo] pip install virtualenv
$ mkdir ~/envs
$ virtualenv ~/envs/lsbaws/
$ cd ~/envs/lsbaws/
$ ls
bin include lib
$ source bin/activate
(lsbaws) $ pip install pyramid
(lsbaws) $ pip install flask
(lsbaws) $ pip install django
|
此時你需要創建一個Web應用。我們先拿Pyramid開始吧。保存以下代碼到保存webserver2.py時相同的目錄。命名為pyramidapp.py。或者直接從Github上下載:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
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應用了:
1
2
|
(lsbaws) $ python webserver2.py pyramidapp:app
WSGIServer: Serving HTTP on port 8888 ...
|
剛才你告訴你的服務器從python模塊‘pyramidapp’中加載可調用的‘app’,現在你的服務器准備好了接受請求然后轉發它們給你的Pyramid應用。目前應用只處理一個路由:/hello 路由。在瀏覽器里輸入http://localhost:8888/hello地址,按回車鍵,觀察結果:
你也可以在命令行下使用‘curl’工具來測試服務器:
1
2
|
$ curl -v http://localhost:8888/hello
...
|
檢查服務器和curl輸出了什么到標准輸出。
現在弄Flask。按照相同的步驟。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
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或者從Github上下載它。然后像這樣運行服務器:
1
2
|
(lsbaws) $ python webserver2.py flaskapp:app
WSGIServer: Serving HTTP on port 8888 ...
|
現在在瀏覽器里輸入http://localhost:8888/hello然后按回車:
再一次,試試‘curl’,看看服務器返回了一條Flask應用產生的消息:
1
2
|
$ curl -v http://localhost:8888/hello
...
|
服務器也能處理Django應用嗎?試試吧!盡管這有點復雜,但我還是推薦克隆整個倉庫,然后使用djangoapp.py,它是GitHub倉庫的一部分。以下的源碼,簡單地把Django ‘helloworld’ 工程(使用Django的django-admin.py啟動項目預創建的)添加到當前Python路徑,然后導入了工程的WSGI應用。
1
2
3
4
5
|
import sys
sys.path.insert(0, './helloworld')
from helloworld import wsgi
app = wsgi.application
|
把以上代碼保存為djangoapp.py,然后用你的Web服務器運行Django應用:
1
2
|
(lsbaws) $ python webserver2.py djangoapp:app
WSGIServer: Serving HTTP on port 8888 ...
|
輸入下面的地址,然后按回車鍵:
雖然你已經做過兩次啦,你還是可以再在命令行測試一下,確認一下,這次是Django應用處理了請求。
1
2
|
$ curl -v http://localhost:8888/hello
...
|
你試了吧?你確定服務器可以和這三個框架一起工作吧?如果沒試,請試一下。閱讀挺重要,但這個系列是關於重建的,也就是說,你要自己動手。去動手試試吧。別擔心,我等你喲。你必須試下,最好呢,你親自輸入所有的東西,確保它工作起來像你期望的那樣。
很好,你已經體驗到了WSGI的強大:它可以讓你把Web服務器和Web框架結合起來。WSGI提供了Python Web服務器和Python Web框架之間的一個最小接口。它非常簡單,在服務器和框架端都可以輕易實現。下面的代碼片段展示了(WSGI)接口的服務器和框架端:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
|
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 ['Hello world!']
run_application(app)
|
以下是它如何工作的:
- 1.框架提供一個可調用的’應用’(WSGI規格並沒有要求如何實現)
- 2.服務器每次接收到HTTP客戶端請求后,執行可調用的’應用’。服務器把一個包含了WSGI/CGI變量的字典和一個可調用的’start_response’做為參數給可調用的’application’。
- 3.框架/應用生成HTTP狀態和HTTP響應頭,然后把它們傳給可調用的’start_response’,讓服務器保存它們。框架/應用也返回一個響應體。
- 4.服務器把狀態,響應頭,響應體合並到HTTP響應里,然后傳給(HTTP)客戶端(這步不是(WSGI)規格里的一部分,但它是后面流程中的一步,為了解釋清楚我加上了這步)
以下是接口的視覺描述:
目前為止,你已經了解了Pyramid,Flask,和Django Web應用,你還了解了實現了WSGI規范服務器端的服務器代碼。你甚至已經知道了不使用任何框架的基本的WSGI應用代碼片段。
問題就在於,當你使用這些框架中的一個來寫Web應用時,你站在一個比較高的層次,並不直接和WSGI打交道,但我知道你對WSGI接口的框架端好奇,因為你在讀本文。所以,咱們一起寫個極簡的WSGI Web應用/Web框架吧,不用Pyramid,Flask,或者Django,然后用你的服務器運行它:
1
2
3
4
5
6
7
8
9
|
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 ['Hello world from a simple WSGI application!\n']
|
再次,保存以上代碼到wsgiapp.py文件,或者直接從GitHub上下載,然后像下面這樣使用你的Web服務器運行應用:
1
2
|
(lsbaws) $ python webserver2.py wsgiapp:app
WSGIServer: Serving HTTP on port 8888 ...
|
輸入下面地址,敲回車。你應該就看到下面結果了:
在你學習怎樣寫一個Web服務器時,你剛剛寫了一個你自己的極簡的WSGI Web框架!棒極啦。
現在,讓我們回頭看看服務器傳輸了什么給客戶端。以下就是使用HTTP客戶端調用Pyramid應用時生成的HTTP響應:
這個響應跟你在本系列第一部分看到的有一些相近的部分,但也有一些新東西。例如,你以前沒見過的4個HTTP頭:Content-Type, Content-Length, Date, 和Servedr。這些頭是Web服務器生成的響應應該有的。雖然他們並不是必須的。頭的目的傳輸HTTP請求/響應的額外信息。
現在你對WSGI接口了解的更多啦,同樣,以下是帶有更多信息的HTTP響應,這些信息表示了哪些部件產生的它(響應):
我還沒有介紹’environ’字典呢,但它基本上就是一個Python字典,必須包含WSGI規范規定的必要的WSGI和CGI變量。服務器在解析請求后,從HTTP請求拿到了字典的值,字典的內容看起來像下面這樣:
Web框架使用字典里的信息來決定使用哪個視圖,基於指定的路由,請求方法等,從哪里讀請求體,錯誤寫到哪里去,如果有的話。
現在你已經創建了你自己的WSGI Web服務器,使用不同的Web框架寫Web應用。還有,你還順手寫了個簡單的Web應用/Web框架。真是段難忘的旅程。咱們簡要重述下WSGI Web服務器必須做哪些工作才能處理發給WSGI應用的請求吧:
- 首先,服務器啟動並加載一個由Web框架/應用提供的可調用的’application’
- 然后,服務器讀取請求
- 然后,服務器解析它
- 然后,服務器使用請求的數據創建了一個’environ’字典
- 然后,服務器使用’environ’字典和’start_response’做為參數調用’application’,並拿到返回的響應體。
- 然后,服務器使用調用’application’返回的數據,由’start_response’設置的狀態和響應頭,來構造HTTP響應。
- 最終,服務器把HTTP響應傳回給戶端。
這就是全部啦。現在你有了一個可工作的WSGI服務器,它可以處理使用像Django,Flask,Pyramid或者 你自己的WSGI框架這樣的兼容WSGI的Web框架寫的基本的Web應用。最優秀的地方是,服務器可以在不修改代碼的情況下,使用不同的Web框架。
在你離開之前,還有個問題請你想一下,“該怎么做才能讓服務器同一時間處理多個請求呢?”
“發明創造時,我們學得最多” —— Piaget
在本系列第二部分,你已經創造了一個可以處理基本的 HTTP GET 請求的 WSGI 服務器。我還問了你一個問題,“怎么讓服務器在同一時間處理多個請求?”在本文中你將找到答案。那么,系好安全帶加大馬力。你馬上就乘上快車啦。准備好Linux、Mac OS X(或任何類unix系統)和 Python。本文的所有源碼都能在GitHub上找到。
首先咱們回憶下一個基本的Web服務器長什么樣,要處理客戶端請求它得做什么。你在第一部分和第二部分創建的是一個迭代的服務器,每次處理一個客戶端請求。除非已經處理了當前的客戶端請求,否則它不能接受新的連接。有些客戶端對此就不開心了,因為它們必須要排隊等待,而且如果服務器繁忙的話,這個隊伍會很長。
以下是迭代服務器webserver3a.py的代碼:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
#####################################################################
# 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代碼:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
#########################################################################
# 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()
|
啟動服務器:
1
|
$ python webserver3b.py
|
現在打開一個新的控制台窗口,運行以下curl命令。你應該立即就會看到屏幕上打印出了“Hello, World!”字符串:
1
2
3
4
|
$ curl http://localhost:8888/hello
Hello, World!
And without delay open up a second terminal window and run the same curl command:
|
立刻再打開一個控制台窗口,然后運行相同的curl命令:
1
|
$ 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。理解socket的一個重要的概念是TCP socket對。
TCP的socket對是一個4元組,標識着TCP連接的兩個終端:本地IP地址、本地端口、遠程IP地址、遠程端口。一個socket對唯一地標識着網絡上的TCP連接。標識着每個終端的兩個值,IP地址和端口號,通常被稱為socket。
所以,元組{10.10.10.2:49152, 12.12.12.3:8888}是客戶端TCP連接的唯一標識着兩個終端的socket對。元組{12.12.12.3:8888, 10.10.10.2:49152}是服務器TCP連接的唯一標識着兩個終端的socket對。標識TCP連接中服務器終端的兩個值,IP地址12.12.12.3和端口8888,在這里就是指socket(同樣適用於客戶端終端)。
服務器創建一個socket並開始接受客戶端連接的標准流程經歷通常如下:
- 服務器創建一個TCP/IP socket。在Python里使用下面的語句即可:
1listen_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - 服務器可能會設置一些socket選項(這是可選的,上面的代碼就設置了,為了在殺死或重啟服務器后,立馬就能再次重用相同的地址)。
1listen_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - 然后,服務器綁定指定地址,bind函數分配一個本地地址給socket。在TCP中,調用bind可以指定一個端口號,一個IP地址,兩者都,或者兩者都不指定。
1listen_socket.bind(SERVER_ADDRESS) - 然后,服務器讓這個socket成為監聽socket。
1listen_socket.listen(REQUEST_QUEUE_SIZE)
listen方法只會被服務器調用。它告訴內核它要接受這個socket上的到來的連接請求了。
做完這些后,服務器開始循環地一次接受一個客戶端連接。當有連接到達時,aceept調用返回已連接的客戶端socket。然后,服務器從這個socket讀取請求數據,在標准輸出上把數據打印出來,並回發一個消息給客戶端。然后,服務器關閉客戶端連接,准備好再次接受新的客戶端連接。
下面是客戶端使用TCP/IP和服務器通信要做的:
以下是客戶端連接服務器,發送請求並打印響應的示例代碼:
1
2
3
4
5
6
7
8
9
10
|
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調用做到的:
1
|
sock.connect(('localhost', 8888))
|
客戶端僅需提供要連接的遠程IP地址或主機名和遠程端口號即可。
可能你注意到了,客戶端不用調用bind和accept。客戶端沒必要調用bind,是因為客戶端不關心本地IP地址和本地端口號。當客戶端調用connect時內核的TCP/IP棧自動分配一個本地IP址地和本地端口。本地端口被稱為暫時端口( ephemeral port),也就是,short-lived 端口。
服務器上標識着一個客戶端連接的眾所周知的服務的端口被稱為well-known端口(舉例來說,80就是HTTP,22就是SSH)。操起Python shell,創建個連接到本地服務器的客戶端連接,看看內核分配給你創建的socket的暫時的端口是多少(在這之前啟動webserver3a.py或webserver3b.py):
1
2
3
4
5
6
|
>>> 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.py 或 webserver3b.py 時,你就在運行一個進程了。
在控制台窗口運行webserver3b.py:
1
|
$ python webserver3b.py
|
在別的控制台窗口使用ps命令獲取這個進程的信息:
1
2
|
$ 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里運行的服務器,那新進程的父進程ID就是BASH shell的進程ID。
自己試試,看看它是怎么工作的。再啟動Python shell,這將創建一個新進程,使用 os.getpid() 和 os.getppid() 系統調用獲取Python shell進程的ID和父進程ID(BASH shell的PID)。然后,在另一個控制台窗口運行ps命令,使用grep查找PPID(父進程ID,我的是3148)。在下面的截圖你可以看到在我的Mac OS X上,子Python shell進程和父BASH shell進程的關系:
另一個要了解的重要概念是文件描述符。那么什么是文件描述符呢?文件描述符是當打開一個存在的文件,創建一個文件,或者創建一個socket時,內核返回的非負整數。你可能已經聽過啦,在UNIX里一切皆文件。內核使用文件描述符來追蹤進程打開的文件。當你需要讀或寫文件時,你就用文件描述符標識它好啦。Python給你包裝成更高級別的對象來處理文件(和socket),你不必直接使用文件描述符來標識一個文件,但是,在底層,UNIX中是這樣標識文件和socket的:通過它們的整數文件描述符。
默認情況下,UNIX shell分配文件描述符0給進程的標准輸入,文件描述符1給進程的標准輸出,文件描述符2給標准錯誤。
就像我前面說的,雖然Python給了你更高級別的文件或者類文件的對象,你仍然可以使用對象的fileno()方法來獲取對應的文件描述符。回到Python shell來看看怎么做:
1
2
3
4
5
6
7
8
9
|
>>> 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系統調用寫一個字符串到標准輸出,write使用整數文件描述符做為參數:
1
2
3
4
|
>>> import sys
>>> import os
>>> res = os.write(sys.stdout.fileno(), 'hellon')
hello
|
有趣的是——應該不會驚訝到你啦,因為你已經知道在UNIX里一切皆文件——socket也有一個分配給它的文件描述符。再說一遍,當你創建一個socket時,你得到的是一個對象而不是非負整數,但你也可以使用我前面提到的fileno()方法直接訪問socket的文件描述符。
還有一件事我想說下:你注意到了嗎?在第二個例子webserver3b.py中,當服務器進程在60秒的睡眠時你仍然可以用curl命令來連接。當然啦,curl沒有立刻輸出什么,它只是在那掛起。但為什么服務器不接受連接,客戶端也不立刻被拒絕,而是能連接服務器呢?答案就是socket對象的listen方法和它的BACKLOG參數,我稱它為 REQUEST_QUEUE_SIZE(請求隊列長度)。BACKLOG參數決定了內核為進入的連接請求准備的隊列長度。當服務器webser3b.py睡眠時,第二個curl命令可以連接到服務器,因為內核在服務器socket的進入連接請求隊列上有足夠的可用空間。
然而增加BACKLOG參數不會神奇地讓服務器同時處理多個客戶端請求,設置一個合理大點的backlog參數挺重要的,這樣accept調用就不用等新連接建立起來,立刻就能從隊列里獲取新的連接,然后開始處理客戶端請求啦。
吼吼!你已經了解了非常多的背景知識啦。咱們快速簡要重述到目前為止你都學了什么(如果你都知道啦就溫習一下吧)。
- 迭代服務器
- 服務器socket創建流程(socket, bind, listen, accept)
- 客戶端連接創建流程(socket, connect)
- socket對
- socket
- 臨時端口和眾所周知端口
- 進程
- 進程ID(PID),父進程ID(PPID),父子關系。
- 文件描述符
- listen方法的BACKLOG參數的意義
現在我准備回答第二部分問題的答案了:“怎樣才能讓服務器同時處理多個請求?”或者換句話說,“怎樣寫一個並發服務器?”
在Unix上寫一個並發服務器最簡單的方法是使用fork()系統調用。
下面就是新的牛逼閃閃的並發服務器webserver3c.py的代碼,它能同時處理多個客戶端請求(和咱們迭代服務器例子webserver3b.py一樣,每個子進程睡眠60秒):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
|
###########################################################################
# 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()
|
在深入討論for如何工作之前,先自己試試,看看服務器確實可以同時處理多個請求,不像webserver3a.py和webserver3b.py。用下面命令啟動服務器:
1
|
$ python webserver3c.py
|
像你以前那樣試試用兩個curl命令,自己看看,現在雖然服務器子進程在處理客戶端請求時睡眠60秒,但不影響別的客戶端,因為它們是被不同的完全獨立的進程處理的。你應該能看到curl命令立刻就輸出了“Hello, World!”,然后掛起60秒。你可以接着想運行多少curl命令就運行多少(嗯,幾乎是任意多),它們都會立刻輸出服務器的響應“Hello, Wrold”,而且不會有明顯的延遲。試試看。
理解fork()的最重要的點是,你fork了一次,但它返回了兩次:一個是在父進程里,一個是在子進程里。當你fork了一個新進程,子進程返回的進程ID是0。父進程里fork返回的是子進程的PID。
我仍然記得當我第一次知道它使用它時我對fork是有多着迷。它就像魔法一樣。我正讀着一段連續的代碼,然后“duang”的一聲:代碼克隆了自己,然后就有兩個相同代碼的實例同時運行。我想除了魔法無法做到,我是認真噠。
當父進程fork了一個新的子進程,子進程就獲取了父進程文件描述符的拷貝:
你可能已經注意到啦,上面代碼里的父進程關閉了客戶端連接:
1
2
|
else: # parent
client_connection.close() # close parent copy and loop over
|
那么,如果它的父進程關閉了同一個socket,子進程為什么還能從客戶端socket讀取數據呢?答案就在上圖。內核使用描述符引用計數來決定是否關閉socket。只有當描述符引用計數為0時才關閉socket。當服務器創建一個子進程,子進程獲取了父進程的文件描述符拷貝,內核增加了這些描述符的引用計數。在一個父進程和一個子進程的場景中,客戶端socket的描述符引用計數就成了2,當父進程關閉了客戶端連接socket,它僅僅把引用計數減為1,不會引發內核關閉這個socket。子進程也把父進程的listen_socket拷貝給關閉了,因為子進程不用管接受新連接,它只關心處理已經連接的客戶端的請求:
1
|
listen_socket.close() # close child copy
|
本文后面我會講下如果不關閉復制的描述符會發生什么。
你從並發服務器源碼看到啦,現在服務器父進程唯一的角色就是接受一個新的客戶端連接,fork一個新的子進程來處理客戶端請求,然后重復接受另一個客戶端連接,就沒有別的事做啦。服務器父進程不處理客戶端請求——它的小弟(子進程)干這事。
跑個題,我們說兩個事件並發到底是什么意思呢?
當我們說兩個事件並發時,我們通常表達的是它們同時發生。簡單來說,這也不錯,但你要知道嚴格定義是這樣的:
1
|
如果你不能通過觀察程序來知道哪個先發生的,那么這兩個事件就是並發的。
|
又到了簡要重述目前為止已經學習的知識點和概念的時間啦.
- 在Unix下寫一個並發服務器最簡單的方法是使用fork()系統調用
- 當一個進程fork了一個新進程時,它就變成了那個新fork產生的子進程的父進程。
- 在調用fork后,父進程和子進程共享相同的文件描述符。
- 內核使用描述符引用計數來決定是否關閉文件/socket。
- 服務器父進程的角色是:現在它干的所有活就是接受一個新連接,fork一個子進來來處理這個請求,然后循環接受新連接。
咱們來看看,如果在父進程和子進程中你不關閉復制的socket描述符會發生什么吧。以下是個修改后的版本,服務器不關閉復制的描述符,webserver3d.py:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
###########################################################################
# 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()
|
啟動服務器:
1
|
$ python webserver3d.py
|
使用curl去連接服務器:
1
2
|
$ curl http://localhost:8888/hello
Hello, World!
|
好的,curl打印出來並發服務器的響應,但是它不終止,一直掛起。發生了什么?服務器不再睡眠60秒了:它的子進程開心地處理了客戶端請求,關閉了客戶端連接然后退出啦,但是客戶端curl仍然不終止。
那么,為什么curl不終止呢?原因就在於復制的文件描述符。當子進程關閉了客戶端連接,內核減少引用計數,值變成了1。服務器子進程退出,但是客戶端socket沒有被內核關閉掉,因為引用計數不是0啊,所以,結果就是,終止數據包(在TCP/IP說法中叫做FIN)沒有發送給客戶端,所以客戶端就保持在線啦。這里還有個問題,如果服務器不關閉復制的文件描述符然后長時間運行,最終會耗盡可用文件描述符。
使用Control-C停止webserver3d.py,使用shell內建的命令ulimit檢查一下shell默認設置的進程可用資源:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
|
$ 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:
1
|
$ ulimit -n 256
|
在同一個控制台上啟動webserver3d.py:
1
|
$ python webserver3d.py
|
使用下面的client3.py客戶端來測試服務器。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
#####################################################################
# 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個連接同時連接服務器。
1
|
$ python client3.py --max-clients=300
|
很快服務器就崩了。下面是我電腦上拋異常的截圖:
教訓非常明顯啦——服務器應該關閉復制的描述符。但即使關閉了復制的描述符,你還沒有接觸到底層,因為你的服務器還有個問題,僵屍!
是噠,服務器代碼就是產生了僵屍。咱們看下是怎么產生的。再次運行服務器:
1
|
$ python webserver3d.py
|
在另一個控制台窗口運行下面的curl命令:
1
|
$ curl http://localhost:8888/hello
|
現在運行ps命令,顯示運行着的Python進程。以下是我的Ubuntu電腦上的ps輸出:
1
2
3
|
$ 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(確保設置打開文件更高,比如500吧):
1
2
|
$ ulimit -u 400
$ ulimit -n 500
|
在同一個控制台窗口運行webserver3d.py:
1
|
$ python webserver3d.py
|
新開一個控制台窗口,啟動client3.py,讓它創建500個連接同時連接到服務器:
1
|
$ python client3.py --max-clients=500
|
然后,服務器又一次崩了,是OSError的錯誤:拋了資源臨時不可用的異常,當試圖創建新的子進程時但創建不了時,因為達到了最大子進程數限制。以下是我的電腦的截圖:
看到了吧,如果你不處理好僵屍,服務器長時間運行就會出問題。我會簡短討論下服務器應該怎樣處理僵屍問題。
咱們簡要重述下目前為止你已經學習到主要知識點:
- 如果不關閉復制描述符,客戶端不會終止,因為客戶端連接不會關閉。
- 如果不關閉復制描述符,長時間運行的服務器最終會耗盡可用文件描述符(最大打開文件)。
- 當fork了一個子進程,然后子進程退出了,父進程沒有等它,而且沒有收集它的終止狀態,它就變成僵屍了。
- 僵屍要吃東西,我們的場景中,就是內存。服務器最終會耗盡可用進程(最大用戶進程),如果不處理好僵屍的話。
- 僵屍殺不死的,你需要等它們。
那么,處理好僵屍的話,要做什么呢?要修改服務器代碼去等僵屍,獲取它們的終止狀態。通過調用wait系統調用就好啦。不幸的是,這不完美,因為如果調用wait,然而沒有終止的子進程,wait就會阻塞服務器,實際上就是阻止了服務器處理新的客戶端連接請求。有其他辦法嗎?當然有啦,其中之一就是使用信息處理器和wait系統調用組合。
以下是如何工作的。當一個子進程終止了,內核發送SIGCHLD信號。父進程可以設置一個信號處理器來異步地被通知,然后就能wait子進程獲取它的終止狀態,因此阻止了僵屍進程出現。
順便說下,異步事件意味着父進程不會提前知道事件發生的時間。
修改服務器代碼,設置一個SIGCHLD事件處理器,然后在事件處理器里wait終止的子進程。webserver3e.py代碼如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
|
###########################################################################
# 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()
|
啟動服務器:
1
|
$ python webserver3e.py
|
使用老朋友curl給修改后的並發服務器發送請求:
1
|
$ curl http://localhost:8888/hello
|
觀察服務器:
剛才發生了什么?accept調用失敗了,錯誤是EINTR。
當子進程退出,引發SIGCHLD事件時,父進程阻塞在accept調用,這激活了事件處理器,然后當事件處理器完成時,accept系統調用就中斷了:
別着急,這個問題很好解決。你要做的就是重新調用accept。以下是修改后的代碼:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
|
###########################################################################
# 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:
1
|
$ python webserver3f.py
|
使用curl給修改后的服務器發送請求:
1
|
$ curl http://localhost:8888/hello
|
看到了嗎?沒有EINTR異常啦。現在,驗證一下吧,沒有僵屍了,帶wait的SIGCHLD事件處理器也能處理好子進程了。怎么驗證呢?只要運行ps命令,看看沒有Z+狀態的進程(沒有進程)。太棒啦!沒有僵屍在四周跳的感覺真安全呢!
- 如果fork了子進程並不wait它,它就成僵屍了。
- 使用SIGCHLD事件處理器來異步的wait終止了的子進程來獲取它的終止狀態
- 使用事件處理器時,你要明白,系統調用會被中斷的,你要做好准備對付這種情況
嗯,目前為止,一次都好。沒有問題,對吧?好吧,幾乎滑。再次跑下webserver3f.py,這次不用curl請求一次了,改用client3.py來創建128個並發連接:
1
|
$ python client3.py --max-clients 128
|
現在再運行ps命令
1
|
$ ps auxw | grep -i python | grep -v grep
|
看到了吧,少年,僵屍又回來了!
這次又出什么錯了呢?當你運行128個並發客戶端時,建立了128個連接,子進程處理了請求然后幾乎同時終止了,這就引發了SIGCHLD信號洪水般的發給父進程。問題在於,信號沒有排隊,父進程錯過了一些信號,導致了一些僵屍到處跑沒人管:
解決方案就是設置一個SIGCHLD事件處理器,但不用wait了,改用waitpid系統調用,帶上WNOHANG參數,循環處理,確保所有的終止的子進程都被處理掉。以下是修改后的webserver3g.py:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
|
###########################################################################
# 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()
|
啟動服務器:
1
|
$ python webserver3g.py
|
使用測試客戶端client3.py:
1
|
$ python client3.py --max-clients 128
|
現在驗證一下沒有僵屍了吧。哈!沒有僵屍的日子真好!
恭喜!這真是段很長的旅程啊,希望你喜歡。現在你已經擁有了自己的簡單並發服務器,而且這個代碼有助於你在將來的工作中開發一個產品級的Web服務器。
我要把它留作練習,你來修改第二部分的WSGI服務器,讓它達到並發。你在這里可以找到修改后的版本。但是你要自己實現后再看我的代碼喲。你已經擁有了所有必要的信息,所以,去實現它吧!
接下來做什么呢?就像Josh Billings說的那樣,
像郵票那樣——用心做一件事,直到完成。
去打好基礎吧。質疑你已經知道的,保持深入研究。
如果你只學方法,你就依賴方法。但如果你學會原理,你可以發明自己的方法。—— 愛默生
以下是我挑出來對本文最重要的幾本書。它們會幫你拓寬加深我提到的知識。我強烈建議你想言設法弄到這些書:從朋友那借也好,從本地圖書館借,或者從亞馬遜買也行。它們是守護者:
- Unix網絡編程,卷1:socket網絡API(第三版)
- UNIX環境高級編程,第三版
- Linux編程接口:Linux和UNIX系統編輯手冊
- TCP/IP詳解,卷1:協議(第二版)
- 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.