计算机网络-应用层

计算机网络协议

在计算机网络的整个体系中, 分成 应用层, 传输层, 网络层, 链路层, 物理层.
而不论是身为开发者, 还是用户, 大多数人基本永远只和 应用层打交道.

应用层

在上一篇已经提到过, 计算机网络, 最核心的功能就是个产生信息, 发送信息.而并不关注其中的接受方究竟是人, 机器.

而协议, 就是双方约定的 可以表达一定含义的 消息内容. 符合协议的, 就能够被机器解读, 并进行下一步操作, 可能还会返回一定的响应内容.

而应用层, 有相应的服务器, 和客户端. 而服务器及客户端也可能处于同一台设备上. 角色也可能会相互转换.

在P2P中, 每一方都即是接收者, 又是发送者. 因此就充当了服务器和客户端的两个角色.

但在这里, 并不打算对应用层的协议本身进行过多的探讨, 在初次接触的过程中, 对整体脉络的把控对我而言更为重要, 至于其中的细节, 反倒无需牵扯太多精力. 如果有兴趣, 不如看看对应的 RFC文档, 都有极为详尽的描述.

在应用层之下, 是传输层. 数据从客户端产生, 通过套接字(Socket)向 传输层发送数据, 当数据流转到 服务器时, 服务器同样通过套接字接受数据, 解读, 并响应. 在一个传输过程中, 主动发起请求的被称为客户端, 而处于等待状态的则是服务器.

而套接字则是运输层和应用层之间的接口. 这样就很好地将两层分离开来.

而协议主要有以下几种:

  1. HTTP(超文本传输协议),在web和服务器之间进行通信的协议. 这之间的过程简化来说就是, HTTP向服务器发起请求, 服务器接受请求, 并按照自己的处理方式, 在服务器上找到相应的文件(html, css, js等其他文件), 返回并传输相应的页面交给浏览器进行解读, 最终显示在浏览器上.

    在这里, 客户端请求的资源主要是页面, 以及与页面相关的渲染文件.
    而在这里, 大多情况下使用的是TCP可持续链接, 稍后再提.

    且需要提到的一个概念是HTTP是无状态协议, 也就是并不保存相应的信息, 每一次请求都会被当做一次全新的请求, 不记录用户的身份标识等其他. 但在web开发的过程中, 却又明显感觉这是有状态的, 记录了用户的身份标识. 记录了当前会话, 这又是怎样一回事呢?

    这就不得不提到Cookie, 无论是会话的SessionID, 又或是其他的登陆状态等等信息, 甚至包括个人喜好浏览等等都保存在相应的 cookie中, 才能够对你进行甄别, 如果你关闭了cookie, 大多数网站你都无法进行登陆, 因为无法保存你的登陆状态, 每一次请求都会被当成是一次全新的会话进行处理(保有疑问,对会话的低层机制不是非常了解), 除非在每次请求相应都在参数中传递相应的信息(-.-).

    cookie是怎样被传递的呢?也是定义在协议中的东西, 需要在响应头中添加, Set-cookie 键, 传输相应的值, 被浏览器解读.

    但可持续链接与不可持续又该怎样理解呢?

    如果你请求一个网页, 网页中含有9个CSS及JS文件, 加HTML文件本身就有10个文件, 因此在非持续性链接中, 总计就需要创建10次TCP链接, 传输相应的文件, 每次TCP链接又都需要进行相应的TCP握手, 断开连接等流程, 增加不必要的时间, 因此默认采取了可持续链接, 在一个TCP链接中传输所有的10个文件, 同时在 HTTP超时后, 断开相应链接.

  2. SMTP(简单邮件传输协议);

    P2P(对等传输), 每一个文件的下载者又作为相应的上传者贡献资源, 加快传输速度;

    FTP(文件传输协议), 与HTTP不同的是, 保有两条TCP链接, 一条用来传送文件, 另一条为控制连接, 传输相应的登陆校验信息, 同时传递一部分指令, 用以在服务器的文件目录进行移动, 同时更改目录结构. 同时TCP链接 会在每次需要传送一个文件时创建, 传送结束之后关闭(无论传送方向是什么).

  3. DNS(Domain Name System), 域名系统.

    DNS是一个由分层的DNS服务器实现的分布式数据库, 是一个使得主机能够查询分布式数据库的应用层协议.

    我们在日常生活中访问网址所使用的都是用的是主机名或者IP地址的方式, 更多的人倾向于使用主机名, 如 www.baidu.com 通过这样的方式去访问百度的主页. 因为便于记忆. 但同样也可以 通过61.135.169.125访问百度主页. IP的一台设备在网络中的唯一标识, 通过局域网的方式, 可以做到IP复用, 但是在公网上的IP地址却是绝对的唯一, 通过一个Ip只能定位到一台主机.

    那么既然Ip是唯一标识, 又如何通过主机名访问到对应的主机呢? DNS, 将主机名映射到对应的IP. 也就是说, 事实上当我们在浏览器上输入 www.baidu.com 的时候, 并不是首先向对应的服务器发起请求.而是:

    先将主机名发送至本地DNS服务器, 可能在公司, 校园网等地方. 在本地DNS服务器中如果能够找到对应的 主机名映射(或者知道去哪里能够找到对应的映射), 就直接返回IP地址, 否则的话则是向 根DNS服务器发起请求.

    根DNS服务器告知本地Dns服务器该向某个 顶级域DNS服务器(TLD, 如.com .net .edu .org等其他)发起请求, 而后本地服务器再度向TLD发起请求, TLD告知应该去哪个权威服务器找到对应的映射( 多数大学公司会维护自己的权威DNS服务器).

    最后本地服务器向权威DNS服务器请求查找到对应的IP地址.

    这样就找到了真实的IP地址, 而后再向对应的IP发起HTTP请求.

    但这同样会存在问题, 我们每次访问一个网站, 都需要经过8次请求响应才能定位到最终的地址. 这无疑是不合适的, 因此会存在DNS缓存, 如果地址被访问, 且不在相应的列表中, 最终会添加进来, 否则可以直接返回对应的IP地址.

    我们常常会遇到的QQ可以登录, 而网页无法访问, 邮件无法发送, 正是因为这两者都是通过DNS转换域名, 而其他的程序则明确知道对应的IP,端口. 可以进行访问.

    当你配置一个错误的DNS时, 依然可以通过61.135.169.125 访问百度的首页.

