IO多路转接—epoll,并且实现epoll版本的http服务器
程序员文章站
2022-06-14 11:17:25
...
下面是关于高级IO我总结的一篇文章:
https://blog.csdn.net/qq_37941471/article/details/80952057
可以了解一下 五种IO模型 以及 它们之间的关系;
当然还有IO多路转接的其他实现方式:poll epoll 以及三者之间的对比
epoll
一. epoll的目的 :
1. 按照man手册来说:为了处理大批量句柄而改进的poll
简单的讲就是改进了poll的缺点,epoll是性能最好的多路IO就绪通知方法。
下面我讲一下我对select poll epoll的理解:
2. 首先这三个都是实现 IO多路转接 的方式:一个进程同时监视多个文件描述符
也就是三者之间的共同优点
1. select
缺点:
1. 代码编写复杂,维护起来较麻烦
2. 每次调用select,都需要重新设置文件描述符(从用户态拷贝到内核态),开销大
为什么需要重新设置?
因为select的输入输出都调用的是同一个函数select,并且输入和输出
是单独作为参数的这个时候我们就需要用一个第三方数组来保存之前的
所关心的文件描述符,以便进行select返回后,和fdset进行FDISSET
判断哪一个所监听的描述符哪个就绪,进行accept操作,并且方便下一次监听
3. 使用过程中,从内核遍历文件描述符,当fd很多的时候,则会开销很大
需要以轮询的方式去获取就绪的文件描述符
4. 能够接收的文件描述符有上限
因为有第三方数组去维护,而这个数组开的最大空间就是:sizeof(fd_set)*8
一般的操作系统,默认的是1024(一个bit位表示一个文件描述符
(因为fd_set的底层是一个位图))
2. poll
优点 :
1. select的输入输出都是调用一个函数,参数是分开的,用位图来描述,
使用起来开销会比较大;而poll使用一个pollfd的结构体来实现的
2. 解决了select能处理的文件描述符有上限的问题
因为poll解决了selec输入输出参数分开的问题,进而当然不需要再用第三方数组
去维护;所以poll能处理的文件描述符可以说是无上限了
(而这里肯定有它的一个上限,但是这个上限是操作系统的上限,和poll没有关系)
缺点:
除了解决了select的部分缺点以外,其他的缺点poll也是有的
3. epoll
在poll的基础上,又做了改进:处理了大批量句柄问题
所以这三个是一步一步改进的,最终epoll是最高效的IO多路的就绪通知机制;
(这个高效的基础是:多连接,少量活跃的机制;如果场景不合适的话,有可能适得其反)
二. epoll的3个函数及其功能 :
1. int epoll_create(int size) :
官方的讲:创建一个epoll的句柄
其实呢,它是创建了一个epoll模型:
1. 在操作系统内核构建一个红黑树
节点 : 表示要关心的哪个文件描述符的事件
key键 :用文件描述符作为key键
2. 在操作系统内核构建一个回调机制
作用:就是减少了操作系统的开销(不用操作系统再去轮询的找就绪事件)
有这么一个机制告诉我们,我们所关心的文件描述符的时间已经就绪
3. 在操作系统内核构建一个就绪队列
如何构建的:有了回调机制,告诉了我们所关心的文件描述符的事件已经就绪
接下来就是把该文件描述符拷贝到就绪队列中;等我们处理的时候
就不用轮询的去找就绪事件,而是 从就绪队列的开始找epoll_wait()
的返回值(>0,成功的情况下)这么大的一个区间,
这段区间就是当前的就绪事件
这三个组在一起的返回值是fd(文件描述符)
有些人说优点还有一个内存映射机制,这样的说法正确吗?:
1. 内存映射机制: 内存直接把就绪队列映射到用户态,
2. 但是我觉得这种说法是错误的。
1. 就绪队列是操作系统在管理
2. 而操作系统就不会把自己的内部暴露给用户态,如果暴露出去就不安全,
3. 我们在使用epoll_wait时,会告诉了我们所关心的文件描述符的事件已经就绪
而这个时候有一个回调机制会告诉我们:所关心的哪个文件描述符的事件已经就绪
不用操作系统一一去找,减少的操作系统的开销;
4. 接下来就是把该文件描述符拷贝到就绪队列中;等我们处理的时候
就不用轮询的去找就绪事件,而是 从就绪队列的开始找epoll_wait()
的返回值(>0,成功的情况下)这么大的一个区间,这段区间就是当前的就绪事件
3. 这个过程中,并没有映射,如果有映射的话,再传一个缓冲区,岂不是多此一举
2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event):
epoll的事件注册函数:要关心哪个文件描述符的事件
1. 第一个参数是epoll_create()的返回值:一个文件描述符;
2.第二个参数表示动作,三个宏来表示:
EPOLL_CTL_ADD:注册新的fd到epfd中;
EPOLL_CTL_MOD:修改已经注册的fd的监听事件;
EPOLL_CTL_DEL:从epfd中删除一个fd;
3. 第三个参数是需要监听的fd ;
4. 第四个参数是告诉内核需要监听什么事 .
events可以是以下几个宏的集合:
EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭);
EPOLLOUT:表示对应的文件描述符可以写;
EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这个应该表示有带外数据到来);
EPOLLERR:表示对应的文件描述符发⽣错误;
EPOLLHUP:表示对应的文件描述符被挂断;
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,
这是相对于水平触发(LevelTriggered)来说的。
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续
监听这个socket的话,需要再次把这个socket加入到EPOLL队列中。
3.int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
返回 :关心事件已经就绪的事件
1. > 0 :满足就绪条件的事件个数
2. 0 : 在规定的时间内没有事件发生(超出timeout设置的时间)
3. -1 :错误
原因由errno标识;此时中间三个参数的值变得不可预测。
三. epoll的工作方式 :
–
epoll的工作方式有两种:
1. 水平触发(LT): 默认的
2. 边缘触发(ET)
另外:selete 和 poll其实也是在LT工作模式下的;而epoll是可以支持LT,又可以支持ET
从我们的 epoll_ctl()中的第四个参数 struct epoll_event *event 可以看出,
这里的结构体epoll_event中有一个字段: events;而这里的参数有一个:
EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,
这是相对于水平触发(LevelTriggered)来说的。
这就是将LT默认的方式变成ET模式的方式。
下面我们来讲一下ET模式和LT模式的区别 :
LT模式 和 ET模式:
下面我们用一个快递员配送快递的例子来解释一下ET模式:
假如 :
1. 我有5个快递,当一个快递到的时候,快递员就打电话让你取,一直打直到你把这个快递取走为止,下一个你的来了依然如此;很显然这样的快递员工作方式效率会很慢。
上面的就是属于LT模式;
2.. 同样的,如果你有5个快递,当一个快递到的时候,快递员第一次给你送的的时候打一次电话,你不来他就替你收着(而这个时候,快递员不会等你),第二个你的来了再给你打一次,你不来他依然替你收着,每次只有快递数量变化的时候才会打电话,这个时候只有你哪一次有时间,将所有的快递都拿走。此种方式效率较高:因为快递员并没有去等
这种模式属于 ET模式。
下面我来介绍一下两者之间的特点:
LT模式 :
1. 当epoll检测到socket上的事件就绪时,可以不立即处理或者只处理一部分
(例如:2KB的数据好了,此时可以一次读1KB,然后剩1KB)
2. 在第二次调用epoll_wait的时候它依然会立即通知你,并且通知socket的读事件就绪
直到缓存区内的数据都读完了,epoll_wait才不会立即返回
3. 支持非阻塞与阻塞
ET模式 :
1. 当epoll检测到socket上的事件就绪时,必须立即处理
(例如:2KB的数据好了,此时可以一次读1KB,然后剩1KB)
2. 但是在第二次调用epoll_wait的时候,它不再立即返回通知你
也就是说,ET模式下,数据就绪以后只有一次处理机会,所以要么不读,要么读完,
不会有只读一部分的情况
(只有在数据从 无变有 或者 少变多 的时候,才会通知你)
3. 性能比LT高
4. 只能采用非阻塞
另外为什么ET模式只支持非阻塞读写呢?
因为: 数据就绪只通知一次,必须在通知后,一次处理完
也就是说:如果使用ET模式,当数据就绪的时候就要一直读,直到数据读完为止
1. 但是如果当前的fd是阻塞的,而读是循环的:那么在读完缓存区的时候,
如果对端每一偶数据在写进来,那么该read函数就会一直阻塞,
这不符合逻辑,不能这么使用
2. 那么就需要将fd设置成非阻塞,当没有数据的时候,read虽然读取不到任何的数据,
但是肯定不会被阻塞住,那么此时说明缓冲区内数据已经读完,read返回继续后序的逻辑
实现epoll版本的http服务器
Makefile :
.PHONY:epoll_server clean
epoll_server:epoll_server.c
gcc -o aaa@qq.com $^
clean:
rm -rf epoll_server
epoll_server.c :
#include <sys/epoll.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/select.h>
#include <netinet/in.h>
#include <arpa/inet.h>
void handler_events(int epfd,struct epoll_event revs[],int num,int listen_sock)
{
struct epoll_event ev;
int i = 0;
for( ; i < num; i++ )
{
int fd = revs[i].data.fd;
// 如果是监听文件描述符,则调用accept接受新连接
if( fd == listen_sock && (revs[i].events & EPOLLIN) )
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
int new_sock = accept(fd,(struct sockaddr *)&client,&len);
if( new_sock < 0 )
{
perror("accept fail ... \n");
continue;
}
printf("get a new link![%s:%d]\n",inet_ntoa(client.sin_addr),ntohs(client.sin_port));
//因为只是一个http协议:连接成功后,下面就是要 请求和响应
// 而服务器端响应之前:要先去读客户端要请求的内容
ev.events = EPOLLIN;
ev.data.fd = new_sock;
epoll_ctl(epfd,EPOLL_CTL_ADD,new_sock,&ev);
continue;
}
// 如果是普通文件描述符,则调用read提供读取数据的服务
if(revs[i].events & EPOLLIN)
{
char buf[10240];
ssize_t s = read(fd,buf,sizeof(buf)-1);
if( s > 0 )// 读成功了
{
buf[s] = 0;
printf(" %s ",buf);
// 读成功后,就是要给服务端响应了
// 而这里的事件是只读事件,所以要进行修改
ev.events = EPOLLOUT;// 只写事件
ev.data.fd = fd;
epoll_ctl(epfd,EPOLL_CTL_MOD,fd,&ev);// 其中EPOLL_CTL_MOD 表示修改
}
else if( s == 0 )
{
printf(" client quit...\n ");
close(fd);// 这里的fd 就是 revs[i].fd
epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL);// 连接关闭,那么就要把描述该连接的描述符关闭
}
else// s = -1 失败了
{
printf("read fai ...\n");
close(fd);// 这里的fd 就是 revs[i].fd
epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL);// 连接关闭,那么就要把描述该连接的描述符关闭
}
continue;
}
// 服务器端给客户端响应: 写
if( revs[i].events & EPOLLOUT )
{
const char* echo = "HTTP/1.1 200 ok \r\n\r\n<html>hello epoll server!!!</html>\r\n";
write(fd,echo,strlen(echo));
close(fd);
epoll_ctl(epfd,EPOLL_CTL_DEL,fd,NULL);
}
}
}
int startup( int port )
{
// 1. 创建套接字
int sock = socket(AF_INET,SOCK_STREAM,0);//这里第二个参数表示TCP
if( sock < 0 )
{
perror("socket fail...\n");
exit(2);
}
// 2. 解决TIME_WAIT时,服务器不能重启问题;使服务器可以立即重启
int opt = 1;
setsockopt(sock,SOL_SOCKET,SO_REUSEADDR,&opt,sizeof(opt));
struct sockaddr_in local;
local.sin_family = AF_INET;
local.sin_addr.s_addr = htonl(INADDR_ANY);// 地址为任意类型
local.sin_port = htons(port);// 这里的端口号也可以直接指定8080
// 3. 绑定端口号
if( bind(sock,(struct sockaddr *)&local,sizeof(local)) < 0 )
{
perror("bind fail...\n");
exit(3);
}
// 4. 获得监听套接字
if( listen(sock,5) < 0 )
{
perror("listen fail...\n");
exit(4);
}
return sock;
}
int main(int argc,char* argv[] )
{
if( argc != 2 )
{
printf("Usage:%s port\n ",argv[0]);
return 1;
}
// 1. 创建一个epoll模型: 返回值一个文件描述符
int epfd = epoll_create(256);
if( epfd < 0 )
{
perror("epoll_create fail...\n");
return 2;
}
// 2. 获得监听套接字
int listen_sock = startup(atoi(argv[1]));//端口号传入的时候是以字符串的形式传入的,需要将其转为整型
// 3. 初始化结构体----监听的结构列表
struct epoll_event ev;
ev.events = EPOLLIN;//关心读事件
ev.data.fd = listen_sock;// 关心的描述文件描述符
// 4. epoll的事件注册函数---添加要关心的文件描述符的只读事件
epoll_ctl(epfd,EPOLL_CTL_ADD,listen_sock,&ev);
struct epoll_event revs[128];
int n = sizeof(revs)/sizeof(revs[0]);
int timeout = 3000;
int num = 0;
while(1)
{
// 5 . 开始调用epoll等待所关心的文件描述符集就绪
switch( num = epoll_wait(epfd,revs,n,timeout) )
{
case 0:// 表示词状态改变前已经超过了timeout的时间
printf("timeout...\n");
continue;
case -1:// 失败了
printf("epoll_wait fail...\n");
continue;
default: // 成功了
handler_events(epfd,revs,num,listen_sock);
break;
}
}
close(epfd);
close(listen_sock);
return 0;
}
测试代码:
1. 运行服务器端:
2. 运行客户端:
在手机上连接服务器端,ip就是自己的电脑的ip,这里我的是:192.168.43.97 端口是8080
另外保证客户端和服务器端在一个局域网内,简单点说就是手机和电脑连接的是一个无线网