TCP协议

1 简述

传输控制协议(TCP,Transmission Control Protocol)是一种面向连接、可靠的、基于字节流的传输层通信协议。

本文对TCP协议关键概念进行了梳理,着重叙述它是如何进行网络间数据传输,以及如何保证可靠的数据传递。并对其依赖的底层IP协议、以太帧等进行简单描述,以便于对该部分知识有系统、完整的了解。

最后,通过java例子,分别以阻塞、非阻塞方式模拟数据传输,来加深理解。
 

2 准备

数据传递不能凭空进行,需要依赖网络,而网络传输方式又五花八门,有局域网(有线、无线)、广域网、移动网等。

虽然网络间数据传输涉及的概念又多又复杂,但可以将它们简化为三个角色:网络、终端(个人电脑、服务器、智能终端)、进程(程序)。数据的传递总是从一个进程开始,到另一个进程结束,传输路径: 进程 -> 终端 -> 网络 .. 网络 -> 终端-> 进程。

国际标准化组织,为了统一网络间通信的标准,提出开放系统互联参考模型(OSI模型),它包含七层: 物理层、数据链路层、网络层、传输层、会话层、表示层、应用层,这些概念不再细述。

TCP/IP协议
分为四层,包含: 应用(进程)层、传输层、网络层、网络接入层。

这些模型,只是为了方便概念的沟通和理解,可参考下图,了解模型、协议的对应关系。

3 特点

TCP协议功能特点,简单来说,就是提供连接的、可靠的、可控的流式数据传输。

3.1 连接性

表示双方数据传输前,必须先沟通,确认已建立连接后,才可以传递数据。

3.2 可靠性

可靠的数据传输是TCP协议的核心特性,  它通过以下机制进行保证.
(1) 接收端收到数据帧,进行校验,如果正确,则会返回确认(acknowledgment)分组给发送端。
(2) 接收端收到数据帧,进行校验,如果错误,或者丢包,则会要求发送端重传(retransmisson)。
(3) 发送端在设定时间内,没有收到确认分组,也会重传该分组。
(4) 发送端在传递数据时,会对每个分组数据进行按顺序编号,接收端依此对分组进行重新排序组合,保证数据的完整、有序

3.3 可控性

试想,如果发送端传递一个分组,等待收到该分组的确认ACK分组后,再发下一组,数据的传输就会受"网络回路时间"的影响,回路周期长时数据传输会很慢,TCP协议采用了"滑动窗口"算法,来高效可控的传递数据,如下:

补充说明

两个进程通过TCP协议在网络间传递数据,会首先建立连接,此时双方会告知对方自己能接收的"最大窗口“值(事实上就是接收缓存区的大小),可以想想,如果在达到对方"接收缓存区"限值之前,就已收到对方反馈的前面数据的ACK,那么数据的传输就不会受到"网络回路时间"影响,性能也会达到网络传输允许的最大值,就像数据的传递不受限制一样,如果接收端不能及时读取"接收缓存区"数据,也可以向发送端调整"窗口"大小,甚至将"窗口"值调整为,暂停数据传递,以此来对数据流量动态调整。

4 协议格式

通过Wireshark工具对网络数据抓包时,会发现,每包(frame)数据都被一层一层包裹,而我们(进程)真实传递的"数据"被包裹在最里面的那一层。最常见的数据包是3层: 最外面是Ethernet(以太帧)的协议头,中间是IP协议头, 最里面是TCP协议头和包裹的真实数据,下面一一介绍。

4.1 Ethernet帧

如果数据的物理传输网络是以太网(局域网),即上面说的数据链路层,那它传输的数据就是以太帧格式如下:

目的地址、来源地址:就是网卡的MAC地址。
类型表明包裹的是什么类型数据,如果是IP分组,该值为0x0800
数据区传递的具体数据,最长1500个字节。
CRC数据校验

可以看出该协议很简洁,通过类型标识,可以支持不同的网络协议。

