Contents

OS

Contents

OS

常见内存分配方式有哪些?

(1)从静态存储区域分配。内存在程序编译的时候就已经分配好,这块内存在程序的整个运行期间都存在。例如全局变量,static变量。

(2)在栈上创建。在执行函数时,函数内局部变量的存储单元都可以在栈上创建,函数执行结束时这些存储单元自动被释放。栈内存分配运算内置于处理器的指令集中,==效率很高==,但是分配的内存容量有限。

(3)从堆上分配,亦称动态内存分配。程序在运行的时候用malloc或new申请任意多少的内存,程序员自己负责在何时用free或delete释放内存。动态内存的生存期由我们决定,使用==非常灵活==,但问题也最多。

页表

单级页表需要去映射整个的虚拟地址,比如32位的系统下,每个进程需要4MB的页表。 4GB / 4KB = 1024 * 1024个条目,每个条目4B,那么就需要4B * 1024 * 1024 = 4MB

但是多级页表的情况下,一级页表已经可以覆盖整个虚拟地址空间,如果一级页表的页表项没有被用到,也就不会去创建更高级的页表,只有在需要的时候才会去创建后续的页表。最坏情况下需要4KB+4MB的页表,但是往往进程用不了这么多的内存,因此相较于单级页表更节省空间。

单级页表:缓存了所有情况的合集,访问速度快,只需要2次访存,缺点是占用空间大

多级页表:只缓存已经存在的情况,占用空间小,但需要更多次的访存,访问时间长

大内存页

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202408281013144.png

通过增加偏移量部分,映射更大的内存页。Linux常用的大内存页面大小为2M和1G

大内存页的好处:

  1. 减少TLB的失效情况:TLB可以缓存的地址转换条目相对较少。(相同数据量)使用大页可以减少所需的页表条目数量,从而减少TLB Misses,提高地址转换的效率。
  2. 提高访存性能、减少页表的内存大小:通过3级页表就可以直接找到对应的页面
  3. 减少缺页中断次数:相同数据量,2MB比4KB的页面的数量更少,缺页中断次数也更少

内核申请内存vmalloc和kmalloc的区别

vmalloc和kmalloc都是用于在内核中申请内存的函数,但是它们有以下几个区别:

  • vmalloc分配的内存==虚拟地址是连续的,而物理地址无须连续==。而kmalloc确保页在物理地址上是连续的,自然虚拟地址也是连续的。
  • vmalloc相比较于kmalloc效率不高,因为获得的页必须转换为虚拟地址空间上连续的页,必须专门建立页表项。
  • vmalloc仅在不得已时才使用——典型的就是为了==申请大块内存==。该函数可能睡眠,因此不能从中断上下文中调用,也不能从其他不允许阻塞的情况下进行调用。
  • kmalloc分配的内存是基于slab,因此slab的一些特性包括着色,对齐等都具备,性能较好。
  • kmalloc一般用于申请小于一页的物理内存;vmalloc一般用于申请较大的内存空间。

操作系统的内存分配一般有哪几种方式,各有什么优缺点?

(1)分页存储管理:优点是不需要连续的内存空间,且内存利用率高(只有很小的页内碎片);缺点是不易于实现内存共享与保护。【linux通过分页管理内存】

(2)分段存储管理:优点是易于实现段内存共享和保护,分段管理系统将程序的内存划分为多个逻辑段(如代码段、数据段、堆栈段等),每个段都可以有独立的访问权限和属性。这样可以更容易地实现共享和保护;缺点是每段都需要连续的内存空间,段与段之间可以是非连续的,内存利用率较低(会产生外部碎片)。

(3)段页式存储管理:优点是不需要连续的内存空间,内存利用率高(只有很小的页内碎片),且易于实现==段内存共享和保护==;缺点是管理软件复杂性较高,需要的硬件以及占用的内存也有所增加,使得执行速度下降。

内存分配的过程

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

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

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

如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作,回收的方式主要是两种:后台内存回收直接内存回收。

内存满了会怎么样

内核在给应用程序分配物理内存的时候,如果空闲物理内存不够,那么就会进行内存回收的工作,主要有两种方式:

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

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

OOM Killer 机制会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源,如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置。

什么是内存泄漏和内存溢出

内存泄漏和内存溢出都是与内存管理相关的问题,但它们的含义和表现形式有所不同。

内存泄漏(Memory Leak)指的是程序在使用完内存后,没有及时释放这些内存,导致内存资源浪费或耗尽的问题。内存泄漏通常是由于程序在动态分配内存后,没有在不再需要这些内存时将其释放。内存泄漏会导致系统的可用内存越来越少,最终导致系统崩溃或者出现不可预期的错误。

内存溢出(Memory Overflow)程序运行过程中申请的内存大于系统能够提供的内存,导致无法申请到足够的内存,于是就发生了内存溢出。内存溢出通常是由于程序在对已分配的内存进行操作时,超出了该内存块的边界而导致的。内存溢出会导致程序崩溃或者出现不可预期的错误。

总的来说,内存泄漏和内存溢出都是内存管理的问题,但它们的表现形式和原因有所不同。内存泄漏是指未能及时释放已经分配的内存,而内存溢出是指申请了过多的内存,超出了系统或进程所能分配的内存空间限制。在开发过程中,需要避免出现内存泄漏和内存溢出的问题,可以通过合理地使用内存分配和释放函数、定期检查内存使用情况等方式来解决。

内存泄露如何检测?避免?

检测

  1. 手动检查代码:仔细检查代码中的内存分配和释放,确保每次分配内存后都有相应的释放操作。比如 malloc和free、new和delete是否配对使用了。

  2. 使用调试器和工具:有一些工具可以帮助检测内存泄露。例如:Valgrind(仅限于Linux和macOS):Valgrind是一个功能强大的内存管理分析工具,可以检测内存泄露、未初始化的内存访问、数组越界等问题。使用Valgrind分析程序时,只需在命令行中输入valgrind –leak-check=yes your_program即可。

避免

  1. 使用智能指针
  2. 异常安全:在C++中,如果程序抛出异常,需要确保在异常处理过程中正确释放已分配的内存。使用try-catch块来捕获异常并在适当的位置释放内存。

哪些内存可以被回收

系统内存紧张的时候,就会进行回收内存的工作,那具体哪些内存是可以被回收的呢?

主要有两类内存可以被回收,而且它们的回收方式也不同。

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

回收内存带来的性能影响

在前面我们知道了回收内存有两种方式。

  • 一种是后台内存回收,也就是唤醒 kswapd 内核线程,这种方式是异步回收的,不会阻塞进程。
  • 一种是直接内存回收,这种方式是同步回收的,会阻塞进程,这样就会造成很长时间的延迟,以及系统的 CPU 利用率会升高,最终引起系统负荷飙高。

可被回收的内存类型有文件页和匿名页:

  • 文件页的回收:对于干净页是直接释放内存,这个操作不会影响性能,而对于脏页会先写回到磁盘再释放内存,这个操作会发生磁盘 I/O 的,这个操作是会影响系统性能的。
  • 匿名页的回收:如果开启了 Swap 机制,那么 Swap 机制会将不常访问的匿名页换出到磁盘中,下次访问时,再从磁盘换入到内存中,这个操作是会影响系统性能的。

可以看到,回收内存的操作基本都会发生磁盘 I/O 的,如果回收内存的操作很频繁,意味着磁盘 I/O 次数会很多,这个过程势必会影响系统的性能,整个系统给人的感觉就是很卡。

在4GB物理内存的机器上申请8G内存

先讲内存分配过程,要考虑32位(1G+3G)还是64位(128T), 有无swap

  • 在 32 位操作系统,因为进程理论上最大能申请 3 GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败。
  • 在 64位 位操作系统,因为进程理论上最大能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存。如果这块虚拟内存被访问了,要看系统有没有 Swap 分区:
    • 如果没有 Swap 分区,因为物理空间不够,进程会被操作系统杀掉,原因是 OOM(内存溢出);
    • 如果有 Swap 分区,即使物理内存只有 4GB,程序也能正常使用 8GB 的内存,进程可以正常运行;

如何防止进程不被OOM

当物理内存和交换空间不够用时,OOM Killer 就会选择杀死进程,那么它是怎样知道要先杀死哪个进程呢?其实 Linux 的每个进程都有一个 oom_score (位于/proc/<pid>/oom_score文件中),这个值越大,就越有可能被 OOM Killer 选中。

  • 如果进程消耗的内存越大,它的 oom_score 通常也会越大。
  • 如果进程运行了很长时间,并且消耗很多 CPU 时间,那么通常它的 oom_score 会偏小。
  • 如果进程以 Superuser 的身份运行,那么它的 oom_score 也会偏小。

如何才能尽量防止某个重要的进程被杀死呢?Linux 每个进程都有一个 oom_adj(位于/proc/<pid>/oom_adj文件中),这个值的范围是 [-17, +15],默认值为 0,值越大越容易被 kill 掉。如果设置为 -17 的话,表示永远不会被清除。进程的 oom_adj 会影响 oom_score 的计算,也就是说,我们可以通过调小进程的 oom_adj 从而降低进程的 oom_score

Linux进程的内存分布

在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同。比如最常见的 32 位和 64 位系统,如下所示:

C:/Users/10503/AppData/Roaming/Typora/typora-user-images/image-20240514201236773.png

通过这里可以看出:

  • 32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间;
  • 64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。

再来说说,内核空间与用户空间的区别:

  • 进程在用户态时,只能访问用户空间内存;
  • 只有进入内核态后,才可以访问内核空间的内存;

虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405142013846.png

接下来,进一步了解虚拟空间的划分情况,用户空间和内核空间划分的方式是不同的,内核空间的分布情况就不多说了。

我们看看用户空间分布的情况,以 32 位系统为例,我画了一张图来表示它们的关系:

通过这张图你可以看到,用户空间内存从低到高分别是 6 种不同的内存段:

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405142013463.png

  • 代码段,包括二进制可执行代码;
  • 数据段,包括已初始化的静态常量、全局变量和常量;
  • BSS 段,包括未初始化的静态变量和全局变量;
  • 堆段,包括动态分配的内存,从低地址开始向上增长;
  • 文件映射段,包括动态库、共享内存(mmap)等,从低地址开始向上增长
  • 栈段,包括局部变量和函数调用的上下文等。栈的大小是固定的,一般是 8 MB。当然系统也提供了参数,以便我们自定义大小;

在这 6 个内存段中,堆和文件映射段的内存是动态分配的。比如说,使用 C 标准库的 malloc() 或者 mmap() ,就可以分别在文件映射段动态分配内存。

为什么要区分数据段和bss段

内存效率

  • .bss段:未初始化变量不需要在可执行文件中存储实际数据,仅需要描述这些变量的大小和位置。在加载时,操作系统会自动将这些变量初始化为零。这减少了可执行文件的大小,提高了内存使用效率。
  • .data段:已初始化变量的初始值需要存储在可执行文件中,并在加载时读取到内存中。

加载时间

  • .bss段:加载时,只需将内存中的这部分区域清零即可,这比加载实际数据要快。
  • .data段:加载时需要从可执行文件中读取实际数据并存储到内存中,可能会稍微慢一些。

结论

区分 .bss.data 段是为了优化程序的内存使用和加载效率。未初始化的变量存储在 .bss 段中,只需在加载时清零,而已初始化的变量存储在 .data 段中,加载时需要从可执行文件中读取初始值。这种区分提高了内存管理的效率,减少了可执行文件的大小,并简化了程序加载过程。

在执行malloc申请内存的时候,操作系统是怎么做的?

从操作系统层面上看,malloc是通过两个系统调用来实现的: brk和mmap

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

  • 方式一:通过 brk() 系统调用从堆分配内存:方式一实现的方式很简单,就是通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405142011600.png

  • 方式二:通过 mmap() 系统调用在文件映射区域分配内存;mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405142012347.png

通常,分配的内存小于128k时,使用brk调用来获得虚拟内存,大于128k时就使用mmap来获得虚拟内存。

进程先通过这两个系统调用获取或者扩大进程的虚拟内存,获得相应的虚拟地址,在访问这些虚拟地址的时候,通过缺页中断,让内核分配相应的物理内存,这样内存分配才算完成。

  • malloc 通过 brk() 方式申请的内存,free 释放内存的时候,并不会把内存归还给操作系统,而是缓存在 malloc 的内存池中,待下次使用

被free回收的内存会首先被ptmalloc使用双链表保存起来,当用户下一次申请内存的时候,会尝试从这些内存中寻找合适的返回。这样就避免了频繁的系统调用,占用过多的系统资源。同时ptmalloc也会尝试对小块内存进行合并,避免过多的内存碎片

  • malloc 通过 mmap() 方式申请的内存,free 释放内存的时候,会把内存归还给操作系统,内存得到真正的释放

