深入理解计算机网络(2)

162 阅读15分钟

TCP协议

TCP是面向连接的,可靠的,基于字节流的传输层通信协议。

TCP与UDP的区别

  • TCP是面向连接的,而UDP是无连接的。所以TCP只能一对一通信,而UDP可以一对多。
  • TCP是面向字节流的,而UIDP是面向报文的。
    • UDP每次发送的数据都是完整的报文,OS不会对其进行拆分,接收方可以清晰消息边界。
    • TCP是面向字节流的,TCP每次发送的数据不是完整的报文,若没有自定义的消息边界,发送方无法知道哪里结束,即粘包问题。
  • TCP有序列号(实现按序接收),确认号(实现丢包重传),拥塞控制,流量控制机制保证数据一定能到达,而UDP都没有,所以TCP可靠,UDP不可靠。
  • TCP有自己的分片机制即MSS,MSS设置小于MTU,主要是为了避免在IP层分片,这样的话丢失一个TCP分片只要重传一个MSS即可,否则要重传整个TCP包(在IP层的分片)。而UDP只有数据大于MTU时才在IP层进行分片。
  • TCP适合于要求可靠传输的应用,例如文件传输ftp,超文本传输http。UDP适用于实时传输的应用,例如直播,DNS

TCP报文头

image.png

  • 包括源端口号,目的端口号,序号,确认号,首部长度,状态位(SYN,ACK,RST,FIN),窗口大小,检验和。其中状态位RST表示连接异常,需要断开连接
  • 为什么没有包长度字段:因为可以根据IP长度-IP首部长度-TCP首部长度计算出来
  • TCP报文的最大长度:理论上受到MSS的限制,但是MSS是为了防止IP层分片,故一般设置为小于MTU-IP首部-TCP首部=1500-20-20(TCP首部和IP首部最少都是20字节),实际可能还受接收方接收窗口的限制。

四元组唯一确定一个TCP连接

  • 四元组即源地址+源端口+目标地址+目标端口
  • 固定IP的服务端监听了一个端口,它的TCP最大连接数是多少?
    • 理论上最大TCP连接数=客户端的IP数 * 客户端的端口数=2的32次方 * 2的16次方 = 2的48次方。
    • 实际上服务端的最大并发TCP连接数受文件描述符限制和内存限制。

UDP包头

image.png

  • UDP报文的最大长度:包长度字段为16位,故最大长度为65535-8字节(首部字段为8字节),如果考虑IP层的MTU限制,则是1500-20-8。

三次握手

三次握手流程

  1. 初始,客户端处于close状态,服务端处于listen状态监听某个端口
  2. 客户端发送SYN报文给服务端,此时客户端为SYN_SENT状态(随机初始序列号,防止历史报文被相同四元组接收,确认号为期待接收的下一个报文序列号)
  3. 服务端收到SYN报文后,发送SYN+ACK报文给客户端,此时服务器为SYN_RECV状态
  4. 客户端收到服务端的同步确认报文后,回复ACK报文(可以携带数据),客户端处于连接建立状态
  5. 服务端收到客户端的ACK报文后也进入连接建立状态

为什么需要三次握手 image.png

  • 避免历史连接,两次握手会有历史连接问题。主要原因是三次握手时服务端有一个半连接状态,可以给客户端阻止历史连接建立。
  • 三次握手才能可靠地同步双方的初始序列号(不是知道对方的序列号,而是要通过确认号确定序列号是可靠的)
  • 三次握手才能确认双方收发能力正常
  • 四次握手可以减少成三次握手

