高性能Web Server思考 原 薦

0xFF

Web服務可以讓你在HTTP協議的基礎上通過XML或者JSON來交換信息。 醍醐灌頂!!!

你可以編寫一段簡短的代碼,通過抓取這些信息然後通過標準的接口開放出來,就如同你調用一個本地函數並返回一個值。(rpc? rest?)

平臺無關性.

目前主流的有如下幾種Web服務:REST、SOAP。

作爲客戶端 向遠端某臺機器的的某個網絡端口發送一個請求 作爲服務端 把服務綁定到某個指定端口,並且在此端口上監聽

Socket編程

現在的網絡編程幾乎都是用Socket來編程

而Unix基本哲學之一就是“一切皆文件”,都可以用“打開open –> 讀寫write/read –> 關閉close”模式來操作。Socket也是一種文件描述符.

常用的Socket類型有兩種:流式Socket(SOCK_STREAM)和數據報式Socket(SOCK_DGRAM)。流式是一種面向連接的Socket,針對於面向連接的TCP服務應用;數據報式Socket是一種無連接的Socket,對應於無連接的UDP服務應用。

Socket如何通信

網絡中的進程之間通過Socket通信.利用三元組(ip地址,協議,端口)就可以標識網絡中的唯一進程

TCP Socket

TCPConn可以用在客戶端和服務器端來讀寫數據

type TCPAddr struct {
	IP IP
	Port int
}

// TCPConn is an implementation of the Conn interface for TCP network connections.
type TCPConn struct {
	conn
}

type conn struct {
	fd *netFD
}

func (c *TCPConn) Write(b []byte) (n int, err os.Error)
	
func (c *TCPConn) Read(b []byte) (n int, err os.Error)

TCP Client

通過net包中的DialTCP()函數建立一個TCP連接,並且返回一個TCPConn.當連接建立的時候,服務器端也會創建一個同類型的對象.

Client:             Server:
   TCPConn <---> TCPConn

客戶端通過TCPConn對象將請求信息發送到服務器端,讀取服務器端響應的信息。

服務器端讀取並解析來自客戶端的請求,並返回應答信息

這個連接只有當任一端關閉了連接之後才失效。建立連接的函數定義如下:

func DialTCP(net string, laddr, raddr *TCPAddr) (c *TCPConn, err os.Error)

eg:

func main() {
  if len(os.Args) != 2 {
    fmt.Fprintf(os.Stderr, "Usage: %s host:port ", os.Args[0])
    os.Exit(1)
  }
  service := os.Args[1]
  tcpAddr, _ := net.ResolveTCPAddr("tcp4", service)
  conn, _ := net.DialTCP("tcp", nil, tcpAddr)
  //話說可以直接走HTTP的包嗎?不應該包裝成TCP的包?還是系統幫你包裝了
  _, _ = conn.Write([]byte("HEAD / HTTP/1.0\r\n\r\n"))
  result, _ := ioutil.ReadAll(conn)
  fmt.Println(string(result))
  os.Exit(0)
}

建立了TCP連接之後,就可以給對方發送HTTP的報文了?(應該是golang做了處理,把HTTP的報文包成了TCP的報文)

TCP Server

  1. 綁定服務到指定端口 func ListenTCP(net string, laddr *TCPAddr) (l *TCPListener, err os.Error)
  2. 監聽端口 func (l *TCPListener) Accept() (c Conn, err os.Error)
service := ":7777"
tcpAddr, _ := net.ResolveTCPAddr("tcp4", service)
listener, _ := net.ListenTCP("tcp", tcpAddr)
for {
  conn, err := listener.Accept()
  if err != nil {
      continue
  }
      
  daytime := time.Now().String()
  conn.Write([]byte(daytime)) // don't care about return value
  conn.Close()                // we're finished with this client
}

注意在for循環中,當有錯誤發生的時候,直接continue而不是退出.在服務器端跑代碼的時候,當有錯誤發生的時候,最好是記錄錯誤,然後客戶端報錯退出,不要影響服務器運行的整個服務.

併發版本:

func main() {
  service := ":1200"
  tcpAddr, _ := net.ResolveTCPAddr("tcp4", service)
  listener, _ := net.ListenTCP("tcp", tcpAddr)
  for {
    conn, err := listener.Accept()
    if err != nil {
      continue
    }
    go handleClient(conn)
  }
}

func handleClient(conn net.Conn) {
  defer conn.Close()
  daytime := time.Now().String()
  conn.Write([]byte(daytime)) // don't care about return value
  // we're finished with this client
}

讀取客戶端請求,並保持長連接版本:

func handleClient(conn net.Conn) {
    conn.SetReadDeadline(time.Now().Add(2 * time.Minute)) // set 2 minutes timeout
    request := make([]byte, 128) // set maxium request length to 128B to prevent flood attack
    defer conn.Close()
    for {
        read_len, err := conn.Read(request)
        if err != nil {
            fmt.Println(err)
            break
        }
    
        if read_len == 0 {
            break // connection already closed by client
        } else if strings.TrimSpace(string(request[:read_len])) == "timestamp" {
            daytime := strconv.FormatInt(time.Now().Unix(), 10)
            conn.Write([]byte(daytime))
        } else {
            daytime := time.Now().String()
            conn.Write([]byte(daytime))
        }

        request = make([]byte, 128) // clear last read content
    }
    
}

request在創建時需要指定一個最大長度以防止flood attack,每次讀取到請求處理完畢後,需要清理request,因爲conn.Read()會將新讀取到的內容append到原內容之後。

常用函數

設置建立連接的超時時間,客戶端和服務器端都適用,當超過設置時間時,連接自動關閉。 func DialTimeout(net, addr string, timeout time.Duration) (Conn, error)

用來設置寫入/讀取一個連接的超時時間。當超過設置時間時,連接自動關閉。 func (c *TCPConn) SetReadDeadline(t time.Time) error func (c *TCPConn) SetWriteDeadline(t time.Time) error

設置keepAlive屬性,是操作系統層在tcp上沒有數據和ACK的時候,會間隔性的發送keepalive包,操作系統可以通過該包來判斷一個tcp連接是否已經斷開,在windows上默認2個小時沒有收到數據和keepalive包的時候人爲tcp連接已經斷開,這個功能和我們通常在應用層加的心跳包的功能類似 func (c *TCPConn) SetKeepAlive(keepalive bool) os.Error

UDP Socket

UDP缺少了對客戶端連接請求的Accept函數。其他和TCP的用法基本幾乎一模一樣

Web服務器工作模型

  1. 多進程方式:爲每個請求啓動一個進程來處理。單個進程問題不會影響其他進程,因此穩定性最好。性能最差
  2. 一個進程中用多個線程處理用戶請求。線程開銷明顯小於進程,而且部分資源還可以共享,但是線程切換過快可能造成線程抖動,且線程過多會造成服務器不穩定。
  3. 異步方式:使用非阻塞方式處理請求。一個進程或線程處理多個請求,不需要額外開銷,性能最好,資源佔用最低。但是可能有一個請求佔用過多資源,其他請求得不到響應.一個進程或線程處理多個請求,不需要額外開銷,性能最好,資源佔用最低。

一個Web請求的處理過程

簡單來說就是:用戶請求-->送達到用戶空間-->系統調用-->內核空間-->內核到磁盤上讀取網頁資源->返回到用戶空間->響應給用戶。這裏面有兩個I/O過程,一個就是客戶端請求的網絡I/O,另一個就是Web服務器請求頁面的磁盤I/O。

  1. 客戶發起請求;
  2. 服務器網卡接受到請求後轉交給內核處理;
  3. 內核根據請求對應的套接字,將請求交給工作在用戶空間的Web服務器進程
  4. Web服務器進程根據用戶請求,向內核進行系統調用,申請獲取相應資源(如index.html)
  5. 內核發現web服務器進程請求的是一個存放在硬盤上的資源,因此通過驅動程序連接磁盤 內核調度磁盤,獲取需要的資源
  6. 內核將資源存放在自己的緩衝區中,並通知Web服務器進程
  7. Web服務器進程通過系統調用取得資源,並將其複製到進程自己的緩衝區中
  8. Web服務器進程形成響應,通過系統調用再次發給內核以響應用戶請求
  9. 內核將響應發送至網卡
  10. 網卡發送響應給用戶

用戶空間的web服務器進程是無法直接操作IO的,需要通過系統調用進行.

----------|___>wait___>|-----------|___>copy___>|----------|
I/O Device|            |內核buffer |            |用戶態進程|
----------|<___________|-----------|<___________|----------|
  1. 進程向內核進行系統調用申請IO
  2. 內核將資源從IO/DEVICE調度到內核的buffer中(wait階段)
  3. 內核還需將數據從內核buffer中複製(copy階段)到web服務器進程所在的用戶空間,纔算完成一次IO調度。

這幾個階段都是需要時間的。根據wait和copy階段的處理等待的機制不同,可將I/O動作分爲如下五種模式:

  • 阻塞I/O:所有過程都阻塞
  • 非阻塞I/O:如果沒有數據buffer,則立即返回EWOULDBLOCK
  • I/O複用(select和poll):在wait和copy階段分別阻塞
  • 信號(事件)驅動I/O(SIGIO):在wait階段不阻塞,但copy階段阻塞(信號驅動I/O,即通知)
  • 異步I/O(aio)AIO

