手把手写C++服务器(31):服务器性能提升关键——IO复用技术两万字长文
Posted 沉迷单车的追风少年
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了手把手写C++服务器(31):服务器性能提升关键——IO复用技术两万字长文相关的知识,希望对你有一定的参考价值。
本系列文章导航: 手把手写C++服务器(0):专栏文章-汇总导航【更新中】
前言: Linux中素有“万物皆文件,一切皆IO”的说法。前面几讲手撕了CGI网关服务器、echo回显服务器、discard服务的代码,但是这几个一次只能监听一个文件描述符,因此性能非常原始、低下。IO复用能使服务器同时监听多个文件描述符,是服务器性能提升的关键。虽然IO复用本身是阻塞的,但是和并发技术结合起来,再加上一点设计模式,一个高性能服务器的基石就基本搭建完成了。
目录
1、预备知识
(1)文件描述符
强烈推荐看一下本系列的第25讲《手把手写C++服务器(25):万物皆可文件之socket fd》
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。 文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
(2)进程阻塞
正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得了CPU资源),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。
(3)缓存IO
缓存I/O又称为标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。在Linux的缓存I/O机制中,操作系统会将I/O的数据缓存在文件系统的页缓存中,即数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。
缓存 I/O 的缺点:
数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。
(4)什么是IO多路复用?
IO 多路复用是一种同步IO模型,实现一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;没有文件句柄就绪就会阻塞应用程序,交出CPU。
2、Linux五大IO模型
(1)阻塞IO
这是最常用的简单的IO模型。阻塞IO意味着当我们发起一次IO操作后一直等待成功或失败之后才返回,在这期间程序不能做其它的事情。阻塞IO操作只能对单个文件描述符进行操作,详见read或write。
(2)非阻塞IO
我们在发起IO时,通过对文件描述符设置O_NONBLOCK flag来指定该文件描述符的IO操作为非阻塞。非阻塞IO通常发生在一个for循环当中,因为每次进行IO操作时要么IO操作成功,要么当IO操作会阻塞时返回错误EWOULDBLOCK/EAGAIN,然后再根据需要进行下一次的for循环操作,这种类似轮询的方式会浪费很多不必要的CPU资源,是一种糟糕的设计。和阻塞IO一样,非阻塞IO也是通过调用read或write来进行操作的,也只能对单个描述符进行操作。
(3)IO多路复用
IO多路复用在Linux下包括了三种,select、poll、epoll,抽象来看,他们功能是类似的,但具体细节各有不同:首先都会对一组文件描述符进行相关事件的注册,然后阻塞等待某些事件的发生或等待超时。IO多路复用都可以关注多个文件描述符,但对于这三种机制而言,不同数量级文件描述符对性能的影响是不同的,下面会详细介绍。
(4)信号驱动IO
信号驱动IO是利用信号机制,让内核告知应用程序文件描述符的相关事件。
但信号驱动IO在网络编程的时候通常很少用到,因为在网络环境中,和socket相关的读写事件太多了,比如下面的事件都会导致SIGIO信号的产生:
- TCP连接建立
- 一方断开TCP连接请求
- 断开TCP连接请求完成
- TCP连接半关闭
- 数据到达TCP socket
- 数据已经发送出去(如:写buffer有空余空间)
上面所有的这些都会产生SIGIO信号,但我们没办法在SIGIO对应的信号处理函数中区分上述不同的事件,SIGIO只应该在IO事件单一情况下使用,比如说用来监听端口的socket,因为只有客户端发起新连接的时候才会产生SIGIO信号。
(5)异步IO
异步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是完全完成了数据的拷贝之后才通知程序进行处理,没有阻塞的数据读写过程。
3、select
select的作用是在一段指定的时间内,监听用户感兴趣的文件描述符上的可读、可写、异常等事件。函数原型如下:
#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
函数返回
- select成功时返回就绪文件描述符的总数;
- 如果在超时时间内没有任何文件描述符就绪,select将返回0;
- select失败时返回-1并设置errno。;
- 如果在select等待期间,程序接收到信号,select立即返回-1,并将errno设置为EINTR。
参数详解
- nfds:指定被监听文件描述符总数。通常被设置为select监听所有文件描述符中的最大值+1。
- readfds:可读事件对应文件描述符集合。
- writefds:可写事件对应文件描述符集合。
- exceptfds:异常事件对应文件描述符集合。
- timeout:设置select超时时间。
重要结构体详解
readfds、writefds、exceptfds都是fd_set结构体,timeout是timeval结构体,这里详解一下这两个结构体。
1、fd_set
fd_set结构体定义比较复杂,涉及到位操作,比较复杂。所以通常用宏来访问fd_set中的位。
#include <sys/select.h>
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是否被设置
- FD_ZERO用来清空文件描述符组。每次调用select前都需要清空一次。
- FD_SET添加一个文件描述符到组中,FD_CLR对应将一个文件描述符移出组中。
- FD_ISSET检测一个文件描述符是否在组中,我们用这个来检测一次select调用之后有哪些文件描述符可以进行IO操作。
2、timeval
struct timeval {
long tv_sec; // 秒数
long tv_usec; // 微妙数
};
使用流程
综上所述,我们一般的使用流程是:
- 准备工作——定义readfds、timeval等
- 使用FD_ZERO清零,使用FD_SET设置文件描述符。因为事件发生后,文件描述符集合都将被内核修改。
- 调用select
- 使用FD_ISSET检测文件描述符是否在组中
代码实例
根据使用流程,给出一个代码示例:
#include <stdio.h>
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
#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之后我们可以同时监听很多个请求,系统的处理能力大大增强了。
4、poll
和select类似,在一定时间内轮询一定数量的文件描述符。
函数原型
#include <poll.h>
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.
使用流程
综上所述,我们一般的使用流程是:
- 定义pollfd数组,并设置poll数组相关参数。
- 设置超时时间
- 调用poll
代码实例
根据使用流程,给出一个代码示例:
#include <stdio.h>
#include <unistd.h>
#include <poll.h>
#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;
}
5、epoll
epoll是Linux特有的IO复用函数,使用一组函数来完成任务,而不是单个函数。
epoll把用户关心的文件描述符上的事件放在内核的一个事件表中,不需要像select、poll那样每次调用都要重复传入文件描述符集或事件集。
epoll需要使用一个额外的文件描述符,来唯一标识内核中的时间表,由epoll_create创建。
函数原型
#include <sys/epoll.h>
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_create:创建一个epoll实例,size参数给内核一个提示,标识事件表的大小。函数返回的文件描述符将作用其他所有epoll系统调用的第一个参数,以指定要访问的内核事件表。
- epoll_ctl:操作文件描述符。fd表示要操作的文件描述符,op指定操作类型,event指定事件。
- epoll_wait:在一段超时时间内等待一组文件描述符上的事件。如果监测到事件,就将所有就绪的事件从内核事件表(epfd参数指定)中复制到第二个参数events指向的数组中。因为events数组只用于输出epoll_wait监测到的就绪事件,而不像select、poll那样就用于传入用户注册的事件,又用于输出内核检测到的就绪事件。这样极大提高了应用程序索引就绪文件描述符的效率。
函数返回
特别注意epoll_wait函数成功时返回就绪的文件描述符总数。select和poll返回文件描述符总数。
以寻找已经就绪的文件描述符,举个例子如下:
epoll_wait只需要遍历返回的文件描述符,但是poll和select需要遍历所有文件描述符
// poll
int 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_wait
int ret = epoll_wait(epollfd, events, MAX_EVENT_NUMBER, -1);
// 仅需要遍历就绪的ret个文件描述符
for (int i = 0; i < ret; i++) {
int sockfd = events[i].data.fd;
}
LT水平触发模式和ET边沿触发模式
epoll监控多个文件描述符的I/O事件。epoll支持边缘触发(edge trigger,ET)或水平触发(level trigger,LT),通过epoll_wait等待I/O事件,如果当前没有可用的事件则阻塞调用线程。
select和poll只支持LT工作模式,epoll的默认的工作模式是LT模式。
水平触发:
- 当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序可以不立即处理此事件。这样应用程序下一次调用epoll_wait的时候,epoll_wait还会再次向应用程序通告此事件,直到事件被处理。
边沿触发:
- 当epoll_wait检测到其上有事件发生并将此事件通知应用程序后,应用程序必须立即处理此事件,后续的epoll_wait调用将不再向应用程序通知这一事件。
所以,边沿触发模式很大程度上降低了同一个epoll事件被重复触发的次数,所以效率更高。
代码实例
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <errno.h>
#define MAXEVENTS 64
static 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;
}
6、三组IO复用函数对比
1. 用户态将文件描述符传入内核的方式
- select:创建3个文件描述符集并拷贝到内核中,分别监听读、写、异常动作。这里受到单个进程可以打开的fd数量限制,默认是1024。
- poll:将传入的struct pollfd结构体数组拷贝到内核中进行监听。
- epoll:执行epoll_create会在内核的高速cache区中建立一颗红黑树以及就绪链表(该链表存储已经就绪的文件描述符)。接着用户执行的epoll_ctl函数添加文件描述符会在红黑树上增加相应的结点。
2. 内核态检测文件描述符读写状态的方式
- select:采用轮询方式,遍历所有fd,最后返回一个描述符读写操作是否就绪的mask掩码,根据这个掩码给fd_set赋值。
- poll:同样采用轮询方式,查询每个fd的状态,如果就绪则在等待队列中加入一项并继续遍历。
- epoll:采用回调机制。在执行epoll_ctl的add操作时,不仅将文件描述符放到红黑树上,而且也注册了回调函数,内核在检测到某文件描述符可读/可写时会调用回调函数,该回调函数将文件描述符放在就绪链表中。
3. 找到就绪的文件描述符并传递给用户态的方式
- select:将之前传入的fd_set拷贝传出到用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
- poll:将之前传入的fd数组拷贝传出用户态并返回就绪的文件描述符总数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。
- epoll:epoll_wait只用观察就绪链表中有无数据即可,最后将链表的数据返回给数组并返回就绪的数量。内核将就绪的文件描述符放在传入的数组中,所以只用遍历依次处理即可。
4. 重复监听的处理方式
- select:将新的监听文件描述符集合拷贝传入内核中,继续以上步骤。
- poll:将新的struct pollfd结构体数组拷贝传入内核中,继续以上步骤。
- epoll:无需重新构建红黑树,直接沿用已存在的即可。
7、经典面试题:epoll更高效的原因?
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
以上是关于手把手写C++服务器(31):服务器性能提升关键——IO复用技术两万字长文的主要内容,如果未能解决你的问题,请参考以下文章
手把手写C++服务器(34):高并发高吞吐IO秘密武器——epoll池化技术两万字长文
手把手写C++服务器:常用boost之program_options命令行参数解析