嵌入式Linux C应用编程入门——进程 ( 二 )

本文主要参考文献是正点原子的C应用编程指南,很多内容与正点原子文档中的一致。作为初学者,为了和大家一起学习所以准备写这一系列文章,也是作为自己学习的一个笔记,如果有错误欢迎大家提出来一起讨论。

进程的诞生与终止

进程的诞生

在 Linux 系统中,所有进程(除了 init 进程)都是由其父进程通过 fork()vfork() 等系统调用创建的。这就是进程诞生的唯一方式。

init 进程(PID = 1)是特殊的最原始父进程

  • 它由内核在系统启动时直接创建并运行(理论上没有父进程)。
  • 它管理着系统上所有其他进程,是所有进程的祖先。
  • 当系统中的某个进程的父进程提前终止时,init 进程会接管它。

在桌面环境(如 Ubuntu)中,孤儿进程可能会被 upstartsystemd(PID 1)接管,而非直接由 init 接管,但本质相同------由 PID 为 1 的进程成为其新的父进程。

进程的终止

进程终止分为正常终止异常终止两种方式:

  • 正常终止

    • main() 函数中使用 return 返回。
    • 调用 exit() 库函数(会执行清理工作)。
    • 调用 _exit()_Exit() 系统调用(直接进入内核)。
  • 异常终止

    • 调用 abort() 函数(发送 SIGABRT 信号)。
    • 进程收到导致终止的信号(如 SIGKILLSIGTERMSIGSEGV 等)。

exit()_exit()status 参数定义了进程的终止状态。父进程可通过 wait() 系统调用获取该状态。虽然 statusint 类型,但仅低 8 位 表示终止状态。惯例是 0 表示成功终止,非 0 表示错误。

如果父进程在 fork() 之前调用了 printf("Hello World!");没有换行符),该字符串仅存在于父进程的 stdio 缓冲区中,尚未输出到屏幕。

子进程通过 fork() 继承了该缓冲区的副本。于是,父、子进程各自的缓冲区中都存有 "Hello World!"

当父进程调用 exit() 时,它刷新缓冲区,输出一次 "Hello World!"

当子进程调用 exit() 时,它刷新其缓冲区副本,再次 输出一次 "Hello World!"

结果就是字符串被打印了两次

解决方法(避免重复输出)

  1. 子进程使用 _exit() 退出 (推荐):因为 _exit() 不会刷新 stdio 缓冲区,子进程的缓冲区副本直接被丢弃。
  2. fork() 之前显式刷新 :调用 fflush(stdout) 将缓冲区内容清空,确保 fork() 复制的是空缓冲区。
  3. 确保输出包含换行符 :对于终端设备(行缓冲模式),输出包含 \n 时会立即刷新缓冲区,从而避免 fork() 时缓冲区内残留数据。

这解释了为何在 fork() 后的子进程中,强烈建议使用 _exit() 而非 exit() 。因为 exit() 的清理机制(刷新 stdio 缓冲区)在子进程中往往是不必要的,且会破坏父进程的终端输出状态。

监视子进程

父进程需要知道子进程何时终止、如何终止(正常退出、被信号杀死或因其他原因停止),并回收子进程资源(防止其变为僵尸进程)。Linux 提供了 wait()waitpid() 以及信号机制来实现对子进程的异步或同步监视。

waitid() 是该系列中的另一个变体,提供了更精细的控制(如支持 SIGCONT 事件和 siginfo_t 结构),但在实际应用中相对少见,故不展开讨论。

wait 函数(系统调用)

wait() 用于阻塞 等待任意一个子进程终止,并回收其资源、获取其终止状态。

c 复制代码
#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *status);
  • status:指向 int 的指针,用于存放子进程的终止状态信息。如果不关心具体状态,可设置为 NULL
  • 返回值 :成功返回终止的子进程 PID;失败返回 -1(如没有子进程可等待,errno 设为 ECHILD)。

