秋招复习笔记——八股文部分:操作系统

笔试得刷算法题,那面试就离不开八股文,所以特地对着小林coding的图解八股文系列记一下笔记。

这一篇笔记是图解系统的内容。

硬件结构

CPU执行程序

计算机基本结构为 5 个部分,分别是运算器、控制器、存储器、输入设备、输出设备,这 5 个部分也被称为冯诺依曼模型

存储单元和输入输出设备的通讯如下:

通过总线完成通讯

内存地址从0编号,自增排列。

CPU一次计算的数据量分为32位(4个字节)以及64位(8个字节),CPU中有寄存器来存储计算时数据,有通用寄存器、程序计数器以及指令寄存器。

总线有地址总线、数据总线以及控制总线,分为完成寻址、读写内存数据以及发送和接收信号。

CPU位宽一般不小于线路位宽,32位CPU一般为32位地址总线。

程序执行的时候,耗费的 CPU 时间少就说明程序是快的,对于程序的 CPU 执行时间,可以拆解成 CPU 时钟周期数(CPU Cycles)和时钟周期时间(Clock Cycle Time)的乘积

CPU执行时间

CPU 时钟周期数可以进一步拆解成:「指令数 x 每条指令的平均时钟周期数(Cycles Per Instruction,简称 CPI)」,由此继续化简公式:

CPU执行时间

磁盘与内存

CPU中有数据存储,也就是CPU Cache,成为CPU高速缓存,通常分为L1、L2、L3三成,L1 Cache通常分为“数据缓存”和“指令缓存”。

CPU中,操作最快的是寄存器,然后依次是CPU Cache,然后是内存,最后是硬盘。

CPU Cache 用的是一种叫 SRAM(Static Random-Access Memory,静态随机存储器) 的芯片。而三层Cache如下所示:

CPU Cache

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

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

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

内存用的芯片和 CPU Cache 有所不同,它使用的是一种叫作 DRAM (Dynamic Random Access Memory,动态随机存取存储器) 的芯片。访问速度更慢,内存速度大概在 200~300 个 时钟周期之间。

CPU访问内存中数据的流程:

CPU访问内存中数据

提升CPU中代码运行效率

CPU Cache 的数据是从内存中读取过来的,它是以一小块一小块读取数据的,而不是按照单个数组元素来读取数据的,在 CPU Cache 中的,这样一小块一小块的数据,称为 Cache Line(缓存块)。Cache Line 是 CPU 从内存读取数据的基本单位,而 Cache Line 是由各种标志(Tag)+ 数据块(Data Block)组成。

一般CPU访问内存,就会通过直接映射Cache来完成(Direct Mapped Cache)。一个内存的访问地址,包括组标记、CPU Cache Line 索引、偏移量这三种信息,于是 CPU 就能通过这些信息,在 CPU Cache 中找到缓存的数据。而对于 CPU Cache 里的数据结构,则是由索引 + 有效位 + 组标记 + 数据块组成

内存地址的访问

如果 CPU 所要操作的数据在 CPU Cache 中的话,这样将会带来很大的性能提升。访问的数据在 CPU Cache 中的话,意味着缓存命中,缓存命中率越高的话,代码的性能就会越好,CPU 也就跑的越快

按照内存布局顺序访问,将可以有效的利用 CPU Cache 带来的好处,这样我们代码的性能就会得到很大的提升,提升数据缓存的命中率。

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

CPU的缓存一致性

把CPU的Cache数据写回到内存的时机很重要。

为了要减少数据写回内存的频率,就出现了写回(Write Back)的方法。
当发生写操作时,新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block「被替换」时才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。

写回机制流程图

由于CPU当前大多是多核的,而L1和L2都是多个核心独有的,就会有缓存一致性问题(Cache Coherence)。

写传播的原则就是当某个 CPU 核心更新了 Cache 中的数据,要把该事件广播通知到其他核心。最常见实现的方式是总线嗅探(Bus Snooping)。但是这就需要 CPU 时刻监听总线,负担很重。

MESI协议则通过添加状态机来改善总线嗅探的问题。具体的方法可以在面试前直接看下面的表格熟悉一下

MESI的操作

CPU执行任务

多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象称为伪共享(False Sharing)。在 Linux 内核中存在 __cacheline_aligned_in_smp 宏定义,其可以在原先a、b两个在物理内存地址上连续的变量,通过这个宏定义将b的地址设置为 Cache Line 对齐地址,避免伪共享。

CPU选择执行的线程,在 Linux 中,线程和进程都是用 task_struct 结构体表示,调度器调度这个结构体,来完成执行。这些任务分为普通任务和实时任务。优先级数值越小则优先级越高。

对于普通任务,一般用完全公平调度(Completely Fair Scheduling)算法,优先选择虚拟运行时间 vruntime 少的任务来执行,当然还要考虑普通任务的权重值,nice 级别越低权重值越大。nice 的值能设置的范围是 -20~19, 值越低,表明优先级越高,因此 -20 是最高优先级,19 则是最低优先级,默认优先级是 0。

vruntime计算公式

软中断

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

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

中断处理程序的上部分和下半部可以理解为:

  • 上半部直接处理硬件请求,也就是硬中断,主要是负责耗时短的工作,特点是快速执行;
  • 下半部是由内核触发,也就说软中断,主要是负责上半部未完成的工作,通常都是耗时比较长的事情,特点是延迟执行;

数据存储

计算机中,负数通过补码来完成保存。所谓的补码就是把正数的二进制全部取反再加 1。用了补码的表示方式,对于负数的加减法操作,实际上是和正数加减法操作一样的。

存小数的方法可以直接去看这个链接:小林图解系统中的数据存储部分

内存管理

虚拟内存

操作系统引入了虚拟内存,进程持有的虚拟地址会通过 CPU 芯片中的内存管理单元(MMU)的映射关系,来转换变成物理地址,然后再通过物理地址访问内存。

为了减少传统的分段管理导致的内存碎片,提出了内存分页(Paging)方法,把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB

虚拟地址和物理地址由MMU通过页表映射

内存管理单元(MMU)会负责映射工作,采用了分页,页与页之间是紧密排列的,所以不会有外部碎片。

如果内存空间不够,操作系统会把其他正在运行的进程中的「最近没被使用」的内存页面给释放掉,也就是暂时写在硬盘上,称为换出(Swap Out)。一旦需要的时候,再加载进来,称为换入(Swap In)。所以,一次性写入磁盘的也只有少数的一个页或者几个页,不会花太多时间,内存交换的效率就相对比较高。

Swap操作提升内存交换效率

在分页机制下,虚拟地址分为两部分页号页内偏移页号作为页表的索引,页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址。但这种简单的分页,存在空间缺陷,因为页表由于多个进程会变得非常庞大,故采用多级页表(Multi-Level Page Table)来解决

例如在32位 CPU 下,页大小 4KB ,一个进程的页表需要 4 MB 空间,而对其进行二次分页,将页表(一级页表)分为 1024 个页表(二级页表),每个表(二级页表)中包含 1024 个「页表项」,形成二级分页。这样,如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表

二级分页

但是多级页表,会造成虚拟内存到物理地址上更多的转换步骤,所以把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等

内存分段和内存分页并不是对立的,它们是可以组合起来在同一个系统中使用的,那么组合起来后,通常称为段页式内存管理。

Linux 系统中的每个段都是从 0 地址开始的整个 4GB 虚拟空间(32 位环境下),也就是所有的段的起始地址都是一样的。这意味着,Linux 系统中的代码,包括操作系统本身的代码和应用程序代码,所面对的地址空间都是线性地址空间(虚拟地址),这种做法相当于屏蔽了处理器中的逻辑地址概念,段只被用于访问控制和内存保护

在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分, 32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间。

内核空间与用户空间的区别:

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

其中用户态的分布:代码段、全局变量、BSS、函数栈、堆内存、映射区。

虚拟内存作用:

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

malloc分配内存

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

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

方式一实现的方式很简单,就是通过 brk() 函数将「堆顶」指针向高地址移动,获得新的内存空间。方式二通过 mmap() 系统调用中「私有匿名映射」的方式,在文件映射区分配一块内存,也就是从文件映射区“偷”了一块内存。这两个方法切换的阈值是 128KB, 小于 128KB 就会采用 brk() 来分配。

malloc会分配的时虚拟内存,且会预分配更大的空间作为内存池。free 内存后堆内存还存在,是针对 malloc 通过 brk() 方式申请的内存的情况。如果 malloc 通过 mmap 方式申请的内存,free 释放内存后就会归归还给操作系统。

如果都用 mmap 来分配内存,等于每次都要执行系统调用。执行系统调用会需要切换进入内核态,然后回到用户态,需要消耗时间。同时,mmap 分配内存释放会归还给操作系统,所以分配的地址都会缺页,第一次访问该虚拟地址就会触发缺页中断,而 brk() 申请的堆空间连续,再次申请只需要从内存池取出;但如果都用 brk(),会造成堆内大量不可用碎片,导致内存泄漏。

缺页中断出发后,进程从用户态进入内核态,缺页中断函数会查看是否有空余的物理内存,有的话就分配并建立映射关系。

内存满了会发生什么

缺页中断触发,如果没有空闲物理内存,就会进行回收内存

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

如果直接内存回收后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了 ——触发 OOM (Out of Memory)机制。OOM Killer 机制会根据算法选择一个占用物理内存较高的进程,然后将其杀死,以便释放内存资源,如果物理内存依然不足,OOM Killer 会继续杀死占用物理内存较高的进程,直到释放足够的内存位置。

申请物理内存流程

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

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

从文件页和匿名页的回收操作来看,文件页的回收操作对系统的影响相比匿名页的回收操作会少一点,因为文件页对于干净页回收是不会发生磁盘 I/O 的,而匿名页的 Swap 换入换出这两个操作都会发生磁盘 I/O。所以尽可能先回收文件页。

尽早触发 kswapd 内核线程异步回收内存。具体看这里:kswapd 回收设置

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

如何避免预读失效和缓存污染

LInux 中,读取的文件数据缓存在文件系统中的 Page Cache,大小有限,所以希望通过保留频繁访问的数据在其中,经典的是采用 LRU(Least recently used)算法完成,一般采用链表,头部是最近使用的数据,尾部就是最久没被使用的。

