本篇博客整理了进程的多方面知识, 旨在从进程的概念、管理、属性、创建等方面让读者更加全面系统地理解进程和操作系统的管理设计。
目录
一、进程是什么
二、操作系统如何管理进程
1.描述进程 PCB
2.组织进程
3.再谈进程和进程管理
三、Linux下的进程管理
1. task_struct
2.查看进程属性
2.1-进入系统目录查看
2.2-使用指令ps查看
四、进程的PID和PPID
1.系统调用接口获取进程PID
2.父进程与子进程
五、创建子进程
1.系统调用接口 - fork()
2.fork()的相关细节
2-2.fork()返回值的疑难问题
2.3-子进程创建后,父子进程谁先运行?
2.4-再谈 bash
六、操作系统中的进程状态
1.运行状态
2.阻塞状态
3.挂起状态
七、Linux下的进程状态
1.D状态
2.T状态
3.僵尸进程
4.孤儿进程
八、进程优先级
1.查看进程优先级
2.PRI 与 NI
3.修改进程优先级
4.优先级的实现原理
九、环境变量
1.环境变量的相关指令
2.部分列举
2.1-PATH
2.2-HOME
2.3-SHELL
3.环境变量的组织方式
4.代码中/进程中获取环境变量
补-命令行参数
4.1-通过main()的第三个参数获取
4.2-通过标准库的全局变量来获取
4.3-通过系统调用获取
补、环境变量可继承
补、常规命令与内建命令
十、进程地址空间
1.语言层面的地址空间
2.虚拟地址
3.进程地址空间
4.写时拷贝的一些迷思
一、进程是什么
任何一个程序想要运行,必须先加载到内存中(详见:【Linux系统】冯•诺依曼体系结构与操作系统-CSDN博客),而一个已经被操作系统加载到内存中的正在运行的程序,就叫进程(或称任务)。
每个可执行程序都是一个二进制文件,而文件是在磁盘的。在冯·诺依曼体系结构中,磁盘是一种外设,可以与内存、CPU进行数据交互。一个程序要运行起来,必须由CPU来运行,而CPU只能从内存中拿数据,这样就必须让程序先从磁盘加载到内存中。于是,当这个程序从磁盘加载到内存中时,这个程序就成为了一个进程。
在Windows下,可以通过任务管理器(快捷键:Ctrl + Alt + . )来查看计算机当前的进程。
而在Linux下,可以通过指令 “ps axj” 或指令 “top” 来查看当前进程的信息。
一个计算机可以同时存在多个进程,这给用户的直观感受是,可以同时使用多个软件。
而操作系统也是一款软件,也是磁盘中的程序,要运行也必须加载到内存。那么,操作系统是如何加载到内存的呢?
其实,计算机在开机时,操作系统就开始运行了。在按下开机键的那一刻,BIOS(基本输入输出系统)芯片就开始启动。它是一个在CPU运行的可执行程序,可以加载计算机的基本资源,然后把操作系统加载到内存中。在BIOS开始工作时,它会通电唤醒CPU、IO接口、内存等资源,接着加载硬件资源——网卡,显卡,磁盘等;接着,它会通过操作系统的引导文件,将操作系统从磁盘加载到内存中,从而让操作系统开始管理软硬件资源。
操作系统要管理软硬件资源,那么操作系统中就可能同时存在着大量的进程,也就是说,操作系统其实是一个管理进程的进程,为了计算机能够持续给用户提供计算服务,它不仅要把程序文件加载到内存,还要对进程做管理。
那,操作系统是如何管理进程的呢?
二、操作系统如何管理进程
操作系统是如何管理进程的呢?——答案很简单,先描述,再组织!
操作系统可以管理软硬件资源,对硬件的管理是先描述再组织(详见:【Linux系统】冯•诺依曼体系结构与操作系统-CSDN博客),对软件的管理也是先描述再组织。具体做法是先用结构体将进程的属性封装起来,再将结构体构建成一种数据结构,如此一来,操作系统对进程的管理就转变成了对数据结构的增删查改。
1.描述进程 PCB
在操作系统中,描述进程的struct结构体被称为 PCB (Process Ctrl Block)。
任何程序被加载到内存成为真正的进程时,操作系统首先要为每一个进程创建一个封装了进程属性的PCB对象,这个对象也叫做进程控制块(本质上就是进程属性的集合),其中有一个进程的各类信息。
所有操作系统中的进程控制块都被叫做PCB,且在不同的平台下,PCB的具体实现是有差异的,其中,Linux下的PCB是task_struct。
2.组织进程
操作系统为每一个进程创建完PCB对象后,会将这些PCB对象组织起来,构建出一个双向链表的数据结构。
所有进程都是以PCB对象构建的双向链表的形式保存在操作系统内核里的,操作系统只要能获取这个双向链表的指针,便可以访问到所有的PCB对象,这样一来,对进程的管理就转变成了对双向链表的增删查改。
例如,运行一个程序,实际上就是操作系统先将程序的文件加载到内存使其成为进程,接着为这个进程创建相应的PCB对象,并将这个PCB对象插入到一个双向链表中;而退出一个程序,实际上就是操作系统先将运行中的程序,或者说进程,它的PCB对象从双向链表中删除,然后将这个进程在内存中所对应的文件进行释放或置为无效。
3.再谈进程和进程管理
可执行程序要运行,必须先加载到内存,而加载到内存的,本质是将代码和数据,这说明一个进程一定有它所对应的代码和数据;操作系统为了管理进程,会为进程创建PCB对象用来描述进程的属性——也就是说,一个进程必须具备两样事物:代码和数据 + PCB对象(其实就是文件内容+文件属性)。
操作系统在管理进程的时候,只关心进程的PCB对象,而不关心进程的代码和数据。
三、Linux下的进程管理
1. task_struct
task_struct 是 Linux 操作系统下的 PCB 结构,它是 Linux 内核的一种类型,会被装载到 RAM(内存)里,并且包含着进程的信息:
- 标识符:描述一个进程的唯一标识符,用来区分不同进程。
- 状态:任务状态,退出码,退出信号等。
- 优先级:相对于其他程序进程的优先级。
- 程序计数器:程序中即将被执行的下一条指令的地址。
- 内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
- 上下文数据:进程执行时处理器的寄存器中的数据。
- I/O状态信息:包括显示的 I/O 请求,分配给进程的 I/O 设备和被进程使用的文件列表。
- 记账信息:可能包括处理时间总和,使用的时钟数总和,时间限制,记账号等。
- 其他......
【补】task_struct的源码:
struct task_struct {
volatile long state; //说明了该进程是否可以执行,还是可中断等信息
unsigned long flags; //Flage 是进程号,在调用fork()时给出
intsigpending; //进程上是否有待处理的信号
mm_segment_taddr_limit; //进程地址空间,区分内核进程与普通进程在内存存放的位置不同//0-0xBFFFFFFF foruser-thead//0-0xFFFFFFFF forkernel-thread
//调度标志,表示该进程是否需要重新调度,若非0,则当从内核态返回到用户态,会发生调度
volatilelong need_resched;
int lock_depth; //锁深度
longnice; //进程的基本时间片
//进程的调度策略,有三种,实时进程:SCHED_FIFO,SCHED_RR,分时进程:SCHED_OTHER
unsigned long policy;
struct mm_struct *mm; //进程内存管理信息
int processor;
//若进程不在任何CPU上运行, cpus_runnable 的值是0,否则是1这个值在运行队列被锁时更新
unsigned long cpus_runnable, cpus_allowed;
struct list_head run_list; //指向运行队列的指针
unsigned longsleep_time; //进程的睡眠时间
//用于将系统中所有的进程连成一个双向循环链表,其根是init_task
struct task_struct *next_task, *prev_task;
struct mm_struct *active_mm;
struct list_headlocal_pages; //指向本地页面
unsigned int allocation_order, nr_local_pages;
struct linux_binfmt *binfmt; //进程所运行的可执行文件的格式
int exit_code, exit_signal;
intpdeath_signal; //父进程终止是向子进程发送的信号
unsigned longpersonality;
//Linux可以运行由其他UNIX操作系统生成的符合iBCS2标准的程序
intdid_exec:1;
pid_tpid; //进程标识符,用来代表一个进程
pid_tpgrp; //进程组标识,表示进程所属的进程组
pid_t tty_old_pgrp; //进程控制终端所在的组标识
pid_tsession; //进程的会话标识
pid_t tgid;
intleader; //表示进程是否为会话主管
struct task_struct*p_opptr,*p_pptr,*p_cptr,*p_ysptr,*p_osptr;
struct list_head thread_group; //线程链表
struct task_struct*pidhash_next; //用于将进程链入HASH表
struct task_struct**pidhash_pprev;
wait_queue_head_t wait_chldexit; //供wait4()使用
struct completion*vfork_done; //供vfork()使用
unsigned long rt_priority; //实时优先级,用它计算实时进程调度时的weight值//it_real_value,it_real_incr用于REAL定时器,单位为jiffies,系统根据it_real_value
//设置定时器的第一个终止时间.在定时器到期时,向进程发送SIGALRM信号,同时根据
//it_real_incr重置终止时间,it_prof_value,it_prof_incr用于Profile定时器,单位为jiffies。
//当进程运行时,不管在何种状态下,每个tick都使it_prof_value值减一,当减到0时,向进程发送
//信号SIGPROF,并根据it_prof_incr重置时间.
//it_virt_value,it_virt_value用于Virtual定时器,单位为jiffies。当进程运行时,不管在何种
//状态下,每个tick都使it_virt_value值减一当减到0时,向进程发送信号SIGVTALRM,根据
//it_virt_incr重置初值。
unsigned long it_real_value, it_prof_value, it_virt_value;
unsigned long it_real_incr, it_prof_incr, it_virt_value;
struct timer_listreal_timer; //指向实时定时器的指针
struct tmstimes; //记录进程消耗的时间
unsigned longstart_time; //进程创建的时间
//记录进程在每个CPU上所消耗的用户态时间和核心态时间
longper_cpu_utime[NR_CPUS],per_cpu_stime[NR_CPUS];
//内存缺页和交换信息:
//min_flt, maj_flt累计进程的次缺页数(Copyon Write页和匿名页)和主缺页数(从映射文件或交换
//设备读入的页面数);nswap记录进程累计换出的页面数,即写到交换设备上的页面数。
//cmin_flt, cmaj_flt,cnswap记录本进程为祖先的所有子孙进程的累计次缺页数,主缺页数和换出页面数。
//在父进程回收终止的子进程时,父进程会将子进程的这些信息累计到自己结构的这些域中
unsignedlong min_flt, maj_flt, nswap, cmin_flt, cmaj_flt, cnswap;
int swappable:1; //表示进程的虚拟地址空间是否允许换出
//进程认证信息
//uid,gid为运行该进程的用户的用户标识符和组标识符,通常是进程创建者的uid,gid
//euid,egid为有效uid,gid
//fsuid,fsgid为文件系统uid,gid,这两个ID号通常与有效uid,gid相等,在检查对于文件
//系统的访问权限时使用他们。
//suid,sgid为备份uid,gid
uid_t uid,euid,suid,fsuid;
gid_t gid,egid,sgid,fsgid;
int ngroups; //记录进程在多少个用户组中
gid_t groups[NGROUPS]; //记录进程所在的组
//进程的权能,分别是有效位集合,继承位集合,允许位集合
kernel_cap_tcap_effective, cap_inheritable, cap_permitted;
int keep_capabilities:1;
struct user_struct *user;
struct rlimit rlim[RLIM_NLIMITS]; //与进程相关的资源限制信息
unsigned shortused_math; //是否使用FPU
charcomm[16]; //进程正在运行的可执行文件名//文件系统信息
int link_count, total_link_count;
//NULL if no tty进程所在的控制终端,如果不需要控制终端,则该指针为空
struct tty_struct*tty;
unsigned int locks;
//进程间通信信息
struct sem_undo*semundo; //进程在信号灯上的所有undo操作
struct sem_queue *semsleeping; //当进程因为信号灯操作而挂起时,他在该队列中记录等待的操作
//进程的CPU状态,切换时,要保存到停止进程的task_struct中
structthread_struct thread;//文件系统信息
struct fs_struct *fs;//打开文件信息
struct files_struct *files;//信号处理函数
spinlock_t sigmask_lock;
struct signal_struct *sig; //信号处理函数
sigset_t blocked; //进程当前要阻塞的信号,每个信号对应一位
struct sigpendingpending; //进程上是否有待处理的信号
unsigned long sas_ss_sp;
size_t sas_ss_size;
int (*notifier)(void *priv);
void *notifier_data;
sigset_t *notifier_mask;
u32 parent_exec_id;
u32 self_exec_id;spinlock_t alloc_lock;
void *journal_info;
};
Linux 内核中, task_struct 基本是被构建成了一个双向链表。
//task_struct的源码节选://...
//用于将系统中所有的进程连成一个双向循环链表,其根是init_task
struct task_struct *next_task, *prev_task;
//...
但实际上一个 task_struct 对象不仅仅属于一个双向链表,还可能存在于多个数据结构中。
2.查看进程属性
2.1-进入系统目录查看
在根目录下有一个名为proc的目录文件,其中存放了包含大量的进程信息子目录。
proc 是 Linux 系统下的一个特别的目录。关机后,目录里面的信息会全部消失;而开机时,操作系统会在其中创建目录和文件。
proc 目录下的所有信息都是 Linux 操作系统以文件系统的方式,把内存中的文件(包括进程信息)可视化了,其实它当中的数据都是内存级的。
1
2
有子目录的名字为数字,而这些数字其实是一些进程的PID,相应PID的进程的各种信息被保存在相应的子目录下。例如,想查看PID为1的进程的信息,直接进入名字为1的目录即可。
进程子目录下的.exe文件是一个链接文件,指向当前进程所对应的可执行程序的路径,就相当于将 task_struct 对象中的内存指针给可视化了。
cwd 表示该进程的工作目录,即进程所对应的可执行程序所在的目录。一般在一个程序中打开或创建一个文件的时候,如果只写了文件名,这个程序就会默认在当前目录下查找文件,或在当前目录下创建文件,这其实是因为进程的PCB对象中存的有当前目录的路径信息。
2.2-使用指令ps查看
单独输入“ps axj”或“ps aux”,会显示所有的进程信息。
而指令ps搭配指令grep,就可以指定显示一个进程的信息。
ps axj | head -1 && ps axj | grep + 进程名/可执行程序名
四、进程的PID和PPID
上文提到,PID 是进程的属性之一,用来唯一地标识一个进程。
由于进程的属性是操作系统在维护的,这些属性信息被封装成一个 task_struct对象保存在操作系统内核中,且用户无法直接访问操作系统内核,所以用户就无法直接获取进程的属性信息,而要通过系统调用接口或上文提到的 ps 指令(内部封装了系统调用接口)去获取进程的属性信息。
1.系统调用接口获取进程PID
要获取进程的 PID ,可以使用系统调用接口getpid()——
- 返回值:调用了getpid()的进程的 PID
- 返回类型: pid_t
为了方便测试getpid()的效果,此处引入以下这段代码:
//test.c #include <stdio.h> #include <unistd.h> #include <sys/types.h> int main() { printf("My PID is:%d\n",getpid()); return 0; }
2.父进程与子进程
输入指令“ps axj”的时候,既可以看到进程的PID,还可以看到PPID。PID 是用来唯一地标识一个进程的,而这个PPID又是什么呢?
一个进程的PPID,可以使用系统调用接口getppid()来获取——
- 返回值:调用了getppid()的进程的 PID
- 返回类型: pid_t
为了方便演示,此处引入以下这段代码:
//test_2.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{ while(1) { printf("My PID is:%d ;My PPID is:%d\n",getpid(),getppid()); sleep(1); } return 0;
}
编译完test_2.c文件后,就直接执行程序,不过特别的是,执行第一次后,终止程序后又执行第二次次。
从上图的运行结果可知,程序的两次执行,PID是不同的,但PPID是相同的。
PID是不同的,这是因为,一个进程终止后再重新启动,操作系统系统会给它重新分配一个 PID;PPID是相同的,这是由于父子进程的存在所导致的。
其实,19881是bash 进程的 PID。
bash 是Linux中的命令行解释器,能够将用户输入的指令翻译给操作系统核心,方便核心去处理指令。每一个指令本质上都是一个可执行程序,当用户在命令行输入指令的时候,bash命令行解释器会为用户创建一个子进程去执行这个指令,即使子进程出现问题了也不会影响到父进程到。
在上文的演示中,test_2就是一个子进程,bash命令行解释器就是一个父进程。一个进程终止后再重新启动,操作系统系统会给它重新分配一个 PID,所以test_2每次执行时,PID是不同的;而test_2的父进程始终是bash命令行解释器,所以test_2每次执行时,PPID是相同的。只有用户退出Linux再重新登陆一次,系统才会重新为分配一个 bash 进程,PPID才会改变。
而PPID其实就是一个进程的父进程的PID。
五、创建子进程
1.系统调用接口 - fork()
上文中,创建子进程都是bash命令行解释器为指令或源代码去创建的,其实,还可以通过系统调用接口fork(),在一个进程的内部手动创建一个子进程——
- 返回值:如果子进程创建成功,就有两个返回值(为了区分父子进程),一个是大于0的正数(其实是子进程的PID,表示父进程),一个是0(表示子进程);如果子进程创建失败,则在原本的进程中返回 -1。
- 返回类型: pid_t 。
为了演示fork()能够在一个进程的基础上又创建一个新的进程,此处引入以下代码:
//test_3.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{ printf("before:only one line\n"); fork(); printf("after:only one line\n"); return 0;
}
由上图的结果可知,“ fork(); ”后的代码“ printf("after:only one line\n"); ”被执行了两次,也就是说,在 “ fork(); ”之前只有一个执行流,而“ fork(); ”之后就变成了两个执行流。
这说明fork()在原本进程的基础上又创建了一个新的进程。
为了演示fork()有两个返回值,此处引入以下代码:
//test_4.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{ printf("我是一个进程,pid:%d, ppid:%d\n",getpid(), getppid()); printf("Here we begin:"); pid_t id = fork(); if(id > 0) { while(1) { printf("我是父进程,pid:%d,ppid:%d\n",getpid(),getppid()); sleep(1); } } else if(id == 0) { while(1) { printf("我是子进程,pid:%d,ppid:%d\n",getpid(),getppid()); sleep(1); } } else { perror("子进程创建失败!\n"); } return 0;
}
由上图的结果可知,id 大于0的情况和 id 等于0的情况同时存在了, if 和 else if的条件判断同时满足,于是有两个死循环在同时执行着。由于在同一个进程中, if 和 else if 的条件判断是不可能同时满足的,所以此时一定存在两个进程,即原本的 test_4 进程,和在 test_4 进程中创建的子进程。
而这也符合 fork()创建子进程的目的——fork()创建子进程后,原来的执行流会从一个变成两个。
2.fork()的相关细节
2-1.fork()具体是怎么创建一个子进程的?
进程 = 封装了进程属性的PCB对象 + 代码和数据。执行一个进程,其实执行的是它的代码和数据,于是,就需要先通过它的PCB对象中找到它的代码和数据,再执行它的代码和数据。
操作系统会为每个进程创建它所对应的PCB对象,以便对进程做管理。在一个进程的基础上调用fork()创建新的子进程,也就相当于在操作系统中又增加了一个进程,这个新创建的子进程也一定有它对应的 PCB对象。其实在新创建的子进程的PCB对象中,封装的大部分属性信息都是以父进程的 PCB 对象为蓝本,直接从父进程的PCB对象中拷贝过来的,剩余的属性信息则是是按需稍作修改。
尽管子进程在创建后,会拥有属于自己的PCB对象,但是它并没有自己的代码和数据,而是使用父进程的代码,所以fork()所在的语句之后,代码是被父子进程共享的(注:子进程只会与父进程共享代码,不一定会拥有与父进程相同的数据,如果有,也有的是一份拷贝,而不是共享的同一份;由于进程之间的独立性,子进程绝不会与父进程共享同一份数据)。
2-2.fork()返回值的疑难问题
- 为什么子进程创建成功后,fork()有两个返回值?
有fork()存在的代码一般是父子共享的。
fork()返回不同的返回值,是为了区分不同的执行流,让不同的执行流去执行不同的代码块,换句话说,就是为了区分父进程和子进程,让父进程和子进程去执行不同的任务。
创建子进程的目的,就是协助父进程完成一些工作。如果返回值是相同的,那 有fork()存在的代码父子都会执行,创建子进程本身就失去了意义。
- 为什么fork()给子进程返回0,给父进程返回子进程的pid?
以现实生活为例,一个父亲可以有多个孩子,而一个孩子一定只有一个父亲。父亲给每个孩子都取了不同的名字,名字就成了一个区分孩子的属性。
进程也一样,一个父进程可以创建多个子进程,而一个子进程一定只有一个父进程,父进程拿着不同子进程的pid,可以识别和区分每一个子进程,正确地对每一个子进程做管理。
而子进程只需拿到0,标识子进程创建成功,且能够跟父进程作区分即可。
- 子进程创建成功后,fork()是如何返回两个返回值的?
父子进程会共享同一份代码,fork()中的return语句也不例外。
在fork()的代码执行到return语句之前,子进程的PCB就已经被创建出来了,使得父子进程都可以被CPU执行调度,所以父进程会执行 return语句返回一个值,而子进程也会执行 return语句返回一个值。fork()创建子进程后,父子进程分别能够执行一次return语句,于是使fork()看上去返回了两个返回值,看上去是一个进程返回两个返回值,实际上是两个进程分别返回了一个返回值。
- 为什么接收fork()返回值的一个变量(上文代码中为id)会有不同的值?
任何平台下,进程在运行时都是具有独立性的,也就是说,一个进程不论是正常退出还是异常终止,都不会影响其他进程的运行。
父进程和子进程也是两个进程,彼此之间也是相互独立的。共享代码并不影响进程之间的独立性,因为代码在加载到内存之后是不会发生改变的;然而数据在程序执行的过程中可能被修改,如果父子进程共享了同一份数据,数据一旦其中一个进程被修改,另一个进程也会受到影响,这就破坏了进程之间的独立性。因此,父子进程禁止共享同一份数据。
而如果子进程需要拥有和父进程一样的数据,操作系统会在执行数据的层面,通过写时拷贝来帮子进程拷贝数据。
这是因为,将父进程的数据全部拷贝一份代价较大,且子进程不会用到拷贝的所有数据,这就会造成资源的浪费;于是,大部分操作系统会选择让子进程与父进程共享父进程的代码和数据,但实际上代码是真的共享,数据却不是真的共享——当子进程需要修改父进程的某一数据时,操作系统会及时检测和制止子进程,并为子进程单独开辟一块空间,按需拷贝一份数据让子进程去修改。
这种用多少就拷贝多少的方法就叫作写时拷贝,能够大大提高系统资源的利用率。
fork()中的return语句在父进程执行一次,子进程执行一次,但子进程执行的时候会发生写时拷贝,所以一个变量(上文代码中为id)在接收fork()返回值之后,其实对应了两块不同的内存空间。
至于为什么同一个变量名能够让父子进程获取到不同的值,这与下文中的进程地址空间有关。
2.3-子进程创建后,父子进程谁先运行?
一般一个进程被创建出来,什么时候被运行,是由操作系统来决定的,用户是决定不了的,用户只管使用即可。
操作系统会在底层去调度每一个进程,而父进程和子进程究竟谁先运行,其实是由调度器来决定的,所以“谁先运行”并无定论。
【补】普通的计算机中只有一个 CPU,进程却可能有很多个,这就意味着CPU其实是一个稀有的资源,而进程必须放到 CPU 上才能执行,所以每个进程之间其实也是一种竞争关系,进程的调度也就需要一个专门的角色——调度器来保证每个进程能够被公平调度。
2.4-再谈 bash
命令行解释器 bash,本身就是一个进程,自己有自己的工作要做。
用户在命令行上输入的指令,本质上是一个个可执行程序,最终也会被加载到内存中变成进程,做各自的工作。
因此在 bash 的源代码中,一定会调用 fork() 去创建子进程,好让自己继续去执行命令行解释的工作,而让子进程去执行用户的指令。
也就是说,用户在命令行输入的所有指令,成功执行后,其实都是命令行解释器 bash的子进程。
六、操作系统中的进程状态
进程状态也是进程的属性之一,指在操作系统中,一个进程所处的不同运行状态,进程状态就决定了该进程接下来要执行什么任务。
一个进程从创建到终止的整个生命期间,有时可能占有CPU在运行,有时可以运行但分不到CPU资源,有时可能有CPU资源但因等待时间而没有运行......在进程的整个生命期间中,进程始终是处在动态过程中的,为了描述进程在动态过程中的情况,于是就有了进程状态这一概念。
常见的进程状态有以下几种:
- 新建状态:此时,进程虽已被创建,但还没有被操作系统接受和分配资源。
- 就绪状态:此时,进程已经获得了所需的资源,并等待被调度执行。
- 运行状态:此时,进程正在执行指令,占用CPU资源。
- 阻塞状态:此时,进程因等待某个事件(如IO操作)而暂时停止执行,并释放CPU等资源。
- 终止状态:此时,进程执行完成或被终止,释放所有资源。
1.运行状态
进程的运行离不开CPU的支持。普通的计算机只有一个 CPU,而进程却可能有很多个,这就注定了 CPU 是一个少量的资源。
CPU 会维护一个运行队列,将进程在内存中的PCB对象以队列的形式在内存中组织起来,从而对进程做调度。所有的进程要运行,它的PCB对象就得在运行队列中排队;而PCB对象在运行队列中排队的进程,就处在运行状态。
一个在CPU上运行的进程,并不一定要运行完才会离开CPU。如果一个进程必须要运行完才会离开CPU,那这个进程中一旦出现死循环,就会影响其他进程的运行。但实际上,一个进程中出现了死循环,其他进程也可以正常运行,而这要归功于时间片。为了避免一个进程长时间占用 CPU 资源,时间片的概念被提出了。
时间片是操作系统中任务调度算法的一种思想。将 CPU 的执行时间划分成固定长度的时间段,每个时间段就称为一个时间片,一般是10毫秒左右。在每个时间片内,操作系统将 CPU 资源分配给一个进程以运行,直到时间片耗尽时,操作系统会自动中断这个进程,并将 CPU 资源分配给下一个进程。
在一个时间片内所有的进程代码都会被执行,这种情况称为并发执行。并发执行发生的时候,会有大量的进程进入 CPU 和离开 CPU 的动作,而这个动作就叫做进程切换。
2.阻塞状态
操作系统对硬件资源做管理时,也会为每一个硬件创建结构体对象。在每一个硬件的结构体对象中,同样维护了一个等待队列,当一个进程需要使用某个硬件资源时,进程在内存中的 PCB 对象就会加入到由硬件维护的等待队列中,此时进程就处于阻塞状态。
例如,键盘是一种硬件输入设备,所以操作系统也为键盘创建了结构体对象。在键盘的结构体对象中,也一定维护了一个等待队列,当一个进程需要从键盘获取数据时,这个进程的PCB对象就被加入到了键盘维护的等待队列中,而进程在等待队列中等待用户在键盘输入的过程,就称为阻塞状态。
实际上,操作系统中的等待队列可能有成百上千个,不仅每一种硬件有等待队列,进程中其实也有等待队列,可能会出现一个进程等待另一个进程结束后才能继续运行的情况。而这具体是怎样实现的,不同的操作系统会有不同的调度算法。
3.挂起状态
挂起状态主要是为了解决进程在排队等待时,可能会导致内存资源浪费的问题。
无论是处于运行状态还是阻塞状态,一个进程在没有被 CPU 调度时,它的代码和数据是没有被使用的。一个可执行程序加载到内存中成为进程,内存中既有这个进程的PCB 对象,也有这个进程的代码和数据。在排队等待的进程可能有很多, 而它们的代码和数据不会被使用,就可能白白占用了内存资源,导致内存资源被浪费。因此,为了提高内存资源的利用率,操作系统就会将还没有被 CPU 调度的进程的代码和数据,先放到磁盘中,只留它的 PCB 对象在队列中排队,等轮到这个进程运行了,再把它的代码和数据放到内存中。
进程在内存中的PCB对象正在某个队列中排队等待,而自己的代码和数据被放到了磁盘中,这种情况就称进程处于挂起状态。
进程的挂起状态对用户是不可见的,因为这是操作系统的一种行为,而用户只管使用,只知道进程正在运行还是没在运行。
【补】操作系统也是一个进程,它真实的进程状态应该是:阻塞 + 挂起。
七、Linux下的进程状态
Linux下的进程状态有以下几种:
- R - 运行状态(running):处于运行状态的进程并不一定正在运行,要么是正在运行,要么是在运行队列里。
- S - 睡眠状态(sleeping):进程正在等待某个事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep)),对应操作系统理论中的阻塞状态。
- D - 磁盘休眠状态(Disk sleep):有时也称不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待 IO 操作的结束。
- T - 暂停状态(stopped):可以通过发送 SIGSTOP 信号给一个进程来暂停它;而这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
- t - 追踪暂停状态(tracing stop):一个进程正在被调试,代码执行在断点处停下就是进程停下来了,停下来等待继续运行,这种状态就是追踪暂停状态。
- X - 死亡状态(dead):这个状态只是一个返回状态,当一个进程的退出信息被读取后,该进程所申请的资源就会立即被释放,该进程也就不存在了,用户不会在任务列表里看到这个状态。
- Z - 僵尸状态(zombie):一个进程已经退出,但它的资源没有全部释放,会留有一部分资源,供上层获取进程退出的原因。进程退出后等待它的退出信息被读取,就称这个进程处于僵尸状态。
【补】Linux内核源码
/*
* 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 */
};
要查看一个进程的状态,只需使用查看进程属性的指令即可:
ps axj | head -1 && ps axj | grep + 进程名/可执行程序名
【补】这里,printf()会访问显示器设备,进程大部分时间会在显示器维护的等待队列里排队,因此进程处于 S 睡眠状态。
结果显示的 + 表示该进程在前台运行,此时在命令行输入指令是不会有任何反应的;可以在输入指令的后面加上 &,表示让该进程在后台运行。要终止后台运行的进程,只能通过指令“kill -9 + 进程的PID”来终止。
1.D状态
D状态其实也是一种阻塞状态。
在 Linux 中,S状态称作浅度睡眠,D状态称作深度睡眠。
浅度睡眠是可以被唤醒的,处于S状态的进程可以响应外部的变化,使用指令kill,是可以终止S状态的进程的。而深度睡眠是不可以被唤醒的,处于D状态的进程不会响应外部的变化,于是,使用指令kill,无法终止D状态的进程。
D状态的进程无法被终止,与以下情景有关:
正常情况下往磁盘中写入数据,进程是需要等待的,等数据写完后,磁盘会给进程一个信号,让进程继续去运行。
假设,有一个进程正在向磁盘中写入大量数据,而这个进程在内存中正等待着磁盘写完数据给它发信息。如果此时内存资源很紧张,操作系统就可能会将这个还处于等待中不做事的进程给终止掉,给其他做事的进程分配资源。但这个进程终止掉后,磁盘就“傻眼”了,数据还没写完但进程没了,磁盘就只好把写入的数据全部删除了,最终导致数据其实没有被写入磁盘,磁盘白忙活了一场。
为了解决这个问题,D状态就诞生了。当一个进程处于D状态时,不会响应任何请求,任何用户和操作系统都不能终止这个进程。
【Tips】终止D状态的进程有两种方法:
- 等待某个条件满足(如等待数据写完)。
- 直接断电。
一般来说,进程的D状态出现的时间都非常短,用户是看不到D状态的。一旦用户查到 了D状态,就说明操作系统可能要崩溃了。
2.T状态
T 状态和 t 状态其实没太大区别,都是表示一个进程处于暂停运行的状态,只是 t 状态特指进程在调试中的暂停。
T状态和S状态很像。S状态的进程一定是在等待某种资源,而T状态的进程也可能是在等待某种资源,或者是正在被其他进程控制。
输入以下指令,可以将一个进程设置为暂停状态:
kill -19 + 进程PID
而输入以下指令,可以结束一个进程的暂停状态 :
kill -18 + 进程PID
结束暂停的进程会到后台运行,此时要终止这个进程,只能输入指令:
kill -9 + 进程PID
3.僵尸进程
Linux下,进程的关系大多是父子关系。若子进程先退出而父进程没有回收子进程的退出信息,这个子进程就称为僵尸进程。
一个进程在退出时,它的所有资源并不会被全部释放,操作系统会把这个进程的各种信息维持一段时间,供父进程获取,进程退出后等待它的退出信息被读取,就称这个进程处于僵尸状态,而处于僵尸状态的进程,就称之为僵尸进程。如果父进程一直不读取这个退出的子进程的信息,那这个进程将会长时间处于僵尸状态,长时间是一个僵尸进程,但这样轻则导致内存资源浪费,重则导致内存泄漏。
为了方便演示,此处引入以下一段代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{printf("I am running...\n");pid_t id = fork();if(id == 0){ //childint count = 5;while(count){printf("I am child...PID:%d, PPID:%d, count:%d\n", getpid(), getppid(), count);sleep(1);count--;}printf("child quit...\n");exit(1);}else if(id > 0){ //fatherwhile(1){printf("I am father...PID:%d, PPID:%d\n", getpid(), getppid());sleep(1);}}else{ //fork error}return 0;
}
输入以下监控脚本,每隔一秒检测一次进程的属性信息:
while :; do ps axj | head -1 && ps axj | grep proc | grep -v grep;echo "######################";sleep 1;done
在以上代码中,fork() 创建了一个子进程,子进程在执行完五次打印后被终止掉(exit()就是用来终止一个进程的),而父进程将一直运行。
子进程执行完五次打印后就处于 Z 状态了,且它的打印结果后面跟了一个“defunct(不存在)”,它正在等待父进程来回收它的资源。
处于 Z 状态的进程是一个僵尸进程,它的相关资源,尤其是它的 task_struct 结构体对象,不能被释放,只有当父进程回收了这个僵尸进程的相关资源后,这个僵尸进程才能变成 X 状态。而如果父进程一直不回收,那僵尸进程会长时间占用内存资源,可能造成内存泄漏。
【Tips】僵尸进程的危害
一个进程退出后,它的退出状态必须维持一段时间,直至它的父进程回收了它的退出信息,以得知它退出的原因、完成任务的情况等。维护进程的退出状态本就是在维护一些数据,这些退出状态有关的信息被保存在它的PCB对象中,也就是说,如果进程一直是Z状态,那它的PCB一直都要维护,一直会占用内存资源。如果父进程一直不回收僵尸进程的信息,那僵尸进程会一直占用内存资源。
如果一个父进程创建了很多的子进程,但就是不回收,轻则导致内存资源的浪费,重则导致内存泄漏。
4.孤儿进程
Linux下,进程的关系大多是父子关系。
若子进程先退出而父进程没有回收子进程的退出信息,这个子进程就称为僵尸进程;若父进程先退出,它的子进程就称之为孤儿进程,且如果将来子进程成为僵尸进程,也没有父进程回收它的信息。
但如果一直不处理孤儿进程的退出信息,那它就会一直占用内存资源。因此,在孤儿进程出现的时候,它们会被1号init进程(即操作系统)“领养”,如果将来孤儿进程进入Z状态,就由1号init进程来回收它们的信息。
为了方便演示,此处引入以下一段代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{printf("I am running...\n");pid_t id = fork();if(id == 0){ //childint count = 5;while(1){printf("I am child...PID:%d, PPID:%d\n", getpid(), getppid());sleep(1);}}else if(id > 0){ //fatherint count = 5;while(count){printf("I am father...PID:%d, PPID:%d, count:%d\n", getpid(), getppid(), count);sleep(1);count--;}printf("father quit...\n");exit(0);}else{ //fork error}return 0;
}
在以上代码中,fork()创建的子进程会一直打印信息,而父进程会在打印5次后退出,此时子进程就变成了孤儿进程。
在父进程未退出时,子进程的PPID就是父进程的PID,而当父进程退出后,子进程的PPID就变成了1,也就是说,子进程被1号进程(即操作系统)领养了。
操作系统会直接从内核层面“领养”孤儿进程,所以当一个子进程的父进程先退出后,子进程会被交给操作系统,由操作系统来充当它的父进程。
八、进程优先级
进程的优先级,指进程被分配CPU资源的先后顺序,决定了谁先拿到CPU资源、谁后拿到CPU资源。
普通的计算机只有一个 CPU,而可以有多个进程,这就注定了 CPU 资源是稀有的,所有进程之间是竞争关系。操作系统必须保证进程之间的竞争是良性竞争,让每个进程都能合理地拿到CPU 资源,具体的手段是让进程按照优先级排队,优先级高的排在队列前面,先拿到资源,优先级低的排在队列后面,后拿到资源。
配置进程优先级对多任务环境的 Linux 很有用,能够改善系统性能(注:切忌轻易修改进程优先级,调度器本身就能公平地替用户调度进程了)。对于多CPU的计算机,根据优先级把进程调度到指定的 CPU ,把不重要的进程调度到其他 CPU,可以大幅改善系统的整体性能。
如果优先级设计(调度算法设计)不合理,可能会导致进程长时间得不到 CPU 资源,发生进程的饥饿问题(在用户看来就是程序卡死了)。
【ps】区分优先级和权限——权限决定的是能不能做,而优先级决定的是能做的时候谁先做、谁后做。
1.查看进程优先级
输入以下指令,使用长格式显示所有进程的信息:
ps -l
输入以下指令,使用长格式显示指定进程的信息:
ps -al | head -1 ; ps -al | grep + 进程名
- UID:代表执行者的身份。
- PID:代表这个进程的代号。
- PPID:代表这个进程是由哪个进程发展衍生而来的,即父进程的代号。
- PRI:代表这个进程可被执行的优先级,其值越小越早被执行,是 task_struct 结构体对象中的一个成员。
- NI:代表这个进程的 nice 值,是进程优先级的修正数据。
2.PRI 与 NI
在上文使用ps指令查看的结果中,PRI 就是进程的优先级(默认值为80),它的值越小,进程的优先级越高。NI 是 nice 值,表示进程优先级的修正数值(默认值为0)。PRI与NI存在以下关系:
PRI(new) = PRI(old) + nice
当 nice 值为负数时,进程的PRI(new)值会变小,即优先级会变高,越快被 CPU 执行。
在 Linux 下要修改进程优先级,只需修改进程的 nice 值。nice 值的取值范围是[-20,19],一共40个级别。
3.修改进程优先级
- 指令top
指令top就相当于Windows操作系统中的任务管理器,能够实时动态地显示系统中进程的资源占用情况。
输入top进入以下界面:
输入 r ,此时系统会等待用户输入要修改优先级的进程的PID:
输入进程PID并按下回车,此时系统会等待用户输入调整后的nice值:
输入调整的nice值后,输入q即可退出。这里输入的nice值为10,再用ps命令查看进程的优先级,进程的NI变成了10,而PRI变成了90(80+10):
如果想提高一个进程的优先级,将它的NI值调为负值,需要超级用户权限才能完成(可以使用sudo指令提权),普通用户只能降低进程的优先级。
- 指令renice
输入renice + 调整的nice值 + 进程PID:
4.优先级的实现原理
在CPU维护的运行队列中,封装了两个位图,以实现进程优先级,让进程按顺序调度。
【补】进程的四个重要概念
- 竞争性: 系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便有了优先级。
- 独立性: 多进程运行,需要独享各种资源,多进程运行期间互不干扰。
- 并行运行: 多个进程在多个CPU下分别同时进行运行,这称之为并行。
- 并发运行: 多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发。
【补】并发运行原理简介
并发运行发生时,操作系统会根据每个进程的时间片去进行进程切换,正在被 CPU 调度的进程,它的时间片一旦结束,就会被操作系统从 CPU 上调走,放到由CPU维护的等待位图里继续排队,等待下一次调度,这个过程被称为进程基于时间片轮转的调度算法。
在这个进程被调走前,程序计数器(也叫PC指针)会记录进程执行的情况,以便CPU下次调用。程序计数器是 CPU 中的一个寄存器(一般是 epi),用于存储下一条将要执行的指令的内存地址,等到CPU再一次调度这个进程的时候,知道该从哪里继续执行了。
CPU 中的寄存器有很多种,例如通用寄存器eax、ebx、ecx、edx,和栈帧有关的ebp、esp、eip,和状态有关的status等。寄存器也有保存数据保存的功能,计算机在运行时,进程相关的数据会保存在寄存器中,可以随时被 CPU 访问或者修改。
和进程有关的数据也被叫做进程的上下文。进程在从 CPU 上离开的时候,要将自己的上下文数据保存好或者带走,保存好的目的是为了未来恢复。进程在被切换的时候,要执行两步操作,即保存上下文和恢复上下文,进程的上文数据量并不大,一般可以直接保存在进程的PCB对象中,当CPU再次调度这个进程的时候,将这些上下文数据再恢复到寄存器中即可。
九、环境变量
环境变量一般是指,在操作系统中用来指定操作系统运行环境的一些参数,它们通常具有某些特殊用途,并且在系统当中通常具有全局特性。
例如,用户编写的C/C++代码,在各个目标文件进行链接的时候,用户无需知道目标文件所链接的动静态库在哪里就可以链接成功,原因就是有相关环境变量会协助编译器查找动静态库。
常见环境变量有:
环境变量名 | 所指内容 |
PATH | 命令的搜索路径 |
HOME | 用户的主工作目录 |
SHELL | 当前Shell |
HOSTNAME | 主机名 |
TERM | 终端类型 |
HISTSIZE | 记录历史命令的条数 |
SSH_TTY | 当前终端文件 |
USER | 当前用户 |
邮箱 | |
PWD | 当前所处路径 |
LANG | 编码格式 |
LOGNAME | 登录用户名 |
1.环境变量的相关指令
- 显示所有的环境变量和它们的值:env
- 显示指定的环境变量和它的值:env | grep +环境变量名
- 显示某个环境变量的值:echo $ + 环境变量名
- 临时修改一个环境变量的值:export + “环境变量名=$环境变量名:” + 修改的值
【ps】临时修改只在用户本次登录有效,退出后再次登录,修改值会复原。
- 创建一个新的环境变量:export + 已初始化的环境变量 / “环境变量名=初始值”
设置了MY_VALUE=100,但环境变量中并没有这个MY_VALUE。
export后,环境变量中有了这个MY_VALUE。
这是因为最开始设置的变量MY_VALUE其实是一个本地变量,在命令行中直接定义的变量就是本地变量,而export可以将本地变量导出,所以环境变量中才有了MY_VALUE。
也可以通过以下方式直接导出:
- 显示本地定义的shell变量和环境变量:set | more
- 清除环境变量:unset + 环境变量名
2.部分列举
2.1-PATH
要执行用户自己生成的可执行程序,必须要在可执行程序名前面带上./(路径)才可以执行;指令本质也是可执行程序,但执行指令的时候不用带./,直接输入指令名就可以执行,这是为什么呢?
要执行一个可执行程序,首先得能找到它。对于用户自己生成的可执行程序,操作系统是无法自己找到的,而用户要执行自己生成的可执行程序,在可执行程序名前面带上./ ,目的是提醒操作系统,这个可执行程序就位于当前目录下,帮助操作系统找到这个可执行程序。
既然如此,执行指令的时候只用输入指令名而不用带./ ,那就说明,操作系统自己凭指令名就能找指令的可执行程序文件。但操作系统是怎么定位指令的文件的呢?——答案是,通过环境变量PATH。
环境变量PATH的值当中有多条路径,这些路径是由冒号隔开的。
当用户输入一条指令按下回车键后,操作系统就会去查看环境变量PATH的值,然后默认从左到右依次在各个路径当中查找这个指令的文件,而这个指令实际就位于PATH当中的某一个路径下,所以只输入指令名而不带路径,操作系统也能找到它。
其实,用户自己生成的可执行程序,也可以像指令一样,只输入程序名而不带路径去执行,具体实现只需输入以下指令即可:
- 法1:将可执行程序拷贝到PATH的某一路径下
sudo cp 可执行程序名 /usr/bin
- 法2:将可执行程序所在的路径临时导入PATH中
export PATH=$PATH:可执行程序所在的路径
2.2-HOME
任何一个用户在运行系统登录时都有自己的主工作目录,普通用户的主工作目录是家目录下的用户账户目录,超级用户的主工作目录是root目录。环境变量HOME中保存的就是该用户的主工作目录。
2.3-SHELL
Linux下输入的各种指令,需要先由命令行解释器进行解释,然后才能执行。命令行解释器也是系统当中的一条指令,当它运行起来成为进程后,就可以为用户进行命令行解释了。
在Linux中,有多种命令行解释器(例如bash、sh等),可以通过查看环境变量SHELL来知道当前所用的命令行解释器是哪一种。
3.环境变量的组织方式
环境变量和它们的值都存在一张环境变量表中,这张表本质是一个字符指针数组,表中的每个元素指向一个以’\0’结尾的字符串,其内容就是一个环境变量和它的值,唯独表末的元素为空指针。
环境变量表届时会传给要运行的程序,而每个要运行的程序都会收到一张环境变量表。
4.代码中/进程中获取环境变量
补-命令行参数
C/C++ 的主函数main()是一个可执行程序的入口,其实,它是可以传参的,且它一共有三个参数:
int main(int argc, char *argv[], char* envp[])
{return 0;
}
其中,第一个参数argc是一个整型变量(默认可以不传),接收向main()传递的字符串个数;第二个参数argv是一张命令行参数表,本质是一个字符指针数组,其中有argc个元素(实际上是argc+1,因为数组的第一个元素默认是main()本身所在程序的路径),每个元素都是一个可执行程序的路径,唯独最后一个元素是空指针,而argv 数组里所存的每个元素,就是命令行参数。
为了方便演示 argv 数组存储命令行参数的具体手段,此处引入以下代码:
//test_Main.c
#include<stdio.h>
int main(int argc, char* argv[])
{ int i = 0; for(; i < argc; i++) { printf("argv[%d]->%s\n",i, argv[i]); } return 0;
}
上面这段代码会根据传入的命令行参数,打印 argv 数组中的元素:
在命令行输入的“./mycode -a -b -c”,会被命令行解释器 bash 当成一个字符串,bash 会把这整个字符串以空格作为分隔符,分成一个个单独的字符串,然后将它们的地址存入到 argv 数组中。
为了方便演示命令行参数的作用,此处引入以下代码:
//test.c
#include <stdio.h>
#include <string.h>
int main(int argc, char* argv[])
{ if(argc != 2) { printf("./mycode [-a|-b|-c|-d]\n"); return 0; } if(strcmp(argv[1], "-a") == 0) { printf("执行功能1\n"); } else if(strcmp(argv[1], "-b") == 0) { printf("执行功能2\n"); } else if(strcmp(argv[1], "-c") == 0) { printf("执行功能3\n"); } else if(strcmp(argv[1], "-d") == 0) { printf("执行功能4\n"); } else { printf("没有该选项!\n"); } return 0;
}
可执行程序 test 后携带的命令行参数不同,test 执行的功能也有所不同:
而这就是命令行参数的重要作用,它可以为指令、工具、软件等提供功能选项。
4.1-通过main()的第三个参数获取
main()的第三个参数 envp 接收的是一张环境变量表,本质也是一个字符指针数组,其中存的是系统的环境变量。我们可以通过打印main()的环境变量表中的元素,来获取系统的环境变量。
为了方便演示,此处引入以下代码:
//print_Envp.c
#include <stdio.h>
int main(int argc, char* argv[], char* envp[]) { int i = 0; for(;envp[i]; i++) { printf("envp[%d]->%s\n", i, envp[i]); } return 0; }
4.2-通过标准库的全局变量来获取
C标准库中定义的全局变量environ是一个二级指针,指向环境变量表,我们可以用它来获取系统的环境变量。但environ没有包含在任何头文件中,所以在使用时要用extern进行声明。、
为了方便演示,此处引入以下代码:
//print_Env.c
#include <stdio.h>
int main() { extern char** environ;int i = 0; for(;environ[i]; i++) { printf("%s\n",environ[i]); } return 0; }
4.3-通过系统调用获取
系统调用接口getenv()可以根据所给环境变量名,在环境变量表找到这个环境变量,并返回一个指向它的值的字符串指针。使用getenv()时需包含头文件<stdlib.h>。
为了方便演示,此处引入以下代码:
//test_Getenv.c
#include<stdio.h>
#include<stdlib.h>
int main()
{printf("%s\n",getenv("PATH"));return 0;
}
补、环境变量可继承
子进程可以继承父进程的环境变量。
用户自己生成的可执行程序,是以bash子进程的形式运行的,而作为子进程,可执行程序可以继承父进程bash的环境变量。
例如,在bash中使用export指令新建了一个环境变量MY_VALUE,而上文中由 print_Envp.c 生成的可执行程序 print_Envp,通过main()的参数 envp 来获取环境变量,运行后也可以获取这个环境变量MY_VALUE。
补、常规命令与内建命令
在命令行输入的指令并不一定都以命令行解释器的子进程的形式来运行。实际上,指令可以分为以下两类:
- 常规命令:通过创建子进程完成的。
- 内建命令:命令行解释器并不创建子进程来执行,而由它亲自来执行,例如调用了自己的代码或系统提供的接口。
常见的内建命令例如 echo、cd 等,bash 并不会创建子进程去执行它们而是亲自去执行它们。
十、进程地址空间
上文中,一个接收了fork()返回值的变量id有两个不同的值,这是因为进程之间的独立性,子进程写时拷贝了一份父进程的数据到另一块空间,导致一个变量对应了两个地址空间,使得同一个变量名可以让父子进程看到不同的值。
按理来说,一个变量应该就只有一个地址才对,为什么一个变量能够有两个地址呢?这与进程地址空间有关。
1.语言层面的地址空间
在学习 C/C++ 语言的时候,或多或少都会了解到语言层面的地址空间。
地址空间基本被分为栈区、堆区、常量区、静态区等。
以Linux kernel 2.6.32的32位机为例,32位地址线最多可以表示2^32个地址,地址空间大小总共有4G(每个地址线上只有0和1两种可能,一个地址对应一个字节,也就是2^32个字节,总共有4G)。
地址空间按照从低地址到高地址,具体被分为代码区、已初始化的全局变量区、未初始化的全局变量区、堆区、栈区、命令行参数区、环境变量区。
- 这里引入以下代码,以验证语言层面的地址空间是按照从低地址到高地址分布的。
//test_addr.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int un_g_val;
int g_val = 100;int main(int argc, char* argv[], char* env[])
{//代码区:printf("code addr : %p\n", main);//已初始化的全局变量区:printf("init global addr : %p\n", &g_val);//未初始化的全局变量区:printf("uninit global addr : %p\n", &un_g_val);char* m1 = (char*)malloc(100);//堆区:printf("heap addr : %p\n", m1);//栈区:printf("stack addr : %p\n", &m1);//命令行参数区:int i = 0for (; i < argc; i++) {printf("argv addr : %p\n", argv[i]); }//环境变量区:for (i = 0; env[i]; i++) {printf("env addr : %p\n", env[i]);}return 0;
}
由演示图,语言层面的地址空间的代码区、已初始化数据区、未初始化数据区、堆区、栈区、命令行参数区、环境变量区,的确是从低地址到高地址分布的。
其中,堆栈空间是相对“生长”的,即堆区是先使用低地址再使用高地址,栈区是先使用高地址再使用低地址(其实堆栈地址相聚很远,这是因为堆栈之间还有一块区域)。
- 这里引入以下代码,以验证堆栈空间是相对“生长”的。
//test_addr_hs.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int main()
{ char* m1 = (char*)malloc(100);char* m2 = (char*)malloc(100);char* m3 = (char*)malloc(100);//堆区:printf("heap addr : %p\n", m1);printf("heap addr : %p\n", m2);printf("heap addr : %p\n", m3);printf("-------------------------------\n");//栈区:printf("stack addr : %p\n", &m1);printf("stack addr : %p\n", &m2);printf("stack addr : %p\n", &m3);return 0;
}
由演示图,堆栈空间是相对“生长”的。
对于一个由staict修饰的静态变量,它的作用域与staict修饰前的变量相同,但它的生命周期会随着程序存在一直存在。
如果一个变量在main()或其他函数内被正常定义,那么它的地址应该位于栈区的;而如果声明这个在main()或其他函数内被定义的变量为 static,那么它应该不在栈区,而在全局变量区。
- 这里引入以下代码,以验证静态变量的地址不在栈区而在全局变量区。
//test_addr_st.c
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int un_g_val;
int g_val = 100;int main()
{ char* m1 = (char*)malloc(100);char* m2 = (char*)malloc(100);//堆区:printf("heap addr : %p\n", m1);printf("heap addr : %p\n", m2);printf("-------------------------------\n");//栈区:printf("stack addr : %p\n", &m1);printf("stack addr : %p\n", &m2);printf("-------------------------------\n");//已初始化的全局变量区:printf("init global addr : %p\n", &g_val);//未初始化的全局变量区:printf("uninit global addr : %p\n", &un_g_val);printf("###############################\n");//普通整型变量的地址:int a = 1;printf("%p\n",&a);//静态变量的地址:static int s_a = 1; printf("%p\n",&s_a);return 0;
}
由演示图,普通整型变量的地址跟栈区更接近,而静态变量的地址跟全局变量区更接近,可见由 static 修饰的静态变量不在栈区,而在全局变量区。
2.虚拟地址
上文中已经提过,同一个变量名可以让父子进程看到不同的值。
- 这里我们再来看一个相似的例子,首先,引入以下代码:
//test_fork.c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>int g_val = 100;
int main()
{pid_t id = fork();if(id == 0){int cnt = 5;// 子进程while(1){printf("I am child , pid:%d, ppid:%d, g_val:%d, &g_val:%p\n", getpid(), getppid(), g_val, &g_val);sleep(1);if(cnt){cnt--;if(cnt == 0){g_val = 200;printf("child has changed\n"); }} } } else if(id > 0) { // 父进程 while(1) { printf("I am parent, pid:%d, ppid:%d, g_val:%d, &g_val:%p\n", getpid(), getppid(), g_val, &g_val); sleep(1); } }else {printf("fork fail\n"); }return 0;
}
在以上代码中,定义了一个全局变量 g_val并初始化为100。调用fork()创建了一个子进程,然后让父子进程同时去访问变量 g_val;而当子进程执行while循环5次后,就在子进程的代码片段中把全局变量 g_val 的值修改为200。
起先,父子进程打印的变量 g_val 的值都为100,等子进程执行while循环5次后,子进程把 g_val 的值改为200,而按理来说,g_val 的值已经改变了,父子进程打印同一个变量 g_val ,结果应该都是200才对。
但由演示图,子进程修改 g_val 的值后,子进程的结果是200没错,而父进程的结果却还是100,发生了同一个变量名 g_val 让父子进程看到了不同的值。
同一个变量名 g_val 让父子进程看到了不同的值,换句话说,父子进程在同一个地址处读出了不同的值。而这合理吗?——合理!因为语言层面的地址其实不是真实的物理地址,而是由操作系统管理的虚拟地址,语言层面的地址空间其实是虚拟地址空间。
计算机硬件上存数据的物理内存,本质是通过物理元件放电和充电来计算和记录数据的,也就是说,由于硬件的特性,一个物理地址只能读取出唯一的一个值,根本不可能读取出第二个值。那也就可以说,以上代码演示的现象不符合真实的物理地址的性质,换句话说,在语言层面上获取的地址,其实都不是真实的物理地址。
但每个在语言层面上获取的地址,确实能读取相应的数据,这是怎么一回事呢?
实际上,为了更好地管理硬件资源,操作系统在物理地址上“蒙”上了一层虚拟地址,而虚拟地址和物理地址之间的转化都由操作系统来完成,操作系统可以通过虚拟地址把数据存入物理地址,也可以通过虚拟地址找到相应的物理地址,把数据取出。
3.进程地址空间
上文中的语言层面的地址空间,准确来说应该叫做进程地址空间。
进程地址空间本质是内存中的一种内核数据结构,在Linux下具体由结构体mm_struct实现。
进程地址空间就类似于一把尺子,尺子的刻度由0x00000000到0xffffffff,尺子按照刻度被划分为各个区域,例如代码区、堆区、栈区等。在结构体mm_struct当中,记录了各个边界刻度,各个边界刻度之间的每一个刻度都代表一个虚拟地址,这些虚拟地址通过页表映射与物理内存建立联系。
由于虚拟地址是由0x00000000到0xffffffff线性增长的,虚拟地址又叫做线性地址,例如,要实现堆栈空间是相对“生长”的,实际就是改变mm_struct当中堆和栈的边界刻度。
用户自己生成的可执行程序实际上也被分为了各个区域,例如已初始化的全局变量区、未初始化的全局变量区等。当这个可执行程序运行起来的时候,操作系统会将对应的数据放到内存中相应的物理地址上。
另外,对可执行程序进行分区的操作,实际是由编译器来完成的,所以才说,代码的优化级别是由编译器决定的。
一个进程被操作系统创建后,它的进程控制块(task_struct)和进程地址空间(mm_struct)也会随之被操作系统创建。因为进程控制块(task_struct)封装了指向进程地址空间(mm_struct)的指针,所以操作系统可以通过进程的进程控制块,轻松找到进程的进程地址空间。
而进程地址空间中的各个虚拟地址,是通过页表去映射到某个物理地址的。
如果像上文的演示中,子进程要将全局变量g_val改为200,那么操作系统会先在物理内存的某个位置上存储 g_val 的新值,然后改变子进程中 g_val 的虚拟地址,最终通过页表映射,将物理地址和新的虚拟地址联系起来。
而像这样,为子进程单独开辟一块空间,按需拷贝一份父进程的数据让子进程去修改的操作,其实就是上文中所提到的写时拷贝。
【Tips】进程地址空间的设计优点
- 有了进程地址空间后,就不会有任何系统级别的越界问题存在了。例如,进程1不会错误的访问到进程2的物理地址,是因为通过一个虚拟地址访问数据时,需要先通过页表映射到物理地址,而页表只会映射独属于这个进程的物理地址。总得来说,虚拟地址和页表的存在为内存上了一层保护的包装。
- 有了进程地址空间后,可以使每个进程都认为自己看到的是相似的空间分布,对内存资源做到了统一的管理。如此一来,在编写程序的时候就只需关注虚拟地址,而无需关注数据的真实物理地址了,大大降低了编写成本,提高了工作效率。
- 有了进程地址空间后,每个进程都认为自己是在独占内存资源,这样可以更好地实现进程之间的独立性,和内存资源合理的按需分配,还可以将进程调度与内存管理进行解耦或分离。
【Tips】一个进程的创建实际上伴随着其进程控制块、进程地址空间和页表的创建。
4.写时拷贝的一些迷思
- 为什么数据要进行写时拷贝?
进程具有独立性,多个进程在运行时,各自需要享有各自的资源且应该互不干扰,不能让子进程修改数据的操作影响到父进程的数据。
- 为什么不在创建子进程的时候,就对父进程的全部数据进行拷贝?
子进程未必会使用父进程的所有数据,且在子进程不对数据进行写入的情况下,其实也没有对父进程的数据进行拷贝的必要。有了写时拷贝的存在,可以按需分配内存资源,合理且高效地利用内存资源。
- 数据能够被写时拷贝,那代码会不会被写时拷贝?
绝大部分情况下是不会的。但这并不代表,代码不能进行写时拷贝,例如,在发生进程替换的时候就需要进行代码的写时拷贝。