协议格式

数据包封装

传输层及其以下的机制由内核提供应用层由用户进程提供应用程序对通讯数据的含义进行解释,而传输层及其以下处理通讯的细节,将数据从一台计算机通过一定的路径发送到另一台计算机。 应用层数据通过协议栈发到网络上时,每层协议都要加上一个数据首部(header),称为封装(Encapsulation),如下图所示:

数据包封装

不同的协议层对数据包有不同的称谓,在传输层叫做段(segment),在网络层叫做数据报(datagram),在链路层叫做帧(frame)。数据封装成帧后发到传输介质上,到达目的主机后每层协议再剥掉相应的首部,最后将应用层数据交给应用程序处理。

以太网帧格式

以太网帧格式

其中的源地址和目的地址是指网卡的硬件地址(也叫MAC地址),长度是48位,是在网卡出厂时固化的。可在shell中使用ifconfig命令查看,“00:50:56:c0:00:01”部分就是硬件地址。类型字段有三种值,分别对应IP、ARP、RARP。帧尾是CRC校验码。

以太网帧中的数据长度规定最小46字节,最大1500字节,ARP和RARP数据包的长度不够46字节,要在后面补填充位。最大值1500称为以太网的最大传输单元(MTU),不同的网络类型有不同的MTU,如果一个数据包从以太网路由到拨号链路上,数据包长度大于拨号链路的MTU,则需要对数据包进行分片(fragmentation)。ifconfig命令输出中也MTU:1500.注意,MTU指指数据帧中有效载荷的最大长度,不包括帧头长度。

ARP数据报格式

在网络通讯时,源主机的应用程序知道目的主机的IP地址和端口号,却不知道目的主机的硬件地址,而数据包首先是被网卡接收到再去处理上层协议的,如果接收到的数据包的硬件地址与本机不符,则直接丢弃。因此在通讯前必须获得目的主机的硬件地址。ARP协议就起到这个作用。源主机发出ARP请求,询问“IP地址是192.168.0.1的主机的硬件地址是多少”,并将这个请求广播到本地网段(以太网帧首部的硬件地址填FF:FF:FF:FF:FF:FF表示广播),目的主机接收到广播的ARP请求,发现其中的IP地址与本机相符,则发送一个ARP应答数据包给源主机,将自己的硬件地址填写在应答包中。

每台主机都维护一个ARP缓存表,可以用arp -a命令查看。缓存表中的表项有过期时间(一般为20分钟),如果20分钟内没有再次使用某个表项,则该表项失效,下次还要发ARP请求来获得目的主机的硬件地址。

ARP数据报的格式如下所示:

ARP数据报格式

源MAC地址、目的MAC地址在以太网首部和ARP请求中各出现一次,对于链路层为以太网的情况是多余的,但如果链路层是其它类型的网络则有可能是必要的。硬件类型指链路层网络类型,1为以太网,协议类型指要转换的地址类型,0x0800为IP地址,后面两个地址长度对于以太网地址和IP地址分别为6和4(字节),op字段为1表示ARP请求,op字段为2表示ARP应答。

看一个具体的例子。

请求帧如下(为了清晰在每行的前面加了字节计数,每行16个字节):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
以太网首部(14字节)

0000: ff ff ff ff ff ff 00 05 5d 61 58 a8 08 06

ARP帧(28字节)

0000: 00 01

0010: 08 00 06 04 00 01 00 05 5d 61 58 a8 c0 a8 00 37

0020: 00 00 00 00 00 00 c0 a8 00 02

填充位(18字节)

0020: 00 77 31 d2 50 10

0030: fd 78 41 d3 00 00 00 00 00 00 00 00

以太网首部:目的主机采用广播地址,源主机的MAC地址是00:05:5d:61:58:a8,上层协议类型0x0806表示ARP。

ARP帧:硬件类型0x0001表示以太网,协议类型0x0800表示IP协议,硬件地址(MAC地址)长度为6,协议地址(IP地址)长度为4,op为0x0001表示请求目的主机的MAC地址,源主机MAC地址为00:05:5d:61:58:a8,源主机IP地址为c0 a8 00 37(192.168.0.55),目的主机MAC地址全0待填写,目的主机IP地址为c0 a8 00 02(192.168.0.2)。

由于以太网规定最小数据长度为46字节,ARP帧长度只有28字节,因此有18字节填充位,填充位的内容没有定义,与具体实现相关。

应答帧如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
以太网首部

0000: **00 05 5d 61 58 a8** 00 05 5d a1 b8 40 08 06

ARP帧

0000: 00 01

0010: 08 00 06 04 00 02 **00 05 5d a1 b8 40** c0 a8 00 02

0020: 00 05 5d 61 58 a8 c0 a8 00 37

填充位

0020: 00 77 31 d2 50 10

0030: fd 78 41 d3 00 00 00 00 00 00 00 00

以太网首部:目的主机的MAC地址是00:05:5d:61:58:a8,源主机的MAC地址是00:05:5d:a1:b8:40,上层协议类型0x0806表示ARP。

ARP帧:硬件类型0x0001表示以太网,协议类型0x0800表示IP协议,硬件地址(MAC地址)长度为6,协议地址(IP地址)长度为4,op为0x0002表示应答,源主机MAC地址为00:05:5d:a1:b8:40,源主机IP地址为c0 a8 00 02(192.168.0.2),目的主机MAC地址为00:05:5d:61:58:a8,目的主机IP地址为c0 a8 00 37(192.168.0.55)。

如果源主机和目的主机不在同一网段,ARP请求的广播帧无法穿过路由器,源主机如何与目的主机通信?

在网段通信时,数据包中的地址就是源IP,目标IP,源MAC,目标MAC,根本用不到网关,而当检测到需要把数据包发到远程网络时,这时,目标MAC就必须改变了,在还没有出内网时,目标MAC必须写成网关的MAC地址发出去,当网关收到时,再把目标MAC地址改成下一跳的MAC地址发出去,而源IP和源MAC以及目标IP不曾改变,就算到达了公网上,目标MAC仍然在不断改变着,直到最后,这个数据包到达目标IP的网络,最终通信结束!

不同网段的主机通信时,主机会封装网关(通常是路由器)的mac地址,然后主机将数据发送给路由器,后续路由进行路由转发,通过arp解析目标地址的mac地址,然后将数据包送达目的地。可参考:https://blog.csdn.net/weixin_43166958/article/details/86503506

IP段格式

IP段格式

  • IP数据报的首部长度和数据长度都是可变长的,但总是4字节的整数倍。对于IPv4,4位版本字段是4。

  • 4位首部长度的数值是以4字节为单位的,最小值为5,也就是说首部长度最小是4x5=20字节,也就是不带任何选项的IP首部,4位能表示的最大值是15,也就是说首部长度最大是60字节

  • 8位TOS字段有3个位用来指定IP数据报的优先级(目前已经废弃不用),还有4个位表示可选的服务类型(最小延迟、最大吞吐量、最大可靠性、最小成本),还有一个位总是0。
  • 总长度是整个数据报(包括IP首部和IP层payload)的字节数。
  • 每传一个IP数据报,16位的标识加1,可用于分片和重新组装数据报。
  • 3位标志和13位片偏移用于分片。
  • TTL(Time to live)是这样用的:源主机为数据包设定一个生存时间,比如64,每过一个路由器就把该值减1,如果减到0就表示路由已经太长了仍然找不到目的主机的网络,就丢弃该包,因此这个生存时间的单位不是秒,而是跳(hop)。
  • 协议字段指示上层协议是TCP、UDP、ICMP还是IGMP。
  • 校验和,只校验IP首部,数据的校验由更高层协议负责。IPv4的IP地址长度为32位。

UDP数据报格式

udp数据报格式

下面分析一帧基于UDP的TFTP协议帧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
以太网首部

0000: 00 05 5d 67 d0 b1 00 05 5d 61 58 a8 08 00

IP首部

0000: 45 00

0010: 00 53 93 25 00 00 80 11 25 ec c0 a8 00 37 c0 a8

0020: 00 01

UDP首部

0020: 05 d4 00 45 00 3f ac 40

TFTP协议

0020: 00 01 'c'':''\''q'

0030: 'w''e''r''q''.''q''w''e'00 'n''e''t''a''s''c''i'

0040: 'i'00 'b''l''k''s''i''z''e'00 '5''1''2'00 't''i'

0050: 'm''e''o''u''t'00 '1''0'00 't''s''i''z''e'00 '0'

0060: 00
  • 以太网首部:源MAC地址是00:05:5d:61:58:a8,目的MAC地址是00:05:5d:67:d0:b1,上层协议类型0x0800表示IP。

  • IP首部:

    • 每一个字节45包含4位版本号和4位首部长度,版本号为4,即IPv4。
    • 首部长度为5,说明IP首部不带有选项字段
    • 服务类型为0,没有使用服务。
    • 16位总长度字段(包括IP首部和IP层payload的长度)为0x0053,即83字节,加上以太网首部14字节可知整个帧长度是97字节。
    • IP报标识是0x9325
    • 标志字段和片偏移字段设置为0x0000,就是DF=0允许分片,MF=0此数据报没有更多分片,没有分片偏移。
    • TTL是0x80,也就是128。
    • 上层协议0x11表示UDP协议。
    • IP首部校验和为0x25ec
    • 源主机IP是c0 a8 00 37(192.168.0.55)
    • 目的主机IP是c0 a8 00 01(192.168.0.1)。
  • UDP首部:

    • 源端口号0x05d4(1492)是客户端的端口号
    • 目的端口号0x0045(69)是TFTP服务的well-known端口号。
    • UDP报长度为0x003f,即63字节,包括UDP首部和UDP层pay-load的长度。
    • UDP首部和UDP层payload的校验和为0xac40。
  • TFTP是基于文本的协议,各字段之间用字节0分隔,开头的00 01表示请求读取一个文件,接下来的各字段是:

1
2
3
4
5
c:\qwerq.qwe
netascii
blksize 512
timeout 10
tsize 0

一般的网络通信都是像TFTP协议这样,通信的双方分别是客户端和服务器,客户端主动发起请求(上面的例子就是客户端发起的请求帧),而服务器被动地等待、接收和应答请求。

客户端的IP地址和端口号唯一标识了该主机上的TFTP客户端进程服务器的IP地址和端口号唯一标识了该主机上的TFTP服务进程,由于客户端是主动发起请求的一方,它必须知道服务器的IP地址和TFTP服务进程的端口号,所以,一些常见的网络协议有默认的服务器端口,例如HTTP服务默认TCP协议的80端口,FTP服务默认TCP协议的21端口,TFTP服务默认UDP协议的69端口

在使用客户端程序时,必须指定服务器的主机名或IP地址,如果不明确指定端口号则采用默认端口,请读者查阅ftp、tftp等程序的man page了解如何指定端口号。

/etc/services中列出了所有well-known的服务端口和对应的传输层协议,这是由IANA(Internet Assigned Numbers Authority)规定的,其中有些服务既可以用TCP也可以用UDP,为了清晰,IANA规定这样的服务采用相同的TCP或UDP默认端口号,而另外一些TCP和UDP的相同端口号却对应不同的服务。

很多服务有well-known的端口号,然而客户端程序的端口号却不必是well-known的,往往是每次运行客户端程序时由系统自动分配一个空闲的端口号,用完就释放掉,称为ephemeral的端口号

前面提过,UDP协议不面向连接,也不保证传输的可靠性,例如:

发送端的UDP协议层只管把应用层传来的数据封装成段交给IP协议层就算完成任务了,如果因为网络故障该段无法发到对方,UDP协议层也不会给应用层返回任何错误信息。

接收端的UDP协议层只管把收到的数据根据端口号交给相应的应用程序就算完成任务了,如果发送端发来多个数据包并且在网络上经过不同的路由,到达接收端时顺序已经错乱了,UDP协议层也不保证按发送时的顺序交给应用层。

通常接收端的UDP协议层将收到的数据放在一个固定大小的缓冲区中等待应用程序来提取和处理,如果应用程序提取和处理的速度很慢,而发送端发送的速度很快,就会丢失数据包,UDP协议层并不报告这种错误。

因此,使用UDP协议的应用程序必须考虑到这些可能的问题并实现适当的解决方案,例如等待应答、超时重发、为数据包编号、流量控制等。一般使用UDP协议的应用程序实现都比较简单,只是发送一些对可靠性要求不高的消息,而不发送大量的数据。例如,基于UDP的TFTP协议一般只用于传送小文件(所以才叫trivial的ftp),而基于TCP的FTP协议适用于各种文件的传输。

TCP数据报格式

TCP数据报格式

