进程基础:进程的概念、状态转换、PCB、创建与终止

大家好,我是你们的老朋友。今天咱们聊聊进程——这个操作系统里最核心的概念之一。说实话,我刚开始学的时候也觉得这玩意儿挺抽象的,不就是个正在运行的程序吗?但后来踩了不少坑才明白,进程远比你想象的要复杂。

进程到底是什么?

先说说我的理解。程序是躺在硬盘上的一堆指令,而进程是这些指令被加载到内存后,正在执行的那个"活物"。你想想看,同一个程序可以同时运行多次,比如你打开三个浏览器窗口,每个都是一个独立的进程。它们共享同一份代码,但各自有独立的数据空间。

我曾在项目中遇到过一个问题:两个进程同时写同一个日志文件,结果日志内容乱成一团。这就是典型的进程间资源竞争问题。说白了,进程是资源分配的最小单位,每个进程都有自己的地址空间、文件描述符、信号处理方式等。

核心要点:进程 = 程序 + 执行状态 + 资源集合。程序是静态的,进程是动态的。

进程的状态转换

进程不是一直跑着的,它会在几种状态之间跳来跳去。嗯,这里要注意,不同教材可能说法不太一样,但最核心的状态就三个:

  • 就绪态:进程已经准备好运行,就等CPU分配时间片了。说白了就是"万事俱备,只欠CPU"。
  • 运行态:进程正在CPU上执行指令。注意,单核CPU上同一时刻只能有一个进程处于运行态。
  • 阻塞态:进程在等待某个事件(比如I/O完成、信号量释放),暂时无法继续执行。

为什么会发生状态转换?我举个例子:你写了一个网络爬虫,它发起HTTP请求后,如果网络延迟很高,进程就会从运行态变成阻塞态,把CPU让给其他进程。等数据回来了,再变回就绪态,等待调度器给它分配CPU。

个人经验:我曾经调试过一个死锁问题,就是因为两个进程互相等待对方释放资源,结果都卡在阻塞态出不来。排查时看进程状态表,一眼就发现了问题。

下面这张图展示了进程状态的完整转换关系:

就绪态 Ready 运行态 Running 阻塞态 Blocked 调度选中 时间片用完 等待事件 事件完成 创建 终止

进程控制块(PCB)

操作系统怎么管理这么多进程?靠的就是PCB。每个进程都有一个PCB,里面存着它的全部"家底"。我习惯把PCB想象成进程的身份证+档案袋。

PCB里都存了啥?我列个表给你看:

字段 说明 我的备注
进程ID(PID) 唯一标识一个进程 就像身份证号
程序计数器 下一条要执行的指令地址 进程被切换时靠它恢复现场
CPU寄存器 通用寄存器、栈指针等 上下文切换的核心数据
内存限制 进程能访问的内存范围 防止越界访问
打开文件列表 进程持有的文件描述符 我遇到过文件描述符泄漏,就是这里没清理
进程状态 就绪/运行/阻塞等 调度器主要看这个

注意:PCB是内核空间的数据结构,用户程序不能直接访问。我曾经试图在用户态直接修改PCB里的状态字段,结果程序直接崩溃了——这是内核才能碰的东西。

进程的创建

在Linux下,创建进程最常用的就是fork()系统调用。我刚开始用的时候觉得它挺神奇的——调用一次,返回两次。

#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        // 子进程
        printf("我是子进程,PID=%d\n", getpid());
    } else if (pid > 0) {
        // 父进程
        printf("我是父进程,子进程的PID=%d\n", pid);
    } else {
        // fork失败
        perror("fork失败");
        return 1;
    }
    
    return 0;
}

fork()执行后,子进程会复制父进程的PCB、地址空间、文件描述符等。但注意,这是"写时复制"——只有真正写入数据时才会复制内存页,这样效率高很多。

除了fork(),还有exec()系列函数用来加载新程序。我习惯的做法是:先用fork()创建子进程,然后在子进程里调用exec()执行新程序。这样父进程可以继续做自己的事。

避坑指南:我曾经在fork()之后忘记检查返回值,结果子进程和父进程都执行了同一段逻辑,导致数据被重复处理。记住:fork()返回后,一定要通过pid判断当前是父进程还是子进程。

进程的终止

进程终止有几种方式:

  • 正常退出:main函数返回,或者调用exit()、_exit()
  • 异常终止:收到信号(比如SIGKILL、SIGSEGV)
  • 被其他进程杀死:通过kill()系统调用

进程终止时,内核会做几件事:释放内存、关闭打开的文件、清理PCB。但注意,子进程终止后,它的PCB不会立即被回收——它会变成"僵尸进程",直到父进程调用wait()或waitpid()来获取它的退出状态。

我曾经踩过的坑:写一个长时间运行的服务时,忘了处理子进程的退出状态,结果系统里积累了几百个僵尸进程。最后不得不重启服务才清理掉。所以,记得在父进程里调用wait(),或者用信号处理机制来回收子进程。

下面这段代码展示了如何正确等待子进程退出:

#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        // 子进程:做一些工作然后退出
        printf("子进程开始工作...\n");
        sleep(2);
        printf("子进程结束\n");
        return 42;  // 返回退出码
    }
    
    // 父进程:等待子进程结束
    int status;
    waitpid(pid, &status, 0);
    
    if (WIFEXITED(status)) {
        printf("子进程正常退出,退出码=%d\n", WEXITSTATUS(status));
    }
    
    return 0;
}

嗯,进程这块内容其实挺多的,但今天咱们先把这些核心概念理清楚。记住:进程是资源分配的基本单位,PCB是它的"身份证",状态转换是调度器工作的基础。搞懂了这些,后面讲线程、同步、通信的时候就会轻松很多。


公众号:蓝海资料掘金营,微信deep3321