C 进阶(7) - 进程控制
进程控制是多进程编程的核心。它主要涵盖了三大核心操作:创建进程、终止进程和回收进程。
进程标识符
在 Linux 系统中,每一个运行的进程都有一个唯一的数字标识,这就是进程标识符(Process ID,简称 PID)。
你可以把它理解为进程在操作系统里的“身份证号”。系统内核正是通过这个唯一的 PID 来识别、区分和管理成千上万个进程的。
在 Linux C 编程中,关于进程标识符,你需要掌握以下几个核心知识点:
🔢 核心概念:PID 与 PPID
- PID (Process ID):当前进程的唯一标识符。
- PPID (Parent Process ID):创建当前进程的那个“父进程”的标识符。在 Linux 中,除了最初的 init 进程,每个进程都有一个父进程。
💻 如何在 C 语言中获取 PID
Linux 提供了两个非常简单的系统调用函数来获取这些标识符(需要包含 <sys/types.h> 和 <unistd.h> 头文件):
getpid():获取当前进程的 PID。getppid():获取当前进程的父进程 PPID。
代码实战:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main() {
// 获取当前进程的 PID
pid_t my_pid = getpid();
// 获取父进程的 PPID
pid_t parent_pid = getppid();
printf("我的进程 PID 是: %d\n", my_pid);
printf("我的父进程 PPID 是: %d\n", parent_pid);
return 0;
}
注:pid_t 本质上就是一个用来存放进程 ID 的整数类型(通常是 int 或 long int)。
🛠️ 如何在 Linux 终端查看 PID
除了写代码,在日常开发和排查问题时,我们经常需要用命令来查看进程的 PID:
ps命令:最常用的查看进程快照的命令。例如输入ps axj或ps -ef,输出结果中的PID列就是进程号,PPID列就是父进程号。/proc目录:Linux 奉行“一切皆文件”的理念。系统会在/proc目录下为每个运行的进程创建一个以 PID 命名的文件夹(例如/proc/1234),里面存放着该进程极其详细的运行状态信息。
💡 PID 的几个重要特性
- 唯一性:在同一时刻,系统中绝不会有两个进程的 PID 是相同的。
- 动态分配:PID 是由操作系统动态分配的。每次你运行同一个程序,系统都会给它分配一个新的 PID,它不会固定不变。
- 循环使用:PID 是一个累加的计数器。当系统的 PID 达到最大值后,会重新从较小的数字开始分配(当然,系统会确保分配给当前空闲的 PID)。
结合你之前了解的 fork() 知识:当父进程调用 fork() 创建子进程时,fork() 在父进程中返回的那个大于 0 的数值,其实就是这个新生子进程的 PID。
fork函数
在 Linux 中,创建一个新进程的唯一方法就是调用 fork() 系统调用(定义在 <unistd.h> 中)。
fork() 的作用非常特殊,它会复制当前进程(父进程),产生一个几乎完全相同的新进程(子进程)。它最精妙的设计在于“一次调用,两次返回”:
- 在父进程中:返回新创建的子进程的 PID(大于 0 的整数)。
- 在子进程中:返回 0。
- 如果创建失败:在父进程中返回 -1。
实战示例:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main() {
pid_t pid = fork(); // 创建子进程
if (pid < 0) {
// fork 失败
perror("fork failed");
} else if (pid == 0) {
// 这里是子进程的逻辑 (pid == 0)
printf("我是子进程,我的 PID 是: %d\n", getpid());
} else {
// 这里是父进程的逻辑 (pid > 0)
printf("我是父进程,我创建的子进程 PID 是: %d\n", pid);
}
return 0;
}
💡 核心机制:写时拷贝(Copy-On-Write, COW)
你可能会担心 fork() 复制整个进程会非常耗费内存。其实 Linux 内核非常聪明,在 fork() 刚执行时,父子进程是共享同一块物理内存的。只有当其中一方试图去修改某块内存数据时,内核才会真正为修改方复制出一份新的内存。这极大地提升了创建进程的效率。
这里就有一个问题,进程和线程的问题,在操作系统原理里面线程是CPU 调度的基本单位,这里对这个概念在做一个解释
之所以说它是 Linux 实现并发和多任务的基石,是因为在 Linux 的底层设计哲学中,进程和线程的界限其实非常模糊。这里为你揭开 Linux 内核在这方面的底层真相:
1. 在 Linux 内核眼里,线程就是“轻量级进程”
在 Linux 内核中,其实并没有一个绝对独立的“线程”概念。内核调度的基本单位统称为任务(task),它们在底层都用同一个结构体 task_struct 来描述。
- 进程:独享所有资源(内存、文件描述符等)的任务。
- 线程:与其他任务共享大部分资源(比如共享同一块内存空间)的任务。
所以,Linux 是通过“共享资源的进程”来实现线程的。正因为如此,线程在 Linux 中经常被称为轻量级进程(Lightweight Process, LWP)。无论是 fork() 创建的重型进程,还是 pthread_create() 创建的轻量级线程,最终都是作为一个个独立的 task 被 CPU 拿去调度的。
2. 进程和线程都会被 CPU 调度
“CPU 调度以线程为单位”,在操作系统理论中是完全正确的(线程是调度的基本单位,进程是资源分配的基本单位)。
但在 Linux 的实际运行中,多进程程序中的进程,和多线程程序中的线程,都会被操作系统内核平等地调度。
- 当你
fork()出多个进程时,这些进程是独立的实体,操作系统会让它们在 CPU 上并发或并行执行。 - 当你创建多个线程时,这些线程也是独立的调度实体,操作系统同样会让它们在 CPU 上并发执行。
3. 既然都能并发,为什么还需要线程?
虽然 fork() 进程也能实现多任务并发,但它和线程在“开销”和“通信”上有巨大的区别:
- 开销不同:
fork()创建一个进程,需要复制父进程的内存空间、文件描述符表等资源(虽然现代 Linux 有写时复制 COW 技术优化,但依然比线程重)。而创建线程只需要分配极少的资源(主要是独立的栈空间),速度极快。 - 通信难度不同:进程之间是相互隔离的,想传个数据得用管道、消息队列、共享内存等复杂的进程间通信(IPC)机制。而线程之间天生共享同一块内存,直接读写全局变量就能通信(当然需要加锁防止冲突)。
总结一下:fork() 创建的是进程。在 Linux 中,进程和线程在底层都是被内核调度的“任务”。fork() 通过创建独立的任务(进程)来打破单任务限制,实现并发;而线程则是通过创建共享资源的“轻量级任务”来实现更细粒度、更低开销的并发。两者殊途同归,都是 Linux 实现多任务并发的基石。
进程终止:exit() 与 _exit()
进程终止就是你之前了解过的内容,主要分为正常终止和异常终止。在多进程编程中,最常接触的是正常终止。
exit():C 标准库函数。它会先执行清理工作(如刷新 I/O 缓冲区),再调用系统调用退出。在父进程中通常使用它。_exit():Linux 系统调用。它直接清理进程,不刷新缓冲区。在fork()后的子进程中,强烈建议使用_exit(),防止子进程意外把父进程缓冲区里的数据重复输出。
进程回收:wait() 与 waitpid()
这是多进程编程中最容易被忽视,但也是最关键的一步。
为什么要回收进程?
当子进程终止后,如果父进程没有去“过问”它的退出状态,子进程的进程控制块(PCB)等内核资源就不会被释放。此时子进程会进入 僵尸状态(Zombie, Z)。僵尸进程虽然不占用内存和 CPU,但会一直占用系统的进程号(PID)。如果产生大量僵尸进程,会导致系统无法创建新进程。
如何回收进程?
父进程需要调用 wait() 或 waitpid() 函数(定义在 <sys/wait.h> 中)来回收子进程,获取其退出信息,并彻底释放其占用的内核资源。
wait(int *status):阻塞等待任意一个子进程退出。如果当前没有子进程退出,父进程会一直卡在这里。waitpid(pid_t pid, int *status, int options):更灵活的等待方式。可以指定等待某个 PID 的子进程,也可以通过设置options为WNOHANG来实现非阻塞等待(如果没有子进程退出,它不会卡住,而是直接返回 0)。
实战示例(父进程回收子进程):
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程:运行 3 秒后退出
printf("子进程开始运行...\n");
sleep(3);
printf("子进程退出。\n");
exit(42); // 子进程以状态码 42 退出
} else {
// 父进程:等待并回收子进程
int status;
printf("父进程正在等待子进程退出...\n");
pid_t wpid = wait(&status); // 阻塞等待,直到子进程退出
if (WIFEXITED(status)) { // 判断子进程是否正常退出
printf("父进程回收成功!子进程 PID: %d, 退出码: %d\n", wpid, WEXITSTATUS(status));
}
}
return 0;
}
总结一下 Linux C 进程控制的标准流程:
- 父进程调用
fork()创建子进程。 - 根据
fork()的返回值,父子进程分别执行不同的代码逻辑。 - 子进程完成任务后,调用
_exit()或exit()终止。 - 父进程调用
wait()或waitpid()回收子进程资源,防止产生僵尸进程。
竞争条件
竞争条件”(Race Condition)是多进程和多线程编程中最经典、也最容易让人头疼的 Bug 来源。
简单来说,当多个进程或线程并发访问共享资源(比如同一个全局变量、同一块内存或同一个文件),而程序的最终结果取决于它们执行的先后顺序时,就发生了竞争条件。
为了让你秒懂,我们结合你之前学的 fork() 和进程知识,来看一个非常直观的例子:
💥 竞争条件是如何发生的?
假设父子进程要同时向屏幕(共享资源)打印一句话。由于 CPU 的调度是随机的,父子进程谁先跑、谁中途被插队,完全不由你控制
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
void print_chars(char *str) {
for (int i = 0; str[i] != '\0'; i++) {
putchar(str[i]);
fflush(stdout); // 强制刷新缓冲区,让字符立刻打印出来
usleep(10000); // 故意睡 0.01 秒,放大竞争效果
}
printf("\n");
}
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程
print_chars("output from child");
} else {
// 父进程
print_chars("output from parent");
wait(NULL);
}
return 0;
}
你期望的结果可能是:output from parentoutput from child
但实际运行多次,你大概率会看到字符完全乱套的“鬼畜”输出:outpoututp fuotr ommpa rcehniltd
为什么会这样?
因为 putchar 打印字符并不是一个“原子操作”(不可分割的整体)。当父进程刚打印完 out,CPU 可能突然切换去执行子进程,子进程接着打印 output,等时间片用完切回父进程,它又从刚才断掉的 p 继续打印。两个进程在抢占式地往同一个地方写数据,结果就乱套了。
🛡️ 如何解决竞争条件?
解决竞争条件的核心思想就是:互斥(Mutual Exclusion)。也就是给共享资源加一把锁,同一时间只允许一个人(进程/线程)进去操作,其他人必须在外面等着。
1. 进程间的解决方案:信号量 (Semaphore)
在 Linux 多进程编程中,最常用的同步机制是信号量。你可以把它想象成一个“红绿灯”或者“通行证”。
sem_wait()(P操作):想进入临界区(访问共享资源)?先拿通行证。如果没有通行证,就乖乖阻塞等待。sem_post()(V操作):用完资源了?把通行证还回去,并唤醒正在等待的其他人。
#include <semaphore.h>
#include <fcntl.h>
// 创建一个初始值为 1 的命名信号量(相当于只有一张通行证)
sem_t *sem = sem_open("/my_sem", O_CREAT, 0644, 1);
// 父进程和子进程都遵守这个规则:
sem_wait(sem); // 进门先抢锁
// ... 安全地访问共享资源(比如打印字符)...
sem_post(sem); // 出门必还锁
通过信号量,我们可以强制让父进程先完整打印完,再让子进程打印,彻底消灭字符交错的现象。
2. 线程间的解决方案:互斥锁 (Mutex)
如果你是在多线程(pthread)环境下,解决竞争条件最常用的就是互斥锁(Mutex)。它的原理和信号量类似,但更轻量:
#include <pthread.h>
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 初始化一把锁
// 线程访问共享变量前:
pthread_mutex_lock(&mutex); // 加锁
counter++; // 安全地修改共享变量
pthread_mutex_unlock(&mutex); // 解锁
如果没有这把锁,两个线程同时对一个全局变量 counter++,也会因为读取、加1、写回这三个步骤被打断,导致最终的计数结果比预期的小。
📌 总结一下
竞争条件本质上就是“多人抢着改同一份数据,且没人排队”。
- 表现:程序结果不可预测,时好时坏,极其难以复现和调试。
- 解决:引入同步机制(如进程间的信号量、线程间的互斥锁),确保对共享资源的访问是原子性和有序的。
exec 函数
exec() 函数是你拼出 Linux 进程控制完整图景的最后一块关键拼图!
如果说 fork() 是孙悟空拔毫毛变出“分身”(子进程),那么 exec() 就是给这个分身施展“夺舍”或“换魂”大法。
exec() 的核心作用非常简单粗暴:用一个新的程序,完全替换掉当前进程的代码、数据和堆栈。
🧠 核心机制:换魂不换壳
调用 exec() 后,会发生以下神奇的现象:
- 换魂(用户空间被替换):当前进程的内存空间(代码段、数据段、堆栈等)会被新程序的镜像完全覆盖。原来的程序瞬间“消失”,变成了新程序。
- 不换壳(内核属性保留):进程的“外壳”依然保留。最典型的就是 PID(进程ID)保持不变,父进程、当前工作目录、打开的文件描述符等内核级属性也都会继承下来。
- 有去无回:这是
exec()最特别的地方。一旦exec()调用成功,它永远不会返回! 因为原来的程序代码已经被新程序覆盖了,根本没有指令能让它跳回来。只有当调用失败(比如找不到新程序)时,它才会返回 -1。
🧩 函数家族:6个兄弟各司其职
exec 其实是一个函数族,包含 6 个功能相似但参数传递方式不同的函数。你可以通过它们名字的后缀来轻松记忆:
| 后缀 | 含义 | 参数传递方式 | 典型代表 |
|---|---|---|---|
| l (list) | 列表 | 参数一个一个列出来,最后以 NULL 结尾 |
execl |
| v (vector) | 数组 | 参数放在一个字符串数组里 (char *argv[]) |
execv |
| p (path) | 路径 | 自动去环境变量 PATH 里找程序,不用写绝对路径 |
execlp, execvp |
| e (env) | 环境 | 可以手动传入自定义的环境变量数组 | execle, execvpe |
实战中怎么选?
- 如果你想像在终端敲命令一样,直接写
ls而不是/bin/ls,就选带 p 的(如execlp)。 - 如果你的参数是动态生成的(比如解析用户的输入),用数组传递的 v 系列(如
execvp)会更方便。
🚀 终极形态:Fork-Exec 模型
结合你之前学的 fork(),Linux 下启动一个新程序的标准范式就是 Fork-Exec 模型:
- 父进程调用
fork():生出一个和自己一模一样的子进程。 - 子进程调用
exec():子进程立刻“换魂”,把自己的内存替换成想要运行的新程序(比如/bin/ls)。 - 父进程调用
wait():父进程等待并回收完成任务的子进程。
为什么要这么麻烦?为什么不直接 exec?
因为 exec 会把当前进程完全替换掉。如果你直接在主程序里调用 exec,你的主程序就“死”了,变成了新程序,再也回不来了。所以必须先 fork 一个“替死鬼”(子进程)去执行新任务,保证父进程能继续存活和控制局面。
💻 代码实战:在 C 程序中执行 ls -l
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
exit(1);
}
else if (pid == 0) {
// ================= 子进程逻辑 =================
printf("子进程 (PID: %d) 准备变身!\n", getpid());
// 调用 execlp,自动在 PATH 中查找 ls 命令
// 参数必须按顺序列出,并以 NULL 结尾
execlp("ls", "ls", "-l", NULL);
// ⚠️ 注意:如果 execlp 成功,下面这行代码永远不会被执行!
// 只有当 execlp 失败(比如系统里没有 ls 命令)才会走到这里
perror("execlp failed");
exit(1);
}
else {
// ================= 父进程逻辑 =================
printf("父进程 (PID: %d) 等待子进程 (PID: %d) 完成任务...\n", getpid(), pid);
wait(NULL); // 阻塞等待子进程结束
printf("子进程已退出,父进程继续运行。\n");
}
return 0;
}
🐚 额外补充:Shell 中的 exec 命令
你在写 Shell 脚本时可能也见过 exec 命令。它的底层原理和 C 语言的 exec 系统调用完全一致:
- 进程替换:在脚本里执行
exec python3 app.py,当前脚本进程会直接变成 Python 进程,脚本后面的代码不会再执行。 - 文件描述符重定向:在脚本开头写
exec > log.txt 2>&1,可以永久改变当前脚本的标准输出,后续所有的echo都会自动写入log.txt,这在写运维脚本时非常有用。
至此,fork()(分身)、exec()(换魂)、wait()(回收)以及进程间通信,已经把 Linux 进程控制的核心知识全部打通了!
解释器文件
简单来说,解释器文件(Interpreter File) 就是我们在 Linux 脚本里最常见到的、以 #!(俗称 Shebang)开头的那个文本文件。
它本质上是一个文本文件(比如你的 Python 脚本 .py 或者 Shell 脚本 .sh),但它的第一行明确告诉操作系统内核:“请别把我当成普通文本,去调用这一行指定的程序(解释器)来执行我!”
📜 解释器文件的标准格式
它的第一行必须严格遵循以下格式:#! 解释器的绝对路径 [可选参数]
比如你写的 Python 温度监控脚本,它的第一行通常长这样:
#!/usr/bin/python
# 或者更通用的写法:
#!/usr/bin/env python
#!:固定开头,用来被系统识别。/usr/bin/python:就是 Python 解释器在你 Linux 系统里的绝对路径。
⚙️ 它的工作机制(内核的“自动转发”)
当你给这个脚本加上可执行权限(chmod +x test.py),然后直接在终端运行 ./test.py 时,神奇的事情发生了:
- 操作系统内核(Kernel)在执行这个文件时,会检查它的开头。
- 发现是以
#!开头,内核就会提取出后面的解释器路径(比如/usr/bin/python)。 - 内核实际上并没有直接执行你的脚本文件,而是悄悄地把命令转换成了:
/usr/bin/python ./test.py。
为什么要用解释器文件?
- 隐藏细节:它隐藏了“这个脚本到底是用什么语言写的”这一事实。你不需要记住它是 Python、Perl 还是 Bash 写的,只要执行
./脚本名,系统就会自动调用对应的解释器去跑。 - 提高效率与便捷性:你不需要每次都在终端里敲
python xxx.py,直接像运行普通程序一样运行脚本即可。
⚠️ 使用时的几个关键注意事项
- 必须使用绝对路径:
#!后面必须写解释器的完整路径(如/usr/bin/python),不能只写python,因为内核在执行时不会去环境变量$PATH里帮你找。 - 长度有限制:
#!这一行是有长度限制的(在 Linux 上通常是 127 个字节),所以路径不要写得过于冗长。 - 小心多余的空格:在解释器路径后面如果加了参数,要注意空格。因为空格也会跟着参数一起传给被引用的程序,有时候多余的空格会导致莫名其妙的报错。
总结一下:你平时写的 test.py,只要加上了 #!/usr/bin/env python 并赋予了执行权限,它就是一个标准的“解释器文件”。系统正是靠这个文件头,才知道该把里面的代码交给 Python 解释器去处理。
进程时间
在 Linux C 语言编程中,想要获取进程的时间,通常分为两类需求:一是获取真实流逝的时间(挂钟时间),二是获取进程实际消耗 CPU 的时间(用户态+内核态)。
结合 Linux 系统编程,以下是几种最常用的获取进程时间的方法:
⏱️ 1. 获取真实流逝时间(挂钟时间)
如果你需要测量一段代码在现实世界中真实跑了多久(包含休眠、等待 I/O 的时间),推荐使用高精度的 clock_gettime。
- 函数:
int clock_gettime(clockid_t clk_id, struct timespec *tp); - 说明:使用
CLOCK_MONOTONIC作为时钟源,可以保证时间不会受系统时间修改(如 NTP 校准)的影响,非常适合用来计算时间差。
⚙️ 2. 获取进程消耗的 CPU 时间
如果你想分析程序的性能,看它到底在用户态和内核态各消耗了多少 CPU 资源,可以使用 times 函数或 clock 函数。
-
方法一:
times函数(信息最全,强烈推荐)- 函数:
clock_t times(struct tms *buf); - 说明:它会将当前进程及子进程的 CPU 时间信息填充到
struct tms结构体中。 - 核心结构体:
- 注意:
times返回的时间单位是时钟滴答数(clock ticks)。要换算成秒,必须除以系统的时钟频率。可以通过sysconf(_SC_CLK_TCK)来获取每秒的滴答数。
- 函数:
-
方法二:
clock函数(用法简单)- 函数:
clock_t clock(void); - 说明:返回程序启动以来消耗的总 CPU 时间(用户态+内核态)。
- 注意:换算成秒时,需要除以宏
CLOCKS_PER_SEC。它的缺点是无法区分用户态和内核态的时间。
- 函数:
💻 3. Linux C 获取进程时间实战代码
下面是一个综合示例,演示了如何在 C 语言中同时获取真实时间和详细的 CPU 时间:
#include <stdio.h>
#include <unistd.h>
#include <sys/times.h>
#include <time.h>
int main() {
// 1. 获取系统时钟频率(每秒滴答数)
long clock_ticks = sysconf(_SC_CLK_TCK);
struct tms start_tms, end_tms;
struct timespec start_real, end_real;
// 2. 记录开始时间
times(&start_tms);
clock_gettime(CLOCK_MONOTONIC, &start_real);
// --- 模拟一段业务代码(包含计算和休眠) ---
for (volatile long i = 0; i < 10000000; i++); // 纯计算消耗 CPU
sleep(2); // 休眠2秒,消耗真实时间但不消耗 CPU
// ---------------------------------------
// 3. 记录结束时间
times(&end_tms);
clock_gettime(CLOCK_MONOTONIC, &end_real);
// 4. 计算并打印结果
printf("=== 进程时间统计 ===\n");
// 真实流逝时间 (秒.纳秒)
double real_time = (end_real.tv_sec - start_real.tv_sec) +
(end_real.tv_nsec - start_real.tv_nsec) / 1e9;
printf("真实流逝时间 (Real Time): %.6f 秒\n", real_time);
// CPU 用户态时间
double user_cpu_time = (double)(end_tms.tms_utime - start_tms.tms_utime) / clock_ticks;
printf("用户态 CPU 时间 (User Time): %.6f 秒\n", user_cpu_time);
// CPU 内核态时间
double sys_cpu_time = (double)(end_tms.tms_stime - start_tms.tms_stime) / clock_ticks;
printf("内核态 CPU 时间 (Sys Time): %.6f 秒\n", sys_cpu_time);
return 0;
}
📌 总结
| 需求场景 | 推荐函数 | 核心要点 |
|---|---|---|
| 测量真实耗时 | clock_gettime |
传入 CLOCK_MONOTONIC,精度可达纳秒级。 |
| 区分用户态/内核态 | times |
返回时钟滴答数,需除以 sysconf(_SC_CLK_TCK) 换算成秒。 |
| 简单获取总 CPU 时间 | clock |
返回总 CPU 时间,需除以 CLOCKS_PER_SEC 换算成秒。 |
在实际的 Linux C 性能分析中,通常会结合 times 和 clock_gettime,这样既能知道程序“跑了多久”,也能知道它“真正忙了多久”。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)