使用环境:Ubuntu18.04
使用工具:VMWare workstations ,xshell
作者在学习Linux的过程中对常用的命令进行记录,通过思维导图的方式梳理知识点,并且通过xshell连接vmware中ubuntu虚拟机进行操作,并将练习的截图注解,每句话对应相应的命令,读者可以无障碍跟练。
本次练习的重点在于Linux的网络编程epoll。
这部分的内容是第十二篇的后续,考虑到内容太多,读起来很累,所以将epoll的部分分离放在这篇。
4 EPOLL多路复用
4.1 epoll介绍
epoll 是在 2.6 内核中提出的,是之前的 select 和 poll 的增强版本。相对于 select 和 poll 来说,epoll更加灵活,没有描述符限制。epoll 使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的 copy 只需一次。同时 epoll 的性能更好,因此在工作中首选 epoll。后面会介绍select和epoll的区别。
4.2 epoll接口的使用
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
int epoll_create(int size);
创建一个句柄,size告诉内核这个监听的数据一共有多大。不同于select的第一个参数,给出最大监听的fd+1的值。需要注意的是,当创建好epoll句柄后,它就是会占用一个fd值,在 linux 下如果查看/proc/进程 id/fd/,是能够看到这个 fd 的,所以在使用完 epoll 后,必须调用 close()关闭,否则可能导致 fd 被耗尽。int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
不同于select函数在监听时告诉内核要监听什么类型事件,而epoll是先注册监听的事件类型。
第一个参数是epoll的句柄
第二个参数表示动作用三个宏来表示,EPOLL_CTL_ADD:注册新的 fd 到 epfd 中;EPOLL_CTL_MOD:修改已经注册的 fd 的监听事件;EPOLL_CTL_DEL:从 epfd 中删除一个 fd;
第三个参数是需要监听的fd
第四个参数是告诉内核需要监听什么事,struct epoll_event结构体:
struct epoll_event {
__uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
其中的events可以是如下几个宏:
宏 | 作用 |
---|---|
EPOLLIN | 表示对应的文件描述符可以读(包括对端 SOCKET 正常关闭); |
EPOLLOUT | 表示对应的文件描述符可以写; |
EPOLLPRI | 表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来); |
EPOLLERR | 表示对应的文件描述符发生错误; |
EPOLLHUP | 表示对应的文件描述符被挂断; |
EPOLLET | 将 EPOLL 设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的。 |
EPOLLONESHOT | 只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个 socket 的话,需要再次把这个 socket 加入到 EPOLL 队列里 |
epoll的两种触发模式:
- LT(level triggered)水平触发,是缺省的工作方式,并且同时支持block和no-block socket.在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的。当epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用 epoll_wait 时,会再次响应应用程序并通知此事件。传统的select/poll都是这种模型的代表。
- ET (edge-triggered)边缘触发,是高速工作方式,只支持non-block socket。在这种模式下,当 epoll_wait 检测到描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用 epoll_wait 时,不会再次响应应用程序并通知此事件。
- ET 模式在很大程度上减少了 epoll 事件被重复触发的次数,因此效率要比 LT 模式高。epoll 工作在ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件句柄的阻塞读/阻塞写操作把处理多个文件描述符的任务饿死。
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待事件的发生,类似于select调用。参数 events 用来从内核得到事件的集合,maxevents 告之内核这个 events 有多大,这个 maxevents 的值不能大于创建 epoll_create()时的 size,参数 timeout 是超时时间(毫秒,0 会立即返回,-1 将不确定,也有说法说是永久阻塞)。该函数返回需要处理的事件数目,如返回 0 表示已超时。
4.3 示例:使用epoll实现即时聊天
通用头文件head.h
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdio.h>
#include <dirent.h>
#include <string.h>
#include <time.h>
#include <grp.h>
#include <pwd.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/select.h>
#include <sys/time.h>
#include <strings.h>
#include <sys/wait.h>
#include <sys/ipc.h>
#include <sys/shm.h>
#include <sys/sem.h>
#include <sys/msg.h>
#include <signal.h>
#include <pthread.h>
#include <errno.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>
#include <sys/epoll.h>
#define ARGS_CHECK(argc,num) {if(argc!=num) {printf("error args\n");return -1;}}
#define ERROR_CHECK(ret,retval,func_name) {if(ret==retval) {printf("errno=%d,",errno);fflush(stdout);perror(func_name);return -1;}}
#define THREAD_ERR_CHECK(ret,func_name) {if(ret!=0) {printf("%s failed,%d %s\n",func_name,ret,strerror(ret));return -1;}}
服务器端server.c
#include"head.h"
int main(int argc,char** argv)
{
ARGS_CHECK(argc,3);
int sfd;
sfd = socket(AF_INET,SOCK_STREAM,0); //socket描述符
ERROR_CHECK(sfd,-1,"socket");
printf("sfd = %d\n",sfd);
int reuse = 1,ret;
//bind绑定之前,先设定一下端口重用
ret = setsockopt(sfd,SOL_SOCKET,SO_REUSEADDR,&reuse,sizeof(int));
ERROR_CHECK(ret,-1,"setsockopt");
struct sockaddr_in ser_addr; //定义服务端描述结构体
bzero(&ser_addr,sizeof(ser_addr));//清空结构体
ser_addr.sin_family=AF_INET;//代表要进行ipv4通信
ser_addr.sin_addr.s_addr=inet_addr(argv[1]);//把ip的点分十进制转为网络字节序
ser_addr.sin_port=htons(atoi(argv[2]));//把端口转为网络字节序
ret = bind(sfd,(struct sockaddr*)&ser_addr,sizeof(ser_addr)); //绑定sfd
ERROR_CHECK(ret,-1,"bind");
ret = listen(sfd,10); //对sfd进行监听
ERROR_CHECK(ret,-1,"listen");
int new_fd; //新的传输文件的描述符
struct sockaddr_in client_addr;
bzero(&client_addr,sizeof(client_addr));
socklen_t addr_len=sizeof(client_addr);
new_fd = accept(sfd,(struct sockaddr*)&client_addr,&addr_len);//接收队列中的建立连接请求,没有就阻塞
ERROR_CHECK(new_fd,-1,"accept");
printf("client ip=%s,port=%d\n",inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port));
//编写即时聊天
char buf[128] = {0};
int epfd = epoll_create(1);//创建一个句柄,即epoll描述符
struct epoll_event event,evs[2];
//先注册标准输入输出
event.data.fd=STDIN_FILENO;
event.events=EPOLLIN;//监控是否可读
//用epfd描述符注册EPOLL_CTL_ADD一个监听STDIN_FILENO的事件event
ret = epoll_ctl(epfd,EPOLL_CTL_ADD,STDIN_FILENO,&event);
ERROR_CHECK(ret,-1,"epoll_ctl");
//再用该结构体注册new_fd,和标准输入用同一个epoll描述符
event.data.fd=new_fd;
event.events = EPOLLIN;
ret = epoll_ctl(epfd,EPOLL_CTL_ADD,new_fd,&event);
ERROR_CHECK(ret,-1,"epoll_ctl");
int ready_fd_num,i;
while(1){
//监听描述对应的事件,-1表示时间不确定,evs用于获取内核中epfd对应的2个事件
ready_fd_num = epoll_wait(epfd,evs,2,-1);
for(i = 0;i<ready_fd_num;i++){
if(evs[i].data.fd==STDIN_FILENO){ //如果是标准输入,就将内容读到缓冲区,并且发送给客户端
bzero(buf,sizeof(buf));
ret=read(STDIN_FILENO,buf,sizeof(buf));
if(!ret){
printf("服务端想断开连接\n");
return 0;
}
send(new_fd,buf,strlen(buf),0);
}
if(evs[i].data.fd==new_fd){ //如果new_fd可读,则读入到缓冲区,并且打印出来
bzero(buf,sizeof(buf));
//服务器接收数据
ret=recv(new_fd,buf,sizeof(buf),0);
ERROR_CHECK(ret,-1,"recv");
if(!ret) { //代表对方断开了
printf("客户端断开了连接\n");
return 0;
}
printf("客户端:%s\n",buf);
}
}
}
return 0;
}
客户端client.c
#include"head.h"
int main(int argc,char** argv)
{
ARGS_CHECK(argc,3);
int sfd;
sfd=socket(AF_INET,SOCK_STREAM,0);//初始化一个网络描述符,对应了一个缓冲区
ERROR_CHECK(sfd,-1,"socket");
printf("sfd=%d\n",sfd);
struct sockaddr_in ser_addr;
bzero(&ser_addr,sizeof(ser_addr));//清空
ser_addr.sin_family=AF_INET;//代表要进行ipv4通信
ser_addr.sin_addr.s_addr=inet_addr(argv[1]);//把ip的点分十进制转为网络字节序
ser_addr.sin_port=htons(atoi(argv[2]));//把端口转为网络字节序
//客户端就要去连接服务器
int ret=connect(sfd,(struct sockaddr *)&ser_addr,sizeof(ser_addr));
ERROR_CHECK(ret,-1,"connect");
//编写即时聊天
char buf[128]={0};
fd_set rdset;
while(1)
{
//清空集合并写入要监控的描述符
FD_ZERO(&rdset);
FD_SET(STDIN_FILENO,&rdset);
FD_SET(sfd,&rdset);
//监控哪一个描述符就绪
ret=select(sfd+1,&rdset,NULL,NULL,NULL);
if(FD_ISSET(STDIN_FILENO,&rdset))//如果标准输入可读
{
bzero(buf,sizeof(buf));
ret=read(STDIN_FILENO,buf,sizeof(buf));
if(!ret)
{
printf("I want go\n");
break;
}
send(sfd,buf,strlen(buf)-1,0);//发送对应的字符串到对端,不带\n
}
if(FD_ISSET(sfd,&rdset))//如果sfd可读
{
bzero(buf,sizeof(buf));
//服务器接收数据
ret=recv(sfd,buf,sizeof(buf),0);
ERROR_CHECK(ret,-1,"recv");
if(!ret)//代表对方断开了
{
printf("byebye\n");
break;
}
printf("%s\n",buf);
}
}
close(sfd);
}
4.4 epoll和select的优缺点
epoll和select都是Linux下的I/O多路复用机制,但是它们的实现方式不同。select使用的是轮询的方式,而epoll使用的是事件通知的方式。因此,epoll在处理大量连接时,效率更高,而且不会随着连接数的增加而降低效率。另外,epoll支持水平触发和边缘触发两种模式,而select只支持水平触发模式。
select和epoll都是用于I/O多路复用的机制,但是它们有一些区别:
select的优点:
-
select是标准的系统调用,几乎所有的操作系统都支持它;
-
select支持的文件描述符数量没有上限,可以处理大量的连接;
-
select可以同时处理多种类型的I/O事件,包括读、写和异常事件。
select的缺点:
-
select每次调用都需要将所有的文件描述符从用户空间复制到内核空间,效率较低;
-
select返回的文件描述符集合是一个线性的数组,每次遍历都需要遍历整个数组,效率较低;
-
select对文件描述符的监控是“水平触发”,即只要文件描述符上有数据可读或可写,就会一直通知应用程序,这会导致应用程序频繁地被唤醒。
epoll的优点:
-
epoll是Linux特有的系统调用,效率较高;
-
epoll使用“事件触发”的方式,只有当文件描述符上有数据可读或可写时才会通知应用程序,避免了频繁唤醒应用程序的问题;
-
epoll支持的文件描述符数量没有上限,可以处理大量的连接。
epoll的缺点:
-
epoll只能在Linux系统上使用,不具有通用性;
-
epoll的API比select复杂,使用起来较为困难;
-
epoll不能同时处理异常事件,需要额外的处理。