与UDP协议一样也有源端口号和目的端口号,通讯的双方由IP地址和端口号标识。32位序号、32位确认序号、窗口大小稍后详细解释。4位首部长度和IP协议头类似,表示TCP协议头的长度,以4字节为单位,因此TCP协议头最长可以是4x15=60字节,如果没有选项字段,TCP协议头最短20字节。URG、ACK、PSH、RST、SYN、FIN是六个控制位。16位检验和将TCP协议头和数据都计算在内。

TCP协议

TCP通信时序

下图是一次TCP通讯的时序图。TCP连接建立断开。包含熟知的三次握手和四次挥手

TCP通信时序

在这个例子中,首先客户端主动发起连接、发送请求,然后服务器端响应请求,然后客户端主动关闭连接。两条竖线表示通讯的两端,从上到下表示时间的先后顺序,注意,数据从一端传到网络的另一端也需要时间,所以图中的箭头都是斜的。双方发送的段按时间顺序编号为1-10,各段中的主要信息在箭头上标出,例如段2的箭头上标着SYN, 8000(0), ACK 1001,表示该段中的SYN位置1,32位序号是8000,该段不携带有效载荷(数据字节数为0),ACK位置1,32位确认序号是1001带有一个mss(Maximum Segment Size,最大报文长度)选项值为1024。

三次握手

  • 客户端发送一个带SYN标志的TCP报文到服务器。这是三次握手过程中的段1

    • 客户端发出段1,SYN位表示连接请求
    • 序号是1000,这个序号在网络通讯中用作临时的地址,每发一个数据字节,这个序号要加1,这样在接收端可以根据序号排出数据包的正确顺序,也可以发现丢包的情况
    • 规定SYN位和FIN位也要占一个序号,这次虽然没发数据,但是由于发了SYN位,因此下次再发送应该用序号1001(段4)
    • mss表示最大段尺寸,如果一个段太大,封装成帧后超过了链路层的最大帧长度,就必须在IP层分片,为了避免这种情况,客户端声明自己的最大段尺寸,建议服务器端发来的段不要超过这个长度。
  • 服务器端回应客户端,是三次握手中的第2个报文段,同时带ACK标志和SYN标志。它表示对刚才客户端SYN的回应;同时又发送SYN给客户端,询问客户端是否准备好进行数据通讯。

    • 服务器发出段2,也带有SYN位,同时置ACK位表示确认,确认序号是1001,表示“我接收到序号1000及其以前所有的段,请你下次发送序号为1001的段”,也就是应答了客户端的连接请求,同时也给客户端发出一个连接请求,同时声明最大尺寸为1024。
  • 客户必须再次回应服务器端一个ACK报文,这是报文段3。

    • 客户端发出段3,对服务器的连接请求进行应答,确认序号是8001。在这个过程中,客户端和服务器分别给对方发了连接请求,也应答了对方的连接请求,其中服务器的请求和应答在一个段中发出,因此一共有三个段用于建立连接,称为“三方握手(three-way-handshake)”。在建立连接的同时,双方协商了一些信息,例如双方发送序号的初始值、最大段尺寸等。

在TCP通讯中,如果一方收到另一方发来的段,读出其中的目的端口号,发现本机并没有任何进程使用这个端口,就会应答一个包含RST位的段给另一方。例如,服务器并没有任何进程使用8080端口,我们却用telnet客户端去连接它,服务器收到客户端发来的SYN段就会应答一个RST段,客户端的telnet程序收到RST段后报告错误Connection refused:

1
2
3
4
5
$ telnet 192.168.0.200 8080

Trying 192.168.0.200...

telnet: Unable to connect to remote host: Connection refused

数据传输过程

  • 客户端发出段4,包含从序号1001开始的20个字节数据。

  • 服务器发出段5,确认序号为1021,对序号为1001-1020的数据表示确认收到,同时请求发送序号1021开始的数据,服务器在应答的同时也向客户端发送从序号8001开始的10个字节数据,这称为piggyback。

  • 客户端发出段6,对服务器发来的序号为8001-8010的数据表示确认收到,请求发送序号8011开始的数据。

在数据传输过程中,ACK和确认序号是非常重要的,应用程序交给TCP协议发送的数据会暂存在TCP层的发送缓冲区中,发出数据包给对方之后,只有收到对方应答的ACK段才知道该数据包确实发到了对方,可以从发送缓冲区中释放掉了,如果因为网络故障丢失了数据包或者丢失了对方发回的ACK段,经过等待超时后TCP协议自动将发送缓冲区中的数据包重发。

四次挥手

由于TCP连接是全双工的,因此每个方向都必须单独进行关闭。这原则是当一方完成它的数据发送任务后就能发送一个FIN来终止这个方向的连接。收到一个 FIN只意味着这一方向上没有数据流动,一个TCP连接在收到一个FIN后仍能发送数据。首先进行关闭的一方将执行主动关闭,而另一方执行被动关闭

  • 客户端发出段7,FIN位表示关闭连接的请求。

  • 服务器发出段8,应答客户端的关闭连接请求。

  • 服务器发出段9,其中也包含FIN位,向客户端发送关闭连接请求。

  • 客户端发出段10,应答服务器的关闭连接请求。

建立连接的过程是三次握手,而关闭连接通常需要4个段,服务器的应答和关闭连接请求通常不合并在一个段中,因为有连接半关闭的情况,这种情况下客户端关闭连接之后就不能再发送数据(ACK是可以的,只是没有数据)给服务器了,但是服务器还可以发送数据给客户端,直到服务器也关闭连接为止。

滑动窗口(TCP流量控制)

介绍UDP时描述了这样的问题:如果发送端发送的速度较快,接收端接收到数据后处理的速度较慢,而接收缓冲区的大小是固定的,就会丢失数据。TCP协议通过“滑动窗口(Sliding Window)”机制解决这一问题。看下图的通讯过程:

滑动窗口

  • 发送端发起连接,声明最大段尺寸是1460,初始序号是0,窗口大小是4K,表示“我的接收缓冲区还有4K字节空闲,你发的数据不要超过4K”。接收端应答连接请求,声明最大段尺寸是1024,初始序号是8000,窗口大小是6K。发送端应答,三次握手结束。

  • 发送端发出段4-9,每个段带1K的数据,发送端根据窗口大小知道接收端的缓冲区满了,因此停止发送数据。

  • 接收端的应用程序提走2K数据,接收缓冲区又有了2K空闲,接收端发出段10,在应答已收到6K数据的同时声明窗口大小为2K。

  • 接收端的应用程序又提走2K数据,接收缓冲区有4K空闲,接收端发出段11,重新声明窗口大小为4K。

  • 发送端发出段12-13,每个段带2K数据,段13同时还包含FIN位。

  • 接收端应答接收到的2K数据(6145-8192),再加上FIN位占一个序号8193,因此应答序号是8194,连接处于半关闭状态,接收端同时声明窗口大小为2K。

  • 接收端的应用程序提走2K数据,接收端重新声明窗口大小为4K。

  • 接收端的应用程序提走剩下的2K数据,接收缓冲区全空,接收端重新声明窗口大小为6K。

  • 接收端的应用程序在提走全部数据后,决定关闭连接,发出段17包含FIN位,发送端应答,连接完全关闭。

上图在接收端用小方块表示1K数据,实心的小方块表示已接收到的数据,虚线框表示接收缓冲区,因此套在虚线框中的空心小方块表示窗口大小,从图中可以看出,随着应用程序提走数据,虚线框是向右滑动的,因此称为滑动窗口。

从这个例子还可以看出,发送端每次发送1KB数据,而接收端的应用程序可以每次提走2KB数据,当然也有可能一次提走3K或6K数据,或者一次只提走几个字节的数据。也就是说,应用程序所看到的数据是一个整体,或说是一个流(stream),在底层通讯中这些数据可能被拆成很多数据包来发送,但是一个数据包有多少字节对应用程序是不可见的,因此TCP协议是面向流的协议。而UDP是面向消息的协议,每个UDP段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据,这一点和TCP是很不同的。

TCP 状态转移

TCP状态转移

  • 实线部分:主动发起连接,主动关闭连接。主动发起连接才会出现FIN_WAIT_2状态。TIME_WAIT的时间:不确定对方是否收到发送的ACK。

  • 虚线部分:被动发起连接,被动关闭连接

  • 小细线部分:两端同时操作

状态解读:

  • CLOSED:表示初始状态。

  • LISTEN:该状态表示服务器端的某个SOCKET处于监听状态,可以接受连接。

  • SYN_SENT:这个状态与SYN_RCVD遥相呼应,当客户端SOCKET执行CONNECT连接时,它首先发送SYN报文,随即进入到了SYN_SENT状态,并等待服务端的发送三次握手中的第2个报文。SYN_SENT状态表示客户端已发送SYN报文。

  • SYN_RCVD: 该状态表示接收到SYN报文,在正常情况下,这个状态是服务器端的SOCKET在建立TCP连接时的三次握手会话过程中的一个中间状态,很短暂。此种状态时,当收到客户端的ACK报文后,会进入到ESTABLISHED状态。

  • ESTABLISHED:表示连接已经建立。

  • FIN_WAIT_1: FIN_WAIT_1和FIN_WAIT_2状态的真正含义都是表示等待对方的FIN报文。区别是:

    • FIN_WAIT_1状态是当socket在ESTABLISHED状态时,想主动关闭连接,向对方发送了FIN报文,此时该socket进入到FIN_WAIT_1状态。

    • FIN_WAIT_2状态是当对方回应ACK后,该socket进入到FIN_WAIT_2状态,正常情况下,对方应马上回应ACK报文,所以FIN_WAIT_1状态一般较难见到,而FIN_WAIT_2状态可用netstat看到。

  • FIN_WAIT_2: 主动关闭链接的一方,发出FIN收到ACK以后进入该状态。称之为半连接或半关闭状态。该状态下的socket只能接收数据,不能发。

  • TIME_WAIT: 表示收到了对方的FIN报文,并发送出了ACK报文,等2MSL后即可回到CLOSED可用状态。如果FIN_WAIT_1状态下,收到对方同时带 FIN标志和ACK标志的报文时,可以直接进入到TIME_WAIT状态,而无须经过FIN_WAIT_2状态。

  • CLOSING: 这种状态较特殊,属于一种较罕见的状态。正常情况下,当你发送FIN报文后,按理来说是应该先收到(或同时收到)对方的 ACK报文,再收到对方的FIN报文。但是CLOSING状态表示你发送FIN报文后,并没有收到对方的ACK报文,反而却也收到了对方的FIN报文。什么情况下会出现此种情况?如果双方几乎在同时close一个SOCKET的话,那么就出现了双方同时发送FIN报文的情况,也即会出现CLOSING状态,表示双方都正在关闭SOCKET连接。

  • CLOSE_WAIT: 此种状态表示在等待关闭。当对方关闭一个SOCKET后发送FIN报文给自己,系统会回应一个ACK报文给对方,此时则进入到CLOSE_WAIT状态。接下来,察看是否还有数据发送给对方,如果没有可以 close这个SOCKET,发送FIN报文给对方,即关闭连接。所以在CLOSE_WAIT状态下,需要关闭连接

  • LAST_ACK: 该状态是被动关闭一方在发送FIN报文后,最后等待对方的ACK报文。当收到ACK报文后,即可以进入的到CLOSED可用状态。

半关闭状态

当TCP链接中A发送FIN请求关闭,B端回应ACK后(A端进入FIN_WAIT_2状态),B没有立即发送FIN给A时,A方处在半链接状态,此时A可以接收B发送的数据,但是A已不能再向B发送数据。

从程序的角度,可以使用API来控制实现半连接状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <sys/socket.h>

int shutdown(int sockfd, int how);

sockfd: 需要关闭的socket的描述符

how:允许为shutdown操作选择以下几种方式:

SHUT_RD(0): 关闭sockfd上的读功能,此选项将不允许sockfd进行读操作。该套接字不再接受数据,任何当前在套接字接受缓冲区的数据将被无声的丢弃掉。

SHUT_WR(1): 关闭sockfd的写功能,此选项将不允许sockfd进行写操作。进程不能在对此套接字发出写操作。

SHUT_RDWR(2):关闭sockfd的读写功能。相当于调用shutdown两次:首先是以SHUT_RD,然后以SHUT_WR。

使用close中止一个连接,但它只是减少描述符的引用计数,并不直接关闭连接,只有当描述符的引用计数为0时才关闭连接。

shutdown不考虑描述符的引用计数,直接关闭描述符。也可选择中止一个方向的连接,只中止读或只中止写。

注意:

  • 如果有多个进程共享一个套接字,close每被调用一次,计数减1,直到计数为0时,也就是所用进程都调用了close,套接字将被释放。

  • 在多进程中如果一个进程调用了shutdown(sfd, SHUT_RDWR)后,其它的进程将无法进行通信。但,如果一个进程close(sfd)将不会影响到其它进程。

