【Linux】进程篇Ⅰ:进程信息、进程状态、环境变量、进程地址空间


硬件 - - 冯诺依曼计算机
1、CPU 不和外设直接沟通,而是和内存打交道
2、数据层面:外设也只会和内存打交道
软件 - - 操作系统
手段:对下通过管理好软硬件资源
目的:对上给用户提供良好(安全、稳定…)的执行环境
管理的本质:先描述,再组织
管理的实际是数据,用面向对象进行描述,数据结构进行组织。

一、概述

进程 = 内核关于进程的相关数据结构  // task_struct
   +
   当前进程的代码和数据

这个相关的数据结构就是我们通常所说的 PCB(process control block),Linux 下的 PCB 是 task_struct

比如我们输入 ./可执行程序 的时候:数据从磁盘调到内存变成进程

阻塞:就是不被调度。
阻塞一定是因为 当前进程需要等待某种资源就绪
也一定是 进程 task _struct 结构体需要在某种被 OS 管理的资源下排队(queue)。

挂起:操作系统对阻塞的进程,为了腾出内存空间,将进程的代码和数据部分放入磁盘中,直到轮到进程被调度时,在调出代码和数据进入内存。(可以理解成一种特殊的阻塞状态)

二、查看进程信息

1. 系统文件夹 /proc

正在执行的进程,会有一个和进程 PID 同名的文件夹,存在 /proc 目录下,其中存放进程相关信息。

进程消失后,同名文件夹消失。

2. 用户级工具 ps

# 查看全部进程
ps axj 
# 查看某个程序的进程
ps axj | grep [可执行程序]
# 拿 进程表头 && 某个程序的进程
ps axj | head -1 && ps ajx | grep [可执行程序]
# 拿 进程表头 && 某个程序的进程 && 去掉自己 grep 这个进程
ps axj | head -1 && ps ajx | grep [可执行程序] | grep -v grep
# 在上面的基础上,每隔一秒打印一次结果
while :; do ps axj | head -1 && ps ajx | grep [可执行程序] | grep -v grep; sleep 1; echo "----------"; done

3. getpid() 函数:查看进程 PID

函数声明:
pid_t getpid(void); // 查看自身进程 PID
pid_t getppid(void); // 查看父进程 PID
头文件包含:
#include <sys/types.h>
#include <unistd.h>

getpid() :当前程序运行时可以获得 自身进程 PID
getppid() :当前程序运行时可以获得 父进程 PID

pid_t 相当于一个有符号整数,返回的就是 PID 号,也是 /proc 里的文件名


🐎测试代码:

在这里插入图片描述

观察结果如下:

在这里插入图片描述
频繁多次运行发现:子进程每次进入都是新的 PID,父亲的 PPID 一直都是同一个。查看这里的 3395 为例,可知父进程是 bash

在这里插入图片描述

在这里插入图片描述


结论
  1. 🎯bash(命令行解释器) 也是个进程
  1. 🎯命令行启动 的 所有程序,最终都会变成进程,而该进程 对应的 父进程 都是 bash
此外
PGID:进程组
SID:会话 ID
TTY:进程打开的终端

4. 用 kill 杀进程

除了 ctrl+C,杀进程有专门的命令 kill

方法一:

kill -9 [进程PID]

方法二:

killall [可执行文件]

(如果我们不小心 bash 把他杀了,bash 会崩溃…需要重新连接一下

结论
  1. 🎯如何创建的子进程??
    fork 之后,执行流会变成两个执行流
    fork 之后,谁先运行由调度器决定
    fork 之后,fork 之后的代码共享,通常我们通过 if 和 else if 进行执行流分流

5. 进程优先级

cpu资源分配的先后顺序,就是指进程的优先权(priority)。

优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。

还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善系统整体性能

输入 ps -l 命令可以得到的关键信息有如下内容:

  • UID : 代表执行者的身份
  • PID : 代表这个进程的代号
  • PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
  • PRI :代表这个进程可被执行的优先级,其值越小越早被执行
  • NI :代表这个进程的 nice 值

PRI and NI

  • PRI 也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被 CPU 执行的先后顺序,此值越小 进程的优先级别越高
  • 那 NI 呢?就是我们所要说的 nice 值了,其表示进程 可被执行的优先级的 修正数值
  • PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old,即 80)+nice
  • 这样,当 nice 值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行
  • 所以,调整进程优先级,在 Linux 下,就是调整进程 nice 值
  • nice 其取值范围是 -20 至 19,一共 40 个级别。

PRI vs NI

  • 需要强调一点的是,进程的 nice 值不是进程的优先级,他们不是一个概念,但是进程 nice 值会影响到进程的优先级变化。
  • 可以理解 nice 值是进程优先级的修正修正数据

