知识体系之APUE/内核编程

知识体系之APUE/内核编程网络编程 apue

大家好,欢迎来到IT知识分享网。

目录

一、APUE/内核编程

1.基本概念与实现 

1.1.进程3态

1.1.1.进程调度的方式

1.1.2.调度原则

1.1.3.调度算法

1.2.僵尸进程/孤儿进程

1.2.1.僵尸进程

1.2.2.孤儿进程

1.3.pread/pwrite

1.4.缓冲区

1.4.1.用户空间的I/O缓冲区

1.4.2.内核缓冲区

1.5.fork

1.5.1.子类继承了父类的?

1.5.2.子进程也有与父进程不同的属性

1.5.3.COW(写时拷贝机制) copy-on-write

1.5.4.fork后父进程和子进程的栈变量,堆变量,全局变量,静态变量,常量是私有还是公有的?

1.6.守护进程

1.6.1.定义

1.6.2.会话/进程组

1.6.3.守护进程创建过程

1.7.信号SIGPIPE(broken pipe) 

2.进程通信

2.1.无名管道pipe()

2.2.有名管道fifo

2.3.socket_pair

2.4.消息队列

2.5.共享内存shm

2.5.1.效率对比

2.6.信号量

2.7.信号

2.8.socket

3.线程同步

3.1.互斥锁\自旋锁\读写锁\原子操作\CAS\信号量

3.2.条件变量

3.3.一些问答

3.3.1. 同一个进程中,哪些东西是线程私有的?

3.3.2. errno的多线程问题

3.3.3. 常见错误码

3.4. 研讨: 线程数究竟设多少合理

4.五种网络IO模型

4.1.什么是IO

4.2.网络IO模型

4.2.1.阻塞IO

4.2.2.非阻塞IO

4.2.3.信号驱动IO

4.2.4.异步IO/完成端口

4.2.5.IO多路复用

4.3.DMA 技术

4.3.1.为什么要有DMA技术?(无DMA有什么弊端)

4.3.2.介绍

 4.3.3.有DMA技术的数据拷贝

4.4.零拷贝

4.4.1.传统IO流程(一次读写)

4.4.2.零拷贝: sendfile

4.4.3.零拷贝: mmap+write

4.4.4.零拷贝: Driect IO

4.5.零拷贝应用典型案例

4.5.1.kafka

4.5.2.mysql

5.操作系统

5.1.中断

5.1.1.中断触发的方式

5.1.2.硬件中断处理函数会做如下的事情:

5.1.2.中断的触发流程

5.2.文件系统

5.2.1.文件系统的基本组成

5.2.2.虚拟文件系统

5.2.3.文件I/O

5.3.死锁

5.3.1.操作系统的死锁产生必要条件有什么?

5.3.2.死锁避免的方法

5.3.3.死锁发生了,怎么解决:只能强制剥夺

6.计算机网络

6.1.ISO/OSI七层模型

6.1.1.应用层协议,哪些协议使用了TCP?哪些协议使用的UDP?

6.1.2.Ping/Traceroute实现原理

6.1.3.ARP/RARP 地址解析协议 

6.1.4.在浏览器输入URL回车后,发生了什么?

6.1.5.Linux接收网络包的流程

7.性能

7.1.top

7.1.1.top会输出哪些信息

7.1.2.CPU负载是什么意思?

7.1.3.CPU占比具体包含了什么?

7.1.4.问题的原因分别是什么?怎么排查?

7.2.性能问题分析及优化

8.page cache

8.1.(面试题)进程写文件时,进程发生了崩溃(操作系统还未崩溃),已写入的数据会丢失吗

8.2.page cache是什么?

8.3.page和page cache

8.4. Page Cache 与 buffer cache

8.5.page cache适用场景

8.5.1.不适用于大文件传输

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


二、进程 | 线程

1.进程

1.1.进程PCB

1.1.1.PCB包含哪些信息

1.1.2.每个PCB是如何组织的呢?

1.2.进程的上下文切换

1.2.1.CPU上下文切换

1.2.2.进程的上下文切换到底是切换什么呢?

2.线程

2.1.简介

2.2.线程与进程的比较

2.3.线程的上下文切换

2.4.线程的实现

2.4.1.线程的三种实现方式

2.4.2.用户线程和内核线程的对应关系

2.5.用户线程

2.5.1.用户线程如何理解?

2.5.2.用户线程优缺点

2.6.内核线程

2.6.1.内核线程如何理解?

2.6.2.内核线程优缺点

2.7.内核线程(轻量级进程)


三、CPU、内存管理

1.硬件结构

1.1.存储器的层次关系

1.2.CPU

1.2.1.CPU缓存结构

1.2.2.CPU Cache的数据结构和读取过程是什么样的?

1.2.3.如何写出让CPU跑的更快的代码?

1.2.4.如何提升多核 CPU 的缓存命中率?

1.2.5.如何提升多个CPU的缓存命中率?

2.CPU缓存一致性

2.1.CPU Cache 的数据写入

2.1.1.写穿 Write Through

2.1.2.写回write back

2.2.缓存一致性问题

2.3.MESI协议

2.3.1.MESI协议介绍

2.3.2.状态转换

2.4.伪共享

2.4.1.伪共享场景描述

2.4.2.避免伪共享的方法

3.Linux虚拟内存管理

3.1.虚拟内存

3.1.1.为什么要使用虚拟地址访问内存

3.1.2.虚拟内存与物理内存映射

3.2.malloc

3.2.1.malloc是如何分配内存的?

3.2.2.malloc() 分配的是物理内存吗?

3.2.3.malloc(1) 会分配多大的虚拟内存?

3.2.4.free 释放内存,会归还给操作系统吗?

3.2.5.为什么不全部使用 mmap 来分配内存?

3.2.6.为什么不全部使用 brk 来分配内存?

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

4.内存管理

4.1.内存满了,会发生什么?

4.1.1.内存分配的过程是怎样的?

4.2.在4GB物理内存的机器上,申请8G内存会怎么样?

4.2.1.操作系统虚拟内存大小

4.2.2.Swap 机制的作用

4.3.内存页面置换算法

4.3.1.缺页中断

4.3.2.页面置换算法

5.如何避免「预读失效」和「缓存污染」的问题

​编辑 5.1.预读失效,怎么办? — 冷热隔离

5.1.1.什么是预读机制?

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

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

5.2.缓存污染,怎么办?– 提高门槛

5.2.1.什么是缓存污染?

5.2.2.缓存污染会带来什么问题?

5.2.3.怎么避免缓存污染造成的影响?

6.页面置换算法


一、APUE/内核编程

写在最前:进程和线程的区别

进程

线程

CPU资源分配的最小单位

CPU调度最小单位

是否拥有独立的地址空间

×

切换速度

占用资源数

多4G

较少(每线程默认占1MB堆栈)

安全性

相互独立,互不影响

一个线程崩溃,可能会导致整个进程崩溃

通信代价

Q: 内核是如何实现进程和线程的?

A: 实际上,对于linux内核而言,是没有线程这个概念的,创建线程/进程都是采用do_fork系统调用,也是创建一个task_struct结构体,只不过创建线程时,指定一些变量是共享的。

1.基本概念与实现 

1.1.进程3态

运行

当一个进程在处理机CPU上运行时,则称该进程处于运行状态。

知识体系之APUE/内核编程

就绪

当一个进程获得了除CPU以外的一切所需资源,一旦得到处理机即可运行,则称此进程处于就绪状态。

阻塞

一个进程正在等待某一事件发生(例如请求I/O而等待I/O完成等)而暂时停止运行,这时即使把处理机分配给进程也无法运行,故称该进程处于阻塞状态。

1.1.1.进程调度的方式

抢占式调度:当一个进程正在CPU上执行时,若有某个更为重要或紧迫的进程(优先级更高)的进程需要使用CPU,则立即暂停正在执行的进程,将CPU分配给这个更重要的进程

非抢占式调度:当一个进程正在CPU上执行时,即使有某个更为重要或者紧迫的进程进入就绪队列,仍然让正在执行的进程继续执行,直到该进程完成或发生某种事件而进入阻塞态时,才把CPU分配给更为重要或紧迫(优先级更高)的进程

1.1.2.调度原则

原则一:如果运行的程序,发生了 I/O 事件的请求,那 CPU 使用率必然会很低,因为此时进程在阻塞等待硬盘的数据返回。这样的过程,势必会造成 CPU 突然的空闲。所以,为了提高 CPU 利用率,在这种发送 I/O 事件致使 CPU 空闲的情况下,调度程序需要从就绪队列中选择一个进程来运行。

原则二:有的程序执行某个任务花费的时间会比较长,如果这个程序一直占用着 CPU,会造成系统吞吐量(CPU 在单位时间内完成的进程数量)的降低。所以,要提高系统的吞吐率,调度程序要权衡长任务和短任务进程的运行完成数量。

原则三:从进程开始到结束的过程中,实际上是包含两个时间,分别是进程运行时间和进程等待时间,这两个时间总和就称为周转时间。进程的周转时间越小越好,如果进程的等待时间很长而运行时间很短,那周转时间就很长,这不是我们所期望的,调度程序应该避免这种情况发生。

原则四:处于就绪队列的进程,也不能等太久,当然希望这个等待的时间越短越好,这样可以使得进程更快的在 CPU 中执行。所以,就绪队列中进程的等待时间也是调度程序所需要考虑的原则。

原则五:对于鼠标、键盘这种交互式比较强的应用,我们当然希望它的响应时间越快越好,否则就会影响用户体验了。所以,对于交互式比较强的应用,响应时间也是调度程序需要考虑的原则。

针对上面的五种调度原则,总结成如下:

  • CPU 利用率:调度程序应确保 CPU 是始终匆忙的状态,这可提高 CPU 的利用率;
  • 系统吞吐量:吞吐量表示的是单位时间内 CPU 完成进程的数量,长作业的进程会占用较长的 CPU 资源,因此会降低吞吐量,相反,短作业的进程会提升系统吞吐量;
  • 周转时间:周转时间是进程运行+阻塞时间+等待时间的总和,一个进程的周转时间越小越好;
  • 等待时间:这个等待时间不是阻塞状态的时间,而是进程处于就绪队列的时间,等待的时间越长,用户越不满意;
  • 响应时间:用户提交请求到系统第一次产生响应所花费的时间,在交互式系统中,响应时间是衡量调度算法好坏的主要标准。

说白了,这么多调度原则,目的就是要使得进程要「快」

1.1.3.调度算法

先来先服务调度算法

短作业优先调度算法

优先级调度算法

时间轮旋转调度算法

多级反馈队列调度算法

多级反馈优先级队列调度:是「时间片轮转算法」和「最高优先级算法」的综合和发展

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

知识体系之APUE/内核编程

来看看,它是如何工作的:

  • 设置了多个队列,赋予每个队列不同的优先级,每个队列优先级从高到低,同时优先级越高时间片越短
  • 新的进程会被放入到第一级队列的末尾,按先来先服务的原则排队等待被调度,如果在第一级队列规定的时间片没运行完成,则将其转入到第二级队列的末尾,以此类推,直至完成;
  • 当较高优先级的队列为空,才调度较低优先级的队列中的进程运行。如果进程运行时,有新进程进入较高优先级的队列,则停止当前运行的进程并将其移入到原队列末尾,接着让较高优先级的进程运行;

分析:可以发现,对于短作业可能可以在第一级队列很快被处理完。对于长作业,如果在第一级队列处理不完,可以移入下次队列等待被执行,虽然等待的时间变长了,但是运行时间也变更长了,所以该算法很好的兼顾了长短作业,同时有较好的响应时间。

1.2.僵尸进程/孤儿进程

1.2.1.僵尸进程

僵尸进程:没有被回收的进程

  1. 一个进程fork创建子进程,子进程退出后,父进程没有调用wait/waitpid回收子进程资源,这个子进程就叫做僵尸进程
  2. 尸体会占用系统中的进程号/进程资源,一旦太多将会耗尽系统资源

1. 当子进程退出时,将发送SIGCHLD信号

2. 父进程使用wait/waitpid,回收子进程的资源

wait

阻塞

waitpid

提供了非阻塞方式;可以回收指定子进程(-1:所有子进程,>0等待pid子进程;==0,进程组)

1.2.2.孤儿进程

孤儿进程:父进程退出了,子进程还在

  1. 它会被init进程收养
  2. 孤儿进程并不存在问题,它只是没有了父进程而已(一般守护进程都这么玩)