阻塞非阻塞:阻塞和非阻塞指的是執行一個操作是等操作結束再返回,還是馬上返回。等待是阻塞的,自己輪詢是非阻塞的 同步異步:是事件本身的一個屬性.

I/O不管是I還是O,對外設(磁盤)的訪問都要分成請求和執行兩個階段.請求就是看外設的狀態信息(比如是否準備好了),執行纔是真正的I/O操作.

非阻塞是主動查詢外設的狀態,select/poll也是主動查詢,但是他們可以查詢多個fd的狀態,select有fd個數限制,epoll是基於回調函數的.用callback代替輪詢.select是遍歷fd.

epoll與IOCP比,epoll多了內核copy到應用層的阻塞

如何提高Web服務器的併發連接處理能力

  • 基於線程,即一個進程生成多個線程,每個線程響應用戶的每個請求。
  • 基於事件的模型,一個進程處理多個請求,並且通過epoll機制來通知用戶請求完成。
  • 基於磁盤的AIO(異步I/O)
  • 支持mmap內存映射,mmap傳統的web服務器,進行頁面輸入時,都是將磁盤的頁面先輸入到內核緩存中,再由內核緩存中複製一份到web服務器上,mmap機制就是讓內核緩存與磁盤進行映射,web服務器,直接複製頁面內容即可。不需要先把磁盤的上的頁面先輸入到內核緩存去。

Apache模型

之所以稱之爲應用服務器,是因爲他們真的要跑具體的業務應用,如科學計算、圖形圖像、數據庫讀寫等。它們很可能是CPU密集型的服務,事件驅動並不合適。例如一個計算耗時2秒,那麼這2秒就是完全阻塞的,什麼event都沒用。想想MySQL如果改成事件驅動會怎麼樣,一個大型的join或sort就會阻塞住所有客戶端。這個時候多進程或線程就體現出優勢,每個進程各幹各的事,互不阻塞和干擾。當然,現代CPU越來越快,單個計算阻塞的時間可能很小,但只要有阻塞,事件編程就毫無優勢。所以進程、線程這類技術,並不會消失,而是與事件機制相輔相成,長期存在。

  • prefork:多進程,每個請求用一個進程響應,這個過程會用到select機制來通知。最穩定
  • worker:多線程,一個進程可以生成多個線程,每個線程響應一個請求,但通知機制還是select不過可以接受更多的請求。
  • event:基於異步I/O模型,一個進程或線程,每個進程或線程響應多個用戶請求,它是基於事件驅動(也就是epoll機制)實現的。一個進程響應多個用戶請求,利用callback機制,讓套接字複用,請求過來後進程並不處理請求,而是直接交由其他機制來處理,通過epoll機制來通知請求是否完成;在這個過程中,進程本身一直處於空閒狀態,可以一直接收用戶請求。可以實現一個進程程響應多個用戶請求。支持持海量併發連接數,消耗更少的資源。

Nginx模型

事件驅動服務器,最適合做的就是這種IO密集型工作,如反向代理,它在客戶端與WEB服務器之間起一個數據中轉作用,純粹是IO操作,自身並不涉及到複雜計算。

Nginx會按需同時運行多個進程:一個主進程(master)和幾個工作進程(worker),配置了緩存時還會有緩存加載器進程(cache loader)和緩存管理器進程(cache manager)等。所有進程均是僅含有一個線程,並主要通過“共享內存”的機制實現進程間通信。主進程以root用戶身份運行,而worker、cache loader和cache manager均應以非特權用戶身份運行。

所以nginx是既不會有線程切換,也不會有進程切換.一個進程要跑在一個單獨的核上會沒有切換.

把一個完整的連接請求處理都劃分成了事件,一個一個的事件。比如accept(), recv(),磁盤I/O,send()等,每部分都有相應的模塊去處理,一個完整的請求可能是由幾百個模塊去處理。真正核心的就是事件收集和分發模塊,這就是管理所有模塊的核心。只有核心模塊的調度才能讓對應的模塊佔用CPU資源,從而處理請求。

