【學習點滴】linux下網絡編程的函數socket、read、io複用

目錄

幾個函數

socket:

bind

listen,connect

accept

io複用機制

什麼情況下用et比較好

muduo


 

幾個函數

socket:

socket()用於創建一個socket描述符(socket descriptor),它唯一標識一個socket。這個socket描述字跟文件描述字一樣,後續的操作都有用到它,把它作爲參數,通過它來進行一些讀寫操作。

int socket(int domain, int type, int protocol);

domain:即協議域,又稱爲協議族(family)。常用的協議族有,AF_INET(IPv4)、AF_INET6(IPv6)、AF_LOCAL(或稱AF_UNIX,Unix域socket)、AF_ROUTE等等。協議族決定了socket的地址類型,在通信中必須採用對應的地址,如AF_INET決定了要用ipv4地址(32位的)與端口號(16位的)的組合、AF_UNIX決定了要用一個絕對路徑名作爲地址。 
type:指定socket類型。常用的socket類型有,SOCK_STREAM(流式套接字)、SOCK_DGRAM(數據報式套接字)、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等 
protocol:就是指定協議。常用的協議有,IPPROTO_TCP、PPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它們分別對應TCP傳輸協議、UDP傳輸協議、STCP傳輸協議、TIPC傳輸協議。

注意:並不是上面的type和protocol可以隨意組合的,如SOCK_STREAM不可以跟IPPROTO_UDP組合。當protocol爲0時,會自動選擇type類型對應的默認協議。

 

當我們調用socket創建一個socket時,返回的socket描述字它存在於協議族(address family,AF_XXX)空間中,但沒有一個具體的地址。如果想要給它賦值一個地址,就必須調用bind()函數,否則就當調用connect()、listen()時系統會自動隨機分配一個端口。

sockaddr_un
進程間通信的一種方式是使用UNIX套接字,人們在使用這種方式時往往用的不是網絡套接字,而是一種稱爲本地套接字的方式。這樣做可以避免爲黑客留下後門。

創建
使用套接字函數socket創建,不過傳遞的參數與網絡套接字不同。域參數應該是PF_LOCAL或者PF_UNIX,而不能用PF_INET之類。本地套接字的通訊類型應該是SOCK_STREAM或SOCK_DGRAM,協議爲默認協議。例如:
 int sockfd;
 sockfd = socket(PF_LOCAL, SOCK_STREAM, 0);

綁定
創建了套接字後,還必須進行綁定才能使用。不同於網絡套接字的綁定,本地套接字的綁定的是struct sockaddr_un結構。struct sockaddr_un結構有兩個參數:sun_family、sun_path。sun_family只能是AF_LOCAL或AF_UNIX,而sun_path是本地文件的路徑。通常將文件放在/tmp目錄下。例如:

 struct sockaddr_un sun;
 sun.sun_family = AF_LOCAL;
 strcpy(sun.sun_path, filepath);
 bind(sockfd, (struct sockaddr*)&sun, sizeof(sun));

監聽
本地套接字的監聽、接受連接操作與網絡套接字類似。

連接
連接到一個正在監聽的套接字之前,同樣需要填充struct sockaddr_un結構,然後調用connect函數。

連接建立成功後,我們就可以像使用網絡套接字一樣進行發送和接受操作了。甚至還可以將連接設置爲非阻塞模式,這裏就不贅述了。


 

 

 

bind

bind()函數把一個地址族中的特定地址賦給socket。例如對應AF_INET、AF_INET6就是把一個ipv4或ipv6地址和端口號組合賦給socket。

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

函數的三個參數分別爲: 
sockfd:即socket描述字,它是通過socket()函數創建了,唯一標識一個socket。bind()函數就是將給這個描述字綁定一個名字。 
addr:一個const struct sockaddr *指針,指向要綁定給sockfd的協議地址。

addrlen:對應的是地址的長度。 

其中,addr參數

struct sockaddr_in {
    sa_family_t    sin_family; /* address family: AF_INET */
    in_port_t      sin_port;   /* port in network byte order */
    struct in_addr sin_addr;   /* internet address */
};
/* Internet address. */
struct in_addr {
    uint32_t       s_addr;     /* address in network byte order */
};



	//服務器IP+PORT					用於綁定端口使用
	struct sockaddr_in serverAddr;
	serverAddr.sin_family = PF_INET;				//選擇協議
	serverAddr.sin_port = htons(SERVER_PORT);			//選擇端口,此處宏定義爲8888
	serverAddr.sin_addr.s_addr = inet_addr(SERVER_IP);		//選擇ip,宏定義爲"127.0.0.1"

htons將主機的無符號短整形數轉換成網絡字節順序 
htonl將主機的無符號長整形數轉換成網絡字節順序

inet_addr()的功能是將一個點分十進制的IP轉換成一個長整數型數(u_long類型)

