📌 核心结论(TL;DR):
LT(水平触发) :默认模式。只要缓冲区有数据没读完,
epoll_wait就会一直通知,安全但可能效率低。ET(边缘触发) :加了
EPOLLET。只有数据从无到有时才通知一次,效率极高(Nginx 默认使用),但必须一次处理完,否则数据会丢失。致命铁律 :使用 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

