python之協程與IO操做

 

協程

協程,又稱微線程,纖程。英文名Coroutine。python

協程的概念很早就提出來了,但直到最近幾年纔在某些語言(如Lua)中獲得普遍應用。react

子程序,或者稱爲函數,在全部語言中都是層級調用,好比A調用B,B在執行過程當中又調用了C,C執行完畢返回,B執行完畢返回,最後是A執行完畢。linux

因此子程序調用是經過棧實現的,一個線程就是執行一個子程序。nginx

子程序調用老是一個入口,一次返回,調用順序是明確的。而協程的調用和子程序不一樣。git

協程看上去也是子程序,但執行過程當中,在子程序內部可中斷,而後轉而執行別的子程序,在適當的時候再返回來接着執行。程序員

線程和進程的操做是由程序觸發系統接口,最後的執行者是系統;協程的操做則是程序員。github

協程能夠被認爲是一種用戶空間線程,與傳統的搶佔式線程相比,有2個主要的優勢:web

  • 與線程不一樣,協程是本身主動讓出CPU,並交付他指望的下一個協程運行,而不是在任什麼時候候都有可能被系統調度打斷。所以協程的使用更加清晰易懂,而且多數狀況下不須要鎖機制。
  • 與線程相比,協程的切換由程序控制,發生在用戶空間而非內核空間,所以切換的代價很是的小。
  • 某種意義上,協程與線程的關係相似與線程與進程的關係,多個協程會在同一個線程的上下文之中運行。

 

協程存在的意義:對於多線程應用,CPU經過切片的方式來切換線程間的執行,線程切換時須要耗時(保存狀態,下次繼續)。協程,則只使用一個線程,在一個線程中規定某個代碼塊執行順序。ajax

協程的適用場景:當程序中存在大量不須要CPU的操做時(IO),適用於協程;算法

協程的好處:

  • 無需線程上下文切換的開銷
  • 無需原子操做鎖定及同步的開銷
  • 方便切換控制流,簡化編程模型
  • 高併發+高擴展性+低成本:一個CPU支持上萬的協程都不是問題。因此很適合用於高併發處理。

缺點:

  • 沒法利用多核資源:協程的本質是個單線程,它不能同時將 單個CPU 的多個核用上,協程須要和進程配合才能運行在多CPU上.固然咱們平常所編寫的絕大部分應用都沒有這個必要,除非是cpu密集型應用。
  • 進行阻塞(Blocking)操做(如IO時)會阻塞掉整個程序

 

進程、線程和協程的區別

進程:

進程之間不共享任何狀態,進程的調度由操做系統完成,每一個進程都有本身獨立的內存空間,進程間通信主要是經過信號傳遞的方式來實現的,實現方式有多種,信號量、管道、事件等,任何一種方式的通信效率都須要過內核,致使通信效率比較低。因爲是獨立的內存空間,上下文切換的時候須要保存先調用棧的信息、cpu各寄存器的信息、虛擬內存、以及打開的相關句柄等信息,因此致使上下文進程間切換開銷很大,通信麻煩。 


線程:

線程之間共享變量,解決了通信麻煩的問題,可是對於變量的訪問須要鎖,線程的調度主要也是有操做系統完成,一個進程能夠擁有多個線程,可是其中每一個線程會共享父進程像操做系統申請資源,這個包括虛擬內存、文件等,因爲是共享資源,因此建立線程所須要的系統資源佔用比進程小不少,相應的可建立的線程數量也變得相對多不少。線程時間的通信除了能夠使用進程之間通信的方式之外還能夠經過共享內存的方式進行通訊,因此這個速度比經過內核要快不少。另外在調度方面也是因爲內存是共享的,因此上下文切換的時候須要保存的東西就像對少一些,這樣一來上下文的切換也變得高效。


協程:

協程的調度徹底由用戶控制,一個線程能夠有多個協程,用戶建立了幾個線程,而後每一個線程都是循環按照指定的任務清單順序完成不一樣的任務,當任務被堵塞的時候執行下一個任務,當恢復的時候再回來執行這個任務,任務之間的切換隻須要保存每一個任務的上下文內容,就像直接操做棧同樣的,這樣就徹底沒有內核切換的開銷,能夠不加鎖的訪問全局變量,因此上下文的切換很是快;另外協程還須要保證是非堵塞的且沒有相互依賴,協程基本上不能同步通信,多采用一步的消息通信,效率比較高。

進程、線程與協程

  從硬件發展來看,從最初的單核單CPU,到單核多CPU,多核多CPU,彷佛已經到了極限了,可是單核CPU性能卻還在不斷提高。server端也在不斷的發展變化。若是將程序分爲IO密集型應用和CPU密集型應用,兩者的server的發展以下:

    IO密集型應用: 多進程->多線程->事件驅動->協程
    CPU密集型應用:多進程-->多線程                                                                                                                                                                    

  調度和切換的時間:進程   >   線程   >  協程

不須要實現複雜的內存共享且需利用多cpu,用多進程;實現複雜的內存共享及IO密集型應用:多線程或協程;實現複雜的內存共享及CPU密集型應用:協程

 

 

網絡編程模型

咱們首先來簡單回顧一下一些經常使用的網絡編程模型。網絡編程模型能夠大致的分爲同步模型和異步模型兩類。

  • 同步模型:

同步模型使用阻塞IO模式,在阻塞IO模式下調用read等IO函數時會阻塞線程直到IO完成或失敗。 同步模型的典型表明是thread_per_connection模型,每當阻塞在主線程上的accept調用返回時則建立一個新的線程去服務於新的socket的讀/寫。這種模型的優勢是程序邏輯簡潔,符合人的思惟;缺點是可伸縮性收到線程數的限制,當鏈接愈來愈多時,線程也愈來愈多,頻繁的線程切換會嚴重拖累性能,同時不得不處理多線程同步的問題。

  • 異步模型:

異步模型通常使用非阻塞IO模式,並配合epoll/select/poll等多路複用機制。在非阻塞模式下調用read,若是沒有數據可讀則當即返回,並通知用戶沒有可讀(EAGAIN/EWOULDBLOCK),而非阻塞當前線程。異步模型能夠使一個線程同時服務於多個IO對象。 異步模型的典型表明是reactor模型。在reactor模型中,咱們將全部要處理的IO事件註冊到一箇中心的IO多路複用器中(通常爲epoll/select/poll),同時主線程阻塞在多路複用器上。一旦有IO事件到來或者就緒,多路複用器返回並將對應的IO事件分發到對應的處理器(即回調函數)中,最後處理器調用read/write函數來進行IO操做。

異步模型的特色是性能和可伸縮性比同步模型要好不少,可是其結構複雜,不易於編寫和維護。在異步模型中,IO以前的代碼(IO任務的提交者)和IO以後的處理代碼(回調函數)是割裂開來的。

協程與網絡編程

協程的出現出現爲克服同步模型和異步模型的缺點,並結合他們的優勢提供了可能: 如今假設咱們有3個協程A,B,C分別要進行數次IO操做。這3個協程運行在同一個調度器或者說線程的上下文中,並依次使用CPU。調度器在其內部維護了一個多路複用器(epoll/select/poll)。 協程A首先運行,當它執行到一個IO操做,但該IO操做並無當即就緒時,A將該IO事件註冊到調度器中,並主動放棄CPU。這時調度器將B切換到CPU上開始執行,一樣,當它碰到一個IO操做的時候將IO事件註冊到調度器中,並主動放棄CPU。調度器將C切換到cpu上開始執行。當全部協程都被「阻塞」後,調度器檢查註冊的IO事件是否發生或就緒。假設此時協程B註冊的IO時間已經就緒,調度器將恢復B的執行,B將從上次放棄CPU的地方接着向下運行。A和C同理。 這樣,對於每個協程來講,它是同步的模型;可是對於整個應用程序來講,它是異步的模型。

 

編程範式

編程範式(Programming Paradigm)是某種編程語言典型的編程風格或者說是編程方式。隨着編程方法學和軟件工程研究的深刻,特別是OO思想的普及,範式(Paradigm)以及編程範式等術語漸漸出如今人們面前。面向對象編程(OOP)經常被譽爲是一種革命性的思想,正由於它不一樣於其餘的各類編程範式。編程範式也許是學習任何一門編程語言時要理解的最重要的術語。

托馬斯.庫恩提出「科學的革命」的範式論以後,Robert Floyd在1979年圖靈獎的頒獎演說中使用了編程範式一詞。編程範式通常包括三個方面,以OOP爲例:

  1. 學科的邏輯體系——規則範式:如類/對象、繼承、動態綁定、方法改寫、對象替換等等機制。
  2. 心理認知因素——心理範式:按照面向對象編程之父Alan Kay的觀點,「計算就是模擬」。OO範式極其重視隱喻(metaphor)的價值,經過擬人化,按照天然的方式模擬天然。
  3. 天然觀/世界觀——觀念範式:強調程序的組織技術,視程序爲鬆散耦合的對象/類的集合,以繼承機制將類組織成一個層次結構,把程序運行視爲相互服務的對象們之間的對話。

簡單的說,編程範式是程序員看待程序應該具備的觀點。

爲了進一步加深對編程範式的認識,這裏介紹幾種最多見的編程範式。 

須要再次提醒注意的是:編程範式是編程語言的一種分類方式,它並不針對某種編程語言。就編程語言而言,一種編程語言也能夠適用多種編程範式。 

過程化(命令式)編程 

過程化編程,也被稱爲命令式編程,應該是最原始的、也是咱們最熟悉的一種傳統的編程方式。從本質上講,它是「馮.諾伊曼機「運行機制的抽象,它的編程思惟方式源於計算機指令的順序排列。

(也就是說:過程化語言模擬的是計算機機器的系統結構,而並非基於語言的使用者的我的能力和傾向。這一點咱們應該都很清楚,好比:咱們最先曾經使用過的單片機的彙編語言。)

過程化編程的步驟是:

首先,咱們必須將待解問題的解決方案抽象爲一系列概念化的步驟。而後經過編程的方式將這些步驟轉化爲程序指令集(算法),而這些指令按照必定的順序排列,用來講明如何執行一個任務或解決一個問題。這就意味着,程序員必需要知道程序要完成什麼,而且告訴計算機如何來進行所需的計算工做,包括每一個細節操做。簡言之,就是將計算機看做一個有始有終服從命令的裝置。

因此在過程化編程中,把待解問題規範化、抽象爲某種算法是解決問題的關鍵步驟。其次,纔是編寫具體算法和完成相應的算法實現問題的正確解決。固然,程序員對待解問題的抽象能力也是很是重要的因素,但這自己已經與編程語言無關了。

 程序流程圖是過程化語言進行程序編寫的有效輔助手段。 

儘管現存的計算機編程語言不少,可是人們把全部支持過程化編程範式的編程語言都被概括爲過程化編程語言。例如機器語言、彙編語言、BASIC、COBOL、C 、FORTRAN、語言等等許多第三代編程語言都被概括爲過程化語言。 

過程化語言特別適合解決線性(或者說循序漸進)的算法問題。它強調「自上而下(自頂向下)」「精益求精」的設計方式。這種方式很是相似咱們的工做和生活方式,由於咱們的平常活動都是循序漸進的順序進行的。 

 過程化語言趨向於開發運行較快且對系統資源利用率較高的程序。過程化語言很是的靈活並強大,同時有許多經典應用範例,這使得程序員能夠用它來解決多種問題。 

過程化語言的不足之處就是它不適合某些種類問題的解決,例如那些非結構化的具備複雜算法的問題。問題出如今,過程化語言必須對一個算法加以詳盡的說明,而且其中還要包括執行這些指令或語句的順序。實際上,給那些非結構化的具備複雜算法的問題給出詳盡的算法是極其困難的。 

普遍引發爭議和討論的地方是:無條件分支,或goto語句,它是大多數過程式編程語言的組成部分,反對者聲稱:goto語句可能被無限地濫用;它給程序設計提供了製造混 亂的機會。目前達成的共識是將它保留在大多數語言中,對於它所具備的危險性,應該經過程序設計的規定將其最小化。 

事件驅動編程 

其實,基於事件驅動的程序設計在圖形用戶界面(GUI)出現好久前就已經被應用於程序設計中,但是隻有當圖形用戶界面普遍流行時,它才逐漸形演變爲一種普遍使用的程序設計模式。 

在過程式的程序設計中,代碼自己就給出了程序執行的順序,儘管執行順序可能會受到程序輸入數據的影響。

在事件驅動的程序設計中,程序中的許多部分可能在徹底不可預料的時刻被執行。每每這些程序的執行是由用戶與正在執行的程序的互動激發所致。 

  • 事件。就是通知某個特定的事情已經發生(事件發生具備隨機性)。 
  • 事件與輪詢。輪詢的行爲是不斷地觀察和判斷,是一種無休止的行爲方式。而事件是靜靜地等待事情的發生。事實上,在Windows出現以前,採用鼠標輸入字符模式的PC應用程序必須進行串行輪詢,並以這種方式來查詢和響應不一樣的用戶操作。 
  • 事件處理器。是對事件作出響應時所執行的一段程序代碼。事件處理器使得程序可以對於用戶的行爲作出反映。 

事件驅動經常用於用戶與程序的交互,經過圖形用戶接口(鼠標、鍵盤、觸摸板)進行交互式的互動。固然,也能夠用於異常的處理和響應用戶自定義的事件等等。

事件的異常處理比用戶交互更復雜。 

事件驅動不只僅侷限在GUI編程應用。可是實現事件驅動咱們還須要考慮更多的實際問題,如:事件定義、事件觸發、事件轉化、事件合併、事件排隊、事件分派、事件處理、事 件連帶等等。

其實,到目前爲止,咱們尚未找到有關純事件驅動編程的語言和相似的開發環境。全部關於事件驅動的資料都是基於GUI事件的。 

屬於事件驅動的編程語言有:VB、C#、Java(Java Swing的GUI)等。它們所涉及的事件絕大多數都是GUI事件。 

面向對象編程 

過程化範式要求程序員用循序漸進的算法看待每一個問題。很顯然,並非每一個問題都適合這種過程化的思惟方式。這也就致使了其它程序設計範式出現,包括咱們如今介紹的面向對象的程序設計範式。 

面向對象的程序設計模式已經出現二十多年,通過這些年的發展,它的設計思想和設計模式已經穩定的進入編程語言的主流。來自TIOBE Programming Community2010年11月份編程語言排名的前三名Java、C、C++中,Java和C++都是面向對象的編程語言。 

面向對象的程序設計包括了三個基本概念:封裝性、繼承性、多態性。面向對象的程序語言經過類、方法、對象和消息傳遞,來支持面向對象的程序設計範式。 

1. 對象

世間萬事萬物都是對象。

面向對象的程序設計的抽象機制是將待解問題抽象爲面向對象的程序中的對象。利用封裝使每一個對象都擁有個體的身份。程序即是成堆的對象,彼此經過消息的傳遞,請求其它對象 進行工做。 

