用C语言写了一个百万并发服务器
Epoll机制消除全量遍历开销,Reactor模式支撑异步非阻塞IO,系统参数调优突破默认限制。这一架构体系构成了C语言高性能网络编程的基础范式,同样也是Nginx、Redis等业界标杆软件的底层引擎。往期精选文章开发专属👉【就业避坑】C++ 就业前景全解析:为什么劝退声不断,大厂核心岗仍刚需 C++?👉【大厂标准】Linux C/C++ 后端开发系统学习路线👉【音视频】音视频流媒体高级开发
原作者:Linux教程
原文地址:https://mp.weixin.qq.com/s/DtCZSSlHKnmXuqJ56Rr3mA
说到高并发架构,很多开发者首先想到的是C++、Go这些语言,却忽略了纯C语言贴近系统底层、可极致压榨硬件性能的先天优势。今天这篇文章就带大家用纯C语言实现百万连接(核心:epoll+Reactor)。
一、传统阻塞 IO 为何撑不起百万连接
在网络编程的早期,传统阻塞 I/O 模型凭借简单直观的设计思路,成为构建网络应用的首选。其典型模式是 “一连接一线程”,即每有一个客户端连接到服务器,服务器就创建一个新线程来专门处理该连接的所有 I/O 操作。这就好比一家餐厅,每来一桌客人,就安排一位专属服务员全程服务 。
但在高并发场景下,这种模型的弊端暴露无遗。当并发连接数达到万级甚至百万级时,大量线程的创建和管理成为沉重负担。线程的上下文切换开销巨大,每次切换都需要保存和恢复线程的运行环境,包括寄存器状态、程序计数器等,这会占用大量 CPU 时间,使得 CPU 真正用于处理业务逻辑的时间大幅减少。同时,每个线程都需要占用一定的内存空间,用于存放线程栈、局部变量等,海量线程会迅速耗尽服务器的内存资源,导致系统性能急剧下降,甚至崩溃。想象一下,餐厅里一下子来了上百万桌客人,需要雇佣上百万个服务员,不仅管理难度极大,成本也会高得难以承受,还会因为服务员之间频繁的沟通协调(上下文切换)浪费大量时间 。
因此,传统阻塞 I/O 模型在面对高并发时,就像一辆小马拉大车的破旧马车,无论如何也无法承载百万并发的庞大负载,急需更高效的解决方案来打破这一性能瓶颈。
二、epoll+Reactor 为何能行?
传统阻塞IO搭配多线程的架构,天生无法适配高并发场景,想要突破百万连接瓶颈,势必需要一套更高效、更轻量化的IO解决方案。
epoll 是 Linux 内核提供的一种高效 I/O 多路复用机制,是 select 和 poll 的增强版。它通过三个系统调用 epoll_create、epoll_ctl 和 epoll_wait 实现对大量文件描述符的高效管理。epoll 内部使用红黑树来存储被监控的文件描述符,当有事件发生时,内核将就绪的文件描述符放入一个就绪链表中,epoll_wait 只需遍历这个链表,而无需像 select 和 poll 那样遍历所有文件描述符,这使得 epoll 的时间复杂度从 O (n) 降至 O (1),大大提高了事件监听的效率,尤其是在处理大量连接时,优势极为明显 。
Reactor 模式则是一种事件驱动的设计模式,它将 I/O 事件的监听和处理分离。Reactor 负责监听文件描述符上的事件,一旦有事件发生,就将其分发给对应的事件处理器(Handler)进行处理。这就好比一个大型活动的调度中心,有专人负责收集各方信息(监听事件),然后将任务分配给不同的执行小组(事件处理器) 。
当 epoll 与 Reactor 模式相结合,便产生了1+1>2的质变。epoll 高效的事件监听能力为 Reactor 提供了坚实的基础,使其能够轻松应对百万级别的并发连接;而 Reactor 模式的事件分发机制,则充分发挥了 epoll 的优势,确保每个事件都能得到及时、准确的处理。在这种组合下,服务器可以用单线程或少量线程管理海量连接,极大地减少了线程上下文切换开销和内存占用,显著提升了系统的并发处理能力,为实现百万并发提供了可靠的技术支撑。
三、原理:epoll 与 Reactor 模式深度拆解
仅照搬代码并非真正掌握,唯有吃透底层原理,方能灵活优化、按需拓展。
接下来我们拆解epoll与Reactor模式的核心逻辑。
3.1 epoll:高并发的 “性能王者”
3.1.1 epoll 的核心机制:红黑树 + 就绪链表
epoll 之所以能在高并发场景中脱颖而出,得益于其精妙的内部实现机制,而红黑树与就绪链表的组合堪称其中的精髓 。
epoll的操作逻辑简洁明晰,核心依托三大系统调用,掌握即可把握其精髓:epoll_create,用于创建epoll实例,相当于搭建专属的事件监控中枢;epoll_ctl,实现待监听文件描述符的增删改操作,完成监控任务的配置;epoll_wait,阻塞等待事件触发,实时接收监控中枢的事件通知。
其远超select与poll的效率,核心源于内部红黑树与就绪链表的精妙设计。红黑树负责存储全量待监听文件描述符,增删查改操作效率极高;当某一连接产生读写事件(如数据可读),内核会自动将该连接移入就绪链表,无需全量检索。
epoll_wait执行时,无需遍历所有连接,仅需扫描就绪链表,按需处理活跃事件,杜绝无效算力消耗,这便是epoll能承载海量并发的核心奥义,在百万连接场景下,优势尤为突出。
3.1.2 LT 与 ET:触发模式的选择
epoll包含水平触发LT与边缘触发ET两种模式。
水平触发是 epoll 的默认触发模式,它的工作机制较为直观。在 LT 模式下,只要文件描述符对应的缓冲区中还有未处理的数据,epoll_wait 就会持续通知应用程序。例如,当一个 socket 接收缓冲区中有数据时,即使应用程序一次没有读取完所有数据,下次调用 epoll_wait 时,依然会收到该 socket 的可读事件通知,直到缓冲区数据被完全处理 。这种模式的优点是编程相对简单,不容易遗漏事件,因为只要数据存在就会不断被提醒处理,非常适合初学者和对稳定性要求较高、数据处理逻辑相对复杂的场景。就好比你在收拾房间,只要还有没整理好的物品,就会不断有人提醒你去处理 。
在 ET 模式下,epoll_wait 仅在文件描述符的状态发生变化时,即从无数据变为有数据(可读事件)或从可写变为不可写(可写事件)等状态转变时,才会通知应用程序一次 。这就要求应用程序在收到通知后,必须一次性尽可能多地处理完所有可用数据。因为如果没有处理完,下次即使缓冲区中还有数据,也不会再收到通知,除非有新的数据到来再次触发状态变化 。ET 模式通常需要配合非阻塞 I/O 使用,因为在处理大量数据时,阻塞 I/O 可能会导致处理不及时而错过后续事件。同时,应用程序还需要有完善的缓冲处理逻辑,来确保数据的完整接收和处理 。虽然 ET 模式编程难度较高,但它能有效减少 epoll_wait 的调用次数,提高系统的整体效率,非常适合高并发、大流量的服务器程序,在追求极致性能的百万并发场景中,ET 模式往往是首选 。
3.2 Reactor 模式:事件驱动的 “反应堆” 架构
3.2.1 Reactor 模式的核心组件
Reactor 模式作为一种事件驱动的设计模式,核心包含四大组件 :
句柄(Handle),也可理解为文件描述符(FD),是操作系统提供的一种抽象,用于标识各种 I/O 资源,如网络 socket、文件等 。在 Reactor 模式中,句柄就像是工厂中的各个生产设备,是产生 I/O 事件的源头,每个句柄都代表着一个潜在的 I/O 操作,为后续的事件处理提供基础 。
同步事件分离器,在基于 Linux 的实现中通常就是 epoll,它如同工厂的 “监控摄像头”,负责监听多个句柄上的 I/O 事件。epoll 凭借其高效的事件监听机制,能够同时监控大量句柄,一旦有事件发生,就迅速捕捉到并通知后续组件 。
事件分发器,也可称为事件循环,是 Reactor 模式的 “调度员”。它不断地循环执行,调用同步事件分离器(如 epoll_wait)获取就绪事件,然后根据事件类型将其分发给对应的事件处理器,确保每个事件都能找到合适的 “处理小组” 。
事件处理器则是真正执行具体业务逻辑的 “工人”,它是一系列回调函数的集合。每个事件处理器对应一种特定类型的事件,如处理新连接的 AcceptHandler、处理数据读取的 ReadHandler 等 。当事件分发器将事件传递过来时,事件处理器就会执行预先定义好的回调逻辑,完成如接受客户端连接、读取和处理数据等操作 。
这些组件之间分工明确、相互协作,并且高度解耦。这种解耦特性使得系统在面对高并发场景时,具有极强的扩展性。当业务需求增加,需要处理更多类型的事件或更多并发连接时,只需增加相应的事件处理器,而不会对其他组件造成过多影响,就像工厂可以轻松增加新的生产小组来应对业务增长 。
3.2.2 Reactor 的工作流程:注册 - 等待 - 分发 - 处理
Reactor 模式的工作流程是一个严谨且高效的事件处理闭环 。
在服务器初始化阶段,首先会将需要监听的句柄(如监听 socket 的文件描述符)及其感兴趣的事件(通常是可读事件,用于接受新连接)注册到同步事件分离器(epoll)中。这就像是在工厂开工前,给监控摄像头设定好需要关注的生产环节 。
注册完成后,事件分发器进入事件循环,调用 epoll_wait 开始阻塞等待事件的发生。此时,整个系统就像一个待命的部队,处于高度警戒状态,随时准备响应可能出现的事件 。
当有客户端发起连接请求,或者已连接的 socket 有数据可读等事件发生时,epoll_wait 会被唤醒,返回就绪事件的集合 。这些事件就像是工厂中触发的各种生产信号,等待着被处理 。
事件分发器拿到就绪事件后,会根据事件类型将其分发给对应的事件处理器 。例如,如果是新连接事件,就会分发给 AcceptHandler;如果是数据可读事件,就会分发给 ReadHandler 。这一步就如同调度员根据不同的生产信号,将任务分配给相应的生产小组 。
事件处理器收到事件后,会执行预先定义好的回调函数,完成具体的业务逻辑处理 。对于 AcceptHandler 来说,它会调用 accept 系统调用接受新连接,并将新连接的 socket 也注册到 epoll 中,以便后续监听其事件;对于 ReadHandler,则会从 socket 中读取数据,并进行相应的业务处理,如解析数据、执行业务逻辑等 。处理完成后,如果需要向客户端发送响应数据,还会注册写事件,等待 socket 可写时进行数据发送 。至此,一次事件的处理流程完成,系统又回到事件循环,继续等待下一轮事件的到来 。
通过这样的 “注册 - 等待 - 分发 - 处理” 流程,Reactor 模式能够有条不紊地处理大量并发 I/O 事件,充分发挥其在高并发场景下的优势,确保服务器高效稳定地运行 。
四、纯 C 实战:从零搭建百万并发服务器
接下来我们进入实战环节。以纯C搭建基于epoll+Reactor的百万并发服务器。
4.1 核心数据结构设计:连接管理的关键
4.1.1 conn 结构体:封装连接的核心属性
实现海量连接的高效管理,第一步是设计规整的连接结构体,而 conn 结构体则是整个连接管理体系的核心。conn 结构体就像是一个精密的 “连接信息仓库”,精心封装了每个连接的关键属性,为服务器高效处理海量连接提供了坚实的数据支撑 。
typedef int(*RCALLBACK)(int fd);
struct conn {
int fd; // 连接对应的文件描述符,如同连接的“身份证”,是识别和操作连接的关键标识
char rbuffer[BUFFER_LENGTH]; // 读缓冲区,用于存储从客户端接收的数据,好比一个数据“暂存区”
int rlength; // 读缓冲区中已接收数据的长度,记录着当前接收到的数据量
char wbuffer[BUFFER_LENGTH]; // 写缓冲区,存放即将发送给客户端的数据,是数据“发送前的准备区”
int wlength; // 写缓冲区中待发送数据的长度,明确了要发送的数据量
RCALLBACK send_callback; // 发送数据时的回调函数指针,当需要发送数据时,通过这个指针调用相应的回调函数,实现灵活的数据发送逻辑
union {
RCALLBACK recv_callback; // 接收数据时的回调函数指针,用于处理接收到的数据
RCALLBACK accept_callback; // 接受新连接时的回调函数指针,在有新连接到来时,执行对应的处理逻辑
} recv_action;
};
fd 字段作为文件描述符,是操作系统用于标识连接的唯一编号,服务器通过它对连接进行各种操作,如读取数据、写入数据、关闭连接等 。
读缓冲区 rbuffer 和写缓冲区 wbuffer 的设计至关重要。将读写缓冲区分离开来,就像在工厂里设置了两个独立的原材料仓库(读缓冲区)和成品仓库(写缓冲区),各司其职,互不干扰,大大提高了数据处理的效率和稳定性 。当有数据从客户端发来时,首先存入读缓冲区,服务器可以按照自己的节奏从读缓冲区读取数据进行处理;处理完的数据则存入写缓冲区,等待合适的时机发送给客户端 。rlength 和 wlength 字段分别记录了读缓冲区和写缓冲区中数据的长度,方便服务器准确地进行数据读取和发送操作,避免数据丢失或溢出 。
而回调函数指针 send_callback、recv_callback 和 accept_callback 则为服务器的事件驱动处理机制提供了强大的灵活性。它们就像是一个个 “任务处理器”,当特定的事件发生时,服务器通过这些指针调用相应的回调函数,执行预先定义好的处理逻辑 。例如,当有新连接到来时,通过 accept_callback 调用接受新连接的回调函数,完成连接的建立和初始化工作;当连接上有数据可读时,通过 recv_callback 调用接收数据的回调函数,从读缓冲区读取数据并进行处理;当需要向客户端发送数据时,通过 send_callback 调用发送数据的回调函数,将写缓冲区中的数据发送出去 。
4.1.2 数组 + Union:高效管理连接与回调
为了实现对百万级连接的高效管理和回调函数的灵活调用,我们采用了数组结合 Union 类型的巧妙设计 。
struct conn conn_list[CONN_SIZE] = {0};
这里定义的 conn_list 数组,以连接的文件描述符 fd 作为数组下标,实现了对连接信息的 O (1) 时间复杂度访问 。这就好比一个大型图书馆,每个书架都有明确的编号(fd),读者(服务器)可以根据编号快速找到对应的书籍(连接信息),极大地提高了查找效率 。无论并发连接数有多少,都能在极短的时间内定位到特定连接的相关信息,为服务器实时处理大量连接提供了有力保障 。
union {
RCALLBACK recv_callback;
RCALLBACK accept_callback;
} recv_action;
在 conn 结构体中,recv_action 字段采用 Union 类型,将 recv_callback 和 accept_callback 这两个回调函数指针封装在一起 。由于在同一时刻,一个连接要么是在接受新连接(触发 accept_callback),要么是在接收数据(触发 recv_callback),不会同时发生这两种情况,所以使用 Union 类型可以在不影响功能的前提下,巧妙地减少内存占用 。就像一个多功能工具盒,虽然有多个功能模块,但每次只能使用其中一个,通过巧妙的设计,将多个功能模块整合在一起,既节省了空间,又不影响使用 。
这种设计不仅优化了内存使用,还简化了回调函数的调用和管理逻辑。服务器在处理不同类型的事件时,只需根据事件类型访问 recv_action 中的相应回调函数指针,就能快速调用对应的回调函数,实现对事件的高效处理 。在处理新连接事件时,通过 conn_list [fd].recv_action.accept_callback 调用接受新连接的回调函数;在处理数据接收事件时,通过 conn_list [fd].recv_action.recv_callback 调用接收数据的回调函数 。
4.2 Reactor 核心模块实现
4.2.1 服务器初始化:多端口监听的小技巧
服务器搭建的第一步为初始化流程,涵盖socket创建、端口绑定、监听启动等核心步骤。
int init_server(unsigned short port) {
// 创建TCP流式socket
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定本机所有网卡
servaddr.sin_port = htons(port);
// 端口绑定,失败则打印错误信息
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
printf("bind failed: %s\n", strerror(errno));
return -1;
}
listen(sockfd, 10); // 启动监听,设置监听队列长度
return sockfd;
}
在 init_server 函数中,首先通过 socket 系统调用创建一个 TCP 套接字,这个套接字就像是服务器对外开放的一扇 “大门”,等待客户端的连接请求 。AF_INET 表示使用 IPv4 协议,SOCK_STREAM 表示使用 TCP 协议,这是一种面向连接的可靠传输协议,非常适合对数据准确性和完整性要求较高的应用场景 。
接着,设置服务器地址结构 servaddr,将地址族设置为 AF_INET,IP 地址设置为 INADDR_ANY,表示服务器可以绑定到任何可用的网络接口,这样可以使服务器更灵活地适应不同的网络环境 。端口号则通过参数 port 传入,并使用 htons 函数将其转换为网络字节序,确保在不同主机之间通信时端口号的一致性 。
随后,使用 bind 系统调用将创建的套接字绑定到指定的地址和端口上,这一步就像是给 “大门” 安装上了特定的 “门牌号码”,让客户端能够准确找到服务器 。如果 bind 操作失败,说明可能存在端口被占用等问题,此时打印错误信息并返回 - 1,提示服务器初始化失败 。
最后,通过 listen 系统调用将套接字设置为监听状态,准备接受客户端的连接请求 。listen 函数的第二个参数 10 表示监听队列的长度,即最多可以同时处理 10 个未处理的连接请求 。在高并发场景下,合理设置监听队列长度非常重要,它直接影响到服务器对新连接的处理能力 。如果队列长度设置过小,当有大量客户端同时发起连接请求时,可能会导致部分连接请求被丢弃;而设置过大,则可能会占用过多的系统资源 。
这里还有一个提升并发处理能力的小技巧,就是采用多端口监听策略 。在实际应用中,一个端口所能承载的最大并发连接数是有限的,通常受到系统资源和网络协议的限制 。通过监听多个端口,可以将并发连接分散到不同端口上,从而有效减轻单端口的 backlog 压力 。可以在服务器启动时,循环调用 init_server 函数,绑定多个不同的端口,为百万连接的接入预留充足的空间 。
同时,在设置 listen 参数时,还可以结合内核调优来进一步提升服务器性能 。例如,可以调整内核参数 somaxconn,它表示系统级别的监听队列最大值 。适当增大 somaxconn 的值,可以增加服务器能够处理的未完成连接请求数量,从而提高服务器在高并发场景下的稳定性 。但需要注意的是,增大 somaxconn 也会占用更多的内核内存资源,所以需要根据服务器的实际配置和业务需求进行合理调整 。
4.2.2 三大回调函数:accept/recv/send
Reactor模式的核心落地依托accept_cb、recv_cb 和 send_cb 这三大回调函数,分别对应新连接接入、数据读取、数据发送三大核心场景,构成服务器的业务处理核心。
int accept_cb(int fd) {
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
// 接收客户端新连接
int clientfd = accept(fd, (struct sockaddr*)&clientaddr, &len);
if (clientfd < 0) return -1;
// 为新连接注册可读事件
event_register(clientfd, EPOLLIN);
// 每千条连接打印日志,便于调试与连接数监控
if (clientfd % 1000 == 0) {
struct timeval current;
gettimeofday(¤t, NULL);
int time_used = TIME_SUB_MS(current, begin);
memcpy(&begin, ¤t, sizeof(struct timeval));
printf("accept finshed:%d, time_used:%d\n", clientfd, time_used);
}
return 0;
}
accept_cb 函数负责处理新连接的接受。当监听套接字上有新连接到来时,epoll_wait 会捕获到 EPOLLIN 事件,并调用 accept_cb 函数 。在函数内部,首先通过 accept 系统调用接受新连接,返回一个新的文件描述符 clientfd,这个 clientfd 就代表了与客户端建立的新连接 。如果 accept 操作失败,返回 - 1 并结束函数 。接着,通过 event_register 函数将新连接的文件描述符 clientfd 注册到 epoll 实例中,并监听其读事件(EPOLLIN),以便后续能够及时处理客户端发来的数据 。为了便于监控服务器的连接接受情况,当新连接的文件描述符是 1000 的倍数时,打印出当前的连接编号和从服务器启动以来处理这些连接所花费的时间,方便开发者了解服务器的运行状态和性能表现 。
int recv_cb(int fd) {
// 读取客户端上行数据
int count = recv(fd, conn_list[fd].rbuffer, BUFFER_LENGTH, 0);
// 客户端断开连接,释放对应资源
if (count == 0) {
printf("client finshed: %d\n", fd);
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
return 0;
}
// 数据存入读缓冲区,配置回显数据
conn_list[fd].rlength = count;
conn_list[fd].wlength = conn_list[fd].rlength;
memcpy(conn_list[fd].wbuffer, conn_list[fd].rbuffer, conn_list[fd].wlength);
// 切换为可写事件,准备数据下发
set_event(fd, EPOLLOUT, 0);
return count;
}
recv_cb 函数用于处理客户端数据的接收 。当已连接的套接字上有数据可读时,epoll_wait 会触发 EPOLLIN 事件,进而调用 recv_cb 函数 。在函数中,通过 recv 系统调用从套接字 fd 中读取数据,将数据存入对应的读缓冲区 conn_list [fd].rbuffer 中,并记录读取到的数据长度 count 。如果 count 为 0,表示客户端已经关闭连接,此时打印连接关闭信息,关闭套接字 fd,并从 epoll 实例中删除对该套接字的监听,释放相关资源 。如果读取到数据,则更新读缓冲区的长度 conn_list [fd].rlength,并将读缓冲区中的数据复制到写缓冲区 conn_list [fd].wbuffer 中,准备回显给客户端 。同时,通过 set_event 函数将该套接字的事件类型修改为 EPOLLOUT,即监听写事件,以便在套接字可写时将数据发送出去 。
int send_cb(int fd) {
// 向客户端下发数据
int count = send(fd, conn_list[fd].wbuffer, conn_list[fd].wlength, 0);
// 发送完毕,切回可读事件,等待下一轮数据交互
set_event(fd, EPOLLIN, 0);
return count;
}
send_cb 函数负责将数据发送给客户端 。当套接字可写时,epoll_wait 会捕获到 EPOLLOUT 事件,从而调用 send_cb 函数 。在函数中,通过 send 系统调用将写缓冲区 conn_list [fd].wbuffer 中的数据发送给客户端,发送的数据长度为 conn_list [fd].wlength 。发送完成后,通过 set_event 函数将套接字的事件类型再次修改为 EPOLLIN,即重新监听读事件,等待客户端的下一次请求 。这样,通过不断地在 EPOLLIN 和 EPOLLOUT 事件之间切换,实现了数据的高效收发和连接状态的有效管理,构成了一个完整的事件处理状态机 。
4.2.3 事件循环:Reactor架构的核心引擎
事件循环是Reactor架构的核心引擎,相当于服务器的“心脏”,持续驱动事件监听与处理,单线程即可释放极致性能,支撑百万并发运转。
while (1) {
struct epoll_event events[1024] = {0};
// 阻塞等待事件触发
int nready = epoll_wait(epfd, events, 1024, -1);
// 遍历活跃事件,精准分发处理
for (int i = 0; i < nready; i++) {
int connfd = events[i].data.fd;
// 可读事件触发,调用对应接收/接入回调
if (events[i].events & EPOLLIN) {
conn_list[connfd].recv_action.recv_callback(connfd);
}
// 可写事件触发,调用数据发送回调
if (events[i].events & EPOLLOUT) {
conn_list[connfd].send_callback(connfd);
}
}
}
在这个主事件循环中,首先定义了一个 epoll_event 数组 events,用于存储 epoll_wait 返回的就绪事件 。然后,通过 epoll_wait 系统调用阻塞等待事件的发生 。epoll_wait 的第一个参数 epfd 是之前创建的 epoll 实例的文件描述符,它就像是一个 “事件接收器”,负责监听所有注册到它上面的文件描述符的事件;第二个参数 events 是用于存储就绪事件的数组;第三个参数 1024 表示最多可以返回 1024 个就绪事件;第四个参数 - 1 表示无限期阻塞,直到有事件发生 。
当有事件发生时,epoll_wait 会返回就绪事件的数量 nready 。接下来,通过一个 for 循环遍历这些就绪事件 。对于每个就绪事件,首先获取其对应的文件描述符 connfd 。然后,根据事件类型进行处理 。如果事件类型是 EPOLLIN,即有数据可读,就调用 conn_list [connfd].recv_action.recv_callback (connfd),执行数据接收的回调函数 recv_cb,从套接字中读取数据并进行相应处理;如果事件类型是 EPOLLOUT,即套接字可写,就调用 conn_list [connfd].send_callback (connfd),执行数据发送的回调函数 send_cb,将写缓冲区中的数据发送给客户端 。
这个主事件循环不断地重复执行,持续监听和处理各种网络事件,确保服务器能够及时响应每个连接的请求 。在高并发场景下,它能够高效地管理百万级别的连接,通过 epoll 的高效事件通知机制和 Reactor 模式的事件驱动架构,将大量的 I/O 操作分散到不同的事件处理阶段,避免了传统阻塞 I/O 模型中线程阻塞和上下文切换的开销,极大地提高了服务器的并发处理能力和整体性能 。可以说,主事件循环是整个百万并发服务器的灵魂所在,它的稳定运行是实现高性能网络服务的关键 。
五、百万并发的关键优化策略
代码编写完成后,切勿直接运行,想要真正实现百万并发承载,系统层面与代码层面的优化必不可少。Linux系统默认配置存在诸多性能限制,未优化前难以突破万级连接,以下核心优化步骤,务必逐一落实。
5.1 系统参数调优
Linux系统默认参数对文件描述符数量、TCP队列长度等设置严苛,无法适配百万并发需求,需通过内核参数调优,放开性能限制:
其一,放开文件描述符限制:每条连接占用一个FD,默认1024的上限远不足以支撑百万并发,需修改/etc/security/limits.conf,配置软限制与硬限制为* soft nofile 1048576、* hard nofile 1048576;同步修改/etc/sysctl.conf,设置fs.file-max = 2097152,执行sysctl -p生效,从系统与进程双层面解锁FD上限。
其二,调大TCP监听队列:默认somaxconn仅为128,高并发场景下极易出现连接丢包,需修改net.core.somaxconn = 65535,扩容监听队列长度,稳稳承接海量连接请求。
其三,优化内存交换策略:调低vm.swappiness=10,引导系统优先使用物理内存,规避磁盘交换带来的性能暴跌,保障服务器响应速率稳定。
5.2 代码层优化:非阻塞 IO + 内存复用
代码层面的两项核心优化,可进一步提升并发性能,实现百万连接稳定运行,操作简便且效果显著:
启用非阻塞IO:在传统的阻塞 I/O 模型中,当进行 I/O 操作(如读取数据或写入数据)时,如果数据尚未准备好,线程会被阻塞,直到操作完成 。在百万并发场景下,这种阻塞行为会严重影响服务器的并发处理能力,因为一个线程被阻塞,就会导致整个事件循环无法及时处理其他连接的事件 。通过将 FD 设置为非阻塞模式,当 I/O 操作无法立即完成时,系统会立即返回错误(如 EAGAIN 或 EWOULDBLOCK),而不会阻塞线程 。这样,服务器可以继续处理其他连接的事件,大大提高了事件处理的并发度 。在 C 语言中,可以使用 fcntl 函数来设置 FD 为非阻塞模式 。首先获取 FD 的当前标志位:int flags = fcntl(fd, F_GETFL, 0);,然后将标志位设置为非阻塞模式:fcntl(fd, F_SETFL, flags | O_NONBLOCK); 。在使用非阻塞 I/O 时,需要注意处理返回的错误,合理安排重试逻辑,以确保数据的完整传输 。
践行内存复用:在百万并发服务器中,频繁地进行内存分配(如使用 malloc 函数)和释放(如使用 free 函数)会产生较大的内存开销和性能损耗 。为了减少这种开销,可以采用内存复用策略,即预先分配一块较大的内存空间作为内存池,然后在需要时从内存池中获取内存块,使用完毕后再将其归还到内存池中,而不是频繁地向操作系统申请和释放内存 。可以使用链表来管理内存池中的空闲内存块 。在初始化时,创建一个链表节点,将预先分配的内存块挂载到链表上 。当需要分配内存时,从链表中取出一个节点,将其对应的内存块分配出去;当内存块使用完毕后,将其重新插入到链表中 。这样,通过内存池的方式,可以减少系统调用的次数,降低内存碎片化的风险,提高内存的使用效率 。在实现内存池时,还需要考虑线程安全问题,特别是在多线程环境下,需要使用互斥锁等同步机制来确保内存池的正确访问 。同时,合理设置内存池的大小也非常关键,过小的内存池可能无法满足并发需求,而过大的内存池则会浪费内存资源 。
5.3 连接管理:超时清理与资源回收
百万并发服务器长期运行,易出现无效连接占用资源、内存泄漏等问题,需建立完善的超时清理机制,避免服务器因资源耗尽宕机。
添加时间轮定时器,为每条连接配置超时阈值,长时间无数据交互的无效连接,自动关闭FD、从epoll中移除、释放对应内存资源,避免资源持续累积损耗,保障服务器长期稳定运行。
此外,需养成规范的资源回收习惯,客户端断开连接时,务必及时关闭FD、通过epoll_ctl删除对应事件,从根源规避资源泄漏,实现服务器7×24小时稳定运维。
六、源码:服务器 + 客户端
6.1 百万并发服务器源码(纯 C)
下面是百万并发服务器 C 语言源码,包含了服务器初始化、事件注册、事件处理回调函数以及主事件循环等核心部分。
#include <errno.h>
#include <stdio.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>
#include <poll.h>
#include <sys/epoll.h>
#include <sys/time.h>
// 缓冲区大小
#define BUFFER_LENGTH 1024
// 最大连接数
#define CONNECTION_SIZE 1048576
// 最大监听端口数
#define MAX_PORTS 20
// 计算两个时间差,单位为毫秒
#define TIME_SUB_MS(tv1, tv2) ((tv1.tv_sec - tv2.tv_sec) * 1000 + (tv1.tv_usec - tv2.tv_usec) / 1000)
// 定义回调函数指针类型
typedef int(*RCALLBACK)(int fd);
// 连接结构体定义
struct conn {
int fd; // 连接对应的文件描述符
char rbuffer[BUFFER_LENGTH]; // 读缓冲区
int rlength; // 读缓冲区中已接收数据的长度
char wbuffer[BUFFER_LENGTH]; // 写缓冲区
int wlength; // 写缓冲区中待发送数据的长度
RCALLBACK send_callback; // 发送数据时的回调函数指针
union {
RCALLBACK recv_callback; // 接收数据时的回调函数指针
RCALLBACK accept_callback; // 接受新连接时的回调函数指针
} recv_action;
};
// epoll实例的文件描述符
int epfd = 0;
// 记录开始时间
struct timeval begin;
// 连接列表,以文件描述符为下标,方便快速访问
struct conn conn_list[CONNECTION_SIZE] = {0};
// 设置事件,flag为1时添加事件,为0时修改事件
int set_event(int fd, int event, int flag) {
if (flag) { // non-zero add
struct epoll_event ev;
ev.events = event;
ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev);
} else { // zero mod
struct epoll_event ev;
ev.events = event;
ev.data.fd = fd;
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);
}
return 0;
}
// 注册事件到epoll实例,并初始化连接相关信息
int event_register(int fd, int event) {
if (fd < 0) return -1;
conn_list[fd].fd = fd;
conn_list[fd].recv_action.recv_callback = recv_cb;
conn_list[fd].send_callback = send_cb;
memset(conn_list[fd].rbuffer, 0, BUFFER_LENGTH);
conn_list[fd].rlength = 0;
memset(conn_list[fd].wbuffer, 0, BUFFER_LENGTH);
conn_list[fd].wlength = 0;
set_event(fd, event, 1);
return 0;
}
// 接受新连接的回调函数
int accept_cb(int fd) {
struct sockaddr_in clientaddr;
socklen_t len = sizeof(clientaddr);
int clientfd = accept(fd, (struct sockaddr*)&clientaddr, &len);
if (clientfd < 0) {
printf("accept errno: %d --> %s\n", errno, strerror(errno));
return -1;
}
// 注册新连接的读事件
event_register(clientfd, EPOLLIN);
if ((clientfd % 1000) == 0) {
struct timeval current;
gettimeofday(¤t, NULL);
int time_used = TIME_SUB_MS(current, begin);
memcpy(&begin, ¤t, sizeof(struct timeval));
printf("accept finshed: %d, time_used: %d\n", clientfd, time_used);
}
return 0;
}
// 接收数据的回调函数
int recv_cb(int fd) {
memset(conn_list[fd].rbuffer, 0, BUFFER_LENGTH);
int count = recv(fd, conn_list[fd].rbuffer, BUFFER_LENGTH, 0);
if (count == 0) { // 客户端断开连接
printf("client disconnect: %d\n", fd);
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
return 0;
} else if (count < 0) { // 接收数据出错
printf("count: %d, errno: %d, %s\n", count, errno, strerror(errno));
close(fd);
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
return 0;
}
conn_list[fd].rlength = count;
// 简单回显,将接收到的数据复制到写缓冲区
conn_list[fd].wlength = conn_list[fd].rlength;
memcpy(conn_list[fd].wbuffer, conn_list[fd].rbuffer, conn_list[fd].wlength);
// 修改为监听写事件,准备发送数据
set_event(fd, EPOLLOUT, 0);
return count;
}
// 发送数据的回调函数
int send_cb(int fd) {
int count = 0;
if (conn_list[fd].wlength != 0) {
count = send(fd, conn_list[fd].wbuffer, conn_list[fd].wlength, 0);
}
// 发送完成后重新监听读事件
set_event(fd, EPOLLIN, 0);
return count;
}
// 初始化服务器,创建监听套接字并绑定端口
int init_server(unsigned short port) {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定到任意地址
servaddr.sin_port = htons(port);
if (-1 == bind(sockfd, (struct sockaddr*)&servaddr, sizeof(struct sockaddr))) {
printf("bind failed: %s\n", strerror(errno));
return -1;
}
listen(sockfd, 10); // 监听队列长度为10
return sockfd;
}
int main() {
unsigned short port = 2000;
epfd = epoll_create(1);
int i = 0;
// 监听多个端口,提升并发处理能力
for (i = 0; i < MAX_PORTS; i++) {
int sockfd = init_server(port + i);
conn_list[sockfd].fd = sockfd;
conn_list[sockfd].recv_action.accept_callback = accept_cb;
set_event(sockfd, EPOLLIN, 1);
}
gettimeofday(&begin, NULL);
while (1) { // 主事件循环
struct epoll_event events[1024] = {0};
int nready = epoll_wait(epfd, events, 1024, -1);
int i = 0;
for (i = 0; i < nready; i++) {
int connfd = events[i].data.fd;
if (events[i].events & EPOLLIN) {
conn_list[connfd].recv_action.recv_callback(connfd);
}
if (events[i].events & EPOLLOUT) {
conn_list[connfd].send_callback(connfd);
}
}
}
return 0;
}
6.2 多端口客户端源码(纯 C)
多端口客户端源码,可以实现对服务器多端口的并发连接请求,模拟产生测试流量 。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define MAX_PORTS 20
#define PORT_BASE 2000
#define BUFFER_SIZE 1024
int main() {
int sockfd[MAX_PORTS];
struct sockaddr_in servaddr[MAX_PORTS];
char buffer[BUFFER_SIZE];
// 初始化每个端口的套接字和服务器地址
for (int i = 0; i < MAX_PORTS; i++) {
sockfd[i] = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd[i] < 0) {
perror("socket creation failed");
return 1;
}
servaddr[i].sin_family = AF_INET;
servaddr[i].sin_addr.s_addr = inet_addr("127.0.0.1");
servaddr[i].sin_port = htons(PORT_BASE + i);
}
// 连接到服务器的各个端口
for (int i = 0; i < MAX_PORTS; i++) {
if (connect(sockfd[i], (struct sockaddr *)&servaddr[i], sizeof(servaddr[i])) < 0) {
perror("connection failed");
close(sockfd[i]);
return 1;
}
printf("Connected to port %d\n", PORT_BASE + i);
}
// 发送和接收数据示例,这里简单发送固定数据并接收回显
for (int i = 0; i < MAX_PORTS; i++) {
const char *send_data = "Hello, Server!";
send(sockfd[i], send_data, strlen(send_data), 0);
int len = recv(sockfd[i], buffer, BUFFER_SIZE - 1, 0);
if (len > 0) {
buffer[len] = '\0';
printf("Received from port %d: %s\n", PORT_BASE + i, buffer);
}
}
// 关闭所有连接
for (int i = 0; i < MAX_PORTS; i++) {
close(sockfd[i]);
}
return 0;
}
编译与运行说明:
- 编译客户端:在 Linux 环境下,使用gcc -o client client.c命令进行编译,其中client.c是上述客户端源码文件的文件名,编译后生成名为client的可执行文件 。
- 运行客户端:直接运行生成的可执行文件./client,即可启动客户端连接到服务器的多个端口进行并发测试 。若服务器地址不是127.0.0.1,需要修改代码中servaddr[i].sin_addr.s_addr的值为服务器的实际 IP 地址 。
七、总结
实现百万级并发的核心在于三点:Epoll机制消除全量遍历开销,Reactor模式支撑异步非阻塞IO,系统参数调优突破默认限制。这一架构体系构成了C语言高性能网络编程的基础范式,同样也是Nginx、Redis等业界标杆软件的底层引擎。
往期精选文章 |Linux C/C++ 开发专属:
👉 【就业避坑】C++ 就业前景全解析:为什么劝退声不断,大厂核心岗仍刚需 C++?
👉 【大厂标准】Linux C/C++ 后端开发系统学习路线
👉 【音视频】音视频流媒体高级开发核心学习路径
👉 【Qt进阶】C++ Qt 桌面 & 嵌入式开发一条龙学习攻略
👉 【内核底层】Linux 内核硬核修炼指南
👉 【面试冲刺】C/C++ 高频八股面试题 1000 题(三)
👉 【项目实战】
手撕线程池:C++ 程序员的能力试金石
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)