进程是进程是操作系统分配资源的最小单位。线程是程序执行的最小单位。
进程和线程资源共享:
线程共享的内容包括:
- 1.进程代码段
- 2.进程数据段(包括BSS段)
- 所以全局变量和静态变量是共享的。
- 3.进程打开的文件描述符
- 4.信号的处理器
- 5.进程的当前目录和
- 6.进程用户ID与进程组ID
- 7.堆
线程独有的内容包括:
- 1.线程ID
- 2.寄存器组的值
- 3.线程的栈 (注意,线程栈是理论上私有,由于,没有独立地址空间所以依旧可以互相访问。也就是子线程依旧可以访问主线程的栈或其他线程的栈)(我们所说的线程栈独有是因为新建线程的时候会从进程的
mmap
区划一块给线程当栈,然后系统自动在这个部分当栈而已。) - 4.错误返回码
5.线程的信号屏蔽码
- 在一个线程中访问另一个线程的局部变量是否合法?
- 如果被访问的变量的声明周期长于访问方,则是合法。否则行为未定义。参考
文件描述符
- 文件描述符表每个进程都有一个
- 打开文件表和 i-node 表整个系统只有一个,它们三者之间的关系如下图所示。
有了以上对文件描述符的认知,我们很容易理解以下情形:
- 同一个进程的不同文件描述符可以指向同一个文件;
- 不同进程可以拥有相同的文件描述符;
- 不同进程的同一个文件描述符可以指向不同的文件(一般也是这样,除了 0、1、2 这三个特殊的文件);
- 不同进程的不同文件描述符也可以指向同一个文件。
进程退出
- 进程退出的时候会自动关闭自己打开的所有文件和网络连接
- 进程退出的时候不会自动销毁共享内存(注意不是mmap是shmget)。因为进程间通信使用的数据结构是内核级别的。创建 后由内核管理
孤儿进程 僵尸进程
孤儿进程:
一个父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。孤儿进程将被init进程(进程号为1)所收养,并由init进程对它们完成状态收集工作。
一句话总结:父进程比子进程走得早。子进程由系统接管。变成孤儿进程
僵尸进程:
一个进程使用fork创建子进程,如果子进程退出,而父进程并没有调用wait或waitpid获取子进程的状态信息(没有回收子进程资源),那么子进程的进程描述符(资源)仍然保存在系统中。这种进程称之为僵死进程。也就是子进程结束了父进程没结束,子进程部分PCB信息就会被悬挂。 当子进程走完了自己的生命周期后,它会执行exit()系统调用,内核释放该进程所有的资源,包括打开的文件,占用的内存等。但是仍然为其保留一定的信息(包括进程号the process ID,退出码exit code,退出状态the terminationstatus of the process,运行时间the amount of CPU time taken by the process等),这些数据会一直保留到系统将它传递给它的父进程为止,直到父进程通过wait / waitpid来取时才释放。 也就是说,当一个进程死亡时,它并不是完全的消失了。进程终止,它不再运行,但是还有一些残留的数据等待父进程收回。当父进程 fork() 一个子进程后,它必须用 wait() (或者 waitpid())等待子进程退出。正是这个 wait() 动作来让子进程的残留数据消失。
- 一句话总结:如果子进程结束,父进程没有回收子进程的资源,则子进程部分PCB信息会被悬挂。如果不用wait或waitpid回收就会变成僵尸进程。因为没办法回收掉其中资源
守护进程
- 守护进程的父进程是init进程,因为它真正的父进程在fork出子进程后就先于子进程exit退出了,所以它是一个由init继承的孤儿进程。守护进程是非交互式程序,没有控制终端,所以任何输出,无论是向标准输出设备stdout还是标准出错设备stderr的输出都需要特殊处理。
- 守护进程的名称通常以d结尾。
创建守护进程:
fork()创建子进程,父进程exit()退出
这是创建守护进程的第一步。由于守护进程是脱离控制终端的,完成这一步后就会在Shell终端里造成程序已经运行完毕的假象。之后的所有工作都在子进程中完成,而用户在Shell终端里则可以执行其他命令,从而在形式上做到了与控制终端的脱离,在后台工作。
- 由于父进程先于子进程退出,子进程就变为孤儿进程,并由 init 进程作为其父进程收养。
- 此步骤目的是脱离控制终端在后台工作。使其被init进程接管。但是此时并未完全脱离。
在子进程调用setsid()创建新会话
- 在调用了 fork() 函数后,子进程全盘拷贝了父进程的会话期、进程组、控制终端等,虽然父进程退出了,但会话期、进程组、控制终端等并没有改变。这还不是真正意义上的独立开来,而 setsid() 函数能够使进程完全独立出来。
- setsid()创建一个新会话,调用进程担任新会话的首进程,其作用有:
- 使当前进程脱离原会话的控制
- 使当前进程脱离原进程组的控制
- 使当前进程脱离原控制终端的控制
- 这样,当前进程才能实现真正意义上完全独立出来,摆脱其他进程的控制。
- 此步骤目的是彻底和父进程断开联系完全独立。因为需要创建新的会话以使守护进程脱离原会话,进程组,控制终端的控制。但是现在它依旧可以重新申请打开一个控制终端,因为它是会话组长。
再次 fork() 一个子进程,父进程exit退出
- 我们刚才提到了,在上一步骤结束的时候,进程已经成为无终端的会话组长,但它可以重新申请打开一个控制终端。但是我们不希望让他打开终端,怎么办?那就重复第一步。再次fork子进程,由于该子进程不是会话首进程,所以该进程将不能重新打开控制终端。然后退出父进程。
- 此步骤目的是通过再次创建子进程后结束当前进程,使进程不再是会话首进程来禁止进程重新打开控制终端。
在子进程中调用chdir()让根目录“/”成为子进程的工作目录;
- 这一步也是必要的步骤。使用fork创建的子进程继承了父进程的当前工作目录。由于在进程运行中,当前目录所在的文件系统(如“/mnt/usb”)是不能卸载的,这对以后的使用会造成诸多的麻烦(比如系统由于某种原因要进入单用户模式)。因此,通常的做法是让”/”作为守护进程的当前工作目录,这样就可以避免上述的问题,当然,如有特殊需要,也可以把当前工作目录换成其他的路径,如/tmp。改变工作目录的常见函数是chdir。(避免原父进程当前目录带来的一些麻烦)
在子进程中调用umask()重设文件权限掩码为0;
- 文件权限掩码是指屏蔽掉文件权限中的对应位。比如,有个文件权限掩码是050,它就屏蔽了文件组拥有者的可读与可执行权限(就是说可读可执行权限均变为7)。由于使用fork函数新建的子进程继承了父进程的文件权限掩码,这就给该子进程使用文件带来了诸多的麻烦。因此把文件权限掩码重设为0即清除掩码(权限为777),这样可以大大增强该守护进程的灵活性。通常的使用方法为umask(0)。(相当于把权限开发)
在子进程中close()不需要的文件描述符
- 同文件权限码一样,用fork函数新建的子进程会从父进程那里继承一些已经打开了的文件。这些被打开的文件可能永远不会被守护进程读写,但它们一样消耗系统资源,而且可能导致所在的文件系统无法卸下。其实在上面的第二步之后,守护进程已经与所属的控制终端失去了联系。因此从终端输入的字符不可能达到守护进程,守护进程中用常规方法(如printf)输出的字符也不可能在终端上显示出来。所以,文件描述符为0、1和2 的3个文件(常说的输入、输出和报错)已经失去了存在的价值,也应被关闭。
- 此步骤主要目的是:关闭失去价值的如输入、输出、报错等对应的文件描述符
主线程和子线程退出关系:
主线程和子线程之间没有必然的退出次序关系。主线程退出,子线程可以继续执行,也可以整体退出。;子线程退出,主线程也可以继续执行。 程序加载到内存中执行的时候,进程就会生成一个主线程。虽然主线程和子线程之间没有必然的退出次序关系,但是如果进程终止,那么进程下所有的线程都会终止
- 若想主线程在子线程前结束,并且进程不结束,就需要用到pthread_exit()函数。按照POSIX标准定义,当主线程在子线程终止之前调用pthread_exit()时,子线程是不会退出的。 这里在main函数中调用pthread_exit()只会是主线程退出,而进程并未退出。通过调用pthread_exit函数,线程会显式终止。如果主线程调用pthread_exit,它会先终止,然后等待其他子线程终止后,进程才终止。
- 如果主线程以return的方式退出,则子线程会退出。如果主线程使用的是pthread_exit() ,实际上是提前结束了 main 的主线程,也就无法执行后续的 exit() 函数了。所以,这种方法是可以达到主线程退出子线程继续运行的目的。
EXIT和return区别
EXIT
- 是系统层级的系统调用,指的是让进程退出。调用后会开始进行资源的清理和回收。
- exit是一个函数
return
- return是一个关键字,用于退出这个函数。(结束函数的执行)
pthread_exit
所以使用pthread_exit可以维持子线程不退出的原因是,虽然主进程也是主线程,但是我只让主进程自己的线程退出,也就是不回收进程资源,相当于进程没有结束,子线程依旧可以执行。
用户空间(0-3G):进程私有,内核空间(3G-4G):所有进程共享
注意,此处的所有进程共享内核空间的意思是共享内核页表。也就是所有进程看到的内核虚拟地址空间是同一个。
- 内核对于所有的进程,不但物理内存只有一份,虚拟内存也是只有一份。也就是说
- A进程用户态访问0x100虚拟地址和B进程用户态访问0x100虚拟地址是不同的虚拟地址,也即A进程用户态在0x100虚拟地址里面放了一个数值123,B进程用户态的0x100虚拟地址看不到123,因为对应的是不同的物理地址。因为不是同一张页表
- A进程内核态访问的0xff虚拟地址,和B进程内核态访问的0xff虚拟地址,是同一个虚拟地址,也对应相同的物理地址。也即A进程内核态在0xff虚拟地址放一个数值123,B进程的内核态如果能够访问0xff虚拟地址的话,也能看到123。
我们提到过,子进程fork父进程会复制页表。进程创建的时候会从init进程fork。所以自然会fork init进程的页表。也即实现了内核空间的共享。所以
- 一个进程在内核态 可以直接通过虚拟地址访问其他进程内核态的数据,因为他们是一个页表。
- 一个进程在内核态 不可以直接通过虚拟地址访问其他进程的用户态的数据,因为他们不使用同一个页表。
- 这也就是进程的隔离性,也就是每一个进程都有自己的页表。但是内核空间的页表是共享的。所以一个空间在内核态使用内核页表可以访问其他进程的内核态数据。
由于系统中只有一个内核实例在运行,因此所有进程都映射到单一内核地址空间。内核中维护全局数据结构和每个进程的一些对象信息,后者包括的信息使得内核可以访问任何进程的地址空间。通过地址转换机制进程可以直接访问当前进程的地址空间(通过MMU),而通过一些特殊的方法也可以访问到其它进程的地址空间。
- 每个进程都有两个栈。一个用户栈,一个内核栈。内核栈运行在内核态。
- 每一个进程都有自己独立的内核栈,尽管他们共享内核态页表。
- 在内核运行的过程中,如果碰到系统调用创建进程,会创建
task_struct
这样的实例,内核的进程管理代码会将实例创建在3G
至3G+896M
的虚拟空间中(这一部分叫做直接映射区。就是这一块空间是连续的,和物理内存是非常简单的映射关系,其实就是虚拟内存地址减去3G
,就得到物理内存的位置。)当然也会被放在物理内存里面的前896M
里面,相应的页表也会被创建。 - 在内核运行的过程中,会涉及内核栈的分配,内核的进程管理的代码会将内核栈创建在
3G
至3G+896M
的虚拟空间中,当然也就会被放在物理内存里面的前896M
里面,相应的页表项也会被创建。 - 内核空间的不同进程的内核栈虚拟地址互不重叠。重叠了不就坏了么。
- 在内核运行的过程中,如果碰到系统调用创建进程,会创建
- 为什么每一个进程都是用各自的内核栈呢。
- 假设某个进程通过系统调用运行在内核态(使用这个全局内核堆栈),此时如果被抢占,发生一次切换,另一个进程开始运行,如果这个当前进程又通过系统调用陷入内核,那么这个进程也将使用这个全局内核堆栈,这样的话就把以前那个进程的内核空间堆栈给破坏了。 而如果进程使用独立的内核栈,就避免了这种情况的发生
- 每当我们创建新的线程的时候都会一同创建线程的内核栈,实现上是通过 slab 分配器从
thread_info_cache
缓存池中分配出来,其大小为THREAD_SIZE
,一般来说是一个页大小 4K。
https://time.geekbang.org/column/article/95715
https://fanlv.wiki/2021/07/25/linux-mem/#%E5%86%85%E6%A0%B8%E5%9C%B0%E5%9D%80%E7%A9%BA%E9%97%B4
蓝色区域的.bbs .data不太懂。
https://www.jianshu.com/p/174c1da40c03
内核栈
看杂记的六大内存区
每一个进程都有一个自己的页表
进程有独立地址空间,线程没有。因为线程属于进程,多个线程都属于一个进程,所以互相之间共享这个地址空间。也导致没有隔离性。
中断中的缺页中断
在请求分页的过程中,如果访问的页面不再内存中,会产生一次缺页中断,在外存中找到所缺的一页将其调入内存。
步骤:
保护cpu现场
分析中断原因
转入缺页中断处理函数
恢复cpu现场,继续执行
缺页异常
会出现缺页异常的情况:
- 线性地址不在虚拟地址空间中
- 线性地址在虚拟地址空间中,但没有访问权限
- 接上一条,没有与物理地址建立映射关系
fork等系统调用时并没有映射物理页(没有权限),写数据->缺页异常->写时拷贝
- 映射关系建立了,但在交换分区中
- 页面访问权限不足
上下文切换
上下文分为三种:寄存器上下文,系统级上下文 和 用户级上下文
寄存器上下文:
- 通用寄存器
- 程序寄存器(IP)
- 处理器状态寄存器(EFLAGS)
- 栈指针(ESP)
系统级上下文
- 进程控制块task_struct
- 内存管理信息(mm_struct、vm_area_struct、pgd、pte)
- 内核栈
用户级上下文
- 正文
- 数据
- 用户态堆栈
- 共享存储区
CPU的上下文切换- CPU上下文包含寄存器和程序计数器。也就是寄存器上下文
任务是交给 CPU 运行的,那么在每个任务运行前,CPU 需要知道任务从哪里加载,又从哪里开始运行。
所以,操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器。
- CPU 寄存器是 CPU 内部一个容量小,但是速度极快的内存(缓存)。我举个例子,寄存器像是你的口袋,内存像你的书包,硬盘则是你家里的柜子,如果你的东西存放到口袋,那肯定是比你从书包或家里柜子取出来要快的多。
- 程序计数器则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。
所以说,CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文。
CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。
系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。(也叫做保存和恢复CPU现场)
一句话:任务需要切换的时候,(CPU切换到其他任务进行执行的时候),需要先保存旧任务的上下文,然后再加载新的上下文,上下文包括cpu寄存器和程序计数器。
上面说到所谓的「任务」,主要包含进程、线程和中断。所以,可以根据任务的不同,把上下文切换分成:进程上下文切换、线程上下文切换和中断上下文切换。
进程的上下文切换 - 进程上下文包括内核空间的资源(内核堆栈,寄存器等) + 用户空间资源(虚拟内存,堆,栈,全局变量等) 这一大堆就是PCB
进程是由内核管理和调度的,所以进程的切换只能发生在内核态。
所以,进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。而且,由于切换了进程,也切换了页表。涉及到缓存的部分比如L1 L2 L3 和 TLB大概率会被全部击穿,导致需要进行IO
通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行。
一句话,我们把重要的信息保存在PCB里面,进程上下文就是PCB内容(如进程ID,进程堆栈,进程各个寄存器的状态)。
所以 进程的上下文切换需要寄存器上下文(CPU现场),系统级上下文(内核空间的资源) 和 用户级上下文 (用户空间资源)同时切换。
发生进程上下文切换有哪些场景?
- 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,进程就从运行状态变为就绪状态,系统从就绪队列选择另外一个进程运行;
- 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;
- 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;
- 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;
- 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;
线程的上下文切换:
在前面我们知道了,线程与进程最大的区别在于:线程是调度的基本单位,而进程则是资源拥有的基本单位。
所以,所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源。
对于线程和进程,我们可以这么理解:
- 当进程只有一个线程时,可以认为进程就等于线程;
- 当进程拥有多个线程时,这些线程会共享相同的虚拟内存(虚拟地址空间)和全局变量, 堆等资源,这些资源在上下文切换时是不需要修改的;
- 另外,线程也有自己的私有数据,比如栈和寄存器等,这些在上下文切换时是需要保存的。(这部分不共享,每一个线程独有)
所以上下文切换这还得看线程是不是属于同一个进程:
- 当两个线程不属于同一个进程,则切换的过程就跟进程上下文切换一样;
- 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据。比如上面写到的不共享的,重点就是俩:寄存器,栈,线程ID;
所以,线程的上下文切换相比进程,开销要小很多
所以 同进程线程的上下文切换只需要切换寄存器上下文。因为系统级上下文(如进程控制块,内存管理信息)和 用户级上下文(如虚拟内存,堆栈等等是共享的)。注意,这里不切换用户级上下文是因为线程栈是从进程的mmap里面划出来的。因为处于同一地址空间所以用户级上下文没有切换,只是多分出来了一块而已。这也就是为什么我们线程也可以访问其他线程的栈,包括主线程的栈。因为处于同一个地址空间。
系统调用
- 凡是与资源有关的操作、会直接影响到其他进程的操作,一定需要操作系统介入,即需要通过系统调用来实现
- 系统调用发生在用户态,对系统调用的处理发生在内核态。执行陷入指令会产生内中断,使处理器从用户态进入内核态
- 因为用户态和内核态不共用一个堆栈,所以用户态调用系统调用时,得先保存用户态的信息到寄存器,然后切入到内核态,将寄存器中的信息拷贝到内核栈开始执行,执行结束后,还原之前用户态的状态让用户态继续往下执行。
- 软中断指令
int 0X80
这个int不是integer,interrupt。
用户态和核心态之间的切换是通过中断实现的。并且,中断是实现状态切换的唯一途径。
中断只可以被操作系统进行操作。我们想要操作中断必须使用系统调用
- 当中断发生时,CPU立即进入核心态。
- 当中断发生后,当前运行的进程暂停运行,并由操作系统内核对中断进行处理。
- 对于不同的中断信号,会进行不同的处理。
- CPU执行完每一条指令后都会检查是否有中断信号。除执行了关中断指令外。
整个过程是这样的:
- 保存 CPU 寄存器里原来用户态的指令位 (保存内核空间资源)
- 为了执行内核态代码,CPU 寄存器需要更新为内核态指令的新位置。
- 跳转到内核态运行内核任务。
- 当系统调用结束后,CPU 寄存器需要恢复原来保存的用户态(内核空间资源),然后再切换到用户空间,继续运行进程。
中断的核心是跳转。因为CPU内部的中断控制器接收到中断指令后,需要查找对应的中断处理程序,然后告知CPU具体使用的中断处理程序。也就是说,针对特定的中断号,CPU会有不同的处理方式。也就是说,IRQ这种可屏蔽中断信号只负责告诉CPU有事情来了,CPU可以选择不响应(屏蔽)也可以响应。而且具体的响应方式依靠IDT中断描述符表来决定。
程序在执行过程中通常有用户态和内核态两种状态,CPU对处于内核态根据上下文环境进一步细分,因此有了下面三种状态:
- 内核态,运行于进程上下文,内核代表进程运行于内核空间。
- 内核态,运行于中断上下文,内核代表硬件运行于内核空间。
- 用户态,运行于用户空间。
所以,一次系统调用的过程,其实是发生了两次 CPU 上下文切换。(用户态-内核态-用户态)
不过,需要注意的是,系统调用过程中,并不会涉及到虚拟内存等进程用户态的资源,也不会切换进程。这跟我们通常所说的进程上下文切换是不一样的:进程上下文切换,是指从一个进程切换到另一个进程运行;而系统调用过程中一直是同一个进程在运行。
所以,系统调用过程通常称为特权模式切换,而不是上下文切换。系统调用属于同进程内的 CPU 上下文切换。但实际上,系统调用过程中,CPU 的上下文切换还是无法避免的。
系统调用比进程上下文切换少了一步 - 保存用户空间资源。因为系统调用是CPU上下文切换
- 系统调用是CPU上下文切换。注意区分进程上下文切换。
- 所以我们也可以理解为,系统调用也仅仅是进行了寄存器上下文切换。因为只切换了寄存器内容。因为处于同进程中,所以用户级和系统级上下文无需切换,只需要保留。
系统调用过程中可能发生进程切换。比如来自时钟中断 - 时间片用完
- 系统调用过程中可能发生中断。因为中断可以在任意时刻发生。中断不属于任何一个进程上下文。
为什么维护进程开销大?而线程开销小?
进程:
- 创建进程时,分配资源、建立 PCB;
- 终止进程时,回收资源、撤销 PCB;
- 进程切换时,保存当前进程的状态信息;
线程:
- 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;一句话:线程不复制(建立)PCB
- 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;一句话:线程不复制pcb,所以也不撤销PCB
- 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;一句话:共享虚拟地址空间,映射也不修改所以侧面也是共享物理地址空间,切换线程不需要切换页表。
- 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;一句话:线程没有隔离性,文件资源和内存都共享。因为线程是属于进程的
子进程fork父进程,哪些数据复制哪些不复制?
- 所有用户区数据都复制,比如堆,栈,bss, data, text, 内存映射区,环境变量命令行参数等。
- 内核区只有一样不同(不复制):PID。
- 会复制页表(因为每一个进程都有自己的页表),但不复制物理页面。(如果有写时拷贝COW)
一句话:没有写时复制的话全都复制,只有PID不同。有写时复制的话用哪儿复制哪儿。
- 在没有写时复制(copy on write)的时候,fork的那一刻,就会为子进程分配新的虚拟内存并且把物理内存中的数据复制至新开辟的物理内存中,然后映射至虚拟内存。
- 如果有写时复制,则fork的时候为子进程开辟新的虚拟内存,但是物理内存依旧映射至父进程的物理内存。所以可以理解为现在父子进程共享这一段物理内存,但是进程针对这段虚拟内存一定是只读的,因为只有设置为只读,这样进行写操作的时候才能触发缺页异常然后分配新的物理内存并复制里面的内容。在这之后,就可以把进程的内存设置为可读写了 。。
- 注意上面是触发缺页异常不是缺页中断。缺页中断说法不准确,应该叫因为缺页异常触发的中断叫缺页中断,因为要和一般中断区分开。
- 直到:
- 父进程或者是子进程对相应的一段区域(代码段,数据段等等)修改,(理解为对共享区域进行写操作),这个时候内核才会为其对应的段复制一个对应的物理页面然后分配给这个进程使用。同时修改对应的页表。
- 理论上因为写时复制,所以代码段不会被修改,如果有进程替换的话,则代码段会被修改。
- 直到:
- fork前打开的文件描述符共享,fork后的不共享
- 因为子进程也复制了父进程 的PCB,所以也将父进程中的文件描述符复制了,struct file是内核文件表,每个进程只要有它的地址,就可以找到,所以子进程便可以找到这个文件,对文件进行操作。所以fork前打开的文件描述符被复制后,引用计数器也会+1。子进程对文件操作也会影响父进程,实际上是操作的文件中的偏移量,共享了文件偏移量。但是在fork之后打开的文件,那就是各自进程打开各自的了,这当然是不共享的了。
线程崩溃会导致进程崩溃吗
线程崩溃会触发SIGSEGV信号。你可以让他选择不直接崩溃,捕捉信号然后继续运行。但是这个信号一般意味着很严重的错误,你也不希望有任何线程触碰到引起错误的这块。但是线程没有隔离性,各个线程共享同一个地址空间你也无法保证说引起错误的这块一定不会被其他线程碰到。所以一个接着一个的错误会陆续发生。所以进程崩溃是迟早的事。
一个进程中的线程在其他进程中可见吗? – 不可见
每一个进程都认为系统中只有自己,因为虚拟化。所以说他根本就不知道有其他进程,更不可见其他进程中的线程
所有的进程间通信都是间接的。都依赖系统调用
为什么需要虚拟内存? – 查看笔记OS30
查看笔记OS30
Linux有几级页表? – 四级页表
因为页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项
对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:
- 全局页目录项 PGD(Page Global Directory);
- 上层页目录项 PUD(Page Upper Directory);
- 中间页目录项 PMD(Page Middle Directory);
- 页表项 PTE(Page Table Entry);
如何控制虚拟地址和物理地址的转换? – MMU
Linux 系统的根目录下主要包括哪些文件夹,各自的作用是什么?
/boot
存放Linux内核、引导配置等启动文件/bin
存放最基本的用户命令,普通用户有权限执行。/dev
存放硬盘、键盘、鼠标、光驱等各种设备文件。/etc
存放各种配置文件、配置目录/home
存放普通用户的默认工作文件夹(即 宿主目录、家目录)/root
Linux系统管理员(超级用户)root的宿主目录。/sbin
存放最基本的管理命令,一般是管理员才有权限执行。/usr
存放额外安装的应用程序、源码编译文件、文档等各种用户资料/var
存放日志文件、用户邮箱目录、进程运行数据等变化的文档。/tmp
存放系统运行过程中使用的一些临时文件。/proc
存放正在运行的进程
什么时候陷入内核态
注意:陷入内核态和上下文切换无必然关联。上下文切换理解为切换任务。而陷入内核态和此事无必然联系。因为我可以在同一个任务内执行特权指令,但是并不需要切换任务。
中断是陷入内核的唯一方式
中断分为内中断和外中断。
内中断(异常,例外) 中断信号来自CPU内部。和当前指令有关
- 陷阱,陷入
- 一般由陷入指令引发。是应用程序故意引发的。比如系统调用。
- 故障
- 错误条件引发,可以被修复。比如缺页中断
- 终止
- 异常错误无法修复。比如整数除0 或 非法请求
外中断。中断信号来自CPU外部。和当前指令无关。
- 时钟中断
- IO中断请求
Linux 查看进程指令
- ps (process status) 列出当前系统运行的进程
- aux
- all 列出所有用户启动的进程
- user 使用该格式列出
- x 列出当前用户在所有中端下的进程
- -ef
- 打印所有进程
- 这俩区别就是格式不同
- aux
- top
- 交互式的,实时动态显示,默认情况下每3秒更新一次。 以全屏交互式的界面显示进程排名,及时跟踪包括CPU、内存等系统资源占用情况 按ctrl + c 终止
- pstree -aup
- 以树状图的方式展现进程之间的派生关系,显示效果比较直观。
Linux 查看CPU使用率
- top
%us:表示用户空间程序的cpu使用率(没有通过nice调度)
%sy:表示系统空间的cpu使用率,主要是内核程序。
%ni:表示用户空间且通过nice调度过的程序的cpu使用率。
%id:空闲cpu
%wa:cpu运行时在等待io的时间
%hi:cpu处理硬中断的数量
%si:cpu处理软中断的数量
%st:被虚拟机偷走的cpu
GDB
记得编译的时候要加-g
才能源码级调试。-g
选项的作用是在可执行文件中加入源代码的信息,比如可执行文件中第几条机器指令对应源代码的第几行,但并不是把整个源文件嵌入到可执行文件中,所以在调试时必须保证 gdb 能找到源文件。
进程间通信和线程间通信
查看项目笔记
atomic和mutex之间的区别
主要是粒度不同。mutex可以让一大块区间被锁住,而atomic只能对单个变量进行互斥。
编译期间可以确定函数需要多少内存来存储栈帧
- 栈里面只有函数相关的东西,局部变量,入参,返回值,函数调用之类的,描述一个类的信息在编译期就被转变成具体的内存布局了,唯一的区别就是在函数里new的时候放在heap里,不走new的时候就在stack里
挂起 阻塞和睡眠
阻塞:(被动)
进/线程被动暂停执行,阻塞的进程仍处于内存中,OS把处理机分配给另一个就绪进程,而让被暂停的进程处于暂停状态。
(自动)阻塞恢复:需要在等待的资源得到满足(例如获得了锁)后,才会进入就绪状态,等待被调度而执行。
- 阻塞的进程调度机不调度,所以不占用CPU。也就是释放CPU资源
阻塞原因:
- 进程:进程由于提出系统服务请求(如I/O操作),但因为某种原因未得到操作系统的立即响应,或者需要从其他合作进程获得的数据尚未到达等原因。
- 线程:线程锁问题。
挂起(主动)
用户主动暂停执行进/线程,挂起的进程被换出到外存(磁盘)中。
- 挂起的进程不占用CPU,但是原理和阻塞不同。挂起不占用CPU的原因是主动放弃(让出)。调度机会分配时间片给挂起进程,但是挂起进程会主动放弃(让出)。所以在外部来看也是不占用CPU。而阻塞是调度机根本不给阻塞进程分配时间片。
挂起恢复:需要用户主动控制,挂起时线程不会释放对象锁。
挂起原因:
- 终端用户的请求。当终端用户在自己的程序运行期间发现有可疑问题时,希望暂停使自己的程序静止下来。亦即,使正在执行的进程暂停执行;若此时用户进程正处于就绪状态而未执行,则该进程暂不接受调度,以便用户研究其执行情况或对程序进行修改。我们把这种静止状态成为“挂起状态”。
- 父进程的请求。有时父进程希望挂起自己的某个子进程,以便考察和修改子进程,或者协调各子进程间的活动。
- 负荷调节的需要。当实时系统中的工作负荷较重,已可能影响到对实时任务的控制时,可由系统把一些不重要的进程挂起,以保证系统能正常运行。
- 操作系统的需要。操作系统有时希望挂起某些进程,以便检查运行中的资源使用情况或进行记账。
- 对换出的需要。为了缓和内存紧张的情况,将内存中处于阻塞状态的进程换至外存上。
睡眠(主动)
用户主动暂停执行进/线程,睡眠进/线程任存于内存。
睡眠恢复:是自动完成的,睡眠时间到了则恢复到就绪态,睡眠时线程不会释放对象锁。
常用指令
strace -f
跟踪发出的系统调用和由初始进程创建的所有子进程
使用pmap
查看proc
目录下的进程文件(maps
)。
nm
查看符号表
操作系统和进程都是状态机
我们要从状态机的角度理解操作系统和进程。因为是状态机,它会有状态的切换。会有初始状态和终止状态。
fork
理解为一个两个分叉的叉子。也就是复制。它完整的复制出一个原先的状态机,所有的资源都会被复制。所以fork
是创建状态机。- 所以linux的第一个进程是
init
。然后我们会通过fork
这个init
创建所有后续进程。这也是进程树的由来。因为fork
一定会有父子关系。
- 所以linux的第一个进程是
execve
函数族负责把状态机的状态重置(切换)。简单理解,操作系统这个状态机只有两个模式。一个模式是从父进程fork
出来的时候。也就是初始状态。另一个是执行其他任务的时候。execve
是唯一一个可以执行任务的系统调用,所以它是负责状态的切换(重置)- 所以
execve
会让你输入程序路径,传入参数和环境变量。
- 所以
exit
就是销毁状态机。但是要注意杂记4中提到的exit
和_exit
之间的区别。
使用pmap查看proc
目录下的进程文件。
在下面我们会发现内存区域有的会被标识为vvar
和vdso
或vsyscall
- vvar(Virtual Variable):
vvar
是一个虚拟内存区域,用于存储与线程特定变量(Thread-specific Variables)相关的数据。这些变量对于每个线程而言都是唯一的,包括线程 ID、栈保护、时间信息等。vvar
通常位于用户态的内核映射区域,用于提供对这些变量的快速访问。 - vdso(Virtual Dynamic Shared Object):
vdso
是一种特殊的共享对象,它在用户空间和内核空间之间提供了一些函数和数据的映射。这些函数和数据可以直接在用户空间中访问,而无需进行系统调用。系统调用需要进入内核态,会有性能损失。vdso
中包含一些常见的系统调用函数的快速实现,例如获取当前时间和系统调用指令的执行。所以vdso
理解为快速系统调用,也就是无需陷入内核的系统调用。- 举个例子就是
time(2)
- 内核维护的秒级时间。所有进程映射同一个页面
- 可以查看这里
- 举个例子就是
- 比较有意思的事情是,获取
vvar
部分一些特定信息的函数就在vdso
里。 - vsyscall(Virtual System Call):
vsyscall
是一种特殊的映射区域,用于在用户空间中执行一些常见的系统调用。与传统的系统调用相比,vsyscall
提供了更快的调用机制,避免了用户空间和内核空间之间的上下文切换。但是,在现代的Linux系统中,vsyscall
已经被vdso
所取代。
入侵其他进程地址空间
/proc/[PID]/mem
文件:该文件允许对进程的内存进行直接读取和写入操作。通过读取/proc/[PID]/mem
文件,我们可以读取进程的内存内容,包括代码、数据和堆栈等。同时还可以将数据写入/proc/[PID]/mem
文件,以修改进程的内存。
操作系统的本质
操作系统的本质是API+对象
- API+对象 = kernel
- 但是用户直接操作kernel不太现实
- 所以我们提供了kernel的封装,这个应用程序就是shell
- 命令行是shell,桌面也是shell只不过是加了图形
shell 应该理解为是一种编程语言,是一种能把命令翻译成系统调用的编程语言。
- 重定向:
cmd > file < file 2> /dev/null
- 顺序结构:
cmd1; cmd2
,cmd1 && cmd2
,cmd1 || cmd2
- 管道:
cmd1 | cmd2
- 预处理:
$()
,<()
变量/环境变量、控制流……
- 类比窗口管理器里的 “叉”、“最小化”
jobs
,fg
,bg
,wait
jobs
:该命令用于列出当前Shell会话中正在运行的作业(jobs)。作业可以是前台或后台运行的命令或程序。jobs
命令通常与作业控制命令一起使用,例如fg
和bg
,以管理作业。fg
:该命令用于将一个在后台运行的作业切换到前台运行。它将指定的作业ID或最近一个后台作业切换到前台,并将其作为当前正在运行的命令。fg
命令常用的语法是fg [jobID]
,其中jobID
是作业的标识符。bg
:该命令用于将一个在后台暂停的作业切换到继续在后台运行。它将指定的作业ID或最近一个停止的后台作业切换到后台运行。bg
命令常用的语法是bg [jobID]
,其中jobID
是作业的标识符。wait
:该命令用于等待指定的作业完成。它会阻塞当前的Shell进程,直到指定的作业及其相关的子进程全部结束。wait
命令常用的语法是wait [jobID]
,其中jobID
是作业的标识符。如果未指定jobID
,则wait
命令将等待所有当前活动的子进程完成。