2MSL

2MSL (Maximum Segment Lifetime) TIME_WAIT状态的存在有两个理由:

  • 让4次握手关闭流程更加可靠:4次握手的最后一个ACK是是由主动关闭方发送出去的,若这个ACK丢失,被动关闭方会再次发一个FIN过来。若主动关闭方能够保持一个2MSL的TIME_WAIT状态,则有更大的机会让丢失的ACK被再次发送出去。

  • 防止lost duplicate对后续新建正常链接的传输造成破坏。

    • lost uplicate在实际的网络中非常常见,经常是由于路由器产生故障,路径无法收敛,导致一个packet在路由器A,B,C之间做类似死循环的跳转。IP头部有个TTL,限制了一个包在网络中的最大跳数,因此这个包有两种命运,要么最后TTL变为0,在网络中消失;要么TTL在变为0之前路由器路径收敛,它凭借剩余的TTL跳数终于到达目的地。但非常可惜的是TCP通过超时重传机制在早些时候发送了一个跟它一模一样的包,并先于它达到了目的地,因此它的命运也就注定被TCP协议栈抛弃。
    • 另外一个概念叫做incarnation connection,指跟上次的socket pair一摸一样的新连接,叫做incarnation of previous connection。lost duplicate加上incarnation connection,则会对的传输造成致命的错误。

TCP是流式的,所有包到达的顺序是不一致的,依靠序列号由TCP协议栈做顺序的拼接;假设一个incarnation connection这时收到的seq=1000, 来了一个lost duplicate为seq=1000,len=1000, 则TCP认为这个lost duplicate合法,并存放入了receive buffer,导致传输出现错误。通过一个2MSL TIME_WAIT状态,确保所有的lost duplicate都会消失掉,避免对新连接造成错误。

该状态为什么设计在主动关闭这一方

  • 发最后ACK的是主动关闭一方。

  • 只要有一方保持TIME_WAIT状态,就能起到避免incarnation connection在2MSL内的重新建立,不需要两方都有。

如何正确对待2MSL TIME_WAIT?

  • RFC要求socket pair在处于TIME_WAIT时,不能再起一个incarnation connection。但绝大部分TCP实现,强加了更为严格的限制。在2MSL等待期间,socket中使用的本地端口在默认情况下不能再被使用。

  • 若A 10.234.5.5 : 1234和B 10.55.55.60 : 6666建立了连接,A主动关闭,那么在A端只要port为1234,无论对方的port和ip是什么,都不允许再起服务。这甚至比RFC限制更为严格,RFC仅仅是要求socket pair不一致,而实现当中只要这个port处于TIME_WAIT,就不允许起连接。这个限制对主动打开方来说是无所谓的,因为一般用的是临时端口;但对于被动打开方,一般是server,就悲剧了,因为server一般是熟知端口。比如http,一般端口是80,不可能允许这个服务在2MSL内不能起来。

解决方案:

  • 给服务器的socket设置SO_REUSEADDR选项,这样的话就算熟知端口处于TIME_WAIT状态,在这个端口上依旧可以将服务启动。当然,虽然有了SO_REUSEADDR选项,但sockt pair这个限制依旧存在。比如上面的例子,A通过SO_REUSEADDR选项依旧在1234端口上起了监听,但这时若是从B通过6666端口去连它,TCP协议会告诉我们连接失败,原因为Address already in use.

  • RFC 793中规定MSL为2分钟,实际应用中常用的是30秒,1分钟和2分钟等。

程序设计中的问题

做一个测试,首先启动server,然后启动client,用Ctrl-C终止server,马上再运行server,运行结果:

1
2
$ ./server
bind error: Address already in use

这是因为,虽然server的应用程序终止了,但TCP协议层的连接并没有完全断开,因此不能再次监听同样的server端口。用netstat命令可以查看:

1
2
3
$ netstat -apn |grep 6666
tcp 1 0 192.168.1.11:38103 192.168.1.11:6666 CLOSE_WAIT 3525/client
tcp 0 0 192.168.1.11:6666 192.168.1.11:38103 FIN_WAIT2 -

server终止时,socket描述符会自动关闭并发FIN段给client,client收到FIN后处于CLOSE_WAIT状态,但是client并没有终止,也没有关闭socket描述符,因此不会发FIN给server,因此server的TCP连接处于FIN_WAIT2状态。

现在用Ctrl-C把client也终止掉,再观察现象:

1
2
3
4
$ netstat -apn |grep 6666
tcp 0 0 192.168.1.11:6666 192.168.1.11:38104 TIME_WAIT -
$ ./server
bind error: Address already in use

client终止时自动关闭socket描述符,server的TCP连接收到client发的FIN段后处于TIME_WAIT状态。TCP协议规定,主动关闭连接的一方要处于TIME_WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态,因为先Ctrl-C终止了server,所以server是主动关闭连接的一方,在TIME_WAIT期间仍然不能再次监听同样的server端口。

MSL在RFC 1122中规定为两分钟,但是各操作系统的实现不同,在Linux上一般经过半分钟后就可以再次启动server了。

端口复用

在server的TCP连接没有完全断开之前不允许重新监听是不合理的。因为,TCP连接没有完全断开指的是connfd(127.0.0.1:6666)没有完全断开,而我们重新监听的是listenfd(0.0.0.0:6666),虽然是占用同一个端口,但IP地址不同,connfd对应的是与某个客户端通讯的一个具体的IP地址,而listenfd对应的是wildcard address。解决这个问题的方法是使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符。

在server代码的socket()和bind()调用之间插入如下代码:

1
2
3
int opt = 1;

setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

协议

从应用的角度出发,协议可理解为“规则”,是数据传输和数据解释的规则。

协议双发之间遵守的协议中可以称为原始协议。当此协议被更多的人采用,不断的增加、改进、维护、完善。最终形成一个稳定的、完整的协议,被广泛应用于各种应用中,此时该协议就可以成为一个标准协议

TCP协议注重数据的传输。http协议着重于数据的解释