wait() 的阻塞行为 :如果所有子进程都还在运行,wait() 会一直阻塞;如果已有子进程终止,则立即返回并回收该子进程。一次 wait() 调用只能回收一个子进程。
通过检查 status 参数,可以获取子进程的具体终止信息。常用宏包括:

  • WIFEXITED(status):子进程正常终止 → 返回 true
  • WEXITSTATUS(status):返回子进程的退出状态(即 _exit()exit() 的参数值)。
  • WIFSIGNALED(status):子进程被信号终止 → 返回 true
  • WTERMSIG(status):返回导致子进程终止的信号编号。

waitpid 函数(系统调用)

waitpid()wait() 的增强版本,提供了更多的控制选项,允许等待特定的子进程 以及进行非阻塞轮询

c 复制代码
#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *status, int options);
  • pid:指定需要等待的子进程,取值如下:

    • pid > 0:等待进程 ID 等于 pid 的特定子进程。
    • pid == -1:等待任意子进程(等同于 wait(&status))。
    • pid == 0:等待与调用进程同一进程组中的任意子进程。
    • pid < -1:等待进程组 ID 等于 pid 绝对值的进程组中的任意子进程。
  • status:与 wait()status 参数含义完全相同。

  • options:控制等待行为的位掩码,常用取值:

    • WNOHANG:如果没有子进程终止,立即返回 0(非阻塞)。
    • WUNTRACED:除了终止的子进程,也返回因信号而停止(暂停)的子进程状态。
    • WCONTINUED:返回因收到 SIGCONT 信号而恢复执行的子进程状态。
  • 返回值

    • 成功返回终止子进程的 PID。
    • 如果指定了 WNOHANG 且没有子进程状态改变,返回 0
    • 失败返回 -1

waitpid() 的优势

  1. 等待特定子进程wait() 只能按顺序等待任意子进程,无法指定具体目标。
  2. 非阻塞轮询 :通过 WNOHANG 可以轮询检查子进程状态,而不让父进程被阻塞。
  3. 监控停止/恢复 :通过 WUNTRACEDWCONTINUED,可以关注子进程因信号而暂停或恢复运行的事件,而不仅仅是终止事件。

僵尸进程与孤儿进程

僵尸进程(Zombie)

产生条件 :子进程先于父进程终止,但父进程尚未调用 wait() 为其"收尸" (回收资源)。此时,子进程的进程描述符(PCB)仍然保留在内存中,以便父进程后续获取其退出状态,该进程即处于"僵尸状态(Z)"的僵尸进程。

特点与风险

  • 僵尸进程无法通过信号(包括 SIGKILL)杀死 ,因为杀死僵尸进程的唯一方法是让父进程调用 wait() 或父进程自身退出(由 init 进程接管并回收)。
  • 僵尸进程已经释放了大部分资源(如内存),但仍然占用内核进程表中的一个槽位。如果系统中积累大量僵尸进程,进程表会被填满,导致无法创建新进程。

孤儿进程(Orphan)

产生条件 :父进程先于子进程终止,此时子进程变成"孤儿"。Linux 内核会自动将孤儿进程的父进程设置为 init 进程(PID = 1) ,由 init 进程负责回收。

注意 :在桌面版 Ubuntu 系统中,孤儿进程可能会被 upstartsystemd 进程(PID 1) 收养,而非直接由 init 接管,但本质是相同的------由 PID 为 1 的进程成为其新父进程,负责最终回收。

SIGCHLD 信号与异步回收

父进程可以通过捕获 SIGCHLD 信号,实现对子进程状态的异步监视 。当子进程终止(或因信号停止/恢复)时,内核会向父进程发送 SIGCHLD 信号。

标准信号的陷阱SIGCHLD标准信号(非实时信号) ,不支持排队。如果在父进程的信号处理函数执行期间,多个子进程相继终止,这些信号可能会合并为一次递送。如果处理函数只调用一次 wait(),会导致部分僵尸进程成为"漏网之鱼"。

