This commit is contained in:
by931
2022-09-06 22:30:37 +08:00
parent 66970f3e38
commit 3d6528675a
796 changed files with 3382 additions and 3382 deletions

View File

@@ -254,7 +254,7 @@ function hide_canvas() {
<p>进程Process顾名思义就是正在执行的应用程序是软件的执行副本。而线程是轻量级的进程。</p>
<p>进程是分配资源的基础单位。而线程很长一段时间被称作轻量级进程Light Weighted Process是程序执行的基本单位。</p>
<p>在计算机刚刚诞生的年代,程序员拿着一个写好程序的闪存卡,插到机器里,然后电能推动芯片计算,芯片每次从闪存卡中读出一条指令,执行后接着读取下一条指令。闪存中的所有指令执行结束后,计算机就关机。</p>
<p><img src="assets/CgqCHl-iUK2AJ1NsAANGIm3_RCk282.png" alt="Drawing 0.png" /></p>
<p><img src="assets/CgqCHl-iUK2AJ1NsAANGIm3_RCk282.png" alt="png" /></p>
<p>早期的 ENIAC</p>
<p>一开始这种单任务的模型在那个时代叫作作业Job当时计算机的设计就是希望可以多处理作业。图形界面出现后人们开始利用计算机进行办公、购物、聊天、打游戏等因此一台机器正在执行的程序会被随时切来切去。于是人们想到设计进程和线程来解决这个问题。</p>
<p>每一种应用,比如游戏,执行后是一个进程。但是游戏内部需要图形渲染、需要网络、需要响应用户操作,这些行为不可以互相阻塞,必须同时进行,这样就设计成线程。</p>
@@ -268,9 +268,9 @@ function hide_canvas() {
<p>因为通常机器中 CPU 核心数量少(从几个到几十个)、进程&amp;线程数量很多(从几十到几百甚至更多),你可以类比为发动机少,而机器多,因此进程们在操作系统中只能排着队一个个执行。每个进程在执行时都会获得操作系统分配的一个时间片段,如果超出这个时间,就会轮到下一个进程(线程)执行。再强调一下,现代操作系统都是直接调度线程,不会调度进程。</p>
<h4>分配时间片段</h4>
<p>如下图所示,进程 1 需要 2 个时间片段,进程 2 只有 1 个时间片段,进程 3 需要 3 个时间片段。因此当进程 1 执行到一半时,会先挂起,然后进程 2 开始执行;进程 2 一次可以执行完,然后进程 3 开始执行,不过进程 3 一次执行不完,在执行了 1 个时间片段后,进程 1 开始执行;就这样如此周而复始。这个就是分时技术。</p>
<p><img src="assets/CgqCHl-iUNWARGseAACvXwFzOgM513.png" alt="Lark20201104-145535.png" /></p>
<p><img src="assets/CgqCHl-iUNWARGseAACvXwFzOgM513.png" alt="png" /></p>
<p>下面这张图更加直观一些,进程 P1 先执行一个时间片段,然后进程 P2 开始执行一个时间片段, 然后进程 P3然后进程 P4……</p>
<p><img src="assets/Ciqc1F-iUOOAH_pCAAAxJPD4vZk085.png" alt="Lark20201104-145538.png" /></p>
<p><img src="assets/Ciqc1F-iUOOAH_pCAAAxJPD4vZk085.png" alt="png" /></p>
<p>注意,上面的两张图是以进程为单位演示,如果换成线程,操作系统依旧是这么处理。</p>
<h4>进程和线程的状态</h4>
<p>一个进程(线程)运行的过程,会经历以下 3 个状态:</p>
@@ -280,15 +280,15 @@ function hide_canvas() {
<li>当一个进程线程将操作系统分配的时间片段用完后会回到“就绪”Ready状态。</li>
</ul>
<p>我这里一直用进程(线程)是因为旧的操作系统调度进程,没有线程;现代操作系统调度线程。</p>
<p><img src="assets/CgqCHl-iUO-AUnnuAACQlYvu6B4917.png" alt="Lark20201104-145543.png" /></p>
<p><img src="assets/CgqCHl-iUO-AUnnuAACQlYvu6B4917.png" alt="png" /></p>
<p>有时候一个进程线程会等待磁盘读取数据或者等待打印机响应此时进程自己会进入“阻塞”Block状态。</p>
<p><img src="assets/Ciqc1F-iUPuAcCoPAABsXQQRmUA149.png" alt="Lark20201104-145546.png" /></p>
<p><img src="assets/Ciqc1F-iUPuAcCoPAABsXQQRmUA149.png" alt="png" /></p>
<p>因为这时计算机的响应不能马上给出来,而是需要等待磁盘、打印机处理完成后,通过中断通知 CPU然后 CPU 再执行一小段中断控制程序将控制权转给操作系统操作系统再将原来阻塞的进程线程置为“就绪”Ready状态重新排队。</p>
<p>而且一旦一个进程线程进入阻塞状态这个进程线程此时就没有事情做了但又不能让它重新排队因为需要等待中断所以进程线程中需要增加一个“阻塞”Block状态。</p>
<p><img src="assets/Ciqc1F-iURaABVqnAADDuMgPbV8806.png" alt="Lark20201104-145541.png" /></p>
<p><img src="assets/Ciqc1F-iURaABVqnAADDuMgPbV8806.png" alt="png" /></p>
<p>注意因为一个处于“就绪”Ready的进程线程还在排队所以进程线程内的程序无法执行也就是不会触发读取磁盘数据的操作这时“就绪”Ready状态无法变成阻塞的状态因此下图中没有从就绪到阻塞的箭头。</p>
<p>而处于“阻塞”Block状态的进程线程如果收到磁盘读取完的数据它又需要重新排队所以它也不能直接回到“运行”Running状态因此下图中没有从阻塞态到运行态的箭头。</p>
<p><img src="assets/CgqCHl-iUSGAcoiLAAC6OKgt1vo694.png" alt="Lark20201104-145548.png" /></p>
<p><img src="assets/CgqCHl-iUSGAcoiLAAC6OKgt1vo694.png" alt="png" /></p>
<h3>进程和线程的设计</h3>
<p>接下来我们思考几个核心的设计约束:</p>
<ol>
@@ -301,25 +301,25 @@ function hide_canvas() {
<h4>进程和线程的表示</h4>
<p>可以这样设计,在内存中设计两张表,一张是进程表、一张是线程表。</p>
<p>进程表记录进程在内存中的存放位置、PID 是多少、当前是什么状态、内存分配了多大、属于哪个用户等,这就有了进程表。如果没有这张表,进程就会丢失,操作系统不知道自己有哪些进程。这张表可以考虑直接放到内核中。</p>
<p><img src="assets/Ciqc1F-iUfmAKH85AAFKvhw_d6g282.png" alt="Lark20201104-150201.png" /></p>
<p><img src="assets/Ciqc1F-iUfmAKH85AAFKvhw_d6g282.png" alt="png" /></p>
<p>细分的话,进程表需要这几类信息。</p>
<ul>
<li><strong>描述信息</strong>:这部分是描述进程的唯一识别号,也就是 PID包括进程的名称、所属的用户等。</li>
<li><strong>资源信息</strong>:这部分用于记录进程拥有的资源,比如进程和虚拟内存如何映射、拥有哪些文件、在使用哪些 I/O 设备等,当然 I/O 设备也是文件。</li>
<li><strong>内存布局</strong>:操作系统也约定了进程如何使用内存。如下图所示,描述了一个进程大致内存分成几个区域,以及每个区域用来做什么。 每个区域我们叫作一个段。</li>
</ul>
<p><img src="assets/Ciqc1F-iUWyADMH4AACX7Ob_EWs477.png" alt="Lark20201104-145551.png" /></p>
<p><img src="assets/Ciqc1F-iUWyADMH4AACX7Ob_EWs477.png" alt="png" /></p>
<p>操作系统还需要一张表来管理线程,这就是线程表。线程也需要 ID 可以叫作 ThreadID。然后线程需要记录自己的执行状态阻塞、运行、就绪、优先级、程序计数器以及所有寄存器的值等等。线程需要记录程序计数器和寄存器的值是因为多个线程需要共用一个 CPU线程经常会来回切换因此需要在内存中保存寄存器和 PC 指针的值。</p>
<p>用户级线程和内核级线程存在映射关系,因此可以考虑在内核中维护一张内核级线程的表,包括上面说的字段。</p>
<p>如果考虑到这种映射关系,比如 n-m 的多对多映射,可以将线程信息还是存在进程中,每次执行的时候才使用内核级线程。相当于内核中有个线程池,等待用户空间去使用。每次用户级线程把程序计数器等传递过去,执行结束后,内核线程不销毁,等待下一个任务。这里其实有很多灵活的实现,<strong>总体来说,创建进程开销大、成本高;创建线程开销小,成本低</strong></p>
<h4>隔离方案</h4>
<p>操作系统中运行了大量进程,为了不让它们互相干扰,可以考虑为它们分配彼此完全隔离的内存区域,即便进程内部程序读取了相同地址,而实际的物理地址也不会相同。这就好比 A 小区的 10 号楼 808 和 B 小区的 10 号楼 808 不是一套房子,这种方法叫作地址空间,我们将在“<strong>21 讲</strong>”的页表部分讨论“地址空间”的详细内容。</p>
<p>所以在正常情况下进程 A 无法访问进程 B 的内存,除非进程 A 找到了某个操作系统的漏洞,恶意操作了进程 B 的内存,或者利用我们在“<strong>21 讲</strong>”讲到的“进程间通信”的手段。</p>
<p><img src="assets/CgqCHl-iUX-AaaGjAABDIYvxzjM808.png" alt="Lark20201104-145554.png" /></p>
<p><img src="assets/CgqCHl-iUX-AaaGjAABDIYvxzjM808.png" alt="png" /></p>
<p>对于一个进程的多个线程来说,可以考虑共享进程分配到的内存资源,这样线程就只需要被分配执行资源。</p>
<h4>进程(线程)切换</h4>
<p>进程(线程)在操作系统中是不断切换的,现代操作系统中只有线程的切换。 每次切换需要先保存当前寄存器的值的内存,注意 PC 指针也是一种寄存器。当恢复执行的时候,就需要从内存中读出所有的寄存器,恢复之前的状态,然后执行。</p>
<p><img src="assets/CgqCHl-iUY-AEqrUAAKnDhPzBcQ340.png" alt="Lark20201104-145523.png" /></p>
<p><img src="assets/CgqCHl-iUY-AEqrUAAKnDhPzBcQ340.png" alt="png" /></p>
<p>上面讲到的内容,我们可以概括为以下 5 个步骤:</p>
<ol>
<li>当操作系统发现一个进程(线程)需要被切换的时候,直接控制 PC 指针跳转是非常危险的事情,所以操作系统需要发送一个“中断”信号给 CPU停下正在执行的进程线程</li>
@@ -328,18 +328,18 @@ function hide_canvas() {
<li>操作系统保存好进程状态后,执行调度程序,决定下一个要被执行的进程(线程)。</li>
<li>最后,操作系统执行下一个进程(线程)。</li>
</ol>
<p><img src="assets/Ciqc1F-iUZ-Af-t9AAC3WjDjEM4772.png" alt="Lark20201104-145556.png" /></p>
<p><img src="assets/Ciqc1F-iUZ-Af-t9AAC3WjDjEM4772.png" alt="png" /></p>
<p>当然,一个进程(线程)被选择执行后,它会继续完成之前被中断时的任务,这需要操作系统来执行一小段底层的程序帮助进程(线程)恢复状态。</p>
<p><img src="assets/Ciqc1F-iUa-AdqG9AACMOQKJe2Q431.png" alt="Lark20201104-145530.png" /></p>
<p><img src="assets/Ciqc1F-iUa-AdqG9AACMOQKJe2Q431.png" alt="png" /></p>
<p>一种可能的算法就是通过栈这种数据结构。进程(线程)中断后,操作系统负责压栈关键数据(比如寄存器)。恢复执行时,操作系统负责出栈和恢复寄存器的值。</p>
<h4>多核处理</h4>
<p>在多核系统中我们上面所讲的设计原则依然成立只不过动力变多了可以并行执行的进程线程。通常情况下CPU 有几个核,就可以并行执行几个进程(线程)。这里强调一个概念,我们通常说的并发,英文是 concurrent指的在一段时间内几个任务看上去在同时执行不要求多核而并行英文是 parallel任务必须绝对的同时执行要求多核</p>
<p><img src="assets/CgqCHl-iUbyAQr5eAAD6cgjbJ7c031.png" alt="Lark20201104-145533.png" /></p>
<p><img src="assets/CgqCHl-iUbyAQr5eAAD6cgjbJ7c031.png" alt="png" /></p>
<p>比如一个 4 核的 CPU 就好像拥有 4 条流水线,可以并行执行 4 个任务。一个进程的多个线程执行过程则会产生竞争条件,这块我们会在“<strong>19 讲</strong>”锁和信号量部分给你介绍。因为操作系统提供了保存、恢复进程状态的能力,使得进程(线程)也可以在多个核心之间切换。</p>
<h3>创建进程(线程)的 API</h3>
<p>用户想要创建一个进程,最直接的方法就是从命令行执行一个程序,或者双击打开一个应用。但对于程序员而言,显然需要更好的设计。</p>
<p>站在设计者的角度,你可以这样思考:首先,应该有 API 打开应用,比如可以通过函数打开某个应用;另一方面,如果程序员希望执行完一段代价昂贵的初始化过程后,将当前程序的状态复制好几份,变成一个个单独执行的进程,那么操作系统提供了 fork 指令。</p>
<p><img src="assets/Ciqc1F-iUcyAKsUkAADXFCtukIY084.png" alt="Lark20201104-145559.png" /></p>
<p><img src="assets/Ciqc1F-iUcyAKsUkAADXFCtukIY084.png" alt="png" /></p>
<p>也就是说,每次 fork 会多创造一个克隆的进程,这个克隆的进程,所有状态都和原来的进程一样,但是会有自己的地址空间。如果要创造 2 个克隆进程,就要 fork 两次。</p>
<p>你可能会问:那如果我就是想启动一个新的程序呢?</p>
<p>我在上文说过:操作系统提供了启动新程序的 API。</p>