本文主要参考文献是正点原子的C应用编程指南,很多内容与正点原子文档中的一致。作为初学者,为了和大家一起学习所以准备写这一系列文章,也是作为自己学习的一个笔记,如果有错误欢迎大家提出来一起讨论。
进程的诞生与终止
进程的诞生
在 Linux 系统中,所有进程(除了 init 进程)都是由其父进程通过 fork() 或 vfork() 等系统调用创建的。这就是进程诞生的唯一方式。
init 进程(PID = 1)是特殊的最原始父进程:
- 它由内核在系统启动时直接创建并运行(理论上没有父进程)。
- 它管理着系统上所有其他进程,是所有进程的祖先。
- 当系统中的某个进程的父进程提前终止时,init 进程会接管它。
在桌面环境(如 Ubuntu)中,孤儿进程可能会被
upstart或systemd(PID 1)接管,而非直接由 init 接管,但本质相同------由 PID 为 1 的进程成为其新的父进程。
进程的终止
进程终止分为正常终止 和异常终止两种方式:
-
正常终止:
main()函数中使用return返回。- 调用
exit()库函数(会执行清理工作)。 - 调用
_exit()或_Exit()系统调用(直接进入内核)。
-
异常终止:
- 调用
abort()函数(发送SIGABRT信号)。 - 进程收到导致终止的信号(如
SIGKILL、SIGTERM、SIGSEGV等)。
- 调用
exit()和_exit()的status参数定义了进程的终止状态。父进程可通过wait()系统调用获取该状态。虽然status是int类型,但仅低 8 位 表示终止状态。惯例是0表示成功终止,非0表示错误。
如果父进程在 fork() 之前调用了 printf("Hello World!");(没有换行符),该字符串仅存在于父进程的 stdio 缓冲区中,尚未输出到屏幕。
子进程通过 fork() 继承了该缓冲区的副本。于是,父、子进程各自的缓冲区中都存有 "Hello World!"。
当父进程调用 exit() 时,它刷新缓冲区,输出一次 "Hello World!"。
当子进程调用 exit() 时,它刷新其缓冲区副本,再次 输出一次 "Hello World!"。
结果就是字符串被打印了两次。
解决方法(避免重复输出) :
- 子进程使用
_exit()退出 (推荐):因为_exit()不会刷新 stdio 缓冲区,子进程的缓冲区副本直接被丢弃。 - 在
fork()之前显式刷新 :调用fflush(stdout)将缓冲区内容清空,确保fork()复制的是空缓冲区。 - 确保输出包含换行符 :对于终端设备(行缓冲模式),输出包含
\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()的优势:
- 等待特定子进程 :
wait()只能按顺序等待任意子进程,无法指定具体目标。- 非阻塞轮询 :通过
WNOHANG可以轮询检查子进程状态,而不让父进程被阻塞。- 监控停止/恢复 :通过
WUNTRACED和WCONTINUED,可以关注子进程因信号而暂停或恢复运行的事件,而不仅仅是终止事件。
僵尸进程与孤儿进程
僵尸进程(Zombie)
产生条件 :子进程先于父进程终止,但父进程尚未调用 wait() 为其"收尸" (回收资源)。此时,子进程的进程描述符(PCB)仍然保留在内存中,以便父进程后续获取其退出状态,该进程即处于"僵尸状态(Z)"的僵尸进程。
特点与风险:
- 僵尸进程无法通过信号(包括
SIGKILL)杀死 ,因为杀死僵尸进程的唯一方法是让父进程调用wait()或父进程自身退出(由 init 进程接管并回收)。 - 僵尸进程已经释放了大部分资源(如内存),但仍然占用内核进程表中的一个槽位。如果系统中积累大量僵尸进程,进程表会被填满,导致无法创建新进程。
孤儿进程(Orphan)
产生条件 :父进程先于子进程终止,此时子进程变成"孤儿"。Linux 内核会自动将孤儿进程的父进程设置为 init 进程(PID = 1) ,由 init 进程负责回收。
注意 :在桌面版 Ubuntu 系统中,孤儿进程可能会被
upstart或systemd进程(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 数组 |
命名规则:
lvsv:l(list)表示参数以可变参数列表 形式逐个传递;v(vector)表示参数以指针数组形式传递。p:表示使用PATH环境变量查找可执行文件(只需提供文件名,无需完整路径)。e:表示自定义环境变量 (envp数组),而非继承当前进程的环境变量。
如果提供的
file参数包含/字符(即包含路径),则直接按路径查找;否则,按照环境变量PATH中指定的目录列表依次查找。这使得执行系统命令(如ls、echo)时非常方便。
通过envp参数传入的环境变量数组会完全替换新程序的环境变量表(如果这个数组为NULL,那么环境变量会为空)。这与继承当前进程环境不同------调用者可以精确控制新程序能看到哪些环境变量。execle的可变参数列表在最后需要传入envp数组指针,紧跟在最后一个命令行参数(NULL)之后。
system 函数(库函数)
system() 是一个高级封装函数 ,内部通过 fork() + execl() + waitpid() 实现。它创建一个子进程来执行 /bin/sh -c command ,父进程(调用者)会阻塞等待 子进程完成。system() 的主要优点是使用方便,无需手动处理 fork、exec、wait 等细节。
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 进程的终止状态 。需通过WIFEXITED、WEXITSTATUS、WIFSIGNALED等宏解析,以判断命令是正常退出(获取退出码)还是被信号终止(获取信号编号)。
- 如果
注意事项:
- 效率问题 :
system()会创建至少两个进程(一个用于sh,sh再创建子进程执行命令),适合不追求极致性能的场景。- 安全风险 :
system()依赖/bin/sh和环境变量,在多线程或特权程序中可能引入安全漏洞(如命令注入),应谨慎使用。- 信号影响 :
system()执行期间,SIGCHLD信号会被暂时阻塞,而SIGINT和SIGQUIT会被忽略,以防止这些信号干扰waitpid()的等待过程。
如果command为NULL,system()的返回值用于检测 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 恢复 |
收到 SIGSTOP 或 SIGTSTP(如 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结尾,如sshd、httpd、crond,表示其为守护进程(daemon)。
守护进程的核心特征
- 长期运行:守护进程从系统启动开始运行,到系统关机才结束,与用户的登录会话无关。
- 与控制终端脱离 :守护进程没有控制终端,因此不会因终端关闭或用户退出登录而收到
SIGHUP信号。这是守护进程能够长期稳定运行的根本保障。 - 自成进程组和会话 :守护进程通常是一个进程组的组长,同时也是其所属会话的首领(即 PID = PGID = SID),拥有独立的会话,不依附于任何其他会话。
- 无终端 I/O :守护进程的标准输入、标准输出和标准错误通常会重定向到
/dev/null(或日志文件),不会向控制台输出信息,也不会从终端读取输入。
编写守护进程的步骤
编写守护进程需要遵循一套标准的流程,以确保进程能够正确地脱离终端并独立运行:
步骤1:创建子进程,终止父进程
父进程调用 fork() 创建子进程,然后父进程立即 exit() 退出。这样做的目的是:
- 让 Shell 认为命令已执行完毕,释放终端控制权。
- 保证子进程不是进程组组长 ,这是后续调用
setsid()的必要前提(只有非组长进程才能创建新会话)。
步骤2:子进程调用 setsid() 创建新会话
子进程调用 setsid(),使自己成为新会话的首领,并成为新进程组的组长。同时,新会话没有控制终端,子进程彻底脱离了原会话、原进程组和原控制终端。这是守护进程脱离终端的关键步骤。
步骤3:将工作目录更改为根目录
子进程继承了父进程的当前工作目录。如果守护进程的当前目录是某个挂载点下的目录,该文件系统将无法被卸载。因此,通常将守护进程的工作目录更改为 /,避免占用可卸载的文件系统。也可以根据实际需求指定其他目录。
步骤4:重设文件权限掩码(umask)
子进程继承了父进程的 umask。将 umask 设置为 0(umask(0),此时不会有任何进一步被限制的权限),可以确保守护进程创建文件时拥有最大的操作权限,避免因父进程的 umask 限制导致文件权限异常。
步骤5:关闭所有不再需要的文件描述符
子进程从父进程继承了所有打开的文件描述符。这些文件描述符可能永远不会被守护进程使用,但它们会消耗系统资源,并阻止相关文件系统被卸载。因此,应循环关闭所有文件描述符(从 0 到 sysconf(_SC_OPEN_MAX))。
步骤6:将标准输入、输出、错误重定向到 /dev/null
使守护进程的标准输入、标准输出和标准错误指向 /dev/null,确保守护进程不会在终端显示输出,也不会从终端读取输入。实现方式为:
- 打开
/dev/null(读写方式,O_RDWR)。 - 复制文件描述符(
dup(0)和dup(0)),分别对应stdin、stdout和stderr。
步骤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 信号来避免终端关闭的影响:
- 如果在程序中显式忽略
SIGHUP(signal(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)无法捕获 :
SIGKILL和SIGSTOP无法被捕获或忽略,因此无法在信号处理函数中删除文件。 - 系统重启无法清理 :如果将锁文件放在
/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.pid、lightdm.pid)都将 PID 文件放在/var/run/目录下。这是一个临时文件系统(tmpfs) ,系统重启后会被清空,进一步保证锁文件的干净状态。同时,该目录是系统规范的存放 PID 文件的位置,有助于维护系统的组织性。