5.好事多磨 -- TCP网络连接Ⅱ

前言

第4章节通过回声服务示例讲解了TCP服务器端/客户端的实现方法。但这仅是从编程角度的学习,我们尚未详细讨论TCP的工作原理。因此,将详细讲解TCP中必要的理论知识,还将给出第4章节客户端问题的解决方案。

一、回声客户端完美实现

第4章节分析过回声客户端存在的问题。如果大家不太理解,请复习第2章节的TCP传输特性和第4章节的内容哦。

1.回声服务器端没有问题,只有回声客户端有问题?

问题不在服务器端,而在客户端。但只看代码也许不太好理解,因为I/O中使用了相同的函数。先回顾一下回声服务器端的I/O相关代码,下面是echo_server.c的代码。

c 复制代码
while((str_len = read(cInt_sock, message, BUF_SIZE)) != 0)
	write(clnt_sock, message, str_len);

接着回顾回声客户端代码,下面是echo_client.c的代码。

c 复制代码
write(sock, message, strlen(message));
str_len = read(sock, message, BUF_SIZE - 1);

二者都在循环调用read或write函数。实际上之前的回声客户端将100%接收自己传输的数据,只不过接收数据时的单位有些问题。扩展客户端代码回顾范围,下面是echo_client.c中的代码:

c 复制代码
while(1)
{
	fputs("Input message(Q to quit): ", stdout);
	fGets(message,BUF_SIZE,stdin);
	......
	write(sock, message, strlen(message));
	str_len = read(sock, message, BUF_SIZE - 1);
	message[str_len] = 0;
	pRintf("Message from server: %s",message);
}

大家现在理解了吧?回声客户端传输的是字符串,而且是通过调用write函数一次性发送的。

之后还调用一次read函数,期待着接收自己传输的字符串。这就是问题所在。

-- "既然回声客户端会收到所有字符串数据,是否只需多等一会儿?过一段时间后再

调用read函数是否可以一次性读取所有字符串数据?" --

的确,过一段时间后即可接收,但需要等多久?要等10分钟吗?这不符合常理,理想的客户端应在收到字符串数据时立即读取并输出。

2.回声客户端问题解决方法

我们说的回声客户端问题实际上是初级程序员经常犯的错误,其实很容易解决,因为可以提前确定接收数据的大小。若之前传输了20字节长的字符串,则在接收时循环调用read函数读取20个字节即可。既然有了解决方法,接下来给出代码。

c 复制代码
// hello_client2.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include  <arpa/inet.h>
#include <sys/socket.h>
void error_handling(char* message);

