Skip to content

Process

Concepts

进程是一个运行中的程序,Process is a program in execution

一个程序可以有多个进程,One program can be several processes

一个进程也可以有多个程序

进程可以通过GUI或命令行启动,实际上是通过系统调用启动的

一个进程包含多个部分:

  1. program code,也叫做text section
  2. runtime CPU states
  3. various types of memory
    1. stack: temporary data,function parameters,local variables,return addresses
    2. data section: global variables
    3. heap: memory dynamically allocated during runtime

cat /proc/self/maps

cat是bin下的一个动态链接的可执行文件,由shell进程fork出来

proc文件,进程的PID dump出来给用户

self,当前进程,也就是cat这个进程

img

地址较低的地方是代码段,text

操作系统为每一个进程分配一个PCB的结构体,包含进程的PID等信息

Memory layout of a C program

image-20231127161908392

  1. 栈 (high memory -> low memory)

    编译器在编译生成main函数的时候,会插入函数的prolog,移动栈指针

    每次函数调用的时候,才能给local variable分配内存

    因此不停地把栈指针往下移,分配空间来容纳这些局部变量

    对于一个进程,会默认设置栈的大小,如果递归调用的层数过多,栈会溢出

    为什么局部变量要放在栈上,不能放到data段?

    进程在初始化的时候,各个段的大小就确定好了,但是随着函数被调用,可能存在递归调用等,无法预测需要在memory中分配多少内存,没办法在data中存放局部变量;但是栈是可以扩大的,把局部变量放在栈上,每次为当此函数的上下文创建内存空间,直到返回再pop掉,这样就能为每一次调用的局部变量分配内存空间,是一个动态的概念。

  2. 堆 (low memory -> high memory)

    malloc和free是libc实现的

    在libc中,有一段程序维护堆数据结构,记录哪里分配掉了、哪里没有分配掉,当malloc时,由堆管理程序来管理

    可能heap存在一些空洞,即使所有空洞加起来能满足请求,但是因为不连续,堆管理程序会通过系统调用和操作系统申请空间,向上增长

    堆不断向上增长,栈不断向下增长,可能会发生overlap,但64位程序虚拟地址空间很大,不太可能

    note:局部变量values在栈上,被填了一个地址值,但这个地址指向的地方在堆里

Process State

new, running, waiting/blocking, ready, terminated

如图是进程状态的状态图:

  1. 一个进程,到running状态之前,必须在ready状态排队;ready队列是当前系统内可以被调度的所有进程的集合,调度就从ready的队列中拿一个优先级最高的让它变成running状态

  2. 当一个进程的时间片用完之后,被调度出去,就从running变成ready状态

image-20231127163919497

如果恰好,时钟中断到来的时候,CPU发现没有处于ready状态的进程的时候,就去执行idle进程,直到处于waiting状态的进程回到ready状态,再调度进程

note:I/O done的时候会发中断

idle:系统里没有其它进程可被调度的时候,调度idle,它会告诉CPU进入低功耗状态

比如,scanf函数,如果用户没有输入,进程就会在waiting状态,等待用户输入,直到用户输入完成,就能切换到ready状态,等待CPU调度才能进到running状态

Process Control Block(PCB)

所谓的“多了一个子进程”,实际上是多了一个PCB

kernel中,每个进程都要有自己的PCB,包括:

image-20231127164919426

内核中需要数据结构去管理进程,是非常消耗资源的,因此我们不希望有太多的进程

在Linux中,PCB的具体实现,是C结构体task_struct,不同进程的PCB是通过双向链表连起来的


对于一个进程,它的地址空间是0~max,但不是每个地址空间用户态都能使用,只有向OS申请过才能使用,这个由memory-management data来记账

char p=0x56780; p='a';

操作系统不允许访问未申请的空间,会出错,segmentation fault

但OS是软件,CPU是硬件,上述操作实际上是个ST指令

这是因为CPU执行store的时候,先访问TLB,再查表,得到物理地址再去访问memory

但是程序中的地址是虚拟地址,需要映射到物理地址上才能访问

只有当OS记账,把虚拟地址映射到物理地址上,CPU才能访问memory中的这个地址;因此操作系统只要把可访问的地址的页表设置好,不可访问的不去做映射,CPU就能遵循OS的意图了

浮点数运算,很多CPU是不支持的,但有的CPU有fpu,硬件上支持浮点数运算

操作系统内核是不支持浮点数运算的,大部分用户态程序也不会进行浮点数运算