2. 類

每一個對象都是其類中的一個實體。

物以類聚——就是說明:類是類似對象的集合。類中的對象能夠接受相同的消息。換句話說:類包含和描述了「具備共同特性(數據元素)和共同行爲(功能)」的一組對象。

好比:蘋果、梨、橘子等等對象都屬於水果類。 

3. 封裝

封裝(有時也被稱爲信息隱藏)就是把數據和行爲結合在一個包中,並對對象的使用者隱藏數據的實現過程。信息隱藏是面向對象編程的基本原則,而封裝是實現這一原則的一種方 式。

封裝使對象呈現出「黑盒子」特性,這是對象再利用和實現可靠性的關鍵步驟。 

4. 接口

每一個對象都有接口。接口不是類,而是對符合接口需求的類所做的一套規範。接口說明類應該作什麼但不指定如何做的方法。一個類能夠有一個或多個接口。 

5. 方法

方法決定了某個對象究竟可以接受什麼樣的消息。面向對象的設計有時也會簡單地概括爲「將消息發送給對象」。 

6. 繼承

繼承的思想就是容許在已存在類的基礎上構建新的類。一個子類可以繼承父類的全部成員,包括屬性和方法。

繼承的主要做用:經過實現繼承完成代碼重用;經過接口繼承完成代碼被重用。繼承是一種規範的技巧,而不是一種實現的技巧。 

7. 多態

多態提供了「接口與實現分離」。多態不但能改善程序的組織架構及可讀性,更利於開發出「可擴充」的程序。

繼承是多態的基礎。多態是繼承的目的。

合理的運用基於類繼承的多態、基於接口繼承的多態和基於模版的多態,能加強程序的簡潔性、靈活性、可維護性、可重用性和可擴展性。

面向對象技術一方面借鑑了哲學、心理學、生物學的思考方式,另外一方面,它是創建在其餘編程技術之上的,是之前的編程思想的天然產物。

若是說結構化軟件設計是將函數式編程技術應用到命令式語言中進行程序設計,面向對象編程不過是將函數式模型應用到命令式程序中的另外一途徑,此時,模塊進步爲對象,過程龜縮到class的成員方法中。OOP的不少技術——抽象數據類型、信息隱藏、接口與實現分離、對象生成功能、消息傳遞機制等等,不少東西就是結構化軟件設計所擁有的、或者在其餘編程語言中單獨出現。但只有在面嚮對象語言中,他們才共同出現,以一種獨特的合做方式互相協做、互相補充。

 

編程範式 = 語感

知識的學習有幾種方式:一種靠記憶,一種靠練習,一種靠培養。就拿英語學習來講吧,學單詞,單靠記憶便可;學句型、語法,光記憶是不夠的,需要勤加練習方可熟能生巧;而要講出地道的英語,光記憶和練習是遠遠不夠的。從小學到大學,甚至博士畢業,除了英語類專業的學生外,大多數人英語練了一二十年,水平如何?不客氣但很客觀地說:一個字,爛。

緣由只有一個,那就是國內的英語教學方式嚴重失策。教學老是圍繞單詞、詞組、句型、語法轉,缺少對語感的重視和培養,致使學生只會‘中式英語’。一樣道理,一個慣用C語言編程的人也許很快就能寫一些C++程序,但若是他只注重C++的語法而不注重培養OOP 的語感,那麼寫出的程序必定是‘C 式C++’。與其如此,倒不如直接用C 呢。」

一句話:學習編程範式能加強編程語言的語感。

語感是一我的對語言的敏銳感知力,反映了他在語言方面的總體上的直覺把握能力。語感強者,能聽絃外之音,能說雙關之語,能讀雋永之做,能寫曉暢之文。這是一種綜合的素質和修養,其重要性是不言而喻的。那麼如何培養語感呢?普通的學習和訓練固不可少,但若是忽視語言背後的文化背景和思惟方式,終究只是緣木求魚。編程範式正體現了編程的思惟方式,於是是培養編程語言的語感的關鍵。

語感有了,那些設計模式、框架,甚至架構,等看似神祕高深的東西,也會天然而然地來了。

使用yield實現協程操做例子

import time
import queue
def consumer(name):
    print("--->starting eating baozi...")
    while True:
        new_baozi = yield
        print("[%s] is eating baozi %s" % (name,new_baozi))
        #time.sleep(1)
 
def producer():
 
    r = con.__next__()
    r = con2.__next__()
    n = 0
    while n < 5:
        n +=1
        con.send(n)
        con2.send(n)
        print("\033[32;1m[producer]\033[0m is making baozi %s" %n )
 
 
if __name__ == '__main__':
    con = consumer("c1")
    con2 = consumer("c2")
    p = producer()

符合什麼條件就能稱之爲協程:

  1. 必須在只有一個單線程裏實現併發
  2. 修改共享數據不需加鎖
  3. 用戶程序裏本身保存多個控制流的上下文棧
  4. 一個協程遇到IO操做自動切換到其它協程

基於上面這4點定義,咱們剛纔用yield實現的程並不能算是合格的線程.

 

 

Greenlet

greenlet是一個用C實現的協程模塊,相比與python自帶的yield,它能夠使你在任意函數之間隨意切換,而不需把這個函數先聲明爲generator

greelet指的是使用一個任務調度器和一些生成器或者協程實現協做式用戶空間多線程的一種僞併發機制,即所謂的微線程。

greelet機制的主要思想是:生成器函數或者協程函數中的yield語句掛起函數的執行,直到稍後使用next()或send()操做進行恢復爲止。能夠使用一個調度器循環在一組生成器函數之間協做多個任務。

網絡框架的幾種基本的網絡I/O模型:

阻塞式單線程:這是最基本的I/O模型,只有在處理完一個請求以後纔會處理下一個請求。它的缺點是效能差,若是有請求阻塞住,會讓服務沒法繼續接受請求。可是這種模型編寫代碼相對簡單,在應對訪問量不大的狀況時是很是適合的。

阻塞式多線程:針對於單線程接受請求量有限的缺點,一個很天然的想法就是給每個請求開一個線程去處理。這樣作的好處是可以接受更多的請求,缺點是在線程產生到必定數量以後,進程之間須要大量進行切換上下文的操做,會佔用CPU大量的時間,不過這樣處理的話編寫代碼的難道稍高於單進程的狀況。

非阻塞式事件驅動:爲了解決多線程的問題,有一種作法是利用一個循環來檢查是否有網絡IO的事件發生,以便決定如何來進行處理(reactor設計模式)。這樣的作的好處是進一步下降了CPU的資源消耗。缺點是這樣作會讓程序難以編寫,由於請求接受後的處理過程由reactor來決定,使得程序的執行流程難以把握。當接受到一個請求後若是涉及到阻塞的操做,這個請求的處理就會停下來去接受另外一個請求,程序執行的流程不會像線性程序那樣直觀。twisted框架就是應用這種IO模型的典型例子。

非阻塞式Coroutine(協程):這個模式是爲了解決事件驅動模型執行流程不直觀的問題,它在本質上也是事件驅動的,加入了Coroutine的概念。

與線程/進程的區別

線程是搶佔式的調度,多個線程並行執行,搶佔共同的系統資源;而微線程是協同式的調度。

其實greenlet不是一種真正的併發機制,而是在同一線程內,在不一樣函數的執行代碼塊之間切換,實施「你運行一會、我運行一會」,而且在進行切換時必須指定什麼時候切換以及切換到哪。greenlet的接口是比較簡單易用的,可是使用greenlet時的思考方式與其餘併發方案存在必定區別:

1. 線程/進程模型在大邏輯上一般從併發角度開始考慮,把可以並行處理的而且值得並行處理的任務分離出來,在不一樣的線程/進程下運行,而後考慮分離過程可能形成哪些互斥、衝突問題,將互斥的資源加鎖保護來保證併發處理的正確性。

2. greenlet則是要求從避免阻塞的角度來進行開發,當出現阻塞時,就顯式切換到另外一段沒有被阻塞的代碼段執行,直到原先的阻塞情況消失之後,再人工切換回原來的代碼段繼續處理。所以,greenlet本質是一種合理安排了的 串行 。

3. greenlet本質是串行,所以在沒有進行顯式切換時,代碼的其餘部分是沒法被執行到的,若是要避免代碼長時間佔用運算資源形成程序假死,那麼仍是要將greenlet與線程/進程機制結合使用(每一個線程、進程下均可以創建多個greenlet,可是跨線程/進程時greenlet之間沒法切換或通信)。

使用

一個 「greenlet」 是一個很小的獨立微線程。能夠把它想像成一個堆棧幀,棧底是初始調用,而棧頂是當前greenlet的暫停位置。你使用greenlet建立一堆這樣的堆棧,而後在他們之間跳轉執行。跳轉不是絕對的:一個greenlet必須選擇跳轉到選擇好的另外一個greenlet,這會讓前一個掛起,然後一個恢復。兩 個greenlet之間的跳轉稱爲 切換(switch) 。

當你建立一個greenlet,它獲得一個初始化過的空堆棧;當你第一次切換到它,他會啓動指定的函數,而後切換跳出greenlet。當最終棧底 函數結束時,greenlet的堆棧又編程空的了,而greenlet也就死掉了。greenlet也會由於一個未捕捉的異常死掉。

示例:來自官方文檔示例

from greenlet import greenlet
def test1():
   print 12
   gr2.switch()
   print 34
def test2():
   print 56
   gr1.switch()
   print 78
gr1 = greenlet(test1)
gr2 = greenlet(test2)
gr1.switch()
最後一行跳轉到 test1() ,它打印12,而後跳轉到 test2() ,打印56,而後跳轉回 test1() ,打印34,而後 test1() 就結束,gr1死掉。這時執行會回到原來的 gr1.switch() 調用。注意,78是不會被打印的,由於gr1已死,不會再切換。

基於greenlet的框架
# -*- coding:utf-8 -*-
 
 
from greenlet import greenlet
 
 
def test1():
    print(12)
    gr2.switch()
    print(34)
    gr2.switch()
 
 
def test2():
    print(56)
    gr1.switch()
    print(78)
 
 
gr1 = greenlet(test1)
gr2 = greenlet(test2)
gr1.switch()

 

Gevent: (實現遇到IO自動切換)

Gevent 是一個第三方庫,能夠輕鬆經過gevent實現併發同步或異步編程,在gevent中用到的主要模式是Greenlet, 它是以C擴展模塊形式接入Python的輕量級協程。 Greenlet所有運行在主程序操做系統進程的內部,但它們被協做式地調度。

#自動切換
import gevent

def foo():
    print("Running in foo,foo開始>>>1")
    gevent.sleep(2)
    print('Explicit context switch to foo again  foo完成>>>6')

def bar():
    print('Explicit精確的 context 內容 to bar   bar開始>>>2')
    gevent.sleep(1)
    print("Imlicit context sitch back to bar  bar結束>>>5")

def func3():
    print('running func3  func3開始>>>3')
    gevent.sleep(0)
    print('running func3 again  func3結束>>>4')

gevent.joinall([
    gevent.spawn(foo),
    gevent.spawn(bar),
    gevent.spawn(func3),
])

 結果:

結果:
Running in foo,foo開始>>>1
Explicit精確的 context 內容 to bar   bar開始>>>2
running func3  func3開始>>>3
running func3 again  func3結束>>>4
Imlicit context sitch back to bar  bar結束>>>5
Explicit context switch to foo again  foo完成>>>6
遇到sleep就自動切換,sleep後面的參數是秒。上面這個小程序最多2秒左右運行完

 

gevent是一個基於協程(coroutine)的Python網絡函數庫,經過使用greenlet提供了一個在libev事件循環頂部的高級別併發API。

主要特性有如下幾點:

基於libev的快速事件循環,Linux上面的是epoll機制

基於greenlet的輕量級執行單元

API複用了Python標準庫裏的內容

支持SSL的協做式sockets

可經過線程池或c-ares實現DNS查詢

經過monkey patching功能來使得第三方模塊變成協做式
import gevent
 
def func1():
    print('\033[31;1mTom在跟Jack搞...\033[0m')
    gevent.sleep(2)
    print('\033[31;1mTom又回去跟繼續跟Jack搞...\033[0m')
 
def func2():
    print('\033[32;1mTom切換到了跟Sunny搞...\033[0m')
    gevent.sleep(1)
    print('\033[32;1mTom搞完了Jack,回來繼續跟Sunny搞...\033[0m')
 
 
gevent.joinall([
    gevent.spawn(func1),
    gevent.spawn(func2),
    #gevent.spawn(func3),
])

結果:

Tom在跟Jack搞...
Tom切換到了跟Sunny搞...
Tom搞完了Jack,回來繼續跟Sunny搞...
Tom又回去跟繼續跟Jack搞...
Tom在跟Jack搞...
Tom切換到了跟Sunny搞...
Tom搞完了Jack,回來繼續跟Sunny搞...
Tom又回去跟繼續跟Jack搞...

 

經過gevent實現單線程下的多socket併發

server side

import sys
import socket
import time
import gevent
 
from gevent import socket,monkey
monkey.patch_all()
 
 
def server(port):
    s = socket.socket()
    s.bind(('0.0.0.0', port))
    s.listen(500)
    while True:
        cli, addr = s.accept()
        gevent.spawn(handle_request, cli)
 
 
 
def handle_request(conn):
    try:
        while True:
            data = conn.recv(1024)
            print("recv:", data)
            conn.send(data)
            if not data:
                conn.shutdown(socket.SHUT_WR)
 
    except Exception as  ex:
        print(ex)
    finally:
        conn.close()
if __name__ == '__main__':
    server(8001)

 client side   

import socket
 
HOST = 'localhost'    # The remote host
PORT = 8001           # The same port as used by the server
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
while True:
    msg = bytes(input(">>:"),encoding="utf8")
    s.sendall(msg)
    data = s.recv(1024)
    #print(data)
 
    print('Received', repr(data))
s.close()

 

簡單的使用協程寫一個爬蟲:

串行

from urllib import request
import time


def f(url):
    print('GET:%s'%url)
    resp = request.urlopen(url)
    data = resp.read()
    # file = open("data",'wb')#這裏能夠打開這兩步,寫入文件
    # file.write(data)
    print('%d bytes received from %s.'%(len(data),url))



#串性模式
urls = [
    'https://www.python.org/',
    'https://www.yahoo.com/',
    'https://github.com/']

time_start = time.time()
for url in urls:
    f(url)
print("同步cost",time.time()-time_start)

 並行:

#由於gevent檢測不到urllib是否進行了io操做,因此須要打補丁

from urllib import request
import gevent,time

from gevent import monkey#打補丁(把下面有可能有IO操做的單獨作上標記)
monkey.patch_all()#打補丁

