> 文章列表 > Lesson12 udptcp协议

Lesson12 udptcp协议

Lesson12 udptcp协议

netstat命令->查看网络状态

  •  n 拒绝显示别名,能显示数字的全部转化成数字
  • l 仅列出有在 Listen (监听) 的服務状态
  • p 显示建立相关链接的程序名
  • t (tcp)仅显示tcp相关选项
  • u (udp)仅显示udp相关选项
  • a (all)显示所有选项,默认不显示LISTEN相关

pidof命令->查看进程id

  •  pidof + 进程名 -> 查看进程id

 UDP协议

UDP协议端格式

1. 如何分离(封装) 

  •  固定长度的报头,就能将报头和有效载荷分离

 2.如何交付

  • 根据报头中的16位端口号,进行向上交付(进程bind了端口号)

 udp是如何正确的提取整个完整的报头

  • 固定长度的报头 -> 16位udp长度,这也解释了在应用层写端口号的时候,类型是uint16_t类型的

 3.理解udp报文本身

4. sendto/recvfrom/write/read/recv/send ...io类接口

 

  •  这些函数本质都是拷贝函数

5.udp 全双工 && 半双工

  • udp是全双工的
  • 全双工: 一边读,一边写(可以同时发生)
  • 半双工: 只能一边读,或者一边写(不可以同时发生)

TCP协议

TCP协议端格式 

  • 4为首部长度虽然是4个bit位,但是单位是字节
  • 0000->1111 => 0 -> 15,即范围是0->60字节
  • 报头的范围: [20,60]
    • x * 4 = 20 推出 x = 5->0101
    • x * 4 = 60 推出 x = 15->1111

1. TCP是如何交付的

  • 根据报头中的16位端口号,进行向上交付(进程bind了端口号)

2. TCP是如何解包的

  •  先提取20字节

  • 根据标准报头,提取4位首部长度 * 4 = 20 - done

  • 读取[提取4位首部长度 * 4 - 20]字节数据,选项

  • 读完了报头,剩下的都是有效载荷

3.理解可靠性

  •  在网络中不存在100%可靠的协议,无论那台主机都无法保证自己作为最新发送数据的一方
    发送出去的数据都能被对方收到
  • 但是在局部上能做到100%可靠,只有有匹配应答,就能保证刚发出去的消息,对方收到了
  • TCP协议的确认机制: 只要一个报文收到了对应的应答,就能保证我发出的数据对方收到了

4.序号和确认序号

  •  client一次可能向服务器发送多个报头,就会有一个问题,发送的顺序,不一定是接收顺序
  • 序号和确认序号,就是解决这个问题的 

序号和确认序号:

  1.  将请求和应答进行一一对应
  2. 确认序号,表示的含义:确认序号之前的数据已经全部收到的
  3. 允许部分确认丢失,或者不给应答
  4. 为什么要有两个字段数字
    1. 任何通信的一方,工作方式都是全双工的,在发送确认的时候,也可能携带新的数据
  5.  1000 2000 3000 -> 2000 1000 3000,发送和接收的顺序不一定一样,因为任何一方都会收到报文,但报文中会携带序号(可以排序)

TCP的接收缓冲区和发送缓冲区 

  • tcp有发送和接收缓冲区,当我们有clientserver,我们就要有两对接收和发送缓冲区

TCP的6个标记位

  • TCP的6个标记位都是标记报文类型
  • SYN: 该报文是一个链接请求报文
  • FIN: 该报文是一个断开链接请求的报文
  • ACK: 确认应答标志位,
    • 凡是该报文具有应答特征,该标志位都会被设置成1,大部分网络报文ACK都是被设置为1的
      除了一个链接请求报文
  •  RST: reset,连接重置
  • PSH: PUSH,督促对方尽快将数据进行向上交付
  • URG: 紧急标志位,16位紧急指针

