

友情专栏:【把Linux“聊”明白】
有了上篇文章的基础上,我们在学习进程之前,要知道,操作系统是怎么管理进行进程管理的呢?很简单,先把进程描述起来,再把进程组织起来! 通过将每个进程的属性信息封装成数据结构,并以链表等组织形式进行管理,操作系统实现了对进程的创建、调度、终止等全生命周期管理。
先来看课本与内核对于进程的解释:
课本概念:程序的一个执行实例,正在执行的程序等; 内核观点:担当分配系统资源(CPU时间,内存)的实体。
听起来都太抽象,在这里,我们可以理解为进程 = 内核数据结构对象 + 自己的代码和数据,如下图所示:

可以看到,每个进程都对应着其内核数据结构对象和自己的代码和数据,我们可以把其内核数据结构用类似链表的结构连接起来,那么对于进程的管理,就变成对链表的增删查改了。
内核数据结构我们又称为PCB (Process Control Block),即进程控制块。可以理解为进程属性的集合。简单说,它就是描述进程的结构体。
我们通过PCB,就可以直接或者间接的找到进程的所有属性。
在Linux中,具体的PCB是task_struct,PCB是课本的说法。
task_struct是Linux内核的⼀种数据结构,它会被装载到RAM(内存)里并且包含着进程的信息。
既然task_struct是描述进程的结构体,那么它里面详细有什么呢?简单看一下:
标示符:描述本进程的唯一标示符,用来区别其他进程。 状态:任务状态,退出代码,退出信号等。 优先级:相对于其他进程的优先级。 程序计数器:程序中即将被执行的下⼀条指令的地址。 内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。 上下文数据:进程执行时处理器的寄存器中的数据。 I∕O状态信息:包括显示的I/O请求,分配给进程的I∕O设备和被进程使用的⽂文件列表。 记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。 其他信息……
在这里,我们要知道,我们历史上执行的所有指令(内建命令除外)、工具、自己的程序,运行起来,都是进程! 很好理解,现在我们来了解一下如何查看进程呢?
/proc系统文件夹查看如:要获取PID为1的进程信息,你需要查看 /proc/1 这个⽂件夹。

#include <stdio.h>
#include <unistd.h>
int main()
{
while (1)
{
printf("hello world\n");
sleep(1);
}
return 0;
}
ps ajx | head -1 && ps ajx | grep myproc | grep -v grepps ajx:使用特定的格式选项显示进程信息
其它命令组合,就是帮助我们既能清晰看到表头信息,又能准确找到目标进程,排除干扰项所用。
补充:


pid与ppid:
进程id(PID) 父进程id(PPID)
使用一下:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("pid:%d\n",getpid());
printf("ppid:%d\n",getppid());
return 0;
}输出:

我们可以对ppid进行搜索

哦,bash?
说明:bash(命令行解释器)也是一个进程。
我们要知道,OS会为每一个登录的用户,分配一个bash。
补充: exe和cwd
它们是进程对应的两个属性,我们可以在 /proc 下查看进程对应的属性:

执行命令后可以看到:

解释:
exe :指向启动该进程的可执行文件的完整路径。它告诉你这个进程是由哪个程序文件创建的。 cwd :Current Working Directory 的缩写,代表进程的当前工作目录。像我们在C语言中的fopen函数,以写的形式打开文件…,如果我们没有指定路径的话,就会默认在当前路径,即根据cwd来确定路径。
我们是通过fork函数来进行创建子进程的,我们可以来查看一下man手册:


fork有两个返回值,我们可以这样理解,父子进程代码共享,数据各自开辟空间,私有一份(采用写时拷贝),现阶段只能这样理解。
使用:
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t ret = fork();
if(ret == -1)
{
perror("fork");
return 1;
}
else if(ret == 0)
{
printf("我是子进程,我的pid是:%d\n",getpid());
}
else if(ret > 0)
{
printf("我是父进程,我的pid是:%d\n",getpid());
}
return 0;
}输出:

啊,两个判断条件都成立嘛,和我们以前的说法不一样呀,一组if else只能同时成立一个呀!!!
不着急,分析:

上述的说法不是很标准,但是对于我们现阶段理解是足够了,因为我们现在什么都不懂,讲太深直接绕进去了。
所以,在fork之后,父子未来执行不同的代码逻辑。
有了刚才的说明,下面,我们再来对会出现的疑问进行解释:

看代码:
#include <stdio.h>
#include <unistd.h>
int main()
{
int cnt = 100;
pid_t ret = fork();
if(ret == -1)
{
perror("fork");
return 1;
}
else if(ret == 0)
{
cnt+=100;
printf("我是子进程,我的pid是:%d,cnt的值为%d\n",getpid(),cnt);
}
else if(ret > 0)
{
printf("我是父进程,我的pid是:%d,cnt的值为%d\n",getpid(),cnt);
}
return 0;
}输出:

可以看出,进程具有独立性。 现阶段可以这样理解:把父子任何一方,进行数据修改,OS会把被修改的数据在底层拷贝一份,让目标进程修改这个拷贝。(写时拷贝)
有了进程的基本概念,我们来看一下进程的状态吧。
为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在Linux内核里,进程有时候也叫做任务)。
首先,我们要知道,一个CPU(单核),一个调度队列(运行队列),如下图:

也可以说,只要挂载到调度队列,即处于运行状态。
对于阻塞,我们来想一个在C语言中写的scanf函数,它在等待用户输入的时候,进程就处于阻塞。此时,该进程就会被从运行队列中删除,从而挂载到等待队列(wait_queue)中,等待用户输入。阻塞就是等待某种设备或资源就绪。
但是,在有些时候,内存的空间严重不足,我这个进程又处于阻塞状态,那我们可不可以把此进程对应的代码和数据放到磁盘上呢(唤入),在需要的时候又加载进内存呢(唤出),这就叫做阻塞挂起,此时,进程的task_struct又会被挂载到类似于阻塞挂起的队列。
都挺合理,但是,一个结构体(task_struct)怎么会链接在多个队列中呢? 其实,这种链接形式并不是我们在链表中学的那种,在结构体中直接定义一个结构体指针,而是存在一个struct list_head结构体:
struct list_head
{
struct list_head *next, *prev;
}在进程的task_struct中存在多个struct list_head,从而实现一个进程挂载在多个队列中。类似于:

要通过struct task_struct 中的 struct list_head找struct task_struct,也很简单,利用相关结构体的知识来计算。不做说明了。
可见,进程状态的变化,表现之一,就是进程在不同的队列中流动,本质就是链表的增删查改。
在kernel源代码里定义的进程状态:
/*
*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):并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。 S睡眠状态(sleeping):意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))。浅睡眠。 D磁盘休眠状态(Disksleep):有时候也叫不可中断睡眠状态(uninterruptiblesleep),在这个状态的进程通常会等待IO的结束。深度睡眠。 T停止状态(stopped):可以通过发送SIGSTOP信号给进程来停止(T)进程。这个被暂停的进程可以通过发送SIGCONT信号让进程继续运行。 X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。 Z 僵尸状态 (Zombie):进程已经执行完毕,但其退出状态还没有被父进程读取。(后面会重点说)

命令:ps aux / ps ajx
解释:
a:显示一个终端所有的进程,包括其他用户的进程。 x:显示没有控制终端的进程,例如后台运行的守护进程。 j:显示进程归属的进程组ID、会话ID、父进程ID,以及与作业控制相关的信息。 u:以用户为中心的格式显示进程信息,提供进程的详细信息,如用户、CPU和内存使用情况等。
我们创建子进程的目的,就是为了让子进程完成某种事情的。那子进程完成了吗?对应结果相关的信息,父进程得知道吧。这些信息在哪里呢?子进程对应的task_struct中。 所以,在子进程执行完相关的代码后,子进程对应的代码和数据会被清理,但是,子进程对应的task_struct不会被清理,而是将子进程的状态设置为Z。然后,等待被父进程接受相关退出信息。
所以,
僵死状态(Zombies)是⼀个比较特殊的状态。当子进程退出并且父进程(使用wait()系统调用,后续说)没有读取到子进程退出的返回代码时就会产生僵死(尸)进程 僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。 所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态。
现在,我们来模拟一个僵尸进程。
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t ret = fork();
if(ret == -1)
{
perror("fork");
return 1;
}
else if(ret == 0)
{
//child
int cnt = 5;
while(cnt--)
{
printf("我是子进程,我的pid是:%d,父进程pid是%d\n",getpid(),getppid());
sleep(1);
}
}
else if(ret > 0)
{
while(1)
{
printf("我是父进程,我的pid是:%d\n",getpid());
sleep(1);
}
}
return 0;
}编译执行


此时的defunct指的就是:进程实体已死亡,但残骸(task_struct)还在。
如果父进程一直不接受呢?——僵尸进程的危害
明确几点:
至于如何避免?到进程等待时会说明。
僵尸进程指的是子进程想退出发生的情况,那如果父进程先退出呢? 父进程如果提前退出,那么子进程后退出,进入Z之后,那该如何处理呢? 父进程先退出,子进程就称之为“孤儿进程” 孤儿进程被1号init进程领养,当然要有init进程回收喽。
模拟孤儿进程
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t ret = fork();
if(ret == -1)
{
perror("fork");
return 1;
}
else if(ret == 0)
{
//child
while(1)
{
printf("我是子进程,我的pid是:%d,父进程pid是%d\n",getpid(),getppid());
sleep(1);
}
}
else if(ret > 0)
{
int cnt = 5;
while(cnt--)
{
printf("我是父进程,我的pid是:%d\n",getpid());
sleep(1);
}
}
return 0;
}

我们可以用信号来杀。

理解进程状态转换和生命周期管理,是掌握操作系统工作原理的关键基础。后续我们将继续深入进程通信、进程调度等高级话题。