def f(url):
    print('GET:%s'%url)
    resp = request.urlopen(url)
    data = resp.read()
    # file = open("data",'wb')#這裏能夠打開這兩步,寫入文件
    # file.write(data)
    print('%d bytes received from %s.'%(len(data),url))


#異步模式
async_time_start = time.time()
gevent.joinall([
    gevent.spawn(f,'https://www.python.org/'),
    gevent.spawn(f, 'https://www.yahoo.com/'),
    gevent.spawn(f,'https://github.com/')
])
print("異步步cost",time.time()-async_time_start)

 

eventlet

eventlet 是基於 greenlet 實現的面向網絡應用的併發處理框架,提供「線程」池、隊列等與其餘 Python 線程、進程模型很是類似的 api,而且提供了對 Python 發行版自帶庫及其餘模塊的超輕量併發適應性調整方法,比直接使用 greenlet 要方便得多。

其基本原理是調整 Python 的 socket 調用,當發生阻塞時則切換到其餘 greenlet 執行,這樣來保證資源的有效利用。須要注意的是:

eventlet 提供的函數只能對 Python 代碼中的 socket 調用進行處理,而不能對模塊的 C 語言部分的 socket 調用進行修改。對後者這類模塊,仍然須要把調用模塊的代碼封裝在 Python 標準線程調用中,以後利用 eventlet 提供的適配器實現 eventlet 與標準線程之間的協做。

雖然 eventlet 把 api 封裝成了很是相似標準線程庫的形式,但二者的實際併發執行流程仍然有明顯區別。在沒有出現 I/O 阻塞時,除非顯式聲明,不然當前正在執行的 eventlet 永遠不會把 cpu 交給其餘的 eventlet,而標準線程則是不管是否出現阻塞,老是由全部線程一塊兒爭奪運行資源。全部 eventlet 對 I/O 阻塞無關的大運算量耗時操做基本沒有什麼幫助。

關於Linux的epoll機制:

epoll是Linux內核爲處理大批量文件描述符而做了改進的poll,是Linux下多路複用IO接口select/poll的加強版本,它能顯著提升程序在大量併發鏈接中只有少許活躍的狀況下的系統CPU利用率。epoll的優勢:

支持一個進程打開大數目的socket描述符。select的一個進程所打開的FD由FD_SETSIZE的設置來限定,而epoll沒有這個限制,它所支持的FD上限是最大可打開文件的數目,遠大於2048。

IO效率不隨FD數目增長而線性降低:因爲epoll只會對「活躍」的socket進行操做,因而,只有"活躍"的socket纔會主動去調用 callback函數,其餘idle狀態的socket則不會。

使用mmap加速內核與用戶空間的消息傳遞。epoll是經過內核於用戶空間mmap同一塊內存實現的。

內核微調。

libev機制

提供了指定文件描述符事件發生時調用回調函數的機制。libev是一個事件循環器:向libev註冊感興趣的事件,好比socket可讀事件,libev會對所註冊的事件的源進行管理,並在事件發生時觸發相應的程序。

官方文檔中的示例:

>>> import gevent

>>> from gevent import socket

>>> urls = ['www.google.com.hk','www.example.com', 'www.python.org'  ]

>>> jobs = [gevent.spawn(socket.gethostbyname, url) for url in urls]

>>> gevent.joinall(jobs, timeout=2)

>>> [job.value for job in jobs]

['74.125.128.199', '208.77.188.166', '82.94.164.162']

 



註解:gevent.spawn()方法spawn一些jobs,而後經過gevent.joinall將jobs加入到微線程執行隊列中等待其完成,設置超時爲2秒。執行後的結果經過檢查gevent.Greenlet.value值來收集。gevent.socket.gethostbyname()函數與標準的socket.gethotbyname()有相同的接口,但它不會阻塞整個解釋器,所以會使得其餘的greenlets跟隨着無阻的請求而執行。

Monket patching

Python的運行環境容許咱們在運行時修改大部分的對象,包括模塊、類甚至函數。雖然這樣作會產生「隱式的反作用」,並且出現問題很難調試,但在須要修改Python自己的基礎行爲時,Monkey patching就派上用場了。Monkey patching可以使得gevent修改標準庫裏面大部分的阻塞式系統調用,包括socket,ssl,threading和select等模塊,而變成協做式運行。

>>> from gevent import monkey ;

>>> monkey . patch_socket ()

>>> import urllib2

經過monkey.patch_socket()方法,urllib2模塊能夠使用在多微線程環境,達到與gevent共同工做的目的。

事件循環

不像其餘網絡庫,gevent和eventlet相似, 在一個greenlet中隱式開始事件循環。沒有必須調用run()或dispatch()的反應器(reactor),在twisted中是有 reactor的。當gevent的API函數想阻塞時,它得到Hub實例(執行時間循環的greenlet),並切換過去。若是沒有集線器實例則會動態 建立。

libev提供的事件循環默認使用系統最快輪詢機制,設置LIBEV_FLAGS環境變量可指定輪詢機制。LIBEV_FLAGS=1爲select, LIBEV_FLAGS = 2爲poll, LIBEV_FLAGS = 4爲epoll,LIBEV_FLAGS = 8爲kqueue。

Libev的API位於gevent.core下。注意libev API的回調在Hub的greenlet運行,所以使用同步greenlet的API。能夠使用spawn()和Event.set()等異步API。

同步與異步的性能區別

import gevent
 
def task(pid):
    """
    Some non-deterministic task
    """
    gevent.sleep(0.5)
    print('Task %s done' % pid)
 
def synchronous():
    for i in range(1,10):
        task(i)
 
def asynchronous():
    threads = [gevent.spawn(task, i) for i in range(10)]
    gevent.joinall(threads)
 
print('Synchronous:')
synchronous()
 
print('Asynchronous:')
asynchronous()

 

上面程序的重要部分是將task函數封裝到Greenlet內部線程的gevent.spawn。 初始化的greenlet列表存放在數組threads中,此數組被傳給gevent.joinall 函數,後者阻塞當前流程,並執行全部給定的greenlet。執行流程只會在 全部greenlet執行完後纔會繼續向下走。  

遇到IO阻塞時會自動切換任務

from gevent import monkey; monkey.patch_all()
import gevent
from  urllib.request import urlopen
 
def f(url):
    print('GET: %s' % url)
    resp = urlopen(url)
    data = resp.read()
    print('%d bytes received from %s.' % (len(data), url))
 
gevent.joinall([
        gevent.spawn(f, 'https://www.python.org/'),
        gevent.spawn(f, 'https://www.yahoo.com/'),
        gevent.spawn(f, 'https://github.com/'),
])

 

經過gevent實現單線程下的多socket併發

server side

import sys
import socket
import time
import gevent
 
from gevent import socket,monkey
monkey.patch_all()
 
 
def server(port):
    s = socket.socket()
    s.bind(('0.0.0.0', port))
    s.listen(500)
    while True:
        cli, addr = s.accept()
        gevent.spawn(handle_request, cli)
 
 
 
def handle_request(conn):
    try:
        while True:
            data = conn.recv(1024)
            print("recv:", data)
            conn.send(data)
            if not data:
                conn.shutdown(socket.SHUT_WR)
 
    except Exception as  ex:
        print(ex)
    finally:
        conn.close()
if __name__ == '__main__':
    server(8001)

 client side   

import socket
 
HOST = 'localhost'    # The remote host
PORT = 8001           # The same port as used by the server
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, PORT))
while True:
    msg = bytes(input(">>:"),encoding="utf8")
    s.sendall(msg)
    data = s.recv(1024)
    #print(data)
 
    print('Received', repr(data))
s.close()

 併發100個sock鏈接

import socket
import threading

def sock_conn():

    client = socket.socket()

    client.connect(("localhost",8001))
    count = 0
    while True:
        #msg = input(">>:").strip()
        #if len(msg) == 0:continue
        client.send( ("hello %s" %count).encode("utf-8"))

        data = client.recv(1024)

        print("[%s]recv from server:" % threading.get_ident(),data.decode()) #結果
        count +=1
    client.close()


for i in range(100):
    t = threading.Thread(target=sock_conn)
    t.start()

併發100個sock鏈接

 

事件驅動

事件驅動I/O本質上來說就是將基本I/O操做(好比讀和寫)轉化爲你程序須要處理的事件。

計算機程序分類

鼠標的一個點擊,移動,鍵盤的按鍵按下等等操做,都是對應操做系統的一個事件,而後應用程序接受你的操做進行處理
一般,咱們寫服務器處理模型的程序時,有如下幾種模型:
(1)每收到一個請求,建立一個新的進程,來處理該請求;
(2)每收到一個請求,建立一個新的線程,來處理該請求;
(3)每收到一個請求,放入一個事件列表,讓主進程經過非阻塞I/O方式來處理請求
上面的幾種方式,各有千秋,
第(1)種方法,因爲建立新的進程的開銷比較大,因此,會致使服務器性能比較差,但實現比較簡單。
第(2)種方式,因爲要涉及到線程的同步,有可能會面臨 死鎖等問題。
第(3)種方式,在寫應用程序代碼時,邏輯比前面兩種都複雜。
綜合考慮各方面因素,通常廣泛認爲第(3)種方式是大多數 網絡服務器採用的方式,這也是本文討論的重點— 事件驅動處理庫。

全部的計算機程序均可以大體分爲兩類:腳本型(單次運行)和連續運行型(直到用戶主動退出)。

腳本型
腳本型的程序包括最先的批處理文件以及使用Python作交易策略回測等等,這類程序的特色是在用戶啓動後會按照編程時設計好的步驟一步步運行,全部步驟運行完後自動退出。

連續運行型
連續運行型的程序包含了操做系統和絕大部分咱們平常使用的軟件等等,這類程序啓動後會處於一個無限循環中連續運行,直到用戶主動退出時纔會結束。


連續運行型程序

咱們要開發的交易系統就是屬於連續運行型程序,而這種程序根據其計算邏輯的運行機制不一樣,又能夠粗略的分爲時間驅動和事件驅動兩種。

時間驅動

時間驅動的程序邏輯相對容易設計,簡單來講就是讓電腦每隔一段時間自動作一些事情。這個事情自己能夠很複雜、包括不少步驟,但這些步驟都是線性的,按照順序一步步執行下來。

如下代碼展現了一個很是簡單的時間驅動的Python程序。

from time import sleep

def demo():
	print u'時間驅動的程序每隔1秒運行demo函數'

while 1:
	demo()
	sleep(1.0)

 

時間驅動的程序本質上就是每隔一段時間固定運行一次腳本(上面代碼中的demo函數)。儘管腳本自身能夠很長、包含很是多的步驟,可是咱們能夠看出這種程序的運行機制相對比較簡單、容易理解。

舉一些量化交易相關的例子:

  1. 每隔5分鐘,經過新浪財經網頁的公開API讀取一次滬深300成分股的價格,根據當日漲幅進行排序後輸出到電腦屏幕上。
  2. 每隔1秒鐘,檢查一次最新收到的股指期貨TICK數據,更新K線和其餘技術指標,檢查是否知足趨勢策略的下單條件,若知足則執行下單。

對速度要求較高的量化交易方面(日內CTA策略、高頻策略等等),時間驅動的程序會存在一個很是大的缺點:對數據信息在反應操做上的處理延時。例子2中,在每次邏輯腳本運行完等待的那1秒鐘裏,程序對於接收到的新數據信息(行情、成交推送等等)是不會作出任何反應的,只有在等待時間結束後腳本再次運行時纔會進行相關的計算處理。而處理延時在量化交易中的直接後果就是money:市價單滑點、限價單錯過本可成交的價格。

時間驅動的程序在量化交易方面還存在一些其餘的缺點:如浪費CPU的計算資源、實現異步邏輯複雜度高等等。

事件驅動

與時間驅動對應的就是事件驅動的程序:當某個新的事件被推送到程序中時(如API推送新的行情、成交),程序當即調用和這個事件相對應的處理函數進行相關的操做。

上面例子2的事件驅動版:交易程序對股指TICK數據進行監聽,當沒有新的行情過來時,程序保持監聽狀態不進行任何操做;當收到新的數據時,數據處理函數當即更新K線和其餘技術指標,並檢查是否知足趨勢策略的下單條件執行下單。

對於簡單的程序,咱們能夠採用上面測試代碼中的方案,直接在API的回調函數中寫入相應的邏輯。但隨着程序複雜度的增長,這種方案會變得愈來愈不可行。假設咱們有一個帶有圖形界面的量化交易系統,系統在某一時刻接收到了API推送的股指期貨行情數據,針對這個數據系統須要進行以下處理:

  1. 更新圖表上顯示的K線圖形(繪圖)
  2. 更新行情監控表中股指期貨的行情數據(表格更新)
  3. 策略1須要運行一次內部算法,檢查該數據是否會觸發策略進行下單(運算、下單)
  4. 策略2一樣須要運行一次內部算法,檢查該數據是否會觸發策略進行下單(運算、下單)
  5. 風控系統須要檢查最新行情價格是否會致使帳戶的總體風險超限,若超限須要進行報警(運算、報警)

此時將上面全部的操做都寫到一個回調函數中無疑變成了很是差的方案,代碼過長容易出錯不說,可擴展性也差,每添加一個策略或者功能則又須要修改以前的源代碼(有經驗的讀者會知道,常常修改生產代碼是一種很是危險的運營管理方法)。

爲了解決這種狀況,咱們須要用到事件驅動引擎來管理不一樣事件的事件監聽函數並執行全部和事件驅動相關的操做。



事件驅動引擎原理

事件驅動模式能夠進一步抽象理解爲由事件源,事件對象,以及事件監聽器三元素構成,能完成監聽器監聽事件源、事件源發送事件,監聽器收到事件後調用響應函數的動做。

事件驅動主要包含如下元素和操做函數:
元素
1.事件源
2.事件監聽器
3.事件對象

操做函數
4.監聽動做
5.發送事件
6.調用監聽器響應函數

瞭解清楚了事件驅動的工做原理後,讀者能夠試着用本身熟悉的編程語言實現,編程主要實現下面的內容,筆者後續給python實現

用戶根據實際業務邏輯定義
事件源 EventSources
監聽器 Listeners

事件管理者 EventManager
成員
1.響應函數隊列 Handlers
2.事件對象 Event
3.事件對象列表 EventQueue
操做函數
4.監聽動做 AddEventListener
5.發送事件 SendEvent
6.調用響應函數 EventProcess

在實際的軟件開發過程當中,你會常常看到事件驅動的影子,幾乎全部的GUI界面都採用事件驅動編程模型,不少服務器網絡模型的消息處理也會採用,甚至複雜點的數據庫業務處理也會用這種模型,由於這種模型解耦事件發送者和接收者之間的聯繫,事件可動態增長減小接收者,業務邏輯越複雜,越能體現它的優點。