top 命令更改已存在进程的 nice:

  • 进入 top后按 r(renice) –> 输入进程 PID –> 输入 nice 值

二、进程状态分析

task_struct 是一个结构体,内部会包含各种属性,其中就有一项是当前状态。

struct task_struct
{
	int status;
	//...
};	

Linux内核源代码(部分):

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 */
};

0. +

进程状态后面带 + 号
则说明该进程在 前台运行,可以用 ctrl + C 让程序停止。
进程状态后面没有 + 号
该进程在 后台运行,不能用 ctrl + C 让程序停止了。

1. R (running) 运行状态

进程只要是 R 状态,就一定是在CPU上运行吗?
事实上,进程是 R 状态并不直接代表进程在运行,而代表该进程在运行队列中排队,这个队列是由操作系统维护的。

操作系统在内存里,这个队列也在内存里被维护的。操作系统对 task_struct 的管理就是把他们放到不同的队列当中。

进程是什么状态,一般也看这个进程在哪里排队(是 task_struct 在排队,而不是代码和数据)。

运行状态 R 是瞬时状态。当进程会调用资源(如打印到显示器)时,由于 CPU 运行速度太快,我们去 ps axj 进程信息的时候,极大概率只能看到进程的其他状态,而无法捕捉到 R 状态。

2. S (sleeping) 休眠状态

S 休眠状态是 可中断休眠,本质上就是一种 阻塞状态,处于等待某种资源的状态。

3. D (disk sleep) 不可中断的休眠状态

D 是 不可中断休眠,也是阻塞状态的的一种(在做系统管理、运维、系统存储的时候才会遇到)。

面对普通的休眠状态的进程,在特殊场景下,操作系统可以做出判断并杀掉休眠进程。D 状态的休眠,则是操作系统无法杀掉的。只能等进程自己运作,或者拔掉电源…

4. T (stopped) 暂停状态

T 是 暂停状态

用户主动使用 kill -19 操作,可以让进程进入 T 状态:

kill -19 [进程PID]

用户主动关闭 T 状态,使进程变成 R / S(后台运行 / 休眠) 状态,继续运行:

kill -18 [进程PID]

此时进程变成后台运行,无法通过 ctrl + C 的方式结束,需要输入另一个信号 kill -9:

kill -9 [进程PID]

5. t (tracing stop) 追踪暂停状态

追踪暂停,也是暂停的一种。当我们给程序打上断点并在断点处停下时,进程会显示追踪状态。

6. Z (zombie) 僵尸状态

在了解 Z 状态之前,我们先引出一个概念。

main 函数 里的 return 0,实际上是进程退出码。可以交给程序去判断,进程结束的结果是否正确。

// 进程退出码使用举例
int main()
{
	// 算法省略
	int result = 10;
	if(result == 10)
		return 0;	// 正常退出
	else
		return 3;	// 异常退出
}

查看进程退出码:

echo $?

注意:$? 只会保存最后一次执行的退出码。

僵尸状态

子进程退出后,等待后续父进程(OS)读取子进程退出的退出结果的状态。

僵尸进程的危害:
  • 进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就一直处于Z状态?是的!
  • 维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话说,Z状态一直不退出,PCB一直都要维护?是的!
  • 那一个父进程创建了很多子进程,就是不回收,是不是就会造成内存资源的浪费?是的!因为数据结构对象本身就要占用内存,想想C中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间。
  • 会造成内存泄漏。

7. X (dead) 终止状态

终止状态,也是一个瞬时状态。当进程从 Z 状态被回收,会变成 X 终止状态,继而操作系统才会正真释放进程的所有资源。

8. 孤儿进程

孤儿进程:父进程退出,子进程会被 OS 自动领养(通过让 1 号进程成为新的父进程)。被领养的进程,就是孤儿进程

9. 一些概念

  • 竞争性:系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
  • 独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰
  • 并行:多个进程在多个CPU下分别,同时进行运行,这称之为并行
  • 并发:多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发

三、 环境变量🔺

环境变量(environment variables) 一般是指在操作系统中用来指定操作系统运行环境的一些参数。

环境变量本质就是一 个 内存级 的一张表,这张表由 用户在登陆系统的时候,进行给特定用户形成属于自己的环境变量表。在系统当中通常具有全局特性,可以被子进程继承

环境变量中的每一个,都有自己的用途:有的是进行路径查找的,有的时进行身份认证的,有的时进行动态库查找的,有的是用来进行确认当前路径…等等。每一个环境变量都有自己的特定应用场景。每一个元素都是 kv 的。

