《TCP IP网路编程》第九章

第 9 章 套接字的多种可选项

        我们进行套接字编程时往往只关注数据通信,而忽略了套接字具有的不同特性。但是,理解这些特性并根据实际需要进行更改也很重要。下面列出了一些套接字可选项

《TCP IP网路编程》第九章_第1张图片

        从表中可以看出,套接字可选项是分层的。

  • IPPROTO_IP 可选项是IP协议相关事项

  • IPPROTO_TCP 层可选项是 TCP 协议的相关事项

  • SOL_SOCKET 层是套接字的通用可选项。

        可选项的读取和设置通过以下两个函数来完成:getsockopt & setsockopt

#include 

int getsockopt(int sock, int level, int optname, void *optval, socklen_t *optlen);
/*
成功时返回 0 ,失败时返回 -1
sock: 用于查看选项套接字文件描述符
level: 要查看的可选项协议层
optname: 要查看的可选项名
optval: 保存查看结果的缓冲地址值
optlen: 向第四个参数传递的缓冲大小。调用函数候,该变量中保存通过第四个参数返回的可选项信息的字节数。
*/

        上述函数可以用来读取套接字可选项,下面的函数可以更改可选项:

#include 

int setsockopt(int sock, int level, int optname, const void *optval, socklen_t optlen);
/*
成功时返回 0 ,失败时返回 -1
sock: 用于更改选项套接字文件描述符
level: 要更改的可选项协议层
optname: 要更改的可选项名
optval: 保存更改结果的缓冲地址值
optlen: 向第四个参数传递的缓冲大小。调用函数后,该变量中保存通过第四个参数返回的可选项信息的字节数。
*/

        下面示例演示getsockopt使用方法:

#include 
#include 
#include 
#include 
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int tcp_sock, udp_sock;
    int sock_type;
    socklen_t optlen;
    int state;

    optlen = sizeof(sock_type);
    //创建TCP和UDP套接字
    tcp_sock = socket(PF_INET, SOCK_STREAM, 0);
    udp_sock = socket(PF_INET, SOCK_DGRAM, 0);
    printf("SOCK_STREAM: %d\n", SOCK_STREAM);
    printf("SOCK_DGRAM: %d\n", SOCK_DGRAM);
    // 获取TCP套接字的类型,并将其存储在sock_type变量中
    state = getsockopt(tcp_sock, SOL_SOCKET, SO_TYPE, (void *)&sock_type, &optlen);
    if (state)
        error_handling("getsockopt() error");
    printf("Socket type one: %d \n", sock_type);
    // 获取UDP套接字的类型,并将其存储在sock_type变量中
    state = getsockopt(udp_sock, SOL_SOCKET, SO_TYPE, (void *)&sock_type, &optlen);
    if (state)
        error_handling("getsockopt() error");
    printf("Socket type two: %d \n", sock_type);
    return 0;
}
void error_handling(char *message)
{
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

        运行结果:《TCP IP网路编程》第九章_第2张图片       

        上述代码首先创建了一个 TCP 套接字和一个 UDP 套接字。然后通过调用 getsockopt 函数来获得当前套接字的状态。

        用于验证套接类型的 SO_TYPE 是只读可选项,因为套接字类型只能在创建时决定,以后不能再更改

        SO_SNDBUF & SO_RCVBUF:

        创建套接字的同时会生成 I/O 缓冲。SO_RCVBUF 是输入缓冲大小相关可选项。SO_SNDBUF 是输出缓冲大小相关可选项。用这 2 个可选项既可以读取当前 I/O 大小,也可以进行更改。通过下列示例读取创建套接字时默认的 I/O 缓冲大小:

#include 
#include 
#include 
#include 
void error_handling(char *message);

int main(int argc, char *argv[])
{
    int sock;
    int snd_buf, rcv_buf, state;
    socklen_t len;
    // 创建TCP套接字
    sock = socket(PF_INET, SOCK_STREAM, 0);
    len = sizeof(snd_buf);
    state = getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void *)&snd_buf, &len);
    if (state)
        error_handling("getsockopt() error");

    len = sizeof(rcv_buf);
    state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void *)&rcv_buf, &len);
    if (state)
        error_handling("getsockopt() error");

    printf("Input buffer size: %d \n", rcv_buf);// 打印接收缓冲区大小
    printf("Output buffer size: %d \n", snd_buf);// 打印发送缓冲区大小

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

