Python Web服務器

有天一個女士出門散步,路過一個建築工地,看到三個男人在幹活。她問第一個男人,「你在幹什麼呢?」,第一個男人被問得很煩,咆哮道,「你沒看到我在碼磚嗎?」。她對回答不滿意,而後問第二個男人他在幹什麼。第二個男人回答,「我正在砌牆」,而後轉移注意力到第一個男人,他說,「嘿,你碼過頭了,你要把最後一塊磚拿掉。」。她仍是對回答不滿意,而後問第三個男人在幹什麼。第三個男人仰望着天空對她說,「我正在建造世界上最大的教堂。」。當他站在那裏仰望天空的時候,另外兩個男人開始爭論磚位置不對的問題。第三個男人轉向前兩個男人說,「嘿,夥計們,別擔憂那塊磚了,那是裏面的牆,它會被灰泥堵塞起來,而後沒人會看到那塊磚。去另外一層幹活吧。「python

故事的寓意是說,當你瞭解整個系統,理解不一樣的部分如何組織到一塊兒的(磚、牆、教堂),你就能找出問題並快速解決之(磚位置不對)。git

這跟從零開始搭建你的WEB服務器有什麼關係呢?

我相信,要成爲優秀的開發者,你必須對你天天都用的底層的軟件系統有進一步的理解,包括編程語言、編譯器和解釋器、數據庫和操做系統、WEB服務器和WEB框架。爲了更好更深刻的理解這些系統,你能夠從零開始一塊磚地,一面牆地,重建它們。github

子曰:聞之我也野,視之我也饒,行之我也明web

「我看過的,我還記得。」shell

「我作過的,我都理解了。」數據庫

 

(子曰:聞之我也野,視之我也饒,行之我也明)

此時我但願你可以相信,從重建不一樣的軟件系統來開始來學習它們是如何工做的,是一個好主意。django

在這個由3部分組成的系列文章中,我會向你展現怎樣搭建一個基本的WEB服務器。我們開始吧。編程

重中之重,什麼是WEB服務器?

簡而言之,它是一個位於一個物理服務器上的網絡服務器(呀,服務器上的服務器),它等待客戶端發送請求。當它接收到一個請求,就會生成一個響應並回發給客戶端。客戶端和服務器使用HTTP協議通訊。客戶端能夠是瀏覽器或者別的使用HTTP協議的軟件。flask

一個很是簡單的WEB服務器實現長什麼樣呢?如下是我寫的一個。例子是用Python語言寫的,可是即便你不會Python(它是一個很是易學的語言,試試!),你仍然能夠經過代碼和下面的解釋理解相關概念:瀏覽器

如今在你的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,而後按回車:

此時,你已經和運行在你本地主機的服務器創建了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上下載。若是你不帶參數地直接運行它,它就會報怨而後退出。

它真的想給Web框架提供服務,從這開始有趣起來。要運行服務器你惟一須要作的是安裝Python。可是要運行使用Pyramid,Flask,和Django寫的應用,你得先安裝這些框架。一塊兒安裝這三個吧。我比較喜歡使用virtualenv。跟着如下步驟來建立和激活一個虛擬環境,而後安裝這三個Web框架。

此時你須要建立一個Web應用。咱們先拿Pyramid開始吧。保存如下代碼到保存webserver2.py時相同的目錄。命名爲pyramidapp.py。或者直接從Github上下載:

如今你已經準備好用徹底屬於本身的Web服務器來運行Pyramid應用了:

剛纔你告訴你的服務器從python模塊‘pyramidapp’中加載可調用的‘app’,如今你的服務器準備好了接受請求而後轉發它們給你的Pyramid應用。目前應用只處理一個路由:/hello 路由。在瀏覽器裏輸入http://localhost:8888/hello地址,按回車鍵,觀察結果:

你也能夠在命令行下使用‘curl’工具來測試服務器:

檢查服務器和curl輸出了什麼到標準輸出。

如今弄Flask。按照相同的步驟。

保存以上代碼爲flaskapp.py或者從Github上下載它。而後像這樣運行服務器:

如今在瀏覽器裏輸入http://localhost:8888/hello而後按回車:

再一次,試試‘curl’,看看服務器返回了一條Flask應用產生的消息:

服務器也能處理Django應用嗎?試試吧!儘管這有點複雜,但我仍是推薦克隆整個倉庫,而後使用djangoapp.py,它是GitHub倉庫的一部分。如下的源碼,簡單地把Django ‘helloworld’ 工程(使用Django的django-admin.py啓動項目預建立的)添加到當前Python路徑,而後導入了工程的WSGI應用。

把以上代碼保存爲djangoapp.py,而後用你的Web服務器運行Django應用:

輸入下面的地址,而後按回車鍵:

雖然你已經作過兩次啦,你仍是能夠再在命令行測試一下,確認一下,此次是Django應用處理了請求。

你試了吧?你肯定服務器能夠和這三個框架一塊兒工做吧?若是沒試,請試一下。閱讀挺重要,但這個系列是關於重建的,也就是說,你要本身動手。去動手試試吧。別擔憂,我等你喲。你必須試下,最好呢,你親自輸入全部的東西,確保它工做起來像你指望的那樣。

很好,你已經體驗到了WSGI的強大:它可讓你把Web服務器和Web框架結合起來。WSGI提供了Python Web服務器和Python Web框架之間的一個最小接口。它很是簡單,在服務器和框架端均可以輕易實現。下面的代碼片斷展現了(WSGI)接口的服務器和框架端:

如下是它如何工做的:

  • 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,而後用你的服務器運行它:

再次,保存以上代碼到wsgiapp.py文件,或者直接從GitHub上下載,而後像下面這樣使用你的Web服務器運行應用:

輸入下面地址,敲回車。你應該就看到下面結果了:

在你學習怎樣寫一個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的代碼:

要觀察服務器同一時間只處理一個客戶端請求,稍微修改一下服務器,在每次發送給客戶端響應後添加一個60秒的延遲。添加這行代碼就是告訴服務器睡眠60秒。

如下是睡眠版的服務器webserver3b.py代碼:

啓動服務器:

如今打開一個新的控制檯窗口,運行如下curl命令。你應該當即就會看到屏幕上打印出了「Hello, World!」字符串:

馬上再打開一個控制檯窗口,而後運行相同的curl命令:

若是你是在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並開始接受客戶端鏈接的標準流程經歷一般以下:

  1. 服務器建立一個TCP/IP socket。在Python裏使用下面的語句便可:
  2. 服務器可能會設置一些socket選項(這是可選的,上面的代碼就設置了,爲了在殺死或重啓服務器後,立馬就能再次重用相同的地址)。
  3. 而後,服務器綁定指定地址,bind函數分配一個本地地址給socket。在TCP中,調用bind能夠指定一個端口號,一個IP地址,二者都,或者二者都不指定。
  4. 而後,服務器讓這個socket成爲監聽socket。

listen方法只會被服務器調用。它告訴內核它要接受這個socket上的到來的鏈接請求了。

作完這些後,服務器開始循環地一次接受一個客戶端鏈接。當有鏈接到達時,aceept調用返回已鏈接的客戶端socket。而後,服務器從這個socket讀取請求數據,在標準輸出上把數據打印出來,並回發一個消息給客戶端。而後,服務器關閉客戶端鏈接,準備好再次接受新的客戶端鏈接。

下面是客戶端使用TCP/IP和服務器通訊要作的:

如下是客戶端鏈接服務器,發送請求並打印響應的示例代碼:

建立socket後,客戶端須要鏈接服務器。這是經過connect調用作到的:

客戶端僅需提供要鏈接的遠程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):

上面這個例子中,內核分配了60589這個暫時端口。

在我開始回答第二部分提出的問題前,我須要快速講一下幾個重要的概念。你很快就知道爲何重要了。兩個概念是進程和文件描述符。

什麼是進程?進程就是一個正在運行的程序的實例。好比,當服務器代碼執行時,它被加載進內存,運行起來的程序實例被稱爲進程。內核記錄了進程的一堆信息用於跟蹤,進程ID就是一個例子。當你運行服務器 webserver3a.py 或 webserver3b.py 時,你就在運行一個進程了。

