[操作系统] 进程的概念与基础操作详解
在现代操作系统中,**进程(Process)**是一个重要的核心概念。它是操作系统管理资源的基本单位,理解进程的概念以及如何操作它是学习操作系统的基础。本篇文章将深入讲解进程的基本概念、结构和一些典型操作。
什么是进程?
前文所提:应用程序从磁盘加载进内存,而操作系统的管理方法是描述 + 组织,所以通过该种管理方法形成的管理对象就是进程。
从用户的视角来看,进程是一个程序的运行实例;从操作系统的视角来看,进程是一个拥有资源分配能力的实体。
进程 = 内核数据结构对象 + 自己的代码和数据
在Linux中进程可以看做是PCB(task struct)和自己的代码和数据组成的。PCB中包含该进程的所有属性,与代码以及数据共同组成进程,PCB中存在指向其他进程的指针,通过指针的指向,进程通过双向链表的数据结构来进行链接,而进程的管理就是对链表的增删查改。并且每个进程都有独立的地址空间,以避免相互干扰。
我们所使用的指令、工具以及自己的程序,运行起来,都是进程!
进程控制块(PCB)
操作系统使用**进程控制块(Process Control Block, PCB)**来描述和管理进程的所有信息。PCB 是一个重要的数据结构,操作系统通过它来追踪每个进程的状态。
在 Linux 操作系统中,PCB 被实现为一个名为 task_struct
的结构体,其主要内容包括:
PCB 的主要内容分类
- 标识符:如进程 ID (PID),用于唯一标识进程。
- 状态:包括进程当前的运行状态(运行、就绪、阻塞等)。
- 优先级:用于调度时比较不同进程的重要性。(CPU计算的优先级)
- 程序计数器:存储下一条将要执行的指令地址。
- 内存指针:指向进程的代码段、数据段以及共享内存块。
- 上下文数据:包括处理器寄存器中的数据。
- I/O 状态信息:描述进程使用的文件和 I/O 设备。
- 记账信息:记录进程使用的资源总量和时间。
PCB 的组织结构
在 Linux 内核中,所有进程的 PCB 以链表形式组织。通过 task_struct
中的 next
和 prev
指针,形成一个双向链表,对进程进行遍历和管理。
如下图所示:
如何查看进程信息
在 Linux 系统中,可以通过 /proc
文件系统以及用户级工具来查看进程信息:
通过 /proc
文件夹
- 每个进程在
/proc
中都有一个对应的文件夹,文件夹名称是该进程的 PID。 - 数字进程目录是针对单个进程的详细信息存储,字母进程目录(或文件)是关于系统整体信息的汇总。
- 例如,要查看 PID 为 1 的进程信息,可以访问
/proc/1
。
通过命令行工具
ps
** 命令**:显示进程的详细信息。
bash
就是命令行解释器,每启动一个XShell就会有一个bash进程启动,所以输入的指令等信息都是通过父进程bash
处理的,所以当使用命令行启动多个进程后可以发现它们的父进程(PPID)都是bash
。
top
** 命令**:实时显示系统运行的进程和资源使用情况。
通过系统调用获取进程标识符
进程id(PID) :
**<font style="color:rgb(100,106,115);">getpid();</font>**
⽗进程id(PPID):
**<font style="color:rgb(100,106,115);">getppid();</font>**
在sys/types.h
包含获取当前进程ID的函数,比如使用getpid();
获取当前进程的PID:
进程的cwd
与exe
- 现在将进程启动。
- 通过指令查看进程是否存在。
grep
作为指令也是进程,所以显示的时候也会显示grep
的进程信息。
- 查看进程具体信息。
/proc/[PID]
目录下的cwd
和exe
是与进程相关的重要符号链接,它们分别代表了进程的当前工作目录和可执行文件路径。理解这两个概念对于深入掌握进程的行为和状态非常有帮助。
cwd
(Current Working Directory)
- 定义
cwd
是一个符号链接,指向进程的当前工作目录。当前工作目录是指进程在执行过程中,其相对路径的基准目录。就好比你在终端中切换到某个目录,然后运行一个程序,这个被切换到的目录就是程序的当前工作目录。- 例如,假设你在
/home/user/projects
目录下启动了一个名为my_app
的程序,那么/proc/[PID]/cwd
就会指向/home/user/projects
目录。 - 使用
chdir
可以改变cwd
的指向路径。
- 作用和用途
- 文件访问基准:当进程尝试打开一个相对路径的文件时,这个相对路径是相对于
cwd
来解析的。比如,如果my_app
程序尝试创建data.txt
文件,直接使用(./data.txt
)而没有指定绝对路径,那么系统会直接在/home/user/projects
下建立/home/user/projects/data.txt
(假设cwd
是/home/user/projects
)。 - 监控和调试:对于系统管理员和开发者来说,通过查看
cwd
可以了解进程是在哪个目录下运行的,这对于调试程序(特别是当程序试图访问文件时出现路径错误等问题)和监控进程行为非常有用。例如,如果一个进程试图访问一个不存在的文件并报错,查看cwd
可以帮助确定它试图访问文件的完整路径,从而更容易地找到问题所在。
- 文件访问基准:当进程尝试打开一个相对路径的文件时,这个相对路径是相对于
exe
(Executable)
- 定义
exe
是一个符号链接,指向启动该进程的可执行文件的路径。这个可执行文件是进程运行的主体,包含了程序的机器代码和资源。- 例如,如果你使用命令
/usr/bin/my_app
启动了一个程序,那么/proc/[PID]/exe
就会指向/usr/bin/my_app
。
- 作用和用途
- 程序识别:通过
exe
链接,你可以清楚地知道是哪个可执行文件启动了这个进程。这对于系统监控工具来说非常重要,因为它们可以根据可执行文件的路径来识别和分类进程。例如,在一个包含多个不同版本应用程序的系统中,通过exe
可以区分是哪个版本的应用程序正在运行。 - 安全和审计:在安全审计方面,
exe
可以帮助确定是否有未经授权的程序在运行。如果发现exe
指向一个不熟悉或可疑的路径,这可能是一个安全风险的信号。此外,它也可以用于追踪软件的使用情况,比如统计某个特定可执行文件被启动的次数等。 - 重新启动和分析:对于开发者来说,如果需要重新启动进程或对进程进行分析(如性能分析),知道
exe
的路径是非常有用的。可以直接通过这个路径来启动新的进程实例,或者使用调试工具(如gdb
)附加到这个可执行文件上进行分析。
- 程序识别:通过
实际应用示例
假设你正在运行一个名为example_app
的程序,你可以在终端中使用以下命令来查看其cwd
和exe
:
pid=$(pgrep example_app) # 获取example_app进程的PID
ls -l /proc/$pid/cwd # 查看cwd链接
ls -l /proc/$pid/exe # 查看exe链接
这将输出类似以下内容:
lrwxrwxrwx 1 user user 0 Jan 1 12:34 /proc/1234/cwd -> /home/user/projects
lrwxrwxrwx 1 user user 0 Jan 1 12:34 /proc/1234/exe -> /usr/local/bin/example_app
从这个输出中,你可以看到example_app
进程的当前工作目录是/home/user/projects
,而其可执行文件位于/usr/local/bin/example_app
。这些信息对于理解进程的行为和进行系统管理非常关键。
认识fork
以及进程的独立
进程的创建和管理是操作系统的重要功能。在 Linux 中,创建进程主要通过 fork()
系统调用。
通过man
查看fork()
:
- 返回值为
pid_t
类型- 包含在头文件
<unistd.h>
获取进程和父进程的标识符
可以通过以下代码获取进程的 PID 和其父进程的 PPID:
PID:
getpid();
PPID:
getppid();
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main() {
printf("pid: %d\n", getpid());
printf("ppid: %d\n", getppid());
return 0;
}
如何创建子进程以及父子进程关系的理解
fork()
是 Linux 中用于创建新进程的函数。
得到的运行结果如下:
可以看出,在fork();
执行后出现了两个进程,其中一个进程的pid是fork
前的进程的pid,一个是新进程的pid。此时就成功的创建了子进程。但是要如何使用fork()
呢?
首先从fork()
函数本身开始理解:
以下是一个代码示例。
printf("父进程开始运行,pid:%d \n", getpid());
pid_t id = fork(); // 父子进程的独立过程是在调用 fork() 函数时完成,之后父子进程独立
if(id < 0)
{
perror("fork");
return 1;
}
else if(id == 0)
{
// child
while(1)
{
sleep(1);
printf("我是一个子进程!我的pid:%d,我的父进程id:%d\n",getpid(), getppid());
}
}
else
{
// father
while(1)
{
sleep(1);
printf("我是父进程!我的pid:%d,我的父进程id:%d\n",getpid(), getppid());
}
}
运行结果如下:
在
fork()
执行后创建了子进程,并且同上文所讲相同,父进程的父进程是bash
进程。
什么是 fork()
?
fork()
是用于创建进程的系统调用。
- 它会从当前运行的进程(称为父进程)中复制出一个几乎完全相同的新进程(称为子进程)。
- 父子进程几乎完全独立,但共享相同的代码段。
- 父子进程拥有不同的内存空间,彼此之间不影响。
fork()
的返回值
fork()
返回两个值,因为它在两个进程中执行,分别是:
- 在父进程中,
fork()
返回子进程的 PID(进程 ID),这是一个正整数(> 0
)。 - 在子进程中,
fork()
返回0
。 - 创建子进程失败返回-1。
为什么 **fork()**
有两个返回值?
操作系统在执行 fork()
时,会基于当前父进程的状态,创建一个几乎完全相同的子进程。
fork()
会把当前的程序和运行环境复制一份,创建一个新的进程。在fork()
函数内,return
也是代码语句,所以也会作为拷贝的代码,申请新的PCB,拷贝父进程的PCB给子进程。在fork中通过区分父子进程后,通过return
返回两个返回值,两个返回值都对id
进行修改,对变量进行修改,触发了写时拷贝,因此系统会进行空间及数据的分配。这就是为什么返回两个返回值的原因,下文会对该过程进行详细讲解。
- **父进程调用 **
**fork()**
,操作系统知道它是父进程,所以返回子进程的 PID,方便父进程管理。 - **子进程调用 **
**fork()**
,它的视角是:我是子进程,我没有子进程,所以返回0
。
注意:
fork()
的执行结果是两套完全独立的运行环境。fork()
的返回值是区分父进程和子进程的关键。
进程独立的过程详解
父子进程的独立过程是在调用 **fork()**
函数时完成的。具体地说,当 fork()
被调用时,操作系统会执行以下步骤,从而使父进程和子进程完全独立:
进程复制的时机
**fork()**
** 的调用时刻**:操作系统在执行fork()
时,会基于当前父进程的状态,创建一个几乎完全相同的子进程。
进程复制的内容:
- 进程控制块(PCB):
- 操作系统为子进程分配新的 PCB,记录子进程的状态信息(如进程号 PID、父进程号 PPID 等)。
- 子进程的 PCB 是从父进程的 PCB 复制的,因此子进程最初看起来与父进程完全相同。
- 地址空间:
- 操作系统复制父进程的内存结构给子进程,形成一份几乎完全相同的内存空间。这包括:
- 代码段:子进程共享父进程的代码段(只读)。
- 数据段:父进程中的全局变量和静态变量会被复制到子进程。
- 堆和栈:子进程的堆和栈也被复制,但它们的内存分配是独立的。
- 操作系统复制父进程的内存结构给子进程,形成一份几乎完全相同的内存空间。这包括:
- 文件描述符:
- 父进程打开的所有文件描述符会被子进程继承,两者对同一文件的操作是共享的(文件偏移量同步)。
父子进程何时独立?
一旦 fork()
返回,父子进程开始独立运行:
- 子进程的内存空间是父进程的副本,但它与父进程完全分离,修改变量不会相互影响。
- 子进程和父进程的执行流从
fork()
的返回值处分叉:- 父进程继续运行时,
fork()
返回子进程的 PID。 - 子进程继续运行时,
fork()
返回0
。
- 父进程继续运行时,
父子进程的独立性体现在以下几点:
- 内存空间独立:
- 虽然子进程初始时与父进程的内存内容相同,但它的地址空间是独立的,修改子进程的内存不会影响父进程。
- PID 和资源独立:
- 子进程有自己的 PID,调度策略也可能不同。
- 子进程的状态和运行不会直接影响父进程。
- 文件描述符共享但独立操作:
- 父子进程共享文件描述符,但可以独立关闭或操作文件。
独立的实现机制:写时复制(Copy-on-Write, COW)
现代操作系统使用了一种优化机制,叫做 写时复制(COW),以减少不必要的资源浪费:
- 在
**fork()**
刚返回时,父子进程共享相同的物理内存页(只读),因此复制过程很快。 - 当父进程或子进程试图修改内存时:
- 操作系统会为需要修改的部分分配新的物理内存。
- 修改后的内存空间对父子进程来说是独立的。
因此,只有在需要时,内存的独立性才真正实现,也就是需要对对内存中数据进行修改的时候,但逻辑上,父子进程从 fork()
返回后就已经被视为完全独立了。
流程图
调用 fork()
后,父子进程的分离流程可以表示如下:
父进程:
ret = fork(); // 返回子进程 PID (> 0)
------------------------------
| 父进程逻辑 |
| printf("父进程部分"); |
| 独立运行,继续父进程代码 |
------------------------------
子进程:
ret = fork(); // 返回 0
------------------------------
| 子进程逻辑 |
| printf("子进程部分"); |
| 独立运行,继续子进程代码 |
------------------------------
写时拷贝修改ret
内容,进程独立。
总结:操作系统完成进程独立的过程
**fork()**
** 是操作系统分离父子进程的起点**。- 通过资源复制、地址空间分离和调度机制,父子进程实现了完全独立。
- 父子进程虽然共享代码和部分资源,但内存、PID 和运行状态是互相独立的,确保了它们可以并发执行,互不干扰。
- 写时拷贝:当父子进程尝试修改共享数据时,操作系统会将数据复制到独立空间。
基本的独立靠的是
**struct task_struct(PCB)**
独立。当父子进程任何一方进行数据修改的时候触发写时拷贝,操作系统就把修改的数据在底层拷贝一份,让整个目标进程修改这个拷贝,脱离代码共享,实现完全独立。