运行结果:

《TCP IP网路编程》第九章_第3张图片

        可以看出本机的输入缓冲和输出缓冲大小。

        下面的代码演示了,通过程序设置 I/O 缓冲区的大小:

#include 
#include 
#include 
#include 

void error_handling(char *message);

int main(int argc, char *argv[])
{
    int sock; // 套接字描述符
    int snd_buf = 1024 * 3, rcv_buf = 1024 * 3; // 初始化发送缓冲区大小和接收缓冲区大小
    int state; // 状态变量
    socklen_t len; // 用于存储选项的长度变量

    sock = socket(PF_INET, SOCK_STREAM, 0); // 创建TCP套接字

    // 设置接收缓冲区大小
    len = sizeof(rcv_buf);
    state = setsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void *)&rcv_buf, sizeof(rcv_buf));
    if (state)
        error_handling("setsockopt() 错误");

    // 设置发送缓冲区大小
    state = setsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void *)&snd_buf, sizeof(snd_buf));
    if (state)
        error_handling("setsockopt() 错误");

    // 获取设置后的发送缓冲区大小
    len = sizeof(snd_buf);
    state = getsockopt(sock, SOL_SOCKET, SO_SNDBUF, (void *)&snd_buf, &len);
    if (state)
        error_handling("getsockopt() 错误");

    // 获取设置后的接收缓冲区大小
    len = sizeof(rcv_buf);
    state = getsockopt(sock, SOL_SOCKET, SO_RCVBUF, (void *)&rcv_buf, &len);
    if (state)
        error_handling("getsockopt() 错误");

    printf("输入缓冲区大小: %d \n", rcv_buf); // 打印接收缓冲区大小
    printf("输出缓冲区大小: %d \n", snd_buf); // 打印发送缓冲区大小

    return 0;
}

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

运行结果:

《TCP IP网路编程》第九章_第4张图片

        输出结果和我们预想的不是很相同,缓冲大小的设置需谨慎处理,因此不会完全按照我们的要求进行。 

SO_REUSEADDR:

        在学习 SO_REUSEADDR 可选项之前,应该好好理解 Time-wait 状态。看以下代码的示例:

#include 
#include 
#include 
#include 
#include 
#include 

void error_handling(char *message);

#define TRUE 1
#define FALSE 0

int main(int argc, char *argv[])
{
    int serv_sock, clnt_sock; // 服务器套接字和客户端套接字
    char message[30]; // 存储接收和发送的消息
    int option, str_len; // 选项变量和接收数据的长度
    socklen_t optlen, clnt_adr_sz; // 选项长度变量和客户端地址结构长度
    struct sockaddr_in serv_adr, clnt_adr; // 服务器地址和客户端地址结构

    if (argc != 2)
    {
        printf("Usage : %s \n", argv[0]);
        exit(1);
    }

    // 创建TCP套接字
    serv_sock = socket(PF_INET, SOCK_STREAM, 0);
    if (serv_sock == -1)
        error_handling("socket() error");

    /*
    // 可选:设置SO_REUSEADDR选项,用于端口复用
    optlen = sizeof(option);
    option = TRUE;
    setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (void *)&option, optlen);
    */

    // 初始化服务器地址结构
    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);

    // 接受客户端连接请求,创建客户端套接字
    clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr, &clnt_adr_sz);

    // 进入循环,接收客户端发送的消息并回复
    while ((str_len = read(clnt_sock, message, sizeof(message))) != 0)
    {
        // 将接收到的消息回复给客户端
        write(clnt_sock, message, str_len);

        // 在服务器端打印收到的消息
        write(1, message, str_len); // 1代表标准输出
    }

    // 关闭客户端套接字和服务器套接字
    close(clnt_sock);
    close(serv_sock);

    return 0;
}

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

        这是一个回声服务器的服务端代码,可以配合第四章的 echo_client.c 使用,在这个代码中,客户端通知服务器终止程序。在客户端控制台输入 Q 可以结束程序,向服务器发送 FIN 消息并经过四次握手过程。当然,输入 CTRL+C 也会向服务器传递 FIN 信息。强制终止程序时,由操作系统关闭文件套接字,此过程相当于调用 close 函数,也会向服务器发送 FIN 消息。

        这样看不到是什么特殊现象,考虑以下情况:

服务器端和客户端都已经建立连接的状态下,向服务器控制台输入 CTRL+C ,强制关闭服务端

        如果用这种方式终止程序,如果用同一端口号再次运行服务端,就会输出「bind() error」消息,并且无法再次运行。但是在这种情况下,再过大约 3 分钟就可以重新运行服务端。    

        运行结果:

    《TCP IP网路编程》第九章_第5张图片

        上述2种运行方式唯一的区别就是谁先传输FIN消息,但结果却迥然不同。观察下图:

《TCP IP网路编程》第九章_第6张图片   

        假设图中主机 A 是服务器,因为是主机 A 向 B 发送 FIN 消息,故可想象成服务器端在控制台中输入 CTRL+C 。但是问题是,套接字经过四次握手后并没有立即消除,而是要经过一段时间的 Time-wait 状态。当然,只有先断开连接的(先发送 FIN 消息的)主机才经过 Time-wait 状态。因此,若服务器端先断开连接,则无法立即重新运行。套接字处在 Time-wait 过程时,相应端口是正在使用的状态。因此,就像之前验证过的,bind 函数调用过程中会发生错误。

        实际上,不论是服务端还是客户端,都要经过一段时间的 Time-wait 过程。先断开连接的套接字必然会经过 Time-wait 过程,但是由于客户端套接字的端口是任意指定的,所以无需过多关注 Time-wait 状态。

        那到底为什么会有 Time-wait 状态呢?在图中假设,主机 A 向主机 B 传输 ACK 消息(SEQ 5001 , ACK 7502 )后立刻消除套接字。但是最后这条 ACK 消息在传递过程中丢失,没有传递主机 B ,这时主机 B 就会试图重传。但是此时主机 A 已经是完全终止状态,因此主机 B 永远无法收到从主机 A 最后传来的 ACK 消息。基于这些问题的考虑,所以要设计 Time-wait 状态。

地址再分配:

          Time-wait 状态看似重要,但是不一定讨人喜欢。如果系统发生故障紧急停止,这时需要尽快重启服务起以提供服务,但因处于 Time-wait 状态而必须等待几分钟。因此,Time-wait 并非只有优点,这些情况下容易引发大问题。下图中展示了四次握手时不得不延长 Time-wait 过程的情况。

 《TCP IP网路编程》第九章_第7张图片        

        从图上可以看出,在主机 A 四次握手的过程中,如果最后的数据丢失,则主机 B 会认为主机 A 未能收到自己发送的 FIN 信息,因此重传。这时,收到的 FIN 消息的主机 A 将重启 Time-wait 计时器。因此,如果网络状况不理想, Time-wait 将持续。

        解决方案就是在套接字的可选项中更改 SO_REUSEADDR 的状态。适当调整该参数,可将 Time-wait 状态下的套接字端口号重新分配给新的套接字。SO_REUSEADDR 的默认值为 0.这就意味着无法分配 Time-wait 状态下的套接字端口号。因此需要将这个值改成 1 。具体作法已在示例 reuseadr_eserver.c 给出,只需要把注释掉的东西解除注释即可。