正确的处理函数模式

c 复制代码
static void wait_child(int sig)
{
    /* 循环以非阻塞方式回收所有已终止的子进程 */
    while (waitpid(-1, NULL, WNOHANG) > 0)
        continue;
}

使用 while 循环的必要性

  • 即使只触发一次信号,循环也能确保回收当前所有已终止的子进程。
  • waitpid(-1, NULL, WNOHANG) 返回 0 时,表示没有更多僵尸进程需要回收,退出循环。
  • 当返回 -1 时表示出错(如没有子进程),也应退出循环。
  • 这种方式解决了因信号不排队而可能导致僵尸进程残留的问题。

执行新程序

exec 族函数用于将一个新程序加载到当前进程的内存空间中 ,并从头开始执行该程序。调用 exec 后,原进程的代码段、数据段、堆和栈会被新程序的相应部分替换,原有的执行流程被彻底覆盖。exec 调用成功则不会返回 ;如果返回,则说明调用失败。exec 族函数通常与 fork() 配合使用:父进程创建子进程,子进程调用 exec 加载新程序。

execve 函数(系统调用)

execve()exec 族中最基础的系统调用,其他库函数都是在其基础上进行的封装。

c 复制代码
#include <unistd.h>

int execve(const char *filename, char *const argv[], char *const envp[]);
  • filename:新程序的路径(绝对路径或相对路径)。
  • argv:传递给新程序的命令行参数数组(字符串指针数组),以 NULL 结尾。argv[0] 通常为新程序自身的文件名。
  • envp:传递给新程序的环境变量数组(字符串指针数组,格式为 "name=value"),以 NULL 结尾。
  • 返回值 :调用成功不返回 ;失败返回 -1,并设置 errno

exec 族库函数

基于 execve(),C 库提供了一系列名称不同但功能相同的封装函数,通过命名后缀区分其传参方式:

函数名 路径指定 参数传递方式 环境变量来源
execl(path, arg, ...) 直接指定路径 列表 (可变参数,以 NULL 结束) 继承当前进程的 environ
execv(path, argv) 直接指定路径 数组argv 指针数组) 继承当前进程的 environ
execlp(file, arg, ...) PATH 中搜索 列表 (可变参数,以 NULL 结束) 继承当前进程的 environ
execvp(file, argv) PATH 中搜索 数组argv 指针数组) 继承当前进程的 environ
execle(path, arg, ..., envp) 直接指定路径 列表 (可变参数,以 NULL 结束) 自定义 envp 数组
execvpe(file, argv, envp) PATH 中搜索 数组argv 指针数组) 自定义 envp 数组

命名规则

  • l vs vl(list)表示参数以可变参数列表 形式逐个传递;v(vector)表示参数以指针数组形式传递。
  • p :表示使用 PATH 环境变量查找可执行文件(只需提供文件名,无需完整路径)。
  • e :表示自定义环境变量envp 数组),而非继承当前进程的环境变量。

如果提供的 file 参数包含 / 字符(即包含路径),则直接按路径查找;否则,按照环境变量 PATH 中指定的目录列表依次查找。这使得执行系统命令(如 lsecho)时非常方便。
通过 envp 参数传入的环境变量数组会完全替换新程序的环境变量表(如果这个数组为NULL,那么环境变量会为空)。这与继承当前进程环境不同------调用者可以精确控制新程序能看到哪些环境变量。execle 的可变参数列表在最后需要传入 envp 数组指针,紧跟在最后一个命令行参数(NULL)之后。

system 函数(库函数)

system() 是一个高级封装函数 ,内部通过 fork() + execl() + waitpid() 实现。它创建一个子进程来执行 /bin/sh -c command ,父进程(调用者)会阻塞等待 子进程完成。system() 的主要优点是使用方便,无需手动处理 forkexecwait 等细节。

c 复制代码
#include <stdlib.h>