拿一個HTTP請求來說,首先在事件收集分發模塊註冊感興趣的監聽事件,註冊好之後不阻塞直接返回,接下來就不需要再管了,等待有連接來了內核會通知你(epoll的輪詢會告訴進程),cpu就可以處理其他事情去了。一旦有請求來,那麼對整個請求分配相應的上下文(其實已經預先分配好),這時候再註冊新的感興趣的事件(read函數),同樣客戶端數據來了內核會自動通知進程可以去讀數據了,讀了數據之後就是解析,解析完後去磁盤找資源(I/O),一旦I/O完成會通知進程,進程開始給客戶端發回數據send(),這時候也不是阻塞的,調用後就等內核發回通知發送的結果就行。整個下來把一個請求分成了很多個階段,每個階段都到很多模塊去註冊,然後處理,都是異步非阻塞。

事件驅動適合於IO密集型服務,多進程或線程適合於CPU密集型服務

,Nginx的進程也分爲master進程跟worker子進程.(其實還有兩個cache有關的進程, 這裏略過).在啓動nginx之後,master進程就會隨即創建一定數量的worker子進程,並且之後worker子進程數量保持不變.並且這些worker子進程都是單線程的.當一個請求到來時,worker進程中某一個空閒進程就會去處理這個請求.乍一看到這裏nginx的工作模式跟apache沒有什麼區別.關鍵就在於nginx如何處理用戶請求.

worker子進程開始處理請求.這個請求可能是訪問某個網站的靜態頁面.而html頁面都是保存在硬盤上的.站在操作系統角度來看,nginx是沒有辦法直接讀取硬盤上的文件,必須由nginx告訴操作系統需要讀取哪個文件,然後又操作系統去讀取這個文件,讀取完畢操作系統再交給nginx.也就是說,在操作系統讀取文件的時候,nginx是空閒的.如果是apache,那這個時候apache的worker進程/線程就阻塞在這裏等待操作系統把文件讀取好再交個自己,這種就稱之爲IO阻塞.

但是nginx不一樣, nginx的worker進程在這個時候就會註冊一個事件,相當於告訴操作系統:你文件讀好了跟我說一下,我先去處理其他事情.然後這個worker就可以去處理新的用戶請求了.這裏nginx的worker進程並沒有由於操作系統讀取文件而阻塞等待,這種即稱之爲非IO阻塞

當操作系統讀取好文件之後,就會通知ngixn:我文件幫你讀取好了,你過來拿走."操作系統讀取好文件"這個事件被觸發了,於是Nginx就跑回去把文件拿走,然後返回響應.這種由於某個事件出現觸發Nginx執行操作的方式就稱爲事件驅動編程.

我們回顧上面過程,一個用戶請求讀取文件,nginx把讀取文件這個事情通知操作系統之後就去處理下一個用戶請求,直到操作系統讀取好文件之後再返回響應.這種一個請求還沒有處理完畢就去處理下一個請求的編程方式即異步編程


master來管理worker進程,所以我們只需要與master進程通信就行了。master進程會接收來自外界發來的信號,再根據信號做不同的事情。所以我們要控制nginx,只需要通過kill向master進程發送信號就行了。比如kill -HUP pid,則是告訴nginx,從容地重啓nginx,我們一般用這個信號來重啓nginx,或重新加載配置,因爲是從容地重啓,因此服務是不中斷的。

master進程在接到信號後,會先重新加載配置文件,然後再啓動新的worker進程,並向所有老的worker進程發送信號,告訴他們可以光榮退休了。新的worker在啓動後,就開始接收新的請求,而老的worker在收到來自master的信號後,就不再接收新的請求,並且在當前進程中的所有未處理完的請求處理完成後,再退出。

worker進程之間是平等的,每個進程,處理請求的機會也是一樣的。當我們提供80端口的http服務時,一個連接請求過來,每個進程都有可能處理這個連接所有worker進程的listenfd會在新連接到來時變得可讀,爲保證只有一個進程處理該連接,所有worker進程在註冊listenfd讀事件前搶accept_mutex,搶到互斥鎖的那個進程註冊listenfd讀事件,在讀事件裏調用accept接受該連接。當一個worker進程在accept這個連接之後,就開始讀取請求,解析請求,處理請求,產生數據後,再返回給客戶端,最後才斷開連接,這樣一個完整的請求就是這樣的了。我們可以看到,一個請求,完全由worker進程來處理,而且只在一個worker進程中處理。

首先,對於每個worker進程來說,獨立的進程,不需要加鎖,所以省掉了鎖帶來的開銷,同時在編程以及問題查找時,也會方便很多。其次,採用獨立的進程,可以讓互相之間不會影響,一個進程退出後,其它進程還在工作,服務不會中斷,master進程則很快啓動新的worker進程。當然,worker進程的異常退出,肯定是程序有bug了,異常退出,會導致當前worker上的所有請求失敗,不過不會影響到所有請求,所以降低了風險。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章