1. 什么是IO多路轉(zhuǎn)接
IO操作方式有兩種
阻塞等待
- 優(yōu)點:不占用CPU時間片
- 缺點:同一時刻只能處理一個操作,效率低下
非阻塞(忙輪詢)
- 優(yōu)點是提高了程序的執(zhí)行效率,缺點是需要占用更多的CPU和系統(tǒng)資源
- 只有一個任務時
- 多個任務
對于非阻塞方式多任務的場景,也就是上圖中的情況,解決方法是使用IO多路轉(zhuǎn)接技術(shù),常用的IO多路轉(zhuǎn)接技術(shù)包括select/poll/epoll。
select/poll ? ?—— ? ? 實現(xiàn)方式為線性表遍歷
在通信的時候,委托內(nèi)核去檢測連接到server的client,有哪些client是在通信的,比如說有10個client連接,但是只有6個發(fā)送了數(shù)據(jù),要把這6個client找出來,這個工作由內(nèi)核去做。但是內(nèi)核只能給出發(fā)送數(shù)據(jù)的client的個數(shù)6,至于是哪6個client,需要進程自己去遍歷。
在這兩種方式下,可以這么理解,select 代收員比較懶, 她只會告訴你有幾個快遞到了,但是具體是哪個快遞,你需要挨個遍歷一遍。
實際上,多路轉(zhuǎn)接就是進程委托內(nèi)核去做一些事情,在進程中只要調(diào)用select/poll/epoll就可以了,這樣就實現(xiàn)了多任務的處理。
epoll ? ?—— ? ?通過紅黑樹實現(xiàn)
epoll代收快遞員很勤快,她不僅會告訴你有幾個快遞到了,還會告訴你是哪個快遞公司的快遞。
通過上面介紹已經(jīng)大體了解了多路轉(zhuǎn)接是什么,那么多路轉(zhuǎn)接技術(shù)是怎么工作的呢?
先構(gòu)造一張有關文件描述符的列表,將要監(jiān)聽的文件描述符添加到該表中。(類似于阻塞信號集)
然后調(diào)用一個函數(shù),監(jiān)聽該表中的文件描述符,直到這些描述符表中的一個進行I/O操作時,該函數(shù)才返回。(select/poll/epoll).
該函數(shù)為阻塞函數(shù)
- 函數(shù)對文件描述符的檢測操作是由內(nèi)核完成的
- 在返回時,它告訴進程有多少(哪些)描述符要進行I/O操作。
- 文件描述符對應的是內(nèi)核緩沖區(qū),監(jiān)聽文件描述符,實際上就是監(jiān)聽內(nèi)核緩沖區(qū)的read區(qū),因為read區(qū)有數(shù)據(jù)就說明有進程給我發(fā)送數(shù)據(jù)。
- select/poll會返回發(fā)生IO操作的進程個數(shù);
- epoll返回發(fā)生IO操作的進程個數(shù),以及是哪些進程。
2. IO多路轉(zhuǎn)接技術(shù)——select詳解
(1)select()函數(shù)詳解
- 函數(shù)原型
?int?select(?int?nfds,?
? ? ? ? fd_set *readfds, ??/*傳入傳出參數(shù) | 傳入傳出參數(shù):傳入函數(shù)之前,指針指向的內(nèi)存就已經(jīng)有值了,函數(shù)執(zhí)行完畢后,這個內(nèi)存的值可能發(fā)生變化,并通過指針傳遞出來。*/
? ? ? ? fd_set *writefds,
? ? ? ? ? ? ? fd_set *exceptfds,?
struct?timeval *timeout );
- 函數(shù)參數(shù)
nfds:要檢測的文件描述符中最大的fd+1 —— 可以直接傳1024(文件描述符最大是1023,+1就是1024),因為內(nèi)核要做遍歷,所以它需要一個最大值來作為遍歷的終點。
readfds:讀集合,重點關注,因為判斷其他進程有沒有給當前發(fā)送數(shù)據(jù)就是看讀緩沖區(qū)有沒有數(shù)據(jù),讀緩沖區(qū)有數(shù)據(jù)說明有進程連接并發(fā)送數(shù)據(jù)通信,這是被動的,是當前進程無法預知的,所以要把文件描述符放入到讀集合中,讓內(nèi)核檢測讀緩沖區(qū)什么時候有數(shù)據(jù)。也就是告訴內(nèi)核,只檢測文件描述符對應的讀緩沖區(qū)。
——我們想知道對方有沒有發(fā)數(shù)據(jù),所以讓內(nèi)核檢測文件描述符對應的讀緩沖區(qū)是否有數(shù)據(jù),所以要把文件描述符放到讀集合中。讀集合的類型是一個fd_set(fd_set數(shù)據(jù)類型在內(nèi)核中是用一個數(shù)組實現(xiàn)的,數(shù)組大小是1024),這個集合所能存放的文件描述符的個數(shù)最大是1024個。內(nèi)核檢測的方式,是把這些文件描述符放到一個線性表中,然后遍歷線性表。
文件描述符集類型:fd_set readfds;fd_set數(shù)據(jù)類型的內(nèi)核代碼如下,通過下面的內(nèi)核代碼可以看出,使用select多路轉(zhuǎn)接的時候,最多只能委托內(nèi)核檢測1024個文件描述符,這是內(nèi)核決定的。
writefds: 寫集合,寫是進程主動動作,不需要去檢測,一般傳NULL。(寫集合作用:讓內(nèi)核只檢測文件描述符對應的寫緩沖區(qū))
exceptfds: 異常集合,不關心異常傳NULL(讓內(nèi)核只檢測文件描述符是否發(fā)生異常),如果想要捕捉對文件描述符的異常操作就要把它加到異常集合中。
timeout: ?設置select是否阻塞
NULL: 永久阻塞
當檢測到fd變化的時候返回(緩沖區(qū)數(shù)據(jù)變化)
struct timeval timeout;
timeout.tv_sec = 10,阻塞10s,10s后不管fd是否變換,都會返回,也就是說,只有到達指定時間才會返回。
timeout.tv_usec = 0;
?settitimer()
struct?{
long? ? tv_sec; ? ? ? ? ? ? ? ? ? ?
long? ? tv_usec; ? ? ? ? ? ?
? ? ? };
/*賦值的時候,秒和微秒都要賦值,因為最終結(jié)果是二者之和,否則得到的就是一個隨機數(shù)。*/
- 函數(shù)返回值
檢測的文件描述符集合中,只要有一個fd變化了,select函數(shù)就返回。
有幾個文件描述符發(fā)生變化,就返回幾,然后再通過遍歷,把變化的fd找出來。
(2)文件描述符操作函數(shù)
- 全部清空
void FD_ZERO(fd_set ? ? ?*set); //所有標志位清0
- 從集合中刪除某一項
void FD_CLR(int fd, ? ? ?fd_set *set); //在set中清除fd
- 將某個文件描述符添加到集合
void FD_SET(int fd, fd_set *set);
判斷某個文件描述符是否在集合中
int FD_ISSET(int fd, fd_set *set); //fd對應集合中的標志位是0則返回0,是1就返回1
(3)使用select函的優(yōu)缺點
- 優(yōu)點:跨平臺
- 缺點:
每次調(diào)用select,都需要把fd集合從用戶態(tài)拷貝到內(nèi)核態(tài),這個開銷在fd很多時會很大(內(nèi)核態(tài)到用戶態(tài)的頻繁切換,以及fd集合從用戶態(tài)和內(nèi)核態(tài)之間的復制)。
同時每次調(diào)用select都需要在內(nèi)核遍歷傳遞進來的所有fd,這個開銷在fd很多時也很大,客戶端越多select的效率越低,并且隨著進程的增多,效率下降的越來越快。——對于前兩個缺點,poll和select都有這兩個缺點,但是epoll沒有,因為select/poll在用戶和內(nèi)核有兩塊內(nèi)存,所以需要來回復制,而epoll是內(nèi)核和用戶使用同一塊共享內(nèi)存。
select支持的文件描述符數(shù)量太小了,默認是1024。poll不受1024的影響,但是poll不可以跨平臺,其他方面二者差不多。(select中的fd_set是用數(shù)組實現(xiàn)的,而poll用的是鏈表實現(xiàn)的,所以不受限制。epoll就更厲害了,用的是樹來實現(xiàn)的)?!獙嶋H上進程中文件描述符最多是1024個,這個數(shù)字是可以修改的,只要修改相應的配置文件,重啟電腦就好了。
(4)select工作過程分析
首先假設客戶端A、B、C、D、E、 F連接到服務器,分別對應文件描述符 3、4、100、101、102、103(fd都是server端的,每有一個client連接到server,都會產(chǎn)生一個用于通信的fd)。
現(xiàn)在,server通過select函數(shù)來委托內(nèi)核去檢測客戶端ABCDEF是否給server發(fā)數(shù)據(jù)了。
fd_set reads, temp; —— 文件描述符表reads,存放在用戶空間;內(nèi)核會拷貝一份,復制到內(nèi)核區(qū)。因為在內(nèi)核中會修改這個表并覆蓋原來的reads,所以我們需要提前備份一下原始表temp。
FD_SET(3, &reads); —— 調(diào)用6次把3、4、100、101、102、103依次加入reads集合。
select(103+1, &reads, NULL, NULL, NULL);
103+1表示要檢測的文件描述符中數(shù)字最大的fd+1,來指定遍歷的終點。
reads是傳入傳出參數(shù),內(nèi)核會對拿到的初始表進行修改,根據(jù)讀緩沖區(qū)是否有數(shù)據(jù)將相應的位分別置1或者清0,然后用修改后的表覆蓋傳入的初始表reads,并作為傳出參數(shù)傳出。
在上面的圖中
文件描述符0、1、2分別是標準輸入、標準輸出、標準錯誤,所以供我們使用的文件描述符是從數(shù)字3開始的。
被修改后的表在內(nèi)核中,它會再一次拷貝,并放到用戶區(qū),且覆蓋原來的reads,這時候的reads是內(nèi)核處理后的(fd變化則保留1,否則清0),所以只要遍歷reads,就可以找出發(fā)送數(shù)據(jù)的client,reads相應位值為1的文件描述符對應的client發(fā)送了數(shù)據(jù)。那么我們就對應的執(zhí)行read操作,去讀數(shù)據(jù)。
select中傳入的參數(shù)nfds是104,所以內(nèi)核會遍歷檢測0-103文件描述符,先檢測文件描述符標志位是不是1,如果是1再去檢測fd對應的讀緩沖區(qū)有沒有數(shù)據(jù),有數(shù)據(jù)說明和該fd通信的client發(fā)送數(shù)據(jù)了。
client連接server的時候會進行三次握手,發(fā)送FIN數(shù)據(jù)包到server的監(jiān)聽文件描述符lfd對應的讀緩沖區(qū)中。所以,要想知道有沒有client發(fā)出連接請求,就要把lfd放到讀集合中,讓內(nèi)核去檢測。也就是說,有沒有連接請求也是委托內(nèi)核去檢測。
(5)select多路轉(zhuǎn)接代碼實現(xiàn)
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
#include<sys/types.h>
#include<string.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<ctype.h>
intmain(int?argc, constchar* argv[])
{
if(argc <?2)
? ? {
printf("eg: ./a.out portn");
exit(1);
? ? }
structsockaddr_inserv_addr;
socklen_t?serv_len =?sizeof(serv_addr);
int?port =?atoi(argv[1]);
// 創(chuàng)建套接字
int?lfd =?socket(AF_INET, SOCK_STREAM,?0);
// 初始化服務器 sockaddr_in?
memset(&serv_addr,?0, serv_len);
? ? serv_addr.sin_family = AF_INET;?// 地址族?
? ? serv_addr.sin_addr.s_addr =?htonl(INADDR_ANY);?// 監(jiān)聽本機所有的IP
? ? serv_addr.sin_port =?htons(port);?// 設置端口?
// 綁定IP和端口
? ??bind(lfd, (struct?sockaddr*)&serv_addr, serv_len);
// 設置同時監(jiān)聽的最大個數(shù)
? ??listen(lfd,?36);
printf("Start accept ......n");
structsockaddr_inclient_addr;
socklen_t?cli_len =?sizeof(client_addr);
// 最大的文件描述符
int?maxfd = lfd;
// 文件描述符讀集合
? ? fd_set reads, temp;
// init 初始化
? ??FD_ZERO(&reads);
? ??FD_SET(lfd, &reads);
while(1)
? ? {
// 委托內(nèi)核做IO檢測
? ? ? ? temp = reads;
//在Linux下maxfd必須寫正確,要及時更新;在Windows下可以隨便寫
int?ret =?select(maxfd+1, &temp,?NULL,?NULL,?NULL);
if(ret ==?-1)
? ? ? ? {
? ? ? ? ? ??perror("select error");
exit(1);
? ? ? ? }
// 客戶端發(fā)起了新的連接?
// 用于監(jiān)聽的文件描述符有且只有1個lfd,lfd對應位為1,說明有新的連接請求
if(FD_ISSET(lfd, &temp))
? ? ? ? {
// 接受新連接,返回一個用于通信的cfd,并加入到原始的讀集合reads(備份)
// 接受連接請求 - accept不阻塞 //因為只要進入if語句,就說明有新連接
int?cfd =?accept(lfd, (struct?sockaddr*)&client_addr, &cli_len);
if(cfd ==?-1)
? ? ? ? ? ? {
? ? ? ? ? ? ? ??perror("accept error");
exit(1);
? ? ? ? ? ? }
char?ip[64];
printf("new client IP: %s, Port: %dn",?
? ? ? ? ? ? ? ? ? ?inet_ntop(AF_INET, &client_addr.sin_addr.s_addr, ip,?sizeof(ip)),
? ? ? ? ? ? ? ? ? ?ntohs(client_addr.sin_port));
// 將cfd加入到待檢測的讀集合中 - 下一次就可以檢測到了
// 下次循環(huán)的時候,如果cfd發(fā)生變化就可以檢測到,當前循環(huán)是檢測不到的,這也說明select是異步的。
? ? ? ? ? ??FD_SET(cfd, &reads);
// 更新最大的文件描述符//maxfd決定了內(nèi)核遍歷檢測的范圍
? ? ? ? ? ? maxfd = maxfd < cfd ? cfd : maxfd;
? ? ? ? }
// 已經(jīng)連接的客戶端有數(shù)據(jù)到達
// 需要遍歷去判斷哪個client通信的cfd發(fā)生了變化(說明通信了),變化則read讀取數(shù)據(jù)。
// i為啥是從lfd+1開始的?
// 因為lfd是第一個創(chuàng)建的文件描述符,而文件描述符創(chuàng)建的規(guī)則是當前最小空閑,所以lfd+1應該就是第一個用于通信的文件描述符cfd。
for(int?i=lfd+1; i<=maxfd; ++i)
? ? ? ? {
if(FD_ISSET(i, &temp))
? ? ? ? ? ? {
char?buf[1024] = {0};
int?len =?recv(i, buf,?sizeof(buf),?0);
if(len ==?-1)
? ? ? ? ? ? ? ? {
? ? ? ? ? ? ? ? ? ??perror("recv error");
exit(1);
? ? ? ? ? ? ? ? }
elseif(len ==?0)
? ? ? ? ? ? ? ? {
printf("客戶端已經(jīng)斷開了連接n");
? ? ? ? ? ? ? ? ? ??close(i);
// 從讀集合中刪除
? ? ? ? ? ? ? ? ? ??FD_CLR(i, &reads);
? ? ? ? ? ? ? ? }
else
? ? ? ? ? ? ? ? {
printf("recv buf: %sn", buf);
? ? ? ? ? ? ? ? ? ??send(i, buf,?strlen(buf)+1,?0);
//strlen(buf)不包括'',所以需要+1,并且前提是buf已經(jīng)被初始化為0
//必須把''發(fā)出去來表示字符串結(jié)束,否則數(shù)據(jù)可能出錯(比實際數(shù)據(jù)長),出現(xiàn)亂碼
? ? ? ? ? ? ? ? }
? ? ? ? ? ? }
? ? ? ? }
? ? }
? ??close(lfd);
return0;
}