Linux系统之美:进程初识
本节重点:
- 认识冯诺依曼体系结构
- 操作系统的概念与定位
- 深入理解进程概念,了解PBC
- 初识进程,学会创建进程
一、冯诺依曼体系结构
我们常见的计算机,笔记本以及我们不常见的计算机,如服务器,大部分都遵守冯诺依曼体系结构:
这里我们来简单分析一下;
输入设备:键盘、鼠标、话筒、摄像头、网卡、磁盘....
输出设备:显示器、磁盘、网卡、打印机....
存储器:内存
中央处理器(CPU)=运算器+控制器
由冯诺依曼体系结构我们可以清楚地看到:CPU在数据层面只和内存打交道,而输入和输出设备主要与内存打交道。这也说明当软件或程序运行时必须首先将自己的代码或者数据从磁盘加载到内存之后才能让CPU进一步地访问处理。
这里就有一个疑问:CPU直接访问磁盘不好吗,为什么必须加载到内存之中呢?
答:首先我们必须明白的是,CPU处理数据的速度非常快为了充分发挥CPU的计算效能必须源源不断地为CPU提供数据,而磁盘的访问速度远比不上CPU处理数据的速度,导致CPU必须花费大量时间去等待数据,导致整体性能的大幅下降。
所以为了避免这样的事情发生,磁盘中的代码和数据首先加载(拷贝)到内存中,因为内存的访问速度远高于磁盘,而且由于内存结构的特殊设计使得CPU可以快速准确地拿到所需数据大大提高了系统的响应速度和处理能力。
对冯诺依曼体系结构的理解不能停留在概念上,要深入到对软件数据流的理解上,所以在这里我们来试着解释一下我们从登录上QQ开始与某位朋友聊天开始数据的流动过程:
首先,我们需要明白的是两台设备之间的数据流动就是两台冯诺依曼体系之间数据的流动。用户1通过键盘将信息传入内存之中,CPU(中央处理器)会将待发信息进行处理加密,之后通过网卡将处理过的信息发送到网络服务器之中。
用户2通过网卡获取到网络服务器之中的信息并传输到内存之中由CPU(中央服务器)进行进一步处理,如解密,处理完成后再传输到内存之中并输入到输出设备(显示器)。
二、操作系统
2.1 概念
操作系统是管理计算机硬件与软件资源的系统软件,同时也是计算机系统的内核与基石。它的主要任务是为用户提供一个清晰、简洁、易用的工作界面,并管理计算机的硬件和软件资源,使得这些资源能够被用户或应用程序高效、安全地访问和使用。
2.2 准确理解“管理”
概念中我们可知:操作系统通过管理计算机的硬件与软件资源,为用户提供一个良好的执行环境。那么我们该如何理解“管理”一词以及操作系统是如何对软硬件资源进行管理呢?
答:先描述再组织
操作系统首先通过结构体(struct)或者类来描述各个软硬件资源,例如,进程控制块(PCB)用于描述进程的状态、程序计数器、寄存器、内存管理信息等;设备控制块(DCB)用于描述设备的属性、状态、操作等。
之后,操作系统采用双向链表,或者更高效的数据结构如哈希表将描述各个软硬件资源的结构体或者类联系起来,通过建立相应的“增删查改”算法使得操作系统能够高效地查找、访问和管理资源。
也就是说,操作系统层面的“管理”本质上就是对数据结构中数据的增删查改。
2.3 系统调用与库函数
在开发角度,操作系统会对外表现为一个整体,而且操作系统“不信任”每一个人。就像日常生活中银行会为用户提供一系列金融服务,但是不会允许用户进入保险库自行进行相应操作,因为银行也不信任每一个人。所以银行设立了各个业务窗口,用户只需要到指定窗口说明业务,提交证件银行后台就会自动为用户完成相应服务。
操作系统也是如此,为了便于上层开发使用其会暴露自己的部分接口,这部分由操作系统提供的接口叫做系统调用。
系统调用在使用上功能比较基础,对用户的要求也相对比较高,所以有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就更有利于更上层的开发者和用户进行二次开发。
例如:printf函数封装了操作系统提供的write系统调用。
printf
函数将格式化后的字符串传递给底层的系统调用,然后操作系统通过write系统调用将数据输出到终端。
三、进程
3.1 基本概念与基本操作
- 课本概念:程序的一个执行实例,正在执行的程序等
- 内核观点:担当分配系统资源(CPU,内存)的实体。
在文章之前我们说过,CPU在数据层面只和内存打交道,软件和程序运行时必须首先将自己的代码或者数据从磁盘加载到内存之后才能让CPU进一步地访问处理。操作系统必然要对多个被加载到内存中的程序进行管理,管理思路与前面一样:先描述再组织
3.2 描述进程-PCB
进程信息被放在一个叫做进程控制快的数据结构中,可以理解为进程属性的集合。课本上称之为PCB(process control block),linux操作系统中的PCB为task_struct。
task_struct-PCB的一种
在linux操作系统中描述进程的结构体叫做task_struct,task_struct是Linux内核数据结构的一种,它会被装载到RAM(内存)里并且包含着进程的信息,也就是说进程的所有属性都可以直接或间接地通过task_struct找到。
task_struct内容
- 标识符(PID):描述本进程的唯一标识符,用来区别其他进程
- 状态:任务状态、退出代码、退出信号等
- 优先级:相较于其他进程的优先级
- 程序计数器:程序中即将被执行的下一条指令的地址
- 内存指针:包括程序代码和进程相关数据的指针,还有和其它进程共享的内存块的指针
- 上下文数据:程序执行时处理器的寄存器中的数据,主要涉及到进程切换与调度
- I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备和被进程使用的设备列表
- 记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等
- 其他信息
3.3 组织进程
可以在内核源代码里找到它。所有运行在系统里的进程都以task_struct链表的形式存在内核中。这样对进程的管理就变成了对链表的增删查改。
3.4 查询进程
这里我们需要明白的是,在操作系统中我们历史上执行的所有指令、工具以及自己的程序运行起来全都是进程。
3.4.1 利用proc目录查询进程信息
而在Linux系统中我们说“一切皆文件”,进程的信息可以通过/proc系统文件夹查看,如要查看进程PID为1888122的进程信息,就应该查看/proc/1888122这个文件夹:
此时我们写一段代码运行起来:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("程序开始运行:");
while(1)
{
sleep(1);
printf("我是一个进程,我的PID是:%d\n",getpid());
}
return 0;
}
此时我们得到了进程的PID为1888122,进入/proc/1888122我们会看到这样的信息:
这些都是该进程对应的属性,这里我们需要特别注意的是 cwd(进程当前的工作目录),exe(进程对应的可执行文件),这也说明当每个进程(包括我们运行的指令,工具,程序)运行时会自动保存对应的工作目录。
3.4.2 利用ps命令查询进程信息
查询当前所有进程:
ps axj
查询特定进程:
ps axj |grep 进程对应的可执行文件
此时查询的结果我们并不能轻易地看懂,我们想把每个数值对应的属性也加上:
ps axj | head -1 && ps axj | grep 进程对应的可执行文件
或者
ps axj | head -1 ; ps axj | grep 进程对应的可执行文件
此时我们查询的结果就加上属性行了:
在这里我们不难看出来一共匹配了三条结果包含code关键字,第一条是关于MySQL进程的我们可以忽略,第二条是我们所要查询的进程信息,第三条是与grep本身相关的一条进程,我们说过在操作系统中我们历史上执行的所有指令、工具以及自己的程序运行起来全都是进程,所以grep本身也是一个进程,因为每次运行时grep也包含了关键字code所以也一并过滤了出来。
想要过滤掉grep我们执行以下命令:
ps axj | head -1 ; ps axj | grep 进程对应的可执行文件 | grep -v grep
反复查询某一特定进程:
while : ; do ps axj | head -1 ; ps axj | grep 进程对应的可执行文件 | grep -v grep ; sleep 1 ;done
3.5 创建进程·
3.5.1 通过系统调用创建进程-fork初识
man fork
3.5.2 利用fork创建子进程的原理:
fork是一个系统调用函数,用于创建一个新进程,即子进程。
子进程是父进程的副本,几乎完全复制了父进程的资源,这意味着在 fork 调用成功的那一刻,子进程的代码段、数据段、堆栈段以及进程的其他许多属性都与父进程相同。通过fork创建子进程后,系统中会存在两个进程:父进程和子进程。这两个进程将独立运行,各自执行自己的代码。
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("程序开始运行\n");
pid_t id=fork();
if(id==0)
{
//父进程:
printf("这里是父进程代码\n");
}
else if(id>0)
{
//子进程:
printf("这里是子进程代码\n");
}
else
{
//fork失败:
printf("fork fail\n");
}
return 0;
}
3.5.3 fork的工作流程:
1. 分配资源:
当一个进程调用 fork 时操作系统会为新进程分配必要的资源,包括内存空间、文件描述符等。
2.复制父进程的数据结构:
操作系统会复制父进程的进程控制块(task_struct),这是进程在内核中的数据结构,用于存储进程的状态、内存信息、文件描述符等。
复制父进程的内存页表,使子进程获得与父进程相同的虚拟地址空间内容。
3.设置子进程的状态:
子进程会获得一个新的进程 ID(PID),与父进程不同。
4.返回执行:
fork
函数会复制出子进程,接着子进程和父进程都处于就绪状态,开始由内核调度执行。
fork
函数在父进程和子进程中各返回一次:
- 在父进程中,
fork
返回子进程的 PID。· - 在子进程中,
fork
返回 0。 - 如果创建失败,
fork
在父进程中返回 -1。
3.5.4 代码创建一个子进程
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("程序开始\n");
pid_t id=fork();
if(id==0)
{
//子进程:
printf("我是一个子进程,我的PID是:%d,我的父进程PID是:%d\n",getpid(),getppid());
}
else if(id<0)
{
//子进程创建失败:
perror("fork fail!\n");
return 1;
}
else
{
//父进程:
printf("我是一个父进程,我的PID是:%d,我的父进程PID是:%d\n",getpid(),getppid());
}
return 0;
}
四、小知识(初识命令行解释器bash)
4.1 引入:
这里我们首先写一段简单的代码来不断打印该进程与其父进程的PID:
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
int main()
{
while(1)
{
sleep(1);
printf("我是一个进程,我的PID是:%d,我的父进程PID是:%d\n",getpid(),getppid());
}
return 0;
}
编译形成可执行文件后,我们反复终止/运行该代码:
我们知道,在Linux系统中,PID(Process ID)是进程标识符的缩写,它是操作系统为每个正在运行的进程分配的一个唯一数字标识符,每次进程PID的不同说明我们的可执行文件运行时都会创建一个新的进程,而这些创建出的的新进程都有一个共同点那就是,它们都是同一个父进程的子进程,而这个进程就是bash进程。
4.2 基本概念
进程定义:Bash进程是当用户登录到Linux系统并启动Bash Shell时创建的一个进程。它是用户与系统交互的主要界面,允许用户通过命令行输入命令来执行各种任务。
PID(进程标识符):每个Bash进程在创建时都会被分配一个唯一的PID,用于在系统中唯一标识该进程。
也就是说当我们登录Linux时系统就会自动创建一个bash(命令行解释器)进程,bash的主要作用是实时对我们的命令进行解析并调用相应的程序来执行这些命令,它是用户与操作系统之间的桥梁。
这里我们自己可以简单地验证一下:
这里我们来查一下PID为1945183地进程信息:
ps axj | head -l && ps axj | grep 1945183 | grep -v grep
这里我们不难看出这个PID一直不变的进程就是bash进程。
当一个用户登录Linux系统时系统就会创建一个bash进程,当多个用户登录时系统就会创建多个相应的bash进程,这里我们同时启动4台机器再查一下bash进程:
ps axj | head -1 && ps axj |grep 'bash'| grep -v grep
4.3 工作原理
命令解析:当用户通过Bash命令行输入命令时,Bash进程会解析该命令,检查其语法和参数是否正确。
命令执行:如果命令合法,Bash进程会创建一个新的子进程来执行该命令。子进程执行完毕后,会将结果返回给Bash进程,Bash进程再将结果显示给用户。
4.4 启动与退出
启动:当用户登录到Linux系统时,系统会根据用户的配置自动启动Bash进程(或用户指定的其他Shell进程)。
退出:当用户输入exit
命令或按下Ctrl+D
组合键时,Bash进程会退出。此外,如果Bash进程接收到终止信号(如kill
命令发送的信号),也会退出。