如ipAddr.S_un.S_addr = inet_addr("127.0.0.1"); //將字符串形式的IP地址轉換爲按網絡字節順序的整型值

htonl,其實是host to network, l 的意思是返回類型是long

htons,其實是host to network, s 的意思是返回類型是short

ntohs 以及 ntohl  同理

 

 


    通常服務器在啓動的時候都會綁定一個衆所周知的地址(如ip地址+端口號),用於提供服務,客戶就可以通過它來接連服務器;而客戶端就不用指定,有系統自動分配一個端口號和自身的ip地址組合。這就是爲什麼通常服務器端在listen之前會調用bind(),而客戶端就不會調用,而是在connect()時由系統隨機生成一個。

在實際網絡編程中,往往會設置成 serverAddr.sin_addr.s_addr =INADDR_ANY; 這是因爲服務器主機可能有多個網卡即多個IP地址,設爲這個就能保證對此服務器上任意網卡的請求都能被本socket fd監聽到。

 

 

listen,connect

如果作爲一個服務器,在調用socket()、bind()之後就會調用listen()來監聽這個socket,如果客戶端這時調用connect()發出連接請求,服務器端就會接收到這個請求。

int listen(int sockfd, int backlog);
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

listen函數的第一個參數即爲要監聽的socket描述字,第二個參數爲相應socket可以排隊的最大連接個數。socket()函數創建的socket默認是一個主動類型的,listen函數將socket變爲被動類型的,等待客戶的連接請求。

connect函數的第一個參數即爲客戶端的socket描述字,第二參數爲服務器的socket地址,第三個參數爲socket地址的長度。客戶端通過調用connect函數來建立與TCP服務器的連接。

listen函數將主動套接字轉換爲被動監控套接字,其第二個參數backlog決定了內核的連接緩存隊列長度。對於一個給定的監聽套接字,內核維護兩個隊列:

① 未就緒隊列,存放沒有完成三路握手的連接,監聽套接字收到SYN並返回ACK+SYN,連接處於SYN_RECV狀態,等待對端發送ACK。如果已完成隊列非滿,則接收ACK,連接握手完成,進入已完成隊列;如果已完成隊列滿則丟棄ACK,對端重發ACK(對端看到的連接是ESTABLISED狀態),若未就緒隊列中的SYN_RECV等待直到超時還沒進入已完成隊列則丟棄連接(對端不知道,只有在讀寫套接字時才知道)。

② 已完成隊列,存放已經完成三路握手的連接(ESTABLISHED),等待accept取走連接。

backlog決定了兩個隊列的長度之和(並不是說兩個隊列之和等於backlog,而是存在個轉換,依賴於具體實現)。

如果未就緒隊列滿則忽略新到來的SYN請求,對端重發,如果一直不能進入未就緒隊列則對端connect失敗返回。

當監聽套接字關閉時:① 會對已完成隊列中的每個連接發送復位分節RST,對端捕獲RST被動關閉連接;② 直接釋放未就緒隊列的連接,這時對端不知道,對端的連接狀態依然保持ESTABLISHED狀態,直到對端主動關閉連接,由於監聽端已經關閉連接,所以以RST響應對端的FIN,對端收到RST直接關閉連接。(類似於半打開連接)

 

 

accept

TCP服務器端依次調用socket()、bind()、listen()之後,就會監聽指定的socket地址了。TCP客戶端依次調用socket()、connect()之後就向TCP服務器發送了一個連接請求。TCP服務器監聽到這個請求之後,就從已完成連接隊列中取出一個socket,調用accept()函數取接收請求,這樣連接就建立好了。之後就可以開始網絡I/O操作了,即類同於普通文件的讀寫I/O操作。

其中:

      listen

          在server這端,準備了一個未完成的連接隊列,保存只收到SYN_C的socket結構;這些套接口處於 SYN_RCVD 狀態,

          還準備了已完成的連接隊列,即保存了收到了最後一個ACK的socket結構。這些套接口處於 ESTABLISHED 狀態。

          這裏需要注意的是,listen()函數不會阻塞,它主要做的事情爲,將該套接字和套接字對應的連接隊列長度告訴 Linux 內核,然後,listen()函數就結束。

      accept

         應用進程調用accept的時候,就是去檢查上面說的已完成的連接隊列,如果隊列裏有連接,就返回一個可以讀寫的連接

         如果沒有,即空的,blocking方試調用,就睡眠等待;

                                         nonblocking方式調用,就直接返回,一般一"EWOULDBLOCK“ errno告訴調用者,連接隊列是空的。 
 

函數原型:

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

accept函數的第一個參數爲服務器的socket描述字,第二個參數爲指向struct sockaddr *的指針,用於返回客戶端的協議地址,第三個參數爲客戶端協議地址的長度。如果accpet成功,那麼其返回值是由內核自動生成的一個全新的描述字,代表與返回客戶的TCP連接。