int system(const char *command);
  • command:需要执行的 shell 命令字符串(如 "ls -al""echo Hello")。

  • 返回值 (与 command 是否为 NULL 有关,此处默认 command 非空):

    • 如果 fork()waitpid() 失败,返回 -1(属于系统调用层面的错误,与命令是否执行成功无关)。
    • 如果子进程无法执行 shell(即 execl 失败),返回值如同子进程调用 _exit(127) 终止------即返回值中的退出码为 127(可通过 WEXITSTATUS 解析)。
    • 如果所有系统调用成功,返回值是 执行 command 的 shell 进程的终止状态 。需通过 WIFEXITEDWEXITSTATUSWIFSIGNALED 等宏解析,以判断命令是正常退出(获取退出码)还是被信号终止(获取信号编号)。

注意事项

  1. 效率问题system() 会创建至少两个进程(一个用于 shsh 再创建子进程执行命令),适合不追求极致性能的场景。
  2. 安全风险system() 依赖 /bin/sh 和环境变量,在多线程或特权程序中可能引入安全漏洞(如命令注入),应谨慎使用。
  3. 信号影响system() 执行期间,SIGCHLD 信号会被暂时阻塞,而 SIGINTSIGQUIT 会被忽略,以防止这些信号干扰 waitpid() 的等待过程。
    如果 commandNULLsystem() 的返回值用于检测 shell 是否可用------如果 shell 可用则返回非零值,否则返回 0。

进程状态与进程关系

本节介绍了 Linux 进程的六种状态 及其转换关系,以及进程之间的三种组织层次:无关系父子进程关系进程组(Process Group)会话(Session)

进程的六种状态

Linux 进程在其生命周期中会处于以下六种状态之一:

状态名称 英文 含义 典型场景
就绪态 Ready 具备被 CPU 调度的所有条件,但尚未获得 CPU 使用权 进程被创建后、时间片耗尽重新排队、被唤醒后等待调度
运行态 Running 正在 CPU 上执行指令 当前进程获得 CPU 并正在执行
僵尸态 Zombie 进程已终止,但父进程尚未调用 wait() 为其"收尸" 子进程退出、父进程未处理
可中断睡眠 Interruptible Sleep 进程因等待资源而主动挂起,可被信号唤醒 等待 I/O 完成、等待用户输入(read 从终端读取)
不可中断睡眠 Uninterruptible Sleep 进程在等待不可被信号打断的内核操作(如磁盘 I/O) 等待磁盘读写完成、等待硬件操作(通常不因信号唤醒)
暂停态 Stopped 进程被信号暂停运行,可被 SIGCONT 恢复 收到 SIGSTOPSIGTSTP(如 Ctrl+Z

进程关系

Linux 进程间存在四种关系:

  • 无关系:两个进程完全独立,没有亲缘或功能关联。
  • 父子进程关系 :由 fork()vfork() 创建的进程之间构成父子关系。如果父进程先于子进程终止,init 进程(PID=1)会成为子进程的新父进程(收养孤儿)。
  • 进程组:一个或多个进程的集合。进程组简化了批量信号发送和作业控制(见下节详细展开)。
  • 会话 :一个或多个进程组的集合。会话通常与一个控制终端 关联,用户通过该终端与系统交互。每个会话最多有一个前台进程组 (接收终端信号)和一个或多个后台进程组(不接收终端信号)。

进程组(Process Group)

进程组是为了便于对一组相关进程进行管理而引入的概念:

  • 组长进程 :进程组的第一个进程(通常创建该组的进程)。组长进程的 PID 等于该进程组的 PGID
  • 继承规则:新进程默认继承父进程的 PGID。
  • 生命周期:进程组只要还有至少一个成员(无论是否是组长),该组就继续存在。

常用系统调用

获取进程组 ID:

c 复制代码
#include <unistd.h>

pid_t getpgrp(void);        /* 获取调用者的 PGID */
pid_t getpgid(pid_t pid);   /* 获取指定进程的 PGID(pid=0 表示调用者) */

创建新组或加入现有组:

c 复制代码
#include <unistd.h>

int setpgid(pid_t pid, pid_t pgid);   /* 将 pid 进程的 PGID 设为 pgid */
int setpgrp(void);                    /* 等价于 setpgid(0, 0) */

规则

  • 如果 pid == pgid,则 pid 指定的进程成为该新进程组的组长。
  • 进程只能为自己或尚未执行 exec 的子进程设置 PGID。

会话(Session)

会话是一个或多个进程组的集合。用户登录时,系统会创建一个新的会话,并将登录 Shell 作为该会话的首领进程。会话可以有一个控制终端,该终端与用户交互,并将键盘生成的信号传递给前台进程组。

核心概念

  • 会话首领(Session Leader) :创建会话的进程(通常是登录 Shell)。会话首领的进程组 ID(PGID)等于会话 ID(SID)
  • 控制终端:会话可以关联一个控制终端(如物理串口或 SSH 伪终端),也可以没有(守护进程通常没有)。
  • 前台/后台进程组:一个会话中,同时只有一个前台进程组(可读写终端,接收终端信号),其余为后台进程组(通常不可读终端,且不接收终端信号)。
  • 控制进程 :与控制终端建立连接的会话首领进程称为控制进程。当控制终端断开连接时,内核会向控制进程发送 SIGHUP 信号。
  • 有控制终端的进程 :由 Shell 启动的进程(前台命令、后台任务)都与终端建立了控制关系。这种关系的本质是:内核在进程的 PCB 中记录了一个指向该终端设备的指针 。通过这个指针,终端产生的信号(如 Ctrl+C)才能准确地找到应该发给哪些进程。

  • 无控制终端的进程(守护进程) :Web 服务器(如 Nginx)、数据库等后台进程,它们的 PCB 中没有 指向任何终端的指针。因此,Ctrl+C 或关闭终端窗口的 SIGHUP 信号,对它们毫无影响。

结论 :如果终端和进程建立了控制关系,它们就绑定了;如果没建立,它们就是完全无关的独立实体。这个关系由进程组和会话决定。
当终端(控制终端)关闭或网络断开时,内核向会话首领发送 SIGHUP 信号,默认行为是终止该进程。如果应用程序忽略 SIGHUP,进程可以脱离终端继续运行(成为守护进程的基础)。

常用系统调用

获取会话 ID:

c 复制代码
#include <unistd.h>

pid_t getsid(pid_t pid);   /* 获取指定进程的会话 ID(pid=0 表示调用者) */

创建新会话(调用者不能是进程组组长):

c 复制代码
#include <unistd.h>

pid_t setsid(void);        /* 创建新会话,调用者成为会话首领和进程组组长 */

守护进程

守护进程(Daemon,也称精灵进程)是一种运行在后台、独立于控制终端、生存期长的特殊进程。它通常在系统启动时开始运行,直到系统关机才终止,不受用户登录注销的影响。大多数 Linux 服务器(如 httpd、sshd、crond)都是以守护进程方式运行的。

命名惯例 :守护进程的名字通常以字母 d 结尾,如 sshdhttpdcrond,表示其为守护进程(daemon)。

守护进程的核心特征

  • 长期运行:守护进程从系统启动开始运行,到系统关机才结束,与用户的登录会话无关。
  • 与控制终端脱离 :守护进程没有控制终端,因此不会因终端关闭或用户退出登录而收到 SIGHUP 信号。这是守护进程能够长期稳定运行的根本保障。
  • 自成进程组和会话 :守护进程通常是一个进程组的组长,同时也是其所属会话的首领(即 PID = PGID = SID),拥有独立的会话,不依附于任何其他会话。
  • 无终端 I/O :守护进程的标准输入、标准输出和标准错误通常会重定向到 /dev/null(或日志文件),不会向控制台输出信息,也不会从终端读取输入。

编写守护进程的步骤

编写守护进程需要遵循一套标准的流程,以确保进程能够正确地脱离终端并独立运行:

步骤1:创建子进程,终止父进程

父进程调用 fork() 创建子进程,然后父进程立即 exit() 退出。这样做的目的是:

  • 让 Shell 认为命令已执行完毕,释放终端控制权。
  • 保证子进程不是进程组组长 ,这是后续调用 setsid() 的必要前提(只有非组长进程才能创建新会话)。

步骤2:子进程调用 setsid() 创建新会话

子进程调用 setsid(),使自己成为新会话的首领,并成为新进程组的组长。同时,新会话没有控制终端,子进程彻底脱离了原会话、原进程组和原控制终端。这是守护进程脱离终端的关键步骤。

步骤3:将工作目录更改为根目录

子进程继承了父进程的当前工作目录。如果守护进程的当前目录是某个挂载点下的目录,该文件系统将无法被卸载。因此,通常将守护进程的工作目录更改为 /,避免占用可卸载的文件系统。也可以根据实际需求指定其他目录。

步骤4:重设文件权限掩码(umask)

子进程继承了父进程的 umask。将 umask 设置为 0umask(0),此时不会有任何进一步被限制的权限),可以确保守护进程创建文件时拥有最大的操作权限,避免因父进程的 umask 限制导致文件权限异常。