典型协议

  • 应用层 常见的协议有HTTP协议,FTP协议。
    • HTTP(超文本传输协议,Hyper Text Transfer Protocol)是互联网上应用最为广泛的一种网络协议
    • FTP文件传输协议(File Transfer Protocol)。
  • 传输层 常见协议有TCP/UDP协议。

    • TCP(传输控制协议,Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议。
    • UDP用户数据报协议(User Datagram Protocol)是OSI参考模型中一种无连接的传输层协议,提供面向事务的简单不可靠信息传送服务。
  • 网络层 常见协议有IP协议、ICMP协议、IGMP协议。

    • IP协议是因特网互联协议(Internet Protocol)。
    • ICMP协议是Internet控制报文协议(Internet Control Message Protocol)它是TCP/IP协议族的一个子协议,用于在IP主机、路由器之间传递控制消息。
    • IGMP协议是 Internet 组管理协议(Internet Group Management Protocol),是因特网协议家族中的一个组播协议。该协议运行在主机和组播路由器之间。
  • 网络接口层 常见协议有ARP协议、RARP协议。

    • ARP协议是正向地址解析协议Address Resolution Protocol,通过已知的IP,寻找对应主机的MAC
    • RARP是反向地址转换协议,通过MAC地址确定IP地址。

网络应用程序设计模式

C/S模式

传统的网络应用设计模式,客户机(client)/服务器(server)模式。需要在通讯两端各自部署客户机和服务器来完成数据通信。

优点:

  • 客户端位于目标主机上可以保证性能,将数据缓存至客户端本地,从而提高数据传输效率

  • c端和s端都需要自己定义,协议使用灵活

  • 可以提前在本地进行大量数据的缓存处理,从而提高观感

缺点:

  • 从用户角度出发,需要将客户端安插至用户主机上,对用户主机的安全性构成威胁
  • 开发工作量大,调试困难

B/S模式

浏览器(browser)/服务器(server)模式。只需在一端部署服务器,而另外一端使用每台PC都默认配置的浏览器即可完成数据的传输。

优点:

  • 使用标准浏览器作为客户端,其工作开发量较小只需开发服务器端即可。
  • 由于其采用浏览器显示数据,因此移植性非常好不受平台限制

缺点:

  • 网络应用支持受限
  • 没有客户端放到对方主机上,缓存数据不尽如人意,从而传输数据量受到限制。应用的观感大打折扣。
  • 必须与浏览器一样,采用标准http协议进行通信,协议选择不灵活

分层模型

分层模型图

四层模型(TCP/IP模型):

四层模型

一般在应用开发过程中,讨论最多的是TCP/IP模型。

通信过程

两台计算机通过TCP/IP协议通讯的过程如下所示:

两台计算机通过TCP/IP协议通讯的过程

上图对应两台计算机在同一网段中的情况,如果两台计算机在不同的网段中,那么数据从一台计算机到另一台计算机传输过程中要经过一个或多个路由器,如下图所示:

不同网段通信过程

链路层工作

链路层有以太网、令牌环网等标准,链路层负责网卡设备的驱动、帧同步(即从网线上检测到什么信号算作新帧的开始)、冲突检测(如果检测到冲突就自动重发)、数据差错校验等工作。交换机是工作在链路层的网络设备,可以在不同的链路层网络之间转发数据帧(比如十兆以太网和百兆以太网之间、以太网和令牌环网之间),由于不同链路层的帧格式不同,交换机要将进来的数据包拆掉链路层首部重新封装之后再转发。

网络层工作

网络层的IP协议是构成Internet的基础。Internet上的主机通过IP地址来标识,Internet上有大量路由器负责根据IP地址选择合适的路径转发数据包,数据包从Internet上的源主机到目的主机往往要经过十多个路由器路由器是工作在第三层的网络设备,同时兼有交换机的功能,可以在不同的链路层接口之间转发数据包,因此路由器需要将进来的数据包拆掉网络层和链路层两层首部并重新封装。 IP协议不保证传输的可靠性,数据包在传输过程中可能丢失,可靠性可以在上层协议或应用程序中提供支持。

网络层负责点到点(ptop,point-to-point)的传输(这里的“点”指主机或路由器)

传输层工作

传输层负责端到端(end-to-end)的传输(这里的“端”指源主机和目的主机)。传输层可选择TCP或UDP协议。

TCP是一种面向连接的、可靠的协议,有点像打电话,双方拿起电话互通身份之后就建立了连接,然后说话就行了,这边说的话那边保证听得到,并且是按说话的顺序听到的,说完话挂机断开连接。也就是说TCP传输的双方需要首先建立连接,之后由TCP协议保证数据收发的可靠性,丢失的数据包自动重发,上层应用程序收到的总是可靠的数据流,通讯之后关闭连接。

UDP是无连接的传输协议,不保证可靠性,有点像寄信,信写好放到邮筒里,既不能保证信件在邮递过程中不会丢失,也不能保证信件寄送顺序。使用UDP协议的应用程序需要自己完成丢包重发、消息排序等工作。

目的主机收到数据包后,如何经过各层协议栈最后到达应用程序呢?其过程如下图所示:

数据包传输过程

以太网驱动程序首先根据以太网首部中的“上层协议”字段确定该数据帧的有效载荷(payload,指除去协议首部之外实际传输的数据)是IP、ARP还是RARP协议的数据报,然后交给相应的协议处理。假如是IP数据报,IP协议再根据IP首部中的“上层协议”字段确定该数据报的有效载荷是TCP、UDP、ICMP还是IGMP,然后交给相应的协议处理。假如是TCP段或UDP段,TCP或UDP协议再根据TCP首部或UDP首部的“端口号”字段确定应该将应用层数据交给哪个用户进程。IP地址是标识网络中不同主机的地址,而端口号就是同一台主机上标识不同进程的地址,IP地址和端口号合起来标识网络中唯一的进程。

虽然IP、ARP和RARP数据报都需要以太网驱动程序来封装成帧,但是从功能上划分,ARP和RARP属于链路层,IP属于网络层。虽然ICMP、IGMP、TCP、UDP的数据都需要IP协议来封装成数据报,但是从功能上划分,ICMP、IGMP与IP同属于网络层,TCP和UDP属于传输层。

进程以及进程切换

进程是操作系统的伟大发明之一,对应用程序屏蔽了CPU调度、内存管理等硬件细节,而抽象出一个进程的概念,让应用程序专心于实现自己的业务逻辑既可,而且在有限的CPU上可以“同时”进行许多个任务。但是它为用户带来方便的同时,也引入了一些额外的开销。如下图,在进程运行中间的时间里,虽然CPU也在忙于干活,但是却没有完成任何的用户工作,这就是进程机制带来的额外开销。

进程切换

在进程A切换到进程B的过程中,先保存A进程的上下文,以便于等A恢复运行的时候,能够知道A进程的下一条指令是啥。然后将要运行的B进程的上下文恢复到寄存器中。这个过程被称为上下文切换。上下文切换开销在进程不多、切换不频繁的应用场景下问题不大。但是现在Linux操作系统被用到了高并发的网络程序后端服务器。在单机支持成千上万个用户请求的时候,这个开销影响较大。因为用户进程在请求Redis、Mysql数据等网络IO阻塞掉的时候,或者在进程时间片到了,都会引发上下文切换。

进程状态变化

测试

实验方法为创建两个进程并在它们之间传送一个令牌。其中一个进程在读取令牌时就会引起阻塞。另一个进程发送令牌后等待其返回时也处于阻塞状态。如此往返传送一定的次数,然后统计他们的平均单次切换时间开销。编译、运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <stdio.h>  
#include <stdlib.h>
#include <sys/time.h>
#include <time.h>
#include <sched.h>
#include <sys/types.h>
#include <unistd.h> //pipe()

int main()
{
int x, i, fd[2], p[2];
char send = 's';
char receive;
pipe(fd);
pipe(p);
struct timeval tv;
struct sched_param param;
param.sched_priority = 0;

while ((x = fork()) == -1);
if (x==0) {
sched_setscheduler(getpid(), SCHED_FIFO, &param);
gettimeofday(&tv, NULL);
printf("Before Context Switch Time%u s, %u us\n", tv.tv_sec, tv.tv_usec);
for (i = 0; i < 10000; i++) {
read(fd[0], &receive, 1);
write(p[1], &send, 1);
}
exit(0);
}
else {
sched_setscheduler(getpid(), SCHED_FIFO, &param);
for (i = 0; i < 10000; i++) {
write(fd[1], &send, 1);
read(p[0], &receive, 1);
}
gettimeofday(&tv, NULL);
printf("After Context SWitch Time%u s, %u us\n", tv.tv_sec, tv.tv_usec);
}
return 0;
}

//gcc 编译运行
//Before Context Switch Time1617894469 s, 453169 us
//After Context SWitch Time1617894469 s, 506257 us

每次执行的时间会有差异,多次运行后平均每次上下文切换耗时3.5us左右。当然这个数字因机器而异。

测试系统调用的时候,最低值是200ns。可见,上下文切换开销要比系统调用的开销要大。系统调用只是在进程内将用户态切换到内核态,然后再切回来,而上下文切换可是直接从进程A切换到了进程B。显然这个上下文切换需要完成的工作量更大。

进程切换开销

上下文切换的时候,CPU的开销都具体有哪些呢?开销分成两种,一种是直接开销、一种是间接开销。

直接开销就是在切换时,cpu必须做的事情,包括:

1、切换页表全局目录

2、切换内核态堆栈

3、切换硬件上下文(进程恢复前,必须装入寄存器的数据统称为硬件上下文)

  • ip(instruction pointer):指向当前执行指令的下一条指令
  • bp(base pointer): 用于存放执行中的函数对应的栈帧的栈底地址
  • sp(stack poinger): 用于存放执行中的函数对应的栈帧的栈顶地址
  • cr3:页目录基址寄存器,保存页目录表的物理地址
  • ……

4、刷新TLB

5、系统调度器的代码执行

间接开销主要指的是虽然切换到一个新进程后,由于各种缓存并不热,速度运行会慢一些如果进程始终都在一个CPU上调度还好一些,如果跨CPU的话,之前热起来的TLB、L1、L2、L3因为运行的进程已经变了,所以以局部性原理cache起来的代码、数据也都没有用了,导致新进程穿透到内存的IO会变多。上面的实验并没有很好地测量到这种情况,所以实际的上下文切换开销可能比3.5us要大。

测试工具

lmbench是用于评价系统综合性能的多平台开源benchmark,能够测试包括文档读写、内存操作、进程创建销毁开销、网络等性能。这个工具的优势是是进行了多组实验,每组2个进程、8个、16个。每个进程使用的数据大小也在变,充分模拟cache miss造成的影响。lmbench显示的进程上下文切换耗时从2.7us到5.48之间。

线程上下文切换耗时

在Linux下其实本并没有线程,只是为了迎合开发者口味,搞了个轻量级进程出来就叫做了线程。轻量级进程和进程一样,都有自己独立的task_struct进程描述符,也都有自己独立的pid从操作系统视角看,调度上和进程没有什么区别,都是在等待队列的双向链表里选择一个task_struct切到运行态。只不过轻量级进程和普通进程的区别是可以共享同一内存地址空间、代码段、全局变量、同一打开文件集合

同一进程下的线程getpid()看到的pid是一样的,其实task_struct里还有一个tgid字段。对于多线程程序来说,getpid()系统调用获取的实际上是这个tgid,因此隶属同一进程的多线程看起来PID相同。

实际测试:

线程和进程测试差不多,创建20个线程,在线程之间通过管道来传递信号。接到信号就唤醒,然后再传递信号给下一个线程,自己睡眠。这个实验里单独考虑了给管道传递信号的额外开销,并在第一步就统计了出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
#include <stdio.h>
#include <unistd.h>
#include <sys/time.h>
#include<pthread.h>

int pipes[20][3];
char buffer[10];
int running = 1;

void inti()
{
int i =20;
while(i--)
{
if(pipe(pipes[i])<0)
exit(1);
pipes[i][2] = i;
}
}

void distroy()
{
int i =20;
while(i--)
{
close(pipes[i][0]);
close(pipes[i][1]);
}
}

double self_test()
{
int i =20000;
struct timeval start, end;
gettimeofday(&start, NULL);
while(i--)
{
if(write(pipes[0][1],buffer,10)==-1)
exit(1);
read(pipes[0][0],buffer,10);
}
gettimeofday(&end, NULL);
return (double)(1000000*(end.tv_sec-start.tv_sec)+ end.tv_usec-start.tv_usec)/20000;
}

void *_test(void *arg)
{
int pos = ((int *)arg)[2];
int in = pipes[pos][0];
int to = pipes[(pos + 1)%20][1];
while(running)
{
read(in,buffer,10);
if(write(to,buffer,10)==-1)
exit(1);
}
}

double threading_test()
{
int i = 20;
struct timeval start, end;
pthread_t tid;
while(--i)
{
pthread_create(&tid,NULL,_test,(void *)pipes[i]);
}
i = 10000;
gettimeofday(&start, NULL);
while(i--)
{
if(write(pipes[1][1],buffer,10)==-1)
exit(1);
read(pipes[0][0],buffer,10);
}
gettimeofday(&end, NULL);
running = 0;
if(write(pipes[1][1],buffer,10)==-1)
exit(1);
return (double)(1000000*(end.tv_sec-start.tv_sec)+ end.tv_usec-start.tv_usec)/10000/20;
}

int main()
{
inti();
printf("%6.6f\n",self_test());
printf("%6.6f\n",threading_test());
distroy();
exit(0);
}
1
2
3
4
$ gcc xiancheng.c -o xiancheng -pthread
dongshifu@dong:~/test$ ./xiancheng
1.332800
3.168695

参考

https://mp.weixin.qq.com/s/uq5s5vwk5vtPOZ30sfNsOg

cpu多核真相

物理CPU:主板上真正安装的CPU的个数

物理核:一个CPU会集成多个物理核心

逻辑核:超线程技术可以把一个物理核虚拟出来多个逻辑核

超线程里的2个逻辑核实际上是在一个物理核上运行的,模拟双核心运作,共享该物理核的L1和L2缓存。物理计算能力并没有增加,超线程技术只有在多任务的时候才能提升机器核整体的吞吐量。而且据Intel官方介绍,相比实核,平均性能提升只有20-30%30%左右。

通过top命令看到的CPU核是逻辑核,linux下可以通过/proc/cupinfo来查看更加详细的信息:

1
2
3
4
5
#cat /proc/cpuinfo | grep "physical id" | sort | uniq #查看物理cpu个数

#cat /proc/cpuinfo| grep "cpu cores"| uniq #查看每个cpu的物理核数量

#cat /proc/cpuinfo | grep -E "core id|process|physical id" #查看逻辑核数量

缓存

286之前的时代的CPU本是没有缓存的,因为当时的CPU和内存速度差异没有现在这么大,CPU直接访问内存。但是到386时代,CPU和内存的速度不匹配了,第一次出现了缓存。而且最早的缓存并没有放在CPU模块里,而是放在主板上的。再往后CPU越来越快,现在CPU的速度比内存要快百倍以上,所以就逐步演化出了L1、L2、L3三级缓存结构,而且都集成到的CPU芯片里,以进一步提高访问速度。

现代Intel的CPU架构的基本结构:

cpu核心架构

L1最接近于CPU,速度也最快,但是容量最小。一般现代CPU的L1会分成两个,一个用来cache data,一个用来cache code,这是因为code和data的更新策略并不相同,而且因为CISC的变长指令,code cache要做特殊优化。一般每个核都有自己独立的data L1和code L1。
越往下,速度越慢,容量越大。L2一般也可以做到每个核一个独立的。但是L3一般就是整颗CPU共享的了。

linux下实际查看

Linux的内核的开发者定义了一套框架模型来完成这一目的,它就是CPUFreq系统。CPUFreq提供的sysfs接口,可以看到比/proc/cpuinfo更为详细的CPU详细信息。

1
2
3
4
5
6
7
# cd /sys/devices/system/cpu/;ll
drwxr-xr-x 9 root root 0 4月 8 14:15 cpu0/
drwxr-xr-x 9 root root 0 4月 8 14:15 cpu1/
drwxr-xr-x 9 root root 0 4月 8 14:15 cpu2/
drwxr-xr-x 9 root root 0 4月 8 14:15 cpu3/
drwxr-xr-x 9 root root 0 4月 8 14:15 cpu4/
......

L1一级缓存查看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# cat cpu0/cache/index0/level
1
# cat cpu0/cache/index0/size
32K
# cat cpu0/cache/index0/type
Data
# cat cpu0/cache/index0/shared_cpu_list
0,4
# cat cpu0/cache/index1/level
1
# cat cpu0/cache/index1/size
32K
# cat cpu0/cache/index1/type
Instruction
# cat cpu0/cache/index1/shared_cpu_list
0,4

从上面的level接口可以看出index0和index1都是一级缓存,只不过一个是Data数据缓存,一个是Instruction也就是代码缓存
上面提到的是每个Core是独立的L1缓存,为什么shared_cpu_list显示有共享?这里看到的cpu0并不是物理Core,而是逻辑核,都是超线程技术虚拟出来的。实际上cpu0和cpu4是属于一个物理Core,所以每个Data L1和Instruction是这两个逻辑核共享的。本台电脑总共是有4个Data L1,4个Instrunction L1,大小都是32K。

L2二级缓存查看:

1
2
3
4
5
6
# cat cpu0/cache/index2/size
256K
# cat cpu0/cache/index2/type
Unified
# cat cpu0/cache/index2/shared_cpu_list
0,4

二级缓存要比一级缓存大不少,有256K,但是不分Data和Instruction。另外L2和L1一样,也是总共有4个,每两个逻辑核共享一个L2。

L3三级缓存查看:

1
2
3
4
5
6
# cat cpu0/cache/index3/size
8192K
# cat cpu0/cache/index3/type
Unified
# cat cpu0/cache/index3/shared_cpu_list
0-7

L3达到了8M,买CPU的时候商品里能看到的缓存属性一般告诉的就是L3属性。因为L3要比L2和L1看起来要大的多。但实际上我的这台电脑里L3只有以个,每个CPU各一个,不像是L2、L1有很多。

另外,Linux上还有个dmidecode命令,也能查看到一些关于CPU缓存的信息

1
$ sudo dmidecode -t cache

Cache Line

Cache Line是本级缓存向下一层取数据时的基本单位。可以通过如下方式查看:

1
2
3
4
5
6
7
8
9
# cd /sys/devices/system/cpu/;ll
# cat cpu0/cache/index0/coherency_line_size
64
# cat cpu0/cache/index1/coherency_line_size
64
# cat cpu0/cache/index2/coherency_line_size
64
# cat cpu0/cache/index3/coherency_line_size
64

可以看到L1、L2、L3的Cache Line大小都是64字节(注意是字节。内存中的实际情况是:一次IO其实吐出来的只有64比特,注意是比特。 一个cache line请求需要内存吐8次数据。 这个是64bit是由总线位宽决定的,没办法改)。就是说每次cpu从内存获取数据的时候,都是以该单位来进行的,哪怕只取一个bit,CPU也是给取一个Cache Line然后放到各级缓存里存起来。

TLB缓存

TLB(Translation Lookaside Buffer)是一个小的,虚拟寻址的缓存,其中每一行都保存着一个由单个PTE(Page Table Entry 页表项)组成的块。如果没有TLB,则每次取数据都需要两次访问内存,即查页表获得物理地址和取数据。缓存位于MMU内

虚拟内存

用户的视角里每个进程都有自己独立的地址空间,A进程的4GB和B进程4GB是完全独立不相关的,他们看到的都是操作系统虚拟出来的地址空间。但是呢,虚拟地址最终还是要落在实际内存的物理地址上进行操作的。操作系统就会通过页表的机制来实现进程的虚拟地址到物理地址的翻译工作。其中每一页的大小都是固定的

页表管理的两个关键点,分别是页面大小和页表级数

1.页面大小
在Linux下,通过如下命令可以查看到当前操作系统的页大小

1
2
# getconf PAGE_SIZE
4096

可以看到当前的Linux机器的页表是4KB的大小。

2.页表级数

1)如果页表级数越少,虚拟地址到物理地址的映射会很快,但是需要管理的页表项会很多,能支持的地址空间也有限。

