FILE TAGS
OS
硬件结构
START
Basic
如何写出让 CPU 跑的更快的代码?
Back:
- 对于数据缓存,我们在遍历数据的时候,应该按照内存布局的顺序操作,这是因为 CPU Cache 是根据 CPU Cache Line 批量操作数据的,所以顺序地操作连续内存数据时,性能能得到有效的提升;
- 对于指令缓存,有规律的条件分支语句能够让 CPU 的分支预测器发挥作用,进一步提高执行的效率;
另外,对于多核 CPU 系统,线程可能在不同 CPU 核心来回切换,这样各个核心的缓存命中率就会受到影响,于是要想提高线程的缓存命中率,可以考虑把线程绑定 CPU 到某一个 CPU 核心。
END
START
Basic
CPU Cache 的数据写入分为哪两种方式
Back:
- 写直达(Write Through):保持内存与 Cache 一致性最简单的方式是,把数据同时写入内存和 Cache 中
- 写回(Write Back):当发生写操作时,新的数据仅仅被写入 Cache Block 里,然后标记 dirty,只有当修改过的 Cache Block「被替换」时才需要写到内存中
END
START
Basic
缓存一致性模型
Back:
缓存一致性模型要保证以下的两点:
- 第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为写传播(Write Propagation);
- 第二点,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务的串行化(Transaction Serialization)。
END
START
Basic
MESI 协议
Back:
MESI 协议其实是 4 个状态单词的开头字母缩写,分别是:
- Modified,已修改
- Exclusive,独占
- Shared,共享
- Invalidated,已失效
Tags: OS
END
进程管理
START
Basic
死锁是什么,如何避免死锁
Back:
当两个线程为了保护两个不同的共享资源而使用了两个互斥锁,这两个互斥锁应用不当的时候,可能会造成两个线程都在等待对方释放锁,在没有外力的作用下,这些线程会一直相互等待,就没办法继续运行,这种情况就是发生了死锁。
死锁需要同时满足以下四个条件:
- 互斥条件
- 持有并等待条件
- 不可剥夺条件
- 环路等待条件
避免死锁最常见并且可行的方法是使用资源有序分配法,来破坏环路等待条件:
线程 A 和线程 B 获取资源的顺序要一样,当线程 A 是先尝试获取资源 A,然后尝试获取资源 B 的时候,线程 B 同样也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和线程 B 总是以相同的顺序申请自己想要的资源。
END
START
Basic
常见的锁有哪几种,分别适用于什么场景
Back:
互斥锁与自旋锁
最底层的两种锁:
- 互斥锁是一种【独占锁】,当一个线程加锁成功之后,其他的线程尝试获取同一个锁就会失败,加锁失败的线程会释放 CPU,自然该线程就会被阻塞,这是由操作系统内核实现的(将线程置为睡眠状态)
- 自旋锁是通过 CPU 提供的
CAS
原子指令实现的,在用户态完成加锁和解锁操作,不会产生线程上下文切换。自旋锁在加锁失败的时候会进入【忙等待】
互斥锁的开销成本为两次上下文切换的成本,一次是切换为睡眠状态,另一次是锁被释放的时候转变为就绪状态,一次上下文切换的开销大约在十几纳秒到几微秒之间
因此如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。
读写锁
- 读优先锁:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 仍然可以成功获取读锁,最后直到读线程 A 和 C 释放读锁后,写线程 B 才可以成功获取写锁。
- 写优先锁:当读线程 A 先持有了读锁,写线程 B 在获取写锁的时候,会被阻塞,并且在阻塞过程中,后续来的读线程 C 获取读锁时会失败,于是读线程 C 将被阻塞在获取读锁的操作,这样只要读线程 A 释放读锁后,写线程 B 就可以成功获取写锁。
- 公平读写锁:用队列来维护获取锁的线程
乐观锁与悲观锁
互斥锁、自旋锁、读写锁都是悲观锁
如果多线程同时修改共享资源的概率比较低,就可以使用乐观锁
乐观锁的工作方式:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。
乐观锁虽然去除了加锁解锁的操作,但是一旦发生冲突,重试的成本非常高,所以只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁。
END
START
Basic
一个进程最多可以创建多少个线程?
Back:
创建线程会占用内存空间,比如线程独占的栈空间通常为 1 MB(通过 ulimit 命令查看),因此内存空间大小对线程数有限制。而 64 位操作系统的内存空间通常是非常大的(128 T)理论上不会受虚拟内存大小的限制,而会受系统的参数或性能限制。
END
START
Basic
进程崩溃的信号机制
Back:
其实信号有很多类型的,在 Linux 中可以通过 kill -l
查看所有可用的信号:
发生非法内存访问的时候,会调用 kill 系统调用向进程发送信号(假设为 11,即 SIGSEGV,一般非法访问内存报的都是这个错误)
可以自己注册信号处理函数,或者直接忽略信号:
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
// 自定义信号处理函数,处理自定义逻辑后再调用 exit 退出
void sigHandler(int sig) {
printf("Signal %d catched!\n", sig);
exit(sig);
}
int main(void) {
signal(SIGSEGV, sigHandler);
int *p = (int *)0xC0000fff;
*p = 10; // 针对不属于进程的内核空间写入数据,崩溃
}
// 以上结果输出: Signal 11 catched!
int main(void) {
// 忽略信号
signal(SIGSEGV, SIG_IGN);
// 产生一个 SIGSEGV 信号
raise(SIGSEGV);
printf("正常结束");
}
例如 JVM自己定义了信号处理函数,这样当发送 kill pid 命令(默认会传 15 也就是 SIGTERM)后,JVM 就可以在信号处理函数中执行一些资源清理之后再调用 exit 退出。
JVM 自定义了自己的信号处理函数,拦截了 SIGSEGV 信号,针对这两者不让它们崩溃。
END
文件系统
START
Basic
Linux 文件系统会为每个文件分配哪些数据结构?
Back:
- 索引节点(inode):文件的唯一标识,存储文件的元数据,存储于磁盘中
- 目录项(Entry):记录文件的名字、索引节点指针以及于其他目录项的层级关联关系,目录项由内核维护,缓存在内存之中
目录项和索引节点是多对一的关系,一个文件可以有多个别名(硬链接)
磁盘进行格式化的时候,会被分成三个存储区域:
- 超级块,用来存储文件系统的详细信息,比如块个数、块大小、空闲块等等。
- 索引节点区,用来存储索引节点;
- 数据块区,用来存储文件或目录数据;
不可能把超级块和索引节点区全部加载到内存,这样内存肯定撑不住,所以只有当需要使用的时候,才将其加载进内存,它们加载进内存的时机是不同的: - 超级块:当文件系统挂载时进入内存;
- 索引节点区:当文件被访问时进入内存;
END
START
Basic
什么是虚拟文件系统?
Back:
操作系统希望对用户提供一个统一的借口,于是在用户层与文件系统层引入了一个中间层,就是虚拟文件系统
根据存储位置的不同,可以把文件系统分为三类:
- 磁盘的文件系统,它是直接把数据存储在磁盘中,比如 Ext 2/3/4、XFS 等都是这类文件系统。
- 内存的文件系统,这类文件系统的数据不是存储在硬盘的,而是占用内存空间,我们经常用到的
/proc
和/sys
文件系统都属于这一类,读写这类文件,实际上是读写内核中相关的数据。 - 网络的文件系统,用来访问其他计算机主机数据的文件系统,比如 NFS、SMB 等等。
END
START
Basic
文件在硬盘上通常是怎么组织的
Back:
- 连续空间存放方式:读写效率高,一次磁盘寻道就能读出整个文件,inode里需要指定【起始块的位置】和【长度】。存在【磁盘空间碎片】和【文件长度不易扩展】的缺陷
- 非连续空间存放方式:又分为链表方式和索引方式
- 链表方式:
- 隐式链表:inode 存放【第一块】和【最后一块】的位置,并且每个数据块要维护一个指针来指向下一块。缺点是只能通过指针顺序访问
- 显式链接:把用于链接文件各数据块的指针,显式地存放在内存的一张链接表中,该表在整个磁盘仅设置一张,每个表项中存放链接指针,指向下一个数据块号。内存中的这样一个表也叫做文件分配表(FAT 表),由于检索操作是在内存中进行的,提高了检索速度,大大减少了访问磁盘的次数,缺点是不适用于大磁盘(内存会撑爆)
- 索引方式:inode 包含指向索引数据块的指针,索引数据块存放指向文件数据块的指针列表,缺点是存储索引带来的开销
- 链表方式:
Unix 文件的实现方式(Ext 2/3 文件系统):
- 如果存放文件所需的数据块小于 10 块,则采用直接查找的方式;
- 如果存放文件所需的数据块超过 10 块,则采用一级间接索引方式;
- 如果前面两种方式都不够存放大文件,则采用二级间接索引方式;
- 如果二级间接索引也不够存放大文件,这采用三级间接索引方式;
比较灵活地支持小文件和大文件的存放
END
START
Basic
文件系统空闲空间管理策略
Back:
- 空闲表法:所有空闲空间建立一张表,表内容包括空闲区的第一个块号和该空闲区的块个数
- 空闲链表法:每一个空闲块里有一个指针指向下一个空闲块
- 位图法:磁盘上所有的盘块都有一个二进制位与之对应。当值为 0 时,表示对应的盘块空闲,值为 1 时,表示对应的盘块已分配。在 Linux 文件系统就采用了位图的方式来管理空闲空间,不仅用于数据空闲块的管理,还用于 inode 空闲块的管理,因为 inode 也是存储在磁盘的,自然也要有对其管理。
如果只是把位图放在一个块(4 KB)里,那么只能表示 128 M 的空间,因此 Linux 系统中按照 128 M 分组,每个组里都有一个块位图
END
START
Basic
文件系统中目录是怎么存储的?
Back:
Linux 中一切皆文件,目录当然也不例外,只不过目录的块里面保存的是目录里面一项一项的文件信息
目录查询是通过在磁盘上反复搜索完成,需要不断地进行 I/O 操作,开销较大。所以,为了减少 I/O 操作,把当前使用的文件目录缓存在内存,以后要使用该文件时只要在内存中操作,从而降低了磁盘操作次数,提高了文件系统的访问速度。
END
START
Basic
什么是软链接和硬链接?
Back:
- 硬链接是多个目录项中的【索引节点】指向同一个 inode,inode 是不能跨越文件系统的,每个文件系统都有各自的 inode 数据结构和列表,硬链接是不能跨文件系统的。只有删除文件的所有硬链接及原文件时,系统才会彻底删除该文件
- 软链接相当于重新创建一个文件,这个文件有独立的 inode,但是这个文件的内容是另一个文件的路径。软链接是可以跨文件系统的,甚至目标文件被删除了,软链接文件还在但只是包含路径指向的文件被删除了
END
START
Basic
阻塞/非阻塞 IO,同步/异步 IO
Back:
I/O 是分为两个过程的:
- 数据准备的过程
- 数据从内核空间拷贝到用户进程缓冲区的过程
阻塞 I/O 会阻塞在「过程 1 」和「过程 2」,而非阻塞 I/O 和基于非阻塞 I/O 的多路复用只会阻塞在「过程 2」,所以这三个都可以认为是同步 I/O。
异步 I/O 则不同,「过程 1 」和「过程 2 」都不会阻塞。
END
START
Basic
Page Cache 和 Buffer Cache
Back:
Page Cache 的本质是由 Linux 内核管理的内存区域。我们通过 mmap 以及 buffered I/O 将文件读取到内存空间实际上都是读取到 Page Cache 中。
Page Cache 用于缓存文件的页数据,buffer cache 用于缓存块设备(如磁盘)的块数据。
- 页是逻辑上的概念,因此 Page Cache 是与文件系统同级的;
- 块是物理上的概念,因此 buffer cache 是与块设备驱动程序同级的。
在早期,,Page Cache 与 buffer cache 是完全分离的,这种设计导致很多数据被缓存了两次,浪费内存。在 2.4 版本内核之后,两块缓存近似融合在了一起:如果一个文件的页加载到了 Page Cache,那么同时 buffer cache 只需要维护块指向页的指针就可以了。只有那些没有文件表示的块,或者绕过了文件系统直接操作(如dd命令)的块,才会真正放到 buffer cache 里。
Page Cache 中的每个文件都是一颗基数树,树的每个节点都是一个页。根据文件内的偏移量就可以快速定位到所在的页
END
START
Basic
什么是 lock_free
?
Back:
lock-free
的关键描述是:如果一个线程被暂停,那么其他线程应能继续前进,它需要有系统级(system-wide)的吞吐。它描述的是代码逻辑的属性,不使用锁的代码,大多具有这样的属性。
例如,如果我们有三个线程共享一个资源,其中一个线程获取到了锁,另外两个线程在锁上等待,如果获取锁的线程因为某种原因被永久挂起了,那么另外两个线程也将永远得不到执行的机会,此时整个系统(system-wide)就没办法推进,因此所有基于锁的实现,包括基于 CAS 的 spinlock,都不是 lock-free
的。
lock-free
的同步基于 CPU 提供的 read-modify-write 原语,著名的“比较和交换”CAS(Compare And Swap)在X86机器上是通过cmpxchg系列指令实现的原子操作。
通过CAS实现Lock-free的代码通常借助循环,代码如下:
do {
T expect_value = *ptr;
} while (!CAS(ptr, expect_value, new_value));
- 创建共享数据的本地副本:expect_value。
- 根据需要修改本地副本,从ptr指向的共享数据里load后赋值给expect_value。
- 检查共享的数据跟本地副本是否相等,如果相等,则把新值复制到共享数据。
第三步是关键,虽然CAS是原子的,但加载expect_value跟CAS这2个步骤,并不是原子的。所以,我们需要借助循环,如果ptr内存位置的值没有变(*ptr == expect_value
),那就存入新值返回成功;否则说明加载expect_value后,ptr指向的内存位置被其他线程修改了,这时候就返回失败,重新加载expect_value,重试,直到成功为止。
CAS loop支持多线程并发写,这个特点太有用了,因为多线程同步,很多时候都面临多写的问题,我们可以基于CAS实现Fetch-and-add (FAA)算法,它看起来像这样:
T faa(T& t) {
T temp = t;
while (!compare_and_swap(x, temp, temp + 1));
}
END
设备管理
START
Basic
输入输出设备可以分为哪两大类,块设备是什么?
Back:
输入输出设备可以分为两大类:
- 块设备:把数据存储在固定大小的块中,每个块有自己的地址,硬盘、USB 是常见的块设备
- 字符设备:以字符为单位发送或者接受一个字符流,字符设备是不可寻址的,鼠标是常见的字符设备
块设备通常传输的数据量非常大,于是控制器设立了一个数据缓冲区,这个缓冲区是双向缓冲的,即囤了一部分再发送给设备,或者拷贝到内存
END
START
Basic
CPU 是如何与块设备的控制寄存器和数据缓冲区通信的?
Back:
- 端口 I/O,每个控制寄存器被分配一个 I/O 端口,可以通过特殊的汇编指令操作这些寄存器,比如
in/out
类似的指令。 - 内存映射 I/O,将所有控制寄存器映射到内存空间中,这样就可以像读写内存一样读写数据缓冲区。
END
START
Basic
DMA 的工作方式是什么样的?
Back:
- CPU 需对 DMA 控制器下发指令,告诉它想读取多少数据,读完的数据放在内存的某个地方就可以了;(实际上是源和目标两个地址,如果是用户空间则为虚拟地址,如果是设备地址则有可能是由对方 DMA 控制器感知的逻辑地址)
- 接下来,DMA 控制器会向磁盘控制器发出指令,通知它从磁盘读数据到其内部的缓冲区中,接着磁盘控制器将缓冲区的数据传输到内存;(注意 DMA 会请求仲裁系统总线控制权,因为需要传输数据,并且使得 CPU 和 DMA 只有其中一个在对内存进行操作,保证一致性)
- 当磁盘控制器把数据传输到内存的操作完成后,磁盘控制器在总线上发出一个确认成功的信号到 DMA 控制器;
- DMA 控制器收到信号后,DMA 控制器发中断通知 CPU 指令完成,CPU 就可以直接用内存里面现成的数据了;
END
START
Basic
设备控制器如何与 CPU 通信,例如数据读取完成怎么通知 CPU?
Back:
- 轮训等待,轮训一个控制器寄存器的状态标记位,这种傻瓜式的方式会占用 CPU 的全部时间
- 中断,通过硬件的中断控制器
- 硬中断
- 软中断:代码调用
INT
指令触发
END
START
Basic
存储系统的 I/O 软件分层是什么样的?
Back:
其中通用块层是处于文件系统和磁盘驱动器中间的一个块设备抽象层,它主要有两个功能:
- 第一个功能,向上为文件系统和应用程序,提供访问块设备的标准接口,向下把各种不同的磁盘设备抽象为统一的块设备,并在内核层面,提供一个框架来管理这些设备的驱动程序;
- 第二功能,通用层还会给文件系统和应用程序发来的 I/O 请求排队,接着会对队列重新排序、请求合并等方式,也就是 I/O 调度,主要目的是为了提高磁盘读写的效率。
END
START
Basic
键盘敲入字母时,期间发生了什么?
Back:
- 当用户输入了键盘字符,键盘控制器就会产生扫描码数据,并将其缓冲在键盘控制器的寄存器中,紧接着键盘控制器通过总线给 CPU 发送中断请求。
- CPU 收到中断请求后,操作系统会保存被中断进程的 CPU 上下文,然后调用键盘的中断处理程序。
- 键盘的中断处理程序是在键盘驱动程序初始化时注册的,那键盘中断处理函数的功能就是从键盘控制器的寄存器的缓冲区读取扫描码,再根据扫描码找到用户在键盘输入的字符,如果输入的字符是显示字符,那就会把扫描码翻译成对应显示字符的 ASCII 码,比如用户在键盘输入的是字母 A,是显示字符,于是就会把扫描码翻译成 A 字符的 ASCII 码。
- 得到了显示字符的 ASCII 码后,就会把 ASCII 码放到「读缓冲区队列」,接下来就是要把显示字符显示屏幕了,显示设备的驱动程序会定时从「读缓冲区队列」读取数据放到「写缓冲区队列」,最后把「写缓冲区队列」的数据一个一个写入到显示设备的控制器的寄存器中的数据缓冲区,最后将这些数据显示在屏幕里。
- 显示出结果后,恢复被中断进程的上下文。
END
网络系统
START
Basic
现代网络系统是如何实现零拷贝的 ?
Back:
传统的文件传输是将磁盘上的文件读取出来,然后通过网络协议发送给客户端。期间共发生了四次内核态和用户态的上下文切换和四次内存拷贝
优化文件系统传输的性能就是要
- 减少上下文切换的次数,即减少系统调用的次数
- 减少数据拷贝的次数,例如用户的缓冲区就是没有必要存在的
零拷贝技术的实现方式通常有 2 种:
- mmap + write
mmap
可以将内核缓冲区里的数据直接【映射】到用户空间
- sendfile
- 将
read()
和write()
合并为一个系统调用sendfile
,接受两个文件描述符,支持可以由 CPU 直接将内核缓冲区数据拷贝到 socket 缓冲区,这样就省下了一个系统调用 - 如果网卡支持
SG-DMA
,sendfile
可以支持网卡直接对内核缓冲区的数据进行拷贝,发送数据直接进入网卡,而不用进入 socket 缓冲区,这样就只剩下两次数据拷贝了,这就是所谓的零拷贝技术,指的是 CPU 全程没有进行数据拷贝
- 将
END
START
Basic
针对大文件的传输应该怎么实现?
Back:
PageCache被用来缓存最近访问的数据,且使用了预读。但是在传输大文件(例如 GB 级别的文件时),PageCache 会不起作用,这样就白白浪费了 DMA 多做的一次数据拷贝,并且会使得其他的热点小文件无法使用到 PageCache
异步 I/O 将读分为两个部分:
- 前半部分,内核向磁盘发起读请求,但是可以不等待数据就位就返回
- 后半部分,当内核将磁盘数据拷贝到进程缓冲区之后,进程将接收到内核的通知,再去处理数据
异步 I/O 并没有涉及到 PageCache,可以理解成它不需要 PageCache 来 buffer 数据。绕开 PageCache 的 I/O 叫直接 I/O,使用 PageCache 的 I/O 则叫缓存 I/O。通常,对于磁盘,异步 I/O 只支持直接 I/O。
于是在高并发的场景下,针对大文件的传输方式,应该使用异步 I/O+直接 I/O的方式来替代零拷贝技术
END
START
Basic
什么是 I/O 多路复用
Back:
如果每个 TCP 连接对应一个进程/线程处理,上下文切换和创建销毁的开销也非常大。
如果多个请求复用一个进程,就是多路复用,很类似一个 CPU 并发多个进程,所以也叫做时分多路复用
select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。
END
START
Basic
select/poll是如何实现多路复用的?
Back:
select 实现多路复用的方式是将已连接的 Socket 都放到一个文件描述符集合里,然后通过 select 调用来将文件描述符集合拷贝到内核里,内核通过遍历文件描述符集合的方式来检查是否有网络事件发生,如果有则标记。最后将整个文件描述符集合拷贝回用户态里,用户态再通过遍历的方法找到可读或者可写的 Socket(总结:内核替代遍历)
select 使用固定长度的 BitsMap,因此锁支持的文件描述符的个数是有限制的
poll 就是采用动态数组以链表形式来组织的 select
END
START
Basic
Epoll 是如何实现多路复用的?
Back:
int s = socket(AF_INET, SOCK_STREAM, 0);
bind(s, ...);
listen(s, ...)
int epfd = epoll_create(...);
epoll_ctl(epfd, ...); //将所有需要监听的socket添加到epfd中
while(1) {
int n = epoll_wait(...);
for(接收到数据的socket){
//处理
}
}
epoll 通过两个方面,很好解决了 select/poll 的问题。
- 第一点,epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,select/poll 每次操作豆浆整个 socket 集合传给内核,而 epoll 则是在内核中维护红黑树,每次只需要传入一个带检测的 socket
- 第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件。当某个 socket 有事件发生时,通过回调函数内核将其加入到这个就绪事件列表中,当用户调用
epoll_wait()
函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。
END
START
Basic
什么是一致性哈希算法?
Back:
一致性哈希算法就很好地解决了分布式系统在扩容或者缩容时,发生过多的数据迁移的问题。
一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上。
一致性哈希要进行两步哈希:
- 第一步:对存储节点进行哈希计算,也就是对存储节点做哈希映射,比如根据节点的 IP 地址进行哈希;
- 第二步:当对数据进行存储或访问时,对数据进行哈希映射;
映射的结果往瞬时针方向找到的第一个存储节点,就是存储该数据的节点
在一致哈希算法中,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响。
但是一致性哈希算法并不能保证节点在哈希环上均匀分布,一致性哈希算法虽然减少了数据迁移量,但是存在节点分布不均匀的问题。
我们可以通过加入虚拟节点来让分布更加均匀
除此之外,虚拟节点可以有若干好处:
- 节点变化时,会有不同的节点分担系统的变化
- 可以为硬件配置更好的祭奠增加权重,比如对权重更高的节点增加更多的虚拟节点即可
END