文章目录
前言
本篇笔记主要解决在网络传输过程中遇到的套接字超时问题处理和TCP粘包问题。
一、套接字超时是什么
套接字通信过程中默认使用的是阻塞函数,如果条件不满足则程序一直阻塞在这里,所以我们想要设置一个固定的时间,一旦阻塞的时间超过我们设定的这个时间,就会强制线程或者进程处理其他任务。
例如:
// 等待并接受客户端连接
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// 通信
// 接收数据
ssize_t read(int fd, void *buf, size_t count);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
// 发送数据
ssize_t write(int fd, const void *buf, size_t count);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
// 连接服务器的时候
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
二、套接字超时处理的两种思路
2.1 定时器(不可用)
例如sleep(1),让线程等待一定的时长,(不可用原因:不能马上醒过来,在指定时间之内,如果满足条件,不能直接解除阻塞)。例如接受数据read/recv,只要读缓冲区没有数据,我们就要阻塞在这里,但是假如我们让程序睡眠十秒后退出,在这十秒之后读缓冲区有数据了,不能马上退出,直至睡眠完成,所以就是说程序大部分时间都在睡觉,所以该思路不可用。
2.2 select
IO多路转接函数最后一个参数是设置阻塞时长,所以这些函数可以帮助我们检测内核fd状态(读/写/异常),例如accept检测读缓冲区,检测读缓冲区有没有数据就行,我们在这里设置一个阻塞时长,如果一直没有检测到数据并且超时,那么IO转接函数就会直接退出。如果在阻塞过程中,如果检测到状态变化则直接返回。
例如:
int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout); // 单位: s
int poll(struct pollfd *fds, nfds_t nfds, int timeout); // 单位: 毫秒
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);// 单位: 毫秒
三、超时处理select实现
3.1 accept
// 等待并接受客户端连接
// 如果没有客户端连接, 一直阻塞
// 检测accept函数对应的fd(监听的文件描述法)的读缓冲区就可以了
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
// 使用select检测状态
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
struct timeval val = {3, 0}; // 3s
// 监听的sockfd放到读集合中进行检测
fd_set rdset;
FD_ZERO(&rdset);
FD_SET(sockfd, &rdset); // sockfd监听的文件描述符
int ret = select(sockfd+1, &rdset, NULL, NULL, &val);
if(ret == 0)
{
// 超时了, 最后一个参数等待时长用完了
}
else if(ret == 1)
{
// 有新连接
accept(); // 绝对不阻塞
}
else
{
// 异常, select调用失败, 返回值为 -1
}
在传统的网络编程里,服务器端调用 accept 函数时,如果没有客户端连接到来,该函数会一直阻塞,所以我们使用 select 函数对监听套接字进行监控,同时设置一个超时时间。在这个超时时间内,若有新的客户端连接到来,select 函数会返回,接着就可以调用 accept 函数去接受连接;若超时时间用完仍没有新连接,select 函数也会返回,这样程序就能继续执行其他任务。
3.2 read超时
// 等待并对方发送数据到本地
// 如果对方没有发送数据, 一直阻塞
// 检测read函数对应的fd(通信的文件描述符)的读缓冲区就可以了
ssize_t read(int fd, void *buf, size_t count);
// 使用select检测状态
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
struct timeval val = {3, 0}; // 3s
// 通信的fd放到读集合中进行检测
fd_set rdset;
FD_ZERO(&rdset);
FD_SET(fd, &rdset); // fd通信的文件描述符
int ret = select(fd+1, &rdset, NULL, NULL, &val);
if(ret == 0)
{
// 超时了, 最后一个参数等待时长用完了
}
else if(ret == 1)
{
// 有新数据达到-> 对方发送来的通信数据
read()/recv(); // 绝对不阻塞
}
else
{
// 异常, select调用失败, 返回值为 -1
}
当调用read函数从套接字读取数据时,如果没有数据到达,函数会一直阻塞,借助select在规定时间内监控指定套接字的可读状态,若超时仍无数据,程序可继续执行其他操作;若有数据到达,再调用read函数读取数据,此时read函数不会阻塞。
3.3 write超时
伪代码:
// 将要发送的数据写到本地写缓冲区
// 本地写缓冲区, 一直阻塞
// 检测write函数对应的fd(通信的文件描述符)的写缓冲区就可以了
ssize_t write(int fd, const void *buf, size_t count);
// 使用select检测状态
struct timeval {
time_t tv_sec; /* seconds */
suseconds_t tv_usec; /* microseconds */
};
struct timeval val = {3, 0}; // 3s
// 通信的fd放到写集合中进行检测
fd_set wrset;
FD_ZERO(&wrset);
FD_SET(fd, &wrset); // fd通信的文件描述符
int ret = select(fd+1, NULL, &wrset, NULL, &val);
if(ret == 0)
{
// 超时了, 最后一个参数等待时长用完了
}
else if(ret == 1)
{
// 写缓冲区可写
write()/send(); // 绝对不阻塞
}
else
{
// 异常, select调用失败, 返回值为 -1
}
3.4 connect超时
Posix 定义了与 select/epoll 和 非阻塞 connect 相关的规定:
连接过程中写缓冲区不可用;连接建立成功时,socket 文件描述符变为可写。(连接建立时,写缓冲区空闲,所以可写);连接建立失败时,socket 文件描述符既可读又可写。 (由于有未决的错误,从而可读又可写)
连接失败, 错误判定方式:
当用select检测连接时,socket既可读又可写,只能在可读的集合通过getsockopt获取错误码。
处理流程:
- 设置connect函数操作的文件描述符为非阻塞
- 调用connect
- 使用select检测
- 需要getsockopt进行判断
- 设置connect函数操作的文件描述符为阻塞 -> 状态还原
// connect超时处理
// 设置非阻塞
int flag = fcntl(connfd, F_GETFL);
flag |= O_NONBLOCK;
fcntl(connfd, F_SETFL, flag);
// 连接服务器 -> 不阻塞了
connect(connfd, &serveraddress, &addlen);
// 通过select检测
struct timeval val = {3, 0}; // 3s
// 通信的connfd放到写集合中进行检测
fd_set wrset;
FD_ZERO(&wrset);
FD_SET(connfd, &wrset); // fd通信的文件描述符
// 函数返回了, connect有结果了, 成功/失败 -> 过程走完了, 得到了结果
int ret = select(fd+1, NULL, &wrset, NULL, &val);
if(ret == 0)
{
//这里就是检测了val时间,还是没返回,就是超时了
// 超时了, connect还在连接过程中
}
else if(ret == 1)
{
// 写缓冲区可写
// 连接过程完成了, 得到了结果
int opt;
getsockopt(connfd, SOL_SOCKET, SO_ERROR, &opt, sizeof(opt));
if(opt > 0)
{
// connect失败了
}
else if(opt == 0)
{
// connect连接成功了
}
}
else
{
// 异常, select调用失败, 返回值为 -1
}
// 将connfd状态还原 -> 阻塞
connect的超时处理跟前面几个不太一样,首先要将connect设置为非阻塞,无论连接成功与否直接返回(不用在这儿阻塞,如果这里不设置非阻塞,就可能在这里一直阻塞,因为有可能三次握手由于某种一直连不上)。可以理解为直接获取三个状态(连接过程中,连接成功,连接失败,我们需要用select来获取连接过程中这个状态,所以要设置为非阻塞)。
默认函数是有一个超时处理的,但是如果这个时间不能满足我们,我们就需要自己处理,不能直接用select的原因就是没有意义,因为我们不能检测写缓冲区,无论写缓冲区可不可用都不能代表connect是否成功。
四、TCP粘包问题
4.1 原因
- 想象一个场景,客户端每隔1s给服务器发送一条数据, 每条数据长度 100字节 , 服务器每隔2s接收一次数据,服务器就会接收一个数据得到200字节 -> 粘包(不能区分两个包)
- 怎么造成的?
- 发送的时候, 内核进行了优化, 数据到达一定量发送一次
- 网络环境不好, 有延时
- 接收方接收数据频率低, 一次性读到了多条客户端发送的数据
- 解决方案:
- 发送的时候, 强制缓冲区数据被发送出去 - > flush
- 在发送数据的时候每个数据包添加包头 包头: 一块内存,
- 存储了当前这条消息的属性信息 ,属于谁 -> char[12] ,有多大 -> int……
4.2 解决逻辑
- 发送端:
- 将数据长度转换为网络字节序,作为头部发送。
- 再发送实际数据内容。
- 接收端:
- 先读取 4 字节长度字段,转换为主机字节序。
- 根据长度读取完整数据。
伪代码如下:
// 发送逻辑
int dataLen = sendData.size() + 1; // +1 包含 '\0'
unsigned char *netdata = (unsigned char*)malloc(dataLen + 4);
int netlen = htonl(dataLen);
memcpy(netdata, &netlen, 4); // 写入长度
memcpy(netdata + 4, sendData.data(), dataLen); // 写入数据
// 接收逻辑
int netdatalen = 0;
readn(&netdatalen, 4); // 读取长度
int n = ntohl(netdatalen);
char *tmpBuf = (char*)malloc(n);
readn(tmpBuf, n); // 读取数据