【Linux】进程(中)

🔥铅笔小新z:个人主页

🎬博客专栏:Linux学习

💫滴水不绝,可穿石;步履不休,能至渊。


三、进程

3.5 进程状态

进程在操作系统中不是一成不变的,它就像人一样,有工作的时候(运行),有睡觉的时候(等待),也有生病或者去世的时候。

3.5.1 Linux中的进程状态源码解析

在Linux内核源码(以经典版本为例,可以在 fs/proc/array.cinclude/linux/sched.h 中找到)中,进程的状态是通过一个数组或一系列宏定义来表示的。我们来看内核里经典的定义:

c 复制代码
/*
 * The task state array is a strange "bitmap" of
 * reasons to sleep. Thus "running" is zero, and
 * you can test for combinations of others with
 * simple bit tests.
 */
static const char * const task_state_array[] = {
    "R (running)",       /* 0  - 运行或就绪状态 */
    "S (sleeping)",      /* 1  - 可中断睡眠状态 */
    "D (disk sleep)",    /* 2  - 不可中断睡眠状态 */
    "T (stopped)",       /* 4  - 暂停状态 */
    "t (tracing stop)",  /* 8  - 跟踪停止状态(被调试) */
    "X (dead)",          /* 16 - 死亡状态 */
    "Z (zombie)",        /* 32 - 僵尸状态 */
};

各状态产生的原因与定义:

  • R (Running - 运行状态): 并不意味着进程一定在占用CPU,它代表进程在运行队列中,随时准备好被CPU调度,或者正在被CPU执行。

  • S (Sleeping - 可中断睡眠): 进程在等待某个事件完成(例如等待用户输入、等待网络数据)。这种睡眠是可以被信号(Signal)打断的。

  • D (Disk sleep - 不可中断睡眠): 深度睡眠,通常是在等待磁盘I/O。为了保护数据一致性,即使你发送 kill -9 也无法杀死处于D状态的进程,必须等它I/O结束。

  • T (Stopped - 停止状态): 进程被暂停了。比如你在终端按了 Ctrl+Z,或者发送了 SIGSTOP 信号,进程就会进入T状态。

  • t (Tracing stop): 进程正在被调试器(如gdb)追踪。

  • Z (Zombie - 僵尸状态): 进程已经退出,但是它的父进程还没有通过 wait()waitpid() 读取它的退出状态。

  • X (Dead - 死亡状态): 这是一个瞬态,进程即将被彻底清理,你几乎无法在系统工具中看到它。

3.5.2 阻塞和挂起

3.5.2.1 阻塞

一句话总结:在内存里,但干不了活。

在Linux系统中,当进程需要等待某个事件发生(比如等待键盘输入、等待网络数据包到达、等待磁盘I/O完成,或者等待获取某个锁)时,它就无法继续向下执行代码。为了不浪费CPU资源,它会主动交出CPU的使用权,进入阻塞状态。

  • 资源占用: 依然驻留在物理内存中。
3.5.2.2 挂起

一句话总结:既干不了活,又被踢出了内存。

在这个状态下,进程:

  1. 依然在等待那个特定的事件(比如等待I/O)。

  2. 它的数据已经被放到了磁盘的Swap区。

状态转换的奇妙之处:

如果进程在磁盘上等到了事件发生,它能不能立刻运行?不能!

它会从 "阻塞挂起" 状态变成 "就绪挂起" 状态。也就是说,它现在万事俱备,只欠一个"内存座位"。只有空出了座位,OS才会把它调入内存,变成正常的 "就绪" 状态,等待CPU调度执行。

3.5.3 如何查看进程状态

查看进程状态最常用的指令有两个:

  • ps aux 列出系统中所有进程的详细信息。其中 STAT 列就是进程状态。

  • ps -al 或者 ps -eo pid,stat,cmd 可以自定义输出格式,更加清晰。

  • top Linux下的任务管理器,动态实时查看(S 列代表状态)。