1.理解TCP的连接

  •  因为有大量的client将来可能会链接server,所以server端一定会存在大量的连接
  • OS会通过先描述,再组织的方式管理这些连接
  • 所谓的连接:本质其实就是内核的一种数据结构类型,建立连接成功的时候,就是在内存中创建对应的连接对象,再对多个连接对象进行某种数据结构的组织
  •  维护连接是有成本的(内存+cpu)

2. 理解TCP的三次握手 

 

  •  这三次握手不一定都要保证成功

为什么需要三次握手

  • 站在client端,他认为只要发送了SYN给server,就建立了连接
  • 站在server端,他认为只要发送了SYN给client,就建立了连接
  • 但实际上需要收到对方的应答,才算是建立的连接,
  • 所以1次肯定不行,偶数次也不行,3次就刚好可以(客户端和服务端都会建立链接)
  • 这3次握手也能验证全双工,

 3. 理解四次挥手

  •  如果我们发生服务器中具有大量的close_wait状态的连接
    • 有可能是因为应用层写的时候有bug(没有关闭对应的连接sockfd)
  •  为什么会有TIME_WAIT状态,而不是直接就是CLOSED状态
    • 虽然4次挥手已经完成,但是主动断开连接的一方要维持一定的TIME_WAIT状态,
    • 在该状态下,连接其实已经结束了,但是地址信息ip和port依旧是被占用的,等待上面其他的发送结束(等待时间也是不确定的)

setsockopt函数 

  • 4次挥手结束后TIME_WAITCLOSED状态的时间太长,该函数会缩短这个时间

确认应答(ACK)机制 

TCP将每个字节的数据都进行了编号. 即为序列号

  •  每一个ACK都带有对应的确认序列号, 意思是告诉发送者, 我已经收到了哪些数据; 下一次你从哪里开始发.

超时重传机制

  • 主机A发送数据给B之后, 可能因为网络拥堵等原因, 数据无法到达主机B;
  • 如果主机A在一个特定时间间隔内没有收到B发来的确认应答, 就会进行重发
但是, 主机A未收到B发来的确认应答, 也可能是因为ACK丢失了

  •  主机B会收到很多重复数据. 那么TCP协议通过序列号就能够识别出那些包是重复的包, 并且把重复的丢弃掉.达到去重的效果

超时时间的影响

  • 这个时间的长短, 随着网络环境的不同, 是有差异的.
  • 如果超时时间设的太长, 会影响整体的重传效率;
  • 如果超时时间设的太短, 有可能会频繁发送重复的包;

解决方案 

TCP为了保证无论在任何环境下都能比较高性能的通信, 因此会动态计算这个最大超时时间 

  • Linux中(BSD Unix和Windows也是如此), 超时以500ms为一个单位进行控制, 每次判定超时重发的超时间, 他都是500ms的整数倍.
  • 如果重发一次之后, 仍然得不到应答, 等待 2*500ms 后再进行重传.
  • 如果仍然得不到应答, 等待 4*500ms 进行重传. 依次类推, 以指数形式递增.
  • 累计到一定的重传次数, TCP认为网络或者对端主机出现异常, 强制关闭连接

 连接管理机制

 

 

 滑动窗口

确认应答策略,
对每一个发送的数据段, 都要给一个ACK确认应答.收到ACK后再发送下一个数据段.
这样做有一个比较大的缺点,
就是性能较差. 尤其是数据往返的时间较长的时候

  •  窗口大小指的是无需等待确认应答而可以继续发送数据的最大值.
    上图的窗口大小就是4000个字节(四个段).
  • 发送前四个段的时候, 不需要等待任何ACK, 直接发送;

  •  滑动窗口: 在自己的发送缓冲区中,属于自己的发送缓冲区中的一部分
    • 一边想要给对方推更多的数据,另一边又想要对方来得及接收
  • 滑动窗口的上限 == 对方的接收能力
  • 本质: 让sender方,可以一次性发送对方接收数据的上限