为什么不全使用mmap分配内存

因为向操作系统申请内存,是要通过系统调用的,执行系统调用是要进入内核态的,然后在回到用户态,运行态的切换会耗费不少时间。

所以,申请内存的操作应该避免频繁的系统调用,如果都用 mmap 来分配内存,等于每次都要执行系统调用。

另外,因为 mmap 分配的内存每次释放的时候,都会归还给操作系统,于是每次 mmap 分配的虚拟地址都是缺页状态的,然后在第一次访问该虚拟地址的时候,就会触发缺页中断。

也就是说,频繁通过 mmap 分配的内存话,不仅每次都会发生运行态的切换,还会发生缺页中断(在第一次访问虚拟地址后),这样会导致 CPU 消耗较大

为什么不全使用brk

前面我们提到通过 brk 从堆空间分配的内存,并不会归还给操作系统,那么我们那考虑这样一个场景。

如果我们连续申请了 10k,20k,30k 这三片内存,如果 10k 和 20k 这两片释放了,变为了空闲内存空间,如果下次申请的内存小于 30k,那么就可以重用这个空闲内存空间。

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405142016760.png

但是如果下次申请的内存大于 30k,没有可用的空闲内存空间,必须向 OS 申请,实际使用内存继续增大。

因此,随着系统频繁地 malloc 和 free ,尤其对于小块内存,堆内将产生越来越多不可用的碎片,导致“内存泄露”。而这种“泄露”现象使用 valgrind 是无法检测出来的。

所以,malloc 实现中,充分考虑了 brk 和 mmap 行为上的差异及优缺点,默认分配大块内存 (128KB) 才使用 mmap 分配内存空间

mmap原理

mmap(Memory Map)是一种内存映射文件的机制,在Linux系统中广泛应用于文件读写、共享内存、动态链接库等领域。mmap的原理可以简单概括为以下几个步骤:

  1. 打开文件:首先需要使用open系统调用打开文件,获取文件描述符。
  2. 映射文件到内存:使用mmap系统调用将文件映射到进程的虚拟地址空间中。mmap系统调用将文件内容映射到一个或多个虚拟内存页中,这些页可以是匿名页(即没有对应的文件)或者文件页(即与文件关联的页)。映射可以是只读的,也可以是可写的。
  3. 访问映射区域:通过访问映射区域,触发缺页中断,分配物理内存,添加虚拟地址到物理地址的映射,然后读取或写入映射区域的数据,可以实现文件读写、共享内存等功能。这些操作会直接访问对应的内存页,而不需要通过系统调用来进行文件读写或者共享内存的操作。
  4. 取消映射:使用munmap系统调用取消映射,释放虚拟内存页,将对应的文件内容写回到磁盘中(如果是文件页),并释放对应的文件描述符。

总之,mmap的原理是将文件映射到进程的虚拟地址空间中,通过访问映射区域来实现文件读写、共享内存等功能。mmap的优点是可以避免频繁的文件读写操作,提高了文件读写的效率,并且可以实现进程间的共享内存,方便了进程之间的通信。同时,mmap还可以通过设置权限位来控制映射区域的访问权限,提高了数据的安全性。

free() 函数只传入一个内存地址,为什么能知道要释放多大的内存?

malloc 返回给用户态的内存起始地址比进程的堆空间起始地址多了 16 字节。这个多出来的 16 字节就是保存了该内存块的描述信息,比如有该内存块的大小。

这样当执行 free() 函数时,free 会对传入进来的内存地址向左边偏移 16 字节,然后从这个 16 字节的分析出当前的内存块的大小,自然就知道要释放多大的内存了。

栈空间的大小

ulimit -a显示当前所有的资源限制,其中stack size就是栈空间的大小默认是8M

或者通过ulimit -s也可以查看为8192,可以通过ulimit -s xxxx临时改变栈空间的大小

永久改变可以在/etc/security/limits.conf 中也可以改变栈空间大小:

1
2
3
#<domain> <type> <item> <value>

* soft stack 102400

为什么要有page cache,OS怎么设计page cache的?

加快从磁盘读取文件的速率。page cache中有一部分磁盘文件的缓存,因为从磁盘中读取文件比较慢, 所以读取文件先去page cache中去查找,如果命中,则不需要去磁盘中读取,大大加快读取速度。

在Linux 内核中,文件的每个数据块最多只能对应一个 Page Cache 项,它通过两个数据结构来管理这些 Cache项,一个是radix tree,另一个是双向链表(LRU)。Radix tree 是一种搜索树,每个文件都有一个基数树,Linux内核利用这个数据结构来通过文件内偏移快速定位Cache 项。双向链表是为了使用LRU策略,内核维护两个链表:active_listinactive_list,用于分别管理活跃页和非活跃页。

预读失效和缓存污染(如何改进LRU)

预读失效会带来什么问题?

如果这些被提前加载进来的页,并没有被访问,相当于这个预读工作是白做了,这个就是预读失效

如果使用传统的 LRU 算法,就会把「预读页」放到 LRU 链表头部,而当内存空间不够的时候,还需要把末尾的页淘汰掉。

如果这些「预读页」如果一直不会被访问到,就会出现一个很奇怪的问题,不会被访问的预读页却占用了 LRU 链表前排的位置,而末尾淘汰的页,可能是热点数据,这样就大大降低了缓存命中率

如何避免预读失效造成的影响?

我们不能因为害怕预读失效,而将预读机制去掉,大部分情况下,空间局部性原理还是成立的。

要避免预读失效带来影响,最好就是让预读页停留在内存里的时间要尽可能的短,让真正被访问的页才移动到 LRU 链表的头部,从而保证真正被读取的热数据留在内存里的时间尽可能长

那到底怎么才能避免呢?

Linux 操作系统和 MySQL Innodb 通过改进传统 LRU 链表来避免预读失效带来的影响,具体的改进分别如下:

  • Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)

Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)

  • active list 活跃内存页链表,这里存放的是最近被访问过(活跃)的内存页;
  • inactive list 不活跃内存页链表,这里存放的是很少被访问(非活跃)的内存页;

有了这两个 LRU 链表后,预读页就只需要加入到 inactive list 区域的头部,当页被真正访问的时候,才将页插入 active list 的头部。如果预读的页一直没有被访问,就会从 inactive list 移除,这样就不会影响 active list 中的热点数据。如果预读页被访问到了,就会从inactive list升级到active list中,对应的,al中末尾的页会降级到il中。

什么是缓存污染?

虽然 Linux (实现两个 LRU 链表)和 MySQL (划分两个区域)通过改进传统的 LRU 数据结构,避免了预读失效带来的影响。

但是如果还是使用「只要数据被访问一次,就将数据加入到活跃 LRU 链表头部(或者 young 区域)」这种方式的话,那么还存在缓存污染的问题

当我们在批量读取数据的时候,由于数据被访问了一次,这些大量数据都会被加入到「活跃 LRU 链表」里,然后之前缓存在活跃 LRU 链表(或者 young 区域)里的热点数据全部都被淘汰了,如果这些大量的数据在很长一段时间都不会被访问的话,那么整个活跃 LRU 链表(或者 young 区域)就被污染了

解决方法

提升进入active list的门槛,linux在内存页被访问第二次时,才将页从inactive list升级到active list中

进程、线程、协程

对于有线程系统:

  • 进程是资源分配的独立单位
  • 线程是资源调度的独立单位

对于无线程系统:

  • 进程是资源调度、分配的独立单位

协程:协程,也称为轻量级线程,是用户态的线程,完全由应用程序控制,不需要操作系统介入调度。协程提供了一种避免阻塞的方法,通过在单个线程内的任务之间显式地手动切换来实现并发

首先,我们来谈谈进程。进程是操作系统中进行资源分配和调度的基本单位,它拥有自己的独立内存空间和系统资源。每个进程都有独立的堆和栈,不与其他进程共享。进程间通信需要通过特定的机制,如管道、消息队列、信号量等。由于进程拥有独立的内存空间,因此其稳定性和安全性相对较高,但同时上下文切换的开销也较大,因为需要保存和恢复整个进程的状态。

接下来是线程。线程是进程内的一个执行单元,也是CPU调度和分派的基本单位。与进程不同,线程共享进程的内存空间,包括堆和全局变量。线程之间通信更加高效,因为它们可以直接读写共享内存。线程的上下文切换开销较小,因为只需要保存和恢复线程的上下文,而不是整个进程的状态。然而,由于多个线程共享内存空间,因此存在数据竞争和线程安全的问题,需要通过同步和互斥机制来解决。

最后是协程。协程是一种用户态的轻量级线程,其调度完全由用户程序控制,而不需要内核的参与。协程拥有自己的寄存器上下文和栈,但与其他协程共享堆内存。协程的切换开销非常小,因为只需要保存和恢复协程的上下文,而无需进行内核级的上下文切换。这使得协程在处理大量并发任务时具有非常高的效率。然而,协程需要程序员显式地进行调度和管理,相对于线程和进程来说,其编程模型更为复杂。

进程的状态

所以,在一个进程的活动期间至少具备三种基本状态,即运行状态、就绪状态、阻塞状态。

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202404251131003.png

上图中各个状态的意义:

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

当然,进程还有另外两个基本状态:

  • 创建状态(new):进程正在被创建时的状态;
  • 结束状态(Exit):进程正在从系统中消失时的状态;

于是,一个完整的进程状态的变迁如下图:

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202404251131734.png

如果有大量处于阻塞状态的进程,进程可能会占用着物理内存空间,显然不是我们所希望的,毕竟物理内存空间是有限的,被阻塞状态的进程占用着物理内存就一种浪费物理内存的行为。

所以,在虚拟内存管理的操作系统中,通常会把阻塞状态的进程的物理内存空间换出到硬盘,等需要再次运行的时候,再从硬盘换入到物理内存。

那么,就需要一个新的状态,来描述进程没有占用实际的物理内存空间的情况,这个状态就是挂起状态。这跟阻塞状态是不一样,阻塞状态是等待某个事件的返回。

另外,挂起状态可以分为两种:

  • 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;
  • 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行;

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202404251135861.png

进程之间通信方式以及优缺点

  • 管道(PIPE)
    • 有名管道:一种半双工的通信方式,它允许无亲缘关系进程间的通信
      • 优点:可以实现任意关系的进程间的通信
      • 缺点:
        1. 长期存于系统中,使用不当容易出错
        2. 缓冲区有限
    • 无名管道:一种半双工的通信方式,只能在具有亲缘关系的进程间使用(父子进程)
      • 优点:简单方便
      • 缺点:
        1. 局限于单向通信
        2. 只能创建在它的进程以及其有亲缘关系的进程之间
        3. 缓冲区有限
  • 信号量(Semaphore):一个计数器,可以用来控制多个线程对共享资源的访问
    • 优点:可以同步进程
    • 缺点:信号量有限
  • 信号(Signal):一种比较复杂的通信方式,用于通知接收进程某个事件已经发生
  • 消息队列(Message Queue):是消息的链表,存放在内核中并由消息队列标识符标识
    • 优点:可以实现任意进程间的通信,并通过系统调用函数来实现消息发送和接收之间的同步,无需考虑同步问题,方便
    • 缺点:信息的复制需要额外消耗 CPU 的时间,不适宜于信息量大或操作频繁的场合
  • 共享内存(Shared Memory):映射一段能被其他进程所访问的内存,这段共享内存由一个进程创建,但多个进程都可以访问
    • 优点:无须复制,快捷,信息量大
    • 缺点:
      1. 通信是通过将共享空间缓冲区直接附加到进程的虚拟地址空间中来实现的,因此进程间的读写操作的同步问题
      2. 利用内存缓冲区直接交换信息,内存的实体存在于计算机中,只能同一个计算机系统中的诸多进程共享,不方便网络通信
  • 套接字(Socket):可用于不同计算机间的进程通信
    • 优点:
      1. 传输数据为字节级,传输数据可自定义,数据量小效率高
      2. 传输数据时间短,性能高
      3. 适合于客户端和服务器端之间信息实时交互
      4. 可以加密,数据安全性强
    • 缺点:需对传输的数据进行解析,转化成应用级的数据。

