在经典的计算机网络 ISO 七层模型中,最接近用户的,是应用层,其次是传输层。应用层中,HTTP 是最重要的协议之一,而 TCP,则是传输层中最重要的协议之一,这两类协议可以说是现代互联网的基石。无论是组网,编码,网络故障定位,面试,这两类协议都是相当重要的,网上已经有很多相关的分析文章,这里也整理下我关于这两个协议的相关理解。

TCP

TCP 在传输层中实现,它是一个面向连接的协议,面向连接是相对于 UDP 这种无连接而言,具体说来,就是客户端和服务端传数据之前,会经过一个称为三次握手的过程。由于 TCP 是传输层协议,因此三次握手也是发生在传输层,传输层利用网络层提供的功能,向上层提供可靠的服务,注意这个可靠二字。TCP 通过一系列的措施,如确认号,重传等机制,处理传输过程中丢失、超时、数据错误的问题,保证了可靠二字。

三次握手

先来看看建立 TCP 时发生的三次握手示意图,这里需要说明下,网上已经有很多类似的关于 TCP 三次握手的图了,基本样式都是画两条竖线,竖线两边标识 TCP 状态,竖线中间画几个箭头,但很少指明竖线代表什么,而且把状态标识在了箭头的起始点和终点,个人认为这种方式不严谨,因此本文没有照搬网上的那些图,而是自己画了两张图。重点是为了说明: 两条竖线代表着客户端和服务器的时间轴,从上往下看,以时间为参照;每一个状态对应着一个时间段,而不是对应报文的发送和接收时刻,把状态标识在箭头的起始位置和终点位置,是不严谨的,因此我以不同的颜色标识时间段,并把状态标识在时间段中间位置 三次握手

第一次握手:建立 TCP 连接的发起者是客户端,客户端 TCP 先将 SYN 同步序列号置 1,说明这是一个连接请求,并随机选择一个初始序号(client_isn)并将其放置在起始的TCP SYN报文段的序号字段中(seq),然后客户端进入到 SYN-SENT 状态。

第二次握手:服务器收到了 TCP SYN 报文段,会为该 TCP 分配 TCP 缓存和变量,首先,服务端也将自己的 SYN 置为 1,然后,该 TCP 的首部确认字段(ACK)被置为 client_isn+1,也就是告诉客户端:“我收到了,你能收到我的吗?”。最后,服务器也选择自己的初始序号(server_isn),并将其放在 TCP 报文段首部的序号字段中,一并发给客户端,这时服务器进入到 SYN_RCVD 状态。

第三次握手:客户端在收到服务器的 SYN ACK 报文段后,客户端也给该连接分配缓存和变量。客户端首先会将 server_isn+1,放置到 TCP 报文段首部来确认服务器的允许连接(ack=server+1)。因为连接实际已经建立了,因此 SYN 被置 0。同时也置 ACK=1,这个报文发出去后,客户端进入到 ESTABLISHED 状态。服务端收到报文,也进入到 ESTABLISHED 状态。

三次握手完成后,客户端和服务器就已经建立了一条可靠的 TCP 连接信道,双方可以正常收发数据了。

四次挥手

说完了建立连接时的三次握手,再来看下断开连接时的四次挥手,先看下图,需要注意的是,不同于上面的客户端和服务器,TCP 的建立时,都是由客户端主动发起连接请求的,而 TCP 的断开,可以是任意一方发起,也就是说服务端也可以主动要求关闭,即只有主动关闭和被动关闭,网上很多博客文章里,草草的把挥手时的发起方写为客户端,会让人以为只有客户端才能关闭连接,有很大的误导性。为了消除这种误导,下面在说明断开连接时,只写主动关闭方和被动关闭方。 先看下四次挥手示意图,从上往下看,以时间轴为参照。 四次挥手

第一次挥手:当要断开 TCP 连接时,主动关闭方会发 FIN=1,seq=u(相当于前面已经传过去的最后一个字节的序号+1)的报文,表示自己已经没有数据需要发送,想关闭 TCP 连接了,然后自己进入 FIN-WAIT-1 状态,等待对方确认。

