在高并发的 Linux 网络编程中,如何高效地处理大量并发连接是一个核心问题。poll 和 epoll 都是 Linux 提供的 I/O 多路复用机制,它们允许一个进程同时监听多个文件描述符,并在其中任何一个文件描述符准备好进行 I/O 操作时通知进程。选择合适的 I/O 模型对于应用程序的性能至关重要,尤其是在处理高并发场景下的网络请求,例如使用 Nginx 反向代理服务器处理大量客户端请求,需要根据具体的并发连接数和请求模式来优化性能。
阻塞 I/O 的局限性
在传统的阻塞 I/O 模型中,每个客户端连接都需要创建一个线程或进程来处理。当客户端数量增加时,系统资源消耗迅速增长,导致性能瓶颈。此外,线程/进程切换也会带来额外的开销。
I/O 多路复用的优势
I/O 多路复用允许单个线程同时监听多个文件描述符(例如 socket),避免了为每个连接创建线程/进程的开销。当某个文件描述符就绪时,poll 或 epoll 会通知应用程序,应用程序可以立即处理该文件描述符上的 I/O 操作。
Poll 的原理与使用
poll 系统调用通过轮询(polling)所有注册的文件描述符来检查其状态。它维护一个 pollfd 结构体数组,每个 pollfd 结构体包含一个文件描述符和一个事件掩码。
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 关注的事件 */
short revents; /* 实际发生的事件 */
};
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
events 字段指定了应用程序关心的事件,例如 POLLIN(可读)、POLLOUT(可写)等。revents 字段由内核填充,表示实际发生的事件。
示例代码:
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <poll.h>
#include <unistd.h>
#include <string.h>
#define PORT 8080
#define MAX_CLIENTS 10
int main() {
int server_fd, new_socket, i;
struct sockaddr_in address;
int addrlen = sizeof(address);
struct pollfd fds[MAX_CLIENTS + 1]; // +1 for server socket
char buffer[1024] = {0};
// 创建 socket
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 绑定 socket
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 监听 socket
if (listen(server_fd, 3) < 0) {
perror("listen failed");
exit(EXIT_FAILURE);
}
// 初始化 pollfd 数组
fds[0].fd = server_fd; // 服务器socket
fds[0].events = POLLIN; // 监听可读事件
for (i = 1; i <= MAX_CLIENTS; i++) {
fds[i].fd = -1; // 初始化为-1,表示空闲
}
printf("Server listening on port %d\n", PORT);
while (1) {
int ready = poll(fds, MAX_CLIENTS + 1, -1); // -1 表示无限期阻塞
if (ready < 0) {
perror("poll failed");
exit(EXIT_FAILURE);
}
if (fds[0].revents & POLLIN) { // 新的连接请求
if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
perror("accept failed");
exit(EXIT_FAILURE);
}
printf("New connection, socket fd is %d, address: %s:%d \n", new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
// 将新的 socket 添加到 pollfd 数组中
for (i = 1; i <= MAX_CLIENTS; i++) {
if (fds[i].fd == -1) {
fds[i].fd = new_socket;
fds[i].events = POLLIN;
break;
}
}
if (i > MAX_CLIENTS) {
printf("Too many clients!\n");
close(new_socket); // 关闭连接
}
}
// 处理已连接的 socket
for (i = 1; i <= MAX_CLIENTS; i++) {
if (fds[i].fd > 0 && (fds[i].revents & POLLIN)) {
memset(buffer, 0, sizeof(buffer));
int valread = read(fds[i].fd, buffer, 1024);
if (valread == 0) { // 客户端关闭连接
printf("Client disconnected, socket fd %d \n", fds[i].fd);
close(fds[i].fd);
fds[i].fd = -1; // 重置 pollfd
} else if (valread < 0) {
perror("read failed");
close(fds[i].fd);
fds[i].fd = -1; // 重置 pollfd
} else {
printf("Received: %s from socket %d\n", buffer, fds[i].fd);
send(fds[i].fd, buffer, strlen(buffer), 0);
}
}
}
}
return 0;
}
Epoll 的原理与使用
epoll 是一种更高效的 I/O 多路复用机制。与 poll 不同,epoll 使用事件驱动的方式,只有在文件描述符状态发生变化时才会通知应用程序。epoll 主要涉及三个系统调用:
epoll_create():创建一个 epoll 实例,返回一个文件描述符。epoll_ctl():向 epoll 实例中添加、修改或删除文件描述符及其关联的事件。epoll_wait():等待 epoll 实例上的事件发生。
int epoll_create(int size);
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);
struct epoll_event {
uint32_t events; /* Epoll 事件 */
epoll_data_t data; /* 用户数据 */
};
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
示例代码:
#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#define PORT 8080
#define MAX_EVENTS 10
int main() {
int server_fd, new_socket, epoll_fd, i;
struct sockaddr_in address;
int addrlen = sizeof(address);
struct epoll_event event, events[MAX_EVENTS];
char buffer[1024] = {0};
// 创建 socket
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 设置 socket 为非阻塞
int flags = fcntl(server_fd, F_GETFL, 0);
if (fcntl(server_fd, F_SETFL, flags | O_NONBLOCK) < 0) {
perror("fcntl failed");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(PORT);
// 绑定 socket
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 监听 socket
if (listen(server_fd, 3) < 0) {
perror("listen failed");
exit(EXIT_FAILURE);
}
// 创建 epoll 实例
if ((epoll_fd = epoll_create1(0)) < 0) {
perror("epoll_create1 failed");
exit(EXIT_FAILURE);
}
event.events = EPOLLIN; // 监听可读事件
event.data.fd = server_fd;
// 将服务器 socket 添加到 epoll 实例中
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &event) < 0) {
perror("epoll_ctl failed");
exit(EXIT_FAILURE);
}
printf("Server listening on port %d\n", PORT);
while (1) {
int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); // -1 表示无限期阻塞
if (nfds < 0) {
perror("epoll_wait failed");
exit(EXIT_FAILURE);
}
for (i = 0; i < nfds; i++) {
if (events[i].data.fd == server_fd) { // 新的连接请求
while ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) > 0) {
printf("New connection, socket fd is %d, address: %s:%d \n", new_socket, inet_ntoa(address.sin_addr), ntohs(address.sin_port));
// 设置新的 socket 为非阻塞
flags = fcntl(new_socket, F_GETFL, 0);
if (fcntl(new_socket, F_SETFL, flags | O_NONBLOCK) < 0) {
perror("fcntl failed");
close(new_socket);
continue;
}
event.events = EPOLLIN | EPOLLET; // 边缘触发模式
event.data.fd = new_socket;
// 将新的 socket 添加到 epoll 实例中
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_socket, &event) < 0) {
perror("epoll_ctl failed");
close(new_socket);
}
}
if(errno != EAGAIN && errno != EWOULDBLOCK){
perror("accept failed");
}
} else { // 已连接的 socket 有数据可读
int fd = events[i].data.fd;
memset(buffer, 0, sizeof(buffer));
int valread = read(fd, buffer, 1024);
if (valread == 0) { // 客户端关闭连接
printf("Client disconnected, socket fd %d \n", fd);
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL); // 从 epoll 实例中移除
close(fd);
} else if (valread < 0) {
perror("read failed");
epoll_ctl(epoll_fd, EPOLL_CTL_DEL, fd, NULL); // 从 epoll 实例中移除
close(fd);
} else {
printf("Received: %s from socket %d\n", buffer, fd);
send(fd, buffer, strlen(buffer), 0);
}
}
}
}
close(server_fd);
close(epoll_fd);
return 0;
}
Poll 与 Epoll 的性能对比
- Poll: 每次调用
poll都会轮询所有注册的文件描述符,时间复杂度为 O(n),其中 n 是文件描述符的数量。因此,当文件描述符数量很大时,poll的性能会显著下降。 - Epoll: 基于事件驱动,只有在文件描述符状态发生变化时才会通知应用程序。
epoll使用红黑树等数据结构来维护文件描述符,插入、删除和查找的时间复杂度为 O(log n)。在大量并发连接的情况下,epoll的性能优于poll。
适用场景:
- Poll: 适用于连接数较少,且活动连接占比较高的情况。
- Epoll: 适用于连接数较多,但活动连接占比较低的情况。例如,高并发的 Web 服务器,如 Nginx,通常使用
epoll来处理客户端连接。在使用宝塔面板等工具简化服务器管理时,仍然需要理解底层 I/O 模型的选择对服务器性能的影响。
实战避坑经验总结
- 选择合适的触发模式:
epoll支持边缘触发(ET)和水平触发(LT)两种模式。ET 模式只在文件描述符状态发生变化时通知应用程序,因此需要一次性读取所有数据,否则可能会丢失事件。LT 模式只要文件描述符可读/可写,就会一直通知应用程序,相对简单,但效率较低。选择合适的触发模式需要根据应用程序的需求进行权衡。 - 避免惊群效应: 在多线程/多进程环境下,多个线程/进程可能同时被唤醒处理同一个事件,导致资源竞争。可以使用
EPOLLONESHOT事件来避免惊群效应。 - 合理设置 timeout: 在
poll和epoll_wait中设置合适的timeout值,可以避免程序长时间阻塞。 - 文件描述符泄露: 确保在使用完文件描述符后及时关闭,避免文件描述符泄露。
理解 poll 和 epoll 的原理和使用方法,可以帮助我们编写出更高效、更稳定的网络应用程序,更好地应对高并发场景下的挑战。在实际应用中,要根据具体的业务场景和性能需求,选择合适的 I/O 多路复用机制,并进行相应的优化。
冠军资讯
GC触发器