注意:accept的第一個參數爲服務器的socket描述字,是服務器開始調用socket()函數生成的,稱爲監聽socket描述字;而accept函數返回的是已連接的socket描述字。一個服務器通常通常僅僅只創建一個監聽socket描述字,它在該服務器的生命週期內一直存在。內核爲每個由服務器進程接受的客戶連接創建了一個已連接socket描述字,當服務器完成了對某個客戶的服務,相應的已連接socket描述字就被關閉。

其中struct sockaddr *addr    爲

struct sockaddr_in client_address;    //用來保存自動創建的已連接客戶端的socket的ip和端口
socklen_t client_addrLength = sizeof(struct sockaddr_in);
int clientfd = accept(listener, (struct sockaddr*)&client_address, &client_addrLength);

 

 

recv和send

ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

recv:

    第一個參數指定接收端套接字描述符;

    第二個參數指明一個緩衝區,該緩衝區用來存放recv函數接收到的數據;

    第三個參數指明buf的長度;

    第四個參數一般置0。(man裏面說了,flag=0的話此行爲與read行爲大致相同)

(1)recv先等待s的發送緩衝中的數據被協議傳送完畢,如果協議在傳送s的發送緩衝中的數據時出現網絡錯誤,那麼recv函數返回SOCKET_ERROR,

(2)如果s的發送緩衝中沒有數據或者數據被協議成功發送完畢後,recv先檢查套接字s的接收緩衝區,如果s接收緩衝區中沒有數據或者協議正在接收數據,那麼recv就一直等待,直到協議把數據接收完畢。當協議把數據接收完畢,recv函數就把s的接收緩衝中的數據copy到buf中(注意協議接收到的數據可能大於buf的長度,所以 在這種情況下要調用幾次recv函數才能把s的接收緩衝中的數據copy完。recv函數僅僅是copy數據,真正的接收數據是協議來完成的),recv函數返回其實際copy的字節數。如果recv在copy時出錯,那麼它返回SOCKET_ERROR;如果recv函數在等待協議接收數據時網絡中斷了,那麼它返回0。

注意:在Unix系統下,如果recv函數在等待協議接收數據時網絡斷開了,那麼調用recv的進程會接收到一個SIGPIPE信號,進程對該信號的默認處理是進程終止。

 

 

首先阻塞接收的recv有時候會返回0,這僅在對端已經關閉TCP連接時纔會發生。

而當拔掉設備網線的時候,recv並不會發生變化,仍然阻塞,如果在這個拔網線階段,socket被關掉了,後果可能就是recv永久的阻塞了。所以一般對於阻塞的socket都會用setsockopt來設置recv超時,當超時時間到達後,recv會返回錯誤,也就是-1,而此時的錯誤碼是EAGAIN或者EWOULDBLOCK,POSIX.1-2001上允許兩個任意一個出現都行,所以建議在判斷錯誤碼上兩個都寫上。

      如果socket是被對方用linger爲0的形式關掉,也就是直接發RST的方式關閉的時候,recv也會返回錯誤,錯誤碼是ECONNREST
 

一般設置超時的阻塞recv常用的方法都如下:

Linux環境下,須如下定義:struct timeval timeout = {3,0}; 
//設置發送超時
setsockopt(socket,SOL_SOCKET,SO_SNDTIMEO,(char *)&timeout,sizeof(struct timeval));

//設置接收超時
setsockopt(socket,SOL_SOCKET,SO_RCVTIMEO,(char *)&timeout,sizeof(struct timeval));
 

阻塞與非阻塞recv返回值沒有區分,都是

 >  0  成功接收數據大小。

 =  0  另外一端關閉了套接字

 = -1     錯誤,需要獲取錯誤碼errno(win下是通過WSAGetLastError())

 errno被設爲以下的某個值:

EAGAIN:在套接字已標記爲非阻塞情況下,接收操作出現阻塞或者接收超時

               對非阻塞socket而言,EAGAIN不是一種錯誤。在VxWorks和Windows上,EAGAIN的名字叫EWOULDBLOCK。
EBADF:sock不是有效的描述符

ECONNREFUSE:遠程主機拒絕網絡連接

EFAULT:內存空間訪問出錯
EINTR:操作被信號中斷
EINVAL:參數無效
ENOMEM:內存不足
ENOTCONN:與面向連接關聯的套接字尚未被連接上
ENOTSOCK:sock索引的不是套接字 
返回值<0時並且(errno == EINTR || errno == EWOULDBLOCK || errno == EAGAIN)的情況下認爲連接是正常的,繼續接收。
只是阻塞模式下recv會阻塞着接收數據,非阻塞模式下如果沒有數據會返回,不會阻塞着讀,因此需要循環讀取)。
 

 

