高性能服务器的核心在于使用非阻塞I/O和事件驱动模型。在Linux上,这通常通过 epoll 系统调用来实现。下面是一个简化的、基于Reactor模式的回显(Echo)服务器,它会接收客户端发来的数据并原样返回。

C++17 实现代码
#include <iostream>
#include <string>
#include <vector>
#include <cstring>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <sys/epoll.h>

const int MAX_EVENTS = 1024;
const int PORT = 8080;

// 设置套接字为非阻塞模式
void set_non_blocking(int sockfd) {
    int flags = fcntl(sockfd, F_GETFL, 0);
    fcntl(sockfd, F_SETFL, flags | O_NONBLOCK);
}

int main() {
    // 1. 创建监听套接字
    int listen_sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listen_sockfd < 0) {
        perror("socket creation failed");
        return 1;
    }

    // 设置地址复用,防止重启时端口被占用
    int opt = 1;
    if (setsockopt(listen_sockfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)) < 0) {
        perror("setsockopt failed");
        return 1;
    }

    // 2. 绑定地址和端口
    struct sockaddr_in server_addr{};
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);

    if (bind(listen_sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
        perror("bind failed");
        return 1;
    }

    // 3. 开始监听
    if (listen(listen_sockfd, 10) < 0) {
        perror("listen failed");
        return 1;
    }
    set_non_blocking(listen_sockfd); // 将监听套接字也设为非阻塞

    std::cout << "Server listening on port " << PORT << std::endl;

    // 4. 创建epoll实例
    int epoll_fd = epoll_create1(0);
    if (epoll_fd < 0) {
        perror("epoll_create1 failed");
        return 1;
    }

    // 5. 将监听套接字添加到epoll监控
    struct epoll_event event{};
    event.events = EPOLLIN; // 监控可读事件
    event.data.fd = listen_sockfd;
    if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_sockfd, &event) < 0) {
        perror("epoll_ctl add listen failed");
        return 1;
    }

    // 6. 事件循环
    struct epoll_event events[MAX_EVENTS];
    while (true) {
        // 等待事件发生
        int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        if (nfds < 0) {
            perror("epoll_wait failed");
            break;
        }

        for (int i = 0; i < nfds; ++i) {
            if (events[i].data.fd == listen_sockfd) {
                // --- 处理新连接 ---
                struct sockaddr_in client_addr{};
                socklen_t client_len = sizeof(client_addr);
                int conn_sockfd = accept(listen_sockfd, (struct sockaddr*)&client_addr, &client_len);
                if (conn_sockfd < 0) {
                    perror("accept failed");
                    continue;
                }
                std::cout << "New connection from " << inet_ntoa(client_addr.sin_addr) << ":" << ntohs(client_addr.sin_port) << std::endl;

                set_non_blocking(conn_sockfd); // 新连接的套接字也必须是非阻塞的

                // 将新连接的套接字加入epoll监控
                event.events = EPOLLIN | EPOLLET; // 使用边缘触发(ET)模式
                event.data.fd = conn_sockfd;
                if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_sockfd, &event) < 0) {
                    perror("epoll_ctl add client failed");
                    close(conn_sockfd);
                }
            } else {
                // --- 处理客户端数据 ---
                int conn_sockfd = events[i].data.fd;
                char buffer[1024];
                ssize_t count = read(conn_sockfd, buffer, sizeof(buffer));

                if (count < 0) {
                    // 非阻塞模式下,EAGAIN或EWOULDBLOCK表示数据已读完
                    if (errno == EAGAIN || errno == EWOULDBLOCK) {
                        // std::cerr << "Read finished for fd " << conn_sockfd << std::endl;
                    } else {
                        perror("read failed");
                    }
                } else if (count == 0) {
                    // 客户端关闭连接
                    std::cout << "Client " << conn_sockfd << " disconnected." << std::endl;
                    epoll_ctl(epoll_fd, EPOLL_CTL_DEL, conn_sockfd, nullptr);
                    close(conn_sockfd);
                } else {
                    // 收到数据,回显给客户端
                    // 注意:在实际高性能场景中,write也可能需要处理EAGAIN
                    if (write(conn_sockfd, buffer, count) < 0) {
                        perror("write failed");
                    }
                }
            }
        }
    }

    close(listen_sockfd);
    close(epoll_fd);
    return 0;
}
关键点解析
  1. 非阻塞I/O:通过 fcntl 将套接字设置为非阻塞模式。这样,readaccept 等系统调用在没有数据或连接时会立即返回,而不是让线程挂起,从而允许一个线程处理成千上万个连接。
  2. epoll 事件驱动epoll 是Linux下高效的I/O多路复用机制。服务器通过 epoll_wait 等待多个套接字上的事件(如可读、可写),当有事件发生时,内核会通知程序,程序再进行处理。这避免了为每个连接创建线程的巨大开销。
  3. 边缘触发(ET)模式:代码中使用了 EPOLLET。这意味着只有当套接字的状态发生变化时(例如,从无数据到有数据),epoll_wait 才会通知一次。这要求程序必须一次性将缓冲区的数据读完(或写入直到EAGAIN),性能通常比水平触发(LT)模式更高。
  4. C++17特性:代码中使用了C++17的类模板参数推导(CTAD),例如 struct sockaddr_in server_addr{};,可以自动推导类型并进行值初始化,使代码更简洁。



