在网络编程中,对套接字的I/O的系统调用(如read
,write
,connect
)进行超时处理是至关重要的,特别是在需要响应及时的实时数据或避免无限期阻塞的情境下。本文将深入介绍处理套接字I/O超时的两种方法:setsockopt
和select
。setsockopt
允许直接设置套接字的发送和接收超时时间,而select
提供了一种多路复用的机制,使得在等待多个套接字就绪时能够设置超时。
1 setsockopt
SO_SNDTIMEO
和SO_RCVTIMEO
是与套接字选项相关的两个选项,它们可以用于设置发送和接收数据的超时时间。这两个选项主要用于在套接字上设置超时,以便在指定的时间内等待发送或接收操作完成。
SO_SNDTIMEO
(发送超时)
SO_SNDTIMEO
用于设置发送数据的超时时间。通过这个选项,你可以指定在发送数据时等待的最长时间。如果在指定的时间内无法完成发送操作,系统将会返回一个错误。
struct timeval tv;
tv.tv_sec = 3;
tv.tv_usec = 0;
setsockopt(socket_descriptor, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
SO_RCVTIMEO
(接收超时)
SO_RCVTIMEO
用于设置接收数据的超时时间。通过这个选项,可以指定在接收数据时等待的最长时间。如果在指定的时间内未接收到数据,系统将会返回一个错误。
struct timeval tv;
tv.tv_sec = 3;
tv.tv_usec = 0;
setsockopt(socket_descriptor, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
返回值
当超时时间到达后,read
/recv
/write
/send
等函数将返回-1,同时errno.h
中的全局变量errno
将置为EWOULDBLOCK
。
例子
下面是一个简单的服务端/客户端的例子,如果3秒内没有收到数据则recv
会立即返回。
(1)服务端
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <string.h>
#include <errno.h>
int main() {
int server_socket, client_socket;
struct sockaddr_in server_addr, client_addr;
// 创建服务器套接字
server_socket = socket(AF_INET, SOCK_STREAM, 0);
if (server_socket == -1) {
perror("socket");
exit(EXIT_FAILURE);
}
// 设置服务器地址
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(8080);
// 绑定服务器套接字
if (bind(server_socket, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("bind");
exit(EXIT_FAILURE);
}
// 监听连接
if (listen(server_socket, 1) == -1) {
perror("listen");
exit(EXIT_FAILURE);
}
printf("Server listening on port 8080...\n");
socklen_t client_addr_len = sizeof(client_addr);
client_socket = accept(server_socket, (struct sockaddr *)&client_addr, &client_addr_len);
if (client_socket == -1) {
perror("accept");
exit(EXIT_FAILURE);
}
printf("Client connected...\n");
// 设置发送和接收超时为3秒
struct timeval tv;
tv.tv_sec = 3;
tv.tv_usec = 0;
setsockopt(client_socket, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
setsockopt(client_socket, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
char buffer[1024];
ssize_t bytesRead, bytesWritten;
// 尝试接收数据
bytesRead = recv(client_socket, buffer, sizeof(buffer), 0);
if (bytesRead == -1) {
//perror("recv");
if (errno == EWOULDBLOCK) {
// 超时,需要进行适当的处理
printf("timeout\n");
} else {
perror("recv");
// 其他错误处理
}
} else if (bytesRead == 0) {
printf("Connection closed by client.\n");
} else {
buffer[bytesRead] = '\0';
printf("Received: %s\n", buffer);
// 尝试发送数据
const char *response = "Hello, Client!";
bytesWritten = write(client_socket, response, strlen(response));
if (bytesWritten == -1) {
perror("write");
} else {
printf("Sent: %s\n", response);
}
}
// 关闭套接字
close(client_socket);
close(server_socket);
return 0;
}
(2)客户端
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int main() {
int client_socket;
struct sockaddr_in server_addr;
// 创建客户端套接字
client_socket = socket(AF_INET, SOCK_STREAM, 0);
if (client_socket == -1) {
perror("socket");
exit(EXIT_FAILURE);
}
// 设置服务器地址
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 服务器地址
server_addr.sin_port = htons(8080);
// 连接到服务器
if (connect(client_socket, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
perror("connect");
exit(EXIT_FAILURE);
}
printf("Connected to server...\n");
while(1);//客户端:不发任何消息,等待服务端recv超时
return 0;
}
2 select
在上一节IO复用模型之select原理及例子中我们介绍了select
函数的使用,其中最后一个字段可以设置超时时间。select
相比setsockopt
更常用,所以这里重点介绍这个方法。先回忆一下select
的原型:
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
返回值:
-
大于0: 表示有一个或多个文件描述符就绪(可读、可写或出错)。
- 返回值表示就绪的文件描述符的数量。
-
等于0: 表示在指定的超时时间内没有文件描述符就绪。
- 如果
select
函数的超时参数为NULL
,它可能会一直等待,直到有文件描述符就绪或出错。 - 如果超时参数设置为一个时间值,表示等待指定时间内是否有文件描述符就绪。
- 如果
-
等于-1: 表示出现错误。
- 可以通过查看
errno
变量获取具体的错误信息。
- 可以通过查看
在Linux中errno
可能返回EINTR
:
EINTR
是在系统调用(select
是一个系统调用)被信号中断时返回的错误码,当一个信号(例如SIGINT
、SIGTERM
)被发送给进程,并且进程正在执行一个系统调用时,该系统调用可能会被中断,返回 EINTR
错误。
2.1 读/写操作的超时处理
下面使用select
来封装一下读、写和连接操作的超时处理流程。
1、读操作
下面是一个监听读描述符fd
的例子,如果三秒没有数据到来,则select
将返回0。同时我们还要判断EINTR
的返回。
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(fd, &readfds);
struct timeval timeout;
timeout.tv_sec = 3;
timeout.tv_usec = 0;
do
{
ret = select(fd + 1, &readfds, NULL, NULL, &timeout);
} while (ret < 0 && errno == EINTR); //如果是中断信号则继续select
if (ready == -1) {
// 出错
} else if (ready == 0) {
// 超时
} else {
// 文件描述符可读
if (FD_ISSET(fd, &readfds)){...}
}
2、写操作
写操作和读操作类似,实现如下:
fd_set writefds;
FD_ZERO(&writefds);
FD_SET(fd, &writefds);
struct timeval timeout;
timeout.tv_sec = 3;
timeout.tv_usec = 0;
do
{
ret = select(fd + 1, NULL, &writefds, NULL, &timeout);
} while (ret < 0 && errno == EINTR); //如果是中断信号则继续select
if (ready == -1) {
// 出错
} else if (ready == 0) {
// 超时
} else {
// 文件描述符可读
if (FD_ISSET(fd, &writefds)){...}
}
但在实际使用过程中,我一般将写描述符监听的超时时间设置为0,select
的返回值可以判断当前内核是否有资源可以处理写操作,比如当前内存不足了,select
将返回0。
2.2 连接操作(connect)的超时处理
对于系统调用connect
,在网线没插或者对端没有在listen
等情况下,connect
函数可能会阻塞几十秒才返回。所以我们很有必要设置connect
函数为非阻塞。
1、设置文件描述符为非阻塞
实现这个功能需要用到fcntl
函数,它可以用来改变已打开文件描述符的属性:
int fcntl(int fd, int cmd, ... /* arg */);
其中参数说明如下:
- fd:文件描述符,表示要操作的文件或套接字。
- cmd:操作命令,指定对文件描述符进行何种操作。常用的命令包括:
F_DUPFD
: 复制文件描述符F_GETFD
: 获取文件描述符标志F_SETFD
: 设置文件描述符标志F_GETFL
: 获取文件状态标志F_SETFL
: 设置文件状态标志F_GETOWN
: 获取异步I/O进程ID或套接字拥有者F_SETOWN
: 设置异步I/O进程ID或套接字拥有者
- arg:可选参数,取决于操作命令,根据不同的命令,参数可能是一个整数、一个结构体指针等
我们可以使用操作命令F_SETFL
设置文件描述符的非阻塞(O_NONBLOCK
)属性来让connect
函数变为非阻塞,现在我们就可以封装两个函数:设置非阻塞模式的函数setNonBlocking
和设置阻塞模式的函数setBlocking
// 将文件描述符设置为非阻塞模式
int setNonBlocking(int sockfd) {
int flags = fcntl(sockfd, F_GETFL, 0);
if (flags == -1) {
perror("fcntl");
return -1;
}
if (fcntl(sockfd, F_SETFL, flags | O_NONBLOCK) == -1) {
perror("fcntl");
return -1;
}
return 0;
}
// 将文件描述符设置为阻塞模式
int setBlocking(int sockfd) {
int flags = fcntl(sockfd, F_GETFL, 0);
if (flags == -1) {
perror("fcntl");
return -1;
}
if (fcntl(sockfd, F_SETFL, flags & ~O_NONBLOCK) == -1) {
perror("fcntl");
return -1;
}
return 0;
}
2、connect函数的封装
假设有一个待连接的套接字sockfd
,然后我们将connect
函数设置为非阻塞,整体的代码流程如下:
(1)将套接字设置为非阻塞模式
if (setNonBlocking(sockfd) == -1) {
close(sockfd);
return 1;
}
(2)调用connect函数
在连接成功的情况下,connect
函数将返回0。但前面设置了套接字为非阻塞,所以这里connect
函数将立即返回,而大概率是不可能在这么一瞬间建立连接的,所以connect
这里会返回-1(这里就不判断返回0的情况了),然后置errno
全局变量为EINPROGRESS
,表示正在连接中。
struct sockaddr_in serverAddr;
serverAddr.sin_family = AF_INET;
serverAddr.sin_port = htons(8080);
serverAddr.sin_addr.s_addr = inet_addr("127.0.0.1");
if (connect(sockfd, (struct sockaddr*)&serverAddr, sizeof(serverAddr)) == -1)
{
if (errno == EINPROGRESS) {
// 连接正在进行中,可以通过select/poll/epoll来检查连接状态,下面用select来判断
printf("Connect in progress...\n");
} else {
perror("connect");
close(sockfd);
return 1;
}
}
(3)使用select判断连接是否建立成功
当连接成功建立后,套接字将可写,所以我们可以使用select
来监听写描述符,并使用timeout
超时字段来设置超时时间:
- 这里需要注意,
select
返回1的时候并不一定表示连接已经建立成功,如果检查套接字的错误状态(使用getsockopt
函数和SO_ERROR
选项)发现没有错误,才表示连接成功建立
int ret;
fd_set wset;
struct timeval tv = {.tv_sec = 5,.tv_usec = 0};
FD_ZERO(&wset);
FD_SET(sockfd, &wset);
do
{
ret = select(sockfd + 1, NULL, &wset, NULL, &tv);
} while (ret < 0 && errno == EINTR);
if(ret == 0)
{
//连接超时:在超时时间内没有连接上
return 1;
}else if(ret < 0)
{
//可以查看errno看发生了什么错误,一般是内核的问题
}else if(ret == 1)
{
//检测到可写
int error, n;
socklen_t len = sizeof(error);
n = getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &len);
if(n == 0)
{
//理论上此时已经建立连接成功,但实际发现无论网络是否正常都会返回0,所以继续使用getsockname判断
struct sockaddr_in clientAddr;
socklen_t clientAddrLen;
n = getsockname(sockfd, (struct sockaddr*)&clientAddr, &clientAddrLen);
if(n == 0)
{
//此时建立连接成功
return 0;
}
}else
{
//建立连接失败
return 1;
}
}
(4)恢复阻塞模式
在connect
完毕后,需要恢复原来的设置:
// 恢复套接字为阻塞模式
if (setBlocking(sockfd) == -1) {
close(sockfd);
return 1;
}