总论

  • 现代操作系统,内核一般会提供 4 个基本能力:
    • 管理进程、线程,决定哪个进程、线程使用 CPU,也是进程调度的能力;
    • 管理内存,决定内存的分配和回收,也是内存管理的能力;
    • 管理硬件设备,为进程与硬件设备之间提供通信能力,也是硬件通信能力;
    • 提供系统调用,如果应用程序要运行更高权限运行的服务,就需要有系统调用,是用户程序与操作系统之间的接口
  • 内核具有很高的权限,可控制 cpu、内存、硬盘等硬件,而应用程序具有的权限很小。
  • 因此大多数操作系统,把内存分成了两个区域
    • 内核空间:这个内存空间只有内核程序可以访问
    • 用户空间:这个内存空间专门给应用程序使用
  • 用户空间的代码只能访问一个局部的内存空间,而内核空间的代码可以访问所有内存空间
    • 当程序使用用户空间时,我们常说该程序在用户态执行
    • 当程序使内核空间时,程序则在内核态执行
  • 应用程序如果需要进入内核空间,就需要通过系统调用
    • 内核程序执行在内核态,用户程序执行在用户态
      • 应用程序使用系统调用时,会产生一个中断。发生中断后, CPU 会中断当前在执行的用户程序,转而跳转到中断处理程序(开始执行内核程序)
      • 内核处理完后,主动触发中断,把 CPU 执行权限交回给用户程序,回到用户态继续工作
  • 现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程 A 和 进程 B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响