线程之间的通信方式

  • 锁机制:包括互斥锁/量(mutex)、读写锁(reader-writer lock)、自旋锁(spin lock)、条件变量(condition)

    • 互斥锁/量(mutex):提供了以排他方式防止数据结构被并发修改的方法。涉及到内核切换,当线程加锁失败时,内核会把线程的状态从「运行」状态设置为「睡眠」状态,然后把 CPU 切换给其他线程运行;接着,当锁被释放时,之前「睡眠」状态的线程会变为「就绪」状态,然后内核会在合适的时间,把 CPU 切换给该线程运行。

    • 读写锁(reader-writer lock):允许多个线程同时读共享数据,而对写操作是互斥的。

    • 自旋锁(spin lock)与互斥锁类似,都是为了保护共享资源。互斥锁是当资源被占用,申请者进入睡眠状态;而自旋锁则循环检测保持者是否已经释放锁。自旋锁是通过 CPU 提供的 CAS 函数(Compare And Swap),在「用户态」完成加锁和解锁操作,不会主动产生线程上下文切换,所以相比互斥锁来说,会快一些,开销也小一些。

      一般加锁的过程,包含两个步骤:

      • 第一步,查看锁的状态,如果锁是空闲的,则执行第二步;
      • 第二步,将锁设置为当前线程持有;

      CAS 函数就把这两个步骤合并成一条硬件级指令,形成原子指令,这样就保证了这两个步骤是不可分割的,要么一次性执行完两个步骤,要么两个步骤都不执行。

      比如,设锁为变量 lock,整数 0 表示锁是空闲状态,整数 pid 表示线程 ID,那么 CAS(lock, 0, pid) 就表示自旋锁的加锁操作,CAS(lock, pid, 0) 则表示解锁操作。

      使用自旋锁的时候,当发生多线程竞争锁的情况,加锁失败的线程会「忙等待」,直到它拿到锁。这里的「忙等待」可以用 while 循环等待实现,不过最好是使用 CPU 提供的 PAUSE 指令来实现「忙等待」,因为可以减少循环等待时的耗电量。

      自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。

      自旋锁开销少,在多核系统下一般不会主动产生线程切换,适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源,所以自旋的时间和被锁住的代码执行的时间是成「正比」的关系,我们需要清楚的知道这一点。

      自旋锁与互斥锁使用层面比较相似,但实现层面上完全不同:当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对

      它俩是锁的最基本处理方式,更高级的锁都会选择其中一个来实现,比如读写锁既可以选择互斥锁实现,也可以基于自旋锁实现。

    • 条件变量(std::condition_variable):可以以原子的方式阻塞进程,直到某个特定条件为真为止。对条件的测试是在互斥锁的保护下进行的。条件变量始终与互斥锁(unique_lock)一起使用。

  • 信号量机制(Semaphore)

    • 无名线程信号量
    • 命名线程信号量
  • 信号机制(Signal):类似进程间的信号处理

  • 屏障(barrier):屏障允许每个线程等待,直到所有的合作线程都达到某一点,然后从该点继续执行。

线程间的通信目的主要是用于线程同步,所以线程没有像进程通信中的用于数据交换的通信机制

父进程、子进程的关系以及区别

父进程和子进程的关系

  1. 创建关系
    • 父进程通过调用系统调用 fork() 创建子进程。fork() 调用会复制父进程的地址空间、文件描述符等资源,从而创建一个新的子进程。
  2. 资源共享
    • 子进程继承父进程的大部分资源,如打开的文件描述符、环境变量等。但是,父进程和子进程有独立的内存空间。虽然子进程初始时复制了父进程的内存空间,但之后的修改不会相互影响。
  3. 独立运行
    • 父进程和子进程是独立的进程,各自有独立的进程ID(PID)。子进程的父进程ID(PPID)指向父进程的PID。
  4. 进程控制
    • 父进程可以通过系统调用 wait()waitpid() 等等待子进程终止,并获取子进程的退出状态。
    • 父进程可以使用 kill() 系统调用向子进程发送信号,控制其行为。
  5. 退出处理
    • 当子进程终止时,它会向父进程发送一个 SIGCHLD 信号。父进程可以捕捉这个信号并进行相应的处理。
    • 如果父进程先于子进程终止,子进程会被 init 进程(PID为1的进程)收养,成为孤儿进程。

多进程fork后不同进程会共享哪些资源

共享内存:如果父进程在调用fork()前分配了共享内存,那么子进程会共享这些内存段。这对于进程间通信(IPC)很有用。

消息队列、信号量和其他IPC机制:父进程和子进程共享这些进程间通信机制(如消息队列、信号量等)。这些资源通常用于进程间的同步和通信。

父子进程内核数据结构

主要体现在task_struct结构体中的parent\children\sibing

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
/*
 * pointers to (original) parent process, youngest child, younger sibling,
 * older sibling, respectively.  (p->father can be replaced with
 * p->real_parent->pid)
 */
struct task_struct __rcu *real_parent; /* real parent process */
struct task_struct __rcu *parent; /* recipient of SIGCHLD, wait4() reports */
/*
 * children/sibling forms the list of my natural children
 */
struct list_head children;      /* list of my children */
struct list_head sibling;       /* linkage in my parent's children list */
struct task_struct *group_leader;       /* threadgroup leader */
  1. struct task_struct *real_parent;/* 真正的父进程(被调试的情况下) */
  2. struct task_struct *parent;/* 父进程 ,parent和real_parent的区别:real_parent是亲爹,调fork的那个,parent呢是干爹,大部分情况下亲爹干爹是一个人,ps看到的是干爹,什么时候亲爹干爹不一样的,比如有一种情况,比如亲爹死了,但是呢又得有一个父进程,比如1号进程就会被当成父进程。但进程不是1号fork出来的。*/
  3. struct list_head children;指向子进程链表的头部。链表中的所有元素都是它的子进程。
  4. struct list_head sibling;用于把当前进程插入到兄弟链表中,链接兄弟节点

僵尸进程和孤儿进程和守护进程

僵尸进程 (Zombie Process)

僵尸进程是已经终止但其进程描述符仍然存在的进程。它的出现是由于父进程尚未读取子进程的终止状态。具体流程如下:

  1. 子进程执行完毕并终止,但它的进程描述符(包括退出状态等)仍保留在系统中。
  2. 父进程需要通过系统调用 wait()waitpid() 来读取子进程的退出状态,并释放其进程描述符。
  3. 在父进程读取子进程的退出状态之前,子进程处于“僵尸”状态。此时,子进程已经结束执行,但其进程表项仍然存在。

僵尸进程的危害在于,它们会占用系统的进程表项资源。如果系统中存在过多的僵尸进程,可能导致系统无法创建新的进程。

ps 命令可以显示系统中所有进程的信息。可以通过以下命令过滤出僵尸进程:

1
ps aux | grep 'Z'

僵尸进程占用的是进程表项,而不是系统资源,如内存或CPU时间。因此,僵尸进程不能直接通过发送信号来“杀掉”,因为它们已经处于终止状态。

如何避免僵尸进程:

  1. 通过signal(SIGCHLD, SIG_IGN)通知内核对子进程的结束不关心,由内核回收。这种方法忽略SIGCHLD信号,这常用于并发服务器的性能的一个技巧因为并发服务器常常fork很多子进程,子进程终结之后需要服务器进程去wait清理资源。如果将此信号的处理方式设为忽略,可让内核把僵尸子进程转交给init进程去处理,省去了大量僵尸进程占用系统资源。
  2. 父进程调用wait/waitpid等函数等待子进程结束,如果尚无子进程退出wait会导致父进程阻塞。waitpid可以通过传递WNOHANG使父进程不阻塞立即返回。
  3. 如果父进程很忙可以用signal注册信号处理函数,在信号处理函数调用wait/waitpid等待子进程退出。

孤儿进程 (Orphan Process)

孤儿进程是父进程已经终止,但子进程仍在运行的进程。具体流程如下:

  1. 子进程创建并运行。
  2. 父进程在子进程之前终止,此时子进程变为孤儿进程。
  3. 孤儿进程会被系统中的“init”进程(通常是 PID 为 1 的进程)收养。init 进程会自动成为这些孤儿进程的父进程,并负责在它们终止时清理它们的资源。

孤儿进程本身并不会对系统造成直接危害,因为 init 进程会管理这些进程,确保它们的资源得到正确的回收。

守护进程(Daemon Process)

守护进程是一种在后台运行的进程,通常在系统启动时启动,并且在整个系统运行期间持续运行。守护进程通常执行系统级任务,例如日志记录、任务调度等。

特点:

  1. 后台运行
    • 守护进程在后台运行,通常不直接与用户交互。它们在系统启动时启动,并在系统运行期间一直运行。
  2. 脱离控制终端
    • 守护进程在启动时会脱离控制终端,通常会重新将自身父进程设置为 init 进程。
  3. 启动方式
    • 守护进程通常通过系统启动脚本、服务管理器(如 systemd)等方式启动。

创建守护进程的步骤

  1. 创建子进程并终止父进程:通过 fork() 创建一个子进程,然后让父进程退出,确保子进程不是一个进程组的组长。
  2. 调用 setsid():在子进程中调用 setsid(),使子进程成为新的登录会话的组长和进程组长,并与终端脱离关系。
  3. 更改工作目录:通常将工作目录更改为根目录,以避免守护进程占用导致卸载不了的文件系统。
  4. 重设文件权限掩码:重设文件权限掩码,以确保文件创建权限的正确性。umask(0);
  5. 调用close,关闭不再需要的文件描述符:关闭所有继承的文件描述符,通常包括标准输入、标准输出和标准错误。
  6. 重定向标准输入、输出和错误:将标准输入、标准输出和标准错误重定向到 /dev/null避免终端输出

进程终止的几种方式

1、main函数的自然返回,return

2、调用exit函数,属于c的函数库

3、调用_exit函数,属于系统调用

4、调用abort函数,异常程序终止,同时发送SIGABRT信号给调用进程。

5、接受能导致进程终止的信号:ctrl+c (^C)、SIGINT(SIGINT中断进程)

http://oss.interviewguide.cn/img/202205212344845.png

为什么有些进程不能通过ctrl c结束

可能是因为进程中写了signal(SIGINT, SIG_IGN),那么这个进程就会忽略SIGINT信号,那么此时可以通过kill -9 pid去结束

那么是否通过signal(SIGKILL, SIG_IGN)忽略SIGKILL信号呢

不可以,linux规定SIGKILL和SIGSTOP不能被忽略

中断、硬中断、软中断、异常的区别

现代处理器的中断概念变得越来越广泛,已经不仅仅局限于外部设备中断。 中断存在的意义是CPU控制外部设备的同时,外部设备也可以高效的“控制CPU”。发展至今,这种设计思想扩展到了外部硬件控制CPU、软件控制CPU、CPU运行管理等三个方面,分别对应硬中断软中断异常

中断

  • 硬中断:硬中断是由硬件设备发出的中断信号,用于通知CPU有紧急的硬件事件需要处理。例如,键盘输入、鼠标点击、硬盘读写完成等。硬中断是可屏蔽的
  • 软中断:软中断是由软件程序发出的中断信号,用于处理系统调用或特定的任务,如系统调用、任务调度等。软中断不可屏蔽(由操作系统内核触发(中断指令)和处理,异步执行,可能在稍后的时间处理任务,处理硬中断处理程序没有完成的任务)

异常

CPU异常发生在各种错误的情况下,如当访问无效的内存地址或除零时,为了作出反应,产生了异常处理机制。核心思想是CPU工作过程遇到了不被允许的错误或者强制停止指令等。(由CPU内部检测到的错误或特殊条件触发,同步执行,当异常发生时,立即中断当前指令的执行并进入异常处理程序。)

可以将异常做如下分类。

陷阱(Trap):通常由用户程序显式触发(如系统调用)

故障(Fault):发生时,CPU能够恢复到引发异常的指令(如页面错误)

终止(Abort):通常由严重错误触发,无法恢复(除0错误,非法使用特权指令)

中断为什么要分上半部和下半部

中断请求的处理程序应该要短且快,这样才能减少对正常进程运行调度地影响,而且中断处理程序可能会暂时关闭中断,这时如果中断处理程序执行时间过长,可能在还未执行完中断处理程序前,会丢失当前其他设备的中断请求

那 Linux 系统为了解决中断处理程序执行过长和中断丢失的问题,将中断过程分成了两个阶段,分别是「上半部和下半部分」

  • 上半部用来快速处理中断,一般会暂时关闭中断请求,主要负责处理跟硬件紧密相关或者时间敏感的事情。
  • 下半部用来延迟处理上半部未完成的工作,一般以「内核线程」的方式运行。

下半部处理过程中可以被中断,上半部处理时不可被中断。

中断下半部的三种实现

软中断(Softirq)

任务队列(Tasklet)

工作队列(Workqueue)

中断怎么发生,中断处理大概流程

中断的发生

中断的发生通常涉及以下步骤:

  1. 外部中断产生:硬件设备(如键盘、鼠标、网络适配器、定时器等)检测到特定事件(如按键按下、数据包到达、定时器超时等)。

  2. 发出中断信号

    硬件设备发送中断信号到中断控制器

  3. 通知处理器产生中断的中断号:中断控制器根据中断请求的优先级,将中断信号发送给CPU,并提供一个中断向量(Interrupt Vector),该向量是一个唯一标识中断源的整数。

  4. CPU响应中断:CPU在每个指令周期结束时检查是否有中断请求。如果有中断请求且中断使能(即中断允许),CPU暂停当前指令的执行。