1.3.pread/pwrite

带偏移量的原子的读写文件

调用pread相当于顺序调用lseek和read,但pread和这种调用又有重大区别:

  • 调先调用lseek,再调用read/write(原子操作,不能被打断)
  • 因为历史上有些系统不支持O_APPEND,才定义了pread和pwrite。 因为lseek与read之间,可能会出现非预期的效果,所以定义pread
  • 随机访问的话,pread/pwrite比较方便

1.4.缓冲区

用户数据 — IO缓冲区 — 内核缓冲区 — 磁盘/外设

        read把数据从内核缓冲区复制到I/O缓冲区,write把数据从I/O缓冲区复制到内核缓冲区,它们不等价于数据在内核缓冲区和磁盘之间的交换。

知识体系之APUE/内核编程

1.4.1.用户空间的I/O缓冲区

        I/O缓冲区,其作用是减少read和write的次数,即减少了系统调用,从而减少了系统开销,提高了I/O速度

用户空间IO缓冲的类型:下面的3个缓冲是指的是用户空间的I/O缓冲区,不是内核缓冲区

1. 全缓冲:如果缓冲区写满了就写回内核。

场景:常规文件的写入写出(磁盘)通常是全缓冲的

过程:数据—-I/O缓冲—-内核缓冲—-外设

2. 行缓冲:在I/O缓冲区中遇到换行符或者缓冲区写满时,就自动把数据送到内核缓冲区。

场景:标准输入(0)和标准输出(1)对应终端设备时通常是行缓冲的。

3. 无缓冲:用户层不提供缓冲,数据流直接到内核缓冲区,再到磁盘等外设上

场景:标准错误输出(2)通常是无缓存的,因为它必须尽快输出,且是输出到具有交互式的设备上,如屏幕,不是磁盘

1.4.2.内核缓冲区

写操作

1. 从理论上讲,内核可以在任何时候写磁盘,但并不是所有的write操作都会导致内核的写动作

2. 内核会把要写的数据暂时存在内核缓冲区中,积累到一定数量后再一次写入

3. 有时会导致意外情况,比如断电,内核还来不及把内核缓冲区中的数据写到磁盘上,这些更新的数据就会丢失

1.5.fork

返回值:fork调用1次,返回2次

  1. 子进程返回值是0
  2. 父进程返回值是新建子进程的pid

1.5.1.子类继承了父类的?

子进程继承了父进程的几乎所有的属性:

实际UID,GID和有效UID,GID 环境变量 附加GID 调用exec()时的关闭标志 UID设置模式比特位 GID设置模式比特位 进程组号 会话ID 控制终端 当前工作目录 根目录 文件创建掩码UMASK 文件长度限制ULIMIT 预定值, 如优先级和任何其他的进程预定参数, 根据种类不同决定是否可以继承 

1.5.2.子进程也有与父进程不同的属性

进程号:子进程号不同与任何一个活动的进程组号 子进程继承父进程的文件描述符或流时, 具有自己的一个拷贝并且与父进程和其它子进程共享该资源 子进程的用户时间和系统时间被初始化为0 子进程的超时时钟设置为0 子进程不继承父进程的记录锁 pending signals 也不会被继承:阻塞信号集初始化为空集

1.5.3.COW(写时拷贝机制) copy-on-write

① 为了节省物理内存,在调用fork生成子进程后,子进程与原进程共享同一块内存区

② 当且仅当某一个进程写操作时,系统才会为写进程复制一份新的物理页面,使父进程和子进程各自拥有自己物理页面

        经过fork()以后,父进程和子进程拥有相同内容的代码段、数据段和用户堆栈,就像父进程把自己克隆了一遍。

        事实上,父进程只复制了自己的PCB块。而代码段,数据段和用户堆栈内存空间并没有复制一份,而是与子进程共享。只有当子进程在运行中出现写操作时,才会产生中断,并为子进程分配内存空间。

1.5.4.fork后父进程和子进程的栈变量,堆变量,全局变量,静态变量,常量是私有还是公有的?

        全都是私有的(都是私有的!父子进程不共用这个变量,各自有一份。发生写时,会发生写时拷贝,创建一份)

1.6.守护进程

1.6.1.定义

        守护进程是后台运行的,不与任何终端相关联的进程

1.6.2.会话/进程组

进程都有父进程,父进程也有父进程,这就形成了一个以init进程为根的家族树。

除此之外,进程还有其他层次关系:进程、进程组、会话。

进程组

进程组是一组相关进程的集合

会话

会话是一组相关进程组的集合

一个进程会有如下ID:进程ID(pid)、进程组ID(pgid)、会话ID(sid),默认情况下,新创建的进程会继承父进程的进程组ID和会话ID。

进程组、会话是为了支持shell作业控制而引入的概念

当有新的用户登录linux时,登陆进程会为这个用户创建一个会话,用户登录的shell就是会话的受进程,会话的首进程ID会作为整个会话的ID。(会话开始于用户登录,终止于用户退出,在此期间,所有的进程都属于这个会话)

在执行shell命令时,可以使用管道,让多个进程互相配合完成一项工作,这一组进程属于同一个进程组。

1.6.3.守护进程创建过程

