计算机组成
计算机系统是由两大部分组成:硬件系统和软件系统。
计算机硬件由五大基本部分组成:运算器、控制器、存储器、输入设备和输出设备。
计算机软件则由操作系统(OS)和各种应用程序组成。
计算机硬件组成
计算机硬件由五个基本部分组成:运算器、控制器、存储器、输入设备和输出设备。也就是大名鼎鼎的冯诺依曼计算机模型。
- 输入设备:向计算机输入数据和信息的设备。包括键盘,鼠标,摄像头,网卡和硬盘等。
- 输出设备:是计算机的终端,用于接收计算机发出的数据信息。包括显示器,声卡,网卡等。
- 存储器:存储器是计算机的记忆装置,它的主要功能是存放程序和数据。程序是计算机操作的依据,数据是计算机操作的对象。存储器又分为内存和外存(硬盘)。
- 控制器:控制器的基本功能是从内存取指令和执行指令,并将指令解码然后执行,以完成应用程序的功能。
- 运算器:运算器用来进行算术运算(+-*/)和逻辑运算(&|!),在计算机中,任何复杂运算都会转化为基本的算术与逻辑运算,然后在运算器中完成。
输入设备和输出设备一般都是外围设备,简称外设,统称为I/O。而运算器和控制器统称为中央处理器,即CPU(Central Processing Unit),它是整个计算机的核心部件,是计算机的“大脑”。它控制了计算机的运算、处理、输入和输出等工作。CPU和存储器(内存、硬盘)也是计算机中最重要的组成部分。
冯诺依曼计算机模型的具体应用,就是现代计算机当中的硬件架构设计:
一般来说,计算机的运行过程就是:启动磁盘上的程序 -> 程序加载进内存 -> CPU执行内存中的指令 -> CPU运算数据 -> 输出运算结果 -> 内存数据修改/内存数据存盘/内存数据通过网络发送。
至于CPU和内存具体是怎么交互的,则是通过:芯片组 + 总线
芯片组通常又分为两个桥接器(北桥芯片/南桥芯片)来控制各组件的沟通。北桥芯片负责连接速度较快的CPU、主内存与显卡等设备;南桥芯片则负责连接速度较慢的周边设备,如硬盘、USB、网卡等。
北桥芯片的最主要功能是内存控制,即内存中的数据先入北桥的内存控制器,再入CPU处理。这部分数据吞吐量大,延迟低。因此北桥表面覆盖了明显的一大块散热片,而且距离CPU非常近,就在CPU插座旁边。南桥芯片则距离CPU较远,主要控制输入输出设备和外部设备,如USB、网卡、硬盘、音频、键盘等。
随着发展则出现了单芯片架构,在单芯片架构中,以前的北桥芯片被集成到了CPU中,我们知道CPU的数据主要都是由主内存提供,将内存控制器整合到CPU中, 理论上这样可以加速CPU与主内存的传输速度!(Intnet是双芯片架构,AMD是单芯片架构)
芯片组控制了数据传输的流转,也就是数据从哪里到哪里的问题,总线(Bus)则是实际数据传输的高速公路。
总线(Bus) 是计算机各种功能部件之间传送信息的 公共通信干线。按照计算机所传输的 信息种类,计算机的总线可以划分为 数据总线、地址总线和 控制总线,分别用来传输数据地址控制信号和数据。简单来说地址总线的作用是寻址,即CPU告诉内存需要哪一个内存地址上的数据;控制总线的作用是对外部组件的控制,例如CPU希望从内存读取数据则会在控制总线上发一个“读信号”,如果希望往内存中写一个数据则会发一个“写信号”;而数据总线的作用顾名思义就是用来传输数据本身的了。
CPU
中央处理器(Central Processing Unit,CPU)是一台计算机的运算核心(运算器)和控制核心(控制器)。
CPU工作可以分为5个阶段:取指令、指令译码、执行指令、访存取数、结果写回。
CPU逻辑上分为3个模块,分别是"控制单元","运算单元","存储单元"。
- 运算单元只管算,例如做加法、做位移等等。但是,它不知道应该算哪些数据,运算结果应该放在哪里。
- 存储单元即负责存储数据,包括CPU 内部的缓存和寄存器组,是CPU中暂时存放数据的地方。也是因为CPU通过总线从内存读取数据速度太慢了,才在寄存器与内存之间设置了缓存cache。数据从内存里拿出来先放到寄存器,然后CPU再从寄存器里读取数据来进行处理,处理完后同样把数据通过寄存器存放到内存里,CPU不直接和内存打交道。
- 控制单元负责从内存读取一条指令并放在寄存器,然后执行这条指令。这个指令会指导运算单元取出存储单元中的某几个数据,计算出个结果,然后放在存储单元的某个地方。cpu会不断重复以上三个步骤,使内存代码段的指令被逐个的执行,直到程序结束为止。
根据 top 命令,我们可以查看 CPU 的工作情况:
%Cpu(s): 0.3 us, 0.3 sy, 0.0 ni, 99.3 id, 0.0 wa, 0.0 hi, 0.0 si, 0.0 st
衡量 CPU 很重要的一个指标就是 CPU 的空闲率。如果空闲率过低,证明 CPU 运转太过繁忙,则程序的响应将会变慢。
CPU内部缓存逐步发展到L1/L2/L3 Cache的三级缓存结构,存储器速度:寄存器 > 缓存 > 内存。通过三级缓存结构,可以极大地提高CPU运行效率,但是从方法论上来说,任何一件事情,肯定有好处和坏处。三级缓存结构好处显而易见,可以提高CPU运行效率,不用被内存读写速度慢拖累。但在数据同步方面会比较麻烦。特别是多核处理器,在修改缓存数据之后,需要及时同步给其它核的CPU,不然对方读取到的就是脏数据。这也就是我们Java开发常说的内存屏障问题,需要采用volatile关键词来获取最新的数据。
内存
内存(Memory)是计算机的重要部件,也称内存储器和主存储器,它用于暂时存放CPU中的运算数据,以及与硬盘等外部存储器交换的数据。
它是外存与CPU进行沟通的桥梁,计算机中所有程序的运行都在内存中进行。磁盘的存储空间虽然很大,但是计算机的CPU没办法直接跟磁盘交互,它只跟内存打交道。
CPU通过总线读写内存的数据,如果数据不在内存中,就会去磁盘中检索对应的数据到内存中,然后返回给CPU。所以内存在计算机中核心的作用就是用来做主缓存,配合CPU读写数据。
只要计算机开始运行,操作系统就会把需要运算的数据从内存调到CPU中进行运算,当运算完成,CPU将结果传送出来。
虚拟内存
上面讲到了,CPU会读写数据到内存中,那CPU是直接跟内存的实际物理地址交互的嘛?如果是每个程序都直接跟内存物理地址交互,那就会存在:A程序写入a数据到内存中,同时B程序修改b数据到内存中。因为a和b对应的同一个内存物理地址,所以导致b直接覆盖a的数据。那如果这样的话,整个计算机将会乱套。所以我们的操作系统给出了这个解决方案,就是虚拟内存。
先看两个概念:
- 虚拟内存地址:我们程序所使用的内存地址叫做虚拟内存地址(Virtual Memory Address)
- 物理内存地址:实际存在硬件里面的空间地址叫物理内存地址(Physical Memory Address)
CPU其实不会直接跟内存物理地址交互,而是通过一个叫做内存管理单元(MMU),来将虚拟地址转为实际的物理地址。
有了这个MMU之后,就不会出现数据互相影响的情况了。操作系统会给每一个进程分配独立的一套虚拟地址,各个进程之间互不干涉。通过虚拟内存来管理实际的物理地址,每一个进程申请的物理地址,因为有虚拟内存的统一管理,所以不会出现互相影响的情况。
虚拟内存的实现形式目前有:内存分段、内存分页或者是二者组合使用。
通过 top 命令、 或者 free 命令 可以查看机器内存。
total used free shared buff/cache available
Mem: 15G 5.5G 2.4G 744M 7.5G 8.9G
Swap: 1.5G 0B 1.5G
磁盘
磁盘(disk)是指利用磁记录技术存储数据的存储器。磁盘是计算机主要的存储介质,可以存储大量的二进制数据,并且断电后也能保持数据不丢失。早期计算机使用的磁盘是软磁盘(Floppy Disk,简称软盘),如今常用的磁盘是硬磁盘(Hard disk,简称硬盘)。目前市面上一般有两种磁盘,一种是机械磁盘(又叫做HDD),另外一种是固态磁盘(又叫做SSD)。
后者比前者速度快,稳定性要好,当然价格相对也更贵。下面介绍经典的HDD磁盘是如何寻址的。
机械磁盘是由很多盘片组成的圆柱体,每个盘面有很多的磁道(同心圆),磁道里面又可以划分为很多的扇区,扇区才是真正存储数据的地方,扇区也是磁盘中最小的物理存储单位。地址寻址本质上,就是从一个个扇区中找到所需数据的过程。通常情况下每个扇区的大小是512字节。硬盘不是一次读写一个字节而是一次读写一个扇区(512个字节)。
机械磁盘存在两组运动,一组是磁盘的旋转运动(旋转时间),一组是机械臂控制磁头的沿半径方向的直线运动(寻道时间),其中寻道时间远大于旋转时间。所以我们在寻址时优先考虑磁盘旋转,而后再考虑磁头移动。因为读取数据,本质上是通过磁头读取介质中的正负磁性,然后通过电流传输回去,这个读取过程是非常快速的(纳秒级),相对旋转时间和寻道时间几乎可以忽略不计。所以读取速度的瓶颈就是如何减少寻址的时间。
在不知道具体物理地址时,进行全盘遍历寻址操作步骤如下:
- 并发同时进行最外层磁道的读取;(旋转)
- 如果整个圆柱体的当前磁道都读取不到数据的情况下,再移动磁头;(寻道)
- 然后再接着继续旋转读取磁道信息,这样一直反复直到找到对应的数据。
当知道物理地址时,读/写磁盘上某一指定数据步骤如下:
- 首先根据柱面号,移动磁头,使磁头移动到相应的柱面上;(寻道)
- 根据盘面号来确定指定盘面号上的具体磁道
- 盘面确定以后,盘片开始旋转,将指定扇区号的磁道段移动至磁头下。(旋转)
经过上面步骤,指定数据的存储位置就被找到了。这时就可以开始读/写操作了。可以知道磁盘读取依靠的就是机械运动,分为寻道时间、旋转延迟、传输时间三个部分,这三个部分耗时相加就是一次磁盘IO的时间,一般大概10ms左右。
磁盘读取时间成本是访问内存的几百倍到几万倍之间。
既然这么慢,为了提高效率,要尽量减少磁盘I/O。为了达到这个目的,磁盘往往不是严格按需读取,而是每次都会预读,即使只需要一个字节,磁盘也会从这个位置开始,顺序向后读取一定长度的数据放入内存,这个称之为预读。
这样做的理论依据是计算机科学中著名的局部性原理:程序运行期间所需要的数据通常比较集中,当一个数据被用到时,其附近的数据也通常会马上被使用。
由于磁盘顺序读取的效率很高(不需要寻道时间,只需很少的旋转时间),一般来说,磁盘的顺序读的效率是随机读的40到400倍都有可能,顺序写是随机写的10到100倍 。因此对于具有局部性的程序来说,预读可以提高I/O效率。因为程序接下来可能用到的数据已经提前预读进来了。
预读的长度一般为页(page)的整倍数。
页是计算机管理存储器的逻辑块,硬件及操作系统往往将主存和磁盘存储区分割为连续的大小相等的块,每个存储块称为一页,页大小通常为4k当然也有16K的,主存和磁盘以页为单位交换数据。
当程序要读取的数据不在主存中时,会触发一个缺页异常,此时系统会向磁盘发出读盘信号,磁盘会找到数据的起始位置并向后连续读取一页或几页载入内存中,然后程序继续运行。
扇区、块/簇、page的关系
- 扇区:是硬盘的最小读写单元,即每次至少读1个扇区的数据(一般大小=512B)
- 块/簇:是操作系统针对硬盘读写的最小单元,每个块/簇可以包括2、4、8、16、32 ... 等2的n次方个扇区。一般是(磁盘)块=4k,即1块=8个扇区
- 页(page):是内存与操作系统之间操作的最小单元,可以认为一页=2^n块=2^n扇区
扇区 <= 块/簇 <= page,(磁盘块大小:stat /boot,页大小:getconf PAGE_SIZE)
(Windows下如NTFS等文件系统称呼为“簇”,Linux下如Ext4等文件系统中称呼为“块”,块/簇、页都是虚拟出来的概念,以便操作系统与内存和硬盘进行通信)
操作系统
操作系统概述
操作系统(OS)是指控制和管理整个计算机系统的硬件和软件资源,并对计算机资源进行合理的分配,提供给用户和其他软件方便的接口和环境的程序集合。常用的操作系统有 windows、linux、mac os等。
一般来说,操作系统应具备的核心功能
- 管理进程:决定哪个进行、线程使用CPU,即进程调度
- 管理内存:决定内存的分配和回收
- 管理文件系统:提供文件的创建/删除/读写等能力
- 管理硬件设备:为进程与硬件设备之间提供通信能力
- 提供系统调用接口:允许用户程序通过接口访问操作系统的内部服务和资源
操作系统位于计算机的硬件与应用软件之间。传统的操作系统将:进程、内存、文件系统、设备管理这四大部分看作系统的内核,这四大部分组成的系统是纯粹的操作系统。操作系统由操作系统的内核、以及系统调用接口两部分组成。
操作系统内核
操作系统内核(Kernal)是一组应用软件,这个软件能够控制所有硬件及计算机活动。如硬盘访问、网卡传输和键盘开始工作等,开机后内核程序将会常驻受保护的内存中。硬件由内核管理后,操作系统将会提供一组系统调用接口,帮助完成诸如显示、读写设备等基本操作。
操作系统在计算机结构中的位置(红色部分)如下:
操作系统运行状态
操作系统的两种运行状态分为用户态和内核态。区分用户态、内核态主要是为了对访问能力进行限制,因为计算机中有⼀些比较危险的操作,极容易导致系统崩坏。两种状态中,用户态的权限较低,而内核态的权限较高。
- 用户态:用户态运行的程序只能受限地访问内存,只能直接读取用户程序的数据,并且不允许访问外围设备,用户态下的 CPU 不允许独占,也就是说 CPU 能够被其他程序获取。
- 内核态:内核态运行的程序可以访问计算机的任何数据和资源,不受限制,包括外围设备,比如网卡、硬盘等。处于内核态的 CPU 可以从一个程序切换到另外一个程序,并且占用 CPU 不会发生抢占情况。
用户态和内核态的转换
用户态切换到内核态主要有3种方式
- 系统调用:这是用户态进程主动要求切换到内核态的一种方式,用户态进程通过系统调用申请使用操作系统提供的服务程序完成工作,比如 new Thread().start 实际上就是执行了一个创建新进程的系统调用。而系统调用的机制其核心还是使用了操作系统为用户特别开放的一个中断来实现,例如Linux的int 80h中断,再比如申请内存等,也需要转换到内核态去做。
- 异常:当CPU在执行运行在用户态下的程序时,发生了某些事先不可知的异常,这时会触发由当前运行进程切换到处理此异常的内核相关程序中,也就转到了内核态,比如缺页异常。
- 外围设备的中断:当外围设备完成用户请求的操作后,会向CPU发出相应的中断信号,这时CPU会暂停执行下一条即将要执行的指令转而去执行与中断信号对应的处理程序,如果先前执行的指令是用户态下的程序,那么这个转换的过程自然也就发生了由用户态到内核态的切换。比如硬盘读写操作完成,系统会切换到硬盘读写的中断处理程序中执行后续操作等。
这3种方式是系统在运行时由用户态转到内核态的最主要方式,其中系统调用可以认为是用户进程主动发起的,异常和外围设备中断则是被动的。
操作系统进程
进程概述
我们编写的代码只是一个存储在硬盘的静态文件,通过编译后就会产生二进制可执行文件,当我们运行这个可执行文件后,它会被装载到内存中,接着CPU会执行程序中的每一条指令,那么这个运行中的程序,就会被称为进程。
当进程要从硬盘读取数据时,CPU不需要阻塞等待数据的返回,而是去执行另外的进程。当硬盘数据返回时,CPU会收到个中断,于是CPU再继续运行这个进程。
这种多个程序,交替执行的思想,就是CPU管理多个进程的初步想法。对于一个支持多进程的系统,CPU会从一个进程快速切换到另一个进程,期间每个进程各运行几十或几百个毫秒。虽然单核的CPU在一瞬间,只能运行一个进程。但在1秒钟期间,他可能会交替运行多个进程,这样就产生并行的错觉,实际上这是并发,即“伪并行”。现代计算机中常见的CPU核数可以达到8核甚至更多,每个核都可视作一个CPU,则8核CPU就可以真并行执行8个进程。
进程状态
对于一个CPU来说,它会在进程之间不停地做切换。也就是说,一个进程并不是自始至终连续不停的运行的,它与并发执行中的其他进程的执行是相互制约的。它有时处于运行状态,有时又由于某种原因而暂停运行处于等待状态,当使它暂停的原因消失后,它又进入准备运行状态。所以,在一个进程的活动期间至少具备三种基本状态,即运行状态,就绪状态,阻塞状态。
创建进程常见的原因有:
- 系统初始化
- 用户通过系统提供的API创建新进程
- 由现有进程派生子进程
- 批处理作业初始化
进程创建后,会按照下列步骤进行初始化:
- 给新进程分配一个进程id(pid)
- 分配内存空间
- 初始化PCB
- 进入就绪队列
运行状态,就绪状态,阻塞状态,加上进程另外两个基本状态:创建状态、结束状态。得到一个完整的进程状态变迁如下(与Java线程状态一致):
另外,还有一个状态叫挂起状态,它表示进程没有占用物理内存空间。这跟阻塞状态是不一样,阻塞状态是等待是某个事件的返回。由于虚拟内存管理原因,进程的所使用的空间可能并没有映射到物理内存,而是在 硬盘上,这时进程就会出现挂起状态,另外调用Sleep也会被挂起。
挂起状态可以分为两种:
- 阻塞挂起状态:进程在外存(硬盘)并等待某个事件的出现;
- 就绪挂起状态:进程在外存(硬盘),但只要进入内存,即刻立刻运行;
这两种挂起状态加上前面的五种状态,就变成了七种状态变迁
进程控制块
对于一个被执行的程序,操作系统会为该程序创建一个进程。进程作为一种抽象概念,可将其视为一个容器,该容器聚集了相关资源,包括地址空间,线程,打开的文件,保护许可等。有一句经典的话 程序 = 算法 + 数据结构,因此对于单个进程,可以基于一种数据结构来表示它,这种数据结构称之为进程控制块(PCB)。
在操作系统中,是用进程控制块(process control block ,PCB)数据结构来描述进程的。
PCB是进程存在的唯一标识,这意味着一个进程的存在,必然会有一个PCB,如果进程消失了,那么PCB也会随之消失。
PCB中记录了操作系统所需要的、用于描述进程情况及控制进程运行所需要的全部信息。PCB中包含的信息有:进程标识符(PID)、上下文数据、进程调度信息、进程控制信息、I / O 状态信息等
在一个系统中,通常可拥有数十个乃至数千个PCB,为能对它们进行有效管理,应该用适当的方式将它们组织起来,目前,常见的 PCB 组织方式有两种,链表方式和索引方式。
通常是通过链表的方式进行组织,把具有相同状态的进程链在一起,组成各种队列。
如就绪链表, 阻塞链表, 运行链表。链表形式组织成的队列如下图:
除了链表的组织方式,还有索引方式,它的工作原理:将同一状态的进程组织在一个索引表中,索引表项指向相应的PCB,不同状态对应不同的索引表。
一般会选择链表,因为可能面临进程创建,销毁等调度导致进程状态发生变化,所以链表能够更加灵活地插入和删除。
操作系统会按照一定策略选择合适的进程,使之拥有CPU的使用权而进入运行。
进程切换
各个进程之间是共享CPU资源的,在不同的时候进程之间需要切换,让不同的进程可以在CPU执行,那么一个进程切换到另一个进程运行,就称为进程的上下文切换。操作系统对进程的切换也是基于PCB。
需要注意的是,进程的上下文开销是很大的。一般发生进程上下文切换的场景有:
- 进程的CPU时间片耗尽:为了保证所有进程可以得到公平调度,CPU时间被划分为一段段的时间片,这些时间片再被轮流分配给各个进程。这样,当某个进程的时间片耗尽了,就会被系统挂起,切换到其它正在等待CPU的进程运行;
- 进程的系统资源不足(比如内存资源)时,要等到资源满足才可以运行,这个时候进程也会被挂起,并由系统调度其他进程运行;
- 当进程通过睡眠函数 sleep 这样的方法将自己主动挂起时,自然也会重新调度;
- 当有优先级更高的进程运行时,为了保证高优先级进程的运行,当前进程会被挂起,由高优先级进程来运行;
- 发生硬件中断时,CPU上的进程会被中断挂起,转而执行内核的中断服务程序;
进程切换过程一般涉及以下步骤:
- 保存处理器上下文环境: 将CPU程序计数器和寄存器的值保存到当前进程的私有堆栈里
- 更新当前进程的PCB(包括状态更变)
- 将当前进程移到就绪队列或者阻塞队列
- 根据调度算法,选择就绪队列中一个合适的新进程,将其更改为运行态
- 更新内存管理的数据结构
- 新进程将其堆栈中保存的上下文信息载入到CPU的寄存器和程序计数器,占有CPU
简单说进程切换的时候首先需要先保护现场,即把当前CPU的PC指针和一些寄存器,保存在进程的PCB中,保证后续可以切换回来继续运行,再把PCB移入到相应的队列中(阻塞或就绪),然后开始选择要切换的进程(由操作系统调度器调度),把该进程的状态设置为运行态,把它PCB中的内容(PC和寄存器)加载到CPU,该进程可以开始执行了。
一个进程至少包含一个线程,线程是进程当中的一条执行流程。同一个进程内多个线程之间可以共享代码段、数据段、打开的文件等资源,但每个线程各自都有一套独立的寄存器和栈。可以说进程是资源分配的最小单位,线程是CPU调度的最小单位;进程拥有一个完整的资源平台,而线程只独享必不可少的资源,如寄存器和栈;线程同样有就绪,阻塞,执行三种基本状态。线程切换的开销比进程切换的开销小得到。
进程调度
在操作系统中负责进程调度工作的是被叫做调度器或者分派器的程序模块。所谓进程调度,就是指在系统中所有的就绪进程里,按照某种策略确定一个合适的进程并让处理器运行它。
调度方式:
- 不可剥夺调度方式(非抢占式):进程被选择运行后直到被阻塞,或者直到该进程退出,才会调用另外一个进程。(效率不高)
- 可剥夺调度方式(抢占式):进程被选择运行后只运行某段时间,如果在该时段结束时,该进程仍然在运行时,则会把它挂起,接着调度程序从就绪队列挑选另外一个进程。(低优先级进程可能抢不到CPU时间)
调度原则:
- 尽量提高CPU的利用率,要增加系统的吞吐量(单位时间内CPU完成的进程数量)
- 减小周转时间(周转时间是进程运行+阻塞时间+等待时间的总和)
- 减小等待时间(等待时间是系统在就绪队列中的时间)
- 减少响应时间(提交请求到产生响应所用的时间,这是衡量调度算法的主要标准)
调度算法:
- 先来先服务算法,这是非抢占式的先来先服务算法,每次从就绪队列中选择最先进入队列的进程,然后一直运行直到进程退出或者被阻塞。
- 最短作业优先调度算法:会优先选择运行时间最短的进程,有助于提高吞吐量
- 高响应比优先调度算法:主要权衡了短作业和长作业,每次选择响应比最高的进程去运行,响应比=(等待时间+要求服务时间)/要求服务时间,所以相同的等待时间,要求服务时间越短会先运行满足了短作业进程;相同的要求服务时间,等待时间越高会先服务,满足了长作业进程。(这是一个理想型的算法,现实中实现不了,因为没有办法评估进程的要求服务时间)。
- 时间片轮转调度算法:每个进程被分配一个可以运行的时间段,称为时间片,在时间片内还没有运行完会将CPU资源分配给其他进程,如果进程阻塞或者结束就马上进行CPU的切换。关键在于分配时间片的长度,如果时间片太多就会导致过多的进程上下文切换降低CPU使用率,过长的时间片又会导致短作业的响应时间边长。
- 最高优先级调度算法:从就绪队列中选择最高优先级的进程进行运行,优先级划分可以分为静态优先级(在进程创建的时候就确定了优先级)和动态优先级(优先级动态调整,比如进程运行时间增加会降低优先级,进程等待时间增加,会升高优先级)
- 多级反馈队列调度算法:是时间片轮转算法和最高优先级算法的综合,有多个队列,每个队列按照优先级从高到低,同时优先级越高的时间片越短,如果有新的进程加入优先级高的队列,就会停止当前进程,去运行优先级更高的进程。
进程通信
每个进程的用户地址空间都是独立的,一般而言是不能互相访问的(因为每个进程的虚拟内存地址都不一样),但内核空间是每个进程都共享的,所以进程之间要通信必须通过内核。
进程间通信目的一般有共享数据,数据传输,消息通知,进程控制等。以 Unix/linx 为例,重要的几种进程间通信方式有:管道、消息队列、共享内存、信号量、信号、Socket
进程通信 - 管道
ps -aux | grep mysql
上述命令中就是我们常用的匿名单向管道, 该命令里的坚线【|】就是一个管道,功能是将前一个命令(ps -aux)的输出,作为后一个命(grep mysql) 的输入,从这功能描述,可以看出管道传输数据是单向的,如果想相互通信,我们需要创建两个管道才行(可通过mkfifo 管道名 创建)。
管道的优点就是简单,缺点就是效率低下,不适合进程间频繁的进行数据交换。
进程通信 - 消息队列
前面说到管道的通信方式是效率低的,因此管道不适合进程间频繁地交换数据。
对于这个问题,消息队列的通信模式就可以解决。比如,A进程要给 B 进程发送消息,A 进程把数据放在对应的消息队列后就可以正常返回了,B 进程需要的时候再去读取数据就可以了。同理,B 进程要给 A 进程发送消息也是如此。
再来,消息队列是保存在内核中的消息链表,在发送数据时,会分成一个一个独立的数据单元,也就是消息体数据块),消息体是用户自定义的数据类型,消息的发送方和接收方要约定好消息体的数据类型,以每个消息体都是固定大小的存储块,不像管道是无格式的字节流数据。如果进程从消息队列中读取了消息体,内核就会把这个消息体删除。
消息队列生命周期随内核,如果没有释放消息队列或者没有关闭操作系统,消息队列会一直存在,而前面提到的匿名管道的生命周期,是随进程的创建而建立,随进程的结束而销毁。
消息队列的缺点是,通信过程中存在用户态与内核态之间的数据拷贝开销,因为进程写入数据到内核中的消息队列时,会发生从用户态拷贝数据到内核态的过程,同理,另一进程读取内核中的消息数据时,会发生从内核态拷贝数据到用户态的过程,导致通信不及时,且传输的附件也会有大小限制。
进程通信 - 共享内存
消息队列的读取和写入的过程,都会有发生用户态与内核态之间的消息拷贝过程。那共享内存的方式,就很好的解决了这一问题。
现代操作系统,对于内存管理,采用的是虚拟内存技术,也就是每个进程都有自己独立的虚拟内存空间,不同进程的虚拟内存映射到不同的物理内存中。所以,即使进程A和进程B 的虚拟地址是一样的,其实访问的是不同的物理内存地址,对于数据的增删查改互不影响。
共享内存的机制,就是拿出一块虚拟地址空间来,映射到相同的物理内存中。这样这个进程写入的东西,另外一个进程马上就能看到了,不需要拷贝来拷贝去,传来传去,大大提高了进程间通信的速度。
共享内存的缺点是,如果两个进程同时往相同的地址写入数据,则先写入的数据可能会被覆盖。
进程通信 - 信号量
为了防止共享内存通信方式带来的问题,使用信号量实现了一个保护机制,信号量其实是一个整型的计数器,主要用于实现进程间的互斥与同步,而不是用于缓存进程间通信的数据。是通过P,V这两个原子操作来控制信号量资源的数量,P在进入共享资源之前信号量-1,V在离开共享资源之后,信号量+1,常在线程池中运用,当消息队列中有数据时,通过信号量通知线程池取出任务。
进程通信 - 信号
对于异常情况下的工作模式,就需要用「信号」的方式来通知进程。Linux为了响应各种信号提供了几十种信号,有信号发生时不管在运行什么都会有三种处理情况:1、执行默认操作(比如终止进程),2、捕捉信号(可以定义信号处理函数,信号发生时去执行相应的函数),3、忽略信号。
运行在shell 终端的进程,我们可以通过键盘输入某些组合键的时候,给进程发送信号。例如
- Ctrl+C产生SIGINT 信号,表示终止该进程
- Ctrl+Z 产生SIGTSTP 信号,表示停止该进程,但还未结束
如果进程在后台运行,可以通过 kill 命令的方式给进程发送信号,但前提需要知道运行中的进程 PID 号,例如: kill -9 1050,表示给PID为1050的进程发送SIGKILL 信号,用来立即结束该进程。
所以,信号事件的来源主要有硬件来源(如键盘 CItr+C)和软件来源 (如 kill 命令)。
信号是进程间通信机制中唯一的异步通信机制
进程需要为信号设置相应的监听处理,当收到特定信号时,执行相应的操作,类似很多编程语言里的通知机制。
进程通信 - Socket
可以跨网络与不同主机上的进程进行通信,也可以在同主机上进行通讯;socket一般有三种通讯方式:TCP字节流通讯、UDP数据报通讯、本地进程通讯。使用本地的socket通讯不需要绑定IP地址和端口而是绑定一个本地文件,其他与TCP或者UDP通讯均无区别。
操作系统文件系统
以下这张图大体上描述了 Linux 系统上,应用程序对磁盘上的文件进行读写时,从上到下经历了哪些事情。
文件系统的基本组成
文件系统是操作系统中负责管理持久数据的子系统,说简单点,就是负责把用户的文件存到盘硬件中,因为即使计算机断电了,磁盘里的数据并不会丢失,所以可以持久化的保存文件。
文件系统的目的是对磁盘上的文件进行组织管理,它的基本数据单位是文件。组织的方式不同,就会形成不同的文件系统,比如常见的 Ext4、XFS、ZFS 以及网络文件系统 NFS 等等。
但是不同类型的文件系统标准和接口可能各有差异,我们在做应用开发的时候却很少关心系统调用以下的具体实现,大部分时候都是直接系统调用 open
, read
, write
, close
来实现应用程序的功能,不会再去关注我们具体用了什么文件系统(UFS、XFS、Ext4、ZFS),磁盘是什么接口(IDE、SCSI,SAS,SATA 等),磁盘是什么存储介质(HDD、SSD)
应用开发者之所以这么爽,各种复杂细节都不用管直接调接口,是因为内核为我们做了大量的有技术含量的脏活累活。上面的那张图看到 Linux 在各种不同的文件系统之上,虚拟了一个 VFS,目的就是统一各种不同文件系统的标准和接口,让开发者可以使用相同的系统调用来使用不同的文件系统。
文件系统首先要先挂载到某个目录才可以正常使用,比如 Linux 系统在启动时,会把文件系统挂载到根目录.
Linux 最经典的一句话是[一切皆文件],不仅通的文件和目录,就连块设备、管道、socket 等,也都是统一交给文件系统管理的。
用 ls -l 命令看最前面的字符可以看到这个文件是什么类型
brw-r--r-- 1 root root 1, 2 4月 25 11:03 bnod // 块设备文件
crw-r--r-- 1 root root 1, 2 4月 25 11:04 cnod // 符号设备文件
drwxr-xr-x 2 wrn3552 wrn3552 6 4月 25 11:01 dir // 目录
-rw-r--r-- 1 wrn3552 wrn3552 0 4月 25 11:01 file // 普通文件
prw-r--r-- 1 root root 0 4月 25 11:04 pipeline // 有名管道
srwxr-xr-x 1 root root 0 4月 25 11:06 socket.sock // socket文件
lrwxrwxrwx 1 root root 4 4月 25 11:04 softlink -> file // 软连接
-rw-r--r-- 2 wrn3552 wrn3552 0 4月 25 11:07 hardlink // 硬链接(本质也是普通文件)
Linux 文件系统会为每个文件分配两个数据结构:索引节点(index node)和目录项(directory entry),它们主要用来记录文件的元信息和目录层次结构。
- 索引节点,也就是 inode,用来记录文件的元信息,比如 inode 编号、文件大小、访问权限、创建时间、修改时间、数据在磁盘的位置等等,索引节点是文件的唯一标识,它们之间一一对应,也同样都会被存储在硬盘中,所以索引节点同样占用磁盘空间。
- 目录项,也就是 dentry,用来记录文件的名字、索引节点指针以及与其他目录项的层级关联关系,多个目录项关联起来,就会形成目录结构,但它与索引节点不同的是,目录项是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存,以此提高文件查找速度。
由于索引节点唯一标识一个文件,而目录项记录着文件的名,所以索引节点和目录项的关系是一对多,也就是说,一个文件可以有多个别字。比如,硬链接的实现就是多个目录项中的索引节点指向同一个文件。
注意,目录也是文件,也是用索引节点唯一标识,和普通文件不同的是,普通文件在磁盘里面保存的是文件数据,而目录文件在磁盘里面保存子目录或文件。
可以认为:
- Linux文件系统 = VFS + 索引节点(inode)+目录项(dentry),
- 一个文件/目录 = 一个索引节点 = n个目录项
目录项和目录是一个东西吗?
虽然名字很相近,但是它们不是一个东西,目录是个文件,持久化存储在磁盘,而目录项是内核一个数据结构,缓存在内存。目录和文件都有目录项,即目录项这个数据结构是可以表示目录也可以表示文件的。
如果查询目录频繁从磁盘读,效率会很低,所以内核会把已经读过的目录用目录项这个数结构存在内存,下次再读到相同的目录时,只需从内存读就可以,大大提高文件系统的效率。
文件系统的磁盘存储
磁盘读写的最小单位是扇区,扇区的大小只有 512B 大小,很明显,如果每次读写都以这么小为单位,那这读写的效率会非常低。
所以,文件系统把多个扇区组成了一个块,每次读写的最小单位就是逻辑块(数据块),Linux 中的逻辑块大小为 4KB,也就是一次性读写 8 个扇区,这将大大提高了磁盘的读写的效率。
另外,磁盘进行格式化的时候,会被分成三个存储区域,分别是超级块、索引节点区和数据块区。
- 超级块,用来存储文件系统的详细信息,比如块个数、块大小、空闲块等等;
- 索引节点区,用来存储索引节点;
- 数据块区,用来存储文件或目录数据;
以上就是索引节点、目录项以及文件数据的关系,下面这个图就很好的展示了它们之间的关系:
再次引用:Linux 文件系统会为每个文件分配两个数据结构:索引节点(index node)和目录项(directory entry),它们主要用来记录文件的元信息和目录层次结构。
索引节点存储在磁盘,目录项是由内核维护的一个数据结构,不存放于磁盘,而是缓存在内存,以此提高文件查找速度。索引节点是存储在硬盘上的数据,那么为了加速文件的访问,通常也会把访问到的索引节点加载到内存中。
但我们不可能把超级块和索引节点区全部加载到内存,这样内存肯定撑不住,所以只有当需要使用的时候,才将其加载进内存,它们加载进内存的时机是不同的:
- 超级块:当文件系统挂载时进入内存;
- 索引节点区:当文件被访问时进入内存;
文件块
在操作系统的辅助之下,磁盘中的数据在计算机中都会呈现为易读的形式,并且我们不需要关心数据到底是如何存放在磁盘中,存放在磁盘的哪个地方等等问题,这些全部都是由操作系统完成的。
前面说过,磁盘的最小单位是扇区,一般是512B,而几乎所有的文件系统都会把文件分割成固定大小的块来存储,通常一个块的大小为 4K(8个扇区),块是操作系统针对硬盘读写的最小单元,即操作系统以块为单位为文件分配存储空间。操作系统还将整个文件系统划分为若干块组,一般一个块组的大小为 128MB(32 K 个块,256K 个扇区)。
与内存管理一样,为了方便对磁盘的管理,文件的逻辑地址也被分为一个个的文件块。于是文件的逻辑地址就是(逻辑块号,块内地址)的形式。用户通过逻辑地址来操作文件,操作系统负责完成逻辑地址与物理地址的映射。
已分配块空间的管理
不同的文件系统为文件分配磁盘空间会有不同的方式,这些方式各自都有优点和缺点。有连续分配、链式分配、索引分配的方法。
- 连续分配要求每个文件在磁盘上占有一组连续的块,该分配方式较为简单。这种方式虽然读写效率高,但是有「磁盘空间碎片」和「文件长度不易扩展」的缺陷。
- 隐式链式分配时目录项中只会记录文件所占磁盘块中的第一块的地址和最后一块磁盘块的地址,然后通过在每一个磁盘块中存放一个指向下一磁盘块的指针,从而可以根据指针找到下一块磁盘块。隐式链接分配很方便文件拓展。所有空闲磁盘块都可以被利用到,无碎片问题,存储利用率高,但是链接分配只能顺序访问,不支持随机访问,查找效率低。
- 显示链接是把用于链接各个物理块的指针显式地存放在一张表中,该表称为文件分配表(FAT,File Allocation Table),由于查找记录的过程是在内存中进行的,因而不仅显著地提高了检索速度,而且大大减少了访问磁盘的次数。但也正是整个表都存放在内存中的关系,它的主要的缺点是不适用于大磁盘。
- 索引分配的实现是为每个文件创建一个「索引数据块」,里面存放的是指向文件数据块的指针列表,说白了就像书的目录一样,要找哪个章节的内容,看目录查就可以。另外,文件头需要包含指向「索引数据块」的指针,这样就可以通过文件头知道索引数据块的位置,再通过索引数据块里的索引信息找到对应的数据块。索引数据也是存放在磁盘块,存储索引也会带来的开销。
空闲块空间的管理
前面说到的文件的存储是针对已经被占用的数据块组织和管理,接下来的问题是,如果我要保存一个数据块,我应该放在硬盘上的哪个位置呢?难道需要将所有的块扫描一遍,找个空的地方随便放吗?那这种方式效率就太低了,所以针对磁盘的空闲空间也是要引入管理的机制,接下来介绍几种常见的方法:空闲表法、空闲链表法、位图法。
位图是利用二进制的一位来表示磁盘中一个盘块的使用情况,磁盘上所有的盘块都有一个二进制位与之对应。当值为 0 时,表示对应的盘块空闲,值为 1 时,表示对应的盘块已分配。它形式如下如:111001110111100111 ...
在 Linux 文件系统就采用了位图的方式来管理空闲空间,不仅用于数据空闲块的管理,还用于 inode 空闲块的管理,因为 inode 也是存储在磁盘的,自然也要有对其管理。
文件的存储
用户在创建一个新文件时,Linux 内核会通过 inode 的位图找到空闲可用的 inode,并进行分配。要存储数据时,会通过块的位图找到空闲的块。
数据块的位图是放在磁盘块里的,假设是放在一个块里,一个块 4K,每位表示一个数据块,共可以表示 4 * 1024 * 8 = 2^15 个空闲块,由于 1 个数据块是 4K 大小,那么最大可以表示的空间为 2^15 * 4 * 1024 = 2^27 个 byte,也就是 128M。
也就是说按照上面的结构,如果采用「一个块的位图 + 一系列的块」,外加「一个块的 inode 的位图 + 一系列的 inode 的结构」能表示的最大空间也就 128M,这太少了,现在很多文件都比这个大。
在 Linux 文件系统,把这个结构称为一个块组,那么有 N 多的块组,就能够表示 N 大的文件。如1G的磁盘,则有 1G / 128M = 8个块组,50G磁盘则有400个块组。
最终,整个文件系统格式就是下面这个样子。
最前面的第一个块是引导块,在系统启动时用于启用引导,接着后面就是一个一个连续的块组了,块组的内容如下:
- 超级块,包含的是文件系统的重要信息,比如 inode 总个数、块总个数、每个块组的 inode 个数、每个块组的块个数等等。
- 块组描述符,包含文件系统中各个块组的状态,比如块组中空闲块和 inode 的数目等,每个块组都包含了文件系统中「所有块组的组描述符信息」。
- 数据位图和 inode 位图, 用于表示对应的数据块或 inode 是空闲的,还是被使用中。
- inode 列表,包含了块组中所有的 inode,inode 用于保存文件系统中与各个文件和目录相关的所有元数据。
- 数据块,包含文件的有用数据。
计算机网络
计算机网络分层
- 物理层实现了物理设备间数据的传输;
- 数据链路层实现了局域网内数据的传输;
- 网络层实现了不同网络间数据的传输;
- 传输层为应用进程之间提供端口到端口的可靠通信;
- 应用层使得用户和程序之间可以通过网络进行交互。
应用层和内核互通的机制,就是通过 Socket 系统调用。所以经常有人会问,Soket 属于哪一层,其实它哪一层都不属于,它属于操作系统的概念,而非网络协议分层的概念,只不过操作系统选择对于网络协议的实现模式是,二到四层的处理代码在内核里面,七层的处理代码让应用自己去做,两者需要跨内核态和用户态通信,就需要一个系统调用完成这个衔接,这就是 Socket。
零拷贝
为什么要有DMA技术
在没有 DMA(直接内存访问)技术前,I/0的过程是这样的:
- CPU 发出对应的指令给磁盘控制器,然后返回;
- 磁盘控制器收到指令后,于是就开始准备数据,会把数据放入到磁盘控制器的内部缓冲区中,然后产生一个中断;
- CPU 收到中断信号后,停下手头的工作,接着把磁盘控制器的缓冲区的数据一次一个字节地读进自己的寄存器,然后再把寄存器里的数据写入到内存。
可以看到,整个数据的传输过程,都要需要 CPU 亲自参与搬运数据的过程,而且这个过程,CPU 是不能做其他事情的。简单的搬运几个字符数据那没问题,但是如果我们用干兆网卡或者硬盘传输大量数据的时候,都用 CPU 来搬运的话,肯定忙不过来.
计算机科学家们发现了事情的严重性后,于是就发明了DMA 技术,也就是直接内存访问(Direct Memory Access)技术。简单理解就是,在进行 I/O 设备和内存的数据传输的时候,数据搬运的工作全部交给 DMA 控制器,而PU 不再参与任与数据搬运相关的事情,这样CPU就可以去处理别的事务。
DMA技术具体过程:
- 用户进程调用read 方法,向操作系统发出I/O 请求,请求读取数据到自己的内存缓冲区中,进程进入阻塞状态
- 操作系统收到请求后,进一步将I/O 请求发送DMA,然后让 CPU 执行其他任务;
- DMA进一步将I/O 请求发送给磁盘;
- 磁盘收到 DMA的 I/O 请求,把数据从磁盘读取到磁盘控制器的缓冲区中,当磁盘控制器的缓冲区被读满后,向 DMA 发起中断信号,告知自己缓冲区已满
- DMA 收到磁盘的信号,将磁盘控制器缓冲区中的数据拷贝到内核缓冲区中,此时不占用 CPU,CPU 可以执行其他任务。
- 当DMA读取了足够多的数据,就会发送中断信号给 CPU;
- CPU 收到DMA的信号,知道数据已经准备好,于是将数据从内核拷贝到用户空间,系统调用返回;
可以看到,整个数据传输的过程,CPU 不再参与数据搬运的工作,而是全程由 DMA 完成,但是 CPU 在这个过程中也是必不可少的,因为传输什么数据,从哪里传输到哪里,都需要 CPU 来告诉 DMA 控制器。
早期DMA 只存在在主板上,如今由于 I/O 设备越来越多,数据传输的需求也不尽相同,所以每个I/O 设备里面都有自己的 DMA控制器。
如果服务端要提供文件传输的功能,我们能想到的最简单的方式是:将磁盘上的文件读取出来,然后通过网络协议发送给客户端。传统 I/O 的工作方式是,数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I/O 接口从磁盘读取或写入。也就是说,传统的文件传输会存在4 次数据拷贝,其中两次是 DMA 的拷贝,另外两次则是通过 CPU 拷贝的。
下面说一下这个过程:·
- 第一次烤贝,把磁盘上的数据拷贝到操作系统内核的缓冲区里,这个拷贝的过程是通过 DMA 搬运的。
- 第二次烤贝,把内核缓冲区的数据拷贝到用户的缓冲区里,于是我们应用程序就可以使用这部分数据了,这个拷贝到过程是由 CPU 完成的。
- 第三次烤贝,把刚才拷贝到用户的缓冲区里的数据,再拷贝到内核的 socket 的缓冲区里,这个过程依然还是由 CPU 搬运的
- 第四次烤贝,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程又是由DMA搬运的
我们回过头看这个文件传输的过程,我们只是搬运一份数据,结果却搬运了 4次,过多的数据拷贝无疑会消耗CPU 资源,大大降低了系统性能。
这种简单又传统的文件传输方式,存在冗余的上文切换和数据拷贝,在高并发系统里是非常糟糕的,多了很多不必要的开销,会严重影响系统性能。
所以,要想提高文件传输的性能,就需要减少【用户态与内核态的上下文切换】和【内存拷贝】的次数。
那如何优化文件传输的性能?
先来看看,如何减少【用户态与内核态的上下文切换】的次数呢?
读取磁盘数据的时候,之所以要发生上下文切换,这是因为用户空间没有权限操作磁盘或网卡,内核的权限最高,这些操作设备的过程都需要交由操作系统内核来完成,所以一般要通过内核去完成某些任务的时候,就需要使用操作系统提供的系统调用函数。
而一次系统调用必然会发生 2次上下文切换:首先从用户态切换到内核态,当内核执行完任务后,再切换回用户态交由进程代码执行。
所以,要想减少上下文切换到次数,就要减少系统调用的次数。
再来看看,如何减少【数据拷贝】的次数?在前面我们知道了,传统的文件传输方式会历经 4次数据猪贝,而且这里面,【从内核的读缓冲区拷贝到用户的缓冲区里,再从用户的缓冲区里拷贝到 socket的缓冲里】,这个过程是没有必要的。
因为文件传输的应用场景中,在用户空间我们并不会对数据再加工,所以数据实际上可以不用搬运到用户空间,因此用户的缓冲区是没有必要存在的。
如何实现零拷贝
零拷贝技术实现的方式通常有2种:
- mmap + write
- sendfile
mmap + write
我们知道,read()系统调用的过程中会将内核缓冲区的数据拷贝到用户的缓冲区里,于是为了减少这一步开销,我们可以用 mmap() 替read()系统调用数。mmap() 系统调用函数会直接把内核缓冲区里的数据映射到用户空间,这样,操作系统内核与用户空间就不需要再进行任何的数据拷贝操作。
sendfile
sendfile系统调用,可以直接把内核缓冲区里的数据拷贝到 socket 缓冲区里,不再贝到用户态,这样就只有 2次上下文切换,和3次数据拷贝。
但是这还不是真正的零拷贝技术,如果网卡支持 SG-DMA (The Scater-Gather Direct Memory Access)技术(和普通的 DMA有所不同),我们可以进一步减少通过CPU把内核缓冲区里的数据拷贝到 socket 缓冲区的过程。
这就是所谓的零拷贝(Zero-cpy)技术,因为我们没有在内存层面去拷贝数据,也就是说全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA来进行传输的。
零烤贝技术的文件传输方式相比传统文件传输的方式,减少了 2次上下文切换和数据拷贝次数,只需要 2次上下文切换和数据拷贝次数,就可以完成文件的传输,而且 2次的数据拷贝过程,都不需要通过CPU,2次都是由 DMA 来搬运。
所以总体来看零拷贝技术可以把文件传输的性能提高至少一倍以上。
第一步拷贝中,将磁盘文件读进内核的缓冲区时,是读进Page Cache。page 是内存管理 分配的基本单位, Page Cache 由多个 page 构成。page 在操作系统中通常为 4KB 大小(通常也等于一个磁盘块的大小),而 Page Cache 的大小则为 4KB 的整数倍。
读取 /proc/meminfo 获取内存情况可知:Page Cache = Buffers + Cached + SwapCached
在高并发的场景下,针对大文件的传输的方式,应该使用[异步IO + 直接IO ]来替代零拷贝技术。
直接I/O应用场景常见的两种:
- 应用程序已经实现了磁盘数据的缓存,那么可以不需要 Pageache 再次缓存,减少额外的性能损耗在 MySQL数库中,可以通过参数设置开启直接 IO,默认是不开启;
- 传输大文件的时候,由于大文件难以命中 PageCache 缓存,而且会占满 Pageache 导致热点文件法充分利用内存,从而增大了性能开销,因此,这时应该使用直接IO。
传输文件的时候,我们要根据文件的大小来使用不同的方式:
- 传输大文件的时候,使用 [异步 IO + 直接 IO]
- 传输小文件的时候,则使用[零拷贝技术]
在Nginx 中,我们可以用如下配置,来根据文件的大小来使用不同的方式:
location /video/ {
sendfile on;
aio on;
directio 1024m;
}
当文件大小大于directio 值后,使用[异步I/O + 直接I/O],否则使用[零拷贝技术](缓存IO)。
HTTP
HTTP协议(HyperText Transfer Protocol,超文本传输协议)是用于从服务器传输超文本到本地浏览器的传送协议。它基于TCP协议,由HTTP客户端向服务器发起一个请求,HTTP服务器一旦收到请求,将向客户端发回一个状态行,比如"HTTP/1.1 200 OK",和响应的消息。(消息的消息体可能是请求的文件、错误消息、或者其它信息)
HTTP协议比较重要的的两个特点:无连接、无状态
- 无连接:无连接的含义是限制每次连接只处理一个请求。HTTP1.0时,每个http请求都会打开一个tcp连接,并且请求过后就断开这个tcp连接。随着时间的推移,网页变得越来越复杂,里面可能嵌入了很多图片,这时候每次访问图片都需要建立一次 TCP 连接就显得很低效。HTTP1.1后,Keep-Alive 被提出用来解决这效率低的问题。Keep-Alive 功能使客户端到服务器端的连接持续有效,即一次TCP连接内客户端可以发起多次请求,Keep-Alive 功能就省去了建立或者重新建立连接的时间。虽然说Keep-Alive使用了长连接优化了效率,但这是属于HTTP请求之外的,我们始终都要认为HTTP请求在结束后连接就会关闭。这是HTTP的特性。至于下层实现是否在结束请求后关闭连接,都不会改变这个特性。所以我们说HTTP协议是无连接的。
- 无状态:HTTP协议是无状态协议。指协议对于事务处理没有记忆能力,服务器不知道客户端是什么状态。即我们给服务器发送 HTTP 请求之后,服务器根据请求,会给我们发送数据过来,但是,发送完,不会记录任何信息。我们通过cookie、session等技术可以使得客户端和服务器保持状态。但是HTTP协议本身是没有状态的。
下面来看一下HTTP报文的结构。
请求报文由四部分构成,即请求行、请求头部、空行和请求数据
请求报文中的首个字段,即请求方法,就是我们常见的GET、POST。此外还有HEAD、PUT、DELETE等。
响应报文也由四部分构成,即状态行、消息头部、空行和响应数据
TCP
TCP(Transmission Control Protocol 传输控制协议)是面向连接的、可靠传输的协议。同时具有流量控制、拥塞控制、面向字节流传输等特点。
- 面向连接:就是说应用程序在使用TCP协议之前,必须先建立TCP连接。在传送数据完毕后,必须释放已经建立的TCP连接。(即三次握手与四次挥手)
- 可靠传输:就是说通过TCP连接保证传送的数据,无差错、不丢失、不重复、并且按序到达。TCP通过数据分段、接收确认和超时重传等方式来实现。
- 流量控制:TCP提供全双工通信,会话双方都可以同时接收和发送数据。都设有接收缓存和发送缓存,用来临时存放双向通信的数据。在发送时,应用程序在把数据传送给发送缓存后,就可以做自己的事,而TCP在合适的时候把数据发送出去。在接收时,TCP把收到的数据放入接收缓存,上层的应用进程在合适的时候读取接收缓存中的数据,对其进行处理。如果发送端的发送速度太快,导致接收端的接收缓冲区很快的填充满了。此时如果发送端仍旧发送数据,那么接下来发送的数据都会丢包,继而导致丢包的一系列连锁反应,超时重传呀什么的。所以TCP根据接收端对数据的处理能力,决定发送端的发送速度,防止发送端发送过快,这个机制就是流量控制。TCP通过滑动窗口的概念来进行流量控制。
滑动窗口协议:允许发送方在收到确认报文前发送个数据分组,而不必每发送一个分组就停下来等待确认。因此该协议可以高效可靠地发送大量的数据。如果发送方收到接收方的窗口大小为0的TCP数据报,代表此时接收方的接收缓存已经充满,那么发送方将停止发送数据,等到接受方发送窗口大小不为0的数据报的到来。
- 拥塞控制:如果TCP在启动一个连接的时马上向网络发送大量数据包,可能会导致路由器或链路负载过量,产生网络拥塞,使得TCP连接的吞吐量急剧下降。由于TCP源端无法知道网络资源当前的使用情况,因此新建立的TCP连接不能一开始就发送大量数据,只能逐步增加每次发送的数据量,以避免上述现象的发生。拥塞控制就是防止过多的数据注入到网络中,这样可以使网络中的路由器或链路不致过载。常用的方法就是:1. 慢开始、拥塞控制 2. 快重传、快恢复。(具体实现就不贴出来了)
流量控制和拥塞控制的现象都是丢包,实现机制都是让发送方发的慢一点,发的少一点。但流量控制丢包位置是在接收端上,拥塞控制丢包位置是在路由器上。流量控制的对象是接收方,怕发送方发的太快,使得接收方来不及处理,拥塞控制的对象是网络,怕发送发发的太快,造成网络拥塞,使得网络来不及处理。
- 面向字节流:指的是TCP将应用层数据看成是一连串的无结构的字节流,通过TCP连接传输到对面的应用层。对于太长的数据,TCP会将数据流分成适当长度的报文段,之后会将数据包发送给网络层,然后通过各种网络协议将数据包发送至接收端的传输层。在这过程中,传输层为了保证数据在传输中不发生丢包的现象,因此给每一个数据包标注一个序号,保证了接收端可以按照数据包的按序接收。
(说TCP是面向字节流的指的是在应用层,数据以字节流的形式通过TCP连接在应用进程之间传送。TCP报文段指的是应用层数据向下到达传输层时以TCP报文段的形式向网络层传送。)