中断处理的流程

关中断:CPU 响应中断后,首先要保护程序的现场状态,在保护现场的过程中,CPU 不应响应更高级中断源的中断请求。否则,若现场保存不完整,在中断服务程序结束后,也就不能正确地恢复并继续执行现行程序。

保存断点:为保证中断服务程序执行完毕后能正确地返回到原来的程序,必须将原来的程序的断点(即程序计数器PC)保存起来。

中断服务程序寻址:根据中断向量(同时也可确定中断源),通过中断向量表,找到中断处理程序的入口地址,然后中断服务程序的入口地址送入程序计数器PC。

保存现场和屏蔽字:进入中断服务程序后,首先要保存现场,现场信息一般是指程序状态字寄存器PSW和某些通用寄存器的内容。

开中断:允许更高级中断请求得到响应。实现中断嵌套

执行中断服务程序:这是中断系统的核心。不同的中断请求会有不同的中断服务程序。(执行中断上半部->执行中断下半部)

关中断:保证在恢复现场和屏蔽字时不被打断。

恢复现场和屏蔽字:将现场和屏蔽字恢复到原来的状态。

开中断中断返回:中断服务程序的最后一条指令通常是一条中断返回指令,使其返回到原程序的断点处,以便继续执行任务。

CAS

CAS(Compare-And-Swap 或 Compare-And-Set)是一种在多线程环境中使用的原子操作,广泛用于实现无锁编程(lock-free programming)。这种操作通过处理器直接支持,确保在读取、修改、更新变量时的原子性,这意味着在整个操作过程中,不会被其他线程打断。

CAS 操作的基本原理

CAS 操作通常涉及三个参数:

  • 一个内存位置(Memory Location)
  • 一个预期原值(Expected Value)
  • 一个新值(New Value)

在使用上,通常会记录下某块内存中的旧值,通过对旧值进行一系列的操作后得到新值,然后通过CAS操作将新值旧值进行交换。如果这块内存的值在这期间内没被修改过,则旧值会与内存中的数据相同,这时CAS操作将会成功执行 使内存中的数据变为新值。如果内存中的值在这期间内被修改过,则一般来说旧值会与内存中的数据不同,这时CAS操作将会失败,新值将不会被写入内存。

1
2
3
4
5
6
7
8
bool compare_and_swap(int* ptr, int expected, int new_value) {
    if (*ptr == expected) {
        *ptr = new_value;
        return true;  // 操作成功
    } else {
        return false;  // 操作失败
    }
}

如何避免ABA的问题

标记版本号(Version Number)

给变量附加一个版本号,每次变量更新时,版本号也随之递增。CAS操作不仅比较变量的值,还比较版本号,如果版本号不同,则CAS失败。

什么是悲观锁、乐观锁

前面提到的互斥锁、自旋锁、读写锁,都是属于悲观锁。

乐观锁一般会使用版本号机制(顺序锁)或CAS算法实现。

悲观锁做事比较悲观,它认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁

那相反的,如果多线程同时修改共享资源的概率比较低,就可以采用乐观锁。

乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作

放弃后如何重试,这跟业务场景息息相关,虽然重试的成本很高,但是冲突的概率足够低的话,还是可以接受的。

可见,乐观锁的心态是,不管三七二十一,先改了资源再说。另外,你会发现乐观锁全程并没有加锁,所以它也叫无锁编程

这里举一个场景例子:在线文档。

我们都知道在线文档可以同时多人编辑的,如果使用了悲观锁,那么只要有一个用户正在编辑文档,此时其他用户就无法打开相同的文档了,这用户体验当然不好了。

那实现多人同时编辑,实际上是用了乐观锁,它允许多个用户打开同一个文档进行编辑,编辑完提交之后才验证修改的内容是否有冲突。

怎么样才算发生冲突?这里举个例子,比如用户 A 先在浏览器编辑文档,之后用户 B 在浏览器也打开了相同的文档进行编辑,但是用户 B 比用户 A 提交早,这一过程用户 A 是不知道的,当 A 提交修改完的内容时,那么 A 和 B 之间并行修改的地方就会发生冲突。

服务端要怎么验证是否冲突了呢?通常方案如下:

  • 由于发生冲突的概率比较低,所以先让用户编辑文档,但是浏览器在下载文档时会记录下服务端返回的文档版本号;
  • 当用户提交修改时,发给服务端的请求会带上原始文档版本号,服务器收到后将它与当前版本号进行比较,如果版本号不一致则提交失败,如果版本号一致则修改成功,然后服务端版本号更新到最新的版本号。

实际上,我们常见的 SVN 和 Git 也是用了乐观锁的思想,先让用户编辑代码,然后提交的时候,通过版本号来判断是否产生了冲突,发生了冲突的地方,需要我们自己修改后,再重新提交。

乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。

进程之间私有和共享的资源

  • 私有:虚拟内存空间、寄存器、内核堆栈【操作系统为每个进程在内核模式下执行时使用的堆栈】

线程之间私有和共享的资源

  • 私有:线程栈,寄存器、线程ID、错误返回码errno
  • 共享:地址空间、文件

进程栈和线程栈的区别

线程栈默认大小为8M, 通过mmap开辟线程栈,线程栈在进程的堆区,不能动态增长

进程栈是用户态的栈,在堆区的上方,进程栈的实时大小并不是固定的,Linux 内核会根据入栈情况对栈区进行动态增长(其实也就是添加新的页表)。但是并不是说栈区可以无限增长,它也有最大限制上限ulimit -s(默认8192 8MB )

进程、线程、协程切换的区别

进程切换

**进程的上下文切换不仅包含了虚拟地址空间,还包括了内核堆栈、寄存器等内核空间的资源。**通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行。

地址空间切换和处理器状态切换(硬件上下文切换)

地址空间切换:pgd中保存的是进程的页全局目录的虚拟地址,进程的pgd虚拟地址转化为物理地址存放在ttbr0_el1(arm)/ CR3(x86)中,这是用户空间的页表基址寄存器,当访问用户空间地址的时候mmu会通过这个寄存器来做遍历页表获得物理地址。地址空间切换过程中,还会清空tlb。

处理器状态切换:寄存器的保存和切换,包括通用寄存器、pc、sp(内核栈的sp)寄存器

当从一个进程切换到另一个进程时,内核也需要切换到新进程的内核栈,因为每个进程都有自己的内核栈。内核栈保存了进程在内核态下的函数调用、局部变量等。

线程切换

线程栈、寄存器,用户切换到内核态

协程切换

少量寄存器,如程序计数器和栈指针,用户空间

什么是中断上下文?

中断上下文(Interrupt Context)

跟进程上下文不同,中断上下文切换并不涉及到进程的用户态。所以即便中断过程打断了一个正在用户态的进程,也不需要保存和恢复这个进程的虚拟内存、全局变量等用户态资源。中断上下文其实只包括内核态中断服务程序执行所必需的状态,包括 CPU 寄存器、内核堆栈、硬件中断参数等。

什么时候用进程,什么时候用线程

  • 使用进程的场景

    1. 独立运行的任务
      • 进程拥有独立的地址空间,不会与其他进程共享内存。这使得进程适用于需要独立运行、互不干扰的任务。
    2. 高可靠性要求
      • 由于进程之间的隔离,即使一个进程崩溃,其他进程仍然可以继续运行。这对于需要高可靠性的应用程序很重要。
    3. 安全性
      • 进程之间的隔离也提供了更高的安全性,因为一个进程无法直接访问另一个进程的内存空间。这适用于需要高安全性的应用场景。

    使用线程的场景

    1. 高性能需求
      • 线程之间共享相同的内存空间,线程间的通信和数据共享比进程间的通信(如通过管道、套接字等)更高效。这适用于需要高性能和快速数据交换的应用场景。
    2. 轻量级任务
      • 创建和销毁线程的开销通常比进程小,线程的上下文切换也比进程快。这适用于需要处理==大量轻量级并发任务的场景==。
    3. 实时性要求
      • 对于需要快速响应的实时应用程序,线程的使用可以减少延迟。
    4. 多任务并行处理
      • 线程非常适合处理需要并行处理的任务,例如并行计算、多 I/O 操作等。
  • 进程:适用于独立运行、高可靠性、安全性。

    线程:适用于高性能需求、轻量级任务、实时性要求、多任务并行处理的场景。

一个进程最多可以创建多少个线程,和什么有关

这个问题跟两个东西有关系:

  • 进程的虚拟内存空间上限,因为创建一个线程,操作系统需要为其分配一个栈空间,如果线程数量越多,所需的栈空间就要越大,那么虚拟内存就会占用的越多。
  • 系统参数限制,虽然 Linux 并没有内核参数来控制单个进程创建的最大线程个数,但是有系统级别的参数来控制整个系统的最大线程个数。

我们先看看,在进程里创建一个线程需要消耗多少虚拟内存大小?

我们可以执行 ulimit -a 这条命令,查看进程创建线程时默认分配的栈空间大小,比如我这台服务器默认分配给线程的栈空间大小为 8M。

在前面我们知道,在 32 位 Linux 系统里,一个进程的虚拟空间是 4G,内核分走了1G,留给用户用的只有 3G

那么假设创建一个线程需要占用 10M 虚拟内存,总共有 3G 虚拟内存可以使用。于是我们可以算出,最多可以创建差不多 300 个(3G/10M)左右的线程。

说完 32 位系统的情况,我们来看看 64 位系统里,一个进程能创建多少线程呢?

我的测试服务器的配置:

  • 64 位系统;
  • 2G 物理内存;
  • 单核 CPU。

64 位系统意味着用户空间的虚拟内存最大值是 128T,这个数值是很大的,如果按创建一个线程需占用 10M 栈空间的情况来算,那么理论上可以创建 128T/10M 个线程,也就是 1000多万个线程,有点魔幻!

所以按 64 位系统的虚拟内存大小,理论上可以创建无数个线程。

事实上,肯定创建不了那么多线程,除了虚拟内存的限制,还有系统的限制。

比如下面这三个内核参数的大小,都会影响创建线程的上限:

  • /proc/sys/kernel/threads-max,表示系统支持的最大线程数,默认值是 14553
  • /proc/sys/kernel/pid_max,表示系统全局的 PID 号数值的限制,每一个进程或线程都有 ID,ID 的值超过这个数,进程或线程就会创建失败,默认值是 32768
  • /proc/sys/vm/max_map_count,表示限制一个进程可以拥有的VMA(虚拟内存区域)的数量,具体什么意思我也没搞清楚,反正如果它的值很小,也会导致创建线程失败,默认值是 65530

总结

  • 32 位系统,用户态的虚拟空间只有 3G,如果创建线程时分配的栈空间是 10M,那么一个进程最多只能创建 300 个左右的线程。
  • 64 位系统,用户态的虚拟空间大到有 128T,理论上不会受虚拟内存大小的限制,而会受系统的参数或性能限制

线程崩溃了,进程也会崩溃吗?

一般来说如果线程是因为非法访问内存引起的崩溃,那么进程肯定会崩溃,为什么系统要让进程崩溃呢,这主要是因为在进程中,各个线程的地址空间是共享的,既然是共享,那么某个线程对地址的非法访问就会导致内存的不确定性,进而可能会影响到其他线程,这种操作是危险的,操作系统会认为这很可能导致一系列严重的后果,于是干脆让整个进程崩溃

进程崩溃的本质是:操作系统对进程发出了信号,例如非法访问内存的信号是 SIGSEGV(序号 11)

想要防止进程奔溃,需要自定义信号处理函数去拦截 SIGSEGV 信号。参考 JVM 中线程崩溃但 JVM 进程不会崩溃

什么是线程同步和互斥

线程同步(Thread Synchronization)

线程同步是指在多个线程之间协调执行顺序,以确保程序按预期运行。同步的目的是避免竞态条件(Race Condition),即多个线程同时访问和修改共享资源导致的数据不一致问题。