握手丢失

  • 第一次握手丢失:客户端收不到服务器的SYN+ACK报文,会超时重发SYN报文(序号与之前一样),每次超时时间是上一次两倍,有最大重传次数,超过则断开连接。
  • 第二次握手丢失:客户端没收到SYN+ACK会触发超时重传,重传SYN报文,服务器也会触发超时重传,重传SYN+ACK报文。
  • 第三次握手丢失:服务器收不到ACK,会重传SYN+ACK。 客户端以为连接建立了,发送数据给服务器,服务器收到数据回RST包,客户端知道连接建立失败重新连接(注意ACK报文不会重传

建立连接后客户端故障怎么办

  • 如果客户端很长一段时间没有消息,服务器会触发保活机制,即服务器每隔一段时间发送1个探测报文,如果一直没有回应则认为当前TCP连接已死亡,断开连接
  • TCP保活机制的默认时间有点长,实际可以在应用层实现了一个心跳机制,例如http长连接的超时时间。

建立连接后服务器进程崩溃怎么办

  • 此时服务器内核会回收该进程所有TCP连接资源,内核会主动发起四次挥手,与客户端断开连接(客户端进程崩溃也相同),注意进程崩溃和主机宕机不同。

连接建立后客户端宕机

  • 宕机后如果没重启一定时间后服务端触发保活机制
  • 宕机后客户端重启了,此时虽然连接没断开,但是客户端进程没了(即使进程还存在,TCP连接的数据结构也没了)所以客户端收到服务端的数据后会回复RST,重新建立连接。

SYN洪泛攻击

  • 第三次握手中服务器等待客户端回复ACK,处于半连接状态,会将半连接对象放到半连接队列中,攻击者伪造大量IP地址发送SYN包但是不回复ACK,导致服务器半连接队列被占满,无法响应其他连接;且服务器还要不断重传SYN+ACK包,消耗资源,最终导致服务器不可用
  • 解决方法是增大半连接队列,减少SYN+ACK重传次数,绕过半连接队列建立连接
    • 绕过半连接队列建立连接:开启syncookies参数,当个半连接队列满了之后,服务端再收到SYN包不会丢弃而是生成一个cookie,将其放到第二次握手报文的序列号中,客户端响应ACK会带上这个cookie,服务端检查ACK包的cookie是否合法,若合法将连接对象放到全连接队列中,整个过程不需要半连接队列参与。但是这种方式也可能会被ACK攻击,即生成各种cookie让服务器去解码耗尽CPU资源。

四次挥手

四次挥手流程

  1. 客户端发送FIN报文给服务端,进入FIN_WAIT_1状态
  2. 服务端收到FIN报文后,发送ACK给客户端,进入Close_wait状态。客户端收到ACK报文后进入FIN_WAIT_2状态
  3. 服务端发送数据,发送完后向客户端发送FIN报文,进入LAST_ACK状态。
    • 四次握手的原因是服务端的ACK和FIN分开发送,如果服务端没有数据要发送了,也可以合并为三次
  4. 客户端收FIN报文后,回复ACK报文,进入Time_WAIT状态,等待2MSL断开连接。
    • 主动关闭连接的一方才有Time_Wait状态
  5. 服务端收到ACK报文后进入Close状态

挥手丢失

  • 第一次挥手丢失:客户端触发超时重传,若重传一定次数还没收到ACK,则直接进入close状态
  • 第二次挥手丢失:客户端触发超时重传,而服务端的ACK是不会重传的。
  • 第三次挥手丢失:服务端会重发FIN报文,达到最大重发次数后断开连接,客户端没有收到FIN报文会处于FIN_Wait_2状态,但是这个状态有时长限制,超时自动断开连接
  • 第四次挥手丢失:此时客户端处于Time_wait状态,服务端处于Last_ACK状态,服务端会重发FIN报文,客户端收到FIN报文后会发送ACK报文并重置2MSL定时器

为什么要有TIME_WAIT状态

  • 防止历史连接中的数据被后面的相同四元组的连接错误接收,经过了Time_wait状态,之前连接的数据包都已经被丢弃了,新连接接收到的包一定是新发送的包。注意序列号无法用来判断是新老数据,因为序列号是有一定范围的,会产生回绕
  • 保证被动关闭的一方能够正确地关闭,即使最后一个ACK丢了也没事

为什么TIME_WAIT必须等待2MSL

  • MSL是报文最大存活时间,超过这个时间报文将被丢弃。2MSL是发送方发送数据包到收到接收方响应的最大时长。 2MSL就是允许ACK报文丢失一次,ACK在一个MSL内丢失了,对方重发的FIN可以在第2个MSL内到达,处于Time_wait状态的一方可以做出应对。重发ACK后2MSL又重新计时。

TIME_WAIT状态过多产生什么后果

  • 主要就是占用系统资源和占用端口资源。
    • 客户端大量端口被TIME_WAIT状态的连接占用时,可能会导致新连接无法建立,出现address already in use异常
    • 大量Time_wait状态的连接占用内存资源,文件描述符,CPU资源。

服务器出现大量TIME_WAIT的原因

说明服务器主动断开了很多TCP连接,有三种可能的场景

  1. HTTP没有使用长连接。此时每次请求后都要释放连接,所以不管哪一方禁用了长连接,最后都是服务器响应完后主动关闭连接
  2. HTTP长连接超时。客户端长时间没有发送请求,服务器会主动关闭长连接
  3. HTTP长连接的请求数量达到上限。服务端通常有参数定义一条Http长连接上能处理的最大请求数量,在QPS较高的场景,达到请求阈值时就会关闭这个长连接,就会导致大量Time_wait状态。

如何优化TIME_WAIT

  • 复用处于TIME_WAIT的socket为新的连接所用
  • 跳过Time_wait状态,直接关闭连接
  • 服务端不要主动断开连接,让客户端去承担TIME_WAIT
  • 客户端和服务器都要开启长连接,避免频繁关闭的短链接

服务器出现大量CLOSE_WAIT的原因

  • CLOSE_WAIT状态是被动关闭方才有的状态,即被动关闭方发完数据后没有调用close函数关闭连接,就无法发FIN报文,从而无法进入Last_ACK状态。
  • 至于没有调用close函数的原因就要具体分析代码了,可能是没调用accept获取连接的socket,也可能是在close函数之前抛出异常了

TCP分片

若HTTP请求消息比较长,这时TCP需要以MSS的长度为单位拆分,MSS一般设置为比IP层的MTU小从而避免在IP层分片。之所以不用IP层的分片,是因为IP层的分片效率较低,若一个TCP报文被分为多个IP分片,那么该报文丢失时需要重传所有IP分片,若使用MSS,则重传一个MSS即可。

TCP重传机制,流量控制,拥塞控制

  • 超时重传机制:发出去的数据包超过一定时间没有收到确认就会重传,重传一定次数还没有收到确认就发送失败。
  • 滑动窗口机制(流量控制)
    • 窗口可以提高报文传输效率,窗口内的数据包无需等待前面的包的确认应答,就可以继续发送数据。同时窗口可以控制报文传输的速度,避免了发送方发送过多数据导致接收方无法接收
    • 发送方窗口内包含发送但未确认的数据和等待发送的数据,随着已发送数据被确认,窗口内等待发送的数据会被发送,整个窗口向前移动,发送方窗口大小是由接收方的窗口大小决定的。
    • 接收方窗口内包含未收到但是可以收到的数据,也就是一个可以接收数据的缓冲区,接收方的窗口大小是根据当前操作系统的处理能力,窗口内数据量动态调整。
  • 拥塞控制机制
    • 慢开始:TCP刚建立连接时,发送方每收到一个ACK就会将拥塞窗口加一,此时拥塞窗口是指数增长的,当拥塞窗口达到门限值就会使用拥塞避免算法
      • 注意拥塞窗口不是线性增加的,而是指数增加的,因为第一次窗口为1,发送1个,收到ACK后第二次窗口为2,发送2个,收到2个ACK,窗口为4,发送4个
    • 拥塞避免:每收到一个ACK,拥塞窗口增加1/cwnd,即变为线性增长
    • 拥塞发生:网络拥塞,出现了数据包重传。
      • 若是发送了超时重传,说明网络确实阻塞了,门限值变为拥塞窗口一半,拥塞窗口变为1,然后继续慢启动。
      • 若发送了快重传,即收到三个重复确认,说明网络还行,先重传对方未接收到的数据包,再把拥塞窗口减半,门限值为当前拥塞窗口(为了后续继续拥塞避免),然后进入快速恢复。
    • 快恢复:快重传丢失的包并收到ACK后,如果再收到新数据的ACK,说明已经进入正常状态了。拥塞窗口降到门限值开始拥塞避免
  • 流量控制和拥塞控制的区别
    • 流量控制是控制发送方的发送速度避免填满接收方的缓冲区,而拥塞控制是针对网络上出现的阻塞现象,降低自己的发送数据量来减缓网络阻塞。
    • 发送方在使用滑动窗口调节发送数据量的基础上,还使用拥塞窗口控制发送量,故发送窗口等于接收窗口和拥塞窗口的较小值

TCP的Socket编程

  • 服务端初始化socket,调用bind将socket绑定到指定IP地址和端口,并调用listen监听端口(注意这个socket是服务端用来监听的,是监听socket)
  • 服务端调用accept阻塞等待客户端连接
    • 如果使用epoll。
    • 先用epoll_create创建epoll实例
    • 调用epoll_ctl将socket注册到epoll监控列表
    • 调用epoll_wait等待IO事件,事件到来时返回事件数
    • 若是连接事件,调用accept获取socket并将其注册到epoll监控列表
    • 若是读写事件,则调用read/write函数进行处理
  • 客户端初始化socket,调用connect向服务端的地址和端口发起请求,调用write写出数据(通过IO流)
  • 服务端accept返回用于传输的socket,调用read读取数据(注意这个socket是用来收发客户端数据的,是已完成连接socket)
  • 客户端调用close断开连接,服务端处理完数据后调用close关闭连接

image.png

image.png

TCP握手原理

image.png

  • 服务端收到SYN请求后,内核将连接(并不是完整的socket对象)插入半连接队列,并响应SYN+ACK
  • 服务端收到客户端的ACK后,内核根据ACK的IP端口将对应的连接从半连接队列取出,然后完成连接将其插入全连接队列,进程调用accept取出一个连接(不关心取哪个)(accept只是负责从全连接队列中取出一个已建立连接的socket返回,与三次握手没什么关系,就算没有accept也能建立连接)

SYN半连接队列和Accept全连接队列

  • 半连接队列和全连接队列在调用listen时被创建,用于存储未完成和已完成的连接
    • 如果没调用listen,请求到来时可能找不到对应的socket,就返回RST。
    • 但理论上没有listen也能建立连接(自连接或两个客户端都打开TCP连接,客户端有全局hash表保存socket)
  • 半连接队列底层是hash表,是为了快速找到对应的连接
    • 半连接队列满时会丢弃新来的连接,半连接队列是Dos攻击(拒绝服务攻击)的目标
  • 全连接队列底层是链表,直接取走第一个连接就行
    • 全连接队列满时会丢掉后续进来的TCP连接,导致服务器的请求数量上不去。此时应该增大队列容量。 队列容量=min(listen函数参数backlog, 内核参数somaxconn)
    • 丢弃是Linux默认策略,可以自定义策略(如回复RST报文)。但是一般用默认策略,即使丢掉客户端的ACK但是客户端仍然认为连接已建立,客户端会发请求而服务端不会回复ACK,客户端会重发,那么当服务端的全连接队列有空位了,此客户端就会连接成功,这样可以应对突发流量

思考:TCP有什么可以优化的地方

  • 三次握手:考虑SYN或SYN+ACK报文重传优化,全连接队列和半连接队列优化
  • 四次挥手:FIN报文重传优化,FIN_WAIT时间,Time_wait状态优化
  • 数据传输性能:滑动窗口大小优化,收发缓冲区优化