day3-高并发epoll
之前的实现只能帮助我们处理一个客户端的链接,仅有一个客户端独占服务器是极其浪费资源的做法。那么如何实现为多个客户端服务,就需要用到epoll。
按照我的理解,这玩意就是一个由端口组成的红黑树,只不过是被监视的树。
1、错误检测机制
同day2,这部分代码会发生的改动不大,以后就不再写了。
void errif(bool condition, const char *errmsg){
if(condition){
perror(errmsg);
exit(EXIT_FAILURE);
}
}
2、服务端
day4之后会逐渐将面向过程的代码改为面向对象的代码,即封装起来,一定要搞清楚它们之间的关系,谁用到谁,谁是谁的参数,谁是epoll的拥有者,谁是epoll的节点。
依旧不变的步骤:创建套接字,初始化服务器地址,绑定socket到固定ip端口,监听事件。
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
errif(sockfd == -1, "socket create error");
struct sockaddr_in serv_addr;
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
serv_addr.sin_port = htons(8888);
errif(bind(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr)) == -1, "socket bind error");
errif(listen(sockfd, SOMAXCONN) == -1, "socket listen error");
之后是创建epoll,记得我之前说过,再linux中万物皆是文件,既然是文件,文件描述符必不可少,接下来创建epoll的socket。
int epfd = epoll_create1(0);
errif(epfd == -1, "epoll create error");
之后需要创建两个epoll_event 变量,注意这两个的含义,如果不懂的话会引起很大的麻烦,其中events是用于读取事件并存储的,ev是用以将时间注册到epoll中的。epoll一定要把时间通知形式改为边缘触发,不然阻塞太严重。
struct epoll_event events[MAX_EVENTS], ev;
bzero(&events, sizeof(events));
bzero(&ev, sizeof(ev));
ev.data.fd = sockfd;
ev.events = EPOLLIN | EPOLLET;
之后将服务器的socket设置为无阻塞模式,在epoll中需要对所有端口进行监听,如果不将每一个socket设置为无阻塞模式,会导致端口一直等待,造成整个服务器阻塞。不符合我们的目的。
setnonblocking(sockfd);
//这里是setnonblocking函数的定义
void setnonblocking(int fd){
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);
}
之后将服务器socket放入epoll中。
epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev);
之后是对链接到服务器的客户端进行处理,这里为了保证一直轮询,使用一个死循环,接下来的代码都在一个死循环中。
epoll_wait是在读取所有就绪事件,返回的是事件数量
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
errif(nfds == -1, "epoll wait error");
接下来对就绪事件进行轮询
for(int i = 0; i < nfds; ++i)
第一个判断,查看事件是否是新客户端的连接操作。当事件socket为服务器时代表着新的连接产生,至于为什么,我也不知道。
操作基本没怎么变,创建客户端地址,通过系统获得客户端socket,之后将ev初始化(记得ev的含义是什么),之后将套接字设置为无阻塞,将该文件置于红黑树中。
if(events[i].data.fd == sockfd){ //新客户端连接
struct sockaddr_in clnt_addr;
bzero(&clnt_addr, sizeof(clnt_addr));
socklen_t clnt_addr_len = sizeof(clnt_addr);
int clnt_sockfd = accept(sockfd, (sockaddr*)&clnt_addr, &clnt_addr_len);
errif(clnt_sockfd == -1, "socket accept error");
printf("new client fd %d! IP: %s Port: %d\n", clnt_sockfd, inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port));
bzero(&ev, sizeof(ev));
ev.data.fd = clnt_sockfd;
ev.events = EPOLLIN | EPOLLET;
setnonblocking(clnt_sockfd);
epoll_ctl(epfd, EPOLL_CTL_ADD, clnt_sockfd, &ev);
}
第二个判断,EPOLLIN下如果不是新的连接到来的话,那么就是可读事件的到来,写着写着上面那个不懂的问题就明白了。
既然是读取事件,少不了的是缓冲区,这里缓冲区大小一定要小于客户端的缓冲区,不然会导致下面三种可能
1、服务器回显的数据无法完整进入客户端内存,导致阻塞
2、客户端频繁请求服务端的处理降低服务端的处理效率
else if(events[i].events & EPOLLIN){ //可读事件
char buf[READ_BUFFER];
while(true){ //由于使用非阻塞IO,读取客户端buffer,一次读取buf大小数据,直到全部读取完毕
bzero(&buf, sizeof(buf));
ssize_t bytes_read = read(events[i].data.fd, buf, sizeof(buf));
if(bytes_read > 0){
printf("message from client fd %d: %s\n", events[i].data.fd, buf);
write(events[i].data.fd, buf, sizeof(buf));
} else if(bytes_read == -1 && errno == EINTR){ //客户端正常中断、继续读取
printf("continue reading");
continue;
} else if(bytes_read == -1 && ((errno == EAGAIN) || (errno == EWOULDBLOCK))){//非阻塞IO,这个条件表示数据全部读取完毕
printf("finish reading once, errno: %d\n", errno);
break;
} else if(bytes_read == 0){ //EOF,客户端断开连接
printf("EOF, client fd %d disconnected\n", events[i].data.fd);
close(events[i].data.fd); //关闭socket会自动将文件描述符从epoll树上移除
break;
}
}
}
3、客户端
客户端没啥变化,短期内也不会变化,就是这样
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
errif(sockfd == -1, "socket create error");
struct sockaddr_in serv_addr;
bzero(&serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
serv_addr.sin_port = htons(8888);
errif(connect(sockfd, (sockaddr*)&serv_addr, sizeof(serv_addr)) == -1, "socket connect error");
while(true){
char buf[BUFFER_SIZE]; //在这个版本,buf大小必须大于或等于服务器端buf大小,不然会出错,想想为什么?
bzero(&buf, sizeof(buf));
scanf("%s", buf);
ssize_t write_bytes = write(sockfd, buf, sizeof(buf));
if(write_bytes == -1){
printf("socket already disconnected, can't write any more!\n");
break;
}
bzero(&buf, sizeof(buf));
ssize_t read_bytes = read(sockfd, buf, sizeof(buf));
if(read_bytes > 0){
printf("message from server: %s\n", buf);
}else if(read_bytes == 0){
printf("server socket disconnected!\n");
break;
}else if(read_bytes == -1){
close(sockfd);
errif(true, "socket read error");
}
}
close(sockfd);
return 0;
}
4、补充
我发现之前在介绍Epool的过程中缺乏对I/O复用的解释,现在来补充一下
(1)I/O模式
a、阻塞式IO
b、非阻塞式IO
c、IO复用
d、信号驱动式IO
e、异步IO
五种IO模式这张图总结的很棒,可以看得出来四种同步模型的区别在于第⼀阶段等待数据的处理⽅式不同,第⼆阶段均为将数据从内核空间复制到⽤户空间缓冲区期间,进程阻塞于recvfrom调⽤。
我们这里使用的是Epool实现的IO复用,这是Linux中特有的方法,在大多数系统中使用的仍是select/poll
select 包括1、将⽂件描述符集合拷贝到内核⾥,让内核来检查是否有⽹络事件产⽣;2、通过遍历,有事件产⽣就把此socket标记为可读/可写,然后再整个拷贝回⽤户态;3、⽤户态还需要遍历找到刚刚标记的socket。
poll 使用动态数组,以链表形式来组织,相⽐于select,没有⽂件描述符个数限制,当然还会受到系统⽂件描述符限制。
Epoll只需要将socket 加⼊内核中的红⿊树⾥而不需要整体拷贝,而且不需要轮询只返回有事件发生的socket个数
select 内核需要将消息传递到⽤户空间,都需要内核拷贝动作;epoll通过内核和⽤户空间共享⼀块内存来实现
的。
重要!!!select 只⼯作在低效的LT模式,epoll 可以在 ET ⾼效模式⼯作。
以上这些应该将IO和Epoll讲的很清楚了,这也是计算机原理里的一部分,放在应用里比死记硬背强很多。