进程

基本概念

进程 = 内核数据结构(PCB) + 代码和数据

OS为了管理多个被加载到内存的程序,为每个程序创建PCB数据结构对象链入进程列表,对进程的管理就变成对链表的增删查改

  • 进程列表具体的对象是一个叫做 PCB 的结构体(Process Control Block 即 进程管理块)
  • 具体在linux中的PCB叫做task_struct
  • 进程的所有属性,都可以直接间接通过task_struct找到

基础命令/系统调用

程序获取进程id

sys/types.h 中的getpid()函数

top

查看系统中所有的进程,用q退出

ps

常用的参数搭配:

  • 查看系统中所有的进程
1
ps axj

通过**/proc 目录**同样可以查询到进程

使用管道 | grep 进程名,可以查看具体的进程

| head -1 打印属性表头

指令之间使用;或者&&可以同时执行多条指令:

1
ps axj | head -1; ps axj |  grep myprocess

因为grep本身也是进程,所以如果不想看到grep,可以用grep -v grep(-v表示取反)

kill

kill命令常用参数搭配:

用于杀掉指定pid的进程

1
kill -9 [pid]

进程理论知识/系统调用

每个进程执行时都有一个exe文件和一个cwd文件,前者是进程对应的可执行程序,后者用于记录当前路径
代码中可以使用**chdir()**改变当前路径

代码中获取父进程pidgetppid()

bash本身就是一个进程,每次登录都会分配一个bash进程

代码创建子进程:fork()

返回值类型是pid_t
当程序执行到fork()之后会创建子进程,fork之后的代码会同时被两个进程执行
如果成功,fork会将子进程的pid返回给父进程,并且将0返回给子进程;如果失败,返回-1
进程具有独立性,如果父进程被杀掉,子进程依然可以运行
父子任何一方对数据修改,OS会将该数据在底层拷贝,让目标进程修改该拷贝,该过程叫写时拷贝
父子共享同一份代码,但是数据以写时拷贝的方式各自私有一份,实现进程的独立

进程状态就是task_struct内部的一个整数

在linux中,一个pcb节点既可以属于内存的全局链表也可以属于调度队列
每个cpu都有自己的调度队列(runqueue),遵循FIFO(调度算法之一),先出的优先级高

!!!进程状态的变化,表现之一就是在不同队列中流动,本质都是数据结构的增删查改

进程切换原理:

  • 寄存器中的临时数据保存的是进程的上下文数据
  • 当进程被切换走的时候,会将当前寄存器的临时数据拷贝(task_struct)带走,新的进程将自己的数据覆盖至寄存器,系统通过pcb找到TSS(任务状态段)内具体的进程上下文数据

优先级:

  • linux的优先级总共由140个,前[0,99]是实时优先级,宏观上进程调度是从上到下遍历,局部上对同优先级的进程先进先出

进程状态

状态分类

状态 解释
运行状态 只要进程在调度队列中,就是运行状态
阻塞状态 等待某种设备或资源就绪
进程阻塞就是将pcb从调度队列取出,链接到等待就绪的硬件结构体节点,
硬件发生变化os将该pcb重新入队到调度队列,成为运行状态
阻塞挂起 当内存不足时,OS将不会被调度的进程或代码块唤出到磁盘上,只在内存保留pcb
当OS检测到硬件发生变化的时候,将对应的进程或代码块唤入到内存,此时将该进程改变为运行状态
这种操作叫做swap交换分区的唤入唤出操作
运行(就绪)挂起 阻塞挂起之后如果内存依然吃紧,OS会将调度队列末端的进程唤出,当真正调度的时候再唤出

进程状态的表示

task_state_array中的进程状态
	R - running(运行状态) 0 
	S - sleep(阻塞状态/可中断休眠/浅睡眠) 1 可以被ctrl+c杀掉
	D - disk sleep(不可中断休眠/深睡眠)
	T - stopped(暂停) 4 运行时ctrl+z用户暂停
	t - tracing stop(暂停) 8 调试的断点
	x - dead
	z - zombie(僵尸进程)

特殊进程状态

状态 解释
孤儿进程 当父进程被杀掉,其子进程会被bash接管,在后台运行,这种子进程被称为孤儿进程
僵尸进程 当子进程结束但是未被父进程正常回收,此时子进程会成为僵尸进程,为了后续进程的创建更快,该进程的pcb会暂时被保留

进程地址空间

基础概念 (重要)

一个进程有一个虚拟地址空间
一个进程有一套页表
页表是用来做虚拟地址和物理地址的映射的

父进程创建子进程的时候,子进程也会有自己的进程地址空间和页表,默认情况下父进程的页表拷贝至子进程,所以父进程和子进程页表中的地址映射都指向同样的物理地址(内存),即浅拷贝(指针拷贝),这就实现了共享

当子进程要对变量做修改时,操作系统会拷贝该变量并开辟一个新的地址空间,新的地址覆盖子进程页表中的旧地址,变量修改在新的地址中独立发生,变量在父子进程中虚拟地址一样而物理地址不同,这称为写时拷贝

  • 写时拷贝就解决了为什么 pid_t id = fork(); 中fork可以同时给父子进程返回不同的值

虚拟进程空间本质是一个数据结构:mm_struct

- 是在内核中给进程创建的结构体对象
- 内部对每一个区域用start和end分别作为空间划分

程序加载到内存:

  1. 在虚拟地址空间中申请指定大小的空间

  2. 加载程序,申请物理空间

    由1和2 在页表进行映射,实现物理地址和虚拟地址的转换,内存地址空间初始化的值很大一部分来自于程序加载至内存时而来