知识体系之APUE/内核编程

  1. fork创建子进程,父进程调用exit退出
    • 由于守护进程是脱离控制终端的,这样做,使得父进程先于子进程退出,子进程变为孤儿进程由init进程收养
  2. 子进程调用setsid()创建新会话
    1. 调用fork后,子进程全盘拷贝了父进程的会话、进程组、控制终端等,虽然父进程退出了,但是会话、进程组、控制终端等却没有改变。这不是真正意义上的独立开来,而,setsid函数就能够使子进程完全独立开来,即:使当前进程脱离原会话/原进程组/原控制终端的控制
  3. 再次fork一个子进程,父进程exit退出

    现在,子进程已经成为无终端的会话组长,但它可以通过fork一个子进程重新申请打开一个控制终端,该新的子进程不是会话受进程,它不能重新打开控制终端。(也就是说,通过再次创建子进程结束当前进程,使进程不再使会话首进程来禁止进程重新打开控制终端)

  4. 在子进程中调用chdir(‘/’)让根目录称为子进程的工作目录
  5. 在子进程中调用umask重设文件权限掩码为0(相当于把权限放开)
  6. 在子进程中close不需要的文件描述符
    1. 用fork函数新建的子进程会从父进程那里继承一些已经打开了的文件。这些被打开的文件可能永远不会被守护进程读写,但它们一样消耗系统资源。
    2. 其实在上面的第二步之后,守护进程已经与所属的控制终端失去了联系,因此从终端输入的字符不可能达到守护进程,守护进程中用常规方法(如printf)输出的字符也不可能在终端上显示出来。所以,文件描述符为0、1和2 的3个文件已经失去了存在的价值,也应被关闭。(关闭失去价值的输入、输出、报错等对应的文件描述符

知识体系之APUE/内核编程

1.7.信号SIGPIPE(broken pipe) 

管道破裂

原因

向没有打开or意外终止的管道中写数据,写进程就会收到SIGPIPE信号

1. 第一次写,写进程会收到RST标记,异常终止连接

2. 在收到RST标记后,写进程仍然执意要再向已经关闭的管道中写数据,写进程就会收到SIGPIPE信号!该信号默认情况下,会使写进程终止。

使用场景

在服务器设计时,极有可能出现客户端已经挂掉了,服务端还会继续向客户端套接字写入数据的场景,这会导致产生SIGPIPE信号,导致服务端进程挂掉!

如果服务端不处理该SIGPIPE信号,服务端是很危险的!

服务端增加捕捉SIGPIPE的代码

方案1: signal(SIGPIPE, SIG_IGN);   // 忽略该信号, 不执行默认的操作

方案2: 注册SIGPIPE信号处理函数,处理该信号

2.进程通信

单工:只能向一个方向发送

半双工:可以两个方向,但是同一个时刻,只允许一个方向

全双工:可以两个方向,同一个时刻,允许两个方向

2.1.无名管道pipe()

只能用于具有亲缘关系的进程之间的通信(父子进程/兄弟进程)

知识体系之APUE/内核编程

半双工(方向只能朝一个方向流动),一个读端,一个写端

先进先出

阻塞(管道里没数据,读端阻塞;管道满,写端阻塞)

流管道

fd[0] 读端   fd[1]写端

知识体系之APUE/内核编程

2.2.有名管道fifo

fifo与pipe相比,因为fifo有路径名与之相关联,所以fifo可以在无关进程之间交换数据

使用:mkfifo( “fifo_name”, 0666)

2.3.socket_pair

        一种管道,全双工,两端都可以读写。与上面用法一样,唯一不同是全双工

2.4.消息队列

消息队列是存放在内核中的,一个消息队列由一个标识符(即队列ID)来标识

特点

消息队列是面向记录的,其中的消息具有特定的格式以及特定的优先级

消息队列独立于发送进程/接收进程。进程终止时,消息队列以及其内容不会删除

消息队列可以实现消息的随机查询,消息不一定要以先进先出的次序读取,也可以按照类型读取

msgget

创建/打开消息队列,返回消息队列ID

msgsnd

添加消息,成功返回0

msgrcv

读取消息,成功返回消息的长度。在读取数据时,参数type可以指定接收某个类型的数据

msgclt

2.5.共享内存shm

指两个/多个进程共享的同一个内存区域

特点

共享内存时最快的一种IPC,因为进程是直接读写内存

大小

映射区的大小都是以页面大小PAGESIZE为单位的

2.5.1.效率对比

补充:(吞吐)内存 / 磁盘 = 10w

1. 管道/消息队列

        管道/共享内存存在于内核中,因此,读写数据时,要涉及到用户空间和内核空间的来回拷贝

2. 共享内存

        多个进程的虚拟地址空间的一段,映射到一块相同的物理内存 ===> 这样,不同进程之间的通信就不需要经过内核

知识体系之APUE/内核编程

Q1: mmap将共享内存映射到进程的虚拟地址空间时有没有分配物理内存? 什么时候才真正分配物理内存给共享变量?

A1.

        mmap是在进程的虚拟地址空间中(堆和栈中间,称为文件映射区域的地方)找一块空闲的虚拟内存。这两种方式分配的都是虚拟内存,没有分配物理内存。

        在第一次访问已分配的虚拟地址空间的时候,发生缺页中断,操作系统负责分配物理内存,然后建立虚拟内存和物理内存之间的映射关系。

2.6.信号量

与前面介绍过的IPC不同,它是一个计数器(用于实现进程间的互斥/同步)

特点

信号量用于同步,若在进程间传递数据需要结合共享内存

信号量基于操作系统的PV操作,程序对信号量的操作都是原子操作

2.7.信号

该信号是signal,信号处理函数

Q: 机制是什么?

A: 实际上是中断,参考下面中断

2.8.socket

        网络套接字,四元组

3.线程同步

3.1.互斥锁\自旋锁\读写锁\原子操作\CAS\信号量

3.2.条件变量

与互斥锁一起使用

使用场景

取代采用while循环一直轮询等待的条件到来,减少CPU的开销,用条件标量等待条件的到来

当队列中没有数据时,使用条件变量让消费者wait(等待时,使用while循环,防止虚假唤醒)

当生产者生产数据放到队列后,使用notify唤醒在条件变量上等待的消费者

3.3.一些问答

3.3.1. 同一个进程中,哪些东西是线程私有的?

线程ID:进程用tid来标识线程

寄存器:每个线程有自己的寄存器,该寄存器中保存了线程上下文,线程切换时,保存/恢复现场

线程自己的局部变量

errno:不同的线程有自己的错误码

线程的信号屏蔽码:由于每个线程所感兴趣的信号不同

线程优先级

3.3.2. errno的多线程问题

        每个系统调用失败后,都会设置errno,在多线程程序中,由于每个线程的errno只属于自己。(每个线程都会分配内存errno,保存自己的错误码)

3.3.3. 常见错误码

错误码

错误信息

可能原因

处理

EAGAIN

Try again

读数据时,没有数据在底层缓冲时

while循环读取

EPIPE

broken pipe

信号SIGPIPE(Broken pipe)

EINTR

被其他系统调用中断

处理很简单,再重新执行一次

ETIMEOUT

连接超时

EADDRINUSE

地址已被占用

设置地址复用标志

ENOTCONN

连接建立失败

3.4. 研讨: 线程数究竟设多少合理

4.五种网络IO模型

4.1.什么是IO

Q: 天天说IO,什么是IO呢?我们经常使用的read/write,到底干了啥?

A: 

个人理解,read/write数据实际上,数据是存放在内核中的缓冲区中的

  1. 读数据,实际上是用户将内核缓冲区中的数据读到应用层缓冲区
  2. 写数据,实际上是用户将应用层缓冲区中的数据,写入到内核缓冲区

4.2.网络IO模型

4.2.1.阻塞IO

         当调用read函数阻塞读取数据时,如果此时内核缓冲区中没有数据,就阻塞等待,这就是阻塞IO模型。

        当内核缓冲区中有数据时,read函数将读到数据,就会将数据拷贝到应用层缓冲区。

4.2.2.非阻塞IO

        非阻塞read数据时,当内核缓冲区中没有数据时,程序将不会一直阻塞在read函,read函数会立即返回(EWOULDBLOCK)。

        此时程序员可以控制程序去做其他事情。在此期间,程序员要不断的轮询调用read查看是否有数据到来,直到有数据到来,read才读取数据。

        缺点:死等待,没有数据CPU一直等待,CPU利用率低

4.2.3.信号驱动IO

原理

  1. 应用程序需要建立信号处理程序(当数据到来时,从内核中拷贝数据到应用层)
  2. 当内核中数据准备好的时候,会给应用程序发送一个信号,SIGIO
  3. 会触发注册的信号处理函数,将数据拷贝走

 缺点:忙等待,消耗CPU资源

拉模式:需要应用程序注册回调函数,【主动】将数据从内核空间拷贝到应用空间,是一种【拉模式】

4.2.4.异步IO/完成端口

        可以看到,这种模式和“信号驱动IO”最大的区别是:当内核中数据准备好后,是内核主动的将数据拷贝到应用空间(而不是由应用层自己注册的回调函数,将数据拷贝到应用层空间)。这是一种【推模式】。

        内核将数据拷贝到应用层空间后,将会通知你,你之后就可以处理接下来的逻辑了。

  1. 当应用程序调用aio_read时,内核一方面去取数据报内容返回,另一方面将程序控制权还给应用程序
  2. 应用程序继续执行其他的任务,是一种非阻塞的状态
  3. 当内核中的数据报就绪时,由内核主动地将数据报拷贝到应用程序中,返回aio_read中定义好的函数处理程序

        异步IO与同步IO的核心区别:异步IO无需自己负责读写,OS会自动将数据从内核空间拷贝到用户空间

4.2.5.IO多路复用

        IO多路复用有一个文件描述符集合,对这个集合中的每个元素进行循环监听,处理就绪的文件描述符

select/poll/epoll的区别

epoll

select

poll

时间复杂度

O(1)

select/poll:当有I/O时间发生时,不能知道是哪个fd触发了,只能无差别的轮询所有fd。 O(N)

底层实现

红黑树、就绪链表

数组

链表

poll与select没有本质上的区别, 但是它没有最大连接数的限制,原因是它是基于链表来存储的。

select缺点①②③

poll缺点②③

①最大连接数有上限

②对fd集合需要轮询扫描③需要将fd集合从内核和用户空间来回拷贝

epoll

不是轮询方式监听fd,只有活跃的fd才会调用callback

epoll底层原理

知识体系之APUE/内核编程

epoll_create

创建一个epoll文件描述符,底层同时创建一棵红黑树、一个就绪链表rdlist

红黑树存储了所有监控的文件描述符

就绪链表存储就绪文件描述符

epoll_clt

删除:将文件描述符从红黑树上摘除

插入:先查看红黑树是否有该fd:如果有,不再插入;如果没有,插入。(epoll使用“事件”的就绪通知方式,通过epoll_ctl注册fd,一旦该fd就绪,内核就会采用类似callback的回调机制来激活该fd,epoll_wait便可以收到通知)

epoll_wait

只是从就绪链表中取出元素,将该元素上的事件复制到用户态区间(使用mmap提高效率)

 epoll水平触发/边缘触发

        根本区别:事件触发后,是否从红黑树中摘除

水平触发

只要高电平or低电平时,才会触发。即:有数据可读时,就会一直触发

(事件没处理完,不会从激活链表中摘除,会一直触发,直到事件处理完,才移除激活队列)

边缘触发

只有电平发生变化时,才会触发

(事件来到时,只触发一次,就从激活链表中摘除)

举例

一个管道收到了1kb的数据,epoll会立即返回,此时读了512字节数据,然后再次调用epoll。

①如果是水平触发的,epoll会立即返回,因为有数据准备好了

②如果是边缘触发的不会立即返回,因为此时虽然有数据可读,但是已经触发了一次通知,在这次通知到现在还没有新的数据到来,直到有新的数据到来时,epoll才会返回。

当epoll网络模型时:

①对于读事件,如果用水平触发不用担心数据有没有读完,因为下次epoll返回时,没有读完的socket依然会被返回。但是要注意这种模式下的写事件,因为是水平触发,每次socket可写时,epoll都会返回,当我们写的数据包过大时,一次写不完,要多次才能写完或者每次socket写都写一个很小的数据包时,每次写都会被epoll检测到,因此长期关注socket写事件会无故cpu消耗过大甚至导致cpu跑满,所以在水平触发模式下一般不关注socket可写事件而是通过调用socket write或者send api函数来写socket。

说到这,可以看到水平触发在效率上是没有边缘触发高的,因为每一次socket读或写可能被返回两次甚至多次,所以有时候也会用到边缘触发。但是这种模式下在读数据的时候一定要注意,因为如果一次没有把数据读完,且在socket没有新的数据可读时,epoll就不回返回了,将导致数据没有读取完。

因此,对于边缘触发,必须:①使用非阻塞IO ②要while循环一次性读写完全部数据

EPOLL_ONESHOT与边缘触发

解释

EPOLL_ONESHOT

        因为et模式需要循环读取,但是在读取过程中,如果有新的事件到达,很可能触发了其他线程来处理这个socket,那就乱了

原理

        一旦事件触发后,就调用epoll_clt将事件从红黑树上摘除,直到处理完该事件,再调用epoll_clt将该事件添加到红黑树。

        这样,就可以保证同一个fd事件在同一时刻只能触发一次!

epoll可以监听普通文件么?

答: 不行,返回Operation not allow错误(错误码EPERM)

原因:出现 Operation not allow 的原因就是:被监听的文件没有提供 poll 接口

开启多个进程,创建多个epoll监听同一个fd,执行epoll_wait,当事件fd触发后,会不会发生惊群?

① 默认情况下,会发生惊群

② 解决方案:为该fd设置SO_REUSEPORT

  1. 修改 bind 系统调用实现,以便支持多个进程可以绑定到相同的 IP 和端口
  2. 修改处理新建连接的实现,查找 listener 的时候,能够支持在监听相同 IP 和端口的多个 socket 之间均衡选择(不产生惊群,选择一个提供服务)。且同一个源ip和端口的所有包,必须要发送到同一个listener

SO_REUSEPORT\SO_REUSEADDR区别

1. 功能:

        SO_REUSEADDR用于对TCP套接字处于TIME_WAIT状态下的socket,才可以重复绑定使用

        SO_REUSEPORT是允许多个socket绑定到同一个ip+port上

2. 两者使用场景完全不同

        SO_REUSEADDR这个套接字选项通知内核,如果端口忙,但TCP状态位于TIME_WAIT,可以重用端口。这个一般用于当你的程序停止后想立即重启的时候,如果没有设定这个选项,会报错EADDRINUSE,需要等到TIME_WAIT结束才能重新绑定到同一个ip+port上

        SO_REUSEPORT用于多核环境下,允许多个线程或者进程绑定和监听同一个ip+port,无论UDP、TCP(以及TCP是什么状态)

3. 对于多播,两者意义相同

4.3.DMA 技术

4.3.1.为什么要有DMA技术?(无DMA有什么弊端)

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

知识体系之APUE/内核编程

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

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

4.3.2.介绍

介绍

        DMA 技术很容易理解,本质上,DMA 技术就是我们在主板上放一块独立的芯片。在进行内存和 I/O 设备的数据传输的时候,我们不再通过 CPU 来控制数据传输,而直接通过 DMA 控制器(DMA Controller,简称 DMAC)。这块芯片,我们可以认为它其实就是一个协处理器(Co-Processor)。

        DMA技术,简单理解就是,在进行 I/O 设备和内存 的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,这样 CPU 就可以去处理别的事务。

注意:这里面的“协”字。DMAC 是在“协助”CPU,完成对应的数据传输工作。在 DMAC 控制数据传输的过程中,DMAC 还是被 CPU 控制,只是数据的拷贝行为不再由 CPU 来完成。

使用场景

DMAC 的价值在如下情况中尤其明显:

  1. 当要传输的数据特别大、速度特别快
  2. 传输的数据特别小、速度特别慢的时候

比如说,

  1. 用千兆网卡或者硬盘传输大量数据的时候,如果都用 CPU 来搬运的话,肯定忙不过来,所以可以选择 DMAC
  2. 当数据传输很慢的时候,DMAC 可以等数据到齐了,再发送信号,给到 CPU 去处理,而不是让 CPU 在那里忙等待。

作用

DMAC 代替了 CPU 负责「内存与磁盘」、「内存与网卡」之间的数据搬运,CPU 作为 DMAC 的控制者。

但是 DMAC 有其局限性,DMAC 仅仅能用于「设备间」交换数据时进行数据拷贝,但是「设备内部」的数据拷贝还需要 CPU 来亲力亲为。例如, CPU 需要负责内核空间与用户空间之间的数据拷贝(内存内部的拷贝),如下图所示:

知识体系之APUE/内核编程

上图中的 read buffer 也就是 page cache,socket buffer 也就是 Socket 缓冲区。

 4.3.3.有DMA技术的数据拷贝

        可以看到, CPU 不再参与「将数据从磁盘控制器缓冲区搬运到内核空间」的工作,这部分工作全程由 DMA 完成

知识体系之APUE/内核编程

4.4.零拷贝

零拷贝的特点是 CPU 不全程负责内存中的数据写入其他组件,CPU 仅仅起到管理的作用。

注意:零拷贝不是不进行拷贝,而是 CPU 不再全程负责数据拷贝时的搬运工作。如果数据本身不在内存中,那么必须先通过某种方式拷贝到内存中(这个过程 CPU 可以仅仅负责管理,DMAC 来负责具体数据拷贝),因为数据只有在内存中,才能被转移,才能被 CPU 直接读取计算。

零拷贝技术的具体实现方式有很多,例如:sendfile、mmap、直接 Direct I/O、splice

前瞻性的技术总结:

  • DMA 技术:DMA 负责内存与其他组件之间的数据拷贝,CPU 仅需负责管理,而无需负责全程的数据拷贝
  • 使用 page cache 的 zero copy:
    • sendfile:一次代替 read/write 系统调用,通过使用 DMA 技术以及传递文件描述符,实现了 zero copy
    • mmap:仅代替 read 系统调用,将内核空间地址映射为用户空间地址,write 操作直接作用于内核空间。通过 DMA 技术以及地址映射技术,用户空间与内核空间无须数据拷贝,实现了 zero copy
  • 不使用 page cache 的 Direct I/O:读写操作直接在磁盘上进行,不使用 page cache 机制,通常结合用户空间的用户缓存使用。通过 DMA 技术直接与磁盘/网卡进行数据交互,实现了 zero copy

4.4.1.传统IO流程(一次读写)

        从流程图中可以看出传统的IO流程包括 4次上下文的切换、4次拷贝数据

知识体系之APUE/内核编程

如果没有优化,读取磁盘数据,再通过网卡传输的场景性能比较差:

4 次 copy:

  • 物理设备 <-> 内存:
    • CPU 负责将数据从磁盘搬运到内核空间的 Page Cache 中;
    • CPU 负责将数据从内核空间的 Socket 缓冲区搬运到的网络中;
  • 内存内部拷贝:
    • CPU 负责将数据从内核空间的 Page Cache 搬运到用户空间的缓冲区;
    • CPU 负责将数据从用户空间的缓冲区搬运到内核空间的 Socket 缓冲区中;

4 次上下文切换:

  1. read 系统调用时:用户态切换到内核态;
  2. read 系统调用完毕:内核态切换回用户态;
  3. write 系统调用时:用户态切换到内核态;
  4. write 系统调用完毕:内核态切换回用户态;

我们不免发出抱怨:

  1. CPU 全程负责内存内部的数据拷贝还可以接受,因为内存的数据拷贝效率还行(不过还是比 CPU 慢很多),但是如果要 CPU 全程负责内存与磁盘、内存与网卡的数据拷贝,这将难以接受,因为磁盘、网卡的 I/O 速度远小于内存;
  2. 4 次 copy 太多了,4 次上下文切换也太频繁了;

4.4.2.零拷贝: sendfile

snedfile 的应用场景是:用户从磁盘读取一些文件数据后不需要经过任何计算与处理就通过网络传输出去。此场景的典型应用是消息队列。

sendfile 主要使用到了两个技术:

  1. DMA 技术
  2. 传递文件描述符代替数据拷贝

1. 利用 DMA 技术

sendfile 依赖于 DMA 技术,将四次 CPU 全程负责的拷贝与四次上下文切换减少到两次(利用 DMA 技术减少 2 次 CPU 全程参与的拷贝),如下图所示:

  • DMA 负责磁盘到内核空间中的 Page cache(read buffer)的数据拷贝以及从内核空间中的 socket buffer 到网卡的数据拷贝。

知识体系之APUE/内核编程

 2次上下文切换、3次拷贝(两次DMA拷贝、一次CPU拷贝)

知识体系之APUE/内核编程

  1. 用户进程发起sendfile系统调用(上下文从用户态切换到内核态)
  2. DMA将数据从硬盘拷贝到内核缓冲区
  3. CPU将内核缓冲区中的数据拷贝到socket缓冲区
  4. DMA异步把数据从socket缓冲器拷贝到网卡
  5. (上下文从内核态切换到用户态),sendflie函数返回

2.传递文件描述符代替数据拷贝(sendfile + DMA scatter/gather实现了零拷贝)

传递文件描述可以代替数据拷贝,这是由于两个原因:

  • page cache 以及 socket buffer 都在内核空间中
  • 数据在传输中没有被更新

知识体系之APUE/内核编程

利用传递文件描述符代替内核中的数据拷贝

注意事项:只有网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术才可以通过传递文件描述符的方式避免内核空间内的一次 CPU 拷贝。这意味着此优化取决于 Linux 系统的物理网卡是否支持(Linux 在内核 2.4 版本里引入了 DMA 的 scatter/gather – 分散/收集功能,只要确保 Linux 版本高于 2.4 即可)

知识体系之APUE/内核编程

        在linux2.4版本后,对sendflie做了优化升级,引入SG-DMA技术,其实就是对DMA拷贝加入了scatter/gather操作,它可以直接从内核空间缓冲区中将数据读取到网卡,这样的话还可以省去CPU拷贝

        2次上下文切换、2次数据拷贝(2次DMA拷贝):这是真正的零拷贝技术,全程没有CPU拷贝,所有的数据都是通过DMA进行传输的

  1. 用户进程发起sendfile系统调用(上下文从用户态切换到内核态)
  2. DMA将数据从硬盘拷贝到内核缓冲区
  3. CPU将内核缓冲区中的文件描述符信息(包括内核缓冲区的内存地址和偏移量)直接发送到socket缓冲区
  4. DMA根据文件描述符信息直接把数据从内核缓冲区拷贝到网卡
  5. (上下文从内核态切换到用户态),sendflie函数返回

4.4.3.零拷贝: mmap+write

        4次上下文切换、3次拷贝数据(一次CPU拷贝、两次DMA拷贝)

知识体系之APUE/内核编程

mmap

  1. 用户进程调用mmap发起IO调用(切换1: 上下文从用户态切换到内核态)
  2. CPU利用DMA控制器,将数据从硬盘拷贝到内核缓冲区
  3. (切换2: 上下文从内核状态切回用户态)mmap方法返回

write

  1. 用户进程调用write发起IO调用(切换3: 上下文从用户态切换到内核态)
  2. CPU将内核缓冲区的数据拷贝到socket缓冲区
  3. CPU利用DMA控制器,将数据从socket缓冲区拷贝到网卡(切换4: 上下文从内核态切换到用户态),write方法返回

4.4.4.零拷贝: Driect IO

Direct I/O 即直接 I/O。其名字中的”直接”二字用于区分使用 page cache 机制的缓存 I/O。

  • 缓存文件 I/O:用户空间要读写一个文件并不直接与磁盘交互,而是中间夹了一层缓存,即 page cache;
  • 直接文件 I/O:用户空间读取的文件直接与磁盘交互,没有中间 page cache 层;

“直接”在这里还有另一层语义:其他所有技术中,数据至少需要在内核空间存储一份,但是在 Direct I/O 技术中,数据直接存储在用户空间中,绕过了内核。

Direct I/O 模式如下图所示:(用户空间直接通过 DMA 的方式与磁盘以及网卡进行数据拷贝)

知识体系之APUE/内核编程

 Direct I/O 的读写非常有特点

  • Write 操作:由于其不使用 page cache,所以其进行写文件,如果返回成功,数据就真的落盘了(不考虑磁盘自带的缓存);
  • Read 操作:由于其不使用 page cache,每次读操作是真的从磁盘中读取,不会从文件系统的缓存中读取。

4.5.零拷贝应用典型案例

4.5.1.kafka

Kafka 作为一个消息队列,涉及到磁盘 I/O 主要有两个操作:

  • Provider 向 Kakfa 发送消息,Kakfa 负责将消息以日志的方式持久化落盘(应用层数据 ===> 磁盘)
  • Consumer 向 Kakfa 进行拉取消息,Kafka 负责从磁盘中读取一批日志消息,然后再通过网卡发送(磁盘数据 ===> 网络)

Kakfa 服务端接收 Provider 的消息并持久化的场景下使用 mmap 机制[6],能够基于顺序磁盘 I/O 提供高效的持久化能力,使用的 Java 类为 java.nio.MappedByteBuffer。

Kakfa 服务端向 Consumer 发送消息的场景下使用 sendfile 机制[7],这种机制主要两个好处:

  • sendfile 避免了内核空间到用户空间的 CPU 全程负责的数据移动;
  • sendfile 基于 Page Cache 实现,因此如果有多个 Consumer 在同时消费一个主题的消息,那么由于消息一直在 page cache 中进行了缓存,因此只需一次磁盘 I/O,就可以服务于多个 Consumer;

使用 mmap 来对接收到的数据进行持久化,使用 sendfile 从持久化介质中读取数据然后对外发送是一对常用的组合。但是注意,你无法利用 sendfile 来持久化数据,利用 mmap 来实现 CPU 全程不参与数据搬运的数据拷贝。

4.5.2.mysql

MySQL 的具体实现比 Kakfa 复杂很多,这是因为支持 SQL 查询的数据库本身比消息队列对复杂很多。

5.操作系统

5.1.中断

5.1.1.中断触发的方式

  1. 主动触发(程序中通过函数接口,通知CPU进行中断处理)
  2. 内部中断(数据溢出、非法地址访问、未识别的操作码…)
  3. 外部中断(输入/输出设备、硬件设备故障…)

5.1.2.硬件中断处理函数会做如下的事情:

  • 需要先「暂时屏蔽中断」,表示已经知道内存中有数据了,告诉网卡下次再收到数据包直接写内存就可以了,不要再通知 CPU 了,这样可以提高效率,避免 CPU 不停的被中断。
  • 接着,发起「软中断」,然后恢复刚才屏蔽的中断。

至此,硬件中断处理函数的工作就已经完成。

5.1.2.中断的触发流程

  1. 某些操作,产生电信号,通过硬件上的中断引脚,被传递到中断控制器,中断控制器会向CPU发送中断请求
  2. CPU收到中断请求后,判断是否响应中断
  3. (保护现场)中断触发,将当前正在运行的程序上下文保存到寄存器/堆栈
    1. 将当前断点压入堆栈:程序的下一条指令、寄存器的值
  4. (中断处理)CPU寻找中断服务程序的入口地址,跳转到中断程序运行
    1. 根据中断号,在中断向量表中找出相应的中断程序的入口地址,跳转到中断程序执行
  5. (恢复现场)中断处理结束后,CPU会将之前保存在堆栈中的断点和寄存器重新恢复
    1. 恢复断点的动作:将先前的指针和寄存器的内容从堆栈中弹出
  6. CPU继续运行之前被打断的程序

5.2.文件系统

5.2.1.文件系统的基本组成

        文件系统是操作系统中负责管理持久化数据的子系统说简单点,就是负责把用户的文件存到磁盘硬件中,因为即使计算机断电了,磁盘里的数据并不会丢失,所以可以持久化的保存文件。

        文件系统的基本数据单位是文件,它的目的是对磁盘上的文件进行组织管理,那组织的方式不同,就会形成不同的文件系统。

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

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

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

        注意2:文件的名字,是存放在目录项的,而不是存放在inode的

Q1: 目录和目录项是一个东西么?

A1: 虽然名字很接近,但是它们不是一个东西

  1. 目录是个文件,持久化存储在磁盘
  2. 目录项entry是内核一个数据结构,缓存在内存(上面已经说过)

Q2: 磁盘格式化

A2: 会被分成3个存储区域,分别是超级块、索引节点区、数据块区

  • 超级块,用来存储文件系统的详细信息,比如块个数、块大小、空闲块等等(当文件系统挂载时,载入内存)
  • 索引节点区,用来存储索引节点(当文件被访问时载入内存)
  • 数据块区,用来存储文件或目录数据

Q3: 那文件数据是如何存储在磁盘的呢?

A3: 

        磁盘读写的最小单位是扇区,扇区的大小只有512B,很明显,每次读写都以这么小的单位,那么读写的效率会非常低。

        所以,文件系统把多个扇区组成一个逻辑块,每次读写最小单位就是逻辑块(数据块),Linux的逻辑大小为4KB,也就是一次性读写8个扇区,这将大大提高了磁盘的读写速率。


        以上就是索引节点inode、目录项entry以及文件数据的关系,下面这个图就很好的展示了它们之间的关系。

知识体系之APUE/内核编程

5.2.2.虚拟文件系统

        文件系统的种类众多,而操作系统希望对用户提供一个统一的接口,于是在用户层与文件系统层引入中间层,这个中间层称为虚拟文件系统VFS

        VFS定义了一组所有文件系统都支持的数据结构和标准接口,这样程序员就不需要了解文件系统的工作原理,只需要了解VFS提供的统一接口即可。

        文件系统首先要挂载到某个目录才可以正常使用,比如,linix系统在启动时,会把文件系统挂载到根目录。

知识体系之APUE/内核编程

文件系统分类,根据存储位置不同,分为3类:

  1. 磁盘的文件系统
    1. 直接把数据存储在磁盘中,比如EXT2/3/4、XFS
  2. 网络的文件系统
    1. 用来访问其他计算机主机数据的文件系统,比如NFS、SMB等等
  3. 内存的文件系统
    1. 这类文件系统的数据不是存储在硬盘的,而是占用内存空间,比如常用的/proc和/sys文件系统都属于这一类,读写这类文件,实际上是读写内核中相关的数据结构

5.2.3.文件I/O

缓冲区与非缓冲区IO

        这里所说的「缓冲」特指标准库内部实现的缓冲(用户空间的IO缓冲区),目的:减少系统调用的次数

  • 缓冲 I/O:利用的是标准库的缓存实现文件的加速访问,而标准库再通过系统调用访问文件
  • 非缓冲 I/O:直接通过系统调用访问文件,不经过标准库缓存

直接与非直接IO

        我们都知道磁盘IO是非常慢的,所以Linux内核为了减少磁盘IO次数,在系统调用后,会把用户数据拷贝到内核中缓存起来,这个内核缓冲空间也就是页缓存,只有当缓存满足某些条件时,才发起磁盘IO的请求。

        那么,根据是否利用操作系统的内核缓冲区,可以将文件IO分为直接IO与非直接IO

        1. 直接IO:不会发生内核缓存和用户程序之间的数据复制,而是直接经过文件系统访问磁盘(O_DIRECT)

        2. 非直接IO:读操作时,数据从内核缓存中拷贝给用户程序;写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘

5.3.死锁

5.3.1.操作系统的死锁产生必要条件有什么?

互斥、请求与保持、不可剥夺、环路等待

5.3.2.死锁避免的方法

        死锁避免的方法:互斥不能被破坏,另外的3个条件可以被破坏

  • 请求与保持:资源一次性分配
  • 可剥夺资源:当进程新的资源未得到满足时,释放现在已经占有的资源
  • 环路等待:资源有序分配

5.3.3.死锁发生了,怎么解决:只能强制剥夺

  • 资源剥夺法:挂起暂时放到外存上某些死锁进程,并抢占它的资源,将这些资源分配给其他的死锁进程。但是应防止被挂起的进程长时间得不到资源而饥饿
  • 撤销/终止进程法:强制撤销部分、甚至全部死锁进程,并剥夺这些进程的资源(这种方式的优点是实现简单,但所付出的代价可能会很大。因为有些进程可能已经运行了很长时间,已经接近结束了,一旦被终止可谓功亏一篑,以后还得从头再来)
  • 进程回退法:让一个或多个死锁进程回退到足以避免死锁的地步。这就要求系统要记录进程的历史信息,设置还原点

6.计算机网络

6.1.ISO/OSI七层模型

物理层

设备之间比特流的传输,物理媒介

数据链路层

数据成帧:将IP包封装成帧

透明传输:

差错控制:就检验码、循环冗余检验码CRC

MTU:最大传输单元1500,数据链路层的数据帧最大限制

网络层

提供逻辑IP地址,路由选路;子网划分

ICMP报告传输中的错误(主机不可达/重定向)

IP/ICMP

ARP/RARP

传输层

提供处于网络连接中两台计算机之间的数据传输

TCP/UDP

会话层

对应用会话的管理/同步

RPC

表示层

数据表现形式:加密/解密,压缩/解压缩

JPEG/GIF/ASCII

应用层

用户接口

HTTP/FTP/DNS/Telnet/NFS

6.1.1.应用层协议,哪些协议使用了TCP?哪些协议使用的UDP?

TCP

HTTP, SSL ,FTP, SMTP, POP3, IMAP

UDP

DNS

6.1.2.Ping/Traceroute实现原理

Ping是ICMP应用实例,可以检查网络是否通畅或者查看网络连接速度,便于分析和判定网络故障

        ping 命令会发送一个 ICMP,接收端在收到此 ICMP 后产生 ICMPecho (ICMP回声应答) ,通过此过程来确定两台网络机器是否连通,时延是多少

①构建数据包:ICMP、IP、以太帧

②传输给目标主机

③解析数据包:以太帧、IP、ICMP

④发送应答报文

Traceroute也是ICMP应用实例,用于:路由跟踪,用来跟踪一个分组从源点到终点的整个过程

Traceroute是通过ICMP协议中的时间超时差错报告报文来实现的(它从源主机到目的主机发送一连串的IP数据报p1-pn,并且数据报是无法交付的udp数据报。过程见下:

1. 第一个数据报的TTL=1,当这个数据报转发到第一个路由器的时候,路由器收到后TTL减一,减一之后,TTL变成0,并会向源主机返回一个超时差错报告报文

2. 第二个数据报的TTL=2,当这个数据报转发到第二个路由器的时候,TTL变成0,并会向源主机返回一个超时差错报告报文

3. 第K个数据报的行为和步骤1,2一致

4. 直到第N个数据报pn到达目的主机,但是数据报无法交付,此时目的主机会向源主机返回终点不可达差错报告报文

        通过这种方式,源主机就可以通过发送过来的“超时差错报文和终点不可达差错报文”来得到经过的路由器以及往返时间等信息,达到路由跟踪的目的。

6.1.3.ARP/RARP 地址解析协议 

        作用:在以太网环境中,数据的传输所依赖的是MAC地址而非IP地址,而将已知IP地址转换为MAC地址的工作是由ARP协议来完成的

知识体系之APUE/内核编程

  1. 主机A,(采用广播的方式)发送ARP请求包询问:目标IP=172.20.1.2,MAC地址是?
  2. IP地址=172.20.1.2目标主机,收到请求后,会返回数据包,告诉A主机:我是IP=172.20.1.2,我的MAC=xx:xx:xx:xx:xx

6.1.4.在浏览器输入URL回车后,发生了什么?

知识体系之APUE/内核编程

  1. URL解析
    1. 知识体系之APUE/内核编程
    2. URL实际上是请求服务器里的文件资源
      1. 地址解析:判断输入的是一个合法的URL还是一个带搜索的关键字,并根据输入的内容进行自动完成、字符编码等操作
      2. 安全检查、访问限制 
  2. 生成HTTP请求消息
    1. 对 URL 进行解析之后,浏览器确定了 Web 服务器和文件名,接下来就是根据这些信息来生成 HTTP 请求消息了
  3. DNS解析(真正的地址查询):网址 ==> IP地址
    1. 通过浏览器解析 URL 并生成 HTTP 消息后,需要委托操作系统将消息发送给 Web 服务器
    2. 但在发送之前,还有一项工作需要完成,那就是查询服务器域名对应的 IP 地址,因为委托操作系统发送消息时,必须提供通信对象的 IP 地址
    3. 比如我们打电话的时候,必须要知道对方的电话号码,但由于电话号码难以记忆,所以通常我们会将对方电话号 + 姓名保存在通讯录里。==> 所以,有一种服务器就专门保存了 Web 服务器域名与 IP 的对应关系,它就是 DNS 服务器
      1. 域名的层级关系
        1. DNS 中的域名都是用句点来分隔的,比如 www.server.com,这里的句点代表了不同层次之间的界限
        2. 在域名中,越靠右的位置表示其层级越高
        3. 实际上域名最后还有一个点,比如 www.server.com.,这个最后的一个点代表根域名
      2. 域名解析的工作流程
        1. 客户端首先会发出一个 DNS 请求,问 www.server.com 的 IP 是啥,并发给本地 DNS 服务器
        2. 浏览器缓存:浏览器会先看自身有没有对这个域名的缓存,如果有,就直接返回
        3. 操作系统缓存:如果没有,就去问操作系统,操作系统也会去看自己的缓存,如果有,就直接返回
        4. hosts文件:如果没有,再去 hosts 文件看,也没有,才会去问「本地 DNS 服务器」
        5. 本地DNS服务器,执行域名解析
  4. 通过 DNS 获取到 IP 后,就可以把 HTTP 的传输工作交给操作系统中的协议栈
    1. 知识体系之APUE/内核编程
    2. 应用程序(浏览器)通过调用 Socket 库,来委托协议栈工作。
      1. 协议栈的上半部分有两块,分别是:负责收发数据的 TCP 和 UDP 协议,这两个传输协议会接受应用层的委托执行收发数据的操作
      2. 协议栈的下面一半是:用 IP 协议控制网络包收发操作,在互联网上传数据时,数据会被切分成一块块的网络包,而将网络包发送给对方的操作就是由 IP 负责的。
      3. 此外, IP 中还包括 ICMP 协议和 ARP 协议
        1. ICMP 用于告知网络包传送过程中产生的错误以及各种控制信息。
        2. ARP 用于根据 IP 地址,采用”广播“的方式,查询相应的以太网 MAC 地址
          1. ARP缓存
  5. 出口 ==> 网卡
    1. 介绍
      1. 网络包只是存放在内存中的一串二进制数字信息,没有办法直接发送给对方。因此,我们需要将数字信息转换为电信号,才能在网线上传输,也就是说,这才是真正的数据发送过程。
      2. 负责执行这一操作的是网卡,要控制网卡还需要靠网卡驱动程序
      3. 网卡驱动获取网络包之后,会将其复制网卡内的缓存区中,接着会在其开头加上报头和起始帧分界符,在末尾加上用于检测错误的帧校验序列

    最后网卡会将包转为电信号,通过网线发送出去

  6. 送别者 ==> 交换机
    1. 交换机的设计是将网络包原样转发到目的地。交换机工作在 MAC 层,也称为二层网络设备
      1. 首先,电信号到达网线接口,交换机里的模块进行接收,接下来交换机里的模块将电信号转换为数字信号
      2. 然后通过包末尾的 FCS 校验错误,如果没问题则放到缓冲区。这部分操作基本和计算机的网卡相同,但交换机的工作方式和网卡不同。
  7. 出境大门 ==> 路由器
  8. 互相扒皮 ==> 服务器与客户端
    1. 知识体系之APUE/内核编程

6.1.5.Linux接收网络包的流程

        网卡是计算机里的一个硬件,专门负责接收和发送网络包,当网卡接收到一个网络包后,会通过 DMA 技术,将网络包写入到指定的内存地址,也就是写入到 Ring Buffer ,这个是一个环形缓冲区,接着就会告诉操作系统这个网络包已经到达。

那应该怎么告诉操作系统这个网络包已经到达了呢?

        最简单的一种方式就是触发中断,也就是每当网卡收到一个网络包,就触发一个中断告诉操作系统。

        但是,这存在一个问题,在高性能网络场景下,网络包的数量会非常多,那么就会触发非常多的中断,要知道当 CPU 收到了中断,就会停下手里的事情,而去处理这些网络包,处理完毕后,才会回去继续其他事情,那么频繁地触发中断,则会导致 CPU 一直没完没了的处理中断,而导致其他任务可能无法继续前进,从而影响系统的整体效率。

        所以为了解决频繁中断带来的性能开销,Linux 内核在 2.6 版本中引入了 NAPI 机制,它是混合「中断和轮询」的方式来接收网络包,它的核心概念就是不采用中断的方式读取数据,而是①首先采用中断唤醒数据接收的服务程序,②然后 poll 的方法来轮询数据

        因此,当有网络包到达时,①会通过 DMA 技术,将网络包写入到指定的内存地址,②接着网卡向 CPU 发起硬件中断,③当 CPU 收到硬件中断请求后,根据中断表,调用已经注册的中断处理函数

硬件中断处理函数会做如下的事情:

  • 需要先「暂时屏蔽中断」,表示已经知道内存中有数据了,告诉网卡下次再收到数据包直接写内存就可以了,不要再通知 CPU 了,这样可以提高效率,避免 CPU 不停的被中断。
  • 接着,发起「软中断」,然后恢复刚才屏蔽的中断。

至此,硬件中断处理函数的工作就已经完成。

硬件中断处理函数做的事情很少,主要耗时的工作都交给软中断处理函数了。

软中断的处理:

        内核中的 ksoftirqd 线程专门负责软中断的处理,当 ksoftirqd 内核线程收到软中断后,就会来轮询处理数据。

        ksoftirqd 线程会从 Ring Buffer 中获取一个数据帧,用 sk_buff 表示,从而可以作为一个网络包交给网络协议栈进行逐层处理。

7.性能

❓如何分析进程的性能手段,如何去优化它?

❓Linux自带的iostat会输出什么?一般会关注哪些列?

7.1.top

7.1.1.top会输出哪些信息

知识体系之APUE/内核编程

7.1.2.CPU负载是什么意思?

load avg:显示的是一段时间内,正在执行和等待使用CPU的平均进程数

一般情况下,load average == CPU核心个数,才认为CPU被充分利用。当load average > CPU核心个数,系统出现过载

7.1.3.CPU占比具体包含了什么?

 知识体系之APUE/内核编程

usr:CPU在用户态运行时间占比。越高,说明有应用程序比较繁忙

sys:CPU在内核态运行时间占比。越高,说明内核比较繁忙

nice:改变过优先级的进程占用CPU百分比。越高,说明进程可能频繁调整优先级

idle:空闲CPU百分比。user + nice + idle 应该接近 100%。

iowait:等待输入输出的CPU时间百分比。越高,说明磁盘IO是瓶颈

hi(hardware irq):硬件中断

si(software irq):软件中断

st(steal time)实时:只有 Linux 在作为虚拟机运行时 steal 才是有意义的。它表示虚机等待 CPU 资源的时间(虚机分到的是虚拟 CPU,当需要真实的 CPU 时,可能真实的 CPU 正在运行其它虚机的任务,所以需要等待)

7.1.4.问题的原因分别是什么?怎么排查?

  • usr CPU 和 Nice CPU 高,说明用户态进程占用了较多的 CPU,所以应该着重排查进程的性能问题
  • sys CPU 高,说明内核态占用了较多的 CPU所以应该着重排查内核线程或者系统调用的性能问题
  • 若 %iowait 的值过高,表示硬盘存在I/O瓶颈
  • 若 %idle 的值高但系统响应慢时,有可能是 CPU 等待分配内存,此时应加大内存容量
  • 若 %idle 的值持续低于1,则系统的 CPU 处理能力相对较低,表明系统中最需要解决的资源是 CPU

7.2.性能问题分析及优化


8.page cache

8.1.(面试题)进程写文件时,进程发生了崩溃(操作系统还未崩溃),已写入的数据会丢失吗

面试题:当向磁盘写文件时,写一般的时候,如果进程崩了(操作系统还未崩溃),文件里面会有数据么?如果用的是带缓冲的写文件,文件里面是否有数据?(大概就是,进程写文件(使用缓冲 IO)过程中,写一半的时候,进程发生了崩溃,已写入的数据会丢失吗?)

答案:是不会的。

知识体系之APUE/内核编程

        因为进程在执行 write (使用缓冲 IO)系统调用的时候,实际上是将文件数据写到了内核的 page cache,它是文件系统中用于缓存文件数据的缓冲,所以即使进程崩溃了(由于操作系统没有挂),操作系统会把数据写入内核的 page cache。当读数据的时候,也是从内核的 page cache 读取,因此还是依然读的进程崩溃前写入的数据。

        内核会找个合适的时机,将 page cache 中的数据持久化到磁盘。但是如果 page cache 里的文件数据,在持久化到磁盘化到磁盘之前,操作系统发生了崩溃,那这部分数据就会丢失了。

        当然, 我们也可以在程序里调用 fsync 函数,在写文文件的时候,立刻将文件数据持久化到磁盘,这样就可以解决系统崩溃导致的文件数据丢失的问题。

Q1:C语言的fwrite写了一段内存写到文件里面去,假设fwrite返回了,进程crash && 操作系统没宕机,数据会丢么?

A1:不会丢

Q2:先调用fwrite,之后调用fsync,之后操作系统宕机了,数据会丢么?

A2:在刷新数据到磁盘之前,如果操作系统崩了,数据会丢

Q3:先调用fwrite,之后调用fsync,之后操作系统宕机&&磁盘掉电了,数据会丢么?

A3:不会丢。fsync函数,会立即将pagecache中的数据刷写入磁盘

8.2.page cache是什么?

知识体系之APUE/内核编程

上图中,红色部分为 Page Cache。可见 Page Cache 的本质是由 Linux 内核管理的内存区域。我们通过 mmap 以及 buffered I/O 将文件读取到内存空间实际上都是读取到 Page Cache 中。

8.3.page和page cache

  • page是内存管理分配的基本单位,在操作系统中通常为4KB大小
  • page cache是由多个page构成,则为page的4KB整数倍

tip1:并不是所有 page 都被组织为 Page Cache

Linux 系统上供用户可访问的内存分为两个类型,即:

  • File-backed pages:文件备份页也就是 Page Cache 中的 page,对应于磁盘上的若干数据块;对于这些页最大的问题是脏页回盘;
  • Anonymous pages:匿名页不对应磁盘上的任何磁盘数据块,它们是进程的运行是内存空间(例如方法栈、局部变量表等属性);

tip2:为什么 Linux 不把 Page Cache 称为 block cache,这不是更好吗?

答:因为从磁盘中加载到内存的数据不仅仅放在 Page Cache 中,还放在 buffer cache 中。

8.4. Page Cache 与 buffer cache

执行 free 命令,注意到会有两列名为 buffers 和 cached,也有一行名为 “-/+ buffers/cache”。

~ free -m total used free shared buffers cached Mem:  96440 32515 0 5368 39900 -/+ buffers/cache: 51172 77784 Swap: 16002 0 16001

cached 列表示当前的页缓存(Page Cache)占用量:page cache用于缓存文件的页数据

buffers 列表示当前的块缓存(buffer cache)占用量:buffer cache用户缓存块设备(如磁盘)的块数据

  • 页是逻辑上的概念,因此 Page Cache 是与文件系统同级的
  • 块是物理上的概念,因此 buffer cache 是与块设备驱动程序同级的

8.5.page cache适用场景

8.5.1.不适用于大文件传输

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

所以,

  1. 读磁盘数据的时候,优先在 PageCache 找,如果数据存在则可以直接返回;如果没有,则从磁盘中读取,然后缓存 PageCache 中。
  2. 读取磁盘数据的时候,需要找到数据所在的位置,但是对于机械磁盘来说,就是通过磁头旋转到数据所在的扇区,再开始「顺序」读取数据,但是旋转磁头这个物理动作是非常耗时的,为了降低它的影响,PageCache 使用了「预读功能」

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

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

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

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

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

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

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

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

知识体系之APUE/内核编程

具体过程:

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

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

知识体系之APUE/内核编程

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

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

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

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

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

总结:传输文件的时候,要根据文件的大小来使用不同的方式:

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

二、进程 | 线程

1.进程

1.1.进程PCB

PCB是进程存在的唯一标识:进程消失了,PCB也随之消失

1.1.1.PCB包含哪些信息

  1. 进程描述信息
    1. 进程标识符:标识各个进程,每个进程有唯一的标识符
    2. 用户标识符:进程归属的用户,用户标识符主要为共享和保护服务
  2. 进程控制和管理信息
    1. 进程当前的状态
    2. 进程优先级
  3. 资源分配清单:有关内存地址空间活虚拟地址空间的信息,所打开的列表和所使用的IO设备信息
  4. CPU相关信息:CPU 中各个寄存器的值,当进程被切换时,CPU 的状态信息都会被保存在相应的 PCB 中,以便进程重新执行时,能从断点处继续执行

1.1.2.每个PCB是如何组织的呢?

通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。比如:

  • 将所有处于就绪状态的进程链在一起,称为就绪队列
  • 把所有因等待某事件而处于等待状态的进程链在一起就组成各种阻塞队列
  • 另外,对于运行队列在单核 CPU 系统中则只有一个运行指针了,因为单核 CPU 在某个时间,只能运行一个程序。

1.2.进程的上下文切换

        各个进程之间是共享 CPU 资源的,在不同的时候进程之间需要切换,让不同的进程可以在 CPU 执行,那么这个一个进程切换到另一个进程运行,称为进程的上下文切换

1.2.1.CPU上下文切换

        任务是交给 CPU 运行的,那么在每个任务运行前,CPU 需要知道任务从哪里加载,又从哪里开始运行。所以,操作系统需要事先帮 CPU 设置好 CPU 寄存器和程序计数器

        CPU 寄存器和程序计数是 CPU 在运行任何任务前,所必须依赖的环境,这些环境就叫做 CPU 上下文

        CPU 上下文切换就是先把前一个任务的 CPU 上下文(CPU 寄存器和程序计数器)保存起来,然后加载新任务的上下文到这些寄存器和程序计数器,最后再跳转到程序计数器所指的新位置,运行新任务。

1.2.2.进程的上下文切换到底是切换什么呢?

进程是由内核管理和调度的,所以进程的切换只能发生在内核态。

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

通常,会把交换的信息保存在进程的 PCB,当要运行另外一个进程的时候,我们需要从这个进程的 PCB 取出上下文,然后恢复到 CPU 中,这使得这个进程可以继续执行,如下图所示:

知识体系之APUE/内核编程

发生进程上下文切换有哪些场景?

  • 公平调度,某个进程的CPU时间片耗尽
  • 抢占式调度,当有优先级更高的进程运行时
  • 进程在系统资源不足时,要等到资源满足后才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行
  • 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度
  • 发生硬件中断时,CPU 上的进程会被中断挂起,转而执行内核中的中断服务程序

2.线程

2.1.简介

线程是进程当中的一条执行流程。

同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈,这样可以确保线程的控制流是相对独立的。

线程的优点:

  • 一个进程中可以同时存在多个线程;
  • 各个线程之间可以并发执行;
  • 各个线程之间可以共享地址空间和文件等资源;

线程的缺点:当进程中的一个线程崩溃时,会导致其所属进程的所有线程崩溃

2.2.线程与进程的比较

线程与进程的比较如下:

  • 进程是资源分配的单位,线程是 CPU 调度的单位
  • 进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈
  • 线程同样具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系
  • 线程能减少并发执行的时间和空间开销

对于,线程相比进程能减少开销,体现在:

  • 线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
  • 线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
  • 同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
  • 由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;

所以,不管是时间效率,还是空间效率线程比进程都要高

2.3.线程的上下文切换

这还得看线程是不是属于同一个进程:

  • 当两个线程不是属于同一个进程,则切换的过程就跟进程上下文切换一样;
  • 当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据;

2.4.线程的实现

2.4.1.线程的三种实现方式

主要有三种线程的实现方式:

  • 用户线程(User Thread:在用户空间实现的线程,不是由内核管理的线程,是由用户态的线程库来完成线程的管理;
  • 内核线程(Kernel Thread:在内核中实现的线程,是由内核管理的线程;
  • 轻量级进程(LightWeight Process:在内核中来支持用户线程;

2.4.2.用户线程和内核线程的对应关系

1:1  、 N:1 、 M:N

2.5.用户线程

2.5.1.用户线程如何理解?

  1. 用户线程是基于用户态的线程管理库来实现的
  2. 线程控制块(Thread Control Block, TCB 也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB

        所以,用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。

知识体系之APUE/内核编程

2.5.2.用户线程优缺点

用户线程的优点

  • 每个进程都需要有它私有的线程控制块(TCB)列表,用来跟踪记录它各个线程状态信息(PC、栈指针、寄存器),TCB 由用户级线程库函数来维护,可用于不支持线程技术的操作系统;
  • 用户线程的切换也是由线程库函数来完成的,无需用户态与内核态的切换,所以速度特别快;

用户线程的缺点

  • 由于操作系统不参与线程的调度,如果一个线程发起了系统调用而阻塞,那进程所包含的用户线程都不能执行了。
  • 当一个线程开始运行后,除非它主动地交出 CPU 的使用权,否则它所在的进程当中的其他线程无法运行,因为用户态的线程没法打断当前运行中的线程,它没有这个特权,只有操作系统才有,但是用户线程不是由操作系统管理的。
  • 由于时间片分配给进程,故与其他进程比,在多线程执行时,每个线程得到的时间片较少,执行会比较慢;

2.6.内核线程

2.6.1.内核线程如何理解?

内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。

知识体系之APUE/内核编程

2.6.2.内核线程优缺点

内核线程的优点

  • 在一个进程当中,如果某个内核线程发起系统调用而被阻塞,并不会影响其他内核线程的运行;
  • 分配给线程,多线程的进程获得更多的 CPU 运行时间;

内核线程的缺点

  • 在支持内核线程的操作系统中,由内核来维护进程和线程的上下文信息,如 PCB 和 TCB
  • 线程的创建、终止和切换都是通过系统调用的方式来进行,因此对于系统来说,系统开销比较大

2.7.内核线程(轻量级进程)

轻量级进程(Light-weight process,LWP)是内核支持的用户线程,一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持,而且 LWP 是由内核管理并像普通进程一样被调度。

在大多数系统中,LWP与普通进程的区别也在于它只有一个最小的执行上下文和调度程序所需的统计信息。一般来说,一个进程代表程序的一个实例,而 LWP 代表程序的执行线程,因为一个执行线程不像进程那样需要那么多状态信息,所以 LWP 也不带有这样的信息。

知识体系之APUE/内核编程


三、CPU、内存管理

1.硬件结构

1.1.存储器的层次关系

知识体系之APUE/内核编程

        每个存储器只和相邻的一层存储器设备打交道,并且存储设备为了追求更快的速度,所需的材料成本必然也是更高,也正因为成本太高,所以 CPU 内部的寄存器、L1\L2\L3 Cache 只好用较小的容量,相反内存、硬盘则可用更大的容量,这就我们今天所说的存储器层次结构。

        另外,当 CPU 需要访问内存中某个数据的时候,如果寄存器有这个数据,CPU 就直接从寄存器取数据即可,如果寄存器没有这个数据,CPU 就会查询 L1 高速缓存,如果 L1 没有,则查询 L2 高速缓存,L2 还是没有的话就查询 L3 高速缓存,L3 依然没有的话,才去内存中取数据。

1.2.CPU

1.2.1.CPU缓存结构

知识体系之APUE/内核编程

L1高速缓存

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

L2高速缓存

  • L2高速缓存,L2 高速缓存同样每个 CPU 核心都有,但是 L2 高速缓存位置比 L1 高速缓存距离 CPU 核心 更远,它大小比 L1 高速缓存更大,CPU 型号不同大小也就不同,通常大小在几百 KB 到几 MB 不等,访问速度则更慢,速度在 10~20 个时钟周期

L3高速缓存

  • L3 高速缓存通常是多个 CPU 核心共用的,位置比 L2 高速缓存距离 CPU 核心 更远,大小也会更大些,通常大小在几 MB 到几十 MB 不等,具体值根据 CPU 型号而定。
  • 访问速度相对也比较慢一些,访问速度在 20~60个时钟周期。

知识体系之APUE/内核编程

1.2.2.CPU Cache的数据结构和读取过程是什么样的?

CPU Cache结构

  • Cpu Cache是由很多Cache Line组成的
  • Cache Line是CPU从内存读取数据的基本单位:CPU每次从内存中读取数据的最小单位
    • 服务器的 L1 Cache Line 大小是 64 字节,也就意味着 L1 Cache 一次载入数据的大小是 64 字节
  • Cache Line是由各种标志Tag+数据块Data Block组成

知识体系之APUE/内核编程

1.2.3.如何写出让CPU跑的更快的代码?

「如何写出让 CPU 跑得更快的代码?」这个问题,等价于「如何写出 CPU 缓存命中率高的代码?」

在前面我也提到, L1 Cache 通常分为「数据缓存」和「指令缓存」,这是因为 CPU 会分别处理数据和指令,比如 1+1=2 这个运算,+ 就是指令,会被放在「指令缓存」中,而输入数字 1 则会被放在「数据缓存」里。

因此,我们要分开来看「数据缓存」和「指令缓存」的缓存命中率

① 如何提升数据缓存的命中率?

遍历二维数组,有2种形式

  1. array[i][j] 访问数组元素的顺序,正是和内存中数组元素存放的顺序一致
  2. array[j][i] 来访问,则访问的顺序就是跳跃式

        因此,遇到这种遍历数组的情况时,按照内存布局顺序访问,将可以有效的利用 CPU Cache 带来的好处,这样我们代码的性能就会得到很大的提升

② 如何提升指令缓存的命中率?

  • 第一个操作,循环遍历数组,把小于 50 的数组元素置为 0;
  • 第二个操作,将数组排序

        实际上,CPU 自身的动态分支预测已经是比较准的了,所以只有当非常确信 CPU 预测的不准,且能够知道实际的概率情况时,才建议使用这两种宏。

1.2.4.如何提升多核 CPU 的缓存命中率?

        比如在 C/C++ 语言中编译器提供了 likely 和 unlikely 这两种宏,如果 if 条件为 ture 的概率大,则可以用 likely 宏把 if 里的表达式包裹起来,反之用 unlikely 宏。

        实际上,CPU 自身的动态分支预测已经是比较准的了,所以只有当非常确信 CPU 预测的不准,且能够知道实际的概率情况时,才建议使用这两种宏。

1.2.5.如何提升多个CPU的缓存命中率?

        现代 CPU 都是多核心的,线程可能在不同 CPU 核心来回切换执行,这对 CPU Cache 不是有利的,虽然 L3 Cache 是多核心之间共享的,但是 L1 和 L2 Cache 都是每个核心独有的,如果一个线程在不同核心来回切换,各个核心的缓存命中率就会受到影响,相反,如果线程都在同一个核心上执行,那么其数据的 L1 和 L2 Cache 的缓存命中率可以得到有效提高,缓存命中率高就意味着 CPU 可以减少访问 内存的频率。

        当有多个同时执行「计算密集型」的线程,为了防止因为切换到不同的核心,而导致缓存命中率下降的问题,我们可以把线程绑定在某一个 CPU 核心上,这样性能可以得到非常可观的提升。

        实现方式:在 Linux 上提供了 sched_setaffinity 方法,来实现将线程绑定到某个 CPU 核心这一功能。

2.CPU缓存一致性

2.1.CPU Cache 的数据写入

        数据不光是只有读操作,还有写操作,那么如果数据写入 Cache 之后,内存与 Cache 相对应的数据将会不同,这种情况下 Cache 和内存数据都不一致了,于是我们肯定是要把 Cache 中的数据同步到内存里的。

        问题来了,那在什么时机才把 Cache 中的数据写回到内存呢?为了应对这个问题,下面介绍两种针对写入数据的方法:

2.1.1.写穿 Write Through

保持内存与 Cache 一致性最简单的方式是,把数据同时写入内存和 Cache 中,这种方法称为写直达(Write Through

分析:写直达法很直观,也很简单,但是问题明显,无论数据在不在 Cache 里面,每次写操作都会写回到内存,这样写操作将会花费大量的时间,无疑性能会受到很大的影响

知识体系之APUE/内核编程

2.1.2.写回write back

既然写直达由于每次写操作都会把数据写回到内存,而导致影响性能,于是为了要减少数据写回内存的频率,就出现了写回(Write Back)的方法

核心:在写回机制中,当发生写操作时,新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block「被替换」时,才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。

        知识体系之APUE/内核编程

那具体如何做到的呢?下面来详细说一下:

知识体系之APUE/内核编程

        当且仅当数据不在CPU Cache中,并且该 CPU Cache被其他数据占用 && 标记为Drity状态 时,才将CPU Cache中的数据。

2.2.缓存一致性问题

在多个核心(CPU),如何保证多个CPU Cache缓存的一致性。

2.3.MESI协议

2.3.1.MESI协议介绍

MESI 协议其实是 4 个状态单词的开头字母缩写,分别是:

  • Modified,已修改
  • Exclusive,独占
  • Shared,共享
  • Invalidated,已失效

「已修改」状态就是我们前面提到的脏标记,代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里

「已失效」状态,表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据。

「独占」和「共享」状态都代表 Cache Block 里的数据是干净的,也就是说,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。

「独占」和「共享」的差别在于,独占状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心,因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。

2.3.2.状态转换

在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,独占状态下的数据就会变成「共享」状态

「共享」状态代表着相同的数据在多个 CPU 核心的 Cache 里都有,所以当我们要 更新 某个Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后再更新当前 Cache 里面的数据

我们举个具体的例子来看看这四个状态的转换:

  1. 当 A 号 CPU 核心从内存读取变量 i 的值,数据被缓存在 A 号 CPU 核心自己的 Cache 里面,此时其他 CPU 核心的 Cache 没有缓存该数据,于是标记 Cache Line 状态为「独占」,此时其 Cache 中的数据与内存是一致的;
  2. 然后 B 号 CPU 核心也从内存读取了变量 i 的值,此时会发送消息给其他 CPU 核心,由于 A 号 CPU 核心已经缓存了该数据,所以会把数据返回给 B 号 CPU 核心。在这个时候, A 和 B 核心缓存了相同的数据,Cache Line 的状态就会变成「共享」,并且其 Cache 中的数据与内存也是一致的;
  3. 当 A 号 CPU 核心要修改 Cache 中 i 变量的值,发现数据对应的 Cache Line 的状态是共享状态,则要向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后 A 号 CPU 核心才更新 Cache 里面的数据,同时标记 Cache Line 为「已修改」状态,此时 Cache 中的数据就与内存不一致了。
  4. 如果 A 号 CPU 核心「继续」修改 Cache 中 i 变量的值,由于此时的 Cache Line 是「已修改」状态,因此不需要给其他 CPU 核心发送消息,直接更新数据即可。
  5. 如果 A 号 CPU 核心的 Cache 里的 i 变量对应的 Cache Line 要被「替换」,发现 Cache Line 状态是「已修改」状态,就会在替换前先把数据同步到内存

2.4.伪共享

2.4.1.伪共享场景描述

        因为多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象称为伪共享(False Sharing

下图描述:

  1. 假设 1 号核心绑定了线程 A,2 号核心绑定了线程 B,线程 A 只会读写变量 A,线程 B 只会读写变量 B
  2. 1号核心需要修改变量 A、2号核心需要修改变量 B
  3. A和B占据同一个Cache Line:由于 CPU 从内存读取数据到 Cache 的单位是 Cache Line,也正好变量 A 和 变量 B 的数据归属于同一个 Cache Line,所以 A 和 B 的数据都会被加载到 Cache

知识体系之APUE/内核编程

现象:cache频繁失效

  1. 当1号核心修改A时,会让2号线程的CPU Cache失效
  2. 当2号核心修改A时,会让1号线程的CPU Cache失效

        可以发现,如果 1 号和 2 号 CPU 核心这样持续交替的分别修改变量 A 和 B,就会导致 Cache 频繁的失效,虽然变量 A 和 B 之间其实并没有任何的关系,但是因为同时归属于一个 Cache Line ,这个 Cache Line 中的任意数据被修改后,都会相互影响。

2.4.2.避免伪共享的方法

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

在 Linux 内核中存在 __cacheline_aligned_in_smp 宏定义,是用于解决伪共享的问题。

从上面的宏定义,我们可以看到:

  • 如果在多核(MP)系统里,该宏定义是 __cacheline_aligned,也就是 Cache Line 的大小;
  • 而如果在单核系统里,该宏定义是空的;

因此,针对在同一个 Cache Line 中的共享的数据,如果在多核之间竞争比较严重,为了防止伪共享现象的发生,可以采用上面的宏定义使得变量在 Cache Line 里是对齐的。

struct test { // 写法1 int a; int b; } struct test { // 写法2 int a; int b __cacheline_aligned_in_smp; }

知识体系之APUE/内核编程

所以,避免 Cache 伪共享实际上是用空间换时间的思想,浪费一部分 Cache 空间,从而换来性能的提升。

3.Linux虚拟内存管理

  • 进程无论是在用户态还是在内核态能够看到的都是虚拟内存空间,物理内存空间被操作系统所屏蔽进程是看不到的。
  • 进程通过虚拟内存地址访问这些数据结构的时候,虚拟内存地址会在内存管理子系统中被转换成物理内存地址,通过物理内存地址就可以访问到真正存储这些数据结构的物理内存了。随后就可以对这块物理内存进行各种业务操作,从而完成业务逻辑。

3.1.虚拟内存

3.1.1.为什么要使用虚拟地址访问内存

  • 虚拟内存可以使得进程对运行内存超过物理内存大小,因为程序运行符合局部性原理,CPU 访问内存会有很明显的重复访问的倾向性,对于那些没有被经常使用到的内存,我们可以把它换出到物理内存之外,比如硬盘上的 swap 区域
  • 由于每个进程都有自己的页表,所以每个进程的虚拟内存空间就是相互独立的。进程也没有办法访问其他进程的页表,所以这些页表是私有的,这就解决了多进程之间地址冲突的问题
  • 页表里的页表项中除了物理地址之外,还有一些标记属性的比特,比如控制一个页的读写权限,标记该页是否存在等。在内存访问方面,操作系统提供了更好的安全性

用大白话描述

        既然物理内存地址可以直接定位到数据在内存中的存储位置,那为什么我们不直接使用物理内存地址去访问内存而是选择用虚拟内存地址去访问内存呢?

  1. 假设现在没有虚拟内存地址,我们在程序中对内存的操作全都都是使用物理内存地址,在这种情况下,程序员就需要精确的知道每一个变量在内存中的具体位置,我们需要手动对物理内存进行布局,明确哪些数据存储在内存的哪些位置,除此之外我们还需要考虑为每个进程究竟要分配多少内存?内存紧张的时候该怎么办?如何避免进程与进程之间的地址冲突?等等一系列复杂且琐碎的细节
  2. 虚拟内存引入之后,进程的视角就会变得非常开阔,每个进程都拥有自己独立的虚拟地址空间,进程与进程之间的虚拟内存地址空间是相互隔离,互不干扰的。每个进程都认为自己独占所有内存空间,自己想干什么就干什么。
  3. 当 CPU 访问进程的虚拟地址时,经过地址翻译硬件将虚拟地址转换成不同的物理地址,这样不同的进程运行的时候,虽然操作的是同一虚拟地址,但其实背后写入的是不同的物理地址,这样就不会冲突了。
  4. 分页的方式使得我们在加载程序的时候,不再需要一次性都把程序加载到物理内存中。我们完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去

3.1.2.虚拟内存与物理内存映射

  • 我们程序所使用的内存地址叫做虚拟内存地址
  • 实际存在硬件里面的空间地址叫物理内存地址

知识体系之APUE/内核编程

多级页表

知识体系之APUE/内核编程

TLB缓存

引入原因:多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道查询页表转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销。

TLB:页表缓存(又名“快表”)

知识体系之APUE/内核编程

3.2.malloc

3.2.1.malloc是如何分配内存的?

  • 方式一:通过 brk() 系统调用从堆分配内存
  • 方式二:通过 mmap() 系统调用在文件映射区域分配内存;

malloc() 源码里默认定义了一个阈值:

  • 如果用户分配的内存小于 128 KB,则通过 brk() 申请内存;
  • 如果用户分配的内存大于 128 KB,则通过 mmap() 申请内存;

3.2.2.malloc() 分配的是物理内存吗?

不是的,malloc() 分配的是虚拟内存。(如果分配后的虚拟内存没有被访问的话,虚拟内存是不会映射到物理内存的,这样就不会占用物理内存了)

3.2.3.malloc(1) 会分配多大的虚拟内存?

malloc() 在分配内存的时候,并不是老老实实按用户预期申请的字节数来分配内存空间大小,而是会预分配更大的空间作为内存池

3.2.4.free 释放内存,会归还给操作系统吗?

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

3.2.5.为什么不全部使用 mmap 来分配内存?

  1. 调用 mmap 来分配内存,每次都要执行系统调用
    1. 每次都会发生运行态的切换
    2. 还会发生缺页中断(在第一次访问虚拟地址后),这样会导致 CPU 消耗较大
  2. mmap 分配的内存每次释放的时候,都会归还给操作系统

为了改进上面的问题,malloc 通过 brk() 系统调用在堆空间申请内存的时候,由于堆空间是连续的,所以直接预分配更大的内存来作为内存池,当内存释放的时候,就缓存在内存池中。可以避免上面的问题。

3.2.6.为什么不全部使用 brk 来分配内存?

因为通过 brk 从堆空间分配的内存,并不会归还给操作系统,随着多次malloc和free,会导致出现内存碎片(内存在,但是无法被使用)

详细描述见下:

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

知识体系之APUE/内核编程

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

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

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

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

本质:内存块的头信息

malloc 返回给用户态的内存起始地址比进程的堆空间起始地址多了 16 字节吗?

这个多出来的 16 字节就是保存了该内存块的描述信息,比如有该内存块的大小。

知识体系之APUE/内核编程

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

4.内存管理

4.1.内存满了,会发生什么?

4.1.1.内存分配的过程是怎样的?

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

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

缺页中断处理函数会看是否有空闲的物理内存

  1. 如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。
  2. 如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作,回收的方式主要是两种:直接内存回收和后台内存回收。
  3. 如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了 ——触发 OOM (Out of Memory)机制

内存回收

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

OOM Killer

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

知识体系之APUE/内核编程

4.2.在4GB物理内存的机器上,申请8G内存会怎么样?

第一个问题「在 4GB 物理内存的机器上,申请 8G 内存会怎么样?」存在比较大的争议,有人说会申请失败,有的人说可以申请成功。

这个问题在没有前置条件下,就说出答案就是耍流氓。这个问题要考虑三个前置条件:

  • 操作系统是 32 位的,还是 64 位的?
  • 申请完 8G 内存后会不会被使用?
  • 操作系统有没有使用 Swap 机制?

所以,我们要分场景讨论

先说下结论:

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

4.2.1.操作系统虚拟内存大小

32 位操作系统和 64 位操作系统的虚拟地址空间大小是不同的,在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,如下所示:

知识体系之APUE/内核编程

 通过这里可以看出:

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

现在可以回答这个问题了:在 32 位操作系统、4GB 物理内存的机器上,申请 8GB 内存,会怎么样?

因为 32 位操作系统,进程最多只能申请 3 GB 大小的虚拟内存空间,所以进程申请 8GB 内存的话,在申请虚拟内存阶段就会失败(我手上没有 32 位操作系统测试,我估计失败的原因是 OOM)。

在 64 位操作系统、4GB 物理内存的机器上,申请 8G 内存,会怎么样?

64 位操作系统,进程可以使用 128 TB 大小的虚拟内存空间,所以进程申请 8GB 内存是没问题的,因为进程申请内存是申请虚拟内存,只要不读写这个虚拟内存,操作系统就不会分配物理内存。

4.2.2.Swap 机制的作用

前面讨论在 32 位/64 位操作系统环境下,申请的虚拟内存超过物理内存后会怎么样?

  • 在 32 位操作系统,因为进程最大只能申请 3 GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败。
  • 在 64 位操作系统,因为进程最大只能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存。

程序申请的虚拟内存,如果没有被使用,它是不会占用物理空间的。当访问这块虚拟内存后,操作系统才会进行物理内存分配。

如果申请物理内存大小超过了空闲物理内存大小,就要看操作系统有没有开启 Swap 机制:

  • 如果没有开启 Swap 机制,程序就会直接 OOM;
  • 如果有开启 Swap 机制,程序可以正常运行。

什么是 Swap 机制?

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

        另外,当内存使用存在压力的时候,会开始触发内存回收行为,会把这些不常访问的内存先写到磁盘中,然后释放这些内存,给其他更需要的进程使用。再次访问这些内存时,重新从磁盘读入内存就可以了。

        这种,将内存数据换出磁盘,又从磁盘中恢复数据到内存的过程,就是 Swap 机制负责的。

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

  • 换出(Swap Out) ,是把进程暂时不用的内存数据存储到磁盘中,并释放这些数据占用的内存;
  • 换入(Swap In),是在进程再次访问这些内存的时候,把它们从磁盘读到内存中来;

4.3.内存页面置换算法

4.3.1.缺页中断

在了解内存页面置换算法前,我们得先谈一下缺页异常(缺页中断)

当 CPU 访问的页面不在物理内存时,便会产生一个缺页中断,请求操作系统将所缺页调入到物理内存。

我们来看一下缺页中断的处理流程,如下图:

知识体系之APUE/内核编程

  1. 在 CPU 里访问一条 Load M 指令,然后 CPU 会去找 M 所对应的页表项。
  2. 如果该页表项的状态位是「有效的」,那 CPU 就可以直接去访问物理内存了,如果状态位是「无效的」,则 CPU 则会发送缺页中断请求。
  3. 操作系统收到了缺页中断,则会执行缺页中断处理函数,先会查找该页面在磁盘中的页面的位置。
  4. 找到磁盘中对应的页面后,需要把该页面换入到物理内存中,但是在换入前,需要在物理内存中找空闲页,如果找到空闲页,就把页面换入到物理内存中。
  5. 页面从磁盘换入到物理内存完成后,则把页表项中的状态位修改为「有效的」。
  6. 最后,CPU 重新执行导致缺页异常的指令。

Q:上面所说的过程,第 4 步是能在物理内存找到空闲页的情况,那如果找不到呢?

A:找不到空闲页的话,就说明此时内存已满了,这时候,就需要「页面置换算法」选择一个物理页,如果该物理页有被修改过(脏页),则把它换出到磁盘,然后把该被置换出去的页表项的状态改成「无效的」,最后把正在访问的页面装入到这个物理页中。

4.3.2.页面置换算法

  • 最佳页面置换算法:淘汰在「未来」最长时间不访问的页面
  • 先进先出置换算法:淘汰内存中驻留时间很长的页面
  • 最近最久未使用的置换算法:淘汰最长时间没有被访问的页面
  • 时钟页面置换算法:
  • 最不常用置换算法:淘汰「访问次数」最少的那个页面

5.如何避免「预读失效」和「缓存污染」的问题

先抛出2个问题:

  1. 操作系统在读磁盘的时候,会额外多读一些到内存中,但是最后这些数据也没用到,有什么改善的方法么?
  2. 批量读数据的时候,可能会把热点数据挤出去,这个有什么改善的方法么?

咋一看,以为是在问操作系统的问题,其实这两个题目都是在问如何改进 LRU 算法

因为传统的 LRU 算法存在这两个问题:

  • 「预读失效」导致缓存命中率下降(对应第一个题目)
  • 「缓存污染」导致缓存命中率下降(对应第二个题目)

常见的组件都进行了一定的优化

  • Redis 的缓存淘汰算法则是通过实现 LFU 算法来避免「缓存污染」而导致缓存命中率下降的问题(Redis 没有预读机制)。
  • MySQL 和 Linux 操作系统是通过改进 LRU 算法来避免「预读失效和缓存污染」而导致缓存命中率下降的问题。

这次,就重点讲讲 「MySQL的Buffer Pool」 和 「Linux 操作系统的页缓存」是如何改进 LRU 算法的?

知识体系之APUE/内核编程 5.1.预读失效,怎么办? — 冷热隔离

5.1.1.什么是预读机制?

Linux 操作系统为基于 Page Cache 的读缓存机制提供预读机制,一个例子是:

  • 应用程序只想读取磁盘上文件 A 的 offset 为 0-3KB 范围内的数据,由于磁盘的基本读写单位为 block(4KB),于是操作系统至少会读 0-4KB 的内容,这恰好可以在一个 page 中装下。
  • 但是操作系统出于空间局部性原理(靠近当前被访问数据的数据,在未来很大概率会被访问到),会选择将磁盘块 offset [4KB,8KB)、[8KB,12KB) 以及 [12KB,16KB) 都加载到内存,于是额外在内存中申请了 3 个 page;

上图中,应用程序利用 read 系统调动读取 4KB 数据,实际上内核使用预读机制(ReadaHead) 机制完成了 16KB 数据的读取,也就是通过一次磁盘顺序读将多个 Page 数据装入 Page Cache。

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

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

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

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

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

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

那到底怎么才能避免呢?

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

  • Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list)
  • MySQL 的 Innodb 存储引擎是在一个 LRU 链表上划分来 2 个区域:young 区域 和 old 区域

这两个改进方式,设计思想都是类似的,都是将数据分为了冷数据和热数据,然后分别进行 LRU 算法。不再像传统的 LRU 算法那样,所有数据都只用一个 LRU 算法管理。

Linux 操作系统是如何避免预读失效带来的影响?

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

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

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

5.2.缓存污染,怎么办?– 提高门槛

5.2.1.什么是缓存污染?

背景:

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

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

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

5.2.2.缓存污染会带来什么问题?

我以 MySQL 举例子,Linux 发生缓存污染的现象也是类似。

当某一个 SQL 语句扫描了大量的数据时,在 Buffer Pool 空间比较有限的情况下,可能会将 Buffer Pool 里的所有页都替换出去,导致大量热数据被淘汰了,等这些热数据又被再次访问的时候,由于缓存未命中,就会产生大量的磁盘 I/O,MySQL 性能就会急剧下降。

注意, 缓存污染并不只是查询语句查询出了大量的数据才出现的问题,即使查询出来的结果集很小,也会造成缓存污染。

5.2.3.怎么避免缓存污染造成的影响?

产生的根本原因:LRU 算法只要数据被访问一次,就将数据加入活跃 LRU 链表(或者 young 区域),这种 LRU 算法进入活跃 LRU 链表的门槛太低了!正式因为门槛太低,才导致在发生缓存污染的时候,很容就将原本在活跃 LRU 链表里的热点数据淘汰了

所以,只要我们提高进入到活跃 LRU 链表(或者 young 区域)的门槛,就能有效地保证活跃 LRU 链表(或者 young 区域)里的热点数据不会被轻易替换掉

Linux 操作系统和 MySQL Innodb 存储引擎分别是这样提高门槛的:

  • Linux 操作系统:在内存页被访问第二次的时候,才将页从 inactive list 升级到 active list 里。
  • MySQL Innodb:在内存页被访问第二次的时候,并不会马上将该页从 old 区域升级到 young 区域,因为还要进行停留在 old 区域的时间判断
    • 如果第二次的访问时间与第一次访问的时间在 1 秒内(默认值),那么该页就不会被从 old 区域升级到 young 区域;
    • 如果第二次的访问时间与第一次访问的时间超过 1 秒,那么该页就从 old 区域升级到 young 区域;

提高了进入活跃 LRU 链表(或者 young 区域)的门槛后,就很好了避免缓存污染带来的影响。

在批量读取数据时候,如果这些大量数据只会被访问一次,那么它们就不会进入到活跃 LRU 链表(或者 young 区域),也就不会把热点数据淘汰,只会待在非活跃 LRU 链表(或者 old 区域)中,后续很快也会被淘汰

6.页面置换算法

免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://haidsoft.com/118936.html

(0)
上一篇 2025-11-09 11:20
下一篇 2025-11-09 11:33

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

关注微信