理论上,浮点数寄存器是需要保存起来的;但是如果下一个进程不使用浮点数寄存器,就没必要保存;只有当下一个进程使用浮点数寄存器的时候,再去保存浮点数寄存器,采用lazy-saving的思想(把保存浮点数寄存器的操作推迟到不得不做的时候再做)

编译的时候可以加个选项,支持硬浮点运算,那这个程序是可以在包含硬浮点操作的CPU上运行的;如果CPU不支持硬浮点运算,会抛出异常“非法指令”,交给操作系统内核处理,有OS做fpu emulation,返回正确结果,虽然慢一点,但不影响得到正确结果

emulation,功能模拟

如qemu实际上是个软件,模拟RISC v每一条指令的语义,在emulator里执行CPU指令;如fpu emulation,在没有浮点运算的架构上,去模拟浮点运算;emulator保证的是指令语义的执行

simulation

simulator不仅保证语义的正确性,还要模拟流水线的状态,甚至做到时钟级别的准确性


Threads

一个进程中的线程1和线程2,可能run在不同的Core上

每一个线程都要有自己单独的栈

Process Scheduling进程调度

从系统的可响应性的角度,进程调度是非常频繁的,usually in milliseconds,因为操作系统对响应性的要求非常高,对用户来说,如果点一下还要等几秒钟,我的进程才能被调度,太慢啦

但为了提高CPU的利用率出发,进程调度是很消耗资源的,又希望它不那么频繁

进程调度是通过队列的形式,链表实现的queue

进程在不同的队列 (queue) 之间迁移:因为处在不同状态的进程的PCB被放在不同的队列中

Swap In/Out

如果运行的进程数量多、进程占用的内存大,物理内存比较紧张

  1. 不带交换分区,操作系统会把占用内存过大的进程kill掉

  2. 带交换分区 (有storage),把某个进程写到storage上,这个进程占用的物理内存就能被释放掉,降低物理内存的压力,这就是swap out

    当这个进程下一次被调度的时候,由于只能操作memory,就把这个进程swap in进来,但不需要把它写到原来的内存地址上,只需要重新建立虚拟地址的映射即可

不过现在,大部分情况下内存压力不是特别大,所以不会分配交换分区

Scheduler

Scheduler需要平衡I/O-bound processCPU-bound process的需求

I/O-bound,I/O请求比较频繁,CPU计算比较少;CPU-bound相反

通用计算机一般是I/O-bound的

Context Switch

进程切换的时候需要完成上下文切换,保存旧的进程状态、load新的进程的状态

Context-switch is overhead,操作系统越复杂,PCB保存的信息越多,上下文切换需要的时间越长

上下文切换的时间和硬件支持有关,比如CPU里有两套general purpose registers,只有一套执行单元,就直接load另一套register set就好了,不需要save PCB,这就是Hyper Threading

同一时刻只能执行一个硬件线程,但是切换非常快,让人觉得有两套CPU在执行,省去了save和load的操作

Process Creation

init进程是操作系统运行之后的第一个进程,是操作系统唯一显式地运行的一个进程。父进程创建子进程,子进程再创建其它进程,形成进程树

design choices:

  1. 可能的资源共享有三种:all,subset,none

  2. 父进程和子进程,内存资源是不共享的,共享的资源极其少

    • child duplicates parent address space (e.g., Linux)

    • child has a new program loaded into it (e.g., Windows)

  3. 父进程和子进程的执行

    • parent and children execute concurrently

    • parent waits until children terminate

UNIX/Linux的system call:fork(), exec(), wait()

子进程退出的时候,要报告信息给父进程

Process Termination

进程执行到最后一条指令,会要求kernel删除这个进程(exit)

  1. 由OS,通过 wait 把返回值从子进程给到父进程

  2. 进程占有的资源被操作系统回收

父进程对子进程有一定的控制权,父进程可能终止子进程的执行:

  1. child has exceeded allocated resources

  2. task assigned to child is no longer required

  3. if parent is exiting, some OS does not allow child to continue, 所有的子进程都会被terminated,称为cascading termination

zombie vs orphan:

当子进程结束的时候,操作系统不能马上把子进程从process table中删掉,他要等到父进程call wait()

  1. zombie (僵尸进程):子进程已经结束执行了,但是父进程还没有call wait

  2. orphan (孤儿进程):父进程马上就结束了,没有call wait,子进程要执行的任务又很长,当子进程结束之后,操作系统会把子进程的parent赋为systemd

systemd,是用户态进程,它会周期性地call wait()

