Linux 进程从入门到实战(二)


.
个人主页: 晓风飞
专栏: 数据结构|Linux|C语言
路漫漫其修远兮,吾将上下而求索


文章目录


进程

Linux 内核 7 种进程状态

Linux 里进程状态用字母表示:

R:Running / Runnable 运行 / 就绪

S:Sleeping 休眠(可中断)

D:Disk sleep 不可中断休眠

T:Stopped 停止

t:Tracing stop 调试暂停

Z:Zombie 僵尸

X:Dead 死亡

重点记住:

R = 运行队列中的进程(正在运行 or 就绪等待)

S/D/T = 阻塞状态

挂起 = 内核内部操作,不对外显示

R状态

经典面试坑:为什么死循环打印,状态却是 S?

写一个死循环打印:

c 复制代码
#include<stdio.h>
#include<unistd.h>
int main()
{
        while(1)
        {
                printf("我是一个进程,我正在运行:%d?\n",getpid());
        }
        return 0;
}

运行gcc fs.c,./a.out

你用 ps 查看,发现状态是 S(休眠),不是 R!

原因:

printf 是IO 操作(显示器 / 网络终端)

IO 速度远慢于 CPU

进程大部分时间都在等待外设就绪

所以进程会进入休眠等待,状态显示 S

如果你把 printf 去掉,纯计算死循环:

再查看,状态一定是 R!

因为:

没有任何 IO 等待

进程永远在运行队列里

所以永远显示 R

六、单核 CPU 下的状态真相

单核 CPU 同一时刻只能运行一个进程。

当你用 ps 命令查看进程状态时,肯定执行的是 ps 进程

while死循环是没有在cpu上运行的,因为被ps占据cpu了,但它一直在运行队列里,所以状态依旧显示 R

这就是 Linux 中 R = 就绪 + 运行 的真正含义。

Linux 中 R = 运行队列里的进程(就绪 + 运行)

Linux 中 S/D/T = 阻塞等待

挂起是内核内部操作,用户看不到状态

带 IO 的程序容易进入 S 状态,纯 CPU 计算程序才是 R 状态

sleep休眠状态

可中段休眠状态!

c 复制代码
#include<stdio.h>
#include<unistd.h>
int main()
{
        int a = 0;
        scanf("%d",&a);
        return 0;
} 

我们gcc编译下后运行,在ps查看下状态

之前讲过scanf在等待键盘输入时,如果键盘没有数据,这时候处于阻塞状态,在linux中怎么变成s了?

其实linux内核中的s状态他就属于操作系统学科里的阻塞状态

printf打印涉及外设,向显示器打印,大量的消息在等待外设所以状态为r

s休眠状态,可中断休眠状态

s后面为什么有+,+表示在前台
./myproc表示在前台
./myproc &表示在后台运行

补充知识:前后台进程与+号的含义

前台与后台的区分

在Linux命令行中,执行一个程序:

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

int main() 
{
    int a;
    printf("please input a number: ");
    scanf("%d", &a);
    printf("you entered: %d\n", a);
    return 0;
}

· ./myproc ------ 前台运行

复制代码
[dzh@iZ7xve6aquwnm3zofunwsmZ work]$ ./procmy
please input a number: 

· ./myproc & ------ 后台运行

用ps aux查看进程状态时,状态后面带+的表示前台进程,不带+的表示后台进程。

为什么要有前后台?

因为键盘只有一个。多个进程同时存在时,你敲键盘输入的数据到底应该给谁?必须明确。所以操作系统规定:有且只有一个前台进程,前台进程拥有键盘输入权。其他进程都是后台进程,不能读取键盘。后台存在的意义,比如下载东西时在后台运行

前台进程的运行效果

写一个简单的程序myproc.c:

c 复制代码
#include <stdio.h>
#include<unistd.h>
int main() {
   	while(1)
   	{
   	printf("I am a process:pid:%d\n",getpd());
   	sleep(1);
   	}
    return 0;
}

