【在Linux世界中追寻伟大的One Piece】传输层协议TCP

2024-09-07 13:02:54 浏览数 (1)

1 -> TCP协议

TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流传输层通信协议。它是互联网协议套件中的核心协议之一,由IETF的RFC 793定义。TCP提供了一种全双工通信方式,确保数据的顺序性、完整性和可靠性。TCP通过三次握手建立连接,并在数据传输过程中使用序列号、确认应答、重传机制、流量控制和拥塞控制等技术来维护通信的可靠性。在TCP连接的生命周期中,还包括数据传送和连接终止两个阶段。数据传送阶段中,TCP使用滑动窗口机制来控制发送速率,避免接收方缓冲区溢出。连接终止阶段则通过四次挥手来优雅地关闭连接。

2 -> TCP协议段格式

TCP(Transmission Control Protocol,传输控制协议)协议段的格式包括固定长度的首部和可变长度的数据部分。首部中包含了用于建立和维护连接、传输控制和错误检测等功能的各种字段。

固定首部字段

  • 源端口(Source Port):16位,标识发送端应用程序的端口。
  • 目的端口(Destination Port):16位,标识接收端应用程序的端口。
  • 序号(Sequence Number):32位,标识发送的数据字节流中的第一个字节。
  • 确认号(Acknowledgment Number):32位,接收端期望接收的下一个序号,用于确认已正确接收的数据。
  • 数据偏移(Header Length):4位,指示TCP首部的长度,单位为32位字(4字节)。
  • 保留(Reserved):6位,当前保留,必须设置为0。
  • 控制位(Control Bits):9位,包含了6个标志位。
    • 6位标志位:
      • URG:紧急指针是否有效。
      • ACK:确认号是否有效。
      • PSH:提示接收端应用程序立刻从TCP缓冲区把数据读走。
      • RST:对方要求重新建立连接;我们把携带RST标识的称为复位报文段
      • SYN:请求建立连接; 我们把携带SYN标识的称为同步报文段
      • FIN:通知对方, 本端要关闭了, 我们称携带FIN标识的为结束报文段
  • 窗口大小(Window Size):16位,发送端的接收窗口大小,用于流量控制。
  • 校验和(Checksum):16位,用于检测首部和数据在传输过程中的错误。
  • 紧急指针(Urgent Pointer):16位,仅在URG控制位为1时有效,指示紧急数据的结束位置。

可变选项字段

  • 选项(Options):长度可变,最多可达40字节,包含了如最大报文段大小(MSS)、窗口缩放因子、时间戳等可选信息。

填充(Padding)

  • 填充(Padding):用于确保TCP首部的长度是4字节的整数倍。

数据部分

  • 数据(Data):可变长度,包含了实际传输的应用层数据。

TCP段的首部长度最小为20字节,这是不包含任何选项时的长度。选项字段的存在使得TCP首部可以根据需要扩展,以支持不同的网络环境和应用需求。

3 -> 确认应答(ACK)机制

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

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

4 -> 超时重传机制

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

但是,主机A未收到B发来的确认应答,也可能是因为ACK丢失了。

因此主机B会收到很多重复数据。那么TCP协议需要能够识别出那些包是重复的包,并且把重复的丢弃掉。

这时候我们可以利用前面提到的序列号,就可以很容易做到去重的效果。

那么,如果超时的时间如何确定?

  • 最理想的情况下,找到一个最小的时间,保证"确认应答一定能在这个时间内返回"。
  • 但是这个时间的长短,随着网络环境的不同,是有差异的。
  • 如果超时时间设的太长,会影响整体的重传效率。
  • 如果超时时间设的太短,有可能会频繁发送重复的包。

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

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

5 -> 连接管理机制

在正常情况下,TCP要经过三次握手建立连接,四次挥手断开连接。

