Linux 学习笔记(五十九):epoll 的生死抉择——LT 与 ET 模式

📌 核心结论(TL;DR):

  1. LT(水平触发) :默认模式。只要缓冲区有数据没读完,epoll_wait 就会一直通知,安全但可能效率低。

  2. ET(边缘触发) :加了 EPOLLET。只有数据从无到有时才通知一次,效率极高(Nginx 默认使用),但必须一次处理完,否则数据会丢失。

  3. 致命铁律 :使用 ET 模式,必须将 socket 设置为非阻塞 IO ,并配合 while 循环一直读到 EAGAIN 为止。

一、 什么是 LT 与 ET?

  • LT(水平触发) :就像"亲妈喊吃饭",只要饭没吃完,就会一直喊你。只要读缓冲区有数据,epoll_wait 就会一直返回该 fd。

  • ET(边缘触发) :就像"后妈喊吃饭",只喊一次,不吃拉倒。只有在数据刚到达 的那一刻(缓冲区状态改变),epoll_wait 才会返回一次该 fd。后续如果不处理,数据永远留存在缓冲区。

二、 为什么 ET 模式性能更好?

在百万并发场景下,如果使用 LT 模式,如果某几条连接的数据没有一次性读完,会导致 epoll_wait 频繁空转,引发大量的系统调用。而 ET 模式大幅减少了 epoll_wait 的返回次数,降低了内核态与用户态的切换开销。

三、 踩坑实战:ET 模式必须搭配非阻塞 IO

cpp 复制代码
// 1. 设为非阻塞
int flags = fcntl(conn_fd, F_GETFL);
fcntl(conn_fd, F_SETFL, flags | O_NONBLOCK);

// 2. 循环读取直到 EAGAIN
while (1) {
    int n = read(conn_fd, buf, sizeof(buf));
    if (n == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) {
        break; // 读空了,退出循环
    }
    // 处理数据...
}

为什么不能用阻塞 IO?

在 ET 模式下,收到事件后必须把缓冲区读空。如果用阻塞 IO,当缓冲区数据被读完后,最后一次 read 会死死阻塞 整个线程,导致服务器无法响应任何其他连接。只有非阻塞 IO 才能在数据读完后返回 -1 和 EAGAIN,从而安全退出循环。

自我实战操作

咱们基于之前的代码,写一个 ET 模式的服务器,并踩一下阻塞 IO 的坑。

bash 复制代码
cd ~/linux_study/network_test
mkdir et_lt_test && cd et_lt_test

实战1 统一客户端 client.c

(功能:连上服务器,一次性发送 10 个字节 "0123456789",然后休眠一会退出

cpp 复制代码
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>

int main() {
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(9999);
    inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);

    connect(sock, (struct sockaddr*)&addr, sizeof(addr));
    
    // 一次性发送 10 个字节
    const char *msg = "0123456789"; 
    printf("[客户端] 准备发送 10 个字节: %s\n", msg);
    send(sock, msg, strlen(msg), 0);
    
    sleep(3); // 等服务器处理完
    close(sock);
    return 0;
}

实战2 水平触发服务器 server_lt.c (默认模式)

(注意:这里故意设置一个小缓冲区 char buf[5],一次只能读 4 个字节)

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/epoll.h>

#define PORT 9999
#define MAX_EVENTS 10

int main() {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(PORT);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(listen_fd, 5);

    int epfd = epoll_create(1);
    struct epoll_event ev, events[MAX_EVENTS];
    
    ev.events = EPOLLIN; // 默认就是水平触发(LT)
    ev.data.fd = listen_fd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

    printf("LT 服务器启动,监听 9999 端口...\n");

    while (1) {
        int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
        for (int i = 0; i < nfds; i++) {
            int fd = events[i].data.fd;
            if (fd == listen_fd) {
                int conn_fd = accept(listen_fd, NULL, NULL);
                ev.events = EPOLLIN; // 新连接也是 LT 模式
                ev.data.fd = conn_fd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);
            } else {
                char buf[5] = {0}; // 故意设置极小缓冲区
                int n = read(fd, buf, 4); // 一次只读 4 字节
                if (n > 0) {
                    printf("[LT 读到了 %d 字节] -> %s\n", n, buf);
                } else if (n == 0) {
                    close(fd);
                    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                }
            }
        }
    }
    return 0;
}

👉 LT模式测试现象 :

编译运行服务器,再运行客户端。服务器会打印 3次:

cpp 复制代码
[LT 读到了 4 字节] -> 0123
[LT 读到了 4 字节] -> 4567
[LT 读到了 2 字节] -> 89

因为缓冲区里还有数据没读完,LT 模式会一直通知你,直到读完为止。


实战3 边缘触发服务器 server_et.c (非阻塞 + 循环读取)

(重点看:如何把 fd 变成非阻塞,如何用 while 循环读)

cpp 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <arpa/inet.h>
#include <sys/epoll.h>

#define PORT 9999
#define MAX_EVENTS 10

// 把 fd 设为非阻塞的函数
void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

int main() {
    int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    
    struct sockaddr_in addr;
    memset(&addr, 0, sizeof(addr));
    addr.sin_family = AF_INET;
    addr.sin_port = htons(PORT);
    addr.sin_addr.s_addr = INADDR_ANY;
    bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr));
    listen(listen_fd, 5);

    int epfd = epoll_create(1);
    struct epoll_event ev, events[MAX_EVENTS];
    
    ev.events = EPOLLIN; 
    ev.data.fd = listen_fd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

    printf("ET 服务器启动,监听 9999 端口...\n");

    while (1) {
        int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
        for (int i = 0; i < nfds; i++) {
            int fd = events[i].data.fd;
            if (fd == listen_fd) {
                int conn_fd = accept(listen_fd, NULL, NULL);
                
                // 关键:将新连接设为非阻塞
                set_nonblocking(conn_fd); 
                
                ev.events = EPOLLIN | EPOLLET; // 关键:加 EPOLLET
                ev.data.fd = conn_fd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ev);
            } else {
                // 收到事件,必须死循环把数据全读干!
                while (1) {
                    char buf[5] = {0}; // 依然是极小缓冲区
                    int n = read(fd, buf, 4);
                    if (n > 0) {
                        printf("[ET 读到了 %d 字节] -> %s\n", n, buf);
                    } else if (n == -1) {
                        // 必须用非阻塞读,读到 EAGAIN 才代表读干了
                        if (errno == EAGAIN || errno == EWOULDBLOCK) {
                            break; // 读完了,跳出 while,继续等 epoll_wait
                        }
                        break;
                    } else if (n == 0) {
                        close(fd);
                        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
                        break;
                    }
                }
            }
        }
    }
    return 0;
}

👉 ET模式测试现象 :

编译运行服务器,再运行客户端。服务器会瞬间连续打印3次:

cpp 复制代码
[ET 读到了 4 字节] -> 0123
[ET 读到了 4 字节] -> 4567
[ET 读到了 2 字节] -> 89

因为收到通知后,我们在 while 循环里一次性把数据全干光了。只触发了一次 epoll_wait。


🛠️ 如何编译测试?

为了让你一次搞定,复制下面的命令:

bash 复制代码
# 1. 编译
gcc client.c -o client
gcc server_lt.c -o server_lt
gcc server_et.c -o server_et

# 2. 测 LT (开两个终端,A跑服务器,B跑客户端)
./server_lt
./client

# 3. 测 ET (按 Ctrl+C 停掉 LT,换 ET)
./server_et
./client

相关推荐
水云桐程序员7 小时前
从零开发软件的步骤,软件开发从零到完成应该怎么做?
笔记·学习·软件构建·个人开发
David猪大卫7 小时前
【Linux修炼】环境变量、进程地址空间
linux·c语言·经验分享·笔记·学习·ubuntu·centos
Android系统攻城狮7 小时前
Linux FFmpeg深度解析之avcodec_align_dimensions调用流程与实战(二十九)
服务器·linux音视频·音视频进阶·ffmpeg音视频
飞飞传输8 小时前
飞驰云联文件交换建设方法论:从单点传输到持续治理
大数据·运维·安全
叼烟扛炮8 小时前
Linux 学习笔记(五十六):IO 多路复用初探——select 的“三宗罪”
linux·服务器·数据库·笔记·学习·php
Ruiery8 小时前
Linux 6.6内核内存管理深度解析(十五):CMA 连续内存分配器
linux·运维·服务器
xixingzhe29 小时前
AI 对话打字机效果:SSE 和 HTTP 的区别与适用场景
网络·网络协议·http
xiaoye-duck9 小时前
《C++ 项目:手写大模型接入 SDK》Gemini接口设计(上):初始化、全量响应实现和测试
开发语言·网络·c++·语言模型
测试界的小码农9 小时前
【Linux 命令实战(01):认识 Linux 终端,从零掌握第一个命令】
linux·运维·服务器