上一节,我们学习了TCP协议的服务器-客户端的编程流程以及对中间的过程进行了详细的讨论,那么,这一节,我们对于TCP协议的特点进行进一步的分析,这也是面试的重点和难点。
目录
4.2 同一个端口可不可以被一个 TCP 和一个 UDP 的应用程序同时使用?
一、TCP 协议特点
通过前面的学习,我们知道:TCP 协议提供的是:面向连接、可靠的、字节流服务。
1.1 连接的建立与断开
使用 TCP 协议通信的双发必须先建立连接(三次握手),然后才能开始数据的读写。双方都必须为该连接分配必要的内核资源,以管理连接的状态和连接上数据的传输。TCP 连接是全双工的,双方的数据可以通过一个连接进行读写。完成数据交换之后,通信双方都必须断开连接以释放系统资源(四次挥手)。 使用 tcpdump 抓包命令可以抓包观察 TCP 连接的建立与关闭。该命令需要管理员权限,格式如下(假设两个测试用的主机 IP 地址为 192.168.43.214 和 192.168.43.160 ) :
三次握手发生在客户端执行 connect()的时候,该方法返回成功,则说明三次握手已经建 立。三次握手示例图如下:
四次挥手发生在客户端或服务端执行 close()关闭连接的时候,示例图如下:
1.1.1 面试题
1、四次挥手的过程可以用三次完成吗?
2、挥手时,可能受到什么样的攻击?
3、为什么是三次握手,可不可以是两次为什么?
4、三次握手时可能出现什么攻击?
1.2 TCP 状态转移(面试题)
下图是 TCP 连接从建立到关闭整个过程中通信两端状态的变化。tcp状态的改变是在建立连接和断开连接的基础上的 ,其中 CLOSED 是假想的起始点,并不是一个实际的状态。这种状态变化就好比我们打电话通话处于不同的状态,但是只要双方拨通了电话,那么就一直是通话中。只有在拨打电话和挂断电话时,状态会发生变化。
上图中,TIME_WAIT 状态一般情况下是主动关闭的一端才会出现的状态。该状态出现后,会维持一段长为 2MSL(Maximum Segment Life)的时间,才能完全关闭。MSL 是 TCP 报文 段在网络中的最大生存时间,标准文档 RFC1122 的建议值是 2min。 在 Linux 系统上,一个 TCP 端口不能被同时打开多次(两次及以上)。当一个 TCP 连接 处于 TIME_WAIT 状态时,我们将无法立即使用该连接占用着的端口来建立一个新连接,必须要等待这两分钟过去,才能继续使用这个端口。
如上图所示:服务器会跟很多客户端有连接,每个连接都有自己的状态。每一个连接都会有自己的接收缓冲区和发送缓冲区。
面试题:
题目:
一个局域网内,有一个客户端一个服务器,他们都已完成三次握手状态,没有发送数据,此时拔掉网线,服务器再close(),重新运行服务器,运行之后在插上网线,问此时客户端跟服务器的状态。
1.3 流式服务特点
TCP 字节流的特点,发送端执行的写操作次数和接收端执行的读操作次数之间没有任何数量关系,应用程序对数据的发送和接收是没有边界限制的。多次发送的数据会被对方一次接受,或者一次发送的数据被对方,分多次接受。
修改循环收发的服务器端的代码如下:
char buff[128]={0};
recv(sockfd,buff,1,0);
/*一次只接收一个字符*/
客户端发个hello,服务器将接收字符个数改成1,出现的结果是:循环5次把hello打印完,直到把buff里的数据打印完。客服端那里会一次收到5个ok。
1.4 应答确认与超时重传
TCP 发送的报文段是交给 IP 层传送的。但 IP 层只能提供尽最大努力的服务,也就是说,TCP 下面的网络所提供的是不可靠的传输。因此,TCP 必须采用适当的措施才能使两个运输层之间的通信变得可靠。TCP 的可靠传输是通过使用应答确认和超时重传来完成。下图是通过 netstat 命令抓包看到的信息:
面试题:
下图是无差错时,数据交互的流程:发送端发送数据 m1 给接收端,接收端收到数据后会给发送端一个确认信息,以表明数据已经被成功收到。在发送方未收到确认信息前,M1 应继续被保留,直到确认信息到达才能丢弃。
下图是出现差错时,数据交互的流程:
1.5 滑动窗口
TCP 协议是利用滑动窗口实现流量控制的。一般来说,我们总是希望数据传输得更快一些,不会一次只发一个字节。但是如果发送方把数据发得过快,接受方就可能来不及接收, 这就会造成数据的丢失。所谓流量控制就是让发送方的发送速率不要太快,要让接收方来的及接收。在 TCP 的报头中有一个字段叫做接收通告窗口,这个字段由接收端填充,是接收端告诉发送端自己还有多少缓冲区可以接收数据。于是发送端就可以根据这个接收端的处理能力来发送数据,而不会导致接收端处理不过来。所以发送端就会有一个发送窗口,这个发送窗口的大小是由接收端填充的接收通告窗口的大小决定的,并且窗口的位置会随着发送端数据的发送和接收到接收端对数据的确认而不断的向右滑动,将之称为滑动窗口。发送方的滑动窗口示意图如下:
当收到 36 的 ack,并发出 46-51 的字节后,窗口滑动的示意图如下:
二、多进程、多线程处理并发
如下图所示, 当一个客户端与服务器建立连接以后,服务器端 accept()返回,进而准备循环接收客户端发过来的数据。如果客户端暂时没发数据,服务端会在第 40 行的 recv()阻 塞。此时,其他客户端向服务器发起连接后,由于服务器阻塞了,无法执行 accept()接受连 接,也就是其他客户端发送的数据,服务器无法读取。服务器也就无法并发同时处理多个客户端。
这个问题可以通过引入多线程和多进程来解决。服务端接受一个客户端的连接后,创建 一个线程或者进程,然后在新创建的线程或进程中循环处理数据。主线程(父进程)只负责监听客户端的连接,并使用 accept()接受连接,不进行数据的处理。如下图所示:
多线程处理并发的服务器端示例代码 MultiThread.c 如下:主线程负责监听端口和接受客户端连接,每接受到一个客户端连接后,就创建一个新线程来处理该客户端的通信。每个子线程会循环接收客户端发送的数据,并回复一个确认消息"ok"。当客户端断开连接时,子线程会关闭相应的客户端套接字并退出。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <assert.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <pthread.h>
// 线程函数,用来处理单个客户端的收发数据
void* fun(void * arg)
{
int c = (int)arg; // 将传入的参数转换为整数类型的客户端套接字描述符
while( 1 )
{
char buff[128] = {0}; // 用于接收数据的缓冲区
// 接收客户端发送的数据,如果接收失败或连接关闭,则退出循环
if ( recv(c, buff, 127, 0) <= 0 )
{
break;
}
printf("recv(%d)=%s\n", c, buff); // 打印接收到的数据
send(c, "ok", 2, 0); // 发送确认消息给客户端
}
printf("one client over(%d)\n", c); // 打印客户端连接结束的消息
close(c); // 关闭客户端连接
}
int main()
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0); // 创建套接字
assert(sockfd != -1); // 确认套接字创建成功
struct sockaddr_in saddr, caddr; // 定义服务器和客户端的地址结构
memset(&saddr, 0, sizeof(saddr)); // 将服务器地址结构清零
saddr.sin_family = AF_INET; // 设置地址族为AF_INET
saddr.sin_port = htons(6000); // 设置端口号为6000,并转换为网络字节序
saddr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 设置IP地址为127.0.0.1
int res = bind(sockfd, (struct sockaddr*)&saddr, sizeof(saddr)); // 绑定套接字到指定的IP地址和端口
assert(res != -1); // 确认绑定成功
listen(sockfd, 5); // 开始监听,最大连接数为5
while( 1 )
{
int len = sizeof(caddr); // 客户端地址结构长度
// 接受客户端连接请求,返回客户端套接字描述符
int c = accept(sockfd, (struct sockaddr*)&caddr, &len);
if ( c < 0 )
{
continue; // 如果接受失败,继续等待下一个连接
}
printf("accept c = %d\n", c); // 打印接受到的客户端套接字描述符
pthread_t id; // 定义线程id
// 创建子线程处理客户端连接,传入客户端套接字描述符作为参数
pthread_create(&id, NULL, fun, (void*)c);
}
close(sockfd); // 关闭服务器套接字
exit(0); // 退出程序
}
多进程处理并发的服务器端示例代码 MultiProcess.c 如下:主进程负责监听端口和接受客户端连接,每接受到一个客户端连接后,创建一个子进程来处理该客户端的通信。子进程会循环接收客户端发送的数据,并回复一个确认消息"OK"。当客户端断开连接时,子进程会关闭相应的客户端套接字并退出。主进程通过捕捉SIGCHLD信号来处理子进程退出,防止产生僵尸进程。
#include <stdio.h>
#include <stdlib.h>
#include <assert.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <signal.h>
// 处理客户端连接的函数
void DealClientLink(int c, struct sockaddr_in caddr)
{
while (1)
{
char buff[128] = {0}; // 用于接收数据的缓冲区
int n = recv(c, buff, 127, 0); // 接收客户端发送的数据
if (n <= 0) // 如果接收失败或客户端关闭连接,则退出循环
{
break;
}
// 打印客户端发送的数据,包括客户端的IP地址和端口号
printf("%s:%d %s\n", inet_ntoa(caddr.sin_addr), ntohs(caddr.sin_port), buff);
send(c, "OK", 2, 0); // 发送确认消息给客户端
}
printf("one client unlink\n"); // 打印客户端断开连接的消息
close(c); // 关闭客户端连接
}
// 信号处理函数,用于处理子进程退出时的SIGCHLD信号
void sigfun(int sign)
{
wait(NULL); // 等待子进程结束,防止僵尸进程
}
int main()
{
signal(SIGCHLD, sigfun); // 注册SIGCHLD信号处理函数,处理僵尸进程
int sockfd = socket(AF_INET, SOCK_STREAM, 0); // 创建套接字
assert(-1 != sockfd); // 确认套接字创建成功
struct sockaddr_in saddr; // 定义服务器的地址结构
memset(&saddr, 0, sizeof(saddr)); // 将服务器地址结构清零
saddr.sin_family = AF_INET; // 设置地址族为AF_INET
saddr.sin_port = htons(6000); // 设置端口号为6000,并转换为网络字节序
saddr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 设置IP地址为127.0.0.1
int res = bind(sockfd, (struct sockaddr*)&saddr, sizeof(saddr)); // 绑定套接字到指定的IP地址和端口
assert(-1 != res); // 确认绑定成功
listen(sockfd, 5); // 开始监听,最大连接数为5
while (1)
{
struct sockaddr_in caddr; // 定义客户端的地址结构
int len = sizeof(caddr); // 客户端地址结构长度
int c = accept(sockfd, (struct sockaddr*)&caddr, &len); // 接受客户端连接请求,返回客户端套接字描述符
assert(-1 != c); // 确认接受成功
// 打印接受到的客户端连接成功的消息,包括客户端的IP地址和端口号
printf("%s:%d Link Success\n", inet_ntoa(caddr.sin_addr), ntohs(caddr.sin_port));
pid_t pid = fork(); // 创建子进程
assert(-1 != pid); // 确认子进程创建成功
if (0 == pid)
{
DealClientLink(c, caddr); // 子进程处理客户端连接
exit(0); // 必须结束子进程,否则会有多个进程调用accept
}
else
{
close(c); // 父进程关闭客户端连接描述符
}
}
close(sockfd); // 关闭服务器套接字
exit(0); // 退出程序
}
客户端代码 TcpClient.c 如下:客户端首先创建一个套接字,然后连接到指定IP地址和端口号的服务器。连接成功后,客户端进入一个循环,从标准输入获取用户输入的数据,并将其发送到服务器。随后,客户端接收服务器的响应并打印出来。如果用户输入"end",客户端会退出循环,关闭套接字并结束程序。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <assert.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main()
{
int sockfd = socket(AF_INET, SOCK_STREAM, 0); // 创建套接字
assert(sockfd != -1); // 确认套接字创建成功
struct sockaddr_in saddr; // 定义服务器的地址结构
memset(&saddr, 0, sizeof(saddr)); // 将服务器地址结构清零
saddr.sin_family = AF_INET; // 设置地址族为AF_INET
saddr.sin_port = htons(6000); // 设置端口号为6000,并转换为网络字节序
saddr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 设置IP地址为127.0.0.1
int res = connect(sockfd, (struct sockaddr*)&saddr, sizeof(saddr)); // 连接到服务器
assert(res != -1); // 确认连接成功
while (1)
{
char buff[128] = {0}; // 用于存储用户输入的缓冲区
printf("input:\n"); // 提示用户输入
fgets(buff, 128, stdin); // 从标准输入获取用户输入
if (strncmp(buff, "end", 3) == 0) // 如果用户输入"end",则退出循环
{
break;
}
send(sockfd, buff, strlen(buff), 0); // 发送用户输入的数据到服务器
memset(buff, 0, 128); // 清空缓冲区
recv(sockfd, buff, 127, 0); // 接收服务器的响应
printf("buff=%s\n", buff); // 打印服务器的响应
}
close(sockfd); // 关闭套接字
exit(0); // 退出程序
}
三、UDP协议
3.1 UDP协议编程流程
UDP 提供的是无连接、不可靠的、数据报服务。可以通俗的将TCP理解成打电话,UDP理解成发短信。
socket()用来创建套接字,使用 udp 协议时,选择数据报服务 SOCK_DGRAM。sendto() 用来发送数据,由于 UDP 是无连接的,每次发送数据都需要指定对端的地址(IP 和端 口)。recvfrom()接收数据,每次都需要传给该方法一个地址结构来存放发送端的地址。 recvfrom()可以接收所有客户端发送给当前应用程序的数据,并不是只能接收某一个客户端的数据。
UDP 服务端编程示例代码:
1. #include <stdio.h>
2. #include <stdlib.h>
3. #include <unistd.h>
4. #include <string.h>
5. #include <assert.h>
6. #include <sys/socket.h>
7. #include <netinet/in.h>
8. #include <arpa/inet.h>
9.
10. int main()
11. {
12. int sockfd = socket(AF_INET,SOCK_DGRAM,0);
13. assert( sockfd != -1 );
14.
15. struct sockaddr_in saddr,caddr;
16. memset(&saddr,0,sizeof(saddr));
17. saddr.sin_family = AF_INET;
18. saddr.sin_port = htons(6000);
19. saddr.sin_addr.s_addr = inet_addr("127.0.0.1");
20.
21. int res = bind(sockfd,(struct sockaddr*)&saddr,sizeof(saddr));
22. assert( res != -1 );
23.
24. while( 1 )
25. {
26. int len = sizeof(caddr);
27. char buff[128] = {0};
28. recvfrom(sockfd,buff,127,0,(struct sockaddr*)&caddr,&len);
29. printf("ip:%s,port:%d,buff=%s\n",inet_ntoa(caddr.sin_addr), ntohs(caddr.si
n_port),buff );
30.
31. sendto(sockfd,"ok",2,0,(struct sockaddr*)&caddr,sizeof(caddr));
32. }
33.
34. close(sockfd);
35. }
UDP 客户端编程示例代码:
1. #include <stdio.h>
2. #include <stdlib.h>
3. #include <unistd.h>
4. #include <string.h>
5. #include <assert.h>
6. #include <sys/socket.h>
7. #include <netinet/in.h>
8. #include <arpa/inet.h>
9.
10. int main()
11. {
12. int sockfd = socket(AF_INET,SOCK_DGRAM,0);
13. assert( sockfd != -1 );
14.
15. struct sockaddr_in saddr;
16. memset(&saddr,0,sizeof(saddr));
17. saddr.sin_family = AF_INET;
18. saddr.sin_port = htons(6000);
19. saddr.sin_addr.s_addr = inet_addr("127.0.0.1");
20.
21. while( 1 )
22. {
23. char buff[128] = {0};
24. printf("input:\n");
25.
26. fgets(buff,128,stdin);
27.
28. if ( strncmp(buff,"end",3) == 0 )
29. {
30. break;
31. }
32.
33. sendto(sockfd,buff,strlen(buff),0,(struct sockaddr*)&saddr,sizeof(saddr));
34. memset(buff,0,128);
35.
36. int len = sizeof(saddr);
37. recvfrom(sockfd,buff,127,0,(struct sockaddr*)&saddr,&len);
38.
39. printf("buff=%s\n",buff);
40. }
41.
42. close(sockfd);
43. }
启动服务端和客户端,再关掉服务端,还能再发送数据嘛?可以 因为udp是无连接的,只要服务端启动,有人发数据就接受。关掉服务端对客户端来说,丝毫没有影响
3.2 UDP 协议特点
UDP 数据报服务特点:发送端应用程序每执行一次写操作,UDP 模块就将其封装成一 个 UDP 数据报发送。接收端必须及时针对每一个 UDP 数据报执行读操作,否则就会丢包,因此它不会出现粘包现象。 并且,如果用户没有指定足够的应用程序缓冲区来读取 UDP 数据,则 UDP 数据将被截断。
3.3 应用场景
tcp和udp应用分场景,例如下载一个文件,肯定是要完整的下载下来,数据不能丢失。如果实时通话视频时,那就用udp,因为只是要看当下的你,如果视频过程中网不好,数据没发出去,再重新发,这样慢慢的就会变成录屏,因为tcp有接收缓冲区,重新发的数据都会被对方,接收到接受缓冲区,对方要全部读完,所以这一帧数据没发送成功就不要了。
四、面试题
4.1 TCP和UDP的区别
4.2 同一个端口可不可以被一个 TCP 和一个 UDP 的应用程序同时使用?
4.3 同一个应用程序可以创建多个套接字吗?
至此,已经讲解完毕!篇幅较长,慢慢消化,以上就是全部内容!请务必掌握,创作不易,欢迎大家点赞加关注评论,您的支持是我前进最大的动力!下期再见!