論事件驅動模型
在UI編程中,,經常要對鼠標點擊進行相應,首先如何得到鼠標點擊呢?
方式一:建立一個線程,該線程一直循環檢測是否有鼠標點擊,那麼這個方式有如下幾個缺點:

  1. CPU資源浪費,可能鼠標點擊的頻率很是小,可是掃描線程仍是會一直循環檢測,這會形成不少的CPU資源浪費;若是掃描鼠標點擊的接口是阻塞的呢?
  2. 若是是堵塞的,又會出現下面這樣的問題,若是咱們不但要掃描鼠標點擊,還要掃描鍵盤是否按下,因爲掃描鼠標時被堵塞了,那麼可能永遠不會去掃描鍵盤;
  3. 若是一個循環須要掃描的設備很是多,這又會引來響應時間的問題;
    因此,該方式是很是很差的。

方式二:就是事件驅動模型
目前大部分的UI編程都是事件驅動模型,如不少UI平臺都會提供onClick()事件,這個事件就表明鼠標按下事件。事件驅動模型大致思路以下:

  1. 有一個事件(消息)隊列;
  2. 鼠標按下時,往這個隊列中增長一個點擊事件(消息);
  3. 有個循環,不斷從隊列取出事件,根據不一樣的事件,調用不一樣的函數,如onClick()、onKeyDown()等;
  4. 事件(消息)通常都各自保存各自的處理函數指針,這樣,每一個消息都有獨立的處理函數;
     

     


    事件驅動編程是一種編程範式,這裏程序的執行流由外部事件來決定。它的特色是包含一個事件循環,當外部事件發生時使用回調機制來觸發相應的處理。另外兩種常見的編程範式是(單線程)同步以及多線程編程。
     
    讓咱們用例子來比較和對比一下單線程、多線程以及事件驅動編程模型。下圖展現了隨着時間的推移,這三種模式下程序所作的工做。這個程序有3個任務須要完成,每一個任務都在等待I/O操做時阻塞自身。阻塞在I/O操做上所花費的時間已經用灰色框標示出來了。
    這裏寫圖片描述

最初的問題:怎麼肯定IO操做完了切回去呢?經過回調函數

 

在單線程同步模型中,任務按照順序執行。若是某個任務由於I/O而阻塞,其餘全部的任務都必須等待,直到它完成以後它們才能依次執行。這種明確的執行順序和串行化處理的行爲是很容易推斷得出的。若是任務之間並無互相依賴的關係,但仍然須要互相等待的話這就使得程序沒必要要的下降了運行速度。

 

在多線程版本中,這3個任務分別在獨立的線程中執行。這些線程由操做系統來管理,在多處理器系統上能夠並行處理,或者在單處理器系統上交錯執行。這使得當某個線程阻塞在某個資源的同時其餘線程得以繼續執行。與完成相似功能的同步程序相比,這種方式更有效率,但程序員必須寫代碼來保護共享資源,防止其被多個線程同時訪問。多線程程序更加難以推斷,由於這類程序不得不經過線程同步機制如鎖、可重入函數、線程局部存儲或者其餘機制來處理線程安全問題,若是實現不當就會致使出現微妙且使人痛不欲生的bug。

 

在事件驅動版本的程序中,3個任務交錯執行,但仍然在一個單獨的線程控制中。當處理I/O或者其餘昂貴的操做時,註冊一個回調到事件循環中,而後當I/O操做完成時繼續執行。回調描述了該如何處理某個事件。事件循環輪詢全部的事件,當事件到來時將它們分配給等待處理事件的回調函數。這種方式讓程序儘量的得以執行而不須要用到額外的線程。事件驅動型程序比多線程程序更容易推斷出行爲,由於程序員不須要關心線程安全問題。

 

當咱們面對以下的環境時,事件驅動模型一般是一個好的選擇:

 

  1. 程序中有許多任務,並且…
  2. 任務之間高度獨立(所以它們不須要互相通訊,或者等待彼此)並且…
  3. 在等待事件到來時,某些任務會阻塞。

 

當應用程序須要在任務間共享可變的數據時,這也是一個不錯的選擇,由於這裏不須要採用同步處理。

 

網絡應用程序一般都有上述這些特色,這使得它們可以很好的契合事件驅動編程模型。

 

事件驅動編程是指程序的執行流程取決於事件的編程風格,事件由事件處理程序或者事件回調函數進行處理。當某些重要的事件發生時-- 例如數據庫查詢結果可用或者用戶單擊了某個按鈕,就會調用事件回調函數。

事件驅動編程風格和事件循環相伴相生,事件循環是一個處於不間斷循環中的結構,該結構主要具備兩項功能-- 事件檢測和事件觸發處理,在每一輪循環中,它都必須檢測發生了什麼事件。當事件發生時,事件循環還要決定調用哪一個回調函數。

事件循環只是在一個進程中運行的單個線程,這意味着當事件發生時,能夠不用中斷就運行事件處理程序,這樣作有如下兩個特色:

在任一給定時刻,最多運行一個事件處理程序。

事件處理程序能夠不間斷地運行直到結束。

這使得程序員能放寬同步要求,而且沒必要擔憂執行併發線程會改變共享內存的狀態。

衆所周知的祕密

在至關一段時間內,系統編程領域已經知道事件驅動編程是建立處理衆多併發鏈接的服務的最佳方法。衆所周知,因爲不用保存不少上下文,所以節省了大量內存;又由於也沒有那麼多上下文切換,又節省了大量執行時間。

經常使用的server端linux高併發編程模型

Nginx Vs Apache

大名鼎鼎的Nginx使用了多進程模型,主進程啓動時初始化,bind,監聽一組sockets,而後fork一堆child processes(workers),workers共享socket descriptor。workers競爭accept_mutex,獲勝的worker經過IO multiplex(select/poll/epoll/kqueue/...)來處理成千上萬的併發請求。爲了得到高性能,Nginx還大量使用了異步,事件驅動,non-blocking IO等技術。"What resulted is a modular, event-driven, asynchronous, single-threaded, non-blocking architecture which became the foundation of nginx code."

Nginx 架構

對比着看一下Apache的兩種經常使用運行模式,詳見 Apache Modules

1. Apache MPM prefork模式

主進程經過進程池維護必定數量(可配置)的worker進程,每一個worker進程負責一個connection。worker進程之間經過競爭mpm-accept mutex實現併發和連接處理隔離。 因爲進程內存開銷和切換開銷,該模式相對來講是比較低效的併發。

2. Apache MPM worker模式

因爲進程開銷較大,MPM worker模式作了改進,處理每一個connection的實體改成thread。主進程啓動可配數量的子進程,每一個進程啓動可配數量的server threads和listen thread。listen threads經過競爭mpm-accept mutex獲取到新進的connection request經過queue傳遞給本身進程所在的server threads處理。因爲調度的實體變成了開銷較小的thread,worker模式相對prefork具備更好的併發性能。

小結兩種webserver,能夠發現Nginx使用了更高效的編程模型,worker進程通常跟CPU的core數量至關,每一個worker駐留在一個core上,合理編程能夠作到最小程度的進程切換,並且內存的使用也比較經濟,基本上沒有浪費在進程狀態的存儲上。而Apache的模式是每一個connection對應一個進程/線程,進程/線程間的切換開銷,大量進程/線程的內存開銷,cache miss的機率增大,都限制了系統所能支持的併發數。

用戶空間與內核空間

如今操做系統都是採用虛擬存儲器,那麼對32位操做系統而言,它的尋址空間(虛擬存儲空間)爲4G(2的32次方)。操做系統的核心是內核,獨立於普通的應用程序,能夠訪問受保護的內存空間,也有訪問底層硬件設備的全部權限。爲了保證用戶進程不能直接操做內核(kernel),保證內核的安全,操心繫統將虛擬空間劃分爲兩部分,一部分爲內核空間,一部分爲用戶空間。針對linux操做系統而言,將最高的1G字節(從虛擬地址0xC0000000到0xFFFFFFFF),供內核使用,稱爲內核空間,而將較低的3G字節(從虛擬地址0x00000000到0xBFFFFFFF),供各個進程使用,稱爲用戶空間。

進程切換

爲了控制進程的執行,內核必須有能力掛起正在CPU上運行的進程,並恢復之前掛起的某個進程的執行。這種行爲被稱爲進程切換。所以能夠說,任何進程都是在操做系統內核的支持下運行的,是與內核緊密相關的。

從一個進程的運行轉到另外一個進程上運行,這個過程當中通過下面這些變化:
1. 保存處理機上下文,包括程序計數器和其餘寄存器。
2. 更新PCB信息。
3. 把進程的PCB移入相應的隊列,如就緒、在某事件阻塞等隊列。
4. 選擇另外一個進程執行,並更新其PCB。
5. 更新內存管理的數據結構。
6. 恢復處理機上下文。

注:總而言之就是很耗資源,具體的能夠參考這篇文章:進程切換

進程的阻塞

正在執行的進程,因爲期待的某些事件未發生,如請求系統資源失敗、等待某種操做的完成、新數據還沒有到達或無新工做作等,則由系統自動執行阻塞原語(Block),使本身由運行狀態變爲阻塞狀態。可見,進程的阻塞是進程自身的一種主動行爲,也所以只有處於運行態的進程(得到CPU),纔可能將其轉爲阻塞狀態。當進程進入阻塞狀態,是不佔用CPU資源的

文件描述符fd

文件描述符(File descriptor)是計算機科學中的一個術語,是一個用於表述指向文件的引用的抽象化概念。

文件描述符在形式上是一個非負整數。實際上,它是一個索引值,指向內核爲每個進程所維護的該進程打開文件的記錄表。當程序打開一個現有文件或者建立一個新文件時,內核向進程返回一個文件描述符。在程序設計中,一些涉及底層的程序編寫每每會圍繞着文件描述符展開。可是文件描述符這一律念每每只適用於UNIX、Linux這樣的操做系統。

緩存 I/O

緩存 I/O 又被稱做標準 I/O,大多數文件系統的默認 I/O 操做都是緩存 I/O。在 Linux 的緩存 I/O 機制中,操做系統會將 I/O 的數據緩存在文件系統的頁緩存( page cache )中,也就是說,數據會先被拷貝到操做系統內核的緩衝區中,而後纔會從操做系統內核的緩衝區拷貝到應用程序的地址空間。

緩存 I/O 的缺點:
數據在傳輸過程當中須要在應用程序地址空間和內核進行屢次數據拷貝操做,這些數據拷貝操做所帶來的 CPU 以及內存開銷是很是大的。

IO策略

因爲IO的處理速度要遠遠低於CPU的速度,運行在CPU上的程序不得不考慮IO在準備暑假的過程當中該乾點什麼,讓出CPU給別人仍是本身去幹點別的有意義的事情,這就涉及到了採用什麼樣的IO策略。通常IO策略的選用跟進程線程編程模型要同時考慮,二者是有聯繫的。

 

Linux IO模型

 

網絡IO的本質是socket的讀取,socket在linux系統被抽象爲流,IO能夠理解爲對流的操做。剛纔說了,對於一次IO訪問(以read舉例),數據會先被拷貝到操做系統內核的緩衝區中,而後纔會從操做系統內核的緩衝區拷貝到應用程序的地址空間。因此說,當一個read操做發生時,它會經歷兩個階段:

 

  1. 第一階段:等待數據準備 (Waiting for the data to be ready)。

  2. 第二階段:將數據從內核拷貝到進程中 (Copying the data from the kernel to the process)。

對於socket流而言,

 

  1. 第一步:一般涉及等待網絡上的數據分組到達,而後被複制到內核的某個緩衝區。

  2. 第二步:把數據從內核緩衝區複製到應用進程緩衝區。

網絡應用須要處理的無非就是兩大類問題,網絡IO,數據計算。相對於後者,網絡IO的延遲,給應用帶來的性能瓶頸大於後者。

 

IO介紹

IO在計算機中指Input/Output,也就是輸入和輸出。因爲程序和運行時數據是在內存中駐留,由CPU這個超快的計算核心來執行,涉及到數據交換的地方,一般是磁盤、網絡等,就須要IO接口。

好比你打開瀏覽器,訪問新浪首頁,瀏覽器這個程序就須要經過網絡IO獲取新浪的網頁。瀏覽器首先會發送數據給新浪服務器,告訴它我想要首頁的HTML,這個動做是往外發數據,叫Output,隨後新浪服務器把網頁發過來,這個動做是從外面接收數據,叫Input。因此,一般,程序完成IO操做會有Input和Output兩個數據流。固然也有隻用一個的狀況,好比,從磁盤讀取文件到內存,就只有Input操做,反過來,把數據寫到磁盤文件裏,就只是一個Output操做。

IO編程中,Stream(流)是一個很重要的概念,能夠把流想象成一個水管,數據就是水管裏的水,可是隻能單向流動。Input Stream就是數據從外面(磁盤、網絡)流進內存,Output Stream就是數據從內存流到外面去。對於瀏覽網頁來講,瀏覽器和新浪服務器之間至少須要創建兩根水管,才能夠既能發數據,又能收數據。

因爲CPU和內存的速度遠遠高於外設的速度,因此,在IO編程中,就存在速度嚴重不匹配的問題。舉個例子來講,好比要把100M的數據寫入磁盤,CPU輸出100M的數據只須要0.01秒,但是磁盤要接收這100M數據可能須要10秒,怎麼辦呢?有兩種辦法:

第一種是CPU等着,也就是程序暫停執行後續代碼,等100M的數據在10秒後寫入磁盤,再接着往下執行,這種模式稱爲同步IO;

另外一種方法是CPU不等待,只是告訴磁盤,「您老慢慢寫,不着急,我接着幹別的事去了」,因而,後續代碼能夠馬上接着執行,這種模式稱爲異步IO。

同步和異步的區別就在因而否等待IO執行的結果。比如你去麥當勞點餐,你說「來個漢堡」,服務員告訴你,對不起,漢堡要現作,須要等5分鐘,因而你站在收銀臺前面等了5分鐘,拿到漢堡再去逛商場,這是同步IO。

你說「來個漢堡」,服務員告訴你,漢堡須要等5分鐘,你能夠先去逛商場,等作好了,咱們再通知你,這樣你能夠馬上去幹別的事情(逛商場),這是異步IO。

很明顯,使用異步IO來編寫程序性能會遠遠高於同步IO,可是異步IO的缺點是編程模型複雜。想一想看,你得知道何時通知你「漢堡作好了」,而通知你的方法也各不相同。若是是服務員跑過來找到你,這是回調模式,若是服務員發短信通知你,你就得不停地檢查手機,這是輪詢模式。總之,異步IO的複雜度遠遠高於同步IO。

操做IO的能力都是由操做系統提供的,每一種編程語言都會把操做系統提供的低級C接口封裝起來方便使用,Python也不例外。咱們後面會詳細討論Python的IO編程接口。

 

接觸網絡編程,咱們時常會與各類與IO相關的概念打交道:同步(Synchronous)、異步(ASynchronous)、阻塞(blocking)和非阻塞(non-blocking)