3.5.4 什么是僵尸进程

定义: 子进程已经终止执行,但其父进程尚未调用 wait() 方法来收集其状态信息。此时子进程的进程控制块(PCB,即 task_struct)仍然保留在内存中,这种状态的进程就是僵尸进程。

危害: 僵尸进程本身不占用CPU和内存代码空间,但它会占用系统的进程号(PID)和内核数据结构(PCB)。系统的PID数量是有限的,如果父进程一直不回收,会导致PID耗尽,最终无法创建新进程,引起内存泄漏!

下面是一个维持 30 秒的僵尸进程代码:

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

int main() 
{
    pid_t id = fork(); // 创建子进程

    if (id == 0) 
    {
        // 子进程逻辑
        printf("我是子进程,PID: %d,我即将退出,变成僵尸...\n", getpid());
        exit(0); // 子进程光速退出
    } else if (id > 0) 
    {
        // 父进程逻辑
        printf("我是父进程,PID: %d,我要睡30秒,期间我不回收子进程!\n", getpid());
        sleep(30); // 父进程睡眠30秒,不调用 wait()
        printf("父进程醒来,退出程序。\n");
    } else 
    {
        perror("fork failed");
    }
    return 0;
}

编译运行后,新开一个终端输入 while :; do ps aux | grep a.out | grep -v grep; sleep 1; echo "---"; done,你会清晰地看到子进程状态变成了 Z+)

3.5.5 什么是孤儿进程

定义: 与僵尸进程恰恰相反,孤儿进程是父进程先退出了,而子进程还在运行

此时子进程无家可归了怎么办?Linux有完善的社会福利系统:孤儿进程会被PID为1的系统进程(通常是 initsystemd)收养。此后,PID 1 将负责该子进程的善后工作(回收状态),所以孤儿进程不会造成资源泄漏,它是安全的。

下面是一个创建孤儿进程的代码:

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

int main() 
{
    pid_t id = fork();

    if (id == 0) 
    {
        // 子进程逻辑
        printf("我是子进程,PID: %d,我的父进程是: %d\n", getpid(), getppid());
        sleep(5); // 睡5秒,保证父进程先退出
        printf("我是子进程,现在我的父进程是: %d (我成孤儿被收养了!)\n", getppid());
        sleep(30); // 继续存活
    } else if (id > 0) 
    {
        // 父进程逻辑
        printf("我是父进程,PID: %d,我跑路了!\n", getpid());
        exit(0); // 父进程光速退出
    }
    return 0;
}

3.6 进程优先级

3.6.1 什么是进程优先级

系统中的进程成百上千,但CPU核心可能只有几个。进程优先级就是决定谁先使用CPU,以及能占用多长时间CPU的特权等级。优先级越高的进程,越容易被系统调度执行。

3.6.2 查看系统进程及关键字段含义

我们可以使用指令:ps -l (或者 ps -al) 来查看。

你会看到如下表头,下面是它们的含义:

  • UID (User ID): 执行这个进程的用户的身份ID。

  • PID (Process ID): 当前进程的唯一编号。

  • PPID (Parent Process ID): 父进程的PID。

  • PRI (Priority): 进程的最终优先级,默认 80。数值越小,优先级越高!

  • NI (Nice): 进程优先级的修正数据,称为 nice 值。

3.6.3 优先级的调整 (PRI与NI的关系)

在Linux中,我们通常不直接修改 PRI,而是通过修改 NI (nice值) 来间接调整优先级。

公式为: P R I ( n e w ) = 80 + N I PRI(new) = 80 + NI PRI(new)=80+NI (注意:这里的80是基准值,每次调整都是基于系统默认基准,而不是基于上一次的PRI)

nice的取值范围是:-20 到 19。因此PRI的最终范围通常是 60 到 99。

