简介:之前在学习小林大佬的八股文时,摘录了一些个人认为比较重要的内容,方便后续自己复习。【持续更新ing ~💯】
注:加五角星标注的,是当前掌握不牢固的,需要继续深入学习的内容 ★ \color{red}{★} ★
文章目录
- 一、前言(略)
- 二、硬件结构(略)
- 三、操作系统结构(略)
- 四、内存管理
-
- [4.1 虚拟内存](#4.1 虚拟内存)
-
- [说下虚拟内存的作用?★★★★★ \color{red}{说下虚拟内存的作用? ★★★★★} 说下虚拟内存的作用?★★★★★](#说下虚拟内存的作用?★★★★★ \color{red}{说下虚拟内存的作用? ★★★★★} 说下虚拟内存的作用?★★★★★)
- [缺页中断★★★★★ \color{red}{缺页中断 ★★★★★} 缺页中断★★★★★](#缺页中断★★★★★ \color{red}{缺页中断 ★★★★★} 缺页中断★★★★★)
- [4.2 malloc的内存分配](#4.2 malloc的内存分配)
-
- [malloc() 分配内存过程?](#malloc() 分配内存过程?)
- [malloc() 分配的是物理内存吗?](#malloc() 分配的是物理内存吗?)
- [malloc 申请的内存,free 释放内存会归还给操作系统吗?](#malloc 申请的内存,free 释放内存会归还给操作系统吗?)
- [为什么不全部使用 mmap 来分配内存? ★ \color{red}{★} ★](#为什么不全部使用 mmap 来分配内存? ★ \color{red}{★} ★)
- [既然 brk 那么牛逼,为什么不全部使用 brk 来分配? ★ \color{red}{★} ★](#既然 brk 那么牛逼,为什么不全部使用 brk 来分配? ★ \color{red}{★} ★)
- [free() 函数只传入一个内存地址,为什么能知道要释放多大的内存?](#free() 函数只传入一个内存地址,为什么能知道要释放多大的内存?)
- [4.3 内存满了会发生什么](#4.3 内存满了会发生什么)
-
- [内存分配过程 ★ \color{red}{★} ★](#内存分配过程 ★ \color{red}{★} ★)
- [哪些内存可以被回收(可被回收的内存类型)? ★ \color{red}{★} ★](#哪些内存可以被回收(可被回收的内存类型)? ★ \color{red}{★} ★)
- 回收内存带来的性能影响
- 针对回收内存导致的性能影响,常见的解决方式(稍微了解即可)
- [如何保护一个进程不被 OOM 杀掉呢?(稍微了解即可)](#如何保护一个进程不被 OOM 杀掉呢?(稍微了解即可))
- [4.4 在 4GB 物理内存的机器上,申请 8G 内存会怎么样? ★ \color{red}{★} ★](#4.4 在 4GB 物理内存的机器上,申请 8G 内存会怎么样? ★ \color{red}{★} ★)
-
- [swap机制 ★ \color{red}{★} ★](#swap机制 ★ \color{red}{★} ★)
- [如何避免预读失效和缓存污染的问题 ★ \color{red}{★} ★](#如何避免预读失效和缓存污染的问题 ★ \color{red}{★} ★)
- 五、进程管理
-
- [5.1 进程、线程基础知识](#5.1 进程、线程基础知识)
- [5.2 进程间有哪些通信方式? ★ \color{red}{★} ★](#5.2 进程间有哪些通信方式? ★ \color{red}{★} ★)
- [5.3 多线程冲突了怎么办](#5.3 多线程冲突了怎么办)
- [5.4 怎么避免死锁?](#5.4 怎么避免死锁?)
-
- [死锁产生的条件 ★ \color{red}{★} ★](#死锁产生的条件 ★ \color{red}{★} ★)
- 利用工具排查死锁问题
- 避免死锁问题的发生
- [5.5 什么是悲观锁、乐观锁?](#5.5 什么是悲观锁、乐观锁?)
- [5.6 一个进程最多可以创建多少个线程? ★ \color{red}{★} ★](#5.6 一个进程最多可以创建多少个线程? ★ \color{red}{★} ★)
- [5.7 线程崩溃了,进程也会崩溃吗?](#5.7 线程崩溃了,进程也会崩溃吗?)
-
- [为什么线程崩溃不会导致 JVM 进程崩溃](#为什么线程崩溃不会导致 JVM 进程崩溃)
- 总结
- 六、调度算法(略)
- 七、文件系统
-
- [7.1 文件系统全家桶(略)](#7.1 文件系统全家桶(略))
- [7.2 进程写文件时,进程发生了崩溃,已写入的数据会丢失吗? ★ \color{red}{★} ★](#7.2 进程写文件时,进程发生了崩溃,已写入的数据会丢失吗? ★ \color{red}{★} ★)
-
- 问题结论
- [page 与 Page Cache(内核缓冲区)](#page 与 Page Cache(内核缓冲区))
- [Page Cache 的优劣势 ★ \color{red}{★} ★](#Page Cache 的优劣势 ★ \color{red}{★} ★)
- [八、设备管理 (略)](#八、设备管理 (略))
- 九、网络系统
-
- [9.1 什么是零拷贝?](#9.1 什么是零拷贝?)
-
- [为什么要有 DMA 技术?](#为什么要有 DMA 技术?)
- 传统的文件传输有多糟糕?
- 如何优化文件传输的性能?
- [如何实现零拷贝? ★ \color{red}{★} ★](#如何实现零拷贝? ★ \color{red}{★} ★)
- [PageCache 有什么作用?](#PageCache 有什么作用?)
- 大文件传输用什么方式实现?
- [9.2 I/O 多路复用:select/poll/epoll](#9.2 I/O 多路复用:select/poll/epoll)
-
- [I/O 多路复用 ★ \color{red}{★} ★](#I/O 多路复用 ★ \color{red}{★} ★)
- ```select```、```poll```实现多路复用的方式及区别
- ```epoll```实现多路复用的方式及区别
- [9.3 高性能网络模式:Reactor 和 Proactor](#9.3 高性能网络模式:Reactor 和 Proactor)
- [9.4 什么是一致性哈希?](#9.4 什么是一致性哈希?)
- 十、Linux命令(略)
- 十一、学习心得(略)
- 十二、常见问题(个人补充)
-
-
- 32位系统和64位系统有什么区别?(32位和64位针对的是CPU处理器)
- [什么是内存对齐?优点是什么? ★ \color{red}{★} ★](#什么是内存对齐?优点是什么? ★ \color{red}{★} ★)
-
一、前言(略)
二、硬件结构(略)
三、操作系统结构(略)
四、内存管理
4.1 虚拟内存
虚拟内存 是一种计算机系统内存管理的技术 ,它使得应用程序认为它拥有连续可用的内存(一个连续完整的地址空间)。而实际上,它通常是被分隔成多个物理内存碎片 ,还有部分暂时存储在外部磁盘 存储器上,在需要时进行数据交换。
说下虚拟内存的作用?★★★★★ \color{red}{说下虚拟内存的作用? ★★★★★} 说下虚拟内存的作用?★★★★★
- 第一,虚拟内存可以使得进程的运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性。而对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域。
- 第二,由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的 。进程也没有办法访问其他进程的页表,这些页表是私有的,这就解决了多进程之间地址冲突的问题。
- 第三,页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性。
缺页中断★★★★★ \color{red}{缺页中断 ★★★★★} 缺页中断★★★★★
当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常。进入系统内核空间分配物理内存、将磁盘数据以页的方式加载到内存中、更新进程页表,最后再返回用户空间,恢复进程的运行。
流程:缺页异常(虚拟地址不在页表中,访问数据不在内存中) → 分配物理空间 → 加载磁盘数据到内存 → 更新进程页表(虚拟内存和物理内存之间的映射关系) → 返回用户空间 & 恢复进程运行
4.2 malloc的内存分配
malloc() 分配内存过程?
实际上,malloc() 并不是系统调用,而是 C 库里的函数,用于动态分配内存。malloc 申请内存的时候,会有两种方式向操作系统申请堆内存。
- 用户分配的内存小于 128 KB 阈值:通过
brk()
系统调用从【堆】分配内存 。其实就是通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。 - 用户分配的内存大于 128 KB 阈值:通过
mmap()
系统调用在【文件映射区域】分配内存。也就是从文件映射区"偷"了一块内存。
注意,不同的 glibc 版本定义的阈值也是不同的。
malloc() 分配的是物理内存吗?
malloc() 分配的是虚拟内存。
如果分配后的虚拟内存没有被访问的话,虚拟内存是不会映射到物理内存的,这样就不会占用物理内存了。
只有在访问已分配的虚拟地址空间时,操作系统通过查找页表,发现虚拟内存对应的页没有在物理内存中,就会触发缺页中断,然后操作系统会建立虚拟内存和物理内存之间的映射关系。
malloc 申请的内存,free 释放内存会归还给操作系统吗?
- malloc 通过
brk()
方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用; - malloc 通过
mmap()
方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放。
为什么不全部使用 mmap 来分配内存? ★ \color{red}{★} ★
频繁通过 mmap()
分配内存的话,不仅每次都会发生运行态的切换,还会发生缺页中断(在第一次访问虚拟地址后),这样会导致 CPU 消耗较大。
为了改进这两个问题,malloc 通过 brk()
系统调用在堆空间申请内存时,由于堆空间是连续的,所以直接预分配更大的内存来作为内存池,当内存释放时,就缓存在内存池中。
等下次申请内存时,就直接从内存池取出对应的内存块就行了,而且可能这个内存块的虚拟地址与物理地址的映射关系还存在,这样不仅减少了系统调用的次数,也减少了缺页中断的次数,这将大大降低 CPU 的消耗。
既然 brk 那么牛逼,为什么不全部使用 brk 来分配? ★ \color{red}{★} ★
随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致"内存泄露"。而这种"泄露"现象使用 valgrind 是无法检测出来的。
所以,malloc 实现中,充分考虑了 brk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128KB) 才使用 mmap 分配内存空间。
free() 函数只传入一个内存地址,为什么能知道要释放多大的内存?
malloc 返回给用户态的内存起始地址比进程的堆空间起始地址多了 16 字节,这里就保存了该内存块的描述信息:比如有该内存块大小 及起始地址 等。这样当执行 free() 函数时,free 会对传入进来的内存地址向左偏移 16 字节 ,然后从这个 16 字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了。
4.3 内存满了会发生什么
内存分配过程 ★ \color{red}{★} ★
应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。
当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存,这时会发现这个虚拟内存没有映射到物理内存, CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理。
缺页中断处理函数会看是否有空闲的物理内存,如果有就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。
如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作,回收的方式主要是两种:直接内存回收和后台内存回收。
-
后台内存回收 (kswapd):在物理内存紧张的时候,会唤醒 kswapd 内核线程来回收内存,这个回收内存的过程是异步的,不会阻塞进程的执行。
-
直接内存回收 (direct reclaim):如果后台异步回收跟不上进程内存申请的速度,就会开始直接回收,这个回收内存的过程是同步的,会阻塞进程的执行。
如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了 ------触发 OOM (Out of Memory)机制。
OOM Killer 机制会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源 。如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置。
哪些内存可以被回收(可被回收的内存类型)? ★ \color{red}{★} ★
可被回收的内存类型有文件页 和匿名页:
- 文件页:内核缓存的磁盘数据 (Buffer)和内核缓存的文件数据 (Cache)
- 干净页:大部分文件页,都可以直接释放内存,需要时再从磁盘读取即可。
- 脏页:而那些被应用程序修改过,并且暂时还没写入磁盘的数据(脏页),就得先写入磁盘,然后才能进行内存释放。
- 匿名页:部分内存没有实际载体,不像文件缓存有硬盘文件这样一个载体,比如 堆 、栈 数据等。这部分内存很可能还要再次被访问,所以不能直接释放内存。
回收内存带来的性能影响
【文件页】的回收:对于干净页是直接释放内存,这个操作不会影响性能;而对于脏页会先写回到磁盘再释放内存,这个操作会发生磁盘 I/O 的,会影响系统性能。
【匿名页】的回收:如果开启了 Linux Swap 机制,那么 Swap 机制会将不常访问的匿名页换出到磁盘中,并释放内存给其他更需要的进程使用。再次访问这些内存时,再从磁盘换入到内存中,这个操作会发生磁盘 I/O,是会影响系统性能的。
文件页和匿名页的回收都是基于 LRU 算法,也就是优先回收不常访问的内存。回收内存的操作基本都会发生磁盘 I/O 的,如果回收内存的操作很频繁,意味着磁盘 I/O 次数会很多,这个过程势必会影响系统的性能。
针对回收内存导致的性能影响,常见的解决方式(稍微了解即可)
- 设置
/proc/sys/vm/swappiness
,调整文件页和匿名页的回收倾向,尽量倾向于回收文件页; - 设置
/proc/sys/vm/min_free_kbytes
,调整 kswapd 内核线程异步回收内存的时机; - 设置
/proc/sys/vm/zone_reclaim_mode
,调整 NUMA 架构下内存回收策略,建议设置为 0,这样在回收本地内存之前,会在其他 Node 寻找空闲内存,从而避免在系统还有很多空闲内存的情况下,因本地 Node 的本地内存不足,发生频繁直接内存回收导致性能下降的问题;
如何保护一个进程不被 OOM 杀掉呢?(稍微了解即可)
我们可以通过调整进程的 /proc/[pid]/oom_score_adj
值,来降低被 OOM killer 杀掉的概率
4.4 在 4GB 物理内存的机器上,申请 8G 内存会怎么样? ★ \color{red}{★} ★
这个问题在没有前置条件下,就说出答案就是耍流氓。这个问题要考虑三个前置条件:
- 操作系统是 32 位的,还是 64 位的?
- 申请完 8G 内存后会不会被使用?
因为只有虚拟内存被真正访问后,触发了缺页中断,才会分配对应的物理内存 - 操作系统有没有使用 Swap 机制?
所以,我们要分场景讨论。简单总结下:
- 在 32 位操作系统,因为进程最大只能申请 3 GB 大小的用户空间虚拟内存,所以直接申请 8G 内存,会申请失败。
- 在 64位 位操作系统,因为进程最大只能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存。如果这块虚拟内存被访问了,要看系统有没有 Swap 分区:
- 如果没有 Swap 分区,因为 4 GB的物理空间不够,进程会被操作系统杀掉,原因是 OOM(内存溢出);
- 如果有 Swap 分区,即使物理内存只有 4GB,程序也能正常使用 8GB 的内存,进程可以正常运行;
swap机制 ★ \color{red}{★} ★
Swap 就是把一块磁盘空间或者本地文件,充当内存来使用,防止物理内存不够用。
- 它包含换出和换入两个过程:
- 换出(Swap Out) ,是把进程暂时不用的内存数据存储到磁盘中,并释放这些数据占用的内存;
- 换入(Swap In),是在进程再次访问这些内存的时候,把它们从磁盘读到内存中来;
- 优缺点:
应用程序实际可以使用的内存空间将远远超过系统的物理内存 。由于硬盘空间的价格远比内存要低,因此这种方式无疑是经济实惠的。
当然,频繁地读写硬盘 ,会显著降低操作系统的运行速率,这也是 Swap 的弊端。 - 触发条件:
- 内存不足:采用直接内存回收(Direct Page Reclaim)。直接内存回收是同步的过程,会阻塞当前申请内存的进程。
- 内存闲置:应用程序在启动阶段使用的大量内存在启动后往往都不会使用(不活跃)。通过运行的后台守护进程 - kSwapd ,我们可以将这部分只使用一次的内存交换到磁盘上为其他内存的申请预留空间。由于kSwapd 是后台进程,所以回收内存的过程是异步的,不会阻塞当前申请内存的进程。
如何避免预读失效和缓存污染的问题 ★ \color{red}{★} ★
在这之前先介绍几个概念,便于后续理解:
Linux 和 MySQL 的缓存
- Linux 操作系统的缓存:在应用程序读取文件数据时,Linux 操作系统是会对读取的文件数据进行缓存的,会缓存在文件系统中的 Page Cache 。
Page Cache 属于内存空间里的数据,由于内存访问比磁盘访问快很多,在下一次访问相同的数据就不需要通过磁盘 I/O 了,命中缓存就直接返回数据即可。
因此,Page Cache 起到了加速访问数据的作用。- MySQL 的缓存:
MySQL 的数据是存储在磁盘里的,为了提升数据库的读写性能,Innodb 存储引擎设计了一个缓冲池(Buffer Pool),Buffer Pool 属于内存空间里的数据。
有了缓冲池后:
- 当读取数据时,如果数据存在于 Buffer Pool 中,客户端就会直接读取 Buffer Pool 中的数据,否则再去磁盘中读取。
- 当修改数据时,首先是修改 Buffer Pool 中数据所在的页,然后将其页设置为脏页,最后由后台线程将脏页写入到磁盘。
磁盘数据页预读的原因:首先,操作系统出于空间局部性原理 :靠近当前被访问数据的数据,在未来很大概率会被访问到。因此会存在磁盘数据的预读。
因此,预读机制带来的好处就是减少了 磁盘 I/O 次数,提高系统磁盘 I/O 吞吐量。
其次再来说下 预读失效 和 缓存污染 :
- 预读失效:如果这些被提前加载进来的页,并没有被访问,相当于这个预读工作是白做了,这个就是预读失效。
- 缓存污染 :当我们在批量读取数据时,由于数据被访问了一次,这些大量数据都会被加入到「活跃 LRU 链表」里,然后之前缓存在活跃 LRU 链表(或者 young 区域)里的热点数据全部都被淘汰了,如果这些大量的数据在很长一段时间都不会被访问的话,那么整个活跃 LRU 链表(或者 young 区域)就被污染了。
传统的 LRU 算法无法避免下面这两个问题:
- 预读失效 导致缓存命中率下降;
- 缓存污染 导致缓存命中率下降;
为了解决「预读失效」的问题,Linux 和 MySQL 对传统的 LRU 链表做了改进:
- Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表 (active list)和非活跃 LRU 链表(inactive list)。
- MySQL Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域 :young区域 和 old 区域。
但是如果还是使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」这种方式的话,那么还存在缓存污染的问题。
为了解决「缓存污染」的问题 ,Linux 操作系统和 MySQL Innodb 存储引擎分别提高了升级为热点数据的门槛:
- Linux 操作系统:在内存页被访问第二次时,才将页从 inactive list 升级到 active list 里。
- MySQL Innodb:在内存页被访问第二次 时,并不会马上将该页从 old 区域升级到 young 区域,因为还要判断当前数据停留在 old 区域的时间 :
- 如果第二次的访问时间与第一次访问的时间 在 1 秒内(默认值),那么该页就不会从 old 区域升级到 young 区域;
- 如果第二次的访问时间与第一次访问的时间 超过 1 秒,那么该页就会从 old 区域升级到 young 区域;
通过提高了进入 active list (或者 young 区域)的门槛后,就很好了避免缓存污染带来的影响。
五、进程管理
5.1 进程、线程基础知识
根据任务的不同,把 CPU 上下文切换分成:进程上下文切换、线程上下文切换、中断上下文切换。
进程的状态
在操作系统的虚拟内存管理中,通常会把阻塞状态的进程物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换入到物理内存。这样可以避免对物理内存的浪费。
挂起状态:描述进程没有占用实际的物理内存空间的情况。
导致进程挂起的原因,不只是因为进程所使用的内存空间由于被换出到硬盘而不在物理内存,还包括如下情况:
- 通过 sleep 让进程间歇性挂起,其工作原理是设置一个定时器,到期后唤醒进程。
- 用户希望挂起一个程序的执行,比如在 Linux 中用 Ctrl+Z 挂起进程;
进程的上下文切换 ★ \color{red}{★} ★
CPU 寄存器 和程序计数器 是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文。
- CPU 寄存器 是 CPU 内部一个容量小,但是速度极快的内存(高速缓存)。
- 程序计数器 则是用来存储 CPU 正在执行的指令位置、或者即将执行的下一条指令位置。
CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置 ,运行新任务。
系统内核会存储保持下来的上下文信息,当此任务再次被分配给 CPU 运行时,CPU 会重新加载这些上下文,这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。
-
进程的上下文切换到底是切换什么呢?
进程是由内核管理和调度的,所以进程的切换只能发生在内核态 。
所以,进程的上下文切换不仅包含了 虚拟内存、栈、全局变量 等用户空间 的资源,还包括了 内核堆栈、寄存器 等内核空间 的资源。 ★ \color{red}{★} ★
-
发生进程上下文切换有哪些常见场景?
- 当某个进程的时间片耗尽,进程就从运行状态变为就绪状态,系统从就绪队列选择另外一个进程运行;
- 进程在系统资源不足(比如内存不足)时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;
- 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;
- 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;
- 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序;
什么是线程?
线程是进程当中的一条执行流程。
同一个进程内多个线程之间可以共享进程的虚拟内存空间【代码段、数据段、打开的文件】等资源,但每个线程各自都有一套独立的 【寄存器和栈】,这样可以确保线程的控制流是相对独立的 。 ★ \color{red}{★} ★
线程的优缺点
- 优点:
- 一个进程中可以同时存在多个线程;
- 各个线程之间可以并发执行;
- 各个线程之间可以共享进程的虚拟地址空间和文件等资源;
- 缺点:
- 当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃(这里是针对 C/C++ 语言,Java语言中的线程崩溃不会造成进程崩溃,具体分析原因可以看这篇:线程崩溃了,进程也会崩溃吗?)。
线程与进程的比较
所谓操作系统的任务调度,实际上的调度对象是线程,而进程只是给线程提供了【虚拟内存、全局变量】等资源,这些资源在线程间是共享的,仅仅是 【栈和寄存器】等算是线程的私有数据。
- 进程是资源(包括内存、打开的文件等)分配的单位,线程是 CPU 调度的单位;
- 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;
- 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
- 线程能减少并发执行的时间和空间开销;
对于,线程相比进程能减少开销,体现在:
- 线程的创建速度比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
- 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
- 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享) ,这意味着同一个进程的线程都具有同一个页表,那么在切换线程时不需要切换页表 。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
- 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递时就不需要经过内核了,这就使得线程之间的数据交互效率更高了;
所以,不管是时间效率,还是空间效率线程比进程都要高。
调度时机
在进程的生命周期中,当进程从一个运行状态到另外一状态变化的时候,其实会触发一次调度。
比如,以下状态的变化都会触发操作系统的调度:
- 从就绪态 → 运行态:当进程被创建时,会进入到就绪队列,操作系统会从就绪队列选择一个进程运行;
- 从运行态 → 阻塞态:当进程发生 I/O 事件而阻塞时,操作系统必须选择另外一个进程运行;
- 从运行态 → 结束态:当进程退出结束后,操作系统得从就绪队列选择另外一个进程运行;
因为,这些状态变化的时候,操作系统需要考虑是否要让新的进程给 CPU 运行,或者是否让当前进程从 CPU 上退出来而换另一个进程运行。
如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断 ,把调度算法分为两类:
- 非抢占式调度 :算法挑选一个进程,然后让该进程运行直到被阻塞或直到该进程退出,才会调用另外一个进程,也就是说不会理时钟中断这个事情。
- 抢占式调度 :算法挑选一个进程,然后让该进程只运行某段时间。如果在该时段结束时,该进程仍然在运行时,则会把它挂起,接着调度程序从就绪队列挑选另外一个进程。这种抢占式调度处理,需要在时间间隔的末端发生时钟中断,以便把 CPU 控制权返回给调度程序进行调度,也就是常说的 时间片机制 。
也就是根据时间片机制运行一段时间后将CPU控制权交给调度程序。
调度原则
针对上面的五种调度原则,总结成如下:
- CPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率;
- 系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量;
- 周转时间:周转时间是进程运行+阻塞时间+等待时间的总和,一个进程的周转时间越小越好;
- 等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意;
- 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。
说白了,这么多调度原则,目的就是要使得进程要「快」。
5.2 进程间有哪些通信方式? ★ \color{red}{★} ★
每个进程的用户地址空间都是独立的 ,一般而言是不能互相访问的,但内核空间是每个进程都共享的 ,所以进程之间要通信必须通过内核。
Linux 内核提供了不少进程间通信的方式,其中最简单的方式就是管道,管道分为「匿名管道」和「命名管道/有名管道」。
【匿名管道 】顾名思义,它没有名字标识,匿名管道是特殊文件只存在于内存,没有存在于文件系统中,shell 命令中的「|」竖线就是匿名管道,通信的数据是无格式的流并且大小受限 ,通信的方式是单向 的,数据只能在一个方向上流动。如果要双向通信,需要创建两个管道,再来匿名管道是只能用于通过 fork()
产生的存在父子关系的进程间通信,匿名管道的生命周期随着进程创建而建立,随着进程终止而消失。
【命名管道/有名管道 】突破了匿名管道只能在亲缘关系进程间的通信限制,因为使用命名管道的前提,需要在文件系统创建一个类型为 p 的设备文件,那么毫无亲缘关系的进程就可以通过这个设备文件进行通信 。另外,不管是匿名管道还是命名管道,进程写入的数据都缓存在内核 中,另一个进程读取数据时候自然也是从内核中获取,同时通信数据都遵循先进先出原则,不支持 lseek 之类的文件定位操作。
【消息队列 】克服了管道通信的数据是无格式字节流的问题 。消息队列实际上是保存在内核的「消息链表」,消息队列的消息体是可以用户自定义的数据类型。发送数据时,会被分成一个一个独立的消息体,当然接收数据时,也要与发送方发送的消息体的数据类型保持一致,这样才能保证读取的数据是正确的。
消息队列通信的速度不是最及时的,毕竟每次数据的写入和读取都需要经过用户态与内核态之间的拷贝过程。
【共享内存 】可以解决消息队列通信中用户态与内核态之间数据拷贝过程带来的开销。它直接分配一个共享空间,每个进程都可以直接访问 ,就像访问进程自己的空间一样快捷方便,不需要陷入内核态或者系统调用,大大提高了通信的速度,享有最快 的进程间通信方式之名。但是便捷高效的共享内存通信,带来新的问题,多进程竞争同个共享资源会造成数据的错乱。
补充:现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程 A 和 进程 B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删改查互不影响。
共享内存机制,就是从两个进程分别拿出一块虚拟地址空间来,映射到相同的物理内存中 。这样这个进程写入的数据,另一个进程马上就能看到,不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。
那么,就需要【信号量 】来保护共享资源,以确保任何时刻只能有一个进程访问共享资源,这种方式就是互斥访问。信号量不仅可以实现访问的互斥性,还可以实现进程间的同步 。信号量其实是一个计数器 ,表示的是资源个数,其值可以通过两个原子操作来控制,分别是 P 操作和 V 操作。
与信号量名字很相似的叫【信号 】,它俩名字虽然相似,但功能一点儿都不一样。信号是进程间通信机制中唯一的异步通信机制 ,信号可以在应用进程和内核之间直接交互 ,内核也可以利用信号来通知用户空间的进程发生了哪些系统事件。信号事件的来源主要有硬件来源(如键盘 Cltr+C )和软件来源(如 kill
命令)。一旦有信号发生,进程有三种方式响应信号 1. 执行默认操作、2. 捕捉信号、3. 忽略信号 。有两个信号是应用进程无法捕捉和忽略的,即 SIGKILL 和 SIGSTOP,这是为了方便我们能在任何时候结束或停止某个进程。
前面说到的通信机制,都是工作于同一台主机,如果要与不同主机的进程间通信,那么就需要 【Socket】 通信了。Socket 实际上不仅用于不同的主机进程间通信,还可以用于本地主机进程间通信。可根据创建 Socket 的类型不同,分为三种常见的通信方式:一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。
注意:
TCP方式通信:监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket ,一个叫作已完成连接socket 。 成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。
对于 UDP 来说,不需要维护连接,那么也就没有所谓的发送方和接收方,甚至都不存在客户端和服务端的概念,只要有一个 socket 多台机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind。
以上,就是进程间通信的主要机制了。你可能会问了,那线程通信间的方式呢?
同个进程下的线程之间都是共享进程的资源,只要是共享变量都可以做到线程间通信,比如全局变量 ,所以对于线程间关注的不是通信方式,而是关注多线程竞争共享资源的问题,信号量也同样可以在线程间实现互斥 与同步:
- 互斥的方式,可保证任意时刻只有一个线程访问共享资源;
- 同步的方式,可保证线程 A 应在线程 B 之前执行;
5.3 多线程冲突了怎么办
操作系统也为每个进程创建巨大、私有的虚拟内存的假象,这种地址空间的抽象让每个程序好像拥有自己的内存,而实际上操作系统在背后秘密地让多个地址空间「复用」物理内存或者磁盘。
线程之间是可以共享进程的资源,比如代码段 、数据段 、堆空间 、打开的文件等资源 。但每个线程都有自己独立的栈空间 和 寄存器 (注意:线程是不能共享进程的栈 空间的)。 ★ \color{red}{★} ★
由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区 (critical section),它是访问共享资源的代码片段,一定不能给多线程同时执行。
而互斥就解决了并发 进程/线程 对临界区的使用问题。
互斥与同步的实现和使用
在进程/线程并发执行的过程中,进程/线程 之间存在协作关系,例如有互斥 、同步的关系。
为了实现进程/线程间正确的协作,操作系统必须提供实现进程协作的措施和方法,主要的方法有两种:
- 锁:加锁、解锁操作;
- 信号量:P、V 操作;
这两个都可以方便地实现 进程/线程 互斥,而信号量比锁的功能更强一些,它还可以方便地实现 进程/线程 同步。
补充:
- 自旋锁/忙等待锁 : 这是最简单的一种锁,一直自旋,占用CPU,直到锁可用。在单处理器上,需要抢占式 的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU上无法使用,因为一个自旋的线程永远不会放弃 CPU。
- 无等待锁 :顾明思议就是获取不到锁的时候,不用自旋。
既然不想自旋,那当没获取到锁的时候,就把当前线程放入到锁的等待队列,然后执行调度程序,把 CPU 让给其他线程执行。
5.4 怎么避免死锁?
简单来说,死锁问题的产生是由两个或者以上线程并行执行时,争夺资源而互相等待造成的。
死锁产生的条件 ★ \color{red}{★} ★
死锁只有同时满足以下四个必要条件才会发生:
- 互斥:多个线程不能同时使用同一个资源
- 持有并等待:线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源
- 不可抢占:当线程已经持有了资源 ,在自己使用完之前不能被其他线程获取
- 环路等待:两个线程获取资源的顺序构成了环形链
利用工具排查死锁问题
- C语言:pstack + gdb
- JAVA:jstack
避免死锁问题的发生
避免死锁问题就只需要破环四个条件其中一个条件就可以 ,最常见并且可行的就是使用 资源有序分配法,来破坏环路等待条件。
线程 A 和 线程 B 获取资源的顺序要一样。
- 首先是,线程 A 是先尝试获取资源 A,然后尝试获取资源 B ,
- 同样的,线程 B 是先尝试获取资源 A,然后尝试获取资源 B。
也就是说,线程 A 和 线程 B 总是以相同的顺序申请自己想要的资源,这样就可以打破死锁了。。
5.5 什么是悲观锁、乐观锁?
互斥锁与自旋锁
- 互斥锁加锁失败后,线程会释放 CPU,切换给其他线程来使用,有切换成本;
- 自旋锁加锁失败后,线程会忙等待 (持续占有CPU试图抢占锁资源 ),直到拿到锁,无切换成本但持续占有CPU资源;
对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的 。当加锁失败时,内核会将线程置为「睡眠」状态,等到锁被释放后,内核会在合适的时机唤醒线程,当这个线程成功获取到锁后,就可以继续执行。如下图:
所以,互斥锁加锁失败时,会从用户态陷入到内核态,让内核帮我们切换线程,虽然简化了使用锁的难度,但存在一定的性能开销成本。
那这个开销成本是什么呢?会有两次线程上下文切换的成本:
- 当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;状态:运行 → 睡眠
- 接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。状态:睡眠 → 就绪
线程上下文切换的是什么?
当两个线程是属于同一个进程,因为虚拟内存空间是共享的,所以在切换时这些资源就保持不动,只要切换线程的私有数据、【寄存器】和【线程栈】等不共享的数据。
上下切换的耗时有大佬统计过,大概在几十纳秒到几微秒之间,如果你锁住的代码执行时间比较短,那可能上下文切换的时间都比你锁住的代码执行时间还要长。
所以,如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用【互斥锁】。
自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。
互斥锁和自旋锁都是最基本的锁,读写锁可以根据场景来选择这两种锁其中的一个进行实现。
读写锁
读写锁适用于能明确区分读操作和写操作的场景,适用于读多写少的场景。
读写锁的工作原理是:
- 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
- 但是,一旦「写锁」被线程持有后,读线程获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。
那为了避免锁资源竞争中的线程饥饿问题,有了公平读写锁 :
公平读写锁 比较简单的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现线程「饥饿」的现象。
乐观锁与悲观锁
- 悲观锁 :多线程同时修改共享资源的概率比较高,很容易出现冲突,所以访问共享资源前,先要上锁。互斥锁、自旋锁、读写锁都属于悲观锁。
- 场景:读少写多,发生冲突概率高
- 乐观锁 :也可以叫做无锁编程。先修改完共享资源,再验证这段时间内有没有发生冲突。如果没有其他线程修改过资源,那么操作完成;如果发现有其他线程已经修改过这个资源,就放弃本次操作(版本号机制)。
- 场景:读多写少,发生冲突概率低。Git、在线文档(多人编辑)
- 特性 :乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高 ,所以只有在冲突概率非常低,且加锁成本非常高的场景下,才考虑使用乐观锁。
5.6 一个进程最多可以创建多少个线程? ★ \color{red}{★} ★
这个问题跟两个东西有关系:进程的虚拟内存空间上限、系统参数限制
- 进程的虚拟内存空间上限 ,因为创建一个线程,操作系统需要为其分配一个栈空间 。如果线程数量越多,所需的栈空间就越大,那么虚拟内存就会占用越多。
查看进程创建线程时默认分配的栈空间大小:ulimit -a
(Linux中默认为8M ),然后使用当前操作系统(假设32位 系统)的用户态虚拟内存(3G)来除以一个线程占用的栈空间的大小(假设为10M):3G/10M ≈ 300个 。
当然,在64位 的操作系统下,理论上可以创建的线程个数为:用户态虚拟内存(128T)/ 一个线程占用的栈空间的大小(假设为10M)≈ 1000w个,有点夸张。。
事实上,肯定创建不了那么多线程,除了虚拟内存的限制,还有系统的限制。
- 系统参数限制 ,虽然 Linux 并没有内核参数来控制单个进程创建的最大线程个数,但是有系统级别的参数来控制整个系统的最大线程个数。
简单总结下:
- 32 位系统,用户态的虚拟空间只有 3G,如果创建线程时分配的栈空间是 10M,那么一个进程最多只能创建 300 个左右的线程。
- 64 位系统 ,用户态的虚拟空间大到有 128T,理论上不会受虚拟内存大小的限制,而会受系统参数或性能限制。
5.7 线程崩溃了,进程也会崩溃吗?
为什么线程崩溃不会导致 JVM 进程崩溃
因为 JVM 自定义了信号处理函数,拦截了 SIGSEGV 信号,针对这两者不让它们崩溃。
总结
正常情况下,操作系统为了保证系统安全,所以针对非法内存访问会发送一个 SIGSEGV 信号,而操作系统一般会调用默认的信号处理函数(一般会让相关的进程崩溃)。
但如果进程觉得 "罪不致死",那么它也可以选择自定义一个信号处理函数,这样的话它就可以做一些自定义的逻辑,比如记录 crash 信息等有意义的事。
回过头来看为什么虚拟机会针对 StackoverflowError 和 NullPointerException 做额外处理让线程恢复呢?针对 stackoverflow 其实它采用了一种栈回溯的方法保证线程可以一直执行下去,而捕获空指针错误主要是这个错误实在太普遍了。
为了这一个很常见的错误而让 JVM 崩溃那线上的 JVM 要宕机多少次,所以出于工程健壮性的考虑,与其直接让 JVM 崩溃倒不如让线程起死回生,并且将这两个错误/异常抛给用户来处理。
六、调度算法(略)
七、文件系统
7.1 文件系统全家桶(略)
7.2 进程写文件时,进程发生了崩溃,已写入的数据会丢失吗? ★ \color{red}{★} ★
问题结论
分情况,要看 page cache 内核缓冲区的数据是否真正的被刷盘:
- 因为进程在执行 write (使用缓冲 IO )系统调用时,实际上是将文件数据写到了内核的 page cache,它是文件系统中用于缓存文件数据的缓冲。所以即使进程崩溃了,文件数据还是保留在内核的 page cache,我们在读数据时,也是从内核的 page cache 读取,因此还是依然读的进程崩溃前写入的数据。
- 内核会找个合适的时机,将 page cache 中的数据持久化到磁盘。但是如果 page cache 里的文件数据,在持久化到磁盘化到磁盘之前,系统发生了崩溃,那这部分数据就会丢失了 。
当然, 我们也可以在程序里调用fsync()
函数,在写完文件时,立刻将文件数据持久化到磁盘,这样就可以解决系统崩溃导致的文件数据丢失的问题。
page 与 Page Cache(内核缓冲区)
page 是内存管理分配的基本单位 , Page Cache 由多个 page 构成。page 在操作系统中通常为 4KB 大小(32bits/64bits),而 Page Cache 的大小则为 4KB 的整数倍。
另一方面,并不是所有 page 都被组织为 Page Cache。
Linux 系统上可供用户访问的内存分为两类:
- File-backed pages:文件备份页也就是 Page Cache 中的 page,对应于磁盘上的若干数据块;对于这些页最大的问题是脏页回盘;
- Anonymous pages:匿名页不对应磁盘上的任何磁盘数据块,它们是进程的运行时内存空间(例如方法栈 、局部变量表等属性);
Page Cache 的优劣势 ★ \color{red}{★} ★
Page Cache 的优势:
- 加快数据访问
如果数据能够在内存中进行缓存,那么下一次访问就不需要通过磁盘 I/O 了,直接命中内存缓存即可。
由于内存访问比磁盘访问快很多,因此加快数据访问是 Page Cache 的一大优势。 - 减少 I/O 次数,提高系统磁盘 I/O 吞吐量
得益于 Page Cache 的缓存以及预读能力,而程序又往往符合局部性原理。因此通过一次 I/O 将多个 page 装入 Page Cache 能够减少磁盘 I/O 次数, 进而提高系统磁盘 I/O 吞吐量。
Page Cache 的劣势:
- 需要占用额外物理内存空间 ,物理内存在比较紧张时可能会导致频繁的 swap 操作,最终导致系统的磁盘 I/O 负载的上升。
- 应用层并没有对外提供很好的管理 API。
应用层即使想优化 Page Cache 的使用策略也很难进行,因此一些应用选择在用户空间实现自己的 page 管理,而不使用 page cache。
例如 MySQL InnoDB 存储引擎以 16KB 的页进行管理。 - 在某些应用场景下比 Direct I/O 多一次磁盘读 I/O 以及磁盘写 I/O。
八、设备管理 (略)
九、网络系统
9.1 什么是零拷贝?
首先说下什么是 零拷贝:是指计算机执行操作时,CPU不需要先将数据从某处内存复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省CPU周期和内存带宽。
为什么要有 DMA 技术?
DMA:直接内存访问(Direct Memory Access)
简单理解就是,在进行 I/O 设备和内存数据传输时,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务。
传统的文件传输有多糟糕?
传统文件传输 中会发生 4 次数据拷贝,其数据流动方向:
磁盘 → 内核缓冲区(PageCache) → 用户缓冲区 → 内核socket缓冲区 → 网卡缓冲区
其中 read()
系统调用:内核缓冲区(PageCache) → 用户缓冲区
write()
系统调用:用户缓冲区 → 内核socket缓冲区
这种简单又传统的文件传输方式是有缺点的:
- 存在冗余的上下文切换 和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。
- 内存与磁盘数据传输的工作都是由 CPU 完成的,而此时 CPU 不能执行其他任务,会特别浪费 CPU 资源。
所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存数据拷贝」的次数。
如何优化文件传输的性能?
① 先来看看,如何减少「用户态与内核态的上下文切换」的次数呢?
读取磁盘数据的时候,之所以要发生上下文切换,这是因为用户空间没有权限操作磁盘或网卡 ,内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务时,就需要使用操作系统提供的系统调用函数 。
而一次系统调用必然会发生 2 次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。
所以,要想减少上下文切换到次数,就要减少系统调用的次数。
② 再来看看,如何减少「数据拷贝」的次数?
在前面我们知道了,传统的文件传输方式会历经 4 次数据拷贝,而且这里面,「从内核的读缓冲区拷贝到用户的缓冲区里,再从用户的缓冲区里拷贝到 socket 的缓冲区里」,这个过程是没有必要的。
因为文件传输的应用场景中,在用户空间我们并不会对数据「再加工」,所以数据实际上可以不用搬运到用户空间(透传 ),因此用户的缓冲区是没有必要存在的。
如何实现零拷贝? ★ \color{red}{★} ★
它通过一次系统调用(
sendfile()
方法)合并了 磁盘读取 (read()
方法)与 网络发送 这两个操作,分别降低了 上下文切换 与 数据拷贝的次数 。 ★ \color{red}{★} ★而之所以降低了数据拷贝的次数,是因为数据拷贝都是发生在内核中的(不需要通过切换用户态,无需借助对应的用户缓冲区),天然就降低了数据拷贝的次数。
零拷贝技术是基于 内核缓冲区(PageCache)的,其实现方式通常有 2 种:
mmap()
(替代read()
) +write()
通过使用 mmap() 来替代 read(), 可以减少一次数据拷贝的过程mmap()
系统调用函数会直接把内核缓冲区(PageCache)里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。- 具体过程如下:
- 应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区;
- 应用进程再调用 write(),操作系统直接将内核缓冲区(PageCache)的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据;
- 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。
- 但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区(PageCache)的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次,仅仅是减少了一次数据拷贝的过程。
sendfile()
该系统调用函数:合并了 磁盘读取 与 网络发送 两个操作,减少了上下文切换,及数据拷贝的次数 。
在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数sendfile(int out_fd, int in_fd, off_t *offset, size_t count)
。它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。- 首先,它可以替代前面的 read() 和 write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。
- 其次,该系统调用,可以直接把内核缓冲区(PageCache)里的数据拷贝到 socket 缓冲区里,不再经过到用户态,这样就只有 2 次上下文切换(一次系统调用对应两次上下文切换),和 3 次数据拷贝(磁盘 → 内核缓冲区(PageCache) → socket缓冲区 → 网卡缓冲区)。
- 但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA (The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区(PageCache)里的数据拷贝到 socket 缓冲区的过程 。
也就是只要 2 次数据拷贝( 磁盘 → 内核缓冲区( P a g e C a c h e ) → 网卡缓冲区 \color{red}{磁盘 → 内核缓冲区(PageCache) → 网卡缓冲区} 磁盘→内核缓冲区(PageCache)→网卡缓冲区), 不再经过 s o c k e t 缓冲区 \color{red}{不再经过socket缓冲区} 不再经过socket缓冲区。
这就是所谓的零拷贝(Zero-copy)技术,因为没有在内存层面去拷贝数据 ,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA控制器 来进行传输的。
零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数 ,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输。而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA控制器 来搬运。
所以总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上。
PageCache 有什么作用?
上面经常提到的「内核缓冲区」,实际上是磁盘高速缓存(PageCache)。
PageCache 是为了提升对文件的读写效率,Linux 内核会以页大小(4KB)为单位,将文件划分为多数据块。当用户对文件中的某个数据块进行读写操作时,内核首先会申请一个内存页(称为 页缓存)与磁盘文件中的数据块进行绑定。
读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘。
PageCache 的优点主要是两个:
- 缓存最近被访问的数据,加速访问
- 预读功能(局部性原理),减少磁盘io
PageCache 的这两个做法,将大大提高读写 小文件 时磁盘的性能。
但是,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能。
因为可能由于 PageCache 被大文件占据,大文件本身难以命中 PageCache 缓存,并且也会导致「热点」小文件被挤出缓存而无法利用到 PageCache,这样在高并发的环境下,会带来严重的性能问题 。 ★ \color{red}{★} ★
大文件传输用什么方式实现?
传输文件的时候,我们要根据文件的大小来使用不同的方式:
- 传输大文件 的时候,使用「异步 I/O + 直接 I/O」;
- 异步 I/O(对于磁盘,异步 I/O 只支持直接 I/O):
内核向磁盘发起读请求,但是可以不等待数据准备好就提前返回 。
当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据。 - 直接I/O:绕开 PageCache 的 I/O
- 异步 I/O(对于磁盘,异步 I/O 只支持直接 I/O):
- 传输小文件的时候,则使用「零拷贝技术」;
9.2 I/O 多路复用:select/poll/epoll
监听的 Socket 和真正用来传输数据的 Socket 是两个:
- 一个叫作 监听 Socket
- 一个叫作 已连接 Socket
I/O 多路复用 ★ \color{red}{★} ★
多路复用 :一个 线程/进程 处理多个io事件流的请求。
select
/poll
/epoll
内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。
select
/poll
/epoll
是如何获取网络事件的呢?
在获取事件时,先把所有连接(文件描述符集合)传给内核,再由内核返回产生了事件的连接,最后在用户态中处理这些连接对应的请求即可。
select
、poll
实现多路复用的方式及区别
两者并没有本质区别,它们内部都是使用「线性结构」来存储进程关注的 Socket 集合。
方式:
- 在使用时,首先要把关注的 Socket 集合通过 select/poll 系统调用从用户态拷贝到内核态。
- 然后由内核检测事件,当有网络事件产生时,内核需要遍历进程关注的 Socket 集合,找到有事件产生的 Socket,并将其状态标记为 可读/可写。
- 然后把整个 Socket 集合从内核态拷贝到用户态,用户态还要再次遍历整个 Socket 集合找到被标记为 可读/可写 的 Socket,然后对其处理。
区别:
select
使用 BitsMap位图 的方式存储 fd_set,支持的文件描述符个数受限(由linux内核中的 FD_SETSIZE 限制),默认为 1024,只能监听 0~1023 的文件描述符。poll
则使用动态数组的方式存储 fd_set,以链表 形式来组织。突破了 select 中文件描述符的个数限制,当然还是会受到系统文件描述符个数限制。
缺点 :
很明显发现,select
和 poll
的缺陷在于:当客户端越多,也就是 Socket 集合越大,Socket 集合的2次遍历(时间复杂度为 O(n))和2次拷贝会带来很大的开销。
此方式随着并发数上来,性能的损耗会呈指数级增长,因此也很难应对 C10K问题(即单机1万个并发连接)。
epoll
实现多路复用的方式及区别
epoll
是解决 C10K 问题的利器,通过两个方面解决了 select
/poll
的问题。
epoll
在内核里使用「红黑树 」来关注进程所有待检测的 Socket。红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。
通过对这棵黑红树的管理,不需要像select
/poll
在每次操作时都传入整个 Socket 集合,而只需要传入一个待检测的 socket ,减少了内核和用户空间大量的数据拷贝和内存分配。- epoll 使用事件驱动 (回调函数)的机制,内核里维护了一个「链表」来记录有事件产生的就绪事件。
只将有事件发生的 Socket 集合传递给用户态应用程序,不需要像 select/poll 那样轮询整个集合(包含有事件和无事件的 Socket ),大大提高了检测的效率。
而且,epoll
支持边缘触发 (产生事件时,仅通知一次)和水平触发 (产生事件时,一直通知)的方式,而 select
/poll
只支持水平触发。一般而言,边缘触发的方式会比水平触发的效率高。
9.3 高性能网络模式:Reactor 和 Proactor
9.4 什么是一致性哈希?
适用场景:分布式系统(数据分片的系统)。
在分布式系统中,每个节点存储的数据是不同的。
不同的负载均衡算法适用的业务场景也不同的。
轮询这类的策略只能适用与每个节点的数据都相同的场景 ,访问任意节点都能请求到数据。但是不适用分布式系统,因为分布式系统意味着数据【水平切分】到了不同节点上。访问数据时,一定要寻址存储该数据的节点。
哈希算法虽然能建立数据和节点的映射关系,但是每次在节点数量发生变化的时候,最坏情况下所有数据都需要迁移,这样太麻烦了,所以哈希算法不适用节点数量变化的场景。
为了减少迁移的数据量,就出现了一致性哈希算法。
一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环 上,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响。
但是一致性哈希算法不能够均匀的分布节点,会出现大量请求都集中在一个节点的情况,也就是数据倾斜 。在这种情况下进行容灾与扩容时,容易出现雪崩连锁反应。
为了解决一致性哈希算法不能够均匀的分布节点的问题,就需要引入虚拟节点 ,对一个真实节点做多个副本。
不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系:缓存数据 ➜ 虚拟节点 ➜ 真实节点
引入虚拟节点后,可以会提高节点的均衡度,还会提高系统的稳定性 (当节点数目变化时,会有不同的节点共同分担系统的变化)。
所以,带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景。
内部碎片和外部碎片本质上都是对内存空间的浪费,区分二者的最主要特征就是:内部碎片产生在分区内,外部碎片产生在分区外,内外是相对于分区/内存块 而言的。
内部碎片产生的原因:
在固定分区中,固定分区的大小大于等于作业大小,当分区大小大于作业大小时就会产生不能被其他作业利用的碎片,称为内部碎片。
在固定分区存储管理中,10KB大小的分区装入8KB大小的作业,分区内产生了2KB大小的内部碎片。
外部碎片产生的原因:
在可变分区存储管理中,系统划分给作业的分区大小等于作业大小,在分区内就不会产生多余的空间。但是在分区外就可能产生小的内存碎片,因为太小不能被分配给作业,小的碎片长期积累浪费了大量的内存空间,我们称之为外部碎片。
解决「外部内存碎片」的问题就是内存交换。
不再需要一次性都把程序加载到物理内存中
而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。
对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。
有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。
TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。
数据段,包括已初始化的静态常量和全局变量; ★ \color{red}{★} ★
BSS 段,包括未初始化的静态变量和全局变量; ★ \color{red}{★} ★
文件映射段,包括动态库、共享内存等
CPU 只会访问虚拟内存地址 ★ \color{red}{★} ★
顺序读比随机读性能好的原因:磁盘预读(局部性原理) ★ \color{red}{★} ★
接收网络数据包:网卡缓冲区 → socket缓冲区 → PageCache 内核缓冲区 → 用户缓冲区
进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。
为什么说 同一个进程下的线程上下文切换的开销要比进程小得多? ★ \color{red}{★} ★
单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码段、全局数据段、堆、共享库 等,这些共享资源在上下文切换时不需要切换,而只需要切换线程的私有数据(独立栈 )、寄存器 等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。
此外还有 tlb 页表的切换开销。
Page Cache 正如其名,是对磁盘上 page(页)的内存缓存,同时可以用于读/写操作。
十、Linux命令(略)
十一、学习心得(略)
十二、常见问题(个人补充)
32位系统和64位系统有什么区别?(32位和64位针对的是CPU处理器)
- 处理数据速度:32位处理器每次最多能处理32位数据,而64位处理器每次最多处理64位数据。
- 支持内存大小:32位操作系统为2^32=4GB,64位操作系统理论值直接达到了 2^64=16TB。
- 支持操作系统:32位的cpu处理器,只能安装32位的电脑操作系统;64位的cpu处理器,则可以安装32位和64位操作系统,所以64位处理器是向下兼容的。
- 支持的软件、系统体积大小也分别不同
什么是内存对齐?优点是什么? ★ \color{red}{★} ★
- 内存对齐:是指cpu在读取内存地址 时,按照一定的偏移量去读取。
- 优点:以空间换时间 ,提高读取效率。CPU每次寻址都是要占用时间的,并且CPU 访问内存时,并不是逐个字节访问,而是以字长(word size)为单位访问,所以数据结构应该尽可能地在自然边界上对齐。
- 有些CPU可以访问任意地址上的任意数据,而有些CPU只能在特定地址访问数据 ,因此不同硬件平台具有差异性,这样的代码就不具有移植性。如果在编译时,将分配的内存进行对齐,这就具有更好的平台移植性了。
- 若访问未对齐的内存,处理器需要做2次内存访问,而对齐的内存访问仅需一次访问,内存对齐后可以提升性能。
示例:在C语言中,若通过
#pragma pack(4)
指定cpu读取单位为4字节,而 int a = 0 保存在内存地址为 0x00000001 ~ 0x00000004 中。若 内存不对齐:那么cpu就会先去读取 0x00000000 ~ 0x00000003 的前4字节数据(并去除 0x00000000 的空数据);然后再去读取 0x00000004 ~ 0x00000007 的后4字节数据(并去除 0x00000005 ~ 0x00000007 的空数据)。
上述过程一共需要CPU访问 2次 内存,并做数据的拼接处理后才能读取到整形变量a的完整数据,影响性能。
而若 内存对齐:则cpu只需访问一次内存即可读取到a的完整数据。