编译后运行./myproc,它会卡住等待输入。此时在终端输入ls、pwd等命令,没有任何反应------因为shell(bash)已经退出了前台,变成了后台进程,你的输入被丢给了前台进程(myproc),但myproc并没有执行命令,所以命令无效。

如果我们在执行时加&让它后台运行:

bash 复制代码
./myproc &

这个时候ctrl + c按下去完全停止不了,但是输入llpwd会发现可以运行但是输出信息是shell输出和程序输出互相干扰,这个时候只能kill - 9

T状态

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

int main() 
{
    int a;
    scanf("%d", &a);
    printf("you entered: %d\n", a);
    return 0;
}

运行(前台):
./myproc

此时进程卡住,等待输入。另开一个终端,用ps aux | grep myproc查看,可以看到状态为S+。

如果我们在运行时加上&让它后台运行:
./myproc &

再用ps查看,状态为S(没有加号)。但是注意,这个程序里面调用了scanf,它会试图读取键盘。作为后台进程,它没有资格读键盘。操作系统会怎么处理?把它暂停,状态变为T(后面我们会讲T状态)。

jobs

这个时候通过jobs命令可以查看后台任务列表:

bash 复制代码
jobs

输出:
[dzh@iZ7xve6aquwnm3zofunwsmZ ~]$ jobs [1]+ Stopped ./procmy

使用fg 1可以将这个后台任务切回前台,此时它重新获得键盘输入权,状态恢复为S(带+号)。然后就可以正常输入了。

3.5 总结前后台规则

· 前台进程:任何时刻在linux命令操作过程中有且只能有一个进程是前台进程,键盘输入的数据只能给前台进程,状态显示带+。拥有键盘

· 后台进程:多个,无键盘,若强行读键盘则被暂停(T状态)。

· Ctrl+C只能杀死前台进程。

· 杀后台进程需要用kill -9 [PID]。

发送信号暂停进程

除了后台读键盘自动暂停,我们还可以手动发送信号让进程暂停。

首先查看所有信号:

bash 复制代码
kill -l

输出1~64号信号。其中:
kill -9 等价于 kill -sigkill

1-31 ~31目前只见过9号信号

34-64 实时信号

· 19号信号:SIGSTOP,暂停进程。

· 18号信号:SIGCONT,继续运行暂停的进程。

示例:写一个无限循环打印的程序loop.c:

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

int main() {
    while(1) {
        printf("hello, PID: %d\n", getpid());
        sleep(1);
    }
    return 0;
}

编译运行,用ps查看状态,大部分时间是S(因为sleep),偶尔出现R。找到PID后:

bash 复制代码
kill -19 12345   # 暂停
ps aux | grep loop   # 状态变为 T
kill -18 12345   # 继续
ps aux | grep loop   # 状态变回 S 或 R

被暂停的前台进程会自动转为后台(+号消失),因为键盘不能给一个暂停的进程。

T状态与阻塞的关系

T状态本质也是一种阻塞:进程在等待一个恢复运行的信号。它与S状态的区别在于触发原因:S是自愿等待资源,T是被动暂停(或非法操作)。


t状态(追踪暂停):gdb调试时的断点

当使用gdb调试程序时,在断点处停下,被调试的子进程会进入t状态(tracing stop)。

演示步骤:

  1. 编译带调试信息的程序:gcc -g myproc.c -o myproc
  2. 启动gdb:gdb ./myproc
  3. 在第8行打上断点:break 8
  4. 运行:run
  5. 程序停在断点处,此时另开一个终端,用ps查看进程状态:
    · gdb进程是父进程,状态正常。
    · 被调试的子进程状态显示为t(小写t)。
  6. 在gdb中执行next单步,进程状态会变化。

小t状态也是暂停,但它是被调试器通过ptrace系统调用控制的。本质上与大T没有太大区别。


D状态(深度睡眠):不可中断睡眠

