一起寫一個Web伺服器(3)

高世界發表於2015-07-30

“發明創造時,我們學得最多” —— 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.

相關文章