本文為linux環境下的總結,其他操作系統本質差別不大。本地文件I/O和網絡I/O邏輯類似。
epoll+多線程的模型
epoll+多線程模型和epoll 單進程區別、優點
對比於redis這樣典型的epoll+單進程為主的模型,個人理解epoll+多線程模型相對來說,epoll+多線程更利於程序員編寫,維護代碼,畢竟多線程的模型更符合多數人的邏輯方式。
例如,單進程下,如果是簡單的一問一答方式的服務類型還是OK得,如果服務器對每一個請求都還有至少1次額外的網絡I/O操作, 此時如果額外的I/O操作采用同步的方式,無疑將會將主進程阻塞得不償失,如果也通過事件驅動的方式進行異步I/O,代碼的編寫和維護成本無疑大大增加。事實上,額外的耗時I/O操作,redis基本都是通過fork一個子進程處理的。
而多線程的情況下,就可以保證在一部分的請求在I/O阻塞的情況下,服務器還能處理另一部分請求,並且代碼寫起來更容易,也能好維護。此外多線程也能利用上處理器的多核,但是這方面我沒接觸過具體的case,體會也不是很明顯。
一種簡單epoll+多線程模型
主線程主要負責listen端口並進行access,將acess到的fd放入一個隊列里。創建固定個數的處理線程,消費隊列里的fd,從fd中讀取請求,進行相應處理。處理后的返回結果直接由處理線程返回到客戶端 。
如果處理線程只涉及計算,沒有其他阻塞操作的話,不考慮超線程技術的話,創建和CPU核數一樣的線程數即可。但是處理線程涉及阻塞操作的話,線程數就要適當增加,以保證時刻都在處理請求而不是全部阻塞。至於要創建多少就要根據實際情況見仁見智了,建少了機器空閑,建多了額外消耗過多的機器資源會反拖累機器處理請求速度。
golang標准網絡庫通信模型——epoll+協程
golang的精髓在於協程,個人理解協程的精髓就在於和epoll的配合使得golang能以較低的開發成本,開發出能支持I/O密集場景下的高並發的服務器。(同樣場景下,用c/c++理論上肯定能開發出性能更好的服務器,但是目前來看開發成本肯定要高不少)。而計算密集的場景情況下,cpu除了計算還得維護一個go的調度器,顯然這並不是go擅長的領域。
golang自帶的網絡庫的網絡通信模型和上述的簡單的epoll+多線程模型類似,但是由於golang使用的是協程,擁有自己的調度器,以及能保證創建的內核線程數量不會太多(調度器保持活躍內核線程數和cpu核數一致),使得在內核線程切換帶來的消耗大大降低,而用戶線程的切換代價和消耗的資源小的多。
golang標准網絡庫簡單探究
對文件描述符fd的封裝 —— netFD
數據結構上只是對socket系統調用返回的fd做一些簡單的封裝 文件描述符,對應的通信協議等,此外還有一些方法的封裝,如將fd加入到epoll里等(epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ..)的封裝)
值得一提,如果不自己在go程序中自己進行epoll_create系統調用的話,一個go程序中只會生成一個epoll實例(通過sync.Once的Do方法實現的)
所以網路庫代碼粗略層次大概是這樣的(以傳輸層net包為例)
net包提供給用戶直接使用的方法:提供listen、accept、dail(作為客戶端連接)等方法封裝,操作的的數據結構是newFD
newFD(fd_unix.go):對文件描述符的封裝,同時也負責提供將fd和多路復用實例關聯的方法 (獲取newFD 一般是調用go自己封裝的一個socket函數)
netpoll.go:對多路復用實例的封裝, 相當於代理層,兼容各種平台
netpoll_*.go: 不同平台的多路復用的系統調用
lisent 監聽端口
簡單來看,listen()時就是調用go自己封裝的socket函數,綁定端口並且監聽,在go的socket函數里如果沒有創建epoll實例的話創建epoll實例(這一步並不是listen特有), 把listen系統調用返回的fd加入到epoll中。
dial 作為客戶端發起連接
dial返回的interface實際上是由Dialer這個struct代理生成的,(這個代理能生成TCP UDP IP UNIX文件對應的conn)
具體生成過程其實步驟邏輯不算少 但是最后也是調用go自己封裝的socket函數, 把生成的連接add到epoll實例里面。
read/accept 讀操作
會直接讀對應newFd里存儲的系統fd, 因為是noblock,所以read()會直接返回, 如果read系統調用返回的是syscall.EAGAIN, 則會調用gopark()將對應的goruntine掛起(設置_Gwaiting狀態)。
write 寫操作
如果第一次write系統調用就將要寫入內容全部寫入fd,就不會阻塞,否則(寫緩沖已滿)同樣將goruntine掛起。
epoll喚醒被阻塞的goruntine
阻塞在read() accept() wait()上的協程,會在netpoll函數中被喚醒,注冊在epoll里的event都是邊緣觸發模式(ET),所以在有數據可讀的時候,觸發可讀事件, 而僅當fd從不可寫變成可寫的時候才會觸發可寫事件(連接時,寫緩沖由滿變空閑,對端讀取了一些數據) 。
go的runtime線程在啟動時,在有使用網路庫的情況才會調用netpoll函數,在linux環境下實際上調用的就是一個for循環不斷進行epoll_wait(阻塞調用), 響應事件的回調函數就是調用goready喚醒該fd對應的goruntine(置為runnnable)。每一次epoll_wait最多返回128個事件。
總結
所以go的標准庫網絡通信模型其實還是比較簡單簡潔的,而其核心在其天然支持協程的特性,利用自己的調度器將傳統多線程在線程數多的情況造成的系統消耗降低到一個比較令人滿意的程度,從而達到一個開放效率和運行效率上的平衡。