FILE TAGS

OS

硬件结构

START

Basic

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

Back:

另外,对于多核 CPU 系统,线程可能在不同 CPU 核心来回切换,这样各个核心的缓存命中率就会受到影响,于是要想提高线程的缓存命中率,可以考虑把线程绑定 CPU 到某一个 CPU 核心。

END

START

Basic

CPU Cache 的数据写入分为哪两种方式

Back:

END

START

Basic

缓存一致性模型

Back:

缓存一致性模型要保证以下的两点:

END

START

Basic

MESI 协议

Back:

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

END

进程管理

START

Basic

死锁是什么,如何避免死锁

Back:

当两个线程为了保护两个不同的共享资源而使用了两个互斥锁,这两个互斥锁应用不当的时候,可能会造成两个线程都在等待对方释放锁,在没有外力的作用下,这些线程会一直相互等待,就没办法继续运行,这种情况就是发生了死锁

死锁需要同时满足以下四个条件:

避免死锁最常见并且可行的方法是使用资源有序分配法,来破坏环路等待条件

线程 A 和线程 B 获取资源的顺序要一样,当线程 A 是先尝试获取资源 A,然后尝试获取资源 B 的时候,线程 B 同样也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和线程 B 总是以相同的顺序申请自己想要的资源。

END

START

Basic

常见的锁有哪几种,分别适用于什么场景

Back:

互斥锁与自旋锁

最底层的两种锁:

互斥锁的开销成本为两次上下文切换的成本,一次是切换为睡眠状态,另一次是锁被释放的时候转变为就绪状态,一次上下文切换的开销大约在十几纳秒到几微秒之间

因此如果你能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁。

读写锁

乐观锁与悲观锁

互斥锁、自旋锁、读写锁都是悲观锁

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

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

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

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:

image.png

磁盘进行格式化的时候,会被分成三个存储区域:

END

START

Basic

什么是虚拟文件系统?

Back:

操作系统希望对用户提供一个统一的借口,于是在用户层与文件系统层引入了一个中间层,就是虚拟文件系统

image.png

根据存储位置的不同,可以把文件系统分为三类:

END

START

Basic

文件在硬盘上通常是怎么组织的

Back:

Unix 文件的实现方式(Ext 2/3 文件系统):

END

START

Basic

文件系统空闲空间管理策略

Back:

END

START

Basic

文件系统中目录是怎么存储的?

Back:

Linux 中一切皆文件,目录当然也不例外,只不过目录的块里面保存的是目录里面一项一项的文件信息

image.png

目录查询是通过在磁盘上反复搜索完成,需要不断地进行 I/O 操作,开销较大。所以,为了减少 I/O 操作,把当前使用的文件目录缓存在内存,以后要使用该文件时只要在内存中操作,从而降低了磁盘操作次数,提高了文件系统的访问速度。

END

START

Basic

什么是软链接和硬链接?

Back:

END

START

Basic

阻塞/非阻塞 IO,同步/异步 IO

Back:

image.png

I/O 是分为两个过程的:

  1. 数据准备的过程
  2. 数据从内核空间拷贝到用户进程缓冲区的过程

阻塞 I/O 会阻塞在「过程 1 」和「过程 2」,而非阻塞 I/O 和基于非阻塞 I/O 的多路复用只会阻塞在「过程 2」,所以这三个都可以认为是同步 I/O。

异步 I/O 则不同,「过程 1 」和「过程 2 」都不会阻塞。

END

START

Basic

Page Cache 和 Buffer Cache

Back:

image.png

Page Cache 的本质是由 Linux 内核管理的内存区域。我们通过 mmap 以及 buffered I/O 将文件读取到内存空间实际上都是读取到 Page 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));
  1. 创建共享数据的本地副本:expect_value。
  2. 根据需要修改本地副本,从ptr指向的共享数据里load后赋值给expect_value。
  3. 检查共享的数据跟本地副本是否相等,如果相等,则把新值复制到共享数据。

第三步是关键,虽然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:

输入输出设备可以分为两大类:

块设备通常传输的数据量非常大,于是控制器设立了一个数据缓冲区,这个缓冲区是双向缓冲的,即囤了一部分再发送给设备,或者拷贝到内存

END

START

Basic

CPU 是如何与块设备的控制寄存器和数据缓冲区通信的?

Back:

END

START

Basic

DMA 的工作方式是什么样的?

Back:

image.png

END

START

Basic

设备控制器如何与 CPU 通信,例如数据读取完成怎么通知 CPU?

Back:

END

START

Basic

存储系统的 I/O 软件分层是什么样的?

Back:

image.png

其中通用块层是处于文件系统和磁盘驱动器中间的一个块设备抽象层,它主要有两个功能:

END

START

Basic

键盘敲入字母时,期间发生了什么?

Back:

image.png

  1. 当用户输入了键盘字符,键盘控制器就会产生扫描码数据,并将其缓冲在键盘控制器的寄存器中,紧接着键盘控制器通过总线给 CPU 发送中断请求
  2. CPU 收到中断请求后,操作系统会保存被中断进程的 CPU 上下文,然后调用键盘的中断处理程序
  3. 键盘的中断处理程序是在键盘驱动程序初始化时注册的,那键盘中断处理函数的功能就是从键盘控制器的寄存器的缓冲区读取扫描码,再根据扫描码找到用户在键盘输入的字符,如果输入的字符是显示字符,那就会把扫描码翻译成对应显示字符的 ASCII 码,比如用户在键盘输入的是字母 A,是显示字符,于是就会把扫描码翻译成 A 字符的 ASCII 码。
  4. 得到了显示字符的 ASCII 码后,就会把 ASCII 码放到「读缓冲区队列」,接下来就是要把显示字符显示屏幕了,显示设备的驱动程序会定时从「读缓冲区队列」读取数据放到「写缓冲区队列」,最后把「写缓冲区队列」的数据一个一个写入到显示设备的控制器的寄存器中的数据缓冲区,最后将这些数据显示在屏幕里。
  5. 显示出结果后,恢复被中断进程的上下文

END

网络系统

START

Basic

现代网络系统是如何实现零拷贝的 ?

Back:

传统的文件传输是将磁盘上的文件读取出来,然后通过网络协议发送给客户端。期间共发生了四次内核态和用户态的上下文切换四次内存拷贝

image.png

优化文件系统传输的性能就是要

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

END

START

Basic

针对大文件的传输应该怎么实现?

Back:

PageCache被用来缓存最近访问的数据,且使用了预读。但是在传输大文件(例如 GB 级别的文件时),PageCache 会不起作用,这样就白白浪费了 DMA 多做的一次数据拷贝,并且会使得其他的热点小文件无法使用到 PageCache

异步 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 的问题。

END

START

Basic

什么是一致性哈希算法?

Back:

一致性哈希算法就很好地解决了分布式系统在扩容或者缩容时,发生过多的数据迁移的问题。

一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上
一致性哈希要进行两步哈希:

在一致哈希算法中,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响

但是一致性哈希算法并不能保证节点在哈希环上均匀分布一致性哈希算法虽然减少了数据迁移量,但是存在节点分布不均匀的问题

我们可以通过加入虚拟节点来让分布更加均匀

除此之外,虚拟节点可以有若干好处:

END