运输层

在对应用层有了大致的了解之后, 知道了从输入一个网址到请求响应之间, 有如此之多的复杂的过程, 应用程序将数据传输放入套接字, 将数据放入传输层, 他们无需关心数据究竟是怎样被传输的, 经过怎样的处理, 只知道, 我发出的数据是一定的, 对方得到的数据一定准确(TCP), 这种相互之间的数据信任并非凭空而来, 而是有下层服务的种种保证机制决定的.

概述

运输层所提供的通信是逻辑通信功能, 当数据从应用层传递下来, 运输层将应用层数据转换成运输层分组(运输层报文段), 运输层将运输层报文段传递给网络层, 网络层再度封装成IP数据报向目的地发送. 并且在每一个层级, 只检查当前层级所对应的报文段.

特别的, 在网络层, IP所提供的服务模型为 尽力而为交付服务, 这表示IP会尽"最大的努力"在通信的主机之间交付报文段, 并不保证报文段能够被交付, 也不保证报文段能够按序交付, 也不保证报文段数据的完整性, 准确性. 因此IP被称为不可靠服务.

但在大多数情况下, 在进程之间的通信, 我们又必须确保数据的完整性, 有序性, 准确性. 网络的五层协议模型中, IP层是尽力而为服务, 应用层将数据通过套接字发送给运输层, 这个过程发生在主机内部, 因此对数据的准确性, 完整性, 有序性的服务都是有运输层来提供的.

而网络层提供的是主机到主机之间的数据交付, 因此进程到进程之间的数据交付由运输层来实现.

进程到进程的数据交付, 数据的差错校验(并非完整性, 有序性, 必须保证传输到进程的数据是正确的), 这两点是运输层所提供的最基本的服务模型, 同时也是UDP所能提供的所有服务. UDP也是一种不可靠服务.

套接字

在上面我们提到了应用程序是将数据传入套接字, 进而进入运输层. 下面先来看几个简单的例子:

//服务端
ServerSocket serverSocket = null;
try {
    serverSocket = new ServerSocket(6666);
} catch (IOException e) {
    e.printStackTrace();
}