预读机制会把多个 Page 数据装入 Page Cache,以此减少磁盘 I/O 次数,提高吞吐量。但如果这些被提前加载进来的页,并没有被访问,相当于这个预读工作是白做了,这个就是预读失效。为了避免此类问题,最好就是让预读页停留在内存里的时间要尽可能的短,让真正被访问的页才移动到 LRU 链表的头部,从而保证真正被读取的热数据留在内存里的时间尽可能长。Linux 操作系统实现两个了 LRU 链表:活跃 LRU 链表(active_list)和非活跃 LRU 链表(inactive_list);将数据分为了冷数据和热数据,然后分别进行 LRU 算法。预读页就只需要加入到 inactive list 区域的头部,当页被真正访问的时候,才将页插入 active list 的头部

缓存污染:当我们在批量读取数据的时候,由于数据被访问了一次,这些大量数据都会被加入到「活跃 LRU 链表」里,然后之前缓存在活跃 LRU 链表(或者 young 区域)里的热点数据全部都被淘汰了,如果这些大量的数据在很长一段时间都不会被访问的话,那么整个活跃 LRU 链表(或者 young 区域)就被污染了。为了解决缓存污染,只要我们提高进入到活跃 LRU 链表(或者 young 区域)的门槛,就能有效地保证活跃 LRU 链表(或者 young 区域)里的热点数据不会被轻易替换掉。在 Linux 中,只有内存页被访问第二次,才将页从 inactive list 升级到 active list 中。

Linux 虚拟内存管理、物理内存管理

直接看这里:Linux 虚拟内存

进程管理

基础知识

进程

编写的代码只是一个存储在硬盘的静态文件,通过编译后就会生成二进制可执行文件,当我们运行这个可执行文件后,它会被装载到内存中,接着 CPU 会执行程序中的每一条指令,那么这个运行中的程序,就被称为「进程」(Process)

单核的 CPU 在某一个瞬间,只能运行一个进程。但在 1 秒钟期间,它可能会运行多个进程,这样就产生并行的错觉,实际上这是并发。

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

进程的三种基本状态

再加上创建状态和结束状态,完整的进程状态图如下:

进程状态图

同时,阻塞装填太多,进程可能占用大量物理内存空间,可以把这些空间换出到硬盘,也就是 swap out,那么需要一个挂起状态来描述没有占用实际物理内存空间的情况。同时挂起还需分成阻塞挂起和就绪挂起。那么就变成了下图:

进程状态切换

PCB 是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个 PCB,如果进程消失了,那么 PCB 也会随之消失。包含了进程描述信息、进程控制和管理信息、资源分配清单以及 CPU 相关信息。

PCB 一般通过链表形式组织,把具有相同状态的进程链在一起,组成各种队列

01 创建进程

操作系统允许一个进程创建另一个进程,而且允许子进程继承父进程所拥有的资源。

创建进程的过程如下:

  • 申请一个空白的 PCB,并向 PCB 中填写一些控制和管理进程的信息,比如进程的唯一标识等;
  • 为该进程分配运行时所必需的资源,比如内存资源;
  • 将 PCB 插入到就绪队列,等待被调度运行;

02 终止进程

进程可以有 3 种终止方式:正常结束、异常结束以及外界干预(信号 kill 掉)。

当子进程被终止时,其在父进程处继承的资源应当还给父进程。而当父进程被终止时,该父进程的子进程就变为孤儿进程,会被 1 号进程收养,并由 1 号进程对它们完成状态收集工作。

终止进程的过程如下:

  • 查找需要终止的进程的 PCB;
  • 如果处于执行状态,则立即终止该进程的执行,然后将 CPU 资源分配给其他进程;
  • 如果其还有子进程,则应将该进程的子进程交给 1 号进程接管;
  • 将该进程所拥有的全部资源都归还给操作系统;
  • 将其从 PCB 所在队列中删除;

03 阻塞进程

当进程需要等待某一事件完成时,它可以调用阻塞语句把自己阻塞等待。而一旦被阻塞等待,它只能由另一个进程唤醒。

阻塞进程的过程如下:

  • 找到将要被阻塞进程标识号对应的 PCB;
  • 如果该进程为运行状态,则保护其现场,将其状态转为阻塞状态,停止运行;
  • 将该 PCB 插入到阻塞队列中去;

04 唤醒进程

进程由「运行」转变为「阻塞」状态是由于进程必须等待某一事件的完成,所以处于阻塞状态的进程是绝对不可能叫醒自己的。

如果某进程正在等待 I/O 事件,需由别的进程发消息给它,则只有当该进程所期待的事件出现时,才由发现者进程用唤醒语句叫醒它。

唤醒进程的过程如下:

  • 在该事件的阻塞队列中找到相应进程的 PCB;
  • 将其从阻塞队列中移出,并置其状态为就绪状态;
  • 把该 PCB 插入到就绪队列中,等待调度程序调度;

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

线程

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

线程是调度的基本单位,而进程则是资源拥有的基本单位。那么对于线程的上下文切换就得分成两种:两个线程不属于同一个进程,那么切换就跟进程上下文切换一样;当两个线程是属于同一个进程,因为虚拟内存是共享的,所以在切换时,虚拟内存这些资源就保持不动,只需要切换线程的私有数据、寄存器等不共享的数据

线程的实现方式有三种:

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

以上三种实现方式,第一种是多个用户线程对一个内核线程;第二种则是一对一;第三种是多对多。

用户线程是基于用户态的线程管理库来实现的,那么线程控制块(Thread Control Block, TCB) 也是在库里面来实现的,对于操作系统而言是看不到这个 TCB 的,它只能看到整个进程的 PCB。所以,用户线程的整个线程管理和调度,操作系统是不直接参与的,而是由用户级线程库函数来完成线程的管理,包括线程的创建、终止、同步和调度等。内核线程是由操作系统管理的,线程对应的 TCB 自然是放在操作系统里的,这样线程的创建、终止和管理都是由操作系统负责。轻量级进程(Light-weight process,LWP)是内核支持的用户线程一个进程可有一个或多个 LWP,每个 LWP 是跟内核线程一对一映射的,也就是 LWP 都是由一个内核线程支持,而且 LWP 是由内核管理并像普通进程一样被调度。在大多数系统中,LWP与普通进程的区别也在于它只有一个最小的执行上下文和调度程序所需的统计信息

调度

调度程序(scheduler):操作系统中选择一个进程运行。在进程的生命周期中,当进程从一个运行状态到另外一状态变化的时候,其实会触发一次调度。如果硬件时钟提供某个频率的周期性中断,那么可以根据如何处理时钟中断 ,分为非抢占式调度和抢占式调度。调度算法的原则如下:

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

一些调度算法:小林总结的调度算法

进程间通信方式

管道

在 Linux 命令中的「|」这个竖线,就是管道,将左边的输出作为右边的输入。这里管道是匿名管道用完就销毁,也可以用mkfifo命令指定管道名字。

管道这种通信方式效率低,不适合进程间频繁地交换数据。

匿名创建管道,会用到系统调用:

int pipe(int fd[2])

返回了两个描述符,一个是管道的读取端描述符 fd[0],另一个是管道的写入端描述符 fd[1]。且只存在于内存之中。

所谓的管道,就是内核里面的一串缓存。如果在 Linux 中使用 fork 创建子进程,那么创建的子进程会复制父进程的文件描述符,为了避免混乱传输,一般会关闭父进程的 fd[0],关闭子进程的 fd[1],从而实现父进程可传输到子进程中。如果要双向传输那么就需要创建两个管道。

对于匿名管道,它的通信范围是存在父子关系的进程。对于命名管道,它可以在不相关的进程间也能相互通信。

消息队列

为了频繁交换数据就可以用消息队列。比如,A 进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此。

消息队列是保存在内核中的消息链表;消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在。

消息队列不适合比较大数据的传输;消息队列通信过程中,存在用户态与内核态之间的数据拷贝开销。

共享内存

共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中

信号量

为了防止多进程竞争共享资源,而造成的数据错乱,所以需要保护机制,使得共享的资源,在任意时刻只能被一个进程访问。正好,信号量就实现了这一保护机制

信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。

信号量的控制有两种原子操作:

  • 一个是 P 操作,这个操作会把信号量减去 1,相减后如果信号量 < 0,则表明资源已被占用,进程需阻塞等待;相减后如果信号量 >= 0,则表明还有资源可使用,进程可正常继续执行。
  • 另一个是 V 操作,这个操作会把信号量加上 1,相加后如果信号量 <= 0,则表明当前有阻塞中的进程,于是会将该进程唤醒运行;相加后如果信号量 > 0,则表明当前没有阻塞中的进程;

P 操作是用在进入共享资源之前,V 操作是用在离开共享资源之后,这两个操作是必须成对出现的。

信号初始化为 1,就代表着是互斥信号量,可以保证共享内存任意时刻只有一个进程访问。用信号量来实现多进程同步的方式,我们可以初始化信号量为 0,代表着同步信号量,可用于保证某一进程一定在前执行。

信号

对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。

这个例如在 Linux 中,一共有 64 种信号,例如 Ctrl + C 就是发送 SIGINT 信号,来终止进程;而常用的 kill -9 PID,就是发送 SIGKILL 信号来立即结束 PID 进程。

信号是进程间通信机制中唯一的异步通信机制

socket

想跨网络与不同主机上的进程之间通信,就需要 Socket 通信了。

创建 socket 的系统调用如下:

int socket(int domain, int type, int protocal)

一般通过前两个参数完成,protocal 直接设为 0,domain 来制定协议族(例如IPV4),type 指定通信特性(SOCK_STREAM 表示的是字节流,对应 TCP、SOCK_DGRAM 表示的是数据报,对应 UDP、SOCK_RAW 表示的是原始套接字)。

有三种常见的通信方式,一个是基于 TCP 协议的通信方式,一个是基于 UDP 协议的通信方式,一个是本地进程间通信方式。具体的在图解网络应该会有。

多线程冲突

竞争条件(race condition),当多线程相互竞争操作共享变量时,由于运气不好,即在执行过程中发生了上下文切换,我们得到了错误的结果,事实上,每次运行都可能得到不同的结果,因此输出的结果存在不确定性(indeterminate)。