相关指令:

  • 启动时设置: nice -n <nice值> <指令>
    (例如:nice -n -10 ./my_program,给程序更高的优先级)
  • 运行中调整: renice -n <nice值> -p <PID>
    (也可以在 top 指令中,按下 r 键,然后输入PID和新的nice值来调整)

3.6.4 四个关键操作系统概念

为了更好理解调度,我们必须搞懂这四个词:

  • 竞争 (Contention): 系统资源(CPU、内存)有限,多个进程互相争夺资源的状态。优先级就是为了合理解决竞争。

  • 独立 (Independence): 每个进程拥有自己独立的虚拟地址空间。进程A崩溃不会影响进程B,这就是操作系统的隔离性。

  • 并行 (Parallelism): 真同时。在多核CPU中,多个进程在同一时刻被多个CPU核心同时执行。

  • 并发 (Concurrency): 假同时。在一个单核CPU上,多个进程通过极快的"上下文切换",交替执行。因为切换速度以微秒计,人类看起来它们像是在同时运行。

3.7 进程切换与调度算法

3.7.1 什么是CPU上下文切换

当CPU要从进程A切换到进程B时,它不能直接跑路。它必须把进程A当前的执行进度(比如通用寄存器里的值、程序计数器PC、栈指针等)保存起来(存到进程A的PCB中)。然后,再把进程B之前保存的进度恢复到CPU寄存器中。这就叫上下文切换

Linux源码:

在Linux内核中,上下文切换的核心动作由 context_switch() 函数负责,而最底层的寄存器替换是由一段汇编宏 switch_to 完成的:

c 复制代码
// 内核 sched/core.c 伪代码逻辑
static __always_inline struct rq *
context_switch(struct rq *rq, struct task_struct *prev,
               struct task_struct *next, struct rq_flags *rf)
{
    // 1. 切换虚拟内存空间 (mm_struct)
    switch_mm_irqs_off(prev->active_mm, next->mm, next);
    
    // 2. 切换CPU寄存器状态 (核心!)
    switch_to(prev, next, prev); 
    
    return rq;
}

3.7.2 O(1) 调度器详解:活动队列与过期队列

在早期的系统中,如果就绪进程很多,操作系统每次挑进程都要遍历一遍,非常耗时。O(1) 调度器通过巧妙的数据结构,让寻找下一个要运行的进程的时间复杂度恒定为 O(1)。

它的核心在于运行队列 (runqueue) 里的双数组设计:

c 复制代码
// 经典 O(1) 调度器的核心结构
struct runqueue 
{
    // ...
    struct prio_array *active;   // 指向活动队列
    struct prio_array *expired;  // 指向过期队列
    struct prio_array arrays[2]; // 实际存储队列的物理内存
    // ...
};

运行机制剖析:

  1. 调度队列 (Priority Array): 系统内部有140个优先级队列(0-139)。普通进程占 100-139(正好对应 nice 的 -20 到 19)。

  2. 活动队列 (active): active 指针指向的数组包含了当前还有时间片的进程。CPU只从这个队列里挑人执行。

  3. 过期队列 (expired):active 队列里的某个进程把自己的时间片耗尽了,它就会被踢出 active,并被放入 expired 指针指向的过期队列中。

  4. 指针交换 (The Magic Swap):active 队列里的所有进程都执行完毕(即 active 变为空了),调度器不需要重新给所有人计算时间片。它只需要把 active 指针和 expired 指针互相交换一下位置! 瞬间,过期队列变成了新的活动队列,游戏继续!这就是 O(1) 算法最迷人的地方。


四、命令行参数和环境变量

4.1 基本概念