内存管理

  • 虚拟内存

    • 单片机的 CPU 是直接操作内存的「物理地址」
      • 想在内存中同时运行两个程序是不可能的。如果第一个程序在 2000 的位置写入一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容,所以同时运行两个程序是根本行不通的
    • 解决方法?
      • 把进程所使用的地址「隔离」开来,即让操作系统为每个进程分配独立的一套「虚拟地址」,人人都有,大家自己玩自己的地址就行,互不干涉
      • 有个前提每个进程都不能访问物理地址,至于虚拟地址最终怎么落到物理内存里,对进程来说是透明的
      • 理解:之所以不能同时运行,就是因为两个程序引用相同物理地址,也就是不能让其使用相同的物理地址,加一层映射过去
    • 操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来
      • 程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入的是不同的物理地址,这样就不会冲突了
      • 引出两种地址概念:
        • 我们程序所使用的内存地址叫做虚拟内存地址
        • 实际存在硬件里面的空间地址叫物理内存地址
      • 操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存
    • 虚拟内存作用:
      • 第一,虚拟内存使得进程对运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域
        • 理解:加载一个很大的存储,原本只能一次性,现在可分按需批
      • 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题
      • 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性
  • 内存分段

    • 程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来
    • 分段机制下,虚拟地址的物理地址的映射
      • 分段机制下的虚拟地址由两部分:段选择因子段内偏移量
        • 段选择子就保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引。段表里面保存的是这个段的基地址、段的界限和特权等级等。
        • 虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址
      • 虚拟地址是通过段表与物理地址进行映射的,分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址
    • 问题:
      • 第一个是内存碎片
        • 内存分段管理可以做到段根据实际需求分配内存,有多少需求就分配多大的段,所以不会出现内部内存碎片,但是由于每个段的长度不固定,所以多个段未必能恰好使用所有的内存空间,会产生了多个不连续的小物理内存,导致新的程序无法被装载,所以会出现外部内存碎片
      • 解决「外部内存碎片」的是内存交换
        • 内存交换空间,在Linux中,是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换
      • 第二个是内存交换的效率低
        • 如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿
  • 内存分页

    • 分段的好处就是能产生连续的内存空间
      • 但会出现「外部内存碎片和内存交换的空间太大」的问题
      • 解决这些问题,那么就要想出能少出现一些内存碎片的办法
    • 分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小,这样一个连续并且尺寸固定的内存空间,我们叫Page)。在 Linux 下,每一页的大小为 4KB
      • 虚拟地址与物理地址之间通过页表来映射
      • 页表存储在内存里,内存管理单元MMU)将虚拟内存地址转换成物理地址工作
      • 当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常,进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行
    • 解决分段问题
      • 内存分页由于内存空间都是预先划分好的,采用了分页,页与页之间是紧密排列的,所以不会有外部碎片
      • 内存分页机制分配内存的最小单位是一页,即程序不足一页大小,我们最少只能分配一个页,所以页内会出现内存浪费,所以针对内存分页机制会有内部内存碎片
      • 内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出Swap Out)。一旦需要的时候,再加载进来,称为换入Swap In)一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高
      • 分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中,只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去
    • 分页机制,虚拟地址与物理地址的映射
      • 分页机制下,虚拟地址分为两部分,页号页内偏移。页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址
      • 总结一下,对于一个内存地址转换,就是这样三个步骤:
        • 把虚拟内存地址,切分成页号和偏移量;
        • 根据页号,从页表里面,查询对应的物理页号;
        • 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址
    • 缺陷:
      • 操作系统是可以同时运行非常多的进程的,那这不就意味着页表会非常的庞大
        • 在 32 位的环境,虚拟地址空间共 4GB,假设一个页大小是 4KB(2^12),就需要大约 100 万 (2^20) 个页,每个「页表项」需要 4 个字节(4B)来存储,那整个 4GB 空间的映射就需要 4MB 的内存来存储页表
        • 100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存
  • 多级页表

    • 解决分页的缺陷
      • 把这 100 多万个「页表项」的单级页表再分页,将页表(一级页表)分为 1024 页表(二级页表),每个表(二级页表)中包含 1024 个「页表项」,形成二级分页
      • 算机组成原理里面无处不在的局部性原理
        • 每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存
      • 一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表
    • 深入理解思想:
      • 从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址
      • 页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项
      • 此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建
  • TLB

    • 多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,带来了时间上的开销
    • 程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域
      • 最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB
      • 有TLB 后,那么 CPU 在寻址时,先查 TLB,如果没找到,才会继续查常规的页表
      • TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个
  • 段页式内存管理

    • 内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为段页式内存管理
    • 段页式内存管理实现方式:
      • 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制
      • 再把每个段划分为多个页,也就对分段划分出来的连续空间,再划分固定大小的页
    • 这样,地址结构就由段号、段内页号和页内位移三部分组成
      • 段页式地址变换中要得到物理地址须经过三次内存访问:
        • 第一次访问段表,得到页表起始地址;
        • 第二次访问页表,得到物理页号;
        • 第三次将物理页号与页内位移组合,得到物理地址
  • Linux内存结构

    • Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护
    • Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制
    • 每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存
  • 分配内存

    • 应用程序通过 申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存

    • 应用程序读写了这块虚拟内存,CPU 就去访问这个虚拟内存, 会发现这个虚拟内存没有映射到物理内存, CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理。

      • 缺页中断处理函数会看是否有空闲的物理内存,如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。

        如果没有空闲的物理内存,内核开始进行回收内存

        • 后台内存回收(kswapd):在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程异步的,不会阻塞进程的执行。
        • 直接内存回收(direct reclaim):如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。

      如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了 ——触发 OOM (Out of Memory)机制

    • 哪些内存可被回收?

      • 主要有两类内存可以被回收,而且它们的回收方式也不同
      • 文件页:内核缓存的磁盘数据(Buffer)和内核缓存的文件数据(Cache)都叫作文件页,回收干净页的方式是直接释放内存,回收脏页的方式是先写回磁盘后再释放内存
        • 被应用程序修改过,暂时还没写入磁盘的数据**::脏页**
      • 匿名页:这部分内存没有实际载体,不像文件缓存有硬盘文件这样一个载体,比如堆、栈数据等。这部分内存可能还要被访问,所以不能直接释放内存,回收的方式是通过 Linux 的 Swap 机制,Swap 会把不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以
    • 回收内存性能影响

      • 回收内存的操作基本发生磁盘 I/O 的,如果回收内存的操作很频繁,意味着磁盘 I/O 次数会很多,这个过程势必会影响系统的性能,整个系统给人的感觉就是很卡
  • 预读失效和缓存污染

    • Linux操作系统的缓存

      • 应用程序读取文件的数据的时候,Linux 操作系统是会对读取的文件数据进行缓存的,会缓存在文件系统中的 Page Cache
      • Page Cache 属于内存空间里数据,由于内存访问比磁盘访问快很多,下一次访问相同的数据就不需要通过磁盘 I/O 了,命中缓存就直接返回数据
      • Page Cache 起到了加速访问数据的作用
    • MySQL的缓存

      • MySQL 的数据是存储在磁盘里的,为了提升数据库的读写性能,Innodb 存储引擎设计了一个缓冲池(Buffer Pool)Buffer Pool 属于内存空间里的数据
      • 有了缓冲池后:
        • 当读取数据时,如果数据存在于 Buffer Pool 中,客户端就会直接读取 Buffer Pool 中的数据,否则再去磁盘中读取
        • 当修改数据时,首先是修改 Buffer Pool 中数据所在的页,然后将其页设置为脏页,最后由后台线程将脏页写入到磁盘
    • 用LRU管理内存数据

      • LRU 算法一般是用「链表」作为数据结构来实现的,链表头部的数据是最近使用的,而链表末尾的数据是最久没被使用的;当空间不够了,就淘汰最久没被使用的节点,也就链表末尾的数据,从而腾出内存空间
      • 因为 Linux 的 Page Cache 和 MySQL 的 Buffer Pool 缓存的基本数据单位都是页(Page)单位,所以后续以「页」名称代替「数据
      • 传统的 LRU 算法的实现思路是这样的:
        • 当访问的页在内存里,就直接把该页对应的 LRU 链表节点移动到链表的头部
        • 当访问的页不在内存里,除了要把该页放入到 LRU 链表的头部,还要淘汰 LRU 链表末尾的页
      • 传统的 LRU 算法没有被 Linux 和 MySQL 使用,传统的 LRU 算法无法避免
        • 预读失效导致缓存命中率下降
        • 缓存污染导致缓存命中率下降
    • 预读失效解决

      • 预读机制是什么?

        • Linux 操作系统为基于 Page Cache 的读缓存机制提供预读机制
        • 通过一次磁盘顺序读将多个 Page 数据装入 Page Cache
        • 读取 4KB 数据后面的数据的时候,不用从磁盘读取,直接在 Page Cache 即可命中数据
          • 好处就是减少了 磁盘 I/O 次数,提高系统磁盘 I/O 吞吐量
      • 预读失效

        • 这些被提前加载进来的页,并没有被访问,相当于这个预读工作是白做了,这个就是预读失效
        • 「预读页」如果一直不会被访问到,就会出现一个问题,不会被访问的预读页却占用了 LRU 链表前排的位置,而末尾淘汰的页,可能是热点数据,这样就大大降低了缓存命中率
      • 避免预读失效

        • 让预读页停留在内存里的时间要尽可能的短,让真正被访问的页才移动到 LRU 链表的头部,从而保证真正被读取的热数据留在内存里的时间尽可能长
          • Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)
          • MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域:young 区域 和 old 区域
        • 思想都类似,都是将数据分为了冷数据和热数据,然后分别进行 LRU 算法。不像传统的 LRU 算法那样,所有数据都只用一个 LRU 算法管理
      • Linux避免预读失效

        • inux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)
          • active list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;
          • inactive list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;
        • 预读页就只需要加入到 inactive list 区域的头部,当页被真正访问的时候,才将页插入 active list 的头部。如果预读的页一直没有被访问,就会从 inactive list 移除,这样就不会影响 active list 中的热点数据
      • MySQL避免预读失效

        • MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域,young 区域 和 old 区域

          young 区域在 LRU 链表的前半部分,old 区域则是在后半部分,这两个区域都有各自的头和尾节点

        • young 区域与 old 区域在 LRU 链表中的占比关系并不是一比一的关系,而是 63:37(默认比例)

        • 划分这两个区域后,预读的页就只需要加入到 old 区域的头部,当页被真正访问的时候,才将页插入 young 区域的头部。如果预读的页一直没有被访问,就会从 old 区域移除,这样就不会影响 young 区域中的热点数据

    • 缓存污染

      • 缓存污染是什么
        • 使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」这种方式的话,那么还存在缓存污染的问题
        • 批量读取数据的时候,由于数据被访问一次,大量数据会被加入到「活跃 LRU 链表」,然后之前缓存在活跃 LRU 链表(或者 young 区域)里的热点数据全部都被淘汰了,如果这些大量的数据在很长一段时间都不会被访问的话,那么整个活跃 LRU 链表(或者 young 区域)就被污染了
      • 缓存污染问题
        • 等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,系统性能就会急剧下降
      • MySQL 举例子
      • 当某一个 SQL 语句扫描了大量的数据时,在 Buffer Pool 空间比较有限的情况下,会将 Buffer Pool 里的所有页都替换出去,导致大量热数据被淘汰了,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,MySQL 性能就会急剧下降
      • 注意, 缓存污染并不只是查询语句查询出了大量的数据才出现的问题,即使查询出来的结果集很小,也会造成缓存污染
      • 避免缓存污染
        • 之前 LRU 算法只要数据被访问一次,将数据加入活跃 LRU 链表(或者 young 区域),这种 LRU 算法进入活跃 LRU 链表的门槛太低
        • 只要我们提高进入到活跃 LRU 链表(或者 young 区域)的门槛,就能有效地保证活跃 LRU 链表(或者 young 区域)里的热点数据不会被轻易替换掉
      • 具体避免缓存污染方式
        • Linux 操作系统:在内存页被访问第二次的时候,才将页从 inactive list 升级到 active list 里
        • MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要进行停留在 old 区域的时间判断:
          • 如果第二次的访问时间与第一次访问的时间在 1 秒内(默认值),那么该页就不会被从 old 区域升级到 young 区域
          • 如果第二次的访问时间与第一次访问的时间超过 1 秒,那么该页就从 old 区域升级到 young 区域
        • 在批量读取数据时候,如果这些大量数据只会被访问一次,那么它们就不会进入到活跃 LRU 链表(或者 young 区域)

