阻塞IO
當你去讀一個阻塞的文件描述符時,如果在該文件描述符上沒有數據可讀,那么它會一直阻塞(通俗一點就是一直卡在調用函數那里),直到有數據可讀。當你去寫一個阻塞的文件描述符時,如果在該文件描述符上沒有空間(通常是緩沖區)可寫,那么它會一直阻塞,直到有空間可寫。以上的讀和寫我們統一指在某個文件描述符進行的操作,不單單指真正的讀數據,寫數據,還包括接收連接accept(),發起連接connect()等操作…
非阻塞IO
當你去讀寫一個非阻塞的文件描述符時,不管可不可以讀寫,它都會立即返回,返回成功說明讀寫操作完成了,返回失敗會設置相應errno狀態碼,根據這個errno可以進一步執行其他處理。它不會像阻塞IO那樣,卡在那里不動!!!
Level_triggered(水平觸發)
當被監控的文件描述符上有可讀寫事件發生時,epoll_wait()會通知處理程序去讀寫。如果這次沒有把數據一次性全部讀寫完(如讀寫緩沖區太小),那么下次調用 epoll_wait()時,它還會通知你在上沒讀寫完的文件描述符上繼續讀寫,當然如果你一直不去讀寫,它會一直通知你!!!如果系統中有大量你不需要讀寫的就緒文件描述符,而它們每次都會返回,這樣會大大降低處理程序檢索自己關心的就緒文件描述符的效率!!!
LT的缺點
LT模式下,可寫狀態的fd會一直觸發事件,該怎么處理這個問題
方法1:每次要寫數據時,將fd綁定EPOLLOUT事件,寫完后將fd同EPOLLOUT從epoll中移除。
方法2:方法一中每次寫數據都要操作epoll。如果數據量很少,socket很容易將數據發送出去。可以考慮改成:數據量很少時直接send,數據量很多時在采用方法1
為什么ET模式下一定要設置非阻塞?
因為ET模式下是無限循環讀,直到出現錯誤為EAGAIN或者EWOULDBLOCK,這兩個錯誤表示socket為空,不用再讀了,然后就停止循環了,如果是阻塞,循環讀在socket為空的時候就會阻塞到那里,主線程的read()函數一旦阻塞住,當再有其他監聽事件過來就沒辦法讀了,給其他事情造成了影響,所以必須要設置為非阻塞。
最大TCP連接數是多少?
理論上是等于 客戶端IP數?客戶端的端口數,即2的32次方?2的16次方即2的48次方,但是實際上受到文件描述符數量限制和內存空間限制。
Edge_triggered(邊緣觸發)
當被監控的文件描述符上有可讀寫事件發生時,epoll_wait()會通知處理程序去讀寫。如果這次沒有把數據全部讀寫完(如讀寫緩沖區太小),那么下次調用epoll_wait()時,它不會通知你,也就是它只會通知你一次,直到該文件描述符上出現第二次可讀寫事件才會通知你!!!這種模式比水平觸發效率高,系統不會充斥大量你不關心的就緒文件描述符!!!
所以ET所以循環處理,保證能將數據讀取完畢,即同時要保證非阻塞IO,不然最后會被阻塞
Reactor
主線程往epoll內核上注冊socket讀事件,主線程調用epoll_wait等待socket上有數據可讀,當socket上有數據可讀的時候,主線程把socket可讀事件放入請求隊列。睡眠在請求隊列上的某個工作線程被喚醒,處理客戶請求,然后往epoll內核上注冊socket寫請求事件。主線程調用epoll_wait等待寫請求事件,當有事件可寫的時候,主線程把socket可寫事件放入請求隊列。睡眠在請求隊列上的工作線程被喚醒,處理客戶請求。
Proactor
主線程調用aio_read函數向內核注冊socket上的讀完成事件,并告訴內核用戶讀緩沖區的位置,以及讀完成后如何通知應用程序,主線程繼續處理其他邏輯,當socket上的數據被讀入用戶緩沖區后,通過信號告知應用程序數據已經可以使用。應用程序預先定義好的信號處理函數選擇一個工作線程來處理客戶請求。工作線程處理完客戶請求之后調用aio_write函數向內核注冊socket寫完成事件,并告訴內核寫緩沖區的位置,以及寫完成時如何通知應用程序。主線程處理其他邏輯。當用戶緩存區的數據被寫入socket之后內核向應用程序發送一個信號,以通知應用程序數據已經發送完畢。應用程序預先定義的數據處理函數就會完成工作。
reactor模式和Proactor模式對比
reactor模式:同步阻塞I/O模式,注冊對應讀寫事件處理器,等待事件發生進而調用事件處理器處理事件。 proactor模式:異步I/O模式。
Reactor和Proactor模式的主要區別就是真正的讀取和寫入操作是有誰來完成的,Reactor中需要應用程序自己讀取或者寫入數據,Proactor模式中,應用程序不需要進行實際讀寫過程。
Reactor:非阻塞同步網絡模型,可以理解為:來了事件我通知你,你來處理
Proactor:異步網絡模型,可以理解為:來了事件我來處理,處理完了我通知你。
理論上:Proactor比Reactor效率要高一些。
webserver相關
并發模型
面向對象的Reactor模型,和面向過程的程序不同,面向對象的程序耦合度更低,可以將每一個行為分離開來,形成一個個事物,如果某一個事物阻塞了或者出現了異常可以及時切換到其他事物中,不會阻塞住整個系統,muduo網絡庫,Redis和Nginx都使用了Reactor模式。通過多線程或者多進程技術,提高了系統的效率。本系統采用多線程提高并發度,因為多線程粒度更小切換的消耗更低,性能更好。使用線程池是為避免線程頻繁創建和銷毀帶來的開銷,在程序的開始創建固定數量的線程,線程數量和計算機內存的數量保持一致可以有較好的CPU利用率,通常IO多的程序開更多的線程,CPU操作多的程序開更少的線程。使用Epoll作為IO多路復用的實現方式,為了調試方便在mac使用了Kqueue作為實現方式。
一個主Reactor主要用來處理accept的連接并負責分配client的連接請求給副Reactor,它是一種面向對象的IO復用模式。在建立連接后用輪詢的方式分配給工作線程,因為涉及到多線程的任務分配會有競爭問題,可以使用eventfd或者條件變量實現異步喚醒工作線程,線程會從Epoll_wait中醒來,獲取從主線程中獲取活躍連接進行處理,主線程會刪除這個連接,這也類似于生產者消費者模型,主線程處理新的連接,等第二次來數據的連接時喚醒消費者線程處理,本系統這里的互斥鎖由某個特定線程中loop創建,不會出現驚群的情況只會被該線程和主線程中使用。
定時任務功能實現
服務器程序通常管理著眾多定時事件,因此有效地組織這些定時事件,使之能在預期的時間點被觸發且不影響服務器的主要邏輯,對于服務器的性能有著至關重要的影響。為此,我們要將每個定時事件分別封裝成定時器,并使用某種容器類數據結構,比如鏈表、排序鏈表和時間輪,將所有定時器串聯起來,以實現對定時事件的統一管理。本項目是為了方便釋放那些超時的非活動連接,關閉被占用的文件描述符,才使用定時器。
每個副Reactor持有一個定時器,用于處理超時請求和長時間不活躍的連接。定時器可以使用時間輪,紅黑樹,雙向鏈表和小根堆實現,我使用了使用了C++標準庫中的priority_queue優先隊列,它底層是堆,通過惰性刪除的方法提高效率,因為在時間片到期的時候并不會馬上刪除超時節點,而是每次連接通信結束的時候循環的檢查,會將超時的節點全部刪除,由于小根堆的特點越早超時的節點越在堆的上方,檢查時間隊列的間隔和頻率減少了,效率就會提高。
Epoll邊緣模式
Epoll的觸發模式在這里我選擇了ET模式,muduo使用的是LT,這兩者IO處理上有很大的不同。ET模式要比LE復雜許多,它對用戶提出了更高的要求,即每次讀,必須讀到不能再讀直到出現EAGAIN,每次寫,寫到不能再寫知道出現EAGAIN。而LT則簡單的多,可以選擇也這樣做,也可以為編程方便,比如每次只read一次,muduo就是這樣做的,這樣可以減少系統調用次數。
線程池模塊
線程池是使用了已經創建好的線程進行循環處理任務,避免了大量線程的頻繁創建與銷毀的成本。
實現思想:利用生產者消費者隊列,創建多個線程并全部初始化去運行,通過條件變量判斷隊列中是否有任務,沒有任務就等待,當有任務時就可以通過條件變量來喚醒阻塞中的線程去處理這個任務。
代碼實現:類主要有兩個類,一個類是任務類,一個類是線程池類,其中任務類有兩個成員變量,一個是數據和處理數據方式的一個函數指針,成員函數有兩個,一個是用于接收任務和數據的處理方式的函數,一個是run函數,其執行這個處理數據的函數。線程池類中的成員變量主要有線程池的最大數量、一個緩沖隊列、一個互斥鎖,用來保護對隊列的操作、一個條件變量,用于實現線程池中線程的同步。threadpool_create是線程的的入口函數,每個線程都在一個死循環里等待任務,當有任務到來,就可以獲取隊列中的任務對象,然后去執行任務對象中的回調函數來處理數據。
核心模塊
Channel類:Channel和一個 EventLoop綁定是一種事物,在Channel類管理一個fd文件描述符,存儲這個事件的數據類型event以及對應的函數,當事件活躍的時候會調用到之前保存在類中的函數。因此,程序中所有帶有讀寫時間的對象都會和一個Channel關聯,包括loop中的eventfd,listenfd,HttpData等。
EventLoop:One loop per thread意味著每個線程只能有一個EventLoop對象,EventLoop即是時間循環,每次從poller里拿活躍事件,并給到Channel里分發處理。EventLoop中的loop函數會在最底層Thread中被真正調用,開始無限的循環,直到某一輪的檢查到退出狀態后從底層一層一層的退出。
建立連接
建立連接的時候服務器端首先使用socket()創建套接字,其次使用bind()將如IPv4綁定到套接字,最后調用listen()監聽連接,使用Epoll IO復用的ET邊緣模式監聽listenfd的讀請求,數據的通信已經由操作系統幫我們完成了,這里的通信是指3次握手的過程,這個過程不需要應用程序參與,當應用程序感知到連接時,此時該連接已經完成了3次握手的過程,accept()會在收到最后ACK文件后放回。另一個原因是一般情況下,連接是客戶端主動發起,服務器端被動連接,也不會出現同時建立的情況。檢查這個連接的fd描述符,如果是第一次建立連接則會講這個描述符加入Epoll的紅黑數中。第二次同一個連接活躍的時候就可以講這個fd加入Epoll的就緒隊列中,使用ET模式會比LT電平模式麻煩,與LT不同的是,LT模式當有活躍事物的時候會不停的觸發提醒直到處理完這個事物,而ET模式的活躍事物只會提醒一次,因此開發的時候要無限循環讀直到就緒連接為空。
假設server只監聽一個端口,一個連接就是一個四元組原ip,原port,對端ip,對端port,那么理論上可以建立2^48個連接,可是,fd可沒有這么多這是由于操作系統限制和用戶進程限制實際上主要首先于內存,可以通過Linux中的終端命令ulimit臨時修改fd數量,通過vim /etc/security/limits.conf文件中修改永久的fd數量。為了避免連接滿了無法處理新的連接,新的連接會阻塞在connect()上,防止空等,在第一次客戶端發起半連接包后就不在處理的DOS攻擊,避免就緒隊列滿了后會導致新連接無法建立。本系統采取的方案是參考muduo網絡庫,準備一個空的文件描述符,限制最大連接數,不要在連接滿了才進行處理,達到一定數量的連接后,accept()阻塞返回后直接close(),這樣對端不會收到RST,客戶端可以知道服務器還存活著而不是宕機。
優雅關閉
通常server和client都可以主動發Fin來關閉連接,這是TCP的特點全雙工連接。對于client(非Keep-Alive),發送完請求后就可以半關閉寫端,這個時候就是告訴客戶端服務器端不在寫數據但是可以繼續讀數據,這也是符合TCP協議的邏輯,然后收到server發來的應答后讀空read(),最后關閉連接。也可以不使用shutdown()半關閉寫端,等讀完直接close()。對于Keep-Alive長連接的情況,需要觀察客戶端的行為,服務器端應該保證不主動斷開主動權掌握在客戶端手里。
共享內存
使用mmap加速內核與用戶空間的消息傳遞。無論是select,poll還是epoll都需要內核把FD消息通知給用戶空間,如何避免不必要的內存拷貝就很重要,在這點上,epoll是通過內核于用戶空間mmap同一塊內存實現的。(mmap 內存共享 少一份拷貝吧)
epoll開發相關
1、單個epoll并不能解決所有問題,特別是你的每個操作都比較費時的時候,因為epoll是串行處理的。 所以你有還是必要建立線程池來發揮更大的效能。
2、如果fd被注冊到兩個epoll中時,如果有時間發生則兩個epoll都會觸發事件。
3、如果注冊到epoll中的fd被關閉,則其會自動被清除出epoll監聽列表。
4、如果多個事件同時觸發epoll,則多個事件會被聯合在一起返回。
5、epoll_wait會一直監聽epollhup事件發生,所以其不需要添加到events中。
6、為了避免大數據量io時,et模式下只處理一個fd,其他fd被餓死的情況發生。linux建議可以在fd聯系到的結構中增加ready位,然后epoll_wait觸發事件之后僅將其置位為ready模式,然后在下邊輪詢ready fd列表
ET模式僅當狀態發生變化的時候才獲得通知,這里所謂的狀態的變化并不包括緩沖區中還有未處理的數據,也就是說,如果要采用ET模式,需要一直read/write直到出錯為止,很多人反映為什么采用ET模式只接收了一部分數據就再也得不到通知了,大多因為這樣;而LT模式是只要有數據沒有處理就會一直通知下去的.