步骤5:关闭所有不再需要的文件描述符

子进程从父进程继承了所有打开的文件描述符。这些文件描述符可能永远不会被守护进程使用,但它们会消耗系统资源,并阻止相关文件系统被卸载。因此,应循环关闭所有文件描述符(从 0sysconf(_SC_OPEN_MAX))。

步骤6:将标准输入、输出、错误重定向到 /dev/null

使守护进程的标准输入、标准输出和标准错误指向 /dev/null,确保守护进程不会在终端显示输出,也不会从终端读取输入。实现方式为:

  1. 打开 /dev/null(读写方式,O_RDWR)。
  2. 复制文件描述符(dup(0)dup(0)),分别对应 stdinstdoutstderr

步骤7:忽略 SIGCHLD 信号(可选但推荐)

设置 SIGCHLD 信号的处理方式为 SIG_IGN。这样,子进程终止时内核会自动将其转交给 init 进程回收,避免产生僵尸进程,也省去了父进程显式调用 wait() 的负担。这对于并发服务器尤为重要------服务器每处理一个客户端请求就创建一个子进程,如果父进程不忽略 SIGCHLD,就必须处理大量僵尸进程。

关于单例模式运行 :守护进程通常以单例模式运行,即系统中同时只能有一个该守护进程的实例。这通常通过文件锁 来实现,在 /var/run/ 目录下创建一个 .pid 文件,并在文件上持有锁。如果锁获取失败,则说明已有实例在运行,程序应退出(详见 9.14 节)。
关于 sysconf(_SC_OPEN_MAX) 的使用 :步骤5 中通过 sysconf(_SC_OPEN_MAX) 获取当前进程可以打开的最大文件描述符数量,以便循环关闭所有继承的描述符。这是确保完全清理的必要手段,因为硬编码最大描述符数量在不同系统中可能不同,且可能随 ulimit 设置而变化。

示例代码

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <signal.h>

int main(void)
{
    pid_t pid;
    int i;

    /* 1. 创建子进程,终止父进程 */
    pid = fork();
    if (0 > pid) {
        perror("fork error");
        exit(-1);
    } else if (0 < pid)
        exit(0);   /* 父进程直接退出 */

    /* 2. 子进程调用 setsid 创建新会话 */
    if (0 > setsid()) {
        perror("setsid error");
        exit(-1);
    }

    /* 3. 将工作目录更改为根目录 */
    if (0 > chdir("/")) {
        perror("chdir error");
        exit(-1);
    }

    /* 4. 重设文件权限掩码 */
    umask(0);

    /* 5. 关闭所有文件描述符 */
    for (i = 0; i < sysconf(_SC_OPEN_MAX); i++)
        close(i);

    /* 6. 将标准输入、输出、错误重定向到 /dev/null */
    open("/dev/null", O_RDWR);
    dup(0);
    dup(0);

    /* 7. 忽略 SIGCHLD 信号 */
    signal(SIGCHLD, SIG_IGN);

    /* 守护进程主循环 */
    for ( ; ; ) {
        sleep(1);
        /* 守护进程的核心工作 */
    }

    exit(0);
}

