目录
1 几种IO工作模式
(1)阻塞等待
阻塞的时候不占用CPU时间片(进程阻塞态)。意思是当有连接请求的时候再唤醒服务端。
如果只有一个连接请求这种方式尚可,如果有多个的话,同一时刻只能处理一个,解决方法是主进程或者主线程只负责阻塞等待,一旦收到连接请求就用子进程或者多线程的方式去处理该连接。
2 非阻塞,忙轮询
优点是提高了程序的执行效率(执行起来更快?而阻塞等待的方式用多进程或者多线程创建进程线程都是需要时间的)
缺点是需要占用更多的cpu和系统资源
忙轮询处理多个连接请求也不合适,因为也是一次只能处理一个连接。
2 IO多路转接 select/poll/epoll
IO多路转接就是委托内核处理业务,处理完返回处理结果。
第一种 select/poll
IO多路转接即委托内核帮忙检测连接的客户端有多少要跟服务器进程通信,select/epoll方式内核会告诉服务端进程有多少进程要跟你通信,但是不会说是哪些进程,具体是哪一个,需要去遍历一遍。这种方式中遍历的是线性表。
第二种 epoll
内核不仅告诉服务端有多少客户端要通信,还会指出是哪些客户端。这种方式中用的是红黑树。
IO多路复用基本做法
(1)先构造一张有关文件描述符的列表,把我们希望内核来帮忙检测的文件描述符添加到该表中。
(2)调用一个函数(select/poll/epoll),始终检测该表中的文件描述符,直到这些文件描述符中的其中一个进行IO操作时(检测IO操作即检测服务端读缓冲区,如果缓冲区中有数组说明要通信,注意,缓冲区是与文件描述符关联的,每一个文件描述符都对应自己的缓冲区),该函数才返回。
需要注意该函数为阻塞函数,且该函数对文件描述符的检测操作是由内核完成的。
(3)函数返回时,它告诉进程有多少(哪些)描述符要进行IO操作。
3 select函数
select函数定义
int select(int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout);
nfds:要检测的文件描述符中最大的fd再加1,可以直接填1024
readfds:读集合,内核会检测读集合中的每个文件描述符在服务端对应的读缓冲区,看有无数据
write:写集合,一半穿NULL,因为写操作一半是主动发起的,不用检测
exceptfds:异常集合,可能有些文件描述符会发生异常
timeout:
传NULL:永久阻塞,当检测到fd变化的时候返回。
timeout:timeval结构体对象,用来设置阻塞时间,如果秒数和微秒数都置零,则不阻塞。
struct{
long tv_sec; // 秒数
long tv_usec; // 微秒数
}
fd_set是文件描述符集,其对应一个含有1024个标志位,编号0到1023的表,对应进程的文件描述符表。需要注意的是,fd_set中存放的都是server端的用于通信的文件描述符。fd_set内部是用数组实现的,大小限制在了1024。
文件描述符集fd_set操作函数
全部清空
void FD_ZERO(fd_set *set);
从集合中删除某一项
void FD_CLR(int fd, fd_set *set);
将某个文件描述符添加到集合
void FD_SET(int fd, fd_set *set);
判断某个文件描述符是否在集合中
int FD_ISSET(int fd, fd_set *set);
select工作过程
设有客户端A,B,C,D,E,F连接到服务器,分别对应服务端用于通信的文件描述符3,4,100,101,102,103。假设在委托内核检测的时候,正好ABC三个客户端发送了数据。
第一步,创建fd_set类型的表。
fd_set reads;
第二步,把需要检测的文件描述符添加到reads中。
FD_SET(3, &read);
FD_SET(4, &read);
FD_SET(100, &read);
......
第三步,调用select函数。
select(103+1, &reads, NULL, NULL, NULL);
阻塞等待,知道检测到缓冲区变化才返回。
因为调用select之后就要委托内核工作了,我们原先的表reads是在用户空间的,调用之后内核会拷贝一份到内核空间。内核做两件事,一件事拿到初始表,一件是修改这个表并返回。如下所示,左边是内核拿到的初始表,然后内核开始检测哪个文件描述符对应的缓冲区有数据。我们的例子中是客户端ABC对应的缓冲区有数据,因此文件描述符对应的3,4,100仍保持是1,而检测的其它位则修改为0,如右边所示,是内核修改过的表,select函数返回后,reads中存放的已经是修改过的表了,所以reads也可以认为是一个传入传出参数吧。我们需要在一开始备份一下原始的reads,不然select之后就已经改变了。
我们拿到修改后的表reads之后,只要用FD_ISSET来遍历reads中的每一位,看看哪一位是1,就可以知道哪些客户端请求通信。
select多路转接代码
伪代码
int main(){
int lfd = socket(); //创建套接字,lfd是用于监听的文件描述符
bind(); // 绑定端口和IP
listen(); // 监听
// 创建fd_set表,其实等价于一张文件描述符表,之所以定义两个是因为其中一个要做备份
fd_set reads, temp;
// 表初始化
FD_ZERO(&read);
// 将监听的文件描述符lfd加入表,因为当有客户端申请连接的时候,也是发送一个SYN数据包到服务端的读缓冲区,所以可以通过select来检测这个缓冲区有无数据来判断是否有客户端请求连接。
FD_SET(lfd, &read);
int manfd = lfd;
// 调用一次select肯定不够,需要循环调用select
while(1){
// 委托内核检测
temp = reads; // reads用来作备份,temp作为传入传出参数
// 当select返回之后,temp的值就已经发生改变了
int ret = select(maxfd + 1, &temp, NULL, NULL, NULL);
// 根据返回结果,先判断有没有新的连接到达,即lfd监听描述符对应的缓冲区有无数据
if(FD_ISSET(lfd, &temp)){
// 如果检测到监听描述符缓冲区有新数据,则说明有了新连接,接受新连接
int cfd = accept(); // 这个时候accept就不会阻塞了,因为内核已经检测到有新连接来了
// cfd对应一个新的客户端的通信用描述符,将其加入reads,用来检测该客户端是否发送数据
fd_set(cfd, &reads);
// 读集合中加入新的文件描述符后最大描述符很大可能会改变,更新maxfd
maxfd = maxfd < cfd ? cfd : maxfd;
}
// 检测客户端是否发送数据
for(int i = lfd + 1; i <= maxfd; ++i){
// 遍历select返回后修改过的temp,看哪个客户端有数据
if(FD_ISSET(i, &temp)){
int len = read();
if(len == 0){ // 说明客户端断开连接?为什么?
FD_CLR(i, &reads); // 将断开连接的客户端对应通信描述符清除
}
write(); // 通信,往客户端写数据
}
}
}
}
select优缺点
优点:跨平台
缺点:
每次调用select,都要把fd_set集合从用户态拷贝到内核态,fd很多时开销会很大,频繁地进行用户空间和内核空间之间的转换本就很耗时,拷贝开销也是不能忽视的。。
每次调用select都需要在内核遍历传递进来的所有fd文件描述符,这个开销在fd很多时也很大,线性遍历开销很大。
select支持的文件描述符数量太少了,默认1024,fd_set底层是用数组实现的。
4 poll函数
poll函数定义
int poll(struct pollfd *fd, nfds_t nfds, int timeout);
fd:pollfd类型数组指针,实际使用时传递pollfd类型数组名,自动退化成指针
nfds:数组中最后一个使用的元素的下标+1,内核会轮询检测fd数组的每个文件描述符
timeout:
-1:永久阻塞
0:调用完成立即返回
>0:等待的时长,单位毫秒
返回值:IO发送变化的文件描述符的个数
pollfd结构体定义为:
struct pollfd{
int fd; // 文件描述符
short events; // 等待的事件
short revents; // 实际发生的事件,是内核给的反馈,类似函数的传入传出参数
}
pollfd结构体相当于把select中的读,写,异常,三种集合合并了,每个pollfd结构体对象都有一个fd参数,然后events参数用来选择该fd是在读集合中,还是写集合,还是异常集合,当然也可以三种都有,用 | 把选项连起来即可。
poll优缺点
优点:
都说poll底层对于文件描述符是链式存储的,因此没有select中对于最大连接数量的限制,但是我看pollfd结构体中也没有指针成员啊,不知道这个链表到底是怎么做的。
缺点:
大量的fd的数组(链表?)被整体复制于用户态和内核地址空间之间,开销大。
遍历fd的开销也很大。
“水平触发”:即如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
水平触发&边缘触发
水平触发:当检测到某描述符事件就绪并通知应用程序时,应用程序可以不立即处理该事件,下次调用时,会再次通知该事件。
边缘触发:当检测到某描述符事件就绪并通知应用程序时,应用程序必须立即处理该事件,如果不处理,下次调用不会再通知该事件。也就是说边缘触发只在状态由未就绪变为就绪时通知一次。
5 epoll函数
主要是三个函数。
epoll_create函数
创建底层红黑树
int epoll_create(int size);
size:计划需要检测的文件描述符的最大数量,但是实际上这个参数填小了也没关系,会自动扩充的。
返回值:文件描述符
该函数生成一个epoll专用的文件描述符,因为在底层epoll是以红黑树的形式组织文件描述符的,调用epoll_create这个函数相当于构造了一个红黑树的根节点。在我看来调用epoll_create相当于就是创建了一棵红黑树,将来可以在这颗树上插入删除结点等等。
epoll_ctl函数
把要检测的结点插入到红黑树上或从树上删除
epoll_ctl用于控制某个epoll文件描述符事件,可以注册、修改、删除
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epfd:传入epoll_create的返回值
op:用三个宏控制注册修改和删除结点
EPOLL_CTL_ADD --- 注册
EPOLL_CTL_MOD --- 修改
EPOLL_CTL_DEL --- 删除
fd:要在红黑树上插入/删除/修改的文件描述符
event:epoll底层红黑树上真正的结点,是epoll_event结构体类型的对象
epoll_event结构体定义如下:
struct epoll_event{
uint32_t events;
epoll_data_t data;
}
对于结构体中成员:
events:用来设置检测文件描述符对应的哪种事件,如下宏,可以用 | 隔开来同时检测多个事件
EPOLLIN----读
EPOLLOUT---写
EPOLLERR---异常
EPOLLPRI---对应文件描述符有紧急数据可读
EPOLLHUP---对应文件描述符被挂断
EPOLLET---将EPOLL设置为边缘触发模式
EPOLLONESHOT---只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket,需要再次把这个socket加入到EPOLL队列中。
data:是epoll_data_t类型的联合体
typedef union epoll_data{
void *ptr; // 如果要描述结点的更多的信息就用这个成员
int fd; // 一般都用这个成员
uint32_t u32;
uint64_t u64;
}
每次调用epoll_ctl都会把参数event拷贝到红黑树上作为一个结点,当检测到文件描述符发生变化时再拷贝到epoll_wait函数的参数events数组中去,就可以知道时哪些文件描述符发生了变化。
epoll_wait函数
相当于前边的select或poll函数,用于检测IO事件的发生,上边的epoll_create和epoll_ctl都是在为这一步做准备。
int epoll_wait(
int epfd,
struct epoll_event *events,
int maxevents,
int timeout
);
epfd:epoll_create的返回值,红黑树的根节点
events:epoll_event类型结构体数组,当检测到某些文件描述符有IO事件,就将这些文件描述符对应的
epoll_event类型的结点复制到这个数组中,即这个数组是一个传入传出参数,正是由于这个数组的存在,
epoll才能标出是哪些文件描述符发生了变化。
maxevents:events数组的大小,用来控制往数组里拷贝元素时不要溢出。
timeout:
-1:永久阻塞
0:立即返回
>0:超时时间,过了这个事件就返回
返回值:有多少个文件描述符状态发生了变化
epoll三种工作模式
水平触发模式LT(默认)
如下图,客服端每次发送100个字节的数据,服务端一次只能读出来50个字节的数组。当客户端发送数据后,epoll_wait检测到读缓冲区有数据,epoll_wait函数返回,然后服务端去读数据,等到一次读完50个字节,进行下一次循环,然后epoll_wait又检测到同样的文件描述符对应的读缓冲区有数据(上次剩下的那50个字节),于是epoll_wait又返回,继续读剩下的50个字节。这就是水平触发模式。
水平触发模式特点:
(1)只要fd对应的缓冲区有数据,epoll_wait就返回。
(2)返回的次数与发送的次数没有关系,一次发送可能会对应多次epoll_wait返回。
边缘触发模式ET
客户端给服务端发数据:
客户端发送一次,服务端就返回一次,也就是说服务端返回的次数严格由客户端发送次数决定。
如果客户端一次发送的数据太多,服务端没有一次性读完呢?那么剩余数据仍然留在缓冲区中,等到客户端再一次发送数据的时候,这些缓冲区中的数据会被读出来,也就是说,虽然客户端又发送了一次数据,但是服务端读到的可能只是上次没有读完的留在缓冲区中的数据。
这样客户端发送一次,服务端epoll_wait就返回一次,如果客户端每次发送的数据都特别多,那会不会造成数据最终没有读完呢?答案是肯定的,但是边缘触发模式是不在乎数据是否读完的,边缘触发模式的存在是为了提升效率,因为epoll_wait调用次数越多,系统的开销就越大,使用边缘触发模式减少epoll_wait的调用次数。
边缘非阻塞触发
上边的边缘触发模式默认文件描述符具有阻塞属性。
上边提到的边缘触发模式中,读不完数据是因为服务端一次读的太少了,如果用while(recv())一次多读一点,是不是可以把数据都读出来呢?因为边缘触发下文件描述符fd具有阻塞属性,因此当一次数据读完以后recv()函数会阻塞,因此再也没法往下运行,当然也不能再调用epoll_wait函数来委托内核检测了。
因此需要第三种模式,即边缘非阻塞触发,即让文件描述符fd具有非阻塞属性。
边缘非阻塞触发是效率最高的。
两种方式来设置边缘触发模式下对应的文件描述符非阻塞。
(1)通过open()函数
设置open()函数中flags参数,设置为 O_WDRW | O_NONBLOCK
(2)通过fcntl设置
第一步,获取当前flag:int flag = fcntl(fd, F_GETFL);
第二步,给flag加上非阻塞属性: flag |= O_NONBLOCK;
第三步,把flag设置回去:fcntl(fd, F_SETFL,flag);
6 文件描述符突破1024限制
select:无法突破1024限制,因为是用数组实现的,如果要修改,需要改变数组大小然后重新编译内核。
poll:可以突破1024,因为内部链表实现。
epoll:可以突破1024,因为内部红黑树实现。
能支持的最大文件描述符数目与具体的机器也有关系,可以通过查看一个配置文件:
不管怎么配置修改文件描述符的上限值,都不能超过这个数。