环境变量就是 Linux 系统为所有程序准备的"全局手册"。它告诉程序:

  • 你是谁?(USER
  • 你在哪?(PWD
  • 如果你要找工具,该去哪些房间找(PATH

如果没有环境变量,你每执行一个命令(比如 ls),都必须输入它的完整家庭住址(比如 /bin/ls)。

4.2 常见的环境变量

  • PATH
    当你在终端敲下 lsgcc 时,系统怎么知道这些程序在哪里?全靠 PATH 。它是一串由冒号 隔开的目录列表(比如 /usr/bin:/bin:/usr/local/bin)。系统会按照这个顺序,挨个仓库去找。如果找遍了都没找到,就会报错:"命令未找到(Conmand not found)"。
  • HOME
    记录了你当前用户的家目录路径(比如 /home/zhangsan)。你敲击 cd ~ 时,系统就是读取了 HOME 的值才知道该把你送回哪个目录。
  • USER
    当前登录的用户名。程序可以通过这个变量知道是谁在使唤它,从而判断你有没有权限。
  • PWD
    Print Working Directory,也就是你此刻所站在哪个目录下。

4.3 查看环境变量的方法

  • env(查看全部的环境变量):

    直接输入这个命令,终端会输出一大串命令,这就是你当前环境下的所有环境变量。

  • echo $变量名(精准查阅某一条):

    如果你只想看 PATH 是什么,就输入 echo $PATH

    注意: 这里的 $ 符号极其重要!它就像是一个"取值符"。如果你只输入 echo PATH ,系统会把你当成复读机,直接在屏幕上打印"PATH"四个字母;加上 $PATH,系统才会去所有变量里把 PATH 背后的内容打印出来。

4.4 main函数的参数:argc,argv,envp

当你在终端输入一行命令去运行你写的程序时(比如 ./my_program -l a.txt),你不仅是在叫醒这个程序,还是在给它派发任务 。main 函数的参数,就是程序接收任务的 "交接单"

标准的 main 函数长这样:

c 复制代码
int main(int argc, char *argv[], char *envp[])
  • argcArgument Count - 参数个数 ):
    • 含义: 派发了多少个任务(包括任务名本身)
    • 例子: ./my_program -l a.txt 用空格隔开了一共 3 个词,所以 argc 的值就是 3 。系统借此告诉程序:"用户给你派送了 3 样东西,你查收一下。"
  • argvArgument Vector - 参数数组 ):
    • 含义: 这是具体的任务清单,一个存放字符串的数组。
    • 例子:
      • argv[0]./my_program(程序名,永远是第一个)
      • argv[1]-l (第一个附加要求)
      • argv[2]a.txt (第二个附加要求)
  • envpEnvironment Pointer - 环境变量指针 ):
    • 含义: 这就是前面说的全部环境变量的"副本"。
    • 作用: 操作系统在启动你的程序时,非常贴心的把当前系统的所有环境变量,打包成一个字符串数组(envp)悄悄塞给了 main 函数。虽然很多时候我们写代码只写前两个参数,但如果你想让你的代码在C语言内部直接读取环境变量,就可以遍历这个 envp 数组。

我们写代码来看看 envp 里有什么:

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

int main(int argc, char *argv[], char *envp[]) 
{
    printf("---  开始查阅环境变量表 ---\n");

    int i = 0;
    // 我们需要一个循环来挨个读取 envp 数组里的内容
    while (envp[i] != NULL) 
    {
        printf("第 %d 项: %s\n", i, envp[i]);
        i++;
    }

    printf("--- 查阅完毕 ---\n");
    return 0;
}


envp 本质上是一个指针数组 ,操作系统为了告诉 main 函数任务"分配完了",会在数组最后一个位置放上 NULL,告诉程序"任务到此结束"。

所以 while 的结束条件是 envp[i] != NULL

4.5 Bash的两张表

4.5.1 本地变量表

  • 特性: 这张表里的变量,只有当前的 Bash 自己认识。 一旦它派其他程序去干活(产生子程序),这些变量是绝对不会告诉别人的。
  • 举例: 你在终端敲下 MA_AGE=20。这个 MA_AGE 就只存在于本地变量表中。如果你接着运行一个 Python 脚本,这个脚本里是绝对读不到 MY_AGE 的。