由于多线程执行操作共享变量的这段代码可能会导致竞争状态,因此我们将此段代码称为临界区(critical section),它是访问共享资源的代码片段,一定不能给多线程同时执行

我们希望这段代码是互斥(mutualexclusion)的,也就说保证一个线程在临界区执行时,其他线程应该被阻止进入临界区,说白了,就是这段代码执行过程中,最多只能出现一个线程。

同步,就是并发进程/线程在一些关键点上可能需要互相等待与互通消息,这种相互制约的等待与互通信息称为进程/线程同步。

互斥和同步,主要可以采用锁/信号量来完成;信号量比锁的功能更加强大。这其中均涉及原子操作,就是要么全部执行,要么都不执行,不能出现执行到一半的中间状态。

信号量实现临界区的互斥访问:将信号量初值均设置为 1;信号量实现临界区的同步访问:将信号量初值均设置为 0。

生产者——消费者模型:需要互斥信号量,以及两个用于生产者和消费者对缓存区访问的同步信号量,一共三个信号量。

哲学家就餐问题:设置五个互斥信号量,同时对于奇数和偶数哲学家区分先拿的叉子,避免同时只能一个哲学家就餐。对于互斥访问有限的竞争问题(如 I/O 设备)一类的建模过程十分有用

读者——写者问题:可以有多个读者,只能一个写者,分别设置两个互斥信号量,同时写一个rCount记录读者数量,只有rCount为零才可以进行对写信号量的 V 操作;同时为了实现公平策略,需要设置一个互斥信号量 flag,无论是writer还是reader都需要对 flag 信号量先操作在进入写/读的互斥信号量操作。对数据库建模非常有用。

避免死锁

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

同时满足以下四个条件才可能发生:

  • 互斥条件;
  • 持有并等待条件;
  • 不可剥夺条件;
  • 环路等待条件;

避免死锁问题就只需要破环其中一个条件就可以,最常见的并且可行的就是使用资源有序分配法,来破环环路等待条件。也就是说,比如两个进程,来获取两个资源,那只要将两者请求获取的顺序改成一样即可

悲观锁、乐观锁

互斥锁、自旋锁

最底层的两种所,两者对加锁失败的处理方式不同:

  • 互斥锁加锁失败后,线程会释放 CPU ,给其他线程;
  • 自旋锁加锁失败后,线程会忙等待,直到它拿到锁;

互斥锁是一种独占锁,如果加锁失败,那么加锁代码会被阻塞,对于互斥锁加锁失败而阻塞的现象,是由操作系统内核实现的,会从用户态陷入到内核态,让内核帮助切换线程。但这会造成有一定的开销成本,也就是两次线程上下文切换的成本。因此,如果能确定被锁住的代码执行时间很短,就不应该用互斥锁,而应该选用自旋锁,否则使用互斥锁

自旋锁是最比较简单的一种锁,一直自旋,利用 CPU 周期,直到锁可用。需要注意,在单核 CPU 上,需要抢占式的调度器(即不断通过时钟中断一个线程,运行其他线程)。否则,自旋锁在单 CPU 上无法使用,因为一个自旋的线程永远不会放弃 CPU。自旋锁适合异步、协程等在用户态切换请求的编程方式,但如果被锁住的代码执行时间过长,自旋的线程会长时间占用 CPU 资源,所以自旋的时间和被锁住的代码执行的时间是成「正比」的关系。

当加锁失败时,互斥锁用「线程切换」来应对,自旋锁则用「忙等待」来应对。

读写锁

读写锁适用于能明确区分读操作和写操作的场景。读写锁在读多写少的场景,能发挥出优势

工作原理如下:

  • 当「写锁」没有被线程持有时,多个线程能够并发地持有读锁,这大大提高了共享资源的访问效率,因为「读锁」是用于读取共享资源的场景,所以多个线程同时持有读锁也不会破坏共享资源的数据。
  • 但是,一旦「写锁」被线程持有后,读线程的获取读锁的操作会被阻塞,而且其他写线程的获取写锁的操作也会被阻塞。

根据实现不同,读写锁可以分为「读优先锁」和「写优先锁」。公平读写锁比较简单的一种方式是:用队列把获取锁的线程排队,不管是写线程还是读线程都按照先进先出的原则加锁即可,这样读线程仍然可以并发,也不会出现「饥饿」的现象。

乐观锁与悲观锁

上文中的互斥锁、自旋锁和读写锁都属于悲观锁。悲观锁认为多线程同时修改共享资源的概率比较高,于是很容易出现冲突,所以访问共享资源前,先要上锁

乐观锁做事比较乐观,它假定冲突的概率很低,它的工作方式是:先修改完共享资源,再验证这段时间内有没有发生冲突,如果没有其他线程在修改资源,那么操作完成,如果发现有其他线程已经修改过这个资源,就放弃本次操作。乐观锁全程并没有加锁,所以它也叫无锁编程乐观锁的一大应用场景就是在线文档只有在冲突概率非常低,且加锁成本非常高的场景时,才考虑使用乐观锁

一个进程最多创建几个线程

有以下两个影响因素:

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

在 Linux 中,可通过 ulimit -a 这条命令查看进程创建线程是默认分配的栈空间大小。针对 32 位 Linux 系统一个进程的虚拟空间有 4G,内核分走 1G,用户空间 3G。那么可创建的线程数最多可有 3G 大小

如果想要修改默认的栈大小,可以通过 ulimit -s number 来调整,number 就是调整后的大小为 number kb。

当然如果是 64 位系统,用户空间的虚拟内存最大能到 128T,理论上可以创建无数个线程。但是有三个内核参数会影响线程的上限:

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

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

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

例如,针对只读内存写入数据;访问了进程无权限访问的空间(如内核空间);访问了不存在的内存。以上均会导致进程崩溃。

进程崩溃——信号机制

线程崩溃之后,由于信号会导致进程崩溃。

机制:CPU执行进程;调用 kill 系统调用向进程发送信号;进程收到信号,CPU 暂停并将控制权转交给操作系统;调用 kill 系统调用向进程发送信号;操作系统根据情况执行相应的信号处理程序(函数),一般执行完信号处理程序逻辑后会让进程退出。如果注册了自己的信号处理函数就会调用,没注册就会执行默认的信号处理程序。

调度算法

进程调度算法

通常如下情况就会发生 CPU 调度:

  1. 当进程从运行状态转到等待状态;
  2. 当进程从运行状态转到就绪状态;
  3. 当进程从等待状态转到就绪状态;
  4. 当进程从运行状态转到终止状态;

1 和 4 两种情况下的调度称为「非抢占式调度」,2 和 3 两种情况下发生的调度称为「抢占式调度」。

常见的调度算法如下。

先来先服务(First Come First Severd, FCFS)算法

每次从就绪队列选择最先进入队列的进程,然后一直运行,直到进程退出或被阻塞,才会继续从队列中选择第一个进程接着运行。

FCFS 对长作业有利,适用于 CPU 繁忙型作业的系统,而不适用于 I/O 繁忙型作业的系统。

最短作业优先(Shortest Job First, SJF)算法

优先选择运行时间最短的进程来运行,这有助于提高系统的吞吐量。

但显然对长作业不利,很容易造成一种极端现象。

高响应比优先 (Highest Response Ratio Next, HRRN)算法

每次进行进程调度时,先计算「响应比优先级」,然后把「响应比优先级」最高的进程投入运行,权衡了短作业和长作业。

「响应比优先级」计算公式如下:

响应优先级计算

时间片轮转(Round Robin, RR)算法

每个进程被分配一个时间段,称为时间片(Quantum),即允许该进程在该时间段中运行。

  • 如果时间片用完,进程还在运行,那么将会把此进程从 CPU 释放出来,并把 CPU 分配另外一个进程;
  • 如果该进程在时间片结束前阻塞或结束,则 CPU 立即进行切换;

通常时间片设为 20ms~50ms 通常是一个比较合理的折中值。

最高优先级(Highest Priority First,HPF)算法

对于多用户计算机系统就有不同的看法了,它们希望调度是有优先级的,即希望调度程序能从就绪队列中选择最高优先级的进程进行运行。

进程的优先级可以分为,静态优先级或动态优先级:

  • 静态优先级:创建进程时候,就已经确定了优先级了,然后整个运行时间优先级都不会变化;
  • 动态优先级:根据进程的动态变化调整优先级,比如如果进程运行时间增加,则降低其优先级,如果进程等待时间(就绪队列的等待时间)增加,则升高其优先级,也就是随着时间的推移增加等待进程的优先级

该算法也有两种处理优先级高的方法,非抢占式和抢占式:

  • 非抢占式:当就绪队列中出现优先级高的进程,运行完当前进程,再选择优先级高的进程。
  • 抢占式:当就绪队列中出现优先级高的进程,当前进程挂起,调度优先级高的进程运行。

该算法可能会导致低优先级的进程永远不会运行

多级反馈队列(Multilevel Feedback Queue)算法

该算法是「时间片轮转算法」和「最高优先级算法」的综合和发展。

这个工作的逻辑可以直接看下面这张图:

多级反馈队列工作流程

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

内存页面置换算法

首先要了解缺页异常(缺页中断)。这个在之前有学习过,不再赘述。

主要流程就是,访问虚拟内存后发现不存在对应的物理内存,就会产生缺页中断,去请求操作系统查询空闲的磁盘空间,将他分配到物理内存,并分配给这一块虚拟内存。

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

虚拟内存的整个管理流程如下图所示:

虚拟内存管理

综上,页面置换算法的功能是,当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面,也就是说选择一个物理页面换出到磁盘,然后把需要访问的页面换入到物理页

最佳页面置换算法(OPT)

思路是,置换在「未来」最长时间不访问的页面。这就需要计算内存中每个逻辑页面的「下一次」访问时间,然后比较,选择未来最长时间不访问的页面。但是实际系统中无法实现,因为程序访问页面时是动态的,我们是无法预知每个页面在「下一次」访问前的等待时间。所以,最佳页面置换算法作用是为了衡量你的算法的效率,你的算法效率越接近该算法的效率,那么说明你的算法是高效的

先进先出置换算法(FIFO)

可以选择在内存驻留时间很长的页面进行中置换,这个就是「先进先出置换」算法的思想。

最近最久未使用置换算法(LRU)