while (true) {
    Socket socket = null;
    try {
        socket = serverSocket.accept();
        System.out.println("New connection accepted " +
                socket.getInetAddress() + ":" + socket.getPort());
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        if (socket != null) {
            try {
                socket.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

//客户端代码
try {
    socket = new Socket("127.0.0.1", 6666);
    Thread.sleep(3000);
    socket.close();
} catch (Exception e) {
    e.printStackTrace();
}

这是一个相当简单的 java的 Socket编程, 目的只是为了了解TCP究竟是怎样运作的.

首先, 进程是基于端口上而言的, 两个相互通信的进程之间必须知道彼此的端口号, 且一致才能够进行相互通信. 这就是在之前提到的 运输层是进程之间的相互通信, IP是主机之间的相互通信, 主机之间进行通信只需要知道彼此的主机地址(即IP)即可.

serverSocket = new ServerSocket(6666); 也就是在服务端的6666号端口监听来自客户端的请求.

而在客户端:socket = new Socket("127.0.0.1", 6666); 也就是会向对应IP地址, 的6666号端口号发起请求. 通过这种方式, 就初步的建立一个连接.

但是在服务端 socket = serverSocket.accept(); 有这样一段代码, 这表示什么意思呢?

TCP是面向连接的, 可靠的服务, 而什么是面向连接呢? 这点比较类似于打电话, 当你拨号成功, 并不意味着你能够开始说话, 传输数据, 唯有收到对方的确认之后, 才能够开始通话. 也就是, 这条虚拟连接, 必须先建立才能够传输数据.

在serverSocket = new ServerSocket(6666)的过程中, 在这里创建了一个特殊的套接字, 用于在 TCP 建立初始连接的 三次握手过程.(至于三次握手, 稍后会提到). 那么也就能够理解, 为什么在 while(true)的循环中, 创建新的Socket, 去接收相应的数据. 因为 ServerSocket又被称作是欢迎套接字, 在三次握手之后,才会创建新的Socket用以接收数据.

那么每次消息到来我们都需要创建对应的Socket去接收吗? 这样的话开销会不会太大? 在HTTP中, 已经有了可持续链接这个概念, 也就是 keep-alive 这个属性, 这样在一次通信建立之后, 不需要对随后的每一个资源请求, 如: 图片, css, js等其他文件都建立新的链接. 我们可以都在同一个连接上传输相应的数据.节省了资源消耗.

那么这里在循环中不断的创建Socket的目的是什么呢? 服务器的进程只有一个, 却需要创建多个套接字. 这就不得不提到另一个概念, 多路复用, 多路分解.

对于每一台主机, 数据都是在运输层传递, 当需要发送数据时, 数据会从应用程序发送到运输层, 所有的数据不区分类型, 均在运输层上进行传递, 但我们知道, 数据最终是要被发送到相应的进程中去, 同时, 数据也来源于不同的进程, 因此需要对不同的进程加以区分, 而在之前也提到过, 进程通过套接字向运输层发送接受数据.

多路分解即是: 运输层获取到数据之后, 根据其特定的 目的IP, 目的端口号, 源端口, 源IP 其中的两种或四种来定位到对应的套接字中. 多路复用即是根据相同的信息, 将多个套接字发送来的数据合并起来, 共同通过运输层进行发送.

对于面向无连接(UDP)和面向连接(TCP)定位套接字所需要的值是不同的.

在面向无连接的套接字, 需要通过 目的端口号, 源端口号来定位相应的套接字(具体稍后再UDP编程中会看到).

而在面向连接的套接字中, 需要通过 目的IP, 源IP, 源端口, 目的端口 来定位套接字. 也就是说, 只要这四个中有任何一个不同, 都会创建一个新的套接字, 所以才会在死循环中, 不断创建 Socket 同时开启多个线程 以应对来自多个源的请求. 需要注意的是, 如果客户端使用的是持续性HTTP, 则在整条连接持续时间客户和服务器之间都会通过同一个套接字交换数据, 而如果是非持续连接, 则在每次请求时都会创建新的 TCP连接, 而后关闭连接.

下面来看一下, UDP套接字:

//服务端
DatagramSocket socket = new DatagramSocket(8800);
DatagramPacket packet = null;
byte[] data = null;
int count = 0;
System.out.println("***服务器端启动,等待发送数据***");
while(true){
    data = new byte[1024];//创建字节数组,指定接收的数据包的大小
    packet = new DatagramPacket(data, data.length);
    socket.receive(packet);//此方法在接收到数据报之前会一直阻塞
    Thread thread = new Thread(new UDPThread(socket, packet));
    thread.start();
    count++;
    System.out.println("服务器端被连接过的次数:"+count);
    InetAddress address = packet.getAddress();
    System.out.println("当前客户端的IP为:"+address.getHostAddress());
    
}

public class UDPThread implements Runnable{

DatagramSocket socket = null;
DatagramPacket packet = null;


public UDPThread(DatagramSocket socket,DatagramPacket packet) {
    this.socket = socket;
    this.packet = packet;
}

//线程
@Override
public void run() {
    String info = null;
    InetAddress address = null;
    int port = 8800;
    byte[] data2 = null;
    DatagramPacket packet2 = null;
    try {
        info = new String(packet.getData(), 0, packet.getLength());
        System.out.println("我是服务器,客户端说:"+info);
        
        address = packet.getAddress();
        port = packet.getPort();
        data2 = "我在响应你!".getBytes();
        packet2 = new DatagramPacket(data2, data2.length, address, port);
        socket.send(packet2);
    } catch (IOException e) {
        e.printStackTrace();
    }
    //socket.close();不能关闭         
}

//客户端
InetAddress address = InetAddress.getByName("localhost");
int port = 8800;
byte[] data = "用户名:admin;密码:123".getBytes();
DatagramPacket packet = new DatagramPacket(data, data.length, address, port);
DatagramSocket socket = new DatagramSocket();
socket.send(packet);
byte[] data2 = new byte[1024];
DatagramPacket packet2 = new DatagramPacket(data2, data2.length);
socket.receive(packet2);
socket.close();

在UDP编程中, 并不需要ServerSocket, 因为无需经过三次握手的环节, 使用的是 DatagramSocket, UDP需要做的事情也很简单, 服务端开启, 等待接受数据, 处理数据, 返回数据, 我们会发现, 并没有像TCP套接字那样, 在一个while循环中创建不同的套接字, 服务端始终保持一个套接字, 在不同的线程中处理来自各方的数据.

原因在于, UDP套接字的标识方式是由 目的IP 和 目的端口号所决定的, 因此, 对于一台主机而言可能会有多个 UDP套接字, 但对于一个进程只能够拥有一个 UDP套接字. 只要 数据的 目的IP和目的端口号都相同, 则会被相同的套接字处理, 进行多路分解.

另外一点是: UDP并没有连接状态, 不存在 keep-alive这样的事情, 在客户端发送数据时:

DatagramPacket packet = new DatagramPacket(data, data.length, address, port);

需要在数据中携带目的地址和目的端口号, 而在服务端处理并发送数据时:

address = packet.getAddress();
port = packet.getPort();
data2 = "我在响应你!".getBytes();
packet2 = new DatagramPacket(data2, data2.length, address, port);

都需要从客户端发送来的数据中, 获取端口号 和 ip地址, 进而在发送的时候, 指定端口ip实现数据的发送. 那么在TCP中呢?

is = socket.getInputStream();
os = socket.getOutputStream();

通过流进行数据传输, 因为TCP是面向连接的.

在运输层, 我们所观测到的是, 进程与进程之间的通信, 而在网络层, 则是主机和主机之间的通信.

UDP

通过对套接字的简单分析, 对UDP, TCP都有了一定程度的了解, 接下来就看看UDP协议吧.

在大的方向上UDP运输层报文段分为两部分, 数据字段, 首部字段, 首部字段又分为4个小的字段, 按顺序分别为 源端口, 目的端口, 长度字段, 检验和字段, 这四个字段每个字段占两个字节. 因此首部字段总计 8个字节.

其中长度字段指明了包括首部在内的UDP报文段长度(以字节为单位).

下面一点点来看:

  1. 长度

    UDP数据的最大长度, 首先, UDP最大支持发送的长度为 2^16 - 1 = 65535字节, 但是, 其中IP数据报头要占去20字节, UDP首部8字节, 因此最大可以传输 65507个字节. 之所以是65535是因为在IP协议中只用了16位bit来表示IP数据报的长度.

    在运输层报文段被封装成 IP数据报之后, 大小超出 PMTU 的分组将可能被分片处理, 如果设置Don't Frag, 大小超出PMTU的分组是无法被发送的. 如果发送的数据大于PMTU的话, 数据会被进行分片处理, 要知道, 校验和, 源端口, 目的端口均被存储在同一个运输层报文段中, 而并不在乎报文段的长度到底多大, 因此在进行分片多次发送之后, 只有当全部的数据都被接收到之后, 才会交给上层协议处理, 在UDP中, 只要有任何一个分片丢失意味着数据错误, 因此整个运输层报文段均会被丢弃. 这个代价无疑是相当高昂的.

    同时当分片越多, 数据错误丢失的概率就越高.

    在局域网进行编程时, 建议将数据长度控制在 1472(MTU值为1500)是比较好的, 同时鉴于Internet上的标准MTU值为576字节,所以建议在进行Internet的UDP编程时,最好将UDP的数据长度控件在548字节(576-8-20)以内.

  2. 校验和

    UDP的校验和同样占据两个字节, 其作用是对传输的报文段从源到目的地时, 其比特是否发生改变. 其原理是将 UDP报文段所有16比特字的和进行反码运算.

    特别需要注意的是: 在做校验和的时候, 会有一个比较特别地概念, 伪首部:

    伪首部

    伪首部并非TCP&UDP数据报中实际的有效成分。伪首部是一个虚拟的数据结构,其中的信息是从数据报所在IP分组头的分组头中提取的,既不向下传送也不向上递交,而仅仅是为计算校验和。

    这样的校验和,既校验了TCP&UDP用户数据的源端口号和目的端口号以及TCP&UDP用户数据报的数据部分,又检验了IP数据报的源IP地址和目的地址。伪报头保证TCP&UDP数据单元到达正确的目的地址。因此,伪报头中包含IP地址并且作为计算校验和需要考虑的一部分。

    伪首部包含32位源IP地址,32位目的IP地址,8位填充0,8位协议,16位TCP/UDP长度。 仅仅是UDP的总长度, 而非报文的总长度.

    二、计算检验和(checksum)的过程很关键,主要分为以下几个步骤:
    1.把伪首部添加到UDP上;
    2.计算初始时是需要将检验和字段添零的;
    3.把所有位划分为16位(2字节)的字
    4.把所有16位的字相加,如果遇到进位,则将高于16字节的进位部分的值加到最低位上,举例,0xBB5E+0xFCED=0x1 B84B,则将1放到最低位,得到结果是0xB84C(这种操作叫做回卷)
    5.将所有字相加得到的结果应该为一个16位的数,将该数取反则可以得到检验和checksum。

    而在接收端, 则是将所有的数据都加和在一起(包括检验和), 最终得到的结果就应该是 1111 1111 1111 1111. TCP/UDP都是通过这种方式实现 差错校验.

  3. 总结

    通过校验和实现的数据的差错校验, 通过套接字及多路复用多路分解实现的进程到进程的数据传输, 这是UDP能够提供的所有服务. 虽然UDP有这样多的不保证, 但他的优点依然是不少的:

    无需三次握手, 无需拥塞控制, 导致其速度是要高于TCP的. 在实时应用中, 通常要求以最小速率进程传输, 但却不希望数据被不断延迟发送, 所以在游戏中通常使用的都是UDP协议, 事实上流量需求不高, 但时延敏感.

    同样的在DNS中使用的也是UDP协议, 在TCP连接中需要用到三次握手, 而DNS是最普遍应用, 要求速度越快越好, 难以忍受TCP的慢速率.

    在流媒体中, 采用TCP,一旦发生丢包,TCP会将后续包缓存起来,等前面的包重传并接收到后再继续发送,延迟会越来越大。 但同样的由于UDP没有网络拥塞控制, 可能会导致路由器中大量分组溢出, 以至于几乎没有UDP分组能够成功到达目的地.

TCP

在介绍TCP之前, 不妨先来看几个关键技术, 也是TCP的核心:

  1. 构造可靠数据传输协议:

    在最理想的状态中, 低层信道完全可信, 即传输数据没有丢失, 错误, 失序, 拥塞, 这种种状况出现. 很简单, 当有数据发送过来时, 客户端运输层发送数据, 而服务端接受数据即可.

    下面来一步步加大难度考虑:

    A:如果存在比特差错, 即数据错误怎么办?

    自动重传请求协议:

    1. 数据校验, 即之前的校验和就是用来做这件事情的.
    2. 接收方反馈, 当校验之后, 如果数据正确, 返回 ACK, 数据错误返回NAK
    3. 客户端收到NAK重传当前分组. 收到ACK发送下一条数据.

    B:但如果 ACK, NAK分组本身出现差错怎么办? 如果发送方同样需要回复ACK, NAK, 当发送方发送的ACK依然出错该怎么办? 会陷入一个死循环中.

    当收到错误数据时, 重传当前分组即可. 但这里会引入新的问题, 对于接收方而言, 它不清楚上次发送的ACK, NAK是否被正确接收到, 因此不清楚当前数据是 新数据 又或是重传数据. 而解决办法则是加入一个新的字段: 序号.

    发送方每次带着自己的序号进行发送, 即可知道当前是否是新的分组数据. 而在TCP协议中, 并没有采取ACK, NAK的方式, 而是当 分组出错时, 发送对上一个分组的ACK, 这样接收方接收到相同的ACK时, 即知道后续数据并没有被正确接收. 因此需要在 ACK的时候, 加上序号字段, 即对哪一个分组的ACK.(原因稍后提到)

    C: 如果低层数据丢包呢? 这样接收方可能并未接收到分组, 也就不会发送ACK, 又或者是ACK本身丢失, 这样发送方就无法接收到对应的ACK, 也就不知道分组是否被正确接收到.

    在这里, 加入了一种新的协议机制, 定时器, 发送方选择一个合适的时间, 当超出这个时间没有收到对应的ACK之后, 进行重传.由于 序号的存在, 所以并不用担心接收方是否无法理解究竟是数据重传还是新传.

    而定时器, 无论是重传还是新传, 只要发送方每次发送数据, 都启动相应的定时器, 过期后, 重传. 收到ACK后, 终止定时器.

    D:检验和, ACK, 重传(数据差错), 序号(ACK差错,重传), 定时器(丢包), 这几部分就构成了可靠数据传输协议的核心部分.

  2. 流水线可靠数据传输协议:

    在上面讨论的简单模型中, 数据始终是按照每次发送接收一个分组, 直到收到对应的ACK之后再进行发送. 这种被称作停等协议. 缺点无疑是巨大的. 在漫长的传输中, 网络中同时只会发送一个分组, 也就是说, 无论带宽究竟多么大, 受限于协议, 每次都只能够发送一个分组. 流量很小.

    让我们换一种方式来看, 增大序号范围, 每次同时可以发送n个分组, 可以将效率提高n倍.

    这对发送方最低要求缓存那些已经发送但是没有确认的分组, 接收方缓存那些已经正确接收的分组.

    而当这种模式下, 失序又该如何处理? 如果先收到 2, 后收到1, 并且1还出错了, 又或者是根本没有收到1.

    1.回退N步(go-back-N GBN协议):

    在这种协议中, 允许发送方同时发送N个分组, 需要维护这样几个参数:

    流水线窗口, 窗口长度为N, 需要将窗口分为下面几个部分:

    A:base被定义为最早的未被确认的分组, 也就是说, 在base序号之前的所有分组都已经发送无误, 且收到对应的ACK, 当收到当前分组的ACK时, 重新发送base以后的所有分组.[0 ~ base - 1为已发送, 且确认得分组.]

    B:nextseqnum被定义为最小的, 未使用的序号(即下一个待发送的序号). [base ~ nextseqnum - 1 为已发送但未被确认的分组.]

    C:[nextseqnum ~ base + N - 1]这部分序号可以被用于那些需要立即发送的分组.

    D:[base + N) 大于等于base + N的序号是不能使用的序号, 直到收到base的ACK为止.

    在发送方:

    当上层有数据要发送, 检查窗口的 nextseqnum 是否等于 base + N, 此时表示窗口已满. 上层等会再试.

    在这里对于ACK的处理为, 累计确认, 也就是当收到序号 n时, 则表示<n的所有序号是否已经收到ACK.(在这种模式下不会收到 大于n的ACK, 这源于接收方的处理方式.)

    而当超时发生时, 也就是base超时, 发送[base ~ nextseqnum - 1]之间的所有数据, 当窗口是满窗口时, 此时 重新发送分组数量为N, 因此被称作回退N步.

    在接收方:

    除非接收到序号正确, 数据无误的分组, 否则都会被丢弃. 在回退N步的原则下, 保留失序的正确的分组是没有意义的, 因为无论正确与否之后都会再进行一次重传.

    2.选择重传(SR)

    在回退N步的策略中, 当网络时延 和 窗口长度变得比较大时, 会导致传输数据非常困难. 后者会使得大量数据被重传, 前者使得分组失序到达概率变高, 同时重传代价变高. 因此 选择重传, 就是在回应的时候带上对应的分组序号, 使得发送方得以确认, 而不再重传这些分组.

    与GBN略有不同的是, 一种是超时处理, 在GBN中, 永远只需要对 base 启动一个超时器, 因为失序分组会导致重传, 而在SR中, 需要知道哪些已经ACK, 哪些没有, 对没有ACK的多个分组 都需要维持相应的定时器, 已进行重传操作.

    对每一个传来的数据正确的分组都会进行ACK, 将失序分组缓存, 直到这一批分组都已经收到正确的数据之后才向上层传递.

    如何确定这批究竟有多少个分组呢?

    无论在TCP 还是 UDP协议中, 在运输层报文段的首部, 都有对数据长度的定义, 当达到之后, 认为这一批数据已经发送完了, 则向上层传递.

    而另一点比较有趣的处理则是, 对于所有发送过来的数据, 即是是接收方之前已经确认过分组, 并且序号小于当前 接受窗口的 base, 依然需要进行ACK操作. 因为之前的响应ACK有可能会丢失.

    在这种SR重传的过程中, 会出现这样一个问题:

    假定序号长度只有4 也就是在 0 1 2 3 之间不断循环使用, 窗口长度为 3,
    当发送方发送了 0 1 2 接收方确认了 0 1 2 序号之后, 下一步期待收到的分组则是 3 0 1, 而如果ACK丢失, 此时发送方重新发送了 0 号分组, 接收方会认为 这是一次失序到达, 并缓存. 而事实上只是一次重传.

    而在另一种情况中, 发送方收到了 0 1 2 的ACK, 此时发送 3 0 1, 但3丢失了, 因此接收方收到 0 1, 此时就已经无法确认是一次重传, 还是新传.

    因此, 窗口长度必须小于等于序号长度的一半.

    考虑当窗口长度为N, 序号长度为S时, 最小未确认序号为base,下一个待发送序号最大可能为为 (base + N) % S, 当前已发送窗口的最大序号为: (base + N - 1 % S) 在任何情况下要求, 接收窗口的 期待接收 与 可能重传的序号不会重复, 可能重传的为 base ~ base + N - 1 中的任意位置, 在极端情况下, 接收窗口对 发送窗口的所有分组都进行了ACK, 但此时发送窗口还未收到ACK, 此时接收窗口的期待接收序列为(base + N) % S ~ (base + N + N - 1) % S (最小期待接收 + 窗口长度).

    考虑当base = 0.

    发送窗口的可能重传序号为: 0 ~ N - 1 % S

    接收窗口的可能重传序号为: N % S ~ 2N - 1 % S

    对接收窗口的左侧求值发现 N < S; 右侧求值发现 2N - 1 < S, 因为序号从0 开始, 也就是 N < (S + 1) / 2;

    才能够保证窗口无论处在何种位置都能够保证发送序号不会出现歧义.

    但仅仅是这样依然是不够的, 需要牵扯到另一个概念:

    分组重新排序, 最长分组寿命, 定义在 RFC1323中, 这个以后再看, 暂时不是很明白.

  3. TCP传输

    当数据从应用层通过套接字向传输层发送, 当TCP连接建立之后, TCP将数据引导至 该连接的 发送缓存中. 然后不时的从缓存中取出一段数据, 数据的最大长度受限于 MSS(最大报文段长度, 在这里让人很容易混淆, 理解为传输层报文段长度, 而事实上仅仅表示传输层的数据部分长度.), 而需要为数据加上对应的头信息之后再链路层中传输, 最大链路层帧长度(MTU), 最大传输单元, 在以太网 和 PPP链路层协议中, 长度定义为 1500, 而TCP首部长度为20, IP层首部长度为20, 因此最大传输数据长度为 1460.

    而在TCP中, 两端的连接状态, 参数等等都是由两个端系统维护的, 而在IP层, 并不存储, 传递任何相应的变量.

    TCP报文段结构:

    2字节源端口号, 2字节目的端口号, 32比特序号, 32比特确认号, 16比特接收窗口.

    4比特首部长度字段: 因为在TCP中, 拥有不确定的选项字段(一般情况下是没有的), 所以TCP首部的长度是不定的. 因此才需要这个首部长度字段.

    选项字段: 用于接收方, 发送方协商MSS时使用/在高速下用作调节窗口因子使用.

    6比特的标志字段: ACK用于确认, RST SYN FIN用于连接的建立和拆除, PSH被设置的时候, 指示数据应被立即交给上层, URG紧急数据.

    16比特的紧急数据字段指针, 指向紧急数据尾的指针. 实践中并未使用.


    在这里需要提出一个问题: 为什么在UDP中存在长度字段, 指示当前数据的总长度, 而在TCP中并没有这个问题呢?

    看了许多解释, 综合一下, 提下自己的看法:

    UDP是面向无连接的, TCP是面向连接的, TCP是流式协议, 当在TCP中发送数据时, 我们只需要将数据源源不断的放入套接字即可, 而无需告诉对方, 究竟放了多少数据, 而当数据发送完成之后呢? 断开连接/当前数据流发送完毕.即可, 否则的话就需要一直接收数据. 另一点则是, 在TCP中, 有序号和 确认序号, 双方都维护一个相应的序号, 就可以很明确的知道数据传输到哪一步, 是否完成等等.

    而在UDP中, 维护了一个长度字段, 在IP协议作为下一层的基础上, 这是一个冗余字段, 因为通过IP层传达的信息可以很轻易的计算出当前报文段长度.

    一种说法是: 作为填充字段, 因为在消息都需要是4字节的整数倍, 所以冗余填充.

    而我的理解, 还有一种可能是因为, 当IP自身维护长度字段, 就无需依赖底层实现, 给予底层更多样化的设置. 将两者分离开来, 网络层及以下仅仅做数据传输的功能. 而数据的可靠性, 准确性, 有序性等等都由自身来维护. 这样才可在变动的底层协议上实现自身的通用性.

TCP核心

  1. 序号和确认号

    首先需要提到的一点是: 在TCP首部的ACK字段, 当连接建立以后, 要求发送的所有报文其ACK必须为1.在使用的时候并没有采取NACK的方式.

    序号: 序号指的并非当前报文段的序号, 而是指的当前报文段字节流的首字节的序号, 假定文件大小 5000字节, 而MSS为1000, 则需要分5次发送, 也就是5个报文段发送, 第一个报文段的首字节也即序号为 0, 第二个序号为1000, 依次类推.

    确认号: 当接收方(B)收到来自发送方(A)的数据之后, 假定A发送的序号为0~499, 数据无误, B知道此时, 下一次接受的数据, 其序号应该为500, 因此在ACK中, 会带上自身的确认号500, 而不论是否失序(也就是说即使B已经接收到1000~1499)的数据, 依然会回复500, 因为在接收方的确认号中,始终发送的是当前流中第一个丢失的字节序号. 在这里如果数据错误, 则会被丢弃, 当成没有接收到处理.

    在实际中, 当接收方接收到失序报文时, 会暂存当前字节, 直到缺失的数据被填充上为止.

    所以当本次发送数据之后, 响应报文段的期待接收序号应该是多少呢? seq + length(数据字段长度); 需要注意的是: TCP是全双工的, 也就是双方可以同时互相发送数据. 因此接收方和发送方只是一个相对概念, 并非绝对概念.

    两者之间的传递模式是: 我的序号是0, 发送了'C'(数据长度为1), 期待接收到的报文段序号为79(确认序号), 当接收端收到数据之后,会发送: 我的序号为79, 期待接受的报文段序号为1(0 + 1), 发送了数据 'H'. 通过这种方式传递数据, 如果某次发现对方的序号与自己期待接收的数据序号不符, 则会将之前的确认再次发送一遍.

    在这里还需要提到的一个概念是, 累计确认, 还是用刚才的5000字节来举例:

    当发送方发送了0~499 字节, 而此时发送方的发送窗口仍有余量, 因此发送了500~999, 1000~1499, 1500~2000, 此时接收方接收到0~499之后, 在响应中的期待接收序号也就是 ACK确认序号为500(并非是499), 而500~999已经丢失, 此时收到了1000~1499, 1500~2000, 每收到一次就会重新发送一次ACK500, 直到收到500~999之后, 会发送ACK2001. 而累计确认的意思则是, 当收到ACK2001时, 表示接收方已经收到了0~2000的所有数据, 而无需对每一个报文段都进行一次 ACK相应的序号(但依然需要进行ACK, 不过ACK是当前缺失的最小字节).

  2. 超时和快速重传

    在发送方, 对每一个发送的但还未被确认的报文段(这里是指未收到正确的ACK序号, 即 ACKseq > base)都维护一个定时器, 如果超时则进行重传, 而如果收到正确的ACK响应, 会更新sendBase 至 ACKseq, 同时重启所有定时器, 而如果对于 base报文段进行重传, 同样重启所有定时器.

    这里考虑一个简单的状况, 当发送了0~499, 500~999之后, 接收方都发送了ACK500, ACK1000, 此时ACK500丢失, 当超时之后, 发送方重新发送0~499, 重启定时器, 在这个时间内, ACK1000到达, 此时发送方就知道接收方已经接收到 0~999所有内容, 发送1000~1499. 累计确认是对双方而言的.

    而当超时发生之后呢? 此时不仅重传0~499, 同时将超时时间翻倍, 如之前是1秒, 现在2秒, 如果超时依然发生变成4秒, 以此类推, 减少发送的次数. 直到收到正确的数据之后, 重新计算调整超时时间.

    而快速重传则是:

    在网络状况时延良好的情况下, 发送方连续发送了 0~499, 500~999, 1000~1499, 1500~1999, 此时0~499在传输过程中丢失或错误, 而其后的三个报文段准确到达, 且响应ACK0, 因为此时接收方, 期待接收的始终是0;

    当发送方连续收到3次对 0的ACK时, 基本就可以了解, 网络拥塞状况良好, 只是数据传输出问题了, 因此不需要等待超时时间到了再重传, 而是直接进行重传.

    另一点需要提到的是, 这里的序号, 表示的是当前传输报文段的数据字段的第一个字节的编号.是随机的, 因为如果相同, 接收方无法确定是当前数据流的重传, 还是另一段数据流的新传.

  3. TCP流量控制

    流量控制并非拥塞控制, 拥塞控制是为了应对网络状况的不同情况而言的. 而流量控制的目的则是为了对接收方与发送方的速率进行协调一致.

    试想, 如果接收方应用程序的读取速率小于发送速率会怎样, 接收方接收到了正确的数据, 给了相应的ACK, 数据缓存, 然后溢出, 数据丢失. 所以需要时刻维护一个相应的 接收窗口,

    在接收方: 需要跟踪这样几个变量, 接收方为当前TCP连接分配接收缓存, 大小为 RcvBuffer, 同时需要知道 应用程序从缓存中已读取的最后一个字节: lastByteRead, 当前连接已接收的最后一个字节: lastByteRcv.

    需要:

    lastByteRcv - lastByteRead <= RcvBuffer;

    即可保证当前接收缓存不会溢出.

    而发送方呢? 接收窗口大小为 rwnd, 发送方需要知道当前还已确认的最后一个字节, lastByteACKed, 当前已发送的最后一个字节, lastByteSent.

    lastByteSent - lastByteAcked 就是接收方在当前时间可能会接收到的数据量, 需要 <= rwnd即可.

    而 rwnd = RcvBuffer - (lastByteRcv - lastByteRead);

    进而通过这种方式进行速率控制, 但当接收方此时已经接收所有数据, rwnd = 0, 此时发送方不会再发送数据, 接收方也没有办法进行主动更新. 何时才能再度进行发送呢? 发送不了.

    因此需要发送方不断发送 字节为 1 的数据, 获取接收方的接收窗口实时更新. 以便在接收方存在缓存有空间时再次发送数据.

  4. TCP连接管理

    TCP是如何创建一条连接, 进而在连接上传输数据的呢?

    当想要创建一条连接时, TCP会发送一条不包含任何数据的 报文段, 在报文段的首部 填充了当前的序号值, seqGo, seqGo是通过一定的算法随机产生的, 目的是为了避开多条并行TCP连接的冲突, 之前提到过, 在同一主机的同一应用程序和服务器之间 可以同时传输多个文件, 建立多条TCP连接.

    前面提到过ACK在连接建立后都必须为1, 那么为什么需要ACK这个字段呢? 在之前的ACK确认序号中, 我们会发现, 在TCP传输数据时, 仅仅需要匹配确认号 和 当前序号即可:

    当A(发送方)发送数据 0~499, A的首部 序号为0, 确认序号为 76(随机值), 当B(接收方)收到对应的数据之后, 发送确认号 500, 同时发送自己的字节序号为 76. 当且仅当 A收到76之后, 表示A此时知道B已经正确的接收了自己的数据. 然后A发送数据 500~999, 序号为500, 确认号为 100(假定B的数据长度为24), 当且仅当B收到序号为 500的数据时, 表示B此时知道A已经正确的接收到了自己的数据.(其他情况都会重新发送 序号76, 确认号500).

    所以就尴尬的发现, 压根没有 ACK字段什么事, 在初始建立连接时, 当 SYN字段为1, ACK字段为0时, 表明了这是一个连接申请. 服务器接收到了连接请求之后, 回复SYN字段为1, ACK字段同样为1, 加上自己的序号 seqBack, 确认号 seqGo + 1. 表示自己收到请求, 发送方可以发送数据了.

    而发送方此时发送 SYN字段为0 ACK为1, 同时 序号为 seqGo + 1, 确认号 seqBack + 1 表示自己已经接收到了服务器的同意信号. 通过这种方式就建立起来一条相应的TCP连接.表示可以传输数据了.

    在连接这里, 又需要提到一个有趣的概念, 状态:

    当连接建立之后, 对应的端口状态处于 ESTABLISHED, 在这里个人感觉更倾向于指套接字所对应的/维持的连接状态.

    在windows命令行下: netstat -a -n 可以查看本机所有的连接状态.

    一共有5种状态:

    LISTENING:表示处于侦听状态,就是说该端口是开放的,等待连接,但还没有被连接。

    ESTABLISHED:状态表示已经建立连接,表面两台机器正在通信, 在TCP连接建立之后就是处于这个状态.

    TIME_WAIT状态表示结束了这次连接,说明曾经访问过,但是现在访问结束了。处在等待关闭的状态中.

    FIN_WAIT_1状态是服务器端主动请求关闭TCP连接,并且主动发送FIN之后,等待客户端回复ACK的状态.

    LAST_ACK状态,关闭一个TCP连接需要从两个方向上分别进行关闭,双方都是通过发送FIN来表示单方向数据的关闭,当通信双方发送了最后一个FIN的时候,发送方此时处于LAST_ACK状态, 当发送方收到对方的确认(Fin的Ack确认)后才真正关闭整个TCP连接.

    而在一开始, 客户端处于CLOSED状态, 当想要建立连接时, 发送SYN报文段, 此时进入 SYN_SENT状态, 此时等待服务器响应的SYN报文段, 服务器初始处于LISTING状态, 当收到SYN报文段之后, 发送SYN + ACK, 在客户端收到之后, 双方进入ESTABLISHED;

    双方在TCP连接上传输数据, 当一方(客户端和服务器都可以发起关闭连接请求, 在这里假定客户端请求.)想要关闭连接时.

    客户端进程会发送关闭连接的命令, 此时将 TCP报文段首部的 FIN 字段将被置为1, 同时进入FIN_WAIT_1状态, 等待来自服务器的带有确认(ACK)的TCP报文段, 而当服务器接收到FIN报文段, 会发送ACK, 进入 CLOSE_WAIT状态, 客户端收到ACK报文段之后进入 FIN_WAIT_2, 此时服务器会继续发送一条 FIN报文段(FIN字段置为1), 进入 LAST_ACK状态, 当客户端收到 FIN字段之后, 会发送ACK, 并进入TIME_WAIT状态, 当服务器接收到ACK之后, 关闭连接, 释放资源.

    需要注意到的地方是, 此时 客户端进入 TIME_WAIT 状态, 并未关闭相应连接, 而必须经过一段时间后, 才能关闭连接. 这个时间是 2MSL(MSL表示数据包在网络中的最大存活时间), TCP是全双工连接, 如果服务器没有收到ACK, 会在超时之后, 再次重发 FIN 字段, 而 客户端只有在 TIME_WAIT的时间内没有收到 FIN 字段, 才表示 服务器端已经正确关闭了连接, 客户端才会关闭连接.

    同样的是为了确保在网络中 的所有旧数据已经 不存在了, 这样当建立一条新的连接时, 才不会引起数据错乱.

    这里我们说 四元组能够唯一确定一个TCP连接, 五元组能够唯一确定一个网络连接(第五元是指 TCP/UDP 即连接类型.)

    参考连接:

    TCP连接的状态转换

  5. TCP拥塞控制

    在可靠数据传输之外, TCP的另一大特点就是拥塞控制. 之前也提到过, 流量控制是指 为发送方和接收方进行速率匹配, 使得接收方不至于缓存溢出.

    而拥塞控制则是源于网络负荷本身, 路由器都是有自己的容量上限的, 当越趋近于容量上限, 传输速率越低.

    而在拥塞控制中, 有这样几个核心要点需要解决:

    1. 两端是如何得知网络拥塞?

    2. 发送方如何限制自身的速率发送呢?

    3. 发送方又是如何调节自身的发送速率?

    在网络拥塞的感知方面: 存在两种, 端到端自身去判断, 感知拥塞. 第二种是, 网络辅助感知拥塞.即 路由器本身来告诉发送方, 当前的网络拥塞状况, 程度.

    而在TCP的拥塞控制中, 采取的是第一种方式, 因为完全不依赖于底层的实现, 而是将这些保证工作由自身来完成. 底层只需要实现不可靠运输即可.

    但具体是怎样做的呢?

    首先来看如何感知出现拥塞的: TCP中将发送方的 "丢包事件" 定义为: 出现超时 / 出现三次 冗余ACK.

    在之前的快速重传中已经提到过了, 当出现三次冗余ACK时, 就认为当前数据已经丢失, 需要进行重传. 所以当发送方认为数据的传输出现了错误, 失序, 丢包等情况时, 则认为网络出现了拥塞状况.

    那如何限制自身的速率发送呢?

    在流量控制章节我们知道, 会维持一个接收窗口rwnd, 以控制流量. 而在这里又定义了另一个 拥塞窗口(congestion), cwnd, 在一个发送方其 未确认的数据量:

    lastByteSent - lastByteACKed <= min{rwnd, cwnd};

    在这里我们仅考虑 cwnd 即 拥塞窗口.

    TCP拥塞控制的核心指导原则:

    当出现丢包事件时, 表示网络中出现拥塞, 需要减少速率.

    当收到ACK时, 表示网络中一切正常, 可以增加速率.

    带宽探测: 当出现拥塞时, 减少速率, ACK增加速率, 在这种不断的试探中, 求取当前网络中可接收的速率最大值. 进而资源最大化利用.

    TCP拥塞控制算法:

    1. 慢启动

      在TCP连接刚刚建立的时候, 将 cwnd初始化为 一个 MSS的较小值, 然后在每次收到 ACK(而非冗余ACK)之后, 进行翻倍处理.以指数形式增长窗口大小.

      当出现丢包事件时: ssthresh(慢启动阈值) 设置为 cwnd / 2(即检测到拥塞时的 cwnd值, 而非 1)

      如果由于超时而导致丢包, cwnd被重置为1(这里指一个报文段长度, 而非字节). 同时, 设置门限值, 重新开始慢启动.

      如果由于3个冗余ACK而导致丢包, 在快速重传之后进入快速恢复状态.

      而当cwnd等于 ssthresh时, 结束慢启动, 转移至 拥塞避免模式.

    2. 拥塞避免

      当 ssthresh存在, 且 cwnd = ssthresh时, 此时说明已经临近上次的拥塞临界值, 需要换一种更加缓和的 增加方式, 而每次只增加 一个MSS.

      当出现丢包事件时: ssthresh 设置为 cwnd / 2

      如果是超时导致的丢包: cwnd被重置为1个 MSS.并进入慢启动状态.

      如果是3个冗余ACK导致的丢包: 会将 (cwnd + 3(3个已经收到的冗余ACK)) / 2; 然后进入快速恢复状态.

    3. 快速恢复

      在这种状态下, 对收到的每一个ACK都增加一个 MSS, 而当收到丢失的ACK时, 此时降低 cwnd, 进而进入拥塞避免状态.

      当超时之后, 同样的 cwnd置为1, ssthresh置为 cwnd / 2, 而后进入慢启动过程.

  6. 总结

    所以在TCP中, 双方端口号是为了创建对应的套接字, 4比特的首部长度, 为了可变 选项字段, 2字节的接收窗口为了流量控制, 4字节的确认号和序号是为了 可靠数据传输. 2字节的校验和是为了可靠数据传输的差错检验. ACK字段是为了建立连接以及确认. SYN是连接初始化使用, FIN是连接关闭使用. RST是为了当请求端口不合法, 驳回请求使用.

    至此运输层总算告一段落.

    网络层且听下回分解.

猜你喜欢

转载自www.cnblogs.com/zyzdisciple/p/8900718.html