4.5.2 环境变量表

  • 特性: 这张表里的变量,是准备传给所有子进程的。 Bash 在启动任何新程序时,都会把这张表原封不动地复印一份(变成刚才说的 envp),塞给新程序。
  • 桥梁: 怎么把本地变量表 里的内容放到环境变量表 里呢?export命令!
    • 当你执行 export MY_AGE ,Bash 就会把 MY_AGE 从本地变量表抄一份到环境变量表里。从这一刻起,你运行的所有程序都能知道 MY_AGE 是 20 了。

4.6 使用 export

  1. 我们需要在当前的终端(也就是当前的 Bash 进程,我们可以叫它"父 Shell")的本地变量表里写下一笔记录。

    要在终端里直接创建一个名为 MY_SECRET,并且赋值为 123 的本地变量,应该输入 MY_SECRET=123这个命令。

  2. 在测试它能不能传给子进程之前,我们要确认一下它真的存进去了。

    echo $MY_SECRET指令。

  3. 在 Linux 里,我们可以直接在终端输入 bash 命令。这相当于在当前的终端里又嵌套打开了一个全新的字 Shell (子进程)。

    如果在子 Shell 里输入 echo $MY_SECRET,屏幕上会直接输出一个空行(什么都没有)。

  4. 那么为了让子 Shell 也能看到这个数据,我们需要在父 Shell 里进行一步操作:把 MY_SECRET从本地变量表复印一份传到环境变量表中。

    export MY_SECRET命令。

4.7 修改系统的 PATH 变量

假如我们想让刚才编译出来的 code 程序,能像 ls 一样,不需要输入前缀 ./,在系统的任何目录下直接敲名字就能运行,我们就需要把这个程序所在的目录"告诉" PATH

我们先用 echo $PATH 命令打印出 PATH

仔细观察你会发现,这里面并没有我们当前写代码的目录(假设我们现在站在 /home/zhangsan/code 这个目录下)。

因为 PATH 里没有标记,所以如果我们在任何地方直接敲 code,系统找遍了上面那些 /usr/bin 之类的公共仓库都没找到,就会无奈地报错:"命令未找到"。

现在,我们要大显身手,把我们自己的目录追加到这张地图的最后面!

在 Bash 里,我们可以用旧的变量值拼接新的内容来覆盖自己。按照 PATH 的规矩,不同的路径之间必须用冒号 : 隔开。

export PATH=$PATH:/home/zhangsan/code

我们来拆解一下这行命令的精妙之处:我们用 $PATH 把旧的内容原封不动地取出来,加上冒号 : 作为连接符,然后把我们的新地址 /home/zhangsan/code 粘在最后。最后,通过 export 命令把这个崭新的 PATH 重新贴回了环境变量表上。

4.8 让改变永久生效 - ~/.bashrc

我们刚才修改的 PATH 其实是"临时版",只要你关掉终端或者重启电脑就会失效。我们可以去探秘 ~/.bashrc 文件,让它永久生效。

为了让它永久保留,我们需要把这行命令写进 Bash 每次启动都会去自动阅读的文件里。对于绝大多数 Linux 用户,这个核心配置文件叫做 ~/.bashrc

4.8.1 拆解 ~/.bashrc

~ 就是 HOME 目录的快捷写法,代表当前用户的家目录 (比如 /home/zhangsan)。所以 ~/.bashrc 的真实意思是:"去我的家目录,找一个叫 .bashrc 的文件"。

4.8.2 进行设置

现在我们已经锁定了这个文件的位置,下一步就是打开它,把咱们刚才写的这句配置抄写在最后一行:
export PATH=$PATH:/home/zhangsan/code

4.8.3 让终端立即阅读文件生效

虽然文件已经改好了,但是当前的这一个终端窗口还在按旧的 ./bashrc 文件办事。因为它是在你修改文件之前启动的,它脑子里的环境变量表还是旧的那一份。如果你现在立马敲 code ,依然会报错。