思想是,选择最长时间没有被访问的页面进行置换,也就是说,该算法假设已经很久没有使用的页面很有可能在未来较长的一段时间内仍然不会被使用。该种算法近似最优置换算法。

虽然 LRU 在理论上是可以实现的,但代价很高。为了完全实现 LRU,需要在内存中维护一个所有页面的链表,最近最多使用的页面在表头,最近最少使用的页面在表尾。困难的是,在每次访问内存时都必须要更新「整个链表」。在链表中找到一个页面,删除它,然后把它移动到表头是一个非常费时的操作。实际中比较少使用。

时钟页面置换算法(Clock)

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

该算法的思路是,把所有的页面都保存在一个类似钟面的「环形链表」中,一个表针指向最老的页面。发生缺页就首先检查表针指向的页面:

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

最不常用置换算法(LFU)

当发生缺页中断时,选择「访问次数」最少的那个页面,并将其淘汰。

实现方式是,对每个页面设置一个「访问计数器」,每当一个页面被访问时,该页面的访问计数器就累加 1。在发生缺页中断时,淘汰计数器值最小的那个页面。

计数器对于硬件成本而言是较高的,而且查找链表本身,如果链表长度很大也是很耗时的。

还有个问题,LFU 算法只考虑了频率问题,没考虑时间的问题,比如有些页面在过去时间里访问的频率很高,但是现在已经没有访问了,而当前频繁访问的页面由于没有这些页面访问的次数高,在发生缺页中断时,就会可能会误伤当前刚开始频繁访问,但访问次数还不高的页面。针对这个问题可以定期减少访问的次数,以前的高访问次数的页面会慢慢减少,相当于加大了被置换的概率。

磁盘调度算法

磁盘调度算法的目的很简单,就是为了提高磁盘的访问性能,一般是通过优化磁盘的访问请求顺序来做到的。寻道的时间是磁盘访问最耗时的部分,如果请求顺序优化的得当,必然可以节省一些不必要的寻道时间,从而提高磁盘的访问性能。

先来先服务算法(FCFS)

顾名思义就是先来的先被服务。

最短寻道时间优先算法(Shortest Seek First,SSF)

优先选择从当前磁头位置所需寻道时间最短的请求。

但这个算法可能存在某些请求的饥饿,这里产生饥饿的原因是磁头在一小块区域来回移动

扫描算法(Scan)

为了解决磁头只在一个小区域来回运动,可以规定:磁头在一个方向上移动,访问所有未完成的请求,直到磁头到达该方向上的最后的磁道,才调换方向,这就是扫描(Scan)算法。

扫描调度算法性能较好,不会产生饥饿现象,但是存在这样的问题,中间部分的磁道会比较占便宜,中间部分相比其他部分响应的频率会比较多,也就是说每个磁道的响应频率存在差异

循环扫描算法(Circular Scan, C-SCAN)

只有磁头朝某个特定方向移动时,才处理磁道访问请求,而返回时直接快速移动至最靠边缘的磁道,也就是复位磁头,这个过程是很快的,并且返回中途不处理任何请求,该算法的特点,就是磁道只响应一个方向上的请求

LOOK 与 C-LOOK算法

为了优化磁头移动到磁盘「最始端或最末端」才开始调换方向的问题,可以磁头在移动到「最远的请求」位置,然后立即反向移动

针对 SCAN 算法的优化则叫 LOOK 算法,它的工作方式,磁头在每个方向上仅仅移动到最远的请求位置,然后立即反向移动,而不需要移动到磁盘的最始端或最末端,反向移动的途中会响应请求

针 C-SCAN 算法的优化则叫 C-LOOK,它的工作方式,磁头在每个方向上仅仅移动到最远的请求位置,然后立即反向移动,而不需要移动到磁盘的最始端或最末端,反向移动的途中不会响应请求

文件系统

文件系统全家桶

基本组成

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

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

目录项和索引节点的关系是多对一。注意,目录也是文件,也是用索引节点唯一标识,和普通文件不同的是,普通文件在磁盘里面保存的是文件数据,而目录文件在磁盘里面保存子目录或文件。目录是个文件,持久化存储在磁盘,而目录项是内核一个数据结构,缓存在内存。

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

总结一下,索引节点、目录项以及文件数据的关系如下图所示:

文件系统的基本组成

上图中可见,磁盘格式化的时候,会被分成三个存储区域,分别是超级块、索引节点区和数据块区。

  • 超级块,用来存储文件系统的详细信息,比如块个数、块大小、空闲块等等。
  • 索引节点区,用来存储索引节点;
  • 数据块区,用来存储文件或目录数据;

为了加速文件访问,通常把索引节点加载到内存中,且只在需要时加载:

  • 超级块:当文件系统挂载时进入内存;
  • 索引节点区:当文件被访问时进入内存;

虚拟文件系统

操作系统希望对用户提供一个统一的接口,于是在用户层与文件系统层引入了中间层,这个中间层就称为虚拟文件系统(Virtual File System,VFS)。

VFS 定义了一组所有文件系统都支持的数据结构和标准接口,在 Linux 文件系统中,用户空间、系统调用、虚拟文件系统、缓存、文件系统以及存储之间的关系如下图:

Linux 下文件系统

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

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

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

文件的使用

打开了一个文件后,操作系统会跟踪进程打开的所有文件,所谓的跟踪呢,就是操作系统为每个进程维护一个打开文件表,文件表里的每一项代表「文件描述符」,所以说文件描述符是打开文件的标识

而操作系统会在打开文件表中维护打开文件的状态信息:

  • 文件指针:系统跟踪上次读写位置作为当前文件位置指针,这种指针对打开文件的某个进程来说是唯一的;
  • 文件打开计数器:文件关闭时,操作系统必须重用其打开文件表条目,否则表内空间不够用。因为多个进程可能打开同一个文件,所以系统在删除打开文件条目之前,必须等待最后一个进程关闭文件,该计数器跟踪打开和关闭的数量,当该计数为 0 时,系统关闭文件,删除该条目;
  • 文件磁盘位置:绝大多数文件操作都要求系统修改文件数据,该信息保存在内存中,以免每个操作都从磁盘中读取;
  • 访问权限:每个进程打开文件都需要有一个访问模式(创建、只读、读写、添加等),该信息保存在进程的打开文件表中,以便操作系统能允许或拒绝之后的 I/O 请求;

文件系统的基本操作单位是数据块。

文件的存储

可以分为连续空间和非连续空间的存放。

连续空间存放方式顾名思义,文件存放在磁盘「连续的」物理空间中。这种模式下,读写效率很高

使用连续存放的方式有一个前提,必须先知道一个文件的大小,所以,文件头里需要指定「起始块的位置」和「长度」,有了这两个信息就可以很好的表示文件存放方式是一块连续的磁盘空间。

连续空间存放的方式虽然读写效率高,但是有「磁盘空间碎片」和「文件长度不易扩展」的缺陷。另外一个缺陷是文件长度扩展不方便

非连续存放方式分为「链表方式」和「索引方式」。

链表的方式存放是离散的,可大大提高磁盘空间的利用率,同时文件的长度可以动态扩展。根据实现的方式的不同,链表可分为「隐式链表」和「显式链接」两种形式。文件要以「隐式链表」的方式存放的话,实现的方式是文件头要包含「第一块」和「最后一块」的位置,并且每个数据块里面留出一个指针空间,用来存放下一个数据块的位置。缺点在于无法直接访问数据块,只能通过指针顺序访问文件,以及数据块指针消耗了一定的存储空间。隐式链接分配的稳定性较差,系统在运行过程中由于软件或者硬件错误导致链表中的指针丢失或损坏,会导致文件数据的丢失。「显式链接」,它指把用于链接文件各数据块的指针,显式地存放在内存的一张链接表中,该表在整个磁盘仅设置一张,每个表项中存放链接指针,指向下一个数据块号。内存中的这样一个表格称为文件分配表(File Allocation Table,FAT)。可通过这种方法解决隐式链表的两个不足,不仅显著地提高了检索速度,而且大大减少了访问磁盘的次数。但也正是整个表都存放在内存中的关系,它的主要的缺点是不适用于大磁盘

索引的实现是为每个文件创建一个「索引数据块」,里面存放的是指向文件数据块的指针列表。另外,文件头需要包含指向「索引数据块」的指针,这样就可以通过文件头知道索引数据块的位置,再通过索引数据块里的索引信息找到对应的数据块。索引的方式优点在于:文件的创建、增大、缩小很方便;不会有碎片的问题;支持顺序读写和随机读写;但问题就是存储索引带来的开销,可能会因为文件很大导致一个索引数据块存不下索引信息。

链表 + 索引的组合,这种组合称为「链式索引块」,它的实现方式是在索引数据块留出一个存放下一个索引数据块的指针,于是当一个索引数据块的索引信息用完了,就可以通过指针的方式,找到下一个索引数据块的信息。那这种方式也会出现前面提到的链表方式的问题,万一某个指针损坏了,后面的数据也就会无法读取了。

索引 + 索引的方式,这种组合称为「多级索引块」,实现方式是通过一个索引块来存放多个索引数据块,一层套一层索引。

对以上方法进行总结:

文件实现方法比较

Unix 文件系统则总结了以上的优缺点,根据文件大小选择不同的存放方式:

  • 如果存放文件所需的数据块小于 10 块,则采用直接查找的方式;
  • 如果存放文件所需的数据块超过 10 块,则采用一级间接索引方式;
  • 如果前面两种方式都不够存放大文件,则采用二级间接索引方式;
  • 如果二级间接索引也不够存放大文件,这采用三级间接索引方式;

则文件头(Inode)需要包含 13 个指针:10个指向数据块的指针和分别指向索引块的三个指针。

这个方案就用在了 Linux Ext 2/3 文件系统里,虽然解决大文件的存储,但是对于大文件的访问,需要大量的查询,效率比较低。

空闲空间管理

为了寻找可供保存数据块的空间,有三种方法:空闲表法、空闲链表法以及位图法。

空闲表法就是为所有空闲空间建立一张表,表内容包括空闲区的第一个块号和该空闲区的块个数,注意,这个方式是连续分配的。当请求分配磁盘空间时,系统依次扫描空闲表里的内容,直到找到一个合适的空闲区域为止。这种方法仅当有少量的空闲区时才有较好的效果,也适用于建立连续文件。