同步與異步的主要區別就在於:會不會致使請求進程(或線程)阻塞。同步會使請求進程(或線程)阻塞而異步不會。

    linux下有五種常見的IO模型,其中只有一種異步模型,其他皆爲同步模型。如圖:

Linux下5種IO模型的小結

 

同步:
      所謂同步,就是在發出一個功能調用時,在沒有獲得結果以前,該調用就不返回。也就是必須一件一件事作,等前一件作完了才能作下一件事。

 

例如普通B/S模式(同步):提交請求->等待服務器處理->處理完畢返回 這個期間客戶端瀏覽器不能幹任何事

異步:
      異步的概念和同步相對。當一個異步過程調用發出後,調用者不能馬上獲得結果。實際處理這個調用的部件在完成後,經過狀態、通知和回調來通知調用者。

     例如 ajax請求(異步): 請求經過事件觸發->服務器處理(這是瀏覽器仍然能夠做其餘事情)->處理完畢

這就是同步和異步。舉個簡單的例子,假若有一個任務包括兩個子任務A和B,對於同步來講,當A在執行的過程當中,B只有等待,直至A執行完畢,B才能執行;而對於異步就是A和B能夠併發地執行,B沒必要等待A執行完畢以後再執行,這樣就不會因爲A的執行致使整個任務的暫時等待。

阻塞
     阻塞調用是指調用結果返回以前,當前線程會被掛起(線程進入非可執行狀態,在這個狀態下,cpu不會給線程分配時間片,即線程暫停運行)。函數只有在獲得結果以後纔會返回。

     有人也許會把阻塞調用和同步調用等同起來,實際上他是不一樣的。對於同步調用來講,不少時候當前線程仍是激活的,只是從邏輯上當前函數沒有返回而已。 例如,咱們在socket中調用recv函數,若是緩衝區中沒有數據,這個函數就會一直等待,直到有數據才返回。而此時,當前線程還會繼續處理各類各樣的消息。

非阻塞
      非阻塞和阻塞的概念相對應,指在不能馬上獲得結果以前,該函數不會阻塞當前線程,而會馬上返回。
對象的阻塞模式和阻塞函數調用
對象是否處於阻塞模式和函數是否是阻塞調用有很強的相關性,可是並非一一對應的。阻塞對象上能夠有非阻塞的調用方式,咱們能夠經過必定的API去輪詢狀 態,在適當的時候調用阻塞函數,就能夠避免阻塞。而對於非阻塞對象,調用特殊的函數也能夠進入阻塞調用。函數select就是這樣的一個例子。

 這就是阻塞和非阻塞的區別。也就是說阻塞和非阻塞的區別關鍵在於當發出請求一個操做時,若是條件不知足,是會一直等待仍是返回一個標誌信息。

1. 同步,就是我調用一個功能,該功能沒有結束前,我死等結果。
2. 異步,就是我調用一個功能,不須要知道該功能結果,該功能有結果後通知我(回調通知)
3. 阻塞,      就是調用我(函數),我(函數)沒有接收完數據或者沒有獲得結果以前,我不會返回。
4. 非阻塞,  就是調用我(函數),我(函數)當即返回,經過select通知調用者

 

同步IO和異步IO的區別就在於:數據拷貝的時候進程是否阻塞!

阻塞IO和非阻塞IO的區別就在於:應用程序的調用是否當即返回!


對於舉個簡單c/s 模式:

 

同步:提交請求->等待服務器處理->處理完畢返回這個期間客戶端瀏覽器不能幹任何事
異步:請求經過事件觸發->服務器處理(這是瀏覽器仍然能夠做其餘事情)->處理完畢
同步和異步都只針對於本機SOCKET而言的。

同步和異步,阻塞和非阻塞,有些混用,其實它們徹底不是一回事,並且它們修飾的對象也不相同。
阻塞和非阻塞是指當進程訪問的數據若是還沒有就緒,進程是否須要等待,簡單說這至關於函數內部的實現區別,也就是未就緒時是直接返回仍是等待就緒;

而同步和異步是指訪問數據的機制,同步通常指主動請求並等待I/O操做完畢的方式,當數據就緒後在讀寫的時候必須阻塞(區別就緒與讀寫二個階段,同步的讀寫必須阻塞),異步則指主動請求數據後即可以繼續處理其它任務,隨後等待I/O,操做完畢的通知,這能夠使進程在數據讀寫時也不阻塞。(等待"通知")

 
網絡IO的模型大體有以下幾種:

1)阻塞I/O(blocking I/O)
2)非阻塞I/O (nonblocking I/O)
3) I/O複用(select 和poll) (I/O multiplexing)
4)信號驅動I/O (signal driven I/O (SIGIO))
5)異步I/O (asynchronous I/O (the POSIX aio_functions))

前四種都是同步,只有最後一種纔是異步IO。

注:因爲signal driven IO在實際中並不經常使用,因此我這隻說起剩下的四種IO Model。

在深刻介紹Linux IO各類模型以前,讓咱們先來探索一下基本 Linux IO 模型的簡單矩陣。以下圖所示:

 

 

每一個 IO 模型都有本身的使用模式,它們對於特定的應用程序都有本身的優勢。本節將簡要對其一一進行介紹。常見的IO模型有阻塞、非阻塞、IO多路複用,異步。

I/O模型

 

阻塞式I/O

非阻塞式I/O

I/O複用;

信號驅動式I/O

異步I/O

一個輸入操做一般包括兩個不一樣的階段:

1) 等待數據準備好;

2) 從內核向進程複製數據;

對於一個套接字上的輸入操做,第一步一般涉及等待數據從網絡中到達。當所等待分組到達時,它被複制到內核中的某個緩衝區。第二步就是把數據從內核緩衝區複製到應用進程緩衝區。

 

網絡IO操做實際過程涉及到內核和調用這個IO操做的進程。以read爲例,read的具體操做分爲如下兩個部分:

 

(1)內核等待數據可讀

(2)將內核讀到的數據拷貝到進程

 

阻塞I/O模型:

 

        簡介:進程會一直阻塞,直到數據拷貝完成

 

     應用程序調用一個IO函數,致使應用程序阻塞,等待數據準備好。 若是數據沒有準備好,一直等待….數據準備好了,從內核拷貝到用戶空間,IO函數返回成功指示。

最流行的I/O模型是阻塞式I/O(blocking I/O)模型,默認狀況下,全部套接字都是阻塞的。以數據報套接字做爲例子,咱們有如圖6-1所示的情形。

 

阻塞I/O模型圖:在調用recv()/recvfrom()函數時,發生在內核中等待數據和複製數據的過程。

 

 

同步阻塞IO

 

當調用recv()函數時,系統首先查是否有準備好的數據。若是數據沒有準備好,那麼系統就處於等待狀態。當數據準備好後,將數據從系統緩衝區複製到用戶空間,而後該函數返回。在套接應用程序中,當調用recv()函數時,未必用戶空間就已經存在數據,那麼此時recv()函數就會處於等待狀態。

 

 

 

     當使用socket()函數和WSASocket()函數建立套接字時,默認的套接字都是阻塞的。這意味着當調用Windows Sockets API不能當即完成時,線程處於等待狀態,直到操做完成。

 

    並非全部Windows Sockets API以阻塞套接字爲參數調用都會發生阻塞。例如,以阻塞模式的套接字爲參數調用bind()、listen()函數時,函數會當即返回。將可能阻塞套接字的Windows Sockets API調用分爲如下四種:

 

    1.輸入操做: recv()、recvfrom()、WSARecv()和WSARecvfrom()函數。以阻塞套接字爲參數調用該函數接收數據。若是此時套接字緩衝區內沒有數據可讀,則調用線程在數據到來前一直睡眠。

 

    2.輸出操做: send()、sendto()、WSASend()和WSASendto()函數。以阻塞套接字爲參數調用該函數發送數據。若是套接字緩衝區沒有可用空間,線程會一直睡眠,直到有空間。

 

    3.接受鏈接:accept()和WSAAcept()函數。以阻塞套接字爲參數調用該函數,等待接受對方的鏈接請求。若是此時沒有鏈接請求,線程就會進入睡眠狀態。

 

   4.外出鏈接:connect()和WSAConnect()函數。對於TCP鏈接,客戶端以阻塞套接字爲參數,調用該函數向服務器發起鏈接。該函數在收到服務器的應答前,不會返回。這意味着TCP鏈接總會等待至少到服務器的一次往返時間。

 

  使用阻塞模式的套接字,開發網絡程序比較簡單,容易實現。當但願可以當即發送和接收數據,且處理的套接字數量比較少的狀況下,使用阻塞模式來開發網絡程序比較合適。

 

    阻塞模式套接字的不足表現爲,在大量創建好的套接字線程之間進行通訊時比較困難。當使用「生產者-消費者」模型開發網絡程序時,爲每一個套接字都分別分配一個讀線程、一個處理數據線程和一個用於同步的事件,那麼這樣無疑加大系統的開銷。其最大的缺點是當但願同時處理大量套接字時,將無從下手,其擴展性不好

 

非阻塞IO模型

       簡介:非阻塞IO經過進程反覆調用IO函數(屢次系統調用,並立刻返回);在數據拷貝的過程當中,進程是阻塞的;

 

      進程把一個套接字設置成非阻塞是在通知內核:當所請求的I/O操做非得把本進程投入睡眠才能完成時,不要把本進程投入睡眠,而是返回一個錯誤.

       咱們把一個SOCKET接口設置爲非阻塞就是告訴內核,當所請求的I/O操做沒法完成時,不要將進程睡眠,而是返回一個錯誤。這樣咱們的I/O操做函數將不斷的測試數據是否已經準備好,若是沒有準備好,繼續測試,直到數據準備好爲止。在這個不斷測試的過程當中,會大量的佔用CPU的時間。

 

    把SOCKET設置爲非阻塞模式,即通知系統內核:在調用Windows Sockets API時,不要讓線程睡眠,而應該讓函數當即返回。在返回時,該函數返回一個錯誤代碼。圖所示,一個非阻塞模式套接字屢次調用recv()函數的過程。前三次調用recv()函數時,內核數據尚未準備好。所以,該函數當即返回WSAEWOULDBLOCK錯誤代碼。第四次調用recv()函數時,數據已經準備好,被複制到應用程序的緩衝區中,recv()函數返回成功指示,應用程序開始處理數據。

 



 

   

 

前三次調用recvfrom時沒有數據可返回,所以內核轉而當即返回一個EWOULDBLOCK錯誤。第四次調用recvfrom時已有一個數據報準備好,它被複制到應用進程緩衝區,因而recvfrom成功返回。咱們接着處理數據。

當一個應用進程像這樣對一個非阻塞描述符循環調用recvfrom時,咱們稱之爲輪詢(polling)。應用進程只需輪詢內核,以查看某個操做是否就緒。這麼作每每耗費大量CPU時間。

 當使用socket()函數和WSASocket()函數建立套接字時,默認都是阻塞的。在建立套接字以後,經過調用ioctlsocket()函數,將該套接字設置爲非阻塞模式。Linux下的函數是:fcntl().
    套接字設置爲非阻塞模式後,在調用Windows Sockets API函數時,調用函數會當即返回。大多數狀況下,這些函數調用都會調用「失敗」,並返回WSAEWOULDBLOCK錯誤代碼。說明請求的操做在調用期間內沒有時間完成。一般,應用程序須要重複調用該函數,直到得到成功返回代碼。

 

    須要說明的是並不是全部的Windows Sockets API在非阻塞模式下調用,都會返回WSAEWOULDBLOCK錯誤。例如,以非阻塞模式的套接字爲參數調用bind()函數時,就不會返回該錯誤代碼。固然,在調用WSAStartup()函數時更不會返回該錯誤代碼,由於該函數是應用程序第一調用的函數,固然不會返回這樣的錯誤代碼。

 

    要將套接字設置爲非阻塞模式,除了使用ioctlsocket()函數以外,還能夠使用WSAAsyncselect()和WSAEventselect()函數。當調用該函數時,套接字會自動地設置爲非阻塞方式。

 

  因爲使用非阻塞套接字在調用函數時,會常常返回WSAEWOULDBLOCK錯誤。因此在任什麼時候候,都應仔細檢查返回代碼並做好對「失敗」的準備。應用程序接二連三地調用這個函數,直到它返回成功指示爲止。上面的程序清單中,在While循環體內不斷地調用recv()函數,以讀入1024個字節的數據。這種作法很浪費系統資源。

 

    要完成這樣的操做,有人使用MSG_PEEK標誌調用recv()函數查看緩衝區中是否有數據可讀。一樣,這種方法也很差。由於該作法對系統形成的開銷是很大的,而且應用程序至少要調用recv()函數兩次,才能實際地讀入數據。較好的作法是,使用套接字的「I/O模型」來判斷非阻塞套接字是否可讀可寫。

 

    非阻塞模式套接字與阻塞模式套接字相比,不容易使用。使用非阻塞模式套接字,須要編寫更多的代碼,以便在每一個Windows Sockets API函數調用中,對收到的WSAEWOULDBLOCK錯誤進行處理。所以,非阻塞套接字便顯得有些難於使用。

 

    可是,非阻塞套接字在控制創建的多個鏈接,在數據的收發量不均,時間不定時,明顯具備優點。這種套接字在使用上存在必定難度,但只要排除了這些困難,它在功能上仍是很是強大的。一般狀況下,可考慮使用套接字的「I/O模型」,它有助於應用程序經過異步方式,同時對一個或多個套接字的通訊加以管理。


IO複用模型:

 

             簡介:主要是select和epoll;對一個IO端口,兩次調用,兩次返回,比阻塞IO並無什麼優越性;關鍵是能實現同時對多個IO端口進行監聽;

 

      I/O複用模型會用到select、poll、epoll函數,這幾個函數也會使進程阻塞,可是和阻塞I/O所不一樣的的,這兩個函數能夠同時阻塞多個I/O操做。並且能夠同時對多個讀操做,多個寫操做的I/O函數進行檢測,直到有數據可讀或可寫時,才真正調用I/O操做函數。

 

 

 

咱們阻塞於select調用,等待數據報套接字變爲可讀。當select返回套接字可讀這一條件時,咱們調用recvfrom把所讀數據報復制到應用進程緩衝區。

 

比較圖6-3和圖6-1,I/O複用並不顯得有什麼優點,事實上因爲使用select須要兩個而不是單個系統調用,I/O複用還稍有劣勢。使用select的優點在於咱們能夠等待多個描述符就緒。

I/O複用密切相關的另外一種I/O模型是在多線程中使用阻塞式I/O(咱們常常這麼幹)。這種模型與上述模型極爲類似,但它並無使用select阻塞在多個文件描述符上,而是使用多個線程(每一個文件描述符一個線程),這樣每一個線程均可以自由的調用recvfrom之類的阻塞式I/O系統調用了。