6.1 银行数据丢失的故事

这是理解D状态最关键的一个故事。

假设有一个进程,它要向磁盘写入1GB的重要数据(比如银行一天的转账记录)。进程把数据交给磁盘驱动,磁盘说:"行,我去写,你等着,无论成功还是失败,我都会给你一个结果。"于是进程进入S状态(可中断睡眠),等待磁盘完成。

此时操作系统内存严重不足,它需要杀掉一些进程来释放内存。操作系统看到这个进程在S状态,心想"这家伙可以被中断",就把它杀掉了。

过了一会儿,磁盘写完了(或者因为磁盘空间不足写失败),回来找进程:"喂,你的数据我写完了/写失败了,结果在这儿呢!"------但进程已经没了。磁盘手里的结果无处可送,数据处于不确定状态。如果是银行交易记录,就可能造成几千万的损失。

银行行长震怒,找来操作系统、进程、磁盘三方对质:

· 进程说:"我是受害者,我老老实实在等结果,是你操作系统杀了我。"

· 操作系统说:"我内存不足,杀进程是我的职责,你不能怪我。"

· 磁盘说:"我是硬件牛马,让我干啥就干啥,写失败我通知了,但进程没了,怪我咯?"

行长无奈,找程序员解决。程序员于是增加了D状态(Disk Sleep),深度睡眠也叫不可中断睡眠。也属于一种阻塞态

D状态的规则

当一个进程进行同步磁盘I/O时,它会进入D状态。处于D状态的进程:

· 不能被任何信号中断(包括kill -9)。

· 操作系统无权杀死它。

· 只能等磁盘I/O完成后自行唤醒。

这是为了保护重要数据不丢失。如果进程长期处于D状态(比如连续多次ps都能看到),说明磁盘性能极差或即将损坏。

如何模拟D状态?

使用dd命令进行大量磁盘写操作。

bash 复制代码
dd if=/dev/zero of=./log.txt bs=1M count=1000

dev/zero 字符类型文件,允许用户读,但是读出来的是大量随机值

dev/null 字符设备文件,往文件里写的内容被系统自动丢弃

· if=/dev/zero:从无限零设备读取数据。

· of=./largefile:写入当前目录下的largefile文件。

· bs=1M:每次读写1MB。

· count=1000:写1000次,共约1GB。

在dd执行过程中,另开终端用ps aux | grep dd查看,有一定概率看到D状态(尤其是当磁盘较慢或负载较高时)。D状态通常是瞬时的,很难捕捉,但理论上存在。

警告:不要直接向/dev/sda等块设备写入,否则会破坏分区表。

如果你的系统中一个进程持续几分钟显示D状态,那么基本上可以断定磁盘快挂了。


Z状态(僵尸)与X状态(死亡)

7.1 为什么要僵尸?

为什么需要僵尸状态?一个故事

很多概念如果直接讲会很抽象,我们先讲一个生活中的故事。

想象你早上在公园晨跑,突然一位大爷在你面前倒下,你走近一看,他已经没有呼吸了。这时你会怎么做?作为有责任感的青年,你立刻打了110和120。

警察和医生很快赶到。医生确认大爷已经死亡。警察没有立刻通知家属,而是先拉起警戒线,叫来法医。法医仔细检查大爷的血液、毛发、皮肤,采集各种信息。为什么要这么做?因为警察必须搞清楚:大爷是自然死亡、意外死亡,还是被人谋杀?只有得出明确结论,排除他杀可能后,警察才会通知家属来料理后事。

换句话说,一个人死亡后,尸体不能马上处理掉。必须先把死亡原因调查清楚,给社会一个交代。这个调查过程,就是保留尸体一段时间,从中提取关键信息。

类比到进程:退出信息保存在PCB中

同样道理,你创建一个进程,是让它帮你完成某项任务。当这个进程退出(死亡)时,作为它的"父亲"------父进程,需要知道:任务完成了吗?正常结束还是出错了?错误码是多少?

