一直以来,对于网络连接中的细节都不是很清楚,最近特意梳理了一下,大部分内容来自书籍网络是怎样连接的(户根勒)
首先看下连接的整体流程
1.输入URL
在浏览器中输入URL网址就可以得到我们想要的网页,这有两个要素,浏览器和URL网址。浏览器是一个具备多种客户端功能的综合性客户端软件,它需要一些东西来判断应该使用其中哪种功能来访问相应的数据, 而各种不同的URL 就是用来干这个的, 比如访问 Web 服务器时用“http:”, 而访问 FTP服务器时用“ ftp:”。有了url浏览器就会通过我们url定义的规则去访问我们需要的资源。
下图是几种URL的含义
2.DNS域名解析
我们知道域名背后指向的是服务器地址,显然这种关系不是凭空产生的,需要我们自己来定义,所以就有了域名解析。通过域名解析可以把域名指向对应的服务器地址,构成映射关系,这样别人就可以通过域名找到你的主机了。如果你有购买过域名,服务商肯定会提供域名解析管理页面,类似下面,这样你就可以根据自己的情况,定义想要的映射关系。
2.1 为什么要用域名和ip地址的组合呢?
域名是给人来用的,为了便于记忆,人们要记住一串由数字组成的ip地址是十分困难的。ip地址是给机械使用的,在信息传递过程中,存在无数的路由器, 它们之间相互配合, 根据 IP地址来判断应该把数据传送到什么地方。IP 地址的长度为 32 比特, 也就是 4 字节,相对地, 域名最短也要几十个字节, 最长甚至可以达到 255 字节。 换句话说, 使用 IP 地址只需要处理 4 字节的数字,而域名则需要处理几十个到 255个字节的字符,这增加了路由器的负担,传送数据也会花费更长的时间。所以需要有一个机制能够通过名称来查询 IP 地址, 或者通过 IP地址来查询名称,这样就能够在人和机器双方都不做出牺牲的前提下完美地解决问题。这个机制就是 DNS。
2.2 DNS域名解析过程
DNS 中的域名都是用句点来分隔的, 比如 www.lab.glasscom.com, 这里的句点代表了不同层次之间的界限。 在域名中, 越靠右的位置表示其层级越高, 比如 www.lab.glasscom.com 这个域名如果按照公司里的组织结构来说, 大概就是“ com 事业集团 glasscom 部 lab 科的 www”这样。 其中, 相当于一个层级的部分称为域。 因此, com 域的下一层是glasscom 域, 再下一层是 lab 域, 再下面才是 www 这个名字。真是因为这种层次的关系,上级保存下级的信息,所以我们只需找到最上层的信息就可以找到所有的信息了。com,cn,jp这样的算得上是顶层DNS服务器,但实际上上面还有一层就是根域,根域的 DNS 服务器信息保存在互联网中所有的 DNS 服务器中。
下图表示的就是DNS的查询过程,先找到最近的DNS服务器(本地DNS服务器,也就是客户端TCP/IP设置中填写的DNS服务器地址),然后再去访问根域DNS服务器,然后一层一层往下找。
在实际的情况更复杂,为了加快查找,有些层会有缓存,这样就不一定要从根域名开始往下找,大体流程如下。
1> 首先,检查浏览器是否有缓存
浏览器本身是会缓存域名的,但是这是有限制的,不仅浏览器缓存大小有限制,而且缓存的时间也有限制。这个缓存时间太长和太短都不好,如果缓存时间太长,一旦域名被解析到的IP有变化,会导致被客户端缓存的域名无法解析到变化后的IP地址,以致该域名不能正常解析。
2> 其次,检查系统缓存 + hosts
操作系统自身也会有dns缓存
我们可以通过设置hosts文件内容来进行域名绑定
3> 如果以上都没有命中,那么就会检查本地DNS服务器(LDNS)
LDNS一般是电信运营商提供的,也可以使用像Google提供的DNS服务器。
在我们的网络配置中都会有“DNS服务器地址”这一项,这个地址就用于解决前面所说的如果两个过程无法解析时要怎么办,操作系统会把这个域名发送给这里设置的LDNS,也就是本地区的域名服务器。这个DNS通常都提供给你本地互联网接入的一个DNS解析服务。大约80%的域名解析都到这里就已经完成了,所以LDNS主要承担了域名的解析工作。
4> 依然没有命中,则直接查找最顶端的根域DNS服务器
根域DNS服务器会返回顶层DNS服务器地址给本地DNS服务器。
5> 接下来,访问 4 步中返回的顶层DNS服务器
本地DNS服务器拿到DNS顶层服务器地址后会去发送请求,顶层DNS服务器查询后返回管理方DNS服务器地址给本地DNS服务器。
6> 最后,访问 5 步中返回的管理方DNS服务器
本地DNS服务器拿到管理方DNS服务器地址后会去发送请求,管理方DNS服务器查询存储的域名和IP的映射关系表,并返回给本地DNS服务器,本地DNS服务器返回该域名对应的IP和TTL值给浏览器。
2.3 DNS查询IP的底层原理
下面是原理的伪代码,可以按照这个图简单分析,实际DNS查询和TCP连接建立比较类似,这里可以先简单了解,后面会详细介绍
从上图我们可以了解到的信息有
1> 从上到下分别是应用程序,Socket库,操作系统协议栈,网卡。。。
2> 浏览器要获取IP地址,先会去调用Socket库(就是一堆通用程序组件的集合,其他的应用程序都需要使用其中的组件)中的域名解析器,并预留将返回的结果存在应用程序的内存中。
3> 浏览器和Socket库并不具备使用网络收发数据的功能,需要使用协议栈执行发送消息的操作, 然后通过网卡将消息发送给 DNS服务器。并且我们注意到这里了使用的是UDP协议(后面章节的建立连接会讲到TCP握手,这里并不是使用TCP协议)。
4> 查询到后dns服务器会一层一层的返回数据信息。
3 建立TCP连接,(4 发送请求,7 断开连接)
这里会将步骤3 4 7 连起来一起讲解,这样更利于整体把握。
知道了 IP 地址之后, 就可以委托操作系统内部的协议栈向这个目标 IP 地址, 也就是我们要访问的 Web 服务器发送建立连接的请求了。要更好的理解这部分内容就要对TCP/IP软件的分层结构有个认识。
分为不同的层次,分别承担不同的功能。上面部分会向下面部分委派工作,下面部分接受委派并进行实际执行,这一上下关系只是一个总体的规则, 其中也有一部分上下关系不明确, 或者上下关系相反的情况, 所以也不必过于纠结。
可以按照 Socket库 -> 协议栈 -> 网卡驱动程序 -> 网卡 -> 网络 这个大体的思路进行分析。
1> 最上面部分是网络应用程序,比如浏览器,电子邮件客户端,web服务器等。
2> 然后是Socket库,前面DNS解析是提过,包括域名解析器,用来向DNS服务器发出查询,还有其他一些其他功能,后面章节会提及。
3> 再下面是操作系统内部,其中包括协议栈,协议栈的上半部分有两块, 分别是负责用 TCP 协议收发数据的部分和负责用 UDP 协议收发数据的部分, 它们会接受应用程序的委托执行收发数据的操作。下面一半是用 IP 协议控制网络包收发操作的部分。 在互联网上传送数据时, 数据会被切分成一个一个的网络包 , 而将网络包发送给通信对象的操作就是由 IP 来负责的。 此外, IP 中还包括ICMP协议和 ARP 协议。ICMP 用于告知网络包传送过程中产生的错误以及各种控制消息, ARP 用于根据 IP 地址查询相应的以太网 MAC 地址 。
4> IP 下面的网卡驱动程序负责控制网卡硬件, 而最下面的网卡则负责完成实际的收发操作, 也就是对网线中的信号执行发送和接收的操作。
3.1 收发数据的全貌
我们可以把数据通道想象成一条管道, 将数据从一端送入管道, 数据就会到达管道的另一端然后被取出。 数据可以从任何一端被送入管道, 数据的流动是双向的。 不过, 这并不是说现实中真的有这么一条管道, 只是为了帮助大家理解数据收发操作的全貌,要注意到这些操作并不是浏览器等程序完成的,而是由协议栈来代劳的,向操作系统内部的协议栈发出委托时,需要按照指定的顺序来调用 Socket 库中的程序组件。。
关于这条管道,大致总结以下 4 个阶段。
( 1)创建套接字(创建套接字阶段)
( 2)将管道连接到服务器端的套接字上(连接阶段)
( 3)收发数据(通信阶段)
( 4)断开管道并删除套接字(断开阶段)
我们事先提取几个关键词,简单理解下,
1> 应用程序:浏览器等。
2> Socket库:应用程序会通过调用Socket库向协议栈发出委托。
3> 协议栈:协议栈负责数据的收发工作。
4> 套接字:于管道两端的数据出入口,协议栈是根据套接字中记录的控制信息来工作的,在协议栈内部有一块用于存放控制信息的内存空间, 这里记录了用于控制通信操作的控制信息, 例如通信对象的 IP 地址、 端口号、 通信操作的进行状态等。协议栈则需要根据这些信息判断下一步的行动。
5> 描述符:套接字的唯一标识,号码牌
注意:为了便于整体把握,我们将这四个阶段一起讲解。下图是收发数据的底层原理伪代码
3.2 创建套接字阶段
浏览器调用socket库提出创建套接字申请,然后协议栈根据申请执行创建套接字的操作,这个过程中,协议栈首先会分配一个用于存放套接字的内存空间,相当于给控制信息准备一个容器,我们需要往其中写入控制信息。创建之初,数据收发还没开始,先向该内存空间写入初始状态控制信息,这样就完成了创建套接字的操作。
在我们创建套接字的同时,每个套接字就有了唯一编号,称之为描述符,接下来需要将这个套接字的描述符告知应用程序。之后,应用程在向协议栈收发数据委托的时候就需要提供这个描述符,这个描述符可以确定相应的套接字,套接字中又有相关信息,这样一来,只需提供这个描述符,应用程序就不用每次都告诉协议栈应该和谁进行通信了。
3.3 连接阶段
套接字创建之后,应用程序会调用connect,随后协议栈会将本地的套接字和服务器的套接字进行连接。在连接操作过程中,还需要提前分配一块用来临时存放要收发的数据的内存空间,这块空间被称为缓冲区,该缓冲区在执行数据收发操作的时候要用到。
分别看下客户端和服务端的情况
客户端:套接字创建之初,里面只有初始信息,不知道通讯的对象是谁,这时即便想要发送数据,协议栈也不知道应该将数据发给谁。但是其实应用程序是知道通讯对象的,通过DNS域名解析得到目标服务器的IP地址,同时不同的服务占用服务器不同的端口,一般来说这都是预先定好的,这些能帮助我们找到通讯对象。应用程序知道,协议栈不知道通讯对象,是因为在调用 socket 创建套接字时, 这些必要信息并没有传递给协议栈,所以我们需要将这些信息告诉协议栈,这也是连接操作的目的之一。
服务端:同样也会创建套接字,初始阶段也不知道要和谁通讯,甚至连服务端应用程序都不知道应该和谁通讯,所以一般来说服务器会一直监听等待客户端的通讯,客户端在通讯的过程中会带着通讯的必要信息。
3.3.1 调用connect
调用 connect 时,需要指定描述符、服务器 IP 地址和端口号这 3 个参数。
第 1 个参数, 即描述符,connect 会将应用程序指定的描述符告知协议栈, 然后协议栈根据这个描述符来判断到底使用哪一个套接字去和服务器端的套接字进行连接, 并执行连接的操作。
第 2 个参数, 即服务器 IP 地址, 就是通过 DNS 服务器查询得到的我们要访问的服务器的 IP 地址。
第 3 个参数, 即端口号,同时指定 IP 地址和端口号时,就可以明确识别出某台具体的计算机上的某个具体的套接字。
这里思考两个问题
问题1:到目前我们了解到能够识别套接字有两套机制,一种是描述符,另一种是IP+端口号,那么他们有什么区别呢?
1> 描述符是用来在一台计算机内部识别套接字的机制, 比如浏览器和协议栈之间
2> 那么IP+端口号就是用来让通信的另一方能够识别出套接字的机制,比如客户端和服务端之间,实际上web端和服务端又会有不同,后面第5章节介绍WEB服务器原理时会讲到。
问题2:服务端和客户端使用端口号的区别?
1> 服务器上所使用的端口号是根据应用的种类事先规定好的, 仅此而已。 比如 Web 是 80 号端口, 电子邮件是 25 号端口。
2> 客户端在创建套接字时, 协议栈会为这个套接字随便分配一个端口号 。 接下来, 当协议栈执行连接操作时, 会将这个随便分配的端口号通知给服务器。
3.3.2 通讯双方交换控制信息
连接操作实际上是通讯双方交换控制信息,通讯操作使用的控制信息大体可以分为两类,
一类就是我们上面提到的保存在套接字中的信息,用来控制协议栈操作。
还有一类就是客户端和服务器相互联络时交换的控制信息,称为TCP头部信息,这些信息不仅连接时需要, 包括数据收发和断开连接操作在内, 整个通信过程中都需要,这些内容在 TCP 协议的规格中进行了定义。注意区分IP头部,以太网头部,TCP头部是不同的头部。
3.3.3 连接操作的实际过程
下面来看一下具体的操作过程。 这个过程是从应用程序调用 Socket 库的 connect 开始的。
connect( < 描述符 >, < 服务器 IP 地址和端口号 >, …)
上面的调用提供了服务器的 IP 地址和端口号, 这些信息会传递给协议栈中的 TCP 模块。 然后, TCP 模块会与该 IP 地址对应的对象, 也就是与服务器的 TCP 模块交换控制信息, 这一交互过程包括下面几个步骤。
首先, 客户端先创建一个包含表示开始数据收发操作的控制信息的头部。 头部包含很多字段, 这里要关注的重点是发送方和接收方的端口号。 到这里, 客户端(发送方)的套接字就准确找到了服务器(接收方)的套接字, 也就是搞清楚了我应该连接哪个套接字。
然后, 我们将头部中的控制位的 SYN 比特设置为 1,大家可以认为它表示连接 。 此外还需要设置适当的序号和窗口大小。
当 TCP 头部创建好之后, 接下来 TCP 模块会将信息传递给 IP 模块并委托它进行发送 。 IP 模块执行网络包发送操作后,网络包就会通过网络到达服务器, 然后服务器上的 IP 模块会将接收到的数据传递给 TCP 模块,服务器的 TCP 模块根据 TCP 头部中的信息找到端口号对应的套接字, 也就是说, 从处于等待连接状态的套接字中找到与 TCP 头部中记录的端口号相同的套接字就可以了。 当找到对应的套接字之后, 套接字中会写入相应的信息, 并将状态改为正在连接 。
上述操作完成后, 服务器的 TCP 模块会返回响应, 这个过程和客户端一样, 需要在 TCP 头部中设置发送方和接收方端口号以及 SYN 比特。 此外,在返回响应时还需要将 ACK 控制位设为1, 这表示已经接收到相应的网络包。 网络中经常会发生错误, 网络包也会发生丢失, 因此双方在通信时必须相互确认网络包是否已经送达 , 而设置ACK 比特就是用来进行这一确认的。 接下来, 服务器 TCP 模块会将 TCP头部传递给 IP 模块, 并委托 IP 模块向客户端返回响应。
然后, 网络包就会返回到客户端, 通过 IP 模块到达 TCP 模块, 并通过 TCP 头部的信息确认连接服务器的操作是否成功。 如果 SYN 为 1 则表示连接成功, 这时会向套接字中写入服务器的 IP 地址、 端口号等信息, 同时还会将状态改为连接完毕。 到这里, 客户端的操作就已经完成,
但其实还剩下最后一个步骤。 刚才服务器返回响应时将 ACK 比特设置为 1, 相应地,客户端也需要将 ACK 比特设置为 1 并发回服务器, 告诉服务器刚才的响应包已经收到。 当这个服务器收到这个返回包之后, 连接操作才算全部完成。现在, 套接字就已经进入随时可以收发数据的状态了, 大家可以认为这时有一根管子把两个套接字连接了起来。
以上实际就是大名鼎鼎的TCP三次握手,下面是图解
3.4 通讯阶段
当套接字连接起来之后, 剩下的事情就简单了。 只要将数据送入套接字, 数据就会被发送到对方的套接字中。 当然, 应用程序无法直接控制套接字, 因此还是要通过 Socket 库委托协议栈来完成这个操作。 这个操作需要使用 write 这个程序组件,需要指定描述符和发送数据, 然后协议栈就会将数据发送到服务器。 由于套接字中已经保存了已连接的通信对象的相关信息, 所以只要通过描述符指定套接字, 就可以识别出通信对象, 并向其发送数据。
协议栈并不关心应用程序传来的数据是什么内容。 应用程序在调用 write 时会指定发送数据的长度, 在协议栈看来, 要发送的数据就是一定长度的二进制字节序列而已。
3.4.1 协议栈何时发送网络包
协议栈并不是一收到数据就马上发送出去, 而是会将数据存放在内部的发送缓冲区中, 并等待应用程序的下一段数据。 应用程序交给协议栈发送的数据长度是由应用程序本身来决定的,有些程序会一次性传递所有的数据,有些程序则会逐字节或者逐行传递数据,协议栈并不能控制这一行为。 如果一收到数据就马上发送出去, 就可能会发送大量的小包, 导致网络效率下降, 因此需要在数据积累到一定量时再发送出去。 至于要积累多少数据才能发送, 不同种类和版本的操作系统会有所不同, 不能一概而论, 但都是根据下面几个要素来判断的。
第一个判断要素是每个网络包能容纳的数据长度, 协议栈会根据一个叫作 MTUA 的参数来进行判断。 MTU 表示一个网络包的最大长度, 在以太网中一般是 1500 字节。 MTU 是包含头部的总长度, 因此需要从MTU 减去头部的长度, 然后得到的长度就是一个网络包中所能容纳的最大数据长度, 这一长度叫作 MSSC。 当从应用程序收到的数据长度超过或者接近 MSS 时再发送出去, 就可以避免发送大量小包的问题了
另一个判断要素是时间。 当应用程序发送数据的频率不高的时候, 如果每次都等到长度接近 MSS 时再发送, 可能会因为等待时间太长而造成发送延迟, 这种情况下, 即便缓冲区中的数据长度没有达到 MSS, 也应该果断发送出去。 为此, 协议栈的内部有一个计时器, 当经过一定时间之后,就会把网络包发送出去。
判断要素就是这两个, 但它们其实是互相矛盾的。 如果长度优先, 那么网络的效率会提高, 但可能会因为等待填满缓冲区而产生延迟; 相反地,如果时间优先, 那么延迟时间会变少, 但又会降低网络的效率。 因此, 在进行发送操作时需要综合考虑这两个要素以达到平衡。
协议栈也给应用程序保留了控制发送时机的余地。 应用程序在发送数据时可以指定一些选项, 比如如果指定“ 不等待填满缓冲区直接发送”, 则协议栈就会按照要求直接发送数据。 像浏览器这种会话型的应用程序在向服务器发送数据时, 等待填满缓冲区导致延迟会产生很大影响, 因此一般会使用直接发送的选项。
3.4.2 对较大数据进行拆分
HTTP 请求消息一般不会很长, 一个网络包就能装得下, 但如果其中要提交表单数据, 长度就可能超过一个网络包所能容纳的数据量,这种情况下, 发送缓冲区中的数据就会超过 MSS 的长度, 这时我们当然不需要继续等待后面的数据了。 发送缓冲区中的数据会被以 MSS 长度为单位进行拆分, 拆分出来的每块数据会被放进单独的网络包中。
3.4.3使用ACK号确认网络包已收到
首先, TCP 模块在拆分数据时,会先算好每一块数据相当于从头开始的第几个字节, 接下来在发送这一块数据时, 将算好的字节数写在 TCP 头部中,“ 序号” 字段就是派在这个用场上的。 然后, 发送数据的长度也需要告知接收方, 不过这个并不是放在TCP 头部里面的, 因为用整个网络包的长度减去头部的长度就可以得到数据的长度, 所以接收方可以用这种方法来进行计算。
在实际的通信中,序号并不是从 1 开始的, 因为如果序号都从 1 开始, 通信过程就会非常容易预测,所以初始值是随机的,这样对方就搞不清楚序号到底是从多少开始计算的, 因此需要在开始收发数据之前将初始值告知通信对象。 实际上, 连接操作中在将 SYN 设为 1 的同时, 还需要同时设置序号字段的值, 而这里的值就代表序号的初始值
首先, 客户端在连接时需要计算出与从客户端到服务器方向通信相关的序号初始值, 并将这个值发送给服务器(①)。 接下来, 服务器会通过这个初始值计算出 ACK 号并返回给客户端(②)。 初始值有可能在通信过程中丢失, 因此当服务器收到初始值后需要返回 ACK 号作为确认。 同时, 服务器也需要计算出与从服务器到客户端方向通信相关的序号初始值, 并将这个值发送给客户端(图 2.9 ②)。 接下来像刚才一样, 客户端也需要根据服务器发来的初始值计算出 ACK 号并返回给服务器( ③)。 到这里, 序号和 ACK 号都已经准备完成了,实际上这也是上面提到的TCP三次握手的内容。
接下来就可以进入数据收发阶段了。 数据收发操作本身是可以双向同时进行的, 但 Web 中是先由客户端向服务器发送请求, 序号也会跟随数据一起发送( ④)。 然后, 服务器收到数据后再返回 ACK 号(⑤)。 从服务器向客户端发送数据的过程则正好相反(⑥⑦)。
通过“序号”和“ACK 号”可以确认接收方是否收到了网络包。TCP 采用这样的方式确认对方是否收到了数据, 在得到对方确认之前, 发送过的包都会保存在发送缓冲区中。 如果对方没有返回某些包对应的 ACK 号, 那么就重新发送这些包。
3.4.4 根据网络包平均往返时间调整 ACK 号等待时间
TCP 采用了动态调整等待时间的方法, 这个等待时间是根据 ACK 号返回所需的时间来判断的。 具体来说, TCP 会在发送数据的过程中持续测量 ACK 号的返回时间, 如果 ACK 号返回变慢, 则相应延长等待时间; 相对地, 如果 ACK 号马上就能返回, 则相应缩短等待时间。
3.4.5 使用窗口有效管理 ACK 号
每发送一个包就等待一个 ACK 号的方式是最简单也最容易理解的, 但在等待 ACK 号的这段时间中, 如果什么都不做那实在太浪费了。 为了减少这样的浪费, TCP 采用滑动窗口方式来管理数据发送和 ACK 号的操作。 所谓滑动窗口, 就是在发送一个包之后, 不等待 ACK 号返回, 而是直接发送后续的一系列包。 这样一来, 等待 ACK 号的这段时间就被有效利用起来了。
这种方式效率高,但是也会有个问题,如果不等返回 ACK 号就连续发送包, 就有可能会出现发送包的频率超过接收方处理能力的情况。可以通过下面的方法来避免这种情况的发生。 首先, 接收方需要告诉发送方自己最多能接收多少数据,然后发送方根据这个值对数据发送操作进行控制, 这就是滑动窗口方式的基本思路。图中, 接收方将数据暂存到接收缓冲区中并执行接收操作。 当接收操作完成后, 接收缓冲区中的空间会被释放出来, 也就可以接收更多的数据了,这时接收方会通过 TCP 头部中的窗口字段将自己能接收的数据量告知发送方。 这样一来, 发送方就不会发送过多的数据, 导致超出接收方的处理能力了。
3.4.6 ACK 与窗口的合并
接收方在发送 ACK 号和窗口更新时, 并不会马上把包发送出去, 而是会等待一段时间, 在这个过程中很有可能会出现其他的通知操作,这样就可以把两种通知合并在一个包里面发送了。当需要连续发送多个 ACK 号时, 也可以减少包的数量, 这是因为 ACK 号表示的是已收到的数据量, 也就是说, 它是告诉发送方目前已接收的数据的最后位置在哪里, 因此当需要连续发送 ACK 号时, 只要发送最后一个 ACK 号就可以了, 中间的可以全部省略。当需要连续发送多个窗口更新时也可以减少包的数量, 因为连续发生窗口更新说明应用程序连续请求了数据, 接收缓冲区的剩余空间连续增加。 这种情况和 ACK 号一样, 可以省略中间过程, 只要发送最终的结果就可以了。
3.4.7 接收HTTP响应消息
当消息返回后, 需要执行的是接收消息的操作。 接收消息的操作是通过 Socket 库中的 read 程序组件委托协议栈来完成的。 首先, 协议栈尝试从接收缓冲区中取出数据并传递给应用程序, 但这个时候请求消息刚刚发送出去, 响应消息可能还没返回。 响应消息的返回还需要等待一段时间, 因此这时接收缓冲区中并没有数据, 那么接收数据的操作也就无法继续。 这时, 协议栈会将应用程序的委托, 也就是从接收缓冲区中取出数据并传递给应用程序的工作暂时挂起, 等服务器返回的响应消息到达之后再继续执行接收操作。
协议栈接收数据的具体操作过程简单总结一下 。 首先,协议栈会检查收到的数据块和 TCP 头部的内容, 判断是否有数据丢失, 如果没有问题则返回 ACK 号。 然后,协议栈将数据块暂存到接收缓冲区中, 并将数据块按顺序连接起来还原出原始的数据, 最后将数据交给应用程序。 具体来说, 协议栈会将接收到的数据复制到应用程序指定的内存地址中, 然后将控制流程交回应用程序。将数据交给应用程序之后, 协议栈还需要找到合适的时机向发送方发送窗口更新。
3.5 断开阶段
收发数据结束的时间点应该是应用程序判断所有数据都已经发送完毕的时候。协议栈在设计上允许任何一方先发起断开过程,客户端和服务端都可以断开连接。无论哪种情况, 完成数据发送的一方会发起断开过程, 这里我们以服务器一方发起断开过程为例来进行讲解。
第一次挥手(FIN=1,seq=u)
假设客户端想要关闭连接,客户端发送一个 FIN 标志位置为1的包,表示自己已经没有数据可以发送了,但是仍然可以接受数据。
发送完毕后,客户端进入 FIN_WAIT_1 状态。
第二次挥手(ACK=1,ACKnum=u+1)
服务器端确认客户端的 FIN包,发送一个确认包,表明自己接受到了客户端关闭连接的请求,但还没有准备好关闭连接。
发送完毕后,服务器端进入 CLOSE_WAIT 状态,客户端接收到这个确认包之后,进入 FIN_WAIT_2 状态,等待服务器端关闭连接。
第三次挥手(FIN=1,seq=w)
服务器端准备好关闭连接时,向客户端发送结束连接请求,FIN置为1。
发送完毕后,服务器端进入 LAST_ACK 状态,等待来自客户端的最后一个ACK。
第四次挥手(ACK=1,ACKnum=w+1)
客户端接收到来自服务器端的关闭请求,发送一个确认包,并进入 TIME_WAIT状态,等待可能出现的要求重传的 ACK包。
服务器端接收到这个确认包之后,关闭连接,进入 CLOSED 状态。
客户端等待了某个固定时间(两个最大段生命周期,2MSL,2 Maximum Segment Lifetime)之后,没有收到服务器端的 ACK,认为服务器端已经正常关闭连接,于是自己也关闭连接,进入 CLOSED状态。
网络上比较主流的文章都说关闭TCP会话是四次挥手,但是实际上为了提高效率通常合并第二、三次的挥手,即三次挥手。
和服务器的通信结束之后, 用来通信的套接字也就不会再使用了, 这时我们就可以删除这个套接字了。 不过, 套接字并不会立即被删除, 而是会等待一段时间之后再被删除,等待这段时间是为了防止误操作, 引发误操作的原因有很多。
比如,最后客户端返回的 ACK 号丢失了, 结果会如何呢? 这时, 服务器没有接收到 ACK 号, 可能会重发一次 FIN。 如果这时客户端的套接字已经删除了, 会发生什么事呢? 套接字被删除, 那么套接字中保存的控制信息也就跟着消失了, 套接字对应的端口号就会被释放出来。 这时, 如果别的应用程序要创建套接字, 新套接字碰巧又被分配了同一个端口号,而服务器重发的 FIN 正好到达, 会怎么样呢? 本来这个 FIN 是要发给刚刚删除的那个套接字的, 但新套接字具有相同的端口号, 于是这个 FIN 就会错误地跑到新套接字里面, 新套接字就开始执行断开操作了。 之所以不马上删除套接字, 就是为了防止这样的误操作。
3.7 小结
最后整体回顾下这一章节讲的TCP收据收发全过程
数据收发操作的第一步是创建套接字。 一般来说, 服务器一方的应用程序在启动时就会创建好套接字并进入等待连接的状态。 客户端则一般是在用户触发特定动作, 需要访问服务器的时候创建套接字。 在这个阶段,还没有开始传输网络包。
创建套接字之后, 客户端会向服务器发起连接操作。 首先, 客户端会生成一个 SYN 为 1 的 TCP 包并发送给服务器(图 2.13 ①)。 这个 TCP 包的头部还包含了客户端向服务器发送数据时使用的初始序号, 以及服务器向客户端发送数据时需要用到的窗口大小 A。 当这个包到达服务器之后, 服务器会返回一个 SYN 为 1 的 TCP 包(图 2.13 ②)。 和图 2.13 ①一样, 这个包的头部中也包含了序号和窗口大小, 此外还包含表示确认已收到包①的ACK 号 B。 当这个包到达客户端时,客户端会向服务器返回一个包含表示确认的 ACK 号的 TCP 包(图 2.13 ③)。 到这里, 连接操作就完成了, 双方进入数据收发阶段。
数据收发阶段的操作根据应用程序的不同而有一些差异, 以 Web 为例, 首先客户端会向服务器发送请求消息。 TCP 会将请求消息切分成一定大小的块, 并在每一块前面加上 TCP 头部, 然后发送给服务器(图 2.13 ④)。TCP 头部中包含序号, 它表示当前发送的是第几个字节的数据。 当服务器收到数据时, 会向客户端返回 ACK 号(图 2.13 ⑤)。 在最初的阶段, 服务器只是不断接收数据, 随着数据收发的进行, 数据不断传递给应用程序,接收缓冲区就会被逐步释放。 这时, 服务器需要将新的窗口大小告知客户端。 当服务器收到客户端的请求消息后, 会向客户端返回响应消息, 这个过程和刚才的过程正好相反(图 2.13 ⑥⑦)。
服务器的响应消息发送完毕之后, 数据收发操作就结束了, 这时就会开始执行断开操作。 以 Web 为例,服务器会先发起断开过程 A。 在这个过程中, 服务器先发送一个 FIN 为 1 的 TCP 包(图 2.13 ⑧), 然后客户端返回一个表示确认收到的 ACK 号(图 2.13 ⑨)。 接下来, 双方还会交换一组方向相反的 FIN 为 1 的 TCP 包(图 2.13 ⑩)和包含 ACK 号的 TCP 包(图 2.13k)。最后, 在等待一段时间后, 套接字会被删除。
5 WEB服务器
5.1 服务器程序的结构
服务器需要同时和多个客户端通信, 但一个程序来处理多个客户端的请求是很难的, 因为服务器必须把握每一个客户端的操作状态。 因此一般的做法是, 每有一个客户端连接进来, 就启动一个新的服务器程序, 确保服务器程序和客户端是一对一的状态。
具体来说, 服务器程序的结构如图 6.1 所示。 首先, 我们将程序分成两个模块, 即等待连接模块(图 6.1( a))和负责与客户端通信的模块(图6.1 ( b))A。 当服务器程序启动并读取配置文件完成初始化操作后,就会运行等待连接模块( a)。 这个模块会创建套接字, 然后进入等待连接的暂停状态。 接下来, 当客户端连发起连接时, 这个模块会恢复运行并接受连接,然后启动客户端通信模块( b), 并移交完成连接的套接字。 接下来, 客户端通信模块( b)就会使用已连接的套接字与客户端进行通信, 通信结束后,这个模块就退出了。
每次有新的客户端发起连接, 都会启动一个新的客户端通信模块( b),因此( b)与客户端是一对一的关系。 这样,( b)在工作时就不必考虑其他客户端的连接情况, 只要关心自己对应的客户端就可以了。 通过这样的方式, 可以降低程序编写的难度。 服务器操作系统具有多任务 A、 多线程 B 功能, 可以同时运行多个程序 C, 服务器程序的设计正是利用了这一功能。
当然, 这种方法在每次客户端发起连接时都需要启动新的程序, 这个过程比较耗时, 响应时间也会相应增加。 因此, 还有一种方法是事先启动几个客户端通信模块, 当客户端发起连接时, 从空闲的模块中挑选一个出来将套接字移交给它来处理。后面会研究nginx服务器的工作方式。
5.2 服务端的具体工作过程
伪代码如下
首先, 协议栈调用 socket 创建套接字(图 6.2( 1)), 这一步和客户端是相同的。
接下来调用 bind 将端口号写入套接字中(图 6.2( 2-1))。 在客户端发起连接的操作中, 需要指定服务器端的端口号。设置好端口号之后, 协议栈会调用 listen 向套接字写入等待连接状态这一控制信息(图 6.2( 2-1))。 这样一来, 套接字就会开始等待来自客户端的连接网络包。
然后, 协议栈会调用 accept 来接受连接(图 6.2( 2-2))。 由于等待连接的模块在服务器程序启动时就已经在运行了, 所以在刚启动时, 应该还没有客户端的连接包到达。 可是, 包都没来就调用 accept 接受连接, 可能大家会感到有点奇怪, 不过没关系, 因为如果包没有到达, 就会转为等待包到达的状态, 并在包到达的时候继续执行接受连接操作。 因此, 在执行accept 的时候, 一般来说服务器端都是处于等待包到达的状态, 这时应用程序会暂停运行。 在这个状态下, 一旦客户端的包到达, 就会返回响应包并开始接受连接操作。 接下来, 协议栈会给等待连接的套接字复制一个副本, 然后将连接对象等控制信息写入新的套接字中(图 6.3)。 刚才我们介绍了调用 accept 时的工作过程, 到这里, 我们就创建了一个新的套接字,并和客户端套接字连接在一起了。
当 accept 结束之后, 等待连接的过程也就结束了, 这时等待连接模块会启动客户端通信模块, 然后将连接好的新套接字转交给客户端通信模块,由这个模块来负责执行与客户端之间的通信操作。 之后的数据收发操作和刚才说的一样, 与客户端的工作过程是相同的。
问题1:在复制出一个新的套接字之后, 原来那个处于等待连接状态的套接字会怎么样呢?
其实它还会以等待连接的状态继续存在, 当再次调用 accept, 客户端连接包到达时, 它又可以再次执行接受连接操作。
问题2:服务端新创建的套接字副本和原来的等待连接的套接字具有相同的端口号,这样不会有问题吗?
端口号是用来识别套接字的, 但是,实际上服务端新创建的套接字副本和原来的等待连接的套接字具有相同的端口号。然而这并不会有问题,要确定某个套接字时, 不仅使用服务器端套接字对应的IP地址和端口号, 还同时使用客户端的端口号再加上 IP 地址, 总共使用下面 4 种信息来进行判断。服务器上可能存在多个端口号相同的套接字, 但客户端的套接字都是对应不同端口号的, 因此我们可以通过客户端的端口号来确定服务器上的某个套接字。
5.3 nginx服务器是如何接收消息的
Nginx 采用的是多进程(单线程) & 多路IO复用模型。使用了 I/O 多路复用技术的 Nginx,就成了”并发事件驱动“的服务器。这里我们探讨下和上面联系比较紧密的多进程工作模式。
我们看下nginx的多进程工作模式
1、Nginx 在启动后,会有一个 master 进程和多个相互独立的 worker 进程。
2、接收来自外界的信号,向各worker进程发送信号,每个进程都有可能来处理这个连接。
3、 master 进程能监控 worker 进程的运行状态,当 worker 进程退出后(异常情况下),会自动启动新的 worker 进程。
使用多进程模式,不仅能提高并发率,而且进程之间相互独立,一个 worker 进程挂了不会影响到其他 worker 进程。
5.3.1 惊群现象
主进程(master 进程)首先通过 socket() 来创建一个 sock 文件描述符用来监听,然后fork生成子进程(workers 进程),子进程将继承父进程的 sockfd(socket 文件描述符),之后子进程 accept() 后将创建已连接描述符(connected descriptor)),然后通过已连接描述符来与客户端通信。
那么,由于所有子进程都继承了父进程的 sockfd,那么当连接进来时,所有子进程都将收到通知并“争着”与它建立连接,这就叫“惊群现象”。大量的进程被激活又挂起,只有一个进程可以accept() 到这个连接,这当然会消耗系统资源。
5.3.2 Nginx对惊群现象的处理
Nginx 提供了一个 accept_mutex 这个东西,这是一个加在accept上的一把共享锁。即每个 worker 进程在执行 accept 之前都需要先获取锁,获取不到就放弃执行 accept()。有了这把锁之后,同一时刻,就只会有一个进程去 accpet(),这样就不会有惊群问题了。accept_mutex 是一个可控选项,我们可以显示地关掉,默认是打开的。
6 语言解析:PHP-FPM
6.1 nginx和php-fpm配合工作
nginx服务器起得作用实际上是内容分发,我们会有很多请求,比如我们请求一个静态图片,只需要找到相应目录下的文件即可,但是如果有个php请求,那么这就超出了nginx的能力范围,需要有人专门处理php请求。
如果你有这方面经验,那么在nginx的虚拟主机配置中有这么一段
21 location ~ .php$ {
22 fastcgi_pass 127.0.0.1:9000;
23 fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
24 include fastcgi_params;
25 }
这段配置的意思就是,如果是php请求,那么nginx就将请求发送给本机的9000端口处理,关于fastcgi_params的两句是定义nginx变量和fastcgi变量的关系,在/etc/nginx/目录下会有个 fastcgi_params文件,可以打开看下,比较简单。
这里的主角其实就是监听9000端口的php-fpm,这个端口是可以通过配置文件自己定义的,一般默认使用9000,service php-fpm start 运行后,查看9000端口情况
$ netstat -anlp | grep 9000
tcp 0 0 127.0.0.1:9000 0.0.0.0:* LISTEN 750/php-fpm: master
6.2 cgi,fastcgi,php-cgi,php-fpm的关系
讲到这里,我们要了解下这段历史,区分几个概念
1> CGI
CGI全称是“公共网关接口”(Common Gateway Interface),HTTP服务器与你的或其它机器上的程序进行“交谈”的一种工具,其程序须运行在网络服务器上。
CGI可以用任何一种语言编写,只要这种语言具有标准输入、输出和环境变量。如php,perl,tcl等。
服务器接收到请求后,如果是index.html这样的静态文件,可以直接去相应的目录找到这个文件,然后返回给客户端,但是当发送的请求是index.php这样请求,显然这个是需要解析的,此时就需要服务器将这个请求传递给cgi程序解析,解析完成后返回结果。但是要传递什么内容呢,这个就是cgi来规定的。
2> Fastcgi
Fastcgi是用来提高CGI程序性能的,是CGI的升级版,一种语言无关的协议
服务器每次将请求传递给cig程序解析的时候都会解析配置文件,比如php.ini,想想就知道这会影响性能,所以单独使用上面提到的CGI程序会很慢。fastcgi会先启动一个master解析配置文件,初始化环境,然后再启动多个worker,当请求过来的时候master会传递给worker,然后立即去接受下一个请求。当worker不够用的时候会增加,当空闲的worker多的时候会停掉一些,动态增减worker的机制可以提高性能,节省资源。
3> php-cgi
PHP-CGI是php自带的Fast-CGI管理器.
php.ini修改之后,必须kill掉php-cgi再启动php.ini 才生效。不可以平滑的重启
内存不能动态分配
启动php,指定启动的worker ,长期驻留在内存里 ,用户访问php文件, php-cgi 处理请求,返回结果
4> Php-fmp
PHP-FPM 是 PHP 针对 FastCGI 协议的具体实现,非官方fastCgi进程管理器,后来php5.4开始,被官方收录了
可以平滑重启php
动态调度进程
启动php,动态指定启动的worker ,长期驻留在内存里 ,根据来访压力动态增减worker的进程数量,用户访问php文件, php-fpm 处理请求,返回结果
php-cgi和php-fpm的关系呢
php54是之前是一种关系,php54之后另一种关系。php54之前,php-fpm(第三方编译)是管理器,php-cgi是解释器。php54之后,php-fpm(官方自带),master 与 pool 模式。php-fpm 和 php-cgi 没有关系了。php-fpm又是解释器,又是管理器。网上大部分说法:php-fpm 是管理php-cgi 的,是针对php54之前的
6.3 php-fpm 的配置
配置文件位于/etc/php-fpm.conf (或者是该文件include /etc/php-fpm.d/文件夹下的配置)
pm = dynamic #指定进程管理方式,有3种可供选择:static、dynamic和ondemand。
pm.max_children = 16 #static模式下创建的子进程数或dynamic模式下同一时刻允许最大的php-fpm子进程数量。
pm.start_servers = 10 #动态方式下的起始php-fpm进程数量。
pm.min_spare_servers = 8 #动态方式下服务器空闲时最小php-fpm进程数量。
pm.max_spare_servers = 16 #动态方式下服务器空闲时最大php-fpm进程数量。
pm.max_requests = 2000 #php-fpm子进程能处理的最大请求数。
pm.process_idle_timeout = 10s
request_terminate_timeout = 120
pm三种进程管理模式说明如下:
-
pm = static,始终保持一个固定数量的子进程,这个数由pm.max_children定义,这种方式很不灵活,也通常不是默认的。
-
pm = dynamic,启动时会产生固定数量的子进程(由pm.start_servers控制)可以理解成最小子进程数,而最大子进程数则由pm.max_children去控制,子进程数会在最大和最小数范围中变化。闲置的子进程数还可以由另2个配置控制,分别是pm.min_spare_servers和pm.max_spare_servers。如果闲置的子进程超出了pm.max_spare_servers,则会被杀掉。小于pm.min_spare_servers则会启动进程(注意,pm.max_spare_servers应小于pm.max_children)。
-
pm = ondemand,这种模式和pm = dynamic相反,把内存放在第一位,每个闲置进程在持续闲置了pm.process_idle_timeout秒后就会被杀掉,如果服务器长时间没有请求,就只会有一个php-fpm主进程。弊端是遇到高峰期或者如果pm.process_idle_timeout的值太短的话,容易出现504 Gateway Time-out错误,因此pm = dynamic和pm = ondemand谁更适合视实际情况而定。
static管理模式适合比较大内存的服务器,而dynamic则适合小内存的服务器,你可以设置一个pm.min_spare_servers和pm.max_spare_servers合理范围,这样进程数会不断变动。ondemand模式则更加适合微小内存,例如512MB或者256MB内存,以及对可用性要求不高的环境。
我安装的php-fpm默认配置文件,
pm = dynamic
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 35
;pm.max_requests = 500
注意pm.max_requests
这句其实是被注释掉的,默认值是0,也就是不限制子进程请求次数。
以下是我启动php-fpm后进程情况,启动了一个master进程,和5个依托于该master进程的worker进程。
$ ps -ef | grep php-fpm
root 28212 1 0 09:12 ? 00:00:00 php-fpm: master process (/etc/php-fpm.conf)
apache 28214 28212 0 09:12 ? 00:00:00 php-fpm: pool www
apache 28215 28212 0 09:12 ? 00:00:00 php-fpm: pool www
apache 28216 28212 0 09:12 ? 00:00:00 php-fpm: pool www
apache 28217 28212 0 09:12 ? 00:00:00 php-fpm: pool www
apache 28218 28212 0 09:12 ? 00:00:00 php-fpm: pool www
root 28226 28103 0 09:12 pts/1 00:00:00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn php-fpm
内存占用过高问题
PHP 进程本身并不存在内存泄露的问题,每个进程完成请求处理后会回收内存,但是并不会释放给操作系统,这就导致大量内存被 PHP-FPM 占用而无法释放,请求量升高时性能骤降。
- 1.考虑设置合适的进程数
按照php-fpm进程数=内存/2/30来计算,1GB内存适合的php-fpm进程数为10-20之间,具体还得根据你的PHP加载的附加组件有关系。
- 2.考虑每个进程占用内存大小
PHP-FPM 需要控制单个子进程请求次数的阈值。很多人会误以为 max_requests 控制了进程的并发连接数,实际上 PHP-FPM 模式下的进程是单一线程的,请求无法并发。这个参数的真正意义是提供请求计数器的功能,超过阈值数目后自动回收,缓解内存压力
减少pm.max_requests数,当一个 PHP-CGI 进程处理的请求数累积到 max_requests 个后,自动重启该进程,这样达到了释放内存的目的了。