进程
基本概念
进程 = 内核数据结构(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()**改变当前路径
代码中获取父进程pid:getppid()
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. 地址转换中可以对地址和操作进行合法性判定以及拦截,保护物理内存
- 野指针
- 指针在访问内存时,OS发现页表中没有对应的映射,可能会杀掉该进程
- char *str = "hello world"; *str = 'H'; (权限拦截)
- 这段代码可以成功编译,但是无法运行
- 字符串储存在字符常量区,在正文代码和初始化区之间,被硬编码在代码中
- 字符常量区的权限是只读的,OS在查询页表时发现访问的区域为只读,所以页表转换失败
3. 让进程管理 和 内存管理解耦合
堆区域可以开辟多个空间,为什么?
- mm_struct内部的mmap维护了一个vm_area_struct的链表,将每一个堆空间串联起来管理
进程控制
创建
fork unistd库
调用fork函数时内核的工作:
- 分配新内存块和内核数据结构给子进程
- 父进程部分数据拷贝至子进程
- 添加子进程至系统进程列表
- fork返回,开始调度器调度
终止
进程退出的场景:
- 运行完毕,结果正确
- 运行完毕,结果错误
- 代码异常终止
父进程bash会获得子进程的退出码
使用 echo $? 可以打印获得最近一个程序(进程)的退出码
进程退出码会写入到task_struct中
系统中标准c提供了一整套退出码与字符串的对应,总共134个,strerror
遍历:
1 | for(int i = 0;i < 134;i++) |
对于错误结果的退出码可以直接return errno;来自动检测返回对应情况的代码
一旦代码出现异常,退出码无意义
程序退出的方法:
- main函数结束,进程结束
- c语言提供了**
exit()函数,调用等于main函数return
系统调用提供了_exit()**
两者最大区别:exit进程退出会刷新缓冲区(退出前将缓冲区内容打印,库级别缓冲区),_exit不会
等待
当子进程被杀掉,而父进程还未中止时,子进程会成为僵尸进程,占用内存造成内存泄漏
进程等待可以让父进程回收子进程,防止内存泄漏
1 | pid_t wait(int *status); |
第一种方法是阻塞式等待
第二种方法,在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 | int execl(const char* path,const char* arg,...); |
例如:
1 | execl("/usr/bin/ls","ls","-l",NULL); |
execlp
1 | int execlp(const char* file,const char* arg,...); |
execv
1 | int execv(const char* path,char* const argv[]); |
execvp
1 | int execvp(const char* file,char* const argv[]); |
execvpe
1 | int execvpe(const char* file,char* const argv[],char* const envp[]); |
execle
1 | 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
可以查看指定的当前运行的进程的信息