信號驅動IO

    簡介:兩次調用,兩次返回;

咱們也能夠用信號,讓內核在描述符就緒時發送SIGIO信號通知咱們。咱們稱這種模型爲信號驅動式I/O(signal-driven I/O),圖6-4是它的概要展現。

 

    首先咱們容許套接口進行信號驅動I/O,並安裝一個信號處理函數,進程繼續運行並不阻塞。當數據準備好時,進程會收到一個SIGIO信號,能夠在信號處理函數中調用I/O操做函數處理數據。

 

 

咱們首先開啓套接字的信號驅動式I/O功能,並經過sigaction系統調用安裝一個信號處理函數。改系統調用將當即返回,咱們的進程繼續工做,也就是說他沒有被阻塞。當數據報準備好讀取時,內核就爲該進程產生一個SIGIO信號。咱們隨後就能夠在信號處理函數中調用recvfrom讀取數據報,並通知主循環數據已經準備好待處理,也能夠當即通知主循環,讓它讀取數據報。

不管如何處理SIGIO信號,這種模型的優點在於等待數據報到達期間進程不被阻塞。主循環能夠繼續執行,只要等到來自信號處理函數的通知:既能夠是數據已準備好被處理,也能夠是數據報已準備好被讀取。

異步IO模型

     簡介:數據拷貝的時候進程無需阻塞。

  異步I/O(asynchronous I/O)由POSIX規範定義。演變成當前POSIX規範的各類早起標準所定義的實時函數中存在的差別已經取得一致。通常地說,這些函數的工做機制是:告知內核啓動某個操做,並讓內核在整個操做(包括將數據從內核複製到咱們本身的緩衝區)完成後通知咱們。這種模型與前一節介紹的信號驅動模型的主要區別在於:信號驅動式I/O是由內核通知咱們什麼時候能夠啓動一個I/O操做,而異步I/O模型是由內核通知咱們I/O操做什麼時候完成。圖6-5給出了一個例子。

 

     當一個異步過程調用發出後,調用者不能馬上獲得結果。實際處理這個調用的部件在完成後,經過狀態、通知和回調來通知調用者的輸入輸出操做


異步非阻塞IO

對比同步非阻塞IO,異步非阻塞IO也有個名字--Proactor。這種策略是真正的異步,使用註冊callback/hook函數來實現異步。程序註冊本身感興趣的socket 事件時,同時將處理各類事件的handler也就是對應的函數也註冊給內核,不會有任何阻塞式調用。事件發生後內核之間調用對應的handler完成處理。這裏暫且理解爲內核作了event的調度和handler調用,具體究竟是異步IO庫如何作的,如何跟內核通訊的,後續繼續研究。

同步IO引發進程阻塞,直至IO操做完成。
異步IO不會引發進程阻塞。
IO複用是先經過select調用阻塞。

咱們調用aio_read函數(POSIX異步I/O函數以aio_或lio_開頭),給內核傳遞描述符、緩衝區指針、緩衝區大小(與read相同的三個參數)和文件偏移(與lseek相似),並告訴內核當整個操做完成時如何通知咱們。該系統調用當即返回,而且在等待I/O完成期間,咱們的進程不被阻塞。本例子中咱們假設要求內核在操做完成時產生某個信號。改信號直到數據已複製到應用進程緩衝區才產生,這一點不一樣於信號驅動I/O模型。

各類I/O模型的比較

6-6對比了上述5中不一樣的I/O模型。能夠看出,前4中模型的主要區別在於第一階段,由於他們的第二階段是同樣的:在數據從內核複製到調用者的緩衝區期間,進程阻塞於recvfrom調用。相反,異步I/O模型在這兩個階段都要處理,從而不一樣於其餘4中模型。

 

 

 

同步I/O和異步I/O對比

POSIX把這兩個術語定於以下:

同步I/O操做(sysnchronous I/O opetation)致使請求進程阻塞,直到I/O操做完成;

異步I/O操做(asynchronous I/O opetation)不致使請求進程阻塞。

根據上述定義,咱們的前4種模型----阻塞式I/O模型、非阻塞式I/O模型、I/O複用模型和信號驅動I/O模型都是同步I/O模型,由於其中真正的I/O操做(recvfrom)將阻塞進程。只有異步I/O模型與POSIX定義的異步I/O相匹配。

 IO多路複用之select、poll、epoll詳解

 

IO多路複用是指內核一旦發現進程指定的一個或者多個IO條件準備讀取,它就通知該進程。IO多路複用適用以下場合:

 

  1. 當客戶處理多個描述符時(通常是交互式輸入和網絡套接口),必須使用I/O複用。

  2. 當一個客戶同時處理多個套接口時,而這種狀況是可能的,但不多出現。

  3. 若是一個TCP服務器既要處理監聽套接口,又要處理已鏈接套接口,通常也要用到I/O複用。

  4. 若是一個服務器即要處理TCP,又要處理UDP,通常要使用I/O複用。

  5. 若是一個服務器要處理多個服務或多個協議,通常要使用I/O複用。

 

與多進程和多線程技術相比,I/O多路複用技術的最大優點是系統開銷小,系統沒必要建立進程/線程,也沒必要維護這些進程/線程,從而大大減少了系統的開銷。

 

目前支持I/O多路複用的系統調用有 select,pselect,poll,epoll,I/O多路複用就是經過一種機制,一個進程能夠監視多個描述符,一旦某個描述符就緒(通常是讀就緒或者寫就緒),可以通知程序進行相應的讀寫操做但select,pselect,poll,epoll本質上都是同步I/O,由於他們都須要在讀寫事件就緒後本身負責進行讀寫,也就是說這個讀寫過程是阻塞的,而異步I/O則無需本身負責進行讀寫,異步I/O的實現會負責把數據從內核拷貝到用戶空間。

 

對於IO多路複用機制不理解的同窗,能夠先行參考《聊聊Linux 五種IO模型》,來了解Linux五種IO模型。

 

1 select、poll、epoll簡介

 

epoll跟select都能提供多路I/O複用的解決方案。在如今的Linux內核裏有都可以支持,其中epoll是Linux所特有,而select則應該是POSIX所規定,通常操做系統均有實現。

 

1.1 select

 

基本原理:

 

select 函數監視的文件描述符分3類,分別是writefds、readfds、和exceptfds。調用後select函數會阻塞,直到有描述符就緒(有數據 可讀、可寫、或者有except),或者超時(timeout指定等待時間,若是當即返回設爲null便可),函數返回。當select函數返回後,能夠經過遍歷fdset,來找到就緒的描述符。

 

基本概念

  IO多路複用是指內核一旦發現進程指定的一個或者多個IO條件準備讀取,它就通知該進程。IO多路複用適用以下場合:

  (1)當客戶處理多個描述字時(通常是交互式輸入和網絡套接口),必須使用I/O複用。

  (2)當一個客戶同時處理多個套接口時,而這種狀況是可能的,但不多出現。

  (3)若是一個TCP服務器既要處理監聽套接口,又要處理已鏈接套接口,通常也要用到I/O複用。

  (4)若是一個服務器即要處理TCP,又要處理UDP,通常要使用I/O複用。

  (5)若是一個服務器要處理多個服務或多個協議,通常要使用I/O複用。

  與多進程和多線程技術相比,I/O多路複用技術的最大優點是系統開銷小,系統沒必要建立進程/線程,也沒必要維護這些進程/線程,從而大大減少了系統的開銷。

select的調用過程以下所示:

 

(1)使用copy_from_user從用戶空間拷貝fd_set到內核空間

(2)註冊回調函數__pollwait

(3)遍歷全部fd,調用其對應的poll方法(對於socket,這個poll方法是sock_poll,sock_poll根據狀況會調用到tcp_poll,udp_poll或者datagram_poll)

(4)以tcp_poll爲例,其核心實現就是__pollwait,也就是上面註冊的回調函數。

(5)__pollwait的主要工做就是把current(當前進程)掛到設備的等待隊列中,不一樣的設備有不一樣的等待隊列,對於tcp_poll來講,其等待隊列是sk->sk_sleep(注意把進程掛到等待隊列中並不表明進程已經睡眠了)。在設備收到一條消息(網絡設備)或填寫完文件數據(磁盤設備)後,會喚醒設備等待隊列上睡眠的進程,這時current便被喚醒了。

(6)poll方法返回時會返回一個描述讀寫操做是否就緒的mask掩碼,根據這個mask掩碼給fd_set賦值。

(7)若是遍歷完全部的fd,尚未返回一個可讀寫的mask掩碼,則會調用schedule_timeout是調用select的進程(也就是current)進入睡眠。當設備驅動發生自身資源可讀寫後,會喚醒其等待隊列上睡眠的進程。若是超過必定的超時時間(schedule_timeout指定),仍是沒人喚醒,則調用select的進程會從新被喚醒得到CPU,進而從新遍歷fd,判斷有沒有就緒的fd。

(8)把fd_set從內核空間拷貝到用戶空間。

 

基本流程,如圖所示:

 

%u5728%u8FD9%u91CC%u8F93%u5165%u56FE%u7247%u6807%u9898
輸入圖片說明

 

select目前幾乎在全部的平臺上支持,其良好跨平臺支持也是它的一個優勢select的一個缺點在於單個進程可以監視的文件描述符的數量存在最大限制,在Linux上通常爲1024,能夠經過修改宏定義甚至從新編譯內核的方式提高這一限制,可是這樣也會形成效率的下降。

 

調用select的函數爲r, w, e = select.select(rlist, wlist, xlist[, timeout]),前三個參數都分別是三個列表,數組中的對象均爲waitable object:均是整數的文件描述符(file descriptor)或者一個擁有返回文件描述符方法fileno()的對象;

  • rlist: 等待讀就緒的list
  • wlist: 等待寫就緒的list
  • errlist: 等待「異常」的list

 

select方法用來監視文件描述符,若是文件描述符發生變化,則獲取該描述符。
一、這三個list能夠是一個空的list,可是接收3個空的list是依賴於系統的(在Linux上是能夠接受的,可是在window上是不能夠的)。
二、當 rlist  序列中的描述符發生可讀時(accetp和read),則獲取發生變化的描述符並添加到 r  序列中
三、當 wlist  序列中含有描述符時,則將該序列中全部的描述符添加到 w  序列中
四、當 errlist 序列中的句柄發生錯誤時,則將該發生錯誤的句柄添加到 e 序列中
五、當 超時時間 未設置,則select會一直阻塞,直到監聽的描述符發生變化
    當 超時時間 =  1 時,那麼若是監聽的句柄均無任何變化,則select會阻塞  1  秒,以後返回三個空列表,若是監聽的描述符(fd)有變化,則直接執行。

六、在list中能夠接受Ptython的的file對象(好比sys.stdin,或者會被open()os.open()返回的object),socket object將會返回socket.socket()。也能夠自定義類,只要有一個合適的fileno()的方法(須要真實返回一個文件描述符,而不是一個隨機的整數)。

select代碼註釋

import select
import socket
import sys
import queue

# 建立一個TCP/IP socket
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setblocking(False)
# 綁定socket到指定端口
server_address = ('localhost', 10000)
print(sys.stderr, 'starting up on %s port %s' % server_address)
server.bind(server_address)
# 監聽鏈接的地址
server.listen(5)
inputs = [server]
# Socket的讀操做
outputs = []
# socket的寫操做
message_queues = {}
while inputs:
    # Wait for at least one of the sockets to be ready for processing
    print( '\nwaiting for the next event')
    readable, writable, exceptional = select.select(inputs, outputs, inputs)
    # 監聽句柄序列,若是某個發生變化,select的第一個rLest會拿到數據,output只要有數據wLest就能獲取到,select的第三個參數inputs用來監測異常,並賦值給exceptional。
    # 監聽inputs,outputs,inputs  若是他們的值有變化,就將分別賦值給readable,writable,exceptional。
    for s in readable:
        # 遍歷readable的值。
        if s is server:
            connection, client_address = s.accept()
            # 若是s 是server,那麼server socket將接收鏈接。
            print('new connection from', client_address)
            # 打印出鏈接客戶端的地址。
            connection.setblocking(False)
            # 設置socket 爲非阻塞模式。
            inputs.append(connection)
            # 由於有讀操做發生,因此將此鏈接加入inputs
            message_queues[connection] = queue.Queue()
            # 爲每一個鏈接建立一個queue隊列。使得每一個鏈接接收到正確的數據。
        else:
            data = s.recv(1024)
            # 若是s不是server,說明客戶端鏈接來了,那麼就接受客戶端的數據。
            if data:
                # 若是接收到客戶端的數據
                print(sys.stderr, 'received "%s" from %s' % (data, s.getpeername()) )
                message_queues[s].put(data)
                # 將收到的數據放入隊列中
                if s not in outputs:
                    outputs.append(s)
                    # 將socket客戶端的鏈接加入select的output中,而且用來返回給客戶端數據。
            else:
                print('closing', client_address, 'after reading no data')
                # 若是沒有收到客戶端發來的空消息,則說明客戶端已經斷開鏈接。
                if s in outputs:
                    outputs.remove(s)
                    # 既然客戶端都斷開了,我就不用再給它返回數據了,因此這時候若是這個客戶端的鏈接對象還在outputs列表中,就把它刪掉
                inputs.remove(s)
                # inputs中也刪除掉
                s.close()
                # 把這個鏈接關閉掉
                del message_queues[s]
                # 刪除此客戶端的消息隊列

    for s in writable:
        # 遍歷output的數據
        try:
            next_msg = message_queues[s].get_nowait()
        except queue.Empty:
            # 獲取對應客戶端消息隊列中的數據,若是隊列中的數據爲空,從消息隊列中移除此客戶端鏈接。
            print('output queue for', s.getpeername(), 'is empty')
            outputs.remove(s)
        else:
            print( 'sending "%s" to %s' % (next_msg, s.getpeername()))
            s.send(next_msg)
            # 若是消息隊列有數據,則發送給客戶端。
    for s in exceptional:
        # 處理 "exceptional conditions"
        print('handling exceptional condition for', s.getpeername() )
        inputs.remove(s)
        # 取消對出現異常的客戶端的監聽
        if s in outputs:
            outputs.remove(s)
            # 移除客戶端的鏈接對象。
        s.close()
        # 關閉此socket鏈接
        del message_queues[s]
        # 刪除此消息隊列。

'''

在select/poll時代,服務器進程每次都把這100萬個鏈接告訴操做系統(從用戶態複製句柄數據結構到內核態),讓操做系統內核去查詢這些套接字上是否有事件發生,

輪詢完後,再將句柄數據複製到用戶態,讓服務器應用程序輪詢處理已發生的網絡事件,這一過程資源消耗較大,所以,select/poll通常只能處理幾千的併發鏈接。

epoll的設計和實現與select徹底不一樣。epoll經過在Linux內核中申請一個簡易的文件系統(文件系統通常用什麼數據結構實現?B+樹)。把原先的select/poll調用分紅了3個部分:

1)調用epoll_create()創建一個epoll對象(在epoll文件系統中爲這個句柄對象分配資源)

2)調用epoll_ctl向epoll對象中添加這100萬個鏈接的套接字

3)調用epoll_wait收集發生的事件的鏈接

'''

 

