TCP(Transmission Control Protocol,传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。它是互联网协议套件中的核心协议之一,由IETF的RFC 793定义。TCP提供了一种全双工通信方式,确保数据的顺序性、完整性和可靠性。TCP通过三次握手建立连接,并在数据传输过程中使用序列号、确认应答、重传机制、流量控制和拥塞控制等技术来维护通信的可靠性。在TCP连接的生命周期中,还包括数据传送和连接终止两个阶段。数据传送阶段中,TCP使用滑动窗口机制来控制发送速率,避免接收方缓冲区溢出。连接终止阶段则通过四次挥手来优雅地关闭连接。
TCP(Transmission Control Protocol,传输控制协议)协议段的格式包括固定长度的首部和可变长度的数据部分。首部中包含了用于建立和维护连接、传输控制和错误检测等功能的各种字段。
固定首部字段
可变选项字段
填充(Padding)
数据部分
TCP段的首部长度最小为20字节,这是不包含任何选项时的长度。选项字段的存在使得TCP首部可以根据需要扩展,以支持不同的网络环境和应用需求。
TCP将每个字节的数据都进行了编号。即为序列号。
每一个ACK都带有对应的确认序列号,意思是告诉发送者,我已经收到了哪些数据;下一次从哪里开始发。
但是,主机A未收到B发来的确认应答,也可能是因为ACK丢失了。
因此主机B会收到很多重复数据。那么TCP协议需要能够识别出那些包是重复的包,并且把重复的丢弃掉。
这时候我们可以利用前面提到的序列号,就可以很容易做到去重的效果。
那么,如果超时的时间如何确定?
TCP为了保证无论在任何环境下都能比较高性能的通信,因此会动态计算这个最大超时时间。
在正常情况下,TCP要经过三次握手建立连接,四次挥手断开连接。
服务端状态转化:
客户端状态转化:
下图是TCP状态转换的一个汇总:
现在做一个测试,首先启动server,然后启动client,然后用Ctrl-C使server终止,这时马上再运行server,结果是:
这是因为,虽然server的应用程序终止了,但TCP协议层的连接并没有完全断开,因此不能再次监听同样的server端口。我们用netstat命令查看一下:
为什么是TIME_WAIT的时间是2MSL?
#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: %s\n", 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即可解决问题。
对每一个发送的数据段,都要给一个ACK确认应答。收到ACK后再发送下一个数据段。这样做有一个比较大的缺点,就是性能较差。尤其是数据往返的时间较长的时候。
既然这样一发一收的方式性能较低,那么我们一次发送多条数据,就可以大大的提高性能(其实是将多个段的等待时间重叠在一起了)。
那么如果出现了丢包,如何进行重传?这里分两种情况讨论。
情况一:数据包已经抵达,ACK被丢了。
这种情况下,部分ACK丢了并不要紧,因为可以通过后续的ACK进行确认。
情况二:数据包就直接丢了。
这种机制被称为"高速重发控制"(也叫"快重传")。
接收端处理数据的速度是有限的。如果发送端发的太快,导致接收端的缓冲区被打满,这个时候如果发送端继续发送,就会造成丢包,继而引起丢包重传等等一系列连锁反应。
因此TCP支持根据接收端的处理能力,来决定发送端的发送速度。这个机制就叫做流量控制(Flow Control)。
接收端如何把窗口大小告诉发送端呢?回忆TCP首部中,有一个16位窗口字段,就是存放了窗口大小信息。
那么问题来了,16位数字最大表示65535,那么TCP窗口最大就是65535字节么?
实际上,TCP首部40字节选项中还包含了一个窗口扩大因子M,实际窗口大小是窗口字段的值左移M位。
虽然TCP有了滑动窗口这个大杀器,能够高效可靠的发送大量的数据。但是如果在刚开始阶段就发送大量的数据,仍然可能引发问题。
因为网络上有很多的计算机,可能当前的网络状态就已经比较拥堵。在不清楚当前网络状态下,贸然发送大量的数据,是很有可能引起雪上加霜的。
TCP引入慢启动机制,先发少量的数据,探探路,摸清当前的网络拥堵状态,再决定按照多大的速度传输数据。
像上面这样的拥塞窗口增长速度,是指数级别的。"慢启动"只是指初使时慢,但是增长速度非常快。
少量的丢包,我们仅仅是触发超时重传;大量的丢包,我们就认为网络拥塞。
当TCP通信开始后,网络吞吐量会逐渐上升;随着网络发生拥堵,吞吐量会立刻下降。
拥塞控制,归根结底是TCP协议想尽可能快的把数据传输给对方,但是又要避免给网络造成太大压力的折中方案。
如果接收数据的主机立刻返回ACK应答,这时候返回的窗口可能比较小。
一定要记得,窗口越大,网络吞吐量就越大,传输效率就越高。我们的目标是在保证网络不拥塞的情况下尽量提高传输效率。
那么所有的包都可以延迟应答么?肯定也不是。
具体的数量和超时时间,依操作系统不同也有差异;一般N取2,超时时间取200ms。
在延迟应答的基础上,我们发现,很多情况下,客户端服务器在应用层也是"一发一收"的。意味着客户端给服务器说了"How are you",服务器也会给客户端回一个"Fine,thank you"。
那么这个时候ACK就可以搭顺风车,和服务器回应的"Fine, thank you"一起回给客户端。
创建一个TCP的socket,同时在内核中创建一个发送缓冲区和一个接收缓冲区。
由于缓冲区的存在,TCP程序的读和写不需要一一匹配,例如:
进程终止:进程终止会释放文件描述符,仍然可以发送FIN。和正常关闭没有什么区别。
机器重启:和进程终止的情况相同。
机器掉电/网线断开:接收端认为连接还在,一旦接收端有写入操作,接收端发现连接已经不在了,就会进行reset。即使没有写入操作,TCP自己也内置了一个保活定时器,会定期询问对方是否还在。如果对方不在,也会把连接释放。
另外,应用层的某些协议,也有一些这样的检测机制。例如HTTP长连接中,也会定期检测对方的状态。例如QQ,在QQ断线之后,也会定期尝试重新连接。
为什么TCP这么复杂?因为要保证可靠性,同时又尽可能的提高性能。
可靠性:
提高性能:
其他:
这些协议利用TCP的可靠性特性,如序列号、确认应答、重传机制等,来确保数据的正确顺序和完整性。在设计基于TCP的应用层协议时,开发者需要考虑如何在应用层进一步确保数据的完整性和应用程序的特定需求。
我们说了TCP是可靠连接,那么是不是TCP一定就优于UDP?TCP和UDP之间的优点和缺点,不能简单、绝对的进行比较。
归根结底,TCP和UDP都是程序员的工具,什么时机用,具体怎么用,还是要根据具体的需求场景去判定。