我们平时写代码中生成的可执行文件 xx,在我们需要运行它时输入的 ./xx 实际上就是这个可执行文件的路径。而众多的命令实际也是一个个可执行文件,为什么命令可以直接被读取,而我们生成的可执行文件则要带上路径呢?

分别 which 一下随便某个命令、再 which 我们的可执行文件可以发现。是因为我们的可执行文件不在 PATH 路径下。

两个解决思路,让我们输入可执行文件名 xx,就可以执行程序:
1、把我们生成的可执行文件 cp -rf 到 PATH 的路径下。
2、把可执行文件所在路径 export 到原有路径后面。

1. 常见的环境变量

  • PATH : 指定命令的搜索路径
  • HOME : 指定用户的主工作目录(即用户登陆到 Linux 系统中时,默认的目录)
  • SHELL : 当前 Shell,它的值通常是 /bin/bash。

2. 有关指令

which:在环境变量中查找某个命令的路径

env:输出所有 环境变量

set :同时输出 环境变量 和 本地变量

unset [变量名]:取消某个 本地 / 环境变量

echo $[环境变量名称]:查看某个环境变量

export [变量名]:设置新的 / 更新环境变量
本质上就是,把本地变量添加到环境变量表里!

(注意:如果环境变量被我们误操作不慎覆盖,导致命令无法使用,只需要重启虚拟机即可)

PATH 环境变量举例
------------------

# 添加路径到环境变量
export PATH = $PATH:[指定路径]

# 设置并覆盖原来的环境变量
export PATH = [指定路径]
环境变量
存在 shell 里
放进环境变量表
可以被子进程继承
普通本地变量
存在 shell 里
只能由 shell 内部调用
不能被子进程继承
# 设置新的环境变量,env 中可查,可以被子进程继承
export hello = 123456
# 设置普通的本地变量,env 中没有
hey = abcde

# 查看变量的值
echo $hello
echo $hey
这里要引出一个问题了:
	既然我们说,本地变量只能在 shell 内部使用,不能被子进程继承,
	echo 命令必然会调用子进程,子进程又是怎么访问到本地变量的呢?
这里要用 内建命令 来解释了。后续更新。

3. 通过代码如何获取环境变量

  1. 命令行第三个参数,就是 环境变量表
#include <stdio.h>
int main(int argc, char *argv[], char *env[])
{
	 int i = 0;
	 for(; env[i]; i++)
	 {
	 	printf("%s\n", env[i]);
	 }
	 return 0;
}
  1. 通过第三方变量 environ 获取
    (libc 中定义的全局变量 environ 指向环境变量表,environ 没有包含在任何头文件中,所以在使用时 要用 extern 声明
#include <stdio.h>
int main(int argc, char *argv[])
{
	 extern char **environ;
	 int i = 0;
	 for(; environ[i]; i++)
	 {
		 printf("%s\n", environ[i]);
	 }
	 return 0;
}

其实获取环境变量最主要的是下面这种方式:

4. 通过系统调用 获取 或 设置 环境变量

putenv
getenv

常用getenv和putenv函数来访问特定的环境变量。

🌰我们模拟实现一个pwd

#include <stdio.h>
#include <stdlib.h>

int main()
{
	 char* pwd = getenv("PWD");
	 if(pwd == NULL)
	 	perror("geienv");
	 else
	 	printf("%s\n", pwd);
	 return 0;
}

5. 补充:命令行的 int argc 和 char *argv[]

int argc

用户在运行程序的命令行的参数个数,在VS中默认为1。

char *argv[]

字符串数组,其各元素值为命令行中各字符串的首地址,指针数组的长度为参数个数argc。

argv[0] 程序的全名

argv[1] 指向在 DOS 命令中执行程序名后的第一个字符串

argv[2] 指向执行程序名后的第二个字符串

argv[3] 指向执行程序名后的第三个字符串

argv[argc] 为 null

void Usage(const char *name)
{
    printf("\nUsage: %s -[a|b|c]\n\n", name);
    exit(0); // 终止进程
}

int main(int argc, char *argv[])
    if(argc != 2) Usage(argv[0]);

    if(strcmp(argv[1], "-a") == 0) printf("打印当前目录下的文件名\n");
    else if(strcmp(argv[1], "-b") == 0) printf("打印当前目录下的文件的详细信息\n");
    else if(strcmp(argv[1], "-c") == 0) printf("打印当前目录下的文件名(包含隐藏文件)\n");
    else printf("其他功能,待开发\n");

	return 0;
}

四、进程地址空间🔺

先看如下这个测试:

🐎测试代码:

#include <stdio.h>
#include <unistd.h>
#include <assert.h>
int g_val = 100;
int main()
{
	 pid_t id = fork();
	 assert(id >= 0);
	
	 else if(id == 0)	//child
	 { 		
		while(1)
		{
			printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);  	
			g_val++;
			sleep(1);
		}
	 }
	 else	//parent
	 { 
	 	while(1)
	 	{
			printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
			sleep(1);
		}
	 }
	 
	 return 0;
}

测试结果:

parent[2995]: 100 : 0x80497d8
child[2996]: 100 : 0x80497d8
parent[2995]: 100 : 0x80497d8
child[2996]: 101 : 0x80497d8
parent[2995]: 100 : 0x80497d8
child[2996]: 102 : 0x80497d8
parent[2995]: 100 : 0x80497d8
child[2996]: 103 : 0x80497d8

我们已经知道的是:

  • 子进程对全局数据修改,并不影响父进程。进程具有独立性!

进而可以发现,父子进程,输出地址是一致的,但是变量内容不一样!能得出如下结论:

  • 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量。
  • 但地址值是一样的,说明,该地址绝对不是物理地址!
  • 在 Linux 地址下,这种地址叫做 虚拟地址 / 线性地址
  • 我们在用 C/C++ 语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理。

OS 必须负责将 虚拟地址 转化成 物理地址。

1. 如何理解 进程地址空间

进程地址空间,本质上也是一个内核数据结构,struct mm_struct{};

在这里插入图片描述


2. 为什么要有地址空间?

  • 防止地址随意访问,保护物理内存与其他进程。
  • 地址空间 和 物理内存 之间 由 页表 连接,这张表不止简单的定义了映射关系,还为用户设置了各种区域的不同权限。
  • 例如,我们知道 char* str = “hello”; 如果我们去编译 str = ‘H’; 肯定是不能通过的,因为我们通过 页表 所映射的 “hello” 所存入的物理内存的常量区,在 key 被设置为只读!我们平时说的 代码是只读的,也是这个道理。

3. malloc 的本质?

  • 当我们像 OS 申请内存时,操作系统是立马给我们这个地址,还是需要的时候再给我们?
  • 首先,OS 一般不允许任何的浪费不高效。
  • 其次,申请内存不一定立马使用。
  • 什么意思呢?在我们申请成功之后,使用之前,就有一段小小的时间窗口(这个空间没有被正常使用,但别人用不了),这块空间处于闲置状态?这个不高效的行文,OS 就不允许的!
  • 于是,我们申请成功后,地址空间给我们一个地址,连接页表的 key(此前是 进程管理),而 value 也是没有映射存在的,即此时没有开辟空间。
  • 只有当我们调用或者访问内存,OS 发现没有相应的数据,才会给我们把数据换入。(这里是内存管理
  • 这个现象叫做 缺页中断,也是一个典型的 解耦合

在这里插入图片描述

4. 再谈 地址空间

所以,为什么要有地址空间:

  1. 防止地址随意访问,保护物理内存与其他进程。
  2. 将 进程管理 和 内存管理 进行 解耦合
  3. 更重要的是,可以让进程以统一的视角,看待自己的代码和数据

(扩展)解释第三点:

  • 我们的程序在被编译,还没有被加载到内存的时候,程序内部也存在地址!

  • 编译器编译可执行程序时,本来就是按照 虚拟地址空间 的方式、各种内存布局来编译的,在磁盘上已经给我们规定好了代码区、已初始化数据区…等等这样的概念。编译时只需要进行模块式的加载,进行对应的映射到内存,则有了物理地址。而代码彼此之间是使用虚拟地址互相跳转的。所以说,程序在没加载到内存的时候就有了地址,这个地址是虚拟地址。

  • 比如,我们写一个函数,再去调用它,在反汇编中查看能看到他们的地址,这个地址就是 程序自拟的 虚拟地址。

在这里插入图片描述

  • 可以看到的是,地址空间约束的不光是 OS,我们的编译器也需要遵守这样的规则。
  • 所以一个可执行程序加载到内存,是拥有两套地址的。

最后一个问题:

进程和代码必须一直在内存中?

不一定!实际上,我们拥有了虚拟地址空间,我们的代码是可以边加载边执行的。需要的数据才加载,这样就可以保证我们在内存使用量很低的情况下,还完成了大软件的运行。


🔗下接 进程篇Ⅱ:进程开始、进程终止、进程等待、程序替换


👉🔗链接在此


🥰如果本文对你有些帮助,请给个赞或收藏,你的支持是对作者大大莫大的鼓励!!(✿◡‿◡) 欢迎评论留言~~


  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值