2)相反页表级数越多,需要的存储的页表数据就会越少,而且能支持到比较大的地址空间,但是虚拟地址到物理地址的映射就会越慢

linux虚拟内存实现

32位系统的虚拟内存实现:二级页表

如果想支持32位的操作系统下的4GB进程虚拟地址空间,假设页表大小为4K,则共有2的20次方页面。如果采用速度最快的1级页表,对应则需要2的20次方个页表项。一个页表项假如4字节,那么一个进程就需要(1048576*4=)4M的内存来存页表项。
如果是采用2级页表,如图,则创建进程时只需要有一个页目录就可以了,占用(1024*4)=4KB的内存。剩下的二级页表项只有用到的时候才会再去申请。

二级页表

这样则只需要页目录1024个,页表项1024个,总共2028个页表管理条目,(2048*4=)8k就可以支持起4GB的地址空间转换。

64位系统的虚拟内存实现:四级页表

现在的操作系统需要支持的可是48位地址空间(理论上可以支持64位,但其实现在只支持到了48位,也足够用了),而且要支持成百上千的进程,如果不采用分级页表的方式,则创建进程时就需要为其维护一个2的36次方个页表项(64位Linux目前只使用了地址中的48位的,在这里面,最后12位都是页内地址,只有前36位才是用来寻找页表的), 2^36 *4Byte=32GB,这个更不能忍。也必须和32位系统一样,进一步提高页表的级数。

Linux在v2.6.11以后,最终采用的方案是4级页表,分别是:

  • PGD:page Global directory(47-39), 页全局目录
  • PUD:Page Upper Directory(38-30),页上级目录
  • PMD:page middle directory(29-21),页中间目录
  • PTE:page table entry(20-12),页表项

这样,一个64位的虚拟空间,初始创建的时候只需要维护一个29 大小的一个页全局目录就够了,现在的页表数据结构被扩展到了8byte。这个页全局目录仅仅需要(29 *8=)4K,剩下的中间页目录、页表项只需要在使用的时候再分配就好了。Linux就是通过这种方式支持起(2^48 =)256T的进程地址空间的。

TLB

使用多级页表虽然只需要4k的初始页目录就可以支持起一个256T的进程地址空间。但是,这也带来了额外的问题,页表是存在内存里的。那就是一次内存IO光是虚拟地址到物理地址的转换就要去内存查4次页表,再算上真正的内存访问,竟然需要5次内存IO才能获取一个内存数据!

CPU的L1、L2、L3的缓存思想一致,既然进行地址转换需要的内存IO次数多,且耗时。那么干脆就在CPU里把页表尽可能地cache起来不就行了么,所以就有了TLB(Translation Lookaside Buffer,位于MMU中,MMU位于CPU内),专门用于改进虚拟地址到物理地址转换速度的缓存。其访问速度非常快,和寄存器相当,比L1访问还快。

MMU

MMU(内存管理单元):包括从逻辑地址到虚拟地址(线性地址)再到内存地址的变换过程、页式存储管理、段式存储管理、段页式存储管理、虚拟存储管理(请求分页、请求分段、请求段页)。 MMU位于CPU内部,可以假想为一个进程的所需要的资源都放在虚拟地址空间里面,而CPU在取指令时,机器指令中的地址码部分为虚拟地址(线性地址),需要经过MMU转换成为内存地址,才能进行取指令。MMU完成两大功能:

  • 虚拟地址到内存地址的地址变换

  • 设置修改CPU对内存的访问级别。比如在Linux的虚拟地址空间中,3-4G为内核空间,访问级别最高,可以访问整个内存;而0-3G的用户空间只能访问用户空间的内容。其实这也是由MMU的地址变换机制所决定的。对于Inter(英特尔)CPU架构,CPU对内存的访问设置了4个访问级别:0、1、2、3,0最高,4最低。而Linux下,只是使用了CPU的两种级别:0、3。

CPU的状态属于程序状态字PSW的一位,系统模式(0),用户模式(1),CPU交替执行操作系统程序和用户程序。0级对应CPU的内核态(特权态、管态、系统态),而3级对应用户态(普通态或目态),这其实是对内核的一种保护机制。例如,在执行printf函数的时候,其本身是在用户空间执行,然后发生系统调用,调用系统函数write将用户空间的数据写入到内核空间,最后把内核的数据刷到(fsync)磁盘上,在这个过程中,CPU的状态发生了变化,从0级(用户态)到3级(内核态)。

有了TLB之后,CPU访问某个虚拟内存地址的过程如下:

  • CPU产生一个虚拟地址
  • MMU从TLB中获取页表,翻译成物理地址
  • MMU把物理地址发送给L1/L2/L3/缓存
  • L1/L2/L3/内存将地址对应数据返回给CPU

第2步是类似于寄存器的访问速度,所以如果TLB能命中,则虚拟地址到物理地址的时间开销几乎可以忽略。

扩展

因为TLB并不是很大,只有4k,而且现在逻辑核又造成会有两个进程来共享。所以可能会有cache miss的情况出现。而且一旦TLB miss造成的后果可比物理地址cache miss后果要严重一些,最多可能需要进行5次内存IO才行。

参考

https://mp.weixin.qq.com/s/PQTuFZO51an6OAe3WX4BVw

https://mp.weixin.qq.com/s/mssTS3NN7-w2df1vhYSuYw

内存底层原理

内存底层结构

由于高速缓存的工作机制,内存对齐后性能高。且底层实现中内存的IO是以8个字节64bit为单位进行的。

内存物理结构:一个内存是由若干个黑色的内存颗粒构成的。每一个内存颗粒叫做一个chip每个chip内部,由8个bank组成。每一个bank是一个二维平面上的矩阵。矩阵中每一个元素中都是保存了1个字节,也就是8个bit

内存编址

对于在应用程序中内存中地址连续的8个字节,例如0x0000-0x0007,是从位于bank上的呢?直观感觉,应该是在第一个bank上吗?其实不是的,程序员视角看起来连续的地址0x0000-0x0007,实际上是位于8个bank中的,每一个bank只保存了一个字节。在物理上,他们并不连续。下图很好地阐述了实际情况。

连续8字节在内存中的实际分布

编址原因:是电路工作效率。内存中的8个bank是可以并行工作的。如果想读取地址0x0000-0x0007,每个bank工作一次,拼起来就是需要的数据,IO效率会比较高。但要存在一个bank里,那这个bank只能自己干活。只能串行进行读取,需要读8次,这样速度会慢很多。

内存对齐最最底层的原因是内存的IO是以8个字节64bit为单位进行的。 对于64位数据宽度的内存,假如cpu也是64位的cpu(现在的计算机基本都是这样的),每次内存IO获取数据都是从同行同列的8个bank中各自读取一个字节拼起来的。从内存的0地址开始,0-7字节的数据可以一次IO读取出来,8-15字节的数据也可以一次读取出来。

假如指定要获取的是0x0001-0x0008,也是8字节,但是不是0开头的,内存需要怎么工作呢?没有好办法,内存只好先工作一次把0x0000-0x0007取出来,然后再把0x0008-0x0015取出来,把两次的结果都返回。CPU和内存IO的硬件限制导致没办法一次跨在两个数据宽度中间进行IO。

扩展

  • 事实上,编译和链接器会自动替开发者对齐内存的,尽量保证一个变量不跨列寻址。

  • 其实在内存硬件层上,还有操作系统层。操作系统还管理了CPU的一级、二级、三级缓存。高速缓存里的Cache Line是64字节,它是内存IO单位的8倍,不会让内存IO浪费。

内存访问延迟

内存延迟一般是通过CL-tRCD-tRP-tRAS四个参数来标识的。详细理解一下这四个参数的含义:

  • CL(Column Address Latency):发送一个列地址到内存与数据开始响应之间的周期数
  • tRCD(Row Address to Column Address Delay):打开一行内存并访问其中的列所需的最小时钟周期数
  • tRP(Row Precharge Time):发出预充电命令与打开下一行之间所需的最小时钟周期数。

  • tRAS(Row Active Time):行活动命令与发出预充电命令之间所需的最小时钟周期数。也就是对下一次预充电时间进行限制。

除了CL是固定周期数以外,其它的三个都是最小周期。另外上面的参数都是以时钟周期为单位的。因为现代的内存都是一个时钟周期上下沿分别各传输一次数据,所以用Speed/2就可以得出,如果机器的Speed是1066MHz,则时钟周期为533MHz。自己的机器可以通过dmidecode命令查看:

1
dmidecode | grep -P -A16 "Memory Device"

四个工作场景

  • 场景1:

进程需要内存地址0x0000为的一个字节的数据,CPU这时候向内存控制器发出请求,内存控制器进行行地址的预充电,需要等待tRP个时钟周期。再发出打开一行内存的命令,又需要等待tRCD个时钟周期。接着发送列地址,再等待CL个周期。最终将0x0000-0x0007的数据全部返回给了CPU。CPU把这些数据放入到了自己的cache里,并帮你开始对0x0000的数据进行运算。

  • 场景2:

进程需要内存地址0x0003的一个字节数据,CPU发现发现它在自己的cache里存在,直接使用就好了。这个场景里其实根本就没有内存IO发生

  • 场景3:

进程需要内存地址0x0008的一个字节数据,CPU的cache并没有命中,于是向内存控制器请求。内存控制器发现行地址和上一次工作的行地址一致,这次只需要发送列地址后等待CL个周期,就可以拿到0x0008-0x0015的数据并返回给CPU了。

  • 场景4:

进程需要内存地址0xf000的一个字节数据,同样CPU的cache并不命中,向内存控制器请求。内存控制器一看(内心有些许的郁闷),这次行w地址又变了,得,和场景1一样。继续等待tRP+tRCD+CL个周期后,才能够取到数据并返回。

实际的计算机的内存IO过程中还需要进行逻辑地址和物理地址的转换,这里忽略。

实际计算

内存也存在和磁盘一样,随机IO比顺序IO要慢的问题。如果行地址同上一次访问的不一致,则需要重新拷贝row buffer,延迟周期需要tRP+tRCD+CL。而如果是顺序IO的话(行地址不变),只需要CL个周期既可完成。

估算内存的延时,若测试机器上的内存参数Speed为1066MHz(通过dmidecode查得),该值除以2就是时钟周期的频率=1066/2=533Mhz。其延迟周期为7-7-7-24。

  • 随机IO:这种状况下需要tRP+tRCD+CL个时钟周期,7+7+7=21个周期。但是还有个tRAS的限制,两次行地址预充电不得小于24。所以得按24来计算,24*(1s/533Mhz) = 45ns
  • 顺序IO:这种状况下只需要CL个时钟周期 7*(1s/533Mhz)=13ns

扩展:CPU的cache line虚拟内存概念

因为对于内存来说,随机IO一次开销比顺序IO高好几倍。所以操作系统在工作的时候,会尽量让内存通过顺序IO的方式来进行。做法关键就是Cache Line。当CPU发现缓存不命中的时候,实际上从来不会向内存去请求1个字节,8个字节这种。而是一次性就要64字节,然后放到自己的Cache中存起来。

用上面的例子来看,

  • 如果随机请求8字节:耗时是45ns
  • 如果随机请求64字节:耗时是45+7*13 = 136ns

开销也没贵多少,因为只有第一个字节是随机IO,后面的7个字节都是顺序IO。数据是8倍,但是IO耗时只有3倍,而且取出来的数据后面大概率要用,所以计算机内部就这么搞了,通过这种方式避免一些随机IO

另外,内存也支持burst(突发传输)模式,在这种模式下可以只传入一次行列地址,就命令内存返回该内存开头的连续字节数据,比如64字节。这种模式下,只有第一次的8字节需要真正的行列访问延迟,后面的7个字节可以直接按内存的数据频率给吐出来。

内存核心频率

