PART0:OS,这货到底是个啥?

  • OS,是个啥?
  • OS的结构们:
    • 存储器:

      • 存储器的层次结构:
      • 内存:我们的程序和数据都是存储在内存,我们的程序和数据都是存储在内存,每一个字节都对应一个内存地址。内存用的芯片和 CPU Cache 有所不同,它使用的是一种叫作 DRAM (Dynamic Random Access Memory,动态随机存取存储器) 的芯片。内存用的芯片和 CPU Cache 有所不同,它使用的是一种叫作 DRAM (Dynamic Random Access Memory,动态随机存取存储器) 的芯片。DRAM 存储一个 bit 数据,只需要一个晶体管和一个电容就能存储,但是因为数据会被存储在电容里,电容会不断漏电,所以需要「定时刷新」电容,才能保证数据不会被丢失,这就是 DRAM 之所以被称为「动态」存储器的原因,只有不断刷新,数据才能被存储起来
      • 寄存器:CPU 中的寄存器,处理速度是最快的,但是能存储的数据也是最少的
      • CPU Cache:中文称为 CPU 高速缓存,CPU Cache 用的是一种叫 SRAM(Static Random-Access Memory,静态随机存储器,之所以叫静态就是因为只要有电数据就可以保持存在,一旦断电数据就会丢失) 的芯片。处理速度相比寄存器慢了一点,但是能存储的数据也稍微多了一些。CPU Cache 通常会分为 L1、L2、L3 三层,其中 L1 Cache 通常分成数据缓存和指令缓存,L1 是距离 CPU 最近的,因此它比 L2、L3 的读写速度都快、存储空间都小

        • 寄存器和 CPU Cache 都是在 CPU 内部,跟 CPU 挨着很近,因此它们的读写速度都相当的/快,但是能存储的数据很少。CPU 里的寄存器和 Cache,是整个计算机存储器中价格最贵的,虽然存储空间很小,但是读写速度是极快的,而相对比较便宜的内存和硬盘,速度肯定比不上 CPU 内部的存储器,但是能弥补存储空间的不足。

          • L1:L1 高速缓存的访问速度几乎和寄存器一样快,通常只需要 2~4 个时钟周期,而大小在几十 KB 到几百 KB 不等。每个 CPU 核心都有一块属于自己的 L1 高速缓存,指令和数据在 L1 是分开存放的,所以 L1 高速缓存通常分成指令缓存和数据缓存。它两的大小通常是一样的

            • 比如 1+1=2 这个运算,+ 就是指令,会被放在「指令缓存」中,而输入数字 1 则会被放在「数据缓存」里。
          • L2:L2 高速缓存同样每个 CPU 核心都有,大小比 L1 高速缓存更大,CPU 型号不同大小也就不同,通常大小在几百 KB 到几 MB 不等,访问速度则更慢,速度在 10~20 个时钟周期。
          • L3:L3 高速缓存通常是多个 CPU 核心共用的,位置比 L2 高速缓存距离 CPU 核心 更远,大小也会更大些,通常大小在几 MB 到几十 MB 不等,访问速度相对也比较慢一些,访问速度在 20~60个时钟周期。
        • CPU Cache 是由很多个 Cache Line 组成的,CPU Line 是 CPU 从内存读取数据的基本单位【CPU 从内存中读取数据到 Cache 的时候,并不是一个字节一个字节读取,而是一块一块的方式来读取数据的,这一块一块的数据被称为 CPU Line(缓存行),所以 CPU Line 是 CPU 从内存读取数据到 Cache 的单位,L1 Cache 一次载入数据的大小是 64 字节。】,而 CPU Line 是由各种标志(Tag)+ 数据块(Data Block)组成
        • CPU Cache 的数据结构和读取过程:CPU Cache 的数据是从内存中读取过来的,它是以一小块一小块读取数据的而不是按照单个数组元素来读取数据的,在 CPU Cache 中的,这样一小块一小块的数据,称为 Cache Line(缓存块)
          • 缓存块:CPU 读取数据的时候,无论数据是否存放到 Cache 中,CPU 都是先访问 Cache,只有当 Cache 中找不到数据时,才会去访问内存,并把内存中的数据读入到 Cache 中,CPU 再从 CPU Cache 读取数据

            • 比如,有一个 int array[100] 的数组,当载入 array[0] 时,由于这个数组元素的大小在内存只占 4 字节,不足 64 字节,CPU 就会顺序加载数组元素到 array[15],意味着 array[0]~array[15] 数组元素都会被缓存在 CPU Cache 中了【又比如,当 CPU 访问内存数据时,如果数据不在 CPU Cache 中,则会一次性会连续加载 64 字节大小的数据到 CPU Cache,那么当访问 array[0][0] 时,由于该元素不足 64 字节,于是就会往后顺序读取 array[0][0]~array[0][15] 到 CPU Cache 中。顺序访问的 array[i][j] 因为利用了这一特点,所以就会比跳跃式访问的 array[j][i] 要快。】,因此当下次访问这些数组元素时,会直接从 CPU Cache 读取,而不用再从内存中读取,大大提高了 CPU 读取数据的性能。
            • CPU Line 的三个信息:
              • 组标记:使用取模方式映射的话,就会出现多个内存块对应同一个 CPU Line。为了区别不同的内存块,在对应的 CPU Line 中我们还会存储一个组标记(Tag)。这个组标记会记录当前 CPU Line 中存储的数据对应的内存块,我们可以用这个组标记来区分不同的内存块。
              • 数据:从内存加载过来的实际存放数据(Data)。
              • 有效位(Valid bit):用来标记对应的 CPU Line 中的数据是否是有效的,如果有效位是 0,无论 CPU Line 中是否有数据,CPU 都会直接访问内存,重新加载数据。
          • CPU 访问内存数据时,是一小块一小块数据读取的,具体这一小块数据的大小,取决于 coherency_line_size 的值,一般 64 字节在内存中,这一块的数据我们称为内存块(Block),读取的时候我们要拿到数据所在内存块的地址。常见的通过内存地址找到 CPU Cache 中的数据的策略有以下几种
            • 直接映射:对于直接映射 Cache 采用的策略,就是 把内存块的地址始终映射在一个 CPU Line(缓存块) 的地址,映射是通过取模运算实现的,取模运算的结果就是内存块地址对应的 CPU Line(缓存块) 的地址。比如内存共被划分为 32 个内存块,CPU Cache 共有 8 个 CPU Line,假设 CPU 想要访问第 15 号内存块,如果 15 号内存块中的数据已经缓存在 CPU Line 中的话,则是一定映射在 7 号 CPU Line 中,因为 15 % 8 的值是 7
            • 全相连 Cache (Fully Associative Cache)
            • 组相连 Cache (Set Associative Cache)
          • CPU 在从 CPU Cache 读取数据的时候,并 不是读取 CPU Line 中的整个数据块,而是读取 CPU 所需要的一个数据片段,这样的数据统称为一个字(Word)。所以我们就得通过偏移量在对应的CPU Line中找到所需的字。因此,一个内存的访问地址,包括组标记、CPU Line 索引、偏移量这三种信息,于是 CPU 就能通过这些信息,在 CPU Cache 中找到缓存的数据
          • 如何提升多核 CPU 的缓存命中率
            • 在单核 CPU,虽然只能执行一个线程,但是操作系统给每个线程分配了一个时间片,时间片用完了,就调度下一个线程,于是各个线程就按时间片交替地占用 CPU,从宏观上看起来各个线程同时在执行。而现代 CPU 都是多核心的,线程可能在不同 CPU 核心来回切换执行,这对 CPU Cache 不是有利的,虽然 L3 Cache 是多核心之间共享的,但是 L1 和 L2 Cache 都是每个核心独有的,如果一个线程在不同核心来回切换,各个核心的缓存命中率就会受到影响,相反如果线程都在同一个核心上执行,那么其数据的 L1 和 L2 Cache 的缓存命中率可以得到有效提高,缓存命中率高就意味着 CPU 可以减少访问 内存的频率。在 Linux 上提供了 sched_setaffinity 方法,当有多个同时执行「计算密集型」的线程,为了防止因为切换到不同的核心,而导致缓存命中率下降的问题,我们可以把线程绑定在某一个 CPU 核心上,这样性能可以得到非常可观的提升
        • 读操作与写操作中,如果数据写入 Cache 之后,内存与 Cache 相对应的数据将会不同,这种情况下 Cache 和内存数据都不一致了,于是我们肯定是要把 Cache 中的数据同步到内存里的。
          • 两种写入数据的方法:

            • 写直达(Write Through):把数据同时写入内存和 Cache 中,写入前会先判断数据是否已经在 CPU Cache 里面了:如果数据已经在 Cache 里面,先将数据更新到 Cache 里面,再写入到内存里面;如果数据没有在 Cache 里面,就直接把数据更新到内存里面,这种方法缺陷很明显:无论数据在不在 Cache 里面,每次写操作都会写回到内存,这样写操作将会花费大量的时间,无疑性能会受到很大的影响。
            • 写回(Write Back):为了要减少数据写回内存的频率,在写回机制中,当发生写操作时,新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block被替换时才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。【写回这个方法,在把数据写入到 Cache 的时候,只有在缓存不命中,同时数据对应的 Cache 中的 Cache Block 为脏标记的情况下,才会将数据写到内存中,而在缓存命中的情况下,则在写入后 Cache 后,只需把该数据对应的 Cache Block 标记为脏即可,而不用写到内存里。这样的好处是,如果我们大量的操作都能够命中缓存,那么大部分时间里 CPU 都不需要读写内存,自然性能相比写直达会高很多。】
              • 如果当发生写操作时,数据已经在 CPU Cache 里的话,则把数据更新到 CPU Cache 里,同时标记 CPU Cache 里的这个 Cache Block 为脏(Dirty)的,这个脏的标记代表这个时候,我们 CPU Cache 里面的这个 Cache Block 的数据和内存是不一致的,这种情况是不用把数据写到内存里的;
              • 如果当发生写操作时,数据所对应的 Cache Block 里存放的是「别的内存地址的数据」的话,就要检查这个 Cache Block 里的数据有没有被标记为脏的,如果是脏的话,我们就要把这个 Cache Block 里的数据写回到内存,然后再把当前要写入的数据,先从内存读入到 Cache Block 里。,然后再写入的数据写入到 Cache Block,最后也把它标记为脏的;如果 Cache Block 里面的数据没有被标记为脏,则就直接将数据写入到这个 Cache Block 里,然后再把这个 Cache Block 标记为脏的就好了。
        • 由于 L1/L2 Cache 是多个核心各自独有的,那么会带来多核心的 缓存一致性(Cache Coherence) 的问题,如果不能保证缓存一致性的问题,就可能造成结果错误。

          • 利用同步两个不同核心里面的缓存数据来解决缓存一致性问题的机制就是
          • 要实现的这个机制的话,要保证做到下面这 2 点:
            • 第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播【要把该事件广播通知到其他核心】到其他核心的 Cache,这个称为写传播(Write Propagation);
            • 写传播最常见的实现方式就是总线嗅探(Bus Snooping)
              • CPU 需要每时每刻监听总线上的一切活动,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载【比如当 A 号 CPU 核心修改了 L1 Cache 中 i 变量的值,通过总线把这个事件广播通知给其他所有的核心,然后每个 CPU 核心都会监听总线上的广播事件,并检查是否有相同的数据在自己的 L1 Cache 里面,如果 B 号 CPU 核心的 L1 Cache 中有该数据,那么也需要把该数据更新到自己的 L1 Cache。】
            • 第二点,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的【相同顺序的数据变化】,这个称为事务的串形化(Transaction Serialization)。
              • 要实现事务串形化,要做到 2 点:

                • CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心;
                • 如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了锁,才能进行对应的数据更新。
              • MESI 协议:基于总线嗅探机制实现了事务串形化,也用状态机机制降低了总线带宽压力(总线嗅探只是保证了某个 CPU 核心的 Cache 更新数据这个事件能被其他 CPU 核心知道,但是并不能保证事务串形化)。也做到了 CPU 缓存一致性。MESI 协议其实是 4 个状态单词的开头字母缩写:Modified,已修改、Exclusive,独占、Shared,共享、Invalidated,已失效

                • Modified,已修改:指脏标记,代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里。
                • Exclusive,独占:代表 Cache Block 里的数据是干净的,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。独占状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。另外,在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,独占状态下的数据就会变成共享状态
                • Shared,共享:代表 Cache Block 里的数据是干净的,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。共享状态代表着相同的数据在多个 CPU 核心的 Cache 里都有,所以当我们要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后再更新当前 Cache 里面的数据。
                • Invalidated,已失效:表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据。
        • Cache 伪共享:Cache 伪共享问题上是一个性能杀手,我们应该要规避它。
          • 使用数组进行数组的加载时, CPU 就会加载数组里面连续的多个数据到 Cache 里,因此我们应该按照物理内存地址分布的顺序去访问元素,这样访问数组元素的时候,Cache 命中率就会很高,于是就能减少从内存读取数据的频率, 从而可提高程序的性能。
          • 使用单独的变量的时候,则会有 Cache 伪共享的问题:如果 1 号和 2 号 CPU 核心这样持续交替的分别修改变量 A 和 B,就会重复 ④ 和 ⑤ 这两个步骤,Cache 并没有起到缓存的效果,虽然变量 A 和 B 之间其实并没有任何的关系,但是因为同时归属于一个 Cache Line ,这个 Cache Line 中的任意数据被修改后,都会相互影响,从而出现 ④ 和 ⑤ 这两个步骤。这种因为多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象称为伪共享(False Sharing)




          • 避免伪共享的方法:对于多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据刚好在同一个 Cache Line 中,否则就会出现为伪共享的问题。

            • Java 并发框架 Disruptor中有一个 RingBuffer 类会经常被多个线程使用

              • RingBufferFelds 里面定义的 这些变量都是 final 修饰的,意味着第一次加载之后不会再修改, 又由于前后各填充了 7 个不会被读写的 long 类型变量,所以无论怎么加载 Cache Line,这整个 Cache Line 里都没有会发生更新操作的数据,于是只要数据被频繁地读取访问,就自然没有数据被换出 Cache 的可能,也因此不会产生伪共享的问题
    • CPU:
      • 32 位和 64 位 CPU 最主要区别在于一次能计算多少字节数据:32 位 CPU 一次可以计算 4 个字节;64 位 CPU 一次可以计算 8 个字节;这里的 32 位和 64 位,通常称为 CPU 的位宽【为了能一次计算大数的运算,CPU 需要支持多个 byte 一起计算,所以 CPU 位宽越大,可以计算的数值就越大】

        • 64 位相比 32 位 CPU 的优势在哪吗?64 位 CPU 的计算性能一定比 32 位 CPU 高很多吗?64 位相比 32 位 CPU 的优势主要体现在两个方面

          • 64 位 CPU 可以一次计算超过 32 位的数字,而 32 位 CPU 如果要计算超过 32 位的数字,要分多步骤进行计算,效率就没那么高,但是大部分应用程序很少会计算那么大的数字,所以只有运算大数字的时候,64 位 CPU 的优势才能体现出来,否则和 32 位 CPU 的计算性能相差不大
          • 64 位 CPU 可以寻址更大的内存空间,32 位 CPU 最大的寻址地址是 4G,即使你加了 8G 大小的内存,也还是只能寻址到 4G,而 64 位 CPU 最大寻址地址是 2^64,远超于 32 位 CPU 最大寻址地址的 2^32
        • 64 位和 32 位软件,实际上代表指令是 64 位还是 32 位的
          • 如果 32 位指令在 64 位机器上执行,需要一套兼容机制,就可以做到兼容运行了。但是如果 64 位指令在 32 位机器上执行,就比较困难了,因为 32 位的寄存器存不下 64 位的指令
          • 操作系统其实也是一种程序,操作系统会分成 32 位操作系统、64 位操作系统,其代表意义就是操作系统中程序的指令是多少位,
          • 硬件的 64 位和 32 位指的是 CPU 的位宽,软件的 64 位和 32 位指的是指令的位宽
      • CPU 要读写内存数据的时候,一般需要通过下面这三个总线:
        • 首先要通过「地址总线」来指定内存的地址;【64 位 CPU 寻址范围则很大,理论最大的寻址空间为 2^64】
        • 然后通过「控制总线」控制是读或写命令
        • 最后通过「数据总线」来传输数据

PART1: 程序在图灵机的执行过程,一张纸带一个读写头,一个格子一个格子去打。转眼回到21世纪,咱们基本上就是 CPU取指执行取指执行【一个程序执行的时候,CPU 会根据程序计数器里的内存地址,从内存里面把需要执行的指令读取到指令寄存器里面执行,然后根据指令长度自增,开始顺序读取下一条指令。】

  • 现代大多数 CPU 都使用来流水线的方式来执行指令,所谓的流水线就是把一个任务拆分成多个小任务,于是一条指令通常分为 4 个阶段,称为 4 级流水线

    • 下面这 4 个阶段,我们称为指令周期(Instrution Cycle),CPU 的工作就是一个周期接着一个周期,周而复始。

  • 比如a = 1 + 2 在 32 位 CPU 的执行过程:
  • 比如0.1+0.2=?【0.1 和 0.2 这两个数字用二进制表达会是一个一直循环的二进制数,比如 0.1 的二进制表示为 0.0 0011 0011 0011… (0011 无限循环),对于计算机而言,0.1 无法精确表达,这是浮点数计算造成精度损失的根源。因此,IEEE 754 标准定义的浮点数只能根据精度舍入,然后用「近似值」来表示该二进制,那么意味着计算机存放的小数可能不是一个真实值。0.1 + 0.2 并不等于完整的 0.3,这主要是因为这两个小数无法用「完整」的二进制来表示,只能根据精度舍入,所以计算机里只能采用近似数的方式来保存,那两个近似数相加,得到的必然也是一个近似数。】

    • 如果不用补码的方式来表示负数,而只是像正数那样把最高位的符号标志位变为 1 表示负数,此时-2+1=-3而不是等于-1,所以此时要想运算正确,这种负数的表示方式是不能用常规的加法来计算了,就需要特殊处理,要先判断数字是否为负数,如果是负数就要把加法操作变成减法操作(还需要多一步操作来判断是否为负数,如果为负数,还得把加法反转成减法,或者把减法反转成加法)才可以得到正确对结果。【而用了补码的表示方式,对于负数的加减法操作,实际上是和正数加减法操作一样的。】
    • 现在绝大多数计算机使用的浮点数,一般采用的是 IEEE 制定的国际标准,这种标准形式如下图


      • 算指数的时候需要加上偏移量是因为指数可能是正数,也可能是负数,即指数是有符号的整数,而有符号整数的计算是比无符号整数麻烦的,所以为了减少不必要的麻烦,在实际存储指数的时候,需要把指数转换成无符号整数。float 的指数部分是 8 位,IEEE 标准规定单精度浮点的指数取值范围是 -126 ~ +127,于是为了把指数转换成无符号整数,就要加个偏移量,比如 float 的指数偏移量是 127,这样指数就不会出现负数了而当我们需要计算实际的十进制数的时候,再把指数减去偏移量即可
      • 因为 IEEE 标准规定,二进制浮点数的小数点左侧只能有 1 位,并且还只能是 1,既然这一位永远都是 1,那就可以不用存起来了于是就让 23 位尾数只存储小数部分,然后在计算时会自动把这个 1 加上,这样就可以节约 1 位的空间,尾数就能多存一位小数,相应的精度就更高了一点。