SIGHUP 信号与控制终端

当用户准备退出登录(如关闭终端窗口或 SSH 断开)时,内核会向该会话的会话首领 发送 SIGHUP 信号。SIGHUP 的默认行为是终止进程。因此,如果普通进程没有忽略 SIGHUP 信号,它会在终端关闭时被自动终止

守护进程通过忽略 SIGHUP 信号来避免终端关闭的影响

  • 如果在程序中显式忽略 SIGHUPsignal(SIGHUP, SIG_IGN)),即使进程原本属于某个会话(有控制终端),它也不会因终端关闭而被终止。
  • 如果进程通过 setsid() 创建了新会话并没有控制终端 ,内核根本不会向它发送 SIGHUP 信号(因为已经没有终端与之关联)。

这两种情况下,进程将会变为守护进程。

单例模式运行

单例模式运行是指一个程序在同一时刻只能运行一个实例 ,如果程序已经在运行,则后续启动的实例会检测到并自动退出。这是许多守护进程(如 httpd、crond)的标配机制,也是系统资源管理的重要保障------防止多个实例同时操作同一资源(如硬件设备、共享文件、网络端口)导致冲突或数据损坏。常见的实现方法包括:基于文件存在性判断基于文件锁

方法一:通过文件存在与否进行判断(不推荐)

基本思路:程序启动时,检查一个特定的文件是否存在:如果存在,则认为已有实例在运行,退出;如果不存在,则创建该文件,程序结束时删除该文件。

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

#define LOCK_FILE "./testApp.lock"

static void delete_file(void)
{
    remove(LOCK_FILE);
}

int main(void)
{
    int fd;

    /* 以 O_CREAT | O_EXCL 方式打开文件:文件存在则失败,文件不存在则创建 */
    fd = open(LOCK_FILE, O_RDONLY | O_CREAT | O_EXCL, 0666);
    if (-1 == fd) {
        fputs("不能重复执行该程序!\n", stderr);
        exit(-1);
    }

    /* 注册进程终止处理函数,确保程序结束时删除文件 */
    if (atexit(delete_file))
        exit(-1);

    puts("程序运行中...");
    sleep(10);
    puts("程序结束");

    close(fd);
    exit(0);
}

优点:实现简单直观,易于理解。

缺点

  • 异常退出无法清理 :如果程序被 SIGKILL 杀死或通过 _exit() 终止,atexit 注册的处理函数不会执行,锁文件会残留。
  • 不可靠信号(如 SIGKILL)无法捕获SIGKILLSIGSTOP 无法被捕获或忽略,因此无法在信号处理函数中删除文件。
  • 系统重启无法清理 :如果将锁文件放在 /tmp 目录下,重启后文件会被清空,能解决部分问题;但如果放在普通目录中,系统重启后文件依然存在,导致程序永远无法再次启动。

该方法的根本缺陷在于:文件存在性与程序是否正在运行不能保证严格同步 ------程序异常退出后文件残留,或文件被误删除后多个实例同时启动,都会导致逻辑失效。因此,该方法在实际工程中不建议使用

方法二:使用文件锁(推荐)

基本思路 :程序启动时,打开一个特定的文件(通常位于 /var/run/ 目录下,命名为 程序名.pid),并尝试获取文件锁。如果获取成功,则表示没有其他实例在运行,程序将自身 PID 写入文件,并持续持有该锁直至程序退出;如果获取失败,则表示已有实例在运行,程序退出。