int main()
{
    int sock;
    char message[BUF_SIZE];
    int str_len, recv_len, recv_cnt;
    struct sockaddr_in serv_adr;

    if(argv != 3){
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }

    sock = socket(PF_INET, SOCK_STREAM, 0);
    if(sock == -1)
        error_handling("socket() error");

    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
    serv_adr.sin_port. = htons(atoi(argv[2]));

    if(connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
        error_handling("connect() error!");
    else
        puts("Connected......");

    while(1)
    {
        fputs("Input message(Q to quit): ", stdout);
        fgets(message, BUF_SIZE, stdin);
        if(!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
            break;

        str_len = write(sock, message, strlen(message));// 这里开始

        recv_len = 0;
        while(recv_len < str_len)
        {
            recv_cnt = read(sock, &message[recv_len], BUF_SIZE - 1);
            if(recv_len == -1)
                error_handling("read() error!");
            recv_len += recv_cnt;
        }
        message[recv_len] = 0;// 这里结束
        printf("Message from server: %s, message");
        close(sock);
        return 0 ;
}
}

void error_handling(char* message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

以上代码第43~53行是变更及添加的部分。之前的示例仅调用1次read函数,上述示例为了接收所有传输数据而循环调用read函数。另外,代码第46行循环可以写成如下形式,可能这种方式更容易理解。

c 复制代码
while(recv_len != str_len)
{
	...
}

接收的数据大小应和传输的相同,因此,recv_len中保存的值等于str_len中保存的值时,即可跳出while循环。也许各位认为这种循环写法更符合逻辑,但有可能引发无限循环。假设发生异常情况,读取数据过程中recv_len超过str_len,此时就无法退出循环。而如果while循环写成下面这种形式,则即使发生异常也不会陷入无限循环。

c 复制代码
while(recv_len < str_len)
{
	...
}

写循环语句时应尽量降低因异常情况而陷人无限循环的可能。以上示例可以结合第4章的echo_server.c运行。大家已经非常熟悉运行结果。

二、如果问题不在于回声客户端:定义应用层协议

回声客户端可以提前知道接收的数据长度,但我们应该意识到,更多情况下这不太可能。既然如此,若无法预知接收数据长度时应如何收发数据?此时需要的就是应用层协议的定义。之前的回声服务器端/客户端中定义了如下协议。

-- "收到Q就立即终止连接。" --

同样,收发数据过程中也需要定好规则(协议)以表示数据的边界,或提前告知收发数据的大小。服务器端/客户端实现过程中逐步定义的这些规则集合就是应用层协议。可以看出,应用层协议并不是高深莫测的存在,只不过是为特定程序的实现而制定的规则。

下面编写程序以体验应用层协议的定义过程。该程序中,服务器端从客户端获得多个数字和运算符信息。服务器端收到数字后对其进行加减乘运算,然后把结果传回客户端。例如,向服务器端传递3、5、9的同时请求加法运算,则客户端收到3+5+9的运算结果;若请求做乘法运算,则客户端收到3×5×9的运算结果。而如果向服务器端传递4、3、2的同时要求做减法,则客户端将收到4-3-2的运算结果,即第一个参数成为被减数。

大家根据以上要求思考下如何编写服务器端/客户端,细节部分可以自定义。我实现的程序运行结果如下。先给出服务器端运行结果。

三、计算器服务器端/客户端示例

大家尝试实现了吗?它在功能上没有特别之处,但若想在网络环境下实现这些功能并非易事。特别是不熟悉C语言中的数组及指针应用的人,会在实现程序功能时吃苦头。因此,我希望通过本示例补充回声服务器端/客户端实现中未涉及的部分。如果可能,还是希望大家自已动手实现。若成功实现(而不是看源代码理解),有助于大家提升自信哦。

我编写程序前设计了如下应用层协议,但这只是为实现程序而设计的最低协议,实际的应用程序实现中需要的协议更详细、准确。

■ 客户端连接到服务器端后以1字节整数形式传递待算数字个数。

■ 客户端向服务器端传递的每个整数型数据占用4字节。

■ 传递整数型数据后接着传递运算符。运算符信息占用1字节。

■ 选择字符+、一、*之一传递。

■ 服务器端以4字节整数型向客户端传回运算结果。

■ 客户端得到运算结果后终止与服务器端的连接。

这种程度的协议相当于实现了一半程序,这也说明应用层协议设计在网络编程中的重要性。

只要设计好协议,实现就不会成为大问题。另外,之前也讲过,调用close函数将向对方传递EOF,请大家记住这一点并加以运用。接下来给出我实现的计算器客户端代码。实际上,与服务器端相比,客户端中有更多需要学习的内容。

c 复制代码
// op_client.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include  <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
#define RLT_SIZE 4 // 10
#define OPSZ 4
void error_handling(char* message);

int main(int argc, char *argv[])
{
    int sock;
    char opmsg[BUF_SIZE]; // 17
    int result, opnd_cnt, i;
    struct sockaddr_in serv_adr;
    if(argc != 3){
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }

    sock = socket(PF_INET, SOCK_STREAM, 0);
    if(sock == -1)
        error_handling("socket() error");

    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
    serv_adr.sin_port = htons(atoi(argv[2]));

    if(connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
        error_handling("connect() error!");
    else
        puts("Connected......");

    fputs("Operand count: ", stdout);
    scanf("%d", &opnd_cnt);// 33
    opmsg[0]=(char)opnd_cnt;

    for(i = 0; i < opnd_cnt; ++i) // 36
    {
        printf("Operand %d: ", i+1);
        scanf("%d", (int*)&opmsg[i*OPSZ+1]);
    } // 40
    fgetc(stdin);
    fputs("Operator: ", stdout);
    scanf("%c", &opmsg[opnd_cnt*OPSZ+1]);
    write(sock, opmsg, opnd_cnt*OPSZ+2);
    read(sock, &result, RLT_SIZE);

    printf("Operation result: %d \n", result);
    close(sock);
    return 0;
}

void error_handling(char* message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

第10、11行:将待算数字的字节数和运算结果的字节数设为常数。

第17行:为收发数据准备的内存空间,需要数据积累到一定程度后再收发,因此通过数组创建。

第40、41行:从程序用户的输入中得到待算数个数后,保存至数组opmsg。强制转换成char

类型,因为协议规定待算数个数应通过1字节整数型传递,因此不能超过1字

节整数型能够表示的范围。该示例中用的是有符号整数型,但待算数个数不

能是负数,因此使用无符号整数型更合理。

第43~47行:从程序用户的输入中得到待算整数,保存到数组opmsg。4字节int型数据要保存到char数组,因而转换成int指针类型。若不太理解此部分,应需要单独复习指针。

第48行:第43行中需输入字符,在此之前调用fgetc函数删掉缓冲中的字符n。

第50行:最后输入运算符信息,保存到opmsg数组。

第51行:调用write函数一次性传输opmsg数组中的运算相关信息。可以调用1次write函数进行传输,也可以分成多次调用。前面反复强调过,这是因为TCP中不存在数据边界。

第52行:保存服务器端传输的运算结果。待接收的数据长度为4字节,因此调用1次read函数即可接收。

客户端的实现到此结束,给出客户端向服务器端传输的数据结构示例:

从图中可以看出,若想在同一数组中保存并传输多种数据类型,应把数组声明为char类型。而且需要额外做一些指针及数组运算。接下来给出服务器端代码。

c 复制代码
// op_server.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include  <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
#define OPSZ 4
void error_handling(char* message);
int calculate(int opnum, int opnds[], char oprator);

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock;
    char opinfo[BUF_SIZE];
    int result, opnd_cnt, i;
    int recv_cnt, recv_len;
    struct sockaddr_in serv_adr, clnt_adr;
    socklen_t clnt_adr_sz;
    if(argc != 2){
        printf("Usage : %s <port>\n", argv[0]);
        exit(1);
    }

    serv_sock = socket(PF_INET, SOCK_STREAM, 0);
    if (serv_sock)
        error_handling("socket() error");
    
    memset(&serv_adr, 0, sizeof(serv_adr));
    serv_adr.sin_family = AF_INET;
    serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_adr.sin_port = htons(atoi(argv[1]));

    if(bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
        error_handling("bind() error");
    if(listen(serv_sock, 5) == -1)
        error_handling("listen() error");
    clnt_adr_sz = sizeof(clnt_adr);

    for (i = 0; i < 5; i++) // 42
    {
        opnd_cnt = 0;
        clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &clnt_adr_sz);
        read(clnt_sock, &opnd_cnt, 1); // 46

        recv_len = 0;
        while((opnd_cnt*OPSZ + 1) > recv_len)
        {
            recv_cnt = read(clnt_sock, &opinfo[recv_len], BUF_SIZE - 1);
            recv_len += recv_cnt;
        }
        result = calculate(opnd_cnt, (int*)opinfo, opinfo[recv_len - 1]); // 54
        write(clnt_sock, (char*)&result, sizeof(result)); // 55
        close(clnt_sock);
    }
    close(serv_sock);
    return 0;
}

int calculate(int opnum, int opnds[], char op)
{
    int result = opnds[0], i;
    switch(op)
    {
    case '+':
        for(i = 1; i < opnum; ++i) result += opnds[i];
        break;
    case '-':
        for(i = 1; i < opnum; ++i) result -= opnds[i];
        break;
    case '*':
        for(i = 1; i < opnum; ++i) result *= opnds[i];
        break; 
    }
    return result;
}


void error_handling(char* message)
{
	fputs(message, stderr);
	fputc('\n', stderr);
	exit(1);
}

■ 第42行:为了接收5个客户端的连接请求而编写的for语句。

■ 第46行:首先接收待算数个数。

■ 第42~46行:根据第39行中的待算数个数接收待算数。

■ 第54行:调用calculate函数的同时传递待算数和运算符信息参数。

■ 第55行:向客户端传输calculate函数返回的运算结果。

对计算器服务器端/客户端的讲解到此结束,大叫可能略感困难,但思考一下应该就能理解。

三、TCP原理

本想在此结束TCP相关介绍,觉得会稍显仓促,所以补充讲解TCP的理论部分。本章节内容将成为日后理解套接字选项的基础,希望大家能够全部掌握。

1.TCP套接字中的I/O缓冲

如前所述,TCP套接字的数据收发无边界。服务器端即使调用1次write函数传输40字节的数据,客户端也有可能通过4次read函数调用每次读取10字节。但此处也有一些疑问,服务器端一次性传输了40字节,而客户端居然可以缓慢地分批接收。客户端接收10字节后,剩下的30字节在何处等候呢?是不是像飞机为等待着陆而在空中盘旋一样,剩下30字节也在网络中徘徊并等待接收呢?

实际上,write函数调用后并非立即传输数据,read函数调用后也并非马上接收数据。更准确地说,write函数调用瞬间,数据将移至输出缓冲;read函数调用瞬间,从输人缓冲读取数据。

调用write函数时,数据将移到输出缓冲,在适当的时候(不管是分别传送还是一次性传送)传向对方的输人缓冲。这时对方将调用read函数从输入缓冲读取数据。这些I/O缓冲特性可整理如下:

■ I/O缓冲在每个TCP套接字中单独存在。

■ I/O缓冲在创建套接字时自动生成。

■ 即使关闭套接字也会继续传递输出缓冲中遗留的数据。

■ 关闭套接字将丢失输人缓冲中的数据。

那么,下面这种情况会引发什么事情?理解了I/O缓冲后,各位应该可以猜出其流程:

-- "客户端输入缓冲为50字节,而服务器端传输了100字节。" --

这的确是个问题。输人缓冲只有50字节,却收到了100字节的数据。可以提出如下解决方案:

-- "填满输入缓冲前迅速调用read函数读取数据,这样会腾出一部分空间,问题就解决了。" --

当然,这只是我的一个小玩笑,相信大家不会当真,那么马上给出结论:

-- "不会发生超过输入缓冲大小的数据传输。" --

也就是说,根本不会发生这类问题,因为TCP会控制数据流。TCP中有滑动窗口(Sliding Window)协议,用对话方式呈现如下:

■ 套接字A:"你好,最多可以向我传递50字节。"

■ 套接字B:"OK!"

■ 套接字A:"我腾出了20字节的空间,最多可以收70字节。"

■ 套接字B:"OK!"

数据收发也是如此,因此TCP中不会因为缓冲溢出而丢失数据。

(write函数和Windows的send函数并不会在完成向对方主机的数据传输时

返回,而是在数据移到输出缓冲时。但TCP会保证对输出缓冲数据的传输,

所以说write函数在数据传输完成时返回。要准确理解这句话。)

2.TCP内部工作原理1:与对方套接字的连接

TCP套接字从创建到消失所经过程分为如下3步。

■ 与对方套接字建立连接。

■ 与对方套接字进行数据交换。

■ 断开与对方套接字的连接。

首先讲解与对方套接字建立连接的过程。连接过程中套接字之间的对话如下。

■ [Shake1]套接字A:"你好,套接字B。我这儿有数据要传给你,建立连接吧。"

■ [Shake2]套接字B:"好的,我这边已就绪。"

■ [Shake3]套接字A:"谢谢你受理我的请求。"

TCP在实际通信过程中也会经过3次对话过程,因此,该过程又称Three-wayhandshaking(三次握手)。接下来给出连接过程中实际交换的信息格式:

套接字是以全双工(Full-duplex)方式工作的。也就是说,它可以双向传递数据。因此,收发数据前需要做一些准备。首先,请求连接的主机A向主机B传递如下信息:

SYN\] SEQ:X, ACK:- 该消息中SEQ为X,ACK为空,而SEQ为X的含义如下: -- "现传递的数据包序号为X,如果接收无误,请通知我向您传递X+1号数据包。" -- 这是首次请求连接时使用的消息,又称SYN。SYN是Synchronization的简写,表示收发数据前传输的同步消息。接下来主机B向A传递如下消息: \[SYN+ACK\] SEQ:Y, ACK:X+1 此时SEQ为Y,ACK为X+1,而SEQ为Y的含义如下: "现传递的数据包序号为Y,如果接收无误,请通知我向您传递Y+1号数据包。" 而ACKX+1的含义如下: "刚才传输的SEQ为X的数据包接收无误,现在请传递SEQ为X+1的数据包。" 对主机A首次传输的数据包的确认消息(ACK X+1)和为主机B传输数据做准备的同步消息(SEQ Y)捆绑发送,因此,此种类型的消息又称SYN+ACK。 收发数据前向数据包分配序号,并向对方通报此序号,这都是为防止数据丢失所做的准备。 通过向数据包分配序号并确认,可以在数据丢失时马上查看并重传丢失的数据包。因此,TCP可以保证可靠的数据传输。最后观察主机A向主机B传输的消息: \[ACK\] SEQ:Z, ACK:Y+1 之前也讨论过,TCP连接过程中发送数据包时需分配序号。在之前的序号Y的基础上加1,也就是分配Y+1。此时该数据包传递如下消息: "已正确收到传输的SEQ为Y的数据包,现在可以传输SEQ为Y+1的数据包。" 这样就传输了添加ACK Y+1的ACK消息。至此,主机A和主机B确认了彼此均就绪。 ### 3.TCP内部工作原理2:与对方主机的数据交换 通过第一步三次握手过程完成了数据交换准备,下面就正式开始收发数据: ![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/83ba24ac5cc44afebe871e7231bea2d4.png) 给出了主机A分2次(分2个数据包)向主机B传递200字节的过程。首先,主机A通过1个数据包发送100个字节的数据,数据包的SEQ为1200。主机B为了确认这一点,向主机A发送ACK1301消息。 此时的ACK号为1301而非1201,原因在于ACK号的增量为传输的数据字节数。假设每次ACK号不加传输的字节数,这样虽然可以确认数据包的传输,但无法明确100字节全都正确传递还是丢失了一部分,比如只传递了80字节。因此按如下公式传递ACK消息: ACK号 → SEQ号 + 传递的字节数+1 与三次握手协议相同,最后加1是为了告知对方下次要传递的SEQ号。下面分析传输过程中数据包消失的情况: ![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/5918d84cbebe435a85ee9d3252ee8f51.png) 通过SEQ1301数据包向主机B传递100字节数据。但中间发生了错误,主机B未收到。经过一段时间后,主机A仍未收到对于SEQ1301的ACK确认,因此试着重传该数据包。为了完成数据包重传,TCP套接字启动计时器以等待ACK应答。若相应计时器发生超时(Time-out!)则重传。 ### 4.TCP的内部工作原理3:断开与套接字的连接 TCP套接字的结束过程也非常优雅。如果对方还有数据需要传输时直接断掉连接会出问题,所以断开连接时需要双方协商。断开连接时双方对话如下。 ■ 套接字A:"我希望断开连接。" ■ 套接字B:"哦,是吗?请稍候。" ■ 套接字B:"我也准备就绪,可以断开连接。" ■ 套接字A:"好的,谢谢合作。" 先由套接字A向套接字B传递断开连接的消息,套接字B发出确认收到的消息,然后向套接字A传递可以断开连接的消息,套接字A同样发出确认消息。 ![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/1fcd531ab32e4c3d935620d76f5a33df.png) 数据包内的FIN表示断开连接。也就是说,双方各发送1次FIN消息后断开连接。此过程经历4个阶段,因此又称四次握手(Four-wayhandshaking)。SEQ和ACK的含义与之前讲解的内容一致。向主机A传递了两次ACK5001,也许这会让各位感到困惑。其实,第二次FIN数据包中的ACK5001只是因为接收ACK消息后未接收数据而重传的。 ## 总结 讲解了TCP协议基本内容TCP流控制(FlowControl),希望这有助于大家理解TCP数据传输特性。

相关推荐
韭菜钟1 小时前
WIndows下一键切换网卡IP脚本
windows·网络协议·tcp/ip
Dream of maid1 小时前
Linux(下)
linux·运维·服务器
齐鲁大虾1 小时前
统信系统UOS常用命令集
linux·运维·服务器
fengfuyao9852 小时前
基于STM32的4轴步进电机加减速控制工程源码(梯形加减速算法)
网络·stm32·算法
瀚高PG实验室3 小时前
审计策略修改
网络·数据库·瀚高数据库
forAllforMe3 小时前
etherCAT的协议VoE,FoE,EoE,CoE的概念和区别
网络
专吃海绵宝宝菠萝屋的派大星3 小时前
使用Dify对接自己开发的mcp
java·服务器·前端
大数据新鸟4 小时前
操作系统之虚拟内存
java·服务器·网络
迷藏4944 小时前
**eBPF实战进阶:从零构建网络流量监控与过滤系统**在现代云原生架构中,**网络可观测性**和**安全隔离**已成为
java·网络·python·云原生·架构
zmj3203244 小时前
汽车电子内部网络架构图
网络·汽车