进程管理

  • 进程

    • 代码是一个存储在硬盘的静态文件,通过编译后生成二进制可执行文件,当运行这个可执行文件后,它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那这个运行中的程序,就被称为「进程」(Process)

      • 当进程要从硬盘读取数据时,CPU 不需要阻塞等待数据的返回,而是去执行另外的进程。当硬盘数据返回时,CPU 会收到个中断,于是 CPU 再继续运行这个进程
      • 单核的 CPU 在某一个瞬间,只能运行一个进程。但在 1 秒钟期间,它可能会运行多个进程,这样就产生并行的错觉,实际上这是并发
    • 并行与并发

      • 并行:多个CPU运行
      • 并发:单个CPU运行
    • 进程与程序的关系

      • CPU 可以从一个进程切换到另外一个进程,在切换前必须要记录当前进程中运行的状态信息,以备下次切换回来的时候可以恢复执行
      • 可以发现进程有着「运行 - 暂停 - 运行」的活动规律
    • 进程的状态

      • 运行状态(Running):该时刻进程占用 CPU;
      • 就绪状态(Ready):可运行,由于其他进程处于运行状态而暂时停止运行;
      • 阻塞状态(Blocked):该进程正在等待某一事件发生(如等待输入/输出操作的完成)而暂时停止运行,这时,即使给它CPU控制权,它也无法运行;
      • 创建状态(new):进程正在被创建时的状态;
      • 结束状态(Exit):进程正在从系统中消失时的状态

      如果有大量处于阻塞状态的进程,进程可能会占用着物理内存空间,显然不是我们所希望的,毕竟物理内存空间是有限的,被阻塞状态的进程占用着物理内存就一种浪费物理内存的行为描述进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态

      • 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;
      • 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行;
        • 导致进程挂起的原因不只因为进程所使用的内存空间不在物理内存,还包括:
          • 通过 sleep 让进程间歇性挂起,其工作原理是设置一个定时器,到期后唤醒进程。
          • 用户希望挂起一个程序的执行,比如在 Linux 中用 Ctrl+Z 挂起进程;
    • 进程的控制结构

      • 操作系统中用进程控制块(process control block)数据结构来描述进程

      • PCB 是进程存在的唯一标识,这意味着一个进程的存在

      • 包含信息

        进程描述信息:

        • 进程标识符:标识各个进程,每个进程都有一个并且唯一的标识符;
        • 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务;

        进程控制和管理信息:

        • 进程当前状态,如 new、ready、running、waiting 或 blocked 等;
        • 进程优先级:进程抢占 CPU 时的优先级;

        资源分配清单:

        • 有关内存地址空间或虚拟地址空间的信息,所打开文件的列表和所使用的 I/O 设备信息。

        CPU 相关信息:

        • CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行
      • PCB组织方式

        • 是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列
        • 将所有处于就绪状态的进程链在一起,称为就绪队列
        • 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列
        • 另外,对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序
    • 进程的上下文切换

      • 各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么一个进程切换到另一个进程运行,称为进程的上下文切换
      • CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文
      • 操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器
        • 程序计数器则用来存储CPU正在执行指令位置、或即将执行的下一条指令位置
        • CPU 寄存器是 CPU 内部一个容量小,但是速度极快的内存(缓存)
      • 进程由内核管理和调度的,所以进程的切换只能发生在内核态
      • 进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源
      • 通常,会把交换信息保存在进程 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行
  • 线程

    • 产生源由:
      • 对多进程的这种方式,会存在问题:
        • 进程之间如何通信,共享数据?
        • 维护进程的系统开销较大,如创建进程时,分配资源、建立 PCB;终止进程时,撤销 PCB;进程切换时,保存当前进程的状态信息;
      • 需要有一种新的实体
        • 实体之间可以并发运行
        • 实体之间共享相同的地址空间
      • 线程( Thread ),线程之间可以并发运行且共享相同的地址空间
    • 线程是?
      • 线程是进程当中的一条执行流程
      • 同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的
      • 优点:
        • 一个进程中可以同时存在多个线程
        • 各个线程之间可以并发执行
        • 各个线程之间可以共享地址空间和文件等资源
      • 缺点:
        • 当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃(C++会,Java不会)
    • 线程与进程
      • 线程与进程的比较如下:
        • 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;
        • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
        • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
        • 线程能减少并发执行的时间和空间开销;
      • 线程相比进程能减少开销,体现在:
        • 创建时间快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
        • 终止时间快,因为线程释放的资源相比进程少很多;
        • 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
        • 同一进程的各线程间共享内存和文件资源,那在线程之间数据传递时候,就不需要经过内核,这就使得线程之间的数据交互效率更高了;
    • 线程上下文切换
      • 所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了虚拟内存、全局变量等资源
      • 切换内容
        • 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
        • 当两个线程是属于同一个进程,因虚拟内存是共享,所以切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据
      • 线程实现
        • 用户线程(User Thread):在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理;
        • 内核线程(Kernel Thread):在内核中实现的线程,是由内核管理的线程;
        • 轻量级进程(LightWeight Process):在内核中来支持用户线程;
    • 调度时机
      • 进程的生命周期中,当进程从一个运行状态到另外一状态变化的时候,其实会触发一次调度
      • 非抢占式调度算法挑选一个进程,然后让该进程运行直到被阻塞,或者直到该进程退出,才会调用另外一个进程,也就是说不会理时钟中断这个事情。
      • 抢占式调度算法挑选一个进程,然后让该进程只运行某段时间,如果在该时段结束时,该进程仍然在运行时,则会把它挂起,接着调度程序从就绪队列挑选另外一个进程。这种抢占式调度处理,需要在时间间隔的末端发生时钟中断,以便把 CPU 控制返回给调度程序进行调度,也就是常说的时间片机制
  • 进程通信

    每个进程的用户地址空间都是独立的,一般而言是不能互相访问的,但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核

    • 管道

      $ ps auxf | grep mysql
      

      命令行里「|」竖线是一个管道,功能是将前一个命令(ps auxf)的输出,作为后一个命令(grep mysql)的输入,从这功能描述,可以看出管道传输数据是单向的

      • 上面这种管道是没有名字,所以「|」表示的管道称为匿名管道,用完就销毁

      使用命名管道前,先需要通过 mkfifo 命令来创建,并且指定管道名字:

      $ mkfifo myPipe
      

      myPipe 就是这个管道的名称,基于 Linux 一切皆文件的理念,所以管道也是以文件的方式存在

      $ echo "hello" > myPipe  // 将数据写进管道
      
      $ cat < myPipe  // 读取管道里的数据
      hello
      
      • 这种类型是命名管道,也被叫做 FIFO,因为数据是先进先出的传输方式

      管道这种通信方式效率低,不适合进程间频繁地交换数据

    • 消息队列

      消息队列的通信模式就可以解决管道问题。比如,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此

      • 消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体(数据块),消息体是用户自定义的数据类型,如果进程从消息队列中读取了消息体,内核就会把这个消息体删除
      • 缺点:
        • 消息队列不适合比较大数据的传输
        • 通信也不及时
        • 消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销
    • 共享内存

      • 消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝过程。共享内存解决
      • 共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中,这个进程写入的东西,另外一个进程马上就能看到了,都不需要拷贝来拷贝去
    • 信号量

      • 防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。信号量实现了这一保护机制

      • 信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据

      • 信号量表示资源的数量,控制信号量的方式有两种原子操作:

        • 一个是 P 操作,这个操作会把信号量减去 1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行
        • 另一个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程

        P 操作用在进入共享资源之前,V 操作用在离开共享资源之后,这两个操作必须成对出现的

    • 信号

      • 对于异常情况下的工作模式,就需要用「信号」的方式来通知进程
      • 在 Linux 操作系统中, 为了响应各种各样的事件,提供了几十种信号
        • 可以通过 kill -l 命令,查看所有的信号
          • Ctrl+C 产生 SIGINT 信号,表示终止该进程;
          • Ctrl+Z 产生 SIGTSTP 信号,表示停止该进程,但还未结束;
          • kill -9 1050 ,表示 PID 为 1050 的进程发送 SIGKILL ,立即结束该进程
    • Socket

      • 管道、消息队列、共享内存、信号量和信号都是在同一台主机上进行进程间通信,跨网络与不同主机上的进程之间通信,就需要 Socket 通信

      • 实际上,Socket 通信不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信

      • 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 即可
      • 这里需要注意的是,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。

        所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket

  • 多线程冲突

    当多线程相互竞争操作共享变量时,在执行过程中发生了上下文切换,得到了错误的结果,事实上,每次运行都可能得到不同的结果,因此输出的结果存在不确定性

    • 互斥与同步

      • 互斥: 由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此将此段代码称为临界区(critical section),它是访问共享资源的代码片段,一定不能给多线程同时执行
      • 互斥(mutualexclusion)也就说保证一个线程在临界区执行时,其他线程应该被阻止进入临界区
      • 同步:并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步
      • 通俗理解:
        • 同步:「操作 A 应在操作 B 之前执行」,「操作 C 必须在操作 A 和操作 B 都完成之后才能执行」等;
        • 互斥:「操作 A 和操作 B 不能在同一时刻执行」
    • 互斥与同步实现

      • :加锁、解锁操作

        • 任何想进入临界区线程,必须先执行加锁操作。若加锁操作顺利通过,则线程可进入临界区;完成对临界资源的访问后再执行解锁操作,以释放该临界资源

        • CPU 结构提供原子操作指令 —— 测试和置位(Test-and-Set)指令

        原文:https://blog.csdn.net/fan1570285527/article/details/121129848

        • bool test_and_set(bool *target) 
          {
          	bool rv = *target;
          	// 通过地址寻址,真实地修改target的值
          	*target = true;
          
          	// 返回的是传入时target的值
          	return rv;
          }
          

          通过指针传入参数target,在函数体中创建了临时变量rv来存储target的初始值,然后真实地修改target所指向内存地址的值为true,并将临时变量rv的值返回

        • 举例:

          • case1:传入参数为false,那么该函数会返回false,但过程中会真实地修改target所指向内存地址的值为true
          • case2:传入参数为true,那么该函数会返回true,此时过程中对target所指向内存地址的修改操作将不会引起变化
        • 采用指令(Test-and-Set)的互斥实现原理

          • 实现互斥,声明一个布尔变量lock,初始化为false

            // 采用指令test_and_set()的互斥实现
            do {
            	// 进入区代码
            	while(test_and_set(&lock));  		
            	// 临界区资源操作
            	criricalSectionOperation();
            	// 退出区代码
            	lock = false;		
            	// 剩余区
            	doSomethingElse();	
            } while (true);
            

            可以看到共分为四块,分别是进入区、临界区、退出区和剩余区。其中进入区的while(test_and_set(&lock));语句中的lock便是我们声明的变量,它就代表着临界区资源锁的状态

          • 举例:

            • 假设当前为false,则说明当前临界区资源没有上锁。此时某进程P1想要进入临界区,由于进入区的while循环条件test_and_set(&lock)为假,当前进程可以进入临界区执行相应的操作(注意此时lock变量的值已经被test_and_set()指令修改为true),在执行完临界区操作完毕后会执行到退出区的lock = false;语句,该语句代表着对临界区资源锁的释放

            接下来解释如何如何实现的互斥。

            • 我们假设当前状态时进程P1还在执行临界区操作,而此时有另外一个进程P2也想要进入临界区,那么它也会执行相同的代码块——执行到进入区代码while(test_and_set(&lock));。由于进程P1还没有执行完临界区操作,那么它不会对临界区资源的锁进行释放,即当前变量lock的值为true,所以P2进程会在进入区的while()循环代码无限循环,从而实现了进程P1和进程P2互斥的访问临界区资源
      • 信号量

        • 表示资源的数量,对应的变量是一个整型(sem)变量

        • 两个原子操作的系统调用函数来控制信号量的,分别是:

          • P 操作:将 sem1,相减后,如果 sem < 0,则进程/线程进入阻塞等待,否则继续,表明 P 操作可能会阻塞;
          • V 操作:将 sem1,相加后,如果 sem <= 0,唤醒一个等待中的进程/线程,表明 V 操作不会阻塞;
        • 代码实现【进程同步】使用信号量实现进程同步(附C++实现代码)_停车场 进程同步-CSDN博客

          #include <iostream>
          #include <unistd.h>
          
          using namespace std;
          
          int main(){
              fork();
              for(int i=0; i<5; i++){
                  cout << getpid() << ":before"  << endl;
                  sleep(1); // 模拟耗时操作
                  cout << getpid() << ":before"  << endl;  
              }
          }
          

          创建的两个进程,但输出结果必然乱序

          #include <iostream>
          #include <unistd.h>
          #include <semaphore.h>
          #include <fcntl.h>
          #include <sys/mman.h>
          
          using namespace std;
          int main(){
              sem_t* p = sem_open("/test",O_CREAT|O_RDWR,0666,1);
              // 信号量命名必须是以/开头,其并不是根目录的意思,该文件存于/dev/shm文件下
              // 标志位O_CREAT|O_RDWR有两个作用:该信号量不存在就创建,存在就打开
              // 0666为该信号量文件的权限
              // 1 为信号量个数(也可以设置为其他正整数)
              fork();
              for(int i=0; i<5; ++i){
                  usleep(100);
                  sem_wait(p);  // P操作,出现阻塞,初始值减一
                  // 临界区开始
                  cout << getpid() << "before" << endl;
                  sleep(1); //模拟耗时操作
                  cout << getpid() << "after" << endl;
                  // 临界区结束
                  sem_post(p); // 信号量初始值+1,唤醒阻塞进程,阻塞进程唤醒不定
              }
              sem_close(p);
              p = NULL;
          }
          

          此时一个进程进入临界区执行操作离开后另一个进程才会开始执行

  • 死锁

    • 两个线程为了保护两个不同的共享资源而使用了两个互斥锁,那么这两个互斥锁应用不当的时候,可能会造成两个线程都在等待对方释放锁,在没有外力的作用下,这些线程会一直相互等待,就没办法继续运行,这种情况就是发生了死锁
    • 死锁条件,需同时满足
      • 互斥条件:多个线程不能同时使用同一个资源
      • 持有并等待条件:当线程 A 已经持有了资源 1,又想申请资源 2,而资源 2 已经被线程 C 持有了,所以线程 A 就会处于等待状态,但是线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1
      • 不可剥夺条件:当线程已经持有了资源 ,在自己使用完之前不能被其他线程获取
      • 环路等待条件:在死锁发生的时候,两个线程获取资源的顺序构成了环形链
        • 线程 A 已经持有资源 2,而想请求资源 1, 线程 B 已经获取了资源 1,而想请求资源 2
    • 避免措施
      • 最常见的并且可行的就是使用资源有序分配法,来破环环路等待条件
        • 线程 A 和 线程 B 获取资源的顺序要一样,当线程 A 是先尝试获取资源 A,然后尝试获取资源 B 的时候,线程 B 同样也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和 线程 B 总是以相同的顺序申请自己想要的资源
  • 悲观锁与乐观锁

    • 互斥锁和自旋锁

      已经有一个线程加锁后,其他线程加锁就会失败,互斥锁和自旋锁对加锁失败后的处理方式是不一样的:

      • 互斥锁加锁失败后,线程会释放 CPU ,给其他线程
      • 自旋锁加锁失败后,线程会忙等待,直到它拿到锁
    • 读写锁

      • 读写锁适用于能明确区分读操作和写操作的场景
      • 读写锁在读多写少的场景,能发挥出优势
      • 互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景来选择这两种锁其中的一个进行实现
    • 乐观锁与悲观锁

      • 互斥锁、自旋锁、读写锁,都是属于悲观锁
        • 悲观锁做事比较悲观,认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁
      • 多线程同时修改共享资源的概率比较低,就可以采用乐观锁
        • 先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作
      • 乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