常见的同步机制:

  1. 互斥锁(Mutex)

    • 互斥锁是用于保护共享资源的一种锁机制,确保同一时间只有一个线程可以访问共享资源。
    • 当一个线程获得互斥锁后,其他线程必须等待,直到锁被释放。
    • 互斥锁常用于保护临界区(Critical Section)。
  2. 自旋锁(Spinlock)

    自旋锁是另一种互斥机制,在等待锁释放时,不会进入睡眠,而是不断轮询锁的状态。

    • 适用于临界区短时间锁定的场景。
    • 可能导致忙等(Busy Waiting),消耗CPU时间。
  3. 信号量(Semaphore) PV操作

    • 信号量是一种更高级的同步机制,可以用来控制对资源的访问,允许多个线程同时访问一定数量的资源。
    • 信号量的值表示可以访问资源的线程数,当信号量值为0时,线程必须等待。
  4. 条件变量(Condition Variable)wait和signal操作

    • 条件变量用于线程间的通知机制,允许线程在等待某个条件满足时释放锁,并进入阻塞状态。
    • 当条件满足时,另一个线程可以通知等待线程,重新获得锁并继续执行。
  5. 读写锁(Read-Write Lock)

    • 读写锁允许多个线程同时读取共享资源,但在写入共享资源时,只允许一个线程进行写入,其他线程必须等待。
    • 读写锁提高了读操作的并发性,但仍然保证写操作的独占性。
  6. 原子操作(Atomic Operations)

    原子操作用于在多线程环境中对共享变量进行无锁操作,确保操作的原子性。

    • 提供原子性操作,避免竞态条件。
    • 适用于简单的计数器等场景。
  7. 读-拷贝修改(RCU,Read-Copy Update) 读多写少,允许读操作无锁进行,通过保持数据的多个版本来实现。当需要修改数据时,会先创建数据的一个副本,修改完成后再将指针切换到新的数据版本。这种方式最小化了读操作的延迟。

  8. **顺序锁(seqlock)**读多写少,写者优先级高,写者可以随时获得锁,写者与写者互斥,读者在读前和读后对比序列号

互斥(Mutual Exclusion)

互斥是指确保同一时间只有一个线程能够访问共享资源。互斥的主要目的是保护临界区,防止多个线程同时进入临界区进行并发访问,导致数据不一致或竞争条件。

互斥实现机制:

  1. 互斥锁(Mutex)
    • 互斥锁是最常用的互斥机制,确保同一时间只有一个线程可以进入临界区。
    • 互斥锁提供了 lockunlock 操作,用于获取和释放锁。
  2. 自旋锁(Spinlock)
    • 自旋锁是另一种互斥机制,线程在等待锁时不会进入睡眠状态,而是不断检查锁是否可用,适用于临界区短时间锁定的场景。

线程同步与阻塞的关系?同步一定阻塞吗?阻塞一定同步吗?

线程同步

线程同步是指协调多个线程的执行,以确保它们正确地访问共享资源,防止竞态条件和数据不一致。

阻塞

阻塞是指一个线程在等待某个条件满足或某个事件发生时,停止执行并释放CPU资源,直到条件满足或事件发生后继续执行。阻塞的主要目的是避免忙等待,节省CPU资源。

线程同步与阻塞的关系

线程同步和阻塞之间有一定的关系,但它们并不是完全等价的:

  1. 同步一定阻塞吗?
    • 不一定。线程同步的目的是协调线程对共享资源的访问,确保数据一致性。同步通常会导致某些线程阻塞(例如,等待互斥锁或信号量),但这并不是绝对的。某些同步机制(例如自旋锁)在某些实现中可能不会导致线程阻塞,而是忙等待。
  2. 阻塞一定同步吗?
    • 不一定。阻塞是指线程等待某个条件或事件发生,但这种等待不一定是为了同步。例如,线程可以因为等待I/O操作(如文件读写、网络通信)而阻塞,而这种阻塞并不涉及对共享资源的同步。

死锁

什么是死锁?

两个或多个进程(或线程)在执行过程中由于竞争资源而陷入一种相互等待的状态,导致它们都无法继续执行

原因

  • 系统资源不足
  • 资源分配不当
  • 进程运行推进顺序不合适

产生条件

  • 互斥
  • 请求并保持
  • 不可剥夺
  • 循环等待

处理方法

  • 鸵鸟策略
  • 死锁检测与死锁恢复
  • 死锁预防
  • 死锁避免

鸵鸟策略

把头埋在沙子里,假装根本没发生问题

因为解决死锁问题的代价很高,因此鸵鸟策略这种不采取任务措施的方案会获得更高的性能。

当发生死锁时不会对用户造成多大影响,或发生死锁的概率很低,可以采用鸵鸟策略。

大多数操作系统,包括 Unix,Linux 和 Windows,处理死锁问题的办法仅仅是忽略它。

死锁检测与死锁恢复

不试图阻止死锁,而是当检测到死锁发生时,采取措施进行恢复。

恢复

  • 利用抢占恢复
  • 利用回滚恢复
  • 通过杀死进程恢复

预防

  • 打破互斥条件:改造独占性资源为虚拟资源,大部分资源已无法改造。
  • 打破不可抢占条件:当一进程占有一独占性资源后又申请一独占性资源而无法满足,则退出原占有的资源。
  • 打破占有且申请条件:采用资源预先分配策略,即进程运行前申请全部资源,满足则运行,不然就等待,这样就不会占有且申请。
  • 打破循环等待条件:实现资源有序分配策略,对所有设备实现分类编号,所有进程只能采用按序号递增的形式申请资源。

死锁避免

  • 银行家算法:银行家算法是一种用于避免死锁的动态资源分配算法。它通过模拟试探性分配资源来判断是否会导致系统进入不安全状态,从而决定是否分配资源。银行家算法主要依靠以下几个步骤:
    • 检查资源请求是否满足最大需求: 确保进程请求的资源不超过其声明的最大需求。
    • 检查资源可用性: 确保当前系统中有足够的可用资源满足进程的请求。
    • 试探性分配资源: 暂时分配资源给进程,模拟其执行过程。
    • 安全性检查: 使用安全性算法检查分配后的状态是否安全。如果系统状态仍然安全,则正式分配资源;否则,拒绝请求。

死锁的场景

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <iostream>
#include <thread>
#include <mutex>
 
std::mutex mtxA;
std::mutex mtxB;
 
void threadT1()
{
    	std::unique_lock<std::mutex> lockA(mtxA);
	std::cout << "threasT1 got mtxA" << std::endl;
	// 线程1睡眠2s再获取锁B,保证锁B先被线程2获取,模拟死锁问题的发生
	std::this_thread::sleep_for(std::chrono::seconds(2));
 
	std::cout << "threasT1 try to get mtxB" << std::endl;
	std::unique_lock<std::mutex> lockB(mtxB);
	std::cout << "threasT1 got mtxB" << std::endl;
	std::cout << "threasT1 quit" << std::endl;	
}
 
 
void threadT2()
{
	std::unique_lock<std::mutex> lockB(mtxB);
	std::cout << "threasT2 got mtxB" << std::endl;
	// 线程2睡眠2s再获取锁A,保证锁A先被线程1获取,模拟死锁问题的发生
	std::this_thread::sleep_for(std::chrono::seconds(2));
 
	std::cout << "threasT2 try to get mtxA" << std::endl;
	std::unique_lock<std::mutex> lockA(mtxA);
	std::cout << "threasT2 got mtxA" << std::endl;
	std::cout << "threasT2 quit" << std::endl;
}
 
int main()
{
	std::thread t1(threadT1);
	std::thread t2(threadT2);
	// main主线程等待所有子线程执行完
	t1.join();
	t2.join();
	std::cout << "threasT1 threasT2 all quit" << std::endl;
	system("pause");
	return 0;
}

已经死锁怎么解决,A持有lock1,尝试获取lock2, B持有lock2,尝试获取lock1.

  • 利用抢占恢复
  • 利用回滚恢复
  • 通过杀死进程恢复

进程调度、页面置换

进程调度算法

先来先服务 First Come First Severd, FCFS

短作业优先算法 Shortest Job First SJF

高响应比优先 Highest Response Ratio Next, HRRN

优先权 = 等待时间 + 服务时间 / 服务时间

时间片轮转

最高优先级

多级反馈队列

多级反馈队列(*Multilevel Feedback Queue*)调度算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。

顾名思义:

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

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405161722666.png

内存页面置换算法

最佳页面置换算法

置换未来最长时间不被访问的页面,需要往后看,无法实现

FIFO

LRU

时钟页面置换

那有没有一种即能优化置换的次数,也能方便实现的算法呢?

时钟页面置换算法就可以两者兼得,它跟 LRU 近似,又是对 FIFO 的一种改进。

该算法的思路是,把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面。

当发生缺页中断时,算法首先检查表针指向的页面:

  • 如果它的访问位位是 0 就淘汰该页面,并把新的页面插入这个位置,然后把表针前移一个位置;
  • 如果访问位是 1 就清除访问位,并把表针前移一个位置,重复这个过程直到找到了一个访问位为 0 的页面为止;

我画了一副时钟页面置换算法的工作流程图,你可以在下方看到:

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405161726034.png

最不常用算法LFU

最不常用(LFU)算法,这名字听起来很调皮,但是它的意思不是指这个算法不常用,而是当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰

动态分区分配算法有哪几种?可以分别说说吗?

1、首次适应算法

算法思想:每次都从低地址开始查找,找到第–个能满足大小的空闲分区。

