接下来, 我们将逐步对这个问题进行剖析.
对于服务器端来说, 我们首先创建一个socket, 接下来调用bind绑定服务端的IP地址和端口. 绑定端口是为了内核收到数据之后知道这个数据应该交给哪个进程, 绑定IP地址主要是由于机器上可能有多个网卡,需要选择监听哪个网卡. bind之后, 就要调用listen, 将服务端之前创建的socket转为被动套接字(也就是监听套接字). 之后调用accept, 阻塞在这里等待客户端的链接请求.
对于客户端来说, 首先同样是创建socket, 接下来调用connect向服务端发起连接请求, 这一步其实就是客户端主动发起三次挥手的过程, 三次握手完成, 客户端和服务端就建立了连接, 可以进行收发数据了.
当客户端与服务端的通信完成之后, 其中一方调用close(这里我们默认认为是客户端), 客户端调用close,向服务端发送一个FIN包, 告诉服务器, 我不再发送数据了, 这个时候服务端读到FIN, 知道对方要关闭连接了, read返回0. 一段时间之后, 如果服务端也不再发送数据了, 同样调用close, 发送FIN包告诉客户端, 我也不再给你发送数据了.
我们要知道的是, Linux下一切皆文件, 创建socket返回的是一个文件描述符, 既然是一个文件描述符, 在task_struct结构体中已打开文件的描述符数组中占用一个文件描述符, 这个数组的内容其实就是一个指针, 指向内核当中打开的文件列表, 在这个文件列表中找到对应inode.
网络文件的inode指向的并不是真正的磁盘文件, 它指向的是struct sock结构体, 在这个结构体当中有一个发送队列和一个接收队列, 这是两个独立的队列. 因此TCP可以同时进行数据的接受和发送, 进而实现全双工通信.
前面已经提到过, connect就是主动发起三次握手, 客户端向服务端发送SYN, 表示自己要与服务端建立连接, 自己进入SYN_SENT状态, 服务端收到连接请求, 回复SYN+ACK, 进入SYN_REVD状态, 客户端收到相应, 回复ACK, 此时就已经完成了客户端到服务端的单向连接, 客户端进入ESTABLISHED状态, 服务端收到客户端的ACK响应, 服务端进入ESTABLISHED状态, 三次握手完成, 连接建立成功.
在三次握手过程当中, connect的调用返回问题
connect调用成功或失败都会返回
关于SYN包的序号问题
我们假设这样一种场景, 三次握手完成, 成功建立连接, 客户端连续向服务端发送序号为1, 2, 3的三个数据包, 但3号数据报可能由于网络原因丢包, (TCP具有超时重传机制), 这时客户端重新发送3号数据包, 此时由于某种原因, 客户端连接也断开了, 重新连接之后, 这个时候客户端不想给服务端发送3号数据了, 只想发送1号和2号数据, 但是之前重传的3号数据这个时候到达了服务端, 这个时候客户端和服务端的认知不一样, 就出现错误了.
因此, 考虑到这种情况, 每个连接都要有不同的起始序号, 这个起始序号是随着时间的变化而变化的, 每4ms加 1, 在TCP的头部序号是32位的, 这也就是说, 数据的起始序号想要重复就要花费4个多小时, 这个时候先前的数据包早就已经过了TTL(网络层IP协议头部信息, 限制IP数据包在网络中的存在时间)时间了, 这也就解决了上述问题.
关于connect, listen, accept内部所做的工作
在linux内核当中, 调用listen会在内核当中创建两个队列, 一个用于插入未完成三次握手的请求数据包(SYN队列), 一个用于插入已完成三次握手的连接请求(ACCEPT队列).
accept其实就是阻塞等待客户端的连接请求, 从已经完成三次握手的队列当中取出一个连接, 然后创建一个新的socket, 将目标IP端口信息填入, 用来与客户端进行通信.
connect会在自己内部创建一个connect队列, 将当前要发送SYN连接请求的信息放入该队列, 发送第一次SYN请求到服务器(第一次握手), 服务器收到这个SYN请求, 将这个请求放入SYN队列中, 同时给服务器回复ACK和自己的SYN, 客户端收到这个ACK, 就将连接请求信息从自己的connect队列当中取出, 表示从客户端到服务端的单向连接已经建立成功. 接下来客户端回复ACK,服务端收到响应, 将连接从SYN队列中删除, 形成新的连接放入accept队列当中. 此时accept函数阻塞在pthread_cond_wait, 由于ACCEPT队列当中插入了新的结点而被唤醒, 取走队首结点, accept调用返回.
两次不安全, 四次没必要!
这里我们还可以通过一个例子来理解三次握手
假如小A和小B打电话
A: 小B你在吗?
B: 我在呢. 小A你在吗?
A: 我在呢?
这其实就是一个形象的三次握手.
再来看两次握手的情况
A: 小B你在吗?
B: 我在呢. …(小B要告诉小A的事情)
注意这个时候小B没有去缺认小A在不在, 我们假设小A拨通电话问小B在不在, 说完就放下电话走了, 小B并不知道, 直接开始讲自己的事情(类似于发送消息), 小B说了一大堆, 小A完全没有收到
到这里应该可以大致理解为什么三次握手要保证双方都具有数据收发的能力了吧!
一般情况下, 服务器尝试重新发送的次数默认是5次, 分别间隔1, 2, 4, 8, 16, 32秒的等待后重试, 总共耗时63秒. 当然这个时间是可以配置的.
这里我们把客户端当作主动关闭方, 服务端当作被动关闭方.
客户端作为主动关闭方, 向服务端发送FIN包, 表示自己要关闭连接, 之后进入FIN_WAIT_1状态.
服务端通过这个FIN包感知到客户端想要关闭连接, 给客户端回复ACK, 自己进入CLOSE_WAIT状态.
客户端收到服务端响应的ACK, 自己进入FIN_WAIT_2状态
注意客户端调用close发起关闭连接请求之后, 只是意味着它不再发送数据了, 但是还可以接收数据, 因此两次挥手之后, 服务端可能会继续给客户端发送数据
当服务端不再发送数据了, 同样调用close, 给客户端发送一个FIN包, 自己进入LAST_ACK状态.
客户端收到这个FIN包, 给服务端回复ACK, 自己则进入TIME_WAIT状态.
服务端收到客户端的ACK响应, 进入CLOSED状态.
客户端在2倍的MSL(报文最长存活时间)时间之后, 也进入CLOSED状态.
四次挥手完成, 连接断开.
在四次挥手的过程当中, 主动关闭方最后会有一个TIME_WAIT状态, 在收到被动关闭方的FIN包之后, 为什么没有直接进入closed关闭连接释放资源? 这个TIME_WAIT状态有什么用呢?
我们先设想这样一种场景, 最后一次挥手的ACK由于网络原因丢包, 服务端没有收到这个ACK, 服务端长时间没有收到ACK响应就会重传FIN包, 如果没有TIME_WAIT状态, 此时的客户端就已经是CLOSED状态直接退出了, 也已经释放掉了资源, 这个时候可能会有新的客户端启动, 收到之前服务端重传的FIN包, 这个时候新连接就很纳闷了! 上来就直接给我关了! 因此, 若没有TIME_WAIT这个状态, 有可能会导致正常的新连接被重复发送的旧的FIN包误关闭. 因此, 主动关闭方在收到被动关闭方的FIN包之后最好能够等上一段时间, 这就又牵扯到了, 等待多久比较合适呢? 2倍的MSL(最大报文生存周期)时间
在LInux下, MSL的固定时间为30s, 也就是说TIME_WAIT的时间为60s. 那为什么是2倍的MSL呢?
等待2倍的MSL其实也就是说允许最后一次挥手的ACK丢失一次, 第一个ACK丢失, 被动关闭方重发的FIN包就会在第二个MSL时间内到达, TIME_WAIT就可以应付上述的误关闭新连接的情况.
为什么不是4MSL或者更多呢? 这里可以简单了解一下, 因为就算网络状况很差, 丢包率也为1%, 连续丢两次的概率也就是万分之一, 这样的概率太小了. 与此同时, 2MSL可以保证双向上的历史数据都在网络中消散.
TCP不允许处于连接板打开的状态就单向进行数据传递, 因此在三次握手建立连接时, 服务端会将SYN和ACK放在一起发送给客户端, 其中ACK的相应表明客户端到服务端的连接已经连通, 而SYN是为了打开服务端到客户端的通道而发送的, 因此原本的四次握手只需要三次就可以完成.
对应于三次握手, 当连接处于半关闭状态的时候, TCP是允许单向发送数据的, 当主动关闭方调用close并且受到被动关闭方的ACK关闭连接之后, 被动关闭方仍然可以长时间的发送数据, 这个时候的连接就处于半关闭的状态. 这其实是TCP的双向通道相互独立而导致的, 这也就导致了关闭连接的时候要进行四次挥手, 分别发送FIN包要求对端进行响应.
TIME_WAIT是主动关闭方最后响应ACK(第四次挥手)后的状态, 这也就证明了这台主机大量的主动关闭了连接, 常见于一些爬虫服务器, 可以调整TIME_WAIT的等待时间, 也可以使用开启地址重用的套接字选项(setsockopt).
地址重用就是允许套接字绑定使用中的地址端口, 常常用来防止socket处于TIME_WAIT而无法使用相同的地址信息进行绑定新的套接字