ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++编写高性能服务器实例教程

C++编写高性能服务器实例教程 前言标题里的高性能服务器很容易被理解成用了某个高性能函数就能变快。实际上单机服务器性能主要由四件事决定IO 模型阻塞、多路复用、异步、线程模型每连接一线程、单线程 reactor、reactor 加线程池、内存策略拷贝次数、缓冲区复用、对象池、协议设计是否粘包、是否能批量收发。本文不会给出任何性能提升多少倍的数字——那类数字跟机型、内核版本、报文大小强相关脱离场景没有意义本文只讲每种做法省掉了什么并给出一份可以直接编译运行的骨架。还有一个前提必须说清以下的epoll、accept4、SO_REUSEPORT、sendfile全是 Linux 专有的系统调用不是 C 标准库的一部分。同样的思想在 FreeBSD 上是kqueue在 Windows 上是 IOCP完成端口。示例以Linux GCC 13 / Clang 17、C17为准。本文给一个单线程 reactor 的完整回显服务器非阻塞套接字 epoll边缘触发 每连接待发缓冲可作为继续加线程池的起点。一、为什么是 epoll复杂度花在哪里三种多路复用接口的差别不在哪个快而在每次调用要处理多少数据特性selectpollepoll描述符集合三个位图fd_setstruct pollfd数组内核里的红黑树 就绪链表每次调用整个集合拷进内核 线性扫描整个数组拷进内核 线性扫描只拷贝就绪的那部分出来可监听的 fd 数受FD_SETSIZEglibc 常为 1024限制只受进程 fd 上限限制只受进程 fd 上限限制fd 能否超过 1023不能位图按值索引能能触发模式只有水平触发只有水平触发水平与边缘触发都可选平台POSIX 通用POSIX 通用Linux 专有关键差别是前两列select/poll的复杂度花在监视的集合有多大上epoll的复杂度花在这一轮就绪的有几个上。一个维持上万空闲连接、每秒只有几十个活跃的服务器两者的差距就来自这里。epoll的使用是三件套epoll_create1创建实例epoll_ctl增删改关注的事件epoll_wait等待。每次epoll_ctl之后内核里维护的注册项是长期存在的不需要每轮重传这正是它省掉的开销。要提醒的是epoll_ctl本身也是一次系统调用所以连接频繁建立/销毁的场景下它并不免费。二、非阻塞 边缘触发为什么必须读到 EAGAINepoll有两种触发模式选错是新手最常见的 bug 来源模式通知语义读法要求适合水平触发 LT默认只要缓冲区里还有数据每轮epoll_wait都会通知可以只读一次剩下的下轮再读逻辑简单代码量小边缘触发 ETEPOLLET只在状态发生变化时通知一次必须循环读到返回EAGAIN一次读到空减少epoll_wait的唤醒次数适合高并发小报文边缘触发的边缘指的是状态跳变数据从没有变成有的那一刻通知你一次。如果你只读了 1 KB 就返回而缓冲区里还剩 5 KB内核不会再通知你那 5 KB 就永远卡在那里表现为连接hang 住。所以 ET 模式下的读循环必须是循环 n read(fd, buf, sizeof buf) n 0 - 处理继续循环 n 0 - 对端关闭结束 n -1 且 errno EINTR - 继续循环 n -1 且 errno EAGAIN/EWOULDBLOCK - 读空了退出循环 n -1 其他 - 真错误关闭同样的道理适用于写ET 模式下套接字可写事件也是一次性的所以手里有数据没发完时必须自己记着等 EPOLLOUT发空了再取消关注否则要么丢数据、要么被EPOLLOUT反复叫醒形成忙循环。三、完整骨架单线程 epoll 回显服务器下面这份代码可以直接保存为echo_server.cpp编译运行g -stdc17 -O2 -Wall -Wextra echo_server.cpp -pthread -o echo_server。它做了四件事监听、接受连接、ET 读循环、带高水位保护的发送。// echo_server.cpp —— Linux GCC 13 / Clang 17C17 // 单线程 epoll 非阻塞 边缘触发的回显服务器骨架 #include arpa/inet.h #include netinet/in.h #include netinet/tcp.h #include sys/epoll.h #include sys/socket.h #include unistd.h #include cerrno #include cstdint #include cstdio #include string #include unordered_map #include vector namespace { constexpr std::uint16_t kPort 9000; constexpr int kMaxEvents 1024; constexpr std::size_t kReadChunk 64 * 1024; // 单次读取缓冲 constexpr std::size_t kOutHighWater 8 * 1024 * 1024; // 每连接待发上限 // 待发数据用偏移量代替反复 erase(0, n)避免每次搬运整块内存 struct OutBuf { std::string data; std::size_t off 0; bool empty() const { return off data.size(); } void consume(std::size_t n) { off n; if (off data.size()) { // 发完了就整体复位容量留给下次复用 data.clear(); off 0; } } }; struct Conn { OutBuf out; }; // 修改关注的事件want_write 为 false 表示不再关心可写 bool update_interest(int epfd, int fd, bool want_write, bool want_read true) { epoll_event ev{}; ev.events EPOLLET; if (want_read) ev.events | EPOLLIN; if (want_write) ev.events | EPOLLOUT; ev.data.fd fd; return ::epoll_ctl(epfd, EPOLL_CTL_MOD, fd, ev) 0; } // 尽量把 out 里的数据写出去。返回 false 表示这条连接已经不能用了。 bool flush(int epfd, int fd, OutBuf out) { while (!out.empty()) { const ssize_t n ::write(fd, out.data.data() out.off, out.data.size() - out.off); if (n 0) { out.consume(static_caststd::size_t(n)); // 短写只前进了 n 字节 continue; } if (n -1 errno EINTR) continue; if (n -1 (errno EAGAIN || errno EWOULDBLOCK)) { return update_interest(epfd, fd, /*want_write*/true); // 等内核可写 } return false; // EPIPE / ECONNRESET / 其他错误 } return update_interest(epfd, fd, /*want_write*/false); } void close_conn(int epfd, int fd) { ::epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr); // fd 关闭后会自动摘除DEL 是显式保险 ::close(fd); } } // namespace int main() { // SOCK_NONBLOCK / SOCK_CLOEXEC 是 Linux 扩展移植时可改用 fcntl 设置 const int lfd ::socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0); if (lfd 0) { std::perror(socket); return 1; } int one 1; ::setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, one, sizeof(one)); sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(kPort); if (::bind(lfd, reinterpret_castsockaddr*(addr), static_castsocklen_t(sizeof(addr))) 0) { std::perror(bind); return 1; } if (::listen(lfd, SOMAXCONN) 0) { std::perror(listen); return 1; } const int epfd ::epoll_create1(EPOLL_CLOEXEC); if (epfd 0) { std::perror(epoll_create1); return 1; } // 监听套接字用水平触发lt 下有连接就通知配合循环 accept 也不会漏 epoll_event lev{}; lev.events EPOLLIN; lev.data.fd lfd; if (::epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, lev) 0) { std::perror(epoll_ctl ADD listen); return 1; } std::vectorepoll_event events(kMaxEvents); std::unordered_mapint, Conn conns; for (;;) { const int n ::epoll_wait(epfd, events.data(), kMaxEvents, -1); if (n 0) { if (errno EINTR) continue; std::perror(epoll_wait); break; } for (int i 0; i n; i) { const int fd events[static_caststd::size_t(i)].data.fd; const std::uint32_t revents events[static_caststd::size_t(i)].events; if (fd lfd) { // 循环 accept即使监听套接字是 LT多连接同时到达时也一次收完 for (;;) { sockaddr_in peer{}; socklen_t plen static_castsocklen_t(sizeof(peer)); const int cfd ::accept4(lfd, reinterpret_castsockaddr*(peer), plen, SOCK_NONBLOCK | SOCK_CLOEXEC); if (cfd 0) { if (errno EAGAIN || errno EWOULDBLOCK) break; // 已排空 if (errno EINTR) continue; std::perror(accept4); break; } int nodelay 1; // 关掉 Nagle小报文不希望被攒着等 ACK ::setsockopt(cfd, IPPROTO_TCP, TCP_NODELAY, nodelay, sizeof(nodelay)); epoll_event cev{}; cev.events EPOLLIN | EPOLLET; cev.data.fd cfd; if (::epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, cev) 0) { ::close(cfd); // 注册失败就别把它放进 map continue; } conns.emplace(cfd, Conn{}); } continue; } const auto it conns.find(fd); if (it conns.end()) continue; // 同一批事件里已经处理过并关闭的连接 Conn c it-second; bool dead false; if ((revents EPOLLOUT) ! 0) { if (!flush(epfd, fd, c.out)) dead true; } if (!dead (revents EPOLLIN) ! 0) { char buf[kReadChunk]; for (;;) { const ssize_t nr ::read(fd, buf, sizeof(buf)); if (nr 0) { c.out.data.append(buf, static_caststd::size_t(nr)); // 回显 continue; } if (nr 0) { dead true; break; } // 对端关闭 if (errno EINTR) continue; if (errno EAGAIN || errno EWOULDBLOCK) break; // 读空才算读完 dead true; break; } // 对端一直不收待发缓冲会无限涨超过高水位就断开保护自己 if (!dead c.out.data.size() kOutHighWater) dead true; if (!dead !c.out.empty()) { if (!flush(epfd, fd, c.out)) dead true; } } // EPOLLHUP / EPOLLERR 不一定同时置 EPOLLIN这里统一收尾 if ((revents (EPOLLHUP | EPOLLERR)) ! 0) dead true; if (dead) { conns.erase(it); close_conn(epfd, fd); } } } ::close(epfd); ::close(lfd); return 0; }几点实现说明。char buf[kReadChunk]是 64 KiB 的栈上数组单线程时没问题但把它搬进线程函数时要注意线程栈大小Linux 上 glibc 默认 8 MBstd::thread也沿用该默认值这是实现定义的不是标准规定。conns用std::unordered_map只是为了骨架清晰真实服务器通常换成开放寻址的哈希表或数组索引因为unordered_map每个节点一次堆分配、迭代缓存局部性差——这是原理上的取舍不是一定更快。close_conn里的EPOLL_CTL_DEL只是保险按 Linux 的行为fd 被close后会自动从 epoll 实例中摘掉。四、往高性能再走一步骨架之外真正拉开差距的是这几件事都只是省掉了什么的问题线程模型。单 reactor 只用到一个核。常见扩展是 one-loop-per-thread主线程负责accept把新连接按 fd 取模分给若干从 reactor 线程每个线程有自己的epoll实例和自己的连接表。这样共享状态几乎为零省掉了锁的争用。避免拷贝。回显场景里数据本来要内核到用户态再到内核用sendfile可以直接把文件页缓存送进套接字省掉用户态这一趟。readv/writev能把多个缓冲区一次系统调用发出去省的是系统调用次数而不是拷贝。缓冲区复用。用对象池管理读写缓冲避免每条消息都malloc/free每连接预分配固定大小环形缓冲省掉std::string反复扩容。惊群处理。多个进程/线程同时epoll_wait同一个监听 fd 时新连接会把所有等待者叫醒只有一个人抢到。Linux 4.5 起可以用EPOLLEXCLUSIVE声明只唤醒一个或者干脆用SO_REUSEPORT让每个进程各自bind由内核做分流。应用层协议。TCP 是字节流没有消息边界。必须自己定义长度前缀或分隔符否则一定遇到粘包/半包。这一条与性能无关却是服务器正确性的前提。常见坑点#场景❌ 错误做法✅ 正确做法1ET 模式只读一次read一次就返回剩下的数据永远不再通知循环读到EAGAIN才退出2把EAGAIN当错误见-1就关连接先判EAGAIN/EWOULDBLOCK那是暂时没数据3忽略短写一次write后就认为发完了丢掉偏移量记录已发字节数没发完就注册EPOLLOUT4EPOLLOUT常驻一上来就监听EPOLLOUT可写时被反复唤醒形成忙循环只在有待发数据时注册发空就取消5阻塞套接字配 epoll忘记O_NONBLOCKET 读循环里read卡死整个线程创建时用SOCK_NONBLOCK或fcntl设置6同一批事件重复处理一个 fd 在同一轮里既读又写读过程中把它close了还继续用处理前查连接表处理中用dead标记循环末尾统一关闭7对端不收数据待发缓冲无限增长最后 OOM设置高水位超限就断开这条连接8忘记SO_REUSEADDR重启服务时bind报EADDRINUSETIME_WAIT 占着端口监听前设置SO_REUSEADDR第 3 条值得多说一句write返回的数字是实际写进去多少不是请求写多少。内核发送缓冲区满时它可能只写了一半。这与前面第 2 条并列是套接字编程里最容易漏掉的两个边界。总结层面做法省掉了什么IO 模型epoll替代select/poll每轮把整个监视集合拷进内核并线性扫描触发模式ET 读到EAGAIN反复唤醒代价是必须写对读循环套接字非阻塞 TCP_NODELAY线程被单个 IO 阻塞小报文的延迟等待线程模型one-loop-per-thread全局锁争用内存缓冲区复用 高水位频繁分配释放与无界增长协议长度前缀粘包/半包带来的解析错误高性能服务器不是一个函数而是一组取舍先用epoll 非阻塞 ET 把 IO 模型做对再把线程模型和缓冲策略按压测结果逐步替换。骨架能跑通只是起点真正决定上限的是协议设计和内存策略——而这两件事都必须在写第一行代码前想清楚。
返回列表