在控制檯窗口運行webserver3b.py:

在別的控制檯窗口使用ps命令獲取這個進程的信息:

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來看看怎麼作:

雖然在Python中處理文件和socket,一般使用高級的文件/socket對象,但有時候你須要直接使用文件描述符。下面這個例子告訴你如何使用write系統調用寫一個字符串到標準輸出,write使用整數文件描述符作爲參數:

有趣的是——應該不會驚訝到你啦,由於你已經知道在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秒):

在深刻討論for如何工做以前,先本身試試,看看服務器確實能夠同時處理多個請求,不像webserver3a.py和webserver3b.py。用下面命令啓動服務器:

像你之前那樣試試用兩個curl命令,本身看看,如今雖然服務器子進程在處理客戶端請求時睡眠60秒,但不影響別的客戶端,由於它們是被不一樣的徹底獨立的進程處理的。你應該能看到curl命令馬上就輸出了「Hello, World!」,而後掛起60秒。你能夠接着想運行多少curl命令就運行多少(嗯,幾乎是任意多),它們都會馬上輸出服務器的響應「Hello, Wrold」,並且不會有明顯的延遲。試試看。

理解fork()的最重要的點是,你fork了一次,但它返回了兩次:一個是在父進程裏,一個是在子進程裏。當你fork了一個新進程,子進程返回的進程ID是0。父進程裏fork返回的是子進程的PID。

我仍然記得當我第一次知道它使用它時我對fork是有多着迷。它就像魔法同樣。我正讀着一段連續的代碼,而後「duang」的一聲:代碼克隆了本身,而後就有兩個相同代碼的實例同時運行。我想除了魔法沒法作到,我是認真噠。

當父進程fork了一個新的子進程,子進程就獲取了父進程文件描述符的拷貝:

你可能已經注意到啦,上面代碼裏的父進程關閉了客戶端鏈接:

那麼,若是它的父進程關閉了同一個socket,子進程爲何還能從客戶端socket讀取數據呢?答案就在上圖。內核使用描述符引用計數來決定是否關閉socket。只有當描述符引用計數爲0時才關閉socket。當服務器建立一個子進程,子進程獲取了父進程的文件描述符拷貝,內核增長了這些描述符的引用計數。在一個父進程和一個子進程的場景中,客戶端socket的描述符引用計數就成了2,當父進程關閉了客戶端鏈接socket,它僅僅把引用計數減爲1,不會引起內核關閉這個socket。子進程也把父進程的listen_socket拷貝給關閉了,由於子進程不用管接受新鏈接,它只關心處理已經鏈接的客戶端的請求:

本文後面我會講下若是不關閉複製的描述符會發生什麼。

你從併發服務器源碼看到啦,如今服務器父進程惟一的角色就是接受一個新的客戶端鏈接,fork一個新的子進程來處理客戶端請求,而後重複接受另外一個客戶端鏈接,就沒有別的事作啦。服務器父進程不處理客戶端請求——它的小弟(子進程)幹這事。

跑個題,咱們說兩個事件併發究竟是什麼意思呢?

當咱們說兩個事件併發時,咱們一般表達的是它們同時發生。簡單來講,這也不錯,但你要知道嚴格定義是這樣的:

又到了簡要重述目前爲止已經學習的知識點和概念的時間啦.

  • 在Unix下寫一個併發服務器最簡單的方法是使用fork()系統調用
  • 當一個進程fork了一個新進程時,它就變成了那個新fork產生的子進程的父進程。
  • 在調用fork後,父進程和子進程共享相同的文件描述符。
  • 內核使用描述符引用計數來決定是否關閉文件/socket。
  • 服務器父進程的角色是:如今它乾的全部活就是接受一個新鏈接,fork一個子進來來處理這個請求,而後循環接受新鏈接。

我們來看看,若是在父進程和子進程中你不關閉複製的socket描述符會發生什麼吧。如下是個修改後的版本,服務器不關閉複製的描述符,webserver3d.py:

啓動服務器:

使用curl去鏈接服務器:

好的,curl打印出來併發服務器的響應,可是它不終止,一直掛起。發生了什麼?服務器再也不睡眠60秒了:它的子進程開心地處理了客戶端請求,關閉了客戶端鏈接而後退出啦,可是客戶端curl仍然不終止。

那麼,爲何curl不終止呢?緣由就在於複製的文件描述符。當子進程關閉了客戶端鏈接,內核減小引用計數,值變成了1。服務器子進程退出,可是客戶端socket沒有被內核關閉掉,由於引用計數不是0啊,因此,結果就是,終止數據包(在TCP/IP說法中叫作FIN)沒有發送給客戶端,因此客戶端就保持在線啦。這裏還有個問題,若是服務器不關閉複製的文件描述符而後長時間運行,最終會耗盡可用文件描述符。

使用Control-C中止webserver3d.py,使用shell內建的命令ulimit檢查一下shell默認設置的進程可用資源:

看到上面的了咩,個人Ubuntu上,進程的最大可打開文件描述符是1024。

如今我們看看怎麼讓服務器耗盡可用文件描述符。在已存在或新的控制檯窗口,調用服務器最大可打開文件描述符爲256:

在同一個控制檯上啓動webserver3d.py:

使用下面的client3.py客戶端來測試服務器。

在新的控制檯窗口裏,啓動client3.py,讓它建立300個鏈接同時鏈接服務器。

很快服務器就崩了。下面是我電腦上拋異常的截圖:

教訓很是明顯啦——服務器應該關閉複製的描述符。但即便關閉了複製的描述符,你尚未接觸到底層,由於你的服務器還有個問題,殭屍!

是噠,服務器代碼就是產生了殭屍。我們看下是怎麼產生的。再次運行服務器:

在另外一個控制檯窗口運行下面的curl命令:

如今運行ps命令,顯示運行着的Python進程。如下是個人Ubuntu電腦上的ps輸出:

你看到上面第二行了咩?它說PId爲9102的進程的狀態是Z+,進程的名稱是。這個就是殭屍啦。殭屍的問題在於,你殺死不了他們啊。

即便你試着用 $ kill -9 來殺死殭屍,它們仍是會倖存下來噠,本身試試看看。

殭屍究竟是什麼呢?爲何我們的服務器會產生它們呢?殭屍就是一個進程終止了,可是它的父進程沒有等它,尚未接收到它的終止狀態。當一個子進程比父進程先終止,內核把子進程轉成殭屍,存儲進程的一些信息,等着它的父進程之後獲取。存儲的信息一般就是進程ID,進程終止狀態,進程使用的資源。嗯,殭屍仍是有用的,但若是服務器很差好處理這些殭屍,系統就會愈來愈堵塞。我們看看怎麼作到的。首先中止服務器,而後新開一個控制檯窗口,使用ulimit命令設置最大用戶進程爲400(確保設置打開文件更高,好比500吧):

在同一個控制檯窗口運行webserver3d.py:

新開一個控制檯窗口,啓動client3.py,讓它建立500個鏈接同時鏈接到服務器:

而後,服務器又一次崩了,是OSError的錯誤:拋了資源臨時不可用的異常,當試圖建立新的子進程時但建立不了時,由於達到了最大子進程數限制。如下是個人電腦的截圖:

看到了吧,若是你不處理好殭屍,服務器長時間運行就會出問題。我會簡短討論下服務器應該怎樣處理殭屍問題。

我們簡要重述下目前爲止你已經學習到主要知識點:

  • 若是不關閉複製描述符,客戶端不會終止,由於客戶端鏈接不會關閉。
  • 若是不關閉複製描述符,長時間運行的服務器最終會耗盡可用文件描述符(最大打開文件)。
  • 當fork了一個子進程,而後子進程退出了,父進程沒有等它,並且沒有收集它的終止狀態,它就變成殭屍了。
  • 殭屍要吃東西,咱們的場景中,就是內存。服務器最終會耗盡可用進程(最大用戶進程),若是不處理好殭屍的話。
  • 殭屍殺不死的,你須要等它們。

