目录
1.进程概述
进程是操作系统进行资源分配的最小单位,而内存是进程运行必不可少的资源。那么,为什么需要内存呢?CPU 太快,但其数据容量极小且功能单一,而其他 I/O 等硬件功能多样,只是相对于 CPU 来说它们又太慢,所以便需要内存在 CPU 与 I/O 设备间进行缓冲。
现代操作系统均支持多任务,操作系统一般会为每个进程分配 独享的内存空间,这个独享的内存空间只是在进程自己看来是独享的,实际上其只是操作系统为其分配的 虚拟内存空间,虚拟内存在真正被使用时才映射到物理内存上。进程每次访问自己内存空间的某个地址 (虚拟地址)时,操作系统都需要把地址翻译成实际物理内存地址。
虚拟内存是操作系统里的概念。由于数据存储的基本单位都是 Byte(字节),如果将每一个虚拟内存的 Byte 都对应到物理内存的地址,每个条目最少需要 8 字节(32 位虚拟地址 -> 32 位物理地址),如果在 4G 内存的情况下,就需要 32GB 的空间来存放虚拟内存地址与物理内存地址的对照表,那么这张表就大得真正的物理地址也放不下,于是操作系统引入了 页(Page) 的概念。
操作系统将整个物理内存划分为很多的页(页的大小一般为 4K,不同操作系统实现可能会不同),之后在给进程进行内存分配时,都以页为单位。这样虚拟内存页对应物理内存页的映射表就大大减小了,4G 内存时只需要 8M 的映射表即可。操作系统虚拟内存到物理内存的映射表,就被称为页表。
由于分页技术的广泛使用,现代的 CPU 设计中便多了一种硬件,即 内存管理单元 MMU(Memory Management Unit),专门用来将翻译虚拟内存地址。
Linux 的虚拟地址空间采用 “分段+分页” 结合的方式实现。分段是将内存划分成各个段落(Segment),每个段落的长度可以不同,且虚拟地址空间中未使用的空间不会映射到物理内存中,所以操作系统不会为这段空间分配物理内存。这样的话,内核为刚创建的进程分配的物理内存可以很小,随着进程运行不断使用内存,内核再为进程按需分配物理内存。也就是说,尽管地址空间的范围和物理内存大小一样,但不会将全部空间映射到物理内存。
2.进程的内存布局
在 32 操作系统中,其虚拟地址为 32 位长度,因此其虚拟地址空间的范围为 2 * 32 = 4GB
。Linux 系统将地址空间按 3:1 比例划分,其中用户空间(user space)占 3GB,内核空间(kernel space)占 1GB。Linux 系统进程的虚拟内存地址空间布局如下图所示:
各个分段的含义:
-
文本段(Text):也称为代码段。进程启动时会将程序的代码加载到物理内存中,文本段映射到这片物理内存。
-
数据段(Data):包含程序显式初始化的全局变量和静态变量,即已初始化且初值不为0的全局变量(也包括静态全局变量)和静态局部变量,这些数据是在程序真正运行前就已经确定的数据,所以可以提前加载到内存保存好。
-
未初始化数据(BSS):未初始化的全局变量和静态变量,这些变量的值是在程序真正运行起来并为其赋值后才能确定的,所以程序加载之初,只需要记录它的内存地址和所需大小。出于历史原因,这段空间也称为 BSS 段。
-
栈(Stack):位于用户空间的顶部,是一个可以动态增长和收缩的内存段落,由栈帧(Stack Frames)组成,进程每调用一次函数,都将为该函数分配一个栈帧,栈帧中保存了该函数的局部变量、参数值和返回值。栈帧会在函数返回时被清理掉。注意,编译器会将函数参数放入寄存器来优化程序,只有寄存器放不下的参数才使用栈帧来保存。由于栈中数据严格的遵守 FIFO 的顺序,这个简单的设计意味着不必使用复杂的数据结构来追踪栈中的内容,只需要一个简单的指针指向栈的顶端即可,因此压栈(pushing)和出栈(popping)过程非常迅速、准确。
-
堆(Heap):与栈一样,堆用于运行时内存分配;但不同的是,堆用于存储那些生存期与函数调用无关的数据。如用系统调用 malloc 申请的内存便在堆上,这些申请的内存在不需要时必须手动释放,否则便会出现内存泄漏。
栈的内存地址向下增长,堆得内存地址向上增长。
-
内存映射段(Memory Mapping):在栈与堆之间,有一个内存映射端。内核通过这一段将文件的内容直接映射到内存,进程可以通过 mmap 系统调用请求这种映射。内存映射是一种方便高效的文件 I/O 方式,所以它也被用来加载动态库。
-
内核段(Kernel):这部分是操作系统内核运行时所占用内存在各进程虚拟地址空间中的映射。所有进程都有,且映射地址相同,因为都映射到内核使用的内存。这段内存只有内核能访问,用户进程无法访问到该段落。
3.Linux和Windows空间对比
对于Windows系统,理论上是16TB
对于Linux 64位系统,理论上,64bit内存地址可用空间为0x0000000000000000 ~ 0xFFFFFFFFFFFFFFFF(16位十六进制数),这是个相当庞大的空间,Linux实际上只用了其中一小部分(256T)。
Linux64位操作系统仅使用低47位,高17位做扩展(只能是全0或全1)。所以,实际用到的地址为空间为0x0000000000000000 ~ 0x00007FFFFFFFFFFF(user space)和0xFFFF800000000000 ~ 0xFFFFFFFFFFFFFFFF(kernel space),其余的都是unused space。
user space 也就是用户区由以下几部分组成:代码段,数据段,BSS段,heap,stack
4.进程控制块(PCB)
每个进程在内核中都有一个进程控制块(PCB)来维护进程相关的信息。Linux内核的进程控制块是task_struct结构体。task_struct是Linux内核的一种数据结构,它会被装载到RAM并且包含着进程的信息。每个进程都把它的信息放在task_struct这个数据结构里,task_struct包含了许多内容: 标示符:描述本进程的唯一标示符,用来区别其他进程。
状态:任务状态,退出代码,退出信号等。
优先级:相对于其他进程的优先级。
程序计数器:程序中即将被执行的下一条指令的地址。
内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
上下文环境:进程执行时处理器的寄存器中的数据。
I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
记账信息:可能包括处理器时间总和,使用的时钟总和,时间限制,记账号等。
保存在进程信息的数据结构叫做task_struct,并且可以在include/linux/sched.h里找到它。所有运行在系统里的 进程都以task_struct链表的形式存在内核里。
struct task_struct
{
volatile long state; //说明了该进程是否可以执行,还是可中断等信息
unsigned long flags; // flags 是进程号,在调用fork()时给出
int sigpending; // 进程上是否有待处理的信号
mm_segment_t addr_limit; //进程地址空间,区分内核进程与普通进程在内存存放的位置不同 //0-0xBFFFFFFF for user-thead //0-0xFFFFFFFF for kernel-thread
//调度标志,表示该进程是否需要重新调度,若非0,则当从内核态返回到用户态,会发生调度
volatile long need_resched;
int lock_depth; //锁深度
long nice; //进程的基本时间片
//进程的调度策略,有三种,实时进程: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 long sleep_time; //进程的睡眠时间
//用于将系统中所有的进程连成一个双向循环链表, 其根是init_task
struct task_struct *next_task, *prev_task;
struct mm_struct *active_mm;
struct list_head local_pages; //指向本地页面
unsigned int allocation_order, nr_local_pages;
struct linux_binfmt *binfmt; //进程所运行的可执行文件的格式
int exit_code, exit_signal;
int pdeath_signal; //父进程终止时向子进程发送的信号
unsigned long personality;
//Linux可以运行由其他UNIX操作系统生成的符合iBCS2标准的程序
int did_exec:1;
pid_t pid; //进程标识符,用来代表一个进程
pid_t pgrp; //进程组标识,表示进程所属的进程组
pid_t tty_old_pgrp; //进程控制终端所在的组标识
pid_t session; //进程的会话标识
pid_t tgid;
int leader; //表示进程是否为会话主管
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_list real_timer; //指向实时定时器的指针
struct tms times; //记录进程消耗的时间
unsigned long start_time; //进程创建的时间
//记录进程在每个CPU上所消耗的用户态时间和核心态时间
long per_cpu_utime[NR_CPUS], per_cpu_stime[NR_CPUS];
//内存缺页和交换信息:
//min_flt, maj_flt累计进程的次缺页数(Copy on Write页和匿名页)和主缺页数(从映射文件或交换
//设备读入的页面数); nswap记录进程累计换出的页面数,即写到交换设备上的页面数。
//cmin_flt, cmaj_flt, cnswap记录本进程为祖先的所有子孙进程的累计次缺页数,主缺页数和换出页面数。
//在父进程回收终止的子进程时,父进程会将子进程的这些信息累计到自己结构的这些域中
unsigned long 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_t cap_effective, cap_inheritable, cap_permitted;
int keep_capabilities:1;
struct user_struct *user;
struct rlimit rlim[RLIM_NLIMITS]; //与进程相关的资源限制信息
unsigned short used_math; //是否使用FPU
char comm[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中
struct thread_struct thread;
struct fs_struct *fs; //文件系统信息
struct files_struct *files; //打开文件信息
spinlock_t sigmask_lock; //信号处理函数
struct signal_struct *sig; //信号处理函数
sigset_t blocked; //进程当前要阻塞的信号,每个信号对应一位
struct sigpending pending; //进程上是否有待处理的信号
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;
};
5.进程的状态
运行态R(TASK_RUNNING) 进程获取到CPU使用权并得到响应的资源,正在执行系统任务 就绪态A 进程准备就绪,等待CPU使用权与资源 睡眠态S (TASK_INTERRUPTIBLE) 可中断的睡眠状态。
D (TASK_UNINTERRUPTIBLE) 不可中断的睡眠状态。
【睡眠态S 可以被中断 无法继续(可以被叫起来)D 不可被中断(不能被叫起来)】
暂停状态或跟踪状态 T (TASK_STOPPED or TASK_TRACED) 向进程发送一个SIGSTOP信号,它就会因响应该信号而进入TASK_STOPPED状态(除非该进程本身处于TASK_UNINTERRUPTIBLE状态而不响应信号)。(SIGSTOP与SIGKILL信号一样,是非常强制的。不允许用户进程通过signal系列的系统调用重新设置对应的信号处理函数。) 向进程发送一个SIGCONT信号,可以让其从TASK_STOPPED状态恢复到TASK_RUNNING状态。
当进程正在被跟踪时,它处于TASK_TRACED这个特殊的状态。“正在被跟踪”指的是进程暂停下来,等待跟踪它的进程对它进行操作。比如在gdb中对被跟踪的进程下一个断点,进程在断点处停下来的时候就处于TASK_TRACED状态。而在其他时候,被跟踪的进程还是处于前面提到的那些状态。
退出状态X(TASK_DEAD - EXIT_DEAD) 进程即将被销毁。进程在退出过程中也可能不会保留它的task_struct
退出状态Z (TASK_DEAD - EXIT_ZOMBIE) 进程成为僵尸进程。
进程在退出的过程中,处于TASK_DEAD状态。
在这个退出过程中,进程占有的所有资源将被回收,除了task_struct结构(以及少数资源)以外。于是进程就只剩下task_struct这么个空壳,故称为僵尸。
< 高优先级(not nice to other users)
N 低优先级(nice to other users)
L 页面锁定在内存(实时和定制的IO)
s 一个信息头
l 多线程(使用 CLONE_THREAD,像NPTL的pthreads的那样)
+在前台进程组
其中这个参数来列出所有的信息以提供自己检查程序的问题!在上面的程序列出当中,说明如下:
USER:说明该程序是属于哪一个人的;
PID:该程序的代号;
%CPU:代表该程序使用了多少 CPU 资源;
%MEM:代表该程序使用了多少的 RAM ;
VSZ, RSS:占去的 ram 的大小( bytes );
TTY:是否为登入者执行的程序?若为 tty1-tty6 则为本机登入者,若为 pts/?? 则为远程登入者执行的程序
STAT:该程序的状态
START:该程序开始的日期;
TIME:该程序运行的时间?
COMMAND:该程序的内容
6.进程的状态转换
(1) 就绪→执行 处于就绪状态的进程,当进程调度程序为之分配了处理机后,该进程便由就绪状态转变成执行状态。
(2) 执行→就绪 处于执行状态的进程在其执行过程中,因分配给它的一个时间片已用完而不得不让出处理机,于是进程从执行状态转变成就绪状态。
(3) 执行→阻塞 正在执行的进程因等待某种事件发生而无法继续执行时,便从执行状态变成阻塞状态。
(4) 阻塞→就绪 处于阻塞状态的进程,若其等待的事件已经发生,于是进程由阻塞状态转变为就绪状态。
7.进程源语
fork() #进程创建 exec()进程功能重载, wait() #进程回收 waitpid() #进程回收,wait函数的升级版
Linux操作系统,强调进程间的亲缘关系,所有的进程都有其父进程
父进程 -fork-> 子进程
Linux进程体系,所有的进程追溯其父进程,会追溯到pid=init进程,init进程是一切的起始
查看Linux父进程的方法
pstree-显示子进程的父进程
# -a 表示输出命令行选项 # p表PID # s表示指定进程的父进程 pstree -aps 3080 # 3080 是子进程号 systemd,1 └─dockerd,15006 -H fd:// └─docker-containe,15024 --config /var/run/docker/containerd/containerd.toml └─docker-containe,2991 -namespace moby -workdir... └─app,3009 └─(app,2080)
在这个结构中,如果有子进程,必然是父进程创建出来的 调用fork为父进程,被fork创建的进程为子进程 调用一次fork 创建一个子进程
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("initing \n");
pid_t pid = fork();#pid_t 进程ID
if (pid >0)
{
/* code */
printf("father process...\n");
while(1) sleep(10);
}
return 0;
}
继承:默认情况下父进程的工作以及数据会继承给子进程,子进程直接使用
创建子进程流程:
ps aux 查看进程信息
fork 的核心任务:
1.create(进程创建)--------------> 创建一块虚拟地址空间(一个空壳)
2.clone(克隆,进程初始化)------->1.pcb 2.将父进程的资源继承拷贝给子进程
注意事项:
内核:PCB 是部分拷贝(pid 不拷贝),可以继承的拷贝给子进程一份,其他的重新生成
用户空间:完成拷贝
子进程将父进程的空间拷贝一份,无论如何修改资源与父进程无关
父子进程任务执行,父进程从代码段起始位置开始执行,子进程是从fork()之后的代码段开始执行的 区分父子进程工作区:利用fork返回pid区别父子进程。父进程中返回子进程pid(>0) ,子进程中返回0,失败返回-1
区分父子进程
示例:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main()
{
printf("initing...\n");
pid_t pid = fork();
if(pid >0)
{
printf("parent process \n");
while(1) sleep(10);
}else if(pid ==0)
{
printf("child process \n");
while(1) sleep(10);
}else
{
perror("fork call failed");
exit(0);
}
}
创建多进程模型(一父多子):
示例:
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t pid;
int i;
for( i =0 ;i <10;i++)
{
pid = fork();
if(pid == 0) //如果不加pid ==0 循环会创建多少个子进程呢?
break;
}
}
8.fork()
fork函数的版本变化:
version 1:第一版的fork,开发者调用fork后,内核创建子进程,并将父进程资源拷贝给子进程,子进程继承所有父进程资源与任务。(内核空间部分拷贝)
问题:在某些特殊开发环境下和特殊要求下,继承是一个缺点。(比如继承父业,父进程通过内核将大部份资源继拷贝给子进程,但是子进程没有使用,导致内核克隆拷贝没有意义,造成资源浪费)
version 2:第二版vfork,开发者调vfork后,内核创建子进程,但是不会发生继承过程,不会将任何父进程资源拷贝给子进程,子进程需要重载自定义任务
问题:如果子进程需要继承资源,那么vfork()就不可用(比如 一份财产拿不到)
官方提供这两个版本,开发者根据需要自行选择
当fork 读时共享,写时拷贝技术 vfork已经逐渐废弃了
实际上,准确的来说,Linux的fork 是通过 写时拷贝 (copy-on-write)实现。写时拷贝是一种可以推迟甚至不用避免拷贝的技术。更具体来讲,在执行fork语句后,内核并不复制父进程的整个地址空间,而是父子进程共享父进程的地址空间(此时父子进程对于地址空间是只读指令),在父进程或者子进程进行写指令时,子进程才会复制一份地址空间,从而使得父子进程拥有自己的虚拟地址空间,在自己的地址空间进行写操作。也就是说,资源的复制是在需要写入时才会进行,在此之前,只会以只读方式进行共享。
对于文件资源,fork之后的父子进程共享文件,fork之后的父进程与子进程的文件描述符表指向相同的文件表,引用计数增加,共享文件偏移指针。
version 3:最终版fork,开发者调用fork后,内核创建子进程,而后使用读时共享写时复制的方式,解决继承困境。
读共享采用映射技术,将父进程的用户空间映射给子进程,子进程通过映射可以读访问父进程的资源,写的时候采用clone技术
示例:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
void joba()
{
printf("joba....\n");
}
void jobb()
{
printf("jobb....\n");
}
int main()
{
pid_t pid;
int i;
for( i =0 ;i <2;i++)
{
pid = fork();
if(pid == 0)
break;
}
if(pid > 0)
{
printf("parent process\n");
while(1) sleep(1);
}
else if(pid ==0)
{
if(i)
{
joba();
exit(0);
}
else
{
jobb();
exit(0);
}
}
return 0;
}
多进程开发的注意事项:关于子进程工作范围 子进程不应该踏出自己的工作区域,工作区代码执行完毕立即结束