内存真正的工作频率是核心频率,时钟频率和数据频率都是在核心频率的基础上,通过技术手段放大出来的。内存越新,放大的倍数越多核心频率已经多年没有实质性进步了,这是受物理材料的极限限制,内存的核心频率一直在133MHz~200MHz之间徘徊

实际的内存提速使用的是电路时钟周期预取以及Bank Group等技术。

扩展:内存延迟

内存还有个概念叫IO频率、也叫时钟频率。简单理解为将DDR内存的Speed频率除以2,就是内存的IO频率。这个必须和CPU的外频相匹配才能工作。例如对于DDR3来说,假如核心频率133Mhz的内存工作频率下,匹配533MHz的CPU外频,其IO频率就是533Mhz。数据传输因为上下沿都可以传,所以是核心频率的8倍,也就是1066MHz左右。

所有的内存条都有CL-tRCD-tRP-tRAS四个参数。其中最重要的是CL-tRCD-tRP这三个参数,只要费点劲,所有的在售内存你都能找到这3个值。例如经典的DDR3-1066、DDR3-1333及DDR3-1600的CL值分别为7-7-7、8-8-8及9-9-9。现在京东上一条比较流行的台式机内存金士顿(Kingston)DDR4 2400 8G,其时序是17-17-17。

第四个参数有时候会被省略。原因有二,第一:现在的开发者不需要直接和内存打交道,而操作系统呢又做的比较内存友好,很少会有这个开销真正发生。第二,这个开销的值要比其它的值大很多,实在不太好看。商家为了内存能多卖一些,干脆就避而不谈了。

好了,问题来了。为什么内存越进步,延迟周期反而会变大了呢?

这就是因为延迟周期使用延迟时间除以内存Speed算出来的。这其实根本就不科学,最科学的办法应该是用延迟时间来评估。延迟时间很大程度上是受内存的核心频率的制约的。而这些年核心频率又基本上没有进步,所以延迟时间也不会有实质的降低。内存的制造商们又为了频率数据好看,能多卖些内存,非得采用Speed作为主周期来用。导致在用这个周期一衡量,貌似延迟周期就越来越大了。

测试参考

在各种情况下进行内存访问延迟实验。最快的情况下其实不是内存在IO,而是CPU的高速缓存在工作。穿透到内存的话,顺序IO延时大约在10ns左右,随机访问确实比顺序访问慢的多,大概是4倍的关系。

现代的服务器里,CPU和内存条都有多个,它们之间目前主要采用的是复杂的NUMA架构进行互联,NUMA把服务器里的CPU和内存分组划分成了不同的node。属于同一个node里的CPU和内存之间访问速度会比较快。而如果跨node的话,则需要经过QPI总线,总体来说,速度会略慢一些

参考

https://mp.weixin.qq.com/s/F0NTfz-3x3UxQeF-GSavRg

https://mp.weixin.qq.com/s/ps8VfGpbL4-xKggM2ywjHw

https://mp.weixin.qq.com/s/3KOXcvmtc5jiwzGSTSF_yQ

https://mp.weixin.qq.com/s/OR2XB4J76haGc1THeq7WQg

新建一个空文件占用多少磁盘空间

linux下touch一个空文件:

1
touch empyt_file

进行该操作,是否要消耗磁盘空间?需要的话,大概能消耗多少?

ls这个命令可以查看文件大小:

1
2
3
4
$ touch empty_file
$ ls -l
total 0
-rw-r--r-- 1 dongshifu dongshifu 0 Aug 17 17:49 empty_file

ls命令表示这个空文件占用的是0。文件的大小确实是0,因为还没有为该文件写入任何内容。但是现在要思考的是,一个空文件是否占用磁盘空间。所以直觉告诉这绝对不可能,磁盘上多出来一个文件,怎么可能一点空间开销都没有!

为了解开这个谜底,还需要借助df命令。输入df –i

1
2
3
4
# df -i
Filesystem Inodes IUsed IFree IUse% Mounted on
......
/dev/sdb1 2147361984 12785019 2134576965 1% /search

该命令输出展示了文件系统中inode的使用情况。注意IUsed是12785019。继续新建一个空文件:

1
2
3
4
5
# touch empty_file2
df -i
Filesystem Inodes IUsed IFree IUse% Mounted on
......
/dev/sdb1 2147361984 12785020 2134576964 1% /search

这下注意IUsed变成了12785020。

得出结论:新建一个空文件会占用一个Inode。

Inode