如何实现:空闲分区以地址递增的次序排列。每次分配内存时顺序查找空闲分区链( 或空闲分[表),找到大小能满足要求的第-一个空闲分区。

2、最佳适应算法

算法思想:由于动态分区分配是一种连续分配方式,为各进程分配的空间必须是连续的一整片区域。因此为了保证当“大进程”到来时能有连续的大片空间,可以尽可能多地留下大片的空闲区,即,优先使用更小的空闲区。

如何实现:空闲分区按容量递增次序链接。每次分配内存时顺序查找空闲分区链(或空闲分区表),找到大小能满足要求的第-一个空闲分区。

3、最坏适应算法

又称最大适应算法(Largest Fit)

算法思想:为了解决最佳适应算法的问题—即留下太多难以利用的小碎片,可以在每次分配时优先使用最大的连续空闲区,这样分配后剩余的空闲区就不会太小,更方便使用。

如何实现:空闲分区按容量递减次序链接。每次分配内存时顺序查找空闲分区链(或空闲分区表),找到大小能满足要求的第-一个空闲分区。

4、邻近适应算法

算法思想:首次适应算法每次都从链头开始查找的。这可能会导致低地址部分出现很多小的空闲分区,而每次分配查找时,都要经过这些分区,因此也增加了查找的开销。如果每次都从上次查找结束的位置开始检索,就能解决上述问题。

如何实现:空闲分区以地址递增的顺序排列(可排成-一个循环链表)。每次分配内存时从上次查找结束的位置开始查找空闲分区链(或空闲分区表),找到大小能满足要求的第一

文件系统

文件系统的基本组成

Linux 文件系统会为每个文件分配两个数据结构:索引节点(*index node*)和目录项(*directory entry*),它们主要用来记录文件的元信息和目录层次结构。

  • 索引节点,也就是 inode,用来记录文件的元信息,比如 inode 编号、文件大小、访问权限、创建时间、修改时间、数据在磁盘的位置等等。索引节点是文件的唯一标识,它们之间一一对应,也同样都会被存储在硬盘中,所以索引节点同样占用磁盘空间
  • 目录项,也就是 dentry,用来记录文件的名字、索引节点指针以及与其他目录项的层级关联关系。多个目录项关联起来,就会形成目录结构,但它与索引节点不同的是,目录项是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存

由于索引节点唯一标识一个文件,而目录项记录着文件的名字,所以目录项和索引节点的关系是多对一,也就是说,一个文件可以有多个别名。比如,硬链接的实现就是多个目录项中的索引节点指向同一个文件。

注意,目录也是文件,也是用索引节点唯一标识,和普通文件不同的是,普通文件在磁盘里面保存的是文件数据,而目录文件在磁盘里面保存子目录或文件。

linux文件系统,给一个路径,怎么拿到数据,讲述全流程

在Linux操作系统中,从给定的文件路径获取数据的过程涉及多个层次的操作系统组件,包括用户空间应用程序、系统调用接口、内核的VFS(虚拟文件系统)、具体的文件系统以及物理硬件。这个过程可以分为几个主要步骤,下面将详细讲述这一过程:

1. 应用层请求

用户通过应用程序发起对文件的访问请求。这通常涉及使用像open(), read(), write()这样的系统调用。例如,一个应用程序可能会调用open("/path/to/file.txt", O_RDONLY)来请求读取一个文件。

2. 系统调用

应用程序的请求通过系统调用传达给操作系统的内核。系统调用为应用程序提供了一个接口来执行文件操作,如打开文件、读取文件等。

3. 虚拟文件系统(VFS)

  • 路径解析:内核接收到文件路径后,通过VFS开始解析路径。这个过程称为路径名解析,它从根目录(/)开始,逐级解析路径中的每一个组件(目录或文件名)。每一个目录项都需要查找相应的inode。
  • 检查权限:在路径解析的每一步,VFS都会检查当前进程是否有权限访问目标目录或文件。

4. 文件系统操作

  • 定位inode:一旦路径被完全解析,VFS使用最后一个路径组件(在这个例子中是"file.txt")来获取文件的inode。inode包含了文件的所有元数据,包括文件的类型、权限、大小、以及指向实际数据块的指针。
  • 打开文件:如果请求是打开文件,内核会在内部创建一个文件描述符,并与该inode关联。文件描述符是一个整数值,应用程序用它来引用打开的文件

5. 页缓存(Page Cache)

  • 数据读取:如果进行读操作,内核会检查所需数据是否已在页缓存中。如果在,就直接从缓存中读取数据,提高读取速度。
  • 磁盘访问:如果数据不在缓存中,内核将指令发送到硬盘,读取数据块到页缓存中,然后再将数据传递给应用程序。

6. 数据传输

  • 用户空间和内核空间的数据交换:数据从页缓存复制到用户空间缓冲区,这通常在系统调用read()中完成。

7. 系统调用返回

  • 完成操作:系统调用完成后,控制权和请求的数据返回给应用程序。如果有错误发生(如文件不存在或权限不足),相应的错误代码会被返回。

8. 关闭文件

  • 释放资源:当应用程序完成文件操作后,它会调用close()系统调用来关闭文件。这会释放文件描述符和相关的内核资源。

多个进程同时对一个文件描述符进行写操作,会出问题吗

1. 写操作的原子性:

  • 在POSIX标准中,通常情况下,如果写入的数据量小于等于文件系统的块大小(如4KB),则写操作是原子的。这意味着多个进程的写操作不会互相交错,数据写入是完整的。
  • 如果写入的数据量大于文件系统的块大小,则写操作可能不是原子的。在这种情况下,多个进程同时写入时,数据可能会交错,导致文件内容出现不一致。

2. 文件锁:

  • 为了避免多个进程同时写入引发的竞争条件,通常会使用文件锁(file locking)机制。
  • 进程可以使用flockfcntllockf等系统调用对文件进行加锁。加锁后,只有持有锁的进程可以进行写操作,其他进程会被阻塞或返回错误。
  • 使用锁机制可以确保多个进程对同一文件进行写操作时,数据的完整性和一致性。

什么是零拷贝?

磁盘可以说是计算机系统最慢的硬件之一,读写速度相差内存 10 倍以上,所以针对优化磁盘的技术非常的多,比如零拷贝、直接 I/O、异步 I/O 等等,这些优化的目的就是为了提高系统的吞吐量,另外操作系统内核中的磁盘高速缓存区,可以有效的减少磁盘的访问次数。

这次,我们就以「文件传输」作为切入点,来分析 I/O 工作方式,以及如何优化传输文件的性能。

为什么要有 DMA 技术?

在没有 DMA 技术前,I/O 的过程是这样的:

  • CPU 发出对应的指令给磁盘控制器,然后返回;
  • 磁盘控制器收到指令后,于是就开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断
  • CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器,然后再把寄存器里的数据写入到内存,而在数据传输的期间 CPU 是无法执行其他任务的。

为了方便你理解,我画了一副图:

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405151608294.png

可以看到,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。

简单的搬运几个字符数据那没问题,但是如果我们用千兆网卡或者硬盘传输大量数据的时候,都用 CPU 来搬运的话,肯定忙不过来。

计算机科学家们发现了事情的严重性后,于是就发明了 DMA 技术,也就是直接内存访问(*Direct Memory Access*) 技术。

什么是 DMA 技术?简单理解就是,在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务

那使用 DMA 控制器进行数据传输的过程究竟是什么样的呢?下面我们来具体看看。

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405151612042.png

具体过程:

  • 用户进程调用 read 方法,向操作系统发出 I/O 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态;
  • 操作系统收到请求后,进一步将 I/O 请求发送 DMA,然后让 CPU 执行其他任务;
  • DMA 进一步将 I/O 请求发送给磁盘;
  • 磁盘收到 DMA 的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满;
  • DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务
  • 当 DMA 读取了足够多的数据,就会发送中断信号给 CPU;
  • CPU 收到 DMA 的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回;

可以看到, CPU 不再参与「将数据从磁盘控制器缓冲区搬运到内核空间」的工作,这部分工作全程由 DMA 完成。但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。

早期 DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个 I/O 设备里面都有自己的 DMA 控制器。

传统的文件传输有多糟糕?

如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。

传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。

代码通常如下,一般会需要两个系统调用:

1
2
read(file, tmp_buf, len);
write(socket, tmp_buf, len);

代码很简单,虽然就两行代码,但是这里面发生了不少的事情。

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405151622715.png

首先,期间共发生了 4 次用户态与内核态的上下文切换,因为发生了两次系统调用,一次是 read() ,一次是 write(),每次系统调用都得先从用户态切换到内核态,等内核完成任务后,再从内核态切换回用户态。

上下文切换到成本并不小,一次切换需要耗时几十纳秒到几微秒,虽然时间看上去很短,但是在高并发的场景下,这类时间容易被累积和放大,从而影响系统的性能。

其次,还发生了 4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的,下面说一下这个过程:

  • 第一次拷贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
  • 第二次拷贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
  • 第三次拷贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的。
  • 第四次拷贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由 DMA 搬运的。

我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了 4 次,过多的数据拷贝无疑会消耗 CPU 资源,大大降低了系统性能。

这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。

所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数

如何优化文件传输的性能?

先来看看,如何减少「用户态与内核态的上下文切换」的次数呢?

读取磁盘数据的时候,之所以要发生上下文切换,这是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务的时候,就需要使用操作系统提供的系统调用函数。

而一次系统调用必然会发生 2 次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。

所以,要想减少上下文切换到次数,就要减少系统调用的次数

再来看看,如何减少「数据拷贝」的次数?

在前面我们知道了,传统的文件传输方式会历经 4 次数据拷贝,而且这里面,「从内核的读缓冲区拷贝到用户的缓冲区里,再从用户的缓冲区里拷贝到 socket 的缓冲区里」,这个过程是没有必要的。

因为文件传输的应用场景中,在用户空间我们并不会对数据「再加工」,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的

如何实现零拷贝?

零拷贝技术实现的方式通常有 2 种:

  • mmap + write
  • sendfile

下面就谈一谈,它们是如何减少「上下文切换」和「数据拷贝」的次数。

mmap + write

在前面我们知道,read() 系统调用的过程中会把内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替换 read() 系统调用函数。

1
2
buf = mmap(file, len);
write(sockfd, buf, len);

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405151625377.png

具体过程如下:

  • 应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区;
  • 应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据;
  • 最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的。

我们可以得知,通过使用 mmap() 来代替 read(), 可以减少一次数据拷贝的过程。

但这还不是最理想的零拷贝,因为仍然需要通过 CPU 把内核缓冲区的数据拷贝到 socket 缓冲区里,而且仍然需要 4 次上下文切换,因为系统调用还是 2 次。

sendfile

在 Linux 内核版本 2.1 中,提供了一个专门发送文件的系统调用函数 sendfile(),函数形式如下:

1
2
#include <sys/socket.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

它的前两个参数分别是目的端和源端的文件描述符,后面两个参数是源端的偏移量和复制数据的长度,返回值是实际复制数据的长度。

首先,它可以替代前面的 read()write() 这两个系统调用,这样就可以减少一次系统调用,也就减少了 2 次上下文切换的开销。

其次,该系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再拷贝到用户态,这样就只有 2 次上下文切换,和 3 次数据拷贝。如下图:

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405151627119.png

但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。

你可以在你的 Linux 系统通过下面这个命令,查看网卡是否支持 scatter-gather 特性:

1
2
$ ethtool -k eth0 | grep scatter-gather
scatter-gather: on

于是,从 Linux 内核 2.4 版本开始起,对于支持网卡支持 SG-DMA 技术的情况下, sendfile() 系统调用的过程发生了点变化,具体过程如下:

  • 第一步,通过 DMA 将磁盘上的数据拷贝到内核缓冲区里;
  • 第二步,缓冲区描述符和数据长度传到 socket 缓冲区,这样网卡的 SG-DMA 控制器就可以直接将内核缓存中的数据拷贝到网卡的缓冲区里,此过程不需要将数据从操作系统内核缓冲区拷贝到 socket 缓冲区中,这样就减少了一次数据拷贝;

所以,这个过程之中,只进行了 2 次数据拷贝,如下图:

C:/Users/10503/AppData/Roaming/Typora/typora-user-images/image-20240515162813351.png

这就是所谓的零拷贝(*Zero-copy*)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。

零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数,只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。

所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上

PageCache作用

回顾前面说道文件传输过程,其中第一步都是先需要先把磁盘文件数据拷贝「内核缓冲区」里,这个「内核缓冲区」实际上是磁盘高速缓存(*PageCache*)

由于零拷贝使用了 PageCache 技术,可以使得零拷贝进一步提升了性能,我们接下来看看 PageCache 是如何做到这一点的。

读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘。

但是,内存空间远比磁盘要小,内存注定只能拷贝磁盘里的一小部分数据。

那问题来了,选择哪些磁盘数据拷贝到内存呢?

我们都知道程序运行的时候,具有「局部性」,所以通常,刚被访问的数据在短时间内再次被访问的概率很高,于是我们可以用 PageCache 来缓存最近被访问的数据,当空间不足时淘汰最久未被访问的缓存。

所以,读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。

还有一点,读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响,PageCache 使用了「预读功能」

比如,假设 read 方法每次只会读 32 KB 的字节,虽然 read 刚开始只会读 0 ~ 32 KB 的字节,但内核会把其后面的 32~64 KB 也读取到 PageCache,这样后面读取 32~64 KB 的成本就很低,如果在 32~64 KB 淘汰出 PageCache 前,进程读取到它了,收益就非常大。

所以,PageCache 的优点主要是两个:

  • 缓存最近被访问的数据;
  • 预读功能;

这两个做法,将大大提高读写磁盘的性能。

但是,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能

这是因为如果你有很多 GB 级别文件需要传输,每当用户访问这些大文件的时候,内核就会把它们载入 PageCache 中,于是 PageCache 空间很快被这些大文件占满。

另外,由于文件太大,可能某些部分的文件数据被再次访问的概率比较低,这样就会带来 2 个问题:

  • PageCache 由于长时间被大文件占据,其他「热点」的小文件可能就无法充分使用到 PageCache,于是这样磁盘读写的性能就会下降了;
  • PageCache 中的大文件数据,由于没有享受到缓存带来的好处,但却耗费 DMA 多拷贝到 PageCache 一次;

所以,针对大文件的传输,不应该使用 PageCache,也就是说不应该使用零拷贝技术,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache,这样在高并发的环境下,会带来严重的性能问题。

大文件传输什么方式实现?

那针对大文件的传输,我们应该使用什么方式呢?

我们先来看看最初的例子,当调用 read 方法读取文件时,进程实际上会阻塞在 read 方法调用,因为要等待磁盘数据的返回,如下图:

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405151633358.png

具体过程:

  • 当调用 read 方法时,会阻塞着,此时内核会向磁盘发起 I/O 请求,磁盘收到请求后,便会寻址,当磁盘数据准备好后,就会向内核发起 I/O 中断,告知内核磁盘数据已经准备好;
  • 内核收到 I/O 中断后,就将数据从磁盘控制器缓冲区拷贝到 PageCache 里;
  • 最后,内核再把 PageCache 中的数据拷贝到用户缓冲区,于是 read 调用就正常返回了。

对于阻塞的问题,可以用异步 I/O 来解决,它工作方式如下图:

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405151633719.png

它把读操作分为两部分:

  • 前半部分,内核向磁盘发起读请求,但是可以不等待数据就位就可以返回,于是进程此时可以处理其他任务;
  • 后半部分,当内核将磁盘中的数据拷贝到进程缓冲区后,进程将接收到内核的通知,再去处理数据;

而且,我们可以发现,异步 I/O 并没有涉及到 PageCache,所以使用异步 I/O 就意味着要绕开 PageCache。

绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。通常,对于磁盘,异步 I/O 只支持直接 I/O

前面也提到,大文件的传输不应该使用 PageCache,因为可能由于 PageCache 被大文件占据,而导致「热点」小文件无法利用到 PageCache。

于是,在高并发的场景下,针对大文件的传输的方式,应该使用「异步 I/O + 直接 I/O」来替代零拷贝技术

直接 I/O 应用场景常见的两种:

  • 应用程序已经实现了磁盘数据的缓存,那么可以不需要 PageCache 再次缓存,减少额外的性能损耗。在 MySQL 数据库中,可以通过参数设置开启直接 I/O,默认是不开启;
  • 传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 PageCache 导致「热点」文件无法充分利用缓存,从而增大了性能开销,因此,这时应该使用直接 I/O。

另外,由于直接 I/O 绕过了 PageCache,就无法享受内核的这两点的优化:

  • 内核的 I/O 调度算法会缓存尽可能多的 I/O 请求在 PageCache 中,最后「合并」成一个更大的 I/O 请求再发给磁盘,这样做是为了减少磁盘的寻址操作;
  • 内核也会「预读」后续的 I/O 请求放在 PageCache 中,一样是为了减少对磁盘的操作;

于是,传输大文件的时候,使用「异步 I/O + 直接 I/O」了,就可以无阻塞地读取文件了。

所以,传输文件的时候,我们要根据文件的大小来使用不同的方式:

  • 传输大文件的时候,使用「异步 I/O + 直接 I/O」;
  • 传输小文件的时候,则使用「零拷贝技术」;

I/O多路复用:select/poll/epoll

多进程模型

基于最原始的阻塞网络 I/O, 如果服务器要支持多个客户端,其中比较传统的方式,就是使用多进程模型,也就是为每个客户端分配一个进程来处理请求。

服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept() 函数就会返回一个「已连接 Socket」,这时就通过 fork() 函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。

这两个进程刚复制完的时候,几乎一模一样。不过,会根据返回值来区分是父进程还是子进程,如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程。

正因为子进程会复制父进程的文件描述符,于是就可以直接使用「已连接 Socket 」和客户端通信了,

可以发现,子进程不需要关心「监听 Socket」,只需要关心「已连接 Socket」;父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接 Socket」,只需要关心「监听 Socket」。

下面这张图描述了从连接请求到连接建立,父进程创建生子进程为客户服务。

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405151925381.png

另外,当「子进程」退出时,实际上内核里还会保留该进程的一些信息,也是会占用内存的,如果不做好“回收”工作,就会变成僵尸进程,随着僵尸进程越多,会慢慢耗尽我们的系统资源。

因此,父进程要“善后”好自己的孩子,怎么善后呢?那么有两种方式可以在子进程退出后回收资源,分别是调用 wait()waitpid() 函数。

这种用多个进程来应付多个客户端的方式,在应对 100 个客户端还是可行的,但是当客户端数量高达一万时,肯定扛不住的,因为每产生一个进程,必会占据一定的系统资源,而且进程间上下文切换的“包袱”是很重的,性能会大打折扣。

进程的上下文切换不仅包含了虚拟内存、栈、全局变量等用户空间的资源,还包括了内核堆栈、寄存器等内核空间的资源。

多线程模型

既然进程间上下文切换的“包袱”很重,那我们就搞个比较轻量级的模型来应对多用户的请求 —— 多线程模型

线程是运行在进程中的一个“逻辑流”,单进程中可以运行多个线程,同进程里的线程可以共享进程的部分资源,比如文件描述符列表、进程空间、代码、全局数据、堆、共享库等,这些共享些资源在上下文切换时不需要切换,而只需要切换线程的私有数据、寄存器等不共享的数据,因此同一个进程下的线程上下文切换的开销要比进程小得多。

当服务器与客户端 TCP 完成连接后,通过 pthread_create() 函数创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。

如果每来一个连接就创建一个线程,线程运行完后,还得操作系统还得销毁线程,虽说线程切换的上写文开销不大,但是如果频繁创建和销毁线程,系统开销也是不小的。

那么,我们可以使用线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出「已连接 Socket 」进行处理。

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405151926531.png

需要注意的是,这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁。

上面基于进程或者线程模型的,其实还是有问题的。新到来一个 TCP 连接,就需要分配一个进程或者线程,那么如果要达到 C10K,意味着要一台机器维护 1 万个连接,相当于要维护 1 万个进程/线程,操作系统就算死扛也是扛不住的。

I/O 多路复用

既然为每个请求分配一个进程/线程的方式不合适,那有没有可能只使用一个进程来维护多个 Socket 呢?答案是有的,那就是 I/O 多路复用技术。

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405151932448.png

一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。

我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件

select/poll/epoll 是如何获取网络事件的呢?在获取事件时,先把所有连接(文件描述符)传给内核,再由内核返回产生了事件的连接,然后在用户态中再处理这些连接对应的请求即可。

select/poll/epoll 这是三个多路复用接口,都能实现 C10K 吗?接下来,我们分别说说它们。

select/poll

select 实现多路复用的方式是,将已连接的 Socket 都放到一个文件描述符集合,然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生,检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写, 接着再把整个文件描述符集合拷贝回用户态里,然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

所以,对于 select 这种方式,需要进行 2 次「遍历」文件描述符集合,一次是在内核态里,一个次是在用户态里 ,而且还会发生 2 次「拷贝」文件描述符集合,先从用户空间传入内核空间,由内核修改后,再传出到用户空间中。

select 使用固定长度的 BitsMap,表示文件描述符集合,而且所支持的文件描述符的个数是有限制的,在 Linux 系统中,由内核中的 FD_SETSIZE 限制, 默认最大值为 1024,只能监听 0~1023 的文件描述符。

poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

但是 poll 和 select 并没有太大的本质区别,都是使用「线性结构」存储进程关注的 Socket 集合,因此都需要遍历文件描述符集合来找到可读或可写的 Socket,时间复杂度为 O(n),而且也需要在用户态与内核态之间拷贝文件描述符集合,这种方式随着并发数上来,性能的损耗会呈指数级增长。

epoll

epoll的用法

在epoll中一共提供是三个API函数,分别处理不同的操作,函数原型如下:

1
2
3
4
5
6
7
#include <sys/epoll.h>
// 创建epoll实例,通过一棵红黑树管理待检测集合
int epoll_create(int size);
// 管理红黑树上的文件描述符(添加、修改、删除)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// 检测epoll树中是否有就绪的文件描述符
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

epoll_create()函数的作用是创建一个红黑树模型的实例,用于管理待检测的文件描述符的集合。

1
int epoll_create(int size);
  • 函数参数 size:在Linux内核2.6.8版本以后,这个参数是被忽略的,只需要指定一个大于0的数值就可以了。
  • 函数返回值:
    • 失败:返回-1
    • 成功:返回一个有效的文件描述符,通过这个文件描述符就可以访问创建的epoll实例了

epoll_ctl()函数的作用是管理红黑树实例上的节点,可以进行添加、删除、修改操作。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 联合体, 多个变量共用同一块内存        
typedef union epoll_data {
 	void        *ptr;
	int          fd;	// 通常情况下使用这个成员, 和epoll_ctl的第三个参数相同即可
	uint32_t     u32;
	uint64_t     u64;
} epoll_data_t;

struct epoll_event {
	uint32_t     events;      /* Epoll events */
	epoll_data_t data;        /* User data variable */
};
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

函数参数:

  • epfd:epoll_create() 函数的返回值,通过这个参数找到epoll实例
  • op:这是一个枚举值,控制通过该函数执行什么操作
    • EPOLL_CTL_ADD:往epoll模型中添加新的节点
    • EPOLL_CTL_MOD:修改epoll模型中已经存在的节点
    • EPOLL_CTL_DEL:删除epoll模型中的指定的节点
  • fd:文件描述符,即要添加/修改/删除的文件描述符
  • event:epoll事件,用来修饰第三个参数对应的文件描述符的,指定检测这个文件描述符的什么事件
    • events:委托epoll检测的事件
      • EPOLLIN:读事件, 接收数据, 检测读缓冲区,如果有数据该文件描述符就绪
      • EPOLLOUT:写事件, 发送数据, 检测写缓冲区,如果可写该文件描述符就绪
      • EPOLLERR:异常事件
    • data:用户数据变量,这是一个联合体类型,通常情况下使用里边的fd成员,用于存储待检测的文件描述符的值,在调用epoll_wait()函数的时候这个值会被传出。
  • 函数返回值:
    • 失败:返回-1
    • 成功:返回0

epoll_wait()函数的作用是检测创建的epoll实例中有没有就绪的文件描述符。

1
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
  • 函数参数:

  • epfd:epoll_create() 函数的返回值, 通过这个参数找到epoll实例

  • events:传出参数, 这是一个结构体数组的地址, 里边存储了已就绪的文件描述符的信息

  • maxevents:修饰第二个参数, 结构体数组的容量(元素个数)

  • timeout:如果检测的epoll实例中没有已就绪的文件描述符,该函数阻塞的时长, 单位ms 毫秒

    • 0:函数不阻塞,不管epoll实例中有没有就绪的文件描述符,函数被调用后都直接返回
    • 大于0:如果epoll实例中没有已就绪的文件描述符,函数阻塞对应的毫秒数再返回
    • -1:函数一直阻塞,直到epoll实例中有已就绪的文件描述符之后才解除阻塞
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
#include <stdio.h>
#include <ctype.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <string.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <sys/epoll.h>

// server
int main(int argc, const char* argv[])
{
    // 创建监听的套接字
    int lfd = socket(AF_INET, SOCK_STREAM, 0);
    if(lfd == -1)
    {
        perror("socket error");
        exit(1);
    }

    // 绑定
    struct sockaddr_in serv_addr;
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(9999);
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);  // 本地多有的IP
    
    // 设置端口复用
    int opt = 1;
    setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    // 绑定端口
    int ret = bind(lfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
    if(ret == -1)
    {
        perror("bind error");
        exit(1);
    }

    // 监听
    ret = listen(lfd, 64);
    if(ret == -1)
    {
        perror("listen error");
        exit(1);
    }

    // 现在只有监听的文件描述符
    // 所有的文件描述符对应读写缓冲区状态都是委托内核进行检测的epoll
    // 创建一个epoll模型
    int epfd = epoll_create(100);
    if(epfd == -1)
    {
        perror("epoll_create");
        exit(0);
    }

    // 往epoll实例中添加需要检测的节点, 现在只有监听的文件描述符
    struct epoll_event ev;
    ev.events = EPOLLIN;    // 检测lfd读读缓冲区是否有数据
    ev.data.fd = lfd;
    ret = epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
    if(ret == -1)
    {
        perror("epoll_ctl");
        exit(0);
    }

    struct epoll_event evs[1024];
    int size = sizeof(evs) / sizeof(struct epoll_event);
    // 持续检测
    while(1)
    {
        // 调用一次, 检测一次
        int num = epoll_wait(epfd, evs, size, -1);
        for(int i=0; i<num; ++i)
        {
            // 取出当前的文件描述符
            int curfd = evs[i].data.fd;
            // 判断这个文件描述符是不是用于监听的
            if(curfd == lfd)
            {
                // 建立新的连接
                int cfd = accept(curfd, NULL, NULL);
                // 新得到的文件描述符添加到epoll模型中, 下一轮循环的时候就可以被检测了
                ev.events = EPOLLIN;    // 读缓冲区是否有数据
                ev.data.fd = cfd;
                ret = epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
                if(ret == -1)
                {
                    perror("epoll_ctl-accept");
                    exit(0);
                }
            }
            else
            {
                // 处理通信的文件描述符
                // 接收数据
                char buf[1024];
                memset(buf, 0, sizeof(buf));
                int len = recv(curfd, buf, sizeof(buf), 0);
                if(len == 0)
                {
                    printf("客户端已经断开了连接\n");
                    // 将这个文件描述符从epoll模型中删除
                    epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL);
                    close(curfd);
                }
                else if(len > 0)
                {
                    printf("客户端say: %s\n", buf);
                    send(curfd, buf, len, 0);
                }
                else
                {
                    perror("recv");
                    exit(0);
                } 
            }
        }
    }

    return 0;
}

