目录
一、进程概念
课本概念:进程是程序的一个执行实例,是正在执行的程序。
内核观点:进程是承担系统资源(CPU时间、内存)的实体。
当我们写完代码之后,编译连接就形成一个可执行程序.exe,本质是二进制文件,在磁盘上存放着。双击这个.exe文件把程序运行起来就是把程序从磁盘加载到内存,然后CPU才能执行其代码语句。当把程序加载到内存后,这个程序就叫做进程。所有启动程序的过程,本质都是在系统上创建进程,双击.exe文件也不例外:
二、PCB
1.什么是PCB
根据操作系统管理是先描述再组织,那么操作系统是如何描述进程的呢?先预想一下,肯定是先描述进程信息,然后再把这些信息用数据结构组织起来进行管理。那么进程都有哪些信息呢?使用
ps axj
命令查看系统当中的进程,也就是正在运行的程序:
看到进程的属性至少有PPID、PID、PGID、SID、TTY、TPGID、STAT、UID、TIME、COMMAND。
进程信息被放在一个叫做进程控制块PCB(Process Control Block)的数据结构中,它是进程属性的集合。
操作系统创建进程时,除了把磁盘上的代码和数据加载到内存以外,还要在系统内部为进程创建一个task_struct,是一个struct。
2.什么是task_struct
Linux操作系统的下的PCB就是task_struct,所以task_struct是PCB的一种,在其他操作系统中的PCB就不一定叫task_struct。
创建进程不仅仅把代码和数据加载到内存,还要为进程创建task_struct,所以进程不仅仅是运行起来的程序,更准确的来说,进程是程序文件内容和操作系统自动创建的与进程相关的数据结构,其实进程还包括其他内容,今天先说这两个。
操作系统对每一个进程进行了描述,这就有了一个一个的PCB,Linux中的PCB就是task_struct,这个struct会有next、prev指针,可以用双向链表把进程链接起来,task_struct结构体的部分指针也可以指向进程的代码和数据:
所有运行在系统里的进程,都以task_struct作为链表节点的形式存储在内核里,这样就把对进程的管理变成了对链表的增删改查操作。
增:当生成一个可执行程序时,将.exe文件存放到磁盘上,双击运行这个.exe程序时,操作系统会将该进程的代码和数据加载到内存,并创建一个进程,对进程描述以后形成task_struct,并把插入到双向链表中。
删:进程退出就是将该进程的task_struct节点从双向链表中删除,操作系统把内存中该进程的代码和数据进行释放。
3.task_struct包含内容
标示符: 描述本进程的唯一标示符,用来区别其他进程。
状态: 任务状态,退出代码,退出信号等。
优先级: 相对于其他进程的优先级。
程序计数器: 程序中即将被执行的下一条指令的地址。
内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
上下文数据: 进程执行时处理器的寄存器中的数据。
I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
还有一些其他信息。下面解释task_struct包含内容的具体含义。
三、task_struct内容详解
1.查看进程
(1)通过系统目录查看
proc是一个系统文件夹,在根目录下,通过ls可以看到该文件夹:
可以通过
ls /proc
命令查看进程的信息,数字是PID:
如果想查看进程信息,比如查看PID为989的进程信息,使用命令
ls /proc/PID
查看:
(2)通过ps命令查看
使用
ps aux
命令查看进程,可以看到所有进程:
如果结合grep可以查看某一个进程:
比如想查看包含proc的进程,可以使用如下命令:
ps aux | head -1 && ps aux | grep proc | grep -v grep
(3)通过top命令查看
也可以通过
top
命令查看:
(4)通过系统调用获取进程PID和父进程PPID
获取进程ID和获取父进程ID可以通过以下方式进行获取,其中pid_t是short类型变量:
-
#include <sys/types.h>
-
#include <unistd.h>
-
-
pid_t getpid(void);
//获取当前进程ID
-
pid_t getppid(void);
//获取当前进程的父进程ID
获取当前进程,process.c
-
#include<sys/types.h>
-
#include<stdio.h>
-
#include<unistd.h>
-
-
int main()
-
{
-
while(
1)
-
{
-
printf(
"hello linux!:pid:%d\n",
getpid());
//获取当前进程ID
-
sleep(
1);
-
}
-
-
return
0;
-
}
Makefile:
-
process:process.c
-
gcc -o
$@ $?
-
.PHONY:clean
-
clean:
-
rm -f process
运行之后,就获取到了当前进程的PID,即进程号:
关闭进程可以通过ctrl+c或者来关闭进程。另开一个窗口,现在通过ps来查看进程:
这也就验证了getpid获取到的是PID。
-
#include<sys/types.h>
-
#include<stdio.h>
-
#include<unistd.h>
-
-
int main()
-
{
-
while(
1)
-
{
-
printf(
"hello linux!:pid:%d,ppid:%d\n",
getpid(),
getppid());
-
sleep(
1);
-
}
-
-
return
0;
-
}
使用ps命令查看,发现父进程的ID是11081,但是11081同时也是bash的子进程:
这是因为,运行命令行的命令有风险,命令行出错了,不能影响命令行解释,因此在命令行上运行的命令,基本上父进程都是bash。
使用如下命令可查看到进程内部的所有属性信息:
ls /proc/当前进程ID -al
当进程退出时,就没有/proc/18448这个文件夹了,ctrl c后,再去查看文件夹,已经不存在了:
2.状态
之前写代码的返回值是0 ,这个0是进程退出时的退出码,这个退出码是要被父进程拿到的,返回给系统,父进程通过系统拿到。比如以下代码的退出码是0
-
#include<stdio.h>
-
int main()
-
{
-
printf(
"hello linux!\n");
-
return
0;
-
}
那么使用
echo $?
就可以查看到进程退出码为0:
假如将退出码改为99 :
那么程序运行后的退出码也变成了99:
所以,状态的作用是输出最近执行的命令的退出码。
3.优先级
权限指的是能不能,而优先级指的是已经能了,有权限了,但是至于什么时候执行得先排队 ,这就像在餐馆点餐结帐出小票之后,已经可以拿到餐食了,但是什么时候能拿到呢?需要排队,在这个过程中,是否出小票就代表是否有权限,排队取餐就代表的是优先级。
4.程序计数器
当CPU执行程序时,执行当前行指令时,怎么知道下一行指令是什么呢?程序计数器pc中存放下一条指令的地址,当操作系统执行完当前行指令后,pc自动会++,直接执行下一行命令。
内存指针可以通过task_struct中的内存指针,通过PCB 找到进程的代码和数据。
5.上下文数据
当操作系统维护进程队列时,由于进程代码可能不会在很短时间就能执行完毕,假如操作系统也不会在执行一个进程时,让其他进程一直等待,直到当前进程执行完毕,那可能当前进程需要执行很久才执行完毕,其他进程会一直处于等待状态,这不合理。那么操作系统在实际执行进程调度时,按时间片分配执行时间,时间片一到,就切换下一个进程。时间片是一个进程单次运行的最长时间。
比如有4个进程,在40ms之内先让第一个进程运行10ms,时间一到就算没有运行完毕,就把第一个进程从队列头移动到队列尾,再让第二个进程运行10ms。40ms后,使得用户感知到这4个进程都推进了,其实本质上是通过CPU的快速切换完成的。
有可能在一个进程的生命周期内被调度成百上千次。比如CPU有5个寄存器,进程A正在运行时时间片到了,被切走的时候,会把CPU里和进程A相关的保存到寄存器里面的临时数据带走。当进程B调度完后,再次调度进程A的时候,会把进程A里面保存的临时数据再恢复到CPU的寄存器当中,继续上次切走时的状态继续运行,因此保护上下文能够保证多个进程切换时共享CPU。
6.I/O状态信息
文件操作有fopen、fclose、fread、fwrite等函数,其实是进程在操作文件,因为在把代码写完之后,程序运行起来时,操作系统会找到这个进程,进程打开文件进行IO操作,其实IO都是进程在进行IO,所以操作系统需要维护进程和IO信息。
7.记账信息
记录历史上一个进程所享受过的软硬件资源的结合。
四、通过系统调用创建进程
1.使用fork创建子进程
fork用来创建子进程:
-
#include <unistd.h>
-
pid_t fork(void);
//通过复制调用进程创建一个新进程。新进程称为子进程。调用进程称为父进程。
先看一个奇奇怪怪的代码:
forkProcess_getpid.c
-
#include<unistd.h>
-
#include<stdio.h>
-
-
int main()
-
{
-
int ret = fork();
-
-
if(ret >
0)
-
{
-
printf(
"I am here\n");
-
}
-
else
-
{
-
printf(
"I am here,too\n");
-
}
-
-
sleep(
1);
-
return
10;
-
}
按道理来说,要么打印I am here,要么打印I am here,too。但是请看执行结果,发现两句话都打印了,也就是既执行了if又执行了else:
再看代码:
-
#include<stdio.h>
-
#include<unistd.h>
-
-
int main()
-
{
-
int ret = fork();
-
-
while(
1)
-
{
-
printf(
"I am here,pid = %d,ppid = %d\n",
getpid(),
getppid());
-
sleep(
1);
-
}
-
-
return
10;
-
}
发现有两个pid和ppid:
这说明执行while死循环不只一个执行流在执行, 而是两个执行流在执行,每一行两个id都是父子关系。这是因为fork之后有两个执行流同时执行while循环。
可以看到bash 16202创建了子进程 16705,子进程又创建了子进程 16706:
2.理解fork创建子进程
再来说为什么if和else都执行了。
./可执行程序、命令行、fork,站在操作系统角度,创建进程的方式没有差别,都是系统中多了个进程。fork创建出来的子进程,和父进程不一样,父进程在磁盘上是有可执行程序的,运行可执行程序时会把对应的代码和数据加载到内存中去运行。
但是子进程只是被创建出来的,没有进程的代码和数据,默认情况下,子进程会继承父进程的代码和数据,子进程的数据结构task_struct也会以父进程的task_struct为模板来初始化子进程的task_struct。因此子进程会执行父进程fork之后的代码,来访问父进程的数据。
总结:当fork创建子进程时,系统里面多了个进程,实际上是多了个以父进程为模板的描述进程的数据结构task_struct和以父进程为模板的代码和数据。因此fork之后,if和else中的代码都执行了。如果把task_struct比作基因,把代码和数据比作事业,那么子进程既继承了父进程的基因,又继承了父进程的事业。
3.fork后的数据修改
代码是不可以被修改的。 那么数据呢?子进程和父进程共享数据,当父进程修改数据时,子进程看到的数据也被修改了,那么父进程就会影响子进程。那这两个进程还具有独立性吗?
当父子进程都只读不写数据时,数据是共享的。但是这两个进程中的任何一个进程要修改数据,都会对对方造成影响,这时候作为进程管理者同时也是内存管理者的操作系统就要站出来干涉了。修改时,操作系统会在内存中重新开辟一块空间,把这部分数据拷贝过去之后再做修改,而不是在原数据上做修改,这叫做写时拷贝。
写时拷贝是为了维护进程独立性,为了防止多个进程运行时互相干扰。而在创建子进程时不会让子进程把父进程的所有数据全部都拷贝一份,因为并不是所有情况下都可能产生数据写入,所以这就避免了fork时的效率降低和浪费更多空间的问题。因此只有写入数据时再开辟空间才是合理的。
4.fork的返回值
(1)fork返回值含义
fork出子进程后,一般会让子进程和父进程去干不同的事情,这时候如何区分父子进程呢?fork函数的返回值如下:
打印一下fork的返回值:
forkProcess_getpid.c
-
#include<stdio.h>
-
#include<unistd.h>
-
-
iint main()
-
{
-
pid_t ret = fork();
-
-
while(
1)
-
{
-
printf(
"Hello forkProcess,pid = %d,ppid = %d,ret = %d\n",
getpid(),
getppid(),ret);
-
sleep(
1);
-
}
-
-
return
10;
-
}
打印结果如下:
这说明:
- fork准备return的时候子进程被创建出来了。
- 这里有两个返回值,由于函数的返回值是通过寄存器写入的, 函数返回时把变量值写入到保存数据的空间。所以当父子执行流执行完毕以后,有两次返回,就有两个不同的返回值,就要进行写入,谁先返回谁就先写入,即发生写时拷贝。
- 给父进程返回子进程的pid的原因是,一个父进程可能有多个子进程,子进程必须得用pid来进行标识区分,所以一般给父进程返回子进程的pid来控制子进程。子进程想知道父进程pid可以通过get_ppid( )来获取。这样就可以维护父子进程了。
(2)根据fork返回值让父子进程执行不同的功能
通过返回值来让父子进程分流,去执行不同的功能:
-
#include<stdio.h>
-
#include<unistd.h>
-
-
int main()
-
{
-
pid_t ret = fork();
-
-
//通过if else来分流
-
if(ret ==
0)
//child
-
{
-
while(
1)
-
{
-
printf(
"I am child, pid = %d,ppid = %d\n",
getpid(),
getppid());
-
sleep(
1);
-
}
-
}
-
else
if(ret >
0)
//parent
-
{
-
while(
1)
-
{
-
printf(
"I am parent, pid = %d,ppid = %d\n",
getpid(),
getppid());
-
sleep(
3);
-
}
-
}
-
else
-
{
-
}
-
-
return
0;
-
}
这就让父子进程执行了不同的功能,上述代码父进程每隔3秒打印一次,子进程每隔1秒打印一次:
可以查看到父进程和子进程:
通过fork创建出进程,再通过if else分离,从而让父和子各自执行不同的代码段,实现不同的功能。至于父子进程谁先运行,是由调度器决定的。
五、进程状态
1.进程状态定义
一个进程从创建而产生至撤销而消亡的整个生命期间,有时占有处理器执行,有时虽可运行但分不到处理器、有时虽有空闲处理器但因等待某个事件的发生而无法执行,这说明进程和程序不相同,它是活动的且有状态变化的,能够体现一个进程的生命状态,可以用一组状态来描述:
内核源代码里面的状态定义:
-
/*
-
* 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 */
-
};
通过不同状态来对进程进行区分,从而对进程进行分类。Linux进程的状态信息保存在进程的task_struct中。
2.进程状态分类
使用如下两条命令都可以查看进程当前状态:
ps aux
ps axj
查看到的进程状态:
(1)R-运行状态
R(Running):要么在运行中,要么在运行队列里,所以R状态并不意味着进程一定在运行中,因此系统中可能同时存在多个R状态进程。
如下代码statusType.c:
-
#include<stdio.h>
-
int main()
-
{
-
while(
1);
-
return
0;
-
}
运行起来之后,一直处于运行状态,会发现是R+ 状态,其中+表示在前台运行:
如果运行时在后面加&,就会在后台运行,就变成R状态了:
后台运行的进程只能用kill -9 进程号来杀掉了:
在运行状态的进程,是可以被CPU调度的,当操作系统切换进程时,就会直接在运行队列里选取R状态进程。
(2)S-浅睡眠状态
S(Sleeping) :进程正在等待某事件完成,可以被唤醒,也可被杀死,浅睡眠状态也叫做可中断睡眠。
比如如下代码:
status.c
-
int main()
-
{
-
printf(
"hello linux\n");
-
sleep(
20);
-
-
return
0;
-
}
在运行后20s内查看status进程的状态,发现为S+,执行kill命令后,该进程被杀死:
(3)D-深睡眠状态
D(Disk sleep):进程正在等待IO,不能被杀死,必须自动唤醒才能恢复,也叫不可中断睡眠状态。
进程等待IO时,比如对磁盘写入,正在写入时,进程处于深度睡眠状态,需要等待磁盘将是否写入成功的信息返回给进程,因此此时进程不会被杀掉
(4)T-停止状态
T(Stopped):可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送SIGCONT 信号让进程继续运行。
运行起来的status进程,通过SIGSTOP信号被暂停了,状态由S+变为T:
又通过SIGCONT信号恢复了,状态由T变为S:
kill -l命令可列出操作系统中所有信号,其中18就是SIGCONT信号,19就是SIGSTOP信号:
因此上述kill SIGCONT 进程号 也可以用kill -18 进程号来代替,kill SIGSTOP 进程号 也可以写成kill -19 进程号来代替。
(5)Z-僵尸状态
当进程退出时,所占用的资源不是立即被释放,而是要暂时保存进程的所有退出信息来辨别进程死亡的原因(比如代码有问题、被操作系统杀死等),这些数据都保存在task_struct中,供父进程或系统读取,这就是僵尸状态存在的原因。
当进程退出并且父进程没有读取到子进程退出的返回码时就会产生僵尸进程。僵尸进程会以终止状态保持在进程表中,并且会一直等待父进程读取退出状态码。
如下代码,statusZombie.cc:
-
#include<iostream>
-
#include<unistd.h>
-
using
namespace std;
-
-
int main()
-
{
-
pid_t id = fork();
-
if(id ==
0)
-
{
-
while(
1)
-
{
-
cout <<
"child is running" << endl;
-
sleep(
20);
-
}
-
}
-
else
-
{
-
cout <<
"father" << endl;
-
sleep(
50);
-
}
-
return
0;
-
}
Makefile:
-
statusZombie:statusZombie.cc
-
g++ -o
$@ $^
-
.PHONY:clean
-
clean:
-
rm -f statusZombie
使用如下监控进程脚本
while :; do ps axj | head -1 && ps ajx | grep 进程名 | grep -v grep;sleep 1; echo "####################"; done
来监控进程状态,进程运行之后,父进程和子进程的状态变成了S:
杀掉子进程后,子进程的状态变成了Z状态:
所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程就进入Z状态。
(6)X-死亡状态
这个状态只是一个返回状态,在任务列表里看不到这个状态。因为当进程退出时,释放进程所占用的资源时一瞬间就释放完了,所以死亡状态看不到。
3.僵尸进程危害
从僵尸状态我们知道了僵尸进程退出时会等待父进程或系统读取其返回码来辨别进程死亡的原因。这就像我们在写代码时,main函数的返回值都是0:
-
#include<stdio.h>
-
int main()
-
{
-
//code
-
return
0;
-
}
返回值0就是为了告诉操作系统代码顺利执行结束,可以使用echo $?来获取进程最后一次退出时的退出码:
当子进程退出,而父进程还在运行,但是父进程没有读取子进程的退出信息,子进程就进入了僵尸状态。
如下面代码zombieProcess.c,子进程在打印5次之后退出,父进程没有读取子进程的退出信息,此时子进程就变成僵尸状态:
-
#include<stdio.h>
-
#include<stdlib.h>
-
#include<unistd.h>
-
int main()
-
{
-
pid_t id = fork();
-
if(id ==
0)
//child
-
{
-
int count =
5;
-
while(count)
-
{
-
printf(
"child PID:%d,PPID:%d,count:%d\n",
getpid(),
getppid(),count);
-
sleep(
1);
-
count--;
-
}
-
printf(
"child is quiting\n");
-
exit(
1);
-
}
-
else
if(id >
0)
//father
-
{
-
while(
1)
-
{
-
printf(
"father PID:%d,PPID:%d\n",
getpid(),
getppid());
-
sleep(
1);
-
}
-
}
-
else
//fork error
-
{
-
//do nothing
-
}
-
return
0;
-
}
使用监控脚本就可以看到子进程的状态就变成了僵尸状态:
僵尸进程危害:
(1)进程的退出状态必须被维持下去,因为它要把退出信息告诉父进程,如果父进程一直不读取,那么子进程就一直处于僵尸状态
(2)由于进程基本信息是保存在task_struct中的,如果僵尸状态一直不退出,只要父进程没有读取子进程退出信息,那么PCB一直都需要维护。
(3)如果一个父进程创建了多个子进程,并且不回收,那么就要维护多个task_struct 数据结构,会造成内存资源的浪费
(4)僵尸进程申请的资源无法进行回收,那么僵尸进程越多,实际可用的资源就越少,也就是说,僵尸进程会导致内存泄漏
六、孤儿进程
僵尸进程是子进程先退出,但是父进程没有读取子进程的退出信息。
假如父进程先退出,子进程后退出,此时子进程处于僵尸状态,没有父进程来读取它的退出信息,此时子进程就称为孤儿进程。
如下代码orphanProcess.c,父进程在5秒后终止退出,子进程并没有退出:
-
#include<stdio.h>
-
#include<unistd.h>
-
#include<stdlib.h>
-
int main()
-
{
-
pid_t id = fork();
-
if(id ==
0)
//child
-
{
-
while(
1)
-
{
-
printf(
"child\n");
-
sleep(
2);
-
}
-
}
-
else
//father
-
{
-
sleep(
5);
-
printf(
"father is quiting\n");
-
exit(
1);
//父进程5秒后终止
-
}
-
return
0;
-
}
启动监控脚本,查看到父进程退出后,子进程就变成了孤儿进程,但是子进程的PPID变成了1,即子进程的父进程变成了1号进程:
1号进程是什么进程呢?
1号进程是init进程,也叫做操作系统进程,当出现孤儿进程的时候,孤儿进程就会被1号int进程领养,当孤儿进程进入僵尸状态时,就由1号init进程回收。
为什么孤儿进程会被1号进程领养呢?
如果孤儿进程要退出时,需要被回收, 那么需要一个进程回收它,所以孤儿进程被1号init进程领养,也就能被1号init进程回收了。
七、进程优先级
1.概念
进程的优先级就是CPU资源分配的先后顺序 ,即进程的优先权,优先权高的进程有优先执行权力。
还有一些其他概念:
- 竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
- 独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰
- 并行: 多个进程在多个CPU下分别,同时进行运行,这称之为并行
- 并发: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发
2.为什么要有进程优先级
因为CPU资源是有限的,一个CPU只能同时运行一个进程,当系统中有多个进程时,就需要进程优先级来确定进程获取CPU资源的能力。
另外,配置进程优先权对多任务环境的linux很有用,可以改善系统性能。还可以把进程运行到指定的CPU上,这就把不重要的进程安排到某个CPU,可以大大改善系统整体性能。
3. 查看系统进程
使用:
ps -l
命令查看系统进程:
可以看到
- UID : 代表执行者的身份,表明该进程由谁启动
- PID : 代表这个进程的代号
- PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
- PRI :代表这个进程可被执行的优先级,其值越小越早被执行
- NI :代表这个进程的nice值
4.PRI和NI
- PRI是进程的优先级,也就是就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高
- NI就是nice值,表示进程可被执行的优先级的修正数值
- PRI值越小越快被执行,加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice
- 当nice值为负值时,该程序优先级值将变小,即其优先级会变高,则其越快被执行
- Linux下调整进程优先级,就是调整进程nice值
- nice其取值范围是-20至19,一共40个级别。
注意: nice值不是进程的优先级,是进程优先级的修正数据,会影响到进程的优先级变化。
5.使用top命令更改进程优先级
(1)更改NI值
先运行一个进程,使用
ps -l
查看进程号、优先级及NI值,比如执行./forkProcess_getpid进程:
可以查看到优先级为80,NI值为0:
在运行top命令之后,输入r,就会有PID to renice,此时输入进程号5255,再输入NI值,此处设为10:
然后查看进程的优先级和NI值,优先级变成了90,NI值变成了10:
说明优先级和NI值已经被改了。由此也能验证:
PRI(new) = PRI(old)+nice
PRI(old)一般都是80,这就是为什么没有修改NI值之前,用ps -al命令查看到的进程的PRI都是80的原因。
(2)NI的取值范围
现在验证一下NI(nice)的取值范围,假如将NI的值设为100:
再查看进程的优先级和NI值,发现NI值变成了19,优先级增加了19:
这说明NI的上限就是19,那么下限呢?此时PID变成了12452,
将NI值改为-100:
发现NI值变成了-20,说明本次 的NI值变成了-20,优先级减小了20:
这说明NI的取值范围为-20~19,一共40个级别。
(3)NI取值范围较小的原因
因为优先级再怎么设置,也只能是一种相对的优先级,不能出现绝对的优先级,否则会出现很严重的进程“饥饿问题”,即某个进程长时间得不到CPU资源,而调度器需要较为均衡地让每个进程享受到CPU资源。
八、环境变量
1.概念
环境变量(environment variables)指操作系统中用来指定操作系统运行环境的一些参数。例如:在编写C/C++代码的时候,在链接的时候,从来不知道所链接的动态静态库在哪里,但是照样可以链接成功,生成可执行程序,原因就是有相关环境变量帮助编译器进行查找。
2.常见环境变量
- PATH : 指定命令的搜索路径
- HOME : 指定用户的主工作目录(即用户登陆到Linux系统中时,默认的目录)
- SHELL : 当前Shell,它的值通常是/bin/bash。
3.如何查看环境变量
我们运行可执行程序时,都需要在可执行程序前面加上./才能执行:
但是在执行系统命令时,为什么不需要在前面加上./呢?
命令、程序、工具,本质都是可执行文件,./的作用就是帮系统确认对应的程序在哪里,由于环境变量的存在,所以执行系统命令时,不需要在系统命令前加./
查看环境变量的方法:
echo $PATH
系统通过PATH进行路径查找,查找规则就是,在PATH中先在第一个路径中找,找不到就在第二个路径中找,再找不到就在第三个路径中找……,如果找到了就不往下找了,直接将找到的路径下的程序运行起来,这就完成了路径查找。即系统执行命令时,操作系统通过环境变量PATH,去搜索对应的可执行程序路径。
如何让forkProgress执行时不带./,跟执行系统命令一样,有2种做法:
- 把forkProgress命令拷贝到以上6种任意一个路径里,不过这种做法不推荐,会污染命令池
- 把当前路径添加到PATH环境变量中
平时安装软件,就是把软件拷贝到系统环境变量中特定的命令路径下就完成了,安装的过程其实就是拷贝的过程。
不能直接把当前路径赋值给PATH,否则上面的6种路径就全没了。可以使用export导入环境变量:
export PATH=$PATH:程序路径
查找到 forkProcess的路径:
添加环境变量:
现在在其他路径下也可以执行该可执行程序了,比如在家目录下执行:
4.和环境变量相关的命令
环境变量的本质是操作系统在内存/磁盘上开辟的空间,用来保存系统相关的数据。在语言上定义环境变量的本质是在内存中开辟空间,存放key、value值,即变量名和数据。
- echo:显示某个环境变量值
- export:设置一个新的环境变量
- env:显示所有环境变量
- set:显示本地定义的shell变量和环境变量
- unset:清除环境变量
用echo显示某个变量的值:
export设置一个新的环境变量, 前面已经设置过了:
env显示所有环境变量:
set显示环境变量:
unset清除环境变量:
5.环境变量的组织方式
(1)环境表
每个进程在启动的时候都会收到一张环境表,环境表主要指环境变量的集合,每个进程都有一个环境表,用于记录与当前进程相关的环境变量信息。
环境表采用字符指针数组的形式进行存储,然后使用全局变量char** envrion来记录环境表的首地址,使用NULL表示环境表的末尾:
以前写c代码时,main函数可以带2个参数:
-
#include<stdio.h>
-
-
int main(int argc,char *argv[])
-
{
-
return
0;
-
}
其中第二个参数argv是指针数组,数组元素一共有argc个,argc决定了有几个有效命令行那个字符串。可以把命令行参数的细节打印出来:
-
#include<stdio.h>
-
-
int main(int argc,char *argv[])
-
{
-
int i =
0;
-
for(i =
0;i<argc;i++)
-
{
-
printf(
"argv[%d] = %s\n",i,argv[i]);
-
}
-
return
0;
-
}
命令行带参数运行:
命令行参数数组的元素个数是动态变化的,有几个参数就有对应的长度大小:
在命令行中传递的各种各样的数据最终都会传递给main函数,由main函数一次保存在argv中,由argc再表明个数 。
数组结尾是NULL,那么可以不使用argc吗?不可以,原因有两个:
- 作为数组传参,一般建议把个数带上
- 用户填参数到命令行,如果想限定用户输入命令行参数的个数,就要用到argc,例如:
-
if(argc != 5)
-
{
-
//TODO
-
}
-
命令行参数的作用在于,同一个程序可以用给它带入不同参数的方式来让它呈现出不同的表现形式或功能,例如:
实现一个程序,假如输入参数为o或e,就打印hello linux:
inputPara.c
-
#include<stdio.h>
-
#include<string.h>
-
#include<unistd.h>
-
int main(int argc,char *argv[])
-
{
-
-
if(argc !=
2)
//输入参数不为2时
-
{
-
printf(
"Usage: %s -[l|n]\n",argv[
0]);
-
return
1;
-
}
-
if(
strcmp(argv[
1],
"-l") ==
0)
//输入第二个参数为-l
-
{
-
printf(
"hello linux! -l\n");
-
}
-
else
if(
strcmp(argv[
1],
"-n") ==
0)
//输入第三个参数为-n
-
{
-
printf(
"hello linux -n\n");
-
}
-
else
-
{
-
printf(
"hello\n");
-
}
-
-
return
0;
-
}
输入不同的参数就有不同的执行结果:
命令行参数的意义在于,指令有很多选项,用来完成同一个命令的不同子功能。选项底层使用的就是命令行参数。
假如函数没有参数,那么可以使用可变参数列表去获取。
(2)获取环境变量
- 使用getenv获取环境变量
-
#include <stdlib.h>
-
-
char *getenv(const char *name);
获取PATH、HOME、SHELL这3个环境变量:
-
#include<stdio.h>
-
#include<stdlib.h>
-
-
int main()
-
{
-
printf(
"PATH:%s\n",
getenv(
"PATH"));
-
printf(
"HOME:%s\n",
getenv(
"HOME"));
-
printf(
"SHELL:%s\n",
getenv(
"SHELL"));
-
-
return
0;
-
}
如下:
- 使用命令行第3个参数获取环境变量
使用命令行第3个参数env获取环境变量:
env1.c
-
#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;
-
}
结果如下:
- 通过第三方变量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;
-
}
结果如下:
6.环境变量的全局属性
环境变量通常具有全局属性,可以被子进程继承。
如下代码:
geteEnvironment.c
-
#include<stdio.h>
-
#include<sys/types.h>
-
#include<unistd.h>
-
-
int main()
-
{
-
printf(
"pid = %d,ppid = %d\n",
getpid(),
getppid());
-
return
0;
-
}
发现每次运行该程序,子进程的ID都不相同,但是父进程的ID都相同
命令行上启动的进程,父进程都是bash,bash的环境变量是从系统里读的,系统的环境变量就在系统配置中,bash登陆时,bash就把系统的配置导入到自己的上下文当中。子进程的环境变量是系统给的,也就是父进程bash给的。环境变量一旦导出是可以影响子进程的
环境变量具有全局属性的原因是环境变量是可以被继承的。比如bash创建子进程后,子进程又创建了更多的子进程,相当于从bash开始,一个环境变量被设置,所有的子进程全都看到了bash环境变量,所有用户都可以获得这个环境变量,用这些环境变量做一些搜索查找等的任务,gcc和gdb能链接到各种库的原因是,他们都是命令,都是bash的子进程,bash的所有关于库路径的查找,头文件查找等各种全局设计都可以被这些命令找到,本质上是因为环境变量可以指导编译工具去进行相关查找,所以在编译程序时不用带很多选项,默认就能找到,能够让程序快速完成翻译和调试。
7.本地变量
与环境变量相对的还有本地变量,针对当前用户的当前进程生效,是一种临时变量,退出本次登陆后就失效了。
如下,变量value的值在没有退出登录前,打印到是5,ctrl+d退出登录后
再去echo $value发现value已经失效了
本地变量能被子进程继承吗?用env查看,发现shell的上下文中是没有的:
说明本地变量是不能被继承的,只能bash自己用。
现在使用getenv获取这个本地变量的环境变量:
getLocalValue.c
-
#include<stdio.h>
-
#include<stdlib.h>
-
-
int main()
-
{
-
printf(
"value = %d\n",
getenv(
"value")) ;
-
return
0;
-
}
运行之后发现,value变成了0,说明刚刚定义的value变量就是本地变量
把定义的value变量用export导成环境变量,实际上是导给了父进程bash的环境变量列表:
这时候用env查看,发现shell的上下文中有了:
这说明环境变量已经到给了父进程bash,bash中已经有了环境变量,./getLocalValue.c运行时,它的环境变量信息会继承自父进程,父进程现在多了一个环境变量,用env就能够获取成功了。
九、程序地址空间
1.程序地址空间分布
C/C++程序地址空间:
那么C/C++的程序地址空间是内存吗?为了验证它到底是什么,可以使用如下代码:
printfProcessAddress.c
-
#include<stdio.h>
-
#include<string.h>
-
#include<stdlib.h>
-
-
int g_UnValue;
-
int g_Value =
1;
-
-
int main()
-
{
-
const
char *string =
"hello world";
-
char *heap = (
char*)
malloc(
10);
-
int a =
5;
-
-
printf(
"code address:%p\n",main);
//代码区
-
-
printf(
"read only string:%p\n",string);
//字符常量区
-
printf(
"statck address:%p\n",&string);
//栈区
-
-
printf(
"uninit address:%p\n",&g_UnValue);
//未初始化全局变量区
-
printf(
"Init address:%p\n",&g_Value);
//已初始化全局变量区
-
-
printf(
"heap address:%p\n",heap);
//堆区
-
printf(
"stack address:%p\n",&heap);
//栈区
-
-
printf(
"stack a:%p\n",&a);
//栈区
-
-
return
0;
-
}
运行之后发现:
(1)代码区的地址0x40057d最小,说明在程序地址空间中,代码区在最下面;
(2)字符串常量区0x400710次之
(3)已初始化全局变量区0x60103c次之
(4)未初始化全局变量区0x601044次之
(5)堆区0x17e4010、0x17e4030次之,两个地址依次增大,说明堆是向上增长的
(6)栈区地址最大,并且3个栈地址是依次减小的:
先打印了高地址,最后打印了低地址, 这说明栈是向下增长的。
以上就完整还原了程序地址空间的地址分布。
2.程序地址空间是虚拟地址
先看一段下面的代码,子进程在运行过程中修改了全局变量的值:
printfFork.c
-
#include<stdio.h>
-
#include<string.h>
-
#include<unistd.h>
-
-
int g_Value =
1;
-
-
int main()
-
{
-
//发生写时拷贝时,数据是父子进程各自私有一份
-
if(fork() ==
0)
//子进程
-
{
-
int count =
5;
-
while(count)
-
{
-
printf(
"child,times:%d,g_Value = %d,&g_Value = %p\n",count,g_Value,&g_Value);
-
count--;
-
sleep(
1);
-
if(count ==
3)
-
{
-
printf(
"############child开始更改数据############\n");
-
g_Value =
5;
-
printf(
"############child数据更改完成############\n");
-
}
-
}
-
}
-
else
//父进程
-
{
-
while(
1)
-
{
-
printf(
"father:g_Value = %d,&g_Value = %p\n",g_Value,&g_Value);
-
sleep(
1);
-
}
-
}
-
-
return
0;
-
}
但是打印时却发现,同一个地址,g_Value值却不一样:
如果写时拷贝访问的是同一个物理地址的话,为什么得到的g_Value是不一样的值呢?所以程序地址空间使用的不是物理地址,而是虚拟地址。
C/C++中用到的都是虚拟地址,操作系统不会把物理内存暴露给用户,物理地址由操作系统统一管理,操作系统负责把虚拟地址转化成物理地址。在计算机刚启动时,操作系统没有加载,因此计算机就只能访问物理内存,操作系统启动之后,CPU正常运行,就进入了虚拟空间。
所以上面画的程序地址空间分布图不是物理地址,而是进程虚拟地址空间。
3.虚拟地址
进程地址空间本质上是操作系统内的一种数据结构类型,操作系统让每个进程都感受到自己在独占系统内存资源,每个进程都认为自己独占4GB空间。
(1)mm_struct
在创建一个进程时,进程的task_struct结构中包含了一个指向mm_struct结构的指针,用来描述进程虚拟地址空间,即用户看到的空间。mm_struct中包含装入的可执行映像信息和进程的页表目录指针pgd,通过页表将虚拟地址映射为实际的物理地址:
每个进程都认为mm_struct代表整个内存的地址空间。地址空间不仅能够形成区域,还能再各个区域中抽象出一个地址,因为这个地址是线性连续的。start和end就对应到数组下标,下标对应到虚拟地址。task_struct所看到的地址不是物理地址,而是虚拟地址。
每个进程都只有一个虚拟空间,这个虚拟空间可以被别的进程共享。
那么虚拟地址的作用是什么呢?
虚拟地址本质上在软件上为进程画了饼,让每个进程都感受到自己在独占资源。无论怎样画饼,最终都要能让进程访问地址数据,读取并执行代码进行计算。
(2)页表和MMU
页表是一种数据结构,记录页面和页框的对应关系,本质是映射表,增加了权限管理,隔离了地址空间,能够将虚拟地址转换成物理地址。操作系统为每个进程维护一张页表。
MMU(Memory Manage Unit)内存管理单元,是虚拟地址的整体空间,是对整个用户空间的描述。MMU一般继承在CPU当中。
所以进程的各个区包括代码区,已初始化区、未初始化区、堆区、栈区、共享区等都是虚拟地址,经过页表和MMU映射成对应的物理地址,再让进程去访问代码和数据。
(3)进程地址空间存在的原因
如果进程直接访问内存行不行呢?为什么中间要映射呢?
这是因为加了一个中间层有利于管理,防止进程的不合法行为。如果让一个进程直接去访问物理内存,可以访问自己的代码和数据,但这个进程也有可能访问修改别的进程的代码和数据,甚至有些恶意进程通过非法指针访问操作别的进程的代码和数据,这会带来严重问题,可能威胁到系统安全。
存在进程地址空间的原因:
① 通过添加一层软件层,完成有效的对进程操作内存进行风险管理(权限管理),本质的目的是为了保护物理内存及各个进程的数据安全。
现在在虚拟地址和物理内存中间加了一个页表,就相当于加了一个软件层,这个软件层是操作系统的代言人,页表和MMU在映射时,其实是操作系统在映射,能不能映射是由操作系统决定的,这就能做到权限管理。比如:
-
const
char *str =
"spring";
-
*str =
"summer";
//报错,不允许
修改str指向的变量的值时,是不被允许的。因为str是栈上的局部变量,但是spring在字符常量区,不可以修改,因为操作系统给你的权限只有r读权限。这就是为什么代码区内容是不可以修改的,字符常量区内容也不能修改的原因。因为页表是有权限管理的,给代码区和字符常量区分配的权限是r读权限。所以str指向的是虚拟地址,当要对*str进行写入的时候,访问的也是虚拟地址,这就需要操作系统进行虚拟地址和物理地址之间的转换,但是看到*str的权限是r读,不能写入,就把进程崩溃掉。
② 将内存申请和内存使用的概念在时间上划分清楚,通过虚拟地址空间,来屏蔽底层申请内存的过程,达到进程读写内存和操作系统管理操作进行软件层面上的分离的目的,让应用和内存管理解耦。
假如某个进程申请1000个字节,那么它能够马上使用这1000个字节吗?不一定,可能会存在暂时不会全部使用,甚至暂时不使用的情况。站在操作系统角度,如果把这1000个字节的空间马上给这个进程,那么就意味着,本来可以给别人马上用的空间,现在却被你闲置着。因此操作系统不会马上给这个进程分配1000个字节的物理内存空间,但是在进程虚拟地址空间上是批准了的。当进程马上要使用这1000个字节的空间时,进程就会告诉上层说已经申请1000个字节的空间了,准备要访问了,这是操作系统就会在物理内存中申请1000个字节的空间,这次的申请空间是透明的,然后把这实际的1000个字节的空间和进程申请的虚拟空间建立映射关系。
③ 站在CPU和应用层的角度,进程可以统一看作使用4GB空间,每个区域的相对位置是确定的,目的是让每个进程都认为自己独占着系统资源。
程序的代码和数据是要加载到物理内存的,操作系统需要知道main函数的物理地址。如果每个进程的main函数物理地址都不一样,那么对于CPU来说执行进程代码时,都要去不同的物理地址找main函数,这样很麻烦。每个进程的main函数物理起始地址可能都不相同,但是有了进程地址空间以后,就可以把main函数的物理起始地址,通过页表和MMU都映射成同一个虚拟空间地址,这样就把这一个虚拟空间地址和各个进程的物理地址建立起了映射关系。假如还要运行其它进程,就可以把其他进程的main函数其实地址映射到那个虚拟空间地址。这样CPU在读取进程的时候,main函数起始代码统一都从同一个起始位置去读,每个进程的main函数入口位置都可以找到。
另外,数据和代码可能在物理内存中不连续,而页表通过映射的方式把所有的代码区、已初始化全局数据区、未初始化全局数据区等映射到虚拟地址空间上时,可以把它们映射到连续的区域,形成线性区域。
4.写时拷贝
在printfFork.c的代码中,让子进程运行5秒,第3秒的时候,把g_Value的值改掉了,所以同一个地址子进程打印了2次1,后面的都是5了,父进程一直打印1,原因就是每个进程都有自己的页表,地址是虚拟地址而不是物理地址。
程序刚开始运行时,只有一个进程,即父进程, 父进程的pcb指向父进程的地址空间,全局变量定义出来的时候,子进程没有fork,g_Value对应已初始化区域的定义的全局变量,经过页表映射到物理内存上的g_Value
当fork创建子进程时,以父进程为模板为子进程创建新的pcb、地址空间和页表 。子进程把父进程的大部分内容都继承下来了,比如地址空间,子进程的地址空间、页表也都和父进程一样,所以创建子进程后,一开始子进程也指向了父进程的g_Value:
在第3秒的时候,子进程修改了g_Value的值,操作系统并没有让子进程直接把值改了,因为进程具有独立性,互不干扰。修改时发生写时拷贝,给子进程重新开辟一块物理空间,把g_Value变量值拷贝进来,再重新建立子进程的虚拟地址到物理地址之间的映射 :
因此看到的子进程和父进程打印的地址是一样的,是因为虚拟地址是一样的。值不一样的原因是在物理内存上本来就是不同的变量。
转载:https://blog.csdn.net/gx714433461/article/details/128102804