起初我觉得学习套接字并不需要知道内部的通信原理,因为这些都是由系统来处理,但是随着后来的深入我发现了这个错误的想法,后面有关套接字的相关设置都与内部通信原理息息相关(包括后来的设置套接字可选项、套接字I/O缓存),今天一起来探究一下套接字的内部的通信原理
上一篇我们聊了Socket连接创建及通信流程的一些基本函数 《套接字(Socket)编程(一) 函数概念篇》,这篇我们将来聊一聊TCP的连接和断开、TCP套接字I/O缓冲区、UDP套接字I/O缓冲区、UDP套接字的无连接和有连接模式
关于下面说到的TCP套接字连接握手、断开握手以及中间的数据交互涉及到的 SYN、ACK、FIN、SEQ等关键词请参考我后面一片文章《TCP/IP 协议及数据格式》,文章 “3.2 TCP协议数据报的头”部分,里面有详细的讲解;关于连接、断开和通信过程中ACK和SEQ的数值变化以及交互的细节可以参考这篇文章里面的 “3.3 TCP通信数据交互细节和实践”部分,里面有详细的数据抓取和讲解。
一、TCP连接的三次握手和断开的四次握手
在讲到下面函数之前,我们不得不先说下连接过程和端口的过程,虽然我们用C代码写连接的时候看不到握手的过程,但是里面的参数我们是可以通过相关函数调用进行设置,后面讲的函数很多关乎到这里面的参数设置
首先来说下TCP套接字连接进程中的10种状态
状态 | 描述 |
---|---|
LISTEN | 侦听来自远端TCP协议端口的连接请求 |
SYN-SENT | 发送连接请求后,等待匹配的连接请求 |
SYN-RECEIVED | 收到和发送一个连接请求后,等待确认 |
ESTABLISHED | 连接已经打开,可以发送或接收数据 |
FIN-WAIT-1 | 发送了中断连接请求,等待对方确认 |
FIN-WAIT-2 | 收到对方对于中断请求的确认,等待对方的中断请求 |
TIME-WAIT | 等待足够的时间,以保证远端收到连接中断请求的确认 |
CLOSE-WAIT | 等待本地应用层发来中断请求 |
LAST-ACK | 等待远端TCP协议对连接中断的确认 |
CLOSED | 没有任何连接 |
1.1 TCP连接时的三次握手
步骤:
①第一条消息为SYN消息Synchronization
同步消息,表示收发数据前传输的同步消息
SEQ = x
:表示现在传递的数据包的序号为x,如果接收无误,请通知我向你传递x+1号数据包
②接着服务器会回复SYN+ACK类型数据消息,服务器对客户端首次传输的数据包确认(ACK)和服务器传输数据做准备的同步消息(SYN)捆绑发送
ACK = x+1
:表示刚才客户端的序号为x的数据包接收无误,接下来请传输序号为x+1的数据包
SEQ = y
:表示现传递的数据包序号为y,如果接收无误,请通知我向你传输y+1号数据包
③客户端回复服务器ACK消息
SEQ = x+1
:表示向服务器传递序号为x+1数据包
ACK = y+1
:表示接受服务器端的序号为y的数据包接收无误,接下来可以传输序号为y+1的数据包
连接三次握手为了防止已失效的连接请求报文段突然又传到了服务器,导致服务器误认为客户端想请求连接而发生连接的错误
举个例子:在两次握手的前提下,Client发出连接请求,但因为丢失了,故而不能收到Server的确认。于是Client重新发出请求,然后收到确认,建立连接,数据传输完毕后,释放连接,Client发了2个,但是,某种情况下,Client发出的第一个连接请求在某个节点滞留了,延误到达Server。假设此时Server已经释放连接,那么Server在收到此实现的连接请求后,就误认为Client又发出一次连接请求,在两次握手的情况下(Client发生请求,Server接受请求并确认),Server就认为Client又发出一次新连接请求。此时Server就又给Client发生一个确认,表示同意建立连接。因为是两次握手,Client收到后,也不再次发出确认连接。此时Server会等待Client发送的数据,而Client本来就没有要求发送数据,肯定也无动于衷。此时Server的资源就被浪费了。
1.2 TCP数据交换
通过第一步的三次握手过程完成数据交换,下面就正式开始收发数据,其默认方式如下图
如图:客户端分两次向服务器传递了200字节的过程,首先客户端通过一个数据包发送100个字节,数据包的SEQ为1200,服务器为了确认这一点,向客户端发送ACK为1300消息。
此时ACK号为1300而非1200,也不是1301,原因在于ACK号的增量为传递的数据字节数,假设每次ACK号不加传输的字节,这样虽然可以确认数据包的传输,但无法确认100字节全部正确传递还是丢失了一部分,比如只传递了80个字节,按公式计算传递ACK消息ACK号 = SEQ号 + 传递的字节数
。
网上和书上很多说法说这里ACK值的计算应该是ACK号 = SEQ号 + 传递的字节数 + 1
,实际这种说法是不准确的,在连接的三次握手和断开的4次握手里面,每次传输数据长度为0(这些数据报只有报头),回复确认都是ACK号 = 接收到数据报的SEQ号 + 1
,但是除了这特别的情况外(连接完成,正常通信过程中),每个数据报的SEQ都是这段TCP数据首个字节的序号,按上面的图来说,第一次传输时SEQ = 1200,其实序列号1200也是该段数据第一个字节的序号,那么第二个字节的序号就是1201,传输了100个字节,该数据段的最后一个字节的序号应该是1299,下次需要传输的数据要从1300开始,所以服务器回复的确认号是1300,而不是1301,这里需要注意下。
客户端在规定时间没有收到服务器的确认消息,则认为丢失,然后重传
2. 断开时的四次握手
对于TCP的断开也是非常优雅的,如下图
大概流程简单翻译:
客户端:“我希望断开连接”
服务器:“哦,是吗?请稍等”
服务器:“我也准备就绪,可以断开连接”
客户端:“好的,谢谢合作”
如上图所示,数据包内的FIN表示断开连接,也就是说双方各发送1次FIN消息后断开连接,此过程经理4个阶段,因此又称四次握手,SEQ和ACK前面已经做过解释,故省略。
注意:
服务器收到客户端连FIN报文段后就立即发送确认,然后就进入close-wait状态,此时TCP服务器进程就通知高层应用进程,此时是“半关闭”状态。即客户端不可以发送数据到服务器,但是服务器可以发送数据给客户端。
此时,若服务器没有数据报要发送给客户端了,其应用进程就通知TCP释放连接,然后发送给客户端FIN报文段,并等待确认。
客户端发送确认后,进入time-wait,注意,此时TCP连接还没有释放掉,然后经过时间等待计时器设置的2MSL后,客户端才进入到close状态。
为什么要等待呢?
①、为了保证客户端发送的最后一个ACK报文段能够到达服务器。即最后这个确认报文段很有可能丢失,那么服务器会超时重传,然后客户端再一次确认,同时启动2MSL计时器,如此下去。如果没有等待时间,发送完确认报文段就立即释放连接的话,服务器就无法重传了(连接已被释放,任何数据都不能出传了),因而也就收不到确认,就无法按照步骤进入CLOSE状态,即必须收到确认才能close,流程看下图
②、防止“已失效的连接请求报文段”出现在连接中。经过2MSL,那些在这个连接持续的时间内,产生的所有报文段就可以都从网络中消失。即在这个连接释放的过程中会有一些无效的报文段滞留在楼阁结点,但是呢,经过2MSL这些无效报文段就肯定可以发送到目的地,不会滞留在网络中。这样的话,在下一个连接中就不会出现上一个连接遗留下来的请求报文段了。
可以看出:服务器结束TCP连接的时间比客户端早一点,因为服务器收到确认就断开连接了,而客户端还得等待Time-Wait,虽然这个Time-Wait看似重要,但是在实际开发中并不那么讨人喜欢,后面的一片文章里面有介绍怎么去掉这个Time-Wait的等待(套接字(Socket)编程(三) 套接字可选项)。
上面经过四次握手断开的属于正常断开,经过四次握手双方都知道连接断开了,但是平时通信的过程中有各种原因会造成异常断开,比方说服务器断电或者客户端断电...一般的TCP通信中有两种方式来解决这个异常:①、自己在应用层定时发送心跳包来判断连接是否正常,此方法比较通用,灵活可控,但改变了现有的协议;②、使用TCP的keepalive机制,TCP协议自带的保活功能,使用起来简单,减少了应用层代码的复杂度, 推测也会更节省流量,因为一般来说应用层的数据传输到协议层时都会被加上额外的包头包尾,由TCP协议提供的检活,其发的探测包,理论上实现的会更精妙(用更少的字节完成更多的目标),耗费更少的流量;具体请看后面文章实现,这里先不做深聊
二、TCP套接字中的I/O缓冲区
如当前所述,TCP套接字收发无边界,服务器端调用1次send函数传输40个字节,客户端也能通过4次调用recv函数每次读10个字节,那么这个时候问题就来了,服务器一次发送了40个字节的数据,而客户端则可以缓慢分批读取,客户端读取了10个字节后剩余的30个字节的数据在什么地方呢?
实际上调用send函数不是立即发送数据,调用recv函数也并非立马接收数据,更精确的讲,send函数调用瞬间,将数据移至输出缓冲区,recv函数调用瞬间,从输入缓冲区读取数据,入下图所示
如图所示,调用send函数将数据移至缓冲区,在适当的时候(不管是分批传送还是一次性传送)传向对方的输入缓冲区,这个时对方将调用recv函数从自己的输入缓冲队列里面读取对方发送过来的数据。
特性
- I/O缓冲区在每个套接字中单独存在
- I/O缓冲区在创建套接字时自动生成
- 即使关闭套接字,也可以继续传送输入缓冲区里面的数据
- 关闭套接字将丢失输入缓冲区里面的数据
三、UDP套接字中的I/O缓冲区
前面说过TCP数据传输中不存在边界,这表示数据传输过程中调用I/O函数的次数不具有任何意义,相反UDP是具有数据边界的协议,传输中调用I/O函数的次数非常重要,因为输入函数的调用次数应和输出函数的调用次数完全一致,这样才能保证接收全部已发送数据,例如调用了3次输出函数发送数据,就必须调用3次输入函数才能完成接收,下面通过简单的例子来印证下
发送UDP数据端代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <errno.h>
int sendPacket(void);
int main(int argc, const char * argv[]) {
@autoreleasepool {
int send_result = sendPacket();
if (send_result == 0) printf("开启发送失败\n");
}
return 0;
}
#pragma mark ---广播
int sendPacket()
{
printf("请输入UDP数据传送IP地址:");
char ip[INET_ADDRSTRLEN];
scanf("%s",ip);
int send_sock;
send_sock = socket(AF_INET, SOCK_DGRAM, 0);
if (send_sock < 0) return 0;
struct sockaddr_in addr;
addr.sin_len = sizeof(struct sockaddr_in);
addr.sin_family = AF_INET;
addr.sin_port = htons(2001);
inet_aton(ip, &addr.sin_addr);
printf("开始连续三次发送数据\n");
char msg1[] = "Hi";
char msg2[] = "Hello";
char msg3[] = "Nice to meet you";
sendto(send_sock, msg1, sizeof(msg1), 0, (struct sockaddr*)&addr, addr.sin_len);
sendto(send_sock, msg2, sizeof(msg2), 0, (struct sockaddr*)&addr, addr.sin_len);
sendto(send_sock, msg3, sizeof(msg3), 0, (struct sockaddr*)&addr, addr.sin_len);
printf("关闭UDP套接字\n");
close(send_sock);
return 1;
}
接收UDP数据端代码
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <errno.h>
int recvPacket(void);
int main(int argc, const char * argv[]) {
@autoreleasepool {
int recv_result = recvPacket();
if (recv_result == 0) printf("开启接收失败\n");
}
return 0;
}
#pragma mark ---UDP数据接收端
int recvPacket()
{
int recv_sock;
recv_sock = socket(AF_INET, SOCK_DGRAM, 0);
if (recv_sock < 0) return 0;
struct sockaddr_in addr,recv_addr;
socklen_t len = sizeof(struct sockaddr_in);
addr.sin_family = AF_INET;
addr.sin_len = sizeof(struct sockaddr_in);
addr.sin_port = htons(2001);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(recv_sock, (struct sockaddr*)&addr, addr.sin_len) < 0) {
printf("%s\n",strerror(errno));
return 0;
}
printf("开始接收UDP数据\n");
char recv_buffer[512];
for (int i = 0; i<3; i++) {
sleep(5);
memset(recv_buffer, 0, 512);
ssize_t recv_len = recvfrom(recv_sock,
recv_buffer,
sizeof(recv_buffer),
0,
(struct sockaddr*)&recv_addr,
&len);
if (recv_len <= 0) {
printf("接收数据失败:%s\n",strerror(errno));
break;
}
printf("recv: %s\n",recv_buffer);
}
printf("关闭UDP套接字\n");
close(recv_sock);
return 1;
}
UDP发送端打印
/**
* 请输入UDP数据传送IP地址:10.22.70.99
* 开始连续三次发送数据
* 关闭UDP套接字
*/
UDP接收端打印
/**
* 开始接收UDP数据
* recv: Hi
* recv: Hello
* recv: Nice to meet you
* 关闭UDP套接字
*/
结论
从上面的例子可以看出,发送端连续发送了三个UDP数据包,而接收端轮循了三次去接收,每次接收到都等待5s再进行下次数据读取,也就是说接收端在读第一次数据的时候,其实输入缓冲区里面已经有三组数据,但是他只读取了最前面的一组,这正好说明了发送端和接收端在传输数据的过程中调用I/O数据次数要一致才能将全部数据读取完
解释
UDP套接字传输的数据包又称数据报,实际上数据报也属于数据包的一种,只是与TCP包不同,其本身可以成为一个完整数据,这与UDP数据传输特性有关,UDP中存在数据边界,1个数据包即可以成为1个完整数据,因此称为数据报。
四、UDP套接字的连接(connected)和非连接(unconnected)
TCP套接字传输数据需要注册目的地址的IP和端口号信息,而UDP中则无需注册,于是通过sendto()
函数发发送数据的流程大概如下
① 向UDP套接字中注册目标IP和端口号
② 传输数据
③ 删除套接字中注册的IP和端口号
每次调用sendto()
函数,每次都重复上面步骤变更目标地址,因此可以利用同一个UDP套接字向不同的地址发送数据,这种未注册目标地址信息的套接字称为未连接套接字,相反注册过地址的套接字称为连接套接字,显然UDP默认套接字为未连接套接字。
在平时开发中有这么一种情况,需要向同一个地址连续发送UDP数据,比方说前面的《TFTP服务器和TFTP客户端》,需要向同一个客户端地址连续发送数据包和接收该地址发送过来的确认包,这个时候如果还用无连接模式发送数据,上述的第一个阶段和第三个阶段占整个通信过程近1/3的时间。
创建已连接UDP套接字
创建已连接UDP套接字,跟前面创建未连接UDP套接字步骤一样,只不过多调用了connect函数,创建步骤如下:
int sock = socket(AF_INET, SOCK_DGRAM, 0);
addr.sin_family = AF_INET;
addr.sin_port = ....
addr.sin_addr.s_addr = ...
connect(sock, (struct sockaddr*)&addr, sizeof(addr));
针对UDP套接字调用connect()
函数并不意味着要与对方套接字连接,只是向UDP套接字注册目标IP和端口信息,之后就与TCP套接字一样,每次调用sendto()
函数时只需传输数据,因为已经指定了接收对象,所以不仅可以使用sendto()
、recvfrom
函数,还可以使用send()
、recv()
函数进行数据传送和读取。
五、尾声
接下来的文章会继续更新有关套接字的详解,这是我起初学习的一个流程,希望也对大家有帮助!