滑动窗口的进一步理解

  • 滑动窗口的本质: 就是指针或下标 

  • 更新:win_start = 收到的应答报文中的确认序号,
            win_end=win_start + 收到报文中的窗口大小

  • 滑动窗口不一定都是右移也可以为0
  • 如果收到的不是开始的报文的应答,而是中间的,不影响
  • 滑动窗口如果一直右移滑动,是不存在越界问题的

 快重传 vs 超时重传

  • 快重传是有条件的, 
  • 这两个不是对立的,而是相互协作

拥塞控制

  • 虽然TCP有了滑动窗口这个大杀器, 能够高效可靠的发送大量的数据.
    但是如果在刚开始阶段就发送大量的数据, 仍然可能引发问题.
  • 因为网络上有很多的计算机, 可能当前的网络状态就已经比较拥堵.
    在不清楚当前网络状态下, 贸然发送大量的数据, 是很有可能引起雪上加霜的.
  • TCP引入 慢启动 机制, 先发少量的数据, 探探路, 摸清当前的网络拥堵状态, 再决定按照多大的速度传输数据;

 

  • 拥塞窗口: 单台主机一次向网络中发送大量数据时,可能会引发网络拥塞的上限值
  • 滑动窗口的大小 = min(拥塞窗口,对方窗口大小(接收能力))

拥塞窗口变化大小

  •  当TCP开始启动的时候, 慢启动阈值等于窗口最大值;
  • 在每次超时重发的时候, 慢启动阈值会变成原来的一半, 同时拥塞窗口置回1

拥塞窗口变化原因

  • 它是指数级别增长,特点: 前期慢,后期快
  •  在网络拥塞时
    • 前期要让网络有一个缓一缓的机会
    • 中后期,网络恢复之后,需要尽快恢复通信 -> 避免影响通信效率

拥塞控制的总结

  • 少量的丢包, 我们仅仅是触发超时重传; 大量的丢包, 我们就认为网络拥塞;
    • TCP通信开始后, 网络吞吐量会逐渐上升;
    • 随着网络发生拥堵, 吞吐量会立刻下降;
  • 拥塞控制, 归根结底是TCP协议想尽可能快的把数据传输给对方,
    但是又要避免给网络造成太大压力的折中方案.

延迟应答

  

  • 假设接收端缓冲区为1M. 一次收到了500K的数据; 如果立刻应答, 返回的窗口就是500K;
  • 但实际上可能处理端处理的速度很快, 10ms之内就把500K数据从缓冲区消费掉了;
  • 在这种情况下, 接收端处理还远没有达到自己的极限, 即使窗口再放大一些, 也能处理过来;
  • 如果接收端稍微等一会再应答, 比如等待200ms再应答, 那么这个时候返回的窗口大小就是1M;

延迟应答的时间

  • 数量限制: 每隔N个包就应答一次;
  • 时间限制: 超过最大延迟时间就应答一次

捎带应答

 

  •  ACK可以和服务器回应的 信息 一起回给客户端

 面向字节流

创建一个TCPsocket, 同时在内核中创建一个 发送缓冲区 和一个 接收缓冲区;
  • 调用write时, 数据会先写入发送缓冲区中;
  • 如果发送的字节数太长, 会被拆分成多个TCP的数据包发出;
  • 如果发送的字节数太短, 就会先在缓冲区里等待, 等到缓冲区长度差不多了, 或者其他合适的时机发送出去;
  • 接收数据的时候, 数据也是从网卡驱动程序到达内核的接收缓冲区;
    然后应用程序可以调用
    read从接收缓冲区拿数据;
  • 另一方面, TCP的一个连接, 既有发送缓冲区, 也有接收缓冲区, 那么对于这一个连接, 既可以读数据, 也可以写数据. 这个概念叫做 全双工

 由于缓冲区的存在, TCP程序的读和写不需要一一匹配, 例如:

  • 写100个字节数据时, 可以调用一次write写100个字节, 也可以调用100次write, 每次写一个字节;
  • 读100个字节数据时, 也完全不需要考虑写的时候是怎么写的, 既可以一次read 100个字节, 也可以一次read一个字节, 重复100次;