epoll 通过两个方面,很好解决了 select/poll 的问题。

第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。而 select/poll 内核里没有类似 epoll 红黑树这种保存所有待检测的 socket 的数据结构,所以 select/poll 每次操作时都传入整个 socket 集合给内核,而 epoll 因为在内核维护了红黑树,可以保存所有待检测的 socket ,所以只需要传入一个待检测的 socket,减少了内核和用户空间大量的数据拷贝和内存分配。

第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

从下图你可以看到 epoll 相关的接口作用:

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405151948459.png

epoll 的方式即使监听的 Socket 数量越多的时候,效率不会大幅度降低,能够同时监听的 Socket 的数目也非常的多了,上限就为系统定义的进程打开的最大文件描述符个数。因而,epoll 被称为解决 C10K 问题的利器

插个题外话,网上文章不少说,epoll_wait 返回时,对于就绪的事件,epoll 使用的是共享内存的方式,即用户态和内核态都指向了就绪链表,所以就避免了内存拷贝消耗。

这是错的!看过 epoll 内核源码的都知道,压根就没有使用共享内存这个玩意。你可以从下面这份代码看到, epoll_wait 实现的内核代码中调用了 __put_user 函数,这个函数就是将数据从内核拷贝到用户空间。

epoll为什么用红黑树,不用哈希表,在内核是如何感知消息的?

