首先我们要了解概念:
同步和异步:
描述的是用户线程与内核的交互方式:同步是指用户线程发起IO请求后需要等待或者轮询内核IO操作完成后才能继续执行;而异步是指用户线程发起IO请求后仍继续执行,当内核IO操作完成后会通知用户线程,或者调用用户线程注册的回调函数。
阻塞和非阻塞:
描述的是用户线程调用内核IO操作的方式:阻塞是指IO操作需要彻底完成后才返回到用户空间;而非阻塞是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成。
常用的IO模型有以下几种:
-
同步阻塞IO(Blocking IO):即传统的IO模型。
同步阻塞 -
同步非阻塞IO(Non-blocking IO):默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK。
同步非阻塞 -
IO多路复用(IO Multiplexing):即经典的Reactor设计模式,有时也称为异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型。
IO多路复用
本文重点分析IO多路复用:
常用的IO多路复用方式有select,poll以及epoll,为什么会有三种方式是因为这三种方式出现的事件有前后,最开始select,poll为select的优化版本,epoll则是颠覆版本,性能最佳,目前解决IO复用基本上都是用的epoll。
select,poll和epoll都是内核层面的,来让我们解开他们的面纱。
对于服务器来说,accept是阻塞的,当有客户端传来连接并建立连接完成之后,accept才会完成,然后获取到已经建立连接的这个socket的文件描述符,后续的read和write就是根据此文件描述符来操作的,read和write可以设置为阻塞或者非阻塞,上面的同步非阻塞IO是开了多个线程,然后每个线程循环访问内核看是否有数据到来,可以发现没有访问都使用了一次read系统调用,这是非常占用资源的,而且开多线程也比较浪费资源,为解决这个问题,把这个循环访问放到内核态怎么样,这样既可以减少系统调用,也可以减少用户态太多线程的使用。我们先来看下select
select
select 是操作系统提供的系统调用函数,通过它,可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理:
对应服务器端,要开两个线程,一个线程用来不断接受客户端的连接,并且把socket文件描述符放到一个list里面。
while(1) {
connfd = accept(listenfd);
fcntl(connfd, F_SETFL, O_NONBLOCK);
fdlist.add(connfd);
}
然后另外开一个线程不用自己循环遍历,而是调用select系统调用,交给操作系统来遍历list中的文件描述符。
当select返回的时候,代表已经检测到有数据接收了,但是select不会返回具体哪一个文件描述符对应的socket收到数据了,而是返回收到数据的socket的文件描述符的数量,如上图所示,select系统调用返回2,然后用户态还要遍历刚刚的list
while(1) {
nready = select(list);
// 用户层依然要遍历,只不过少了很多无效的系统调用
for(fd <-- fdlist) {
if(fd != -1) {
// 只读已就绪的文件描述符
read(fd, buf);
// 总共只有 nready 个已就绪描述符,不用过多遍历
if(--nready == 0) break;
}
}
}
从上面的分析中我们能够看出select中的几个特点:
- 虽然用户不用遍历,但是需要将list数组拷贝到内核中,让内核来遍历,但是在高并发的场景下,数组的拷贝会消耗很多资源。
- 虽说是把遍历放到了内核态,但是select实际上还是遍历,对于内核来说是同步的过程,只是减少了系统调用切换上下文的开销。
- select系统调用返回的是可读文件描述符的个数,具体哪个可读还是得用户来遍历判断。
select的优点:
(1)可以一次等待多个套接字socket。
(2)减少系统调用的使用,节省系统资源。
(3)减少线程的使用,节省资源。
select的缺点:
(1)select支持监听的socket文件描述符数量太少,最多1024个。
(2)每次select系统调用都会把文件描述符fd的数组从用户态拷贝到内核态,这个开销在fd很多时会很大。
(3)每次select系统调用返回文件描述符的数量,用户态还要遍历fd数组,开销在fd很多时也很大。
针对select的缺点,poll增添了支持fd的数量,去掉了select只能监听1024个文件描述符的限制,但是没有解决核心问题,epoll应时而生
epoll
针对于上文所说的select的三个特点
- 虽然用户不用遍历,但是需要将list数组拷贝到内核中,让内核来遍历,但是在高并发的场景下,数组的拷贝会消耗很多资源。
- 虽说是把遍历放到了内核态,但是select实际上还是遍历,对于内核来说是同步的过程,只是减少了系统调用切换上下文的开销。
- select系统调用返回的是可读文件描述符的个数,具体哪个可读还是得用户来遍历判断。
epoll针对这三点都进行了改进。
1.内核中存储了一份文件描述符fd的集合,无需用户每次都从用户态传入,只需要告诉内核修改的部分就可以。
2.内核中不再通过轮询的方式找到就绪的文件描述符fd,而是通过异步IO事件进行唤醒。
3.内核会将有IO事件发生的文件描述符fd返回给用户,用户不需要自己进行遍历。
epoll提供了三个系统调用的入口:
第一步,创建一个 epoll 句柄
int epoll_create(int size);
第二步,向内核添加、修改或删除要监控的文件描述符。
int epoll_ctl(
int epfd, int op, int fd, struct epoll_event *event);
第三步,类似发起了 select() 调用
int epoll_wait(
int epfd, struct epoll_event *events, int max events, int timeout);
原理如下所示:
epoll有两种操作模式–LT(level trigger水平模式)和ET(edge trigger边缘模式)。
简单来讲,LT是epoll的默认操作模式,当epoll_wait函数检测到有事件发生并将通知应用程序,而应用程序不一定必须立即进行处理,这样epoll_wait函数再次检测到此事件的时候还会通知应用程序,直到事件被处理。
而ET模式,只要epoll_wait函数检测到事件发生,通知应用程序立即进行处理,后续的epoll_wait函数将不再检测此事件。因此ET模式在很大程度上降低了同一个事件被epoll触发的次数,因此效率比LT模式高。
epoll示例代码:
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <assert.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <pthread.h>
#define true 1
#define false 0
#define MAX_EVENT_NUMBER 1024
#define BUFFER_SIZE 10
/*将文件描述符设置为非阻塞*/
int setnonblocking(int fd)
{
int old_option = fcntl(fd, F_GETFL);
int new_option = old_option | O_NONBLOCK;
fcntl(fd, F_SETFL, new_option);
return old_option;
}
/*将文件描述符fd上的EPOLLIN注册到epollfd指示的epoll内核事件表中。 参数enable_et 指定是否对fd采用ET模式*/
void addfd(int epollfd, int fd, int enable_et)
{
struct epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN;
if(enable_et){
event.events |= EPOLLET;
}
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &event );
setnonblocking(fd);
}
/*LT 模式的工作原理*/
void lt(struct epoll_event *events, int number, int epollfd, int listenfd)
{
char buf[BUFFER_SIZE];
int i = 0;
for(i= 0;i<number;i++){
int sockfd = events[i].data.fd;
if(sockfd == listenfd){
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(listenfd, (struct sockaddr *)&client_address, &client_addrlength);
addfd(epollfd, connfd, false);
}else if(events[i].events & EPOLLIN){
/*只要socket读缓存中的还有未读的数据,此代码就能触发*/
printf("event trigger once\n");
memset(buf, 0, BUFFER_SIZE);
int ret = recv(sockfd, buf, BUFFER_SIZE-1, 0);
if(ret <= 0){
close(sockfd);
continue;
}
printf("get %d bytes of content: %s\n", ret, buf);
}else{
printf("Something else happen !!!\n");
}
}
}
/*ET 模式的工作流程*/
void et(struct epoll_event* events, int number, int epollfd, int listenfd)
{
char buf[BUFFER_SIZE];
int i=0;
for(i =0; i<number; i++){
int sockfd = events[i].data.fd;
if(sockfd == listenfd){
struct sockaddr_in client_address;
socklen_t client_addrlength = sizeof(client_address);
int connfd = accept(listenfd, (struct sockaddr* )&client_address, &client_addrlength);
addfd(epollfd, connfd, true);/*对connfd开启ET模式*/
}else if(events[i].events & EPOLLIN){
/*这段代码不会被重复触发,所以我们循环读取数据,以确保把socket缓冲区的数据全部读取*/
printf("Event trigger once !!!\n");
while(1){
memset(buf, 0, BUFFER_SIZE);
int ret = recv(sockfd, buf, BUFFER_SIZE-1, 0);
if(ret < 0){
/*对于非阻塞IO,下面的事件成立标识数据已经全部读取完毕。此后,epoll就能再次触发sockfd上的sockfd的EPOLLIN事件,以驱动下一次读操作*/
if((errno == EAGAIN) || (errno == EWOULDBLOCK)){
printf("read later\n");
break;
}
close(sockfd);
break;
}else if(ret == 0){
close(sockfd);
}else{
printf("get %d bytes of content: %s\n", ret, buf);
}
}
}else{
printf("something else happen\n");
}
}
}
int main(int argc, char *argv[])
{
if(argc <= 2){
printf("Usage: %s ip_address port_number\n", argv[0]);
return 1;
}
const char *ip = argv[1];
int port = atoi(argv[2]);
int ret = 0;
struct sockaddr_in address;
bzero(&address, sizeof(address));
address.sin_family = AF_INET;
inet_pton(AF_INET, ip, &address.sin_addr);
address.sin_port = htons(port);
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
assert(listenfd >= 0);
ret = bind(listenfd, (struct sockaddr *)&address, sizeof(address));
assert(ret != -1);
int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
ret = listen(listenfd, 5);
assert(ret != -1);
struct epoll_event events[MAX_EVENT_NUMBER];
int epollfd = epoll_create(5);
assert(epollfd != -1);
addfd(epollfd, listenfd, true);
while(1){
int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
if(ret<0){
printf("epoll failure\n");
break;
}
//lt(events, ret, epollfd, listenfd);
et(events, ret, epollfd, listenfd);
}
close(listenfd);
return 0;
}
一切的开始,都起源于这个 read 函数是操作系统提供的,而且是阻塞的,我们叫它 阻塞 IO。
为了破这个局,程序员在用户态通过多线程来防止主线程卡死。
后来操作系统发现这个需求比较大,于是在操作系统层面提供了非阻塞的 read 函数,这样程序员就可以在一个线程内完成多个文件描述符的读取,这就是 非阻塞 IO。
但多个文件描述符的读取就需要遍历,当高并发场景越来越多时,用户态遍历的文件描述符也越来越多,相当于在 while 循环里进行了越来越多的系统调用。
后来操作系统又发现这个场景需求量较大,于是又在操作系统层面提供了这样的遍历文件描述符的机制,这就是 IO 多路复用。
多路复用有三个函数,最开始是 select,然后又发明了 poll 解决了 select 文件描述符的限制,然后又发明了 epoll 解决 select 的三个不足。
IO 模型的演进,其实就是时代的变化,倒逼着操作系统将更多的功能加到自己的内核而已。
参考:
IO多路复用到底是不是异步的? - 知乎 (zhihu.com)
epoll经典代码示例_遇见你是我最美丽的意外-CSDN博客_epoll示例代码