粘包问题

  • 首先要明确, 粘包问题中的 "包" , 是指的应用层的数据包 

  • 站在应用层的角度, 看到的只是一串连续的字节数据 
  • 明确两个包之间的边界,就可以解决这个问题 

TCP异常情况

  • 进程终止: 进程终止会释放文件描述符, 仍然可以发送FIN. 和正常关闭没有什么区别.
    机器重启: 和进程终止的情况相同.
  • 机器掉电/网线断开: 接收端认为连接还在, 一旦接收端有写入操作, 接收端发现连接已经不在了
    就会进行reset.即使没有写入操作, TCP自己也内置了一个保活定时器,
    会定期询问对方是否还在. 如果对方不在, 也会把连接释放.

TCP小结

要保证可靠性, 同时又尽可能的提高性能 

可靠性:

  • 1.校验和,2.序列号(按序到达) 3.确认应答 4.超时重发 5.连接管理 6流量控制 7.拥塞控制 
提高性能:
  • 1.滑动窗口 2.快速重传 3.延迟应答 4.捎带应答

TCP/UDP对比

  •  TCP用于可靠传输的情况, 应用于文件传输, 重要状态更新等场景; 
  • UDP用于对高速传输和实时性要求较高的通信领域, 例如, 早期的QQ, 视频传输等. 另外UDP可以用于广播
  • TCP不一定就优于UDP,他们之间的优点和缺点, 不能简单, 绝对的进行比较,需要看具体场景

理解 listen 的第二个参数

 

  •  首先accpet函数是从已经建立好连接中,获取对应的连接,它是不需要参与三次握手的

  •  但如果上层来不及调用accept函数,并且对端还来的大量的连接,那怎么处理这些连接?
    • 服务器本身要维护一个连接队列,不能没有,不能太长
    • 这个连接队列就和listen的第二个参数有关
  • 这个队列的长度 = listen的第二个参数 : ESTABLISHED -> SYN_RECV 

演示

 Sock.hpp

#pragma once#include <iostream>
#include <string>
#include <cstring>
#include <cerrno>
#include <cassert>
#include <unistd.h>
#include <memory>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <ctype.h>class Sock
{
private:// listen的第二个参数,意义:底层全连接队列的长度 = listen的第二个参数+1const static int gbacklog = 1;public:Sock() {}int Socket(){int listensock = socket(AF_INET, SOCK_STREAM, 0);if (listensock < 0){exit(2);}int opt = 1;setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));return listensock;}void Bind(int sock, uint16_t port, std::string ip = "0.0.0.0"){struct sockaddr_in local;memset(&local, 0, sizeof local);local.sin_family = AF_INET;local.sin_port = htons(port);inet_pton(AF_INET, ip.c_str(), &local.sin_addr);if (bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0){exit(3);}}void Listen(int sock){if (listen(sock, gbacklog) < 0){exit(4);}}// 一般经验// const std::string &: 输入型参数// std::string *: 输出型参数// std::string &: 输入输出型参数int Accept(int listensock, std::string *ip, uint16_t *port){struct sockaddr_in src;socklen_t len = sizeof(src);int servicesock = accept(listensock, (struct sockaddr *)&src, &len);if (servicesock < 0){return -1;}if(port) *port = ntohs(src.sin_port);if(ip) *ip = inet_ntoa(src.sin_addr);return servicesock;}bool Connect(int sock, const std::string &server_ip, const uint16_t &server_port){struct sockaddr_in server;memset(&server, 0, sizeof(server));server.sin_family = AF_INET;server.sin_port = htons(server_port);server.sin_addr.s_addr = inet_addr(server_ip.c_str());if(connect(sock, (struct sockaddr*)&server, sizeof(server)) == 0) return true;else return false;}~Sock() {}
};

main.cc

#include "Sock.hpp"int main()
{Sock sock;int listensock = sock.Socket();sock.Bind(listensock, 8080);sock.Listen(listensock);while(true){sleep(1);}
}

 Makefile

TcpServer:main.ccg++ -o $@ $^ -std=c++11
.PHONY:clean
clean:rm -f TcpServer