那麼,處理好殭屍的話,要作什麼呢?要修改服務器代碼去等殭屍,獲取它們的終止狀態。經過調用wait系統調用就好啦。不幸的是,這不完美,由於若是調用wait,然而沒有終止的子進程,wait就會阻塞服務器,實際上就是阻止了服務器處理新的客戶端鏈接請求。有其餘辦法嗎?固然有啦,其中之一就是使用信息處理器和wait系統調用組合。

如下是如何工做的。當一個子進程終止了,內核發送SIGCHLD信號。父進程能夠設置一個信號處理器來異步地被通知,而後就能wait子進程獲取它的終止狀態,所以阻止了殭屍進程出現。

順便說下,異步事件意味着父進程不會提早知道事件發生的時間。

修改服務器代碼,設置一個SIGCHLD事件處理器,而後在事件處理器裏wait終止的子進程。webserver3e.py代碼以下:

啓動服務器:

使用老朋友curl給修改後的併發服務器發送請求:

觀察服務器:

剛纔發生了什麼?accept調用失敗了,錯誤是EINTR。

當子進程退出,引起SIGCHLD事件時,父進程阻塞在accept調用,這激活了事件處理器,而後當事件處理器完成時,accept系統調用就中斷了:

彆着急,這個問題很好解決。你要作的就是從新調用accept。如下是修改後的代碼:

啓動修改後的webserver3f.py:

使用curl給修改後的服務器發送請求:

看到了嗎?沒有EINTR異常啦。如今,驗證一下吧,沒有殭屍了,帶wait的SIGCHLD事件處理器也能處理好子進程了。怎麼驗證呢?只要運行ps命令,看看沒有Z+狀態的進程(沒有進程)。太棒啦!沒有殭屍在四周跳的感受真安全呢!

  • 若是fork了子進程並不wait它,它就成殭屍了。
  • 使用SIGCHLD事件處理器來異步的wait終止了的子進程來獲取它的終止狀態
  • 使用事件處理器時,你要明白,系統調用會被中斷的,你要作好準備對付這種狀況

嗯,目前爲止,一次都好。沒有問題,對吧?好吧,幾乎滑。再次跑下webserver3f.py,此次不用curl請求一次了,改用client3.py來建立128個併發鏈接:

如今再運行ps命令

看到了吧,少年,殭屍又回來了!

此次又出什麼錯了呢?當你運行128個併發客戶端時,創建了128個鏈接,子進程處理了請求而後幾乎同時終止了,這就引起了SIGCHLD信號洪水般的發給父進程。問題在於,信號沒有排隊,父進程錯過了一些信號,致使了一些殭屍處處跑沒人管:

解決方案就是設置一個SIGCHLD事件處理器,但不用wait了,改用waitpid系統調用,帶上WNOHANG參數,循環處理,確保全部的終止的子進程都被處理掉。如下是修改後的webserver3g.py:

啓動服務器:

使用測試客戶端client3.py:

如今驗證一下沒有殭屍了吧。哈!沒有殭屍的日子真好!

 

恭喜!這真是段很長的旅程啊,但願你喜歡。如今你已經擁有了本身的簡單併發服務器,並且這個代碼有助於你在未來的工做中開發一個產品級的Web服務器。

我要把它留做練習,你來修改第二部分的WSGI服務器,讓它達到併發。你在這裏能夠找到修改後的版本。可是你要本身實現後再看個人代碼喲。你已經擁有了全部必要的信息,因此,去實現它吧!

接下來作什麼呢?就像Josh Billings說的那樣,

像郵票那樣——用心作一件事,直到完成。

去打好基礎吧。質疑你已經知道的,保持深刻研究。

若是你只學方法,你就依賴方法。但若是你學會原理,你能夠發明本身的方法。—— 愛默生

如下是我挑出來對本文最重要的幾本書。它們會幫你拓寬加深我提到的知識。我強烈建議你想言設法弄到這些書:從朋友那借也好,從本地圖書館借,或者從亞馬遜買也行。它們是守護者:

    1. Unix網絡編程,卷1:socket網絡API(第三版)
    2. UNIX環境高級編程,第三版
    3. Linux編程接口:Linux和UNIX系統編輯手冊
    4. TCP/IP詳解,卷1:協議(第二版)
    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.
相關文章
相關標籤/搜索