那么inode里都存了哪些和文件相关的信息呢?稍微看一下内核的源代码。以ext2文件系统为例,在linux-2.6里的文件fs/ext2/ext2.h中,可以找到内核对于inode结构体的定义。该结构体较为复杂,主要存储除了文件内容以外的一些其他数据,选一些比较关键的截取出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct ext2_inode {
__le16 i_mode; // 文件权限
__le16 i_uid; // 文件所有者ID
__le32 i_size; // 文件字节数大小
__le32 i_atime; // 文件上次被访问的时间
__le32 i_ctime; // 文件创建时间
__le32 i_mtime; // 文件被修改的时间
__le32 i_dtime; // 文件被删除的时间
__le16 i_gid; // 文件所属组ID
__le16 i_links_count; // 此文件的inode被连接的次数
__le32 i_blocks; // 文件的block数量
...
__le32 i_block[EXT2_N_BLOCKS]; // 指向存储文件数据的块的数组
...

可以看到和文件相关的所属用户、访问时间等都是存在inode中的。使用stat命令就可以直接看到文件inode中数据。

每个inode到底是多大呢?dumpe2fs可以查看(XFS的话使用xfs_info)。

1
2
3
4
# dumpe2fs -h /dev/mapper/vgroot-lvroot
dumpe2fs 1.41.12 (17-May-2010)
......
Inode size: 256

Inode size表示每个Inode的大小。一般,每个inode都是256字节。两个inode的大小正好对齐到磁盘扇区的512字节。

文件名保存地址

Inode结构体都看完了,搞了半天不知道有没有发现一个问题,inode里并没有存储文件名!!那么,文件名到底跑哪儿去了?

fs/ext2/ext2.h中,可以找到如下文件夹相关的结构体

1
2
3
4
5
6
struct ext2_dir_entry {
__le32 inode; /* Inode number */
__le16 rec_len; /* Directory entry length */
__le16 name_len; /* Name length */
char name[]; /* File name, up to EXT2_NAME_LEN */
};

该结构体就是文件夹所使用的数据结构。没错,文件名是存在其所属的文件夹中的,就是其中的char name[]字段。和文件名一起,文件夹里还记录了该文件的inode号等信息。

一个字节的文件实际占用多少磁盘空间

假如给文件里只写入1个字节,那么这个文件实际的磁盘占用多大,难道真的是1个字节吗?

实际操作:

1
2
3
4
5
6
7
# mkdir tempDir
# cd tempDir
# du -h
0 .
# touch test
# du -h
0

在一个目录中创建了一个空的文件以后,通过du命令看到的该文件夹的占用空间并没有发生变化。这符合之前的认识,因为空文件只占用inode。接着修改文件,添加一个字母:

1
2
3
echo "a" > test
# du -h
4.0K

保存后再次查看该目录的空间占用。发现由原来的0增加到了4K。 所以说,文件里的内容不论多小,哪怕是一个字节,其实操作系统也会分配4K的。哦,当然了还得再算前文中说到的inode和文件夹数据结构中存储的文件名等所用的空间。 所以,不要在系统里维护一大堆的碎文件。文件再小,占用磁盘其实一点都不少!

4K占用的底层原理

再把linux源代码文件fs/ext2/ext2.h里关于inode的定义翻出来,找到结构体中定义的指向数据节点用的block数组:

1
2
3
4
struct ext2_inode {
......
__le32 i_block[EXT2_N_BLOCKS]; // 指向存储文件数据的块的数组
......

当文件没有数据需要存储的时候,这个数组都是空值。而当写入了1个字节以后,文件系统就需要申请block去存储了,申请完后,指针放在这个数组里。哪怕文件内容只有一个字节,仍然会分配一个整的Block,因为这是文件系统的最小工作单位。那么这个block大小是多大呢,ext下可以通过dumpe2fs查看。

1
2
3
#dumpe2fs -h /dev/mapper/vgroot-lvroot
......
Block size: 4096

一般情况,一个Block是4KB。

大文件如何存储

inode中定义的block数组大小呢,只有EXT2_N_BLOCKS个。再查看一下这个常量的定义,发现它是15,相关内核中定义如下:

1
2
3
4
5
#define EXT2_NDIR_BLOCKS        12
#define EXT2_IND_BLOCK EXT2_NDIR_BLOCKS
#define EXT2_DIND_BLOCK (EXT2_IND_BLOCK + 1)
#define EXT2_TIND_BLOCK (EXT2_DIND_BLOCK + 1)
#define EXT2_N_BLOCKS (EXT2_TIND_BLOCK + 1)

就按4K的block size来看,15个block只够存的下154=60K的文件。 这个文件大小肯定不是那么简单,存一个mp4大片都得上G了。那*Linux是怎么实现大文件存储的呢?其实上面宏的定义过程已经告知了一切,就是只有12个数组直接存block指针,其余的用来做间接索引(EXT2_IND_BLOCK),二级间接索引(EXT2_DIND_BLOCK)和三级索引(EXT2_TIND_BLOCK)。

inode中的直接与间接索引

这样,一个文件可以使用的空间就指数倍的扩展了。 文件小的时候,都用直接索引,磁盘IO少,性能好。文件大的时候,访问一个block可能得先进行三次的IO,性能略慢,不过有OS层面的页缓存、目录项缓存的加持,也还好

小结

文件系统是按照inode+block来组织的,所以不管文件多小,哪怕只有一个字节,在数据上都会消耗掉整整一个块(当然还得算上inode等开销)。这个块大小可以通过dumpe2fs等命令来查看。如果想改变这个块大小怎么办?对不起,只能重新格式化。

再扯的远一点,所有的文件系统理念都是按照块来分配的,包括分布式文件系统,例如HDFS。由于HDFS应用场景是各种GB、TB甚至是PB级别的数据处理。所以为了降低block的管理成本,它的block size设置的非常大。在比较新的版本里,一个block直接就是128M,没看错,单位是M。

参考

https://mp.weixin.qq.com/s/WE6BodR_q0GSKks_TgYL1w

SSD

SSD硬盘不像机械硬盘IO时依赖两个耗时的机械轴行为:磁盘旋转,以及磁道寻道,SSD硬盘的访问延迟要比机械硬盘要低的多,在随机IO下的表现尤其明显。

SSD的组成结构

机械硬盘和ssd虽然都同为硬盘,但底层实现技术却完全不一样,机械硬盘使用的是磁性材料记忆,而SSD用的是类似u盘的闪存技术。实现技术的不同,必然在硬盘内部结构上他们就有天壤之别。不像机械硬盘里的圆形碟片,SSD是由一些电路和黑色的存储颗粒构成。SSD硬盘是基于NAND Flash存储技术的,属于非易失性存储设备,也即掉电了数据不会丢。

SSD中包含Die,Plane,Block以及Page几个概念,其中:每个Die有若干个Plane,每个Plane有若干个Block,每个Block有若干个Page。Page是磁盘进行读写的最小单位,一般为2KB/4KB/8KB/16KB等。

SSD中的扇区

与机械硬盘一样,在SSD中虽然每一个物理Page的大小为2K到16K不等,但是为了兼容性,也必须使用逻辑扇区。SSD控制器在逻辑上会把整个磁盘再重新划分成一个个的“扇区”,采用和新机械硬盘一样的LBA方式来进行编址(整个磁盘的扇区从0到某个最大值方式排列,并连成一条线)。当需要读取某几个”扇区”上数据的时候,SSD控制器通过访问这个LBA MapTable,再来找到要实际访问的物理Page。SSD最小的读写单位就是Page,没办法对扇区来进行读写的。

SSD的闪存单元

SSD是由一个个的Page组成。而在每一个Page里,又包含了许许多多的闪存单元。现代的闪存单元有多种类型,目前主流的主要分为SLC、MLC和TLC。在SLC里,一个单元的电压只分成高低两种状态,所以只能表示1bit数据。到了MLC,硬是把一个单元里的电压按照高低分成了四种状态,所以可以表示2bit。到了TLC,直接一个单元应拆分成8个电压高低不同的状态,为了表示3bit。由于TLC在数据读写需要八种不同电压状态,而施加不同的电压状态、 就需要更精确,也就需要更长的时间才能得以实现。另外由于电压状态多,出错的可能性也会更大。

以上三种闪存单元对比:从性能和稳定性角度来看,SLC最好。从容量角度看,TLC最大。这就是为什么日常看到的工业级的SSD要比笔记本SSD要贵很多,其中一个很重要的原因就是工业级的盘往往采用的闪存单元是SLC或MLC,而我们家用的笔记本一般都是TCL,因为便宜。

小问题

假设某SSD的Page大小是4KB,一个文件是16KB。那么该文件是存在一个黑色的存储颗粒里,还是多个颗粒里?

假设只写在一个颗粒里,那么对该文件进行读取的时候,就只能用到一条Flash通道,这样速度就会比较慢。如果存在相邻的4个颗粒里,每个写入4KB。这样多个Flash通道的带宽会充分发挥出来,传输速度也更快。所以,实际中是分散在多个。

机械硬盘

好像本科时候学的操作系统课程在讲解磁盘的时候非常简略。现在复习一下。

磁盘结构

对于常见的机械磁盘,分磁盘面、磁道、柱面和扇区。

磁盘逻辑结构

磁道和扇区

有以下概念 :

  • 磁盘面:磁盘是由一叠磁盘面叠加组合构成,每个磁盘面上都会有一个磁头负责读写。

  • 磁道(Sector/Track):每个盘面会围绕圆心划分出多个同心圆圈,每个圆圈叫做一个磁道

  • 柱面(Cylinders):所有盘片上的同一位置的磁道组成的立体叫做一个柱面。

  • 扇区(Sector):以磁道为单位管理磁盘仍然太大,所以又把每个磁道划分出了多个扇区。而磁盘存储的最小组成单位就是扇区

单柱面的存储容量 = 每个扇区的字节数 每柱面扇区数 磁盘面数 。

整体磁盘的容量 = 单柱面容量 * 总的柱面数字。

扇区与扇区之间其实不是紧挨着的,而是在每个扇区结尾还有一个存储纠错码的位置。假设某一个扇区读取时发生了错误,这样在扇区结尾的纠错码就能发现。磁头就会在磁盘下一圈转过来的时候再读取一遍。

linux下查询

查看操作系统挂载的硬盘数量及大小,借助lsblk命令

然后通过fdisk可以查看硬盘的详细信息:

1
2
3
4
5
6
7
$ fdisk -l /dev/sda
#可以查看到heads(磁头)数量,从而确定盘面数
#cylinders 数量:每个盘面的磁道数
#sectors/track 每个磁道上的扇区
#每个逻辑扇区以及物理扇区的大小: 512bytes/4096bytes
#每个units的大小=盘面数*每个磁道上的扇区数*逻辑扇区大小
#磁盘总大小 = cylinders数 * units大小

每个磁道可以存储的数据都是一样的吗?

老式的磁盘里,确实是每个磁道数据都是一样的。这样越是内圈磁道的存储密度越大。目的就是为了访问方便,通过一个CHS地址:柱面地址(Cylinders)、磁头地址(Heads)、扇区地址(Sectors)直接定位到存储数据所在的扇区。但是这产生的问题就是外圈磁道的数据密度没有充分发挥出来,造成磁盘存储容量很难提升

现代的磁盘人们改用等密度结构生产硬盘,也就是说,外圈磁道的扇区比内圈磁道多。这种磁盘里扇区是线性编号的,即从0到某个最大值方式排列,并连成一条线。这种寻址模式叫做LBA,全称为Logic Block Address(即扇区的逻辑块地址)。磁盘内部是自己会通过磁盘控制器来完成CHS到LBA的转换,进而定位到具体的物理扇区

物理扇区大小

现代科技进步了,磁盘底层的最小组成单位并不是扇区512字节,physical Sector size 4KB。但这时存在一个问题是扇区大小为512字节的假设已经贯穿于整个软件链,比如BIOS,启动加载器,操作系统内核,文件系统代码,以及磁盘工具,等等。直接切换到4096 byte兼容性问题太大了,所以每个新的磁盘控制器将4096字节的物理扇区对应成了8个512字节的逻辑扇区,兼容各种老软件。

除了fdisk -l命令外,如下方式也可以查看物理/逻辑扇区大小。

1
2
#cat /sys/block/sda/queue/physical_block_size
#cat /sys/block/sda/queue/logical_block_size

磁头的数量

磁盘不可能真的装很多磁头,通过fdisk -l看到的磁头数量和扇区以及磁道一样,是被虚拟出来的。

磁盘分区

分区是操作系统对磁盘进行管理的第一步,这也是任何一个计算机使用者都非常熟悉的概念。例如Windows下的C、D、E、F盘。那么操作系统的设计者是如何把整块磁盘分成C、D等分区的?

为了方便讨论,这里假设要分的硬盘是有50个盘面,3000个柱面。给出两种方案

  • 方案一:50个盘面,C盘是0-10盘面, D盘是10-20个盘面,……
  • 方案二:3263个柱面,C盘0-1000个柱面,D盘1001-2001个柱面,……

接下来讨论下那种方案更优秀,这得从磁盘的读写延时角度说起。读写原理说起来也简单,就是磁头要找到指定的磁道,指定的扇区,进而把数据读取出来或者写入进去的过程。这个过程分成如下三步:

  • 第一步,首先是磁头径向移动来寻找数据所在的磁道。这部分时间叫寻道时间。寻道时间,现代磁盘大概在3-15ms,其中寻道时间大小主要受磁头当前所在位置和目标磁道所在位置相对距离的影响
  • 第二步,找到目标磁道后通过盘面旋转,将目标扇区移动到磁头的正下方,这部分时间叫旋转延迟。现在主流服务器上经常使用的是1W转/分钟的磁盘,每旋转一周所需的时间为60*1000/10000=6ms,故其旋转延迟为(0-6ms)
  • 第三步,向目标扇区读取或者写入数据,这部分时间叫存取时间。这个是电磁操作,所以一般耗时较短,为零点几ms。

到此为止,单次磁盘IO时间 = 寻道时间 + 旋转延迟 + 存取时间

分区上采用哪一种方案,最主要看的是那种方式性能更快。在磁盘分区的使用中,存在一个基本事实,那就是同一分区下的数据经常会一起读取。两种方案的对于旋转延迟、和存取时间上表现的性能是一样的,主要区别是在寻道时间的表现上

假如采用第一种,那么这样磁头就需要在3000多个磁道间不停地跳来跳去,这样磁盘的寻道时间就降不下来。而对于方案二,假如对于磁盘C,只需要在磁头在1-1000个磁道间移动就可以了,大大降低了寻道时间。所以所有的操作系统采用的都是方案二,没有用方案一的。

分区的过程就是输入起始柱面号和截至柱面号的过程。不过在实际中,分区并不能从0号柱面开始的,因为磁盘的第一个磁道对应的柱面会被用来安装引导加载程序以及磁盘分区表。所以,操作系统通过按磁道对应的柱面划分分区,来降低磁盘IO所花费的的寻道时间 ,最终提高磁盘的读写性能。

机械硬盘的缺点及解决办法

主要问题

机械硬盘更多是用机械技术做出来的产品。当把带有机械技术基因的磁盘搭到计算机,尤其是应用到服务器领域的时候,暴露出了机械技术的两个严重问题:

  • 第一,速度慢。如果把内存和CPU的速度比作汽车和飞机的话,机械硬盘毫秒级别的延迟几乎就是牛车级别的。

  • 第二,容易坏。经常听说谁谁的磁盘坏了,很少有听说过谁的内存条,CPU坏了。

要想保证服务器运转的稳定和高速,就必须解决硬盘的这两个缺陷。

解决办法

多硬盘连接

单块硬盘不行,尝试同时使用多块硬盘。但假如给了N块硬盘,如何设计一个使用的方案?

  • RAID 0:把一个文件分成N片,每一片都散列在不同的硬盘上。这样当文件进行读取的时候,就可以N块硬盘一起来工作,从而达到读取速度提高到N倍的效果。

RAID0

缺点:没有解决容易坏的问题,任何一块硬盘坏了都会导致存储系统故障

  • RAID 1:仍然把文件分片,但是所有的分片都存在一块硬盘上,其它的硬盘只存拷贝。这既提高了硬盘的访问速度,也解决了坏的问题。任意一块硬盘坏了,存储系统都可以正常使用,只不过速度会打一点折扣。

RAID1

缺点:实现成本高。

  • RAID 5:样要对文件进行分片,但是不对存储的数据进行备份,而是会再单独存一个校验数据片。假如文件分为A1 A2 A3,然后需要再存一个校验片到别的磁盘上。这样不管A1,A2还是A3那一片丢失了,都可以根据另外两片和校验片合成出来。既保证了数据的安全性,又只用了一块磁盘做冗余存储

RAID5

假如有8块256GB的硬盘,那么RAID5方案下的磁盘阵列从用户角度来看可用的存储空间是7*256GB,只“浪费”了一块盘的空间,所以目前RAID5应用比较广泛。

RAID卡缓存

硬盘延迟是毫秒级别的,即使是多快硬盘并行,也只能提升数倍而已,不能够达到量级的提升。和CPU内存的纳秒级别工作频率比起来,还是太慢。在计算机界,没有缓存解决不了的速度问题,如果有,那就再加一层。现代磁盘本身也基本都带了缓存,另外在一些比较新的raid卡里,硬件开发者们又搞出来了一层“内存”,并且还自己附带一块电池,这就是RAID卡缓存。几款主流RAID卡的配置:

  • PERC S120 入门软件阵列卡,主板集成无缓存 支持RAID 0 1

  • PERC H330 入门硬件RAID卡,无板载缓存, 支持RAID 0 1 5 10 50

  • PERC H730 主流硬件RAID卡带有1G缓存和电池 支持RAID 0 1 5 6 10 50 60
  • PERC H730P 高性能硬件RAID卡带有2G缓存和电池 支持RAID 0 1 5 6 10 50 60
  • PERC H830 同H730P,没有内置接口,使用外置接口连接附加存储磁盘柜用

拿目前服务器端出镜率比较高的H730和H730P来看,他们分别带了1G和2G的缓存卡,并且自带电池。电池的作用就是当发现主机意外断电的时候,能够快速把缓存中的数据写回到磁盘中去。对于写入,一般操作系统写到这个RAID卡里就完事了,所以速度快。对于读取也是,只要缓存里有,就不会透传到磁盘的机械轴上。

文件相关函数里设置DIRECT I/O仅仅只能绕开操作系统本身的Page Cache,而RAID卡里的缓存,对于Linux来说,可以说算是一个黑盒。换句话说,就是操作系统并不清楚RAID卡是从缓存里吐的数据,还是真正从硬盘里读的。

虚拟内存

使用虚拟地址的原因

单片机的 CPU 是直接操作内存的物理地址。在这种情况下,要想在内存中同时运行两个程序是不可能的。如果第一个程序在 1000 的位置写入一个新的值,将会擦掉第二个程序存放在相同位置上的所有内容,所以同时运行两个程序是根本行不通的,这两个程序会立刻崩溃。

无法同时运行两个程序的原因在于:两个程序都引用了绝对物理地址

可以把进程所使用的地址隔」开来,即让操作系统为每个进程分配独立的一套虚拟地址,每个进程再自己的地址操作即可,互不干涉。但是有个前提每个进程都不能访问物理地址,至于虚拟地址最终怎么落到物理内存里,对进程来说是透明的,操作系统已经把这些都安排的明明白白了。

虚拟地址和物理地址的映射

引出了两种地址的概念:

  • 程序所使用的内存地址叫做虚拟内存地址Virtual Memory Address
  • 实际存在硬件里面的空间地址叫物理内存地址Physical Memory Address)。

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

内存分段与内存分页

操作系统管理虚拟地址和物理地址时主要使用内存分段和内存分页。

内存分段

程序是由若干个逻辑分段组成的,如可由代码分段、数据分段、栈段、堆段组成。不同的段是有不同的属性的,所以就用分段(Segmentation)的形式把这些段分离出来。

分段机制下的虚拟地址由两部分组成,段选择因子(重要部分:段号 ) 段内偏移量

内存分段

  • 段选择子保存在段寄存器里面。段选择子里面最重要的是段号,用作段表的索引段表里面保存的是这个段的基地址、段的界限和特权等级等。
  • 虚拟地址中的段内偏移量应该位于 0 和段界限之间,如果段内偏移量是合法的,就将段基地址加上段内偏移量得到物理内存地址。

分段机制会把程序的虚拟地址分成 4 个段,每个段在段表中有一个项,在这一项找到段的基地址,再加上偏移量,于是就能找到物理内存中的地址,如下图:

内存分段-虚拟地址和物理地址

如果要访问段 3 中偏移量 500 的虚拟地址,可以计算出物理地址为,段 3 基地址 7000 + 偏移量 500 = 7500。

分段的办法很好,解决了程序本身不需要关心具体的物理内存地址的问题,但它也有一些不足之处:

  • 第一个就是内存碎片的问题。
  • 第二个就是内存交换的效率低的问题。
内存碎片问题

看一个具体的例子。假设有 1G 的物理内存,用户执行了多个程序,其中:

  • 游戏占用了 512MB 内存
  • 浏览器占用了 128MB 内存
  • 音乐占用了 256 MB 内存。

这个时候,如果关闭了浏览器,则空闲内存还有 1024 - 512 - 256 = 256MB。

如果这个 256MB 不是连续的,被分成了两段 128 MB 内存,这就会导致没有空间再打开一个 200MB 的程序。

内存碎片问题

内存碎片的问题共有两处地方:

  • 外部内存碎片,也就是产生了多个不连续的小物理内存,导致新的程序无法被装载;
  • 内部内存碎片,程序所有的内存都被装载到了物理内存,但是这个程序有部分的内存可能并不是很常使用,这也会导致内存的浪费;

解决外部内存碎片的问题就是内存交换

可以把音乐程序(暂不使用)占用的那 256MB 内存写到硬盘上,然后再从硬盘上读回来到内存里。不过再读回的时候,我们不能装载回原来的位置,而是紧紧跟着那已经被占用了的 512MB 内存后面。这样就能空缺出连续的 256MB 空间,于是新的 200MB 程序就可以装载进来。

这个内存交换空间,在 Linux 系统里,也就是我们常看到的 Swap 空间,这块空间是从硬盘划分出来的,用于内存与硬盘的空间交换。

内存交换效率低问题

对于多进程的系统来说,用分段的方式,内存碎片是很容易产生的,产生了内存碎片,那不得不重新 Swap 内存区域,这个过程会产生性能瓶颈。因为硬盘的访问速度要比内存慢太多了,每一次内存交换,都需要把一大段连续的内存数据写到硬盘上。所以,如果内存交换的时候,交换的是一个占内存空间很大的程序,这样整个机器都会显得卡顿。

内存分页

分段的好处就是能产生连续的内存空间,但是会出现内存碎片和内存交换的空间太大的问题。为了解决内存分段的内存碎片和内存交换效率低的问题,就出现了内存分页。

要解决这些问题,那么就要想出能少出现一些内存碎片的办法。另外,当需要进行内存交换的时候,让需要交换写入或者从磁盘装载的数据更少一点,这样就可以解决问题了。这个办法,也就是内存分页Paging)。

