select/poll/epoll学习
IO模型
一次网络IO会涉及两个系统对象:
- 等待数据准备好
- 将数据从内核空间的buffer拷贝到用户空间进程的buffer
而这五种IO模型的特点就在于以怎样的方式来处理这两个系统对象和两个阶段.
Unix 有五种 I/O 模型:
- 阻塞式 I/O
- 非阻塞式 I/O
- I/O 复用(select 和 poll)
- 信号驱动式 I/O(SIGIO)
- 异步 I/O(AIO)
1. 阻塞式IO
应用进程被阻塞,直到数据从内核buffer复制到应用进程buffer中才返回。
特点:
- 在准备数据阶段:被阻塞
- 数据从内核buffer复制到用户态buffer阶段:被阻塞
-
recvfrom
执行结束之后才能之后后面的程序
2. 非阻塞式IO
用户态程序执行IO调用后,无论IO是否完成,都会立刻返回结果,应用程序需要不断的执行这个系统调用去获知IO是否完成。(注意,这里会返回IO是否已经完成的状态,而不是数据是否准备好)
特点:
- 在数据准备阶段:非阻塞式(会立刻返回一个错误码)
- 数据从内核buffer复制到用户态buffer阶段:阻塞式
-
recvfrom
会立刻返回结果,一般会用一个循环来不停的去判断IO是否完成。 - 实时性会比较好,但CPU利用率比较低
3. IO复用
让单个进程具有处理多个 I/O 事件的能力。又被称为 Event Driven I/O,即事件驱动 I/O。
如果一个 Web 服务器没有 I/O 复用,那么每一个 Socket 连接都需要创建一个线程去处理。如果同时有几万个连接,那么就需要创建相同数量的线程。相比于多进程和多线程技术,I/O 复用不需要进程线程创建和切换的开销,系统开销更小。
特点:
- 数据准备阶段:阻塞,并会返回一个“事件已经发生的信号”(这里的事件就是数据已经准备好了)
- 数据从内核buffer复制到用户态buffer阶段:阻塞
IO复用的实现有select/poll/epoll,后面会详细说
4. 信号驱动 I/O
个人理解 信号驱动IO = 事件驱动机制 + 非阻塞式IO
信号驱动IO是指:进程预先告知内核,使得 当某个socketfd有events(事件)发生时,内核使用信号通知相关进程。
因此通知完了之后,并不会被阻塞。
当内核通知相关进程,它感兴趣的事件发生了的时候(这里就是数据已经准备好了),然后再去做recvfrom,将数据从内核态复制到用户态。
特点:
- 数据准备阶段:非阻塞式。(因为只是向OS发送一个通知,立刻就返回了)
- 数据从内核buffer复制到用户态buffer阶段:阻塞式。
- 相比于前面的非阻塞式IO的轮询,信号驱动IO的CPU利用率更高
5. 异步IO
理解异步IO,先要理解异步,通俗来说就是,只要我触发了一IO调用,那么这个IO在这之后的任何一个时刻完成对我的程序都不会有影响,因此我就没必要非等你IO完了才继续往下执行,而是利用“委派”的思想,让内核去帮我完成.
因此,在调用完异步IO的系统调用(例如aio_read)之后,应用进程可以继续执行,不会被阻塞,内核会在所有操作完成之后向应用进程发送信号。
注意,异步IO也是 “事件驱动 + 非阻塞” , 但它和信号驱动IO的区别是, 异步 I/O 的信号是通知应用进程 I/O 完成,而信号驱动 I/O 的信号是通知应用进程可以开始 I/O。
特点:
- 数据准备阶段:非阻塞
- 数据从内核buffer复制到用户态buffer阶段:非阻塞
- 相当于把IO操作给委派出去了,所以自己完全不会被阻塞
五大 I/O 模型比较
- 同步 I/O:将数据从内核缓冲区复制到应用进程缓冲区的阶段(第二阶段),应用进程会阻塞。
- 异步 I/O:第二阶段应用进程不会阻塞。
同步 I/O 包括阻塞式 I/O、非阻塞式 I/O、I/O 复用和信号驱动 I/O ,
它们的主要区别在第一个阶段。
非阻塞式 I/O 、信号驱动 I/O 和异步 I/O 在第一阶段不会阻塞。
IO复用——select/poll/epoll
这三种对IO复用的实现方式的区别主要在于:
- 对socketfd的存储方式
- 以怎样的方式去通知用户级进程去获取已经发生的事件(或者说用户级进程用怎样的方式去获取已经放发生的事件)
1. select
int main()
{
char buffer[MAXBUF];
int fds[5];
struct sockaddr_in addr;
struct sockaddr_in client;
int addrlen, n,i,max=0;;
int sockfd, commfd;
fd_set rset;
for(i=0;i<5;i++)
{
if(fork() == 0)
{
child_process();
exit(0);
}
}
sockfd = socket(AF_INET, SOCK_STREAM, 0);
memset(&addr, 0, sizeof (addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(2000);
addr.sin_addr.s_addr = INADDR_ANY;
bind(sockfd,(struct sockaddr*)&addr ,sizeof(addr));
listen (sockfd, 5);
// ==================从这里开始=====================
// 初始化rset(32个长整型 = 一个1024的bitmap)
// 主要目的是获取最大的文件描述符
// 将打开的文件描述符fds_i对应的位置为1(linux默认最多打开1024个文件,因此这个文件描述符小于1024)
for (i=0;i<5;i++)
{
memset(&client, 0, sizeof (client));
addrlen = sizeof(client);
fds[i] = accept(sockfd,(struct sockaddr*)&client, &addrlen);
if(fds[i] > max)
max = fds[i];
}
// 开始监听事件
while(1){
FD_ZERO(&rset);
for (i = 0; i< 5; i++ ) {
FD_SET(fds[i],&rset);
}
puts("round again");
// 在这里会阻塞,当有事件发生时,会修改rset的值,
// 即发生事件的对应bit会被置位1,其他都被置为0
// 因此,每循环一轮都要对rset重新初始化一次
select(max+1, &rset, NULL, NULL, NULL);
// 遍历所有监听的文件描述符,若被置位了,那么就去进行IO
for(i=0;i<5;i++) {
if (FD_ISSET(fds[i], &rset)){
memset(buffer,0,MAXBUF);
read(fds[i], buffer, MAXBUF);
puts(buffer);
}
}
}
return 0;
}
优点:
- 每个操作系统都实现了select模式,可移植性较强
- 在一些监听事件个数比较少的情况下,也比较优秀
缺点:
- 因为对文件描述符的存储是一个1024的bitmap, 所以一个进程最多监听的事件个数被限制。
- 因为select每次都会对传入的readset进行一个修改,所以在每次轮询的时候,都要重新进行一次初始化,这也是比较低效的
- 事件监听的种类是通过传入fd_set类型的参数来进行的,因此不太好扩展.
- 每次都需要遍历所有的文件描述符才能知道事件发生的是哪一个socket,如果监听的事件比较多,且只有一个事件发生,那么也需要遍历全部,这样显然是很低效的。
- 在进行IO的时候,需要将数据从内核态复制到用户态,这一复制过程开销也比较大.
2. poll
poll的实现大体上和select类似,只是改变了对事件的存储形式,不再使用fd_set, 而是使用一个结构体来保存. 这个结构体中保存的数据主要有:
- 对应的文件描述符
- 被监听的事件类型
- 事件是否发生的一个标志位
struct pollfd {
int fd; // 对应的文件描述符
short events; // 被监听的事件类型
short revents; // 事件是否发生的一个标志位
};
for (i=0;i<5;i++)
{
memset(&client, 0, sizeof (client));
addrlen = sizeof(client);
pollfds[i].fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
pollfds[i].events = POLLIN;
}
sleep(1);
while(1){
// 这儿不再需要每次都初始化了
puts("round again");
poll(pollfds, 5, 50000);
for(i=0;i<5;i++) {
if (pollfds[i].revents & POLLIN){
pollfds[i].revents = 0; // 事件得到处理,标志为复原
memset(buffer,0,MAXBUF);
read(pollfds[i].fd, buffer, MAXBUF);
puts(buffer);
}
}
}
优点:
- 解决了select的监听事件个数存在上限问题,和每处理一轮请求需要重新初始化的问题。
缺点:
- 并不是所有系统都实现了poll模型,因此跨平台性较差
- 依然存在遍历所有事件 和 内核态数据复制到用户态的问题
3. epoll
3.1 epoll_create
int epoll_create(int size);
epoll_create() 该 函数生成一个epoll专用的文件描述符。它其实是在内核申请一空间,用来存放你想关注的socket fd上是否发生以及发生了什么事件。
3.1 epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll_ctl() ⽤于向内核注册新的描述符或者是改变某个文件描述符的状态。
3.1 epoll_wait
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
已注册的描述符在内核中会被维护在 ⼀棵红⿊树上,通过回调函数内核会将 I/O 准备好的描述符加入到⼀个链表中管理(这个链表就是传入的第二个指针参数events),进程调⽤ epoll_wait() 便可 以得到事件完成的描述符。
struct epoll_event events[5];
// epfd是一个文件描述符,指向一个内核中的文件区域
int epfd = epoll_create(10);
...
...
for (i=0;i<5;i++)
{
static struct epoll_event ev;
memset(&client, 0, sizeof (client));
addrlen = sizeof(client);
ev.data.fd = accept(sockfd,(struct sockaddr*)&client, &addrlen);
ev.events = EPOLLIN;
// 向epfd指向的那块区域中添加( EPOLL_CTL_ADD )一条 监听记录信息:
// < 需要被监听的文件描述符 (ev.data.fd), 正在监听的事件(ev) >
epoll_ctl(epfd, EPOLL_CTL_ADD, ev.data.fd, &ev);
}
while(1){
puts("round again");
// 等待正在监听的事件发生
// 若返回-1,则说明在监听时出现了中断或错误
// 若返回0,则说明超时,没有事件发生
// 若大于0,则表示正在监听的所有事件中,发生了的事件个数,并且会把发生了的事件放到
// 传入的这个events数组中
// ps: 这里的监听-复制,是使用硬中断来做的( 网卡收到新数据的时候,会向cpu发送一个中断信号,然后内核会帮我们把发生的事件直接复制到events数组中,这就省去了内核态到用户态复制的一个开销 )
// 原本是: 监听->保存到内核态空间->复制到用户态空间->使用
// 现在是: 监听->直接保存到用户态空间events
nfds = epoll_wait(epfd, events, 5, 10000);
// 读取events数组里的数据,然后进行处理
for(i=0;i<nfds;i++) {
memset(buffer,0,MAXBUF);
read(events[i].data.fd, buffer, MAXBUF);
puts(buffer);
}
}
优点:
- 不需要每次遍历所有监听的events,而只用去遍历已经发生的events
- 内核直接将准备好的数据写到了用户态,节省了将数据从内核态复制到用户态的开销
缺点:
- 只有linux上实现了epoll
select/poll/epoll的应用场景
很容易产生一种错觉认为只要用 epoll 就可以了,select 和 poll 都已经过时了,其实它们都有各自的使用场景。
1. select 应用场景
-
select 的 timeout 参数精度为微秒,而 poll 和 epoll 为毫秒,因此 select 更加适用于实时性要求比较高的场景,比如核反应堆的控制。
-
select 可移植性更好,几乎被所有主流平台所支持。
2. poll 应用场景
poll 没有最大描述符数量的限制,如果平台支持并且对实时性要求不高,应该使用 poll 而不是 select。
3. epoll 应用场景
-
只需要运行在 Linux 平台上,有大量的描述符需要同时轮询,并且这些连接最好是长连接。
-
需要同时监控小于 1000 个描述符,就没有必要使用 epoll,因为这个应用场景下并不能体现 epoll 的优势。
-
需要监控的描述符状态变化多,而且都是非常短暂的,也没有必要使用 epoll。 因为 epoll 中的所有描述符都存储在内核中,造成每次需要对描述符的状态改变都需要通过 epoll_ctl() 进行系统调用,频繁系统调用降低效率。并且 epoll 的描述符存储在内核,不容易调试。
上一篇: 初始三层架构(超超超详细)
下一篇: 渗透测试之POC基于布尔的sql盲注