第二次挥手:被动关闭方收到报文后,马上发出确认 ACK=1,这个报文段自己的序号 seq=v(相当于服务端前面已经传送过的最后一个字节的序号+1),但发出去 ACK 后,它还不能马上就关闭连接,因为虽然对方没有数据要发了,但可能自己还有数据要发。因此当发完 ACK 后,被动关闭方会进入 CLOSE-WAIT 状态,表示“我知道你要关闭连接了,稍等下,我还有点数据要发”。 主动关闭方收到来自对方的 ACK 后,就进入了 FIN-WAIT-2 状态。

第三次挥手:若被动关闭方已经没数据要发了,那么它就要发 FIN=1 的连接释放报文,假定当前最后一次确认发送的序号为w(seq=w),并且需要重复发送上次确认过的确认号 ack=u+1。这时它就进入 LAST-ACK 状态,等待对方最后确认。

第四次挥手:主动关闭方收到对方的连接释放 FIN 后,在确认号中把 ACK 置为1,确认号是 ack=w+1,而自己的序号仍然是 seq=u+1。发出去后,它就进入 TIME-WAIT 状态。为了保证对方收到这个报文,它会等待一段时间,被称为 2MSL,一般是 30 秒,之后进入 CLOSED 状态,并且释放 TCP 所有资源,而被动关闭方收到 ACK 报文后,也进入到 CLOSED 状态。至此,TCP 连接完全断开。

在网络编程时,主动关闭方调用 close 函数,发送 FIN,进入到 FIN-WAIT-1,被动关闭方收到后,read 函数返回 0, 知道对方要关闭,发送 ACK,进入 CLOSE-WAIT。然后被动关闭方也调用 close 函数,发送 FIN 给对方,进入 LAST-ACK。可以看到,如果被动关闭方如果没调 close 或者忘了调用,那么 TCP 连接就不会关闭,CLOSE-WAIT 状态会一直持续下去。

对于刚学网络编程的同学来说,都知道 TCP 会有三次握手四次挥手,但实际编程时,似乎代码中也没有处理 SYN ACK 握手之类的,仅仅调用 connect,close 等函数,这是因为握手挥手这些动作都是由操作系统内核协议栈完成的,我们不用关心,当我们程序中调用 connect 时,就是告诉操作系统,我想建连接,那么操作系统内核就会帮我们完成三次握手的操作,如果成功,则 connect 返回,因此我们不需要关心握手的过程,close 也类似,同理,像那些给数据包加 TCP 头,IP 头的动作,也不是我们需要关心的,这个操作系统内核会帮我们处理好。

几个问题

1. 为什么要 4 次挥手呢?

被动关闭方收到 FIN 请求后,并不会马上关闭连接,因为它可能还有数据要发送,所以不能和建立连接时那样,直接发送 ACK 和 SYN,只能先回复一个 ACK,表示你的关闭请求(FIN)已经收到了,让我把剩下的东西传完。传完后,才能发送 FIN 报文,从而关闭 TCP 连接。

2. 为什么 TIME-WAIT 状态需要等 2MSL 后才能到 CLOSED 状态?

这是因为虽然双方都同意关闭连接了,而且握手的 4 个报文也都协调和发送完毕,按理可以直接回到 CLOSED 状态(就好比从 SYN_SEND 状态到 ESTABLISH 状态那样);但是因为我们必须要假想网络是不可靠的,主动关闭方不能保证最后发送的 ACK 报文会一定被对方收到,假如这个 ACK 真的丢了,那么被动关闭方会因为没收到 ACK,而重发 FIN 报文,假如主动关闭方没有 TIME-WAIT,而是发完 ACK 就直接关闭,那这时对方重发过来的 FIN 就无法再收到了,因为连接都关了。所以这个 TIME-WAIT 状态的作用就是保证最后的这个 ACK 能被对方收到,万一这个 ACK 丢失了,因为连接还没断,所以还可以用来重发这个丢失的 ACK 报文。

另一方面,假如主动关闭方立刻进入 CLOSED,马上应用程序又用这个端口建立了新的连接,也建立成功了,但现在网络中,可以有上一个连接的数据包,这样就会让接收方迷惑:这个数据包到底是上一个 TCP 连接的呢?还是这个 TCP 连接的呢?为了避免这种情况发生,因此先等待 2MSL,让网络中属于这个连接的包都消失(2MSL 是一个包在网络中生存的最大时间),这样再用这个端口建连接时,就不会有上面的问题。

Linux 下查看 TCP 状态