看完了上面的几毛钱特效,咱们是不是要调用电脑上的各个应用程序了(就相当于进程开始了调度),进程中有很多线程在共享着进程资源并执行任务。【根据进程访问资源的特点,我们可以把进程在系统上的运行状态分为两个级别

  • 所有的用户进程都是运行在用户态的。【我们运行的程序基本都是运行在用户态,用户程序的访问能力有限,如果我们调用操作系统提供的系统态级别的子功能【**凡是与系统态级别的资源有关的操作(如文件管理、进程控制、内存管理等)**】咋办呢?那就需要系统调用了!】,一些比较重要的比如从硬盘读取数据、从键盘获取数据的操作等等操作则是内核态才能做的事情【我们必须通过系统调用方式向OS提出服务请求,并由OS代为完成】**,而这些数据却又对用户程序来说非常重要。所以就涉及到 两种模式下的转换:【用户态---> 内核态---> 用户态,而唯一能够做这些转换操作的只有系统调用,而能够执行系统调用的就只有操作系统】

    • 系统调用按功能大致可以分为五类:

      • 设备管理。完成设备的请求或释放,以及设备启动等功能。

        • 当咱们按下开机键那一刻发生了啥【当你按下开机键的那一刻,在主板上提前写死的固件程序 BIOS 会将硬盘中启动区的 512 字节的数据,原封不动复制到内存中的 0x7c00 这个位置,并跳转到那个位置进行执行】,一张图了解一下【特此感谢 低并发编程老师 的文章,很赞】【 Linux-0.11 源码中的最开始的代码也就是bootsect.s 会被编译成二进制文件,存放在启动区的第一扇区,由 BIOS 搬运到内存的 0x7c00 这个位置,而 CPU 也会从内存的 0x7c00 这个位置,不断往后一条一条语句无脑地执行下去。】

          • 这不就告诉我们,BIOS这个搬运工很牛逼嘛,我们就可以把代码写在硬盘第一扇区,让 BIOS 帮我们加载到内存并由 CPU 去执行,我们不用操心这个过程

            • 而硬盘第一扇区这一个扇区的代码,其实就是操作系统源码中最最最开始的部分,它可以执行一些指令,也可以把硬盘的其他部分加载到内存,其实本质上也是执行一些指令。
          • 反正总而言之,言而总之,经过一系列寄存器运算【段基址左移四位再加上偏移地址】,几个寄存器被附上了指定的值,赋值就是为下一条指令服务的BIOS 规定死了把操作系统代码加载到内存 0x7c00,那么里面的各种数据自然就全都被偏移了这么多,所以把数据段寄存器 ds 设置为这个值,方便了以后通过这种基址的方式访问内存里的数据。也就是说,之后再写的代码,里面访问的数据的内存地址,都先默认加上 0x7c00,再去内存中寻址

            • 其实就是一直在不断调整内存的布局,把这块内存复制到那块,又把那块内存复制到这块,最后形成的内存布局就是下面这货:
            • Intel 本身对于访问内存就分成三类:代码、数据、栈;而 Intel 也提供了三个段寄存器来分别对应着三类内存:代码段寄存器(cs)【cs:eip 表示了我们要执行哪里的代码。】、数据段寄存器(ds)【ds:xxx 表示了我们要访问哪里的数据。】、栈段寄存器(ss)【ss:esp 表示了我们的栈顶地址在哪里。】;
              • 所以进入main.c的main函数前的准备工作中,我们也做了如下工作:
              • 分段和分页,以及这几个寄存器的设置,其实本质上就是安排我们今后访问内存的方式,做了一个初步规划,包括去哪找代码、去哪找数据、去哪找栈,以及如何通过分段和分页机制将逻辑地址转换为最终的物理地址。为之后进入 main 函数做了充分的准备,因为 c 语言虽然很底层了,但也有其不擅长的事情,就交给第一部分的汇编语言来做
          • 而下一条指令的意思是,将内存地址 0x7c00 处开始往后的 512 字节的数据,原封不动复制到 0x90000 处,也就是 将操作系统最开头的代码挪到了 0x90000 这个位置。【代码从硬盘移到内存,又从内存挪了个地方,放在了 0x90000 处。真能折腾】挪到这里后就开始跳转到此处往后再稍稍偏移 go 这个标签所代表的偏移地址处继续折腾
          • 然后,又做了一些工作为后期偷懒做准备(后期跳转和内存访问仅仅需要指定偏移地址即可),这个工作就是数据段寄存器 ds 和代码段寄存器 cs 此时都被设置为了 0x9000,为跳转代码和访问内存数据,奠定了同一个内存的基址地址,方便后续代码的跳转与数据的访问。并且除了这一工作之外,还把代码段寄存器 cs,数据段寄存器 ds,栈段寄存器 ss 和栈基址寄存器 sp 分别设置好了值,方便后续使用。
            • 其实操作系统在做的事情,就是给如何访问代码,如何访问数据,如何访问栈进行了一下内存的初步规划。其中访问代码和访问数据的规划方式就是设置了一个基址而已,访问栈就是把栈顶指针指向了一个远离代码位置的地方而已【将栈顶地址 ss:sp 设置在了离代码的位置 0x90000 足够遥远的 0x9FF00,保证栈向下发展不会轻易撞见代码的位置】
          • 然后,走着走着,会碰见一个中断,CPU 会通过这个中断号,去寻找对应的中断处理程序的入口地址,并跳转过去执行,逻辑上就相当于执行了一个函数。而 0x13 号中断的处理程序是 BIOS 提前给我们写好的,是读取磁盘的相关功能的函数
            • int 0x10 也是一样的,它也是触发 BIOS 提供的显示服务中断处理程序,而 ah 寄存器被赋值为 0x03 表示显示服务里具体的读取光标位置功能。

              • 这个 int 0x10 中断程序执行完毕并返回时,dx 寄存器里的值表示光标的位置,具体说来其高八位 dh 存储了行号,低八位 dl 存储了列号。【计算机在加电自检后会自动初始化到文字模式,在这种模式下,一屏幕可以显示 25 行,每行 80 个字符,也就是 80 列】
          • 然后将从硬盘的第 2 个扇区开始,把数据加载到内存 0x90200 处,共加载 4 个扇区。随后把从硬盘第 6 个扇区开始往后的 240 个扇区,加载到内存 0x10000 处。至此,整个操作系统的全部代码,就已经全部从硬盘中,被搬迁到内存来了
            • 整个操作系统的编译过程其实就是通过 Makefile 和 build.c 配合完成的。最终实现:

              • 把 bootsect.s 编译成 bootsect 放在硬盘的 1 扇区
              • 把 setup.s 编译成 setup 放在硬盘的 2~5 扇区。第二个扇区的最开始处,那也就是 setup.s 文件的第一行代码咯。
              • 把剩下的全部代码(head.s 作为开头)编译成 system 放在硬盘的随后 240 个扇区
          • 然后,CPU就跳转到了 0x90200 这个位置开始执行,这个位置处的代码就是位于 setup.s 的开头。然后最终就知晓存储在内存中的信息是什么,在什么位置,就好了。并且形成一定的内存布局:【清爽的内存布局,是 方便后续操作系统的各个巨大工程:逐渐进入保护模式,并设置分段、分页、中断等机制的地方

            • 第一大工程:模式的转换【从现在的 16 位的实模式转变为之后 32 位的保护模式。但是现在的 CPU 几乎都是支持 32 位模式甚至 64 位模式了,所以这只是个补丁】

              • 开机代码走到现在,我们现在还处于实模式下【这个实模式下的 CPU 计算物理地址的方式:段基址(段寄存器ds中的值)左移四位加上偏移址

                • 上图中的system 模块是由 Makefile 文件可知,是由 head.s 和 main.c 以及其余各模块的操作系统代码合并来的,可以理解为操作系统的全部核心代码编译后的结果
                • head.s 这个文件仅仅是为了顺利进入由后面的 c 语言写就的 main.c 做的准备
              • 当 CPU 切换到保护模式后【在保护模式下要先经过分段机制的转换,才能最终变成物理地址】,ds 寄存器里存储的值在实模式下叫做段基址而在保护模式下叫段选择子,段选择子里存储着段描述符的索引通过段描述符索引,可以从全局描述符表 gdt 中找到一个段描述符,段描述符里存储着段基址段基址取出来,再和偏移地址相加,就得到了物理地址。【操作系统设置了个全局描述符表 gdt,为后面切换到保护模式后,能去那里寻找到段描述符,然后拼凑成最终的物理地址,就这个作用。当然,还有很多段描述符,作用不仅仅是转换成最终的物理地址】

                • 段寄存器(比如 ds、ss、cs)里存储的是段选择子【段选择子拿到自己肚子里面存储的段描述符索引然后按照段描述符索引去全局描述符表 gdt 中寻找段描述符,从段描述符中取出段基址。】
                • 操作系统把全局描述符表(gdt)在内存中的位置信息存储在一个叫 gdtr 的寄存器中。【这个gdt_48 标签位置处表示一个 48 位的数据,其中高 32 位存储着的正是全局描述符表 gdt 的内存地址0x90200 + gdtgdt 是个标签,表示在本文件内的偏移量,而本文件是 setup.s,编译后是放在 0x90200 这个内存地址的,所以要加上 0x90200 这个值。gdt 这个标签处,就是全局描述符表在内存中的真正数据了。】
                • 目前全局描述符表有三个段描述符,第一个为空,第二个是代码段描述符(type=code),第三个是数据段描述符(type=data),第二个和第三个段描述符的段基址都是 0,也就是之后在逻辑地址转换物理地址的时候,通过段选择子查找到无论是代码段还是数据段,取出的段基址都是 0,那么物理地址将直接等于程序员给出的逻辑地址
                • 当CPU 进入了 32 位保护模式后,
                  • 首先配置了全局描述符表 gdt 和中断描述符表 idt。
                  • 然后打开了 A20 地址线。
                  • 然后更改 cr0 寄存器开启保护模式
                  • 最后,跳到了内存地址 0【全局描述符表(gdt)中第 0 项是空值,第一项被表示为代码段描述符,是个可读可执行的段,第二项为数据段描述符,是个可读可写段,不过他们的段基址都是 0。当段基址是 0,偏移也是 0,那加一块就还是 0 咯,所以最终这个跳转指令,就是跳转到内存地址的 0 地址处,开始执行处开始执行代码。【0 位置处存储着操作系统全部核心代码,是由 head.s 和 main.c 以及后面的无数源代码文件编译并链接在一起而成的 system 模块。】【内存地址 0处或者叫零地址处就是操作系统全部代码的 system 这个大模块,system 模块怎么生成的呢?由 Makefile 文件可知,是由 head.s 和 main.c 以及其余各模块的操作系统代码合并来的,可以理解为操作系统的全部核心代码编译后的结果。】
            • 正式进入 c 语言写的 main.c 之前的 head.s文件【head.s 这个文件仅仅是为了顺利进入由后面的 c 语言写就的 main.c 做的准备】中呢东西也不少,但也不多

              • 开头有个标号 _pg_dir:_pg_dir:表示页目录:之后在设置分页机制时,页目录会存放在这里,也就是将页目录表放在内存地址的最开头,也会覆盖这里的代码。】

                • 之后紧挨着这个页目录表,放置 4 个页表,代码里也有这四个页表的标签项。
              • 下来各种寄存器赋值,然后设置了 256 个中断描述符,并且让每一个中断描述符中的中断程序例程都指向一个 ignore_int 的函数地址,这个是个默认的中断处理程序【此时产生任何中断都会指向这个默认的函数 ignore_int】,之后会逐渐被各个具体的中断程序所覆盖。比如之后键盘模块会将自己的键盘中断处理程序,覆盖过去。
              • 再就是原来设置的 gdt 是在 setup 程序中,之后这个地方要被缓冲区覆盖掉,所以这里重新设置在 head 程序中,这块内存区域之后就不会被其他程序用到并且覆盖了
            • 下来就是开启分页机制,并且跳转到 main 函数【将页目录表和页表填写好数值,来覆盖整个 16MB 的内存。随后,开启分页机制
              jmp after_page_tables
              ...
              after_page_tables:push 0push 0push 0push L6push _mainjmp setup_paging //开启分页机制,并且跳转到 main 函数
              L6:jmp L6
              

              • 开启分页机制的开关。其实就是更改 cr0 寄存器中的一位即可(31 位),我们开启保护模式也是改这个寄存器中的一位的值
              • 如 idt 和 gdt 一样,我们也需要通过一个寄存器告诉 CPU 我们把这些页表放在了哪里。相当于告诉 cr3 寄存器,0 地址处就是页目录表,再通过页目录表可以找到所有的页表,也就相当于 CPU 知道了分页机制的全貌了。至此后,整个内存布局如下。

                • 这个图很重要,因为操作系统说白了就是在内存中放置各种的数据结构,来实现“管理”的功能。所以之后我们的学习过程,主心骨其实就是看看,操作系统在经过一番折腾后,又在内存中建立了什么数据结构,而这些数据结构后面又是如何用到的。比如进程管理,就是在内存中建立好多复杂的数据结构用来记录进程的信息,再配合上进程调度的小算法,完成了进程这个强大的功能
              • 具体页表设置好后,映射的内存如下:
            • 开启分页机制后,在分段机制后还得再多一步转换:

              • 其实呀,CPU 在看到我们给出的内存地址后,首先把线性地址被拆分成高 10 位:中间 10 位:后 12 位
            • 而这一切的操作,都由计算机的一个硬件叫 MMU,中文名字叫内存管理单元,有时也叫 PMMU,分页内存管理单元。由这个部件来负责将虚拟地址转换为物理地址。所以整个过程我们不用操心,作为操作系统这个软件层,只需要提供好页目录表和页表即可,这种页表方案叫做二级页表【关于二级页表的详细,可以在这一篇文章中ctrl+F,搜二级页表,多级页表附近那块】,第一级叫页目录表 PDE,第二级叫页表 PTE
              • 所以OS源码中得提前帮我们把页表和页目录表在内存中写好,之后开启 cr0 寄存器的分页开关,仅此而已
            • 咱们开启分页【更改 cr0 寄存器中的一位即可(31 位)or改这个cr0 寄存器中的一位的分页开关值】之后,MMU 就可以帮我们进行分页的转换了。此后指令中的内存地址(就是程序员提供的逻辑地址),就统统要 先经过分段机制的转换,再通过分页机制的转换,才能最终变成物理地址

            • idt【中断描述符表】、gdt【全局描述符表】、页表都设置好了,并且也开启了保护模式,然后就是要准备进入 main.c,而进入main 函数的代码就是: push _main,push 指令就是压栈,五个 push 指令过去后,栈会变成这个样子。经过这一个小小的骚操作,程序终于跳转到 main.c 这个由 c 语言写就的主函数 main 里了
            • 这个 main 函数的全部如下:而整个操作系统也会最终停留在最后一行死循环中,永不返回,直到关机
            • 总结一下,为了跳进 main 函数的准备工作或者说为进入内核前的苦力活大概如下:之后,main 方法就开始执行了,还有目前的内存布局图。【后面的很多东西,其实就是在内存中放置各种的数据结构或者说创建上面没有的什么新的数据结构,来用这些数据结构实现“管理”的功能。】

          • 上面的准备工作做好了,程序就跳到第一个由 c 语言写的,也是操作系统的全部代码骨架的地方,就是 main.c 文件里的 main 方法。里面用少量的代码包含着操作系统启动流程的全部秘密:比如
            • 一些参数的取值和计算、
            • 各种初始化 init 操作【内存初始化 mem_init,中断初始化 trap_init、进程调度初始化 sched_init】、
            • 切换到用户态模式,并在一个新的进程中做一个最终的初始化 init。
              • 这个 init 函数里会创建出一个进程,设置终端的标准 IO,并且再创建出一个执行 shell 程序的进程用来接受用户的命令,到这里其实就出现了我们熟悉的画面。
            • 死循环,如果没有任何任务可以运行,操作系统会一直陷入这个死循环无法自拔。
        • 然后,咱们一块再想一个问题,在键盘上敲一个字符到底发生了什么,咱们从OS层面看看:
      • 文件管理。完成文件的读、写、创建及删除等功能。
        • 见PART11.文件系统
      • 进程管理:就是 在内存中建立好多复杂的数据结构用来记录进程的信息,再配合上进程调度的小算法,完成了进程这个强大的功能
        • 进程控制。完成进程的创建、撤销、阻塞及唤醒等功能。
        • 进程通信。完成进程之间的消息传递或信号传递等功能。
      • 内存管理。完成内存的分配、回收以及获取作业占用内存区大小及地址等功能。【操作系统的内存管理主要负责内存的分配与回收(malloc 函数:申请内存,free 函数:释放内存),另外地址转换也就是将逻辑地址转换成相应的物理地址等功能也是操作系统内存管理做的事情
        • 内存分配的过程:

          • 应用程序通过 malloc 函数申请内存的时候实际上申请的是虚拟内存,如果没有被使用,它是不会占用物理空间的,此时并不会分配物理内存。**当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存【当访问这块虚拟内存后,操作系统才会进行物理内存分配。】**, 这时会发现这个虚拟内存**没有映射**到物理内存, CPU 就会产生**缺页中断**,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理。缺页中断处理函数会看是否有空闲的物理内存,如果有,就直接分配物理内存【如果申请物理内存大小超过了空闲物理内存大小,就要看操作系统有没有开启 Swap 机制:如果没有开启 Swap 机制,程序就会直接 OOM;如果有开启 Swap 机制,程序可以正常运行。】,并建立虚拟内存与物理内存之间的映射关系。如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作。,如果回收内存工作结束后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了触发 OOM (Out of Memory)机制。
          • 虚拟内存:什么是虚拟内存:虚拟内存就是说,让物理内存扩充成更大的逻辑内存,从而让程序获得更多的可用内存【这个在我们平时使用电脑特别是 Windows 系统的时候太常见了。很多时候我们使用了很多占内存的软件,这些软件占用的内存可能已经远远超出了我们电脑本身具有的物理内存。为什么可以这样呢? 正是因为 虚拟内存 的存在,通过 虚拟内存 可以让程序可以拥有超过系统物理内存大小的可用内存空间。另外,虚拟内存为每个进程提供了一个一致的、私有的地址空间,它让每个进程产生了一种自己在独享主存的错觉(每个进程拥有一片连续完整的内存空间)。这样会更加有效地管理内存并减少出错。

            • 虚拟内存的那点事儿
            • 虚拟内存的管理整个流程
            • 虚拟内存使用部分加载或者基于局部性原理的技术,让一个进程或者资源的某些页面加载进内存,从而能够加载更多的进程,甚至能加载比内存大的进程,这样看起来好像内存变大了,这部分内存其实包含了磁盘或者硬盘,并且就叫做虚拟内存。
              • 虚拟内存是计算机系统内存管理的一种技术,我们可以手动设置自己电脑的虚拟内存。不要单纯认为虚拟内存只是“使用硬盘空间来扩展内存“的技术。虚拟内存的重要意义是它定义了一个连续的虚拟地址空间,并且 把内存扩展到硬盘空间【**虚拟内存 使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。**与没有使用虚拟内存技术的系统相比,使用这种技术的系统使得大型程序的编写变得更容易,对真正的物理内存(例如 RAM)的使用也更有效率。目前,大多数操作系统都使用了虚拟内存,如 Windows 家族的“虚拟内存”;Linux 的“交换空间”等】
            • 虚拟内存的实现方式有哪些?
              • 虚拟内存中,允许将一个作业分多次调入内存。釆用连续分配方式时,会使相当一部分内存空间都处于暂时或永久的空闲状态,造成内存资源的严重浪费,而且也无法从逻辑上扩大内存容量。因此,虚拟内存的实需要建立在离散分配的内存管理方式的基础上
              • 虚拟内存的实现有以下三种方式:

                • 请求分页存储管理:

                  • 建立在分页管理之上,为了支持虚拟存储器功能而增加了请求调页功能和页面置换功能。请求分页是目前最常用的一种实现虚拟存储器的方法。请求分页存储管理系统中,在作业开始运行之前,仅装入当前要执行的部分段即可运行。假如在作业运行的过程中发现要访问的页面不在内存,则由处理器通知操作系统按照对应的页面置换算法将相应的页面调入到主存,同时操作系统也可以将暂时不用的页面置换到外存中
                  • 请求分页存储管理建立在分页管理之上。他们的根本区别是是否将程序全部所需的全部地址空间都装入主存【它们之间的根本区别在于是否将一作业的全部地址空间同时装入主存。请求分页存储管理不要求将作业全部地址空间同时装入主存。基于这一点,请求分页存储管理可以提供虚存,而分页存储管理却不能提供虚存。】,这也是请求分页存储管理可以提供虚拟内存的原因
                • 请求分段存储管理
                  • 建立在分段存储管理之上,增加了请求调段功能、分段置换功能。请求分段储存管理方式就如同请求分页储存管理方式一样,在作业开始运行之前,仅装入当前要执行的部分段即可运行;在执行过程中,可使用请求调入中断动态装入要访问但又不在内存的程序段;当内存空间已满,而又需要装入新的段时,根据置换功能适当调出某个段,以便腾出空间而装入新的段
                • 请求段页式存储管理
        • 内存回收:
          • 内存回收的方式主要是两种:

            • 后台内存回收(kswapd):在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。
            • 直接内存回收(direct reclaim)如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行,这样就会造成很长时间的延迟,以及系统的 CPU 利用率会升高,最终引起系统负荷飙高
              • 如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了 ——触发 OOM (Out of Memory)机制。OOM Killer 机制会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源,如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置。
              • 在 Linux 内核里有一个 oom_badness() 函数,它会把系统中可以被杀掉的进程扫描一遍,并对每个进程打分,得分最高的进程就会被首先杀掉。进程得分的结果受下面这两个方面影响:

                • 第一,进程已经使用的物理内存页面数。
                • 第二,每个进程的 OOM 校准值 oom_score_adj。它是可以通过 /proc/[pid]/oom_score_adj 来配置的。我们可以在设置 -1000 到 1000 之间的任意一个数值,调整进程被 OOM Kill 的几率。【我们最好将一些很重要的系统服务的 oom_score_adj 配置为 -1000,比如 sshd,因为这些系统服务一旦被杀掉,我们就很难再登陆进系统了,不建议将我们自己的业务程序的 oom_score_adj 设置为 -1000,因为业务程序一旦发生了内存泄漏,而它又不能被杀掉,这就会导致随着它的内存开销变大,OOM killer 不停地被唤醒,从而把其他进程一个个给杀掉。】
          • 哪些内存可以被回收:主要有两类内存可以被回收【文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存。】,而且它们的回收方式也不同。
            • 文件页(File-backed Page)内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)都叫作文件页。大部分文件页,都可以直接释放内存,以后有需要时,再从磁盘重新读取就可以了。而那些被应用程序修改过,并且暂时还没写入磁盘的数据(也就是脏页),就得先写入磁盘,然后才能进行内存释放。所以,回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存
            • 匿名页(Anonymous Page)这部分内存没有实际载体,不像文件缓存有硬盘文件这样一个载体,比如堆、栈数据等。这部分内存很可能还要再次被访问,所以不能直接释放内存,匿名页(Anonymous Page)这部分内存它们回收的方式是通过 Linux 的 Swap 机制【能保存匿名页的磁盘载体就是Swap 分区】,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了
          • 两种类型内存的回收操作基本都会发生磁盘 I/O 的,如果回收内存的操作很频繁,意味着磁盘 I/O 次数会很多,这个过程势必会影响系统的性能,整个系统给人的感觉就是很卡。解决卡顿的常见的解决方式有以下几种:
            • 调整文件页和匿名页的回收倾向:

              • 文件页的回收操作对系统的影响相比匿名页的回收操作会少一点,因为文件页对于干净页回收是不会发生磁盘 I/O 的,而匿名页的 Swap 换入换出这两个操作都会发生磁盘 I/O。Linux 提供了一个 /proc/sys/vm/swappiness 选项,用来调整文件页和匿名页的回收倾向。swappiness 的范围是 0-100,数值越大,越积极使用 Swap,也就是更倾向于回收匿名页;数值越小,越消极使用 Swap,也就是更倾向于回收文件页。一般建议 swappiness 设置为 0(默认值是 60),这样在回收内存的时候,会更倾向于文件页的回收,但是并不代表不会回收匿名页
            • 尽早触发 kswapd 内核线程异步回收内存:
              • 可以使用 sar -B 1 命令来观察系统的直接内存回收和后台内存回收的指标,可以通过尽早的触发后台内存回收来避免应用程序进行直接内存回收,如果系统时不时发生抖动,并且通过 sar -B 观察到 pgscand 数值很大,那大概率是因为直接内存回收导致的,这时可以增大 min_free_kbytes 这个配置选项来及早地触发后台回收,然后继续观察 pgscand 是否会降为 0。增大了 min_free_kbytes 配置后,这会使得系统预留过多的空闲内存,从而在一定程度上降低了应用程序可使用的内存量,这在一定程度上浪费了内存。极端情况下设置 min_free_kbytes 接近实际物理内存大小时,留给应用程序的内存就会太少而可能会频繁地导致 OOM 的发生。在调整 min_free_kbytes 之前,需要先思考一下,应用程序更加关注什么,如果关注延迟那就适当地增大 min_free_kbytes,如果关注内存的使用量那就适当地调小 min_free_kbytes
            • NUMA 架构下的内存回收策略:针对 CPU 的有两个架构:SMP 架构与NUMA 架构
              • SMP架构:

                • 指的是一种多个 CPU 处理器共享资源的电脑硬件架构,也就是说每个 CPU 地位平等,它们共享相同的物理资源,包括总线、内存、IO、操作系统等。每个 CPU 访问内存所用时间都是相同的,因此,这种系统也被称为一致存储访问结构(UMA,Uniform Memory Access)
                • SMP 架构的问题:随着 CPU 处理器核数的增多,多个 CPU 都通过一个总线访问内存,这样总线的带宽压力会越来越大,同时每个 CPU 可用带宽会减少
              • NUMA 架构:
                • 为了解决 SMP 架构的问题,就研制出了 NUMA 结构,即非一致存储访问结构(Non-uniform memory access,NUMA)
                • NUMA 架构将每个 CPU 进行了分组,每一组 CPU 用 Node 来表示,一个 Node 可能包含多个 CPU 。每个 Node 有自己独立的资源,包括内存、IO 等,每个 Node 之间可以通过互联模块总线(QPI)进行通信,所以,也就意味着每个 Node 上的 CPU 都可以访问到整个系统中的所有内存。但是,访问远端 Node 的内存比访问本地内存要耗时很多

                  • 在 NUMA 架构下,当某个 Node 内存不足时,系统可以从其他 Node 寻找空闲内存,也可以从本地内存中回收内存。
        • 操作系统的内存管理机制?或者说内存管理有哪几种方式?----->简单分为连续分配管理方式和非连续分配管理方式两种。
          • 连续分配管理方式是指为一个用户程序分配一个连续的内存空间,常见的如块式管理

            • 块式管理 : 远古时代的计算机操作系统的内存管理方式。将内存分为几个固定大小的块,每个块中只包含一个进程。如果程序运行需要内存的话,操作系统就分配给它一块,如果程序运行只需要很小的空间的话,分配的这块内存很大一部分几乎被浪费了。这些在每个块中未被利用的空间,我们称之为碎片。
          • 非连续分配管理方式允许一个程序使用的内存分布在离散或者说不相邻的内存中,常见的如页式管理段式管理
            • 段式管理 : 分段是比较早提出的,分段机制或者说分段管理机制目的是为了为每个程序或任务提供单独的代码段(cs)、数据段(ds)、栈段(ss),使其不会相互干扰。段式管理虽然提高了内存利用率,但是段式管理其中的页并无任何实际意义。【段是逻辑单位,分段可以更好满足用户需求】 段式管理把主存分为一段段的,代码分段、数据分段、栈段、堆段,每个段定义了一组逻辑信息,不同的段是有不同的属性的,例如,有主程序段 MAIN、子程序段 X、数据段 D 及栈段 S 等。 段式管理通过段表对应逻辑地址和物理地址

              • 在 Intel 的保护模式下,分段机制是没有开启和关闭一说的,它必须存在 ,而分页机制是可以选择开启或关闭的
              • 分段机制下的虚拟地址由两部分组成,段选择因子和段内偏移量:
              • 虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址
              • 分段内存管理当中,地址是二维的,其中每个段的长度是不一样的,而且每个段内部都是从0开始编址的。分段管理中,每个段内部是连续内存分配,但是段和段之间是离散分配的【所以分段机制会出现内存碎片,和分块机制一样】,因此也存在一个逻辑地址到物理地址的映射关系,相应的就是段表机制
              • 分段的办法解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:
                • 第一个就是 内存碎片 的问题。

                  • 内存碎片:内存碎片的问题共有两处地方:【外部内存碎片,也就是 产生了多个不连续的小物理内存,导致新的程序无法被装载----->解决外部内存碎片的问题就是内存交换。、内部内存碎片,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费;】
                • 第二个就是 内存交换的效率低 的问题。
              • 分页机制和分段机制的共同点和区别:

                • 共同点 :

                  • 分页机制和分段机制都是为了提高内存利用率,减少内存碎片
                  • 页和段都是离散存储的,所以两者都是离散分配内存的方式。但是,每个页和段中的内存是连续的
                • 不同点:
                  • 分页是为了提高内存利用率,而分段是为了满足程序员在编写代码的时候的一些逻辑需求(比如数据共享,数据保护,动态链接等)
                  • 页的大小是固定的,由操作系统决定;而段的大小不固定,取决于我们当前运行的程序
                  • 在 Intel 的保护模式下,分段机制是没有开启和关闭一说的,它必须存在,而分页机制是可以选择开启或关闭的
                  • 分页仅仅是为了满足操作系统内存管理的需求,而段是逻辑信息的单位,在程序中可以体现为代码段,数据段,能够更好满足用户的需要【分页是为了提高内存利用率,而分段是为了满足程序员在编写代码的时候的一些逻辑需求(比如数据共享,数据保护,动态链接等)。】
            • 页式管理 :【操作系统把物理内存(physical RAM)分成一块一块的小内存,每一块内存被称为页(page)】把主存分为大小相等且固定的一页一页的形式【页是物理单位,分页可以有效提高内存利用率】,页较小,相比于块式管理的划分粒度更小,提高了内存利用率,减少了碎片页式管理通过页表对应逻辑地址和物理地址【把内存空间划分为大小相等且固定的块,作为主存的基本单位。因为程序数据存储在不同的页面中,而页面又离散的分布在内存中,因此需要一个页表来记录映射关系以实现从页号到物理块号的映射】。【OS其实和人一样,脑子记不住啥了就用一个本子记呗,MAC地址表、ARP表、路由表】

              • 分页机制中,开机后分页机制默认是关闭状态,需要我们手动开启分页机制,并且设置好页目录表(PDE)和页表(PTE)分页机制其目的在于可以按需使用物理内存,同时也可以在多任务时起到隔离的作用
              • 为了解决内存分段的内存碎片和内存交换效率低的问题,就出现了内存分页
                • 采用了分页,那么释放的内存都是以页为单位释放的【也就不会像分段会产生间隙非常小的内存,这正是分段会产生内存碎片的原因。】,也就不会产生无法给进程使用的小内存。
                • 当需要进行内存交换的时候,内存分页(Paging)可以让需要交换写入或者从磁盘装载的数据更少一点,【如果内存空间不够,操作系统会把其他正在运行的进程中的最近没被使用的内存页面给释放掉,也就是暂时写在硬盘上,称为 换出(Swap Out)。一旦需要的时候,再加载进来,称为 换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。】
                • 在 Linux 下,每一页的大小为 4KB,分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。
              • 由于采用页表做地址转换,读写内存数据时 CPU 要访问两次主存。或者说 访问分页系统中内存数据需要两次的内存访问一次是从内存中访问页表,从中找到指定的物理块号,加上页内偏移得到实际物理地址第二次就是根据第一次得到的物理地址访问内存取出数据
              • 在分页内存管理中,很重要的两点是:要保证虚拟地址到物理地址的转换要快。+要解决虚拟地址空间大,页表也会很大的问题
                • 要保证虚拟地址到物理地址的转换要快:为了提高虚拟地址到物理地址的转换速度,操作系统在 页表方案 基础之上引入了 快表 来加速虚拟地址到物理地址的转换

                  • 操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大。比如在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12) 个页,那么就有大约100万个页,每个页表项需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储页表。100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。【从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项,所以多级页表比普通页表效率高(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。】
                • 要解决虚拟地址空间大,页表也很大的问题,就是引入多级页表咯:引入多级页表的主要目的是为了避免把全部页表一直放在内存中占用过多空间,特别是那些根本就不需要的页表就不需要保留在内存中。多级页表属于时间换空间的典型场景
              • 页表管理机制中有两个很重要的概念:快表和多级页表,刚好用来解决或者说对应上面两个问题。为了提高内存的空间性能,提出了多级页表的概念;但是提到空间性能是以浪费时间性能为基础的,因此为了补充损失的时间性能,提出了快表(即 TLB)的概念。 不论是快表还是多级页表实际上都利用到了程序的局部性原理【局部性原理是虚拟内存技术的基础,正是因为程序运行具有局部性原理,才可以只装入部分程序到内存就开始运行。也就是说在某个较短的时间段内,程序执行局限于某一小部分,程序访问的存储空间也局限于某个区域。】

                • 多级页表:引入多级页表的主要目的是为了避免把全部页表一直放在内存中占用过多空间,特别是那些根本就不需要的页表就不需要保留在内存中。多级页表属于时间换空间的典型场景

                  • 页表方案之一:二级页表,第一级叫页目录表 PDE,第二级叫页表 PTE,相当于 把单级页表再分页,将页表(一级页表:页目录表 PDE)分为 1024 个页表(页表:PTE),每个表(二级页表)中包含 1024 个「页表项」,形成二级分页

                  • 当时 linux-0.11 认为,总共可以使用的内存不会超过 16M,也即最大地址空间为 0xFFFFFF。而按照当前的二级页表的页目录表和页表这种机制,1 个页目录表最多包含 1024 个页目录项(也就是 1024 个页表),1 个页表最多包含 1024 个页表项(也就是 1024 个页),1 页为 4KB(因为有 12 位偏移地址)。因此,16M 的地址空间可以用 1 个页目录表 + 4 个页表搞定(4(页表数)* 1024(页表项数) * 4KB(一页大小)= 16MB,意思就是一页有4KB,而1个页表最多有1024页,那4个页表再乘以4不就行了嘛)。将页目录表和页表填写好数值,来覆盖整个 16MB 的内存。随后,开启分页机制
                  • 此时肯定不是将 4GB 的虚拟地址全部都映射到了物理内存上的话,要不然二级分页占用空间确实是更大了。大多时候存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存
                  • 如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建这个页表项对应的二级页表。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB,这对比单级页表的 4MB 是不是一个巨大的节约?
                  • 页表方案之二:对于 64 位的系统,两级分页肯定不够了,就变成了四级目录:分别是:全局页目录项 PGD(Page Global Directory)、上层页目录项 PUD(Page Upper Directory)、中间页目录项 PMD(Page Middle Directory)、页表项 PTE(Page Table Entry);
                • 快表(TLB):【多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。】可以把快表理解为一种特殊的高速缓冲存储器(Cache),其中的内容是页表的一部分或者全部内容。作为页表的 Cache,它的作用与页表相似,但是提高了访问速率。由于采用页表做地址转换,读写内存数据时 CPU 要访问两次主存。有了快表,有时只要访问一次高速缓冲存储器,一次主存,这样可加速查找并提高指令执行速度。【程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。我们就可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。】

                  • 使用快表之后的地址转换流程1.根据虚拟地址中的页号查快表
                  • 如果该页在快表中,直接从快表中读取相应的物理地址
                  • 如果该页不在快表中,就访问内存中的页表,再从页表中得到物理地址,同时将页表中的该映射表项添加到快表中
                  • 当快表填满后,又要登记新页时,就按照一定的淘汰策略淘汰掉快表中的一个页
            • 段页式管理机制结合了段式管理和页式管理的优点。简单来说 段页式管理机制就是把主存先分成若干段,每个段又分成若干页,也就是说 段页式管理机制 中段与段之间以及段的内部的都是离散的。

              • 段页式内存管理实现的方式:

                • 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;
                • 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;
              • 段页式内存地址结构就由段号、段内页号和页内位移三部分组成
              • 比如,咱们举个计算地址的例子:
    • 用户态 -> 内核态的转换我们都称之为 trap 进内核,也被称之为 陷阱指令(trap instruction)。

      • 首先用户程序会调用 glibc 库,glibc 是一个标准库,同时也是一套核心库,库中定义了很多关键 API。
      • glibc库知道针对不同体系结构调用系统调用的正确方法,它会根据体系结构应用程序的二进制接口设置用户进程传递的参数,来准备系统调用。
      • 然后,glibc 库调用软件中断指令(SWI) ,这个指令通过更新 CPSR 寄存器将模式改为超级用户模式,然后跳转到地址 0x08 处。
      • 到目前为止,整个过程仍处于用户态下,在执行 SWI 指令后,允许进程执行内核代码,MMU 现在允许内核虚拟内存访问
      • 从地址 0x08 开始,进程执行加载并跳转到中断处理程序,这个程序就是 ARM 中的 vector_swi()。
      • 在 vector_swi() 处,从 SWI 指令中提取系统调用号 SCNO,然后使用 SCNO 作为系统调用表 sys_call_table 的索引,调转到系统调用函数。
      • 执行系统调用完成后,将还原用户模式寄存器,然后再以用户模式执行
  • 什么是用户态和内核态:用户态和系统态是操作系统的两种运行状态,将操作系统的运行状态分为用户态和内核态,主要是为了对访问能力进行限制,防止随意进行一些比较危险的操作导致系统的崩溃【当程序使用用户空间时,我们常说该程序在用户态执行,而当程序使内核空间时,程序则在内核态执行。】,比如设置时钟、内存清理,这些都需要在内核态下完成

    • 内核态【或者叫系统态】:内核态运行的程序可以访问计算机的任何数据和资源,不受限制,包括外围设备,比如网卡、硬盘等。处于内核态的 CPU 可以从一个程序切换到另外一个程序,并且占用 CPU 不会发生抢占情况

      • 内核:内核(英语:Kernel,又称核心)在计算机科学中是一个用来管理软件发出的数据 I/O(输入与输出)要求的电脑程序,将这些要求转译为数据处理的指令并交由中央处理器(CPU)及电脑中其他电子组件进行处理,是现代操作系统中最基本的部分。它是为众多应用程序提供对计算机硬件的安全访问的一部分软件,这种访问是有限的,并由内核决定一个程序在什么时候对某部分硬件操作多长时间。 直接对硬件操作是非常复杂的。所以内核通常提供一种硬件抽象的方法,来完成这些操作。有了这个,通过进程间通信机制及系统调用,应用进程可间接控制所需的硬件资源(特别是处理器及 IO 设备)。早期计算机系统的设计中,还没有操作系统的内核这个概念。随着计算机系统的发展,操作系统内核的概念才渐渐明晰起来了!

        • 计算机是由各种外部硬件设备组成的,比如内存、cpu、硬盘等,如果每个应用都要和这些硬件设备对接通信协议,那这样太累了,所以这个中间人就由内核来负责,让内核作为应用连接硬件设备的桥梁或者说中间人【操作系统的内核是连接应用程序和硬件的桥梁,决定着操作系统的性能和稳定性】,应用程序只需关心与内核交互,不用关心硬件的细节。
        • 操作系统的内核(Kernel)是操作系统的核心部分,它负责系统的内存管理,硬件设备的管理,文件系统的管理以及应用程序的管理
          • 现代操作系统,内核一般会提供 4 个基本能力:

            • 管理进程、线程,决定哪个进程、线程使用 CPU,也就是**管理进程调度的能力**;
            • 管理内存,决定内存的分配和回收,也就是内存管理的能力;
            • 管理硬件设备,为进程与硬件设备之间提供通信能力,也就是硬件通信能力;
            • 提供系统调用,如果应用程序要运行更高权限运行的服务,那么就需要有系统调用,它是用户程序与操作系统之间的接口。
        • Linus Torvalds 在设计Linux时,主要理念有以下几点:
          • MultiTask,多任务:

            • MultiTask 的意思是多任务,代表着 Linux 是一个多任务的操作系统。多任务意味着可以有多个任务同时执行,这里的同时可以是并发或并行

              • 对于单核 CPU 时,可以让每个任务执行一小段时间,时间到就切换另外一个任务,从宏观角度看,一段时间内执行了多个任务,这被称为并发【两个及两个以上的作业在同一 时间段 内或者说有一定的时间间隔的执行
              • 对于多核 CPU 时,多个任务可以同时被不同核心的 CPU 同时执行,这被称为并行【两个及两个以上的作业在同一 时刻 执行
          • SMP,对称多处理
            • 代表着每个 CPU 的地位是相等的,对资源的使用权限也是相同的,多个 CPU 共享同一个内存,每个 CPU 都可以访问完整的内存和硬件资源。这个特点决定了 Linux 操作系统不会有某个 CPU 单独服务应用程序或内核程序,而是 每个程序都可以被分配到任意一个 CPU 上被执行
          • ELF,可执行文件链接格式,ELF也是 Linux 操作系统中可执行文件的存储格式
          • Monolithic Kernel,宏内核,Linux 内核架构就是宏内核,意味着 Linux 的内核是一个完整的可执行程序,且拥有最高的权限
            • 宏内核的特征是系统内核的所有模块都运行在内核态,比如进程调度、内存管理、文件系统、设备驱动等,都运行在内核态。

              • 不过,Linux 也实现了动态加载内核模块的功能,例如大部分设备驱动是以可加载模块的形式存在的,与内核其他模块解藕,让驱动开发和驱动加载更为方便、灵活
            • 微内核:与宏内核相反的是微内核
              • 微内核架构的内核只保留最基本的能力,比如进程调度、虚拟机内存、中断等,把一些应用放到了用户空间,比如驱动程序、文件系统等。这样服务与服务之间是隔离的,单个服务出现故障或者完全攻击,也不会导致整个操作系统挂掉,提高了操作系统的稳定性和可靠性
              • 微内核内核功能少,可移植性高,相比宏内核有一点不好的地方在于,由于驱动程序不在内核中,而且驱动程序一般会频繁调用底层能力的,于是驱动和硬件设备交互就需要频繁切换到内核态,这样会带来性能损耗。华为的鸿蒙操作系统的内核架构就是微内核。
            • 混合类型内核:它的架构有点像微内核,内核里面会有一个最小版本的内核,然后其他模块会在这个基础上搭建,然后实现的时候会跟宏内核类似,也就是把整个内核做成一个完整的程序,大部分服务都在内核中,这就像是宏内核的方式包裹着一个微内核
      • CPU vs Kernel(内核):

        • 操作系统的内核(Kernel)属于操作系统层面,而 CPU 属于硬件
        • CPU 主要提供运算、处理各种指令的能力。内核(Kernel)主要负责系统管理比如内存管理,它屏蔽了对硬件的操作
      • 用户态和内核态是如何切换的?
        • 一般用户态 -> 内核态的转换我们都称之为 trap 进内核,也被称之为 陷阱指令(trap instruction)。所有的用户进程都是运行在用户态的,用户程序的访问能力有限,一些比较重要的比如从硬盘读取数据,从键盘获取数据的操作则是内核态才能做的事情,而这些数据却又对用户程序来说非常重要。所以就涉及到两种模式下的转换,即用户态 -> 内核态 -> 用户态,而唯一能够做这些操作的只有 系统调用,而能够执行系统调用的就只有 操作系统


    • 用户态:用户态运行的程序只能受限地访问内存,只能直接读取用户程序的数据,并且不允许访问外围设备,用户态下的 CPU 不允许独占,也就是说 CPU 能够被其他程序获取

      • 在这 6 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc()【malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存。】 或者 mmap() ,就可以分别在堆和文件映射段动态分配内存

        • malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。

          • 通过 brk() 系统调用从堆分配内存,其实就是 通过 brk() 函数将堆顶指针向高地址移动,获得新的内存空间【如果用户分配的内存小于 128 KB,则通过 brk() 申请内存】

            • malloc() 分配的是虚拟内存。如果分配后的虚拟内存没有被访问的话,是不会将虚拟内存不会映射到物理内存,这样就不会占用物理内存了。只有在访问已分配的虚拟地址空间的时候,操作系统通过查找页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,然后操作系统会建立虚拟内存和物理内存之间的映射关系
            • malloc() 在分配内存的时候,并不是老老实实按用户预期申请的字节数来分配内存空间大小,而是会预分配更大的空间作为内存池
            • 如果 malloc 通过 mmap 方式申请的内存,free 释放内存后就会归归还给操作系统。 free 内存后堆内存还存在【先缓存着放进 malloc 的内存池里,当进程再次申请 1 字节的内存时就可以直接复用,这样速度快了很多。】,是针对 malloc 通过 brk() 方式申请的内存的情况。
            • 或者说,malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用,那要是一直用不到不就有点占着茅坑不拉屎的感觉了;malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放
          • 通过 mmap() 系统调用在文件映射区域分配内存;【如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存】

            • 如果经常使用 mmap 来分配内存会很耗费时间。因为向操作系统申请内存,是要通过系统调用的,执行系统调用是要进入内核态的,然后在回到用户态,运行态的切换会耗费不少时间。申请内存的操作应该避免频繁的系统调用,如果都用 mmap 来分配内存,等于每次都要执行系统调用,而malloc通过 brk() 系统调用后当内存释放的时候有些内存块有可能就缓存在内存池中,等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了,而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,这将大大降低 CPU 的消耗
            • 另外,因为 mmap 分配的内存每次释放的时候,都会归还给操作系统,于是每次 mmap 分配的虚拟地址都是缺页状态的,然后在第一次访问该虚拟地址的时候,就会触发缺页中断。频繁通过 mmap 分配的内存话,不仅每次都会发生运行态的切换,还会发生缺页中断(在第一次访问虚拟地址后),这样会导致 CPU 消耗较大
            • malloc 通过 brk() 系统调用在堆空间申请内存的时候,由于堆空间是连续的,所以直接预分配更大的内存来作为内存池,当内存释放的时候,就缓存在内存池中。等下次在申请内存的时候,就直接从内存池取出对应的内存块就行了,而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,这将大大降低 CPU 的消耗
  • 那进程和线程的区别?
    • 进程是个啥:进程是个啥
    • 线程是个啥?线程是个啥,进城、线程、协程区别
      • 提到这里,就得叙叙旧,瞅瞅并发和并行并发和并行是个啥

        • 往最后面翻,最后面也有
  • 概念可以奇货可居,在哪篇文章里面提及就行(咱有链接点一下就过去了),但是,进程与线程的切换流程不能忘吧:(对于linux来说,线程和进程的最大区别就在于地址空间,对于线程切换,第1步是不需要做的,第2步是进程和线程切换都要做的。)
    • 进程切换分两步:

      • 切换页表以使用新的地址空间,一旦去切换上下文,处理器中所有已经缓存的内存地址一瞬间都作废了。(因为每个进程都有自己的虚拟地址空间)

        • 为什么虚拟地址空间切换会比较耗时(所以说线程切换速度比进程快得多,因为进程还要切换虚拟地址空间而线程不用,所以进程切换慢):

          • 每个进程都有自己的虚拟地址空间(所以每个进程都有自己的页表),把 虚拟地址转换为物理地址需要查找页表,页表查找是一个很慢的过程,因此通常使用Cache来缓存常用的地址映射,这样可以加速页表查找,这个 Cache就是TLB(translation Lookaside Buffer,TLB本质上就是一个Cache,是用来加速页表查找的)。
          • 当进程切换后页表也要进行切换,页表切换后TLB就失效了,Cache失效导致命中率降低,那么虚拟地址转换为物理地址就会变慢表现出来的就是程序运行会变慢
          • 而线程切换则不会导致TLB失效,因为线程无需切换地址空间,因此我们通常说线程切换要比较进程切换快,原因就在这里。
      • 切换内核栈和硬件上下文
    • 线程切换:切换内核栈和硬件上下文(而线程是共享所在进程的虚拟地址空间的,因此同一个进程中的线程进行线程切换时不涉及虚拟地址空间的转换。)

PART2-1:同一台设备上的进程间通信方式有哪些?

  • 每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。不同的设备上的进程间通信,就需要网络通信,看计算机网络开篇去吧
  • 单个进程切换来切换去,那玩完了多个进程之间咋通信呢,相互联系一下(同一台设备上的进程间通信方式有哪些?):
    • 管道:就是Linux中的 | 这个竖线,其实所谓的管道,就是内核里面的一串缓存【从管道的一段写入的数据,实际上是缓存在内核中的,另一端读取,也就是从内核中读取这段数据。另外,管道传输的数据是无格式的流且大小受限。】管道/匿名管道(Pipes) :用于具有亲缘关系的父子进程间或者兄弟进程之间的通信。

      • 比如Linux命令:ps auxf | grep mysql,| 竖线就是一个管道,它的功能是 将前一个命令(ps auxf)的输出,作为后一个命令(grep mysql)的输入,从这功能描述,可以看出管道传输数据是单向的,如果想相互通信,我们需要创建两个管道才行
      • 管道这种通讯方式有两种限制
        • 一是半双工的通信,数据只能单向流动
        • 二是只能在具有亲缘关系的进程间使用。进程的亲缘关系通常是指父子进程关系。
      • 管道实现通信功能的步骤:
      • 管道可以分为两类:

        • 匿名管道:单向的,只能在有亲缘关系的进程间通信【因为管道没有实体,也就是没有管道文件,只能通过 fork 来复制父进程 fd 文件描述符,来达到通信的目的。】。

          • | 表示的管道称为匿名管道
          • 匿名管道的创建,需要通过下面这个系统调用:int pipe(int fd[2]),其实这里是使用 fork 创建子进程,创建的子进程会复制父进程的文件描述符,这样就做到了两个进程各有两个「 fd[0] 与 fd[1]」,两个进程就可以通过各自的 fd 写入和读取同一个管道文件实现跨进程通信了

          • 在 shell 里面执行 A | B命令的时候,A 进程和 B 进程都是 shell 创建出来的子进程,A 和 B 之间不存在父子关系,它俩的父进程都是 shell所以说在 shell 里通过 | 匿名管道将多个命令连接在一起,实际上也就是创建了多个子进程,那么在我们编写 shell 脚本时,能使用一个管道搞定的事情,就不要多用一个管道,这样可以减少创建子进程的系统开销
        • 命名管道:以磁盘文件的方式存在,可以实现本机任意两个进程通信。【对于命名管道,它可以在不相关的进程间也能相互通信。因为命令管道,提前创建了一个类型为管道的设备文件,在进程里只要使用这个设备文件,就可以相互通信。】
          • 另外一个类型是命名管道,也被叫做 FIFO,因为数据是先进先出的传输方式。在使用命名管道前,先需要通过 mkfifo 命令来创建,并且指定管道名字:mkfifo myPipe。myPipe 就是这个管道的名称,基于 Linux 一切皆文件的理念,所以管道也是以文件的方式存在。只有当管道里的数据被读完后,命令才可以正常退出。
          • 有名管道(Names Pipes) : 匿名管道由于没有名字,只能用于亲缘关系的进程间通信。为了克服这个缺点,提出了有名管道。有名管道严格遵循先进先出(first in first out)。有名管道以磁盘文件的方式存在,可以实现本机任意两个进程通信。
      • 管道:速度慢,容量有限;管道这种通信方式效率低,不适合进程间频繁地交换数据
    • 信号:信号是一种比较复杂的通信方式,信号可以在任何时候发给某一进程,用于通知接收进程某个事件已经发生,而无需知道该进程的状态。Linux系统中常用信号:【对于异常情况下的工作模式,就需要用信号的方式来通知进程。】在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号,分别代表不同的意义。

      • 信号是进程间通信机制中唯一的异步通信机制,因为可以在任何时候发送信号给某一进程,一旦有信号产生,我们就有下面这几种,用户进程对信号的处理方式。

        • 1.执行默认操作。Linux 对每种信号都规定了默认操作,例如,上面列表中的 SIGTERM 信号,就是终止进程的意思
        • 2.捕捉信号。我们可以为信号定义一个信号处理函数。当信号发生时,我们就执行相应的信号处理函数。
        • 3.忽略信号。当我们不希望处理某些信号的时候,就可以忽略该信号,不做任何处理。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SEGSTOP,它们用于在任何时候中断或结束某一进程
      • 常见命令:
        • SIGHUP:用户从终端注销,所有已启动进程都将收到该进程。系统缺省状态下对该信号的处理是终止进程。
        • SIGTSTP:Ctrl+Z 产生 SIGTSTP 信号,表示停止该进程,但还未结束
        • SIGINT:程序终止信号。程序运行过程中,按Ctrl+C键将产生该信号
          • Ctrl+C 产生 SIGINT 信号,表示终止该进程
        • SIGQUIT:程序退出信号。程序运行过程中,按Ctrl+\键将产生该信号
        • SIGBUS和SIGSEGV:进程访问非法地址
        • SIGFPE:运算中出现致命错误,如除零操作、数据溢出等
        • SIGKILL:用户终止进程执行信号。shell下执行kill -9发送该信号
          • kill、kill -9、kill -3的区别:

            • kill 会默认传15代表的信号为SIGTERM,这是告诉进程你需要被关闭,请自行停止运行并退出,进程可以清理缓存自行结束,也可以拒绝结束。如果是让进程自己执行退出离场程序就使用 kill 命令,,这样进程可以自己执行一些清理动作然后退出
            • kill -9代表的信号是SIGKILL,表示进程被终止,需要立即退出,强制杀死该进程,这个信号不能被捕获也不能被忽略。如果你什么也不需要,就是要杀死一个进程那么就是用 kill -9 命令,很暴力的杀死它
            • kill -3可以打印进程各个线程的堆栈信息,kill -3 pid 后文件的保存路径为:/proc/${pid}/cwd,文件名为:antBuilderOutput.log。如果进程卡死,你需要记录当时的事故现场,那么应该用 kill -3 来记录事故现场的信息然后退出
        • SIGTERM:结束进程信号。shell下执行kill 进程pid发送该信号
        • SIGALRM:定时器信号
        • SIGCLD:子进程退出信号。如果其父进程没有忽略该信号也没有处理该信号,则子进程退出后将形成僵尸进程
      • 实际上 kill 执行的是系统调用,将控制权转移给了内核(操作系统),由内核来给指定的进程发送信号【也就是说虽然给进程发送了 kill 信号,但如果进程自己定义了信号处理函数或者无视信号就有机会逃出生天,当然了 kill -9 命令例外,不管进程是否定义了信号处理函数,都会马上被干掉。其实是JVM 自己定义了信号处理函数,这样当发送 kill pid 命令(默认会传 15 也就是 SIGTERM)后,JVM 就可以在信号处理函数中执行一些资源清理之后再调用 exit 退出。】
        • CPU 执行正常的进程指令
        • 调用 kill 系统调用向进程发送信号
        • 进程收到操作系统发的信号,CPU 暂停当前程序运行,并将控制权转交给操作系统
        • 调用 kill 系统调用向进程发送信号(假设为 11,即 SIGSEGV,一般非法访问内存报的都是这个错误)
        • 操作系统根据情况执行相应的信号处理程序(函数),一般执行完信号处理程序逻辑后会让进程退出。如果进程没有注册自己的信号处理函数,那么操作系统会执行默认的信号处理程序(一般最后会让进程退出),但如果注册了,则会执行自己的信号处理函数,这样的话就给了进程一个垂死挣扎的机会,它收到 kill 信号后,可以调用 exit() 来退出,但也可以使用 sigsetjmp,siglongjmp 这两个函数来恢复进程的执行
    • 信号量信号量是一个计数器,可以用来控制多个进程对共享资源的访问,实现进程间的互斥与同步。它常作为一种锁机制防止某进程正在访问共享资源时,其他进程也访问该资源。因此,主要作为进程间以及同一进程内不同线程之间的同步手段。这种通信方式主要用于解决与同步相关的问题并避免竞争条件。
      • 用了共享内存通信方式,带来新的问题,那就是如果多个进程同时修改同一个共享内存,很有可能就冲突了。例如两个进程都同时写一个地址,那先写的那个进程会发现内容被别人覆盖了。为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。正好,信号量就实现了这一保护机制


      • 信号量:不能传递复杂消息,只能用来同步
    • 消息队列消息队列就是来解决管道的通信方式效率低、不适合进程间频繁地交换数据等问题的消息队列是消息的链接表或者叫链表【消息队列是保存在内核中的消息链表】,包括Posix消息队列和System V消息队列。有足够权限的进程可以向队列中添加消息,被赋予读权限的进程则可以读走队列中的消息。消息队列克服了信号承载信息量少,管道只能承载无格式字节流以及缓冲区大小受限等缺点

      • 比如,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此。
      • 消息队列存放在内存中并由消息队列标识符标识。
      • 管道和消息队列的通信数据都是先进先出的原则,与管道(无名管道:只存在于内存中的文件;命名管道:存在于实际的磁盘介质或者文件系统)不同的是消息队列存放在内核中,只有在内核重启(即,操作系统重启)或者显式地删除一个消息队列时,该消息队列才会被真正的删除
        • 在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块)【消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,所以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。】如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。
        • 消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的 匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁
      • 消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按消息的类型读取.比 FIFO 更有优势
      • 消息队列:容量受到系统限制,且要注意第一次读的时候,要考虑上一次没有读完数据的问题
        • 消息这种模型,两个进程之间的通信就像平时发邮件一样,你来一封,我回一封,可以频繁沟通了。但邮件的通信方式存在不足的地方有两点,一是通信不及时,二是附件也有大小限制,这同样也是消息队列通信不足的点。

          • 消息队列不适合比较大数据的传输,因为在内核中每个消息体都有一个最大长度的限制,同时所有队列所包含的全部消息体的总长度也是有上限。在 Linux 内核中,会有两个宏定义 MSGMAX 和 MSGMNB,它们以字节为单位,分别定义了一条消息的最大长度和一个队列的最大长度。
          • 消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程
    • 共享内存共享内存就是映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问。共享内存是最快的 IPC 方式,它是针对其他进程间通信方式运行效率低而专门设计的。它往往与其他通信机制,如信号量,配合使用,来实现进程间的同步和通信

      • 消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝过程。那共享内存的方式,就很好的解决了这一问题。
      • 共享内存区:能够很容易控制容量,速度快,但要保持同步,比如一个进程在写的时候,另一个进程要注意读写的问题,相当于线程中的线程安全,当然,共享内存区同样可以用作线程间通讯,不过没这个必要,线程间本来就已经共享了同一进程内的一块内存
      • 共享内存使得多个进程可以访问同一块内存空间,不同进程可以及时看到对方进程中对共享内存中数据的更新。这种方式需要依靠某种同步操作,如互斥锁和信号量等。可以说这是最有用的进程间通信方式。
    • 套接字Socket:与其他通信机制不同的是,它可用于不同机器间的进程通信【要想跨网络与不同主机上的进程之间通信:int socket(int domain, int type, int protocal),就需要 Socket 通信了。】
      • Socket:任何进程间都能通讯,但速度慢

        • 套接字通信的方式非常多,有Unix域套接字、TCP套接字、UDP套接字、链路层套接字等等。但最常用的肯定是TCP套接字。
      • 套接字Socket方法主要用于在客户端和服务器之间通过网络进行通信。套接字是支持 TCP/IP 的网络通信的基本操作单元,套接字可以看做是不同主机之间的进程进行双向通信的端点,简单的说就是 通信的两方的一种约定,用套接字中的相关函数来完成通信过程
      • int socket(int domain, int type, int protocal):
        • domain 参数用来指定协议族,比如 AF_INET 用于 IPV4、AF_INET6 用于 IPV6、AF_LOCAL/AF_UNIX 用于本机;
        • type 参数用来指定通信特性,比如 SOCK_STREAM 表示的是字节流,对应 TCP、SOCK_DGRAM 表示的是数据报,对应 UDP、SOCK_RAW 表示的是原始套接字;
        • protocal 参数原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成,protocol 目前一般写成 0 即可
      • 根据创建 socket 类型的不同,通信的方式也就不同:
        • 实现 TCP 字节流通信: socket 类型是 AF_INET 和 SOCK_STREAM;
        • 实现 UDP 数据报通信:socket 类型是 AF_INET 和 SOCK_DGRAM;
        • 实现本地进程间通信: 本地字节流 socket 类型是 AF_LOCAL 和 SOCK_STREAM本地数据报 socket 类型是 AF_LOCAL 和 SOCK_DGRAM。另外,AF_UNIX 和 AF_LOCAL 是等价的,所以 AF_UNIX 也属于本地 socket;【本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别
          • 本地 socket 被用于在同一台主机上进程间通信的场景:
          • 本地 socket 的编程接口和 IPv4 、IPv6 套接字编程接口是一致的,可以支持字节流和数据报两种协议;
          • 本地 socket 的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报 socket 实现