这些信息必须被记录下来,保存在一个地方。这个地方就是进程的 PCB(进程控制块) ,也就是内核中的 task_struct 结构体。

当一个进程执行完毕、主动退出时,它的退出信息会被写入自己的PCB中。父进程如果想了解子进程的死因,只需要读取子进程PCB里的退出信息就行了。

return 值与退出信息

大家写C语言程序时,main 函数最后总会写 return 0。这个 0 就是退出码。你也可以写 return 1return 2 等,都是允许的。

这个 return 的值,最终就会被内核写入进程的退出信息中。通常,0 表示"正常完成任务",非零表示"出错了"或"异常退出"。

所以,退出信息就是通过 return 语句传递并保存在PCB里的

当一个进程退出时,它的代码和数据可以被操作系统立即释放。但是,它的PCB中保存着退出码(exit code)、资源使用统计等信息。这些信息需要父进程来读取------父进程需要知道子进程是正常结束还是出错了,以及返回值是多少。

因此,进程退出后不能立即彻底消失,它的PCB必须保留一段时间,等待父进程通过wait或waitpid系统调用来读取退出信息。在这个等待期间,进程处于Z状态(僵尸)。

一旦父进程读取完毕,进程就从Z状态转为X状态(死亡),随后PCB被释放。X状态是瞬时的,我们通常观察不到。

僵尸状态的定义

现在回到状态话题。

一个进程已经死亡(代码和数据都可以释放了),但它的"尸体"------也就是PCB------还不能被清理,因为父进程可能还没有来读取死亡原因。这个暂时保留PCB、等待父进程前来"验尸"的状态,就叫做 僵尸状态(Z状态)

具体来说:

  • 进程退出了,操作系统会立即释放它的代码和数据(这些不再需要)。
  • 但PCB不能释放,因为里面存着退出码。
  • 这个PCB会继续存在,直到父进程通过 waitwaitpid 系统调用把它取走。
  • 在父进程读取之前,这个进程就处于 Z(Zombie) 状态。
  • 父进程读取之后,进程变为 X(死亡) 状态,PCB被彻底回收。X状态是瞬时的,我们几乎看不到。

重新认识内存泄漏

很多同学以为:用 malloc 申请了内存,如果不 free,进程退出后内存还占着。这是一个误区

事实是:当进程退出时,操作系统会回收该进程的所有内存资源,包括堆空间(malloc申请的内存)。 所以,一个跑完就退出的程序,即使忘记 free,也不会造成长期的内存泄漏。

但是,如果进程是一个永不退出的常驻程序 (比如网易云音乐、浏览器、手机APP),它内部如果不断 malloc 而不 free,内存就会一直涨,这才是真正的内存泄漏。

我们日常使用的绝大多数软件都是死循环(常驻进程)。你打开一个APP不关闭,它就一直运行。所以,对于常驻服务,内存泄漏是致命的;对于一次性任务,影响不大。

僵尸进程的危害

如果父进程一直不读取子进程的退出信息,子进程就会永远停留在Z状态。虽然只占用一个很小的PCB结构体,但成千上万个僵尸就会消耗大量内存,导致内存泄漏。

演示僵尸进程

编写代码zombie.c:

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

int main() 
{
    pid_t pid = fork();
    if (pid == 0) 
    {
        // 子进程:打印5次后退出
        int cnt = 5;
        while (cnt--) 
        {
            printf("我是子进程,我正在运行:%d,ppid: %d\n",getpid(),getppid());
            sleep(1);
        }
        printf("我是子进程,我退出了:%d,ppid: %d\n",getpid(),getppid());
    } 
    else if (pid > 0) 
    {
        // 父进程:什么都不做,一直睡,不回收子进程
        while (1) 
        {
            sleep(1);
        }
    }
    return 0;
}

编译并运行:

bash 复制代码
gcc zombie.c 
./a.out