epoll 单线程支撑十万级并发连接、处理数据的核心原理

我用通俗原理 + 和传统select/poll对比 + 关键底层机制,把核心逻辑讲透,全是面试&底层本质。

一、先给结论:能扛十万级并发,靠这5个核心

  1. epoll 内核就绪队列 + 事件通知模型,不用遍历所有连接
  2. 文件描述符采用红黑树管理,增删查都是 (O(\log n))
  3. 边缘触发 EPOLLET + 非阻塞IO,极致性能、无多余事件通知
  4. 一次 epoll_wait 批量返回就绪事件,用户态内核态拷贝开销极小
  5. 单线程事件循环,无线程切换开销,内存、CPU 消耗极低

二、逐个拆解核心原理

1. 传统 select/poll 为什么扛不住10万连接?

致命缺点:

  • 你要把所有10万个fd都传给内核
  • 内核遍历全部10万fd挨个检查有没有事件
  • 检查完再把10万fd拷贝回用户态
  • 用户态还要再遍历10万fd找哪个有事

连接越多,遍历开销线性暴涨,1万连接就卡了,更别说10万。

时间复杂度

select/poll:(O(n)),n是总连接数。


2. epoll 核心机制1:事件驱动,只返回就绪的fd

epoll 设计思路完全反过来:

  1. 你提前把所有fd注册到内核epoll红黑树
  2. 内核全程帮你监控每个fd状态
  3. 只有有事件就绪(新连接/有数据/断开)的fd,才会被内核放入就绪链表
  4. epoll_wait 只把就绪的少量fd拷贝回用户态

👉 用户态只遍历「有事件的几十个fd」,不是遍历十万个
时间复杂度:(O(1)) 就绪事件数,和总连接数无关

这是能撑十万级空闲长连接的最关键原因。


3. epoll 核心机制2:内核用红黑树管理所有监听fd

  • 所有要监听的 socket fd,在内核用红黑树组织
  • epoll_ctl ADD/DEL/MOD 增删改:(O(\log n))
  • 10万级连接,增删依然极快,不会卡顿

select 是数组、poll 是链表,海量连接下查找、删除都很慢。


4. 核心机制3:EPOLLET 边缘触发 + 全非阻塞

水平触发 LT(默认)

只要缓冲区有数据,每次 epoll_wait 都反复通知
浪费CPU,容易重复处理。

