引言在前面的文章中我们学习了 epoll 的基础用法和 LT 模式。本文将深入讲解两个重要主题epoll 的 ET 模式边缘触发模式的编程要点与完整实现守护进程Linux 后台服务进程的原理与编写规范ET 模式是 epoll 高性能的关键而守护进程是服务器程序的最终运行形态。两者都是 Linux 服务端开发的核心技能。第一部分ET 模式深入一、LT 与 ET 的本质区别二、ET 模式编程三要素三、fcntl 设置非阻塞#include fcntl.h #include errno.h /** * 将文件描述符设置为非阻塞模式 * 原理 * 1. 用 F_GETFL 获取描述符现有的标志位 * 2. 用按位或 (|) 加上 O_NONBLOCK 标志 * 3. 用 F_SETFL 将新标志设置回去 */ int set_nonblock(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags -1) { perror(fcntl F_GETFL error); return -1; } if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) -1) { perror(fcntl F_SETFL error); return -1; } return 0; }关键理解必须先用F_GETFL获取原有标志不能直接设置。因为原有标志中可能包含O_RDONLY、O_WRONLY等访问模式标志直接覆盖会导致描述符无法正常工作。四、ET 模式下的错误码判断#include errno.h // 非阻塞模式下recv 返回 -1 不一定是错误 // 需要检查 errno 来区分无数据可读和真正的错误 n recv(fd, buf, size, 0); if (n -1) { if (errno EAGAIN || errno EWOULDBLOCK) { // 非阻塞模式下数据已读完正常情况 // EAGAIN 和 EWOULDBLOCK 在 Linux 下值相同 break; } else { // 真正的错误 perror(recv error); close(fd); break; } }重要EAGAIN和EWOULDBLOCK在 Linux 下是同一个值但为了可移植性通常两个都检查。五、完整 ET 模式服务器#include stdio.h #include stdlib.h #include string.h #include unistd.h #include fcntl.h #include errno.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #include sys/epoll.h #define PORT 6000 #define MAX_EVENTS 10 #define BUFFER_SIZE 128 /* 设置非阻塞 */ int set_nonblock(int fd) { int flags fcntl(fd, F_GETFL, 0); if (flags -1) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } /* 创建监听套接字 */ int create_listen_socket() { int fd socket(AF_INET, SOCK_STREAM, 0); if (fd -1) { perror(socket); return -1; } int opt 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, opt, sizeof(opt)); struct sockaddr_in addr {0}; addr.sin_family AF_INET; addr.sin_port htons(PORT); addr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(fd, (struct sockaddr*)addr, sizeof(addr)) -1) { perror(bind); close(fd); return -1; } if (listen(fd, 5) -1) { perror(listen); close(fd); return -1; } printf(ET 模式服务器启动端口: %d\n, PORT); return fd; } /* 向 epoll 添加描述符ET模式 非阻塞 */ void epoll_add_et(int epfd, int fd) { set_nonblock(fd); // ② 必须设置为非阻塞 struct epoll_event ev; ev.events EPOLLIN | EPOLLET; // ① 开启 ET 模式 ev.data.fd fd; if (epoll_ctl(epfd, EPOLL_CTL_ADD, fd, ev) -1) perror(epoll_ctl add); } /* ET 模式下的数据读取循环读到 EAGAIN */ void handle_et_read(int epfd, int fd) { char buffer[BUFFER_SIZE]; while (1) { // ③ 循环读取直到读完 int n recv(fd, buffer, BUFFER_SIZE - 1, 0); if (n 0) { buffer[n] \0; printf(收到 fd%d: %s\n, fd, buffer); send(fd, OK, 2, 0); } else if (n 0) { // 对端关闭连接 printf(客户端关闭 fd%d\n, fd); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); break; } else { // n -1需要判断 errno if (errno EAGAIN || errno EWOULDBLOCK) { // 数据已读完非阻塞正常返回 break; } else { // 真正的错误 perror(recv error); epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); close(fd); break; } } } } int main() { int listen_fd create_listen_socket(); if (listen_fd -1) exit(1); int epfd epoll_create(1); if (epfd -1) { perror(epoll_create); exit(1); } epoll_add_et(epfd, listen_fd); struct epoll_event evs[MAX_EVENTS]; while (1) { int n epoll_wait(epfd, evs, MAX_EVENTS, -1); if (n -1) { perror(epoll_wait); break; } for (int i 0; i n; i) { int fd evs[i].data.fd; if (fd listen_fd) { // 监听套接字就绪 while (1) { // accept 也需要循环ET 模式 int client_fd accept(listen_fd, NULL, NULL); if (client_fd -1) { if (errno EAGAIN) break; perror(accept); break; } printf(新连接: fd%d\n, client_fd); epoll_add_et(epfd, client_fd); } } else { // 客户端数据就绪 handle_et_read(epfd, fd); } } } close(listen_fd); close(epfd); return 0; }注意ET 模式下accept也需要循环调用直到返回EAGAIN因为多个连接可能同时到达但 epoll 只通知一次。第二部分守护进程一、什么是守护进程二、核心概念会话、进程组、终端概念说明会话 (Session)一个终端对应一个会话包含多个进程组会话首进程终端中的第一个进程通常是 bash其 PID 即 SID进程组一组相关进程的集合组长 PID 即 PGID组长进程进程组中第一个创建的进程三、守护进程创建步骤四、完整守护进程实现#include stdio.h #include stdlib.h #include unistd.h #include fcntl.h #include sys/stat.h #include time.h #include string.h /** * 创建守护进程 * 成功返回 0失败返回 -1 */ int daemonize() { // ① 第一次 fork退出父进程 pid_t pid fork(); if (pid 0) { return -1; } else if (pid 0) { exit(0); // 父进程退出 } // 现在子进程运行且不是进程组组长 // ② 创建新会话 if (setsid() -1) { return -1; } // 现在子进程是新会话的首进程 新进程组的组长 // 已经脱离原终端控制 // ③ 第二次 fork退出父进程确保不是会话首进程 pid fork(); if (pid 0) { return -1; } else if (pid 0) { exit(0); // 一级子进程退出 } // 现在二级子进程运行不是会话首进程无法获取控制终端 // ④ 切换工作目录到根目录 chdir(/); // ⑤ 清除文件权限掩码 umask(0); // ⑥ 关闭所有文件描述符 int maxfd getdtablesize(); // 获取描述符表大小 for (int i 0; i maxfd; i) { close(i); } // stdin(0)、stdout(1)、stderr(2) 都已被关闭 return 0; }五、守护进程日志写入守护进程没有终端调试信息必须写入日志文件/** * 守护进程主逻辑周期性写入时间到日志文件 */ int main() { // 创建守护进程 if (daemonize() -1) { exit(1); } // 守护进程主循环 while (1) { // 获取当前时间 time_t now time(NULL); struct tm* tm_info localtime(now); // 打开日志文件追加模式/tmp 对所有用户可写 FILE* fp fopen(/tmp/daemon.log, a); if (fp ! NULL) { // 写入格式化时间 fprintf(fp, 守护进程运行中: %s, asctime(tm_info)); fclose(fp); } // 休眠 5 秒 sleep(5); } return 0; }日志相关要点要点说明日志路径通常放在/var/log/或/tmp/打开模式a追加模式不覆盖历史记录时间格式asctime(localtime(now))获取可读时间实时查看tail -f /tmp/daemon.log动态监控六、面试常见问题问题答案要点为什么先 fork 再 setsidsetsid 要求调用进程不能是进程组组长。fork 后子进程不是组长满足条件为什么需要二次 fork第一次 forksetsid 后子进程成为会话首进程可能重新获取控制终端。二次 fork 后不再是会话首进程彻底杜绝为什么要 chdir(/)守护进程可能从 U 盘等目录启动切换到根目录避免占用可卸载的文件系统为什么要 umask(0)继承的 umask 可能限制文件权限清零确保守护进程创建文件时权限完全由 open 参数控制为什么要关闭所有 fd释放从父进程继承的无关描述符节省系统资源七、守护进程的查看与终止# 查看守护进程ps -ef | grep daemon_name# 查看进程的会话 IDps -eo pid,sid,comm | grep daemon_name# 终止守护进程只能通过 killkill PIDkill -9 PID # 强制终止# 实时查看日志tail -f /tmp/daemon.log总结一、ET 模式要点速查要素操作开启 ETev.events EPOLLIN | EPOLLET设置非阻塞fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK)循环读取while(1) { recv() ... if(errnoEAGAIN) break; }accept 处理ET 下 accept 也需循环到 EAGAIN二、守护进程要点速查守护进程 两次 fork setsid chdir(/) umask(0) close(fd)第一次 fork → 子进程不是组长为 setsid 准备setsid() → 创建新会话脱离终端第二次 fork → 确保不是会话首进程chdir(/) → 切换工作目录umask(0) → 清除权限掩码close(fd) → 关闭所有文件描述符三、LT vs ET 选择场景推荐模式简单服务器、学习目的LT默认模式高并发、追求极致性能ET 模式大数据量传输ET减少系统调用快速原型开发LT编程简单