在 Linux 环境,我们经常要看网络相关状态,可以通过netstat命令。比如查看 nginx 服务的网络状态:

# -p 表示打印进程
netstat -anp | grep nginx

执行结果中,有一列展示了网络连接的状态,如 LISTEN,ESTABLISHED 等。

统计当前系统各个 TCP 状态的总数

# 以 tcp 开头,awk 默认以空格分割,$NF 表示最后一个字段,即连接状态,存入到 S 中,S 类似于哈希表,键为 TCP 状态,值为数量
netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'

某机器执行结果如下:

CLOSE_WAIT 22
ESTABLISHED 208
FIN_WAIT2 1
SYN_SENT 1
TIME_WAIT 8

如果你看到你的机器 CLOSE_WAIT 状态很多,那么基本可以肯定是代码代码出了问题,要么是忘了 close 相应的 socket,要么是出现死循环,导致 close 一直没有被调用。

小结

上面介绍了 TCP 的建立和关闭握手过程,以及发送的报文和对应的状态,重点需要关注的是它们的状态流转过程。 可能有同学跟我一样,觉得这些报文和状态流转太难记了。我们始终抓住一点,TCP 保证可靠性的重要机制是确认,什么是确认?就是说你发了一个报文(SYN,FIN),我必须对这个报文进行确认(ACK)。只有双方都确认了,才能后续工作。建立连接时,客户端发了 SYN 报文,因此服务端就发 ACK 确认,同时也发 SYN,客户端收到后,发 ACK 确认,双方都 ACK 了。断开连接时,主动关闭方发了 FIN,被动关闭方发 ACK 先确认,然后再发 FIN,主动关闭方发 ACK 确认,双方也都 ACK 了。

还有一点,TCP 连接是一个逻辑上的概念,通信双方通过状态机来标识连接的建立,如果双方的状态都是 ESTABLISHED,那么就认为连接已建立,可以收发数据。如果双方都不收发数据,那么系统会一直维持这个连接,及时你把网线拔了,双方也是感知不到的。只有当某一方发数据,却没有收到对方的 ACK,重试了几次后(重试次数可以通过内核参数修改),仍然没收到 ACK,那么这时发数据的一方就感知到,这个 TCP 连接可能断了,于是状态变更为 CLOSED。但如果在重试过程中,又把网线插好,对方收到数据后,回复 ACK,那么这个连接仍然是有效的。

HTTP

上面讲完了 TCP 的连接和断开握手挥手过程,再来看下 HTTP。我们知道,HTTP 是一个应用层协议,它基于 TCP,客户端每发一次向服务器发 HTTP 请求之前,都需要完成上述的 TCP 三次握手,断开连接,也需要经过 四次挥手。我们用浏览器访问各种网页,发的每个请求,都是 HTTP。如果你使用的是 google Chrome 浏览器,按 F12 打开开发者模式窗口,然后再打开网页,就可以看到浏览器发了哪些请求了。但为啥我们看不到 TCP 呢?那是因为浏览器是用户软件,运行在应用层,使用的是应用层协议 HTTP,是看不到传输层 TCP 的,按照 ISO 标准来说,下层协议向上层提供服务,上层不用感知下层。因此,HTTP 不用关注传输数据的正确性和顺序等,因为传输层的 TCP 已经保证了,HTTP 也看不到三次握手的过程。

我们来看下一次完成的 HTTP 请求和响应过程,这里推荐大家下载一个 HTTP 神器:httpie,一个完全可以替代 Linux 下 curl 的工具,可以大大简化我们发 HTTP 请求的繁琐方式,而且更加友好美观。本文不打算详细介绍它的使用,具体的使用方法直接在官网查看即可,我们先来试用下,如向本文章的 url 发一个请求,并打印 HTTP 请求和响应头

http https://xujimmy.com/2017/09/10/tcp-http.html -v

输出如下

GET /2017/09/10/tcp-http.html HTTP/1.1
Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: xujimmy.com
User-Agent: HTTPie/0.9.4