select本質上是經過設置或者檢查存放fd標誌位的數據結構來進行下一步處理。這樣所帶來的缺點是:

 

  1. select最大的缺陷就是單個進程所打開的FD是有必定限制的,它由FD_SETSIZE設置,默認值是1024。

    通常來講這個數目和系統內存關係很大,具體數目能夠cat /proc/sys/fs/file-max察看。32位機默認是1024個。64位機默認是2048.

  2. 對socket進行掃描時是線性掃描,即採用輪詢的方法,效率較低。

    當套接字比較多的時候,每次select()都要經過遍歷FD_SETSIZE個Socket來完成調度,無論哪一個Socket是活躍的,都遍歷一遍。這會浪費不少CPU時間。若是能給套接字註冊某個回調函數,當他們活躍時,自動完成相關操做,那就避免了輪詢,這正是epoll與kqueue作的。

  3. 須要維護一個用來存放大量fd的數據結構,這樣會使得用戶空間和內核空間在傳遞該結構時複製開銷大。

select的幾大缺點:

(1)每次調用select,都須要把fd集合從用戶態拷貝到內核態,這個開銷在fd不少時會很大

(2)同時每次調用select都須要在內核遍歷傳遞進來的全部fd,這個開銷在fd不少時也很大

(3)select支持的文件描述符數量過小了,默認是1024

 

poll

 

基本原理:

 

poll本質上和select沒有區別,它將用戶傳入的數組拷貝到內核空間,而後查詢每一個fd對應的設備狀態,若是設備就緒則在設備等待隊列中加入一項並繼續遍歷,若是遍歷完全部fd後沒有發現就緒設備,則掛起當前進程,直到設備就緒或者主動超時,被喚醒後它又要再次遍歷fd。這個過程經歷了屢次無謂的遍歷。

 

它沒有最大鏈接數的限制,緣由是它是基於鏈表來存儲的,可是一樣有一個缺點:

 

  1. 大量的fd的數組被總體複製於用戶態和內核地址空間之間,而無論這樣的複製是否是有意義。

  2. poll還有一個特色是「水平觸發」,若是報告了fd後,沒有被處理,那麼下次poll時會再次報告該fd。

 

注意:

 

從上面看,select和poll都須要在返回後,經過遍歷文件描述符來獲取已經就緒的socket。事實上,同時鏈接的大量客戶端在一時刻可能只有不多的處於就緒狀態,所以隨着監視的描述符數量的增加,其效率也會線性降低。

 

在Python中調用poll

  • select.poll(),返回一個poll的對象,支持註冊和註銷文件描述符。

  • poll.register(fd[, eventmask])註冊一個文件描述符,註冊後,能夠經過poll()方法來檢查是否有對應的I/O事件發生。fd能夠是i 個整數,或者有返回整數的fileno()方法對象。若是File對象實現了fileno(),也能夠看成參數使用。

  • eventmask是一個你想去檢查的事件類型,它能夠是常量POLLIN, POLLPRIPOLLOUT的組合。若是缺省,默認會去檢查全部的3種事件類型。

事件常量 意義
POLLIN 有數據讀取
POLLPRT 有數據緊急讀取
POLLOUT 準備輸出:輸出不會阻塞
POLLERR 某些錯誤狀況出現
POLLHUP 掛起
POLLNVAL 無效請求:描述沒法打開
  • poll.modify(fd, eventmask) 修改一個已經存在的fd,和poll.register(fd, eventmask)有相同的做用。若是去嘗試修改一個未經註冊的fd,會引發一個errnoENOENTIOError
  • poll.unregister(fd)從poll對象中註銷一個fd。嘗試去註銷一個未經註冊的fd,會引發KeyError
  • poll.poll([timeout])去檢測已經註冊了的文件描述符。會返回一個可能爲空的list,list中包含着(fd, event)這樣的二元組。 fd是文件描述符, event是文件描述符對應的事件。若是返回的是一個空的list,則說明超時了且沒有文件描述符有事件發生。timeout的單位是milliseconds,若是設置了timeout,系統將會等待對應的時間。若是timeout缺省或者是None,這個方法將會阻塞直到對應的poll對象有一個事件發生。

poll代碼

#!/usr/bin/env python
#-*- coding:utf-8 -*- 

import socket
import select
import queue

server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setblocking(False)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server_address = ("192.168.1.5", 8080)
server.bind(server_address)
server.listen(5)
print("服務器啓動成功,監聽IP:", server_address)

message_queues = {}
# 超時,毫秒
timeout = 5000
# 監聽哪些事件
READ_ONLY = (select.POLLIN | select.POLLPRI | select.POLLHUP | select.POLLERR)
READ_WRITE = (READ_ONLY | select.POLLOUT)
# 新建輪詢事件對象
poller = select.poll()
# 註冊本機監聽socket到等待可讀事件事件集合
poller.register(server, READ_ONLY)
# 文件描述符到socket映射
fd_to_socket = {server.fileno(): server,}
while True:
    print("等待活動鏈接......")
    # 輪詢註冊的事件集合
    events = poller.poll(timeout)
    if not events:
        print("poll超時,無活動鏈接,從新poll......")
        
        continue
    print("有", len(events), "個新事件,開始處理......")
    
    for fd, flag in events:
        s = fd_to_socket[fd]
        # 可讀事件
        if flag & (select.POLLIN | select.POLLPRI):
            if s is server:
                # 若是socket是監聽的server表明有新鏈接
                connection, client_address = s.accept()
                print("新鏈接:", client_address)
                connection.setblocking(False)

                fd_to_socket[connection.fileno()] = connection
                # 加入到等待讀事件集合
                poller.register(connection, READ_ONLY)
                message_queues[connection] = Queue.Queue()
            else:
                # 接收客戶端發送的數據
                data = s.recv(1024)
                if data:
                    print("收到數據:", data, "客戶端:", s.getpeername()) 
                    message_queues[s].put(data)
                    # 修改讀取到消息的鏈接到等待寫事件集合
                    poller.modify(s, READ_WRITE)
                else:
                    # Close the connection
                    print("closing", s.getpeername())
                    
                    # Stop listening for input on the connection
                    poller.unregister(s)
                    s.close()
                    del message_queues[s]
        # 鏈接關閉事件
        elif flag & select.POLLHUP:
            print(" Closing ", s.getpeername(), "(HUP)")
            
            poller.unregister(s)
            s.close()
        # 可寫事件
        elif flag & select.POLLOUT:
            try:
                msg = message_queues[s].get_nowait()
            except Queue.Empty:
                print(s.getpeername(), " queue empty")
                
                poller.modify(s, READ_ONLY)
            else:
                print("發送數據:", data, "客戶端:", s.getpeername())
                
                s.send(msg)
        # 異常事件
        elif flag & select.POLLERR:
            print("  exception on", s.getpeername())
            
            poller.unregister(s)
            s.close()
            del message_queues[s]

 

1.3 epoll

epoll的原理及改進

在linux2.6(準確來講是2.5.44)由內核直接支持的方法。epoll解決了select和poll的缺點。

  • 對於第一個缺點,epoll的解決方法是每次註冊新的事件到epoll中,會把全部的fd拷貝進內核,而不是在等待的時候重複拷貝,保證了每一個fd在整個過程當中只會拷貝1次。
  • 對於第二個缺點,epoll沒有這個限制,它所支持的fd上限是最大能夠打開文件的數目,具體數目能夠cat /proc/sys/fs/file-max查看,通常來講這個數目和系統內存關係比較大。
  • 對於第三個缺點,epoll的解決方法不像select和poll每次對全部fd進行遍歷輪詢全部fd集合,而是在註冊新的事件時,爲每一個fd指定一個回調函數,當設備就緒的時候,調用這個回調函數,這個回調函數就會把就緒的fd加入一個就緒表中。(因此epoll實際只須要遍歷就緒表)。

epoll同時支持水平觸發和邊緣觸發:

  • 水平觸發(level-triggered):只要知足條件,就觸發一個事件(只要有數據沒有被獲取,內核就不斷通知你)。e.g:在水平觸發模式下,重複調用epoll.poll()會重複通知關注的event,直到與該event有關的全部數據都已被處理。(select, poll是水平觸發, epoll默認水平觸發)
  • 邊緣觸發(edge-triggered):每當狀態變化時,觸發一個事件。e.g:在邊沿觸發模式中,epoll.poll()在讀或者寫event在socket上面發生後,將只會返回一次event。調用epoll.poll()的程序必須處理全部和這個event相關的數據,隨後的epoll.poll()調用不會再有這個event的通知。

在Python中調用epoll

  • select.epoll([sizehint=-1])返回一個epoll對象。

  • eventmask