4.2 IP

网际协议(Internet Protocol) 简称IP协议,它和TCP协议是整个TCP/IP协议中最重要的两个协议,而TCP协议又构建在IP协议之上,可见IP协议的重要性。

该协议的主要功能就是将数据切割为适当的分组大小(不能超过链路层每帧数据的限值),通过网络路由将分组数据送达目的地。

IP协议本身只是把数据分割为分组,然后送到网络, 它不保证送到目的地,也不保证先送的分组先送达,也就是它是不可靠的。数据的可靠性,需要通过ICMP协议,以及上层的TCP协议来保证。格式如下:

只对关键部分描述:(IP分组规定,它的头部一定是4个字节的倍数

IHL:占4位,指的是IP分组的报头长度 (单位:4个字节)
总长度:占16位, 指的是IP分组的整个长度 (单位:字节)
辨识符号、分段:辨识符号来标识数据包,如果数据包被分割为多段传递时,接收端会将相同来源及辨识符号的分段数据进行合并,重新组合为原来的数据包,这种分段处理方式多用在UDP中。
TTL:存活时间,每经过一个路由会自动减1,直到为0,若未达目的地,就会将该分组丢掉。
协议:表明它包裹的是什么类型数据,如果是TCP数据,该值为0x06
来源IP地址、目的IP地址: 就是通信双方的IP地址。 
数据区:长度 = 总长度 - IHL * 4


4.3 TCP

TCP协议位于IP协议上层,被IP协议包裹,也就是IP分组数据区内容,下面是它的格式:

 只对关键部分描述:(TCP的头部也是4个字节的倍数

来源端口、目的端口:它和IP地址组合后,构成了完整的TCP发送、接收地址。
顺序编号:在建立TCP连接时,双方会通过随机数,为自己构建一个起始编号,后续的TCP分组以此为基础,就像数据流的索引一样把数据有序的串联起来。
确认编号:告知对方,已正确收到对方连续数据的最大编号(也可以说是数据的索引),除第一次主动握手传递SYN分组时,为0(因不知对方的数据编号),后续交互都会包含ACK信息。
头偏移量:占4位,指的是TCP分组的报头长度 (单位:4个字节)
编码位:占6位,每一位都代表了TCP分组功能含义,可表示6种: URG、ACK、PUSH、RST、SYN、FIN,并且可以组合,在"会话"部分会详细说明。
滑动窗口:占2个字节,以此字段来控制流量,告知对方自己接收缓存区的大小。
选项字段:(只列举常见的3种)
(1)MSS(最大数据段大小) 
(2)窗口扩充因子(因窗口2个字节,最大值65535,通过该值可倍增窗口数据范围) 
(3)SACK,如果双方握手时,协商支持SACK,根据这些信息TCP就可以只重传哪些真正丢失的报文段, 在接收端收到失序分组时,可提升性能。
数据区:通过TCP真实传递的数据,长度 = IP.总长度 - IP.IHL * 4 - TCP.头偏移量 * 4


4.4 完整包

网络中自始至终传递的是IP分组,在传递过程中,经过不同的网络,根据物理层真实的链路层协议,对IP分组进行打包传递,以下是局域网以太帧的完整格式:

5 会话

以下会话场景,客户端是192.168.0.103,服务端是192.168.0.106,监听端口:8888

5.1 连接

上面是客户端192.168.0.103,跟服务端192.168.0.106:8888建立时,抓取数据包的时序记录,可以看出通过三次握手,来建立连接。

 第一行部分内容:[SYN] Seq=0 Win=4096 Len=0 MSS=1460 WS=1 SACK_PERM=1,可以看出进行SYN握手时,告知了对方序列编号3782324157, 窗口4096字节,最大分段大小1460字节,WS窗口扩展因子1,没有倍增,支持SACK机制。

思考个问题?,按常识理解,2次握手一应一答,对方已经应答了,为什么还要多余的进行第3次握手呢,因为第二次握手仅表示:客户端收到服务端ACK,确认对方收到了自己的SYN数据包,而此时服务端还不能确认客户端收到自己的SYN数据包,也就是说,在建立连接后,双方都要确保对方收到自己的顺序编号、窗口等握手信息,然后双方才可以正常传输数据。事实上,成功的建立连接,3次是最少的次数,如果没有收到对方ACK确认,需要多次重传来确保对方收到。

5.2 断开

上面是客户端192.168.0.103,主动跟服务端192.168.0.106:8888建立时,抓取数据包的时序记录,可以看出通过四次握手,正常的断开了连接关系。

5.3 重置

重置,也可以理解为强制断开,通过向对方发送RST分组数据包,告知对方自己已停止会话。当对方收到RST分组后,会立即通知上层的进程,停止数据传递,并结束连接状态。

场景举例:
a. 握手阶段,客户端192.168.0.103 向服务端192.168.0.106:8888, 发生连接请求(即SYN分组),但服务端没有进程监听8888端口,此时服务端会发RST分组。
b. 传递阶段,如果客户端192.168.0.103被强制退出,此时也会向服务器反馈RST分组。

总之,如果已无法继续跟对方会话,就会向对方反馈RST分组,截取样例: [RST, ACK] Seq=102 Ack=1 Win=0 Len=0,可以看到接收窗口也置为0,也拒绝接收数据。

5.4 状态

通过netstat命令,可查看TCP连接状态,帮助分析问题,下面对连接状态简单整理。

a. 握手阶段
SYN_SENT:主动发起连接方,已发送SYN分组,但还没有收到对方ACK分组时的状态。
SYN_RCVD:被动接收连接方,已收到对方SYN分组,但还没收到对方ACK分组,也就是第三次握手的ACK分组,通常这个状态很难看到。

b. 传输阶段
ESTABLISHED:双方已建立连接,可能正常发送数据。

c. 断开阶段

主动方
FIN_WAIT_1:主动发起断开方,已发送FIN分组,还没收到确认ACK分组前。
FIN_WAIT_2:主动发起断开方,收到对方对FIN分组的回复ACK分组后,进入该状态。
TIME_WAIT:在收到对方FIN分组,并回复ACK后,进入的状态,通常会延迟一段时间才进入关闭状态(主要是处理那些未达分组,避免跟下一次连接混淆影响效率)。

被动方
CLOSE_WAIT:被动方在收到对方FIN分组,回复ACK分组,并通知上层进程,进入该状态。
LAST_ACK:被动方进程在完成中断处理后,关闭连接,发送FIN后,进入该状态。

6 数据传输

6.1 概略图

上面是TCP建立连接后,数据读写、数据包传递的基本流程。一提到数据传输,讨论最多的大概就是发送接收的阻塞和数据传输的速度问题,下面分别对这两个问题分析。

6.2 阻塞

建立连接后,以下是可能出现阻塞的环节:
(1) 发送数据包阻塞,通常由两种原因导致:
a. "网络回路周期"太长, 不能及时收到确认ACK分组,超过对方"窗口"限值后,需等待确认ACK分组,才能继续发送。
b. 接收端的"接收缓存区"已满,向发送端发"零窗口"分组,通知暂停发送,通常是由于接收端的应用进程没有及时读取"接收缓存区"数据导致。
(2) "发送缓存区"已满,写"发送缓存区"阻塞。

(3) 另外,接收端在读完"接收缓存区"内容后,也会阻塞等待新数据

6.3 速度

除网络本身的带宽速度限制外,其它影响因素:
(1) 窗口(即对方的接收缓存区)大小,如果配置过小,因不能及时收到确认ACK分组,数据发送速度就会受到影响。
(2) 网络路由节点较多时,如果"回路周期"耗时太长,可通过适当调大"窗口"优化。
(3) 接收端进程,不能及时读取"接收缓存区"数据,通常是由于进程受业务逻辑影响导致,可适当通过异步处理等手段来优化。
(4) 频繁传递小数据包,也会影响性能,合并后也可以提高性能,通常TCP公共类,已默认实现"合并算法",可通过选项配置。

7 实践-java

7.1 阻塞

对出现的阻塞问题,服务端采用线程池方式,可以满足大多数业务场景需求,参考下面TcpClient、TcpServer类例子,加深理解。

客户端:

package mtr.demo.tcp.blog;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketAddress;

public class TcpClient {
	
	private static final int RECEIVE_BUFFER_SIZE = 4096;
	private static final int SEND_BUFFER_SIZE = 4096;
	private static final int CONNECT_TIMEOUT__MILLISECONDS = 3000;
	private static final int READ_TIMEOUT_MILLISECONDS = 5000;

	public static void main(String[] args) throws Exception {
		
		// 创建连接
		Socket socket = createAndConnect("192.168.0.106", 8888);
		
		try {
			// 发送数据
			System.out.println("开始发送数据");	    
		    for(int i = 0; i < 3; i++) {
			    String msg = buildData(i * 10, 10);
			    socket.getOutputStream().write(msg.getBytes("UTF-8"));
			    System.out.println(String.format("序列%d, 已发送:%s", i, msg));
		    }	    
		    socket.shutdownOutput();
		    
		    // 接收数据
		    System.out.println("开始接收数据");
		    
			byte[] bytes = new byte[1024];
			int len;
			while ((len = socket.getInputStream().read(bytes)) > -1) {
				String msg = new String(bytes, 0, len, "UTF-8");
				System.out.println("服务端回复:" + msg);
			}
			
		} finally {
		    // 断开链接
		    System.out.println("断开链接");
		    socket.close();
		}
	}
	
	/*
	 * 创建并连接Socket
	 * 
	 * note:通常缓存大小、NoDealy不需配置,默认值已满足大多数应用场景.
	 */
	static Socket createAndConnect(String ip, int port) throws IOException {
		Socket socket = new Socket();
		
		socket.setReceiveBufferSize(RECEIVE_BUFFER_SIZE);
		socket.setSendBufferSize(SEND_BUFFER_SIZE);
		socket.setTcpNoDelay(false);
		socket.setSoTimeout(READ_TIMEOUT_MILLISECONDS);

		SocketAddress socketAddress = new InetSocketAddress(ip, port);
		socket.connect(socketAddress, CONNECT_TIMEOUT__MILLISECONDS);		
		return socket;
	}
	
	static String buildData(int start, int len) {
		StringBuilder builder = new StringBuilder();
		
		for(int i = 0; i < len; i++ ) {
			builder.append(String.format("%09d,", start + i));
		}
		return builder.toString();
	}	
}

服务端:

package mtr.demo.tcp.blog;

import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class TcpServer {
	
	private static final int RECEIVE_BUFFER_SIZE = 8192;
	private static final int READ_TIMEOUT_MILLISECONDS = 5000;	
	
	private static final ExecutorService threadPool = Executors.newFixedThreadPool(100);

	public static void main(String[] args) throws IOException {

		// 创建TCP服务
		ServerSocket serverSocket = createServerSocket(8888);		
		System.out.println("服务端已启动,等待客户端的连接...");
			
		// 循环监听
		int count = 0;
		while (true) {
			
			// 等待客户端的连接 
			Socket socket = serverSocket.accept();
			
			count++;
			SocketTask socketTask = new SocketTask(socket, count);
			threadPool.submit(socketTask);

			System.out.println(String.format("新增客户端连接,序号: %d, ip地址: %s", count, socket.getInetAddress().getHostAddress()));
		}
	}
	
	/*
	 * 创建ServerSocket
	 * 
	 * note:通常缓存大小不需配置,默认值已满足大多数应用场景.
	 */	
	static ServerSocket createServerSocket(int port) throws IOException {
		ServerSocket serverSocket = new ServerSocket(port);
		serverSocket.setReceiveBufferSize(RECEIVE_BUFFER_SIZE);
		return serverSocket;
	}
	
	static class SocketTask implements java.lang.Runnable {
		
		private int id;
		private Socket socket;
		
		public SocketTask(Socket socket, int id) {
			this.id = id;
			this.socket = socket;
		}

		@Override
		public void run() {
			
			try {		
				socket.setSoTimeout(READ_TIMEOUT_MILLISECONDS);
				
				// 接收
				int total = 0;	
				int len;
				byte[] bytes = new byte[1024];
				while ((len = socket.getInputStream().read(bytes)) > -1) {					
					System.out.println(String.format("客户端 %d, 说: %s", id, new String(bytes, 0, len, "utf-8")));
					total = total + len;
				}
				System.out.println(String.format("总共收到客户端 %d, 内容大小: %d字节", id, total));
				
				// 应答
				String msg = String.format("hello, 已收到内容大小:%d 字节", total);
				socket.getOutputStream().write(msg.getBytes("utf-8"));		
			} catch (Exception e) {
				e.printStackTrace();				
			} finally {
				try {
					System.out.println(String.format("关闭客户端 %d 连接", id));		
					socket.close();
				} catch (IOException e) {
					e.printStackTrace();
				}
			}			
		}		
	}
}

7.2 非阻塞

客户端通过长连接进行TCP数据传输,如果同时在线上万或几十万的话,采用阻塞方式,可以想想,服务器端分配这么多线程是不可接收的,就算增加机器通常也不能解决问题,经济上也不划算。

而采用非阻塞方式,不必每个连接对应一个线程,就可以处理该场景需求,jvm中java.net.nio下类库已提供基本实现,它主要涉及Chanel(通道)、Selecor选择器、ByteBuffer三个核心概念,可参考以下NioTcpClient、NioTcpSever类例子。

客户端:

package mtr.demo.tcp.blog;

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ClosedByInterruptException;
import java.nio.channels.SocketChannel;
import java.util.Scanner;

public class NioTcpClient {
	
	private static final int RECEIVE_BUFFER_SIZE = 4096;
	private static final int SEND_BUFFER_SIZE = 4096;

	public static void main(String[] args) throws Exception {
		
		// 获取通道
		SocketChannel socketChannel = createAndConnect("192.168.0.106", 8888);
		
		// 监听回复
		ReadThread ReadThread = new ReadThread(socketChannel);
		ReadThread.start();
		
		// 发送数据
		System.out.println("请输入数据:");	    
		ByteBuffer buf = ByteBuffer.allocate(1024);
		Scanner scanner = new Scanner(System.in);
		while (scanner.hasNext()) {
			String inputStr = scanner.next();
			buf.put(inputStr.getBytes("utf-8"));
			buf.flip();
			socketChannel.write(buf);
			buf.clear();
			
			if (inputStr.equals("end")) {
				break;
			}
		}
		
		// 关闭通道
		scanner.close();
		ReadThread.interrupt();
		socketChannel.close();
	}
	
	/*
	 * 创建并连接SocketChannel
	 * 
	 * note:通常缓存大小、NoDealy不需配置,默认值已满足大多数应用场景
	 */
	static SocketChannel createAndConnect(String ip, int port) throws Exception {

		// 获取通道
		SocketChannel socketChannel = SocketChannel.open();		
		socketChannel.setOption(java.net.StandardSocketOptions.SO_RCVBUF, RECEIVE_BUFFER_SIZE);
		socketChannel.setOption(java.net.StandardSocketOptions.SO_SNDBUF, SEND_BUFFER_SIZE);
		socketChannel.setOption(java.net.StandardSocketOptions.TCP_NODELAY, false);
		
		// 阻塞模式
		socketChannel.configureBlocking(false);		
		
		socketChannel.connect(new InetSocketAddress(ip, port));
		while (!socketChannel.finishConnect()) {
			System.out.println("等待完成连接...");
			Thread.sleep(1000);
		}		
		System.out.println("已完成连接");

		return socketChannel;
	}
	
	private static class ReadThread extends Thread {
		private SocketChannel socketChannel = null;
		
		public ReadThread(SocketChannel socketChannel) {
			this.socketChannel = socketChannel;
		}
		
		@Override
		public void run() {			
			System.out.println("开始接收回复...");
			
			try {
				ByteBuffer buf=ByteBuffer.allocate(1024);
				do {
					int len = 0;
					StringBuilder msg = new StringBuilder();
					while((len = socketChannel.read(buf)) > 0){
						buf.flip();
						msg.append(new String(buf.array(), 0, len, "utf-8"));
					}
					
					if (msg.length() > 0) {
						System.out.println("服务端回复:" + msg);
					}
					
					if (len == -1) {
						System.out.println("线程结束");
						break;
					}					
					Thread.sleep(500);
				} while(socketChannel.isConnected());				
			} catch (ClosedByInterruptException e) {
				System.out.println("线程中断关闭");
			} catch (Exception e) {
				e.printStackTrace();
			}
		}
	}
}

服务端:

package mtr.demo.tcp.blog;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;

public class NioTcpServer {

	public static void main(String[] args) throws IOException {
		
		// 服务通道
		ServerSocketChannel serverSocketChannel = createServerSocketChannel(8888);
		System.out.println("服务端已启动,等待客户端的连接...");
		
		// 选择器
		Selector selector = Selector.open();
		
		// 注册 OP_ACCEPT 事件
		serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
		
		// 轮询事件
		int icount = 0;
		while (selector.select() > 0) {
			System.out.println("扫描次数: " + icount++);
			
			// 获取已就绪事件
			Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
			
			while (iterator.hasNext()) {
				SelectionKey sk = iterator.next();
				
				if (sk.isAcceptable()) {

					SocketChannel socketChannel = serverSocketChannel.accept();
					socketChannel.configureBlocking(false);
					
					System.out.println(String.format("新增客户端连接,ip地址: %s", socketChannel.getRemoteAddress()));

					// 注册 OP_READ 事件
					socketChannel.register(selector, SelectionKey.OP_READ);
				} else if (sk.isReadable()) {
	
					SocketChannel socketChannel = (SocketChannel) sk.channel();

					try {
						// 接收
						ByteBuffer buf = ByteBuffer.allocate(1024);
						int len = 0;
						int total = 0;
						while ((len = socketChannel.read(buf)) > 0) {							
							buf.flip();
							System.out.println(new String(buf.array(), 0, len, "utf-8"));
							buf.clear();
							total = total + len;
						}
						
						// 应答
						if (total > 0) {
							buf.put(String.format("hello, sever receive %d bytes", total).getBytes());
							buf.flip();
							int result = socketChannel.write(buf);
							System.out.println("result: " + result);
						}
						
						if (len == -1) {
							sk.channel().close();							
							System.out.println("通道关闭");
						}						
					} catch (Exception e) {
						e.printStackTrace();	
						sk.channel().close();							
						System.out.println("通道关闭:" + e.getMessage());
					}
				}

				// 删除
				iterator.remove();				
			}
		}
	}
	
	/*
	 * 创建ServerSocket
	 * 
	 * note:通常缓存大小不需配置,默认值已满足大多数应用场景.
	 */	
	static ServerSocketChannel createServerSocketChannel(int port) throws IOException {
		// 获取通道
		ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();		
		// 非阻塞模式
		serverSocketChannel.configureBlocking(false);
		// 绑定端口
		serverSocketChannel.bind(new InetSocketAddress(8888));
		
		return serverSocketChannel;
	}
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值