HTTP/1.1 200 OK
Accept-Ranges: bytes
Access-Control-Allow-Origin: *
Age: 0
Cache-Control: max-age=600
Connection: keep-alive
Content-Encoding: gzip
Content-Length: 10207
Content-Type: text/html; charset=utf-8
Date: Mon, 17 Jun 2017 11:11:57 GMT
ETag: W/"5d063c9c-701a"
Expires: Mon, 17 Jun 2017 11:21:57 GMT
Last-Modified: Sun, 16 Jun 2017 12:57:00 GMT
Server: GitHub.com
Vary: Accept-Encoding
Via: 1.1 varnish
X-Cache: MISS
X-Cache-Hits: 0
X-Fastly-Request-ID: 1808fe2ed24a2bed619246226e75770b090d255f
X-GitHub-Request-Id: C03E:0521:11A7ECB:12EDDF4:5D07757D
X-Served-By: cache-hnd18747-HND
X-Timer: S1560769917.195156,VS0,VE103

<!DOCTYPE html>
<html>
<head>
...

由上面可以看到,一次完整的 HTTP 过程,包括两个部分:请求响应请求和响应

请求

HTTP 请求由三部分构成:请求行,请求头,请求正文

请求行

请求行用来说明请求类型,要访问的资源,以及使用的 HTTP 版本。基本格式如下:Method URI Http-version 如上面的请求行为

GET /2017/09/10/tcp-http.html HTTP/1.1

请求头

紧挨着请求行(即第一行)之后的部分,用来说明服务器需要使用的附加信息,例如Host,User-Agent,Accept,Cookie等信息,每行都是以一个k: v的形式组成。服务器可以通过这些信息,来判断客户端的来源以及做些身份鉴别等。如上面的请求头为

Accept: */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Host: xujimmy.com
User-Agent: HTTPie/0.9.4

上面的请求头中,有个叫Connection: keep-alive的东西,这个很有意思,它是告诉服务器:请维护这个连接不要关闭,我后面还要用。注意这句话,这里并不是说维护这个 HTTP 连接,而是维护这个 HTTP 下面的 TCP 连接,这种连接也称为长连接,这样带来的好处是可以将一个 TCP 连接信道复用,发多个 HTTP 请求。在 http1.1 中,长连接这个选项是默认开启的。关于长连接,可以看看本文底部的几个参考链接。

请求正文

在请求行和请求头完了后,空两行,就是请求正文了,包括要传递给服务器的各种参数等,上面的请求没有请求正文。

响应

HTTP 响应也由三部分构成:状态行,响应头,响应体

状态行

状态行由 HTTP 协议版本号, 状态码, 状态消息三部分组成,如上面的状态行为

HTTP/1.1 200 OK

响应头

用来说明客户端需要的一些附加信息,如 Content-Length,Date,Server 等,格式也是以k: v形式返回,如上面的响应头为

Accept-Ranges: bytes
Access-Control-Allow-Origin: *
Age: 0
... //中间的省略
X-Timer: S1560769917.195156,VS0,VE103

响应体

这部分为服务器返回的响应正文,如上面的响应正文为

<!DOCTYPE html>
<html>
...  //省略

综上,可以看到,请求和响应的基本格式,除了起始行有所不同外,其余的头部,正文两部分格式是相同的。

HTTP 状态码

来看下响应状态行中的状态码,状态码有三位数字组成,第一个数字定义了响应的类别,共分五种类别:

1xx:指示信息–表示请求已接收,继续处理

2xx:成功–表示请求已被成功接收、理解、接受

3xx:重定向–要完成请求必须进行更进一步的操作

4xx:客户端错误–请求有语法错误或请求无法实现

5xx:服务器端错误–服务器未能实现合法的请求

常见状态码:

| | | | | —- | — | | — | 200 | OK | 客户端请求成功 | 301 | Moved Permanently | 永久跳转,如将 80 端口的 http 请求永久跳转到 443 端口的 https | 302 | Moved Temporarily | 临时跳转 | 400 | Bad Request | 客户端请求有语法错误,不能被服务器所理解 | 401 | Unauthorized | 请求未经授权,这个状态代码必须和WWW-Authenticate报头域一起使用 | 403 | Forbidden | 服务器收到请求,但是拒绝提供服务 | 404 | Not Found | 请求资源不存在,eg:输入了错误的URL | 500 | Internal Server Error | 服务器发生不可预期的错误 | 502 | Bad Gateway | 作为网关的服务器,如nginx,从后端服务收到一个无效响应 | 503 | Server Unavailable | 服务不可用,服务器当前不能处理客户端的请求,一段时间后可能恢复正常 | 504 | Gateway time-out | 作为网关服务器,如nginx,请求后端服务超时

参考