目录
- 进程概念
- 进程控制块-PCB
- PCB的内容分类
- 标识符
- 查看进程信息的方法
- 状态
- fork函数
- 进程状态
- R运行状态(running)
- S睡眠状态(sleeping)
- D磁盘休眠状态(Disk sleep)
- T停止状态(stopped)
- X死亡状态(dead)
- 优先级
- 进程优先级
- 基本概念
- PRI和NI
- top-查看进程优先级的命令
- 程序计数器
- 内存指针
- 上下文数据
- I/O状态信息
- 记账信息
- 特殊进程
- Z(zombie)-僵尸进程
- 僵尸进程的作用
- 僵尸进程的危害
- 孤儿进程
- 关于进程的其他概念
- 并行和并发的区别
- 程序地址空间
- 实验环境
- c语言角度的内存空间
- 进程地址空间
- mm_struct
- mm_struct的理解
- 页表
- 页表的作用
- 写时拷贝
- 概念
- 解释代码现象
- 为什么需要写时拷贝
- 总结
进程概念
进程(Process)是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
进程控制块-PCB
进程信息都是放在一个叫进程控制块即PCB(process control block),可以简单地理解为进程数据集合。
在操作系统的学习中,我们知道了操作系统要管理进程,就要做到先描述,再使用,在Linux中内核描述进程的结构体叫做task_struct。
PCB的内容分类
标识符
标识符:描述进程的唯一标识符,用来区别其他进程。
首先我们来认识一个命令叫做ps-显示进程状态
语法:ps [参数]
常用参数参考详情
我们可以看到有PPID和PID,PID是本进程的标识符,PPID是父进程的标识符。
查看进程信息的方法
- 上面的ps命令
- 通过/proc系统文件夹来查看
- 通过系统调用来获取进程标识符,getpid()和getppid()
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
printf("pid: %d\n", getpid());
printf("ppid: %d\n", getppid());
return 0;
}
状态
状态:任务状态、退出代码,退出信号等等
fork函数
首先我们认识一下系统调用接口fork-创建一个进程
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
pid_t id=fork();
if(id > 0)
{
//father
printf("pid:%d,ppid:%d\n",getpid(),getppid());
sleep(5);
}
else if(id == 0)
{
//child
printf("pid:%d,ppid:%d\n",getpid(),getppid());
sleep(5);
}
else
{
perror("fork");
return 1;
}
return 0;
}
在fork创建一个新进程后,子进程执行和父进程相同的代码,我们往往通过它的返回值进行分流,使父子进程执行不同的代码。
进程状态
首先我们看看Linux的源代码
我们可以通过源代码看出task_struct中通过一个字符指针数组来进行进程状态分类。
我着重介绍R、S、D、t、T、Z这几个状态。
R运行状态(running)
R运行状态:表示进程在运行中或者在运行队列中(可被cpu调度),不一定正在运作。
我们写一个死循环看看。
#include<stdio.h>
int main()
{
while(1)
{}
return 0;
}
一定要记住,进程是R状态不一定在运行!!!
S睡眠状态(sleeping)
S睡眠状态:表示进程正在休眠不会被cpu调度直到被唤醒,此状态可以被杀掉。
#include<stdio.h>
#include<unistd.h>
int main()
{
sleep(10);
return 0;
}
此状态的意义是在进程需要某种资源才能继续跑下去时,我们将进程进行睡眠,节约cpu资源,在条件满足时将进程唤醒。
D磁盘休眠状态(Disk sleep)
D磁盘休眠状态:也叫不可中断睡眠状态,相比较S状态,在D状态时不能被杀掉(OS也不行)。
此状态不好实现,我们就讲讲为什么会有一个和S状态如此相像的D状态。
在一个进程向硬盘写入数据时,进程应该进入睡眠模式等待硬盘回应,如果此时内存空间严重不足时,OS可能会干掉一些S状态的进程分配给活跃的进程,此时就导致了一个问题,那就是如果硬盘在写入数据出现错误时它向进程询问该怎么办,但是此时进程已经被OS干掉了,就会陷入一个尴尬的场景。
那么上面描述中,是谁的错?
答案是都没错
进程:我与硬盘进行IO时进入睡眠模式节约cpu资源结果被cpu干掉了,我能怎么办?
硬盘:我在存入数据本来就有几率出现错误,但是我回去问进程该怎么办,它一直不回答,我能怎么办?
OS:内存资源已经非常少了,我干掉一些偷懒的进程把资源分配给活跃的进程,我有什么错。
所以为了避免上面这种情况,我们需要一个在睡眠状态不能被干掉的状态,那就是D状态。
T停止状态(stopped)
T停止状态:可以通过发送SIGSTOP信号让进程停止,可以通过发送SIGCONT信号让进程继续运行。
这里借用R状态的代码
首先进程处于R状态,kill命令可以给指定进程发信号,在我们向进程发送SIGSTOP时,我们可以看到进程的STAT从R+变成了T,在发送SIGCONT信号后STAT又变成了R。
信号有关的命令以后细嗦,先放个图了解一下。
X死亡状态(dead)
X死亡状态:这个状态只是一个返回状态,我们不能从ps中的STAT列表中看到此状态。
优先级
优先级:代表进程被cpu调度的优先级。
进程优先级
基本概念
- cpu分配资源给进程的先后顺序,就是进程的优先级
- 进程的优先级高,优先被cpu调度。
- 将不重要的进程优先级降低可以提高cpu资源利用。
我们来看看ps -l中的信息
其中与进程优先级有关的数据就是PRI和NI
PRI和NI
PRI:代表进程的优先级,越低优先级越高。
NI:代表进程的nice值
PRI很好理解,那NI所代表的nice值是什么呢?
- nice值我们可以简单地理解为PRI的修正数据
- PRI越低优先级越高,那引入nice值后:(new)PRI=(old)PRI+NI
- nice值的范围为-20~19。
- 我们通过修改NI调整一个进程的优先级。
top-查看进程优先级的命令
- top(有点类似Windows下的任务管理器)
- 进入top输入“r”->输入修改进程的pid->输入nice值(不管是降低还是增加nice值,都需要sudo提高权限)
修改前
输入“r”
输入nice值
修改成功
我们可以看出**(new)PRI=(old)PRI+NI**
程序计数器
程序计数器:程序中即将被执行的下一行代码的地址
这就是为什么程序知道自己的该怎样执行代码。
内存指针
内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
进程可以通过内存指针找到自己的代码和数据以及共享内存块。
上下文数据
上下文数据:进程执行时寄存器中的数据
我们如何来理解这个上下文数据呢?
首先我们要明白cpu运行进程1执行到i++时,cpu需要将i取到寄存器中进行自加一,然后再写入到内存中,Linux中进程是通过时间片算法切换运行,达到一个并发的效果,但是如果cpu刚将i++完成准备从寄存器中把i写入到内存时进程1的时间片到了,cpu就会切换下一个进程,而cpu的寄存器是有限的,所以进程1在寄存器中的数据就会被覆盖,如果下次轮到进程1的时间片时,cpu又会重复上述过程导致cpu资源浪费,所以为了避免这种情况,我们需要将cpu寄存器中的数据在切片前保存到上下文数据中,这样的话,下次cpu跑该进程时先把上下文数据读取到寄存器中再执行下一步命令。
I/O状态信息
I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
记账信息
记账信息: 可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
特殊进程
Z(zombie)-僵尸进程
- 僵尸进程是一种特殊的状态。当进程退出时并且父进程没有读取进程的退出信息时,就会一直保持Z僵尸模式直到父进程读取。
- 僵尸进程的生命周期为进程退出,父进程存在并读取前。
不多bb,上代码
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
pid_t id=fork();
if(id == 0)
{
//child
printf("i am child,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(5);
}
else if(id > 0)
{
//father
printf("i am father,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(30);
}
else
{
perror("fork");
return 1;
}
return 0;
}
僵尸进程的作用
帮助父进程了解子进程的任务完成情况以及退出信息。,比如,一个人突然死了,有人发现后选择报警肯定不会乱动尸体(除了凶手),警察发现尸体后也会保留案发现场,这些都是因为尸体和现场会留下信息让警察去侦察这个人是怎么死的。
僵尸进程的危害
进程的退出信息是保存在PCB中,如果父进程一直不读取子进程退出信息,这个PCB就会一直存在浪费内存资源。
孤儿进程
- 在子进程退出父进程未读取时,会产生僵尸进程,那么如果父进程比子进程先退出,那子进程的退出信息又改由谁来回收呢?
- 父进程先退出,子进程就会变成孤儿进程,然后被1号进程领养,从而1号进程负责回收子进程的退出信息。
这个代码与僵尸进程的代码相似。
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
pid_t id=fork();
if(id == 0)
{
//child
printf("i am child,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(30);
}
else if(id > 0)
{
//father
printf("i am father,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(5);
}
else
{
perror("fork");
return 1;
}
return 0;
}
关于进程的其他概念
- 竞争性:系统中进程数目众多,cpu资源只有少量,所以进程会竞争cpu资源的使用权,操作系统为了使进程合理使用cpu资源,便有了优先级。
- 独立性:多进程运行会独享很多资源,各个进程运行中互不干扰。
- 并行:多个进程在多个cpu下同时运行。
- 并发:多个进程在单个cpu下采用进程切换的方式,在一个时间段中,使多个进程得到推进。
并行和并发的区别
并行是每个进程都有自己的cpu,在每一刻多个进程同时运行,而并发是很多进程轮流使用一个cpu资源,例如Linux采用时间片切换的算法,使多个进程在一个时间段中看起来像是同时进行,系统下进程数目往往远大于cpu资源,所以并发在大多数场景下更为合理。
程序地址空间
实验环境
kernel 2.6.32
32位平台
c语言角度的内存空间
相信大家在学习c语言的时候一定听过什么内存分为栈区堆区等等栈区向下生长、堆区向上生长,也看过下面这一副图。
我们可以通过一段代码来验证一下是不是如此。
#include<stdio.h>
#include<stdlib.h>
static int init_value=10; //static修饰的全局变量只能在本文件中访问
static int uninit_value;
int main(int argc)
{
int i=0;
int* pi=malloc(sizeof(int));
printf("%p\n",&argc);
printf("%p\n",&i);
printf("%p\n",pi);
printf("%p\n",&uninit_value);
printf("%p\n",&init_value);
printf("%p\n",main);
return 0;
}
我们可以看出确实如此,接下来我们看看另一段代码
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int global_val=10;
int main()
{
pid_t id=fork();
if(id > 0)
{
//father
printf("i am father,%p,%p\n",&global_val,main);
}
else if(id == 0)
{
//child
printf("i am child,%p,%p\n",&global_val,main);
}
else
{
perror("fork");
return 1;
}
return 0;
}
通过运行结果,我们可以得到一个结论,那就是在父子进程未对数据进行修改时,子进程的代码和数据是和父进程共享的。
接下来,我们在上述代码的基础上进行一些小小的改变。
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int global_val=10;
int main()
{
pid_t id=fork();
if(id > 0)
{
//father
sleep(5)
printf("i am father,%p,%d\n",&global_val,global_val);
}
else if(id == 0)
{
//child
global_val=20;
printf("i am child,%p,%d\n",&global_val,global_val);
}
else
{
perror("fork");
return 1;
}
return 0;
}
注意这里让父进程休眠了5秒,子进程将global_val的值改成了20,见证奇迹的时候到了。
我们可以看到父子进程的val不一样,但是他们的地址却是一样的,这到底是为什么呢?
- 首先,他们的val不一样说明父子进程输出的val绝对不是同一个变量。
- 其次,他们的地址一样说明这个打印出来的地址并不是真正的物理地址。
那这个地址到底是什么东西呢?
- 在Linux下,这个地址叫做虚拟地址,听名字我们就知道了它不是真正的物理地址。
- 注意我们通过C/C++打印看到的地址统统都是虚拟地址!用户看不到物理地址,物理地址由操作系统管理。
进程地址空间
有人看到这个标题肯定会有疑问,你怎么一会一个程序地址空间,一会一个进程地址空间呢?其实严格上讲,这个应该叫做进程地址空间。
那进程地址空间到底是什么样子的呢?其实它也是一个结构体,话不多说上源码。
mm_struct
struct mm_rss_stat {
atomic_long_t count[NR_MM_COUNTERS];
};
/* per-thread cached information, */
struct task_rss_stat {
int events; /* for synchronization threshold */
int count[NR_MM_COUNTERS];
};
#else /* !USE_SPLIT_PTLOCKS */
struct mm_rss_stat {
unsigned long count[NR_MM_COUNTERS];
};
#endif /* !USE_SPLIT_PTLOCKS */
struct mm_struct {
struct vm_area_struct * mmap; /* list of VMAs */
struct rb_root mm_rb;
struct vm_area_struct * mmap_cache; /* last find_vma result */
#ifdef CONFIG_MMU
unsigned long (*get_unmapped_area) (struct file *filp,
unsigned long addr, unsigned long len,
unsigned long pgoff, unsigned long flags);
void (*unmap_area) (struct mm_struct *mm, unsigned long addr);
#endif
unsigned long mmap_base; /* base of mmap area */
unsigned long task_size; /* size of task vm space */
unsigned long cached_hole_size; /* if non-zero, the largest hole below free_area_cache */
unsigned long free_area_cache; /* first hole of size cached_hole_size or larger */
pgd_t * pgd;
atomic_t mm_users; /* How many users with user space? */
atomic_t mm_count; /* How many references to "struct mm_struct" (users count as 1) */
int map_count; /* number of VMAs */
spinlock_t page_table_lock; /* Protects page tables and some counters */
struct rw_semaphore mmap_sem;
struct list_head mmlist; /* List of maybe swapped mm's. These are globally strung
* together off init_mm.mmlist, and are protected
* by mmlist_lock
*/
unsigned long hiwater_rss; /* High-watermark of RSS usage */
unsigned long hiwater_vm; /* High-water virtual memory usage */
unsigned long total_vm, locked_vm, shared_vm, exec_vm;
unsigned long stack_vm, reserved_vm, def_flags, nr_ptes;
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
unsigned long saved_auxv[AT_VECTOR_SIZE]; /* for /proc/PID/auxv */
/*
* Special counters, in some configurations protected by the
* page_table_lock, in other configurations by being atomic.
*/
struct mm_rss_stat rss_stat;
struct linux_binfmt *binfmt;
cpumask_t cpu_vm_mask;
/* Architecture-specific MM context */
mm_context_t context;
/* Swap token stuff */
/*
* Last value of global fault stamp as seen by this process.
* In other words, this value gives an indication of how long
* it has been since this task got the token.
* Look at mm/thrash.c
*/
unsigned int faultstamp;
unsigned int token_priority;
unsigned int last_interval;
/* How many tasks sharing this mm are OOM_DISABLE */
atomic_t oom_disable_count;
unsigned long flags; /* Must use atomic bitops to access the bits */
struct core_state *core_state; /* coredumping support */
我们之前提到操作系统要进行管理就要**“先描述,再组织”**,内存管理也不例外,mm_struct就是操作系统用来进行内存管理的结构体。
那么结构体里面的变量都是什么意思为什么操作系统可以通过这些变量来进行内存管理呢?
首先,我们要认识一个问题,那就是我们是如何管理地球上的土地(内存)的,各个国家通过划分边界线的方式来确定自己可以使用的土地,而这些国家通过气候环境等等因素将自己的土地划分成住宅区、农耕区、工业区,我们往往通过尺度单位(记录起始位置+结束位置)来划分土地,而只要边界确定好了,我们就可以通过边界来确认自己使用土地是否合法。
mm_struct的理解
对应到操作系统来说,划分区域是为了更好地管理内存,提高管理内存效率,而mm_struct中的一个个unsigned long变量就是每个区域的起始位置和结束位置,操作系统可以采用基地址+偏移量的方法来判断内存访问和申请是否合法。
页表
我们都知道mm_struct中的地址都是虚拟地址不是真正的物理地址,那么操作系统通过mm_struct来管理真正的物理内存呢?
接下来我们要引出一个概念叫做页表。
页表是一种特殊的数据结构,放在系统空间的页表区,存放逻辑页与物理页帧的对应关系。 每一个进程都拥有一个自己的页表,PCB表中有指针指向页表。
页表的作用
- 存储虚拟地址和物理地址的映射,进程可以通过页表将虚拟地址找到物理地址并进行数据的增删查改。
- 不会将真正的物理地址暴露给用户,保护内存。
写时拷贝
概念
写时拷贝是一种可以推迟甚至免除拷贝数据的技术。 内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。 只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。
解释代码现象
现在可以来解释这个神奇的结果了。
我们通过图来说明上面的代码问题。
- 在父子进程fork子进程时,子进程复制父进程的数据形成task_struct和mm_struct以及页表,此时父子进程的global_val指向同一个物理地址。(子进程拥有自己的task_struct和mm_struct以及页表,但是内容与父进程相同)
- 当子进程执行到global_val=20时就会发生写时拷贝,子进程会在物理内存上申请一个新的空间用来存放自己的global_val变量,接着子进程只需把页表中对应的物理内存修改成新的物理内存地址不需要修改虚拟内存地址,所以我们打印出来的地址一样。
为什么需要写时拷贝
因为进程之间具有独立性,就算是父子进程也不能影响其他进程的数据,并且在创建子进程时不开辟新的物理内存,等到需要时在申请空间,可以高效地使用内存空间。
总结
- 子进程的创建实质就是task_struct和mm_struct以及页表的创建,拷贝父进程的数据。
- 进程地址空间(mm_struct和页表)避免了系统级别的越界问题即错误访问物理内存。
- 每个进程对待内存的看法都一样(因为mm_struct一样)。
写在最后:文中Linux源码来自Linux源码,创作不易,如果这篇文章对你有帮助,希望大佬们三连支持。