事件常量 意義
EPOLLIN 讀就緒
EPOLLOUT 寫就緒
EPOLLPRI 有數據緊急讀取
EPOLLERR assoc. fd有錯誤狀況發生
EPOLLHUP assoc. fd發生掛起
EPOLLRT 設置邊緣觸發(ET)(默認的是水平觸發)
EPOLLONESHOT 設置爲 one-short 行爲,一個事件(event)被拉出後,對應的fd在內部被禁用
EPOLLRDNORM 和 EPOLLIN 相等
EPOLLRDBAND 優先讀取的數據帶(data band)
EPOLLWRNORM 和 EPOLLOUT 相等
EPOLLWRBAND 優先寫的數據帶(data band)
EPOLLMSG 忽視
  • epoll.close()關閉epoll對象的文件描述符。
  • epoll.fileno返回control fd的文件描述符number。
  • epoll.fromfd(fd)用給予的fd來建立一個epoll對象。
  • epoll.register(fd[, eventmask])在epoll對象中註冊一個文件描述符。(若是文件描述符已經存在,將會引發一個IOError
  • epoll.modify(fd, eventmask)修改一個已經註冊的文件描述符。
  • epoll.unregister(fd)註銷一個文件描述符。
  • epoll.poll(timeout=-1[, maxevnets=-1])等待事件,timeout(float)的單位是秒(second)。

基本原理:

 

epoll支持水平觸發和邊緣觸發,最大的特色在於邊緣觸發,它只告訴進程哪些fd剛剛變爲就緒態,而且只會通知一次。還有一個特色是,epoll使用「事件」的就緒通知方式,經過epoll_ctl註冊fd,一旦該fd就緒,內核就會採用相似callback的回調機制來激活該fd,epoll_wait即可以收到通知。

 

epoll代碼註釋

import socket, select
# 'windows'下不支持'epoll'

EOL1 = b'\n\n'
EOL2 = b'\n\r\n'
response = b'HTTP/1.0 200 OK\r\nDate: Mon, 1 Jan 1996 01:01:01 GMT\r\n'
response += b'Content-Type: text/plain\r\nContent-Length: 13\r\n\r\n'
response += b'Hello, world!'

serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serversocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
serversocket.bind(('0.0.0.0', 8080))
serversocket.listen(1)
# 創建socket鏈接。
serversocket.setblocking(0)
# 由於socket自己是阻塞的,setblocking(0)使得socket不阻塞

epoll = select.epoll()
# 建立一個eopll對象
epoll.register(serversocket.fileno(), select.EPOLLIN)
# 在服務器端socket上面註冊對讀event的關注,一個讀event隨時會觸發服務器端socket去接收一個socket鏈接。

try:
   connections = {}; requests = {}; responses = {}
# 生成3個字典,connection字典是存儲文件描述符映射到他們相應的網絡鏈接對象
   while True:
      events = epoll.poll(1)
# 查詢epoll對象,看是否有任何關注的event被觸發,參數‘1’表示,會等待一秒來看是否有event發生,若是有任何感興趣的event發生在此次查詢以前,這個查詢就會帶着這些event的列表當即返回
      for fileno, event in events:
        # event做爲一個序列(fileno,event code)的元組返回,fileno是文件描述符的代名詞,始終是一個整數。
         if fileno == serversocket.fileno():
            # 若是一個讀event在服務器端socket發生,就會有一個新的socket鏈接可能被建立。
            connection, address = serversocket.accept()
            # 服務器端開始接收鏈接和客戶端地址
            connection.setblocking(0)
            # 設置新的socket爲非阻塞模式
            epoll.register(connection.fileno(), select.EPOLLIN)
            # 爲新的socket註冊對讀(EPOLLIN)event的關注
            connections[connection.fileno()] = connection
            requests[connection.fileno()] = b''
            responses[connection.fileno()] = response
         elif event & select.EPOLLIN:
            requests[fileno] += connections[fileno].recv(1024)
            # 若是發生一個讀event,就讀取從客戶端發過來的數據。
            if EOL1 in requests[fileno] or EOL2 in requests[fileno]:
               epoll.modify(fileno, select.EPOLLOUT)
            # 一旦完成請求已經收到,就註銷對讀event的關注,註冊對寫(EPOLLOUT)event的關注,寫event發生的時候,會回覆數據給客戶端。
               print('-'*40 + '\n' + requests[fileno].decode()[:-2])
            # 打印完整的請求,證實雖然與客戶端的通訊是交錯進行的,可是數據能夠做爲一個總體來組裝和處理。
         elif event & select.EPOLLOUT:
            # 若是一個寫event在一個客戶端socket上面發生,他會接受新的數據以便發送到客戶端。
            byteswritten = connections[fileno].send(responses[fileno])
            responses[fileno] = responses[fileno][byteswritten:]
            if len(responses[fileno]) == 0:
                # 每次發送一部分響應數據,直到完整的響應數據都已經發送給操做系統等待傳輸給客戶端。
               epoll.modify(fileno, 0)
            # 一旦完整的響應數據發送完成,就再也不關注讀或者寫event。
               connections[fileno].shutdown(socket.SHUT_RDWR)
            # 若是一個鏈接顯式關閉,那麼socket shutdown是可選的,在這裏這樣使用,是爲了讓客戶端首先關閉。
            # shutdown調用會通知客戶端socket沒有更多的數據應該被髮送或者接收,並會讓功能正常的客戶端關閉本身的socket鏈接。
         elif event & select.EPOLLHUP:
            # HUP掛起event代表客戶端socket已經斷開(即關閉),因此服務器端也須要關閉,沒有必要註冊對HUP event的關注,在socket上面,他們老是會被epoll對象註冊。
            epoll.unregister(fileno)
            # 註銷對此socket鏈接的關注。
            connections[fileno].close()
            # 關閉socket鏈接。
            del connections[fileno]
finally:
   epoll.unregister(serversocket.fileno())
# 去掉已經註冊的文件句柄
   epoll.close()
# 關閉epoll對象
   serversocket.close()
# 關閉服務器鏈接
# 打開的socket鏈接不須要關閉,由於Python會在程序結束時關閉, 這裏的顯示關閉是個好的習慣。



'''

首先咱們來定義流的概念,一個流能夠是文件,socket,pipe等等能夠進行I/O操做的內核對象。

    無論是文件,仍是套接字,仍是管道,咱們均可以把他們看做流。

    以後咱們來討論I/O的操做,經過read,咱們能夠從流中讀入數據;經過write,咱們能夠往流寫入數據。如今假定一個情形,
    咱們須要從流中讀數據,可是流中尚未數據,(典型的例子爲,客戶端要從socket讀如數據,可是服務器尚未把數據傳回來),
    這時候該怎麼辦?

阻塞:阻塞是個什麼概念呢?好比某個時候你在等快遞,可是你不知道快遞何時過來,並且你沒有別的事能夠幹(或者說接下來的事要等快遞來了才能作);
那麼你能夠去睡覺了,由於你知道快遞把貨送來時必定會給你打個電話(假定必定能叫醒你)。

非阻塞忙輪詢:接着上面等快遞的例子,若是用忙輪詢的方法,那麼你須要知道快遞員的手機號,而後每分鐘給他掛個電話:「你到了沒?」

    很明顯通常人不會用第二種作法,不只顯很無腦,浪費話費不說,還佔用了快遞員大量的時間。

    大部分程序也不會用第二種作法,由於第一種方法經濟而簡單,經濟是指消耗不多的CPU時間,若是線程睡眠了,就掉出了系統的調度隊列,暫時不會去瓜分CPU寶貴的時間片了。

    爲了瞭解阻塞是如何進行的,咱們來討論緩衝區,以及內核緩衝區,最終把I/O事件解釋清楚。緩衝區的引入是爲了減小頻繁I/O操做而引發頻繁的系統調用(你知道它很慢的),
    當你操做一個流時,更多的是以緩衝區爲單位進行操做,這是相對於用戶空間而言。對於內核來講,也須要緩衝區。

假設有一個管道,進程A爲管道的寫入方,B爲管道的讀出方。

假設一開始內核緩衝區是空的,B做爲讀出方,被阻塞着。而後首先A往管道寫入,這時候內核緩衝區由空的狀態變到非空狀態,內核就會產生一個事件告訴B該醒來了,
這個事件姑且稱之爲「緩衝區非空」。

    可是「緩衝區非空」事件通知B後,B卻尚未讀出數據;且內核許諾了不能把寫入管道中的數據丟掉這個時候,A寫入的數據會滯留在內核緩衝區中,若是內核也緩衝區滿了,
    B仍未開始讀數據,最終內核緩衝區會被填滿,這個時候會產生一個I/O事件,告訴進程A,你該等等(阻塞)了,咱們把這個事件定義爲「緩衝區滿」。

假設後來B終於開始讀數據了,因而內核的緩衝區空了出來,這時候內核會告訴A,內核緩衝區有空位了,你能夠從長眠中醒來了,繼續寫數據了,咱們把這個事件叫作「緩衝區非滿」

    也許事件Y1已經通知了A,可是A也沒有數據寫入了,而B繼續讀出數據,知道內核緩衝區空了。這個時候內核就告訴B,你須要阻塞了!,咱們把這個時間定爲「緩衝區空」。

這四個情形涵蓋了四個I/O事件,緩衝區滿,緩衝區空,緩衝區非空,緩衝區非滿(注都是說的內核緩衝區,且這四個術語都是我生造的,僅爲解釋其原理而造)。
這四個I/O事件是進行阻塞同步的根本。(若是不能理解「同步」是什麼概念,請學習操做系統的鎖,信號量,條件變量等任務同步方面的相關知識)。

    而後咱們來講說阻塞I/O的缺點。可是阻塞I/O模式下,一個線程只能處理一個流的I/O事件。若是想要同時處理多個流,要麼多進程(fork),要麼多線程(pthread_create),
    很不幸這兩種方法效率都不高。

    因而再來考慮非阻塞忙輪詢的I/O方式,咱們發現咱們能夠同時處理多個流了(把一個流從阻塞模式切換到非阻塞模式再此不予討論):

while true {
    for i in stream[]; {
        if i has data
            read until unavailable
    }
}

    咱們只要不停的把全部流從頭至尾問一遍,又從頭開始。這樣就能夠處理多個流了,但這樣的作法顯然很差,由於若是全部的流都沒有數據,那麼只會白白浪費CPU。
    這裏要補充一點,阻塞模式下,內核對於I/O事件的處理是阻塞或者喚醒,而非阻塞模式下則把I/O事件交給其餘對象(後文介紹的select以及epoll)處理甚至直接忽略。

    爲了不CPU空轉,能夠引進了一個代理(一開始有一位叫作select的代理,後來又有一位叫作poll的代理,不過二者的本質是同樣的)。這個代理比較厲害,
    能夠同時觀察許多流的I/O事件,在空閒的時候,會把當前線程阻塞掉,當有一個或多個流有I/O事件時,就從阻塞態中醒來,因而咱們的程序就會輪詢一遍全部的流
    (因而咱們能夠把「忙」字去掉了)。代碼長這樣:

while true {
    select(streams[])
    for i in streams[] {
        if i has data
            read until unavailable
    }
}

    因而,若是沒有I/O事件產生,咱們的程序就會阻塞在select處。可是依然有個問題,咱們從select那裏僅僅知道了,有I/O事件發生了,但卻並不知道是那幾個流
    (可能有一個,多個,甚至所有),咱們只能無差異輪詢全部流,找出能讀出數據,或者寫入數據的流,對他們進行操做。

    可是使用select,咱們有O(n)的無差異輪詢複雜度,同時處理的流越多,沒一次無差異輪詢時間就越長。再次

說了這麼多,終於能好好解釋epoll了

    epoll能夠理解爲event poll,不一樣於忙輪詢和無差異輪詢,epoll之會把哪一個流發生了怎樣的I/O事件通知咱們。此時咱們對這些流的操做都是有意義的。
    (複雜度下降到了O(1))

    在討論epoll的實現細節以前,先把epoll的相關操做列出:

      epoll_create 建立一個epoll對象,通常epollfd = epoll_create()

      epoll_ctl (epoll_add/epoll_del的合體),往epoll對象中增長/刪除某一個流的某一個事件

好比

epoll_ctl(epollfd, EPOLL_CTL_ADD, socket, EPOLLIN);//註冊緩衝區非空事件,即有數據流入

epoll_ctl(epollfd, EPOLL_CTL_DEL, socket, EPOLLOUT);//註冊緩衝區非滿事件,即流能夠被寫入

epoll_wait(epollfd,...)等待直到註冊的事件發生

(注:當對一個非阻塞流的讀寫發生緩衝區滿或緩衝區空,write/read會返回-1,並設置errno=EAGAIN。而epoll只關心緩衝區非滿和緩衝區非空事件)。

一個epoll模式的代碼大概的樣子是:
while true {
    active_stream[] = epoll_wait(epollfd)
    for i in active_stream[] {
        read or write till
    }
}

'''

 

 

epoll的優勢:

 

  1. 沒有最大併發鏈接的限制,能打開的FD的上限遠大於1024(1G的內存上能監聽約10萬個端口)。

  2. 效率提高,不是輪詢的方式,不會隨着FD數目的增長效率降低。只有活躍可用的FD纔會調用callback函數;即Epoll最大的優勢就在於它只管你「活躍」的鏈接,而跟鏈接總數無關,所以在實際的網絡環境中,Epoll的效率就會遠遠高於select和poll。

  3. 內存拷貝,利用mmap()文件映射內存加速與內核空間的消息傳遞;即epoll使用mmap減小複製開銷

 

epoll對文件描述符的操做有兩種模式:LT(level trigger)和ET(edge trigger)。LT模式是默認模式,LT模式與ET模式的區別以下:

 

LT模式:當epoll_wait檢測到描述符事件發生並將此事件通知應用程序,應用程序能夠不當即處理該事件。下次調用epoll_wait時,會再次響應應用程序並通知此事件。

ET模式:當epoll_wait檢測到描述符事件發生並將此事件通知應用程序,應用程序必須當即處理該事件。若是不處理,下次調用epoll_wait時,不會再次響應應用程序並通知此事件。

 

  1. LT模式

    LT(level triggered)是缺省的工做方式,而且同時支持block和no-block socket。在這種作法中,內核告訴你一個文件描述符是否就緒了,而後你能夠對這個就緒的fd進行IO操做。若是你不做任何操做,內核仍是會繼續通知你的

  2. ET模式

    ET(edge-triggered)是高速工做方式,只支持no-block socket。在這種模式下,當描述符從未就緒變爲就緒時,內核經過epoll告訴你。而後它會假設你知道文件描述符已經就緒,而且不會再爲那個文件描述符發送更多的就緒通知,直到你作了某些操做致使那個文件描述符再也不爲就緒狀態了(好比,你在發送,接收或者接收請求,或者發送接收的數據少於必定量時致使了一個EWOULDBLOCK 錯誤)。可是請注意,若是一直不對這個fd做IO操做(從而致使它再次變成未就緒),內核不會發送更多的通知(only once)

    ET模式在很大程度上減小了epoll事件被重複觸發的次數,所以效率要比LT模式高。epoll工做在ET模式的時候,必須使用非阻塞套接口,以免因爲一個文件句柄的阻塞讀/阻塞寫操做把處理多個文件描述符的任務餓死。

  3. 在select/poll中,進程只有在調用必定的方法後,內核纔對全部監視的文件描述符進行掃描,而epoll事先經過epoll_ctl()來註冊一個文件描述符,一旦基於某個文件描述符就緒時,內核會採用相似callback的回調機制,迅速激活這個文件描述符,當進程調用epoll_wait()時便獲得通知。(此處去掉了遍歷文件描述符,而是經過監聽回調的的機制。這正是epoll的魅力所在。)

 

注意:

 

若是沒有大量的idle-connection或者dead-connection,epoll的效率並不會比select/poll高不少,可是當遇到大量的idle-connection,就會發現epoll的效率大大高於select/poll。

 

2 select、poll、epoll區別

 

  1. 支持一個進程所能打開的最大鏈接數

    %u5728%u8FD9%u91CC%u8F93%u5165%u56FE%u7247%u6807%u9898
    輸入圖片說明
  2. FD劇增後帶來的IO效率問題

    %u5728%u8FD9%u91CC%u8F93%u5165%u56FE%u7247%u6807%u9898
    輸入圖片說明
  3. 消息傳遞方式

    %u5728%u8FD9%u91CC%u8F93%u5165%u56FE%u7247%u6807%u9898
    輸入圖片說明

 

 相同點和不一樣點圖:

 

綜上,在選擇select,poll,epoll時要根據具體的使用場合以及這三種方式的自身特色:

 

  1. 表面上看epoll的性能最好,可是在鏈接數少而且鏈接都十分活躍的狀況下,select和poll的性能可能比epoll好,畢竟epoll的通知機制須要不少函數回調。

  2. select低效是由於每次它都須要輪詢。但低效也是相對的,視狀況而定,也可經過良好的設計改善。

select,poll,epoll都是IO多路複用的機制。I/O多路複用就經過一種機制,能夠監視多個描述符,一旦某個描述符就緒(通常是讀就緒或者寫就緒),可以通知程序進行相應的讀寫操做。但select,poll,epoll本質上都是同步I/O,由於他們都須要在讀寫事件就緒後本身負責進行讀寫,也就是說這個讀寫過程是阻塞的,而異步I/O則無需本身負責進行讀寫,異步I/O的實現會負責把數據從內核拷貝到用戶空間。關於這三種IO多路複用的用法,前面三篇總結寫的很清楚,並用服務器回射echo程序進行了測試。

總結:

 

綜上,在選擇select,poll,epoll時要根據具體的使用場合以及這三種方式的自身特色。

 

一、表面上看epoll的性能最好,可是在鏈接數少而且鏈接都十分活躍的狀況下,select和poll的性能可能比epoll好,畢竟epoll的通知機制須要不少函數回調。

 

二、select低效是由於每次它都須要輪詢。但低效也是相對的,視狀況而定,也可經過良好的設計改善

 

Linux併發網絡編程模型

 

    1  Apache 模型,簡稱 PPC ( Process Per Connection ,):爲每一個鏈接分配一個進程。主機分配給每一個鏈接的時間和空間上代價較大,而且隨着鏈接的增多,大量進程間切換開銷也增加了。很難應對大量的客戶併發鏈接。

    2  TPC 模型( Thread Per Connection ):每一個鏈接一個線程。和PCC相似。

    3  select 模型:I/O多路複用技術。

       .1 每一個鏈接對應一個描述。select模型受限於 FD_SETSIZE即進程最大打開的描述符數linux2.6.35爲1024,實際上linux每一個進程所能打開描數字的個數僅受限於內存大小,然而在設計select的系統調用時,倒是參考FD_SETSIZE的值。可經過從新編譯內核更改此值,但不能根治此問題,對於百萬級的用戶鏈接請求  即使增長相應 進程數, 仍顯得杯水車薪呀。

      .2select每次都會掃描一個文件描述符的集合,這個集合的大小是做爲select第一個參數傳入的值。可是每一個進程所能打開文件描述符如果增長了 ,掃描的效率也將減少。

      .3內核到用戶空間,採用內存複製傳遞文件描述上發生的信息。 

  4 poll 模型:I/O多路複用技術。poll模型將不會受限於FD_SETSIZE,由於內核所掃描的文件 描述符集合的大小是由用戶指定的,即poll的第二個參數。但仍有掃描效率和內存拷貝問題。

 5 pselect模型:I/O多路複用技術。同select。

 6 epoll模型:

    .1)無文件描述字大小限制僅與內存大小相關

   .2)epoll返回時已經明確的知道哪一個socket fd發生了什麼事件,不用像select那樣再一個個比對。

  .3)內核到用戶空間採用共享內存方式,傳遞消息。

FAQ  

一、單個epoll並不能解決全部問題,特別是你的每一個操做都比較費時的時候,由於epoll是串行處理的。 因此你有仍是必要創建線程池來發揮更大的效能。 

二、若是fd被註冊到兩個epoll中時,若是有時間發生則兩個epoll都會觸發事件。

三、若是註冊到epoll中的fd被關閉,則其會自動被清除出epoll監聽列表。四、若是多個事件同時觸發epoll,則多個事件會被聯合在一塊兒返回。五、epoll_wait會一直監聽epollhup事件發生,因此其不須要添加到events中。六、爲了不大數據量io時,et模式下只處理一個fd,其餘fd被餓死的狀況發生。linux建議能夠在fd聯繫到的結構中增長ready位,而後epoll_wait觸發事件以後僅將其置位爲ready模式,而後在下邊輪詢ready fd列表。

相關文章
相關標籤/搜索