边缘触发 ET(你代码里用的 EPOLLIN|EPOLLET

只在状态发生变化的一瞬间通知一次

  • 新连接到达 瞬间 通知一次
  • 客户端发数据 从无到有 瞬间 通知一次
  • 不会重复刷屏通知

配合每个socket都设为非阻塞

  • 一次事件触发,循环read直到读空缓冲区
  • 不丢失数据、不重复触发、CPU利用率拉满

十万连接大部分是空闲长连接,ET模式完全不浪费CPU在空闲fd上。


5. 核心机制4:用户态/内核态 拷贝开销极小

  • select:每次调用都要整表拷贝所有fd进内核、再拷回来
  • epoll:
    • 注册只在 epoll_ctl 时一次
    • epoll_wait 只拷贝少量就绪事件
    • 十万长连接绝大部分没事,几乎无拷贝开销

6. 核心机制5:单线程事件循环,无线程调度开销

普通多线程模型:

  • 1个连接开1个线程
  • 10万连接就要10万线程
  • 线程栈内存爆炸、内核线程调度CPU疯狂上下文切换,直接崩

epoll 模型:

  • 一个线程就能管理10万+连接
  • 没有线程创建、销毁、切换开销
  • 只循环处理有事件的连接,空闲连接完全不占CPU

三、十万级连接能稳定运行的本质总结

  1. 不遍历全部连接,只处理就绪连接(和总连接数无关)
  2. 内核红黑树管理海量fd,增删高效 (O(\log n))
  3. 边缘触发ET + 非阻塞,空闲连接零CPU消耗
  4. 仅就绪事件拷贝,用户态内核态开销极低
  5. 单线程事件循环,无线程上下文切换、无内存爆炸

四、一句话极简概括

epoll 能撑十万级并发,是因为它不轮询所有连接,而是内核主动只把「有事的连接」通知给程序,再加上红黑树管理、边缘触发、非阻塞IO、单线程事件循环,做到十万空闲连接几乎不耗CPU和内存。




select / poll / epoll 并发模型对比图解 + 核心差异

用最直白的模型对比,一眼看懂为什么只有 epoll 能扛十万级并发

一、先统一场景

假设:总连接 100000 个,同一时刻只有 5 个有数据/新连接


1. Select 模型(古董级)

工作流程

  1. 用户态每次把全部 10 万个 fd 拷贝传给内核
  2. 内核遍历全部 10 万 fd,逐个检查有没有事件
  3. 标记就绪的 fd,再把整个集合拷贝回用户态
  4. 用户态再遍历全部 10 万 fd,挨个判断谁有事

致命缺点

  • 每次都全量拷贝 + 全量内核遍历 + 全量用户遍历
  • 时间复杂度:O(n)
  • 有最大 fd 数量限制(默认 1024)
  • 连接越多,越卡,撑不住上万连接

形象比喻

老板把10万份文件全部交给保安,保安从头到尾翻一遍找出有问题的,再把整摞还给老板,老板再从头翻一遍找问题。


2. Poll 模型(改良版,本质没变)

工作流程

和 select 逻辑几乎一样:

  1. 全部 10 万 fd 数组传给内核
  2. 内核遍历全部 fd 检测事件
  3. 拷贝回用户态,用户再遍历全部 fd

改进

  • 去掉了 1024 最大限制

没解决的核心问题

  • 依然全量拷贝、全量遍历 O(n)
  • 十万连接下 CPU 开销依然爆炸

形象比喻

还是翻完整摞文件,只是文件数量不限制了,但还是要全翻。


3. Epoll 模型(现代高性能)

工作流程

  1. 一开始用 epoll_ctl 把 10万 fd 注册到内核红黑树,只注册一次
  2. 内核全程后台自动监控所有 fd,无事完全不管
  3. 只有有事件就绪的 fd,内核放进就绪链表
  4. epoll_wait 只把就绪的 5 个 fd 拷贝回用户态
  5. 用户态只遍历这 5 个就绪 fd 处理即可

核心优势

  • 不用每次传全部 fd
  • 内核不用遍历所有连接
  • 用户态只遍历就绪少量连接
  • 时间复杂度:O(1)(只跟就绪数有关,跟总连接数无关)
  • 红黑树管理 fd:增删改 O(logn)
  • 配合 ET边缘触发 + 非阻塞,空闲 10万连接几乎 0 CPU 消耗

形象比喻

老板提前把 10万份文件编号交给保安保管,保安平时自动盯着
只有出问题的那几份,单独拿给老板,老板只处理这几份,不用翻全部。


二、三者核心对比表

特性 select poll epoll
遍历方式 全量遍历所有fd 全量遍历所有fd 只遍历就绪fd
时间复杂度 O(n) O(n) O(就绪数)
每次拷贝 全量fd拷贝 全量fd拷贝 只拷贝就绪事件
fd上限 限制1024 无限制 无限制
内核管理结构 位图 链表 红黑树+就绪链表
适合并发量 几百~一千 几千 十万~百万级
触发模式 水平触发LT 水平触发LT LT / ET边缘触发

三、为什么你的代码能扛十万级连接?

  1. epoll 不用遍历全部 10万 连接,只处理有事的几个
  2. 内核红黑树管海量 fd,增删高效不卡顿
  3. EPOLLET 边缘触发 + 全非阻塞,空闲连接不浪费 CPU
  4. 单线程事件循环,无线程上下文切换开销
  5. 只拷贝就绪事件,用户态/内核态开销极低

四、一句总结

  • select/poll:人少还行,人多全员挨个点名
  • epoll:不用点名,谁有事主动举手,只处理举手的人
    这就是 epoll 能轻松支撑十万、百万级长连接的根本原理。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