第 9 章 套接字的多种可选项
我们进行套接字编程时往往只关注数据通信,而忽略了套接字具有的不同特性。但是,理解这些特性并根据实际需要进行更改也很重要。下面列出了一些套接字可选项。
从表中可以看出,套接字可选项是分层的。
-
IPPROTO_IP 可选项是IP协议相关事项
-
IPPROTO_TCP 层可选项是 TCP 协议的相关事项
-
SOL_SOCKET 层是套接字的通用可选项。
可选项的读取和设置通过以下两个函数来完成:getsockopt
& setsockopt
#include <sys/socket.h>
int getsockopt(int sock, int level, int optname, void *optval, socklen_t *optlen);
/*
成功时返回 0 ,失败时返回 -1
sock: 用于查看选项套接字文件描述符
level: 要查看的可选项协议层
optname: 要查看的可选项名
optval: 保存查看结果的缓冲地址值
optlen: 向第四个参数传递的缓冲大小。调用函数候,该变量中保存通过第四个参数返回的可选项信息的字节数。
*/
上述函数可以用来读取套接字可选项,下面的函数可以更改可选项:
#include <sys/socket.h>
int setsockopt(int sock, int level, int optname, const void *optval, socklen_t optlen);
/*
成功时返回 0 ,失败时返回 -1
sock: 用于更改选项套接字文件描述符
level: 要更改的可选项协议层
optname: 要更改的可选项名
optval: 保存更改结果的缓冲地址值
optlen: 向第四个参数传递的缓冲大小。调用函数后,该变量中保存通过第四个参数返回的可选项信息的字节数。
*/
下面示例演示getsockopt
使用方法:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
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 套接字和一个 UDP 套接字。然后通过调用 getsockopt 函数来获得当前套接字的状态。
用于验证套接类型的 SO_TYPE 是只读可选项,因为套接字类型只能在创建时决定,以后不能再更改。
SO_SNDBUF
& SO_RCVBUF:
创建套接字的同时会生成 I/O 缓冲。SO_RCVBUF 是输入缓冲大小相关可选项。SO_SNDBUF 是输出缓冲大小相关可选项。用这 2 个可选项既可以读取当前 I/O 大小,也可以进行更改。通过下列示例读取创建套接字时默认的 I/O 缓冲大小:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
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);
}
运行结果:
可以看出本机的输入缓冲和输出缓冲大小。
下面的代码演示了,通过程序设置 I/O 缓冲区的大小:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/socket.h>
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);
}
运行结果:
输出结果和我们预想的不是很相同,缓冲大小的设置需谨慎处理,因此不会完全按照我们的要求进行。
SO_REUSEADDR:
在学习 SO_REUSEADDR 可选项之前,应该好好理解 Time-wait 状态。看以下代码的示例:
#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);
#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 <port>\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 分钟就可以重新运行服务端。
运行结果:
上述2种运行方式唯一的区别就是谁先传输FIN消息,但结果却迥然不同。观察下图:
假设图中主机 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 过程的情况。
从图上可以看出,在主机 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 层。它是否使用会导致如图所示的差异:
只有接收到前一数据的 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算法呢?
-
低延迟应用:对于某些实时应用,如实时游戏、视频通话或实时金融交易等,需要尽可能减少数据包的传输延迟,因为即时响应性是非常重要的。禁用Nagle算法可以立即发送数据,而不需要等待数据包组合。
-
小数据包传输:对于只包含少量数据的小数据包传输,Nagle算法可能会导致数据包被延迟发送,从而引入不必要的延迟。禁用Nagle算法可以确保这些小数据包能够及时发送,减少传输延迟。
-
交互式应用:某些交互式应用,如SSH(Secure Shell)会话或远程桌面连接,需要实时的用户输入和输出。禁用Nagle算法可以确保用户输入的及时传输和命令的实时响应。
需要注意的是,禁用Nagle算法可能会导致网络拥塞,因为会增加网络上的传输次数。因此,在选择禁用Nagle算法时,需要确保网络负载不会过重,并且明确知道该设置符合特定应用场景的要求。