当然,你可以把当前的终端关掉,重新打开一个新窗口,新窗口启动时自然会去读新的配置。

但如果我们不想关掉重开,想要让当前的终端立刻把新改的 ~/.bashrc 重新阅读一遍立马生效,Linux 提供了一个专门的指令:
source ~/.bashrc

4.9 通过系统调用获取或设置环境变量

头文件:<stdlib.h>

4.9.1 getenv()

如果你只需要查阅某个特定的环境变量(比如 USERPATH),完全不需要去辛苦遍历那个庞大的 envp 数组。getenv() 就像一个字典索引,你给它变量名,它直接返回对应的值。

  • 作用: 获取指定环境变量的值
  • 代码:
c 复制代码
#include <stdio.h>
#include <stdlib.h>

int main() {
    // 尝试获取系统名为 "HOME" 的环境变量
    char *my_home = getenv("HOME");

    if (my_home != NULL) {
        printf("我的专属宿舍在: %s\n", my_home);
    } else {
        printf("没找到这个环境变量哦。\n");
    }
    return 0;
}

注意: 如果变量不存在,getenv() 会返回一个 NULL(空指针)。所以在用它打印或做其他操作之前,一定要检查是不是 NULL,否则程序很容易崩溃(段错误)。

4.9.2 putenv()

有时候,程序在运行过程中需要自己增加一个新的环境变量,或者修改现有的变量。这时候就可以用 putenv()

  • 作用: 添加或改变环境变量
  • 格式要求: 必须传入一个格式严格为 变量名=值 的字符串
  • 代码:
c 复制代码
#include <stdio.h>
#include <stdlib.h>

int main() {
    // 设定一个新的环境变量
    char new_env[] = "MY_MOOD=HAPPY";
    putenv(new_env);

    // 马上用 getenv 查一下,看看生效没
    printf("我现在的心情是: %s\n", getenv("MY_MOOD"));

    return 0;
}

C语言圈的两个"潜规则":

  1. 危险的内存指针: putenv() 比较"懒",它不会把你的字符串复制一份,而是直接把那个字符串的内存地址 塞进系统的环境变量表里。如果你传给 putenv() 的字符串是一个很快就会被系统回收的局部变量,环境变量就会变成乱码!现代 C 语言更推荐使用 setenv() 函数,因为它会自动在底层复制一份字符串,用起来更省心。

  2. "单向传递"原则: 你在 C 代码里用 putenv() 增加或修改的变量,只对当前这个程序,以及它未来派生出的"子进程"有效。当你程序运行结束,退回黑框框终端(父进程 Bash)时,你会发现 Bash 的环境变量根本没有发生任何改变。子进程是绝对无权"逆向"修改父进程的配置的。


Linux 进程(中)到此也完结了。


相关推荐
云栖梦泽2 小时前
Linux内核与驱动:11.设备树
linux·c++
白毛大侠2 小时前
Linux 常用命令速查手册
linux·运维·服务器
艾莉丝努力练剑2 小时前
【Linux线程】Linux系统多线程(五):<线程同步与互斥>线程互斥
linux·运维·服务器·c语言·c++·学习·ubuntu
百结2142 小时前
keepalived高可用与负载均衡
运维·负载均衡
Yeats_Liao2 小时前
混合部署架构:CPU+GPU协同推理的任务调度策略
服务器·arm开发·人工智能·架构·边缘计算
weixin_457260502 小时前
Linux 命令精讲(博客案例)
linux·运维·服务器
听风lighting2 小时前
RTT-SMART学习 (二):启动过程
linux·c·rtt·rtos·rtt-smart
wefg12 小时前
【计算机网络】应用层协议(序列化与反序列化/HTTP/HTTPS)
服务器·网络·计算机网络
Elendill2 小时前
【Ubuntu】Mihomo 安装、systemd 托管、TUN 配置、API 测速与切换节点
linux·运维·ubuntu