Netty系列整体栏目
内容 | 链接地址 |
---|---|
【一】深入理解网络通信基本原理和tcp/ip协议 | https://zhenghuisheng.blog.csdn.net/article/details/136359640 |
【二】深入理解Socket本质和BIO | https://zhenghuisheng.blog.csdn.net/article/details/136549478 |
【三】深入理解NIO的基本原理和底层实现 | https://zhenghuisheng.blog.csdn.net/article/details/138451491 |
【四】深入理解反应堆模式的种类和具体实现 | https://zhenghuisheng.blog.csdn.net/article/details/140113199 |
【五】深入理解直接内存与零拷贝 | https://zhenghuisheng.blog.csdn.net/article/details/140721001 |
【六】select、poll和epoll多路复用的区别 | https://zhenghuisheng.blog.csdn.net/article/details/140795733 |
深入理解直接内存与零拷贝
- 一.深入理解select、poll和epoll多路复用的区别
- 1,多路复用-select
- 2,多路复用-poll
- 3,多路复用-epoll
- 4,select和poll慢的原因
- 5,epoll快的原因
- 6,总结
一.深入理解select、poll和epoll多路复用的区别
在前面几篇文章中,了解到nio网络变成中使用了反应堆模式,在反应堆中除了读写事件之外,处理一些业务事件采用的io就是多路复用io,在多路复用io中,主要有三种方式,分别是:select、poll和epool 三种模式。接下来分别讲解这三种模式的区别和优缺点。
在讲解三种模式之前,再谈一下在BIO中的读写数据的场景。在阻塞IO的bio中,并没有引入多路复用,也就是说在读取数据时,只需要执行一条read命令即可,但是在nio中,以select为例,那么就还需要执行一条额外的select命令,效率如何先不讲,相对于bio来说,其效率肯定是快于nio的,因为少执行了一条命令。但是上面的情况只适用于数据量少的场景,如果请求数打起来,那么这个bio的效率也是不行的,因此网络编程的更高的关注度还是在BIO编程上面,通过多路复用,可以通过很少的线程数量处理很多的服务和请求。
因此如果是并发量较少的场景,可以优先选择使用BIO;并发量较大的场景,优先选择使用NIO。
1,多路复用-select
示例代码如下,如在Linux下面的一段c语言代码示例,fd表示文件描述符,在unix或者linux中,一切东西都可以通过文件表示,设计的初衷就是一切皆文件,每个文件有一个标识符,被称为fd
#include <stdio.h>
#include <sys/select.h>
#include <unistd.h>
#include <fcntl.h>
int main() {
fd_set read_fds;
int max_fd, retval;
struct timeval timeout;
FD_ZERO(&read_fds);
FD_SET(STDIN_FILENO, &read_fds);
max_fd = STDIN_FILENO;
timeout.tv_sec = 5;
timeout.tv_usec = 0;
//执行select
retval = select(max_fd + 1, &read_fds, NULL, NULL, &timeout);
return 0;
}
select是最早提出来并实现的一种多路复用的机制,因此这种机制比较通用,在大量的领域以及不同的操作系统中都支持这种方式,这种 支持率最广泛。
但是通过上面这段代码可知,在需要执行一条select函数时,需要传递多个参数,需要传递的文件描述符有多个,但是一个进程打开这个文件描述符的个数有限,默认每个进程最大只能打开1024给文件,其次是每次找到对应的文件描述符时,需要经过一些遍历这些全部的文件,效率也比较慢。总结就是:一个进程打开文件有限,最多只能1024个;每次要遍历全部文件,查询效率慢
2,多路复用-poll
上面的pollfd使用数组来实现,每个并且设置了数组的容量大小,poll直接通过动态数组实现,这样可以保证每个进程打开的文件数是远高于固定数组的
#include <stdio.h>
#include <poll.h>
#include <unistd.h>
int main() {
struct pollfd fds[1];
int retval;
fds[0].fd = STDIN_FILENO;
fds[0].events = POLLIN;
retval = poll(fds, 1, 5000);
}
但是除了上面的数组优化之外,其其他的工作方式还是和原来的poll的方式一样,并且需要去轮训一个更大的数组,这也在找对应的注册事件需要消耗的时间也更多,因此也被淘汰
3,多路复用-epoll
经过长时间的发展,最终引入了epoll模型,epoll模型是poll模型的升级版本,将pool一个函数要做的事情拆分成了三件,分别是:创建、注册、等待 ,让每一件事情都更加细致,灵活度更高。除此之外,上面两个在数据拷贝时,需要从用户态切换到内核态,但是在epoll中通过共享区间来实现,从而加快整体效率
#include <stdio.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <unistd.h>
#define MAX_EVENTS 10
int main() {
//参数定义
int epoll_fd, nfds, i;
struct epoll_event event, events[MAX_EVENTS];
//创建
epoll_fd = epoll_create1(0);
event.data.fd = STDIN_FILENO;
event.events = EPOLLIN;
//注册
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, STDIN_FILENO, &event) == -1) {
perror("epoll_ctl()");
exit(EXIT_FAILURE);
}
//等待
nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, 5000);
close(epoll_fd);
return 0;
}
4,select和poll慢的原因
在研究为什么epoll快之前,先研究一下其他两个为什么慢。如下图所示,在客户端向服务端发起请求之后,首先会先通过三次握手建立连接,三次握手成功之后,如服务端首先会在工作队列中创建一个工作线程,并且会创建一个ServerSocket用于接收客户端的数据,socket中除了读写Buffer之外,还会有一个线程等待列表,用于标识该socket于哪个线程对应,由于此时线程A创建的socket未监听到外部的连接,因此此时线程等待列表为空
如服务端的一段伪代码,首先创建一个客户端的socket连接,然后监听是否有客户端连接这个socket,如果有的话则读取数据
ServerSocket socket = new ServerSocket("80"); //创建一个socket连接
socket.accept(); //监听连接
socket.read(); //读取数据
首先第一行代码服务端创建一个socket,此时服务端创建一个线程A,并且在对应的进程中创建一个Socket对象,并且在内部会有一个线程的等待列表,由于此时并未有客户端来连接,因此该列表为空
在前面bio文章说过,服务端在没有被客户端请求时,则处于阻塞状态。当执行到第二句监听时,那么线程A就会挂起处于阻塞状态,并且从工作队列中加入到阻塞队列,线程A也不会消耗时间片。此时没有外部的客户端来连接,因此在线程等待列表此时也为空
当外部有客户端来连接时,此时服务端这边监听到了数据,那么会执行第三句代码,读取客户端传来的数据。与此同时,线程A又会从阻塞状态变为就绪状态并加入到工作队列里面。并且在服务端对应的的socket中,线程等待列表中会将客户端的目标ip和目标端口号记录,这样就可以知道线程A对应的socket是哪一个
此时读数据的过程就来了,线程A需要定位到是哪一个Socket,那么就需要根据socket里面的等待列表中的ip和端口号进行匹配,假设此时就是已经连接了1024个socket,如下面这段伪代码,就是需要任意一个请求都是要遍历全部的socket,因此效率是比较慢的
//1024个socket数组
int fds[] = {socket,...}
struct thread;
for(int i = 0; i< fds.size ; i++){
//匹配逻辑,找到线程A对应的socket
if(fds[i].host == thread.host && fds[i].port == thread.port){
//删除socket中的等待列表
fds[i].list.remove;
//read读取数据
}
}
因此select慢是因为数组固定+循环找socket是比较慢的;而poll对人支持的是动态数组,但是也需要遍历全部socket,然后通过host主机+端口号找到对应的socket,并且随着动态数组的容量越大,寻找的时间也会相对较长,因此select和poll相对来说都是比较慢的
5,epoll快的原因
快慢是相对的,在得知select和poll慢的原因之后,那么如何优化这种效率慢的问题,epoll的出现成为关键。在很多架构中,当存在解决不了的问题时,那么就会考虑在中间加一层,如熟悉的缓存等。
根据poll对select的优化之后可知,此时存在的瓶颈是遍历时需要找数据比较就,那么能不能通过加一层中间件,提前将外部要连接的socket加入到一个缓存队列里面,这样每次就不需要去全部的socket列表中找对应的socket,直接从缓存队列那数据岂不是更快,当然,epoll也是这样设计的。
epoll的设计如下,在原有工作队列和Socket列表的基础上,增加了一个中间层eventPool列表。首先创建socket和监听数据是和select的流程一样的,主要是第二和第三步,当有数据来之前和之后,底层是如何实现的,其原理如下:在监听的时候,线程A会阻塞,并且此时线程A加入到eventPoll列表中
当客户端socket往服务端发送数据时,服务端会通过 中断机制 去给对应的进程去接收数据,线程A由阻塞状态变为就绪状态加入到工作队列中,此时会触发Socket列表中的对应socket的readBuffer接收数据,并且此时会将刚刚的socket加入到eventPoll的socket列表中
在线程A被唤醒之后,需要从全部的socket中找到对应的线程的socket,用于处理数据,发送数据等。用上面的select方式需要遍历全部的socket,但是通过epoll方式只需要遍历eventPoll中的socket即可,这样就大大的节省了找到socket的时间。select需要遍历1024个socket,而socket只需要在socket列表中查找,可能就只有1,2个socket,因此epoll的的效率远高于select。因此所谓epoll能支持百万连接时完全有可能的,因为那一刻发送数据的连接并不太多。
eventPoll中的Socket采用双向联表的方式实现,在该列表中,不会存真正的进程中的socket,而是会存一个引用地址,可以通过该地址快速的找到该线程对应的Socket。如线程A通过eventPoll中的socket列表找到socket1,此时socket1中,只存了一个真正的 serverSocket 的一个地址,通过这个地址快速定位到具体的serverSocket,而不需要像select模式一样,需要将socket列表中的全部socket列表遍历一遍
eventpoll类似于一块共享内存,用于多个进程间的通讯。
6,总结
- select模式是最早实现的多路复用的模式,因此支持的应用程序最多,应用也是最广泛的。但是效率也相对地下,其一是该文件描述符是有限的,默认最大为1024,内部使用固定数组控制,其二是在线程被唤醒之后定位socket耗时长,需要通过线程中的四元组定位,并且需要遍历全部的文件描述符。四元组指的是: 源ip、源端口号、目标ip、目标端口号
- poll模式在select的模式进行了优化,内部将固定数组改成了动态数组,可以支持更多的文件描述符,但也还存在线程唤醒定位socket的问题,需要遍历全部的socket,并且随着socket支持的数量大于1024,因此提供四元组在遍历数据时也会很慢,影响整体性能
- epoll模式为了解决前二者问题,内部也使用动态数组解决文件描述符不够的问题,同时在socket和线程之间加了一层,让被唤醒的socket在定位线程时,不需要去遍历全部的socket,而是只需要遍历中间层中的socket即可,这样大大的减少比遍历全部socket的时间消耗。通过高效的io事件通知机制,来处理大量文件描述符的场景