空闲链表法,就是使用「链表」的方式来管理空闲空间,每一个空闲块里有一个指针指向下一个空闲块,这样也能很方便的找到空闲块并管理起来。同样不适合用于大型文件系统

位图是利用二进制的一位来表示磁盘中一个盘块的使用情况,磁盘上所有的盘块都有一个二进制位与之对应。当值为 0 时,表示对应的盘块空闲,值为 1 时,表示对应的盘块已分配。在 Linux 文件系统中就采用了这种方法。

文件系统结构

采用「一个块的位图 + 一系列的块」,外加「一个块的 inode 的位图 + 一系列的 inode 的结构」能表示的最大空间也就 128M。

在 Linux 文件系统,把这个结构称为一个块组,那么有 N 多的块组,就能够表示 N 大的文件。

目录的存储

普通文件的块里面保存的是文件数据,而目录文件的块里面保存的是目录里面一项一项的文件信息。

在目录文件的块中,最简单的保存格式就是列表,就是一项一项地将目录下的文件信息(如文件名、文件 inode、文件类型等)列在表里。列表中每一项就代表该目录下的文件的文件名和对应的 inode,通过这个 inode,就可以找到真正的文件。

目录的存储结构

为了提升效率,保存目录的格式改成哈希表,对文件名进行哈希计算,把哈希值保存起来,如果我们要查找一个目录下面的文件名,可以通过名称取哈希。如果哈希能够匹配上,就说明这个文件的信息在相应的块里面。

软链接和硬链接

希望给某个文件取个别名,那么在 Linux 中可以通过硬链接(Hard Link) 和软链接(Symbolic Link) 的方式来实现,它们都是比较特殊的文件,但是实现方式也是不相同的。

硬链接是多个目录项中的「索引节点」指向一个文件,也就是指向同一个 inode,但是 inode 是不可能跨越文件系统的,每个文件系统都有各自的 inode 数据结构和列表,所以硬链接是不可用于跨文件系统的。由于多个目录项都是指向一个 inode,那么只有删除文件的所有硬链接以及源文件时,系统才会彻底删除该文件

软链接相当于重新创建一个文件,这个文件有独立的 inode,但是这个文件的内容是另外一个文件的路径,所以访问软链接的时候,实际上相当于访问到了另外一个文件,所以软链接是可以跨文件系统的,甚至目标文件被删除了,链接文件还是在的,只不过指向的文件找不到了而已

文件I/O

文件操作的标准库是可以实现数据的缓存,那么根据「是否利用标准库缓冲」,可以把文件 I/O 分为缓冲 I/O 和非缓冲 I/O

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

比方说,很多程序遇到换行时才真正输出,而换行前的内容,其实就是被标准库暂时缓存了起来,这样做的目的是,减少系统调用的次数,毕竟系统调用是有 CPU 上下文切换的开销的。

根据是否「利用操作系统的缓存」,可以把文件 I/O 分为直接 I/O 与非直接 I/O

  • 直接 I/O,不会发生内核缓存和用户程序之间数据复制,而是直接经过文件系统访问磁盘。
  • 非直接 I/O,读操作时,数据从内核缓存中拷贝给用户程序,写操作时,数据从用户程序拷贝给内核缓存,再由内核决定什么时候写入数据到磁盘。

如果使用文件操作类的系统调用函数时,指定了 O_DIRECT 标志,则表示使用直接 I/O。如果没有设置过,默认使用的是非直接 I/O。采用默认方法,则在以下几种情况会触发内核缓存写入磁盘:

  • 在调用 write 的最后,当发现内核缓存的数据太多的时候,内核会把数据写到磁盘上;
  • 用户主动调用 sync,内核缓存会刷到磁盘上;
  • 当内存十分紧张,无法再分配页面时,也会把内核缓存的数据刷到磁盘上;
  • 内核缓存的数据的缓存时间超过某个时间时,也会把数据刷到磁盘上;

阻塞 I/O,当用户程序执行 read ,线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read 才会返回。注意,阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程。非阻塞 I/O,非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read 调用才可以获取到结果。这里最后一次 read 调用,获取数据的过程,是一个同步的过程,是需要等待的过程。这里的同步指的是内核态的数据拷贝到用户程序的缓存区这个过程。设置了 O_NONBLOCK 标志,那么就表示使用的是非阻塞 I/O 的方式访问,而不做任何设置的话,默认是阻塞 I/O。

为了解决轮询, I/O 多路复用技术就出来了,如 select、poll,它是通过 I/O 事件分发,当内核数据准备好时,再以事件通知应用程序进行操作。最大的优势在于,用户可以在一个线程内同时处理多个 socket 的 IO 请求。

总结一下,无论是阻塞 I/O、非阻塞 I/O,还是基于非阻塞 I/O 的多路复用都是同步调用。因为它们在 read 调用时,内核将数据从内核空间拷贝到应用程序空间,过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间。而真正的异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待

真正的异步,是发起 aio_read 之后,就立即返回,内核自动将数据从内核空间拷贝到应用程序空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。

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

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

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

进程写文件时的缓存

进程写文件(使用缓冲 IO)过程中,写一半的时候,进程发生了崩溃,已写入的数据不会丢失!!!

进程写数据流程

进程在执行 write (使用缓冲 IO)系统调用的时候,实际上是将文件数据写到了内核的 page cache,它是文件系统中用于缓存文件数据的缓冲,所以即使进程崩溃了,文件数据还是保留在内核的 page cache,我们读数据的时候,也是从内核的 page cache 读取,因此还是依然读的进程崩溃前写入的数据。内核会找个合适的时机,将 page cache 中的数据持久化到磁盘。但是如果 page cache 里的文件数据,在持久化到磁盘化到磁盘之前,系统发生了崩溃,那这部分数据就会丢失了。在程序里调用 fsync 函数,在写文文件的时候,立刻将文件数据持久化到磁盘,这样就可以解决系统崩溃导致的文件数据丢失的问题。

Page Cache

Page Cache 的本质是由 Linux 内核管理的内存区域。通过 mmap 以及 buffered I/O 将文件读取到内存空间实际上都是读取到 Page Cache 中。

Page Cache = Buffers + Cached + SwapCached。

page 是内存管理分配的基本单位, Page Cache 由多个 page 构成。page 在操作系统中通常为 4KB 大小(32bits/64bits),而 Page Cache 的大小则为 4KB 的整数倍。

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

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

Swap 机制指的是当物理内存不够用,内存管理单元(Memory Mangament Unit,MMU)需要提供调度算法来回收相关内存空间,然后将清理出来的内存空间给当前内存申请方。也就是之前一直说到的缺页中断之后,主内存没有可用空间,操作系统会从选择合适的物理内存页驱逐回磁盘,为新的内存页让出位置,选择待驱逐页的过程在操作系统中叫做页面替换(Page Replacement),替换操作又会触发 swap 机制

Linux 通过一个 swappiness 参数来控制 Swap 机制:这个参数值可为 0-100,控制系统 swap 的优先级:

  • 高数值:较高频率的 swap,进程不活跃时主动将其转换出物理内存。
  • 低数值:较低频率的 swap,这可以确保交互式不因为内存空间频繁地交换到磁盘而提高响应延迟。

SwapCached 也是 Page Cache 的一部分。这是因为当匿名页(Inactive(anon) 以及 Active(anon))先被交换(swap out)到磁盘上后,然后再加载回(swap in)内存中,由于读入到内存后原来的 Swap File 还在,所以 SwapCached 也可以认为是 File-backed page,即属于 Page Cache。

**Page Cache 用于缓存文件的页数据,buffer cache 用于缓存块设备(如磁盘)的块数据。**两者的共同目的都是加速数据 I/O。在 Linux 2.4 版本内核之后,两块缓存近似融合在了一起:如果一个文件的页加载到了 Page Cache,那么同时 buffer cache 只需要维护块指向页的指针就可以了。现在提起 Page Cache,基本上都同时指 Page Cache 和 buffer cache 两者。

操作系统为基于 Page Cache 的读缓存机制提供预读机制(PAGE_READAHEAD)。

Page Cache与文件持久化的一致性&可靠性

Linux 提供多种机制来保证数据一致性,但无论是单机上的内存与磁盘一致性,还是分布式组件中节点 1 与节点 2 、节点 3 的数据一致性问题,理解的关键是 trade-off:吞吐量与数据一致性保证是一对矛盾。

首先,需要我们理解一下文件的数据。文件 = 数据 + 元数据元数据用来描述文件的各种属性,也必须存储在磁盘上。因此,我们说保证文件一致性其实包含了两个方面:数据一致+元数据一致。

当前 Linux 下以两种方式实现文件一致性:

  • Write Through(写穿):向用户层提供特定接口,应用程序可主动调用接口来保证文件一致性;
  • Write back(写回):系统中存在定期任务(表现形式为内核线程),周期性地同步文件系统中文件脏数据块,这是默认的 Linux 一致性方案;

Write Through 与 Write back 在持久化的可靠性上有所不同:

  • Write Through 以牺牲系统 I/O 吞吐量作为代价,向上层应用确保一旦写入,数据就已经落盘,不会丢失;
  • Write back 在系统发生宕机的情况下无法确保数据已经落盘,因此存在数据丢失的问题。不过,在程序挂了,例如被 kill -9,Page Cache 中的数据操作系统还是会确保落盘;

Page Cache 优劣势

优势:

  1. 加快数据访问
  2. 减少 I/O 次数,提高系统磁盘 I/O 吞吐量

劣势:

最直接的缺点是需要占用额外物理内存空间,物理内存在比较紧俏的时候可能会导致频繁的 swap 操作,最终导致系统的磁盘 I/O 负载的上升。

Page Cache 的另一个缺陷是对应用层并没有提供很好的管理 API,几乎是透明管理。

最后一个缺陷是在某些应用场景下比 Direct I/O 多一次磁盘读 I/O 以及磁盘写 I/O

设备管理

为了屏蔽设备之间的差异,每个设备都有一个叫设备控制器(Device Control) 的组件,比如硬盘有硬盘控制器、显示器有视频控制器等。

设备管理器

设备控制器里有芯片,它可执行自己的逻辑,也有自己的寄存器,用来与 CPU 进行通信。控制器是有三类寄存器,它们分别是状态寄存器(Status Register)、 命令寄存器(Command Register)以及数据寄存器(Data Register)。

