【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 进程(中)到此也完结了。


相关推荐
A小辣椒14 小时前
TShark:Wireshark CLI 功能
linux
A小辣椒18 小时前
TShark:基础知识
linux
AlfredZhao20 小时前
OCI 明明分配了 200G 系统盘,为什么 df 只看到 30G?
linux·oci
AlfredZhao1 天前
vi 删除指定范围的行,不用再反复按 dd
linux·vi
用户9718356334662 天前
银河麒麟 KY10 申威(SW64) 安装 nginx-1.16.1-2.p01.ky10.sw_64.rpm 详细步骤
linux
猪脚踏浪2 天前
linux 拷贝文件或目录到指定的位置
linux
大树882 天前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
摇滚侠2 天前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
霸道流氓气质2 天前
领域驱动设计(DDD)在 Spring Boot 微服务中的实践指南
运维·spring boot·微服务
bush42 天前
嵌入式linux学习记录十四、术语
linux·嵌入式