分页是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间,我们叫页(Page)。在 Linux 下,每一页的大小为 4KB

虚拟地址与物理地址之间通过页表来映射,如下图:

内存映射

页表实际上存储在 CPU 的内存管理单元MMU) 中,于是 CPU 就可以直接通过 MMU,找出要实际要访问的物理内存地址。而当进程访问的虚拟地址在页表中查不到时,系统会产生一个缺页异常进入系统内核空间分配物理内存、更新进程页表,最后再返回用户空间,恢复进程的运行。

分页解决分段的内存碎片及内存交换效率低问题

由于内存空间都是预先划分好的,也就不会像分段会产生间隙非常小的内存,这正是分段会产生内存碎片的原因。而采用了分页,那么释放的内存都是以页为单位释放的,也就不会产生无法给进程使用的小内存。

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

分页-换入换出

分页的方式使得在加载程序的时候,不再需要一次性都把程序加载到物理内存中。完全可以在进行虚拟内存和物理内存的页之间的映射之后,并不真的把页加载到物理内存里,而是只有在程序运行中,需要用到对应虚拟内存页里面的指令和数据时,再加载到物理内存里面去。

分页机制下,虚拟地址和物理地址的映射

在分页机制下,虚拟地址分为两部分,页号页内偏移页号作为页表的索引页表包含物理页每页所在物理内存的基地址,这个基地址与页内偏移的组合就形成了物理内存地址,见下图:

内存分页寻址

总结一下,对于一个内存地址转换,其实就是这样三个步骤:

  • 把虚拟内存地址,切分成页号和偏移量
  • 根据页号,从页表里面,查询对应的物理页号
  • 直接拿物理页号,加上前面的偏移量,就得到了物理内存地址

简单分页的缺陷

有空间上的缺陷。因为操作系统是可以同时运行非常多的进程的,那这就意味着页表会非常的庞大。在 32 位的环境下,虚拟地址空间共有 4GB,假设一个页的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个页,每个页表项需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有4MB 的内存来存储页表。这 4MB 大小的页表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有自己的页表。那么,100 个进程的话,就需要 400MB 的内存来存储页表,这是非常大的内存了,更别说 64 位的环境了。

多级页表

对于单页表的实现方式,在 32 位和页大小 4KB的环境下,一个进程的页表需要装下 100 多万个页表项,并且每个页表项是占用 4 字节大小的,于是相当于每个页表需占用 4MB 大小的空间。把这个 100 多万个页表项的单级页表再分页,将页表(一级页表)分为 1024 个页表(二级页表),每个表(二级页表)中包含1024 个页表项,形成二级分页。如下图所示:

多级页表

分了二级表,映射 4GB 地址空间就需要 4KB(一级页表)+ 4MB(二级页表)的内存,这样占用空间不是更大了吗?如果 4GB 的虚拟地址全部都映射到了物理内上的,二级分页占用空间确实是更大了,但是,我们往往不会为一个进程分配那么多内存。程序中会用到局部性原理

每个进程都有 4GB 的虚拟地址空间,而显然对于大多数程序来说,其使用到的空间远未达到 4GB,因为会存在部分对应的页表项都是空的,根本没有分配,对于已分配的页表项,如果存在最近一定时间未访问的页表,在物理内存紧张的情况下,操作系统会将页面换出到硬盘,也就是说不会占用物理内存。

如果使用了二级分页,一级页表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级页表的页表项没有被用到,也就不需要创建这个页表项对应的二级页表了,即可以在需要时才创建二级页表。做个简单的计算,假设只有 20% 的一级页表项被用到了,那么页表占用的内存空间就只有 4KB(一级页表) + 20% * 4MB(二级页表)= 0.804MB
,这对比单级页表的 4MB 是一个巨大的节约。

那么为什么不分级的页表就做不到这样节约内存呢?从页表的性质来看,保存在内存中的页表承担的职责是将虚拟地址翻译成物理地址。假如虚拟地址在页表中找不到对应的页表项,计算机系统就不能工作了。所以页表一定要覆盖全部虚拟地址空间,不分级的页表就需要有 100 多万个页表项来映射,而二级分页则只需要 1024 个页表项(此时一级页表覆盖到了全部虚拟地址空间,二级页表在需要时创建)。

把二级分页再推广到多级页表,就会发现页表占用的内存空间更少了,这一切都要归功于对局部性原理的充分应用。对于 64 位的系统,两级分页肯定不够了,就变成了四级目录,分别是:

  • 全局页目录项 PGD(Page Global Directory
  • 上层页目录项 PUD(Page Upper Directory
  • 中间页目录项 PMD(Page Middle Directory
  • 页表项 PTE(Page Table Entry)。

TLB

多级页表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降低了这俩地址转换的速度,也就是带来了时间上的开销程序是有局部性的,即在一段时间内,整个程序的执行仅限于程序中的某一部分。相应地,执行所访问的存储空间也局限于某个内存区域。可以利用这一特性,把最常访问的几个页表项存储到访问速度更快的硬件,于是计算机科学家们,就在 CPU 芯片中,加入了一个专门存放程序最常访问的页表项的 Cache,这个 Cache 就是 TLB(Translation Lookaside Buffer) ,通常称为页表缓存、转址旁路缓存、快表等。

TLB

在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,它用来完成地址转换和 TLB 的访问与交互。有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的页表。TLB 的命中率其实是很高的,因为程序最常访问的页就那么几个。

段页式内存管理

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

段页式内存管理实现的方式:

  • 先将程序划分为多个有逻辑意义的段,也就是前面提到的分段机制;
  • 接着再把每个段划分为多个页,也就是对分段划分出来的连续空间,再划分固定大小的页;

这样,地址结构就由段号、段内页号和页内位移三部分组成。

用于段页式地址变换的数据结构是每一个程序一张段表,每个段又建立一张页表,段表中的地址是页表的起始地址,而页表中的地址则为某页的物理页号,如图所示:

段页式地址变换

段页式地址变换中要得到物理地址须经过三次内存访问:

  • 第一次访问段表,得到页表起始地址;
  • 第二次访问页表,得到物理页号;
  • 第三次将物理页号与页内位移组合,得到物理地址。

可用软、硬件相结合的方法实现段页式地址变换,这样虽然增加了硬件成本和系统开销,但提高了内存的利用率。

linux内存管理

早期 Intel 的处理器从 80286 开始使用的是段式内存管理。但是很快发现,光有段式内存管理而没有页式内存管理是不够的,这会使它的 X86 系列会失去市场的竞争力。因此,在不久以后的 80386 中就实现了对页式内存管理。也就是说,80386 除了完成并完善从 80286 开始的段式内存管理的同时还实现了页式内存管理。

但是这个 80386 的页式内存管理设计时,没有绕开段式内存管理,而是建立在段式内存管理的基础上,这就意味着,页式内存管理的作用是在由段式内存管理所映射而成的的地址上再加上一层地址映射。

由于此时段式内存管理映射而成的地址不再是“物理地址”了,Intel 就称之为“线性地址”(也称虚拟地址)。于是,段式内存管理先将逻辑地址映射成线性地址,然后再由页式内存管理将线性地址映射成物理地址。

逻辑地址和线性地址:

  • 程序所使用的地址,通常是没被段式内存管理映射的地址,称为逻辑地址
  • 通过段式内存管理映射的地址,称为线性地址,也叫虚拟地址

逻辑地址是段式内存管理转换前的地址,线性地址则是页式内存管理转换前的地址。

Linux 内存主要采用的是页式内存管理,但同时也不可避免地涉及了段机制

这主要是上面 Intel 处理器发展历史导致的,因为 Intel X86 CPU 一律对程序中使用的地址先进行段式映射,然后才能进行页式映射。既然 CPU 的硬件结构是这样,Linux 内核也只好服从 Intel 的选择。

但是事实上,Linux 内核所采取的办法是使段式映射的过程实际上不起什么作用。也就是说,“上有政策,下有对策”,若惹不起就躲着走。

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

Linux 的虚拟地址空间是如何分布的?

在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,不同位数的系统,地址空间的范围也不同。比如最常见的 32 位和 64 位系统:

用户空间和内存空间

通过这里可以看出:

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

再来说说,内核空间与用户空间的区别:

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

虽然每个进程都各自有独立的虚拟内存,但是每个虚拟内存中的内核地址,其实关联的都是相同的物理内存。这样,进程切换到内核态后,就可以很方便地访问内核空间内存。

进程内核空间一致

每个进程的内核空间都是一致的,这样其实方便了进程间的通信以及父子进程的创建。具体可参考linux系统-进程间通信linux系统-进程管理

调用malloc进行内存申请的过程

  1. malloc开始搜索空闲内存块,如果能找到一块大小合适的就分配出去
  2. 如果malloc找不到一块合适的空闲内存,那么调用brk等系统调用扩大堆区从而获得更多的空闲内存
  3. malloc调用brk后开始转入内核态,此时操作系统中的虚拟内存系统开始工作,扩大进程的堆区,注意额外扩大的这一部分内存仅仅是虚拟内存,操作系统并没有为此分配真正的物理内存
  4. brk执行结束后返回到malloc,从内核态切换到用户态,malloc找到一块合适的空闲内存后返回
  5. 程序员拿到新申请的内存,程序继续
  6. 有代码读写新申请的内存时系统内部出现缺页中断,此时再次由用户态切换到内核态,操作系统此时真正的分配物理内存,之后再次由内核态切换回用户态,程序继续。

linux中提供了互斥锁(mutex,互斥量)。每个线程在对资源进行操作前都会尝试先加锁,成功加锁才能操作,操作结束解锁。资源还是共享的,线程间也还存在竞争。但通过”锁”可以将资源的访问变成互斥操作,而后与时间有关的错误也将不会再产生。

应该注意:同一时刻,只能有一个线程持有该锁

当A线程对某个全局变量加锁访问,B在访问前尝试加锁,拿不到锁,B会阻塞。C线程不加锁而直接访问该全局变量,此时依然能够访问,但会出现数据混乱。

互斥锁实质上是操作系统提供的一把建议锁(又称”协同锁”),当程序中有多线程访问共享资源的时候需要使用该机制。但是没有强制的限定

借助互斥锁管理共享数据实现同步

C关键词restrict用来限定指针变量,被该关键字限定的指针变量所指向的内存操作必须由本指针完成

1
2
3
4
5
pthread_mutex_t lock; //创建锁
pthread_mutex_init; //初始化
pthread_mutex_lock; //加锁
访问共享数据(stdout)
pthread_mutex_unlock(); //解锁