套接字内部原理

起初我觉得学习套接字并不需要知道内部的通信原理,因为这些都是由系统来处理,但是随着后来的深入我发现了这个错误的想法,后面有关套接字的相关设置都与内部通信原理息息相关(包括后来的设置套接字可选项、套接字 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 连接时的三次握手

TCP建立的三次握手.png

步骤:

连接三次握手为了防止已失效的连接请求报文段突然又传到了服务器,导致服务器误认为客户端想请求连接而发生连接的错误 举个例子:在两次握手的前提下,Client 发出连接请求,但因为丢失了,故而不能收到 Server 的确认。于是 Client 重新发出请求,然后收到确认,建立连接,数据传输完毕后,释放连接,Client 发了 2 个,但是,某种情况下,Client 发出的第一个连接请求在某个节点滞留了,延误到达 Server。假设此时 Server 已经释放连接,那么 Server 在收到此实现的连接请求后,就误认为 Client 又发出一次连接请求,在两次握手的情况下(Client 发生请求,Server 接受请求并确认),Server 就认为 Client 又发出一次新连接请求。此时 Server 就又给 Client 发生一个确认,表示同意建立连接。因为是两次握手,Client 收到后,也不再次发出确认连接。此时 Server 会等待 Client 发送的数据,而 Client 本来就没有要求发送数据,肯定也无动于衷。此时 Server 的资源就被浪费了。

1.2 TCP 数据交换

通过第一步的三次握手过程完成数据交换,下面就正式开始收发数据,其默认方式如下图

TCP数据交换工作原理.png

如图:客户端分两次向服务器传递了 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 的断开也是非常优雅的,如下图

TCP断开四次握手.png

大概流程简单翻译:

如上图所示,数据包内的 FIN 表示断开连接,也就是说双方各发送 1 次 FIN 消息后断开连接,此过程经理 4 个阶段,因此又称四次握手,SEQ 和 ACK 前面已经做过解释,故省略。

注意:

服务器收到客户端连 FIN 报文段后就立即发送确认,然后就进入 close-wait 状态,此时 TCP 服务器进程就通知高层应用进程,此时是“半关闭”状态。即客户端不可以发送数据到服务器,但是服务器可以发送数据给客户端。

此时,若服务器没有数据报要发送给客户端了,其应用进程就通知 TCP 释放连接,然后发送给客户端 FIN 报文段,并等待确认。 客户端发送确认后,进入 time-wait,注意,此时 TCP 连接还没有释放掉,然后经过时间等待计时器设置的 2MSL 后,客户端才进入到 close 状态。 为什么要等待呢?

①、为了保证客户端发送的最后一个 ACK 报文段能够到达服务器。即最后这个确认报文段很有可能丢失,那么服务器会超时重传,然后客户端再一次确认,同时启动 2MSL 计时器,如此下去。如果没有等待时间,发送完确认报文段就立即释放连接的话,服务器就无法重传了(连接已被释放,任何数据都不能出传了),因而也就收不到确认,就无法按照步骤进入 CLOSE 状态,即必须收到确认才能 close,流程看下图

TCP断开Wait-Time.png

②、防止“已失效的连接请求报文段”出现在连接中。经过 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 函数调用瞬间,从输入缓冲区读取数据,入下图所示

TCP套接字的I/O缓冲区.png

如图所示,调用 send 函数将数据移至缓冲区,在适当的时候(不管是分批传送还是一次性传送)传向对方的输入缓冲区,这个时对方将调用 recv 函数从自己的输入缓冲队列里面读取对方发送过来的数据。

特性

三、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()函数发发送数据的流程大概如下

每次调用sendto()函数,每次都重复上面步骤变更目标地址,因此可以利用同一个 UDP 套接字向不同的地址发送数据,这种未注册目标地址信息的套接字称为未连接套接字,相反注册过地址的套接字称为连接套接字,显然 UDP 默认套接字为未连接套接字。

在平时开发中有这么一种情况,需要向同一个地址连续发送 UDP 数据,比方说前面的《TFTP 服务器和 TFTP 客户端》,需要向同一个客户端地址连续发送数据包和接收该地址发送过来的确认包,这个时候如果还用无连接模式发送数据,上述的第一个阶段和第三个阶段占整个通信过程近 1/3 的时间。

创建已连接 UDP 套接字

创建已连接 UDP 套接字,跟前面创建未连接 UDP 套接字步骤一样,只不过多调用了 connect 函数,创建步骤如下:

针对 UDP 套接字调用connect()函数并不意味着要与对方套接字连接,只是向 UDP 套接字注册目标 IP 和端口信息,之后就与 TCP 套接字一样,每次调用sendto()函数时只需传输数据,因为已经指定了接收对象,所以不仅可以使用sendto()recvfrom函数,还可以使用send()recv()函数进行数据传送和读取。

五、尾声

接下来的文章会继续更新有关套接字的详解,这是我起初学习的一个流程,希望也对大家有帮助!