mirror of
https://github.com/zhwei820/learn.lianglianglee.com.git
synced 2025-11-18 07:03:44 +08:00
fix img
This commit is contained in:
@@ -253,7 +253,7 @@ function hide_canvas() {
|
||||
<li>你不懂 Java 多线程,也可以回答好 Java 多线程的面试题;</li>
|
||||
<li>你不熟悉 Docker,也可以回答出容器化应该如何做。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl9V0vKAQ5IbAAHTtF5p1Vc746.png" alt="2.png" /></p>
|
||||
<p><img src="assets/CgqCHl9V0vKAQ5IbAAHTtF5p1Vc746.png" alt="png" /></p>
|
||||
<p>操作系统已不仅仅是一门大学的必修课那么简单,更是计算机领域的本源知识,任何编程语言学下去都会碰到操作系统知识,比如 Java 的虚拟机、Go 语言的协程与通道、Node.js 的 I/O 模型等。任何研发工具学下去也都会碰到操作系统,比如:</p>
|
||||
<ul>
|
||||
<li>MySQL 深入学下去会碰到 InnoDB 文件系统;</li>
|
||||
@@ -280,7 +280,7 @@ function hide_canvas() {
|
||||
<p>中国互联网系统最主要的设计约束:并发高、数据量大(毕竟中国互联网是以人口红利起家的)。比较巧的是,海量用户的 C 端场景和大数据商业分析场景,我刚好都负责过。而高并发、大数据中的很多知识,又需要从操作系统中获取,加上我本身操作系统方面的知识也比较扎实,所以在实际场景这块我也有丰富的经验。</p>
|
||||
<p><strong>接下来再从我的角度来看看“现在要不要学操作系统”,我觉得现在的时机刚刚好</strong>。</p>
|
||||
<p>首先,目前是一个在线教育的风口,我结合自身背景以及拉勾网在线招聘求职方向的优势,给你带来一门针对工作场景的就业提升类操作系统课程,符合平台调性。</p>
|
||||
<p><img src="assets/CgqCHl9V0ueAZF6MAAHXnXl0CKc462.png" alt="3.png" /></p>
|
||||
<p><img src="assets/CgqCHl9V0ueAZF6MAAHXnXl0CKc462.png" alt="png" /></p>
|
||||
<p>再者,云原生架构出现之后,越来越强调“谁开发谁运维”,因此业内对操作系统的需求度在提升、要求也在提高,所以我设计这门针对工作场景的就业提升类操作系统课程符合市场需求。</p>
|
||||
<blockquote>
|
||||
<p>操作系统的需求量也是急剧增长。
|
||||
@@ -323,7 +323,7 @@ function hide_canvas() {
|
||||
<p>所以,操作系统这门课程既适合新手入门,也适合有经验的开发人员进阶学习,这并不受经验影响,不同的是你们的学习目标和学习收获。</p>
|
||||
<h3>寄语</h3>
|
||||
<p>最后,我还想和你说点关于职业发展相关的事情。</p>
|
||||
<p><img src="assets/CgqCHl9V03aAEUTpAAHv8lYGeMI639.png" alt="1.png" /></p>
|
||||
<p><img src="assets/CgqCHl9V03aAEUTpAAHv8lYGeMI639.png" alt="png" /></p>
|
||||
<p>中国有超过 1000 万程序员,大部分人的年薪小于 30 万。我观察到一个这样的现象:一方面求职者们抱怨市场竞争激烈,大家争抢一两个岗位;另一方面很多优秀团队的高薪岗位招人难,闲置多个空位,求职者很多但是符合岗位要求的却很少。到底是什么原因造成企业招人难,求职者求职难的情况呢?</p>
|
||||
<p>其实,<strong>拉开个人薪资和团队整体水平差异的分水岭,根本原因就是计算机基础知识的掌握程度</strong>。基础好的程序员,学习速度快,愿意花时间去积累知识,提高自身能力,因此涨薪快、跳槽更容易;而基础不好的,学习相对较慢,知识输入少,因此涨薪慢、跳槽难。个人能力的高低决定了收入的水平。</p>
|
||||
<p>这个事情很现实,也很不公平。但是反过来想,为什么基础不好的同学,不把时间精力拿出来去填补自己的知识空缺呢?如果你的操作系统知识还是一盘散沙,那么请你现在就开始行动,跟着我一起重学操作系统,把这块知识捡起来。愿正在看这篇文章的你,能通过自己的努力去到更好的团队,拿更高的薪水,进而得到更广阔的发展空间。</p>
|
||||
|
||||
@@ -304,14 +304,14 @@ function hide_canvas() {
|
||||
<p>假设我还不知道信号量、条件变量和锁是什么,于是我通过搜索资料,发现这些名词通通指向一门科学,也就是操作系统。</p>
|
||||
<p>接下来,我会去挑选一门讲操作系统的在线课程或者买一本书来查阅,经过查阅发现这些名词出现在进程和多线程这个部分。然后我翻阅了这两个章节的内容,发现了更多我不知道的知识,比如死锁和饥饿、信号量、竞争条件和临界区、互斥的实现,以及最底层的 CPU 指令。</p>
|
||||
<p>经过以上过程的推导,我开始在脑海中梳理这些知识点,然后动笔画出了一幅基于思考过程的思维导图,将这些知识点串联起来,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9XZQCALTi6AAIjl6n0qNQ452.png" alt="7.png" /></p>
|
||||
<p><img src="assets/CgqCHl9XZQCALTi6AAIjl6n0qNQ452.png" alt="png" /></p>
|
||||
<p>注意,上图梳理出来的知识关系不一定对,但是你一定要敢于去画,这个梳理和探索的过程能够带动你主动思考,锻炼主动解决问题的能力。</p>
|
||||
<p>输出思维导图后,我将开始学习上面那些超出我现阶段知识储备的内容,然后进行归类和整理。</p>
|
||||
<p>这时候,我发现公平锁、可重入锁其实都是锁的一种实现,而 Java 中实现锁这个机制用的是 AQS,而 AQS 最基本的问题是要解决资源竞争的问题。</p>
|
||||
<p>通过学习,我发现资源竞争的问题在操作系统里叫作竞争条件,解决方案是让临界区互斥。让临界区互斥可以用算法的实现,但是为了执行效率,更多的情况是利用 CPU 指令。Java 里用于实现互斥的原子操作 CAS,也是基于 CPU 指令的。</p>
|
||||
<p>操作系统在解决了互斥问题的基础上,还提供了解决更复杂问题的数据结构,比如说信号量、竞争条件等;而程序语言也提供了数据结构,比如说可重入锁、公平锁。</p>
|
||||
<p>经过一番探索,我终于弄明白了,原来实际应用场景中对锁有各种各样的需求,因此不仅仅需要信号量等数据结构,甚至还需要一个快速实现这种数据结构的框架,这个框架就是 AQS。我们可以用 AQS 实现 ReentrantLockLock 的功能。</p>
|
||||
<p><img src="assets/Ciqc1F9V_ciAV08TAAGaQSyH17o250.png" alt="Lark20200907-165512.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9V_ciAV08TAAGaQSyH17o250.png" alt="png" /></p>
|
||||
<p>通过上面的方法,我不仅仅可以把 ReentrantLockLockt 学透,而且顺藤摸瓜找到了所有关联的知识点,比如 AQS 和 CAS。<strong>比起理解最初的知识点,更重要的是我通过这种方法形成了自己的一个知识体系;而且,我会发现在这个知识体系中,操作系统是起到支撑作用的骨架</strong>。</p>
|
||||
<p>与此同时,我还认识到了计算机语言和操作系统之间的联系非常紧密,操作系统知识是学习计算机语言的根基。于是我开始制定学习计划,投入时间学习操作系统。我更偏爱做一次性的时间投入,以防止日后碎片化学习做多次投入,陷入时间黑洞,而这个嗜好让我受益良多。</p>
|
||||
<h3>寄语</h3>
|
||||
|
||||
@@ -280,7 +280,7 @@ function hide_canvas() {
|
||||
<p>比如一个马达的控制程序是可计算的,因为控制过程是可以被抽象成一条条指令的(即可以写程序实现)。比如程序可以先读入传感器的数据,然后根据数据计算出下面要进行加速还是减速。</p>
|
||||
<h4>不可计算问题</h4>
|
||||
<p>但当图灵机遇到“素数是不是有无穷多个?”这样的问题时,事情就变得复杂了。虽然,我们可以通过有限的步骤计算出下一个素数。比如可以每次尝试一个更大的数字,然后通过一系列计算过程判断该数字是不是素数,直到找到一个更大的素数。古希腊数学家埃拉托斯特尼就发明了筛选出给定范围内所有素数的方法。</p>
|
||||
<p><img src="assets/Ciqc1F9V0I2ARHpdAAJnCCDKvK8570.gif" alt="Sieve_of_Eratosthenes_animation.gif" /></p>
|
||||
<p><img src="assets/Ciqc1F9V0I2ARHpdAAJnCCDKvK8570.gif" alt="png" /></p>
|
||||
<p>如上图所示,我们利用埃拉托斯特尼筛法找到的素数越来越多。但是,我们还是不能回答“素数是不是有无穷多个”这样的问题。因为要回答这样的问题,我们会不停地寻找下一个素数。如果素数是无穷的,那么我们的计算就是无穷无尽的,所以这样的问题不可计算。</p>
|
||||
<h4>停机问题</h4>
|
||||
<p>我们也无法实现用一个通用程序去判断另一个程序是否会停止。比如你用运行这段程序来检查一个程序是否会停止时,你会发现不能因为这个程序执行了 1 天,就判定它不会停止,也不能因为这个程序执行了 10 年,从而得出它不会停止的结论。这个问题放到图灵机领域,叫作停机问题,我们无法给出一个判断图灵机是否会停机的通用方法,因此停机问题是一个经典的不可计算问题。</p>
|
||||
@@ -297,7 +297,7 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<h4>P 问题 vs NP 问题</h4>
|
||||
<p>按照摩尔定律所说,人类的计算能力每 18~24 个月翻一倍,我们的计算能力在呈指数形式上升。因此,在所有可以计算的问题中,像 O(N1000)的问题,虽然现在的计算能力不够,但是相信在遥远的未来,我们会拥有能力解决。这种我们有能力解决的问题,统称为多项式时间( Polynomial time)问题。我们今天能解决的问题,都是多项式时间的问题,下面记为 P 类型的问题。</p>
|
||||
<p><img src="assets/Ciqc1F9V1E6AIXioAADZGoACu0o689.png" alt="11.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9V1E6AIXioAADZGoACu0o689.png" alt="png" /></p>
|
||||
<p>另外,还有一类问题复杂度本身也是指数形式的问题,比如 O(2N)的问题。这类型的问题随着规模 N 上升,时间开销的增长速度和人类计算能力增长速度持平甚至更快。因此虽然这类问题可以计算,但是当 N 较大时,因为计算能力不足,最终结果依然无法被解决。</p>
|
||||
<p>由此可见,不是所有可以计算的问题都可以被解决,问题如果不能在多项式时间内找到答案,我们记为 NP 问题。</p>
|
||||
<p>有一部分 NP 问题可以被转化为 P 问题,比如斐波那契数列求第 N 项,可以用缓存、动态规划等方式转化为 O(N) 的问题。但还有更多的 NP 问题,比如一个集合,找出和为零的子集,就没能找到一个合适的转换方法。其实说这么多,就是想告诉大家:如今还有很多问题无法解决,它的数量远远大于我们可以解决的问题,科学家、工程师们也只能望洋兴叹了。</p>
|
||||
|
||||
@@ -258,7 +258,7 @@ function hide_canvas() {
|
||||
<p>想要学懂程序执行的原理,就要从图灵机说起了。它在计算机科学方面有两个巨大的贡献:</p>
|
||||
<p>第一,它清楚地定义了计算机能力的边界,也就是可计算理论;</p>
|
||||
<p>第二,它定义了计算机由哪些部分组成,程序又是如何执行的。</p>
|
||||
<p><img src="assets/Ciqc1F9YkgKAMPJ6ABSBvBPOVvk790.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9YkgKAMPJ6ABSBvBPOVvk790.png" alt="png" /></p>
|
||||
<p>我们先来看一看图灵机的内部构造:</p>
|
||||
<ol>
|
||||
<li>图灵机拥有一条无限长的纸带,纸带上是一个格子挨着一个格子,格子中可以写字符,你可以把纸带看作内存,而这些字符可以看作是内存中的数据或者程序。</li>
|
||||
@@ -270,15 +270,15 @@ function hide_canvas() {
|
||||
<ul>
|
||||
<li>首先,我们将“11、15、+” 分别写入纸带上的 3 个格子(现在纸带上的字符串是11、15、 +),然后将读写头先停在 11 对应的格子上。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F9YoK6AM2frAAAtDcKchOk422.png" alt="1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9YoK6AM2frAAAtDcKchOk422.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li>接下来,图灵机通过读写头读入 11 到它的存储设备中(这个存储设备也叫作图灵机的状态)。图灵机没有说读写头为什么可以识别纸带上的字符,而是假定读写头可以做到这点。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F9YoLWAYNkqAABb6DZsrMk959.png" alt="2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9YoLWAYNkqAABb6DZsrMk959.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li>然后读写头向右移动一个格,用同样的方法将 15 读入图灵机的状态中。现在图灵机的状态中有两个连续的数字,11 和 15。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl9YoL2AYCJbAABc5X0-CI4938.png" alt="3.png" /></p>
|
||||
<p><img src="assets/CgqCHl9YoL2AYCJbAABc5X0-CI4938.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li>接下来重复上面的过程,会读到一个+号。下面我详细说一下这个运算流程:
|
||||
<ul>
|
||||
@@ -291,15 +291,15 @@ function hide_canvas() {
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F9YoMSAa9_WAADEZsnCSoU226.png" alt="4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9YoMSAa9_WAADEZsnCSoU226.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li>读写头向右移动,将结果 26 写入纸带。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl9YoMqAB2JiAAA2igzBi94334.png" alt="5.png" /></p>
|
||||
<p><img src="assets/CgqCHl9YoMqAB2JiAAA2igzBi94334.png" alt="png" /></p>
|
||||
<p>这样,我们就通过图灵机计算出了 11+15 的值。不知道你有没有发现,图灵机构造的这一台机器,主要功能就是读写纸带然后计算;纸带中有数据、也有控制字符(也就是指令),这个设计和我们今天的计算机是一样的。</p>
|
||||
<p>图灵通过数学证明了,一个问题如果可以拆解成图灵机的可执行步骤,那问题就是可计算的。另一方面,图灵机定义了计算机的组成以及工作原理,但是没有给出具体的实现。</p>
|
||||
<h3>冯诺依曼模型</h3>
|
||||
<p><img src="assets/CgqCHl9e5VaANB2BAAEVncqxxwI213.png" alt="1.png" /></p>
|
||||
<p><img src="assets/CgqCHl9e5VaANB2BAAEVncqxxwI213.png" alt="png" /></p>
|
||||
<p>具体的实现是 1945 年冯诺依曼和其他几位科学家在著名的 101 页报告中提出的。报告遵循了图灵机的设计,并提出用电子元件构造计算机,约定了用二进制进行计算和存储,并且将计算机结构分成以下 5 个部分:</p>
|
||||
<ol>
|
||||
<li>输入设备;</li>
|
||||
|
||||
@@ -275,7 +275,7 @@ function hide_canvas() {
|
||||
<li>11 被存储到了地址 0x100;</li>
|
||||
<li>15 被存储到了地址 0x104;</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F9jNVKAbRJhAADt2il2zYI826.png" alt="1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9jNVKAbRJhAADt2il2zYI826.png" alt="png" /></p>
|
||||
<p>2.编译器将<code>a=11+15</code>转换成了 4 条指令,程序启动后,这些指令被导入了一个专门用来存储指令的区域,也就是正文段。如上图所示,这 4 条指令被存储到了 0x200-0x20c 的区域中:</p>
|
||||
<p>0x200 位置的 load 指令将地址 0x100 中的数据 11 导入寄存器 R0;</p>
|
||||
<p>0x204 位置的 load 指令将地址 0x104 中的数据 15 导入寄存器 R1;</p>
|
||||
@@ -295,7 +295,7 @@ function hide_canvas() {
|
||||
<blockquote>
|
||||
<p>这里大家还是看下图,需要看一下才能明白。</p>
|
||||
</blockquote>
|
||||
<p><img src="assets/CgqCHl9fMJiAXO1-AABvVvPHepg435.png" alt="12.png" /></p>
|
||||
<p><img src="assets/CgqCHl9fMJiAXO1-AABvVvPHepg435.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li>最左边的 6 位,叫作<strong>操作码</strong>,英文是 OpCode,100011 代表 load 指令;</li>
|
||||
<li>中间的 4 位 0000是寄存器的编号,这里代表寄存器 R0;</li>
|
||||
@@ -303,7 +303,7 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<p>所以我们是把操作码、寄存器的编号、要读取的地址合并到了一个 32 位的指令中。</p>
|
||||
<p>我们再来看一条求加法运算的 add 指令,16 进制表示是 0x08048000,换算成二进制就是:</p>
|
||||
<p><img src="assets/Ciqc1F9fMKGAT9ymAACIAk1pGnk727.png" alt="11.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9fMKGAT9ymAACIAk1pGnk727.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li>最左边的 6 位是指令编码,代表指令 add;</li>
|
||||
<li>紧接着的 4 位 0000 代表寄存器 R0;</li>
|
||||
@@ -318,7 +318,7 @@ function hide_canvas() {
|
||||
<li>CPU 执行指令,我们将这个部分叫作 Execution。</li>
|
||||
<li>CPU 将结果存回寄存器或者将寄存器存入内存,我们将这个步骤叫作 Store。</li>
|
||||
</ol>
|
||||
<p><img src="assets/Ciqc1F9fMKiAZhMVAABIVEePzcA916.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F9fMKiAZhMVAABIVEePzcA916.png" alt="png" /></p>
|
||||
<p>上面 4 个步骤,我们叫作 CPU 的指令<strong>周期</strong>。CPU 的工作就是一个周期接着一个周期,周而复始。</p>
|
||||
<h4>指令的类型</h4>
|
||||
<p>通过上面的例子,你会发现不同类型(不同 OpCode)的指令、参数个数、每个参数的位宽,都不一样。而参数可以是以下这三种类型:</p>
|
||||
|
||||
@@ -363,13 +363,13 @@ store R2 -> $r
|
||||
<li>返回值如何传递给调用者?</li>
|
||||
</ol>
|
||||
<p>为了解决这 2 个问题,我们就需要用到前面提到的一个叫作栈的数据结构。栈的英文是 Stack,意思是码放整齐的一堆东西。首先在调用方,我们将参数传递给栈;然后在函数执行过程中,我们从栈中取出参数。</p>
|
||||
<p><img src="assets/Ciqc1F9h5mWAGqrjAABpcF79u8M632.png" alt="Lark20200916-181251.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9h5mWAGqrjAABpcF79u8M632.png" alt="png" /></p>
|
||||
<p>函数执行过程中,先将执行结果写入栈中,然后在返回前把之前压入的参数出栈,调用方再从栈中取出执行结果。</p>
|
||||
<p><img src="assets/CgqCHl9h5m2ALcHaAABs3s6zJkQ202.png" alt="Lark20200916-181255.png" /></p>
|
||||
<p><img src="assets/CgqCHl9h5m2ALcHaAABs3s6zJkQ202.png" alt="png" /></p>
|
||||
<p>将参数传递给 Stack 的过程,叫作压栈。取出结果的过程,叫作出栈。栈就好像你书桌上的一摞书,压栈就是把参数放到书上面,出栈就是把顶部的书拿下来。</p>
|
||||
<p>因为栈中的每个数据大小都一样,所以在函数执行的过程中,我们可以通过参数的个数和参数的序号去计算参数在栈中的位置。</p>
|
||||
<p>接下来我们来看看函数执行的整体过程:假设要计算 11 和 15 的和,我们首先在内存中开辟一块单独的空间,也就是栈。</p>
|
||||
<p><img src="assets/Ciqc1F9h5nWAFY-ZAAAwk_1T41E731.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9h5nWAFY-ZAAAwk_1T41E731.png" alt="png" /></p>
|
||||
<p>就如前面所讲,栈的使用方法是不断往上堆数据,所以需要一个栈指针(Stack Pointer, SP)指向栈顶(也就是下一个可以写入的位置)。每次将数据写入栈时,就把数据写到栈指针指向的位置,然后将 SP 的值增加。</p>
|
||||
<p>为了提高效率,我们通常会用一个特殊的寄存器来存储栈指针,这个寄存器就叫作 Stack Pointer,在大多数芯片中都有这个特殊的寄存器。一开始,SP 指向 0x100 位置,而 0x100 位置还没有数据。</p>
|
||||
<ul>
|
||||
@@ -383,18 +383,18 @@ add SP, 4, SP // 栈指针增加4(32位机器)
|
||||
<p>第二条指令将栈指针自增 4。</p>
|
||||
<p>这里用美元符号代表将 11 存入的是 SP 寄存器指向的内存地址,这是一次间接寻址。存入后,栈指针不是自增 1 而是自增了 4,因为我在这里给你讲解时,用的是一个 32 位宽的 CPU 。如果是 64 位宽的 CPU,那么栈指针就需要自增 8。</p>
|
||||
<p>压栈完成后,内存变成下图中所示的样子。11 被写入内存,并且栈指针指向了 0x104 位置。</p>
|
||||
<p><img src="assets/Ciqc1F9h5n-APEFtAAAy3ahEVnE846.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9h5n-APEFtAAAy3ahEVnE846.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li><strong>压栈参数15</strong></li>
|
||||
</ul>
|
||||
<p>然后我们用同样的方法将参数 15 压栈。</p>
|
||||
<p><img src="assets/CgqCHl9h5oWAejVOAAA-DX72fJI426.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHl9h5oWAejVOAAA-DX72fJI426.png" alt="png" /></p>
|
||||
<p>压栈后,11 和 15 都被放入了对应的内存位置,并且栈指针指向了 0x108。</p>
|
||||
<ul>
|
||||
<li><strong>将返回值压栈</strong></li>
|
||||
</ul>
|
||||
<p>接下来,我们将返回值压栈。到这里你可能会问,返回值还没有计算呢,怎么就压栈了?其实这相当于一个占位,后面我们会改写这个地址。</p>
|
||||
<p><img src="assets/CgqCHl9h5o2ARmc3AABEUYqLaKo705.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/CgqCHl9h5o2ARmc3AABEUYqLaKo705.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li><strong>调用函数</strong></li>
|
||||
</ul>
|
||||
@@ -414,7 +414,7 @@ load $(SP - 8) -> R1
|
||||
</code></pre>
|
||||
<p>上面我们用到了一种间接寻址的方式来进行加和运算,也就是利用 SP 中的地址做加减法操作内存。</p>
|
||||
<p>经过函数调用的结果如下图所示,运算结果 26 已经被写入了返回值的位置:</p>
|
||||
<p><img src="assets/Ciqc1F9h5pWAQ-8nAABHqkFWy4k580.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9h5pWAQ-8nAABHqkFWy4k580.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li><strong>发现-解决问题</strong></li>
|
||||
</ul>
|
||||
@@ -424,7 +424,7 @@ load $(SP - 8) -> R1
|
||||
<li>栈不可以被无限使用,11和 15 作为参数,计算出了结果 26,那么它们就可以清空了。如果用调整栈指针的方式去清空,我们就会先清空 26。此时就会出现顺序问题,因此我们需要调整压栈的顺序。</li>
|
||||
</ol>
|
||||
<p>具体顺序你可以看下图。首先,我们将函数参数和返回值换位,这样在清空数据的时候,就会先清空参数,再清空返回值。</p>
|
||||
<p><img src="assets/CgqCHl_lhT6AP75kAAD-cUrMUNg773.png" alt="Lark20201225-140329.png" /></p>
|
||||
<p><img src="assets/CgqCHl_lhT6AP75kAAD-cUrMUNg773.png" alt="png" /></p>
|
||||
<p>然后我们在调用函数前,还需要将返回地址压栈。这样在函数计算完成前,就能跳转回对应的返回地址。翻译成指令,就是下面这样:</p>
|
||||
<pre><code>## 压栈返回值
|
||||
add SP, 4 -> SP
|
||||
@@ -447,11 +447,11 @@ add SP, -(参数个数+1)*4, SP
|
||||
}
|
||||
</code></pre>
|
||||
<p>递归的时候,我们每次执行函数都形成一个如下所示的栈结构:</p>
|
||||
<p><img src="assets/CgqCHl_lhQKAIVTlAAD-cUrMUNg043.png" alt="Lark20201225-140329.png" /></p>
|
||||
<p><img src="assets/CgqCHl_lhQKAIVTlAAD-cUrMUNg043.png" alt="png" /></p>
|
||||
<p>比如执行 sum(100),我们就会形成一个复杂的栈,第一次调用 n = 100,第二次递归调用 n = 99:</p>
|
||||
<p><img src="assets/CgqCHl9kbw6AEGmQAADNH1dIS2Q053.png" alt="1.png" /></p>
|
||||
<p><img src="assets/CgqCHl9kbw6AEGmQAADNH1dIS2Q053.png" alt="png" /></p>
|
||||
<p>它们堆在了一起,就形成了一个很大的栈,简化一下就是这样的一个模型,如下所示:</p>
|
||||
<p><img src="assets/Ciqc1F9kcBCAalP8AACq_zc_LfM551.png" alt="2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9kcBCAalP8AACq_zc_LfM551.png" alt="png" /></p>
|
||||
<p>到这里,递归消耗了更多空间,但是也保证了中间计算的独立性。当递归执行到 100 次的时候,就会执行下面的语句:</p>
|
||||
<pre><code> if(n == 1) {return 1;}
|
||||
</code></pre>
|
||||
@@ -469,7 +469,7 @@ add SP, -(参数个数+1)*4, SP
|
||||
<li>函数调用需要压栈参数、返回值和返回地址。</li>
|
||||
</ul>
|
||||
<p>最后,我们来说说类型是如何实现的,也就是很多语言都支持的 class 如何被翻译成指令。其实 class 实现非常简单,首先一个 class 会分成两个部分,一部分是数据(也称作属性),另一部分是函数(也称作方法)。</p>
|
||||
<p><img src="assets/Ciqc1F9h5rmANakFAACFALCOZaU910.png" alt="Lark20200916-181235.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9h5rmANakFAACFALCOZaU910.png" alt="png" /></p>
|
||||
<p>class 有一个特殊的方法叫作构造函数,它会为 class 分配内存。构造函数执行的时候,开始扫描类型定义中所有的属性和方法。</p>
|
||||
<ul>
|
||||
<li>如果遇到属性,就为属性分配内存地址;</li>
|
||||
|
||||
@@ -268,7 +268,7 @@ function hide_canvas() {
|
||||
<h3>存储器分级策略</h3>
|
||||
<p>既然我们不能用一块存储器来解决所有的需求,那就必须把需求分级。</p>
|
||||
<p>一种可行的方案,就是根据数据的使用频率使用不同的存储器:高频使用的数据,读写越快越好,因此用最贵的材料,放到离 CPU 最近的位置;使用频率越低的数据,我们放到离 CPU 越远的位置,用越便宜的材料。</p>
|
||||
<p><img src="assets/Ciqc1F9kgVGAD_IMAACXR1QKcDo779.png" alt="Lark20200918-174334.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9kgVGAD_IMAACXR1QKcDo779.png" alt="png" /></p>
|
||||
<p>具体来说,通常我们把存储器分成这么几个级别:</p>
|
||||
<ol>
|
||||
<li>寄存器;</li>
|
||||
@@ -296,7 +296,7 @@ function hide_canvas() {
|
||||
<p>内存的主要材料是半导体硅,是插在主板上工作的。因为它的位置距离 CPU 有一段距离,所以需要用总线和 CPU 连接。因为内存有了独立的空间,所以体积更大,造价也比上面提到的存储器低得多。现在有的个人电脑上的内存是 16G,但有些服务器的内存可以到几个 T。内存速度大概在 200~300 个 CPU 周期之间。</p>
|
||||
<h3>SSD 和硬盘</h3>
|
||||
<p>SSD 也叫固态硬盘,结构和内存类似,但是它的优点在于断电后数据还在。内存、寄存器、缓存断电后数据就消失了。内存的读写速度比 SSD 大概快 10~1000 倍。以前还有一种物理读写的磁盘,我们也叫作硬盘,它的速度比内存慢 100W 倍左右。因为它的速度太慢,现在已经逐渐被 SSD 替代。</p>
|
||||
<p><img src="assets/Ciqc1F9kgMWAAU1JAABxd6qpCo0763.png" alt="Lark20200918-173926.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9kgMWAAU1JAABxd6qpCo0763.png" alt="png" /></p>
|
||||
<p>当 CPU 需要内存中某个数据的时候,如果寄存器中有这个数据,我们可以直接使用;如果寄存器中没有这个数据,我们就要先查询 L1 缓存;L1 中没有,再查询 L2 缓存;L2 中没有再查询 L3 缓存;L3 中没有,再去内存中拿。</p>
|
||||
<h3>缓存条目结构</h3>
|
||||
<p>上面我们介绍了存储器分级结构大概有哪些存储以及它们的特点,接下来还有一些缓存算法和数据结构的设计困难要和你讨论。比如 CPU 想访问一个内存地址,那么如何检查这个数据是否在 L1- 缓存中?换句话说,缓存中的数据结构和算法是怎样的?</p>
|
||||
|
||||
@@ -285,7 +285,7 @@ wrappedWillStop()
|
||||
<p>然后我们来说说求对数,求对数也是没有指令的。因为对数是指数的逆运算,当然我们可以利用乘法运算一点点尝试。比如计算 log_210,我们可以先尝试 32,再尝试 3.12 等等,一直找到以 2 为底 10 的对数。这其实是个近似算法。</p>
|
||||
<p>另外,在这个问题上聪明的数学家提出了很多近似算法,提升了计算效率。具体这里比较超纲,面试通常只考到有没有求对数的指令,感兴趣的同学可以学习泰勒级数、牛顿迭代法等。</p>
|
||||
<p>比如下面这个泰勒级数可以用来求以<code>e</code>为底的对数,可以进行相似运算。</p>
|
||||
<p><img src="assets/Ciqc1F9twiuAbp_aAAAe6lkGtXY531.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9twiuAbp_aAAAe6lkGtXY531.png" alt="png" /></p>
|
||||
<p><strong>【补充内容】1 位的 CPU 能操作多大的内存空间?</strong></p>
|
||||
<p>在 03 课时程序的执行中,有个问题我讲的不是很明白,在这里我们再讨论一下。</p>
|
||||
<p>之前提到过 32 位机器只能操作小于 32 位的地址总线,这里其实讲的不太清晰,历史上出现过 32 位操作 40 位地址总线的情况。</p>
|
||||
@@ -330,7 +330,7 @@ wrappedWillStop()
|
||||
<h4>05 | 存储器分级 :SSD、内存和 L1 Cache 相比速度差多少倍?</h4>
|
||||
<p><strong>【问题】</strong> 假设有一个二维数组,总共有 1M 个条目,如果我们要遍历这个二维数组,应该逐行遍历还是逐列遍历?</p>
|
||||
<p><strong>【解析】</strong> 二维数组本质还是 1 维数组。只不过进行了脚标运算。比如说一个 N 行 M 列的数组,第 y 行第 x 列的坐标是: x + y*M。因此当行坐标增加时,内存空间是跳跃的。列坐标增加时,内存空间是连续的。</p>
|
||||
<p><img src="assets/Ciqc1F9twnCAUTt4AACDLWAQvC4277.png" alt="Lark20200925-181059.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9twnCAUTt4AACDLWAQvC4277.png" alt="png" /></p>
|
||||
<p>当 CPU 遍历二维数组的时候,会先从 CPU 缓存中取数据。</p>
|
||||
<p>关键因素在于现在的 CPU 设计不是每次读取一个内存地址,而是读取每次读取相邻的多个内存地址(内存速度 200~300 CPU 周期,预读提升效率)。所以这相当于机器和人的约定,如果程序员不按照这个约定,就无法利用预读的优势。</p>
|
||||
<p>另一方面当读取内存地址跳跃较大的时候,会触发内存的页面置换,这个知识在“<strong>模块五:内存管理</strong>”中学习。</p>
|
||||
|
||||
@@ -252,7 +252,7 @@ function hide_canvas() {
|
||||
<p>早期程序员没有图形界面用,就用 Shell。而且图形界面制作成本较高,不能实现所有功能,因此今天的程序员依然在用 Shell。</p>
|
||||
<p>你平时还经常会看到一个词叫作bash(Bourne Again Shell),它是用 Shell 组成的程序。这里的 Bourne 是一个人名,Steve Bourne 是 bash 的发明者。</p>
|
||||
<p>我们今天学习的所有指令,不是写死在操作系统中的,而是一个个程序。比如<code>rm</code>指令,你可以用<code>which</code>指令查看它所在的目录。如下图所示,你会发现<code>rm</code>指令在<code>/usr/bin/rm</code>目录中。</p>
|
||||
<p><img src="assets/Ciqc1F9rD96AC0GrAAB1NDHyN48035.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9rD96AC0GrAAB1NDHyN48035.png" alt="png" /></p>
|
||||
<p>如上图所示,<code>ramroll</code>是我的英文名字,ubuntu 是我这台机器的名字。我输入了<code>which rm</code>,然后获得了<code>/usr/bin/rm</code>的结果,最终执行这条指令的是操作系统,连接我和操作系统的程序就是 Shell。</p>
|
||||
<p>Linux 对文件目录操作的指令就工作在 Shell 上,接下来我们讲讲文件目录操作指令。</p>
|
||||
<h3>Linux 对文件目录的抽象</h3>
|
||||
@@ -277,7 +277,7 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<p>利用上面这 3 种能力,你就可以方便的构造相对路径了。</p>
|
||||
<p>Linux提供了一个指令<code>pwd</code>(Print Working Directory)查看工作目录。下图是我输入<code>pwd</code>的结果。</p>
|
||||
<p><img src="assets/Ciqc1F9rEAqAYNQMAACAjjKxZlw157.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9rEAqAYNQMAACAjjKxZlw157.png" alt="png" /></p>
|
||||
<p>你可以看到我正在<code>/home/ramroll/Documents</code>目录下工作。</p>
|
||||
<h4>几种常见的文件类型</h4>
|
||||
<p>另一方面,Linux 下的目录也是一种文件;但是文件也不只有目录和可执行文件两种。常见的文件类型有以下 7 种:</p>
|
||||
@@ -299,7 +299,7 @@ function hide_canvas() {
|
||||
<li>没有符号结尾的是普通文件;</li>
|
||||
<li>/ 结尾的是目录。</li>
|
||||
</ol>
|
||||
<p><img src="assets/Ciqc1F9rECOAaC4iAAEqYXENnnI551.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9rECOAaC4iAAEqYXENnnI551.png" alt="png" /></p>
|
||||
<h4>设备文件</h4>
|
||||
<p>Socket 是网络插座,是客户端和服务器之间同步数据的接口。其实,Linux 不只把 Socket 抽象成了文件,设备基本也都被抽象成了文件。因为设备需要不断和操作系统交换数据。而交换方式只有两种——读和写。所以设备是可以抽象成文件的,因为文件也支持这两种操作。</p>
|
||||
<p>Linux 把所有的设备都抽象成了文件,比如说打印机、USB、显卡等。这让整体的系统设计变得高度统一。</p>
|
||||
@@ -307,33 +307,33 @@ function hide_canvas() {
|
||||
<h3>文件的增删改查</h3>
|
||||
<h4>增加</h4>
|
||||
<p>创建一个普通文件的方法有很多,最常见的有<code>touch</code>指令。比如下面我们创建了一个 a.txt 文件。</p>
|
||||
<p><img src="assets/CgqCHl9rEC-Ae_lzAAA_P5LZwCo061.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHl9rEC-Ae_lzAAA_P5LZwCo061.png" alt="png" /></p>
|
||||
<p><code>touch</code>指令本来是用来更改文件的时间戳的,但是如果文件不存在<code>touch</code>也会帮助创建一个空文件。</p>
|
||||
<p>如果你拿到一个指令不知道该怎么用,比如<code>touch</code>,你可以用<code>man touch</code>去获得帮助。<code>man</code>意思是 manual,就是说明书的意思,这里指的是系统的手册。如果你不知道<code>man</code>是什么,也可以使用<code>man man</code>。下图是使用<code>man man</code>的结果:</p>
|
||||
<p><img src="assets/Ciqc1F9rEDqAMZ0vAAXe1wrRPf0386.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9rEDqAMZ0vAAXe1wrRPf0386.png" alt="png" /></p>
|
||||
<p>另外如果我们需要增加一个目录,就需要用到<code>mkdir</code>指令( make directory),比如我们创建一个<code>hello</code>目录,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9rEEGAKH5HAABgveVKHzI705.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/CgqCHl9rEEGAKH5HAABgveVKHzI705.png" alt="png" /></p>
|
||||
<h4>查看</h4>
|
||||
<p>创建之后我们可以用<code>ls</code>指令看到这个文件,<code>ls</code>是 list 的缩写。下面是指令 'ls' 的执行结果。</p>
|
||||
<p><img src="assets/Ciqc1F9rEF-AVHcKAABf8ABbQ0o651.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9rEF-AVHcKAABf8ABbQ0o651.png" alt="png" /></p>
|
||||
<p>我们看到在当前的目录下有一个<code>a.txt</code>文件,还有一个<code>hello</code>目录。如果你知道当前的工作目录,就可以使用<code>pwd</code>指令。</p>
|
||||
<p>如果想看到<code>a.txt</code>更完善的信息,还可以使用<code>ls -l</code>。<code>-l</code>是<code>ls</code>指令的可选参数。下图是<code>ls -l</code>的结果,你可以看到<code>a.txt</code>更详细的描述。</p>
|
||||
<p><img src="assets/CgqCHl9rEGqAA0XWAAEv83hemN0703.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/CgqCHl9rEGqAA0XWAAEv83hemN0703.png" alt="png" /></p>
|
||||
<p>如上图所示,我们看到两个<code>ramroll</code>,它们是<code>a.txt</code>所属的用户和所属的用户分组,刚好重名了。<code>Sep 13</code>是日期。 中间有一个<code>0</code>是<code>a.txt</code>的文件大小,目前<code>a.txt</code>中还没有写入内容,因此大小是<code>0</code>。</p>
|
||||
<p>另外虽然<code>hello</code>是空的目录,但是目录文件 Linux 上来就分配了<code>4096</code>字节的空间。这是因为目录内需要保存很多文件的描述信息。</p>
|
||||
<h4>删除</h4>
|
||||
<p>如果我们想要删除<code>a.txt</code>可以用<code>rm a.txt</code>;如我们要删除<code>hello</code>目录,可以用<code>rm hello</code>。<code>rm</code>是 remove 的缩写。</p>
|
||||
<p><img src="assets/CgqCHl9rEHSAaCuvAACmYor8yvE702.png" alt="Drawing 8.png" /></p>
|
||||
<p><img src="assets/CgqCHl9rEHSAaCuvAACmYor8yvE702.png" alt="png" /></p>
|
||||
<p>但是当我们输入<code>rm hello</code>的时候,会提示<code>hello</code>是一个目录,不可以删除。因此我们需要增加一个可选项,比如<code>-r</code>即 recursive(递归)。目录是一个递归结构,所以需要用递归删除。最后,你会发现<code>rm hello -r</code>删除了<code>hello</code>目录。</p>
|
||||
<p>接下来我们尝试在 hello 目录下新增一个文件,比如相对路径是<code>hello/world/os.txt</code>。需要先创建 hello/world 目录。这种情况会用到<code>mkdir</code>的<code>-p</code>参数,这个参数控制<code>mkdir</code>当发现目标目录的父级目录不存在的时候会递归的创建。以下是我们的执行结果:</p>
|
||||
<p><img src="assets/Ciqc1F9rEJKAYE8qAAFVKf9hzs8021.png" alt="Drawing 9.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9rEJKAYE8qAAFVKf9hzs8021.png" alt="png" /></p>
|
||||
<h4>修改</h4>
|
||||
<p>如果需要修改一个文件,可以使用<code>nano</code>或者<code>vi</code>编辑器。类似的工具还有很多,但是<code>nano</code>和<code>vi</code>一般是<code>linux</code>自带的。</p>
|
||||
<p>这里我不展开讲解了,你可以自己去尝试。在尝试的过程中如果遇到什么问题,可以写在留言区,我会逐一为你解答。</p>
|
||||
<h3>查阅文件内容</h3>
|
||||
<p>在了解了文件的增删改查操作后,下面我们来学习查阅文件内容。我们知道,Linux 下查阅文件内容,可以根据不同场景选择不同的指令。</p>
|
||||
<p>当文件较小时,比如一个配置文件,想要快速浏览这个文件,可以用<code>cat</code>指令。下面 cat 指令帮助我们快速查看<code>/etc/hosts</code>文件。<code>cat</code>指令将文件连接到标准输出流并打印到屏幕上。</p>
|
||||
<p><img src="assets/CgqCHl9rEKSAetBpAAJKmXNMtek042.png" alt="Drawing 10.png" /></p>
|
||||
<p><img src="assets/CgqCHl9rEKSAetBpAAJKmXNMtek042.png" alt="png" /></p>
|
||||
<p>标准输出流(Standard Output)也是一种文件,进程可以将要输出的内容写入标准输出流文件,这样就可以在屏幕中打印。</p>
|
||||
<p>如果用<code>cat</code>查看大文件,比如一个线上的日志文件,因为动辄有几个 G,控制台打印出所有的内容就要非常久,而且刷屏显示看不到东西。</p>
|
||||
<p>而且如果在线上进行查看大文件的操作,会带来不必要的麻烦:</p>
|
||||
@@ -341,7 +341,7 @@ function hide_canvas() {
|
||||
<p>其次,本身文件会读取到内存中,这时内存被大量占用,很危险,这可能导致其他应用内存不足。因此我们需要一些不用加载整个文件,就能查看文件内容的指令。</p>
|
||||
<p><strong>more</strong></p>
|
||||
<p><code>more</code>可以帮助我们读取文件,但不需要读取整个文件到内存中。本身<code>more</code>的定位是一个阅读过滤器,比如你在<code>more</code>里除了可以向下翻页,还可以输入一段文本进行搜索。</p>
|
||||
<p><img src="assets/CgqCHl9rEK6ANctWAAvN_sMIYLA038.png" alt="Drawing 11.png" /></p>
|
||||
<p><img src="assets/CgqCHl9rEK6ANctWAAvN_sMIYLA038.png" alt="png" /></p>
|
||||
<p>如上图所示,我在<code>more</code>查看一个 nginx 日志后,先输入一个<code>/</code>,然后输入<code>192.168</code>看到的结果。<code>more</code>帮我找到了<code>192.168</code>所在的位置,然后又帮我定位到了这个位置。整个过程 more 指令只读取我们需要的部分到内存中。</p>
|
||||
<p><strong>less</strong></p>
|
||||
<p><code>less</code>是一个和<code>more</code>功能差不多的工具,打开<code>man</code>能够看到<code>less</code>的介绍上写着自己是<code>more</code>的反义词(opposite of more)。这样你可以看出<code>linux</code>生态其实也是很自由的一个生态,在这里创造工具也可以按照自己的喜好写文档。<code>less</code>支持向上翻页,这个功能<code>more</code>是做不到的。所以现在<code>less</code>用得更多一些。</p>
|
||||
@@ -362,18 +362,18 @@ function hide_canvas() {
|
||||
<li>例 1:查找 ip 地址</li>
|
||||
</ul>
|
||||
<p>我们可以通过<code>grep</code>命令定位某个<code>ip</code>地址的用户都做了什么事情,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9rELqAbYi4AAfJLxM4xgw204.png" alt="Drawing 12.png" /></p>
|
||||
<p><img src="assets/CgqCHl9rELqAbYi4AAfJLxM4xgw204.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li>例 2:查找时间段的日志</li>
|
||||
</ul>
|
||||
<p>我们可以通过 grep 命令查找某个时间段内用户都做了什么事情。如下图所示,你可以看到在某个 5 分钟内所有用户的访问情况。</p>
|
||||
<p><img src="assets/Ciqc1F9rEMGAQTTHAAYTLdI_HSA050.png" alt="Drawing 13.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9rEMGAQTTHAAYTLdI_HSA050.png" alt="png" /></p>
|
||||
<h3>查找文件</h3>
|
||||
<p>用户经常还会有一种诉求,就是查找文件。</p>
|
||||
<p>之前我们使用过一个<code>which</code>指令,这个指令可以查询一个指令文件所在的位置,比如<code>which grep</code>会,你会看到<code>grep</code>指令被安装的位置是<code>/usr/bin</code>。但是我们还需要一个更加通用的指令查找文件,也就是 find 指令。</p>
|
||||
<p><strong>find</strong></p>
|
||||
<p>find 指令帮助我们在文件系统中查找文件。 比如我们如果想要查找所有<code>.txt</code> 扩展名的文件,可以使用<code>find / -iname "*.txt"</code>,<code>-iname</code>这个参数是用来匹配查找的,i 字母代表忽略大小写,这里也可以用<code>-name</code>替代。输入这条指令,你会看到不断查找文件,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9rEM2AD9SWAAdsfnMr8fw422.png" alt="Drawing 14.png" /></p>
|
||||
<p><img src="assets/CgqCHl9rEM2AD9SWAAdsfnMr8fw422.png" alt="png" /></p>
|
||||
<h3>总结</h3>
|
||||
<p>这节课我们学习了很多指令,不知道你记住了多少?最后,我们再一起复习一下。</p>
|
||||
<ul>
|
||||
|
||||
@@ -253,12 +253,12 @@ function hide_canvas() {
|
||||
<p><em><strong>可以回答:进程是应用的执行副本;而不要回答进程是操作系统分配资源的最小单位。前者是定义,后者是作用</strong></em>*。*</p>
|
||||
<p><strong>ps</strong></p>
|
||||
<p>如果你要看当前的进程,可以用<code>ps</code>指令。p 代表 processes,也就是进程;s 代表 snapshot,也就是快照。所谓快照,就是像拍照一样。</p>
|
||||
<p><img src="assets/CgqCHl9twJSAZMbHAADiXX3JRVw649.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl9twJSAZMbHAADiXX3JRVw649.png" alt="png" /></p>
|
||||
<p>如上图所示,我启动了两个进程,<code>ps</code>和<code>bash</code>。ps 就是我刚刚启动的,被<code>ps</code>自己捕捉到了;<code>bash</code>是因为我开了这个控制台,执行的<code>shell</code>是<code>bash</code>。</p>
|
||||
<p>当然操作系统也不可能只有这么几个进程,这是因为不带任何参数的<code>ps</code>指令显示的是同一个电传打字机(TTY上)的进程。TTY 这个概念是一个历史的概念,过去用来传递信息,现在已经被传真、邮件、微信等取代。</p>
|
||||
<p>操作系统上的 TTY 是一个输入输出终端的概念,比如用户打开 bash,操作系统就为用户分配了一个输入输出终端。没有加任何参数的<code>ps</code>只显示在同一个 TTY 的进程。</p>
|
||||
<p>如果想看到所有的进程,可以用<code>ps -e</code>,<code>-e</code>没有特殊含义,只是为了和<code>-A</code>区分开。我们通常不直接用<code>ps -e</code>而是用<code>ps -ef</code>,这是因为<code>-f</code>可以带上更多的描述字段,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F9twKuAcx9KAAMttqMWk0U603.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9twKuAcx9KAAMttqMWk0U603.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li>UID 指进程的所有者;</li>
|
||||
<li>PID 是进程的唯一标识;</li>
|
||||
@@ -270,11 +270,11 @@ function hide_canvas() {
|
||||
<li>CMD 是进程启动时的命令,如果不是一个 Shell 命令,而是用方括号括起来,那就是系统进程或者内核过程。</li>
|
||||
</ul>
|
||||
<p>另外一个用得比较多的是<code>ps aux</code>,它和<code>ps -ef</code>能力差不多,但是是 BSD 风格的。就是加州伯克利分校研发的 Unix 分支版本的衍生风格,这种风格其实不太好描述,我截了一张图,你可以体会一下:</p>
|
||||
<p><img src="assets/CgqCHl9twMGAAl8XAAOd-4G_G6U649.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl9twMGAAl8XAAOd-4G_G6U649.png" alt="png" /></p>
|
||||
<p>在 BSD 风格中有些字段的叫法和含义变了,如果你感兴趣,可以作为课后延伸学习的内容。</p>
|
||||
<h4>top</h4>
|
||||
<p>另外还有一个和<code>ps</code>能力差不多,但是显示的不是快照而是实时更新数据的<code>top</code>指令。因为自带的<code>top</code>显示的内容有点少, 所以我喜欢用一个叫作<code>htop</code>的指令,具体的安装全方法我会在 10 | 软件的安装: 编译安装和包管理器安装有什么优势和劣势?中给你介绍。本课时,我们先看一下使用效果,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F9twNKAbWUxAAjBKXaXn90775.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9twNKAbWUxAAjBKXaXn90775.png" alt="png" /></p>
|
||||
<p>以上,我们一起把进程学了一个皮毛,更多关于进程的内容我们会在模块四:进程和线程中讨论。</p>
|
||||
<h3>管道(Pipeline)</h3>
|
||||
<p>现在你已经掌握了一点点进程的基础,下面我们来学习管道,管道(Pipeline)的作用是在命令和命令之间,传递数据。比如说一个命令的结果,就可以作为另一个命令的输入。我们了解了进程,所以这里说的命令就是进程。更准确地说,管道在进程间传递数据。</p>
|
||||
@@ -288,7 +288,7 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<p><strong>重定向</strong></p>
|
||||
<p>我们执行一个指令,比如<code>ls -l</code>,结果会写入标准输出流,进而被打印。这时可以用重定向符将结果重定向到一个文件,比如说<code>ls -l > out</code>,这样<code>out</code>文件就会有<code>ls -l</code>的结果;而屏幕上也不会再打印<code>ls -l</code>的结果。</p>
|
||||
<p><img src="assets/Ciqc1F9twOiAWhAGAAU25o5Gb_s323.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9twOiAWhAGAAU25o5Gb_s323.png" alt="png" /></p>
|
||||
<p>具体来说<code>></code>符号叫作覆盖重定向;<code>>></code>叫作追加重定向。<code>></code>每次都会把目标文件覆盖,<code>>></code>会在目标文件中追加。比如你每次启动一个程序日志都写入<code>/var/log/somelogfile</code>中,可以这样操作,如下所示:</p>
|
||||
<pre><code>start.sh >> /var/log/somelogfile
|
||||
</code></pre>
|
||||
@@ -302,7 +302,7 @@ function hide_canvas() {
|
||||
<p>这个写法等价于:</p>
|
||||
<pre><code>ls1 > out 2>&1
|
||||
</code></pre>
|
||||
<p><img src="assets/CgqCHl9twP2AefIFAAL1fMsTbHk961.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/CgqCHl9twP2AefIFAAL1fMsTbHk961.png" alt="png" /></p>
|
||||
<p>相当于把<code>ls1</code>的标准输出流重定向到<code>out</code>,因为<code>ls1 > out</code>出错了,所以标准错误流被定向到了标准输出流。<code>&</code>代表一种引用关系,具体代表的是<code>ls1 >out</code>的标准输出流。</p>
|
||||
<h4>管道的作用和分类</h4>
|
||||
<p>有了进程和重定向的知识,接下来我们梳理下管道的作用。管道(Pipeline)将一个进程的输出流定向到另一个进程的输入流,就像水管一样,作用就是把这两个文件接起来。如果一个进程输出了一个字符 X,那么另一个进程就会获得 X 这个输入。</p>
|
||||
@@ -318,7 +318,7 @@ function hide_canvas() {
|
||||
<p>接下来我们以多个场景举例帮助你深入学习管道。</p>
|
||||
<h4>排序</h4>
|
||||
<p>比如我们用<code>ls</code>,希望按照文件名排序倒序,可以使用匿名管道,将<code>ls</code>的结果传递给<code>sort</code>指令去排序。你看,这样<code>ls</code>的开发者就不用关心排序问题了。</p>
|
||||
<p><img src="assets/Ciqc1F9twQmAUpYzAADI43WGK9A660.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9twQmAUpYzAADI43WGK9A660.png" alt="png" /></p>
|
||||
<h4>去重</h4>
|
||||
<p>另一个比较常见的场景是去重,比如有一个字典文件,里面都是词语。如下所示:</p>
|
||||
<pre><code>Apple
|
||||
@@ -328,7 +328,7 @@ Banana
|
||||
……
|
||||
</code></pre>
|
||||
<p>如果我们想要去重可以使用<code>uniq</code>指令,<code>uniq</code>指令能够找到文件中相邻的重复行,然后去重。但是我们上面的文件重复行是交替的,所以不可以直接用<code>uniq</code>,因此可以先<code>sort</code>这个文件,然后利用管道将<code>sort</code>的结果重定向到<code>uniq</code>指令。指令如下:</p>
|
||||
<p><img src="assets/CgqCHl9twRGAXmhPAACPjv2JnVo451.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/CgqCHl9twRGAXmhPAACPjv2JnVo451.png" alt="png" /></p>
|
||||
<h4>筛选</h4>
|
||||
<p>有时候我们想根据正则模式筛选对应的内容。比如说我们想找到项目文件下所有文件名中含有<code>Spring</code>的文件。就可以利用<code>grep</code>指令,操作如下:</p>
|
||||
<pre><code>find ./ | grep Spring
|
||||
@@ -340,9 +340,9 @@ Banana
|
||||
<p><code>grep -v</code>是匹配不包含 MyBatis 的结果。</p>
|
||||
<h4>数行数</h4>
|
||||
<p>还有一个比较常见的场景是数行数。比如你写了一个 Java 文件想知道里面有多少行,就可以使用<code>wc -l</code>指令,如下所示:</p>
|
||||
<p><img src="assets/Ciqc1F9twRqAH6ezAAD5iEQBhxE628.png" alt="Drawing 8.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9twRqAH6ezAAD5iEQBhxE628.png" alt="png" /></p>
|
||||
<p>但是如果你想知道当前目录下有多少个文件,可以用<code>ls | wc -l</code>,如下所示:</p>
|
||||
<p><img src="assets/Ciqc1F9twSCAN0h-AABgIcsEgKI655.png" alt="Drawing 9.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9twSCAN0h-AABgIcsEgKI655.png" alt="png" /></p>
|
||||
<p><strong>接下来请你思考一个问题:我们如何知道当前</strong><code>java</code><strong>的项目目录下有多少行代码</strong>?</p>
|
||||
<p>提示一下。你可以使用下面这个指令:</p>
|
||||
<pre><code>find -i ".java" ./ | wc -l
|
||||
@@ -361,9 +361,9 @@ Banana
|
||||
<p><code>xargs</code>指令从标准数据流中构造并执行一行行的指令。<code>xargs</code>从输入流获取字符串,然后利用空白、换行符等切割字符串,在这些字符串的基础上构造指令,最后一行行执行这些指令。</p>
|
||||
<p>举个例子,如果我们重命名当前目录下的所有 .a 的文件,想在这些文件前面加一个前缀<code>prefix_</code>。比如说<code>x.a</code>文件需要重命名成<code>prefix_x.a</code>,我们就可以用<code>xargs</code>指令构造模块化的指令。</p>
|
||||
<p>现在我们有<code>x.a``y.a``z.a</code>三个文件,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9twTWALpuzAABnixlvrS8980.png" alt="Drawing 10.png" /></p>
|
||||
<p><img src="assets/CgqCHl9twTWALpuzAABnixlvrS8980.png" alt="png" /></p>
|
||||
<p>然后使用下图中的指令构造我们需要的指令:</p>
|
||||
<p><img src="assets/CgqCHl9twT-AOUALAAE5FDR8Tiw234.png" alt="Drawing 11.png" /></p>
|
||||
<p><img src="assets/CgqCHl9twT-AOUALAAE5FDR8Tiw234.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li>我们用<code>ls</code>找到所有的文件;</li>
|
||||
<li><code>-I</code>参数是查找替换符,这里我们用<code>GG</code>替代<code>ls</code>找到的结果;<code>-I GG</code>后面的字符串 GG 会被替换为<code>x.a``x.b</code>或<code>x.z</code>;</li>
|
||||
@@ -371,18 +371,18 @@ Banana
|
||||
</ul>
|
||||
<p>我们用<code>xargs</code>构造了 3 条指令。这里我再多讲一个词,叫作样板代码。如果你没有用<code>xargs</code>指令,而是用一条条<code>mv</code>指令去敲,这样就构成了样板代码。</p>
|
||||
<p>最后去掉 echo,就是我们想要的结果,如下所示:</p>
|
||||
<p><img src="assets/Ciqc1F9twUiAOcNlAAEsaaMV4DI747.png" alt="Drawing 12.png" /></p>
|
||||
<p><img src="assets/Ciqc1F9twUiAOcNlAAEsaaMV4DI747.png" alt="png" /></p>
|
||||
<h3>管道文件</h3>
|
||||
<p>上面我们花了较长的一段时间讨论匿名管道,用<code>|</code>就可以创造和使用。匿名管道也是利用了文件系统的能力,是一种文件结构。当你学到模块六文件系统的内容,会知道匿名管道拥有一个自己的<code>inode</code>,但不属于任何一个文件夹。</p>
|
||||
<p>还有一种管道叫作命名管道(Named Pipeline)。命名管道是要挂到文件夹中的,因此需要创建。用<code>mkfifo</code>指令可以创建一个命名管道,下面我们来创建一个叫作<code>pipe1</code>的命名管道,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl9twU-ASY8bAAC7_lc6Pr8814.png" alt="Drawing 13.png" /></p>
|
||||
<p><img src="assets/CgqCHl9twU-ASY8bAAC7_lc6Pr8814.png" alt="png" /></p>
|
||||
<p>命名管道和匿名管道能力类似,可以连接一个输出流到另一个输入流,也是 First In First Out。</p>
|
||||
<p>当执行<code>cat pipe1</code>的时候,你可以观察到,当前的终端处于等待状态。因为我们<code>cat pipe1</code>的时候<code>pipe1</code>中没有内容。</p>
|
||||
<p>如果这个时候我们再找一个终端去写一点东西到<code>pipe</code>中,比如说:</p>
|
||||
<pre><code>echo "XXX" > pipe1
|
||||
</code></pre>
|
||||
<p>这个时候,<code>cat pipe1</code>就会返回,并打印出<code>xxx</code>,如下所示:</p>
|
||||
<p><img src="assets/CgqCHl9twViAT-M2AADtPsSTV5c658.png" alt="Drawing 14.png" /></p>
|
||||
<p><img src="assets/CgqCHl9twViAT-M2AADtPsSTV5c658.png" alt="png" /></p>
|
||||
<p>我们可以像上图那样演示这段程序,在<code>cat pipe1</code>后面增加了一个<code>&</code>符号。这个<code>&</code>符号代表指令在后台执行,不会阻塞用户继续输入。然后我们通过<code>echo</code>指令往<code>pipe1</code>中写入东西,接着就会看到<code>xxx</code>被打印出来。</p>
|
||||
<h3>总结</h3>
|
||||
<p>这节课我们为了学习管道,先简单接触了进程的概念,然后学习了重定向。之后我们学习了匿名管道的应用场景,匿名管道帮助我们把 Linux 指令串联起来形成很强的计算能力。特别是<code>xargs</code>指令支持模板化的生成指令,拓展了指令的能力。最后我们还学习了命名管道,命名管道让我们可以真实拿到一个管道文件,让多个程序之间可以方便地进行通信。</p>
|
||||
|
||||
@@ -260,14 +260,14 @@ function hide_canvas() {
|
||||
<li>写权限(w):控制写入文件。</li>
|
||||
<li>执行权限(x):控制将文件执行,比如脚本、应用程序等。</li>
|
||||
</ol>
|
||||
<p><img src="assets/Ciqc1F91G6qACantAAC4GIUeips460.png" alt="1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F91G6qACantAAC4GIUeips460.png" alt="png" /></p>
|
||||
<p>然后每个文件又可以从 3 个维度去配置上述的 3 种权限:</p>
|
||||
<ol>
|
||||
<li>用户维度。每个文件可以所属 1 个用户,用户维度配置的 rwx 在用户维度生效;</li>
|
||||
<li>组维度。每个文件可以所属 1 个分组,组维度配置的 rwx 在组维度生效;</li>
|
||||
<li>全部用户维度。设置对所有用户的权限。</li>
|
||||
</ol>
|
||||
<p><img src="assets/CgqCHl91G9aADTBZAADD7IOpjac809.png" alt="2.png" /></p>
|
||||
<p><img src="assets/CgqCHl91G9aADTBZAADD7IOpjac809.png" alt="png" /></p>
|
||||
<p>因此 Linux 中文件的权限可以用 9 个字符,3 组<code>rwx</code>描述:第一组是用户权限,第二组是组权限,第三组是所有用户的权限。然后用<code>-</code>代表没有权限。比如<code>rwxrwxrwx</code>代表所有维度可以读写执行。<code>rw--wxr-x</code>代表用户维度不可以执行,组维度不可以读取,所有用户维度不可以写入。</p>
|
||||
<p>通常情况下,如果用<code>ls -l</code>查看一个文件的权限,会有 10 个字符,这是因为第一个字符代表的是文件类型。我们在 06 课时讲解“几种常见的文件类型”时提到过,有管道文件、目录文件、链接文件等等。<code>-</code>代表普通文件、<code>d</code>代表目录、<code>p</code>代表管道。</p>
|
||||
<p><strong>学习了这套机制之后,请你跟着我的节奏一起思考以下 4 个问题</strong>。</p>
|
||||
@@ -288,14 +288,14 @@ function hide_canvas() {
|
||||
<p>也就是用户、组维度不可以执行,所有用户可读。</p>
|
||||
<p><strong>问题二:公共执行文件的权限</strong></p>
|
||||
<p>前面提到过可以用<code>which</code>指令查看<code>ls</code>指令所在的目录,我们发现在<code>/usr/bin</code>中。然后用<code>ls -l</code>查看<code>ls</code>的权限,可以看到下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F90SRuAAQCEAADdVOthCFw679.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F90SRuAAQCEAADdVOthCFw679.png" alt="png" /></p>
|
||||
<p>第一个<code>-</code>代表这是一个普通文件,后面的 rwx 代表用户维度可读写和执行;第二个<code>r-x</code>代表组维度不可以写;第三个<code>r-x</code>代表所有用户可以读和执行。后面的两个<code>root</code>,第一个是所属用户,第二个是所属分组。</p>
|
||||
<p>到这里你可能会有一个疑问:如果一个文件设置为不可读,但是可以执行,那么结果会怎样?</p>
|
||||
<p>答案当然是不可以执行,无法读取文件内容自然不可以执行。</p>
|
||||
<p><strong>问题三:执行文件</strong></p>
|
||||
<p>在 Linux 中,如~~果~~一个文件可以被执行,则可以直接通过输入文件路径(相对路径或绝对路径)的方式执行。如果想执行一个不可以执行的文件,Linux 则会报错。</p>
|
||||
<p>当用户输入一个文件名,如果没有指定完整路径,Linux 就会在一部分目录中查找这个文件。你可以通过<code>echo $PATH</code>看到 Linux 会在哪些目录中查找可执行文件,<code>PATH</code>是 Linux 的环境变量,关于环境变量,我将在 “12 | 高级技巧之集群部署中”和你详细讨论。</p>
|
||||
<p><img src="assets/CgqCHl90SSSACa4WAAFIEUypWH4904.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHl90SSSACa4WAAFIEUypWH4904.png" alt="png" /></p>
|
||||
<p><strong>问题四:可不可以都 root</strong></p>
|
||||
<p>最后一个问题是,可不可以都<code>root</code>?</p>
|
||||
<p>答案当然是不行!这里先给你留个悬念,具体原因我们会在本课时最后来讨论。</p>
|
||||
@@ -313,7 +313,7 @@ function hide_canvas() {
|
||||
<p>黑客可以利用 Mysql 的 Copy From Prgram 指令为所欲为,比如先备份你的关键文件,然后再删除他们,并要挟你通过指定账户打款。如果执行最小权限原则,那么黑客即便攻破我们的 Mysql 服务,他也只能获得最小的权限。当然,黑客拿到 Mysql 权限也是非常可怕的,但是相比拿到所有权限,这个损失就小多了。</p>
|
||||
<h4>分级保护</h4>
|
||||
<p>因为内核可以直接操作内存和 CPU,因此非常危险。驱动程序可以直接控制摄像头、显示屏等核心设备,也需要采取安全措施,比如防止恶意应用开启摄像头盗用隐私。通常操作系统都采取一种环状的保护模式。</p>
|
||||
<p><img src="assets/CgqCHl91HB2AdNsAAAEpE6rtlHM754.png" alt="3.png" /></p>
|
||||
<p><img src="assets/CgqCHl91HB2AdNsAAAEpE6rtlHM754.png" alt="png" /></p>
|
||||
<p>如上图所示,内核在最里面,也就是 Ring 0。 应用在最外面也就是Ring 3。驱动在中间,也就是 Ring 1 和 Ring 2。对于相邻的两个 Ring,内层 Ring 会拥有较高的权限,可以改变外层的 Ring;而外层的 Ring 想要使用内层 Ring 的资源时,会有专门的程序(或者硬件)进行保护。</p>
|
||||
<p>比如说一个 Ring3 的应用需要使用内核,就需要发送一个系统调用给内核。这个系统调用会由内核进行验证,比如验证用户有没有足够的权限,以及这个行为是否安全等等。</p>
|
||||
<p><strong>权限包围(Privilege Bracking)</strong></p>
|
||||
@@ -323,7 +323,7 @@ function hide_canvas() {
|
||||
<p>上面我们讨论了 Linux 权限的架构,接下来我们学习一些具体的指令。</p>
|
||||
<h4>查看</h4>
|
||||
<p>如果想查看当前用户的分组可以使用<code>groups</code>指令。</p>
|
||||
<p><img src="assets/CgqCHl90SU6AUJrLAADmRyiiAig313.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/CgqCHl90SU6AUJrLAADmRyiiAig313.png" alt="png" /></p>
|
||||
<p>上面指令列出当前用户的所有分组。第一个是同名的主要分组,后面从<code>adm</code>开始是次级分组。</p>
|
||||
<p>我先给你介绍两个分组,其他分组你可以去查资料:</p>
|
||||
<ul>
|
||||
@@ -331,16 +331,16 @@ function hide_canvas() {
|
||||
<li>sudo 分组用户可以通过 sudo 指令提升权限。</li>
|
||||
</ul>
|
||||
<p>如果想查看当前用户,可以使用<code>id</code>指令,如下所示:</p>
|
||||
<p><img src="assets/CgqCHl90SVSALssXAAGhSpF-cWY440.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/CgqCHl90SVSALssXAAGhSpF-cWY440.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li>uid 是用户 id;</li>
|
||||
<li>gid 是组 id;</li>
|
||||
<li>groups 后面是每个分组和分组的 id。</li>
|
||||
</ul>
|
||||
<p>如果想查看所有的用户,可以直接看<code>/etc/passwd</code>。</p>
|
||||
<p><img src="assets/CgqCHl90SVqAIja7AAXBj3lebBQ651.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/CgqCHl90SVqAIja7AAXBj3lebBQ651.png" alt="png" /></p>
|
||||
<p><code>/etc/passwd</code>这个文件存储了所有的用户信息,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl91HIGAWXWVAACI9cgafaM295.png" alt="WechatIMG144.png" /></p>
|
||||
<p><img src="assets/CgqCHl91HIGAWXWVAACI9cgafaM295.png" alt="png" /></p>
|
||||
<h4>创建用户</h4>
|
||||
<p>创建用户用<code>useradd</code>指令。</p>
|
||||
<pre><code>sudo useradd foo
|
||||
|
||||
@@ -260,14 +260,14 @@ function hide_canvas() {
|
||||
<p>远程操作指令用得最多的是<code>ssh</code>,<code>ssh</code>指令允许远程登录到目标计算机并进行远程操作和管理。还有一个比较常用的远程指令是<code>scp</code>,<code>scp</code>帮助我们远程传送文件。</p>
|
||||
<h4>ssh(Secure Shell)</h4>
|
||||
<p>有一种场景需要远程登录一个 Linux 系统,这时我们会用到<code>ssh</code>指令。比如你想远程登录一台机器,可以使用<code>ssh <a href="/cdn-cgi/l/email-protection" class="__cf_email__" data-cfemail="1366607661537a63">[email protected]</a></code>的方式,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl92j8GAMNHAAAPCrIyhHHk744.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl92j8GAMNHAAAPCrIyhHHk744.png" alt="png" /></p>
|
||||
<p>上图中,我在使用<code>ssh</code>指令从机器<code>u1</code>登录我的另一台虚拟机<code>u2</code>。这里<code>u1</code>和<code>u2</code>对应着 IP 地址,是我在<code>/etc/hosts</code>中设置的,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl92j8mAIMPdAACTOATTrQM694.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgqCHl92j8mAIMPdAACTOATTrQM694.png" alt="png" /></p>
|
||||
<p><code>/etc/hosts</code>这个文件可以设置 IP 地址对应的域名。我这里是一个小集群,总共有两台机器,因此我设置了方便记忆和操作的名字。</p>
|
||||
<h4>scp</h4>
|
||||
<p>另一种场景是我需要拷贝一个文件到远程,这时可以使用<code>scp</code>指令,如下图,我使用<code>scp</code>指令将本地计算机的一个文件拷贝到了 ubuntu 虚拟机用户的家目录中。</p>
|
||||
<p>比如从<code>u1</code>拷贝家目录下的文件<code>a.txt</code>到<code>u2</code>。家目录有一个简写,就是用<code>~</code>。具体指令见下图:</p>
|
||||
<p><img src="assets/Ciqc1F92j9OADjTcAAPER8w5DNg904.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F92j9OADjTcAAPER8w5DNg904.png" alt="png" /></p>
|
||||
<p>输入 scp 指令之后会弹出一个提示,要求输入密码,系统验证通过后文件会被成功拷贝。</p>
|
||||
<h3>查看本地网络状态</h3>
|
||||
<p>如果你想要了解本地的网络状态,比较常用的网络指令是<code>ifconfig</code>和<code>netstat</code>。</p>
|
||||
@@ -275,58 +275,58 @@ function hide_canvas() {
|
||||
<p>当你想知道本地<code>ip</code>以及本地有哪些网络接口时,就可以使用<code>ifconfig</code>指令。你可以把一个网络接口理解成一个网卡,有时候虚拟机会装虚拟网卡,虚拟网卡是用软件模拟的网卡。</p>
|
||||
<p>比如:VMware 为每个虚拟机创造一个虚拟网卡,通过虚拟网卡接入虚拟网络。当然物理机也可以接入虚拟网络,它可以通过虚拟网络向虚拟机的虚拟网卡上发送信息。</p>
|
||||
<p>下图是我的 ubuntu 虚拟机用 ifconfig 查看网络接口信息。</p>
|
||||
<p><img src="assets/Ciqc1F92j9yAaioXAAbz00ZJYlw555.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F92j9yAaioXAAbz00ZJYlw555.png" alt="png" /></p>
|
||||
<p>可以看到我的这台 ubuntu 虚拟机一共有 2 个网卡,ens33 和 lo。<code>lo</code>是本地回路(local lookback),发送给<code>lo</code>就相当于发送给本机。<code>ens33</code>是一块连接着真实网络的虚拟网卡。</p>
|
||||
<h4>netstat</h4>
|
||||
<p>另一个查看网络状态的场景是想看目前本机的网络使用情况,这个时候可以用<code>netstat</code>。</p>
|
||||
<p><strong>默认行为</strong></p>
|
||||
<p>不传任何参数的<code>netstat</code>帮助查询所有的本地 socket,下图是<code>netstat | less</code>的结果。</p>
|
||||
<p><img src="assets/Ciqc1F92j-aAAZlfAAizLye7uc4727.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F92j-aAAZlfAAizLye7uc4727.png" alt="png" /></p>
|
||||
<p>如上图,我们看到的是 socket 文件。socket 是网络插槽被抽象成了文件,负责在客户端、服务器之间收发数据。当客户端和服务端发生连接时,客户端和服务端会同时各自生成一个 socket 文件,用于管理这个连接。这里,可以用<code>wc -l</code>数一下有多少个<code>socket</code>。</p>
|
||||
<p><img src="assets/Ciqc1F92j-2AVEYjAAA8xcVMQzc068.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/Ciqc1F92j-2AVEYjAAA8xcVMQzc068.png" alt="png" /></p>
|
||||
<p>你可以看到一共有 615 个 socket 文件,因为有很多 socket 在解决进程间的通信。就是将两个进程一个想象成客户端,一个想象成服务端。并不是真的有 600 多个连接着互联网的请求。</p>
|
||||
<p><strong>查看 TCP 连接</strong></p>
|
||||
<p>如果想看有哪些 TCP 连接,可以使用<code>netstat -t</code>。比如下面我通过<code>netstat -t</code>看<code>tcp</code>协议的网络情况:</p>
|
||||
<p><img src="assets/CgqCHl92j_aAbxdlAAEAdzG3a2s636.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/CgqCHl92j_aAbxdlAAEAdzG3a2s636.png" alt="png" /></p>
|
||||
<p>这里没有找到连接中的<code>tcp</code>,因为我们这台虚拟机当时没有发生任何的网络连接。因此我们尝试从机器<code>u2</code>(另一台机器)ssh 登录进<code>u1</code>,再看一次:</p>
|
||||
<p><img src="assets/CgqCHl92kAaAMuMDAAFWQdSNGfk978.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/CgqCHl92kAaAMuMDAAFWQdSNGfk978.png" alt="png" /></p>
|
||||
<p>如上图所示,可以看到有一个 TCP 连接了。</p>
|
||||
<p><strong>查看端口占用</strong></p>
|
||||
<p>还有一种非常常见的情形,我们想知道某个端口是哪个应用在占用。如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F92kBKAHr2RAAEnmEOZ8RM010.png" alt="Drawing 8.png" /></p>
|
||||
<p><img src="assets/Ciqc1F92kBKAHr2RAAEnmEOZ8RM010.png" alt="png" /></p>
|
||||
<p>这里我们看到 22 端口被 sshd,也就是远程登录模块被占用了。<code>-n</code>是将一些特殊的端口号用数字显示,<code>-t</code>是指看 TCP 协议,<code>-l</code>是只显示连接中的连接,<code>-p</code>是显示程序名称。</p>
|
||||
<h3>网络测试</h3>
|
||||
<p>当我们需要测试网络延迟、测试服务是否可用时,可能会用到<code>ping</code>和<code>telnet</code>指令。</p>
|
||||
<h4>ping</h4>
|
||||
<p>想知道本机到某个网站的网络延迟,就可以使用<code>ping</code>指令。如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl92kB-ARKR5AAP30Xk0nBg068.png" alt="Drawing 9.png" /></p>
|
||||
<p><img src="assets/CgqCHl92kB-ARKR5AAP30Xk0nBg068.png" alt="png" /></p>
|
||||
<p><code>ping</code>一个网站需要使用 ICMP 协议。因此你可以在上图中看到 icmp 序号。 这里的时间<code>time</code>是往返一次的时间。<code>ttl</code>叫作 time to live,是封包的生存时间。就是说,一个封包从发出就开始倒计时,如果途中超过 128ms,这个包就会被丢弃。如果包被丢弃,就会被算进丢包率。</p>
|
||||
<p>另外<code>ping</code>还可以帮助我们看到一个网址的 IP 地址。 通过网址获得 IP 地址的过程叫作 DNS Lookup(DNS 查询)。<code>ping</code>利用了 DNS 查询,但是没有显示全部的 DNS 查询结果。</p>
|
||||
<h4>telnet</h4>
|
||||
<p>有时候我们想知道本机到某个 IP + 端口的网络是否通畅,也就是想知道对方服务器是否在这个端口上提供了服务。这个时候可以用<code>telnet</code>指令。 如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl92kCmAcPQzAADcRdxOtdw609.png" alt="Drawing 10.png" /></p>
|
||||
<p><img src="assets/CgqCHl92kCmAcPQzAADcRdxOtdw609.png" alt="png" /></p>
|
||||
<p>telnet 执行后会进入一个交互式的界面,比如这个时候,我们输入下图中的文字就可以发送 HTTP 请求了。如果你对 HTTP 协议还不太了解,建议自学一下 HTTP 协议。如果希望和林老师一起学习,可以等待下我之后的《<strong>计算机网络</strong>》专栏。</p>
|
||||
<p><img src="assets/Ciqc1F92kDKAcYUbAASLFyOyBg4948.png" alt="Drawing 11.png" /></p>
|
||||
<p><img src="assets/Ciqc1F92kDKAcYUbAASLFyOyBg4948.png" alt="png" /></p>
|
||||
<p>如上图所示,第 5 行的<code>GET</code> 和第 6 行的<code>HOST</code>是我输入的。 拉勾网返回了一个 301 永久跳转。这是因为拉勾网尝试把<code>http</code>协议链接重定向到<code>https</code>。</p>
|
||||
<h3>DNS 查询</h3>
|
||||
<p>我们排查网络故障时想要进行一次 DNS Lookup,想知道一个网址 DNS 的解析过程。这个时候有多个指令可以用。</p>
|
||||
<h4>host</h4>
|
||||
<p>host 就是一个 DNS 查询工具。比如我们查询拉勾网的 DNS,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F92kD6AOJPQAAGW1va0D9c041.png" alt="Drawing 12.png" /></p>
|
||||
<p><img src="assets/Ciqc1F92kD6AOJPQAAGW1va0D9c041.png" alt="png" /></p>
|
||||
<p>我们看到拉勾网 <a href="http://www.lagou.comw/">www.lagou.com</a> 是一个别名,它的原名是 lgmain 开头的一个域名,这说明拉勾网有可能在用 CDN 分发主页(关于 CDN,我们《计算机网络》专栏见)。</p>
|
||||
<p>上图中,可以找到 3 个域名对应的 IP 地址。</p>
|
||||
<p>如果想追查某种类型的记录,可以使用<code>host -t</code>。比如下图我们追查拉勾的 AAAA 记录,因为拉勾网还没有部署 IPv6,所以没有找到。</p>
|
||||
<p><img src="assets/CgqCHl92kFWAHIqAAACvpo6qaOs100.png" alt="Drawing 13.png" /></p>
|
||||
<p><img src="assets/CgqCHl92kFWAHIqAAACvpo6qaOs100.png" alt="png" /></p>
|
||||
<h4>dig</h4>
|
||||
<p><code>dig</code>指令也是一个做 DNS 查询的。不过<code>dig</code>指令显示的内容更详细。下图是<code>dig</code>拉勾网的结果。</p>
|
||||
<p><img src="assets/CgqCHl92kGaADOhxAAR-BfryZ5g689.png" alt="Drawing 14.png" /></p>
|
||||
<p><img src="assets/CgqCHl92kGaADOhxAAR-BfryZ5g689.png" alt="png" /></p>
|
||||
<p>从结果可以看到<a href="http://www.lagou.c/">www.lagou.com</a> 有一个别名,用 CNAME 记录定义 lgmain 开头的一个域名,然后有 3 条 A 记录,通常这种情况是为了均衡负载或者分发内容。</p>
|
||||
<h3>HTTP 相关</h3>
|
||||
<p>最后我们来说说<code>http</code>协议相关的指令。</p>
|
||||
<h4>curl</h4>
|
||||
<p>如果要在命令行请求一个网页,或者请求一个接口,可以用<code>curl</code>指令。<code>curl</code>支持很多种协议,比如 LDAP、SMTP、FTP、HTTP 等。</p>
|
||||
<p>我们可以直接使用 curl 请求一个网址,获取资源,比如我用 curl 直接获取了拉勾网的主页,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F92kG-AJPyrAANJZYQ4u5w784.png" alt="Drawing 15.png" /></p>
|
||||
<p><img src="assets/Ciqc1F92kG-AJPyrAANJZYQ4u5w784.png" alt="png" /></p>
|
||||
<p>如果只想看 HTTP 返回头,可以使用<code>curl -I</code>。</p>
|
||||
<p>另外<code>curl</code>还可以执行 POST 请求,比如下面这个语句:</p>
|
||||
<pre><code>curl -d '{"x" : 1}' -H "Content-Type: application/json" -X POST http://localhost:3000/api
|
||||
|
||||
@@ -278,20 +278,20 @@ function hide_canvas() {
|
||||
<p><strong>apt</strong></p>
|
||||
<p>接下来我们来重点说说<code>apt</code>,然后再一起尝试使用。因为我这次是用<code>ubuntu</code>Linux 给你教学,所以我以 apt 为例子,yum 的用法是差不多的,你可以自己 man 一下。</p>
|
||||
<p><code>apt</code>全名是 Advanced Packaging Tools,是一个<code>debian</code>及其衍生 Linux 系统下的包管理器。由于<code>advanced</code>(先进)是相对于<code>dpkg</code>而言的,因此它也能够提供和<code>yum</code>类似的下载和依赖管理能力。比如在没有<code>vim</code>的机器上,我们可以用下面的指令安装<code>vim</code>。如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl99kUCAc2xOAAHulKDtr4U742.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl99kUCAc2xOAAHulKDtr4U742.png" alt="png" /></p>
|
||||
<p>然后用<code>dpkg</code>指令查看 vim 的状态是<code>ii</code>。第一个<code>i</code>代表期望状态是已安装,第二个<code>i</code>代表实际状态是已安装。</p>
|
||||
<p>下面我们卸载<code>vim</code>,再通过<code>dpkg</code>查看,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl99kUuAJZSuAAW-FE-CgIY627.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgqCHl99kVCAT9-sAAJPZUhXt9k401.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl99kUuAJZSuAAW-FE-CgIY627.png" alt="png" /></p>
|
||||
<p><img src="assets/CgqCHl99kVCAT9-sAAJPZUhXt9k401.png" alt="png" /></p>
|
||||
<p>我们看到 vim 的状态从<code>ii</code>变成了<code>rc</code>,<code>r</code>是期望删除,<code>c</code>是实际上还有配置文件遗留。 如果我们想彻底删除配置文件,可以使用<code>apt purge</code>,就是彻底清除的意思,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F99kViANbVLAAPQJy3qAX8926.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F99kViANbVLAAPQJy3qAX8926.png" alt="png" /></p>
|
||||
<p>再使用<code>dpkg -l</code>时,<code>vim</code>已经清除了。</p>
|
||||
<p><img src="assets/Ciqc1F99kV-ACJvxAAIopnvusfs472.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F99kV-ACJvxAAIopnvusfs472.png" alt="png" /></p>
|
||||
<p>期待结果是<code>u</code>就是 unkonw(未知)说明已经没有了。实际结果是<code>n</code>,就是 not-installed(未安装)。</p>
|
||||
<p>如果想查询<code>mysql</code>相关的包,可以使用<code>apt serach mysql</code>,这样会看到很多和<code>mysql</code>相关的包,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl99kWeANmD6AAUugWzWDUE531.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/CgqCHl99kWeANmD6AAUugWzWDUE531.png" alt="png" /></p>
|
||||
<p>如果我们想精确查找一个叫作<code>mysql-server</code>的包,可以用<code>apt list</code>。</p>
|
||||
<p><img src="assets/Ciqc1F99kWyAf1pzAAFI7ot6YSY175.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/Ciqc1F99kWyAf1pzAAFI7ot6YSY175.png" alt="png" /></p>
|
||||
<p>这里我们找到了<code>mysql-server</code>包。</p>
|
||||
<p>另外有时候国内的<code>apt</code>服务器速度比较慢,你可以尝试使用阿里云的镜像服务器。具体可参考我下面的操作:</p>
|
||||
<pre><code>cat /etc/apt/sources.list
|
||||
@@ -308,57 +308,57 @@ deb-src http://mirrors.aliyun.com/ubuntu/ focal-proposed main restricted univers
|
||||
deb-src http://mirrors.aliyun.com/ubuntu/ focal-backports main restricted universe multiverse
|
||||
</code></pre>
|
||||
<p>镜像地址可以通过<code>/etc/apt/sources.list</code>配置,注意<code>focal</code>是我用的<code>ubuntu</code>版本,你可以使用<code>sudo lsb_release</code>查看自己的 Ubuntu 版本。如果你想用我上面给出的内容覆盖你的<code>sources.list</code>,只需把版本号改成你自己的。注意,每个<code>ubuntu</code>版本都有自己的代号。</p>
|
||||
<p><img src="assets/CgqCHl99kYCARaKvAAGzk1pe8DY132.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/CgqCHl99kYCARaKvAAGzk1pe8DY132.png" alt="png" /></p>
|
||||
<p>通过上面的学习,相信你已经逐渐了解了包管理器的基本概念和使用。如果你是<code>centos</code>或者<code>fedora</code>,需要自己<code>man</code>一下<code>yum</code>。</p>
|
||||
<h3>编译安装 Nginx</h3>
|
||||
<p>接下来我们说说编译安装 Nginx(发音是 engine X),是一个家喻户晓的 Web 服务器。 它的发明者是俄国的伊戈尔·赛索耶夫。赛索耶夫 2002 年开始写 Nginx,主要目的是解决同一个互联网节点同时进入大量并发请求的问题。注意,大量并发请求不是大量 QPS 的意思,QPS 是吞吐量大,需要快速响应,而高并发时则需要合理安排任务调度。</p>
|
||||
<p>后来塞索耶夫成立了 Nginx 公司, 2018 年估值到达到 4.3 亿美金。现在基本上国内大厂的 Web 服务器都是基于 Nginx,只不过进行了特殊的修改,比如淘宝用 Tengine。</p>
|
||||
<p>下面我们再来看看源码安装,在 Linux 上获取<code>nginx</code>源码,可以去搜索 <a href="https://nginx.org/en/docs/">Nginx 官方网站</a>,一般都会提供源码包。</p>
|
||||
<p><img src="assets/CgqCHl99kYmAXQUyAADGX8gwStA669.png" alt="Drawing 8.png" /></p>
|
||||
<p><img src="assets/CgqCHl99kYmAXQUyAADGX8gwStA669.png" alt="png" /></p>
|
||||
<p>如上图所示,可以看到 nginx-1.18.0 的网址是:<a href="https://nginx.org/download/nginx-1.19.2.tar.gz">http://nginx.org/download/nginx-1.19.2.tar.gz</a>。然后我们用 wget 去下载这个包。 wget 是 GNU 项目下的下载工具,GNU 是早期<code>unix</code>项目的一个变种。<code>linux</code>下很多工具都是从<code>unix</code>继承来的,这就是开源的好处,很多工具不用再次开发了。你可能很难想象<code>windows</code>下的命令工具可以在<code>linux</code>下用,但是<code>linux</code>下的工具却可以在任何系统中用。 因此,<code>linux</code>下面的工具发展速度很快,如今已成为最受欢迎的服务器操作系统。</p>
|
||||
<p>当然也有同学的机器上没有<code>wget</code>,那么你可以用<code>apt</code>安装一下。</p>
|
||||
<ul>
|
||||
<li>第一步:下载源码。我们使用<code>wget</code>下载<code>nginx</code>源码包:</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F99kZWABdtDAAPejhy3vW4914.png" alt="Drawing 9.png" /></p>
|
||||
<p><img src="assets/Ciqc1F99kZWABdtDAAPejhy3vW4914.png" alt="png" /></p>
|
||||
<p>可以像我这样使用<code>cd</code>先切换到家目录。</p>
|
||||
<ul>
|
||||
<li>第二步:解压。我们解压下载好的<code>nginx</code>源码包。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl99kZ2AaXuiAAH8DdruTtI020.png" alt="Drawing 10.png" /></p>
|
||||
<p><img src="assets/CgqCHl99kZ2AaXuiAAH8DdruTtI020.png" alt="png" /></p>
|
||||
<p>用<code>ls</code>发现包已经存在了,然后使用<code>tar</code>命令解压。</p>
|
||||
<p><code>tar</code>是用来打包和解压用的。之所以叫作<code>tar</code>是有一些历史原因:<code>t</code>代表<code>tape</code>(磁带);<code>ar</code>是 archive(档案)。因为早期的存储介质很小,人们习惯把文件打包然后存储到磁带上,那时候<code>unix</code>用的命令就是<code>tar</code>。因为<code>linux</code>是个开源生态,所以就沿袭下来继续使用<code>tar</code>。</p>
|
||||
<p><code>-x</code>代表 extract(提取)。-z代表<code>gzip</code>,也就是解压<code>gz</code>类型的文件。<code>-v</code>代表 verbose(显示细节),如果你不输入<code>-v</code>,就不会打印解压过程了。<code>-f</code>代表 file,这里指的是要操作文件,而不是磁带。 所以<code>tar</code>解压通常带有<code>x</code>和<code>f</code>,打包通常是<code>c</code>就是 create 的意思。</p>
|
||||
<ul>
|
||||
<li>第三步:配置和解决依赖。解压完,我们进入<code>nginx</code>的目录看一看。 如下图所示:</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl99kaWALMdgAAD3nrZGCkk000.png" alt="Drawing 11.png" /></p>
|
||||
<p><img src="assets/CgqCHl99kaWALMdgAAD3nrZGCkk000.png" alt="png" /></p>
|
||||
<p>可以看到一个叫作<code>configure</code>的文件是绿色的,也就是可执行文件。然后我们执行 configure 文件进行配置,这个配置文件来自一款叫作<code>autoconf</code>的工具,也是 GNU 项目下的,说白了就是<code>bash</code>(Bourne Shell)下的安装打包工具(就是个安装程序)。这个安装程序支持很多配置,你可以用<code>./configure --help</code>看到所有的配置项,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F99kayAZu1TAAJeaol9wiw800.png" alt="Drawing 12.png" /></p>
|
||||
<p><img src="assets/Ciqc1F99kayAZu1TAAJeaol9wiw800.png" alt="png" /></p>
|
||||
<p>这里有几个非常重要的配置项,叫作<code>prefix</code>。<code>prefix</code>配置项决定了软件的安装目录。如果不配置这个配置项,就会使用默认的安装目录。<code>sbin-path</code>决定了<code>nginx</code>的可执行文件的位置。<code>conf-path</code>决定了<code>nginx</code>配置文件的位置。我们都使用默认,然后执行<code>./configure</code>,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F99kbKAYqiXAAEc3ZFDVtE635.png" alt="Drawing 13.png" /></p>
|
||||
<p><img src="assets/Ciqc1F99kbKAYqiXAAEc3ZFDVtE635.png" alt="png" /></p>
|
||||
<p><code>autoconf</code>进行依赖检查的时候,报了一个错误,cc 没有找到。这是因为机器上没有安装<code>gcc</code>工具,gcc 是家喻户晓的工具套件,全名是 GNU Compiler Collection——里面涵盖了包括 c/c++ 在内的多门语言的编译器。</p>
|
||||
<p>我们用包管理器,安装<code>gcc</code>,如下图所示。安装<code>gcc</code>通常是安装<code>build-essential</code>这个包。</p>
|
||||
<p><img src="assets/CgqCHl99kbqAG6m9AARoq2Xsv_8899.png" alt="Drawing 14.png" /></p>
|
||||
<p><img src="assets/CgqCHl99kbqAG6m9AARoq2Xsv_8899.png" alt="png" /></p>
|
||||
<p>安装完成之后,再执行<code>./configure</code>,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl99kcOAAUTtAAS2nlzDoGk494.png" alt="Drawing 15.png" /></p>
|
||||
<p><img src="assets/CgqCHl99kcOAAUTtAAS2nlzDoGk494.png" alt="png" /></p>
|
||||
<p>我们看到配置程序开始执行。但是最终报了一个错误,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl99kcqAGqIuAAHKhlCMtYs244.png" alt="Drawing 16.png" /></p>
|
||||
<p><img src="assets/CgqCHl99kcqAGqIuAAHKhlCMtYs244.png" alt="png" /></p>
|
||||
<p>报错的内容是,<code>nginx</code>的<code>HTTP rewrite</code>模块,需要<code>PCRE</code>库。 PCRE 是<code>perl</code>语言的兼容正则表达式库。<code>perl</code>语言一直以支持原生正则表达式,而受到广大编程爱好者的喜爱。我曾经看到过一个 IBM 的朋友用<code>perl</code>加上<code>wget</code>就实现了一个简单的爬虫。接下来,我们开始安装<code>PCRE</code>。</p>
|
||||
<p>一般这种依赖库,会叫<code>pcre-dev</code>或者<code>libpcre</code>。用<code>apt</code>查询了一下,然后<code>grep</code>。</p>
|
||||
<p><img src="assets/CgqCHl99kdKATX0xAAgMkowaX1E974.png" alt="Drawing 17.png" /></p>
|
||||
<p><img src="assets/CgqCHl99kdKATX0xAAgMkowaX1E974.png" alt="png" /></p>
|
||||
<p>我们看到有<code>pcre2</code>也有<code>pcre3</code>。这个时候可以考虑试试<code>pcre3</code>。</p>
|
||||
<p><img src="assets/CgqCHl99kdqACqo1AAfnaBqjC1Y752.png" alt="Drawing 18.png" /></p>
|
||||
<p><img src="assets/CgqCHl99kdqACqo1AAfnaBqjC1Y752.png" alt="png" /></p>
|
||||
<p>安装完成之后再试试<code>./configure</code>,提示还需要<code>zlib</code>。然后我们用类似的方法解决<code>zlib</code>依赖。</p>
|
||||
<p><img src="assets/CgqCHl99keKACHklAAVMkWAY8Es203.png" alt="Drawing 19.png" /></p>
|
||||
<p><img src="assets/CgqCHl99keKACHklAAVMkWAY8Es203.png" alt="png" /></p>
|
||||
<p><code>zlib</code>包的名字叫<code>zlib1g</code>不太好找,需要查资料才能确定是这个名字。</p>
|
||||
<p>我们再尝试配置,终于配置成功了。</p>
|
||||
<p><img src="assets/Ciqc1F99ke2AFl_pAAcxoAUgdw0867.png" alt="Drawing 20.png" /></p>
|
||||
<p><img src="assets/Ciqc1F99ke2AFl_pAAcxoAUgdw0867.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li>第四步:编译和安装。</li>
|
||||
</ul>
|
||||
<p>通常配置完之后,我们输入<code>make && sudo make install</code>进行编译和安装。<code>make</code>是<code>linux</code>下面一个强大的构建工具。<code>autoconf</code>也就是<code>./configure</code>会在当前目录下生成一个 MakeFile 文件。<code>make</code>会根据<code>MakeFile</code>文件编译整个项目。编译完成后,能够形成和当前操作系统以及 CPU 指令集兼容的二进制可执行文件。然后再用<code>make install</code>安装。<code>&&</code>符号代表执行完<code>make</code>再去执行<code>make installl</code>。</p>
|
||||
<p><img src="assets/Ciqc1F99kfaAFXguAAr_SGo4e8E213.png" alt="Drawing 21.png" /></p>
|
||||
<p><img src="assets/Ciqc1F99kfaAFXguAAr_SGo4e8E213.png" alt="png" /></p>
|
||||
<p>你可以看到编译是个非常慢的活。等待了差不多 1 分钟,终于结束了。<code>nginx</code>被安装到了<code>/usr/local/nginx</code>中,如果需要让<code>nginx</code>全局执行,可以设置一个软连接到<code>/usr/local/bin</code>,具体如下:</p>
|
||||
<pre><code>ln -sf /usr/local/nginx/sbin/nginx /usr/local/sbin/nginx
|
||||
</code></pre>
|
||||
|
||||
@@ -249,16 +249,16 @@ function hide_canvas() {
|
||||
<p>本课时将用到一个大概有 5W 多条记录的<code>nginx</code>日志文件,你可以在<a href="https://github.com/ramroll/lagou-os/blob/main/access.log"> GitHub</a>上下载。 下面就请你和我一起,通过分析这个<code>nginx</code>日志文件,去锤炼我们的手艺。</p>
|
||||
<h3>第一步:能不能这样做?</h3>
|
||||
<p>当我们想要分析一个线上文件的时候,首先要思考,能不能这样做? 这里你可以先用<code>htop</code>指令看一下当前的负载。如果你的机器上没有<code>htop</code>,可以考虑用<code>yum</code>或者<code>apt</code>去安装。</p>
|
||||
<p><img src="assets/CgqCHl-BkJ6AcP32AAduMy8fcSw412.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl-BkJ6AcP32AAduMy8fcSw412.png" alt="png" /></p>
|
||||
<p>如上图所示,我的机器上 8 个 CPU 都是 0 负载,<code>2G</code>的内存用了一半多,还有富余。 我们用<code>wget</code>将目标文件下载到本地(如果你没有 wget,可以用<code>yum</code>或者<code>apt</code>安装)。</p>
|
||||
<pre><code>wget 某网址(自己替代)
|
||||
</code></pre>
|
||||
<p>然后我们用<code>ls</code>查看文件大小。发现这只是一个 7M 的文件,因此对线上的影响可以忽略不计。如果文件太大,建议你用<code>scp</code>指令将文件拷贝到闲置服务器再分析。下图中我使用了<code>--block-size</code>让<code>ls</code>以<code>M</code>为单位显示文件大小。</p>
|
||||
<p><img src="assets/Ciqc1F-BkKeAQDs9AACqJbZ2jCM025.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-BkKeAQDs9AACqJbZ2jCM025.png" alt="png" /></p>
|
||||
<p>确定了当前机器的<code>CPU</code>和内存允许我进行分析后,我们就可以开始第二步操作了。</p>
|
||||
<h3>第二步:LESS 日志文件</h3>
|
||||
<p>在分析日志前,给你提个醒,记得要<code>less</code>一下,看看日志里面的内容。之前我们说过,尽量使用<code>less</code>这种不需要读取全部文件的指令,因为在线上执行<code>cat</code>是一件非常危险的事情,这可能导致线上服务器资源不足。</p>
|
||||
<p><img src="assets/CgqCHl-BkK6AcDGvAAjaPXe-Nbc605.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl-BkK6AcDGvAAjaPXe-Nbc605.png" alt="png" /></p>
|
||||
<p>如上图所示,我们看到<code>nginx</code>的<code>access_log</code>每一行都是一次用户的访问,从左到右依次是:</p>
|
||||
<ul>
|
||||
<li>IP 地址;</li>
|
||||
@@ -268,22 +268,22 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<h3>第三步:PV 分析</h3>
|
||||
<p>PV(Page View),用户每访问一个页面就是一次<code>Page View</code>。对于<code>nginx</code>的<code>acess_log</code>来说,分析 PV 非常简单,我们直接使用<code>wc -l</code>就可以看到整体的<code>PV</code>。</p>
|
||||
<p><img src="assets/Ciqc1F-BkL6AGiY-AABQPMnGu40979.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-BkL6AGiY-AABQPMnGu40979.png" alt="png" /></p>
|
||||
<p>如上图所示:我们看到了一共有 51462 条 PV。</p>
|
||||
<h3>第四步:PV 分组</h3>
|
||||
<p>通常一个日志中可能有几天的 PV,为了得到更加直观的数据,有时候需要按天进行分组。为了简化这个问题,我们先来看看日志中都有哪些天的日志。</p>
|
||||
<p>使用<code>awk '{print $4}' access.log | less</code>可以看到如下结果。<code>awk</code>是一个处理文本的领域专有语言。这里就牵扯到领域专有语言这个概念,英文是Domain Specific Language。领域专有语言,就是为了处理某个领域专门设计的语言。比如awk是用来分析处理文本的DSL,html是专门用来描述网页的DSL,SQL是专门用来查询数据的DSL……大家还可以根据自己的业务设计某种针对业务的DSL。</p>
|
||||
<p>你可以看到我们用<code>$4</code>代表文本的第 4 列,也就是时间所在的这一列,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-BkMaAb421AAGUr-N08hM187.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHl-BkMaAb421AAGUr-N08hM187.png" alt="png" /></p>
|
||||
<p>我们想要按天统计,可以利用 <code>awk</code>提供的字符串截取的能力。</p>
|
||||
<p><img src="assets/CgqCHl-BkMuAKo9UAAIcPR902XQ858.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/CgqCHl-BkMuAKo9UAAIcPR902XQ858.png" alt="png" /></p>
|
||||
<p>上图中,我们使用<code>awk</code>的<code>substr</code>函数,数字<code>2</code>代表从第 2 个字符开始,数字<code>11</code>代表截取 11 个字符。</p>
|
||||
<p>接下来我们就可以分组统计每天的日志条数了。</p>
|
||||
<p><img src="assets/CgqCHl-BkNGAB-VgAASNmct9nQA628.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/CgqCHl-BkNGAB-VgAASNmct9nQA628.png" alt="png" /></p>
|
||||
<p>上图中,使用<code>sort</code>进行排序,然后使用<code>uniq -c</code>进行统计。你可以看到从 2015 年 5 月 17 号一直到 6 月 4 号的日志,还可以看到每天的 PV 量大概是在 2000~3000 之间。</p>
|
||||
<h3>第五步:分析 UV</h3>
|
||||
<p>接下来我们分析 UV。UV(Uniq Visitor),也就是统计访问人数。通常确定用户的身份是一个复杂的事情,但是我们可以用 IP 访问来近似统计 UV。</p>
|
||||
<p><img src="assets/Ciqc1F-BkNeAam2YAACxCjlKsvc488.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-BkNeAam2YAACxCjlKsvc488.png" alt="png" /></p>
|
||||
<p>上图中,我们使用 awk 去打印<code>$1</code>也就是第一列,接着<code>sort</code>排序,然后用<code>uniq</code>去重,最后用<code>wc -l</code>查看条数。 这样我们就知道日志文件中一共有<code>2660</code>个 IP,也就是<code>2660</code>个 UV。</p>
|
||||
<h3>第六步:分组分析 UV</h3>
|
||||
<p>接下来我们尝试按天分组分析每天的 UV 情况。这个情况比较复杂,需要较多的指令,我们先创建一个叫作<code>sum.sh</code>的<code>bash</code>脚本文件,写入如下内容:</p>
|
||||
@@ -303,7 +303,7 @@ awk '{print substr($4, 2, 11) " " $1}' access.log |\
|
||||
<p>为了理解最后这一行描述,我们先来简单了解下<code>awk</code>的原理。</p>
|
||||
<p><code>awk</code>本身是逐行进行处理的。因此我们的<code>next</code>关键字是提醒<code>awk</code>跳转到下一行输入。 对每一行输入,<code>awk</code>会根据第 1 列的字符串(也就是日期)进行累加。之后的<code>END</code>关键字代表一个触发器,就是 END 后面用 {} 括起来的语句会在所有输入都处理完之后执行——当所有输入都执行完,结果被累加到<code>uv</code>中后,通过<code>foreach</code>遍历<code>uv</code>中所有的<code>key</code>,去打印<code>ip</code>和<code>ip</code>对应的数量。</p>
|
||||
<p>编写完上面的脚本之后,我们保存退出编辑器。接着执行<code>chmod +x ./sum.sh</code>,给<code>sum.sh</code>增加执行权限。然后我们可以像下图这样执行,获得结果:</p>
|
||||
<p><img src="assets/CgqCHl-BkOKAfpNwAAOFk0EhDjU183.png" alt="Drawing 8.png" /></p>
|
||||
<p><img src="assets/CgqCHl-BkOKAfpNwAAOFk0EhDjU183.png" alt="png" /></p>
|
||||
<p>如上图,<code>IP</code>地址已经按天进行统计好了。</p>
|
||||
<h3>总结</h3>
|
||||
<p>今天我们结合一个简单的实战场景——Web 日志分析与统计练习了之前学过的指令,提高熟练程度。此外,我们还一起学习了新知识——功能强大的<code>awk</code>文本处理语言。在实战中,我们对一个<code>nginx</code>的<code>access_log</code>进行了简单的数据分析,直观地获得了这个网站的访问情况。</p>
|
||||
|
||||
@@ -255,7 +255,7 @@ function hide_canvas() {
|
||||
<h3>第二步:循环遍历 IP 列表</h3>
|
||||
<p>你可以想象一个局域网中有很多服务器需要管理,它们彼此之间网络互通,我们通过一台主服务器对它们进行操作,即通过<code>u1</code>操作<code>v1</code>和<code>v2</code>。</p>
|
||||
<p>在主服务器上我们维护一个<code>ip</code>地址的列表,保存成一个文件,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-GsciASqucAACaCl1bXF4240.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl-GsciASqucAACaCl1bXF4240.png" alt="png" /></p>
|
||||
<p>目前<code>iplist</code>中只有两项,但是如果我们有足够的机器,可以在里面放成百上千项。接下来,请你思考<code>shell</code>如何遍历这些<code>ip</code>?</p>
|
||||
<p>你可以先尝试实现一个最简单的程序,从文件<code>iplist</code>中读出这些<code>ip</code>并尝试用<code>for</code>循环遍历这些<code>ip</code>,具体程序如下:</p>
|
||||
<pre><code>#!/usr/bin/bash
|
||||
@@ -269,20 +269,20 @@ done
|
||||
<p><code>readarray</code>指令将 iplist 文件中的每一行读取到变量<code>ips</code>中。<code>ips</code>是一个数组,可以用<code>echo ${ips[@]}</code>打印其中全部的内容:<code>@</code>代表取数组中的全部内容;<code>$</code>符号是一个求值符号。不带<code>$</code>的话,<code>ips[@]</code>会被认为是一个字符串,而不是表达式。</p>
|
||||
<p><code>for</code>循环遍历数组中的每个<code>ip</code>地址,<code>echo</code>把地址打印到屏幕上。</p>
|
||||
<p>如果用<code>shell</code>执行上面的程序会报错,因为<code>readarray</code>是<code>bash 4.0</code>后支持的能力,因此我们用<code>chomd</code>为<code>foreach.sh</code>增加执行权限,然后直接利用<code>shebang</code>的能力用<code>bash</code>执行,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-GsdSAZPtIAAF5yL5VkdQ049.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-GsdSAZPtIAAF5yL5VkdQ049.png" alt="png" /></p>
|
||||
<h3>第三步:创建集群管理账户</h3>
|
||||
<p>为了方便集群管理,通常使用统一的用户名管理集群。这个账号在所有的集群中都需要保持命名一致。比如这个集群账号的名字就叫作<code>lagou</code>。</p>
|
||||
<p>接下来我们探索一下如何创建这个账户<code>lagou</code>,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-GsdqAc2khAALNpLTWENc494.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl-GsdqAc2khAALNpLTWENc494.png" alt="png" /></p>
|
||||
<p>上面我们创建了<code>lagou</code>账号,然后把<code>lagou</code>加入<code>sudo</code>分组。这样<code>lagou</code>就有了<code>sudo</code>成为<code>root</code>的能力,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-GseCAYss5AAB9-SYXFJU693.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-GseCAYss5AAB9-SYXFJU693.png" alt="png" /></p>
|
||||
<p>接下来,我们设置<code>lagou</code>用户的初始化<code>shell</code>是<code>bash</code>,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-GsiyAGKitAACU_gkGZRI467.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHl-GsiyAGKitAACU_gkGZRI467.png" alt="png" /></p>
|
||||
<p>这个时候如果使用命令<code>su lagou</code>,可以切换到<code>lagou</code>账号,但是你会发现命令行没有了颜色。因此我们可以将原来用户下面的<code>.bashrc</code>文件拷贝到<code>/home/lagou</code>目录下,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-GsjeAL_RwAAEyx32py80146.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-GsjeAL_RwAAEyx32py80146.png" alt="png" /></p>
|
||||
<p>这样,我们就把一些自己平时用的设置拷贝了过去,包括终端颜色的设置。<code>.bashrc</code>是启动<code>bash</code>的时候会默认执行的一个脚本文件。</p>
|
||||
<p>接下来,我们编辑一下<code>/etc/sudoers</code>文件,增加一行<code>lagou ALL=(ALL) NOPASSWD:ALL</code>表示<code>lagou</code>账号 sudo 时可以免去密码输入环节,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-Gsj6AQBXeAAEW0V065r0519.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/CgqCHl-Gsj6AQBXeAAEW0V065r0519.png" alt="png" /></p>
|
||||
<p>我们可以把上面的完整过程整理成指令文件,<code>create_lagou.sh</code>:</p>
|
||||
<pre><code>sudo useradd -m -d /home/lagou lagou
|
||||
sudo passwd lagou
|
||||
@@ -309,10 +309,10 @@ done
|
||||
<p>接下来我们需要打通从主服务器到<code>v1</code>和<code>v2</code>的权限。当然也可以每次都用<code>ssh</code>输入用户名密码的方式登录,但这并不是长久之计。 如果我们有成百上千台服务器,输入用户名密码就成为一件繁重的工作。</p>
|
||||
<p>这时候,你可以考虑利用主服务器的公钥在各个服务器间登录,避免输入密码。接下来我们聊聊具体的操作步骤:</p>
|
||||
<p>首先,需要在<code>u1</code>上用<code>ssh-keygen</code>生成一个公私钥对,然后把公钥写入需要管理的每一台机器的<code>authorized_keys</code>文件中。如下图所示:我们使用<code>ssh-keygen</code>在主服务器<code>u1</code>中生成公私钥对。</p>
|
||||
<p><img src="assets/CgqCHl-GslSAAUT5AATF-5rjGWU079.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/CgqCHl-GslSAAUT5AATF-5rjGWU079.png" alt="png" /></p>
|
||||
<p>然后使用<code>mkdir -p</code>创建<code>~/.ssh</code>目录,<code>-p</code>的优势是当目录不存在时,才需要创建,且不会报错。<code>~</code>代表当前家目录。 如果文件和目录名前面带有一个<code>.</code>,就代表该文件或目录是一个需要隐藏的文件。平时用<code>ls</code>的时候,并不会查看到该文件,通常这种文件拥有特别的含义,比如<code>~/.ssh</code>目录下是对<code>ssh</code>的配置。</p>
|
||||
<p>我们用<code>cd</code>切换到<code>.ssh</code>目录,然后执行<code>ssh-keygen</code>。这样会在<code>~/.ssh</code>目录中生成两个文件,<code>id_rsa.pub</code>公钥文件和<code>is_rsa</code>私钥文件。 如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-GsluAWyS-AAayQyKs6NY181.png" alt="Drawing 8.png" /></p>
|
||||
<p><img src="assets/CgqCHl-GsluAWyS-AAayQyKs6NY181.png" alt="png" /></p>
|
||||
<p>可以看到<code>id_rsa.pub</code>文件中是加密的字符串,我们可以把这些字符串拷贝到其他机器对应用户的<code>~/.ssh/authorized_keys</code>文件中,当<code>ssh</code>登录其他机器的时候,就不用重新输入密码了。 这个传播公钥的能力,可以用一个<code>shell</code>脚本执行,这里我用<code>transfer_key.sh</code>实现。</p>
|
||||
<p>我们修改一下<code>foreach.sh</code>,并写一个<code>transfer_key.sh</code>配合<code>foreach.sh</code>的工作。<code>transfer_key.sh</code>内容如下:</p>
|
||||
<p><em>foreach.sh</em></p>
|
||||
@@ -337,9 +337,9 @@ chmod 600 ~/.ssh/authorized_keys
|
||||
<p>在<code>foreach.sh</code>中我们执行 transfer_key.sh,并且将 IP 地址通过参数传递过去。在 transfer_key.sh 中,用<code>$1</code>读出 IP 地址参数, 再将公钥写入变量<code>pubkey</code>,然后登录到对应的服务器,执行多行指令。用<code>mkdir</code>指令检查<code>.ssh</code>目录,如不存在就创建这个目录。最后我们将公钥追加写入目标机器的<code>~/.ssh/authorized_keys</code>中。</p>
|
||||
<p><code>chmod 700</code>和<code>chmod 600</code>是因为某些特定的<code>linux</code>版本需要<code>.ssh</code>的目录为可读写执行,<code>authorized_keys</code>文件的权限为只可读写。而为了保证安全性,组用户、所有用户都不可以访问这个文件。</p>
|
||||
<p>此前,我们执行<code>foreach.sh</code>需要输入两次密码。完成上述操作后,我们再登录这两台服务器就不需要输入密码了。</p>
|
||||
<p><img src="assets/CgqCHl-GsnuAC-lYAAb76OR4cFs817.png" alt="Drawing 9.png" /></p>
|
||||
<p><img src="assets/CgqCHl-GsnuAC-lYAAb76OR4cFs817.png" alt="png" /></p>
|
||||
<p>接下来,我们尝试一下免密登录,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-GsoGANiKlAAIjYZ8fscs878.png" alt="Drawing 10.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-GsoGANiKlAAIjYZ8fscs878.png" alt="png" /></p>
|
||||
<p>可以发现,我们登录任何一台机器,都不再需要输入用户名和密码了。</p>
|
||||
<h3>第五步:单机安装 Java 环境</h3>
|
||||
<p>在远程部署 Java 环境之前,我们先单机完成以下 Java 环境的安装,用来收集需要执行的脚本。</p>
|
||||
@@ -351,21 +351,21 @@ chmod 600 ~/.ssh/authorized_keys
|
||||
<pre><code>which java
|
||||
java --version
|
||||
</code></pre>
|
||||
<p><img src="assets/Ciqc1F-GspCAJ0r9AAJx-kzES1k505.png" alt="Drawing 11.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-GspCAJ0r9AAJx-kzES1k505.png" alt="png" /></p>
|
||||
<p>根据最小权限原则,执行 Java 程序我们考虑再创建一个用户<code>ujava</code>。</p>
|
||||
<pre><code>sudo useradd -m -d /opt/ujava ujava
|
||||
sudo usermod --shell /bin/bash lagou
|
||||
</code></pre>
|
||||
<p>这个用户可以不设置密码,因为我们不会真的登录到这个用户下去做任何事情。接下来我们为用户配置 Java 环境变量,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-GsqWAa2e2AAJosZCNXpU388.png" alt="Drawing 12.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-GsqWAa2e2AAJosZCNXpU388.png" alt="png" /></p>
|
||||
<p>通过两次 ls 追查,可以发现<code>java</code>可执行文件软连接到<code>/etc/alternatives/java</code>然后再次软连接到<code>/usr/lib/jvm/java-11-openjdk-amd64</code>下。</p>
|
||||
<p>这样我们就可以通过下面的语句设置 JAVA_HOME 环境变量了。</p>
|
||||
<pre><code>export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64/
|
||||
</code></pre>
|
||||
<p>Linux 的环境变量就好比全局可见的数据,这里我们使用 export 设置<code>JAVA_HOME</code>环境变量的指向。如果你想看所有的环境变量的指向,可以使用<code>env</code>指令。</p>
|
||||
<p><img src="assets/CgqCHl-GsrGAMIfNAAW55Kdz1xc547.png" alt="Drawing 13.png" /></p>
|
||||
<p><img src="assets/CgqCHl-GsrGAMIfNAAW55Kdz1xc547.png" alt="png" /></p>
|
||||
<p>其中有一个环境变量比较重要,就是<code>PATH</code>。</p>
|
||||
<p><img src="assets/Ciqc1F-GsriACI2JAAEtgeamQNI945.png" alt="Drawing 14.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-GsriACI2JAAEtgeamQNI945.png" alt="png" /></p>
|
||||
<p>如上图,我们可以使用<code>shell</code>查看<code>PATH</code>的值,<code>PATH</code>中用<code>:</code>分割,每一个目录都是<code>linux</code>查找执行文件的目录。当用户在命令行输入一个命令,Linux 就会在<code>PATH</code>中寻找对应的执行文件。</p>
|
||||
<p>当然我们不希望<code>JAVA_HOME</code>配置后重启一次电脑就消失,因此可以把这个环境变量加入<code>ujava</code>用户的<code>profile</code>中。这样只要发生用户登录,就有这个环境变量。</p>
|
||||
<pre><code>sudo sh -c 'echo "export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64/" >> /opt/ujava/.bash_profile'
|
||||
@@ -396,7 +396,7 @@ done
|
||||
sudo -u ujava -i java --version
|
||||
</code></pre>
|
||||
<p><code>check.sh</code>中我们切换到<code>ujava</code>用户去检查<code>JAVA_HOME</code>环境变量和 Java 版本。执行的结果如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-GstWAFW9yAAQXx_nh6dw719.png" alt="Drawing 15.png" /></p>
|
||||
<p><img src="assets/CgqCHl-GstWAFW9yAAQXx_nh6dw719.png" alt="png" /></p>
|
||||
<h3>总结</h3>
|
||||
<p>这节课我们所讲的场景是自动化运维的一些皮毛。通过这样的场景练习,我们复习了很多之前学过的 Linux 指令。在尝试用脚本文件构建一个又一个小工具的过程中,可以发现复用很重要。</p>
|
||||
<p>在工作中,优秀的工程师,总是善于积累和复用,而<code>shell</code>脚本就是积累和复用的利器。如果你第一次安装<code>java</code>环境,可以把今天的安装脚本保存在自己的笔记本中,下次再安装就能自动化完成了。除了积累和总结,另一个非常重要的就是你要尝试自己去查资料,包括使用<code>man</code>工具熟悉各种指令的使用方法,用搜索引擎查阅资料等。</p>
|
||||
|
||||
@@ -280,9 +280,9 @@ cat < pipe2 > pipe1
|
||||
<h4>08 | 用户和权限管理指令: 请简述 Linux 权限划分的原则?</h4>
|
||||
<p><strong>【问题】</strong> 如果一个目录是只读权限,那么这个目录下面的文件还可写吗?</p>
|
||||
<p><strong>【解析】</strong> 这类问题,你一定要去尝试,观察现象再得到结果。</p>
|
||||
<p><img src="assets/Ciqc1F-JYOSAEeZOAAK-jHkfQpk505.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-JYOSAEeZOAAK-jHkfQpk505.png" alt="png" /></p>
|
||||
<p>你可以看到上图中,foo 目录不可读了,下面的<code>foo/bar</code>文件还可以写。 即便它不可写了,下面的<code>foo/bar</code>文件还是可以写。</p>
|
||||
<p><img src="assets/Ciqc1F-JYOuACHgqAADld0-OED0560.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-JYOuACHgqAADld0-OED0560.png" alt="png" /></p>
|
||||
<p>但是想要创建新文件就会出现报错,因为创建新文件也需要改目录文件。这个例子说明 Linux 中的文件内容并没有存在目录中,目录中却有文件清单。</p>
|
||||
<h4>09 | Linux 中的网络指令:如何查看一个域名有哪些 NS 记录?</h4>
|
||||
<p><strong>【问题】</strong> 如何查看正在 TIME_WAIT 状态的连接数量?</p>
|
||||
@@ -292,33 +292,33 @@ cat < pipe2 > pipe1
|
||||
<h4>10 | 软件的安装: 编译安装和包管理器安装有什么优势和劣势?</h4>
|
||||
<p><strong>【问题】</strong> 如果你在编译安装 MySQL 时,发现找不到libcrypt.so ,应该如何处理?</p>
|
||||
<p><strong>【解析】</strong> 遇到这类问题,首先应该去查资料。 比如查 StackOverflow,搜索关键词:libcrypt.so not found,或者带上自己的操作系统<code>ubuntu</code>。下图是关于 Stackoverflow 的一个解答:</p>
|
||||
<p><img src="assets/Ciqc1F-JYUSACvI4AABGKWEIwZc693.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-JYUSACvI4AABGKWEIwZc693.png" alt="png" /></p>
|
||||
<p>在这里我再多说两句,程序员成长最需要的是学习时间,如果在这前面加一个形容词,那就是大量的学习时间;而程序员最需要掌握的技能就是搜索和学习知识的能力。如果你看到今天的这篇内容,说明已经学完了《重学操作系统》专栏两个模块的知识,希望你可以坚持下去!</p>
|
||||
<h4>11 | 高级技巧之日志分析:利用 Linux 指令分析 Web 日志</h4>
|
||||
<p><strong>【问题 1 】</strong> 根据今天的 access_log 分析出有哪些终端访问了这个网站,并给出分组统计结果。</p>
|
||||
<p><strong>【解析】</strong><code>access_log</code>中有<code>Debian</code>和<code>Ubuntu</code>等等。我们可以利用下面的指令看到,第 12 列是终端,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-JYVKAeXxWAAFX4ed-XgU367.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-JYVKAeXxWAAFX4ed-XgU367.png" alt="png" /></p>
|
||||
<p>我们还可以使用<code>sort</code>和<code>uniq</code>查看有哪些终端,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-JYVqABf8YAAJ8F9oyYEk538.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-JYVqABf8YAAJ8F9oyYEk538.png" alt="png" /></p>
|
||||
<p>最后需要写一个脚本,进行统计:</p>
|
||||
<pre><code>cat nginx_logs.txt |\
|
||||
awk '{tms[$12]++;next}END{for (t in tms) print t, tms[t]}'
|
||||
</code></pre>
|
||||
<p>结果如下:</p>
|
||||
<p><img src="assets/CgqCHl-JYWCAQ5S7AALOO3VxYyE532.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/CgqCHl-JYWCAQ5S7AALOO3VxYyE532.png" alt="png" /></p>
|
||||
<p><strong>【问题 2】</strong> 根据今天的 access_log 分析出访问量 Top 前三的网页。</p>
|
||||
<p>如果不需要 Substring 等复杂的处理,也可以使用<code>sort</code>和<code>uniq</code>的组合。如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-JYWmASpWzAAHX7u4P8x4076.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/CgqCHl-JYWmASpWzAAHX7u4P8x4076.png" alt="png" /></p>
|
||||
<h4>12 | 高级技巧之集群部署:利用 Linux 指令同时在多台机器部署程序</h4>
|
||||
<p><strong>【问题】</strong>~/.bashrc ~/.bash_profile, ~/.profile 和 /etc/profile 的区别是什么?</p>
|
||||
<p><strong>【解析】</strong> 执行一个 shell 的时候分成<strong>login shell</strong>和<strong>non-login shell</strong>。顾名思义我们使用了<code>sudo``su</code>切换到某个用户身份执行 shell,也就是<code>login shell</code>。还有 ssh 远程执行指令也是 login shell,也就是伴随登录的意思——<code>login shell</code> 会触发很多文件执行,路径如下:</p>
|
||||
<p><img src="assets/CgqCHl-M_a2AB4DCAABaALYsBvA370.png" alt="Lark20201019-104257.png" /></p>
|
||||
<p><img src="assets/CgqCHl-M_a2AB4DCAABaALYsBvA370.png" alt="png" /></p>
|
||||
<p>如果以当前用户身份正常执行一个 shell,比如说<code>./a.sh</code>,就是一个<code>non-login</code>的模式。 这时候不会触发上述的完整逻辑。</p>
|
||||
<p>另外shell还有另一种分法,就是<code>interactive</code>和<code>non-interactive</code>。interactive 是交互式的意思,当用户打开一个终端命令行工具后,会进入一个输入命令得到结果的交互界面,这个时候,就是<code>interactive shell</code>。</p>
|
||||
<p><code>baserc</code>文件通常只在<code>interactive</code>模式下才会执行,这是因为<code>~/.bashrc</code>文件中通常有这样的语句,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl-JYZmAU3eiAADOD88ztPA917.png" alt="Drawing 7.png" /></p>
|
||||
<p><img src="assets/CgqCHl-JYZmAU3eiAADOD88ztPA917.png" alt="png" /></p>
|
||||
<p>这个语句通过<code>$-</code>看到当前<code>shell</code>的执行环境,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F-JYZ-AKItgAABi7Cu95fc751.png" alt="Drawing 8.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-JYZ-AKItgAABi7Cu95fc751.png" alt="png" /></p>
|
||||
<p>带 i 字符的就是<code>interactive</code>,没有带i字符就不是。</p>
|
||||
<p>因此, 如果你需要通过 ssh 远程 shell 执行一个文件,你就不是在 interactive 模式下,bashrc 不会触发。但是因为登录的原因,login shell 都会触发,也就是说 profile 文件依然会执行。</p>
|
||||
<h3>总结</h3>
|
||||
|
||||
@@ -259,12 +259,12 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<h4>操作系统分层</h4>
|
||||
<p>从上面 4 种能力来看操作系统和内核之间的关系,通常可以把操作系统分成 3 层,最底层的硬件设备抽象、中间的内核和最上层的应用。</p>
|
||||
<p><img src="assets/CgqCHl-P5meAd3VdAAB1f7DWz-I273.png" alt="Lark20201021-153830.png" /></p>
|
||||
<p><img src="assets/CgqCHl-P5meAd3VdAAB1f7DWz-I273.png" alt="png" /></p>
|
||||
<h4>内核是如何工作的?</h4>
|
||||
<p><strong>为了帮助你理解什么是内核,请你先思考一个问题:进程和内核的关系,是不是像浏览器请求服务端服务</strong>?你可以先自己思考,然后在留言区写下你此时此刻对这个问题的认知,等学完“模块三”再反过头来回顾这个知识,相信你定会产生新的理解。</p>
|
||||
<p>接下来,我们先一起分析一下这个问题。</p>
|
||||
<p>内核权限非常高,它可以管理进程、可以直接访问所有的内存,因此确实需要和进程之间有一定的隔离。这个隔离用类似请求/响应的模型,非常符合常理。</p>
|
||||
<p><img src="assets/CgqCHl-P5naAc5fsAABuTlhIQkw555.png" alt="Lark20201021-153825.png" /></p>
|
||||
<p><img src="assets/CgqCHl-P5naAc5fsAABuTlhIQkw555.png" alt="png" /></p>
|
||||
<p>但不同的是在浏览器、服务端模型中,浏览器和服务端是用不同的机器在执行,因此不需要共享一个 CPU。但是在进程调用内核的过程中,这里是存在资源共享的。</p>
|
||||
<ul>
|
||||
<li>比如,一个机器有 4 个 CPU,不可能让内核用一个 CPU,其他进程用剩下的 CPU。这样太浪费资源了。</li>
|
||||
@@ -283,14 +283,14 @@ function hide_canvas() {
|
||||
<ul>
|
||||
<li><strong>ELF(Executable and Linkable Format)</strong></li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F-P5pOAeET-AAEzXOQTzbA445.png" alt="Lark20201021-153821.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-P5pOAeET-AAEzXOQTzbA445.png" alt="png" /></p>
|
||||
<p>这个名词翻译过来叫作可执行文件链接格式。这是一种从 Unix 继承而来的可执行文件的存储格式。我们可以看到 ELF 中把文件分成了一个个分段(Segment),每个段都有自己的作用。如果想要深入了解这块知识,会涉及部分编译原理的知识,如果你感兴趣可以去网上多查些资料或者去留言区我们一起讨论。</p>
|
||||
<ul>
|
||||
<li><strong>Monolithic Kernel</strong></li>
|
||||
</ul>
|
||||
<p>这个名词翻译过来就是宏内核,宏内核反义词就是 Microkernel ,微内核的意思。Linux 是宏内核架构,这说明 Linux 的内核是一个完整的可执行程序,且内核用最高权限来运行。宏内核的特点就是有很多程序会打包在内核中,比如,文件系统、驱动、内存管理等。当然这并不是说,每次安装驱动都需要重新编译内核,现在 Linux 也可以动态加载内核模块。所以哪些模块在内核层,哪些模块在用户层,这是一种系统层的拆分,并不是很强的物理隔离。</p>
|
||||
<p>与宏内核对应,接下来说说<strong>微内核,内核只保留最基本的能力。比如进程调度、虚拟内存、中断。多数应用,甚至包括驱动程序、文件系统,是在用户空间管理的</strong>。</p>
|
||||
<p><img src="assets/CgqCHl-QEKSAYD22AAFXRfj1rsA581.png" alt="Lark20201021-183457.png" /></p>
|
||||
<p><img src="assets/CgqCHl-QEKSAYD22AAFXRfj1rsA581.png" alt="png" /></p>
|
||||
<p>学到这里,你可能会问:在内核层和在用户层有什么区别吗?</p>
|
||||
<p>感觉分层其实差不多。 我这里说一个很大的区别,比如说驱动程序是需要频繁调用底层能力的,如果在内核中,性能肯定会好很多。对于微内核设计,驱动在内核外,驱动和硬件设备交互就需要频繁做内核态的切换。</p>
|
||||
<p>当然微内核也有它的好处,比如说微内核体积更小、可移植性更强。不过我认为,随着计算能力、存储技术越来越发达,体积小、安装快已经不能算是一个很大的优势了。现在更重要的是如何有效利用硬件设备的性能。</p>
|
||||
@@ -300,11 +300,11 @@ function hide_canvas() {
|
||||
<h3>Window 设计</h3>
|
||||
<p>接下来我们说说 Windows 的设计,Windows 和 Linux 的设计有很大程度的相似性。Windows也有内核,它的内核是 C/C++ 写的。准确地说,Windows 有两个内核版本。一个是早期的Windows 9x 内核,早期的 Win95, Win98 都是这个内核。我们今天用的 Windows 7, Windows 10 是另一个内核,叫作 Windows NT。NT 指的是 New Technology。接下来我们讨论的都是 NT 版本的内核。</p>
|
||||
<p>下面我找到一张 Windows 内核架构的图片给你一个直观感受。</p>
|
||||
<p><img src="assets/Ciqc1F-P5suAH9CJAAFl4zKFbJc816.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-P5suAH9CJAAFl4zKFbJc816.png" alt="png" /></p>
|
||||
<p>Windows 同样支持 Multitask 和 SMP(对称多处理)。Windows 的内核设计属于混合类型。你可以看到内核中有一个 Microkernel 模块。而整个内核实现又像宏内核一样,含有的能力非常多,是一个完整的整体。</p>
|
||||
<p>Windows 下也有自己的可执行文件格式,这个格式叫作 Portable Executable(PE),也就是可移植执行文件,扩展名通常是<code>.exe</code>、<code>.dll</code>、<code>.sys</code>等。</p>
|
||||
<p>PE 文件的结构和 ELF 结构有很多相通的地方,我找到了一张图片帮助你更直观地理解。 因为这部分知识涉及编译原理,我这里就不详细介绍了,感兴趣同学可以在留言区和大家一起讨论,或者查阅更多资料。</p>
|
||||
<p><img src="assets/CgqCHl-P5ySAAg5CAACF0kTmx_k209.png" alt="Lark20201021-153828.png" /></p>
|
||||
<p><img src="assets/CgqCHl-P5ySAAg5CAACF0kTmx_k209.png" alt="png" /></p>
|
||||
<p>Windows 还有很多独特的能力,比如 Hyper-V 虚拟化技术,有关虚拟化技术我们将在“模块八:虚拟化和其他”中详细讲解。</p>
|
||||
<h3>总结</h3>
|
||||
<p>这一讲我们学习了内核的基础知识,包括内核的作用、整体架构以及 3 种内核类型(宏内核、微内核和混合类型内核)。内核很小(微内核)方便移植,因为体积小、安装快;内核大(宏内核),方便优化性能,毕竟内核更了解计算机中的资源。我们还学习了操作系统对执行文件的抽象,但是没有很深入讨论,内核部分有很多知识是需要在后面的几个模块中体现的,比如进程、文件、内存相关的能力等。</p>
|
||||
|
||||
@@ -264,7 +264,7 @@ function hide_canvas() {
|
||||
<p>用户空间中的代码被限制了只能使用一个局部的内存空间,我们说这些程序在<strong>用户态(User Mode)</strong> 执行。内核空间中的代码可以访问所有内存,我们称这些程序在<strong>内核态(Kernal Mode)</strong> 执行。</p>
|
||||
<h4>系统调用过程</h4>
|
||||
<p>如果用户态程序需要执行系统调用,就需要切换到内核态执行。下面我们来讲讲这个过程的原理。</p>
|
||||
<p><img src="assets/CgqCHl-Sm3mAG_x-AAC5MxhOcCc621.png" alt="Lark20201023-165439.png" /></p>
|
||||
<p><img src="assets/CgqCHl-Sm3mAG_x-AAC5MxhOcCc621.png" alt="png" /></p>
|
||||
<p>如上图所示:内核程序执行在内核态(Kernal Mode),用户程序执行在用户态(User Mode)。当发生系统调用时,用户态的程序发起系统调用。因为系统调用中牵扯特权指令,用户态程序权限不足,因此会中断执行,也就是 Trap(Trap 是一种中断)。</p>
|
||||
<p>发生中断后,当前 CPU 执行的程序会中断,跳转到中断处理程序。内核程序开始执行,也就是开始处理系统调用。内核处理完成后,主动触发 Trap,这样会再次发生中断,切换回用户态工作。关于中断,我们将在“<strong>15 课时</strong>”进行详细讨论。</p>
|
||||
<h3>线程模型</h3>
|
||||
@@ -275,7 +275,7 @@ function hide_canvas() {
|
||||
<p><strong>那么用户态进程如果要执行程序,是否也要向内核申请呢</strong>?</p>
|
||||
<p>程序在现代操作系统中并不是以进程为单位在执行,而是以一种轻量级进程(Light Weighted Process),也称作线程(Thread)的形式执行。</p>
|
||||
<p>一个进程可以拥有多个线程。进程创建的时候,一般会有一个主线程随着进程创建而创建。</p>
|
||||
<p><img src="assets/Ciqc1F-SmgGAJVo6AAFL0OwiOWE251.png" alt="2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-SmgGAJVo6AAFL0OwiOWE251.png" alt="png" /></p>
|
||||
<p>如果进程想要创造更多的线程,就需要思考一件事情,这个线程创建在用户态还是内核态。</p>
|
||||
<p>你可能会问,难道不是用户态的进程创建用户态的线程,内核态的进程创建内核态的线程吗?</p>
|
||||
<p>其实不是,进程可以通过 API 创建用户态的线程,也可以通过系统调用创建内核态的线程,接下来我们说说用户态的线程和内核态的线程。</p>
|
||||
@@ -315,16 +315,16 @@ function hide_canvas() {
|
||||
<h4>多对一(Many to One)</h4>
|
||||
<p>用户态进程中的多线程复用一个内核态线程。这样,极大地减少了创建内核态线程的成本,但是线程不可以并发。因此,这种模型现在基本上用的很少。我再多说一句,这里你可能会有疑问,比如:用户态线程怎么用内核态线程执行程序?</p>
|
||||
<p>程序是存储在内存中的指令,用户态线程是可以准备好程序让内核态线程执行的。后面的几种方式也是利用这样的方法。</p>
|
||||
<p><img src="assets/CgqCHl-SmhGAfpLmAAD_dFRlK_o009.png" alt="4.png" /></p>
|
||||
<p><img src="assets/CgqCHl-SmhGAfpLmAAD_dFRlK_o009.png" alt="png" /></p>
|
||||
<h4>一对一(One to One)</h4>
|
||||
<p>该模型为每个用户态的线程分配一个单独的内核态线程,在这种情况下,每个用户态都需要通过系统调用创建一个绑定的内核线程,并附加在上面执行。 这种模型允许所有线程并发执行,能够充分利用多核优势,Windows NT 内核采取的就是这种模型。但是因为线程较多,对内核调度的压力会明显增加。</p>
|
||||
<p><img src="assets/CgqCHl-SmhyAF5x4AADdzPHEVjg818.png" alt="5.png" /></p>
|
||||
<p><img src="assets/CgqCHl-SmhyAF5x4AADdzPHEVjg818.png" alt="png" /></p>
|
||||
<h4>多对多(Many To Many)</h4>
|
||||
<p>这种模式下会为 n 个用户态线程分配 m 个内核态线程。m 通常可以小于 n。一种可行的策略是将 m 设置为核数。这种多对多的关系,减少了内核线程,同时也保证了多核心并发。Linux 目前采用的就是该模型。</p>
|
||||
<p><img src="assets/CgqCHl-Smj2AUNBFAAEUlu4ZjIY978.png" alt="6.png" /></p>
|
||||
<p><img src="assets/CgqCHl-Smj2AUNBFAAEUlu4ZjIY978.png" alt="png" /></p>
|
||||
<h4>两层设计(Two Level)</h4>
|
||||
<p>这种模型混合了多对多和一对一的特点。多数用户态线程和内核线程是 n 对 m 的关系,少量用户线程可以指定成 1 对 1 的关系。</p>
|
||||
<p><img src="assets/Ciqc1F-SmieAL_v4AAFMiFmCAbM160.png" alt="1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-SmieAL_v4AAFMiFmCAbM160.png" alt="png" /></p>
|
||||
<p>上图所展现的是一个非常经典的设计。</p>
|
||||
<p>我们这节课讲解的问题、考虑到的情况以及解决方法,将为你今后解决实际工作场景中的问题打下坚实的基础。比如处理并发问题、I/O 性能瓶颈、思考数据库连接池的配置等,要想完美地解决问题,就必须掌握这些模型,了解问题的本质上才能更好地思考问题衍生出来的问题。</p>
|
||||
<h3>总结</h3>
|
||||
|
||||
@@ -278,9 +278,9 @@ function hide_canvas() {
|
||||
<p>那么谁能随时随地中断操作系统的程序? 谁有这个权限?是管理员账号吗? 当然不是,拥有这么高权限的应该是机器本身。</p>
|
||||
<p>我们思考下这个模型,用户每次按键,触发一个 CPU 的能力,这个能力会中断正在执行的程序,去处理按键。那 CPU 内部是不是应该有处理按键的程序呢?这肯定不行,因为我们希望 CPU 就是用来做计算的,如果 CPU 内部有自带的程序,会把问题复杂化。这在软件设计中,叫作耦合。CPU 的工作就是专注高效的执行指令。</p>
|
||||
<p>因此,每次按键,必须有一个机制通知 CPU。我们可以考虑用总线去通知 CPU,也就是主板在通知 CPU。</p>
|
||||
<p><img src="assets/CgqCHl-ZTb2AThFmAACeKCyumpw628.png" alt="Lark20201028-185329.png" /></p>
|
||||
<p><img src="assets/CgqCHl-ZTb2AThFmAACeKCyumpw628.png" alt="png" /></p>
|
||||
<p>那么 CPU 接收到通知后,如何通知操作系统呢?CPU 只能中断正在执行的程序,然后切换到另一个需要执行的程序。说白了就是改变 PC 指针,CPU 只有这一种办法切换执行的程序。这里请你思考,是不是只有这一种方法:CPU 中断当前执行的程序,然后去执行另一个程序,才能改变 PC 指针?</p>
|
||||
<p><img src="assets/CgqCHl-ZTcaAUGEwAAB9llc1vwo219.png" alt="Lark20201028-185317.png" /></p>
|
||||
<p><img src="assets/CgqCHl-ZTcaAUGEwAAB9llc1vwo219.png" alt="png" /></p>
|
||||
<p>接下来我们进一步思考,CPU 怎么知道 PC 指针应该设置为多少呢?是不是 CPU 知道操作系统响应按键的程序位置呢?</p>
|
||||
<p>答案当然是不知道。</p>
|
||||
<p>因此,我们只能控制 CPU 跳转到一个固定的位置。比如说 CPU 一收到主板的信息(某个按键被触发),CPU 就马上中断当前执行的程序,将 PC 指针设置为 0。也就是 PC 指针下一步会从内存地址 0 中读取下一条指令。当然这只是我们的一个思路,具体还需要进一步考虑。而操作系统要做的就是在这之前往内存地址 0 中写一条指令,比如说让 PC 指针跳转到自己处理按键程序的位置。</p>
|
||||
@@ -293,13 +293,13 @@ function hide_canvas() {
|
||||
<p>通过对以上 7 个问题的思考和分析,我们已经有了一个粗浅的设计,接下来就要开始整理思路了。</p>
|
||||
<h3>思路的整理:中断的设计</h3>
|
||||
<p>整体设计分成了 3 层,第一层是硬件设计、第二层是操作系统设计、第三层是程序语言的设计。</p>
|
||||
<p><img src="assets/Ciqc1F-ZTdKAJxaRAABvzPwzEgU406.png" alt="Lark20201028-185322.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-ZTdKAJxaRAABvzPwzEgU406.png" alt="png" /></p>
|
||||
<p>按键码的收集,是键盘芯片和主板的能力。主板知道有新的按键后,通知 CPU,CPU 要中断当前执行的程序,将 PC 指针跳转到一个固定的位置,我们称为一次<strong>中断</strong>(<strong>interrupt</strong>)。</p>
|
||||
<p>考虑到系统中会出现各种各样的事件,我们需要根据中断类型来判断PC 指针跳转的位置,中断类型不同,PC 指针跳转的位置也可能会不同。比如按键程序、打印机就绪程序、系统异常等都需要中断,包括在“<strong>14 课时</strong>”我们学习的系统调用,也需要中断正在执行的程序,切换到内核态执行内核程序。</p>
|
||||
<p>因此我们需要把不同的中断类型进行分类,这个类型叫作<strong>中断识别码</strong>。比如按键,我们可以考虑用编号 16,数字 16 就是按键中断类型的识别码。不同类型的中断发生时,CPU 需要知道 PC 指针该跳转到哪个地址,这个地址,称为<strong>中断向量(Interupt Vector)。</strong></p>
|
||||
<p>你可以考虑这样的实现:当编号 16 的中断发生时,32 位机器的 PC 指针直接跳转到内存地址 16*4 的内存位置。如果设计最多有 255 个中断,编号就是从 0~255,刚好需要 1K 的内存地址存储中断向量——这个 1K 的空间,称为<strong>中断向量表</strong>。</p>
|
||||
<p>因此 CPU 接收到中断后,CPU 根据中断类型操作 PC 指针,找到中断向量。操作系统必须在这之前,修改中断向量,插入一条指令。比如操作系统在这里写一条<code>Jump</code>指令,将 PC 指针再次跳转到自己处理对应中断类型的程序。</p>
|
||||
<p><img src="assets/CgqCHl-ZTd6AeCy8AACahXIwrgA950.png" alt="Lark20201028-185324.png" /></p>
|
||||
<p><img src="assets/CgqCHl-ZTd6AeCy8AACahXIwrgA950.png" alt="png" /></p>
|
||||
<p>操作系统接管之后,以按键程序为例,操作系统会进行一些处理,包括下面的几件事情:</p>
|
||||
<ol>
|
||||
<li>将按键放入一个队列,保存下来。这是因为,操作系统不能保证及时处理所有的按键,比如当按键过快时,需要先存储下来,再分时慢慢处理。</li>
|
||||
|
||||
@@ -245,7 +245,7 @@ function hide_canvas() {
|
||||
<p id="tip" align="center"></p>
|
||||
<div><h1>16 WinMacUnixLinux 的区别和联系:为什么 Debian 漏洞排名第一还这么多人用?</h1>
|
||||
<p>在我的印象中 Windows 才是最容易被攻击的操作系统,没想到 2020 年美国 NIST 的报告中, Debian 竟然是过去 20 年中漏洞最多的操作系统。Debain 以 3067 个漏洞稳居第一,第二名是 Android,第三名是 Linux Kernel。那么为什么 Debian 漏洞数会排在第一位呢?</p>
|
||||
<p><img src="assets/Ciqc1F-bn1-AS5raAAS__DN2p5g400.png" alt="sm6HBMt28BODYgyh__thumbnail.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-bn1-AS5raAAS__DN2p5g400.png" alt="png" /></p>
|
||||
<p>NIST的数据报告:软件漏洞排名</p>
|
||||
<p>今天我们就以这个问题为引,带你了解更多的操作系统。这就要追溯到 20 世纪操作系统蓬勃发展的年代。那是一个惊艳绝伦的时代,一个个天才黑客,一场场激烈的商战,一次次震撼的产品发布会——每个人都想改变世界,都在积极的抓住时机,把握时代赋予的机会。我们今天的工程师文化——一种最纯粹的、崇尚知识,崇尚创造的文化,也是传承于此。</p>
|
||||
<p>本课时作为内核部分的最后一课,我会带你了解一些操作系统的历史,希望通过这种方式,把这种文化传承下去,让你更有信心去挑战未来的变化。当然,你也可以把本课时当作一个选学的内容,不会影响你继续学习我后面的课程。</p>
|
||||
@@ -257,15 +257,15 @@ function hide_canvas() {
|
||||
<p>所以 IBM 真正开始做计算机是 1949 年小沃森逐渐掌权后。1954 年,IBM 推出了世界上第一个拥有操作系统的商用计算机——IBM 704,并且在 1956 年时独占了计算机市场的 70% 的份额。</p>
|
||||
<p><strong>你可能会问,之前的计算机没有操作系统吗</strong>?</p>
|
||||
<p>我以第一台可编程通用计算机 ENIAC 为例,ENIAC 虽然支持循环、分支判断语句,但是只支持写机器语言。ENIAC 的程序通常需要先写在纸上,然后再由专业的工程师输入到计算机中。 对于 ENIAC 来说执行的是一个个作业,就是每次把输入的程序执行完。</p>
|
||||
<p><img src="assets/Ciqc1F-bn9eAXyISAAPLfwdfvrE593.png" alt="Fm6eVsIl2e6E6btZ__thumbnail.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-bn9eAXyISAAPLfwdfvrE593.png" alt="png" /></p>
|
||||
<p>上图中的画面正是一位程序员通过操作面板在写程序。 那个时候写程序就是接线和使用操作面板开关,和今天我们所说的“写程序”还是有很大区别的。</p>
|
||||
<p>所以在 IBM 704 之前,除了实验室产品外,正式投入使用的计算机都是没有操作系统的。但当时 IBM 704 的操作系统是美国通用移动公司帮助研发的 GM-NAA I/O 系统,而非 IBM 自研。IBM 一直没有重视操作系统的研发能力,这也为后来 IBM 使用微软的操作系统,以及进军个人 电脑 市场的失败埋下了伏笔。</p>
|
||||
<h3>大型机操作系统</h3>
|
||||
<p>1975 年前,还没有个人电脑,主要是银行、政府、保险公司这些企业在购买计算机。因为比较强调数据吞吐量,也就是单位时间能够处理的业务数量,因此计算机也被称作大型机。</p>
|
||||
<p>早期的大型机厂商往往会为每个大型机写一个操作系统。后来 1964 年 IBM 自研了 OS/360 操作系统,在这个操作系统之上 IBM 推出了 System/360 大型机,然后在 1965~1978 年间,IBM 以 System/360 的代号陆陆续续推出了多款机器。开发 System/360 大型机的过程也被称为 IBM 的一次世纪豪赌,雇用了 6W 员工,新建了 5 个工厂。这么大力度的投资背后是小沃森的支持,几乎是把 IBM 的家底掏空转型去做计算机了。 IBM 这家公司喜欢押注,而且一次比一次大——2019 年 IBM 以 340 亿美金收购红帽,可能是 IBM 想在云计算和操作系统市场发力。</p>
|
||||
<p><img src="assets/CgqCHl-bn_aAAPzxAAIv-JIly3Q368.png" alt="F8YIaQiv1EKO9qtX__thumbnail.png" /></p>
|
||||
<p><img src="assets/CgqCHl-bn_aAAPzxAAIv-JIly3Q368.png" alt="png" /></p>
|
||||
<p>IBM 投入了大量人力物力在 System/360 上,也推进了 OS/360 的开发。当时 IBM 还自研了磁盘技术,IBM 自己叫作 DASD(Direct access storage devices)。</p>
|
||||
<p><img src="assets/Ciqc1F-boAeAGCmkAAOXtV5e6Kk533.png" alt="ko2gUuSIc2bozIJ3__thumbnail.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-boAeAGCmkAAOXtV5e6Kk533.png" alt="png" /></p>
|
||||
<p>从上图中你可以看到,IBM 自研的磁盘,非常类似今天硬盘的结构的。当时支持磁盘的操作系统往往叫作 DOS(Disk Operating System)。还有一些是支持磁带的操作系统,叫作 TOS(Tape Operating System)。所以 OS/360 早期叫作 BOS/360,就是 Basic Operating System,后来分成了 DOS/360 和 TOS/360。现在我们不再根据硬件的不同来区分系统了,而是通过驱动程序驱动硬件工作,对硬件的支持更像是插件一样。</p>
|
||||
<p>为了支持大型机的工作,IBM 在1957 年还推出了 Fortran(Formula Translation)语言。这是一门非常适合数值计算的语言,目的是更好地支持业务逻辑处理。计算机、语言、操作系统,这应该是早期计算机的三要素。把这三个环节做好,就能占领市场。</p>
|
||||
<p>那个时代的操作系统是作业式的,相当于处理一个个任务,核心是一个任务的调度器。它会先一个任务处理,完成后再处理另一个任务,当时 IBM 还没有想过要开发分时操作系统,也就是多个任务轮流调度的模型。直到 Unix 系统的前身 Multics 出现,IBM 为了应对时代变化推出了 TSS/360(T 代表 Time Sharing)。</p>
|
||||
@@ -282,27 +282,27 @@ function hide_canvas() {
|
||||
<li>...</li>
|
||||
</ul>
|
||||
<p>后来 IBM 逐渐对 Multics 引起了重视, 推出 TSS/360 系统,这只是做出防御性部署的一个举措。但是同在贝尔实验室 Multics 项目组的丹尼斯·里奇(C 语言的作者)和肯·汤普逊却看到了希望。他们都是 30 岁不到,正是意气风发的时候。两个人对程序设计、操作系统都有着浓厚的兴趣,特别是肯·汤普逊,之前已经做过大量的操作系统开发,还写过游戏,他们都觉得 Multics 设计太过于复杂了。再加上 Multics 没取得商业成功,贝尔实验室叫停了这个项目后,两个人就开始合作写 Unix。Unix 这个名字一方面参考 Multics,另一方面参考了 Uniplexed,它是 Multiplexed 的反义词,含义有点像统一和简化。</p>
|
||||
<p><img src="assets/CgqCHl-boDWAWq5VAAFpIdJc_T0867.png" alt="WiPI95BWeW02HNk8__thumbnail.png" /></p>
|
||||
<p><img src="assets/CgqCHl-boDWAWq5VAAFpIdJc_T0867.png" alt="png" /></p>
|
||||
<p>Unix 早期开放了源代码,可以说是现代操作系统的奠基之作——支持多任务、多用户,还支持分级安全策略。拥有内核、内存管理、文件系统、正则表达式、开发工具、可执行文件格式、命令行工具等等。<strong>可以说,到今天 Unix 不再代表某种操作系统,而是一套统一的,大家都认可的架构标准</strong>。</p>
|
||||
<p>因为开源的原因,Unix 的版本非常复杂。具体你可以看下面这张大图。</p>
|
||||
<p><img src="assets/CgqCHl-boE-AKrskAAOSZ46MgxM476.png" alt="zHoi2igGQMDahWvh__thumbnail.png" /></p>
|
||||
<p><img src="assets/CgqCHl-boE-AKrskAAOSZ46MgxM476.png" alt="png" /></p>
|
||||
<p>绿色的是开源版本,黄色的是混合版本,红色的是闭源版本。这里面有大型机使用的版本,有给工作站使用的版本,也有个人电脑版本。比如 Mac OS、SunOS、Solaris 都有用于个人电脑和工作站;HP-UX 还用作过大型机操作系统。另外,Linux 系统虽然不是 Unix,但是参考了 Unix 的设计,并且遵照 Unix 的规范,它从 Unix 中继承过去不少好用的工具,这种我们称为 Unix-like 操作系统。</p>
|
||||
<h3>个人电脑革命</h3>
|
||||
<p>从大型机兴起后,就陆续有人开始做个人电脑。但是第一台真正火了的个人电脑,是 1975 年 MITS 公司推出的 Altair 8800。</p>
|
||||
<p><img src="assets/Ciqc1F-boGqAHx-SAAYue5wu2tA081.png" alt="c7rFnIITUd4IoFsP__thumbnail.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-boGqAHx-SAAYue5wu2tA081.png" alt="png" /></p>
|
||||
<p>里面有套餐可选,套餐价是 $439。MITS 的创始人 ED Roberts,和投资人承诺可以卖出去 800 台,没想到第一个月就卖出了 1000 台。对于一台没有显示器、没有键盘,硬件是组装的也不是自有品牌的电脑,它的购买者更多的是个人电脑爱好者们。用户可以通过上面的开关进行编程,然后执行简单的程序,通过观察信号灯看到输出。所以,市场对个人电脑的需求,是普遍存在的,哪怕是好奇心,大家也愿意为之买单。比尔·盖茨也买了这台机器,我们后面再说。</p>
|
||||
<p>Altair 8800 出品半年后,做个人电脑的公司就如雨后春笋一样出现了。IBM 当然也嗅到了商机。</p>
|
||||
<p>1976 年 21 岁的乔布斯在一次聚会中说服了 26 岁的沃兹尼亚克一起设计 Apple I 电脑。 沃兹尼亚克大二的时候,做过一台组装电脑,在这次聚会上,他的梦想被乔布斯点燃了,当晚就做了 Apple I 的设计图。1976 年 6 月份,Apple I 电脑就生产出了 200 台,最终卖出去 20 多台。 当时 Apple I 只提供一块板,不提供键盘、显示器等设备。这样的电脑竟然有销量,在今天仍然是不可想象的。</p>
|
||||
<p><img src="assets/CgqCHl-boIKAMVzSAALtwnEkw-w387.png" alt="KC2Kv4gSGBr9JLVT__thumbnail.png" /></p>
|
||||
<p><img src="assets/CgqCHl-boIKAMVzSAALtwnEkw-w387.png" alt="png" /></p>
|
||||
<p>Apple I 在商业上的发展不太成功,但是 1977 年,乔布斯又说服了投资人,投资生产 Apple II。结果当年就让乔布斯身价上百万,两年后就让他身价过亿。</p>
|
||||
<p><img src="assets/Ciqc1F-boJiALGjlAAF0EX3mI_E161.png" alt="nWYA0SyTHnn7jRAC__thumbnail.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-boJiALGjlAAF0EX3mI_E161.png" alt="png" /></p>
|
||||
<p>你可以看到 Apple II 就已经是一个完整的机器了。一开始 Apple II 是苹果自研的操作系统,并带有沃兹尼亚克写的简单的 BASIC 语言解释器。1978 年 Apple 公司花了 13000 美金采购了一家小公司的操作系统,这家小公司负责给苹果开发系统,也就是后来的 Apple DOS 操作系统。这家公司还为 Apple DOS 增加了文件浏览器。</p>
|
||||
<p>1980s 初, 蓝巨人 IBM 感受到了来自 Apple 的压力。如果个人市场完全被抢占,这对于一家专做商业系统的巨头影响会非常大。因此 IBM 成立了一个特别行动小组,代号 Project Chess,目标就是一年要做出一台能够上市的 PC。但是这次 IBM 没有豪赌,只是组织了一个 150 人的团队。因此,他们决定从硬件到软件都使用其他厂商的,当时的说法叫作开放平台。</p>
|
||||
<p>IBM 没有个人电脑上可用的操作系统,因此找到了当时一家做操作系统和个人电脑的厂商,Digital Research 公司。Digital Research 的 CP/M 操作系统已经受到了市场的认可,但是这家公司的创始人竟然拒绝了蓝巨人的提议,态度也不是很友好。这导致 Digital Research 直接错过了登顶的机会。蓝巨人无奈之下,就找到了只有 22 岁的比尔·盖茨。</p>
|
||||
<p>盖茨 22 岁的时候和好朋友艾伦创了微软公司。他其实也购买了 Altair 8800(就是本课时前面我们提到的第一台卖火的机器),但是他们目的是和 Altair 的制造商 MITS 公司搞好关系。最终盖茨成功说服了 MITS 公司雇佣艾伦,在 Altair 中提供 BASIC 解释器。BASIC 这门语言 1964 年就存在了,但是盖茨和艾伦是第一个把它迁移到 PC 领域的。IBM 看上了盖茨的团队,加上 Digital Research 拒绝了自己,有点生气,就找到了盖茨。</p>
|
||||
<p>盖茨非常重视这次机会。但是这里有个问题,微软当时手上是没有操作系统的,他们连夜搞定了一个方案,就是去购买另一家公司的 86-DOS 操作系统,然后承诺 IBM 自己团队负责修改和维护。微软花了 50000 美金买了 86-DOS 的使用权,允许修改和再发布。然后微软再将 86-DOS 授权给 IBM。这里面有非常多有趣的故事,如果你感兴趣可以去查资料了解更多的内容。</p>
|
||||
<p>最后,Project Chess 小组在 1 年内,成功完成了使命,做出了 IBM 个人电脑,看上去非常像 APPLE II。名字就叫 Personal Computer, 就是我们今天说的 PC。86-DOS 也改成了 PC DOS,IBM 的加入又给 PC 市场带了一波节奏,让更多的人了解到了个人电脑。</p>
|
||||
<p><img src="assets/CgqCHl-boLSAYZf9AAHQwVvmQAk488.png" alt="xT4EqQcel0sJAGDa__thumbnail.png" /></p>
|
||||
<p><img src="assets/CgqCHl-boLSAYZf9AAHQwVvmQAk488.png" alt="png" /></p>
|
||||
<p>微软也跟着水涨船高,每销售 1 台 PC,微软虽然拿不到利润,但保留了 PC DOS 的版权。而且拿到 IBM 的合同,为 IBM 开发核心系统,这也使得微软的地位大涨。盖茨相信马上就会有其他厂商开始和 IBM 竞争,会需要 PC DOS,而微软只需要专心做好操作系统就足够了。</p>
|
||||
<p>其实没有用多久, 1982 年康柏公司花了几个月时间,雇用了 100 多个工程师,逆向工程了 IBM PC,然后就推出了兼容 IBM PC 的电脑,价格稍微便宜一点。然后整个产业沸腾了,各种各样的商家都进来逆向 IBM PC。整个产业陷入了价格战,每过半年人们可以花更少的钱,拿到配置更高的机器。这个时候微软就在背后卖操作系统,也就是 PC DOS 的保真版,MS-DOS。直到 10 年后,微软正式和 IBM 决裂。</p>
|
||||
<p>微软第一个视窗操作系统是 1985 年,然后又被 IBM 要求开发它的竞品 OS/2。需要同时推进两个系统,所以微软不是很开心,但是又不能得罪蓝巨人。IBM 也不是很舒服,但是又不得不依赖微软。这个情况一直持续到 1995 年左右,Windows 95 发布的时候,微软还使用 MS-DOS 作为操作系统核心,到了 2001 年 Windows XP 发布的时候,就切换到了 Windows NT 内核。就这样,微软成功发展壮大,并逃离蓝巨人的掌控,成为世界上最大的操作系统公司。</p>
|
||||
@@ -312,7 +312,7 @@ function hide_canvas() {
|
||||
<p>1985 年理查德·斯托曼发布了 GNU 项目,本身 GNU 是一个左递归,就是 GNU = GNU's not Unix。GNU 整体来说还是基于 Unix 生态,但在斯托曼的领导下开发了大量的优质工具,比如 gcc 和 emacs 等。但是斯托曼一直为 GNU 没有自己的操作系统而苦恼。</p>
|
||||
<p>结果 1991 年 GNU 项目迎来了转机,年仅 21 岁的林纳斯·托瓦兹在网络上发布了一个开源的操作系统,就是 Linux。林纳斯的经历和斯托曼有点类似,所以林纳斯会议听斯托曼讲座,让他有种热血沸腾的感觉。林纳斯不满意 MS-DOS 不开源,但是作为学生党,刚刚学完了 Andy 的《操作系统:设计与实现》,本来一开始没有想过要写 Linux。最后是因为 Unix 的商用版本太贵了买不起,才开始写 Linux。</p>
|
||||
<p>斯托曼也觉得 GNU 不能没有操作系统,就统称为 GNU/Linux,并且利用自己的影响力帮助林纳斯推广 Linux。这样就慢慢吸引了世界上一批顶级的黑客,一起来写 Linux。后来 Linux 慢慢成长壮大,成为一块主流的服务器操作系统。当然 Linux 后来也衍生了大量的版本,下图是不同版本的 Linux 的分布。</p>
|
||||
<p><img src="assets/Ciqc1F-boM-AWo1kAABSw_eB0VI629.png" alt="BYJbTW5Cib4ROoRc__thumbnail.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-boM-AWo1kAABSw_eB0VI629.png" alt="png" /></p>
|
||||
<p>数据取自 W3Techs.com 2020</p>
|
||||
<p>Ubuntu 源自 Debian,有着非常漂亮的桌面体验,我就是使用 Ubuntu 开发程序。 Ubuntu 后面有商业公司 Canonical 的支持,也有社区的支持。Centos 源自 Red Hat 公司的企业版 Linux(RHEL),商用版本的各种硬件、软件支持通常会好一些,因此目前国内互联网企业的运维都偏向使用 CentosOS。第三名的 Debian 是 Ubuntu 的源头,是一个完全由自由软件精神驱动的社区产品,提供了大量的自由软件。当然也有人批评 Debian 太过于松散,发行周期太长,漏洞修复周期长等等。</p>
|
||||
<h3>Android</h3>
|
||||
|
||||
@@ -249,7 +249,7 @@ function hide_canvas() {
|
||||
<h4>13 | 操作系统内核:Linux 内核和 Windows 内核有什么区别?</h4>
|
||||
<p><strong>【问题】</strong> Unix 和 Mac OS 内核属于哪种类型?</p>
|
||||
<p><strong>【解析】</strong> Unix 和 Linux 非常类似,也是宏内核。Mac OS 用的是 XNU 内核, XNU 是一种混合型内核。为了帮助你理解,我找了一张 Mac OS 的内核架构图。 如下图所示,可以看到内部是一个叫作 XNU 的宏内核。XNU 是 X is not Unix 的意思, 是一个受 Unix 影响很大的内核。</p>
|
||||
<p><img src="assets/Ciqc1F-iT8KAGRnKAAJ29-TOIo8834.png" alt="Lark20201104-145231.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-iT8KAGRnKAAJ29-TOIo8834.png" alt="png" /></p>
|
||||
<p>Mac OS 内核架构图</p>
|
||||
<h4>14 | 用户态和内核态:用户态线程和内核态线程有什么区别?</h4>
|
||||
<p><strong>【问题】</strong> JVM 的线程是用户态线程还是内核态线程?</p>
|
||||
|
||||
@@ -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 核心数量少(从几个到几十个)、进程&线程数量很多(从几十到几百甚至更多),你可以类比为发动机少,而机器多,因此进程们在操作系统中只能排着队一个个执行。每个进程在执行时都会获得操作系统分配的一个时间片段,如果超出这个时间,就会轮到下一个进程(线程)执行。再强调一下,现代操作系统都是直接调度线程,不会调度进程。</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>
|
||||
|
||||
@@ -264,7 +264,7 @@ function hide_canvas() {
|
||||
<h3>竞争条件</h3>
|
||||
<p>竞争条件就是说多个线程对一个资源(内存地址)的读写存在竞争,在这种条件下,最后这个资源的值不可预测,而是取决于竞争时具体的执行顺序。</p>
|
||||
<p>举个例子,比如两个线程并发执行<code>i++</code>。那么可以有下面这个操作顺序,假设执行前<code>i=0</code>:</p>
|
||||
<p><img src="assets/CgqCHl-lBrSAKBmrAADNiS8bkAY490.png" alt="Lark20201106-161714.png" /></p>
|
||||
<p><img src="assets/CgqCHl-lBrSAKBmrAADNiS8bkAY490.png" alt="png" /></p>
|
||||
<p>虽然上面的程序执行了两次<code>i++</code>,但最终i的值为 1。</p>
|
||||
<p><code>i++</code>这段程序访问了共享资源,也就是变量<code>i</code>,这种访问共享资源的程序片段我们称为<strong>临界区</strong>。在临界区,程序片段会访问共享资源,造成竞争条件,也就是共享资源的值最终取决于程序执行的时序,因此这个值不是确定的。</p>
|
||||
<p>竞争条件是一件非常糟糕的事情,你可以把上面的程序想象成两个自动提款机。如果用户同时操作两个自动提款机,用户的余额就可能会被算错。</p>
|
||||
@@ -292,7 +292,7 @@ cas(&i, i, i+1)
|
||||
cas操作:比较期望值i和i的真实值的值是否相等,如果是,更新目标值
|
||||
</code></pre>
|
||||
<p>假设<code>i=0</code>,考虑两个线程分别执行一次这个程序,尝试构造竞争条件:</p>
|
||||
<p><img src="assets/Ciqc1F-lBr2ATIabAADce4zrAOw887.png" alt="Lark20201106-161708.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-lBr2ATIabAADce4zrAOw887.png" alt="png" /></p>
|
||||
<p>你可以看到通过这种方式,cas 解决了一部分问题,找到了竞争条件,并返回了 false。但是还是无法计算出正确的结果。因为最后一次 cas 失败了。</p>
|
||||
<p>如果要完全解决可以考虑这样去实现:</p>
|
||||
<pre><code>while(!cas(&i, i, i+1)){
|
||||
|
||||
@@ -282,7 +282,7 @@ account=bob, iphone=100
|
||||
…… 以及很多其他的数据
|
||||
</code></pre>
|
||||
<p>我们假设这里的钱可能是 Alice 用某种手段放进来的。或者我们再简化这个模型,比如全世界所有人的钱,都在这个系统里,这样我们就不用关心钱从哪里来这个问题了。如果是比特币,钱是需要挖矿的。</p>
|
||||
<p><img src="assets/Ciqc1F-ryT2AGJM0AAC05iMFOvc116.png" alt="3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-ryT2AGJM0AAC05iMFOvc116.png" alt="png" /></p>
|
||||
<p>如图,这个结构也叫作区块链。每个 Block 下面可以存一些数据,每个 Block 知道上一个节点是谁。每个 Block 有上一个节点的摘要签名。也就是说,如果 Block 10 是 Block 11 的上一个节点,那么 Block 11 会知道 Block 10 的存在,且用 Block 11 中 Block 10 的摘要签名,可以证明 Block 10 的数据没有被篡改过。</p>
|
||||
<p>区块链构成了一个基于历史版本的事实链,前一个版本是后一个版本的历史。Alice 的钱和苹果店的 iPhone 数量,包括全世界所有人的钱,都在这些 Block 里。</p>
|
||||
<p><strong>购买转账的过程</strong></p>
|
||||
@@ -291,7 +291,7 @@ account=bob, iphone=100
|
||||
from=B, to=A, object=iphone, signature=苹果店的签名
|
||||
</code></pre>
|
||||
<p>那么我们可以在末端节点上再增加一个区块,代表这次交易,如下图:</p>
|
||||
<p><img src="assets/Ciqc1F-ryUiAQ5JUAAEC6zaXAKM772.png" alt="4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-ryUiAQ5JUAAEC6zaXAKM772.png" alt="png" /></p>
|
||||
<p>比如,Alice 先在本地完成这件事情,本地的区块链就会像上图那样。 假设有一个中心化的服务器,专门接收这些区块数据,Alice 接下来就可以把数据提交到中心化的服务器,苹果店从中心化服务器上看到这条信息,认为交易被 Alice 执行了,就准备发货。</p>
|
||||
<p>如果世界上有很多人同时在这个末端节点上写新的 Block。那么可以考虑由一个可信任的中心服务帮助合并新增的区块数据。就好像多个人同时编辑了一篇文章,发生了冲突,那就可以考虑由一个人整合大家需要修改和新增的内容,避免同时操作产生混乱。</p>
|
||||
<h4>解决欺诈问题</h4>
|
||||
@@ -310,10 +310,10 @@ from=B1, to=A, object=iphonex2, signature=另一个苹果店的签名
|
||||
<p>所以结论是,区块链一旦写入就不能修改,这样可以防止很多欺诈行为。</p>
|
||||
<h4>解决并发问题</h4>
|
||||
<p>假设全球有几十亿人都在下单。那么每次下单,需要创建新的一个 Block。这种情况,会导致最后面的 Block,开很多分支。</p>
|
||||
<p><img src="assets/Ciqc1F-ryVaAO-KFAADCyXfna24816.png" alt="2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-ryVaAO-KFAADCyXfna24816.png" alt="png" /></p>
|
||||
<p>这个时候你会发现,这里有同步问题对不对? 最傻的方案就是用锁解决,比如用一个集中式的办法,去接收所有的请求,这样就又回到中心化的设计。</p>
|
||||
<p>还有一个高明的办法,就是允许商家开分支。 用户和苹果店订合同,苹果店独立做一个分支,把用户的合同连起来。</p>
|
||||
<p><img src="assets/Ciqc1F-ryV-ATtpAAACJ4ZgkVtU059.png" alt="1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-ryV-ATtpAAACJ4ZgkVtU059.png" alt="png" /></p>
|
||||
<p>这样苹果店自己先维护自己的 Block-Chain,等待合适的时机,再去合并到主分支上。 如果有合同合并不进去,比如余额不足,那再作废这个合同(不发货了)。</p>
|
||||
<p>这里请你思考这样一种处理方式:如果全世界每天有 1000 亿笔订单要处理,那么可以先拆分成 100 个区域,每个区域是 10W 家店。这样最终每家店的平均并发量在 10000 单。 然后可以考虑每过多长时间,比如 10s,进行一次逐级合并。</p>
|
||||
<p>这样,整体每个节点的压力就不是很大了。</p>
|
||||
|
||||
@@ -253,7 +253,7 @@ function hide_canvas() {
|
||||
<p>但是这样对于等待作业的用户来说,是有问题的。比如一笔需要用时 1 天的作业 ,如果等待了 10 分钟,用户是可以接受的;一个用时 10 分钟的作业,用户等待一天就要投诉了。 因此如果用时 1 天的作业先到,用时 10 分钟的任务后到,应该优先处理用时少的,也就是<strong>短作业优先(Shortest Job First,SJF)</strong>。</p>
|
||||
<h3>短作业优先</h3>
|
||||
<p>通常会同时考虑到来顺序和作业预估时间的长短,比如下面的到来顺序和预估时间:</p>
|
||||
<p><img src="assets/Ciqc1F-uUwyAXKj6AABwvcEuVH0735.png" alt="Lark20201113-173325.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-uUwyAXKj6AABwvcEuVH0735.png" alt="png" /></p>
|
||||
<p>这样就会优先考虑第一个到来预估时间为 3 分钟的任务。 我们还可以从另外一个角度来审视短作业优先的优势,就是平均等待时间。</p>
|
||||
<p><strong>平均等待时间 = 总等待时间/任务数</strong></p>
|
||||
<p>上面例子中,如果按照 3,3,10 的顺序处理,平均等待时间是:(0 + 3 + 6) / 3 = 3 分钟。 如果按照 10,3,3 的顺序来处理,就是( 0+10+13 )/ 3 = 7.66 分钟。</p>
|
||||
@@ -274,9 +274,9 @@ function hide_canvas() {
|
||||
<p>为了解决这个问题,我们需要用到<strong>抢占(Preemption)</strong>。</p>
|
||||
<p>抢占就是把执行能力分时,分成时间片段。 让每个任务都执行一个时间片段。如果在时间片段内,任务完成,那么就调度下一个任务。如果任务没有执行完成,则中断任务,让任务重新排队,调度下一个任务。</p>
|
||||
<p>拥有了抢占的能力,再结合之前我们提到的优先级队列能力,这就构成了一个基本的线程调度模型。线程相对于操作系统是排队到来的,操作系统为每个到来的线程分配一个优先级,然后把它们放入一个优先级队列中,优先级最高的线程下一个执行。</p>
|
||||
<p><img src="assets/CgqCHl-uUx2AZFakAACjU3Bi2eE649.png" alt="Lark20201113-173328.png" /></p>
|
||||
<p><img src="assets/CgqCHl-uUx2AZFakAACjU3Bi2eE649.png" alt="png" /></p>
|
||||
<p>每个线程执行一个时间片段,然后每次执行完一个线程就执行一段调度程序。</p>
|
||||
<p><img src="assets/Ciqc1F-uUyaAUVSDAAB3mZmSb3A937.png" alt="Lark20201113-173330.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-uUyaAUVSDAAB3mZmSb3A937.png" alt="png" /></p>
|
||||
<p>图中用红色代表调度程序,其他颜色代表被调度线程的时间片段。调度程序可以考虑实现为一个单线程模型,这样不需要考虑竞争条件。</p>
|
||||
<p>上面这个模型已经是一个非常优秀的方案了,但是还有一些问题可以进一步处理得更好。</p>
|
||||
<ol>
|
||||
@@ -286,14 +286,14 @@ function hide_canvas() {
|
||||
<p>为了解决上面两个问题,我们可以考虑引入多级队列模型。</p>
|
||||
<h3>多级队列模型</h3>
|
||||
<p>多级队列,就是多个队列执行调度。 我们先考虑最简单的两级模型,如图:</p>
|
||||
<p><img src="assets/CgqCHl-uUzCAVhhzAAFSttJfDs4355.png" alt="Lark20201113-173333.png" /></p>
|
||||
<p><img src="assets/CgqCHl-uUzCAVhhzAAFSttJfDs4355.png" alt="png" /></p>
|
||||
<p>上图中设计了两个优先级不同的队列,从下到上优先级上升,上层队列调度紧急任务,下层队列调度普通任务。只要上层队列有任务,下层队列就会让出执行权限。</p>
|
||||
<ul>
|
||||
<li>低优先级队列可以考虑抢占 + 优先级队列的方式实现,这样每次执行一个时间片段就可以判断一下高优先级的队列中是否有任务。</li>
|
||||
<li>高优先级队列可以考虑用非抢占(每个任务执行完才执行下一个)+ 优先级队列实现,这样紧急任务优先级有个区分。如果遇到十万火急的情况,就可以优先处理这个任务。</li>
|
||||
</ul>
|
||||
<p>上面这个模型虽然解决了任务间的优先级问题,但是还是没有解决短任务先行的问题。可以考虑再增加一些队列,让级别更多。比如下图这个模型:</p>
|
||||
<p><img src="assets/Ciqc1F-uUzqAMYY-AADMHX-2Dso456.png" alt="Lark20201113-173318.png" /></p>
|
||||
<p><img src="assets/Ciqc1F-uUzqAMYY-AADMHX-2Dso456.png" alt="png" /></p>
|
||||
<p>紧急任务仍然走高优队列,非抢占执行。普通任务先放到优先级仅次于高优任务的队列中,并且只分配很小的时间片;如果没有执行完成,说明任务不是很短,就将任务下调一层。下面一层,最低优先级的队列中时间片很大,长任务就有更大的时间片可以用。通过这种方式,短任务会在更高优先级的队列中执行完成,长任务优先级会下调,也就类似实现了最短作业优先的问题。</p>
|
||||
<p>实际操作中,可以有 n 层,一层层把大任务筛选出来。 最长的任务,放到最闲的时间去执行。要知道,大部分时间 CPU 不是满负荷的。</p>
|
||||
<h3>总结</h3>
|
||||
|
||||
@@ -250,7 +250,7 @@ function hide_canvas() {
|
||||
<p>要学习这部分知识有一个非常不错的模型,就是哲学家就餐问题。1965 年,计算机科学家 Dijkstra 为了帮助学生更好地学习并发编程设计的一道练习题,后来逐渐成为大家广泛讨论的问题。</p>
|
||||
<h3>哲学家就餐问题</h3>
|
||||
<p>问题描述如下:有 5 个哲学家,围着一个圆桌就餐。圆桌上有 5 份意大利面和 5 份叉子。哲学家比较笨,他们必须拿到左手和右手的 2 个叉子才能吃面。哲学不饿的时候就在思考,饿了就去吃面,吃面的必须前提是拿到 2 个叉子,吃完面哲学家就去思考。</p>
|
||||
<p><img src="assets/CgqCHl-04I2AWTRGAABMYcirc5o121.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl-04I2AWTRGAABMYcirc5o121.png" alt="png" /></p>
|
||||
<p>假设每个哲学家用一个线程实现,求一种并发控制的算法,让哲学家们按部就班地思考和吃面。当然我这里做了一些改动,比如 Dijkstra 那个年代线程还没有普及,最早的题目每个哲学家是一个进程。</p>
|
||||
<h3>问题的抽象</h3>
|
||||
<p>接下来请你继续思考,我们对问题进行一些抽象,比如哲学是一个数组,编号 0~4。我这里用 Java 语言给你演示,哲学家是一个类,代码如下:</p>
|
||||
@@ -362,7 +362,7 @@ void _take(id){
|
||||
<li>第 4 个哲学家获得叉子 3,接下来请求叉子 4。</li>
|
||||
</ul>
|
||||
<p>为了帮助你理解,这里我画了一幅图。</p>
|
||||
<p><img src="assets/CgqCHl-1AGWABRYZAACklm4__ZQ120.png" alt="11111.png" /></p>
|
||||
<p><img src="assets/CgqCHl-1AGWABRYZAACklm4__ZQ120.png" alt="png" /></p>
|
||||
<p>如上图所示,可以看到这是一种循环依赖的关系,在这种情况下所有哲学家都获得了一个叉子,并且在等待下一个叉子。这种等待永远不会结束,因为没有哲学家愿意放弃自己拿起的叉子。</p>
|
||||
<p>以上这种情况称为**死锁(Deadlock),<strong>这是一种</strong>饥饿(Starvation)**的形式。从概念上说,死锁是线程间互相等待资源,但是没有一个线程可以进行下一步操作。饥饿就是因为某种原因导致线程得不到需要的资源,无法继续工作。死锁是饥饿的一种形式,因为循环等待无法得到资源。哲学家就餐问题,会形成一种环状的死锁(循环依赖), 因此非常具有代表性。</p>
|
||||
<p>死锁有 4 个基本条件。</p>
|
||||
|
||||
@@ -269,7 +269,7 @@ function hide_canvas() {
|
||||
<h3>远程调用</h3>
|
||||
<p>远程调用(Remote Procedure Call,RPC)是一种通过本地程序调用来封装远程服务请求的方法。</p>
|
||||
<p>程序员调用 RPC 的时候,程序看上去是在调用一个本地的方法,或者执行一个本地的任务,但是后面会有一个服务程序(通常称为 stub),将这种本地调用转换成远程网络请求。 同理,服务端接到请求后,也会有一个服务端程序(stub),将请求转换为一个真实的服务端方法调用。</p>
|
||||
<p><img src="assets/Ciqc1F-3nPGAUbAMAAC3qcOo5g0709.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F-3nPGAUbAMAAC3qcOo5g0709.png" alt="png" /></p>
|
||||
<p>客户端服务端的通信</p>
|
||||
<p>你可以观察上面这张图,表示客户端和服务端通信的过程,一共是 10 个步骤,分别是:</p>
|
||||
<ol>
|
||||
|
||||
@@ -252,12 +252,12 @@ function hide_canvas() {
|
||||
<p>再看看 <strong>I/O 密集型</strong>,I/O 本质是对设备的读写。读取键盘的输入是 I/O,读取磁盘(SSD)的数据是 I/O。通常 CPU 在设备 I/O 的过程中会去做其他的事情,当 I/O 完成,设备会给 CPU 一个中断,告诉 CPU 响应 I/O 的结果。比如说从硬盘读取数据完成了,那么硬盘给 CPU 一个中断。如果操作对 I/O 的依赖强,比如频繁的文件操作(写日志、读写数据库等),可以看作<strong>I/O 密集型</strong>。</p>
|
||||
<p>你可能会有一个疑问,<strong>读取硬盘数据到内存中这个过程,CPU 需不需要一个个字节处理</strong>?</p>
|
||||
<p>通常是不用的,因为在今天的计算机中有一个叫作 Direct Memory Access(DMA)的模块,这个模块允许硬件设备直接通过 DMA 写内存,而不需要通过 CPU(占用 CPU 资源)。</p>
|
||||
<p><img src="assets/Ciqc1F--MyKAQSfQAABs29xFyFQ392.png" alt="5.png" /></p>
|
||||
<p><img src="assets/Ciqc1F--MyKAQSfQAABs29xFyFQ392.png" alt="png" /></p>
|
||||
<p>很多情况下我们没法使用 DMA,比如说你想把一个数组拷贝到另一个数组内,执行的 memcpy 函数内部实现就是一个个 byte 拷贝,这种情况也是一种<strong>CPU 密集的操作</strong>。</p>
|
||||
<p>可见,区分是计算密集型还是 I/O 密集型这件事比较复杂。按说查询数据库是一件 I/O 密集型的事情,但是如果存储设备足够好,比如用了最好的固态硬盘阵列,I/O 速度很快,反而瓶颈会在计算上(对缓存的搜索耗时成为主要部分)。因此,需要一些可衡量指标,来帮助我们确认应用的特性。</p>
|
||||
<h3>衡量 CPU 的工作情况的指标</h3>
|
||||
<p>我们先来看一下 CPU 关联的指标。如下图所示:CPU 有 2 种状态,忙碌和空闲。此外,CPU 的时间还有一种被偷走的情况。</p>
|
||||
<p><img src="assets/Ciqc1F--MyyAGUJkAACsJU_MgVg506.png" alt="7.png" /></p>
|
||||
<p><img src="assets/Ciqc1F--MyyAGUJkAACsJU_MgVg506.png" alt="png" /></p>
|
||||
<p>忙碌就是 CPU 在执行有意义的程序,空闲就是 CPU 在执行让 CPU 空闲(空转)的指令。通常让 CPU 空转的指令能耗更低,因此让 CPU 闲置时,我们会使用特别的指令,最终效果和让 CPU 计算是一样的,都可以把 CPU 执行时间填满,只不过这类型指令能耗低一些而已。除了忙碌和空闲,CPU 的时间有可能被宿主偷走,比如一台宿主机器上有 10 个虚拟机,宿主可以偷走给任何一台虚拟机的时间。</p>
|
||||
<p>如上图所示,CPU 忙碌有 3 种情况:</p>
|
||||
<ol>
|
||||
@@ -271,7 +271,7 @@ function hide_canvas() {
|
||||
<li>CPU 因为需要等待 I/O 而空闲,比如在等待磁盘回传数据的中断,这种我们称为 I/O Wait。</li>
|
||||
</ol>
|
||||
<p>下图是我们执行 top 指令看到目前机器状态的快照,接下来我们仔细研究一下这些指标的含义:</p>
|
||||
<p><img src="assets/CgqCHl--MzuAVvG-AAMVu_JwSyA231.png" alt="2.png" /></p>
|
||||
<p><img src="assets/CgqCHl--MzuAVvG-AAMVu_JwSyA231.png" alt="png" /></p>
|
||||
<p>如上图所示,你可以细看下 <strong>%CPU(s)</strong> 开头那一行(第 3 行):</p>
|
||||
<ol>
|
||||
<li>us(user),即用户空间 CPU 使用占比。</li>
|
||||
@@ -284,7 +284,7 @@ function hide_canvas() {
|
||||
<li>st(stolen),如果当前机器是虚拟机,这个指标代表了宿主偷走的 CPU 时间占比。对于一个宿主多个虚拟机的情况,宿主可以偷走任何一台虚拟机的 CPU 时间。</li>
|
||||
</ol>
|
||||
<p>上面我们用 top 看的是一个平均情况,如果想看所有 CPU 的情况可以 top 之后,按一下<code>1</code>键。结果如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F--M0uAGZ1pAAmKNbPhB9A282.png" alt="3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F--M0uAGZ1pAAmKNbPhB9A282.png" alt="png" /></p>
|
||||
<p>当然,对性能而言,CPU 数量也是一个重要因素。可以看到我这台虚拟机一共有 16 个核心。</p>
|
||||
<h3>负载指标</h3>
|
||||
<p>上面的指标非常多,在排查问题的时候,需要综合分析。其实还有一些更简单的指标,比如上图中 top 指令返回有一项叫作<code>load average</code>——平均负载。 负载可以理解成某个时刻正在排队执行的进程数除以 CPU 核数。平均负载需要多次采样求平均值。 如果这个值大于<code>1</code>,说明 CPU 相当忙碌。因此如果你想发现问题,可以先检查这个指标。</p>
|
||||
@@ -293,7 +293,7 @@ function hide_canvas() {
|
||||
<p>如果想看更多<code>load average</code>,你可以看<code>/proc/loadavg</code>文件。</p>
|
||||
<h3>通信量(Traffic)</h3>
|
||||
<p>如果怀疑瓶颈发生在网络层面,或者想知道当前网络状况。可以查看<code>/proc/net/dev</code>,下图是在我的虚拟机上的查询结果:</p>
|
||||
<p><img src="assets/CgqCHl--M1aALjSiAALKG4QzX18230.png" alt="4.png" /></p>
|
||||
<p><img src="assets/CgqCHl--M1aALjSiAALKG4QzX18230.png" alt="png" /></p>
|
||||
<p>我们来一起看一下上图中的指标。表头分成了 3 段:</p>
|
||||
<ul>
|
||||
<li>Interface(网络接口),可以理解成网卡</li>
|
||||
@@ -312,10 +312,10 @@ function hide_canvas() {
|
||||
<p>如果你怀疑自己系统的网络有故障,可以查一下通信量部分的参数,相信会有一定的收获。</p>
|
||||
<h3>衡量磁盘工作情况</h3>
|
||||
<p>有时候 I/O 太频繁导致磁盘负载成为瓶颈,这个时候可以用<code>iotop</code>指令看一下磁盘的情况,如图所示:</p>
|
||||
<p><img src="assets/CgqCHl--M2OAJezyAAkRwbdJVmk356.png" alt="1.png" /></p>
|
||||
<p><img src="assets/CgqCHl--M2OAJezyAAkRwbdJVmk356.png" alt="png" /></p>
|
||||
<p>上图中是磁盘当前的读写速度以及排行较靠前的进程情况。</p>
|
||||
<p>另外,如果磁盘空间不足,可以用<code>df</code>指令:</p>
|
||||
<p><img src="assets/CgqCHl--M22AY0VPAAaPk8du-CY254.png" alt="6.png" /></p>
|
||||
<p><img src="assets/CgqCHl--M22AY0VPAAaPk8du-CY254.png" alt="png" /></p>
|
||||
<p>其实 df 是按照挂载的文件系统计算空间。图中每一个条目都是一个文件系统。有的文件系统直接挂在了一个磁盘上,比如图中的<code>/dev/sda5</code>挂在了<code>/</code>上,因此这样可以看到各个磁盘的使用情况。</p>
|
||||
<p>如果想知道更细粒度的磁盘 I/O 情况,可以查看<code>/proc/diskstats</code>文件。 这里有 20 多个指标我就不细讲了,如果你将来怀疑自己系统的 I/O 有问题,可以查看这个文件,并阅读相关手册。</p>
|
||||
<h3>监控平台</h3>
|
||||
|
||||
@@ -260,7 +260,7 @@ function hide_canvas() {
|
||||
<p><strong>【解析】</strong> 这是一道需要大家查一些资料的题目。这里涉及一个叫作内存一致性模型的概念。具体就是说,在同一时刻,多线程之间,对内存中某个地址的数据认知是否一致(简单理解,就是多个线程读取同一个内存地址能不能读到一致的值)。</p>
|
||||
<p>对某个地址,和任意时刻,如果所有线程读取值,得到的结果都一样,是一种强一致性,我们称为线性一致性(Sequencial Consistency),含义就是所有线程对这个地址中数据的历史达成了一致,历史没有分差,有一条大家都能认可的主线,因此称为线性一致。 如果只有部分时刻所有线程的理解是一致的,那么称为弱一致性(Weak Consistency)。</p>
|
||||
<p>那么为什么会有内存不一致问题呢? 这就是因为 CPU 缓存的存在。</p>
|
||||
<p><img src="assets/CgqCHl_A0uOACUBUAACRcLSCqUw476.png" alt="Lark20201127-181946.png" /></p>
|
||||
<p><img src="assets/CgqCHl_A0uOACUBUAACRcLSCqUw476.png" alt="png" /></p>
|
||||
<p>如上图所示:假设一开始 A=0,B=0。两个不在同一个 CPU 核心执行的 Thread1、Thread2 分别执行上图中的简单程序。在 CPU 架构中,Thread1,Thread2 在不同核心,因此它们的 L1\L2 缓存不共用, L3 缓存共享。</p>
|
||||
<p>在这种情况下,如果 Thread1 发生了写入 A=1,这个时候会按照 L1,L2,L3 的顺序写入缓存,最后写内存。而对于 Thread2 而言,在 Thread1 刚刚发生写入时,如果去读取 A 的值,就需要去内存中读,这个时候 A=1 可能还没有写入内存。但是对于线程 1 来说,它只要发生了写入 A=1,就可以从 L1 缓存中读取到这次写入。所以在线程 1 写入 A=1 的瞬间,线程 1 线程 2 无法对 A 的值达成一致,造成内存不一致。这个结果会导致 print 出来的 A 和 B 结果不确定,可能是 0 也可能是 1,取决于具体线程执行的时机。</p>
|
||||
<p>考虑一个锁变量,和 cas 上锁操作,代码如下:</p>
|
||||
|
||||
@@ -258,7 +258,7 @@ function hide_canvas() {
|
||||
<p>那么如何来解决这些问题呢? 历史上有过不少的解决方案,但最终沉淀下的是虚拟化技术。接下来我为你介绍一种历史上存在过的 Swap 技术以及虚拟化技术。</p>
|
||||
<h4>交换(Swap)技术</h4>
|
||||
<p>Swap 技术允许一部分进程使用内存,不使用内存的进程数据先保存在磁盘上。注意,这里提到的数据,是完整的进程数据,包括正文段(程序指令)、数据段、堆栈段等。轮到某个进程执行的时候,尝试为这个进程在内存中找到一块空闲的区域。如果空间不足,就考虑把没有在执行的进程交换(Swap)到磁盘上,把空间腾挪出来给需要的进程。</p>
|
||||
<p><img src="assets/Ciqc1F_Hb-GAermKAACje6hFwj4571.png" alt="Lark20201202-184240.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_Hb-GAermKAACje6hFwj4571.png" alt="png" /></p>
|
||||
<p>上图中,内存被拆分成多个区域。 内核作为一个程序也需要自己的内存。另外每个进程独立得到一个空间——我们称为地址空间(<strong>Address Space)</strong>。你可以认为地址空间是一块连续分配的内存块。每个进程在不同地址空间中工作,构成了一个原始的虚拟化技术。</p>
|
||||
<p>比如:当进程 A 想访问地址 100 的时候,实际上访问的地址是基于地址空间本身位置(首字节地址)计算出来的。另外,当进程 A 执行时,CPU 中会保存它地址空间的开始位置和结束位置,当它想访问超过地址空间容量的地址时,CPU 会检查然后报错。</p>
|
||||
<p>上图描述的这种方法,是一种比较原始的虚拟化技术,进程使用的是基于地址空间的虚拟地址。但是这种方案有很多明显的缺陷,比如:</p>
|
||||
@@ -276,7 +276,7 @@ function hide_canvas() {
|
||||
<p>经过以上分析,需要更好的解决方案,就是我们接下来要学习的虚拟化技术。</p>
|
||||
<h4>虚拟内存</h4>
|
||||
<p>虚拟化技术中,操作系统设计了虚拟内存(理论上可以无限大的空间),受限于 CPU 的处理能力,通常 64bit CPU,就是 264 个地址。</p>
|
||||
<p><img src="assets/Ciqc1F_Hb_aALLF_AABvGKciFvQ002.png" alt="Lark20201202-184243.png" />
|
||||
<p><img src="assets/Ciqc1F_Hb_aALLF_AABvGKciFvQ002.png" alt="png" />
|
||||
虚拟化技术中,应用使用的是虚拟内存,操作系统管理虚拟内存和真实内存之间的映射。操作系统将虚拟内存分成整齐小块,每个小块称为一个<strong>页(Page)</strong>。之所以这样做,原因主要有以下两个方面。</p>
|
||||
<ul>
|
||||
<li>一方面应用使用内存是以页为单位,整齐的页能够避免内存碎片问题。</li>
|
||||
@@ -285,7 +285,7 @@ function hide_canvas() {
|
||||
<p>如果一个应用需要非常大的内存,应用申请的是虚拟内存中的很多个页,真实内存不一定需要够用。</p>
|
||||
<h3>页(Page)和页表</h3>
|
||||
<p>接下来,我们详细讨论下这个设计。操作系统将虚拟内存分块,每个小块称为一个页(Page);真实内存也需要分块,每个小块我们称为一个 Frame。Page 到 Frame 的映射,需要一种叫作页表的结构。</p>
|
||||
<p><img src="assets/CgqCHl_HcAOAERr3AACsFab3D0g908.png" alt="Lark20201202-184247.png" />
|
||||
<p><img src="assets/CgqCHl_HcAOAERr3AACsFab3D0g908.png" alt="png" />
|
||||
上图展示了 Page、Frame 和页表 (PageTable)三者之间的关系。 Page 大小和 Frame 大小通常相等,页表中记录的某个 Page 对应的 Frame 编号。页表也需要存储空间,比如虚拟内存大小为 10G, Page 大小是 4K,那么需要 10G/4K = 2621440 个条目。如果每个条目是 64bit,那么一共需要 20480K = 20M 页表。操作系统在内存中划分出小块区域给页表,并负责维护页表。</p>
|
||||
<p>页表维护了虚拟地址到真实地址的映射。每次程序使用内存时,需要把虚拟内存地址换算成物理内存地址,换算过程分为以下 3 个步骤:</p>
|
||||
<ol>
|
||||
@@ -301,14 +301,14 @@ function hide_canvas() {
|
||||
</ol>
|
||||
<h4>MMU</h4>
|
||||
<p>上面的过程发生在 CPU 中一个小型的设备——内存管理单元(Memory Management Unit, MMU)中。如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_HcBGANfB6AABfKTW4B2g866.png" alt="Lark20201202-184250.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_HcBGANfB6AABfKTW4B2g866.png" alt="png" /></p>
|
||||
<p>当 CPU 需要执行一条指令时,如果指令中涉及内存读写操作,CPU 会把虚拟地址给 MMU,MMU 自动完成虚拟地址到真实地址的计算;然后,MMU 连接了地址总线,帮助 CPU 操作真实地址。</p>
|
||||
<p>这样的设计,就不需要在编写应用程序的时候担心虚拟地址到物理地址映射的问题。我们把全部难题都丢给了操作系统——操作系统要确定MMU 可以读懂自己的页表格式。所以,操作系统的设计者要看 MMU 的说明书完成工作。</p>
|
||||
<p>难点在于不同 CPU 的 MMU 可能是不同的,因此这里会遇到很多跨平台的问题。解决跨平台问题不但有繁重的工作量,更需要高超的编程技巧,Unix 最初期的移植性(跨平台)是 C 语言作者丹尼斯·里奇实现的。</p>
|
||||
<p>学到这里,细心的同学可能会有疑问:MMU 需要查询页表(这是内存操作),而 CPU 执行一条指令通过 MMU 获取内存数据,难道可以容忍在执行一条指令的过程中,发生多次内存读取(查询)操作?难道一次普通的读取操作,还要附加几次查询页表的开销吗?当然不是,这里还有一些高速缓存的设计,这部分我们放到“<strong>25 讲</strong>”中详细讨论。</p>
|
||||
<h4>页表条目</h4>
|
||||
<p>上面我们笼统介绍了页表将 Page 映射到 Frame。那么,页表中的每一项(<strong>页表条目</strong>)长什么样子呢?下图是一个页表格式的一个演示。</p>
|
||||
<p><img src="assets/CgqCHl_HcCiAXdDRAACAza-oxwo742.png" alt="Lark20201202-184252.png" />
|
||||
<p><img src="assets/CgqCHl_HcCiAXdDRAACAza-oxwo742.png" alt="png" />
|
||||
页表条目本身的编号可以不存在页表中,而是通过偏移量计算。 比如地址 100,000 的编号,可以用 100,000 除以页大小确定。</p>
|
||||
<ul>
|
||||
<li>Absent(“在”)位,是一个 bit。0 表示页的数据在磁盘中(不再内存中),1 表示在内存中。如果读取页表发现 Absent = 0,那么会触发缺页中断,去磁盘读取数据。</li>
|
||||
@@ -322,12 +322,12 @@ function hide_canvas() {
|
||||
<h3>大页面问题</h3>
|
||||
<p>最后,我们讨论一下大页面的问题。假设有一个应用,初始化后需要 12M 内存,操作系统页大小是 4K。那么应该如何设计呢?</p>
|
||||
<p>为了简化模型,下图中,假设这个应用只有 3 个区域(3 个段)——正文段(程序)、数据段(常量、全局变量)、堆栈段。一开始我们 3 个段都分配了 4M 的空间。随着程序执行,堆栈段的空间会继续增加,上不封顶。</p>
|
||||
<p><img src="assets/Ciqc1F_HcJSAZ9IlAACGuMSlD50803.png" alt="Lark20201202-184255.png" />
|
||||
<p><img src="assets/Ciqc1F_HcJSAZ9IlAACGuMSlD50803.png" alt="png" />
|
||||
上图中,进程内部需要一个页表存储进程的数据。如果进程的内存上不封顶,那么页表有多少个条目合适呢? 进程分配多少空间合适呢? 如果页表大小为 1024 个条目,那么可以支持 1024*4K = 4M 空间。按照这个计算,如果进程需要 1G 空间,则需要 256K 个条目。我们预先为进程分配这 256K 个条目吗? 创建一个进程就划分这么多条目是不是成本太高了?</p>
|
||||
<p>为了减少条目的创建,可以考虑进程内部用一个更大的页表(比如 4M),操作系统继续用 4K 的页表。这就形成了一个二级页表的结构,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl_lnEqAGPEZAAC-Dsux5E8250.png" alt="1.png" /></p>
|
||||
<p><img src="assets/CgqCHl_lnEqAGPEZAAC-Dsux5E8250.png" alt="png" /></p>
|
||||
<p>这样 MMU 会先查询 1 级页表,再查询 2 级页表。在这个模型下,进程如果需要 1G 空间,也只需要 1024 个条目。比如 1 级页编号是 2, 那么对应 2 级页表中 [2* 1024, 3*1024-1] 的部分条目。而访问一个地址,需要同时给出一级页编号和二级页编号。整个地址,还可以用 64bit 组装,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl_HcK2AGh63AABHzfHvTfg888.png" alt="Lark20201202-184238.png" /></p>
|
||||
<p><img src="assets/CgqCHl_HcK2AGh63AABHzfHvTfg888.png" alt="png" /></p>
|
||||
<p>MMU 根据 1 级编号找到 1 级页表条目,1 级页表条目中记录了对应 2 级页表的位置。然后 MMU 再查询 2 级页表,找到 Frame。最后通过地址偏移量和 Frame 编号计算最终的物理地址。这种设计是一个递归的过程,因此还可增加 3 级、4 级……每增加 1 级,对空间的利用都会提高——当然也会带来一定的开销。这对于大应用非常划算,比如需要 1T 空间,那么使用 2 级页表,页表的空间就节省得多了。而且,这种多级页表,顶级页表在进程中可以先只创建需要用到的部分,就这个例子而言,一开始只需要 3 个条目,从 256K 个条目到 3 个,这就大大减少了进程创建的成本。</p>
|
||||
<h3>总结</h3>
|
||||
<p><strong>那么通过这节课的学习,你现在可以尝试来回答本节关联的面试题目:一个程序最多能使用多少内存</strong>?</p>
|
||||
|
||||
@@ -249,14 +249,14 @@ function hide_canvas() {
|
||||
<p>那么接下来就请你带着这个优化问题,和我一起开始学习今天的内容。</p>
|
||||
<h3>内存管理单元</h3>
|
||||
<p>上一讲我们学习了虚拟地址到物理地址的转换过程。如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_KEYiAGIk6AABN2sQtqqo988.png" alt="Lark20201204-183520.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_KEYiAGIk6AABN2sQtqqo988.png" alt="png" /></p>
|
||||
<p>你可以把虚拟地址看成由页号和偏移量组成,把物理地址看成由 Frame Number 和偏移量组成。在 CPU 中有一个完成虚拟地址到物理地址转换的小型设备,叫作内存管理单元(Memory Management Unit(MMU)。</p>
|
||||
<p>在程序执行的时候,指令中的地址都是虚拟地址,虚拟地址会通过 MMU,MMU 会查询页表,计算出对应的 Frame Number,然后偏移量不变,组装成真实地址。然后 MMU 通过地址总线直接去访问内存。所以 MMU 承担了虚拟地址到物理地址的转换以及 CPU 对内存的操作这两件事情。</p>
|
||||
<p>如下图所示,从结构上 MMU 在 CPU 内部,并且直接和地址总线连接。因此 MMU 承担了 CPU 和内存之间的代理。对操作系统而言,MMU 是一类设备,有多种型号,就好比显卡有很多型号一样。操作系统需要理解这些型号,会使用 MMU。</p>
|
||||
<p><img src="assets/CgqCHl_KEZGAB4tfAAA_7O1Ajlg766.png" alt="Lark20201204-183533.png" /></p>
|
||||
<p><img src="assets/CgqCHl_KEZGAB4tfAAA_7O1Ajlg766.png" alt="png" /></p>
|
||||
<h3>TLB 和 MMU 的性能问题</h3>
|
||||
<p>上面的过程,会产生一个问题:指令的执行速度非常快,而 MMU 还需要从内存中查询页表。最快的内存查询页需要从 CPU 的缓存中读取,假设缓存有 95% 的命中率,比如读取到 L2 缓存,那么每次操作也需要几个 CPU 周期。你可以回顾一下 CPU 的指令周期,如下图所示,有 fetch/decode/execute 和 store。</p>
|
||||
<p><img src="assets/Ciqc1F_KDJ2AakpwAABJqXjoKBc358.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_KDJ2AakpwAABJqXjoKBc358.png" alt="png" /></p>
|
||||
<p>在 fetch、execute 和 store 这 3 个环节中都有可能发生内存操作,因此内存操作最好能在非常短的时间内完成,尤其是 Page Number 到 Frame Number 的映射,我们希望尽快可以完成,最好不到 0.2 个 CPU 周期,这样就不会因为地址换算而增加指令的 CPU 周期。</p>
|
||||
<p>因此,在 MMU 中往往还有一个微型的设备,叫作转置检测缓冲区(Translation Lookaside Buffer,TLB)。</p>
|
||||
<p>缓存的设计,通常是一张表,所以 TLB 也称作快表。TLB 中最主要的信息就是 Page Number到 Frame Number 的映射关系。</p>
|
||||
@@ -309,10 +309,10 @@ function hide_canvas() {
|
||||
<pre><code>sudo sysctl -w vm.nr_hugepages=2048
|
||||
</code></pre>
|
||||
<p><code>sysctl</code>其实就是修改一下配置项,上面我们允许应用使用最多 2048 个大内存页。上面语句执行后,你可以按照下方截图的方式去查看自己大内存页表使用的情况。</p>
|
||||
<p><img src="assets/CgqCHl_KDLaAK0LFAAJ42-0NGSQ136.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHl_KDLaAK0LFAAJ42-0NGSQ136.png" alt="png" /></p>
|
||||
<p>从上图中你可以看到我总共有 2048 个大内存页,每个大小是 2048KB。具体这个大小是不可以调整的,这个和机器用的 MMU 相关。</p>
|
||||
<p>打开大内存分页后如果有应用需要使用,就会去申请大内存分页。比如 Java 应用可以用<code>-XX:+UseLargePages</code>开启使用大内存分页。 下图是我通过一个 Java 程序加上 UseLargePages 参数的结果。</p>
|
||||
<p><img src="assets/Ciqc1F_KDLyAboRvAAJ45v4qI3g629.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_KDLyAboRvAAJ45v4qI3g629.png" alt="png" /></p>
|
||||
<p>注意:我的 Java 应用使用的分页数 = Total-Free+Rsvd = 2048-2032+180 = 196。Total 就是总共的分页数,Free 代表空闲的(包含 Rsvd,Reserved 预留的)。因此是上面的计算关系。</p>
|
||||
<h3>总结</h3>
|
||||
<p><strong>那么通过这节课的学习,你现在可以尝试来回答本节关联的面试题目:什么情况下使用大内存分页</strong>?</p>
|
||||
|
||||
@@ -257,30 +257,30 @@ function hide_canvas() {
|
||||
<p>比如说随机置换,一个新条目被写入,随机置换出去一个旧条目。这种设计,具有非常朴素的公平,但是性能会很差(穿透概率高),因为可能置换出去未来非常需要的数据。</p>
|
||||
<p>再比如先进先出(First In First Out)。设计得不好的电商首页,每次把离现在时间最久的产品下线,让新产品有机会展示,而忽略销量、热度、好评等因素。这也是一种朴素的公平,但是和我们设计缓存算法的初衷——预估未来使用频率更高的数据保留在缓存中,相去甚远。所以,FIFO 的结构也是一种悲观的设计。</p>
|
||||
<p>FIFO 的结构使用一个链表就能实现,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_QoymAebUsAAC5OScaOig811.png" alt="Lark20201209-181216.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_QoymAebUsAAC5OScaOig811.png" alt="png" /></p>
|
||||
<p>为了方便你理解本讲后面的内容,我在这里先做一个知识铺垫供你参考。上图中,新元素从链表头部插入,旧元素从链表尾部离开。 这样就构成了一个队列(Queue),队列是一个经典的 FIFO 模型。</p>
|
||||
<p>还有一种策略是先进后出(First In Last Out)。但是这种策略和 FIFO、随机一样,没有太强的实际意义。因为先进来的元素、后进来的元素,还是随机的某个元素,和我们期望的未来使用频率,没有任何本质联系。</p>
|
||||
<p>同样 FILO 的策略也可以用一个链表实现,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_QozGARRGMAACUhdXtUCg859.png" alt="Lark20201209-181224.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_QozGARRGMAACUhdXtUCg859.png" alt="png" /></p>
|
||||
<p>新元素从链表头部插入链表,旧元素从链表头部离开链表,就构成了一个栈(Stack),栈是一种天然的 FILO 数据结构。这里仅供参考了,我们暂时还不会用到这个方法。</p>
|
||||
<p>当然我们不可能知道未来,但是可以考虑基于历史推测未来。经过前面的一番分析,接下来我们开始讨论一些更有价值的置换策略。</p>
|
||||
<h3>最近未使用(NRU)</h3>
|
||||
<p>一种非常简单、有效的缓存实现就是优先把最近没有使用的数据置换出去(Not Recently Used)。从概率上说,最近没有使用的数据,未来使用的概率会比最近经常使用的数据低。缓存设计本身也是基于概率的,一种方案有没有价值必须经过实践验证——在内存缺页中断后,如果采用 NRU 置换页面,可以提高后续使用内存的命中率,这是实践得到的结论。</p>
|
||||
<p>而且 NRU 实现起来比较简单,下图是我们在“<strong>24 讲</strong>”中提到的页表条目设计。</p>
|
||||
<p><img src="assets/CgqCHl_QozuAMNoVAACEBmcfbc8914.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl_QozuAMNoVAACEBmcfbc8914.png" alt="png" /></p>
|
||||
<p>在页表中有一个访问位,代表页表有被读取过。还有一个脏位,代表页表被写入过。无论是读还是写,我们都可以认为是访问过。 为了提升效率,一旦页表被使用,可以用硬件将读位置 1,然后再设置一个定时器,比如 100ms 后,再将读位清 0。当有内存写入时,就将写位置 1。过一段时间将有内存写入的页回写到磁盘时,再将写位清 0。这样读写位在读写后都会置为 1,过段时间,也都会回到 0。</p>
|
||||
<p>上面这种方式,就构成了一个最基本的 NRU 算法。每次置换的时候,操作系统尽量选择读、写位都是 0 的页面。而一个页面如果在内存中停留太久,没有新的读写,读写位会回到 0,就可能会被置换。</p>
|
||||
<p>这里多说一句,NRU 本身还可以和其他方法结合起来工作,比如我们可以利用读、写位的设计去改进 FIFO 算法。</p>
|
||||
<p>每次 FIFO 从队列尾部找到一个条目要置换出去的时候,就检查一下这个条目的读位。如果读位是 0,就删除这个条目。如果读位中有 1,就把这个条目从队列尾部移动到队列的头部,并且把读位清 0,相当于多给这个条目一次机会,因此也被称为<strong>第二次机会算法</strong>。多给一次机会,就相当于发生访问的页面更容易存活。而且,这样的算法利用天然的数据结构优势(队列),保证了 NRU 的同时,节省了去扫描整个缓存寻找读写位是 0 的条目的时间。</p>
|
||||
<p>第二次机会算法还有一个更巧妙的实现,就是利用循环链表。这个实现可以帮助我们节省元素从链表尾部移动到头部的开销。</p>
|
||||
<p><img src="assets/Ciqc1F_QpS-Ab2r8AAEGCdwUp9k081.png" alt="Lark20201209-182118.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_QpS-Ab2r8AAEGCdwUp9k081.png" alt="png" /></p>
|
||||
<p>如上图所示,我们可以将从尾部移动条目到头部的这个操作简化为头指针指向下一个节点。每次移动链表尾部元素到头部,只需要操作头指针指向下一个元素即可。这个方法非常巧妙,而且容易实现,你可以尝试在自己系统的缓存设计中尝试使用它。</p>
|
||||
<p><strong>以上,是我们学习的第一个比较有价值的缓存置换算法。基本可用,能够提高命中率</strong>。缺点是只考虑了最近用没用过的情况,没有充分考虑综合的访问情况。优点是简单有效,性能好。缺点是考虑不周,对缓存的命中率提升有限。但是因为简单,容易实现,NRU 还是成了一个被广泛使用的算法。</p>
|
||||
<h3>最近使用最少(LRU)</h3>
|
||||
<p>一种比 NRU 考虑更周密,实现成本更高的算法是最近最少使用(Least Recently Used, LRU)算法,它会置换最久没有使用的数据。和 NRU 相比,LRU 会考虑一个时间范围内的数据,对数据的参考范围更大。LRU 认为,最近一段时间最少使用到的数据应该被淘汰,把空间让给最近频繁使用的数据。这样的设计,即便数据都被使用过,还是会根据使用频次多少进行淘汰。比如:CPU 缓存利用 LUR 算法将空间留给频繁使用的内存数据,淘汰使用频率较低的内存数据。</p>
|
||||
<h4>常见实现方案</h4>
|
||||
<p>LRU 的一种常见实现是链表,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_QpTeAK6CAAAC8UoADogQ978.png" alt="Lark20201209-182121.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_QpTeAK6CAAAC8UoADogQ978.png" alt="png" /></p>
|
||||
<p>用双向链表维护缓存条目。如果链表中某个缓存条目被使用到,那么就将这个条目重新移动到表头。如果要置换缓存条目出去,就直接从双线链表尾部删除一个条目。</p>
|
||||
<p>通常 LRU 缓存还要提供查询能力,这里我们可以考虑用类似 Java 中 LinkedHashMap 的数据结构,同时具备双向链表和根据 Key 查找值的能力。</p>
|
||||
<p>以上是常见的实现方案,但是这种方案在缓存访问量非常大的情况下,需要同时维护一个链表和一个哈希表,因此开销较高。</p>
|
||||
|
||||
@@ -245,7 +245,7 @@ function hide_canvas() {
|
||||
<p id="tip" align="center"></p>
|
||||
<div><h1>28 内存回收下篇:三色标记-清除算法是怎么回事?</h1>
|
||||
<p>今天我们继续讨论内存回收问题。在上一讲,我们发现双色标记-清除算法有一个明显的问题,如下图所示:</p>
|
||||
<p><img src="assets/Cip5yF_Z2CCAZ4MFAABZx6AzarA983.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Cip5yF_Z2CCAZ4MFAABZx6AzarA983.png" alt="png" /></p>
|
||||
<p>你可以把 GC 的过程看作标记、清除及程序不断对内存进行修改的过程,分成 3 种任务:</p>
|
||||
<ol>
|
||||
<li>标记程序(Mark)</li>
|
||||
@@ -253,13 +253,13 @@ function hide_canvas() {
|
||||
<li>变更程序(Mutation)</li>
|
||||
</ol>
|
||||
<p><strong>标记(Mark)就是找到不用的内存,清除(Sweep)就是回收不用的资源,而修改(Muation)则是指用户程序对内存进行了修改</strong>。通常情况下,在 GC 的设计中,上述 3 种程序不允许并行执行(Simultaneously)。对于 Mark、Sweep、Mutation 来说内存是共享的。如果并行执行相当于需要同时处理大量竞争条件的手段,这会增加非常多的开销。当然你可以开多个线程去 Mark、Mutation 或者 Sweep,但前提是每个过程都是独立的。</p>
|
||||
<p><img src="assets/Cip5yF_Z2CiASF0QAACL55G2CDE848.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/Cip5yF_Z2CiASF0QAACL55G2CDE848.png" alt="png" /></p>
|
||||
<p>因为 Mark 和 Sweep 的过程都是 GC 管理,而 Mutation 是在执行应用程序,在实时性要求高的情况下可以允许一边 Mark,一边 Sweep 的情况; 优秀的算法设计也可能会支持一边 Mark、一边 Mutation 的情况。这种算法通常使用了 Read On Write 技术,本质就是先把内存拷贝一份去 Mark/Sweep,让 Mutation 完全和 Mark 隔离。</p>
|
||||
<p><img src="assets/CgqCHl_Z2C-ABz5lAAC4Jo2Y4mQ994.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgqCHl_Z2C-ABz5lAAC4Jo2Y4mQ994.png" alt="png" /></p>
|
||||
<p>上图中 GC 开始后,拷贝了一份内存的原本,进行 Mark 和 Sweep,整理好内存之后,再将原本中所有的 Mutation 合并进新的内存。 这种算法设计起来会非常复杂,但是可以保证实时性 GC。</p>
|
||||
<p>上图的这种 GC 设计比较少见,通常 GC 都会发生 STL(Stop The World)问题,Mark/Sweep/Mutation 只能够交替执行。也就是说, 一种程序执行的时候,另一种程序必须停止。</p>
|
||||
<p><strong>对于双色标记-清除算法,如果 Mark 和 Sweep 之间存在 Mutation,那么 Mutation 的伤害是比较大的</strong>。比如 Mutation 新增了一个白色的对象,这个白色的对象就可能会在 Sweep 启动后被清除。当然也可以考虑新增黑色的对象,这样对象就不会在 Sweep 启动时被回收。但是会发生下面这个问题,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_Z2DeAGJzgAABVUsm0aqE938.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_Z2DeAGJzgAABVUsm0aqE938.png" alt="png" /></p>
|
||||
<p>如果一个新对象指向了一个已经删除的对象,一个新的黑色对象指向了一个白色对象,这个时候 GC 不会再遍历黑色对象,也就是白色的对象还是会被清除。<strong>因此,我们希望创建一个在并发环境更加稳定的程序,让 Mark/Mutation/Sweep 可以交替执行,不用特别在意它们之间的关联</strong>。</p>
|
||||
<p>有一个非常优雅地实现就是再增加一种中间的灰色,把灰色看作可以增量处理的工作,来重新定义白色的含义。</p>
|
||||
<h3>三色标记-清除算法(Tri-Color Mark Sweep)</h3>
|
||||
@@ -311,7 +311,7 @@ function hide_canvas() {
|
||||
</ol>
|
||||
<p>如果用户程序创建了新对象,可以考虑把新对象直接标记为灰色。虽然,也可以考虑标记为黑色,但是标记为灰色可以让 GC 意识到新增了未完成的任务。比如用户创建了新对象之后,新对象引用了之前删除的对象,就需要重新标记创建的部分。</p>
|
||||
<p>如果用户删除了已有的对象,通常做法是等待下一次全量 Mark 算法处理。下图中我们删除了 Root Object 到 A 的引用,这个时候如果把 A 标记成白色,那么还需要判断是否还有其他路径引用到 A,而且 B,C 节点的颜色也需要重新计算。关键的问题是,虽然可以实现一个基于 A 的 DFS 去解决这个问题,但实际情况是我们并不着急解决这个问题,因为内存空间往往是有富余的。</p>
|
||||
<p><img src="assets/CgpVE1_Z2NiAbW5kAAD-d5qJRoI176.png" alt="Drawing 11.png" /></p>
|
||||
<p><img src="assets/CgpVE1_Z2NiAbW5kAAD-d5qJRoI176.png" alt="png" /></p>
|
||||
<p><strong>在调整已有的引用关系时,三色标记算法的表现明显更好</strong>。下图是对象 B 将对 C 的引用改成了对 F 的引用,C,F 被加入灰色集合。接下来 GC 会递归遍历 C,F,最终然后 F,E,G 都会进入灰色集合。</p>
|
||||
<p><img src="assets/Ciqc1F_Z4mCANcwoAAFFnKmGj_w824.png" alt="图片32.png" /></p>
|
||||
<p>内存回收就好比有人在随手扔垃圾,清洁工需要不停打扫。如果清洁工能够跟上人们扔垃圾的速度,那么就不需要太多的 STL(Stop The World)。如果清洁工跟不上扔垃圾的速度,最终环境就会被全部弄乱,这个时候清洁工就会要求“Stop The World”。<strong>三色算法的优势就在于它支持多一些情况的 Mutation,这样能够提高“垃圾”被并发回收的概率</strong>。</p>
|
||||
@@ -320,12 +320,12 @@ function hide_canvas() {
|
||||
<p>三色标记-清除算法,还没有解决内存回收产生碎片的问题。通常,我们会在三色标记-清除算法之上,再构建一个整理内存(Compact)的算法。如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl_Z4LSAbg0BAAFFnKmGj_w022.png" alt="图片32.png" />
|
||||
Compact 算法将对象重新挤压到一起,让更多空间可以被使用。我们在设计这个算法时,观察到了一个现象:新创建出来的对象,死亡(被回收)概率会更高,而那些已经存在了一段时间的对象,往往更不容易死亡。这有点类似 LRU 缓存,其实是一个概率问题。接下来我们考虑针对这个现象进行优化。</p>
|
||||
<p><img src="assets/CgpVE1_Z2OuAXxFjAABfInodsKw867.png" alt="Drawing 15.png" /></p>
|
||||
<p><img src="assets/CgpVE1_Z2OuAXxFjAABfInodsKw867.png" alt="png" /></p>
|
||||
<p>如上图所示,你可以把新创建的对象,都先放到一个统一的区域,在 Java 中称为伊甸园(Eden)。这个区域因为频繁有新对象死亡,因此需要经常 GC。考虑整理使用中的对象成本较高,因此可以考虑将存活下来的对象拷贝到另一个区域,Java 中称为存活区(Survior)。存活区生存下来的对象再进入下一个区域,Java 中称为老生代。</p>
|
||||
<p>上图展示的三个区域,<strong>Eden、Survior 及老生代之间的关系是对象的死亡概率逐级递减,对象的存活周期逐级增加</strong>。三个区域都采用三色标记-清除算法。每次 Eden 存活下来的对象拷贝到 Survivor 区域之后,Eden 就可以完整的回收重利用。Eden 可以考虑和 Survivor 用 1:1 的空间,老生代则可以用更大的空间。Eden 中全量 GC 可以频繁执行,也可以增量 GC 混合全量 GC 执行。老生代中的 GC 频率可以更低,偶尔执行一次全量的 GC。</p>
|
||||
<h3>GC 的选择</h3>
|
||||
<p>最后我们来聊聊 GC 的选择。<strong>通常选择 GC 会有实时性要求(最大容忍的暂停时间),需要从是否为高并发场景、内存实际需求等维度去思考</strong>。<strong>在选择 GC 的时候,复杂的算法并不一定更有效。下面是一些简单有效的思考和判断</strong>。</p>
|
||||
<p><img src="assets/Cip5yF_Z2POASXuMAACh7n5TBi8380.png" alt="Drawing 17.png" /></p>
|
||||
<p><img src="assets/Cip5yF_Z2POASXuMAACh7n5TBi8380.png" alt="png" /></p>
|
||||
<ol>
|
||||
<li>如果你的程序内存需求较小,GC 压力小,这个时候每次用双色标记-清除算法,等彻底标记-清除完再执行应用程序,用户也不会感觉到多少延迟。双色标记-清除算法在这种场景可能会更加节省时间,因为程序简单。</li>
|
||||
<li>对于一些对暂停时间不敏感的应用,比如说数据分析类应用,那么选择一个并发执行的双色标记-清除算法的 GC 引擎,是一个非常不错的选择。因为这种应用 GC 暂停长一点时间都没有关系,关键是要最短时间内把整个 GC 执行完成。</li>
|
||||
|
||||
@@ -290,20 +290,20 @@ madvise(mymemory, size, MADV_HUGEPAGE);
|
||||
<p><strong>搜索树模拟 LRU</strong></p>
|
||||
<p>最后我再介绍一个巧妙的方法——用搜索树模拟 LRU。</p>
|
||||
<p>对于一个 8 路组相联缓存,这个方法需要 8-1 = 7bit 去构造一个树。如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl_cbWiANygpAAChKW14Ffw720.png" alt="1.png" /></p>
|
||||
<p><img src="assets/CgqCHl_cbWiANygpAAChKW14Ffw720.png" alt="png" /></p>
|
||||
<p>8 个缓存条目用 7 个节点控制,每个节点是 1 位。0 代表节点指向左边,1 代表节点指向右边。</p>
|
||||
<p>初始化的时候,所有节点都指向左边,如下图所示:</p>
|
||||
<p><img src="assets/CgpVE1_cbZaAOEVvAACaMkDXYtc665.png" alt="2.png" /></p>
|
||||
<p><img src="assets/CgpVE1_cbZaAOEVvAACaMkDXYtc665.png" alt="png" /></p>
|
||||
<p>接下来每次写入,会从根节点开始寻找,顺着箭头方向(0 向左,1 向右),找到下一个更新方向。比如现在图中下一个要更新的位置是 0。更新完成后,所有路径上的节点箭头都会反转,也就是 0 变成 1,1 变成 0。</p>
|
||||
<p><img src="assets/CgpVE1_cbbmAOIQDAACdnlwZGVE658.png" alt="3.png" /></p>
|
||||
<p><img src="assets/CgpVE1_cbbmAOIQDAACdnlwZGVE658.png" alt="png" /></p>
|
||||
<p>上图是<code>read a</code>后的结果,之前路径上所有的箭头都被反转,现在看到下一个位置是 4,我用橘黄色进行了标记。</p>
|
||||
<p><img src="assets/Ciqc1F_gP2WAScBQAACgqJrvexo168.png" alt="Lark20201221-142046.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_gP2WAScBQAACgqJrvexo168.png" alt="png" /></p>
|
||||
<p>上图是发生操作<code>read b</code>之后的结果,现在橘黄色可以更新的位置是 2。</p>
|
||||
<p><img src="assets/CgqCHl_cbg-ABn7-AACe6aOsslk632.png" alt="5.png" /></p>
|
||||
<p><img src="assets/CgqCHl_cbg-ABn7-AACe6aOsslk632.png" alt="png" /></p>
|
||||
<p>上图是读取 c 后的情况。后面我不一一绘出,假设后面的读取顺序是<code>d,e,f,g,h</code>,那么缓存会变成如下图所示的结果:</p>
|
||||
<p><img src="assets/CgqCHl_cbj-ATxdgAACsKCmX118121.png" alt="6.png" /></p>
|
||||
<p><img src="assets/CgqCHl_cbj-ATxdgAACsKCmX118121.png" alt="png" /></p>
|
||||
<p>这个时候用户如果读取了已经存在的值,比如说<code>c</code>,那么指向<code>c</code>那路箭头会被翻转,下图是<code>read c</code>的结果:</p>
|
||||
<p><img src="assets/CgpVE1_cbnmAMnbJAACm2EGytKM521.png" alt="8.png" /></p>
|
||||
<p><img src="assets/CgpVE1_cbnmAMnbJAACm2EGytKM521.png" alt="png" /></p>
|
||||
<p>这个结果并没有改变下一个更新的位置,但是翻转了指向 c 的路径。 如果要读取<code>x</code>,那么这个时候就会覆盖橘黄色的位置。</p>
|
||||
<p><strong>因此,本质上这种树状的方式,其实是在构造一种先入先出的顺序。任何一个节点箭头指向的子节点,应该被先淘汰(最早被使用)</strong>。</p>
|
||||
<p>这是一个我个人觉得非常天才的设计,因为如果在这个地方构造一个队列,然后每次都把命中的元素的当前位置移动到队列尾部。就至少需要构造一个链表,而链表的每个节点都至少要有当前的值和 next 指针,这就需要创建复杂的数据结构。在内存中创建复杂的数据结构轻而易举,但是在 CPU 中就非常困难。 所以这种基于 bit-tree,就轻松地解决了这个问题。当然,这是一个模拟 LRU 的情况,你还是可以构造出违反 LRU 缓存的顺序。</p>
|
||||
@@ -313,7 +313,7 @@ madvise(mymemory, size, MADV_HUGEPAGE);
|
||||
<p><strong>【问题】如果内存太大了,无论是标记还是清除速度都很慢,执行一次完整的 GC 速度下降该如何处理</strong>?</p>
|
||||
<p>【<strong>解析</strong>】当应用申请到的内存很大的时候,如果其中内部对象太多。只简单划分几个生代,每个生代占用的内存都很大,这个时候使用 GC 性能就会很糟糕。</p>
|
||||
<p>一种参考的解决方案就是将内存划分成很多个小块,类似在应用内部再做一个虚拟内存层。 每个小块可能执行不同的内存回收策略。</p>
|
||||
<p><img src="assets/Cip5yF_cbrCAZqANAABmyPzf-Zs709.png" alt="9.png" /></p>
|
||||
<p><img src="assets/Cip5yF_cbrCAZqANAABmyPzf-Zs709.png" alt="png" /></p>
|
||||
<p>上图中绿色、蓝色和橘黄色代表 3 种不同的区域。绿色区域中对象存活概率最低(类似 Java 的 Eden),蓝色生存概率上升,橘黄色最高(类似 Java 的老生代)。灰色区域代表应用从操作系统中已经申请了,但尚未使用的内存。通过这种划分方法,每个区域中进行 GC 的开销都大大减少。Java 目前默认的内存回收器 G1,就是采用上面的策略。</p>
|
||||
<h3>总结</h3>
|
||||
<p>这个模块我们学习了内存管理。<strong>通过内存管理的学习,我希望你开始理解虚拟化的价值,内存管理部分的虚拟化,是一种应对资源稀缺、增加资源流动性的手段</strong>(听起来那么像银行印的货币)。</p>
|
||||
|
||||
@@ -251,20 +251,20 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<p>特别是近年来分布式系统的普及,学习分布式文件系统,也是理解分布式架构最核心的一个环节。其实文件系统最精彩的还是虚拟文件系统的设计,比如 Linux 可以支持每个目录用不同的文件系统。这些文件看上去是一个个目录和文件,实际上可能是磁盘、内存、网络文件系统、远程磁盘、网卡、随机数产生器、输入输出设备等,这样虚拟文件系统就成了整合一切设备资源的平台。大量的操作都可以抽象成对文件的操作,程序的书写就会完整而统一,且扩展性强。</p>
|
||||
<p>这一讲,我会从 Linux 的目录结构和用途开始,带你认识 Linux 的文件系统。Linux 所有的文件都建立在虚拟文件系统(Virtual File System ,VFS)之上,如下图所示:</p>
|
||||
<p><img src="assets/Cip5yF_jAd-APzhvAADyJAEGLTc170.png" alt="Lark20201223-163616.png" /></p>
|
||||
<p><img src="assets/Cip5yF_jAd-APzhvAADyJAEGLTc170.png" alt="png" /></p>
|
||||
<p>当你访问一个目录或者文件,虽然用的是 Linux 标准的文件 API 对文件进行操作,但实际操作的可能是磁盘、内存、网络或者数据库等。<strong>因此,Linux 上不同的目录可能是不同的磁盘,不同的文件可能是不同的设备</strong>。</p>
|
||||
<h3>分区结构</h3>
|
||||
<p>在 Linux 中,<code>/</code>是根目录。之前我们在“<strong>08 讲</strong>”提到过,每个目录可以是不同的文件系统(不同的磁盘或者设备)。你可能会问我,<code>/</code>是对应一个磁盘还是多个磁盘呢?在<code>/</code>创建目录的时候,目录属于哪个磁盘呢?</p>
|
||||
<p><img src="assets/CgpVE1_jAeqAern4AAH5hspmQ0Y638.png" alt="Drawing 1.png" /></p>
|
||||
<p><img src="assets/CgpVE1_jAeqAern4AAH5hspmQ0Y638.png" alt="png" /></p>
|
||||
<p>你可以用<code>df -h</code>查看上面两个问题的答案,在上图中我的<code>/</code>挂载到了<code>/dev/sda5</code>上。如果你想要看到更多信息,可以使用<code>df -T</code>,如下图所示:</p>
|
||||
<p><img src="assets/CgpVE1_jAfGAf6BqAAGJaAmhd0Q927.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/CgpVE1_jAfGAf6BqAAGJaAmhd0Q927.png" alt="png" /></p>
|
||||
<p><code>/</code>的文件系统类型是<code>ext4</code>。这是一种常用的日志文件系统。关于日志文件系统,我会在“**30 讲”**为你介绍。然后你可能还会有一个疑问,<code>/dev/sda5</code>究竟是一块磁盘还是别的什么?这个时候你可以用<code>fdisk -l</code>查看,结果如下图:</p>
|
||||
<p><img src="assets/CgqCHl_jAf-AGBtKAANDnVrYDh0934.png" alt="Drawing 3.png" /></p>
|
||||
<p><img src="assets/CgqCHl_jAf-AGBtKAANDnVrYDh0934.png" alt="png" /></p>
|
||||
<p>你可以看到我的 Linux 虚拟机上,有一块 30G 的硬盘(当然是虚拟的)。然后这块硬盘下有 3 个设备(Device):/dev/sda1, /dev/sda2 和 /dev/sda5。在 Linux 中,数字 1~4 结尾的是主分区,通常一块磁盘最多只能有 4 个主分区用于系统启动。主分区之下,还可以再分成若干个逻辑分区,4 以上的数字都是逻辑分区。因此<code>/dev/sda2</code>和<code>/dev/sda5</code>是主分区包含逻辑分区的关系。</p>
|
||||
<h3>挂载</h3>
|
||||
<p>分区结构最终需要最终挂载到目录上。上面例子中<code>/dev/sda5</code>分区被挂载到了<code>/</code>下。 这样在<code>/</code>创建的文件都属于这个<code>/dev/sda5</code>分区。 另外,<code>/dev/sda5</code>采用<code>ext4</code>文件系统。可见<strong>不同的目录可以采用不同的文件系统</strong>。</p>
|
||||
<p>将一个文件系统映射到某个目录的过程叫作挂载(Mount)。当然这里的文件系统可以是某个分区、某个 USB 设备,也可以是某个读卡器等。你可以用<code>mount -l</code>查看已经挂载的文件系统。</p>
|
||||
<p><img src="assets/Cip5yF_jAfeAIaUWAANFrmAEXQM991.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/Cip5yF_jAfeAIaUWAANFrmAEXQM991.png" alt="png" /></p>
|
||||
<p>上图中的<code>sysfs``proc``devtmpfs``tmpfs``ext4</code>都是不同的文件系统,下面我们来说说它们的作用。</p>
|
||||
<ul>
|
||||
<li><code>sysfs</code>让用户通过文件访问和设置设备驱动信息。</li>
|
||||
@@ -279,7 +279,7 @@ function hide_canvas() {
|
||||
<p>上面这个命令将<code>/dev/sda6</code>挂载到目录<code>abc</code>。</p>
|
||||
<h3>目录结构</h3>
|
||||
<p>因为 Linux 内文件系统较多,用途繁杂,Linux 对文件系统中的目录进行了一定的归类,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_jAhGADnWLAAFf1qd349k816.png" alt="Lark20201223-163621.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_jAhGADnWLAAFf1qd349k816.png" alt="png" /></p>
|
||||
<p><strong>最顶层的目录称作根目录,</strong> 用<code>/</code>表示。<code>/</code>目录下用户可以再创建目录,但是有一些目录随着系统创建就已经存在,接下来我会和你一起讨论下它们的用途。</p>
|
||||
<p><strong>/bin(二进制</strong>)包含了许多所有用户都可以访问的可执行文件,如 ls, cp, cd 等。这里的大多数程序都是二进制格式的,因此称作<code>bin</code>目录。<code>bin</code>是一个命名习惯,比如说<code>nginx</code>中的可执行文件会在 Nginx 安装目录的 bin 文件夹下面。</p>
|
||||
<p><strong>/dev(设备文件)</strong> 通常挂载在<code>devtmpfs</code>文件系统上,里面存放的是设备文件节点。通常直接和内存进行映射,而不是存在物理磁盘上。</p>
|
||||
|
||||
@@ -251,7 +251,7 @@ function hide_canvas() {
|
||||
<p><strong>使用硬盘和使用内存有一个很大的区别,内存可以支持到字节级别的随机存取,而这种情况在硬盘中通常是不支持的</strong>。过去的机械硬盘内部是一个柱状结构,有扇区、柱面等。读取硬盘数据要转动物理的磁头,每转动一次磁头时间开销都很大,因此一次只读取一两个字节的数据,非常不划算。</p>
|
||||
<p>随着 SSD 的出现,机械硬盘开始逐渐消失(还没有完全结束),现在的固态硬盘内部是类似内存的随机存取结构。但是硬盘的读写速度还是远远不及内存。而连续读多个字节的速度,还远不如一次读一个硬盘块的速度。</p>
|
||||
<p>因此,<strong>为了提高性能,通常会将物理存储(硬盘)划分成一个个小块</strong>,比如每个 4KB。这样做也可以让硬盘的使用看起来非常整齐,方便分配和回收空间。况且,数据从磁盘到内存,需要通过电子设备,比如 DMA、总线等,如果一个字节一个字节读取,速度较慢的硬盘就太耗费时间了。过去的机械硬盘的速度可以比内存慢百万倍,现在的固态硬盘,也会慢几十到几百倍。即便是最新的 NvMe 接口的硬盘,和内存相比速度仍然有很大的差距。因此,一次读/写一个块(Block)才是可行的方案。</p>
|
||||
<p><img src="assets/Cip5yF_ls_aAEer_AADHBXF7EHw534.png" alt="Lark20201225-174103.png" /></p>
|
||||
<p><img src="assets/Cip5yF_ls_aAEer_AADHBXF7EHw534.png" alt="png" /></p>
|
||||
<p>如上图所示,操作系统会将磁盘分成很多相等大小的块。这样做还有一个好处就是如果你知道块的序号,就可以准确地计算出块的物理位置。</p>
|
||||
<h3>文件的描述</h3>
|
||||
<p>我们将硬盘分块后,如何利用上面的硬盘存储文件,就是文件系统(File System)要负责的事情了。当然目录也是一种文件,因此我们先讨论文件如何读写。不同的文件系统利用方式不同,今天会重点讨论 3 种文件系统:</p>
|
||||
@@ -262,24 +262,24 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<h4>FAT 表</h4>
|
||||
<p>早期人们找到了一种方案就是文件分配表(File Allocate Table,FAT)。如下图所示:</p>
|
||||
<p><img src="assets/CgpVE1_ltAKAZe8tAACczq1tAiY181.png" alt="Lark20201225-174106.png" /></p>
|
||||
<p><img src="assets/CgpVE1_ltAKAZe8tAACczq1tAiY181.png" alt="png" /></p>
|
||||
<p><strong>一个文件,最基本的就是要描述文件在硬盘中到底对应了哪些块。FAT 表通过一种类似链表的结构描述了文件对应的块</strong>。上图中:文件 1 从位置 5 开始,这就代表文件 1 在硬盘上的第 1 个块的序号是 5 的块 。然后位置 5 的值是 2,代表文件 1 的下一个块的是序号 2 的块。顺着这条链路,我们可以找到 5 → 2 → 9 → 14 → 15 → -1。-1 代表结束,所以文件 1 的块是:5,2,9,14,15。同理,文件 2 的块是 3,8,12。</p>
|
||||
<p><strong>FAT 通过一个链表结构解决了文件和物理块映射的问题,算法简单实用,因此得到过广泛的应用,到今天的 Windows/Linux/MacOS 都还支持 FAT 格式的文件系统</strong>。FAT 的缺点就是非常占用内存,比如 1T 的硬盘,如果块的大小是 1K,那么就需要 1G 个 FAT 条目。通常一个 FAT 条目还会存一些其他信息,需要 2~3 个字节,这就又要占用 2-3G 的内存空间才能用 FAT 管理 1T 的硬盘空间。显然这样做是非常浪费的,问题就出在了 FAT 表需要全部维护在内存当中。</p>
|
||||
<h4>索引节点(inode)</h4>
|
||||
<p>为了改进 FAT 的容量限制问题,可以考虑为每个文件增加一个索引节点(inode)。这样,随着虚拟内存的使用,当文件导入内存的时候,先导入索引节点(inode),然后索引节点中有文件的全部信息,包括文件的属性和文件物理块的位置。</p>
|
||||
<p><img src="assets/CgpVE1_ltBCAP9AZAAC1vcuIPkE631.png" alt="Lark20201225-174108.png" /></p>
|
||||
<p><img src="assets/CgpVE1_ltBCAP9AZAAC1vcuIPkE631.png" alt="png" /></p>
|
||||
<p>如上图,索引节点除了属性和块的位置,还包括了一个指针块的地址。这是为了应对文件非常大的情况。一个大文件,一个索引节点存不下,需要通过指针链接到其他的块去描述文件。</p>
|
||||
<p>这种文件索引节点(inode)的方式,完美地解决了 FAT 的缺陷,一直被沿用至今。FAT 要把所有的块信息都存在内存中,索引节点只需要把用到的文件形成数据结构,而且可以使用虚拟内存分配空间,随着页表置换,这就解决了 FAT 的容量限制问题。</p>
|
||||
<h3>目录的实现</h3>
|
||||
<p>有了文件的描述,接下来我们来思考如何实现目录(Directory)。目录是特殊的文件,所以每个目录都有自己的 inode。目录是文件的集合,所以目录的内容中必须有所有其下文件的 inode 指针。</p>
|
||||
<p><img src="assets/Cip5yF_ltBqAG_agAAB0qsKok0o713.png" alt="Lark20201225-174111.png" /></p>
|
||||
<p><img src="assets/Cip5yF_ltBqAG_agAAB0qsKok0o713.png" alt="png" /></p>
|
||||
<p>文件名也最好不要放到 inode 中,而是放到文件夹中。这样就可以灵活设置文件的别名,及实现一个文件同时在多个目录下。</p>
|
||||
<p><img src="assets/Cip5yF_ltCKAQ8wsAACxv79iv44798.png" alt="Lark20201225-174114.png" /></p>
|
||||
<p><img src="assets/Cip5yF_ltCKAQ8wsAACxv79iv44798.png" alt="png" /></p>
|
||||
<p>如上图,/foo 和 /bar 两个目录中的 b.txt 和 c.txt 其实是一个文件,但是拥有不同的名称。这种形式我们称作“硬链接”,就是多个文件共享 inode。</p>
|
||||
<p><img src="assets/Cip5yF_ltCmANmxDAAEX0KEBlYU772.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/Cip5yF_ltCmANmxDAAEX0KEBlYU772.png" alt="png" /></p>
|
||||
<p>硬链接有一个非常显著的特点,硬链接的双方是平等的。上面的程序我们用<code>ln</code>指令为文件 a 创造了一个硬链接<code>b</code>。如果我们创造完删除了 a,那么 b 也是可以正常工作的。如果要删除掉这个文件的 inode,必须 a,b 同时删除。这里你可以看出 a,b 是平等的。</p>
|
||||
<p>和硬链接相对的是软链接,软链接的原理如下图:</p>
|
||||
<p><img src="assets/CgpVE1_ltDGAXzaKAADF0IcW0HA765.png" alt="Lark20201225-174117.png" /></p>
|
||||
<p><img src="assets/CgpVE1_ltDGAXzaKAADF0IcW0HA765.png" alt="png" /></p>
|
||||
<p>图中<code>c.txt</code>是<code>b.txt</code>的一个软链接,软链接拥有自己的<code>inode</code>,但是文件内容就是一个快捷方式。因此,如果我们删除了<code>b.txt</code>,那么<code>b.txt</code>对应的 inode 也就被删除了。但是<code>c.txt</code>依然存在,只不过指向了一个空地址(访问不到)。如果删除了<code>c.txt</code>,那么不会对<code>b.txt</code>造成任何影响。</p>
|
||||
<p>在 Linux 中可以通过<code>ln -s</code>创造软链接。</p>
|
||||
<pre><code>ln -s a b # 将b设置为a的软链接(b是a的快捷方式)
|
||||
@@ -295,7 +295,7 @@ function hide_canvas() {
|
||||
<h3>解决性能和故障:日志文件系统</h3>
|
||||
<p><strong>在传统的文件系统实现中,inode 解决了 FAT 容量限制问题,但是随着 CPU、内存、传输线路的速度越来越快,对磁盘读写性能的要求也越来越高</strong>。传统的设计,每次写入操作都需要进行一次持久化,所谓“持久化”就是将数据写入到磁盘,这种设计会成为整个应用的瓶颈。因为磁盘速度较慢,内存和 CPU 缓存的速度非常快,如果 CPU 进行高速计算并且频繁写入磁盘,那么就会有大量线程阻塞在等待磁盘 I/O 上。磁盘的瓶颈通常在写入上,因为通常读取数据的时候,会从缓存中读取,不存在太大的瓶颈。</p>
|
||||
<p>加速写入的一种方式,就是利用缓冲区。</p>
|
||||
<p><img src="assets/CgpVE1_ltEeASFyHAAD52tSCqME475.png" alt="Lark20201225-174119.png" /></p>
|
||||
<p><img src="assets/CgpVE1_ltEeASFyHAAD52tSCqME475.png" alt="png" /></p>
|
||||
<p>上图中所有写操作先存入缓冲区,然后每过一定的秒数,才进行一次持久化。 这种设计,是一个很好的思路,但最大的问题在于容错。 比如上图的步骤 1 或者步骤 2 只执行了一半,如何恢复?如果步骤 2 只写入了一半,那么数据就写坏了。如果步骤 1 只写入了一半,那么数据就丢失了。无论出现哪种问题,都不太好处理。更何况写操作和写操作之间还有一致性问题,比如说一次删除 inode 的操作后又发生了写入……</p>
|
||||
<p>解决上述问题的一个非常好的方案就是利用日志。假设 A 是文件中某个位置的数据,比起传统的方案我们反复擦写 A,日志会帮助我们把 A 的所有变更记录下来,比如:</p>
|
||||
<pre><code>A=1
|
||||
@@ -304,12 +304,12 @@ A=3
|
||||
</code></pre>
|
||||
<p>上面 A 写入了 3 次,因此有 3 条日志。日志文件系统文件中存储的就是像上面那样的日志,而不是文件真实的内容。当用户读取文件的时候,文件内容会在内存中还原,所以内存中 A 的值是 3,但实际磁盘上有 3 条记录。</p>
|
||||
<p>从性能上分析,如果日志造成了 3 倍的数据冗余,那么读取的速度并不会真的慢三倍。因为我们多数时候是从内存和 CPU 缓存中读取数据。而写入的时候,因为采用日志的形式,可以考虑下图这种方式,在内存缓冲区中积累一批日志才写入一次磁盘。</p>
|
||||
<p><img src="assets/Cip5yF_ltD-ANGHYAAD43z0foHQ229.png" alt="Lark20201225-174123.png" /></p>
|
||||
<p><img src="assets/Cip5yF_ltD-ANGHYAAD43z0foHQ229.png" alt="png" /></p>
|
||||
<p>上图这种设计可以让写入变得非常快速,多数时间都是写内存,最后写一次磁盘。<strong>而上图这样的设计成不成立,核心在能不能解决容灾问题</strong>。</p>
|
||||
<p>你可以思考一下这个问题——<strong>丢失一批日志和丢失一批数据的差别大不大</strong>。其实它们之间最大的差别在于,如果丢失一批日志,只不过丢失了近期的变更;但如果丢失一批数据,那么就可能造成永久伤害。</p>
|
||||
<p>举个例子,比如说你把最近一天的订单数据弄乱了,你可以通过第三方支付平台的交易流水、系统的支付记录等帮助用户恢复数据,还可以通过订单关联的用户信息查询具体是哪些用户的订单出了问题。但是如果你随机删了一部分订单, 那问题就麻烦了。你要去第三发支付平台调出所有流水,用大数据引擎进行分析和计算。</p>
|
||||
<p>为了进一步避免损失,一种可行的方案就是创建还原点(Checkpoint),比如说系统把最近 30s 的日志都写入一个区域中。下一个 30s 的日志,写入下一个区域中。每个区域,我们称作一个还原点。创建还原点的时候,我们将还原点涂成红色,写入完成将还原点涂成绿色。</p>
|
||||
<p><img src="assets/CgpVE1_ltFyACwCsAADstiN6HAk886.png" alt="Lark20201225-174058.png" /></p>
|
||||
<p><img src="assets/CgpVE1_ltFyACwCsAADstiN6HAk886.png" alt="png" /></p>
|
||||
<p>如上图,当日志文件系统写入磁盘的时候,每隔一段时间就会把这段时间内的所有日志写入一个或几个连续的磁盘块,我们称为还原点(Checkpoint)。操作系统读入文件的时候,依次读入还原点的数据,如果是绿色,那么就应用这些日志,如果是红色,就丢弃。所以上图中还原点 3 的数据是不完整的,这个时候会丢失不到 30s 的数据。如果将还原点的间隔变小,就可以控制风险的粒度。另外,我们还可以对还原点 3 的数据进行深度恢复,这里可以有人工分析,也可以通过一些更加复杂的算法去恢复。</p>
|
||||
<h3>总结</h3>
|
||||
<p>这一讲我们学习了 3 种文件系统的实现,我们再来一起总结回顾一下。</p>
|
||||
|
||||
@@ -261,7 +261,7 @@ function hide_canvas() {
|
||||
<p>网页的历史版本,可以用 URL+ 时间戳进行描述。但是为了检索方便,网页不仅有内容,还有语言、外链等。在存储端可以先不考虑提供复杂的索引,比如说提供全文搜索。但是我们至少应该提供合理的数据读写方式。</p>
|
||||
<p>网页除了内容,还有外链,外链就是链接到网页的外部网站。链接到一个网站的外链越多,那就说明这个网站在互联网中扮演的角色越重要。Google 创立之初就在基于外链的质量和数量为网站打分。外链可能是文字链接、图片链接等,因此外链也可以有版本,比如外链文本调整了,图片换了。除了外链还有标题、Logo,也需要存储。其实要存储的内容有很多,我不一一指出了。</p>
|
||||
<p>我们先看看行存储,可不可以满足需求。比如每个网页( URL) 的数据是一行。 看似这个方案可行,可惜列不是固定。比如外链可能有很多个,如下表:</p>
|
||||
<p><img src="assets/Cip5yF_vGG6AJ1xDAAC7-lhEiss613.png" alt="Lark20210101-204013.png" /></p>
|
||||
<p><img src="assets/Cip5yF_vGG6AJ1xDAAC7-lhEiss613.png" alt="png" /></p>
|
||||
<p>列不固定,不仅仅是行的大小不好确定,而是表格画不出来。何况每一列内容还可能有很多版本,不同版本是搜索引擎的爬虫在不同时间收录的内容,再加上内容本身也很大,有可能一个磁盘 Block 都存不下。看来行存储困难重重。</p>
|
||||
<p>那么列存储行不行呢? 当然不行,我们都不确定到底有多少列? 有的网站有几千个外链,有的一个都没有,外链到底用多少列呢?</p>
|
||||
<p>所以上表只可以作为我们存储设计的一个逻辑概念——这种逻辑概念在设计系统的时候,还有一个名词,叫作领域语言。领域语言是我们的思考方式,从搜索引擎的职责上讲,数据需要按照上面的结构聚合。况且根据 URL 拿数据,这是必须提供的能力。但是底层如何持久化,还需要进一步思考。</p>
|
||||
@@ -278,22 +278,22 @@ function hide_canvas() {
|
||||
<p>对于修改、删除操作可以考虑不支持,因为所有的变更已经记录下来了。</p>
|
||||
<h3>分片(Tablet)的抽象</h3>
|
||||
<p>上面我们提到了可以把若干行组合在一起存储的设计。这个设计比较适合数据在集群中分布。假设存储网页的表有几十个 PB,那么先水平分表,就是通过 行(URL) 分表。URL 按照字典排序,相邻的 URL 数据从物理上也会相近。水平分表的结果,字典序相近的行(URL)数据会形成分片(Tablet),Tablet 这个单词类似药片的含义。</p>
|
||||
<p><img src="assets/Ciqc1F_vGISACSHvAADSDqVVRVA843.png" alt="Lark20210101-204000.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_vGISACSHvAADSDqVVRVA843.png" alt="png" /></p>
|
||||
<p>如上图所示:每个分片中含有一部分的行,视情况而定。分片(Tablet),可以作为数据分布的最小单位。分片内部可以考虑图上的行存储,也可以考虑内部是一个 B+ 树组织的列存储。</p>
|
||||
<p>为了实现分布式存储,每个分片可以对应一个分布式文件系统中的文件。假设这个分布式文件系统接入了 Linux 的虚拟文件系统,使用和操作会同 Linux 本地文件并无二致。其实不一定会这样实现,这只是一个可行的方案。</p>
|
||||
<p>为了存储安全,一个分片最少应该有 2 个副本,也就是 3 份数据。3 份数据在其中一份数据不一致后,可以对比其他两份的结果修正数据。这 3 份数据,我们不考虑跨数据中心。因为跨地域成本太高,吞吐量不好保证,假设它们还在同一地域的机房内,只不过在不同的机器、磁盘上。</p>
|
||||
<p><img src="assets/CgqCHl_vGIyAfLpIAAFm-x3dyxw509.png" alt="Lark20210101-204003.png" /></p>
|
||||
<p><img src="assets/CgqCHl_vGIyAfLpIAAFm-x3dyxw509.png" alt="png" /></p>
|
||||
<h3>块(Chunk)的抽象</h3>
|
||||
<p>比分片更小的单位是块(Chunk),这个单词和磁盘的块(Block)区分开。Chunk 是一个比 Block 更大的单位。Google File System 把数据分成了一个个 Chunk,然后每个 Chunk 会对应具体的磁盘块(Block)。</p>
|
||||
<p>如下图,Table 是最顶层的结构,它里面含有许多分片(Tablets)。从数据库层面来看,每个分片是一个文件。数据库引擎维护到这个层面即可,至于这个文件如何在分布式系统中工作,就交给底层的文件系统——比如 Google File System 或者 Hadoop Distributed File System。</p>
|
||||
<p><img src="assets/CgqCHl_vGJiAVxgcAAEjt38fJYI284.png" alt="Lark20210101-204006.png" /></p>
|
||||
<p><img src="assets/CgqCHl_vGJiAVxgcAAEjt38fJYI284.png" alt="png" /></p>
|
||||
<p>分布式文件系统通常会在磁盘的 Block 上再抽象一层 Chunk。一个 Chunk 通常比 Block 大很多,比如 Google File System 是 64KB,而通常磁盘的 Block 大小是 4K;HDFS 则是 128MB。这样的设计是为了减少 I/O 操作的频率,分块太小 I/O 频率就会上升,分块大 I/O 频率就减小。 比如一个 Google 的爬虫积攒了足够多的数据再提交到 GFS 中,就比爬虫频繁提交节省网络资源。</p>
|
||||
<h3>分布式文件的管理</h3>
|
||||
<p>接下来,我们来讨论一个完整的分布式系统设计。和单机文件系统一样,一个文件必须知道自己的数据(Chunk)存放在哪里。下图展示了一种最简单的设计,文件中包含了许多 Chunk 的 ID,然后每个 ChunkID 可以从 Chunk 的元数据中找到 Chunk 对应的位置。</p>
|
||||
<p><img src="assets/CgpVE1_vGSaAOvq7AACExCrj12U682.png" alt="Lark20210101-204414.png" /></p>
|
||||
<p><img src="assets/CgpVE1_vGSaAOvq7AACExCrj12U682.png" alt="png" /></p>
|
||||
<p>如果 Chunk 比较大,比如说 HDFS 中 Chunk 有 128MB,那么 1PB 的数据需要 8,388,608 个条目。如果每个条目用 64bit 描述,也就是 8 个字节,只需要 64M 就可以描述清楚。考虑到一个 Chunk 必然会有冗余存储,也就是多个位置,实际会比 64M 多几倍,但也不会非常大了。</p>
|
||||
<p>因此像 HDFS 和 GFS 等,为了简化设计会把所有文件目录结构信息,加上 Chunk 的信息,保存在一个单点上,通常称为 Master 节点。</p>
|
||||
<p><img src="assets/CgqCHl_vGPiABpLOAAKq3EbZ3XQ571.png" alt="Lark20210101-204008.png" /></p>
|
||||
<p><img src="assets/CgqCHl_vGPiABpLOAAKq3EbZ3XQ571.png" alt="png" /></p>
|
||||
<p>下图中,客户端想要读取<code>/foo/bar</code>中某个 Chunk 中某段内容(Byterange)的数据,会分成 4 个步骤:</p>
|
||||
<ol>
|
||||
<li>客户端向 Master 发送请求,将想访问的文B件名、Chunk 的序号(可以通过 Chunk 大小和内容位置计算);</li>
|
||||
@@ -301,7 +301,7 @@ function hide_canvas() {
|
||||
<li>客户端向 Chunk 所在的地址(一台 ChunkServer)发送请求,并将句柄(ID)和内容范围(Byterange)作为参数;</li>
|
||||
<li>ChunkServer 将数据返回给客户端。</li>
|
||||
</ol>
|
||||
<p><img src="assets/Ciqc1F_vGQGAFWGDAAKs3c4PcVw331.png" alt="Lark20210101-204011.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_vGQGAFWGDAAKs3c4PcVw331.png" alt="png" /></p>
|
||||
<p>在上面这个模型中,有 3 个实体。</p>
|
||||
<ol>
|
||||
<li>客户端(Client)或者应用(Application),它们是数据的实际使用方,比如说 BigTable 数据库是 GFS 的 Client。</li>
|
||||
|
||||
@@ -250,7 +250,7 @@ function hide_canvas() {
|
||||
<p>【<strong>问题</strong>】<strong>socket 文件都存在哪里</strong>?</p>
|
||||
<p>【<strong>解析</strong>】socket 没有实体文件,只有 inode,所以 socket 是没有名字的文件。</p>
|
||||
<p>你可以在 /proc/net/tcp 目录下找到所有的 TCP 连接,在 /proc/[pid]/fd 下也可以找到这些 socket 文件,都是数字代号,数字就是 socket 文件的 fd,如下图所示:</p>
|
||||
<p><img src="assets/Cip5yF_1k1CAEQSEAAIEojLbG2I362.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/Cip5yF_1k1CAEQSEAAIEojLbG2I362.png" alt="png" /></p>
|
||||
<p>你也可以用<code>lsof -i -a -p [pid</code>查找某个进程的 socket 使用情况。下面结果和你用<code>ls /proc/[pid]/fd</code>看到的 fd 是一致的,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_1k1iAfL9JAAUoAKqNqrU408.png" alt="操作系统 (assets/Ciqc1F_1k1iAfL9JAAUoAKqNqrU408.png).png" /></p>
|
||||
<h4>30 | 文件系统的底层实现:FAT、NTFS 和 Ext3 有什么区别?</h4>
|
||||
|
||||
@@ -256,17 +256,17 @@ function hide_canvas() {
|
||||
<p>对于多数的<strong>应用</strong>和<strong>用户</strong>而言,使用互联网的一个基本要求就是数据可以无损地到达。用户通过应用进行网络通信,应用启动之后就变成了进程。因此,<strong>所有网络通信的本质目标就是进程间通信</strong>。世界上有很多进程需要通信,我们要找到一种通用的,每个进程都能认可和接受的通信方式,这就是<strong>协议</strong>。</p>
|
||||
<h4>应用层</h4>
|
||||
<p>从分层架构上看,应用工作在应用层(<strong>Application Layer</strong>)。应用的功能,都在应用层实现。所以应用层很好理解,说的就是应用本身。当两个应用需要通信的时候,应用(进程中的线程)就调用传输层进行通信。从架构上说,应用层只专注于为用户提供价值即可,没有必要思考数据如何传输。而且应用的开发商和传输库的提供方也不是一个团队。</p>
|
||||
<p><img src="assets/CgpVE1_4KJCAcLUgAABciWzBY2I633.png" alt="Lark20210108-173922.png" /></p>
|
||||
<p><img src="assets/CgpVE1_4KJCAcLUgAABciWzBY2I633.png" alt="png" /></p>
|
||||
<h4>传输层</h4>
|
||||
<p>为应用层提供网络支持的,就是传输层(<strong>Transport Layer</strong>)。</p>
|
||||
<p>传输层控制协议(Transmission Control Protocol)是目前世界上应用最广泛的传输层协议。传输层为应用提供通信能力。比如浏览器想访问服务器,浏览器程序就会调用传输层程序;Web 服务接收浏览器的请求,Web 服务程序就会调用传输层程序接收数据。</p>
|
||||
<p>考虑到应用需要传输的数据可能会非常大,直接传输不好控制。传输层需要将数据切块,即使一个分块传丢了、损坏了,可以重新发一个分块,而不用重新发送整体。在 TCP 协议中,我们把每个分块称为一个 TCP 段(TCP Segment)。</p>
|
||||
<p><img src="assets/Cip5yF_4KJ2AAqCRAABRo3rT3hE804.png" alt="Lark20210108-173925.png" /></p>
|
||||
<p><img src="assets/Cip5yF_4KJ2AAqCRAABRo3rT3hE804.png" alt="png" /></p>
|
||||
<p>传输层负责帮助应用传输数据给应用。考虑到一台主机上可能有很多个应用在传输数据,而一台服务器上可能有很多个应用在接收数据。因此,我们需要一个编号将应用区分开。这个编号就是<strong>端口号</strong>。比如 80 端口通常是 Web 服务器在使用;22 端口通常是远程登录服务在使用。而桌面浏览器,可能每个打开的标签栏都是一个独立的进程,每个标签栏都会使用临时分配的端口号。TCP 封包(TCP Segment)上携带了端口号,接收方可以识别出封包发送给哪个应用。</p>
|
||||
<h4>网络层</h4>
|
||||
<p><strong>接下来你要思考的问题是:传输层到底负不负责将数据从一个设备传输到另一个设备</strong>(主机到主机,Host To Host)。仔细思考这个过程,你会发现如果这样设计,传输层就会违反简单、高效、专注的设计原则。</p>
|
||||
<p>我们从一个主机到另一个主机传输数据的网络环境是非常复杂的。中间会通过各种各样的线路,有形形色色的交叉路口——有各式各样的路径和节点需要选择。<strong>核心的设计原则是,我们不希望一层协议处理太多的问题。传输层作为应用间数据传输的媒介,服务好应用即可</strong>。对应用层而言,传输层帮助实现应用到应用的通信。而实际的传输功能交给传输层的下一层,也就是<strong>网络层(Internet Layer)</strong> 会更好一些。</p>
|
||||
<p><img src="assets/CgpVE1_4KKaAeyVoAABvyFiqSu8542.png" alt="Lark20210108-173928.png" /></p>
|
||||
<p><img src="assets/CgpVE1_4KKaAeyVoAABvyFiqSu8542.png" alt="png" /></p>
|
||||
<p>IP 协议(Internet Protocol)是目前起到统治地位的网络层协议。IP 协议会将传输层的封包再次切分,得到 IP 封包。网络层负责实际将数据从一台主机传输到另一台主机(Host To Host),因此网络层需要区分主机的编号。</p>
|
||||
<p>在互联网上,我们用 IP 地址给主机进行编号。例如 IPv4 协议,将地址总共分成了四段,每段是 8 位,加起来是 32 位。寻找地址的过程类似我们从国家、城市、省份一直找到区县。当然还有特例,比如有的城市是直辖市,有的省份是一个特别行政区。而且国与国体制还不同,像美国这样的国家,一个州其实可以相当于一个国家。</p>
|
||||
<p>IP 协议里也有这个问题,类似行政区域划分,IP 协议中具体如何划分子网,需要配合<strong>子网掩码</strong>才能够明确。每一级网络都需要一个子网掩码,来定义网络子网的性质,相当于告诉物流公司到这一级网络该如何寻找目标地址,也就是寻址(Addressing)。关于更多子网掩码如何工作,及更多原理类的知识我会在拉勾教育的《<strong>计算机网络</strong>》专栏中和你分享。</p>
|
||||
@@ -278,13 +278,13 @@ function hide_canvas() {
|
||||
<h4>物理层</h4>
|
||||
<p>当数据在实际的设备间传递时,可能会用电线、电缆、光纤、卫星、无线等各种通信手段。因此,还需要一层将光电信号、设备差异封装起来,为数据链路层提供二进制传输的服务。这就是<strong>物理层(Physical Layer)。</strong></p>
|
||||
<p>因此,从下图中你可以看到,由上到下,互联网协议可以分成五层,分别是应用层、传输层、网络层、数据链路层和物理层。</p>
|
||||
<p><img src="assets/Cip5yF_4KLaAI5ILAADP48Fx5U4933.png" alt="Lark20210108-173930.png" /></p>
|
||||
<p><img src="assets/Cip5yF_4KLaAI5ILAADP48Fx5U4933.png" alt="png" /></p>
|
||||
<h3>多路复用</h3>
|
||||
<p>在上述的分层模型当中,一台机器上的应用可以有很多。但是实际的出口设备,比如说网卡、网线通常只有一份。因此这里需要用到一个叫作多路复用(Multiplex)的技术。多路复用,就是多个信号,复用一个信道。</p>
|
||||
<h4>传输层多路复用</h4>
|
||||
<p>对应用而言,应用层抽象应用之间通信的模型——比如说请求返回模型。一个应用可能会同时向服务器发送多个请求。因为建立一个连接也是需要开销的,所以可以多个请求复用一个 TCP 连接。复用连接一方面可以节省流量,另一方面能够降低延迟。如果应用<strong>串行地</strong>向服务端发送请求,那么假设第一个请求体积较大,或者第一个请求发生了故障,就会阻塞后面的请求。</p>
|
||||
<p>而使用多路复用技术,如下图所示,多个请求相当于并行的发送请求。即使其中某个请求发生故障,也不会阻塞其他请求。从这个角度看,多路复用实际上是一种 Non-Blocking(非阻塞)的技术。我们再来看下面这张图,不同的请求被传输层切片,我用不同的颜色区分出来,如果其中一个数据段(TCP Segment)发生异常,只影响其中一个颜色的请求,其他请求仍然可以到达服务。</p>
|
||||
<p><img src="assets/CgpVE1_4KL-AayuCAABq3jjcwtE543.png" alt="Lark20210108-173914.png" /></p>
|
||||
<p><img src="assets/CgpVE1_4KL-AayuCAABq3jjcwtE543.png" alt="png" /></p>
|
||||
<h4>网络层多路复用</h4>
|
||||
<p>传输层是一个虚拟的概念,但是网络层是实实在在的。两个应用之间的传输,可以建立无穷多个传输层连接,前提是你的资源足够。但是两个应用之间的线路、设备,需要跨越的网络往往是固定的。在我们的互联网上,每时每刻都有大量的应用在互发消息。而这些应用要复用同样的基础建设——网线、路由器、网关、基站等。</p>
|
||||
<p>网络层没有连接这个概念。你可以把网络层理解成是一个巨大的物流公司。不断从传输层接收数据,然后进行打包,每一个包是一个 IP 封包。然后这个物流公司,负责 IP 封包的收发。所以,是很多很多的传输层在共用底下同一个网络层,这就是网络层的多路复用。</p>
|
||||
|
||||
@@ -262,7 +262,7 @@ for(byte x in bytes) {
|
||||
}
|
||||
</code></pre>
|
||||
<p><code>xor</code>是异或运算。上面的程序在计算字节数组 bytes 的校验和。<code>c</code>是最终的结果。你可以看到将所有<code>bytes</code>两两异或,最终的结果就是校验和。假设我们要传输 bytes,如果在传输过程中<code>bytes</code>发生了变化,校验和有<strong>很大概率</strong>也会跟着变化。当然也可能存在<code>bytes</code>发生变化,校验和没有变化的特例,不过校验和可以很大程度上帮助我们识别数据是否损坏了。</p>
|
||||
<p><img src="assets/CgpVE1_-o5GADgRkAABcTgxXiyw544.png" alt="Lark20210113-153833.png" /></p>
|
||||
<p><img src="assets/CgpVE1_-o5GADgRkAABcTgxXiyw544.png" alt="png" /></p>
|
||||
<p>当要传输数据的时候,数据会被分片,我们把每个分片看作一个字节数组。然后在分片中,预留几个字节去存储校验和。校验和随着数据分片一起传输到目的地,目的地会用同样的算法再次计算校验和。如果二者校验和不一致,代表中途数据发生了损坏。</p>
|
||||
<p><strong>对于 TCP 和 UDP,都实现了校验和算法,但二者的区别是,TCP 如果发现校验核对不上,也就是数据损坏,会主动丢失这个封包并且重发。而 UDP 什么都不会处理,UDP 把处理的权利交给使用它的程序员</strong>。</p>
|
||||
<h4>请求/应答/连接模型</h4>
|
||||
@@ -274,7 +274,7 @@ for(byte x in bytes) {
|
||||
<p>在 TCP 协议当中。我们假设 Alice 和 Bob 是两个通信进程。当 Alice 想要和 Bob 建立连接的时候,Alice 需要发送一个请求建立连接的消息给 Bob。这种请求建立连接的消息在 TCP 协议中称为<strong>同步</strong>(<strong>Synchronization, SYN</strong>)。而 Bob 收到 SYN,必须马上给 Alice 一个响应。这个响应在 TCP 协议当中称为<strong>响应</strong>(<strong>Acknowledgement,ACK</strong>)。请你务必记住这两个单词。不仅是 TCP 在用,其他协议也会复用这样的概念,来描述相同的事情。</p>
|
||||
<p>当 Alice 给 Bob SYN,Bob 给 Alice ACK,这个时候,对 Alice 而言,连接就建立成功了。但是 TCP 是一个双工协议。所谓双工协议,代表数据可以双向传送。虽然对 Alice 而言,连接建立成功了。但是对 Bob 而言,连接还没有建立。为什么这么说呢?你可以这样思考,如果这个时候,Bob 马上给 Alice 发送信息,信息可能先于 Bob 的 ACK 到达 Alice,但这个时候 Alice 还不知道连接建立成功。 所以解决的办法就是 Bob 再给 Alice 发一次 SYN ,Alice 再给 Bob 一个 ACK。以上就是 TCP 的三次握手内容。</p>
|
||||
<p>你可能会问,这明明是<strong>四次握手,哪里是三次握手</strong>呢?这是因为,Bob 给 Alice 的 ACK ,可以和 Bob 向 Alice 发起的 SYN 合并,称为一条 SYN-ACK 消息。TCP 协议以此来减少握手的次数,减少数据的传输,于是 TCP 就变成了三次握手。下图中绿色标签状是 Alice 和 Bob 的状态,完整的 TCP 三次握手的过程如下图所示:</p>
|
||||
<p><img src="assets/CgpVE1_-o7OAHQP4AADkdEFtGfI902.png" alt="Lark20210113-153831.png" /></p>
|
||||
<p><img src="assets/CgpVE1_-o7OAHQP4AADkdEFtGfI902.png" alt="png" /></p>
|
||||
<p><strong>2. TCP 的四次挥手</strong></p>
|
||||
<p>四次挥手(TCP 断开连接)的原理类似。中断连接的请求我们称为 Finish(用 FIN 表示);和三次握手过程一样,需要分析成 4 步:</p>
|
||||
<ul>
|
||||
@@ -284,7 +284,7 @@ for(byte x in bytes) {
|
||||
<li>第 4 步是 Alice 给 ACK</li>
|
||||
</ul>
|
||||
<p>之所以是四次挥手,是因为第 2 步和 第 3 步在挥手的过程中不能合并为 FIN-ACK。原因是在挥手的过程中,Alice 和 Bob 都可能有未完成的工作。比如对 Bob 而言,可能还存在之前发给 Alice 但是还没有收到 ACK 的请求。因此,Bob 收到 Alice 的 FIN 后,就马上给 ACK。但是 Bob 会在自己准备妥当后,再发送 FIN 给 Alice。完整的过程如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F_-o7uASykoAAD1Eo7HnP4749.png" alt="Lark20210113-153824.png" /></p>
|
||||
<p><img src="assets/Ciqc1F_-o7uASykoAAD1Eo7HnP4749.png" alt="png" /></p>
|
||||
<p><strong>3. 连接</strong></p>
|
||||
<p>连接是一个虚拟概念,连接的目的是让连接的双方达成默契,倾尽资源,给对方最快的响应。经历了三次握手,Alice 和 Bob 之间就建立了连接。<strong>连接也是一个很好的编程模型。当连接不稳定的时候,可以中断连接后再重新连接。这种模式极大地增加了两个应用之间的数据传输的可靠性</strong>。</p>
|
||||
<p>以上就是 TCP 中存在的,而 UDP 中没有的机制,你可以仔细琢磨琢磨。</p>
|
||||
|
||||
@@ -251,7 +251,7 @@ function hide_canvas() {
|
||||
<p>为了弄清楚高并发网络场景是如何处理的,我们先来看一个最基本的内容:<strong>当数据到达网卡之后,操作系统会做哪些事情</strong>?</p>
|
||||
<p>网络数据到达网卡之后,首先需要把数据拷贝到内存。拷贝到内存的工作往往不需要消耗 CPU 资源,而是通过 DMA 模块直接进行内存映射。之所以这样做,是因为网卡没有大量的内存空间,只能做简单的缓冲,所以必须赶紧将它们保存下来。</p>
|
||||
<p>Linux 中用一个双向链表作为缓冲区,你可以观察下图中的 Buffer,看上去像一个有很多个凹槽的线性结构,每个凹槽(节点)可以存储一个封包,这个封包可以从网络层看(IP 封包),也可以从传输层看(TCP 封包)。操作系统不断地从 Buffer 中取出数据,数据通过一个协议栈,你可以把它理解成很多个协议的集合。协议栈中数据封包找到对应的协议程序处理完之后,就会形成 Socket 文件。</p>
|
||||
<p><img src="assets/Cip5yGABb8uAECMGAAERrnFoSrI090.png" alt="1111.png" />!</p>
|
||||
<p><img src="assets/Cip5yGABb8uAECMGAAERrnFoSrI090.png" alt="png" />!</p>
|
||||
<p>如果高并发的请求量级实在太大,有可能把 Buffer 占满,此时,操作系统就会拒绝服务。网络上有一种著名的攻击叫作<strong>拒绝服务攻击</strong>,就是利用的这个原理。<strong>操作系统拒绝服务,实际上是一种保护策略。通过拒绝服务,避免系统内部应用因为并发量太大而雪崩</strong>。</p>
|
||||
<p>如上图所示,传入网卡的数据被我称为 Frames。一个 Frame 是数据链路层的传输单位(或封包)。现代的网卡通常使用 DMA 技术,将 Frame 写入缓冲区(Buffer),然后在触发 CPU 中断交给操作系统处理。操作系统从缓冲区中不断取出 Frame,通过协进栈(具体的协议)进行还原。</p>
|
||||
<p>在 UNIX 系的操作系统中,一个 Socket 文件内部类似一个双向的管道。因此,非常适用于进程间通信。在网络当中,本质上并没有发生变化。网络中的 Socket 一端连接 Buffer, 一端连接应用——也就是进程。网卡的数据会进入 Buffer,Buffer 经过协议栈的处理形成 Socket 结构。通过这样的设计,进程读取 Socket 文件,可以从 Buffer 中对应节点读走数据。</p>
|
||||
@@ -259,19 +259,19 @@ function hide_canvas() {
|
||||
<p>以上就是我们对操作系统和网络接口交互的一个基本讨论。接下来,我们讨论一下作为一个编程模型的 Socket。</p>
|
||||
<h3>Socket 编程模型</h3>
|
||||
<p>通过前面讲述,我们知道 Socket 在操作系统中,有一个非常具体的从 Buffer 到文件的实现。但是对于进程而言,Socket 更多是一种编程的模型。接下来我们讨论作为编程模型的 Socket。</p>
|
||||
<p><img src="assets/Ciqc1GABP3OAHezqAABndlGAu9c457.png" alt="Lark20210115-150702.png" /></p>
|
||||
<p><img src="assets/Ciqc1GABP3OAHezqAABndlGAu9c457.png" alt="png" /></p>
|
||||
<p>如上图所示,Socket 连接了应用和协议,如果应用层的程序想要传输数据,就创建一个 Socket。应用向 Socket 中写入数据,相当于将数据发送给了另一个应用。应用从 Socket 中读取数据,相当于接收另一个应用发送的数据。而具体的操作就是由 Socket 进行封装。具体来说,<strong>对于 UNIX 系的操作系统,是利用 Socket 文件系统,Socket 是一种特殊的文件——每个都是一个双向的管道。一端是应用,一端是缓冲</strong>区。</p>
|
||||
<p>那么作为一个服务端的应用,如何知道有哪些 Socket 呢?也就是,哪些客户端连接过来了呢?这是就需要一种特殊类型的 Socket,也就是服务端 Socket 文件。</p>
|
||||
<p><img src="assets/Ciqc1GABP3qADKbBAAB564sk120429.png" alt="Lark20210115-150706.png" /></p>
|
||||
<p><img src="assets/Ciqc1GABP3qADKbBAAB564sk120429.png" alt="png" /></p>
|
||||
<p>如上图所示,当有客户端连接服务端时,服务端 Socket 文件中会写入这个客户端 Socket 的文件描述符。进程可以通过 accept() 方法,从服务端 Socket 文件中读出客户端的 Socket 文件描述符,从而拿到客户端的 Socket 文件。</p>
|
||||
<p>程序员实现一个网络服务器的时候,会先手动去创建一个服务端 Socket 文件。服务端的 Socket 文件依然会存在操作系统内核之中,并且会绑定到某个 IP 地址和端口上。以后凡是发送到这台机器、目标 IP 地址和端口号的连接请求,在形成了客户端 Socket 文件之后,文件的文件描述符都会被写入到服务端的 Socket 文件中。应用只要调用 accept 方法,就可以拿到这些客户端的 Socket 文件描述符,这样服务端的应用就可以方便地知道有哪些客户端连接了进来。</p>
|
||||
<p>而每个客户端对这个应用而言,都是一个文件描述符。如果需要读取某个客户端的数据,就读取这个客户端对应的 Socket 文件。如果要向某个特定的客户端发送数据,就写入这个客户端的 Socket 文件。</p>
|
||||
<p>以上就是 Socket 的编程模型。</p>
|
||||
<h3>I/O 多路复用</h3>
|
||||
<p>在上面的讨论当中,进程拿到了它关注的所有 Socket,也称作关注的集合(Intersting Set)。如下图所示,这种过程相当于进程从所有的 Socket 中,筛选出了自己关注的一个子集,但是这时还有一个问题没有解决:<strong>进程如何监听关注集合的状态变化,比如说在有数据进来,如何通知到这个进程</strong>?</p>
|
||||
<p><img src="assets/Ciqc1GABP4OAdKBcAACAbVkbI0g191.png" alt="Lark20210115-150708.png" /></p>
|
||||
<p><img src="assets/Ciqc1GABP4OAdKBcAACAbVkbI0g191.png" alt="png" /></p>
|
||||
<p>其实更准确地说,一个线程需要处理所有关注的 Socket 产生的变化,或者说消息。实际上一个线程要处理很多个文件的 I/O。<strong>所有关注的 Socket 状态发生了变化,都由一个线程去处理,构成了 I/O 的多路复用问题</strong>。如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1GABP4uAW8-dAAB_SubmZ4Q301.png" alt="Lark20210115-150711.png" /></p>
|
||||
<p><img src="assets/Ciqc1GABP4uAW8-dAAB_SubmZ4Q301.png" alt="png" /></p>
|
||||
<p>处理 I/O 多路复用的问题,需要操作系统提供内核级别的支持。Linux 下有三种提供 I/O 多路复用的 API,分别是:</p>
|
||||
<ul>
|
||||
<li>select</li>
|
||||
@@ -279,7 +279,7 @@ function hide_canvas() {
|
||||
<li>epoll</li>
|
||||
</ul>
|
||||
<p>如下图所示,内核了解网络的状态。因此不难知道具体发生了什么消息,比如内核知道某个 Socket 文件状态发生了变化。但是内核如何知道该把哪个消息给哪个进程呢?</p>
|
||||
<p><img src="assets/Ciqc1GABP5KAVSWVAAFSurtl2bU931.png" alt="Lark20210115-150654.png" /></p>
|
||||
<p><img src="assets/Ciqc1GABP5KAVSWVAAFSurtl2bU931.png" alt="png" /></p>
|
||||
<p><strong>一个 Socket 文件,可以由多个进程使用;而一个进程,也可以使用多个 Socket 文件</strong>。进程和 Socket 之间是多对多的关系。<strong>另一方面,一个 Socket 也会有不同的事件类型</strong>。因此操作系统很难判断,将哪样的事件给哪个进程。</p>
|
||||
<p>这样<strong>在进程内部就需要一个数据结构来描述自己会关注哪些 Socket 文件的哪些事件(读、写、异常等</strong>)。通常有两种考虑方向,<strong>一种是利用线性结构</strong>,比如说数组、链表等,这类结构的查询需要遍历。每次内核产生一种消息,就遍历这个线性结构。看看这个消息是不是进程关注的?<strong>另一种是索引结构</strong>,内核发生了消息可以通过索引结构马上知道这个消息进程关不关注。</p>
|
||||
<h4>select()</h4>
|
||||
|
||||
@@ -262,7 +262,7 @@ function hide_canvas() {
|
||||
<h3>数字签名和证书</h3>
|
||||
<p>在计算机中,数字签名是一种很好的实现签名(模拟现实世界中签名)的方式。 所谓数字签名,就是对摘要进行加密形成的密文。</p>
|
||||
<p>举个例子:现在 Alice 和 Bob 签合同。Alice 首先用 SHA 算法计算合同的摘要,然后用自己私钥将摘要加密,得到数字签名。Alice 将合同原文、签名,以及公钥三者都交给 Bob。如下图所示:</p>
|
||||
<p><img src="assets/CgqCHmAH6jSAER_BAACprlu8LmA391.png" alt="Lark20210120-162725.png" /></p>
|
||||
<p><img src="assets/CgqCHmAH6jSAER_BAACprlu8LmA391.png" alt="png" /></p>
|
||||
<p>Bob 如果想证明合同是 Alice 的,就要用 Alice 的公钥,将签名解密得到摘要 X。然后,Bob 计算原文的 SHA 摘要 Y。Bob 对比 X 和 Y,如果 X = Y 则说明数据没有被篡改过。</p>
|
||||
<p>在这样的一个过程当中,Bob 不能篡改 Alice 合同。因为篡改合同不但要改原文还要改摘要,而摘要被加密了,如果要重新计算摘要,就必须提供 Alice 的私钥。所谓私钥,就是 Alice 独有的密码。所谓公钥,就是 Alice 公布给他人使用的密码。</p>
|
||||
<p><strong>公钥加密的数据,只有私钥才可以解密。私钥加密的数据,只有公钥才可以解密</strong>。这样的加密方法我们称为<strong>非对称加密</strong>,基于非对称加密算法建立的安全体系,也被称作<strong>公私钥体系</strong>。用这样的方法,签约双方都不可以篡改合同。</p>
|
||||
@@ -270,12 +270,12 @@ function hide_canvas() {
|
||||
<p>但是在上面描述的过程当中,仍然存在着一个非常明显的信任风险。这个风险在于,Alice 虽然不能篡改合同,但是可以否认给过 Bob 的公钥和合同。这样,尽管合同双方都不可以篡改合同本身,但是双方可以否认签约行为本身。</p>
|
||||
<p>如果要解决这个问题,那么 Alice 提供的公钥,必须有足够的信誉。这就需要引入第三方机构和证书机制。</p>
|
||||
<p><strong>证书为公钥提供方提供公正机制</strong>。证书之所以拥有信用,是因为证书的签发方拥有信用。假设 Alice 想让 Bob 承认自己的公钥。Alice 不能把公钥直接给 Bob,而是要提供第三方公证机构签发的、含有自己公钥的证书。如果 Bob 也信任这个第三方公证机构,信任关系和签约就成立。当然,法律也得承认,不然没法打官司。</p>
|
||||
<p><img src="assets/CgpVE2AH6j6ASBKvAADJu5B4-Bc773.png" alt="Lark20210120-162728.png" /></p>
|
||||
<p><img src="assets/CgpVE2AH6j6ASBKvAADJu5B4-Bc773.png" alt="png" /></p>
|
||||
<p>如上图所示,Alice 将自己的申请提交给机构,产生证书的原文。机构用自己的私钥签名 Alice 的申请原文(先根据原文内容计算摘要,再用私钥加密),得到带有签名信息的证书。Bob 拿到带签名信息的证书,通过第三方机构的公钥进行解密,获得 Alice 证书的摘要、证书的原文。有了 Alice 证书的摘要和原文,Bob 就可以进行验签。验签通过,Bob 就可以确认 Alice 的证书的确是第三方机构签发的。</p>
|
||||
<p>用上面这样一个机制,合同的双方都无法否认合同。这个解决方案的核心在于<strong>需要第三方信用服务机构提供信用背书</strong>。这里产生了一个最基础的信任链,如果第三方机构的信任崩溃,比如被黑客攻破,那整条信任链条也就断裂了。</p>
|
||||
<h3>信任链</h3>
|
||||
<p>为了固化信任关系,减少风险。最合理的方式就是<strong>在互联网中打造一条更长的信任链,环环相扣,避免出现单点的信任风险</strong>。</p>
|
||||
<p><img src="assets/Cip5yGAH6kWAEWq5AABj5AWYCbQ099.png" alt="Lark20210120-162730.png" /></p>
|
||||
<p><img src="assets/Cip5yGAH6kWAEWq5AABj5AWYCbQ099.png" alt="png" /></p>
|
||||
<p>上图中,由信誉最好的根证书机构提供根证书,然后根证书机构去签发二级机构的证书;二级机构去签发三级机构的证书;最后有由三级机构去签发 Alice 证书。</p>
|
||||
<ul>
|
||||
<li>如果要验证 Alice 证书的合法性,就需要用三级机构证书中的公钥去解密 Alice 证书的数字签名。</li>
|
||||
@@ -286,7 +286,7 @@ function hide_canvas() {
|
||||
<h3>中间人攻击</h3>
|
||||
<p>最后我们再来说说中间人攻击。在 HTTPS 协议当中,客户端需要先从服务器去下载证书,然后再通过信任链验证服务器的证书。当证书被验证为有效且合法时,客户端和服务器之间会利用非对称加密协商通信的密码,双方拥有了一致的密码和加密算法之后,客户端和服务器之间会进行对称加密的传输。</p>
|
||||
<p>在上述过程当中,要验证一个证书是否合法,就必须依据信任链,逐级的下载证书。但是根证书通常不是下载的,它往往是随着操作系统预安装在机器上的。如果黑客能够通过某种方式在你的计算机中预装证书,那么黑客也可以伪装成中间节点。如下图所示:</p>
|
||||
<p><img src="assets/CgpVE2AH6kyAHNWzAABv6F_xIJU589.png" alt="Lark20210120-162718.png" /></p>
|
||||
<p><img src="assets/CgpVE2AH6kyAHNWzAABv6F_xIJU589.png" alt="png" /></p>
|
||||
<p>一方面,黑客向客户端提供伪造的证书,并且这个伪造的证书会在客户端中被验证为合法。因为黑客已经通过其他非法手段在客户端上安装了证书。举个例子,比如黑客利用 U 盘的自动加载程序,偷偷地将 U 盘插入客户端机器上一小段时间预装证书。</p>
|
||||
<p>安装证书后,黑客一方面和客户端进行正常的通信,另一方面黑客和服务器之间也建立正常的连接。这样黑客在中间就可以拿到客户端到服务器的所有信息,并从中获利。</p>
|
||||
<h3>总结</h3>
|
||||
|
||||
@@ -263,7 +263,7 @@ function hide_canvas() {
|
||||
<p><strong>仿真(Simulation)指的是用起来像一台真的机器那样,包括开机、关机,以及各种各样的硬件设备</strong>。在虚拟机上执行的操作系统认为自己就是在实体机上执行。仿真主要的贡献是**让进程可以无缝的迁移,**也就是让虚拟机中执行的进程,真实地感受到和在实体机上执行是一样的——这样程序从虚拟机到虚拟机、实体机到虚拟机的应用迁移,就不需要修改源代码。</p>
|
||||
<p><strong>高效(Efficient)的目标是减少虚拟机对 CPU、对硬件资源的占用</strong>。通常在虚拟机上执行指令需要额外负担10~15% 的执行成本,这个开销是相对较低的。因为应用通常很少将 CPU 真的用满,在容器中执行 CPU 指令开销会更低更接近在本地执行程序的速度。</p>
|
||||
<p>为了实现上述的三种诉求,最直观的方案就是将虚拟机管理程序 Hypervisor 作为操作系统,在虚拟机管理程序(Hypervisor)之上再去构建更多的虚拟机。像这种管理虚拟机的架构,也称为 Type-1 虚拟机,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHmARNXqAXohgAACmFoEZ15k793.png" alt="Lark20210127-174143.png" /></p>
|
||||
<p><img src="assets/CgqCHmARNXqAXohgAACmFoEZ15k793.png" alt="png" /></p>
|
||||
<p>我们通常把虚拟机管理程序(Virtual Machine Monitor,VMM)称为 Hypervisor。在 Type-1 虚拟机中,Hypervisor一方面作为操作系统管理硬件,另一方面作为虚拟机的管理程序。在Hypervisor之上创建多个虚拟机,每个虚拟机可以拥有不同的操作系统(Guest OS)。</p>
|
||||
<h4>二进制翻译</h4>
|
||||
<p>通常硬件的设计假定是由单操作系统管理的。如果多个操作系统要共享这些设备,就需要通过 Hypervisor。当操作系统需要执行程序的时候,程序的指令就通过 Hypervisor 执行。早期的虚拟机设计当中,Hypervisor 不断翻译来自虚拟机的程序指令,将它们翻译成可以适配在目标硬件上执行的指令。这样的设计,我们称为二进制翻译。</p>
|
||||
@@ -273,15 +273,15 @@ function hide_canvas() {
|
||||
<p>为了实现世界切换,虚拟机上的操作系统需要使用硬件设备,比如内存管理单元(MMR)、TLB、DMA 等。这些设备都需要支持虚拟机上操作系统的使用,比如说 TLB 需要区分是虚拟机还是实体机程序。虽然可以用软件模拟出这些设备给虚拟机使用,但是如果能让虚拟机使用真实的设备,性能会更好。现在的 CPU 通常都支持虚拟化技术,比如 Intel 的 VT-X 和 AMD 的 AMD-V(也称作 Secure Virtual Machine)。如果你对硬件虚拟化技术非常感兴趣,可以阅读<a href="https://www.mimuw.edu.pl/~vincent/lecture6/sources/amd-pacifica-specification.pdf">这篇文档</a>。</p>
|
||||
<h4>Type-2 虚拟机</h4>
|
||||
<p>Type-1 虚拟机本身是一个操作系统,所以需要用户预装。为了方便用户的使用,VMware 还推出了 Type-2 虚拟机,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1GARNYSAKM46AADCxGGyD4s927.png" alt="Lark20210127-174145.png" /></p>
|
||||
<p><img src="assets/Ciqc1GARNYSAKM46AADCxGGyD4s927.png" alt="png" /></p>
|
||||
<p>在第二种设计当中,虚拟机本身也作为一个进程。它和操作系统中执行的其他进程并没有太大的区别。但是<strong>为了提升性能,有一部分 Hypervisor 程序会作为内核中的驱动执行</strong>。<strong>当虚拟机操作系统(Guest OS)执行程序的时候,会通过 Hypervisor 实现世界切换</strong>。因此,虽然和 Type-1 虚拟机有一定的区别,但是从本质上来看差距不大,同样是需要二进制翻译技术和虚拟化技术。</p>
|
||||
<h4>Hyper-V</h4>
|
||||
<p>随着虚拟机的发展,现在也出现了很多混合型的虚拟机,比如微软的 Hyper-v 技术。从下图中你会看到,虚拟机的管理程序(Parent Partition)及 Windows 的核心程序,都会作为一个虚拟化的节点,拥有一个自己的 VMBus,并且通过 Hypervisor 实现虚拟化。</p>
|
||||
<p><img src="assets/Ciqc1GARNYuAUFMRAAF9ae1ZQyE404.png" alt="Lark20210127-174148.png" /></p>
|
||||
<p><img src="assets/Ciqc1GARNYuAUFMRAAF9ae1ZQyE404.png" alt="png" /></p>
|
||||
<p>在 Hyper-V 的架构当中不存在一个主的操作系统。实际上,用户开机之后就在使用虚拟机,Windows 通过虚拟机执行。在这种架构下,其他的虚拟机,比如用 VMware 管理的虚拟机也可以复用这套架构。当然,你也可以直接把 Linux 安装在 Hyper-V 下,只不过安装过程没有 VMWare 傻瓜化,其实也是很不错的选择。</p>
|
||||
<h3>容器(Container)</h3>
|
||||
<p><strong>虚拟机虚拟的是计算机,容器虚拟的是执行环境</strong>。每个容器都是一套独立的执行环境,如下图所示,容器直接被管理在操作系统之内,并不需要一个虚拟机监控程序。</p>
|
||||
<p><img src="assets/Ciqc1GARNZOAM0V8AAExEgSEXPg097.png" alt="Lark20210127-174137.png" /></p>
|
||||
<p><img src="assets/Ciqc1GARNZOAM0V8AAExEgSEXPg097.png" alt="png" /></p>
|
||||
<p><strong>和虚拟机有一个最大的区别就是:容器是直接跑在操作系统之上的,容器内部是应用,应用执行起来就是进程</strong>。这个进程和操作系统上的其他进程也没有本质区别,但这个架构设计没有了虚拟机监控系统。当然,容器有一个更轻量级的管理程序,用户可以从网络上下载镜像,启动起来就是容器。容器中预装了一些程序,比如说一个 Python 开发环境中,还会预装 Web 服务器和数据库。因为没有了虚拟机管理程序在中间的开销,因而性能会更高。而且因为不需要安装操作系统,因此容器安装速度更快,可以达到 ms 级别。</p>
|
||||
<p><strong>容器依赖操作系统的能力直接实现,比如:</strong></p>
|
||||
<ul>
|
||||
|
||||
@@ -254,7 +254,7 @@ function hide_canvas() {
|
||||
<p>其实没有不可再分,永远都可以继续拆分下去。只不过从逻辑上讲,系统的拆分,应该结合公司部门组织架构的调整,反映公司的战斗结构编排。但总的来说,互联网上的服务越来越复杂,几个简单的接口就可能形成一个服务,这些服务都要上线。如果用实体机来承载这些服务,开销太大。如果用虚拟机来承载这些服务倒是不错的选择,但是创建服务的速度太慢,不适合今天这个时代的研发者们。</p>
|
||||
<p>试想你的系统因为服务太多,该如何管理?尤其是在大型的公司,员工通过自发组织架构评审就可以上线微服务——天长日久,微服务越来越多,可能会有几万个甚至几十万个。那么这么多的微服务,如何分布到数万台物理机上工作呢?</p>
|
||||
<p>如下图所示,为了保证微服务之间是隔离的,且可以快速上线。每个微服务我们都使用一个单独的容器,而一组容器,又包含在一个虚拟机当中,具体的关系如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1GAT7LeAJznRAAKOxTSise8097.png" alt="Lark20210129-190005.png" /></p>
|
||||
<p><img src="assets/Ciqc1GAT7LeAJznRAAKOxTSise8097.png" alt="png" /></p>
|
||||
<p>上图中的微服务 C 因为只有一个实例存在单点风险,可能会引发单点故障。因此需要为微服务 C 增加副本,通常情况下,我们必须保证每个微服务至少有一个副本,这样才能保证可用性。</p>
|
||||
<p>上述架构的核心就是要解决两个问题:</p>
|
||||
<ol>
|
||||
@@ -266,26 +266,26 @@ function hide_canvas() {
|
||||
<p>Kubernetes(K8s)是一个 Google 开源的容器编排方案。</p>
|
||||
<h4>节点(Master&Worker)</h4>
|
||||
<p><strong>K8s 通过集群管理容器</strong>。用户可以通过命令行、配置文件管理这个集群——从而编排容器;用户可以增加节点进行扩容,每个节点是一台物理机或者虚拟机。如下图所示,Kubernetes 提供了两种分布式的节点。Master 节点是集群的管理者,Worker 是工作节点,容器就在 Worker 上工作,一个 Worker 的内部可以有很多个容器。</p>
|
||||
<p><img src="assets/CgqCHmAT7M-Af_RTAAKzmD-Lpm0018.png" alt="Lark20210129-190008.png" /></p>
|
||||
<p><img src="assets/CgqCHmAT7M-Af_RTAAKzmD-Lpm0018.png" alt="png" /></p>
|
||||
<p>在我们为一个微服务扩容的时候,首选并不是去增加 Worker 节点。可以增加这个微服务的容器数量,也可以提升每个容器占用的 CPU、内存存储资源。只有当整个集群的资源不够用的时候,才会考虑增加机器、添加节点。</p>
|
||||
<p>Master 节点至少需要 2 个,但并不是越多越好。Master 节点主要是管理集群的状态数据,不需要很大的内存和存储空间。Worker 节点根据集群的整体负载决定,一些大型网站还有弹性扩容的手段,也可以通过 K8s 实现。</p>
|
||||
<h4>单点架构</h4>
|
||||
<p>接下来我们讨论一下 Worker 节点的架构。所有的 Worker 节点上必须安装 kubelet,它是节点的管理程序,负责在节点上管理容器。</p>
|
||||
<p>Pod 是 K8s 对容器的一个轻量级的封装,每个 Pod 有自己独立的、随机分配的 IP 地址。Pod 内部是容器,可以 1 个或多个容器。目前,Pod 内部的容器主要是 Docker,但是今后可能还会有其他的容器被大家使用,主要原因是 K8s 和 Docker 的生态也存在着竞争关系。总的来说,如下图所示,kubelet 管理 Pod,Pod 管理容器。当用户创建一个容器的时候,实际上在创建 Pod。</p>
|
||||
<p><img src="assets/Ciqc1GAT7NyAI0-5AAEh6UfvPpY109.png" alt="Lark20210129-190011.png" /></p>
|
||||
<p><img src="assets/Ciqc1GAT7NyAI0-5AAEh6UfvPpY109.png" alt="png" /></p>
|
||||
<p>虽然 K8s 允许同样的应用程序(比如微服务),在一个节点上创建多个 Pod。但是为了保证可用性,通常我们会考虑将微服务分散到不同的节点中去。如下图所示,如果其中一个节点宕机了,微服务 A,微服务 B 还能正常工作。当然,有一些微服务。因为程序架构或者编程语言的原因,只能使用单进程。这个时候,我们也可能会在单一的节点上部署多个相同的服务,去利用更多的 CPU 资源。</p>
|
||||
<p><img src="assets/CgqCHmAT7OaAeadYAAJEm88_Xg8398.png" alt="Lark20210129-190014.png" /></p>
|
||||
<p><img src="assets/CgqCHmAT7OaAeadYAAJEm88_Xg8398.png" alt="png" /></p>
|
||||
<h4>负载均衡</h4>
|
||||
<p>Pod 的 IP 地址是动态的,如果要将 Pod 作为内部或者外部的服务,那么就需要一个能拥有静态 IP 地址的节点,这种节点我们称为服务(Service),服务不是一个虚拟机节点,而是一个虚拟的概念——或者理解成一段程序、一个组件。请求先到达服务,然后再到达 Pod,服务在这之间还提供负载均衡。当有新的 Pod 加入或者旧的 Pod 被删除,服务可以捕捉到这些状态,这样就大大降低了分布式应用架构的复杂度。</p>
|
||||
<p><img src="assets/CgqCHmAT7PeAZRvoAACjdnGXVe0743.png" alt="Lark20210129-190001.png" /></p>
|
||||
<p><img src="assets/CgqCHmAT7PeAZRvoAACjdnGXVe0743.png" alt="png" /></p>
|
||||
<p>如上图所示,当我们要提供服务给外部使用时,对安全的考虑、对性能的考量是超过内部服务的。 K8s 解决方案:在服务的上方再提供薄薄的一层控制程序,为外部提供服务——这就是 Ingress。</p>
|
||||
<p>以上,就是 K8s 的整体架构。 在使用的过程当中,相信你会感受到这个工具的魅力。比如说组件非常齐全,有数据加密、网络安全、单机调试、API 服务器等。如果你想了解更多的内容,可以查看<a href="https://kubernetes.io/docs/concepts/overview/">这些资料</a>。</p>
|
||||
<h3>Docker Swarm</h3>
|
||||
<p>Docker Swarm 是 Docker 团队基于 Docker 生态打造的容器编排引擎。下图是 Docker Swarm 整体架构图。</p>
|
||||
<p><img src="assets/CgqCHmAT7QaAcwO7AAJWW_dhVAU264.png" alt="Drawing 5.png" /></p>
|
||||
<p><img src="assets/CgqCHmAT7QaAcwO7AAJWW_dhVAU264.png" alt="png" /></p>
|
||||
<p>和 K8s 非常相似,节点被分成了 Manager 和 Worker。Manager 之间的状态数据通过 Raft 算法保证数据的一致性,Worker 内部是 Docker 容器。</p>
|
||||
<p>和 K8s 的 Pod 类似,Docker Swarm 对容器进行了一层轻量级的封装——任务(Task),然后多个Task 通过服务进行负载均衡。</p>
|
||||
<p><img src="assets/Ciqc1GAT7RCAYw67AAGVRE-fcmY185.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/Ciqc1GAT7RCAYw67AAGVRE-fcmY185.png" alt="png" /></p>
|
||||
<h3>容器编排设计思考</h3>
|
||||
<p>这样的设计,用户只需要指定哪些容器开多少个副本,容器编排引擎自动就会在工作节点之中复制这些容器。而服务是容器的分组,多个容器共享一个服务。容器自动被创建,用户在维护的时候不需要维护到容器创建级别,只需要指定容器数目,并指定这类型的容器对应着哪个服务。至于之后,哪一个容器中的程序执行出错,编排引擎就会杀死这个出错的容器,并且重启一个新的容器。</p>
|
||||
<p>在这样的设计当中,容器最好是<strong>无状态</strong>的,所以容器中最好不要用来运行 MySQL 这样的数据库。对于 MySQL 数据库,并不是多个实例都可以通过负载均衡来使用。有的实例只可以读,有的实例只可以写,中间还有 Binlog 同步。因此,虽然 K8s 提供了状态管理组件,但是使用起来可能不如虚拟机划算。</p>
|
||||
|
||||
Reference in New Issue
Block a user