這裏只描述同步Socket的send函數的執行流程。當調用該函數時,

(1)send先比較待發送數據的長度len和套接字s的發送緩衝的長度, 如果len大於s的發送緩衝區的長度,該函數返回SOCKET_ERROR; 
(2)如果len小於或者等於s的發送緩衝區的長度,那麼send先檢查協議s的發送緩衝中的數據是否正在發送,如果是就等待協議把數據發送完,如果協議還沒有開始發送s的發送緩衝中的數據或者s的發送緩衝中沒有數據,那麼send就比較s的發送緩衝區的剩餘空間和len 
(3)如果len大於剩餘空間大小,send就一直等待協議把s的發送緩衝中的數據發送完 
(4)如果len小於剩餘 空間大小,send就僅僅把buf中的數據copy到剩餘空間裏(注意並不是send把s的發送緩衝中的數據傳到連接的另一端的,而是協議傳的,send僅僅是把buf中的數據copy到s的發送緩衝區的剩餘空間裏)。

如果send函數copy數據成功,就返回實際copy的字節數,如果send在copy數據時出現錯誤,那麼send就返回SOCKET_ERROR;如果send在等待協議傳送數據時網絡斷開的話,那麼send函數也返回SOCKET_ERROR。

注意:send函數把buf中的數據成功copy到s的發送緩衝的剩餘空間裏後它就返回了,但是此時這些數據並不一定馬上被傳到連接的另一端。如果協議在後續的傳送過程中出現網絡錯誤的話,那麼下一個socket函數就會返回SOCKET_ERROR。(每一個除send外的socket函數在執 行的最開始總要先等待套接字的發送緩衝中的數據被協議傳送完畢才能繼續,如果在等待時出現網絡錯誤,那麼該Socket函數就返回 SOCKET_ERROR)

注意:在Unix系統下,如果send在等待協議傳送數據時網絡斷開的話,調用send的進程會接收到一個SIGPIPE信號,進程對該信號的默認處理是進程終止。

 

io複用機制

1 select的低效率

  select/poll函數效率比較低,主要有以下兩個原因:

  (1)調用select函數後需要對所有文件描述符進行循環查找

  (2)每次調用select函數時都需要向該函數傳遞監視對象信息

  在這兩個原因中,第二個原因是主要原因:每次調用select函數時,應用程序都要將所有文件描述符傳遞給操作系統,這給程序帶來很大的負擔。在高併發的環境下,無論怎樣優化應用程序的代碼,都無法完成應用的服務。  

  所以,select與poll並不適合以Web服務器端開發爲主流的現代開發環境,只在要求滿足以下兩個條件是適用:

  (1)服務器端接入者少

  (2)程序要求兼容性

2 Linux的epoll機制

  由上一節,我們需要一種類似於select的機制來完成高併發的服務器。需要有以下兩個特點(epoll和select的區別)

  (1)應用程序僅向操作系統傳遞1次監視對象

  (2)監視範圍或內容發生變化是,操作系統只通知發生變化的事項給應用程序

  幸運的是,的確存在這樣的機制。Linux的支持方式是epoll,Windows的支持方式是IOCP。

3 epoll函數原型  

  epoll操作由三個函數組成:  

#include <sys/epoll.h>
int epoll_create(int size);
            //成功時返回epoll文件描述符,失敗時返回-1
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
            //成功時返回0,失敗時返回-1
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
            //成功時返回發生事件的文件描述數,失敗時返回-1

(1)epoll_create:創建保存epoll文件描述符的空間,即在內核中創建事件表。

  調用epoll_create函數時創建的文件描述符保存空間稱爲“epoll例程”。但要注意:size參數只是應用程序向操作系統提的建議,操作系統並不一定會生成一個大小爲size的epoll例程。

      epoll在內核初始化的時候向內核註冊了一個文件系統,用於存儲上述被監控的socket,同時還會開闢出epoll自己的內核高速cache區,用於安置需要監控的fd。這些fd以紅黑樹的形式保存在內核cache裏,以支持快速的查找、插入、刪除。這個內核高速cache區,就是建立連續的物理內存頁,然後在之上建立slab層,簡單的說就是物理上分配好你想要的大小的內存對象,每次使用時都是使用空閒的已分配好的對象。
 

(2)epoll_ctl:向空間(事件紅黑樹上)註冊或者註銷文件描述符

  參數epfd指定註冊監視對象的epoll例程的文件描述符,op指定監視對象的添加、刪除或更改等操作,有以下兩種常量:

    1)EPOLL_CTL_ADD:將文件描述符註冊到epoll例程

    2)EPOLL_CTL_DEL:從epoll例程中刪除文件描述符

    3)EPOLL_CTL_MOD:更改註冊的文件描述符的關注事件發生情況

  fd指定需要註冊的監視對象文件描述符,event指定監視對象的事件類型。epoll_event結構體如下:

struct epoll_event
{
      __uint32_t events;
      epoll_data_t data;            
}
typedef union epoll_data
{
      void *ptr;
      int fd;
      __uint32_t u32;
      __uint64_t u64;    
}epoll_data_t;

epoll_event的成員events中可以保存的常量及所指的事件類型有以下:

    1)EPOLLIN:需要讀取數據的情況(包括對端SOCKET正常關閉)

    2) EPOLLOUT:輸出緩衝爲空,可以立即發送數據的情況 (表示對應的文件描述符可以寫) 

    3) EPOLLPRI:表示對應的文件描述符有緊急的數據可讀(這裏應該表示有外帶數據到來)

    4) EPOLLRDHUP:斷開連接或半關閉的情況,這在邊緣觸發方式下非常有用

    5) EPOLLERR:發生錯誤的情況

              6)EPOLLHUP:表示對應的文件描述符被掛斷(收到RST分節)

    7) EPOLLET:以邊緣觸發的方式得到事件通知,因爲默認爲水平觸發

                                    (ET模式拷貝完活躍事件後event【epitem中存放的)不放回就緒隊列)

    8) EPOLLONESHOT:發生一次事件後,相應文件描述符不再收到事件通知。因此需要向epoll_ctl函數的第二個參數EPOLL_CTL_MOD,再次設置事件。注意,如果epitem被設置爲EPOLLONESHOT模式,則當這個epitem上的事件拷貝到用戶空間之後,會將這個epitem上的關注事件清空(只是關注事件被清空,並沒有從epoll中刪除,要刪除必須對那個描述符調用EPOLL_DEL),也就是說即使這個epitem上有觸發事件,但是因爲沒有用戶關注的事件所以不會被重新添加到readylist中.

(3)epoll_wait:與select函數類似,等待文件描述符發生變化。操作系統返回epoll_event類型的結構體通知監視對象的變化。timeout函數是爲毫秒爲單位的等待時間,傳遞-1時,一直等待直到事件發生。聲明足夠大的epoll_event結構體數組後,傳遞給epoll_wait函數時,發生變化的文件符信息將被填入該數組。因此,不需要像select函數那樣針對所有文件符進行循環。參數events用來從內核得到事件的集合maxevents告之內核這個events有多大,這個 maxevents的值不能大於創建epoll_create()時的size,參數timeout是超時時間(毫秒,0會立即返回,-1將不確定,也有說法說是永久阻塞)。該函數返回需要處理的事件數目,如返回0表示已超時。

enum EPOLL_EVENTS
  {
    EPOLLIN = 0x001,

    EPOLLPRI = 0x002,

    EPOLLOUT = 0x004,

    EPOLLRDNORM = 0x040,

    EPOLLRDBAND = 0x080,

    EPOLLWRNORM = 0x100,

    EPOLLWRBAND = 0x200,

    EPOLLMSG = 0x400,

    EPOLLERR = 0x008,

    EPOLLHUP = 0x010,

    EPOLLRDHUP = 0x2000,

    EPOLLWAKEUP = 1u << 29,

    EPOLLONESHOT = 1u << 30,

    EPOLLET = 1u << 31

  };

 

 

