摘要:前面几讲手撕了网关服务器回显服务器服务的代码,但是这几个一次只能监听一个文件描述符,因此性能非常原始低下。复用能使服务器同时监听多个文件描述符,是服务器性能提升的关键。表示要操作的文件描述符,指定操作类型,指定事件。
本系列文章导航: 手把手写C++服务器(0):专栏文章-汇总导航【更新中】
前言: Linux中素有“万物皆文件,一切皆IO”的说法。前面几讲手撕了CGI网关服务器、echo回显服务器、discard服务的代码,但是这几个一次只能监听一个文件描述符,因此性能非常原始、低下。IO复用能使服务器同时监听多个文件描述符,是服务器性能提升的关键。虽然IO复用本身是阻塞的,但是和并发技术结合起来,再加上一点设计模式,一个高性能服务器的基石就基本搭建完成了。
目录
强烈推荐看一下本系列的第25讲《手把手写C++服务器(25):万物皆可文件之socket fd》
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。 文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得了CPU资源),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。
缓存I/O又称为标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。在Linux的缓存I/O机制中,操作系统会将I/O的数据缓存在文件系统的页缓存中,即数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
缓存 I/O 的缺点:
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
IO 多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪就会阻塞应用程序,交出CPU。
这是最常用的简单的IO模型。阻塞IO意味着当我们发起一次IO操作后一直等待成功或失败之后才返回,在这期间程序不能做其它的事情。阻塞IO操作只能对单个文件描述符进行操作,详见read或write。
我们在发起IO时,通过对文件描述符设置O_NONBLOCK flag来指定该文件描述符的IO操作为非阻塞。非阻塞IO通常发生在一个for循环当中,因为每次进行IO操作时要么IO操作成功,要么当IO操作会阻塞时返回错误EWOULDBLOCK/EAGAIN,然后再根据需要进行下一次的for循环操作,这种类似轮询的方式会浪费很多不必要的CPU资源,是一种糟糕的设计。和阻塞IO一样,非阻塞IO也是通过调用read或write来进行操作的,也只能对单个描述符进行操作。
IO多路复用在Linux下包括了三种,select、poll、epoll,抽象来看,他们功能是类似的,但具体细节各有不同:首先都会对一组文件描述符进行相关事件的注册,然后阻塞等待某些事件的发生或等待超时。IO多路复用都可以关注多个文件描述符,但对于这三种机制而言,不同数量级文件描述符对性能的影响是不同的,下面会详细介绍。
信号驱动IO是利用信号机制,让内核告知应用程序文件描述符的相关事件。
但信号驱动IO在网络编程的时候通常很少用到,因为在网络环境中,和socket相关的读写事件太多了,比如下面的事件都会导致SIGIO信号的产生:
上面所有的这些都会产生SIGIO信号,但我们没办法在SIGIO对应的信号处理函数中区分上述不同的事件,SIGIO只应该在IO事件单一情况下使用,比如说用来监听端口的socket,因为只有客户端发起新连接的时候才会产生SIGIO信号。
异步IO和信号驱动IO差不多,但它比信号驱动IO可以多做一步:相比信号驱动IO需要在程序中完成数据从用户态到内核态(或反方向)的拷贝,异步IO可以把拷贝这一步也帮我们完成之后才通知应用程序。我们使用 aio_read 来读,aio_write 写。
同步IO vs 异步IO
1. 同步IO指的是程序会一直阻塞到IO操作如read、write完成
2. 异步IO指的是IO操作不会阻塞当前程序的继续执行
所以根据这个定义,上面阻塞IO当然算是同步的IO,非阻塞IO也是同步IO,因为当文件操作符可用时我们还是需要阻塞的读或写,同理IO多路复用和信号驱动IO也是同步IO,只有异步IO是完全完成了数据的拷贝之后才通知程序进行处理,没有阻塞的数据读写过程。
select的作用是在一段指定的时间内,监听用户感兴趣的文件描述符上的可读、可写、异常等事件。函数原型如下:
#include int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
readfds、writefds、exceptfds都是fd_set结构体,timeout是timeval结构体,这里详解一下这两个结构体。
1、fd_set
fd_set结构体定义比较复杂,涉及到位操作,比较复杂。所以通常用宏来访问fd_set中的位。
#include FD_ZERO(fd_set* fdset); // 清除fdset中的所有位FD_SET(int fd, fd_set* fdset); // 设置fdset中的位FD_CLR(int fd, fd_set* fdset); // 清除fdset中的位int FD_ISSET(int fd, fd_set* fdset); // 测试fdset的位fd是否被设置
2、timeval
struct timeval { long tv_sec; // 秒数 long tv_usec; // 微妙数};
综上所述,我们一般的使用流程是:
根据使用流程,给出一个代码示例:
#include #include #include #include #define TIMEOUT 5 /* select timeout in seconds */#define BUF_LEN 1024 /* read buffer in bytes */int main (void) { struct timeval tv; fd_set readfds; int ret; /* Wait on stdin for input. */ FD_ZERO(&readfds); FD_SET(STDIN_FILENO, &readfds); /* Wait up to five seconds. */ tv.tv_sec = TIMEOUT; tv.tv_usec = 0; /* All right, now block! */ ret = select (STDIN_FILENO + 1, &readfds, NULL, NULL, &tv); if (ret == −1) { perror ("select"); return 1; } else if (!ret) { printf ("%d seconds elapsed./n", TIMEOUT); return 0; } /* * Is our file descriptor ready to read? * (It must be, as it was the only fd that * we provided and the call returned * nonzero, but we will humor ourselves.) */ if (FD_ISSET(STDIN_FILENO, &readfds)) { char buf[BUF_LEN+1]; int len; /* guaranteed to not block */ len = read (STDIN_FILENO, buf, BUF_LEN); if (len == −1) { perror ("read"); return 1; } if (len) { buf[len] = "/0"; printf ("read: %s/n", buf); } return 0; } fprintf (stderr, "This should not happen!/n"); return 1; }
后面一讲会给出一些实用的例子,有了select之后我们可以同时监听很多个请求,系统的处理能力大大增强了。
和select类似,在一定时间内轮询一定数量的文件描述符。
#include int poll(struct pollfd* fds, nfds_t nfds, int timeout);
但是和select不同的是,select需要用三组文件描述符,poll只有一个pollfd文件数组,数组中的每个元素都表示一个需要监听IO操作事件的文件描述符。而且我们只需要关心数组中events参数,revents由内核自动填充。
struct pollfd { int fd; // 文件描述符 short events; // 注册的事件 short revents; // 实际发生的事件,由内核填充 };
具体的事件类型参看手册:https://man7.org/linux/man-pages/man2/poll.2.html
POLLIN There is data to read. POLLPRI There is some exceptional condition on the file descriptor. Possibilities include: • There is out-of-band data on a TCP socket (see tcp(7)). • A pseudoterminal master in packet mode has seen a state change on the slave (see ioctl_tty(2)). • A cgroup.events file has been modified (see cgroups(7)). POLLOUT Writing is now possible, though a write larger than the available space in a socket or pipe will still block (unless O_NONBLOCK is set). POLLRDHUP (since Linux 2.6.17) Stream socket peer closed connection, or shut down writing half of connection. The _GNU_SOURCE feature test macro must be defined (before including any header files) in order to obtain this definition. POLLERR Error condition (only returned in revents; ignored in events). This bit is also set for a file descriptor referring to the write end of a pipe when the read end has been closed. POLLHUP Hang up (only returned in revents; ignored in events). Note that when reading from a channel such as a pipe or a stream socket, this event merely indicates that the peer closed its end of the channel. Subsequent reads from the channel will return 0 (end of file) only after all outstanding data in the channel has been consumed. POLLNVAL Invalid request: fd not open (only returned in revents; ignored in events). When compiling with _XOPEN_SOURCE defined, one also has the following, which convey no further information beyond the bits listed above: POLLRDNORM Equivalent to POLLIN. POLLRDBAND Priority band data can be read (generally unused on Linux). POLLWRNORM Equivalent to POLLOUT. POLLWRBAND Priority data may be written.
综上所述,我们一般的使用流程是:
根据使用流程,给出一个代码示例:
#include #include #include #define TIMEOUT 5 /* poll timeout, in seconds */int main (void) { struct pollfd fds[2]; int ret; /* watch stdin for input */ fds[0].fd = STDIN_FILENO; fds[0].events = POLLIN; /* watch stdout for ability to write (almost always true) */ fds[1].fd = STDOUT_FILENO; fds[1].events = POLLOUT; /* All set, block! */ ret = poll (fds, 2, TIMEOUT * 1000); if (ret == −1) { perror ("poll"); return 1; } if (!ret) { printf ("%d seconds elapsed./n", TIMEOUT); return 0; } if (fds[0].revents & POLLIN) printf ("stdin is readable/n"); if (fds[1].revents & POLLOUT) printf ("stdout is writable/n"); return 0; }
epoll是Linux特有的IO复用函数,使用一组函数来完成任务,而不是单个函数。
epoll把用户关心的文件描述符上的事件放在内核的一个事件表中,不需要像select、poll那样每次调用都要重复传入文件描述符集或事件集。
epoll需要使用一个额外的文件描述符,来唯一标识内核中的时间表,由epoll_create创建。
#include int epoll_create(int size); int epoll_create1(int flags); int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); int epoll_pwait(int epfd, struct epoll_event *events, int maxevents, int timeout, const sigset_t *sigmask);
特别注意epoll_wait函数成功时返回就绪的文件描述符总数。select和poll返回文件描述符总数。
以寻找已经就绪的文件描述符,举个例子如下:
epoll_wait只需要遍历返回的文件描述符,但是poll和select需要遍历所有文件描述符
// pollint ret = poll(fds, MAX_EVENT_NUMBER, -1);// 必须遍历所有已注册的文件描述符for (int i = 0; i < MAX_EVENT_NUMBER; i++) { if (fds[i].revents & POLLIN) { int sockfd = fds[i].fd; }}// epoll_waitint ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);// 仅需要遍历就绪的ret个文件描述符for (int i = 0; i < ret; i++) { int sockfd = events[i].data.fd;}
epoll监控多个文件描述符的I/O事件。epoll支持边缘触发(edge trigger,ET)或水平触发(level trigger,LT),通过epoll_wait等待I/O事件,如果当前没有可用的事件则阻塞调用线程。
select和poll只支持LT工作模式,epoll的默认的工作模式是LT模式。
水平触发:
边沿触发:
所以,边沿触发模式很大程度上降低了同一个epoll事件被重复触发的次数,所以效率更高。
#include #include #include #include #include #include #include #include #include #include #define MAXEVENTS 64static int make_socket_non_blocking (int sfd){ int flags, s; flags = fcntl (sfd, F_GETFL, 0); if (flags == -1) { perror ("fcntl"); return -1; } flags |= O_NONBLOCK; s = fcntl (sfd, F_SETFL, flags); if (s == -1) { perror ("fcntl"); return -1; } return 0;}static int create_and_bind (char *port){ struct addrinfo hints; struct addrinfo *result, *rp; int s, sfd; memset (&hints, 0, sizeof (struct addrinfo)); hints.ai_family = AF_UNSPEC; /* Return IPv4 and IPv6 choices */ hints.ai_socktype = SOCK_STREAM; /* We want a TCP socket */ hints.ai_flags = AI_PASSIVE; /* All interfaces */ s = getaddrinfo (NULL, port, &hints, &result); if (s != 0) { fprintf (stderr, "getaddrinfo: %s/n", gai_strerror (s)); return -1; } for (rp = result; rp != NULL; rp = rp->ai_next) { sfd = socket (rp->ai_family, rp->ai_socktype, rp->ai_protocol); if (sfd == -1) continue; s = bind (sfd, rp->ai_addr, rp->ai_addrlen); if (s == 0) { /* We managed to bind successfully! */ break; } close (sfd); } if (rp == NULL) { fprintf (stderr, "Could not bind/n"); return -1; } freeaddrinfo (result); return sfd;}int main (int argc, char *argv[]){ int sfd, s; int efd; struct epoll_event event; struct epoll_event *events; if (argc != 2) { fprintf (stderr, "Usage: %s [port]/n", argv[0]); exit (EXIT_FAILURE); } sfd = create_and_bind (argv[1]); if (sfd == -1) abort (); s = make_socket_non_blocking (sfd); if (s == -1) abort (); s = listen (sfd, SOMAXCONN); if (s == -1) { perror ("listen"); abort (); } efd = epoll_create1 (0); if (efd == -1) { perror ("epoll_create"); abort (); } event.data.fd = sfd; event.events = EPOLLIN | EPOLLET; s = epoll_ctl (efd, EPOLL_CTL_ADD, sfd, &event); if (s == -1) { perror ("epoll_ctl"); abort (); } /* Buffer where events are returned */ events = calloc (MAXEVENTS, sizeof event); /* The event loop */ while (1) { int n, i; n = epoll_wait (efd, events, MAXEVENTS, -1); for (i = 0; i < n; i++) { if ((events[i].events & EPOLLERR) || (events[i].events & EPOLLHUP) || (!(events[i].events & EPOLLIN))) { /* An error has occured on this fd, or the socket is not ready for reading (why were we notified then?) */ fprintf (stderr, "epoll error/n"); close (events[i].data.fd); continue; } else if (sfd == events[i].data.fd) { /* We have a notification on the listening socket, which means one or more incoming connections. */ while (1) { struct sockaddr in_addr; socklen_t in_len; int infd; char hbuf[NI_MAXHOST], sbuf[NI_MAXSERV]; in_len = sizeof in_addr; infd = accept (sfd, &in_addr, &in_len); if (infd == -1) { if ((errno == EAGAIN) || (errno == EWOULDBLOCK)) { /* We have processed all incoming connections. */ break; } else { perror ("accept"); break; } } s = getnameinfo (&in_addr, in_len, hbuf, sizeof hbuf, sbuf, sizeof sbuf, NI_NUMERICHOST | NI_NUMERICSERV); if (s == 0) { printf("Accepted connection on descriptor %d " "(host=%s, port=%s)/n", infd, hbuf, sbuf); } /* Make the incoming socket non-blocking and add it to the list of fds to monitor. */ s = make_socket_non_blocking (infd); if (s == -1) abort (); event.data.fd = infd; event.events = EPOLLIN | EPOLLET; s = epoll_ctl (efd, EPOLL_CTL_ADD, infd, &event); if (s == -1) { perror ("epoll_ctl"); abort (); } } continue; } else { /* We have data on the fd waiting to be read. Read and display it. We must read whatever data is available completely, as we are running in edge-triggered mode and won"t get a notification again for the same data. */ int done = 0; while (1) { ssize_t count; char buf[512]; count = read (events[i].data.fd, buf, sizeof buf); if (count == -1) { /* If errno == EAGAIN, that means we have read all data. So go back to the main loop. */ if (errno != EAGAIN) { perror ("read"); done = 1; } break; } else if (count == 0) { /* End of file. The remote has closed the connection. */ done = 1; break; } /* Write the buffer to standard output */ s = write (1, buf, count); if (s == -1) { perror ("write"); abort (); } } if (done) { printf ("Closed connection on descriptor %d/n", events[i].data.fd); /* Closing the descriptor will make epoll remove it from the set of descriptors which are monitored. */ close (events[i].data.fd); } } } } free (events); close (sfd); return EXIT_SUCCESS;}
select和poll的动作基本一致,只是poll采用链表来进行文件描述符的存储,而select采用fd标注位来存放,所以select会受到最大连接数的限制,而poll不会。
select、poll、epoll虽然都会返回就绪的文件描述符数量。但是select和poll并不会明确指出是哪些文件描述符就绪,而epoll会。造成的区别就是,系统调用返回后,调用select和poll的程序需要遍历监听的整个文件描述符找到是谁处于就绪,而epoll则直接处理即可。
select、poll都需要将有关文件描述符的数据结构拷贝进内核,最后再拷贝出来。而epoll创建的有关文件描述符的数据结构本身就存于内核态中。
select、poll采用轮询的方式来检查文件描述符是否处于就绪态,而epoll采用回调机制。造成的结果就是,随着fd的增加,select和poll的效率会线性降低,而epoll不会受到太大影响,除非活跃的socket很多。
epoll的边缘触发模式效率高,系统不会充斥大量不关心的就绪文件描述符。
虽然epoll的性能最好,但是在连接数少并且连接都十分活跃的情况下,select和poll的性能可能比epoll好,毕竟epoll的通知机制需要很多函数回调。
这一讲偏理论,主要讲了Linux中三种IO复用。后面几讲会在这一讲的基础上,围绕IO写一些有趣的实战demo,敬请期待。
参考
- https://blog.csdn.net/weixin_42145502/article/details/107320539?ops_request_misc=%257B%2522request%255Fid%2522%253A%2522163011698816780262548239%2522%252C%2522scm%2522%253A%252220140713.130102334..%2522%257D&request_id=163011698816780262548239&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~top_positive~default-1-107320539.pc_v2_rank_blog_default&utm_term=IO%E5%A4%8D%E7%94%A8&spm=1018.2226.3001.4450
- 《Linux高性能服务器编程》
- https://juejin.cn/post/6882984260672847879
- https://zhuanlan.zhihu.com/p/115220699
- https://man7.org/linux/man-pages/man2/poll.2.html
- https://zhuanlan.zhihu.com/p/159135478
文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。
转载请注明本文地址:https://www.ucloud.cn/yun/118798.html
摘要:当一个文件要被多个处理,那么一定要指定执行的先后顺序先执行在执行参考 webpack系列文章: 【Webpack 性能优化系列(2) - source-map】【W...
摘要:模块什么是模块什么是模块化玩过游戏的朋友应该知道,一把装配完整的步枪,一般是枪身消音器倍镜握把枪托。更重要的是,其它大部分语言都支持模块化。这一点与规范完全不同。模块输出的是值的缓存,不存在动态更新。 1.模块 1.1 什么是模块?什么是模块化? 玩过FPS游戏的朋友应该知道,一把装配完整的M4步枪,一般是枪身+消音器+倍镜+握把+枪托。 如果把M4步枪看成是一个页面的话,那么我们可以...
摘要:大家好,我是冰河有句话叫做投资啥都不如投资自己的回报率高。马上就十一国庆假期了,给小伙伴们分享下,从小白程序员到大厂高级技术专家我看过哪些技术类书籍。 大家好,我是...
阅读 2577·2021-11-23 09:51
阅读 808·2021-09-24 10:37
阅读 3565·2021-09-02 15:15
阅读 1943·2019-08-30 13:03
阅读 1862·2019-08-29 15:41
阅读 2584·2019-08-29 14:12
阅读 1382·2019-08-29 11:19
阅读 3278·2019-08-26 13:39