输入输出设备可分为两大类 :块设备(Block Device)和字符设备(Character Device)。

  • 块设备,把数据存储在固定大小的块中,每个块有自己的地址,硬盘、USB 是常见的块设备。
  • 字符设备,以字符为单位发送或接收一个字符流,字符设备是不可寻址的,也没有任何寻道操作,鼠标是常见的字符设备。

块设备通常传输的数据量会非常大,于是控制器设立了一个可读写的数据缓冲区

I/O 控制方式

设备控制器对 CPU 的通知方法,可以通过查询控制器的寄存器的状态标志位,通过轮询来完成,但这会占用 CPU 的全部时间。

也可以通过中断来通知 CPU。中断有两种,一种软中断,例如代码调用 INT 指令触发,一种是硬件中断,就是硬件通过中断控制器触发的。但中断的方式对于频繁读写数据的磁盘,并不友好,这样 CPU 容易经常被打断,会占用 CPU 大量的时间。对于这一类设备的问题的解决方法是使用 DMA(Direct Memory Access) 功能,它可以使得设备在 CPU 不参与的情况下,能够自行完成把设备 I/O 数据放入到内存。那要实现 DMA 功能要有 「DMA 控制器」硬件的支持。

DMA工作方式

CPU 当要读取磁盘数据的时候,只需给 DMA 控制器发送指令,然后返回去做其他事情,当磁盘数据拷贝到内存后,DMA 控制机器通过中断的方式,告诉 CPU 数据已经准备好了,可以从内存读数据了。仅仅在传送开始和结束时需要 CPU 干预。

设备驱动程序

每种设备的控制器的寄存器、缓冲区等使用模式都是不同的,所以为了屏蔽「设备控制器」的差异,引入了设备驱动程序。设备驱动程序会提供统一的接口给操作系统

通常,设备驱动程序初始化的时候,要先注册一个该设备的中断处理函数

中断处理函数处理流程

通用块层

Linux 通过一个统一的通用块层,来管理不同的块设备

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

  • 第一个功能,向上为文件系统和应用程序,提供访问块设备的标准接口,向下把各种不同的磁盘设备抽象为统一的块设备,并在内核层面,提供一个框架来管理这些设备的驱动程序;
  • 第二功能,通用层还会给文件系统和应用程序发来的 I/O 请求排队,接着会对队列重新排序、请求合并等方式,也就是 I/O 调度,主要目的是为了提高磁盘读写的效率。

存储系统 I/O 软件分层

把 Linux 存储系统的 I/O 由上到下可以分为三个层次,分别是文件系统层、通用块层、设备层。

整体层次关系

有了文件系统接口之后,不但可以通过文件系统的命令行操作设备,也可以通过应用程序,调用 read、write 函数,就像读写文件一样操作设备,所以说设备在 Linux 下,也只是一个特殊的文件。

存储系统的 I/O 是整个系统最慢的一个环节,所以 Linux 提供了不少缓存机制来提高 I/O 的效率。

  • 为了提高文件访问的效率,会使用页缓存、索引节点缓存、目录项缓存等多种缓存机制,目的是为了减少对块设备的直接调用。
  • 为了提高块设备的访问效率, 会使用缓冲区,来缓存块设备的数据。

网络系统

零拷贝

为了改善磁盘的 I/O 读写速度,CPU会发出对应指令,然后磁盘控制器就会把数据传入内部缓冲区并产生中断,收到中断信号后 CPU 会把数据读入寄存器然后再写入内存。这一期间 CPU 是不执行其他任务的。所以引入了 DMA 技术,也就是直接内存访问(Direct Memory Access) 技术。在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而 CPU 不再参与任何与数据搬运相关的事情,这样 CPU 就可以去处理别的事务

DMA传输

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

针对网络传输,如果服务端需要文件传输,传统方法就是从磁盘读出再通过网络协议发给客户端。这个过程中调用系统调用 read 和 write,会发生 4 次用户态和内核态的上下文切换,还发生了 4 次数据拷贝(两次 DMA 和两次 CPU 拷贝)。所以,要想提高文件传输的性能,就需要减少「用户态与内核态的上下文切换」和「内存拷贝」的次数

减少上下文切换,就要减少系统调用次数。针对传统传输,需要将数据通过用户缓冲区来完成传输,但实质上用户空间内并不需要处理数据,因此用户的缓冲区是没有必要存在的

零拷贝一共 2 种实现方法:

  • mmap + write
  • sendfile

mmap + write

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

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

mmap() 系统调用函数会直接把内核缓冲区里的数据「映射」到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。此时 mmap() 通过 DMA 把磁盘数据拷贝到内核缓冲区并与进程共享缓冲区,之后调用 write() 那么操作系统会直接将内核缓冲区数据拷贝到 socket 缓冲区,最后由 DMA 把 socket 缓冲区数据拷贝到网卡缓冲区。那么 mmap() 替换 read() 就可以减少一次数据拷贝

sendfile

在 LInux 内核 2.1 中,提供了专门发送文件的系统调用 sendfile() 如下:

#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 次数据拷贝。如下图:

如果网卡支持 SG-DMA(The Scatter-Gather Direct Memory Access)技术(和普通的 DMA 有所不同),我们可以进一步减少通过 CPU 把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。可以查看网卡是否支持 scatter-gather 特性:

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

从 Linux 2.4 开始,sendfile() 开始支持 SG-DMA 技术,更加优化:

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

此时就只需要 2 次数据拷贝

sendfile