在另一个终端执行 ps aux | grep zombie,你会看到:

前5秒,父子进程都在,状态一般是 S(睡眠)。

5秒后,子进程退出,状态变为 Z(僵尸),后面带有 [defunct] 字样。

父进程还在运行,一直不调用 wait,所以子进程会一直保持 Z 状态。

杀掉父进程后,子进程的僵尸会被1号进程回收,然后消失。


孤儿进程

定义与现象

如果父进程先于子进程退出,那么子进程就变成了孤儿进程。操作系统会自动将孤儿进程的父进程改为1号进程(通常是systemd或init)。1号进程会定期wait,回收孤儿进程的退出信息。

演示孤儿进程

修改上面的代码:让父进程先退出,子进程无限运行。

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

int main() {
    pid_t pid = fork();
    if (pid == 0) {
        // 子进程:永远运行
        while (1) {
            printf("我是子进程,我正在运行:%d,ppid: %d\n",getpid(),getppid());
            sleep(2);
        }
    } else {
        // 父进程:5秒后退出
        sleep(5);
        printf("Parent exit\n");
    }
    return 0;
}

运行后,前5秒子进程的父进程是原父进程。5秒后父进程退出,子进程的父进程变成1号被领养(系统)(或别的init进程)。此时用ps查看,子进程状态没有+号,因为它变成了后台孤儿。

退出信息被系统回收,避免内存泄漏

孤儿进程的特征

当一个进程变成孤儿(父进程先退出),会有两个明显的特征:

  1. 状态变为无前台标志

    使用 ps 查看时,孤儿进程的状态只剩下 S(或 R 等),后面没有加号 +。这说明它已经变成了后台进程。

  2. 无法用 Ctrl+C 杀掉

    因为孤儿进程运行在后台,Ctrl+C 只能影响前台进程。要杀掉它,必须用 kill -9 [PID]

为什么要有孤儿进程?它有什么用?

僵尸进程的存在是有意义的------它保留了退出信息供父进程读取。那孤儿进程有什么实际价值呢?

答案是:守护进程(Daemon)就是利用孤儿进程的特性实现的

  • 守护进程是一种在后台长期运行、提供服务的进程(例如 Web 服务器、数据库服务)。
  • 实现方式:父进程先 fork() 一个子进程,然后父进程立即退出,让子进程变成孤儿,被 1 号进程(systemd/init)领养。
  • 这个孤儿进程就此脱离了原来的终端,可以在系统后台周而复始地运行 ,不受用户登录、注销或 Ctrl+C 的影响。
    孤儿进程是守护进程的基础。关于守护进程的详细内容,我们会在后面的网络编程部分深入讲解。
相关推荐
peihexian10 小时前
我也试试qemu虚拟化
linux·运维
阳光九叶草LXGZXJ10 小时前
达梦数据库-学习-57-读写数据页超时告警排查(page[x,x,xxxxxx] disk write uses)-DSC集群版
linux·运维·服务器·数据库·sql·学习
lolo大魔王10 小时前
Linux监测磁盘空间
linux·运维·服务器
不仙52010 小时前
Rocky Linux 8.10 TigerVNC 安装配置指南
linux·服务器·网络
浮生若城10 小时前
Linux基础I/O(1)
linux·运维·服务器
阳光九叶草LXGZXJ11 小时前
达梦数据库-堆栈看问题-01-asmapi_asm_extent_load
linux·运维·数据库·sql·学习
Ujimatsu11 小时前
虚拟机安装openSUSE 16.0及其常用软件(2026.5)
linux·运维·服务器
minji...11 小时前
Linux 网络基础之网络IP层(十)IP 协议,网段划分,IP地址相关问题
linux·运维·服务器·网络·tcp/ip·智能路由器·php
枳实-叶11 小时前
【Linux驱动开发】第10天:设备树零基础入门——DTS/DTB/DTC全解+编译流程
linux·运维·驱动开发