PART2-2:进程间同步的方式有哪些?

  • 临界区:通过 对多线程的串行化 来访问公共资源或一段代码,速度快,适合控制数据访问。

    • 优点:保证在某一时刻只有一个线程能访问数据的简便办法。
    • 缺点:虽然临界区同步速度很快,但 临界区却只能用来同步本进程内的线程,而不可用来同步多个进程中的线程
  • 互斥量:为协调共同对一个共享资源的单独访问而设计的。互斥量跟临界区很相似,互斥量比临界区复杂,互斥对象只有一个,只有拥有互斥对象的线程才具有访问资源的权限。

    • 优点:使用互斥不仅仅能够在同一应用程序不同线程中实现资源的安全共享,而且 可以在不同应用程序的线程之间实现对资源的安全共享
    • 缺点:
      • 互斥量是可以命名的,也就是说它可以跨越进程使用,所以创建互斥量需要的资源更多,所以如果只为了在进程内部是用的话使用临界区会带来速度上的优势并能够减少资源占用量。
      • 通过互斥量可以指定资源被独占的方式使用,但如果有下面一种情况通过互斥量就无法处理,比如现在一位用户购买了一份三个并发访问许可的数据库系统,可以根据用户购买的访问许可数量来决定有多少个线程/进程能同时进行数据库操作,这时候如果利用互斥量就没有办法完成这个要求,信号量对象可以说是一种资源计数器。
  • 信号量:为控制一个具有有限数量用户资源而设计。它允许多个线程在同一时刻访问同一资源,但是 需要限制在同一时刻访问此资源的最大线程数目互斥量是信号量的一种特殊情况,当信号量的最大资源数=1就是互斥量了

    • 优点:适用于对Socket(套接字)程序中线程的同步。
    • 缺点:
  • 事件: 用来通知线程有一些事件已发生,从而启动后继任务的开始。
    • 优点:事件对象通过通知操作的方式来保持线程的同步,并且可以实现不同进程中的线程同步操作。