关键点

  • 文件锁与文件描述符的生命周期绑定 :当进程退出或显式关闭文件描述符时,内核会自动释放该进程持有的所有文件锁。这保证了即使程序异常崩溃或收到 SIGKILL,锁也会被内核自动释放,不会残留。
  • 锁文件与进程 PID 的关联 :成功获取锁后,将当前进程的 PID 写入锁文件(如 1234)。这为管理员提供了方便的调试信息(可随时查看 cat /var/run/app.pid 确认哪个进程在运行)。
c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/file.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

#define LOCK_FILE "./testApp.pid"

int main(void)
{
    char str[20] = {0};
    int fd;

    /* 打开锁文件(不存在则创建) */
    fd = open(LOCK_FILE, O_WRONLY | O_CREAT, 0666);
    if (-1 == fd) {
        perror("open error");
        exit(-1);
    }

    /* 以非阻塞方式获取互斥锁(排它锁) */
    if (-1 == flock(fd, LOCK_EX | LOCK_NB)) {
        fputs("不能重复执行该程序!\n", stderr);
        close(fd);
        exit(-1);
    }

    puts("程序运行中...");

    /* 将文件长度截断为 0,然后写入当前进程的 PID */
    ftruncate(fd, 0);
    sprintf(str, "%d\n", getpid());
    write(fd, str, strlen(str));

    for ( ; ; )
        sleep(1);   /* 程序主循环 */

    exit(0);
}

关于 ftruncate(fd, 0) 的用途:在写入新的 PID 之前,需要将文件长度截断为 0,以确保旧内容(如果有)被完全清除。如果不截断,而新写入的 PID 字符串比旧内容短,文件末尾可能残留旧字符,导致读取时看到混乱的信息。
关于 flock(fd, LOCK_EX | LOCK_NB) 中的 LOCK_NB 标志LOCK_NB 使加锁操作变为非阻塞 。如果没有该标志,当锁已被其他进程持有时,flock() 会一直阻塞等待直到锁被释放(这在单例模式中是不合适的)。使用 LOCK_NB 后,如果加锁失败,函数立即返回 -1,程序据此判断已有实例在运行并主动退出。
关于锁文件存储在 /var/run/ 目录 :大多数标准守护进程(如 acpid.pidlightdm.pid)都将 PID 文件放在 /var/run/ 目录下。这是一个临时文件系统(tmpfs) ,系统重启后会被清空,进一步保证锁文件的干净状态。同时,该目录是系统规范的存放 PID 文件的位置,有助于维护系统的组织性。

相关推荐
jieyucx3 小时前
【系统安全铁律】解密 Linux 权限三色数字密码:chmod 最小化赋权与生产环境避坑指南
linux·安全·系统安全·权限·chmod·777
AOwhisky3 小时前
Linux(CentOS)系统管理入门笔记(第四期)——文件系统(下篇):文件与目录操作实战——cpmvmkdirrmln
linux·运维·笔记·centos·云计算·文件系统
岑梓铭4 小时前
考研408《操作系统》复习笔记,第四章《4.2.2 文件操作、文件共享、文件保护》
linux·考研·操作系统·文件系统·408
Mr.HeBoYan4 小时前
DPDK为什么越来越少使用rte_ring?——从Lock-Free到Run-to-Completion,重新理解现代DPDK架构演进(上)
linux·网络·算法·性能优化·架构·dpdk
云栖梦泽5 小时前
从 IMX415 摄像头驱动理解 Media Controller 框架
linux·开发语言·c++·嵌入式硬件
纳兰青华5 小时前
CentOS Stream 9 下载与安装完全指南:为什么它是当前最稳妥的选择?
linux
草邦设计开发团队_媒体资源平台5 小时前
Linux 稳定常驻启动 PHP 队列,服务器重启自动恢复
linux·服务器·php·队列执行
A_humble_scholar6 小时前
Linux(十六)从页表到线程封装:深入理解 Linux 线程的共享、控制与封装
linux
海盗12346 小时前
.NET编码规范03-C#命名规范大全
linux·服务器·.net