1. select poll epoll1.1 selectselect 的核心一个位图 四个宏 一个select函数驱动的select 的运行机制int nready select(maxfd1,rset,null,null,null) 设置最大描述符在loop中将位图作为参数调用selectif(FD_ISSET(sockfd,rset))每次都需要先使用宏FD_ISSET判断listenfd是否就绪int clientfd accept(sockfd, (struct sockaddr *)clinetaddr, len);如果就绪则调用accpet生成一个clientfd并调用FD_SET设置进去for (i sockfd 1; i maxfd; i)for循环遍历通过select后的位图fd_set继续使用FD_ISSET判断是否就绪只不过从listenfd1开始遍历的是clientfd如果就绪则调用recv接收数据包接着可以做一个send回发select特点总结每次需要将整个位图作为参数拷贝到内核检查所有的fd状态并设置然后再次拷贝回用户态最后用户态再遍历fd_set找到就绪的fd。这样就会导致以下问题的出现位图大小受限一般是1024当然可以手动修改但会增加拷贝和扫描压力两次遍历 用户态和内核态都需要扫描一遍位图每次调用都需要重新设置fd_set因为内核会修改每次都要拷贝用户态 ↔ 内核态1.2 pollpoll 的核心一个pollfd(fd 、关注事件-输入、实际事件-输出) 数组 一个poll函数驱动的poll 的运行机制fds[sockfd].fd sockfd和fds[sockfd].event POLLIN 将listenfd设置到数组中一般也是位于前面int nready poll(fds,maxfd 1,-1);调用poll让内核态改变pollfd fds数组if(fds[sockfd].revents POLLIN)如果就绪则调用accpet生成一个clientfd并将其设置到fds数组中去for (i sockfd 1; i maxfd; i)for循环遍历通过比对pollfd中的实际就绪事件如果就绪则调用recv接收数据包接着可以做一个send回发poll 特点总结每次需要将整个pollfd数组作为参数拷贝到内核检查所有的fd状态并设置然后再次拷贝回用户态最后用户态再遍历pollfd数组找到就绪的fd。这样就会导致以下问题的出现两次遍历 用户态和内核态都需要扫描一遍位图每次都要拷贝用户态 ↔ 内核态可见poll虽然可以自定义pollfd数组大小但是依然没有解决需要将整个数组从用户态拷贝到内核态的资源开销而且每个fd都需要单独一个pollfd结构体对应百万连接下反倒是增加了内存开销。1.3 epollepoll 的核心一个全fd红黑树就绪链表 一个epoll_event(就绪事件类型、fd)数组 三个系统调用epoll 的运行机制epoll_createepoll_create创建epoll实例红黑树 就绪链表并返回一个fd用来管理epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,ev)调用epoll_ctl把监听fd加入到红黑树中int nready epoll_wait(epfd,events,1024,-1)创建epoll_event数组并作为参数调用epoll_wait执行至写入epoll_eventfor(i0;inready;i)和int connfd events[i].data.fdfor循环遍历获取数组中的fd进行比对如果是 listenfd 就绪了 则调用accept接收一个clientfd并封装成epoll_event后epoll_ctl加入到红黑树如果是 clientfd就绪了则调用recv接收连接发来的数据包epoll 特点总结不再需要从用户态往内核态的整体拷贝每次通过epoll_wait将就绪链表中的fd拷贝到用户态进行逻辑处理即可。完全避免了poll select中的不足。红黑树和就绪链表的事件回调机制网卡收到数据后触发硬中断内核协议栈处理将数据包写到 socket 缓冲区调用 socket 上注册的ep_poll_callbackcallback 将该 fd 对应的 epitem 加入就绪链表epoll_wait 返回就绪事件只返回有事件的因此fd 就绪的时候主动通知epoll而非 epoll轮询检查。2. epoll的(LT)水平触发和 ET(边缘触发)2.1 水平触发只要缓冲区有数据下次epoll_wait就会触发通知次数可能是多次更多的系统调用性能稍低一些编码简单好理解没有泄露风险2.2 边缘触发只在fd的event事件状态变化的时候触发一次通知次数只是一次系统调用少编码相对复杂会出现泄露风险2. 3 两个方式的伪代码水平触发在loop中只需要调用recv即可也不需要将clienfd设置为非阻塞intclientfdaccept(sockfd,(structsockaddr*)clinetaddr,len);while(1){if(events[i].eventsPOLLIN){intcountrecv(connfd,buffer,1024,0);}}边缘触发① 将监听fd设置为非阻塞②将事件设置为边缘触发③在recv的外层包含一层while循环直到recv到的count 0并且errno EAGAIN为止数据接收完毕。intclientfdaccept(sockfd,(structsockaddr*)clinetaddr,len);// 设置非阻塞intflagsfcntl(clientfd,F_GETFL,0);fcntl(clientfd,F_SETFL,flags|O_NONBLOCK);ev.eventsEPOLLIN|EPOLLET;//设置为边缘触发while(1){if(events[i].eventsPOLLIN){while(1){intcountrecv(connfd,buffer,1024,0);if(count0){.....}else{if(errnoEAGAIN){//数据读完了可以退出循环break;}}}}}为什么要设置为非阻塞因为阻塞模式下ET内部的while的最后一次recv/read 是读不到数据的那这样就会卡在该行代码处。但是LT不会出现这种情况因为在缓冲区数据为空的时候是无法触发LT模式下的读事件ET也同样如此只不过我们手动无线循环的去recv/read了而已也就是在内部while(1)了因此需要手动跳出。3 reactor 模型reactor 只是对epoll的一个抽象封装核心是将网络i/o的处理与业务逻辑分离具体实现 封装一个结构体conn内部含有fd 、 callback事件 、 读写区域将clienfd封装成conn只需要设置fd和注册事件为accept_cb当在主循环中eopll_wait的时候如果是clienfd调用accep_cb方法该方法会调用accpet并将新连接注册成conn并以fd作为索引放到conns数组如果是是其他的fd则依据注册的事件判断调用匹配的回调函数即可比如recv之后注册EPOLLOUT事件send之后注册EPOLLIN事件。具体例子对于reactor中只需要封装出来的recv_callback和send_callback实现自己处理数据包的业务逻辑就是可以了比如httpserver只需要分析recv的包并且封装数据并注册为EPOLLOUT下次epollwait的时候就可以识别出来这个EPOLLOUT利用此fd再send出去这就是一个简单的webserver了。这里我只需要关心的是recv的http协议如何解析send回发如何组织实现了网络层与业务层的解耦。4 单mainReactor 多subReactor4.1 基本架构mainReactor通常 1 个线程mainReactor只负责accpet也就是负责监听新的连接listenfd并将获得的clienfd分发给subReactorsubReactor多个线程每个 subReactor 运行在自己的线程中拥有独立的 epoll 实例负责处理分配给它的多个 clientfd 的读写事件。4.2 mainReactor处理新连接当mainReactor的epoll_wait返回listenfd可读时它调用accept获得 clientfd。然后选择一个subReactor通常采用简单的轮询或最少连接策略。将clientfd交给该subReactor管理。4.3 如何将clientfd注册到subReactormainReactor接收连接后选择一个subReactor将clienfd以及可能的一些初始状态比如注册EPOLLIN事件打包成一个任务放入到subReactor的任务队列中。给subReactor发过去一个数据触发subReactor的recv方法接收到的数据不重要主要是要触发任务队列中取任务的逻辑取任务后在自己的线程中调用epoll_ctl把mainReactor封装的clienfd加入到自己的epoll实例中。后续该clienfd再触发的时候就是由subReactor进行处理// Main Reactorvoidmain_reactor(){while(running){intnepoll_wait(main_epfd,events,MAX_EVENTS,-1);for(inti0;in;i){intclient_fdaccept(listen_fd,...);// 选择一个 Sub ReactorRound-Robinintidxnext_reactor%num_workers;// 将新连接分发给 Sub Reactorworkers[idx].add_connection(client_fd);}}}// Sub Reactorvoidsub_reactor(intworker_id){while(running){intnepoll_wait(worker_epfd[worker_id],events,MAX_EVENTS,-1);for(inti0;in;i){handle_connection(events[i].data.fd);}}}mainReactor其实不建立任何连接只是作为分发器使用真正建立连接和处理请求的是subReactor。5 io_uring - proactor模型相比较于reactorproactor的明显本质区别在由谁负责实际的数据读写Reactor 模式与 epollReactor 核心思想应用程序注册感兴趣事件当事件就绪的时候由Reactor通知应用程序然后应用程序自己执行读写的实际操作epoll的角色epoll只是提供通知机制。应用程序获取的通知后仍需要调用read/write等系统调用完成数据收发这是同步的但借助非阻塞 fd 和事件循环实现了高并发。Proactor 模式与 io_uringProactor 核心思想异步I/O。应用程序发起异步的读写操作将缓冲区交给操作系统操作系统完成读写后通知应用程序无需等待I/O就绪也无需亲自执行读写io_uring 的角色应用程序通过提交队列SQ提交 I/O 请求如 recv、send、accept内核异步处理这些请求并将结果放入完成队列CQ。当请求完成时应用程序从 CQ 中获取结果此时数据已在内核拷贝到用户缓冲区或发送已完成。整个过程是异步的应用程序发起操作后即可做其他事情无需阻塞等待。linux 5.1引入的异步I/O网络框架//伪代码 -- 主要逻辑1.构建出来两个环形队列--io_uring_setup系统调用structio_uringring;io_uring_queue_init_params(ENTRIES_LENGTH,ring,params);2.获取sq队列,并将listenfd提交准备接受acceptio_uring_prep_accept(sqe,sockfd,(structsockaddr*)addr,addrlen,flags);while(1){3.一次性将sq中的数据都提交给内核执行io_uring_submit(ring);//-- io_uring_ent系统调用 将队列中的数据都丢进去4.这里会阻塞等待cqe中有就绪任务了structio_uring_cqe*cqe;io_uring_wait_cqe(ring,cqe);// 获取返回队列(阻塞在这) --类似于wait5.直接取出就绪的任务structio_uring_cqe*cqes[128];intnreadyio_uring_peek_batch_cqe(ring,cqes,128);// epoll_wait数组指针 --- 返回队列for(i0;inready;i){if(result.eventEVENT_ACCEPT){注意 重新将sockfd加入到sq中 不然就只能接受一次set_event_accept(ring,sockfd,(structsockaddr*)clientaddr,len,0);6要往提交队列添加一个revc任务了intconnfdentries-res;set_event_recv(ring,connfd,buffer,BUFFER_LENGTH,0);}elseif(result.eventEVENT_READ){intretentries-res;if(ret0){close(result.fd);}elseif(ret0){set_event_send(ring,result.fd,buffer,ret,0);}}elseif(result.eventEVENT_WRITE){intretentries-res;set_event_recv(ring,result.fd,buffer,BUFFER_LENGTH,0);}}}
知识点总结(一)I/O多路复用
1. select poll epoll1.1 selectselect 的核心一个位图 四个宏 一个select函数驱动的select 的运行机制int nready select(maxfd1,rset,null,null,null) 设置最大描述符在loop中将位图作为参数调用selectif(FD_ISSET(sockfd,rset))每次都需要先使用宏FD_ISSET判断listenfd是否就绪int clientfd accept(sockfd, (struct sockaddr *)clinetaddr, len);如果就绪则调用accpet生成一个clientfd并调用FD_SET设置进去for (i sockfd 1; i maxfd; i)for循环遍历通过select后的位图fd_set继续使用FD_ISSET判断是否就绪只不过从listenfd1开始遍历的是clientfd如果就绪则调用recv接收数据包接着可以做一个send回发select特点总结每次需要将整个位图作为参数拷贝到内核检查所有的fd状态并设置然后再次拷贝回用户态最后用户态再遍历fd_set找到就绪的fd。这样就会导致以下问题的出现位图大小受限一般是1024当然可以手动修改但会增加拷贝和扫描压力两次遍历 用户态和内核态都需要扫描一遍位图每次调用都需要重新设置fd_set因为内核会修改每次都要拷贝用户态 ↔ 内核态1.2 pollpoll 的核心一个pollfd(fd 、关注事件-输入、实际事件-输出) 数组 一个poll函数驱动的poll 的运行机制fds[sockfd].fd sockfd和fds[sockfd].event POLLIN 将listenfd设置到数组中一般也是位于前面int nready poll(fds,maxfd 1,-1);调用poll让内核态改变pollfd fds数组if(fds[sockfd].revents POLLIN)如果就绪则调用accpet生成一个clientfd并将其设置到fds数组中去for (i sockfd 1; i maxfd; i)for循环遍历通过比对pollfd中的实际就绪事件如果就绪则调用recv接收数据包接着可以做一个send回发poll 特点总结每次需要将整个pollfd数组作为参数拷贝到内核检查所有的fd状态并设置然后再次拷贝回用户态最后用户态再遍历pollfd数组找到就绪的fd。这样就会导致以下问题的出现两次遍历 用户态和内核态都需要扫描一遍位图每次都要拷贝用户态 ↔ 内核态可见poll虽然可以自定义pollfd数组大小但是依然没有解决需要将整个数组从用户态拷贝到内核态的资源开销而且每个fd都需要单独一个pollfd结构体对应百万连接下反倒是增加了内存开销。1.3 epollepoll 的核心一个全fd红黑树就绪链表 一个epoll_event(就绪事件类型、fd)数组 三个系统调用epoll 的运行机制epoll_createepoll_create创建epoll实例红黑树 就绪链表并返回一个fd用来管理epoll_ctl(epfd,EPOLL_CTL_ADD,sockfd,ev)调用epoll_ctl把监听fd加入到红黑树中int nready epoll_wait(epfd,events,1024,-1)创建epoll_event数组并作为参数调用epoll_wait执行至写入epoll_eventfor(i0;inready;i)和int connfd events[i].data.fdfor循环遍历获取数组中的fd进行比对如果是 listenfd 就绪了 则调用accept接收一个clientfd并封装成epoll_event后epoll_ctl加入到红黑树如果是 clientfd就绪了则调用recv接收连接发来的数据包epoll 特点总结不再需要从用户态往内核态的整体拷贝每次通过epoll_wait将就绪链表中的fd拷贝到用户态进行逻辑处理即可。完全避免了poll select中的不足。红黑树和就绪链表的事件回调机制网卡收到数据后触发硬中断内核协议栈处理将数据包写到 socket 缓冲区调用 socket 上注册的ep_poll_callbackcallback 将该 fd 对应的 epitem 加入就绪链表epoll_wait 返回就绪事件只返回有事件的因此fd 就绪的时候主动通知epoll而非 epoll轮询检查。2. epoll的(LT)水平触发和 ET(边缘触发)2.1 水平触发只要缓冲区有数据下次epoll_wait就会触发通知次数可能是多次更多的系统调用性能稍低一些编码简单好理解没有泄露风险2.2 边缘触发只在fd的event事件状态变化的时候触发一次通知次数只是一次系统调用少编码相对复杂会出现泄露风险2. 3 两个方式的伪代码水平触发在loop中只需要调用recv即可也不需要将clienfd设置为非阻塞intclientfdaccept(sockfd,(structsockaddr*)clinetaddr,len);while(1){if(events[i].eventsPOLLIN){intcountrecv(connfd,buffer,1024,0);}}边缘触发① 将监听fd设置为非阻塞②将事件设置为边缘触发③在recv的外层包含一层while循环直到recv到的count 0并且errno EAGAIN为止数据接收完毕。intclientfdaccept(sockfd,(structsockaddr*)clinetaddr,len);// 设置非阻塞intflagsfcntl(clientfd,F_GETFL,0);fcntl(clientfd,F_SETFL,flags|O_NONBLOCK);ev.eventsEPOLLIN|EPOLLET;//设置为边缘触发while(1){if(events[i].eventsPOLLIN){while(1){intcountrecv(connfd,buffer,1024,0);if(count0){.....}else{if(errnoEAGAIN){//数据读完了可以退出循环break;}}}}}为什么要设置为非阻塞因为阻塞模式下ET内部的while的最后一次recv/read 是读不到数据的那这样就会卡在该行代码处。但是LT不会出现这种情况因为在缓冲区数据为空的时候是无法触发LT模式下的读事件ET也同样如此只不过我们手动无线循环的去recv/read了而已也就是在内部while(1)了因此需要手动跳出。3 reactor 模型reactor 只是对epoll的一个抽象封装核心是将网络i/o的处理与业务逻辑分离具体实现 封装一个结构体conn内部含有fd 、 callback事件 、 读写区域将clienfd封装成conn只需要设置fd和注册事件为accept_cb当在主循环中eopll_wait的时候如果是clienfd调用accep_cb方法该方法会调用accpet并将新连接注册成conn并以fd作为索引放到conns数组如果是是其他的fd则依据注册的事件判断调用匹配的回调函数即可比如recv之后注册EPOLLOUT事件send之后注册EPOLLIN事件。具体例子对于reactor中只需要封装出来的recv_callback和send_callback实现自己处理数据包的业务逻辑就是可以了比如httpserver只需要分析recv的包并且封装数据并注册为EPOLLOUT下次epollwait的时候就可以识别出来这个EPOLLOUT利用此fd再send出去这就是一个简单的webserver了。这里我只需要关心的是recv的http协议如何解析send回发如何组织实现了网络层与业务层的解耦。4 单mainReactor 多subReactor4.1 基本架构mainReactor通常 1 个线程mainReactor只负责accpet也就是负责监听新的连接listenfd并将获得的clienfd分发给subReactorsubReactor多个线程每个 subReactor 运行在自己的线程中拥有独立的 epoll 实例负责处理分配给它的多个 clientfd 的读写事件。4.2 mainReactor处理新连接当mainReactor的epoll_wait返回listenfd可读时它调用accept获得 clientfd。然后选择一个subReactor通常采用简单的轮询或最少连接策略。将clientfd交给该subReactor管理。4.3 如何将clientfd注册到subReactormainReactor接收连接后选择一个subReactor将clienfd以及可能的一些初始状态比如注册EPOLLIN事件打包成一个任务放入到subReactor的任务队列中。给subReactor发过去一个数据触发subReactor的recv方法接收到的数据不重要主要是要触发任务队列中取任务的逻辑取任务后在自己的线程中调用epoll_ctl把mainReactor封装的clienfd加入到自己的epoll实例中。后续该clienfd再触发的时候就是由subReactor进行处理// Main Reactorvoidmain_reactor(){while(running){intnepoll_wait(main_epfd,events,MAX_EVENTS,-1);for(inti0;in;i){intclient_fdaccept(listen_fd,...);// 选择一个 Sub ReactorRound-Robinintidxnext_reactor%num_workers;// 将新连接分发给 Sub Reactorworkers[idx].add_connection(client_fd);}}}// Sub Reactorvoidsub_reactor(intworker_id){while(running){intnepoll_wait(worker_epfd[worker_id],events,MAX_EVENTS,-1);for(inti0;in;i){handle_connection(events[i].data.fd);}}}mainReactor其实不建立任何连接只是作为分发器使用真正建立连接和处理请求的是subReactor。5 io_uring - proactor模型相比较于reactorproactor的明显本质区别在由谁负责实际的数据读写Reactor 模式与 epollReactor 核心思想应用程序注册感兴趣事件当事件就绪的时候由Reactor通知应用程序然后应用程序自己执行读写的实际操作epoll的角色epoll只是提供通知机制。应用程序获取的通知后仍需要调用read/write等系统调用完成数据收发这是同步的但借助非阻塞 fd 和事件循环实现了高并发。Proactor 模式与 io_uringProactor 核心思想异步I/O。应用程序发起异步的读写操作将缓冲区交给操作系统操作系统完成读写后通知应用程序无需等待I/O就绪也无需亲自执行读写io_uring 的角色应用程序通过提交队列SQ提交 I/O 请求如 recv、send、accept内核异步处理这些请求并将结果放入完成队列CQ。当请求完成时应用程序从 CQ 中获取结果此时数据已在内核拷贝到用户缓冲区或发送已完成。整个过程是异步的应用程序发起操作后即可做其他事情无需阻塞等待。linux 5.1引入的异步I/O网络框架//伪代码 -- 主要逻辑1.构建出来两个环形队列--io_uring_setup系统调用structio_uringring;io_uring_queue_init_params(ENTRIES_LENGTH,ring,params);2.获取sq队列,并将listenfd提交准备接受acceptio_uring_prep_accept(sqe,sockfd,(structsockaddr*)addr,addrlen,flags);while(1){3.一次性将sq中的数据都提交给内核执行io_uring_submit(ring);//-- io_uring_ent系统调用 将队列中的数据都丢进去4.这里会阻塞等待cqe中有就绪任务了structio_uring_cqe*cqe;io_uring_wait_cqe(ring,cqe);// 获取返回队列(阻塞在这) --类似于wait5.直接取出就绪的任务structio_uring_cqe*cqes[128];intnreadyio_uring_peek_batch_cqe(ring,cqes,128);// epoll_wait数组指针 --- 返回队列for(i0;inready;i){if(result.eventEVENT_ACCEPT){注意 重新将sockfd加入到sq中 不然就只能接受一次set_event_accept(ring,sockfd,(structsockaddr*)clientaddr,len,0);6要往提交队列添加一个revc任务了intconnfdentries-res;set_event_recv(ring,connfd,buffer,BUFFER_LENGTH,0);}elseif(result.eventEVENT_READ){intretentries-res;if(ret0){close(result.fd);}elseif(ret0){set_event_send(ring,result.fd,buffer,ret,0);}}elseif(result.eventEVENT_WRITE){intretentries-res;set_event_recv(ring,result.fd,buffer,BUFFER_LENGTH,0);}}}