IO复用
多进程/线程并发模型,为每个sockets分配一个进程/线程
I/O(多路)复用,采用单个进/线程就可以管理多个socket
I/O复用有3种方案:
- select
- poll
- epoll
select
I/O多路复用详解
27、fd_set与FD_SETSIZE详解
详解fd_set结构体
fd_set结构体
#include <sys/select.h>#define FD_SETSIZE 1024
#define NFDBITS (8 * sizeof(unsigned long))
#define __FDSET_LONGS (FD_SETSIZE/NFDBITS)typedef struct {unsigned long fds_bits[__FDSET_LONGS];
} fd_set;或者typedef struct{long int fds_bits[32];
}fd_set;
fd_set
是文件描述符 fd
的集合,由于每个进程可打开的文件描述符默认值为1024,fd_set
可记录的 fd
个数上限也是1024个
fd_set
采用位图 bitmap
结构,是一个大小为32的 long 型数组,每一个 bit 代表一个描述符是否被监视(类似于一个32x32的矩阵)
操作函数
#include <sys/select.h>
#include <sys/time.h>
void FD_SET(int fd, fd_set *fdset);
void FD_CLR(int fd, fd_set *fdset);
void FD_ISSET(int fd, fd_set *fdset);
void FD_ZERO(fd_set *fdset);
FD_ZERO(&fdset); /将set清零使集合中不含任何fd,清空fdset与所有文件句柄的联系/
FD_SET(fd, &fdset); /将fd加入set集合,建立文件句柄fd与fdset的联系/
FD_CLR(fd, &fdset); /将fd从set集合中清除,清除文件句柄fd与fdset的联系/
FD_ISSET(fd, &fdset); /在调用select()函数后,用FD_ISSET来检测fd是否在set集合中,当检测到fd在set中则返回真,否则,返回假(0)/
select()函数
// nfds:fds中最大fd的值加1
// readfds: 读数据文件描述符集合
// writefds: 写数据文件描述符集合
// exceptfds: 异常情况的文件描述符集合
// timeout: 该方法阻塞的超时时间
int select (int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);struct timeval {long tv_sec; //秒long tv_usec; //毫秒
}
- 用户进程通过
select
系统调用把fd_set
结构的数据拷贝
到内核,由内核来监视并判断哪些连接有数据到来,如果有连接准备好数据,select
系统调用就返回 select
返回后,用户进程只知道某个或某几个连接有数据,但并不知道是哪个连接。所以需要遍历
fds
中的每个fd
, 当该fd
被置位时,代表该fd
表示的连接有数据需要被读取。然后我们读取该fd
的数据并进行业务操作select
第一个参数需要传入最大fd值加1
的数值,目的是为了用户能自定义监视的fd
范围,防止不必要资源消耗- 操作系统会复用用户进程传入的
fd_set
变量,来作为出参,所以我们传入的fd_set
返回时已经被内核修改
过了 select
的方式选择让内核来帮我们监视这些fd
,当有数据可读时就通知我们,避免listenfd在accept()时阻塞
,提升了效率
返回值:
- **>0:**有事件发生
- **=0:**timeout,超时
- **<0:**出错
示例程序
-
tcpseletc.cpp
#include <stdio.h> #include <unistd.h> #include <stdlib.h> #include <string.h> #include <sys/socket.h> #include <arpa/inet.h> #include <sys/fcntl.h>// 初始化服务端的监听端口。 int initserver(int port);int main(int argc,char *argv[]) {if (argc != 2){printf("usage: ./tcpselect port\n"); return -1;}// 初始化服务端用于监听的socket。int listensock = initserver(atoi(argv[1]));printf("listensock=%d\n",listensock);if (listensock < 0){printf("initserver() failed.\n"); return -1;}fd_set readfdset; // 读事件的集合,包括监听socket和客户端连接上来的socket。int maxfd; // readfdset中socket的最大值。// 初始化结构体,把listensock添加到集合中。FD_ZERO(&readfdset);FD_SET(listensock,&readfdset);maxfd = listensock;while (1){// 调用select函数时,会改变socket集合的内容,所以要把socket集合保存下来,传一个临时的给select。fd_set tmpfdset = readfdset;int infds = select(maxfd+1,&tmpfdset,NULL,NULL,NULL);// printf("select infds=%d\n",infds);// 返回失败。if (infds < 0){printf("select() failed.\n"); perror("select()"); break;}// 超时,在本程序中,select函数最后一个参数为空,不存在超时的情况,但以下代码还是留着。if (infds == 0){printf("select() timeout.\n"); continue;}// 检查有事情发生的socket,包括监听和客户端连接的socket。// 这里是客户端的socket事件,每次都要遍历整个集合,因为可能有多个socket有事件。for (int eventfd=0; eventfd <= maxfd; eventfd++){if (FD_ISSET(eventfd,&tmpfdset)<=0) continue; //判断时用tmpfdset集合if (eventfd==listensock){ // 如果发生事件的是listensock,表示有新的客户端连上来。struct sockaddr_in client;socklen_t len = sizeof(client);int clientsock = accept(listensock,(struct sockaddr*)&client,&len);if (clientsock < 0){printf("accept() failed.\n"); continue;}printf ("client(socket=%d) connected ok.\n",clientsock);// 把新的客户端socket加入集合,readfdset集合,注意区别何时用tmpfdset何时用readfdsetFD_SET(clientsock,&readfdset);if (maxfd < clientsock) maxfd = clientsock;continue;}else{// 客户端有数据过来或客户端的socket连接被断开。char buffer[1024];memset(buffer,0,sizeof(buffer));// 读取客户端的数据。ssize_t isize=read(eventfd,buffer,sizeof(buffer));// 发生了错误或socket被对方关闭。if (isize <=0){printf("client(eventfd=%d) disconnected.\n",eventfd);close(eventfd); // 关闭客户端的socket。FD_CLR(eventfd,&readfdset); // 从readfdset集合中移去客户端的socket。// 重新计算maxfd的值,注意,只有当eventfd==maxfd时才需要计算。if (eventfd == maxfd){for (int ii=maxfd;ii>0;ii--){if (FD_ISSET(ii,&readfdset)){maxfd = ii; break;}}printf("maxfd=%d\n",maxfd);}continue;}printf("recv(eventfd=%d,size=%d):%s\n",eventfd,isize,buffer);// 把收到的报文发回给客户端。write(eventfd,buffer,strlen(buffer));}}}return 0; }// 初始化服务端的监听端口。 int initserver(int port) {int sock = socket(AF_INET,SOCK_STREAM,0);if (sock < 0){printf("socket() failed.\n"); return -1;}// Linux如下int opt = 1; unsigned int len = sizeof(opt);setsockopt(sock,SOL_SOCKET,SO_REUSEADDR,&opt,len);setsockopt(sock,SOL_SOCKET,SO_KEEPALIVE,&opt,len);struct sockaddr_in servaddr;servaddr.sin_family = AF_INET;servaddr.sin_addr.s_addr = htonl(INADDR_ANY);servaddr.sin_port = htons(port);if (bind(sock,(struct sockaddr *)&servaddr,sizeof(servaddr)) < 0 ){printf("bind() failed.\n"); close(sock); return -1;}if (listen(sock,5) != 0 ){printf("listen() failed.\n"); close(sock); return -1;}return sock; }
select缺陷与不足
- 可监控的文件描述符数量最大为 1024 个,就代表最大能支持的并发为1024,这个是操作系统内核决定的
- 用户进程的文件描述符集合
fd_set
每次都需要从用户进程拷贝到内核,有一定的性能开销 select
函数返回,我们只知道有文件描述符满足要求,但不知道是哪个,所以需要遍历所有文件描述符,复杂度为O(n)select
机制的这些特性在高并发网络服务器动辄几万几十万并发连接的场景下是低效的
poll
poll
是另一种I/O多路复用的实现方式,它解决了 select
1024个文件描述符的限制问题
poll
是使用 pollfd
结构来替代了 select
的 fd_set
位图,以解决 1024 的文件描述符个数限制
struct pollfd
{int fd; /* file descriptor */short events; /* requested events */short revents; /* returned events */
};
fd
表示要监视的文件描述符events
表示要监视的事件,比如输入、输出或异常revents
表示返回的标志位,标识哪个事件有信息到来,处理完成后记得重置标志位
poll
函数的定义
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
poll
函数的第一个参数传入了一个自定义的pollfd
的数组,原则上已经没有了个数的限制- 但
poll
除了解决了select
存在的文件描述符个数的限制,并没有解决select
存在的其他问题(拷贝
和轮询
) select
和poll
都会随着监控的文件描述符数量增加而性能下降,因此也不太适合高并发场景
epoll
epoll
使用一个文件描述符管理多个描述符,省去了大量文件描述符频繁在用户态和内核态之间拷贝的资源消耗
epoll
操作过程有三个非常重要的接口
epoll_create()函数
/* Creates an epoll instance. Returns an fd for the new instance.The "size" parameter is a hint specifying the number of filedescriptors to be associated with the new instance. The fdreturned by epoll_create() should be closed with close(). */
extern int epoll_create (int __size) __THROW;/* Same as epoll_create but with an FLAGS parameter. The unused SIZEparameter has been dropped. */
extern int epoll_create1 (int __flags) __THROW;
epoll_create()
方法生成一个 epoll
专用的文件描述符(创建一个 epoll
的句柄)
参数 size
在新版本中没有具体意义,填一个大于0的任意值即可
epoll_ctl()函数
/* Manipulate an epoll instance "epfd". Returns 0 in case of success,-1 in case of error ( the "errno" variable will contain thespecific error code ) The "op" parameter is one of the EPOLL_CTL_*constants defined above. The "fd" parameter is the target of theoperation. The "event" parameter describes which events the calleris interested in and any associated user data. */
extern int epoll_ctl (int __epfd, int __op, int __fd,struct epoll_event *__event) __THROW;
epfd
:epoll
专用的文件描述符,epoll_create()
的返回值op
:表示添加、修改、删除的动作,用三个宏来表示:
/* Valid opcodes ( "op" parameter ) to issue to epoll_ctl(). */
#define EPOLL_CTL_ADD 1 /* Add a file descriptor to the interface. */
#define EPOLL_CTL_DEL 2 /* Remove a file descriptor from the interface. */
#define EPOLL_CTL_MOD 3 /* Change file descriptor epoll_event structure. */
fd
:需要监听的文件描述符event
:告诉内核要监听的事件
epoll_event结构体
前端 详解epoll_events结构体
typedef union epoll_data
{void *ptr;int fd;uint32_t u32;uint64_t u64;
} epoll_data_t;struct epoll_event
{uint32_t events; /* Epoll events */epoll_data_t data; /* User data variable */
} __EPOLL_PACKED;
定义了枚举类型的events
enum EPOLL_EVENTS{EPOLLIN = 0x001,
#define EPOLLIN EPOLLINEPOLLPRI = 0x002,
#define EPOLLPRI EPOLLPRIEPOLLOUT = 0x004,
#define EPOLLOUT EPOLLOUTEPOLLRDNORM = 0x040,
#define EPOLLRDNORM EPOLLRDNORMEPOLLRDBAND = 0x080,
#define EPOLLRDBAND EPOLLRDBANDEPOLLWRNORM = 0x100,
#define EPOLLWRNORM EPOLLWRNORMEPOLLWRBAND = 0x200,
#define EPOLLWRBAND EPOLLWRBANDEPOLLMSG = 0x400,
#define EPOLLMSG EPOLLMSGEPOLLERR = 0x008,
#define EPOLLERR EPOLLERREPOLLHUP = 0x010,
#define EPOLLHUP EPOLLHUPEPOLLRDHUP = 0x2000,
#define EPOLLRDHUP EPOLLRDHUPEPOLLEXCLUSIVE = 1u << 28,
#define EPOLLEXCLUSIVE EPOLLEXCLUSIVEEPOLLWAKEUP = 1u << 29,
#define EPOLLWAKEUP EPOLLWAKEUPEPOLLONESHOT = 1u << 30,
#define EPOLLONESHOT EPOLLONESHOTEPOLLET = 1u << 31
#define EPOLLET EPOLLET};
epoll_wait()函数
/* Wait for events on an epoll instance "epfd". Returns the number oftriggered events returned in "events" buffer. Or -1 in case oferror with the "errno" variable set to the specific error code. The"events" parameter is a buffer that will contain triggeredevents. The "maxevents" is the maximum number of events to bereturned ( usually size of "events" ). The "timeout" parameterspecifies the maximum wait time in milliseconds (-1 == infinite).This function is a cancellation point and therefore not marked with__THROW. */
extern int epoll_wait (int __epfd, struct epoll_event *__events,int __maxevents, int __timeout);
epoll_wait()
方法等待事件的产生,类似 select
调用
epfd
:epoll
专用的文件描述符,epoll_create()
的返回值events
:分配好的epoll_event
结构体数组,epoll
将会把发生的事件赋值到events
数组中maxevents
:告诉内核events
数组的大小timeout
:超时时间,单位毫秒,为 -1 时,方法为阻塞
值得注意的是epoll_wait()
函数只能获取是否有注册事件发生,至于这个事件到底是什么、从哪个 socket 来、发送的时间、包的大小等等信息,统统不知道。这就好比一个人在黑黢黢的山洞里,只能听到声响,至于这个声音是谁发出的根本不知道。因此我们就需要struct epoll_event
来帮助我们读取信息
实例
-
tcpepoll.cpp
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <errno.h> #include <unistd.h> #include <fcntl.h> #include <arpa/inet.h> #include <netinet/in.h> #include <sys/epoll.h> #include <sys/socket.h> #include <sys/types.h>#define MAXEVENTS 100// 把socket设置为非阻塞的方式。 int setnonblocking(int sockfd);// 初始化服务端的监听端口。 int initserver(int port);int main(int argc, char *argv[]) {if (argc != 2){printf("usage:./tcpepoll port\n");return -1;}// 初始化服务端用于监听的socket。int listensock = initserver(atoi(argv[1]));printf("listensock=%d\n", listensock);if (listensock < 0){printf("initserver() failed.\n");return -1;}int epollfd;char buffer[1024];memset(buffer, 0, sizeof(buffer));// 创建一个描述符epollfd = epoll_create(1);// 添加监听描述符事件struct epoll_event ev;ev.data.fd = listensock;ev.events = EPOLLIN;epoll_ctl(epollfd, EPOLL_CTL_ADD, listensock, &ev);while (1){struct epoll_event events[MAXEVENTS]; // 存放有事件发生的结构数组。// 等待监视的socket有事件发生。int infds = epoll_wait(epollfd, events, MAXEVENTS, -1);// printf("epoll_wait infds=%d\n",infds);// 返回失败。if (infds < 0){printf("epoll_wait() failed.\n");perror("epoll_wait()");break;}// 超时。if (infds == 0){printf("epoll_wait() timeout.\n");continue;}// 遍历有事件发生的结构数组。for (int ii = 0; ii < infds; ii++){if ((events[ii].data.fd == listensock) && (events[ii].events & EPOLLIN)){// 如果发生事件的是listensock,表示有新的客户端连上来。struct sockaddr_in client;socklen_t len = sizeof(client);int clientsock = accept(listensock, (struct sockaddr *)&client, &len);if (clientsock < 0){printf("accept() failed.\n");continue;}// 把新的客户端添加到epoll中。memset(&ev, 0, sizeof(struct epoll_event));ev.data.fd = clientsock;ev.events = EPOLLIN;epoll_ctl(epollfd, EPOLL_CTL_ADD, clientsock, &ev);printf("client(socket=%d) connected ok.\n", clientsock);continue;}else if (events[ii].events & EPOLLIN){// 客户端有数据过来或客户端的socket连接被断开。char buffer[1024];memset(buffer, 0, sizeof(buffer));// 读取客户端的数据。ssize_t isize = read(events[ii].data.fd, buffer, sizeof(buffer));// 发生了错误或socket被对方关闭。if (isize <= 0){printf("client(eventfd=%d) disconnected.\n", events[ii].data.fd);// 把已断开的客户端从epoll中删除。memset(&ev, 0, sizeof(struct epoll_event));ev.events = EPOLLIN;ev.data.fd = events[ii].data.fd;epoll_ctl(epollfd, EPOLL_CTL_DEL, events[ii].data.fd, &ev);close(events[ii].data.fd); //或者一行关闭命令即可continue;}printf("recv(eventfd=%d,size=%d):%s\n", events[ii].data.fd, isize, buffer);// 把收到的报文发回给客户端。write(events[ii].data.fd, buffer, strlen(buffer));}}}close(epollfd);return 0; }// 初始化服务端的监听端口。 int initserver(int port) {int sock = socket(AF_INET, SOCK_STREAM, 0);if (sock < 0){printf("socket() failed.\n");return -1;}// Linux如下int opt = 1;unsigned int len = sizeof(opt);setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, &opt, len);setsockopt(sock, SOL_SOCKET, SO_KEEPALIVE, &opt, len);struct sockaddr_in servaddr;servaddr.sin_family = AF_INET;servaddr.sin_addr.s_addr = htonl(INADDR_ANY);servaddr.sin_port = htons(port);if (bind(sock, (struct sockaddr *)&servaddr, sizeof(servaddr)) < 0){printf("bind() failed.\n");close(sock);return -1;}if (listen(sock, 5) != 0){printf("listen() failed.\n");close(sock);return -1;}return sock; }// 把socket设置为非阻塞的方式。 int setnonblocking(int sockfd) {if (fcntl(sockfd, F_SETFL, fcntl(sockfd, F_GETFD, 0) | O_NONBLOCK) == -1)return -1;return 0; }
小结
epoll
底层使用了 RB-Tree
红黑树和 list
链表实现。内核创建了红黑树用于存储 epoll_ctl
传来的 socket,另外创建了一个 list
链表,用于存储准备就绪的事件
当 epoll_wait
调用时,仅仅观察这个 list 链表里有没有数据即可。有数据就返回,没有数据就阻塞。所以,epoll_wait
非常高效,通常情况下即使我们要监控百万计的连接,大多一次也只返回很少量准备就绪的文件描述符而已,所以,epoll_wait
仅需要从内核态拷贝很少的文件描述符到用户态
epoll
相比于 select
和 poll
,它更高效的本质在于:
- 减少了用户态和内核态文件描述符状态的拷贝,
epoll
只需要一个专用的文件句柄即可 - 减少了文件描述符的遍历,
select
和poll
每次都要遍历所有的文件描述符,用来判断哪个连接准备就绪;epoll
返回的是准备就绪的文件描述符,效率大大提高 - 没有并发数量的限制,性能不会随文件描述符数量的增加而下降
IO复用总结
select
是较早实现的一种I/O多路复用技术,但它最明显的缺点就是有 1024 个文件描述符数量的限制,也就导致它无法满足高并发的需求
poll
一定程度上解决了 select
文件描述符数量的限制,但和 select
一样,仍然存在文件描述符状态在用户态和内核态的频繁拷贝,和遍历所有文件描述符的问题,这导致了在面对高并发的实现需求时,它的性能不会很高
epoll
高效地解决了以上问题,首先使用一个特殊的文件描述符,解决了用户态和内核态频繁拷贝的问题;其次 epoll_wait
返回的是准备就绪的文件描述符,省去了无效的遍历;再次,底层使用红黑树和链表的数据结构,更加高效地实现连接的监视
工作中常用的 redis、nginx 都是使用了 epoll
这种I/O复用模型,通过单线程就实现了10万以上的并发访问
epoll
不一定任何情况下都比 select
高效,需要根据具体场景。比如并发不是很高,且大部分都是活跃的 socket,那么也许 select
会比 epoll
更加高效,因为 epoll
会有更多次的系统调用,用户态和内核态会有更加频繁的切换