这就是零拷贝(Zero-copy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的。零拷贝技术的文件传输方式相比传统文件传输的方式,减少了 2 次上下文切换和数据拷贝次数只需要 2 次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2 次的数据拷贝过程,都不需要通过 CPU,2 次都是由 DMA 来搬运。所以,总体来看,零拷贝技术可以把文件传输的性能提高至少一倍以上

文件传输过程,其中第一步都是先需要先把磁盘文件数据拷贝「内核缓冲区」里,这个「内核缓冲区」实际上是磁盘高速缓存(PageCache)。读写磁盘相比读写内存的速度慢太多了,所以我们应该想办法把「读写磁盘」替换成「读写内存」。于是,我们会通过 DMA 把磁盘里的数据搬运到内存里,这样就可以用读内存替换读磁盘。而内存很小,就可以用 PageCache 来缓存最近被访问的数据,而且 PageCache 运用了 「预读功能」使得读取后面的顺序数据非常方便。但是,在传输大文件(GB 级别的文件)的时候,PageCache 会不起作用,那就白白浪费 DMA 多做的一次数据拷贝,造成性能的降低,即使使用了 PageCache 的零拷贝也会损失性能

之前最开始说的 CPU 会阻塞等待的问题,可以通过异步 I/O 来解决:

异步 I/O

异步 I/O 并不涉及 PageCache,使用 PageCache 的 I/O 是缓存 I/O,而通常对于磁盘,异步 I/O 只支持直接 I/O。

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

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

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

I/O 多路复用

服务端的 Socket 编程如下:

服务端首先调用 socket() 函数,创建网络协议为 IPv4,以及传输协议为 TCP 的 Socket ,接着调用 bind() 函数,给这个 Socket 绑定一个 IP 地址和端口,绑定这两个的目的是什么?

  • 绑定端口的目的:当内核收到 TCP 报文,通过 TCP 头里面的端口号,来找到我们的应用程序,然后把数据传递给我们。
  • 绑定 IP 地址的目的:一台机器是可以有多个网卡的,每个网卡都有对应的 IP 地址,当绑定一个网卡时,内核在收到该网卡上的包,才会发给我们;

绑定完 IP 地址和端口后,就可以调用 listen() 函数进行监听,此时对应 TCP 状态图中的 listen,如果我们要判定服务器中一个网络程序有没有启动,可以通过 netstat 命令查看对应的端口号是否有被监听。服务端进入了监听状态后,通过调用 accept() 函数,来从内核获取客户端的连接,如果没有客户端连接,则会阻塞等待客户端连接的到来。

客户端发起连接如下:

客户端在创建好 Socket 后,调用 connect() 函数发起连接,该函数的参数要指明服务端的 IP 地址和端口号,然后万众期待的 TCP 三次握手就开始了。在 TCP 连接的过程中,服务器的内核实际上为每个 Socket 维护了两个队列:

  • 一个是「还没完全建立」连接的队列,称为 TCP 半连接队列,这个队列都是没有完成三次握手的连接,此时服务端处于 syn_rcvd 的状态;
  • 一个是「已经建立」连接的队列,称为 TCP 全连接队列,这个队列都是完成了三次握手的连接,此时服务端处于 established 状态;

当 TCP 全连接队列不为空后,服务端的 accept() 函数,就会从内核中的 TCP 全连接队列里拿出一个已经完成连接的 Socket 返回应用程序,后续数据传输都用这个 Socket。

这里有两个 Socket,一个是监听的,一个是真正传输数据的。

TCP 协议的 Socket 调用

以上的 TCP Socket 调用流程基本只能一对一通信,使用的是同步阻塞,服务端没处理完一个客户端的网络 I/O 或者是读写阻塞,其他客户端就无法连接服务端。

TCP 连接是由四元组唯一确认的,这个四元组就是:本机IP, 本机端口, 对端IP, 对端端口

服务器作为服务方,通常会在本地固定监听一个端口,等待客户端的连接。因此服务器的本地 IP 和端口是固定的,于是对于服务端 TCP 连接的四元组只有对端 IP 和端口是会变化的,所以最大 TCP 连接数 = 客户端 IP 数×客户端端口数

对于 IPv4,客户端的 IP 数最多为 2 的 32 次方,客户端的端口数最多为 2 的 16 次方,也就是服务端单机最大 TCP 连接数约为 2 的 48 次方。服务器肯定承载不了那么大的连接数,主要会受两个方面的限制:

  • 文件描述符,Socket 实际上是一个文件,也就会对应一个文件描述符。在 Linux 下,单个进程打开的文件描述符数是有限制的,没有经过修改的值一般都是 1024,不过我们可以通过 ulimit 增大文件描述符的数目;
  • 系统内存,每个 TCP 连接在内核中都有对应的数据结构,意味着每个连接都是会占用一定内存的;

多进程模型

基于最原始的阻塞网络 I/O, 如果服务器要支持多个客户端,其中比较传统的方式,就是使用多进程模型,也就是为每个客户端分配一个进程来处理请求。服务器的主进程负责监听客户的连接,一旦与客户端连接完成,accept() 函数就会返回一个「已连接 Socket」,这时就通过 fork() 函数创建一个子进程,实际上就把父进程所有相关的东西都复制一份,包括文件描述符、内存地址空间、程序计数器、执行的代码等。这两个进程刚复制完的时候,几乎一模一样。不过,会根据返回值来区分是父进程还是子进程,如果返回值是 0,则是子进程;如果返回值是其他的整数,就是父进程。可以直接使用「已连接 Socket 」和客户端通信了。

针对这种方法,子进程不需要关心「监听 Socket」,只需要关心「已连接 Socket」;父进程则相反,将客户服务交给子进程来处理,因此父进程不需要关心「已连接 Socket」,只需要关心「监听 Socket」

当**「子进程」退出时,实际上内核里还会保留该进程的一些信息,也是会占用内存的**,如果不做好“回收”工作,就会变成僵尸进程,随着僵尸进程越多,会慢慢耗尽我们的系统资源。有两种方式可以在子进程退出后回收资源,分别是调用 wait() 和 waitpid() 函数

进程间上下文切换非常占用资源,所以很难满足大量客户端的需求。

多线程模型

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

当服务器与客户端 TCP 完成连接后,通过 pthread_create() 函数创建线程,然后将「已连接 Socket」的文件描述符传递给线程函数,接着在线程里和客户端进行通信,从而达到并发处理的目的。可以使用线程池的方式来避免线程的频繁创建和销毁,所谓的线程池,就是提前创建若干个线程,这样当由新连接建立时,将这个已连接的 Socket 放入到一个队列里,然后线程池里的线程负责从队列中取出「已连接 Socket 」进行处理。这个队列是全局的,每个线程都会操作,为了避免多线程竞争,线程在操作这个队列前要加锁

I/O多路复用

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

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

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_create 创建一个 epol l对象 epfd,再通过 epoll_ctl 将需要监视的 socket 添加到epfd中,最后调用 epoll_wait 等待数据。epoll 在内核里使用红黑树来跟踪进程所有待检测的文件描述字,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里,红黑树是个高效的数据结构,增删改一般时间复杂度是 O(logn)。epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个就绪事件列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

所以解决多用户请求的问题,大多使用 epoll 来完成:

epoll调用过程

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

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

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

一般来说,边缘触发的效率比水平触发的效率要高,因为边缘触发可以减少 epoll_wait 的系统调用次数,系统调用也是有一定的开销的的,毕竟也存在上下文的切换。select/poll 只有水平触发模式,epoll 默认的触发模式是水平触发,但是可以根据应用场景设置为边缘触发模式

高性能网络模式:Reactor 和 Proactor

基于 I/O 多路复用是面向过程的方式写代码的,这样的开发的效率不高,所以基于面向对象的思想对 I/O 多路复用作了一层封装并取了 Reactor 模型的名字。Reactor 模式也叫 Dispatcher 模式,即 I/O 多路复用监听事件,收到事件后,根据事件类型分配(Dispatch)给某个进程 / 线程

Reactor 模式主要由 Reactor 和处理资源池这两个核心部分组成,它俩负责的事情如下:

  • Reactor 负责监听和分发事件,事件类型包含连接事件、读写事件;
  • 处理资源池负责处理事件,如 read -> 业务逻辑 -> send;

Reactor 模式是灵活多变的,可以应对不同的业务场景,灵活在于:

  • Reactor 的数量可以只有一个,也可以有多个;
  • 处理资源池可以是单个进程 / 线程,也可以是多个进程 /线程;

常用的有 3 个方案:

  • 单 Reactor 单进程 / 线程;
  • 单 Reactor 多线程 / 进程;
  • 多 Reactor 多进程 / 线程;

单 Reactor 单进程 / 线程

一般来说,C 语言实现的是「单 Reactor 单进程」的方案,因为 C 语编写完的程序,运行后就是一个独立的进程,不需要在进程中再创建线程。

单 Reactor 单进程

对象里的 select、accept、read、send 是系统调用函数,dispatch 和 「业务处理」是需要完成的操作,其中 dispatch 是分发事件操作。

单 Reactor 单进程的方案因为全部工作都在同一个进程内完成,所以实现起来比较简单,不需要考虑进程间通信,也不用担心多进程竞争。但是,这种方案存在 2 个缺点:

  • 第一个缺点,因为只有一个进程,无法充分利用多核 CPU 的性能;
  • 第二个缺点,Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟;

所以,单 Reactor 单进程的方案不适用计算机密集型的场景,只适用于业务处理非常快速的场景

单 Reactor 多线程/多进程

要克服「单 Reactor 单线程 / 进程」方案的缺点,那么就需要引入多线程 / 多进程,这样就产生了单 Reactor 多线程 / 多进程的方案。

单 Reactor 多线程

这里与单进程的区别在于:

  • Handler 对象不再负责业务处理,只负责数据的接收和发送,Handler 对象通过 read 读取到数据后,会将数据发给子线程里的 Processor 对象进行业务处理
  • 子线程里的 Processor 对象就进行业务处理,处理完后,将结果发给主线程中的 Handler 对象,接着由 Handler 通过 send 方法将响应结果发送给 client

单 Reator 多线程的方案优势在于能够充分利用多核 CPU 的性能,那既然引入多线程,那么自然就带来了多线程竞争资源的问题。避免多线程由于竞争共享资源而导致数据错乱的问题,就需要在操作共享资源前加上互斥锁,以保证任意时间里只有一个线程在操作共享资源,待该线程操作完释放互斥锁后,其他线程才有机会操作共享数据。

单 Reactor 多进程相比单 Reactor 多线程实现起来很麻烦,主要因为要考虑子进程 <-> 父进程的双向通信,并且父进程还得知道子进程要将数据发送给哪个客户端。而多线程间可以共享数据,虽然要额外考虑并发问题,但是这远比进程间通信的复杂度低得多,因此实际应用中也看不到单 Reactor 多进程的模式

「单 Reactor」的模式还有个问题,因为一个 Reactor 对象承担所有事件的监听和响应,而且只在主线程中运行,在面对瞬间高并发的场景时,容易成为性能的瓶颈的地方

多 Reactor 多进程/线程

要解决「单 Reactor」的问题,就是将「单 Reactor」实现成「多 Reactor」,这样就产生了第 多 Reactor 多进程 / 线程的方案。

多 Reactor 多线程

多 Reactor 多线程的方案虽然看起来复杂的,但是实际实现时比单 Reactor 多线程的方案要简单的多,原因如下:

  • 主线程和子线程分工明确,主线程只负责接收新连接,子线程负责完成后续的业务处理。
  • 主线程和子线程的交互很简单,主线程只需要把新连接传给子线程,子线程无须返回数据,直接就可以在子线程将处理结果发送给客户端。

Proactor

前面提到的 Reactor 是非阻塞同步网络模式,而 Proactor 是异步网络模式

这里再强调一次,无论 read 和 send 是阻塞 I/O,还是非阻塞 I/O 都是同步调用。因为在 read 调用时,内核将数据从内核空间拷贝到用户空间的过程都是需要等待的,也就是说这个过程是同步的,如果内核实现的拷贝效率不高,read 调用就会在这个同步过程中等待比较长的时间。而真正的异步 I/O 是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待

发起 aio_read (异步 I/O) 之后,就立即返回,内核自动将数据从内核空间拷贝到用户空间,这个拷贝过程同样是异步的,内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作

对比一下 Reactor 和 Proactor 的区别:

  • Reactor 是非阻塞同步网络模式感知的是就绪可读写事件。在每次感知到有事件发生(比如可读就绪事件)后,就需要应用进程主动调用 read 方法来完成数据的读取,也就是要应用进程主动将 socket 接收缓存中的数据读到应用进程内存中,这个过程是同步的,读取完数据后应用进程才能处理数据。
  • Proactor 是异步网络模式感知的是已完成的读写事件。在发起异步读写请求时,需要传入数据缓冲区的地址(用来存放结果数据)等信息,这样系统内核才可以自动帮我们把数据的读写工作完成,这里的读写工作全程由操作系统来做,并不需要像 Reactor 那样还需要应用进程主动发起 read/write 来读写数据,操作系统完成读写工作后,就会通知应用进程直接处理数据

Reactor 可以理解为「来了事件操作系统通知应用进程,让应用进程来处理」,而 Proactor 可以理解为「来了事件操作系统来处理,处理完再通知应用进程」。这里的「事件」就是有新连接、有数据可读、有数据可写的这些 I/O 事件这里的「处理」包含从驱动读取到内核以及从内核读取到用户空间。无论是 Reactor,还是 Proactor,都是一种基于「事件分发」的网络编程模式,区别在于 Reactor 模式是基于「待完成」的 I/O 事件,而 Proactor 模式则是基于「已完成」的 I/O 事件

Proactor 模式

在 Linux 下的异步 I/O 是不完善的, aio 系列函数是由 POSIX 定义的异步操作接口,不是真正的操作系统级别支持的,而是在用户空间模拟出来的异步,并且仅仅支持基于本地文件的 aio 异步操作,网络编程中的 socket 是不支持的,这也使得基于 Linux 的高性能网络程序都是使用 Reactor 方案

Windows 里实现了一套完整的支持 socket 的异步编程接口,这套接口就是 IOCP,是由操作系统级别实现的异步 I/O,真正意义上异步 I/O,因此在 Windows 里实现高性能网络程序可以使用效率更高的 Proactor 方案

一致性哈希

大多数网站背后肯定不是只有一台服务器提供服务,因为单机的并发量和数据量都是有限的,所以都会用多台服务器构成集群来对外提供服务。那如何来分配客户端的请求就很重要

这个问题就是「负载均衡问题」。解决负载均衡问题的算法很多,不同的负载均衡算法,对应的就是不同的分配策略,适应的业务场景也不同。

最简单的方式,引入一个中间的负载均衡层,让它将外界的请求「轮流」的转发给内部的集群。可以引入权重值,将硬件配置更好的节点的权重值设高,然后根据各个节点的权重值,按照一定比重分配在不同的节点上,让硬件配置更好的节点承担更多的请求,这种算法叫做加权轮询。

加权轮询算法使用场景是建立在每个节点存储的数据都是相同的前提。但是,加权轮询算法是无法应对「分布式系统(数据分片的系统)」的,因为分布式系统中,每个节点存储的数据是不同的

提高系统的容量,就会将数据水平切分到不同的节点来存储,也就是将数据分布到了不同的节点。比如一个分布式 KV(key-valu) 缓存系统,某个 key 应该到哪个或者哪些节点上获得,应该是确定的,不是说任意访问一个节点都可以得到缓存结果的。

这个映射可以想到哈希算法,但是如果节点数量发生了变化,也就是在对系统做扩容或者缩容时,必须迁移改变了映射关系的数据,否则会出现查询不到数据的问题。这就需要我们进行迁移数据,假设总数据条数为 M,哈希算法在面对节点数量变化时,最坏情况下所有数据都需要迁移,所以它的数据迁移规模是 O(M),这样数据的迁移成本太高了。

一致性哈希算法就很好地解决了分布式系统在扩容或者缩容时,发生过多的数据迁移的问题。一致哈希算法也用了取模运算,但与哈希算法不同的是,哈希算法是对节点的数量进行取模运算,而一致哈希算法是对 2^32 进行取模运算,是一个固定的值

一致性哈希是需要两次哈希操作的,一致性哈希是指将「存储节点」和「数据」都映射到一个首尾相连的哈希环上。对「数据」进行哈希映射得到一个结果,映射的结果值往顺时针的方向的找到第一个节点,就是存储该数据的节点。也就是说,需要对指定的 key 值进行读写需要 2 步:

  • 首先,对 key 进行哈希计算,确定此 key 在环上的位置;
  • 然后,从这个位置沿着顺时针方向走,遇到的第一节点就是存储 key 的节点。

在一致哈希算法中,如果增加或者移除一个节点,仅影响该节点在哈希环上顺时针相邻的后继节点,其它数据也不会受到影响。但是一致性哈希算法并不保证节点能够在哈希环上分布均匀,这样就会带来一个问题,会有大量的请求集中在一个节点上。所以,一致性哈希算法虽然减少了数据迁移量,但是存在节点分布不均匀的问题。

要想解决节点能在哈希环上分配不均匀的问题,就是要有大量的节点,节点数越多,哈希环上的节点分布的就越均匀。但问题是,实际中我们没有那么多节点。所以这个时候就加入虚拟节点,也就是对一个真实节点做多个副本。具体做法是,不再将真实节点映射到哈希环上,而是将虚拟节点映射到哈希环上,并将虚拟节点映射到实际节点,所以这里有「两层」映射关系。虚拟节点除了会提高节点的均衡度,还会提高系统的稳定性。当节点变化时,会有不同的节点共同分担系统的变化,因此稳定性更高。带虚拟节点的一致性哈希方法不仅适合硬件配置不同的节点的场景,而且适合节点规模会发生变化的场景

Linux 命令

查看网络性能指标

Linux 网络协议栈是根据 TCP/IP 模型来实现的,TCP/IP 模型由应用层、传输层、网络层和网络接口层,共四层组成,每一层都有各自的职责

TCP/IP 模型

通常是以 4 个指标来衡量网络的性能,分别是带宽、延时、吞吐率、PPS(Packet Per Second),它们表示的意义如下:

  • 带宽,表示链路的最大传输速率,单位是 b/s (比特 / 秒),带宽越大,其传输能力就越强。
  • 延时,表示请求数据包发送后,收到对端响应,所需要的时间延迟。不同的场景有着不同的含义,比如可以表示建立 TCP 连接所需的时间延迟,或一个数据包往返所需的时间延迟。
  • 吞吐率,表示单位时间内成功传输的数据量,单位是 b/s(比特 / 秒)或者 B/s(字节 / 秒),吞吐受带宽限制,带宽越大,吞吐率的上限才可能越高。
  • PPS,全称是 Packet Per Second(包 / 秒),表示以网络包为单位的传输速率,一般用来评估系统对于网络的转发能力。

当然,除了以上这四种基本的指标,还有一些其他常用的性能指标,比如:

  • 网络的可用性,表示网络能否正常通信;
  • 并发连接数,表示 TCP 连接数量;
  • 丢包率,表示所丢失数据包数量占所发送数据组的比率;
  • 重传率,表示重传网络包的比例;

网络配置

可以使用 ifconfig 或者 ip 命令来查看。这两个命令功能都差不多,不过它们属于不同的软件包,ifconfig 属于 net-tools 软件包,ip 属于 iproute2 软件包,我的印象中 net-tools 软件包没有人继续维护了,而 iproute2 软件包是有开发者依然在维护,所以更推荐你使用 ip 工具

查询网口信息

第一,网口的连接状态标志。其实也就是表示对应的网口是否连接到交换机或路由器等设备,如果 ifconfig 输出中看到有 RUNNING,或者 ip 输出中有 LOWER_UP,则说明物理网络是连通的,如果看不到,则表示网口没有接网线。

第二,MTU 大小。默认值是 1500 字节,其作用主要是限制网络包的大小,如果 IP 层有一个数据报要传,而且网络包的长度比链路层的 MTU 还大,那么 IP 层就需要进行分片,即把数据报分成若干片,这样每一片就都小于 MTU。事实上,每个网络的链路层 MTU 可能会不一样,所以你可能需要调大或者调小 MTU 的数值。

第三,网口的 IP 地址、子网掩码、MAC 地址、网关地址。这些信息必须要配置正确,网络功能才能正常工作。

第四,网络包收发的统计信息。通常有网络收发的字节数、包数、错误数以及丢包情况的信息,如果 TX(发送) 和 RX(接收) 部分中 errors、dropped、overruns、carrier 以及 collisions 等指标不为 0 时,则说明网络发送或者接收出问题了

socket 信息

可以使用 netstat 或者 ss,这两个命令查看 socket、网络协议栈、网口以及路由表的信息其中 ss 命令性能更好

查询 socket 信息

都包含了 socket 的状态(State)、接收队列(Recv-Q)、发送队列(Send-Q)、本地地址(Local Address)、远端地址(Foreign Address)、进程 PID 和进程名称(PID/Program name)等。

接收队列(Recv-Q)和发送队列(Send-Q)比较特殊,在不同的 socket 状态。它们表示的含义是不同的。

当 socket 状态处于 Established时:

  • Recv-Q 表示 socket 缓冲区中还没有被应用程序读取的字节数;
  • Send-Q 表示 socket 缓冲区中还没有被远端主机确认的字节数;

而当 socket 状态处于 Listen 时:

  • Recv-Q 表示全连接队列的长度;
  • Send-Q 表示全连接队列的最大长度;

网络吞吐率和 PPS

可以使用 sar 命令当前网络的吞吐率和 PPS,用法是给 sar 增加 -n 参数就可以查看网络的统计信息,比如

  • sar -n DEV,显示网口的统计数据;
  • sar -n EDEV,显示关于网络错误的统计数据;
  • sar -n TCP,显示 TCP 的统计数据

对于带宽,我们可以使用 ethtool 命令来查询,它的单位通常是 Gb/s 或者 Mb/s,不过注意这里小写字母 b ,表示比特而不是字节

连通性和延时

要测试本机与远程主机的连通性和延时,通常是使用 ping 命令,它是基于 ICMP 协议的,工作在网络层。

从日志分析 PV、NV

日志涵盖的信息远不止于此,比如对于 nginx 的 access.log 日志,我们可以根据日志信息分析用户行为。比如分析出哪个页面访问次数(PV)最多,访问人数(UV)最多,以及哪天访问量最多,哪个请求访问最多等等。

要分析日志的时候,先用 ls -lh 命令查看日志文件的大小,如果日志文件大小非常大,最好不要在线上环境做。当发现日志很大的时候,我们可以使用 scp 命令将文件传输到闲置的服务器再分析

对于大文件,我们应该养成好习惯,用 less 命令去读文件里的内容,而不是直接用 cat 来查看文件内容。想看日志最新部分的内容,可以使用 tail 命令。想实时看日志打印的内容,你可以使用 tail -f 命令。

PV 分析

PV 的全称叫 Page View,用户访问一个页面就是一次 PV,比如大多数博客平台,点击一次页面,阅读量就加 1,所以说 PV 的数量并不代表真实的用户数量,只是个点击量。对于 nginx 的 acess.log 日志文件来说,直接使用 wc -l 命令,就可以查看整体的 PV 了。

按时间分组,首先我们先「访问时间」过滤出来,这里可以使用 awk 命令来处理,awk 是一个处理文本的利器。awk 命令默认是以「空格」为分隔符,由于访问时间在日志里的第 4 列,因此可以使用 awk ‘{print $4}’ access.log 命令把访问时间的信息过滤出来。如果只想显示年月日的信息,可以使用 awk 的 substr 函数,可以使用 sort 对日期进行排序,然后使用 uniq -c 进行统计,于是按天分组的 PV 就出来了。使用 uniq -c 命令前,先要进行 sort 排序

UV 分析

UV 的全称是 Uniq Visitor,它代表访问人数,比如公众号的阅读量就是以 UV 统计的,不管单个用户点击了多少次,最终只算 1 次阅读量。可以用「客户端 IP 地址」来近似统计 UV

这里同样,可以按天分组分析,如下命令:相当于把「日期 + IP地址」过滤出来,并去重

按天统计UV

如果需要对当天的 UV 统计,在上面的命令再拼接 awk ‘{uv[$1]++;next}END{for (ip in uv) print ip, uv[ip]}’ 命令就可以了。

终端分析

nginx 的 access.log 日志最末尾关于 User Agent 的信息,主要是客户端访问服务器使用的工具,可能是手机、浏览器等。User Agent 的信息在日志里的第 12 列,因此我们先使用 awk 过滤出第 12 列的内容后,进行 sort 排序,再用 uniq -c 去重并统计,最后再使用 sort -rn(r 表示逆向排序, n 表示按数值排序) 对统计的结果排序

分析 TOP3 的请求

access.log 日志中,第 7 列是客户端请求的路径,先使用 awk 过滤出第 7 列的内容后,进行 sort 排序,再用 uniq -c 去重并统计,然后再使用 sort -rn 对统计的结果排序,最后使用 head -n 3 分析 TOP3 的请求。

  • 28
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值