Android Process Importance Hierarchy

两个不同的进程之间是相互隔离的,但是进程和进程之间能通过ptrace等syscall去trace另一个进程;进程和进程之间的隔离,不是安全隔离,只是资源不共享

安卓操作系统是通过uid来隔离的,对于每一个APP,uid是不一样的

Linux操作系统,进程和进程能操作的文件是不隔离的,因为都是同一个用户在操作

安卓系统进程优先级:

  1. 前台进程 (屏幕上可见的)优先级最高
  2. 后台进程优先级较低
  3. 空进程优先级最低

image-20231207142237406

  1. 安卓启动之后,init进程会load起来Zygote进程,之后使用APP的子进程都是由Zygote进程的子进程
  2. 在启动Zygote的时候,会把所有第三方库都load到物理内存中,虽然加载比较慢,但没关系,这是应用启动时一次性做的
  3. 之后fork子进程的时候,由于子进程是父进程地址空间的拷贝,在子进程的虚拟地址空间中,有libc等库,由于物理内存中以及有第三方库了,启动app时,不同从external storage中加载库
  4. 这样实现了预加载,提高了系统的性能

COW机制 (copy on write):

  1. 子进程拷贝一份父进程的资源,并不是真的直接在PA中拷贝了一份,对于只读的部分,是将子进程中虚拟地址的映射同样映射到PA中;对于可读可写的内存,映射到新开辟的地方
  2. 但这违背了lazy的原则,因为子进程不一定会修改data的值;解决方案是,在拷贝这段内存的时候,把data段标记为read only,如果子进程运行的时候对data进行了写入,就会抛出异常,操作系统发现之后再copy一段data区域

因此就实现了把分配数据内存推迟到第一次写入的时候,而且开辟内存是以page为粒度的,可以只拷贝需要修改的page

多进程架构:Chrome Browser

Google Chrome Browser是多进程的,有三种类型的进程:

  1. Browser process manages user interface, disk and network I/O
  2. Renderer process renders web pages, deals with HTML, Javascript. A new renderer created for each website opened

    • Runs in sandbox restricting disk and network I/O, minimizing effect of security exploits

  3. Plug-in process for each type of plug-in (插件)

插件的进程封在一个隔离的进程里,限制这个进程能使用的system call

  1. Remote Process Call
  2. 对于不可信的行为多的进程,就对它的行为进行限制

Interprocess Communication

一个系统中的进程可以是独立的也可以是协作的

  1. independent process: process that cannot affect or be affected by the execution of another process
  2. cooperating process: processes that can affect or be affected by other processes, including sharing data

•reasons for cooperating processes: information sharing, computation speedup, modularity, convenience, Security

·cooperating processes需要IPC 3. IPC的两种模型

Shared memory:灵活方便,直接往memory中写就可以

Message passing:多进程之间通信时,消息队列是构建好的

image-20231207145357061

inter*** 过程之间的

intra*** 过程之内的

Message Passing

POSIX Shared Memory

  1. 进程先创建共享内存段

    shm_fd = **shm_open**(name, O CREAT | O RDWR, 0666);

  2. 也可以用于打开一个已经存在的段

  3. 设置对象的大小:**ftruncate**(shm_fd, 4096);

  4. 使用 mmap()来内存映射一个文件指针到共享内存对象

    操纵文件变成操纵内存,对文件的操纵效率变高

    使用mmap()返回的指针完成对共享内存的读写

Pipes

管道通信是消息队列的一种实现方式,允许本地的两个进程进行通信

  1. 普通管道 ordinary pipes

    image-20231207151507500

  2. named pipes

Client-server Communication

如果进程A想要调用进程B的函数,并得到返回值,A和B可以是同一台机器的,也可以是网络连接的,即跨进程函数调用,实现这个要用到RPC (remote process call)

  1. 怎么知道远端进程提供了什么函数来调用

    通过桩代码实现远端调用

    1
    2
    3
    4
    //A
    int a=Stub.a(2,3);
    //B
    int a(int, int);
    
    任何一个服务进程上线的时候,需要到中心化的注册处注册,之后这个函数就会暴露给RPC

    客户进程调用的时候,去注册处查询

    服务器把函数调用的参数打包,送给服务进程,执行完,再把返回值打包给客户端,都是以字节流的形式

    安全问题:

    客户端丢过来的函数是否是已经注册过的函数,如果没有注册过,客户端可以欺骗服务端,调用他本不该调用的函数并拿到返回值,安全性有问题

  2. RPC

    image-20231207153132891