服务端状态转化:

  • [CLOSED -> LISTEN]服务器端调用listen后进入LISTEN状态,等待客户端连接。
  • [LISTEN -> SYN_RCVD]一旦监听到连接请求(同步报文段),就将该连接放入内核等待队列中,并向客户端发送SYN确认报文。
  • [SYN_RCVD -> ESTABLISHED]服务端一旦收到客户端的确认报文,就进入ESTABLISHED状态,可以进行读写数据了。
  • [ESTABLISHED -> CLOSE_WAIT]当客户端主动关闭连接(调用close),服务器会收到结束报文段,服务器返回确认报文段并进入CLOSE_WAIT。
  • [CLOSE_WAIT -> LAST_ACK]进入CLOSE_WAIT后说明服务器准备关闭连接(需要处理完之前的数据);当服务器真正调用close关闭连接时,会向客户端发送FIN,此时服务器进入LAST_ACK状态,等待最后一个ACK到来(这个ACK是客户端确认收到了 FIN)。
  • [LAST_ACK -> CLOSED]服务器收到了对FIN的ACK,彻底关闭连接。

客户端状态转化:

  • [CLOSED -> SYN_SENT]客户端调用connect,发送同步报文段。
  • [SYN_SENT -> ESTABLISHED]connect调用成功,则进入ESTABLISHED状态,开始读写数据。
  • [ESTABLISHED -> FIN_WAIT_1]客户端主动调用close时,向服务器发送结束报文段,同时进入FIN_WAIT_1。
  • [FIN_WAIT_1 -> FIN_WAIT_2]客户端收到服务器对结束报文段的确认,则进入FIN_WAIT_2,开始等待服务器的结束报文段。
  • [FIN_WAIT_2 -> TIME_WAIT]客户端收到服务器发来的结束报文段,进入TIME_WAIT,并发出LAST_ACK。
  • [TIME_WAIT -> CLOSED]客户端要等待一个2MSL(Max Segment Life,报文最大生存时间)的时间,才会进入CLOSED状态。

下图是TCP状态转换的一个汇总:

  • 较粗的虚线表示服务端的状态变化情况。
  • 较粗的实线表示客户端的状态变化情况。
  • CLOSED是一个假想的起始点,不是真实状态。

6 -> 理解TIME_WAIT状态

现在做一个测试,首先启动server,然后启动client,然后用Ctrl-C使server终止,这时马上再运行server,结果是:

这是因为,虽然server的应用程序终止了,但TCP协议层的连接并没有完全断开,因此不能再次监听同样的server端口。我们用netstat命令查看一下:

  • TCP协议规定,主动关闭连接的一方要处于TIME_ WAIT状态,等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态。
  • 我们使用Ctrl-C终止了server,所以server是主动关闭连接的一方,在TIME_WAIT期间仍然不能再次监听同样的server端口。
  • MSL在RFC1122中规定为两分钟,但是各操作系统的实现不同,在Centos7上默认配置的值是 60s。
  • 可以通过cat /proc/sys/net/ipv4/tcp_fin_timeout查看msl的值。
  • 规定TIME_WAIT的时间请读者参考UNP 2.7节。

为什么是TIME_WAIT的时间是2MSL?

  • MSL是TCP报文的最大生存时间,因此TIME_WAIT持续存在2MSL的话。
  • 就能保证在两个传输方向上的尚未被接收或迟到的报文段都已经消失(否则服务器立刻重启,可能会收到来自上一个进程的迟到的数据,但是这种数据很可能是错误的)。
  • 同时也是在理论上保证最后一个报文可靠到达(假设最后一个ACK丢失,那么服务器会再重发一个FIN。这时虽然客户端的进程不在了,但是TCP连接还在,仍然可以重发LAST_ACK)。

7 -> 理解CLOSE_WAIT状态

代码语言:javascript复制
#pragma once
#include <functional>
#include "tcp_socket.hpp"

typedef std::function<void(const std::string& req, std::string*
	resp)> Handler;

class TcpServer 
{
public:
	TcpServer(const std::string& ip, uint16_t port) : ip_(ip),
		port_(port) {
	}

