Linux:进程的认识
前言:对于操作系统,先建立一种思想,凡是遇到一个新事物前要先描述,再组织,有了这样的思想也可以用于学习进程的概念。
进程的定义和基本概念
进程的感性理解
Process:在 Linux 中,每个执行的程序都称为一个进程。每一个进程都分配一个 ID 号(PID,进程号)。与 Windows 下的任务管理器中的进程意思相同。
在官方的定义中,进程通常被解释为“运行起来的程序”或“在内存中的程序”。这种定义虽然简单,但严格来说并不容易理解,因为进程和程序显然是不同的概念。程序是静态的,它是一组指令和数据的集合,存储在磁盘中;而进程是动态的,它是程序在内存中的执行实例,拥有独立的内存空间和系统资源。仅仅通过“运行起来的程序”这样的定义,我们无法真正理解进程的本质。因此,我们需要从其他角度来深入理解进程。
首先,我们需要思考一个问题:当程序运行时,它是从哪里开始的?程序的运行起点取决于它的存储位置。程序本质上是一个文件,而文件通常存储在磁盘中。因此,程序要运行起来,就必须从磁盘加载到内存中。这个过程被称为“加载”。加载完成后,程序就进入了内存,但此时内存中只是一个个独立的程序,如何对它们进行有效的管理呢?这就引出了另一个关键概念——操作系统。
操作系统是一个纯粹的管理类软件,同时也是计算机启动时第一个运行的软件。无论何时将磁盘中的程序加载到内存中,内存中早已有一个软件在运行,这个软件就是操作系统。因此,操作系统的一个重要功能就是管理这些从磁盘加载到内存中的程序。当这些程序进入内存后,它们就不再是静态的文件,而是变成了动态的进程。那么,操作系统如何管理这些进程呢?这就需要遵循“先描述,再组织”的原则。
具体来说,操作系统会为每个进程创建一个数据结构,称为进程控制块(PCB),用来描述进程的各种属性和状态。PCB中包含了进程的标识符、状态、优先级、程序计数器、寄存器内容、内存分配信息、打开的文件列表等。通过PCB,操作系统可以全面掌握每个进程的状态和资源使用情况。这就是“描述”的过程。
在“描述”的基础上,操作系统会将这些进程的PCB组织起来,形成一个进程表或进程队列。通过这种方式,操作系统可以高效地进行进程调度、资源分配和状态转换等操作。这就是“组织”的过程。
通过这种方式,操作系统能够有效地管理内存中的多个进程,确保它们能够高效、安全地并发执行。这种管理机制不仅提高了系统的利用率,还增强了系统的响应速度和稳定性。
总结来说,进程不仅仅是“运行起来的程序”或“在内存中的程序”,它是程序在内存中的动态执行实例,拥有独立的内存空间和系统资源。操作系统通过“先描述,再组织”的方式,对进程进行全面的管理,从而确保系统的正常运行和高效性能。
如何具体描述进程?
在日常生活中,如果我们想要管理人,首先需要对人进行描述。例如,我们可以定义一个结构体,里面存储人的各种属性,比如学号、姓名、住址、手机号等。有了这些信息,我们就能对人的数据进行管理,进而对人进行组织和实质性的管理。
同样的道理,对于进程来说,操作系统也需要先对其进行描述,然后才能进行管理。那么,如何描述一个进程呢?进程有许多属性需要描述,比如进程的ID、进程的代码地址、进程的数据地址、进程的状态、进程的优先级、进程的链接字段等。通过这些属性,我们可以像管理人的数据一样管理进程的数据。
例如,进程的ID(PID)是唯一标识一个进程的编号;进程的代码地址和数据地址指示了进程在内存中的存储位置;进程的状态(如运行、就绪、阻塞等)反映了进程当前的活动情况;进程的优先级决定了它在调度时的顺序;而进程的链接字段则可以帮助我们将进程像链表一样组织起来,从而快速找到某个进程。这种链接字段的实现通常依赖于指针的概念,通过指针将多个进程的PCB(进程控制块)串联起来,形成一个进程表或队列。
所有这些属性共同组成了进程的描述信息,而这些信息被存储在一个结构体中。这个结构体有一个官方的名字,叫做进程控制块(PCB,Process Control Block)。PCB是操作系统管理进程的核心数据结构,它包含了进程的所有关键信息。
通过PCB,操作系统可以像管理人一样对进程进行管理。例如,操作系统可以通过PCB中的状态信息决定是否调度某个进程运行,通过优先级信息决定进程的执行顺序,通过链接字段快速定位某个进程。这种“先描述,再组织”的方式,使得操作系统能够高效地管理内存中的多个进程,确保它们能够并发执行,同时避免资源冲突。
总结来说,进程的描述是通过【进程控制块(PCB)】实现的。PCB中存储了进程的各种属性,如ID、代码地址、数据地址、状态、优先级和链接字段等。通过这些信息,操作系统可以像管理人一样对进程进行组织和管理,从而将操作系统的功能与进程的运行紧密结合在一起。理解PCB的作用,对于我们掌握进程管理的原理至关重要。
进程的实质理解
进程=可执行程序+内核数据结构(PCB)
由于我们的研究对象都是在Linux环境下进行研究,那么重点是讲述在Linux下的进程概念,在Linux操作系统下,进程的PCB被叫做是task_struct,也就是说,在Linux下描述进程的结构体不叫PCB,叫做task_struct,这是Linux内核中的一种数据结构,它会被装载到内存中,并且当中会包含着进程的信息,用下图来简答说明这段概念:
这张图片清晰地展示了磁盘上的可执行程序被加载到内存中的过程。内存中早已加载好了操作系统,等待管理这些程序。当程序进入内存后,操作系统会为每个进程创建一个独特的
task_struct
,这个结构体用来描述该进程的各种信息。每个进程都有自己的
task_struct
,操作系统通过这些结构体的数据来管理进程。在内部,操作系统通常使用双向链表的形式来组织这些进程。当有进程被终止时,操作系统会将其从链表中删除;当有新进程进入时,操作系统会将其插入链表中。这种方式不仅方便管理,还能提高效率。因此,进程可以简单理解为:可执行程序加上内核的数据结构。通过这种方式,操作系统能够高效地管理和调度多个进程,确保它们正常运行。
【进程的管理被建模成了数据结构】
数据结构是一种抽象的概念,它的实际意义在于帮助我们更方便地管理数据。以图中展示的进程为例,操作系统作为管理类软件,需要通过数据结构来管理进程。通过使用双向链表这种数据结构,操作系统可以将进程的管理简化为对链表的增删查改操作。有了这种思维方式,管理和处理数据就变得简单了许多。
进程的PCB可以存在于多个链表中,而不仅限于图中的一条链表。本质上,链表中的节点只是数据的一部分,这些数据可以同时存在于多条链表中。
进程的管理并不局限于链表。虽然图中展示了用链表管理进程的方式,但实际上,进程还可以通过队列等其他数据结构进行建模和管理。数据结构的选择取决于具体的需求和场景。
【小结】
我们对进程的理解已经有了初步的框架。然而,这些知识还停留在理论层面,真正的实践操作将帮助我们更深入地理解进程的概念和运行机制。
查看系统进程
命令:ps ajx
- a:所有
- j:任务
- x:把所有的信息全部输出
一般搭配管道使用,如:ps ajx | head - l && ps ajx | grep test,其中 ps ajx | head - l 是把 ps ajx 输出的信息中的第一行信息(属性列)输出。
[root@iZbp1157ft1ib0ydj8jqtzZ ~]# ps ajx | head -1 && ps ajx | grep test
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
5274 5487 5486 5274 pts/0 5486 S+ 0 0:00 grep --color=auto test
在Linux
中,如果想要查看进程,可以在/proc系统文件夹中查看
也可以使用ps和top来获取进程信息
实践进程
通过代码实践来验证:
//创建test.c文件
#include <stdio.h>
#include <unistd.h>
int main()
{
while(1)
{
printf("正在打印进程\n");
sleep(1);
}
return 0;
}
[root@iZbp1157ft1ib0ydj8jqtzZ Linux操作]# ps aux| grep test
root 6563 0.0 0.0 4216 356 pts/0 S+ 10:48 0:00 ./test
root 6890 0.0 0.0 112812 980 pts/2 R+ 10:50 0:00 grep --color=auto test
从中看出,test进程是确实存在的,并且其中还包含一个
grep --color=auto test
进程,因为调用grep
管道指令也算一个进程 。
系统调用
通过系统调用获取进程标示符
- 进程 ID(PID)
- 父进程 ID(PPID)
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
while(1)
{
printf("I am a process, my pid is:%u\n", getpid()); //返回正在调用进程的进程ID
printf("I am a process, my ppid is:%u\n", getppid()); //返回正在调用进程的父进程ID
sleep(1);
}
return 0;
}
I am a process, my pid is:7520
I am a process, my ppid is:7191
[root@iZbp1157ft1ib0ydj8jqtzZ 222]# ps ajx | head -1 && ps ajx | grep 7191
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
5225 7191 7191 7191 pts/3 7520 Ss 0 0:00 /bin/bash --init-......
7191 7520 7520 7191 pts/3 7520 S+ 0 0:00 ./test
8438 8631 8630 8438 pts/0 8630 R+ 0 0:00 grep --color=auto 7191
./test的父进程是命令行解释器--bash.
Linux
中创建进程的方式通常有两种:
- 命令行中直接启动进程
- 通过代码来创建进程
对于第一种很好理解,执行程序
./process
其实就是启动进程,那第二种是什么?如何理解这个概念?就引出了本篇的核心,系统调用fork.
创建进程fork
首先,我们需要对
fork
有一个初步认知。fork
是一个系统级别的调用,它可以为当前进程创建一个子进程。通过代码调用fork
,我们可以在系统中动态地创建新的进程。当用户启动一个进程时,实际上是在系统中新增了一个进程。这意味着操作系统需要管理的进程数量增加了一个。创建一个进程的过程包括向系统申请内存,保存该进程的可执行程序,并创建该进程的 PCB(即
task_struct
对象)。最后,操作系统会将这个task_struct
对象添加到进程列表中,以便进行管理和调度。简单来说,创建进程就是向系统申请资源、保存程序数据、创建 PCB,并将其纳入操作系统的管理范围。
#include <unistd.h>
// pid_t是无符号整数
pid_t fork(void); // fork函数功能:创建一个子进程
运行 man fork 认识 fork。
通过复制调用进程创建一个新进程。
fork 有两个返回值。
父子进程代码共享,数据各自私有一份(采用写时拷贝)。
fork 之后,如果不做任何的分流,fork 下面的所有代码是被父子进程共享的。
#include <stdio.h>
#include <sys/types.h> // getpid, getppid
#include <unistd.h> // getpid, getppid, fork, sleep
int main()
{
printf("I am a father: %u\n", getpid());
fork();
while(1)
{
printf("I am a process, pid: %u, ppid: %u\n", getpid(), getppid());
sleep(1);
}
return 0;
}
fork的理解
站在程序员的角度:
fork
是操作系统中用于创建新进程的系统调用。当调用fork
时,操作系统会创建一个与父进程几乎完全相同的子进程。父子进程共享用户代码(因为代码是只读的,不可修改),但用户数据是各自私有的。为了确保进程之间的独立性,操作系统采用了【写时拷贝(Copy-On-Write, COW)】技术。这意味着只有当某个进程尝试修改数据时,操作系统才会为该进程创建一份独立的数据副本,从而避免进程之间的相互干扰。举个例子,打开 Windows 的任务管理器,可以看到许多进程在运行。如果关闭微信进程,QQ 进程不会受到影响。这是因为操作系统中所有进程都是相互独立的,进程具有独立性。为了确保进程之间不会互相干扰,操作系统通过
fork
创建子进程后,父子进程会继续运行,但谁先运行是由系统的调度优先级决定的,顺序并不固定。总结来说,
fork
创建的子进程与父进程共享代码但拥有独立的数据空间,通过写时拷贝技术确保进程独立性。父子进程的运行顺序由系统调度决定,进程之间互不干扰。
站在操作系统的角度:
fork
之后,从操作系统的角度来看,系统中确实多了一个新的进程。fork
创建的子进程通常以父进程为模板,子进程默认会使用父进程的代码和数据,但通过【写时拷贝(Copy-On-Write, COW)】技术,确保父子进程的数据在修改时是独立的。由于系统中多了一个进程,操作系统会为子进程创建一个新的 PCB(进程控制块),并将父进程 PCB 中的部分内容(如代码段指针、寄存器状态等)拷贝到子进程的 PCB 中。这样,子进程就有了自己的独立描述信息,同时继承了父进程的运行环境。
总结来说,
fork
创建子进程后,操作系统会为其分配新的 PCB,并通过写时拷贝技术确保父子进程的数据独立性。子进程继承了父进程的运行环境,但拥有独立的进程描述信息。
fork的用法
fork
的常规用法是通过创建子进程来实现任务的并行执行。通常,我们使用fork
后,会利用if
语句进行分流,让父进程和子进程执行不同的代码,从而实现并行效果。例如,父进程可以播放音乐,而子进程可以下载文件。
fork
的返回值用于区分父子进程:
如果
fork
执行成功,父进程会返回子进程的 PID(进程 ID),而子进程会返回 0。如果
fork
执行失败,父进程会返回 -1,并且不会创建子进程,同时会设置适当的errno
来指示错误原因。通过这种方式,我们可以利用
fork
的返回值来控制父子进程的执行路径,从而实现并行任务的处理。
#include <stdio.h>
#include <sys/types.h> // getpid, getppid
#include <unistd.h> // getpid, getppid, fork
int main()
{
printf("I'm a father: %u\n", getpid());
pid_t ret = fork();
if (ret == 0)
{
// child process
while (1)
{
printf("child process, pid:%u, ppid:%u\n", getpid(), getppid());
sleep(1);
}
}
else if (ret > 0)
{
// father process
while (1)
{
printf("father process, pid:%u, ppid:%u\n", getpid(), getppid());
sleep(1);
}
}
else
{
// failure
perror("fork");
return 1;
}
return 0;
}
站在语言的角度,是不可能同时进入两个执行流的,既进入 if 也进入 else if 的,即不可能同时执行两个死循环。
但实际的运行结果:
理解fork的返回值
fork
函数有两个返回值的原因在于它的特殊行为:它会在调用进程中创建一个新的子进程。调用fork
后,操作系统会将当前进程(父进程)复制一份,生成一个新的进程(子进程)。由于父子进程是独立的,它们会从fork
调用处继续执行,但返回值不同:
父进程:
fork
返回子进程的 PID(进程 ID)。子进程:
fork
返回 0。如果
fork
失败,父进程会返回 -1,表示没有创建子进程。这种设计是为了让父进程和子进程能够区分自己的身份,从而执行不同的逻辑。
调用一个函数时,这个函数准备 return 了,请问这个函数的功能执行完成了吗?
当一个函数准备
return
时,通常意味着它的主要功能已经执行完成。return
的作用是结束函数的执行,并将控制权交还给调用者,同时可以返回一个值(如果有返回值的话)。不过,
fork
是一个特殊情况。fork
的return
并不是简单的结束函数,而是标志着父子进程的分叉点。在fork
返回时,父进程和子进程都会从fork
调用处继续执行,但返回值不同。
+-------------------+
| 调用 fork() |
| 父进程继续执行 |
+-------------------+
|
v
+-------------------+ +-------------------+
| 操作系统复制进程 | ----> | 创建子进程 |
| 生成子进程 | | 子进程从 fork 处继续 |
+-------------------+ +-------------------+
| |
v v
+-------------------+ +-------------------+
| 父进程返回子进程 PID| | 子进程返回 0 |
| (pid > 0) | | (pid == 0) |
+-------------------+ +-------------------+
| |
v v
+-------------------+ +-------------------+
| 父进程执行后续代码 | | 子进程执行后续代码 |
+-------------------+ +-------------------+
关键点:
调用
fork
:父进程调用fork
,操作系统开始复制父进程。创建子进程:操作系统生成子进程,子进程是父进程的副本。
返回不同值:
父进程返回子进程的 PID。
子进程返回 0。
分流执行:父子进程从
fork
调用处继续执行,但通过返回值区分身份,执行不同的代码。
如果 fork 执行成功,为什么在父进程中返回子进程的 pid,在子进程中返回的是 0 呢?
在人类世界中,每个孩子只有一个亲生父亲,而一个父亲可以有多个孩子。因此,孩子找父亲是非常简单的,因为父亲是唯一的;而父亲为了更方便地管理多个孩子,需要给每个孩子一个独特的标识,并记住他们(比如:张三、李四、王五等)。
类似地,在操作系统中,
fork
创建子进程后:
父进程需要返回子进程的 PID,因为父进程需要知道它创建的子进程是谁,以便管理和跟踪。
子进程只需要知道自己被成功创建,因此在子进程中返回 0 即可。
这种设计使得父进程能够有效地管理多个子进程,而子进程只需关注自己的任务执行。
如果创建多个子进程呢?
通过循环创建,下面这段代码并不完善,只是为了简单理解如果创建多个子进程的情况:
#include <stdio.h>
#include <stdlib.h> // exit
#include <sys/types.h> // getpid, getppid
#include <unistd.h> // getpid, getppid, fork, sleep
int main()
{
// 创建5个子进程
for (int i = 0; i < 5; i++)
{
pid_t ret = fork();
if (ret == 0)
{
// child process
printf("child%d, pid:%u, ppid:%u\n", i, getpid(), getppid());
sleep(1);
exit(1); // 子进程退出
}
}
getchar(); // getchar()目的是不让父进程退出,否则无法回收子进程。
return 0;
}
运行结果:成功创建了 5 个子进程。但程序会一直卡在这里,不会自己退出。
为什么上述代码中,fork 的返回值 ret 有两个值,既等于 0 又大于 0 呢?fork 之后,父子进程如何做到共享用户代码,如何做到用户数据各自私有的呢?
这两个问题学习了进程地址空间就能够很好的理解了。