5 水平觸發與邊緣觸發

  水平觸發:只要引起epoll_wait返回的事件還存在,再次調用epoll_wait時,該事件還會被註冊

  邊緣觸發:每個事件在剛發生的時候被註冊一次,之後就不會被註冊,除非又有新的事件發生。

  比如,一個已連接的socket套接字收到了數據,而讀取緩衝區小於接收到的數據,這時,兩種觸發方式有以下區別:(1)水平觸發:一次讀取之後,套接字緩衝區裏還有數據,再調用epoll_wait,該套接字的EPOLL_IN事件還是會被註冊;(2)邊緣觸發:一次讀取之後,套接字緩衝區裏還有數據,再調用epoll_wait,該套接字的EPOLL_IN事件不會被註冊,除非在這期間,該套接字收到了新的數據。

  epoll默認採用水平觸發。

 for (int i = 0; i < event_cnt; ++i)
        {
            if (ep_events[i].data.fd == listenfd)
            {
                connfd = accept(listenfd, NULL, NULL);
                //設置爲非阻塞I/O
                int flag = fcntl(fd, F_GETFL, 0);
                fcntl(fd, F_SETFL, flag | O_NONBLOCK);

                event.events = EPOLLIN|EPOLLET;       //邊緣觸發
                event.data.fd = connfd;
                epoll_ctl(pefd, EPOLL_CTL_ADD, connfd, &event);
                printf("connect another client\n");
            }
            else
            {
                //讀完每個已連接socket的緩衝區裏的數據
                while (1)
                {
                    int nread = read(ep_events[i].dada.fd, buf, BUF_SIZE);
                    if (nread == 0)
                    {
                        close(ep_events.data.fd);
                        epoll_ctl(epfd, EPOLL_CTL_DEL, ep_events.data.fd, NULL);
                        printf("disconnect with a client\n");
                    }
                    else if (nread < 0)
                    {
                        //errno爲EAGAIN,則緩衝區內已沒有數據
                        if (errno == EAGAIN)
                            break;
                    }
                    else
                    {
                        write(ep_events[i].data.fd, buf, nread);
                    }
                }
            }

幾個說明:

  (1)在使用epoll_ctl註冊事件的時候,選擇邊緣觸發,|EPOLLET

  (2)處理已發生的邊緣觸發的事件時,要處理完所有的數據再返回。例中,使用了循環的方式讀取了套接字中的所有數據

  (3)讀/寫套接字的時候採用非阻塞式I/O。爲何?邊緣觸發方式下,以阻塞方式工作的read&write函數有可能引起服務器端的長時間停頓。

  那麼邊緣觸發好不好?有什麼優點呢?書上說,邊緣觸發可以分離接收數據和處理數據的時間點。也就是說,在事件發生的時候,我們只記錄事件已經發生,而不去處理數據,等到以後的某段時間纔去處理數據,即分離接收數據和處理數據的時間點。好奇的我一定會問:條件觸發沒辦法分離接收數據和處理數據的時間點嗎?答案是可以的。但存在問題:在數據被處理之前,每次調用epoll_wait都會產生相應的事件,在一個具有大量這樣的事件的繁忙服務器上,這是不現實的。

  可是。還沒有說邊緣觸發和條件觸發哪個更好呀?馬克思說,要辯證地看問題。so,邊緣觸發更有可能帶來高性能,但不能簡單地認爲“只要使用邊緣觸發就一定能提高速度”,要具體問題具體分析。好吧,馬克思的這一個“具體問題具體分析”適用於回答絕大部分比較類問題,已和“多喝水”,“重啓一下試試看”,“不行就分”並列成爲最簡單粗暴的4個通用回答。

 

fcntl(listenfd, F_SETFL, O_NONBLOCK);

fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFD, 0) | O_NONBLOCK);將套接字設定爲非阻塞

總結:

1.水平觸發LT時socket爲非阻塞或者阻塞都可以,因此就算這次沒讀完,下次還會觸發此事件

