引言

在前面的文章中,我们学习了 epoll 的基础用法和 LT 模式。本文将深入讲解两个重要主题:

  1. epoll 的 ET 模式:边缘触发模式的编程要点与完整实现

  2. 守护进程: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_RDONLYO_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 再 setsid? setsid 要求调用进程不能是进程组组长。fork 后子进程不是组长,满足条件
为什么需要二次 fork? 第一次 fork+setsid 后子进程成为会话首进程,可能重新获取控制终端。二次 fork 后不再是会话首进程,彻底杜绝
为什么要 chdir("/")? 守护进程可能从 U 盘等目录启动,切换到根目录避免占用可卸载的文件系统
为什么要 umask(0)? 继承的 umask 可能限制文件权限,清零确保守护进程创建文件时权限完全由 open 参数控制
为什么要关闭所有 fd? 释放从父进程继承的无关描述符,节省系统资源

七、守护进程的查看与终止

# 查看守护进程
ps -ef | grep daemon_name

# 查看进程的会话 ID
ps -eo pid,sid,comm | grep daemon_name

# 终止守护进程(只能通过 kill)
kill <PID>
kill -9 <PID>  # 强制终止

# 实时查看日志
tail -f /tmp/daemon.log

总结

一、ET 模式要点速查

要素 操作
开启 ET ev.events = EPOLLIN | EPOLLET
设置非阻塞 fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK)
循环读取 while(1) { recv() ... if(errno==EAGAIN) 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(编程简单)
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