PART2-3:线程同步的方式有哪些?

  • 同步异步:需不需要等待结果返回

    • 需要等待结果返回,才能继续运行就是同步(同步在多线程中还有一层意思就是让多个线程步调一致
    • 不需要等待结果返回,就能继续运行就是异步
  • 线程同步的方式有哪些?【互斥量,信号量,事件都可以被跨越进程使用来进行同步数据操作。】
    • 临界区:通过对多线程的串行化来访问公共资源或一段代码,速度快,适合控制数据访问

      • 每个进程中访问临界资源的那段程序称为临界区的那段程序称为临界区一次仅允许一个进程使用的资源称为临界资源【当多个线程访问一个独占性共享资源时,可以使用临界区对象。拥有临界区的线程可以访问被保护起来的资源或代码段,其他线程若想访问,则被挂起,直到拥有临界区的线程放弃临界区为止,以此达到用原子方式操 作共享资源的目的】

        • 什么是临界区,如何解决冲突

          • 解决冲突的办法有:

            • 每个进程中访问临界资源【一次仅允许一个进程使用的资源称为临界资源】的那段程序称为临界区。如果有若干进程要求进入空闲的临界区,一次仅允许一个进程进入,如已有进程进入自己的临界区,则其它所有试图进入临界区的进程必须等待
            • 进入临界区的进程要在有限时间内退出
            • 如果进程不能进入自己的临界区,则应让出CPU,避免进程出现“忙等”现象
      • 优点:保证在某一时刻只有一个线程能访问数据的简便办法。
      • 缺点:虽然临界区同步速度很快,但却只能用来同步本进程内的线程,而不可用来同步多个进程中的线程
    • 互斥量(Mutex):为协调共同对一个共享资源的单独访问而设计的。互斥量跟临界区很相似,比临界区复杂,互斥对象只有一个,只有拥有互斥对象的线程才具有访问资源的权限。【互斥对象和临界区对象非常相似,只是其允许在进程间使用,而临界区只限制与同一进程的各个线程之间使用,但是更节省资源,更有效率】
      • 采用互斥对象机制,只有拥有互斥对象的线程才有访问公共资源的权限。因为互斥对象只有一个,所以可以保证公共资源不会被多个线程同时访问。比如 Java 中的 synchronized 关键词和各种 Lock 都是这种机制
    • 信号量:为控制一个具有有限数量用户资源而设计。信号量允许多个线程在同一时刻访问同一资源,但是需要限制在同一时刻访问此资源的最大线程数目。互斥量是信号量的一种特殊情况,当信号量的最大资源数=1就是互斥量了。【当需要一个计数器来限制可以使用某共享资源的线程数目时,可以使用“信号量”对象
      • 优点:适用于对Socket(套接字)程序中线程的同步。
      • 缺点:
        • 信号量机制必须有公共内存,不能用于分布式操作系统,这是它最大的弱点;
        • 信号量机制功能强大,但使用时对信号量的操作分散, 而且难以控制,读写和维护都很困难,加重了程序员的编码负担;
        • 核心操作P-V分散在各用户程序的代码中,不易控制和管理,一旦错误,后果严重,且不易发现和纠正。
    • 事件: 用来通知线程有一些事件已发生,从而启动后继任务的开始。【事件机制,则允许一个线程在处理完一个任务后,主动唤醒另外一个线程执行任务
      • 优点:事件对象通过通知操作的方式来保持线程的同步,并且可以实现不同进程中的线程同步操作。通过通知操作的方式来保持多线程同步,还可以方便的实现多线程优先级的比较操作。

PART3:锁

  • 锁:

    • 锁的实现不同,可以分为忙等待锁和无忙等待锁:

      • 忙等待锁的实现:当获取不到锁时,线程就会一直 while 循环,不做任何事情,所以就被称为忙等待锁,也被称为自旋锁(spin lock)。【一直自旋,利用 CPU 周期,直到锁可用。在单处理器上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。】

        • 运用Test-and-Set指令实现忙等待锁:

      • 无等待锁:获取不到锁的时候,不用自旋【既然不想自旋,那当没获取到锁的时候,就把当前线程放入到锁的等待队列,然后执行调度程序,把 CPU 让给其他线程执行。】
  • 死锁:死锁~巴拉巴拉

    • 死锁描述的是这样一种情况:多个进程/线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于进程/线程被无限期地阻塞,因此程序不可能正常终止

      • 其实会产生死锁就是因为 在多线程编程中,我们为了防止多线程竞争共享资源而导致数据错乱,都会在操作共享资源之前加上互斥锁,只有成功获得到锁的线程,才能操作共享资源,获取不到锁的线程就只能等待,直到锁被释放。那么,当两个线程为了保护两个不同的共享资源而使用了两个互斥锁,那么这两个互斥锁应用不当的时候,可能会造成两个线程都在等待对方释放锁,在没有外力的作用下,这些线程会一直相互等待,就没办法继续运行,这种情况就是发生了死锁。
    • 产生死锁的四个必要条件:互斥、占有且等待、不可强占用、循环等待。只要系统发生死锁,这些条件必然成立,但是只要破坏任意一个条件就死锁就不会成立。【只有四个条件同时成立时,死锁才会出现。】
      • 互斥:资源必须处于非共享模式,即资源一次只能被一个进程可以使用【多个线程不能同时使用同一个资源】。如果另一进程申请该资源,那么必须等待直到该资源被释放为止。
      • 占有并等待【请求与保持条件】:一个进程至少应该占有一个资源保持不释放,并等待另一资源,我抱着我的资源去等你的资源,而该资源被其他进程所占有,所以我就抢不到了呗,我就被阻塞了。【大白话就是说:当线程 A 已经持有了资源 1,又想申请资源 2,而资源 2 已经被线程 C 持有了,所以线程 A 就会处于等待状态,但是线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1。
      • 非抢占【不可剥夺条件】:在未完全使用完之前资源不能被抢占。只能在持有资源的进程完成任务后,该资源才会被释放。【当线程已经持有了资源 ,在自己使用完之前不能被其他线程获取,线程 B 如果也想使用此资源,则只能在线程 A 使用完并释放后才能获取。】
      • 循环等待:有一组等待进程 {P0, P1,…, Pn}, P0 等待的资源被 P1 占有,P1 等待的资源被 P2 占有,…,Pn-1 等待的资源被 Pn 占有,Pn 等待的资源被 P0 占有。【在死锁发生的时候,两个线程获取资源的顺序构成了环形链,比如,线程 A 已经持有资源 2,而想请求资源 1, 线程 B 已经获取了资源 1,而想请求资源 2,这就形成资源请求等待的环形图。】
    • 解决死锁的方法可以从多个角度去分析,一般的情况下,有死锁预防、死锁避免、死锁检测、死锁解除、鸵鸟策略 五种。
      • 死锁预防:是采用某种策略,限制并发进程对资源的请求,从而使得死锁的必要条件在系统执行的任何时间上都不满足。【基本思想就是 确保死锁发生的四个必要条件中至少有一个不成立:】

        • 只要破坏四个必要条件中的任何一个就能够预防死锁的发生。

          • 破坏第一个条件 互斥条件:使得资源是可以同时访问的,这是种简单的方法,磁盘就可以用这种方法管理,但是我们要知道,有很多资源往往是不能同时访问的 ,所以这种做法在大多数的场合是行不通的
          • 破坏第三个条件 非抢占 :也就是说可以采用 剥夺式调度算法,但剥夺式调度方法目前一般仅适用于 主存资源 和 处理器资源 的分配,并不适用于所有的资源,会导致 资源利用率下降。
            • 破除“不可剥夺”条件允许进程强行从占有者那里夺取某些资源。当一个已经保持了某些不可被抢占资源的进程,提出新的资源请求而不能得到满足时,它必须释放已经保持的所有资源,待以后需要时再重新申请。这意味着进程已经占有的资源会被暂时被释放,或者说被抢占了
          • 所以一般比较实用的 预防死锁的方法,是通过考虑破坏第二个条件和第四个条件
            • 静态分配策略:静态分配策略可以破坏死锁产生的第二个条件(占有并等待)。所谓静态分配策略,就是指一个进程必须在执行前就申请到它所需要的全部资源,并且知道它所要的资源都得到满足之后才开始执行。进程要么占有所有的资源然后开始执行,要么不占有资源,不会出现占有一些资源等待一些资源的情况。

              • 静态分配策略逻辑简单,实现也很容易,但这种策略 严重地降低了资源利用率,因为在每个进程所占有的资源中,有些资源是在比较靠后的执行时间里采用的,甚至有些资源是在额外的情况下才是用的,这样就可能造成了一个进程占有了一些 几乎不用的资源而使其他需要该资源的进程产生等待 的情况。
              • 破除“请求与保持”条件实行资源预分配策略,进程在运行之前,必须一次性获取所有的资源。缺点:在很多情况下,无法预知进程执行前所需的全部资源,因为进程是动态执行的,同时也会降低资源利用率,导致降低了进程的并发性
            • 层次分配策略:层次分配策略破坏了产生死锁的第四个条件(循环等待)。在层次分配策略下,所有的资源被分成了多个层次,一个进程得到某一次的一个资源后,它只能再申请较高一层的资源;当一个进程要释放某层的一个资源时,必须先释放所占用的较高层的资源,按这种策略,是不可能出现循环等待链的,因为那样的话,就出现了已经申请了较高层的资源,反而去申请了较低层的资源,不符合层次分配策略
              • 破除“循环等待”条件:实行资源有序分配策略,对所有资源排序编号,按照顺序获取资源,将紧缺的,稀少的采用较大的编号,在申请资源时必须按照编号的顺序进行,一个进程只有获得较小编号的进程才能申请较大编号的进程。
      • 死锁避免:则是系统在分配资源时,根据资源的使用情况提前做出预测,从而避免死锁的发生
        • 破坏死锁产生的四个必要条件之一就可以成功 预防系统发生死锁 ,但是会导致 低效的进程运行 和 资源使用率 。而死锁的避免相反,它的角度是允许系统中同时存在四个必要条件 ,只要掌握并发进程中与每个进程有关的资源动态申请情况,做出 明智和合理的选择 ,仍然可以避免死锁,因为四大条件仅仅是产生死锁的必要条件

          • 死锁避免:死锁预防通过约束资源请求,防止4个必要条件中至少一个的发生,可以通过直接或间接预防方法,但是都会导致低效的资源使用和低效的进程执行。而 死锁避免则允许前三个必要条件,但是通过动态地检测资源分配状态,以确保循环等待条件不成立,从而确保系统处于安全状态。所谓安全状态是指:如果系统能按某个顺序为每个进程分配资源(不超过其最大值),那么系统状态是安全的,换句话说就是,如果存在一个安全序列,那么系统处于安全状态银行家算法是经典的死锁避免的算法
        • 我们将系统的状态分为 安全状态 和 不安全状态 ,每当在未申请者分配资源前先测试系统状态,若把系统资源分配给申请者会产生死锁,则拒绝分配,否则接受申请,并为它分配资源
          • 如果操作系统能够保证所有的进程在有限的时间内得到需要的全部资源,则称系统处于安全状态【很显然,系统处于安全状态则不会发生死锁,系统若处于不安全状态则可能发生死锁。】

            • 可以通过一些避免死锁的算法来保证系统保持在安全状态。其中最具有代表性的 避免死锁算法 就是 Dijkstra 的银行家算法,银行家算法用一句话表达就是:当一个进程申请使用资源的时候,银行家算法 通过先 试探 分配给该进程资源,然后通过 安全性算法 判断分配后系统是否处于安全状态,若不安全则试探分配作废,让该进程继续等待,若能够进入到安全的状态,则就 真的分配资源给该进程
          • 否则说系统是不安全的
        • 死锁的避免(银行家算法)改善解决了 资源使用率低的问题 ,但是它要不断地检测每个进程对各类资源的占用和申请情况,以及做 安全性检查 ,需要花费较多的时间
        • 对资源的分配加以限制可以 预防和避免 死锁的发生,但是都不利于各进程对系统资源的充分共享。解决死锁问题的另一条途径是 死锁检测和解除 (这里突然联想到了乐观锁和悲观锁,感觉死锁的检测和解除就像是 乐观锁 ,分配资源时不去提前管会不会发生死锁了,等到真的死锁出现了再来解决嘛,而 死锁的预防和避免 更像是悲观锁,总是觉得死锁会出现,所以在分配资源的时候就很谨慎)。
      • 死锁检测:是指系统设有专门的机构,当死锁发生时,该机构能够检测死锁的发生,并精确地确定与死锁有关的进程和资源。
        • 死锁检测:死锁预防策略是非常保守的,他们 通过限制访问资源和在进程上强加约束来解决死锁的问题。死锁检测则是完全相反,它不限制资源访问或约束进程行为,只要有可能,被请求的资源就被授权给进程。但是操作系统 会周期性地执行一个算法检测前面的循环等待的条件。死锁检测算法是通过资源分配图来检测是否存在环来实现,从一个节点出发进行深度优先搜索,对访问过的节点进行标记,如果访问了已经标记的节点,就表示有存在环,也就是检测到死锁的发生。
        • 操作系统中的每一刻时刻的系统状态都可以用进程-资源分配图来表示,进程-资源分配图是描述进程和资源申请及分配关系的一种有向图【用一个方框表示每一个资源类,方框中的黑点表示该资源类中的各个资源,每个键进程用一个圆圈表示,用 有向边 来表示进程申请资源和资源被分配的情况。】,可用于检测系统是否处于死锁状态
        • 死锁检测步骤:编写一个死锁检测程序的步骤,检测系统是否产生了死锁。
          • 如果进程-资源分配图中无环路,则此时系统没有发生死锁
          • 如果进程-资源分配图中有环路,且每个资源类仅有一个资源,则系统中已经发生了死锁
          • 如果进程-资源分配图中有环路,且涉及到的资源类有多个资源,此时系统未必会发生死锁。如果能在进程-资源分配图中找出一个 既不阻塞又非独立的进程 ,该进程能够在有限的时间内归还占有的资源,也就是把边给消除掉了,重复此过程,直到能在有限的时间内 消除所有的边 ,则不会发生死锁,否则会发生死锁。(消除边的过程类似于 拓扑排序
        • 这种方法对资源的分配不加以任何限制,也不采取死锁避免措施,但系统 定时地运行一个 “死锁检测” 的程序,判断系统内是否出现死锁,如果检测到系统发生了死锁,再采取措施去解除它。
      • 死锁解除: 是与检测相配套的一种措施,用于将进程从死锁状态下解脱出来
        • 死锁解除:死锁解除的常用方法就是终止进程和资源抢占,回滚。所谓进程终止就是简单地终止一个或多个进程以打破循环等待,包括两种方式:终止所有死锁进程和一次只终止一个进程直到取消死锁循环为止;所谓资源抢占就是从一个或者多个死锁进程那里抢占一个或多个资源
        • 当死锁检测程序检测到存在死锁发生时,应设法让其解除,让系统从死锁状态中恢复过来,常用的解除死锁的方法有以下四种:
          • 立即结束所有进程的执行,重新启动操作系统 :这种方法简单,但以前所在的工作全部作废,损失很大。
          • 撤销涉及死锁的所有进程,解除死锁后继续运行 :这种方法能彻底打破死锁的循环等待条件,但将付出很大代价,例如有些进程可能已经计算了很长时间,由于被撤销而使产生的部分结果也被消除了,再重新执行时还要再次进行计算。
          • 逐个撤销涉及死锁的进程,回收其资源直至死锁解除。
          • 抢占资源 :从涉及死锁的一个或几个进程中抢占资源,把夺得的资源再分配给涉及死锁的进程直至死锁解除
      • 鸵鸟策略:把头埋在沙子里,假装根本没发生死锁问题。因为解决死锁问题的代价很高,因此鸵鸟策略这种不采取任何措施的方案会获得更高的性能。当发生死锁时不会对用户造成多大影响,或发生死锁的概率很低,可以采用鸵鸟策略。大多数操作系统,包括 Unix,Linux 和 Windows,处理死锁问题的办法仅仅是忽略它
    • 咱们一般分析死锁,也就是

      • 想排查你的 Java 程序是否死锁,则可以使用 jstack 工具【jstack 用于生成 Java 虚拟机当前时刻的线程快照,“-l”表示长列表(long),打印关于锁的附加信息。】,它是 jdk 自带的线程堆栈分析工具

        • 们在使用 jstack 之前,先要通过 jps 得到运行程序的进程 ID,有了进程 ID(PID)之后,我们就可以使用“jstack -l PID”来发现死锁问题了
        • 磊哥老师的Java中文社群排查死锁的 4 种工具
      • 在 Linux 下,我们可以使用 pstack + gdb 工具来定位死锁问题。pstack 命令可以显示每个线程的栈跟踪信息(函数调用过程),只需要 pstack 就可以了。在定位死锁问题时,我们可以多次执行 pstack 命令查看线程的函数调用过程,多次对比结果确认哪几个线程一直没有变化,且是因为在等待锁,那么大概率是由于死锁问题导致的。因为我们看不到它们是分别在等哪个锁对象,可以使用 gdb 工具进一步确认。
  • 乐观锁与悲观锁
    • 悲观锁(悲观锁的实现往往依靠数据库提供的锁机制,也就是在数据库中,在对数据记录操作前给记录加排它锁)指对数据被外界修改持保守态度,认为数据很容易就会被其他线程修改,所以在数据被处理前先对数据进行加锁,并在整个数据处理过程中,使数据处于锁定状态

      • 如果获取锁失败, 则说明数据正在被线程修改,当前线程等待或者抛出异常
      • 如果获取锁成功,则对记录进行操作 ,然后提交事务后释放排它锁
      • 互斥锁、自旋锁、读写锁,都是属于悲观锁,悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁。
    • 乐观锁:是相对悲观锁来说的,它认为数据在一般情况下不会造成冲突。所以在访问记录前不会加排它锁,而是在进行数据提交更新时才会正式对数据冲突与否进行检测 。
      • 如果多线程同时修改共享资源的概率比较低【只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁】,就可以采用乐观锁。
      • 乐观锁工作方式:乐观锁的心态是,不管三七二十一,先改了资源再说【乐观锁想的是应该不会出问题的】。另外,你会发现乐观锁全程并没有加锁,所以它也叫无锁编程
        • 先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。放弃后如何重试,这跟业务场景息息相关,虽然重试的成本很高,但是冲突的概率足够低的话,还是可以接受的。
      • 乐观锁并不使用数据库提供的锁机制,一般在表中添加version字段或者使用业务状态来实现。乐观锁直到提交时才锁定,所以不会产生任何死锁
  • 互斥锁和自旋锁:如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁
    • 最底层的两种就是互斥锁和自旋锁,有很多高级的锁都是基于它们实现的,你可以认为它们是各种锁的地基。当已经有一个线程加锁后,其他线程加锁则就会失败

      • 互斥锁是一种独占锁

        • 比如当线程 A 加锁成功后,此时互斥锁已经被线程 A 独占了,只要线程 A 没有释放手中的锁,线程 B 加锁就会失败,于是就会释放 CPU 让给其他线程,既然线程 B 释放掉了 CPU,自然线程 B 加锁的代码就会被阻塞。
        • 互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但是存在一定的性能开销成本。这个性能开销成本就是两次线程上下文切换的成本:
          • 当线程加锁失败时,内核会把线程的状态从运行状态设置为睡眠状态,然后把 CPU 切换给其他线程运行
          • 接着,当锁被释放时,之前睡眠状态的线程会变为就绪状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。
      • 自旋锁:自旋锁,看这里
    • 互斥锁和自旋锁对于加锁失败后的处理方式是不一样的
      • 互斥锁加锁失败后,线程会释放 CPU ,给其他线程也就是切换线程
      • 自旋锁加锁失败后,线程会忙等待也就是一直自旋,直到它拿到锁
  • 读写锁:
    • 读写锁原理:

      • 当写锁没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为读锁是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
      • 但是**,一旦写锁被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞**
    • 根据实现的不同,读写锁可以分为读优先锁和写优先锁
      • 读优先锁期望的是,读锁能被更多的线程持有,以便提高读线程的并发性,它的工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 仍然可以成功获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获取写锁

        • 读优先锁对于读线程并发性更好,但也不是没有问题。如果一直有读线程获取读锁,那么写线程将永远获取不到写锁,这就造成了写线程饥饿的现象
      • 写优先锁是优先服务写线程,其工作方式是:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 获取读锁时会失败,于是读线程 C 将被阻塞在获取读锁的操作,这样只要读线程 A 释放读锁后,写线程 B 就可以成功获取写锁
        • 写优先锁可以保证写线程不会饿死,但是如果一直有写线程获取写锁,读线程也会被饿死
      • 公平读写锁比较简单的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现饥饿的现象
    • 由读锁和写锁构成,读写锁适用于能明确区分读操作和写操作的场景,读写锁在读多写少的场景,能发挥出优势
      • 读锁:只读取共享资源用读锁加锁

        • 读锁是共享锁,因为读锁可以被多个线程同时持有。
      • 写锁:要修改共享资源则用写锁加锁,
        • 写锁是独占锁,因为任何时刻只能有一个线程持有写锁,类似互斥锁和自旋锁
  • 公平锁与非公平锁
  • 共享锁与独占锁

PART4-1:OS三大调度机制之一:进程调度算法(进程调度机制)

  • 进程调度策略有哪几种?或者说操作系统中进程调度算法?为了确定首先执行哪个进程以及最后执行哪个进程以实现最大 CPU 利用率【进程是由 CPU 调度的,当 CPU 空闲时,操作系统就选择内存中的某个就绪状态的进程,并给其分配 CPU】

    • 什么时候会发生 CPU 调度呢?

      • 非抢占式调度:当进程正在运行时,它就会一直运行,直到该进程完成或发生某个事件而被阻塞时,才会把 CPU 让给其他进程。

        • 当进程从运行状态转到等待状态
        • 当进程从运行状态转到终止状态
      • 抢占式调度:进程正在运行的时,可以被打断,使其把 CPU 让给其他进程【抢占的原则一般有三种,分别是时间片原则、优先权原则、短作业优先原则。】
        • 当进程从运行状态转到就绪状态
        • 当进程从等待状态转到就绪状态
    • 计算机科学家已经定义了一些算法,它们是:
      • 先到先服务(FCFS)调度算法 :非抢占式的调度算法,按照请求的顺序进行调度【 从就绪队列中选择一个最先进入该队列的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。或者大白话是然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行】。有利于长作业,但不利于短作业,因为短作业必须一直等待前面的长作业执行完毕才能执行,而长作业又需要执行很长时间,造成了短作业等待时间过长。另外,适用于 CPU 繁忙型作业的系统而对I/O密集型进程也不利,因为这种进程每次进行I/O操作之后又得重新排队。

        • 在进程的生命周期中,当进程从一个运行状态到另外一状态变化的时候,其实会触发一次调度。这些状态变化的时候,操作系统需要考虑是否要让新的进程给 CPU 运行,或者是否让当前进程从 CPU 上退出来而换另一个进程运行。另外,如果硬件时钟提供某个频率的周期性中断,那么 可以根据如何处理时钟中断 ,把调度算法分为两类

          • 非抢占式调度算法:挑选一个进程,然后让该进程运行直到被阻塞,或者直到该进程退出,才会调用另外一个进程,也就是说不会理时钟中断这个事情。
          • 抢占式调度算法:挑选一个进程,然后让该进程只运行某段时间,如果在该时段结束时,该进程仍然在运行时,则会把它挂起,接着调度程序从就绪队列挑选另外一个进程。这种抢占式调度处理,需要在时间间隔的末端发生时钟中断,以便把 CPU 控制返回给调度程序进行调度,也就是常说的时间片机制。
      • 短作业优先(SJF)的调度算法:非抢占式的调度算法,按估计运行时间最短的顺序进行调度【从就绪队列中选出一个估计运行时间最短的进程为之分配资源,使它立即执行并一直执行到完成或发生某事件而被阻塞放弃占用 CPU 时再重新调度。】。长作业有可能会饿死,处于一直等待短作业执行完毕的状态。因为如果一直有短作业到来,那么长作业永远得不到调度
        • 这显然对长作业不利,很容易造成一种极端现象。比如,一个长作业在就绪队列等待运行,而这个就绪队列有非常多的短作业,那么就会使得长作业不断的往后推,周转时间变长,致使长作业长期不会被运行。
      • 最短剩余时间优先:最短作业优先的抢占式版本,按剩余运行时间的顺序进行调度。 当一个新的作业到达时,其整个运行时间与当前进程的剩余时间作比较。如果新的进程需要的时间更少,则挂起当前进程,运行新的进程。否则新的进程等待。
      • 高响应比优先调度算法
        • 高响应比优先 (Highest Response Ratio Next, HRRN)调度算法主要是权衡了短作业和长作业。每次进行进程调度时,先计算响应比优先级,然后把响应比优先级最高的进程投入运行,响应比优先级的计算公式:(等待时间+要求服务时间)/(要求服务时间)=优先权
      • 时间片轮转调度算法:将所有就绪进程按 FCFS 的原则排成一个队列,每次调度时,把 CPU 时间分配给队首进程,该进程可以执行一个时间片【时间片轮转调度是一种最古老,最简单,最公平且使用最广的算法,又称 RR(Round robin)调度每个进程被分配一个时间段,称作它的时间片,即该进程允许运行的时间。】。当时间片用完时,由计时器发出时钟中断,调度程序便停止该进程的执行,并将它送往就绪队列的末尾,同时继续把 CPU 时间分配给队首的进程。
        • 时间片轮转算法的效率和时间片的大小有很大关系:因为进程切换都要保存进程的信息并且载入新进程的信息,如果时间片太小,会导致进程切换得太频繁,在进程切换上就会花过多时间。 而如果时间片过长,那么实时性就不能得到保证
      • 优先级调度:为每个进程分配一个优先级,按优先级进行调度【 为每个流程分配优先级,首先执行具有最高优先级的进程,依此类推。具有相同优先级的进程以 FCFS 方式执行。可以根据内存要求,时间要求或任何其他资源要求来确定优先级】。为了防止低优先级的进程永远等不到调度,可以随着时间的推移增加等待进程的优先级
        • 进程的优先级可以分为,静态优先级和动态优先级:

          • 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化
          • 动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级。
      • 多级反馈队列调度算法 :是「时间片轮转算法」和「最高优先级算法」的综合和发展。前面介绍的几种进程调度的算法都有一定的局限性。如短进程优先的调度算法,仅照顾了短进程而忽略了长进程多级反馈队列调度算法既能使高优先级的作业得到响应又能使短作业(进程)迅速完成因而它是目前被公认的一种较好的进程调度算法,UNIX 操作系统采取的便是这种调度算法

        • 多级表示有多个队列,每个队列优先级从高到低,同时优先级越高时间片越短
        • 反馈表示如果有新的进程加入优先级高的队列时,立刻停止当前正在运行的进程,转而去运行优先级高的队列;

PART4-2:OS三大调度机制之二:

  • 页面替换【地址映射过程中,若在页面中发现所要访问的页面不在内存中,则发生缺页中断页面置换算法的目标是尽可能减少页面的换入换出次数】算法:

    • 在程序运行过程中,如果当 CPU 要访问的页面不在内存中,就 发生缺页中断从而将该页调入物理内存中。此时如果内存已无空闲空间,系统必须 从内存中调出一个页面到磁盘对换区中来腾出空间。(最好的算法是老化算法和WSClock算法,他们分别是基于 LRU 和工作集算法。他们都具有良好的性能并且能够被有效的实现。还存在其他一些好的算法,但实际上这两个可能是最重要的。)

      • 页缺失(英语:Page fault,又名硬错误、硬中断、分页错误、寻页缺失、缺页中断、页故障等):指的是当软件试图访问已映射在虚拟地址空间中,但是目前并未被加载在物理内存中的一个分页时,由中央处理器的内存管理单元所发出的中断。通常情况下,用于处理此中断的程序是操作系统的一部分。如果操作系统判断此次访问是有效的,那么操作系统会尝试将相关的分页从硬盘上的虚拟内存文件中调入内存。而如果访问是不被允许的,那么操作系统通常会结束相关的进程。虽然其名为“页缺失”错误,但实际上这并不一定是一种错误。而且这一机制对于利用虚拟内存来增加程序可用内存空间的操作系统(比如Microsoft Windows和各种类Unix系统)中都是常见且有必要的。
      • 缺页中断与一般中断的主要区别是:
        • 缺页中断在指令执行期间产生和处理中断信号,而一般中断在一条指令执行完成后检查和处理中断信号
        • 缺页中断返回到该指令的开始重新执行该指令,而一般中断返回回到该指令的下一个指令执行
      • 内存回收时主要有文件页【回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存】和匿名页两类内存可以回收,对于匿名页而言,回收方式是通过Linux的Swap机制
      • 页面替换【地址映射过程中,若在页面中发现所要访问的页面不在内存中,则发生缺页中断 。】算法有哪些:
        • 最佳页面置换算法(OPT):最优算法在当前页面中置换最后要访问的页面。不幸的是,因为程序访问页面时是动态的,没有办法来判定哪个页面是最后一个要访问的或者说无法预知每个页面在下一次访问前的等待时间。,因此实际上该算法不能使用。然而,它可以作为衡量其他算法的标准。

          • OPT 页面置换算法(最佳页面置换算法) :最佳(Optimal, OPT)置换算法所选择的被淘汰页面将是以后永不使用的,或者是在最长时间内不再被访问的页面,这样可以保证获得最低的缺页率。但由于人们目前无法预知进程在内存下的若千页面中哪个是未来最长时间内不再被访问的,因而该算法无法实现。一般作为衡量其他置换算法的方法,你的算法效率越接近该算法的效率,那么说明你的算法是高效的
          • OPT算法实现需要计算内存中每个逻辑页面的下一次访问时间,然后比较,选择未来最长时间不访问的页面
        • NRU 算法根据 R 位和 M 位的状态将页面分为四类。从编号最小的类别中随机选择一个页面。NRU 算法易于实现,但是性能不是很好。存在更好的算法。
        • FIFO 会跟踪页面加载进入内存中的顺序,并把页面放入一个链表中。有可能删除存在时间最长但是还在使用的页面,因此这个算法也不是一个很好的选择

          • FIFO(First In First Out) 页面置换算法(先进先出页面置换算法) : 总是淘汰最先进入内存的页面,即选择在内存中驻留时间最久的页面进行淘汰。
        • 第二次机会算法是对 FIFO 的一个修改,它会在删除页面之前检查这个页面是否仍在使用。如果页面正在使用,就会进行保留。这个改进大大提高了性能。
        • 时钟 算法是第二次机会算法的另外一种实现形式,时钟算法和第二次算法的性能差不多,但是会花费更少的时间来执行算法。时钟页面置换算法就可以两者兼得,它跟 LRU 近似,又是对 FIFO 的一种改进。
          • 时钟 算法是把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面。当发生缺页中断时,算法首先检查表针指向的页面:

            • 如果它的访问位是 0 就淘汰该页面,并把新的页面插入这个位置,然后把表针前移一个位置
            • 如果访问位是 1 就清除访问位,并把表针前移一个位置,重复这个过程直到找到了一个访问位为 0 的页面为止
        • 最近最久未使用(LRU)算法是一个非常优秀的算法,但是没有特殊的硬件(TLB)很难实现。如果没有硬件,就不能使用 LRU 算法。

          • LRU (Least Recently Used)页面置换算法(最近最久未使用页面置换算法) :LRU 算法赋予每个页面一个访问字段,用来记录一个页面自上次被访问以来所经历的时间 T,当须淘汰一个页面时,选择现有页面中其 T 值最大的,即最近最久未使用的页面予以淘汰。【虽然 LRU 在理论上是可以实现的,但代价很高。为了完全实现 LRU,需要在内存中维护一个所有页面的链表,最近最多使用的页面在表头,最近最少使用的页面在表尾,最麻烦的是在每次访问内存时都必须要更新整个链表。在链表中找到一个页面,删除它,然后把它移动到表头是一个非常费时的操作。。】
          • 内存回收时主要有两类内存可以回收:文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存。
            • 文件页:回收干净页的方式是直接释放内存,回收脏页【那些被应用程序修改过,并且暂时还没写入磁盘的数据(也就是脏页)】的方式是先写回磁盘后再释放内存。【对于干净页是直接释放内存,这个操作不会影响性能,而对于脏页会先写回到磁盘再释放内存,这个操作会发生磁盘 I/O 的,这个操作是会影响系统性能的。】
            • 匿名页:回收的方式是通过 Linux 的 Swap 机制,如果开启了 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。这个操作是会影响系统性能的。
          • LRU 回收算法,实际上维护着 active 和 inactive 两个双向链表,越接近链表尾部,就表示内存页越不常访问。这样,在回收内存时,系统就可以根据活跃程度,优先回收不活跃的内存。【活跃和非活跃的内存页,按照类型的不同,又分别分为文件页和匿名页。】
            • active_list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;
            • inactive_list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;
        • NFU 算法是一种近似于 LRU 的算法,它的性能不是非常好
        • LFU (Least Frequently Used)页面置换算法(最少使用页面置换算法) : 该置换算法选择在之前时期使用最少的页面作为淘汰页
          • 它的实现方式是,对每个页面设置一个访问计数器,每当一个页面被访问时,该页面的访问计数器就累加 1。在发生缺页中断时,淘汰计数器值最小的那个页面。要增加一个计数器来实现,这个硬件成本是比较高的,另外如果要对这个计数器查找哪个页面访问次数最小,查找链表本身,如果链表长度很大,是非常耗时的,效率不高。LFU 算法只考虑了频率问题,没考虑时间的问题,比如有些页面在过去时间里访问的频率很高,但是现在已经没有访问了,而当前频繁访问的页面由于没有这些页面访问的次数高,在发生缺页中断时,就会可能会误伤当前刚开始频繁访问,但访问次数还不高的页面
          • 可以定期减少访问的次数,比如当发生时间中断时,把过去时间访问的页面的访问次数除以 2,相当于稀释一下,也就说,随着时间的流失,以前的高访问次数的页面会慢慢减少,相当于加大了被置换的概率
        • 老化 算法是一种更接近 LRU 算法的实现,并且可以更好的实现,因此是一个很好的选择
        • 最后两种算法都使用了工作集算法。工作集算法提供了合理的性能开销,但是它的实现比较复杂。WSClock 是另外一种变体,它不仅能够提供良好的性能,而且可以高效地实现。

PART4-3:OS三大调度机制之三:磁盘调度算法

  • 磁盘:

    • 磁盘的结构

      中间圆的部分是磁盘的盘片,一般会有多个盘片,每个盘面都有自己的磁头。盘片中的每一层分为多个磁道,每个磁道分多个扇区,每个扇区是 512 字节。多个具有相同编号的磁道形成一个圆柱,称之为磁盘的柱面

    • 磁盘可以说是计算机系统最慢的硬件之一,读写速度相差内存 10 倍以上,所以针对优化磁盘的技术非常的多,比如零拷贝、直接 I/O、异步 I/O 等等,这些优化的目的就是为了提高系统的吞吐量,另外操作系统内核中的磁盘高速缓存区,可以有效的减少磁盘的访问次数
      • 在没用DMA技术前:
      • 用了DMA技术后:
    • 磁盘调度算法:
      • 磁盘调度算法的目的很简单,就是为了提高磁盘的访问性能,一般是通过优化磁盘的访问请求顺序来做到的。寻道的时间是磁盘访问最耗时的部分,如果请求顺序优化的得当,必然可以节省一些不必要的寻道时间,从而提高磁盘的访问性能。假设有下面一个请求序列,每个数字代表磁道的位置:98,183,37,122,14,124,65,67。初始磁头当前的位置是在第 53 磁道
      • 常见的磁盘调度算法:磁盘的写入顺序是从左到右
        • 先来先服务(First-Come,First-Served,FCFS):
        • 最短寻道时间优先算法:优先选择从当前磁头位置所需寻道时间最短的请求,那么根据距离磁头( 53 位置)最近的请求的算法,具体的请求则会是下列从左到右的顺序:65,67,37,14,98,122,124,183
        • 扫描算法算法:也叫做电梯算法
          • 最短寻道时间优先算法会产生饥饿的原因在于:磁头有可能再一个小区域内来回得移动。为了防止这个问题,可以规定:磁头在一个方向上移动,访问所有未完成的请求,直到磁头到达该方向上的最后的磁道,才调换方向,这就是扫描(Scan)算法。比如电梯保持按一个方向移动,直到在那个方向上没有请求为止,然后改变方向。。假设扫描调度算先朝磁道号减少的方向移动,具体请求则会是下列从左到右的顺序:37,14,0,65,67,98,122,124,183
        • 循环扫描算法
          • 扫描算法使得每个磁道响应的频率存在差异,那么要优化这个问题的话,可以总是按相同的方向进行扫描,使得每个磁道的响应频率基本一致。循环扫描(Circular Scan, CSCAN )规定:只有磁头朝某个特定方向移动时,才处理磁道访问请求,而返回时直接快速移动至最靠边缘的磁道,也就是复位磁头,这个过程是很快的,并且返回中途不处理任何请求,该算法的特点,就是磁道只响应一个方向上的请求。假设循环扫描调度算先朝磁道增加的方向移动,具体请求会是下列从左到右的顺序:65,67,98,122,124,183,199,0,14,37
        • LOOK 与 C-LOOK 算法
          • 扫描算法和循环扫描算法,都是磁头移动到磁盘最始端或最末端才开始调换方向,那这其实是可以优化的,优化的思路就是磁头在移动到最远的请求位置,然后立即反向移动。那针对 SCAN 算法的优化则叫 LOOK 算法,它的工作方式,磁头在每个方向上仅仅移动到最远的请求位置,然后立即反向移动,而不需要移动到磁盘的最始端或最末端,反向移动的途中会响应请求

PART5:什么是交换空间:物理内存和交换空间的总容量就是虚拟内存的可用容量

  • 操作系统把物理内存(physical RAM)分成一块一块的小内存每一块内存被称为页(page)

    • Swap:【当内存资源不足时,Linux把某些页的内容转移至硬盘上的一块空间上,以释放内存空间。硬盘上的那块空间叫做交换空间(swap space),而这一过程被称为交换(swapping)【Linux 提供了两种不同的方法启用 Swap,分别是 Swap 分区(Swap Partition)和 Swap 文件(Swapfile)】 【Swap 机制存在的本质原因是 Linux 系统提供了虚拟内存管理机制,每一个进程认为其独占内存空间,因此所有进程的内存空间之和远远大于物理内存。 所有进程的内存空间之和超过物理内存的部分就需要交换到磁盘上。】

      • Swap 就是把一块磁盘空间或者本地文件,当成内存来使用,它包含换出和换入两个过程:

        • 换出(Swap Out) :是把进程暂时不用的内存数据存储到磁盘中,并释放这些数据占用的内存
        • 换入(Swap In):是在进程再次访问这些内存的时候,把它们从磁盘读到内存中来
      • Swap 机制会在 内存不足和内存闲置 的场景下触发:
        • 内存不足当系统需要的内存超过了可用的物理内存时,内核会将内存中不常使用的内存页交换到磁盘上为当前进程让出内存,保证正在执行的进程的可用性,这个内存回收的过程是强制的直接内存回收(Direct Page Reclaim)。直接内存回收是同步的过程,会阻塞当前申请内存的进程。

          • 当系统的物理内存不够用的时候,就需要将物理内存中的一部分空间释放出来,Linux把某些页的内容转移至硬盘上的一块空间上,以释放内存空间供当前运行的程序使用。那些被释放的空间可能来自一些很长时间没有什么操作的程序,这些被释放的空间会被临时保存到磁盘,等到那些程序要运行时,再从磁盘中恢复保存的数据到内存中。

            • 这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。因为硬盘的访问速度要比内存慢太多了,每一次内存交换,我们都需要把一大段连续的内存数据写到硬盘上。所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿
            • 比如,可以把音乐程序占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。
        • 内存闲置应用程序在启动阶段使用的大量内存在启动后往往都不会使用,通过后台运行的守护进程(kSwapd),我们可以将这部分只使用一次的内存交换到磁盘上为其他内存的申请预留空间。kSwapd 是 Linux 负责页面置换(Page replacement)的守护进程,它也是负责交换闲置内存的主要进程,它会在空闲内存低于一定水位 (opens new window)时,回收内存页中的空闲内存保证系统中的其他进程可以尽快获得申请的内存。kSwapd 是后台进程,所以回收内存的过程是异步的,不会阻塞当前申请内存的进程。
          • 程序启动时很多内存页被用来初始化,之后便不再需要,可以交换出去
      • Swap 机制特点:
        • 应用程序实际可以使用的内存空间将远远超过系统的物理内存。由于硬盘空间的价格远比内存要低,因此这种方式无疑是经济实惠的。
        • 频繁地读写硬盘,会显著降低操作系统的运行速率,这也是 Swap 的弊端。

PART6:物理地址、逻辑地址、有效地址、线性地址、虚拟地址的区别?

  • 物理地址就是内存中真正的地址【内存地址寄存器中的地址。物理地址是内存单元真正的地址,它就相当于是你家的门牌号,你家就肯定有这个门牌号,具有唯一性。不管哪种地址,最终都会映射为物理地址

    • 实模式下,段基址 + 段内偏移经过地址加法器的处理,经过地址总线传输,最终也会转换为物理地址。

      • 实模式:
      • 保护模式:
    • 但是在保护模式下,段基址 + 段内偏移被称为线性地址,也叫虚拟地址
      • 不过此时的段基址不能称为真正的地址,而是会被称作为一个选择子的东西,选择子就是个索引,相当于数组的下标,通过这个索引能够在 GDT 中找到相应的段描述符段描述符记录了段的起始、段的大小等信息,这样便得到了基地址

        • 如果此时没有开启内存分页功能,那么这个线性地址可以直接当做物理地址来使用,直接访问内存
        • 如果开启了分页功能,那么这个线性地址又多了一个名字,这个名字就是虚拟地址
          • 线性地址可以看作是虚拟地址,虚拟地址不是真正的物理地址,但是虚拟地址会最终被映射为物理地址
          • 虚拟地址 -> 物理地址的映射【现代处理器使用的是一种称为 虚拟寻址(Virtual Addressing) 的寻址方式。使用虚拟寻址,CPU 需要将虚拟地址翻译成物理地址,这样才能访问到真实的物理内存。】(实际上完成虚拟地址转换为物理地址转换的硬件是 CPU 中含有一个被称为 内存管理单元(Memory Management Unit, MMU) 的硬件)【由计算机的一个硬件叫 MMU,中文名字叫内存管理单元,有时也叫 PMMU,分页内存管理单元来负责将虚拟地址转换为物理地址

    • 不论在实模式还是保护模式下,段内偏移地址都叫做有效地址(也是逻辑地址)我们编程一般只有可能和逻辑地址打交道,逻辑地址由操作系统决定
      • 没有虚拟地址空间的时候,程序直接访问和操作的都是物理内存,这样一来,用户程序可以访问任意内存,寻址内存的每个字节,这样就很容易(有意或者无意)破坏操作系统,造成操作系统崩溃。+ 想要同时运行多个程序特别困难,比如你想同时运行一个微信和一个 QQ 音乐都不行。为什么呢?举个简单的例子:微信在运行的时候给内存地址 1xxx 赋值后,QQ 音乐也同样给内存地址 1xxx 赋值,那么 QQ 音乐对内存的赋值就会覆盖微信之前所赋的值,这就造成了微信这个程序就会崩溃。所以如果直接把物理地址暴露出来的话会带来严重问题,比如可能对操作系统造成伤害以及给同时运行多个程序造成困难。
      • 通过虚拟地址访问内存有以下优势:或者说虚拟地址或者说虚拟内存带来的好处
        • 程序可以使用一系列相邻的虚拟地址来访问物理内存中不相邻的大内存缓冲区
        • 程序可以使用一系列虚拟地址来访问大于可用物理内存的内存缓冲区。当物理内存的供应量变小时,内存管理器会将物理内存页(通常大小为 4 KB)保存到磁盘文件。数据或代码页会根据需要在物理内存与磁盘之间移动
        • 不同进程使用的虚拟地址彼此隔离。一个进程中的代码无法更改正在由另一进程或操作系统使用的物理内存由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的。这就解决了多进程之间地址冲突的问题。页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。】

PART7: 什么是缓冲区溢出?有什么危害?

  • 缓冲区溢出是指当计算机向缓冲区填充数据时超出了缓冲区本身的容量,溢出的数据覆盖在合法数据上.(造成缓冲区溢出的主要原因是程序中没有仔细检查用户输入)
  • 危害有以下两点:
    • 程序崩溃,导致拒绝额服务
    • 跳转并且执行一段恶意代码

PART8:中断:

  • 中断的处理过程:

    • 保护现场:将当前执行程序的相关数据保存在寄存器中,然后入栈
    • 开中断:以便执行中断时能响应较高级别的中断请求
    • 中断处理
    • 关中断:保证恢复现场时不被新中断打扰
    • 恢复现场:从堆栈中按序取出程序数据,恢复中断前的执行状态
  • 中断和轮询有什么区别:
    • 轮询:CPU对特定设备轮流询问。中断:通过特定事件提醒CPU。
    • 轮询:效率低等待时间长,CPU利用率不高。中断:容易遗漏问题,CPU利用率不高

PART9:并发与并行

  • 并发:并发就是单处理器(单核带队)在同一段时间内同时处理多个任务(虽然 并发是指多个任务在同一个时间段内同时执行,但是多个任务是有一定的时间间隔的);但在某一时刻,只有一个任务在执行。单核处理器可以做到并发,其实单个CPU同时只能执行一个任务。当一个任务占用CPU时其他任务会被先挂起

    • 当占用 CPU 的任务时间片用完后,会把 CPU 让给其他任务来使用,所以在单 CPU 时代多线程编程是没有太大意义的,并且线程间频繁的上下文切换还会带来额外开销

      • 线程的上下文切换:cpu不再执行当前的线程的代码转而去执行另一个线程的代码

        • 操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源

          • 调度原则:

            • CPU 利用率:为了提高 CPU 利用率,在这种发送 I/O 事件致使 CPU 空闲的情况下,调度程序需要从就绪队列中选择一个进程来运行。
            • 系统吞吐量:要提高系统的吞吐率,调度程序要权衡长任务和短任务进程的运行完成数量。
            • 周转时间:如果进程的等待时间很长而运行时间很短,那周转时间就很长,这不是我们所期望的,调度程序应该避免这种情况发生。
            • 等待时间:就绪队列中进程的等待时间也是调度程序所需要考虑的原则
            • 响应时间:对于交互式比较强的应用,响应时间也是调度程序需要考虑的原则
        • 线程上下文切换的是什么?线程上下文切换的是什么?
          • 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样
          • 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据
        • 什么时候发生线程上下文切换(Context Switch):
          • 被动的:

            • 线程的cpu时间片用完
            • 垃圾回收
            • 有更高优先级的线程需要运行
          • 主动的:
            • 线程自己调用了sleep、yield、 wait、 join、 park、 synchronized、 lock 等方法
        • 当Context Switch发生时,需要由操作系统保存当前线程的状态,并恢复另一个线程的状态,Java 中对应的概念就是程序计数器(Program Counter Register),它的作用是记住下一条jvm指令的执行地址, 是线程私有的(状态包括程序计数器、虚拟机栈中每个栈帧的信息,如局部变量、操作数栈、返回地址等)(Context Switch频繁发生会影响性能
      • 进程的上下文切换
        • 各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么这个一个进程切换到另一个进程运行,称为进程的上下文切换
        • 进程是由内核管理和调度的,所以进程的切换只能发生在内核态
        • 进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。所以进程的上下文开销是很关键的,我们希望它的开销越小越好,这样可以使得进程可以把更多时间花费在执行程序上,而不是耗费在上下文切换。
        • 发生进程上下文切换有哪些场景?
          • 为了保证所有进程可以得到公平调度,CPU 时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,进程就从运行状态变为就绪状态,系统从就绪队列选择另外一个进程运行;
          • 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;
          • 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度
          • 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;
          • 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序
      • CPU 上下文切换
        • CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务【任务主要包含进程、线程和中断】的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。【系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。】

          • 大多数操作系统都是多任务,通常支持大于 CPU 数量的任务同时运行。实际上,这些任务并不是同时运行的,只是因为系统在很短的时间内,让各个任务有一个时间间隔的分别在 CPU 运行,于是就造成同时运行的错觉。同样的,只要牵涉到切换就得保护现场,所以操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器【CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文。】
          • 可以根据 任务的不同,把 CPU 上下文切换分成: 进程上下文切换线程上下文切换中断上下文切换
    • 并发和并行有什么区别?
      • 比如有两个进程A和B,A运行一个时间片之后,切换到B,B运行一个时间片之后又切换到A。因为切换速度足够快,所以宏观上表现为在一段时间内能同时运行多个程序,但是微观上多个线程还是串行执行的,只是肉眼看不见而已
  • 并行就是在同一时刻,有多个任务在执行。这个需要**多核处理器才能完成**,在微观上就能同时执行多条指令, 不同的程序被放到不同的处理器上运行,这个是物理上的多个进程同时进行


    PART10:
  • 其实阻塞和唤醒就是通过改写state值去玩的, 再加上调度算法就可以实现状态的切换了





    PART11:文件管理或者叫Linux文件系统
    在 Linux 操作系统中,所有被操作系统管理的资源,例如网络接口卡、磁盘驱动器、打印机、输入输出设备、普通文件或是目录都被看作是一个文件,一切都是文件,文件系统是操作系统中负责管理持久数据的子系统
  • Linux 文件系统会 为每个文件分配两个数据结构索引节点(index node)目录项(directory entry),它们主要用来记录文件的元信息和目录层次结构。
    • 索引节点(index node):inode 是 linux/unix 文件系统的基础:inode 就是用来维护或者说存储某个文件的元信息,比如被分成几块、每一块在的地址、文件拥有者,创建时间,权限,大小等信息的一个区域

      • 索引节点是 文件的唯一标识,它们之间一一对应,也同样都会被存储在硬盘中,所以索引节点同样占用磁盘空间。
      • 文件数据存储在块中,块(block)由多个扇区【硬盘的最小存储单位是扇区(Sector)】组成,块的最常见的大小是 4kb,约为 8 个连续的扇区组成(每个扇区存储 512 字节,如果每次读写都以扇区这么小为单位,那这读写的效率会非常低。所以,文件系统把多个扇区组成了一个逻辑块,每次读写的最小单位就是逻辑块(数据块),这将大大提高了磁盘的读写的效率。)。一个文件可能会占用多个 block,但是一个块只能存放一个文件。

        • 我们一般是通过系统调用来打开一个文件,我们打开了一个文件后,操作系统会跟踪进程打开的所有文件,所谓的跟踪呢,就是操作系统为每个进程维护一个打开文件表,文件表里的每一项代表文件描述符,所以说文件描述符是打开文件的标识。操作系统在打开文件表中维护着打开文件的状态和信息:文件一般有以下信息:

          • 文件指针:系统跟踪 上次读写位置 作为当前文件位置指针,这种指针对打开文件的某个进程来说是唯一的;
          • 文件打开计数器:文件关闭时,操作系统必须重用其打开文件表条目,否则表内空间不够用。因为多个进程可能打开同一个文件,所以系统在删除打开文件条目之前,必须等待最后一个进程关闭文件,该计数器跟踪打开和关闭的数量,当该计数为 0 时,系统关闭文件,删除该条目
          • 文件磁盘位置:绝大多数文件操作都要求系统修改文件数据,该信息保存在内存中,以免每个操作都从磁盘中读取
          • 访问权限:每个进程打开文件都需要有一个访问模式(创建、只读、读写、添加等),该信息保存在进程的打开文件表中,以便操作系统能允许或拒绝之后的 I/O 请求
        • block :实际文件的内容,如果一个文件大于一个块时候,那么将占用多个 block,但是一个块只能存放一个文件。(因为数据是由 inode 指向的,如果有两个文件的数据存放在同一个块中,就会乱套了)
      • 虽然,我们将文件存储在了块(block)中,但是我们 还需要一个空间来存储文件的元信息 metadata (如某个文件被分成几块、每一块在的地址、文件拥有者,创建时间,权限,大小等)这种存储文件元信息的区域就叫 inode,译为索引节点:i(index)+node。 每个文件都有一个 inode,存储文件的元信息
        • 可以使用 stat 命令可以查看文件的 inode 信息。每个 inode 都有一个号码,Linux/Unix 操作系统不使用文件名来区分文件,而是使用 inode 号码区分不同的文件
      • 读文件和写文件的过程:文件系统的基本操作单位是数据块
        • 当用户进程从文件读取 1 个字节大小的数据时,文件系统则需要获取字节所在的数据块,再返回数据块对应的用户进程所需的数据部分。
        • 当用户进程把 1 个字节大小的数据写进文件时,文件系统则找到需要写入数据的数据块的位置,然后修改数据块中对应的部分,最后再把数据块写回磁盘。
    • 目录项:
      • 也就是 dentry,用来记录文件的名字、索引节点指针以及与其他目录项的层级关联关系。多个目录项关联起来,就会形成目录结构,但它与索引节点不同的是,目录项是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存
      • 由于索引节点唯一标识一个文件,而目录项记录着文件的名【目录项这个数据结构不只是表示目录,也是可以表示文件的】,所以目录项和索引节点的关系是多对一,也就是说,一个文件可以有多个别名。比如,硬链接的实现就是多个目录项中的索引节点指向同一个文件。
      • 几个重要的概念区别:
        • 普通文件在磁盘里面保存的是文件数据,而目录文件在磁盘里面保存子目录或文件
        • 目录项和目录:目录是个文件,持久化存储在磁盘,而目录项是内核一个数据结构,缓存在内存
        • 硬链接和软链接有什么区别
          • 我们想要给某个文件取个别名,那么在 Linux 中可以通过硬链接(Hard Link) 和软链接(Symbolic Link) 的方式来实现。它们都是比较特殊的文件,但是实现方式也是不相同的。
          • 硬链接和软链接:
            • 硬链接:硬链接就是在目录下创建一个条目,记录着文件名与 inode 编号,这个 inode 就是源文件的 inode。删除任意一个条目,文件还是存在,只要引用数量不为 0。但是硬链接有限制,它不能跨越文件系统,也不能对目录进行链接。

              • 硬链接是多个目录项中的索引节点指向一个文件,也就是指向同一个 inode
            • 软链接:
              • 符号链接文件保存着源文件所在的绝对路径,在读取时会定位到源文件上,可以理解为 Windows 的快捷方式。当源文件被删除了,链接文件就打不开了。因为记录的是路径,所以可以为目录建立符号链接。
    • 文件传输:
      • 传统文件传输:

        • 第一步都是先需要先把磁盘文件数据拷贝内核缓冲区里,这个内核缓冲区实际上是磁盘高速缓存(PageCache)。零拷贝使用了 PageCache 技术,可以使得零拷贝进一步提升了性能。读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘。但是,内存空间远比磁盘要小,内存注定只能拷贝磁盘里的一小部分数据,所以通常,刚被访问的数据在短时间内再次被访问的概率很高,于是我们可以用 PageCache 来缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存。所以,读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。。另外。读取磁盘数据的时候, 需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响, PageCache 使用了「预读功能」
        • PageCache 的优点主要是两个:这两个做法,将大大提高读写磁盘的性能
          • 缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存
          • 预读功能
        • 但是PageCache有个问题,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能。因为如果你有很多 GB 级别文件需要传输,每当用户访问这些大文件的时候,内核就会把它们载入 PageCache 中,于是 PageCache 空间很快被这些大文件占满
      • 优化后的文件传输:
        • 减少用户态与内核态的上下文切换的次数就要减少系统调用的次数

          • 读取磁盘数据的时候,之所以要发生上下文切换,这是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务的时候,就需要使用操作系统提供的系统调用函数。而一次系统调用必然会发生 2 次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。
        • 减少数据拷贝的次数
          • 传统的文件传输方式会历经 4 次数据拷贝,而且这里面从内核的读缓冲区拷贝到用户的缓冲区里,再从用户的缓冲区里拷贝到 socket 的缓冲区里,这个过程是没有必要的因为文件传输的应用场景中,在用户空间我们并不会对数据再加工,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的
    • 文件的存储:文件的数据是要存储在硬盘上面的,数据在磁盘上的存放方式,就像程序在内存中存放的方式那样
      • 针对已经被占用的数据块组织和管理的存储方式:针对已经被占用的数据块组织和管理的存储方式有以下两种

        • 连续空间存放方式:文件存放在磁盘连续的物理空间中【连续空间存放的方式虽然读写效率高,但是有磁盘空间碎片和文件长度不易扩展的缺陷

          • 文件的数据都是紧密相连,读写效率很高,因为一次磁盘寻道就可以读出整个文件
          • 使用连续存放的方式有一个前提,必须先知道一个文件的大小,这样文件系统才会根据文件的大小在磁盘上找到一块连续的空间分配给文件
          • 所以文件头里需要指定起始块的位置和长度,文件头就类似于 Linux 的 inode
        • 非连续空间存放方式:又可以分为链表方式和索引方式
          • 链表方式:

            • 链表的方式存放是离散的,不用连续的,于是就可以消除磁盘碎片,可大大提高磁盘空间的利用率,同时文件的长度可以动态扩展。根据实现的方式的不同,链表可分为隐式链表和显式链接两种形式。

          • 索引方式:
            • 链表的方式解决了连续分配的磁盘碎片和文件动态扩展的问题,但是不能有效支持直接访问(FAT除外),索引的方式可以解决这个问题。
            • 索引的方式优点在于:文件的创建、增大、缩小很方便;不会有碎片的问题;支持顺序读写和随机读写;
            • 由于索引数据也是存放在磁盘块的,如果文件很小,明明只需一块就可以存放的下,但还是需要额外分配一块来存放索引数据,所以缺陷之一就是存储索引带来的开销
          • 链表 + 索引:这种组合称为链式索引块
          • 索引 + 索引:这种组合称为多级索引块,实现方式是通过一个索引块来存放多个索引数据块,一层套一层索引
      • 针对磁盘的空闲空间也是要引入管理的机制有以下三种:
        • 空闲表法:

          • 空闲表法就是为所有空闲空间建立一张表,表内容包括空闲区的第一个块号和该空闲区的块个数,注意,这个方式是连续分配的。
        • 空闲链表法:
          • 使用链表的方式来管理空闲空间,每一个空闲块里有一个指针指向下一个空闲块,这样也能很方便的找到空闲块并管理起来
        • 位图法:
          • 位图是利用二进制的一位来表示磁盘中一个盘块的使用情况,磁盘上所有的盘块都有一个二进制位与之对应。当值为 0 时,表示对应的盘块空闲,值为 1 时,表示对应的盘块已分配。在 Linux 文件系统就采用了位图的方式来管理空闲空间,不仅用于数据空闲块的管理,还用于 inode 空闲块的管理,因为 inode 也是存储在磁盘的,自然也要有对其管理
    • 目录的存储:
      • 基于 Linux 一切皆文件的设计思想,经常用到的目录可以算一个特殊的文件。和普通文件不同的是,普通文件的块里面保存的是文件数据,而目录文件的块里面保存的是目录里面一项一项的文件信息
    • 文件 I/O:
      • 缓冲与非缓冲 I/O:根据是否利用标准库缓冲,可以把文件 I/O 分为缓冲 I/O 和非缓冲 I/O:【缓冲指标准库内部实现的缓冲。很多程序遇到换行时才真正输出,而换行前的内容,其实就是被标准库暂时缓存了起来,这样做的目的是,减少系统调用的次数,毕竟系统调用是有 CPU 上下文切换的开销的】

        • 缓冲 I/O,利用的是标准库的缓存实现文件的加速访问,而标准库再通过系统调用访问文件
        • 非缓冲 I/O,直接通过系统调用访问文件,不经过标准库缓存
      • 直接与非直接 I/O:根据是否利用操作系统的缓存,可以把文件 I/O 分为直接 I/O 与非直接 I/O:
        • Linux 内核为了减少磁盘 I/O 次数,在系统调用后,会把用户数据拷贝到内核中缓存起来,这个内核缓存空间也就是页缓存,只有当缓存满足某些条件的时候,才发起磁盘 I/O 的请求
        • 直接 I/O,不会发生内核缓存和用户程序之间数据复制,而是直接经过文件系统访问磁盘
        • 非直接 I/O,读操作时,数据从内核缓存中拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘
      • 阻塞与非阻塞 I/O VS 同步与异步 I/O
        • 阻塞I/O:

          • 当用户程序执行 read ,线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read 才会返回
        • 非阻塞I/O【为了解决这种傻乎乎轮询方式,于是 I/O 多路复用技术 I/O 多路复用技术的故事就出来了,如 select、poll,它是通过 I/O 事件分发,当内核数据准备好时,再以事件通知应用程序进行操作。这个做法大大改善了应用进程对 CPU 的利用率,在没有被通知的情况下,应用进程可以使用 CPU 做其他的事情。】
          • 非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read 调用才可以获取到结果
  • 虚拟文件系统:
    • 文件系统的种类众多,而操作系统希望对用户提供一个统一的接口,于是在用户层与文件系统层引入了中间层,这个中间层就称为虚拟文件系统(Virtual File System,VFS)。【用户习惯以字节的方式读写文件,而操作系统则是以数据块来读写文件,那屏蔽掉这种差异的工作就是文件系统了】VFS 定义了一组所有文件系统都支持的数据结构和标准接口,这样程序员不需要了解文件系统的工作原理,只需要了解 VFS 提供的统一接口即可
    • 文件系统首先要先挂载到某个目录才可以正常使用,比如 Linux 系统在启动时,会把文件系统挂载到根目录
    • Linux 文件系统中,用户空间、系统调用、虚拟机文件系统、缓存、文件系统以及存储之间的关系

      • page 是内存管理分配的基本单位, Page Cache 由多个 page 构成。page 在操作系统中通常为 4KB 大小(32bits/64bits),而 Page Cache 的大小则为 4KB 的整数倍。但是并不是所有 page 都被组织为 Page Cache,Linux 系统上供用户可访问的内存分为两个类型(File-backed pages:文件备份页也就是 Page Cache 中的 page,对应于磁盘上的若干数据块;对于这些页最大的问题是脏页回盘;、Anonymous pages:匿名页不对应磁盘上的任何磁盘数据块,它们是进程的运行是内存空间(例如方法栈、局部变量表等属性))
    • Linux 支持的文件系统也不少,根据存储位置的不同,可以把文件系统分为三类
      • 磁盘的文件系统,它是直接把数据存储在磁盘中,比如 Ext 2/3/4、XFS 等都是这类文件系统
      • 内存的文件系统,这类文件系统的数据不是存储在硬盘的,而是占用内存空间,我们经常用到的 /proc 和 /sys 文件系统都属于内存的文件系统这一类,读写这类文件,实际上是读写内核中相关的数据
      • 网络的文件系统,用来访问其他计算机主机数据的文件系统,比如 NFS、SMB 等等。
    • Linux 文件类型:Linux 支持很多文件类型,其中非常重要的文件类型有: 普通文件,目录文件,链接文件,设备文件,管道文件,Socket 套接字文件等。

      • 文件的类型:

        • d: 代表目录
        • -: 代表文件
        • l: 代表软链接(可以认为是 window 中的快捷方式)
  • Linux 目录树:所有可操作的计算机资源都存在于目录树这个结构中,对计算资源的访问,可以看做是对这棵目录树的访问

    • Linux目录有几个比较重要

      • /bin存放的是常用的命令
      • /etc存放的是系统管理配置和子目录
      • /home家目录,咱们用户自己的文件
      • /usr,比较重要,存放用户很多的应用和文件
      • /opt安装软件用的
      • /sbin管理员常用的管理命令和程序,放这

巨人肩膀:
https://www.javalearn.cn
https://juejin.cn/post/
分布式服务框架原理与实践
https://xiaolincoding.com/os
低并发编程
javaGuide老师关于Linux的文章
javaGuide老师关于Shell的文章

java基础巩固-宇宙第一AiYWM:为了维持生计,四大基础之OS_Part_1整起(进程线程协程并发并行、进程线程切换进程间通信、死锁\进程调度策略、分段分页、交换空间、OS三大调度机制)相关推荐

  1. java基础巩固-宇宙第一AiYWM:为了维持生计,四大基础之OS_Part_2整起~IO们那些事【包括五种IO模型:(BIO、NIO、IO多路复用、信号驱动、AIO);零拷贝、事件处理及并发等模型】

    PART0.前情提要: 通常用户进程的一个完整的IO分为两个阶段(IO有内存IO.网络IO和磁盘IO三种,通常我们说的IO指的是后两者!):[操作系统和驱动程序运行在内核空间,应用程序运行在用户空间, ...

  2. java基础巩固-宇宙第一AiYWM:为了维持生计,手写RPC~Version07(RPC原理、序列化框架们、网络协议框架们 、RPC 能帮助我们做什么呢、RPC异常排查:ctrl+F搜超时)整起

    上次Version06说到了咱们手写迷你版RPC的大体流程, 对咱们的迷你版RPC的大体流程再做几点补充: 为什么要封装网络协议,别人说封装好咱们就要封装?Java有这个特性那咱就要用?好像是这样.看 ...

  3. java基础巩固-宇宙第一AiYWM:为了维持生计,架构知识+分+微序幕就此拉开之Docker(Docker概念:容器、镜像、仓库)、操作命令、Docker网络、分层、K8S<->Docker~整起

    架构知识+分+微序幕就此拉开之Docker 一.为什么要搞这个Docker,咱们为啥要学,盖房子? 二.Docker的镜像与容器 1.预备知识:虚拟(机).容器(化) 2.Docker.镜像.容器 3 ...

  4. java基础巩固-宇宙第一AiYWM:为了维持生计,多高(多线程与高并发)_Part9~整起(单双列集合们、ArrayList 的扩容机制、HashMap、ConcurrentHashMap )

    再进入正文之前,先看看集合相关操作的时间复杂度: 本故事源自于~ 开唠: PART0: 为什么突然蹦出集合这个玩意,就是因为咱们基础那里学的"数组"不够用~: 数组一般用来保存一组 ...

  5. java基础巩固-宇宙第一AiYWM:为了维持生计,Redis基础Part6(Redis的应用场景、Redis是单线程的速度还快、Redis线程模型:Reactor模式、事件、发布订阅、管道)~整起

    PART1-1:为什么Redis是单线程的 Redis单线程是指: Redis的网络IO和键值对读写是由一个线程来完成的.这也是 Redis 对外提供键值存储服务的主要流程.Redis的其他功能,比如 ...

  6. java基础巩固-宇宙第一AiYWM:为了维持生计,单例模式阅读总结【单例模式不同写法、在JDK中的应用】~整起

    无论是哪种设计模式,自己啃哪本书哪个博客去学,都会考虑最起码的两个问题: 我到底该咋用这种设计模式呀,直接把书上的.百度上的.博客上-的程序们抄过来? 那我该咋用呢?就算把人家程序抄过来,抄过来放在哪 ...

  7. java基础巩固-宇宙第一AiYWM:为了维持生计,多高(多线程与高并发)_Part1~整起(线程与进程篇:线程概念、线程状态、线程死锁)

    这个题目我感觉很多大哥大姐和我一样,虽然夹在众位大哥大姐中跟着一块喊着"多线程与高并发"的口号,但是这里面其实包含的东西并不像名字里面这么少.现在就开始咱们的旅程吧. 特此感谢,低 ...

  8. java基础巩固-宇宙第一AiYWM:为了维持生计,大数据Hadoop之HDFS分布式文件系统(HDFS读写流程、主从集群两种问题“单点故障”及“压力过大内存受限”、HDFS的架构设计)~整起

    Hadoop之HDFS 目录 一.大数据 二.HADOOP 三.HDFS 1.HDFS基本概念 2.HDFS的架构设计 3.HDFS自己对于上面两种数据持久化技术的实现: 4.HDFS读写流程 5.H ...

  9. java基础巩固-宇宙第一AiYWM:为了维持生计,做项目经验之~SSM项目错误集锦Part3(项目蹦+pg数据库坏+100%-->线上故障排查经验【业务bug第一步一定是先看日志,写好日志】)~整起

    项目中遇到的一个问题:项目忽然蹦了,用我们的域名登陆不上去了. 根据之前的经验,一般比如我们项目登不上去了或者数据库不上数据了(数据不更新),直接在Xshell上远程reboot一下,再重启一下tom ...

最新文章

  1. GO语言教程3:杂类
  2. DM9000网卡原理与基地址设置
  3. Mybatis+mysql动态分页查询数据案例——Mybatis的配置文件(mybatis-config.xml)
  4. linux 用户管理以及其他命令
  5. mongodb的架构 副本集搭建
  6. SAP License:美资企业、台资企业和国企的区别
  7. ios 设置按钮不可见_自定义键盘InputAccessoryView在iOS中不可见11
  8. C语言将字符串转换为数字
  9. SuperMap iDesktopX 数据迁移
  10. STM32F429I-DISCO ucLinux 开发环境搭建
  11. 修炼一名程序员的职业水准(林庆忠__署名原创)
  12. 自己照片怎么做成漫画头像?照片变漫画效果方法分享
  13. 科研绘图之R语言生存分析KM曲线累计风险表放在图片内部
  14. PL/SQL登录Oracle数据库提示“无监听程序”解决办法
  15. 游戏专属驱动增多,对玩家来说是好事吗?
  16. ElasticSearch集群故障案例分析: 警惕通配符查询
  17. IT安全交给MSP,企业能当“甩手掌柜”吗?
  18. 力天创见客流统计系统过滤员工
  19. 深度|医疗行业勒索病毒防治解决方案
  20. 作为一名数据科学从业者,你应该知道的P值

热门文章

  1. AcWing 426. 开心的金明(01背包,我爱喝水,天天健康,牛客切不出,喝下水)
  2. tensorflow: 调用训练好的pb模型实例
  3. Unity3D深入浅出 - 新版动画系统(Mecanim)
  4. 如何以安全模式启动计算机,详解如何从安全模式启动电脑【图文】
  5. wenbao与cf倒酒
  6. 《我不是药神》与AI研制新药
  7. 乖离性暗机器人_乖离性百万亚瑟王超弩级黑暗机器人boss通关攻略介绍
  8. 【多线程编程】模拟QQ的“正在输入...”,输入状态检测原理
  9. VSCode插件MySQL连接数据库
  10. uniapp播放海康威视rtsp格式的监控