	bool Start(Handler handler) 
	{
		// 1. 创建 socket;
		CHECK_RET(listen_sock_.Socket());
		// 2. 绑定端口号
		CHECK_RET(listen_sock_.Bind(ip_, port_));
		// 3. 进行监听
		CHECK_RET(listen_sock_.Listen(5));
		// 4. 进入事件循环
		for (;;) 
		{
			// 5. 进行 accept
			TcpSocket new_sock;
			std::string ip;
			uint16_t port = 0;
			if (!listen_sock_.Accept(&new_sock, &ip, &port)) 
			{
				continue;
			}

			printf("[client %s:%d] connect!n", ip.c_str(), port);
			// 6. 进行循环读写
			for (;;) 
			{
				std::string req;
				// 7. 读取请求. 读取失败则结束循环
				bool ret = new_sock.Recv(&req);
				if (!ret) 
				{
					printf("[client %s:%d] disconnect!n", ip.c_str(),
						port);
					// [注意!] 将此处的关闭 socket 去掉
					// new_sock.Close();
					break;
				}

				// 8. 计算响应
				std::string resp;
				handler(req, &resp);

				// 9. 写回响应
				new_sock.Send(resp);
				printf("[%s:%d] req: %s, resp: %sn", ip.c_str(), port,
					req.c_str(), resp.c_str());
			}
		}

		return true;
	}

private:
	TcpSocket listen_sock_;
	std::string ip_;
	uint64_t port_;
};

编译运行服务器。启动客户端链接,查看TCP状态,客户端服务器都为ESTABLELISHED状态,没有问题。

然后我们关闭客户端程序,观察TCP状态。

tcp 0 0 0.0.0.0:9090 0.0.0.0:* LISTEN 5038/./dict_server tcp 0 0 127.0.0.1:49958 127.0.0.1:9090 FIN_WAIT2 - tcp 0 0 127.0.0.1:9090 127.0.0.1:49958 CLOSE_WAIT 5038/./dict_server

此时服务器进入了CLOSE_WAIT状态,结合我们四次挥手的流程图,可以认为四次挥手没有正确完成。

小结:对于服务器上出现大量的CLOSE_WAIT状态,原因就是服务器没有正确的关闭socket,导致四次挥手没有正确完成。这是一个BUG。只需要加上对应的close即可解决问题。

8 -> 滑动窗口

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

既然这样一发一收的方式性能较低,那么我们一次发送多条数据,就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)。

  • 窗口大小指的是无需等待确认应答而可以继续发送数据的最大值。上图的窗口大小就是4000个字节(四个段)。
  • 发送前四个段的时候,不需要等待任何ACK,直接发送。
  • 收到第一个ACK后,滑动窗口向后移动,继续发送第五个段的数据依次类推。
  • 操作系统内核为了维护这个滑动窗口,需要开辟发送缓冲区来记录当前还有哪些数据没有应答;只有确认应答过的数据,才能从缓冲区删掉。
  • 窗口越大,则网络的吞吐率就越高。

那么如果出现了丢包,如何进行重传?这里分两种情况讨论。

情况一:数据包已经抵达,ACK被丢了。

这种情况下,部分ACK丢了并不要紧,因为可以通过后续的ACK进行确认。

情况二:数据包就直接丢了。

  • 当某一段报文段丢失之后,发送端会一直收到1001这样的ACK,就像是在提醒发送端"我想要的是 1001"一样。
  • 如果发送端主机连续三次收到了同样一个"1001"这样的应答,就会将对应的数据1001 - 2000重新发送。
  • 这个时候接收端收到了1001之后,再次返回的ACK就是7001了(因为 2001 - 7000)接收端其实之前就已经收到了,被放到了接收端操作系统内核的接收缓冲区中。

这种机制被称为"高速重发控制"(也叫"快重传")。

9 -> 流量控制

接收端处理数据的速度是有限的。如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应。

因此TCP支持根据接收端的处理能力,来决定发送端的发送速度。这个机制就叫做流量控制(Flow Control)

  • 接收端将自己可以接收的缓冲区大小放入TCP首部中的"窗口大小"字段,通过ACK端通知发送端。
  • 窗口大小字段越大,说明网络的吞吐量越高。
  • 接收端一旦发现自己的缓冲区快满了,就会将窗口大小设置成一个更小的值通知给发送端。
  • 发送端接受到这个窗口之后,就会减慢自己的发送速度。
  • 如果接收端缓冲区满了,就会将窗口置为0;这时发送方不再发送数据,但是需要定期发送一个窗口探测数据段。使接收端把窗口大小告诉发送端。

接收端如何把窗口大小告诉发送端呢?回忆TCP首部中,有一个16位窗口字段,就是存放了窗口大小信息。

那么问题来了,16位数字最大表示65535,那么TCP窗口最大就是65535字节么?

实际上,TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移M位。

10 -> 拥塞控制

虽然TCP有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据。但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题。

