进程控制是多进程编程的核心。它主要涵盖了三大核心操作:创建进程终止进程回收进程

进程标识符

在 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:

  1. ps 命令:最常用的查看进程快照的命令。例如输入 ps axj 或 ps -ef,输出结果中的 PID 列就是进程号,PPID 列就是父进程号。
  2. /proc 目录:Linux 奉行“一切皆文件”的理念。系统会在 /proc 目录下为每个运行的进程创建一个以 PID 命名的文件夹(例如 /proc/1234),里面存放着该进程极其详细的运行状态信息。

💡 PID 的几个重要特性

  1. 唯一性:在同一时刻,系统中绝不会有两个进程的 PID 是相同的。
  2. 动态分配:PID 是由操作系统动态分配的。每次你运行同一个程序,系统都会给它分配一个新的 PID,它不会固定不变。
  3. 循环使用: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 进程控制的标准流程:

  1. 父进程调用 fork() 创建子进程。
  2. 根据 fork() 的返回值,父子进程分别执行不同的代码逻辑。
  3. 子进程完成任务后,调用 _exit() 或 exit() 终止。
  4. 父进程调用 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 parent
output 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 里找程序,不用写绝对路径 execlpexecvp
e (env) 环境 可以手动传入自定义的环境变量数组 execleexecvpe

实战中怎么选?

  • 如果你想像在终端敲命令一样,直接写 ls 而不是 /bin/ls,就选带 p 的(如 execlp)。
  • 如果你的参数是动态生成的(比如解析用户的输入),用数组传递的 v 系列(如 execvp)会更方便。

🚀 终极形态:Fork-Exec 模型

结合你之前学的 fork(),Linux 下启动一个新程序的标准范式就是 Fork-Exec 模型

  1. 父进程调用 fork():生出一个和自己一模一样的子进程。
  2. 子进程调用 exec():子进程立刻“换魂”,把自己的内存替换成想要运行的新程序(比如 /bin/ls)。
  3. 父进程调用 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 时,神奇的事情发生了:

  1. 操作系统内核(Kernel)在执行这个文件时,会检查它的开头。
  2. 发现是以 #! 开头,内核就会提取出后面的解释器路径(比如 /usr/bin/python)。
  3. 内核实际上并没有直接执行你的脚本文件,而是悄悄地把命令转换成了:/usr/bin/python ./test.py

为什么要用解释器文件?

  • 隐藏细节:它隐藏了“这个脚本到底是用什么语言写的”这一事实。你不需要记住它是 Python、Perl 还是 Bash 写的,只要执行 ./脚本名,系统就会自动调用对应的解释器去跑。
  • 提高效率与便捷性:你不需要每次都在终端里敲 python xxx.py,直接像运行普通程序一样运行脚本即可。

⚠️ 使用时的几个关键注意事项

  1. 必须使用绝对路径#! 后面必须写解释器的完整路径(如 /usr/bin/python),不能只写 python,因为内核在执行时不会去环境变量 $PATH 里帮你找。
  2. 长度有限制#! 这一行是有长度限制的(在 Linux 上通常是 127 个字节),所以路径不要写得过于冗长。
  3. 小心多余的空格:在解释器路径后面如果加了参数,要注意空格。因为空格也会跟着参数一起传给被引用的程序,有时候多余的空格会导致莫名其妙的报错。

总结一下:你平时写的 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,这样既能知道程序“跑了多久”,也能知道它“真正忙了多久”。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