2.邊緣觸發ET時一定要設置socket爲非阻塞,因爲這次沒讀完,下次就不會觸發了,應該對recv和send套上一個while循環,直到recv返回值爲-1且error==EAGAIN時表示讀到不可讀。(或者說最後一次讀到的recv返回值小於指明buf的長度就表示已經讀完了)。對於寫操作,一直寫到返回值爲-1且errno==EAGAIN時表示寫完了。

            if (events[i].events & EPOLLIN) {
                n = 0;
                while ((nread = read(fd, buf + n, BUFSIZ-1)) > 0) {//ET下可以讀就一直讀
                    n += nread;
                }
                if (nread == -1 && errno != EAGAIN) {
                    perror("read error");
                }
            if (events[i].events & EPOLLOUT) {
              sprintf(buf, "HTTP/1.1 200 OK\r\nContent-Length: %d\r\n\r\nHello World", 11);
                int nwrite, data_size = strlen(buf);
                n = data_size;
                while (n > 0) {
                    nwrite = write(fd, buf + data_size - n, n);//ET下一直將要寫數據寫完
                    if (nwrite < n) {
                        if (nwrite == -1 && errno != EAGAIN) {
                            perror("write error");
                        }
                        break;
                    }
                    n -= nwrite;
                }
                close(fd);
            }

輸入輸出緩衝區,系統會爲每個socket都單獨分配,並且是在socket創建的時候自動生成的。一般來說,默認的輸入輸出緩衝區大小爲8K=8*1024字節。套接字關閉的時候,輸出緩衝區的數據不會丟失,會由協議發送到另一方;而輸入緩衝區的數據則會丟失。

ET和LT的區別?源碼層面:

/* 該函數作爲callbakc在ep_scan_ready_list()中被調用
 * head是一個鏈表, 包含了已經ready的epitem,
 * 這個不是eventpoll裏面的ready list, 而是上面函數中的txlist.
 */
static int ep_send_events_proc(struct eventpoll *ep, struct list_head *head,
                   void *priv)
{
    struct ep_send_events_data *esed = priv;
    int eventcnt;
    unsigned int revents;
    struct epitem *epi;
    struct epoll_event __user *uevent;

    /* 掃描整個鏈表... */
    for (eventcnt = 0, uevent = esed->events;
         !list_empty(head) && eventcnt < esed->maxevents;) {
        /* 取出第一個成員 */
        epi = list_first_entry(head, struct epitem, rdllink);
        /* 然後從鏈表裏面移除 */
        list_del_init(&epi->rdllink);
        /* 讀取events, 
         * 注意events我們ep_poll_callback()裏面已經取過一次了, 爲啥還要再取?
         * 1. 我們當然希望能拿到此刻的最新數據, events是會變的~
         * 2. 不是所有的poll實現, 都通過等待隊列傳遞了events, 有可能某些驅動壓根沒傳
         * 必須主動去讀取. */
        revents = epi->ffd.file->f_op->poll(epi->ffd.file, NULL) &
            epi->event.events;
        if (revents) {
            /* 將當前的事件和用戶傳入的數據都copy給用戶空間,
             * 就是epoll_wait()後應用程序能讀到的那一堆數據. */
            if (__put_user(revents, &uevent->events) ||
                __put_user(epi->event.data, &uevent->data)) {
                list_add(&epi->rdllink, head);
                return eventcnt ? eventcnt : -EFAULT;
            }
            eventcnt++;
            uevent++;
            if (epi->event.events & EPOLLONESHOT)
                epi->event.events &= EP_PRIVATE_BITS;
            else if (!(epi->event.events & EPOLLET)) {
                /* 嘿嘿, EPOLLET和非ET的區別就在這一步之差呀~
                 * 如果是ET, epitem是不會再進入到readly list,
                 * 除非fd再次發生了狀態改變, ep_poll_callback被調用.
                 * 如果是非ET, 不管你還有沒有有效的事件或者數據,
                 * 都會被重新插入到ready list, 再下一次epoll_wait
                 * 時, 會立即返回, 並通知給用戶空間. 當然如果這個
                 * 被監聽的fds確實沒事件也沒數據了, epoll_wait會返回一個0,
                 * 空轉一次.
                 */
                list_add_tail(&epi->rdllink, &ep->rdllist);
            }
        }
    }
    return eventcnt;
}

就是LT模式下,會把就緒鏈表取出來的epi再放回尾部,下次再wait到再判斷還有沒有可讀寫的事件。

 

什麼情況下用et比較好

作者:戈君
鏈接:https://www.zhihu.com/question/20502870/answer/142303523

    在eventloop類型(包括各類fiber/coroutine)的程序中, 處理邏輯和epoll_wait都在一個線程, ET相比LT沒有太大的差別. 反而由於LT醒的更頻繁, 可能時效性更好些. 在老式的多線程RPC實現中, 消息的讀取分割和epoll_wait在同一個線程中運行, 類似上面的原因, ET和LT的區別不大.
    但在更高併發的RPC實現中, 爲了對大消息的反序列化也可以並行, 消息的讀取和分割可能運行和epoll_wait不同的線程中, 這時ET是必須的, 否則在讀完數據前, epoll_wait會不停地無謂醒來.

 

看到官方文檔中:

Q9:  Do I need to continuously read/write a file descriptor until EAGAIN when using the EPOLLET flag (edge-triggered behavior) ?

A9:  Receiving an event from epoll_wait(2) should suggest to you that such file descriptor is ready for  the  requested  I/O  operation. You must consider it ready until the next (nonblocking) read/write yields EAGAIN.  When and how you will use the file descriptor is entirely up to you.

   For packet/token-oriented files (e.g., datagram socket, terminal in canonical mode),  the  only  way  to  detect  the  end  of  the read/write I/O space is to continue to read/write until EAGAIN.

   For  stream-oriented  files (e.g., pipe, FIFO, stream socket), the condition that the read/write I/O space is exhausted can also be detected by checking the amount of data read from / written to the target file descriptor.  For example, if  you  call  read(2)  by asking  to  read a certain amount of data and read(2) returns a lower number of bytes, you can be sure of having exhausted the read  I/O space for the file descriptor.  The same is true when writing using write(2).  (Avoid this latter technique if you cannot guarantee that the monitored file descriptor always refers to a stream-oriented file.)

    如同上文提到的,使用et模式,某個fd每次有事件發生時我們不比將其讀到EAGAIN才停止,而可以根據我們自己訂的協議,讀出緩衝區的一部分內容進行處理(放到另一個線程中處理),剩下一部分內容下一次再來讀。(eventloop類型的程序中,使用LT模式的話,本線程就會不斷地從epoll_wait中返回,佔滿cpu)。


EPOLLONESHOT

官方文檔中有這樣一段話:
    Since  even  with  edge-triggered  epoll,  multiple events can be generated upon receipt of multiple chunks of data, the caller has the option to specify the EPOLLONESHOT flag, to tell epoll to disable the associated file descriptor after the receipt  of  an  event  with epoll_wait(2).   When  the  EPOLLONESHOT  flag  is  specified,  it  is  the  caller's responsibility to rearm the file descriptor using epoll_ctl(2) with EPOLL_CTL_MOD.

    即使使用邊緣觸發的epoll,也可以在接收到多個數據塊時產生多個事件,因此調用者可以選擇指定EPOLLONESHOT標誌,告訴epoll在接收到epoll_wait(2)事件後禁用相關的文件描述符。當EPOLLONESHOT標記被指定時,調用者的責任是使用epoll_ctl(2)和EPOLL_CTL_MOD重新配置文件描述符。

 

  • Possible pitfalls and ways to avoid them

1. Starvation (edge-triggered)

    如果某fd有大量的I/O操作或是計算,那麼此線程將耗費大量時間處理此fd,其他fd可能不會得到處理,從而導致飢餓現象。(這個問題不是epoll特有的。)

    解決方案是維護一個就緒列表,並在其關聯的數據結構中將文件描述符標記爲ready,從而允許應用程序記住需要處理哪些文件,但仍然在所有就緒文件之間進行輪詢。這還支持忽略已經準備好的文件描述符接收到的後續事件。

2. If using an event cache

If you use an event cache or store all the file descriptors returned from epoll_wait(2), then make sure to provide a way  to  mark  its  closure  dynamically  (i.e., caused by a previous event's processing).  Suppose you receive 100 events from epoll_wait(2), and in event  #47 a condition causes event #13 to be closed.  If you remove the structure and close(2) the file descriptor for event #13,  then  your event cache might still say there are events waiting for that file descriptor causing confusion.          

    One  solution  for  this  is  to  call,  during  the  processing of event 47, epoll_ctl(EPOLL_CTL_DEL) to delete file descriptor 13 and close(2), then mark its associated data structure as removed and link it to a cleanup  list.   If  you  find  another  event  for  file  descriptor  13  in your batch processing, you will discover the file descriptor had been previously removed and there will be no confusion.(對此的一種解決方案是,在處理事件47期間調用epoll_ctl(EPOLL_CTL_DEL)刪除文件描述符13並關閉,然後將其關聯的數據結構標記爲已刪除,並將其鏈接到一個清理列表。如果您在批處理中發現文件描述符13的另一個事件,那麼您將發現該文件描述符已經被刪除,不會造成混淆。)

 

webbench使用:

 

./webbench -t 10 -c 100 --get http://127.0.0.1:8888/hello

 

 

muduo

muduo是一個基於Reactor模式的C++網絡庫。它採用非阻塞I/O模型,基於事件驅動和回調。我們不僅可以通過muduo來學習linux服務端多線程編程,還可以通過它來學習C++11。

我們可以知道,Reactor模式的基礎是事件驅動,事件源可以有多個,並且會併發地產生事件。Reactor模式的核心是一個事件分發器和多個事件處理器,多個事件源向事件分發器發送事件,並要求事件分發器響應,reactor模式的設計難點也是在事件分發器,它必須能夠有條不紊地把響應時間分派到合適的事件處理器中,保證事件處理的最小延遲。事件處理器主要是負責處理事件的業務邏輯,這是關係到具體事件的核心,因此和事件分發器不一樣,它並不太具有一般性。
這裏寫圖片描述

  Reactor模式的特點可以很自然地應用到C/S架構中。在C/S架構的應用程序中,多個客戶端會同時向服務端發送request請求。服務端接收請求,並根據請求內容處理請求,最後向客戶端發送請求結果。這裏,客戶端就相當於事件源,服務端由事件分發器和事件處理器組成。分發器的任務主要是解析請求和將解析後的請求發送到具體的事件處理器中。

從Reactor模式到C/S架構

從技術的層面來說,怎麼把“事件”這個概念放到“請求”上,也就是怎麼樣使得請求到來可以觸發事件,是一個難點。從設計的層面上來說,怎麼樣分發事件使得響應延遲最小,並保持高可擴展性是難點(架構能夠較好地適應各種事件的處理和事件數量的變化)。對於技術層面,linux上的解決方案是:epoll,select等。而設計層面,muduo提供了較好的解決方案。 
    Muduo的基礎設施是epoll,並在此基礎上實現了one-thread-one-loop和thread-pool設計方案。也就是將事件處理器設置成線程池,每個線程對應一個事件處理器;因爲事件處理器主要處理的是I/O事件,而且每個事件處理器可能會處理一個連接上的多個I/O事件,而不是處理完一個事件後直接斷開,因此muduo選擇每個事件處理器一個event-loop。這樣,連接建立後,對於這條連接上的所有事件全權由它的事件處理器在event-loop中處理。 
    我們可以根據上面的reactor架構圖,簡單地繪製出muduo的架構圖:
muduo架構圖

或是:

 

 

 

 

 

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