因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵。在不清楚当前网络状态下,贸然发送大量的数据,是很有可能引起雪上加霜的。

TCP引入慢启动机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。

  • 此处引入一个概念称为拥塞窗口。
  • 发送开始的时候,定义拥塞窗口大小为1。
  • 每次收到一个ACK应答,拥塞窗口加1。
  • 每次发送数据包的时候,将拥塞窗口和接收端主机反馈的窗口大小做比较,取较小的值作为实际发送的窗口。

像上面这样的拥塞窗口增长速度,是指数级别的。"慢启动"只是指初使时慢,但是增长速度非常快。

  • 为了不增长的那么快,因此不能使拥塞窗口单纯的加倍。
  • 此处引入一个叫做慢启动的阈值。
  • 当拥塞窗口超过这个阈值的时候,不再按照指数方式增长,而是按照线性方式增长。
  • 当TCP开始启动的时候,慢启动阈值等于窗口最大值。
  • 在每次超时重发的时候,慢启动阈值会变成原来的一半,同时拥塞窗口置回1。

少量的丢包,我们仅仅是触发超时重传;大量的丢包,我们就认为网络拥塞。

当TCP通信开始后,网络吞吐量会逐渐上升;随着网络发生拥堵,吞吐量会立刻下降。

拥塞控制,归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。

11 -> 延迟应答

如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小。

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

一定要记得,窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。

那么所有的包都可以延迟应答么?肯定也不是。

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

具体的数量和超时时间,依操作系统不同也有差异;一般N取2,超时时间取200ms。

12 -> 捎带应答

在延迟应答的基础上,我们发现,很多情况下,客户端服务器在应用层也是"一发一收"的。意味着客户端给服务器说了"How are you",服务器也会给客户端回一个"Fine,thank you"。

那么这个时候ACK就可以搭顺风车,和服务器回应的"Fine, thank you"一起回给客户端。

13 -> 面向字节流

创建一个TCP的socket,同时在内核中创建一个发送缓冲区和一个接收缓冲区。

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

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

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

14 -> TCP异常情况

进程终止:进程终止会释放文件描述符,仍然可以发送FIN。和正常关闭没有什么区别。

机器重启:和进程终止的情况相同。

机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。

另外,应用层的某些协议,也有一些这样的检测机制。例如HTTP长连接中,也会定期检测对方的状态。例如QQ,在QQ断线之后,也会定期尝试重新连接。

15 -> TCP小节

为什么TCP这么复杂?因为要保证可靠性,同时又尽可能的提高性能。

可靠性:

  • 校验和
  • 序列号(按序到达)
  • 确认应答
  • 超时重发
  • 连接管理
  • 流量控制
  • 拥塞控制

提高性能:

  • 滑动窗口
  • 快速重传
  • 延迟应答
  • 捎带应答

其他:

  • 定时器(超时重传定时器,保活定时器,TIME_WAIT定时器等)

16 -> 基于TCP应用层协议

  • HTTP(超文本传输协议):用于从Web服务器传输超文本到本地浏览器,是万维网通信的基础。
  • FTP(文件传输协议):用于在网络上的计算机之间传输文件。
  • SMTP(简单邮件传输协议):用于发送电子邮件,通常与POP3或IMAP协议结合使用以接收邮件。
  • TELNET:提供了在网络上的计算机之间进行远程登录和命令行交互的能力。
  • SSH(安全外壳协议):提供了加密的远程登录和其他网络服务的方法。

这些协议利用TCP的可靠性特性,如序列号、确认应答、重传机制等,来确保数据的正确顺序和完整性。在设计基于TCP的应用层协议时,开发者需要考虑如何在应用层进一步确保数据的完整性和应用程序的特定需求。

17 -> TCP与UDP对比

我们说了TCP是可靠连接,那么是不是TCP一定就优于UDP?TCP和UDP之间的优点和缺点,不能简单、绝对的进行比较。

  • TCP用于可靠传输的情况,应用于文件传输,重要状态更新等场景。
  • UDP用于对高速传输和实时性要求较高的通信领域,例如,早期的QQ,视频传输等。另外UDP可以用于广播。

归根结底,TCP和UDP都是程序员的工具,什么时机用,具体怎么用,还是要根据具体的需求场景去判定。

0 人点赞