optlen = sizeof(option);
option = TRUE;
setsockopt(serv_sock, SOL_SOCKET, SO_REUSEADDR, (void *)&option, optlen);

TCP_NODELAY:

        为了防止因数据包过多而发生网络过载,Nagle 算法诞生了。它应用于 TCP 层。它是否使用会导致如图所示的差异:《TCP IP网路编程》第九章_第8张图片

        只有接收到前一数据的 ACK 消息, Nagle 算法才发送下一数据。

        TCP 套接字默认使用 Nagle 算法交换数据,因此最大限度的进行缓冲,直到收到 ACK 。左图也就是说一共传递 4 个数据包以传输一个字符串。从右图可以看出,发送数据包一共使用了 10 个数据包。由此可知,不使用 Nagle 算法将对网络流量产生负面影响。即使只传输一个字节的数据,其头信息都可能是几十个字节。因此,为了提高网络传输效率,必须使用 Nagle 算法。

        Nagle 算法并不是什么情况下都适用,网络流量未受太大影响时,不使用 Nagle 算法要比使用它时传输速度快。最典型的就是「传输大文数据」。将文件数据传入输出缓冲不会花太多时间,因此,不使用 Nagle 算法,也会在装满输出缓冲时传输数据包。这不仅不会增加数据包的数量,反而在无需等待 ACK 的前提下连续传输,因此可以大大提高传输速度。

        因此,未准确判断数据性质时不应禁用 Nagle 算法。

禁用 Nagle 算法应该使用:

//将套接字可选项TCP_NODELAY改为1(真)
int opt_val = 1;
setsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&opt_val, sizeof(opt_val));

 通过 TCP_NODELAY 的值来查看Nagle 算法的设置状态:

opt_len = sizeof(opt_val);
getsockopt(sock, IPPROTO_TCP, TCP_NODELAY, (void *)&opt_val, &opt_len);

如果正在使用Nagle 算法,那么 opt_val 值为 0,如果禁用则为 1.


习题:

1、TCP_NODELAY 可选项与 Nagle 算法有关,可通过它禁用 Nagle 算法。请问何时应考虑禁用 Nagle 算法?结合收发数据的特性给出说明。

        Nagle算法是一种流控制算法,它通过在发送数据时进行数据包的延迟,以尝试优化网络传输效率。它的原理是将多个较小的数据包组合成一个较大的数据包发送,从而减少网络上的传输次数,节省网络带宽和降低网络负载。虽然Nagle算法对某些应用场景非常有效,但在某些情况下,它可能会引入显著的传输延迟,这时候可以考虑禁用Nagle算法。

当应该考虑禁用Nagle算法呢?

  1. 低延迟应用:对于某些实时应用,如实时游戏、视频通话或实时金融交易等,需要尽可能减少数据包的传输延迟,因为即时响应性是非常重要的。禁用Nagle算法可以立即发送数据,而不需要等待数据包组合。

  2. 小数据包传输:对于只包含少量数据的小数据包传输,Nagle算法可能会导致数据包被延迟发送,从而引入不必要的延迟。禁用Nagle算法可以确保这些小数据包能够及时发送,减少传输延迟。

  3. 交互式应用:某些交互式应用,如SSH(Secure Shell)会话或远程桌面连接,需要实时的用户输入和输出。禁用Nagle算法可以确保用户输入的及时传输和命令的实时响应。

        需要注意的是,禁用Nagle算法可能会导致网络拥塞,因为会增加网络上的传输次数。因此,在选择禁用Nagle算法时,需要确保网络负载不会过重,并且明确知道该设置符合特定应用场景的要求。

你可能感兴趣的:(《TCPIP网络编程》,tcp/ip,网络,服务器,网络编程)