进程地址空间存在原因:

1. 将地址从无无序变得有序
2. 地址转换中可以对地址和操作进行合法性判定以及拦截,保护物理内存
- 野指针
  - 指针在访问内存时,OS发现页表中没有对应的映射,可能会杀掉该进程
- char *str = "hello world"; *str = 'H'; (权限拦截)
  - 这段代码可以成功编译,但是无法运行
  - 字符串储存在字符常量区,在正文代码和初始化区之间,被硬编码在代码中
  - 字符常量区的权限是只读的,OS在查询页表时发现访问的区域为只读,所以页表转换失败
3. 让进程管理 和 内存管理解耦合

堆区域可以开辟多个空间,为什么?

  • mm_struct内部的mmap维护了一个vm_area_struct的链表,将每一个堆空间串联起来管理

进程控制

创建

fork unistd库

调用fork函数时内核的工作:

  1. 分配新内存块和内核数据结构给子进程
  2. 父进程部分数据拷贝至子进程
  3. 添加子进程至系统进程列表
  4. fork返回,开始调度器调度

终止

进程退出的场景:

  • 运行完毕,结果正确
  • 运行完毕,结果错误
  • 代码异常终止

父进程bash会获得子进程的退出码
使用 echo $? 可以打印获得最近一个程序(进程)的退出码
进程退出码会写入到task_struct

系统中标准c提供了一整套退出码与字符串的对应,总共134个,strerror
遍历:

1
2
3
4
for(int i = 0;i < 134;i++)
{
printf("%d->%s\n"),i,strerror(i)
};

对于错误结果的退出码可以直接return errno;来自动检测返回对应情况的代码

一旦代码出现异常,退出码无意义

程序退出的方法:

  1. main函数结束,进程结束
  2. c语言提供了**exit()函数,调用等于main函数return
    系统调用提供了
    _exit()**
    两者最大区别:exit进程退出会刷新缓冲区(退出前将缓冲区内容打印,库级别缓冲区),_exit不会

等待

当子进程被杀掉,而父进程还未中止时,子进程会成为僵尸进程,占用内存造成内存泄漏
进程等待可以让父进程回收子进程,防止内存泄漏

1
2
pid_t wait(int *status);
pid_t waitpid(int pid,int *status,int options);

第一种方法是阻塞式等待
第二种方法,在waitpid(-1,&status,0);的时候等待任意一个子进程结束,并且不关心子进程的退出状态信息

status是输出型参数,储存的32位整型
低16位中次8位存储的是进程的退出码,第7位存储的是一个 core dump 标志位,一般为0
低7位存储的是异常信号,linux查看所有异常信号可以用指令:kill -l
异常信号从1开始排序,为0时表示无异常。所以 低7位!0 时,退出码无意义
获取异常信号需要 status & 0x7F

waitpid的options参数为WNOHANG时是非阻塞等待

替换

使用execl函数用于实现进程替换,进程替换仅仅替换的是当前进程的 代码和数据
进程替换中没有产生新的进程,替换成功后,源程序execl以后的代码不会被执行
exec系列函数,执行错误的时候返回-1,成功没有返回值

linux中脚本语言需要用解释器来执行
如果不用解释器,需要为文件添加可执行权限,执行时os自动调用解释器

注意:

  • const char与char const的区别
  • 前者指向的是常量,不可修改所指向的数据,但是可以修改指针的指向
  • 后者指针本身是常量,可以修改所指向的数据,但是不能修改指针的指向

主要接口

execl
1
2
3
4
5
int execl(const char* path,const char* arg,...);
// l就是list的意思
// path:路径+程序名
// ...:可变参数列表,最后一个参数必须以NULL结尾
// 命令行怎么写,参数就怎么传,参数以list传入

例如:

1
2
execl("/usr/bin/ls","ls","-l",NULL);
// 第一个ls表示要调用谁,第二个表示如何调用,两者相同不会冲突
execlp
1
2
3
4
int execlp(const char* file,const char* arg,...);
// p就是PATH的意思
// file:不用传入路径,直接传文件名,程序会自动在环境变量查询
// 后面的参数同execl
execv
1
2
3
4
int execv(const char* path,char* const argv[]);
// v就是vector
// path:同execl
// argv:命令行参数表,即指针数组
execvp
1
2
3
int execvp(const char* file,char* const argv[]);
// 具体参考execv和execlp
// 默认将全局环境变量指针environ传入,其次在创建子进程时会将父进程的环境变量拷贝
execvpe
1
2
3
int execvpe(const char* file,char* const argv[],char* const envp[]);
// v-vector; p-PATH; e-environment
// 传入env数组,即要求被替换子进程使用全新的环境变量
execle
1
2
int execle(const char* path,const char* arg,...,char* const envp[]);
// 使用情况参考上述函数
execve

以上全是语言封装,这是唯一的系统调用

1
int execve(const char* filename,char* const argv[],char* const envp[]);
putenv

1
int putenv(char* string);

为当前进程添加一个环境变量
谁调用的就为谁添加,适合搭配上述接口,在父进程创建全局环境变量,然后在子进程中调用,接着进程替换
可以实现在原有的环境变量上添加新的变量传给子进程,而不会要求子进程使用全新的环境变量
添加之后对于execvpe的envp参数直接传入environ全局变量指针即可,(需extern char** environ声明)

相关系统命令

ls /proc/[进程id] -l

可以查看指定的当前运行的进程的信息