epoll 使用红黑树而不是哈希表主要有以下几个原因:

  1. 时间复杂度:红黑树的插入、删除和查找操作的时间复杂度都是 $O(log N)$。虽然哈希表的查找和插入操作的平均时间复杂度是 $O(1)$,但是在最坏情况下,哈希表的时间复杂度是 $O(N)$。对于内核这种需要高可靠性和稳定性能的环境,红黑树提供了更好的性能保障。
  2. 有序性:红黑树是一种自平衡二叉查找树,可以保证元素有序。虽然 epoll 本身不需要有序性,但在一些内核实现中,红黑树的这种特性有助于更高效地管理文件描述符。
  3. 内存利用率:红黑树在内存利用率方面比哈希表更优,因为哈希表需要处理哈希冲突,可能需要额外的空间来存储链表或其他结构,导致内存浪费。而红黑树的结构更为紧凑。

epoll的线程安全?

简要结论就是epoll是通过锁来保证线程安全的,

epoll_ctl:互斥锁ep->mtx用来保护epoll的重要数据结构红黑树,只能有一个对红黑树进行插入、删除、修改

epoll_wait:epoll中粒度最小的自旋锁ep->lock(spinlock)用来保护就绪的队列。

当多个线程等待在 epoll_wait 上,并且有事件发生时,每个线程都有机会调用 epoll_wait 并获取事件。但是,只有其中一个线程的 epoll_wait 返回值会是一个正数,表示成功获取到的事件数量。其他线程的 epoll_wait 返回值可能是 0 或者负数。具体而言,当有事件发生时,内核会唤醒所有等待的线程,但只有一个线程能够成功获取到事件并返回正数的返回值。其他线程在调用 epoll_wait 后可能会因为竞争条件或时间差等原因得到不同的返回值。返回值为 0 表示超时而没有获取到事件,返回值为负数表示发生了错误。

epoll的整个调用过程

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202405292108170.png

用户空间

1.创建 epoll 实例 (epoll_create)

  • 用户程序调用 epoll_create 创建一个 epoll 实例,在内核中分配相关的数据结构。

  • 内核初始化

    1
    
    eventpoll
    

    结构,其中包括:

    • rdllist:就绪队列,用于存储已经触发的事件。
    • rbr:红黑树,用于高效管理文件描述符。
    • wq:等待队列,用于管理等待事件的进程。
  1. 添加 socket (epoll_ctl)
    • 用户程序调用 epoll_ctl,将 socket 添加到 epoll 实例中
    • 内核将 socket 注册到 eventpoll 实例的红黑树中。
  2. 检查就绪队列 (epoll_wait)
    • 用户程序调用 epoll_wait,进入等待状态,等待事件的发生。
    • 如果有事件已经准备好,epoll_wait 会立即返回;否则,进程进入休眠状态,等待内核唤醒。
  3. 主动放弃 CPU (epoll_wait)
    • 用户进程进入休眠状态,等待内核通知有事件发生。

内核空间

  1. 数据到达网卡

    • 当数据到达网卡,网卡通过DMA会将数据包放入其内部缓冲区(ringbuffer),并生成一个硬中断信号通知 CPU 有数据到达。
  2. 硬中断处理程序(ISR)

    ​ CPU触发软中断

  3. 软中断处理程序

    软中断让ksoftirqd负责从ringbuffer取出数据包并将其传递给网络协议栈进行处理:

  4. 网络协议栈处理

    网络协议栈对数据包进行解析和处理,根据协议类型(如 IP、TCP)进行相应的操作:

    1. IP 层处理:如果数据包是 IP 数据包,IP 层会解析 IP 头部信息,进行路由选择,并将数据包传递给上层协议(例如 TCP)。
    2. TCP 层处理:如果数据包是 TCP 数据包,TCP 层会解析 TCP 头部信息,检查序列号,进行数据重组等操作。
  5. 数据包传递到 socket 接收缓冲区

    经过网络协议栈的处理后,数据包最终会被传递到对应的 socket 的接收缓冲区中:

    1. 查找目标 socket:协议栈根据数据包的目的地址和端口号查找到目标 socket。
    2. 放入接收缓冲区:将数据包的数据放入目标 socket 的接收缓冲区(通常是 struct sk_buff 队列)。
  6. 触发回调函数:如果 socket 关注的事件(例如数据可读)已经触发,内核会调用相应的回调函数(如 sk_data_ready),内核会将该 socket 插入到 epoll 实例的就绪队列中(rdllist)。

  7. 检查是否有进程阻塞

    • 内核检查是否有进程在等待这个 epoll 实例上的事件。
    • 如果有,唤醒等待队列中的进程。
  8. 唤醒用户进程,返回事件

    • 被唤醒的用户进程继续执行,epoll_wait 返回已经准备好的事件,用户程序可以对这些事件进行处理。

边缘触发和水平触发

epoll 支持两种事件触发模式,分别是边缘触发(*edge-triggered,ET*)**和**水平触发(*level-triggered,LT*)

这两个术语还挺抽象的,其实它们的区别还是很好理解的。

  • 使用边缘触发模式时,当被监控的 Socket 描述符上有可读事件发生时,服务器端只会从 epoll_wait 中苏醒一次,即使进程没有调用 read 函数从内核读取数据,也依然只苏醒一次,因此我们程序要保证一次性将内核缓冲区的数据读取完;
  • 使用水平触发模式时,当被监控的 Socket 上有可读事件发生时,服务器端不断地从 epoll_wait 中苏醒,直到内核缓冲区数据被 read 函数读完才结束,目的是告诉我们有数据需要读取;

举个例子,你的快递被放到了一个快递箱里,如果快递箱只会通过短信通知你一次,即使你一直没有去取,它也不会再发送第二条短信提醒你,这个方式就是边缘触发;如果快递箱发现你的快递没有被取出,它就会不停地发短信通知你,直到你取出了快递,它才消停,这个就是水平触发的方式。

这就是两者的区别,水平触发的意思是只要满足事件的条件,比如内核中有数据需要读,就一直不断地把这个事件传递给用户;而边缘触发的意思是只有第一次满足条件的时候才触发,之后就不会再传递同样的事件了。

如果使用水平触发模式,当内核通知文件描述符可读写时,接下来还可以继续去检测它的状态,看它是否依然可读或可写。所以在收到通知后,没必要一次执行尽可能多的读写操作。

如果使用边缘触发模式,I/O 事件发生时只会通知一次,而且我们不知道到底能读写多少数据,所以在收到通知后应尽可能地读写数据,以免错失读写的机会。因此,我们会循环从文件描述符读写数据,那么如果文件描述符是阻塞的,当读取完数据后,没有数据可读写时,进程会阻塞在读写函数那里,程序就没办法继续往下执行,也就没有办法去处理其他的socket连接了。所以,边缘触发模式一般和非阻塞 I/O 搭配使用,程序会一直执行 I/O 操作,直到系统调用(如 readwrite)返回错误,错误类型为 EAGAINEWOULDBLOCK

一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。

select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式。

另外,使用 I/O 多路复用时,最好搭配非阻塞 I/O 一起使用。

总结

最基础的 TCP 的 Socket 编程,它是阻塞 I/O 模型,基本上只能一对一通信,那为了服务更多的客户端,我们需要改进网络 I/O 模型。

比较传统的方式是使用多进程/线程模型,每来一个客户端连接,就分配一个进程/线程,然后后续的读写都在对应的进程/线程,这种方式处理 100 个客户端没问题,但是当客户端增大到 10000 个时,10000 个进程/线程的调度、上下文切换以及它们占用的内存,都会成为瓶颈。

为了解决上面这个问题,就出现了 I/O 的多路复用,可以只在一个进程里处理多个文件的 I/O,Linux 下有三种提供 I/O 多路复用的 API,分别是:select、poll、epoll。

select 和 poll 并没有本质区别,它们内部都是使用「线性结构」来存储进程关注的 Socket 集合。

在使用的时候,首先需要把关注的 Socket 集合通过 select/poll 系统调用从用户态拷贝到内核态,然后由内核检测事件,当有网络事件产生时,内核需要遍历进程关注 Socket 集合,找到对应的 Socket,并设置其状态为可读/可写,然后把整个 Socket 集合从内核态拷贝到用户态,用户态还要继续遍历整个 Socket 集合找到可读/可写的 Socket,然后对其处理。

很明显发现,select 和 poll 的缺陷在于,当客户端越多,也就是 Socket 集合越大,Socket 集合的遍历和拷贝会带来很大的开销,因此也很难应对 C10K。

epoll 是解决 C10K 问题的利器,通过两个方面解决了 select/poll 的问题。

  • epoll 在内核里使用「红黑树」来关注进程所有待检测的 Socket,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn),通过对这棵黑红树的管理,不需要像 select/poll 在每次操作时都传入整个 Socket 集合,减少了内核和用户空间大量的数据拷贝和内存分配。
  • epoll 使用事件驱动的机制,内核里维护了一个「链表」来记录就绪事件,只将有事件发生的 Socket 集合传递给应用程序,不需要像 select/poll 那样轮询扫描整个集合(包含有和无事件的 Socket ),大大提高了检测的效率。

而且,epoll 支持边缘触发和水平触发的方式,而 select/poll 只支持水平触发,一般而言,边缘触发的方式会比水平触发的效率高。

I/O模型有哪些

同步阻塞IO

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202408081340526.png

当用户程序的线程调用read获取网络数据的时候,首先这个数据得有,也就是网卡得先收到客户端的数据,然后这个数据在拷贝到内核中,然后在拷贝到用户态中,这整个过程用户线程都是被阻塞的

同步非阻塞IO

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202408081342042.png

线程在没有数据的时候不会在阻塞等待,而是直接返回告知无准备就绪的数据

但是从内核拷贝到用户空间的时候,线程还是会被阻塞的(同步)

这个模型相比同步阻塞IO更加灵活,没有数据可以做别的事,而不是阻塞等待,然后在回来继续调用read查看是否有数据。但是如果服务器有海量的连接,就会有海量的线程不断的进行系统调用

IO多路复用

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202408081346699.png

只用一个线程查看多个连接是否有数据准备就绪,由select监控所管理的连接是否有数据准备就绪,如果有那么可以通知别的线程来read数据,但是read的时候还是会阻塞

信号驱动式IO

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202408081348373.png

上面的select虽然不阻塞,但是得时候去查看是否有数据准备就绪,但是否可以让内核告诉我们数据到了而不是自己去轮询?

信号驱动式IO就可以由内核告知数据已经准备就绪,然后用户线程再去read(还是会阻塞)

但是由于TCP协议的信号有7种,无法区分到底是什么事件产生的信号,基本不用

异步IO

https://jiejiesks.oss-cn-beijing.aliyuncs.com/Note/202408081351239.png

让内核直接把数据拷贝到用户空间之后再告诉线程,调用回调函数,实现异步IO,read的时候也不会阻塞

同步和异步的区别

同步和异步指的是:当前线程是否需要等待方法调用执行完毕

比如说搬运100块石头

同步指的是调用了这个方法,线程需要等待这一百块石头搬运完,才能执行后续的代码

异步指的是调用了这个方法,立即返回,不必等待100石头被搬运完,可以立即执行后面的代码逻辑,当100块石头搬运完成后,利用回调或者事件通知的方式告知事件完毕

阻塞和非阻塞的区别

阻塞和非阻塞的区别:当前接口数据还未准备就绪,线程是否被阻塞挂起

阻塞:当前线程仍然处于CPU时间片的时候,调用了阻塞方法,由于数据为准备就绪,时间片还未到就让出CPU

非阻塞:当数据未准备就绪的时候,线程不会被挂起,而是不断的轮询请求接口,查看数据是否已经准备就绪

总结

同步&非同步:当数据未处理完成时,代码的逻辑处理方式不同

阻塞&非阻塞:当数据未处理完成时,线程的状态

CPU内部结构

CPU的基本架构通常由以下几个部分组成:

  1. 控制单元(Control Unit):负责指令的译码和执行;控制数据在各个功能部件之间的传送;协调各个功能部件的工作,确保指令按照正确的顺序执行。
  2. 运算单元(Arithmetic Logic Unit, ALU):执行所有的算术和逻辑运算,包括加、减、乘、除等基本运算,以及逻辑运算如与、或、非等。
  3. 寄存器(Register):用于存储临时数据和指令操作的数值,包括通用寄存器、程序计数器、指令寄存器和状态寄存器等。其中,通用寄存器用于存储过程中的临时数据,程序计数器用于存储下一条指令的地址,指令寄存器用于存储当前执行的指令,状态寄存器用于存储与算术和逻辑运算相关的标志位,如进位标志、零标志等。
  4. 内部总线(Internal Bus):用于在控制单元、运算单元和寄存器之间传输数据和指令,协调各个组件的工作。

计算机的五大单元是什么?

输入单元、 输出单元、CPU内部的控制单元(CU)、算术逻辑单元(ALU)、存储器(内存和外存)五大部分