1、防人之心不可无:检查数据的有效性
开篇词
为了增强程序的健壮性,我们还需要准备什么。
1.1、对端的异常状况
在前面的第 11 讲以及第 17 讲中,我们已经初步接触过一些防范对端异常的方法;
比如,通过 read 等调用时,可以通过对 EOF 的判断,随时防范对方程序崩溃。
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
if (nBytes == -1) {
error(1, errno, "error read message");
} else if (nBytes == 0) {
error(1, 0, "client closed \n");
}
程序中的第 4 行,当调用 read 函数返回 0 字节时,实际上就是操作系统内核返回 EOF 的一种反映。
如果是服务器端同时处理多个客户端连接,一般这里会调用 shutdown 关闭连接的这一端。
但是,不是每种情况都可以通过读操作来感知异常;
比如,服务器完全崩溃,或者网络中断的情况下,此时,如果是阻塞套接字,会一直阻塞在 read 等调用上,没有办法感知套接字的异常。
解决方法:
1、给套接字的 read 操作设置超时,如果超过了一段时间就认为连接已经不存在。具体的代码片段如下:
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
setsockopt(connfd, SOL_SOCKET, SO_RCVTIMEO, (const char *) &tv, sizeof tv);
while (1) {
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
if (nBytes == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
printf("read timeout\n");
onClientTimeout(connfd);
} else {
error(1, errno, "error read message");
}
} else if (nBytes == 0) {
error(1, 0, "client closed \n");
}
...
}
- 第 4 行调用 setsockopt 函数,设置了套接字的读操作超时,超时时间为在第 1-3 行设置的 5 秒;
- 当然在这里这个时间值是“拍脑袋”设置的,比较科学的设置方法是通过一定的统计之后得到一个比较合理的值。
- 关键之处在读操作返回异常的第 9-11 行,根据出错信息是EAGAIN或者EWOULDBLOCK,判断出超时,转而调用onClientTimeout函数来进行处理。
2、第第 12 讲中提到的办法,添加对连接是否正常的检测。如果连接不正常,需要从当前 read 阻塞中返回并处理。
3、前面第 12 讲也提到过,那就是利用多路复用技术自带的超时能力,来完成对套接字 I/O 的检查,如果超过了预设的时间,就进入异常处理。
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
FD_ZERO(&allreads);
FD_SET(socket_fd, &allreads);
for (;;) {
readmask = allreads;
int rc = select(socket_fd + 1, &readmask, NULL, NULL, &tv);
if (rc < 0) {
error(1, errno, "select failed");
}
if (rc == 0) {
printf("read timeout\n");
onClientTimeout(socket_fd);
}
...
}
- 这段代码使用了 select 多路复用技术来对套接字进行 I/O 事件的轮询;
- 程序的 13 行是到达超时后的处理逻辑,调用onClientTimeout函数来进行超时后的处理。
1.2、缓冲区处理
一个设计良好的网络程序,应该可以在随机输入的情况下表现稳定。
那么程序都有可能出现哪几种漏洞呢?
第一个例子
char Response[] = "COMMAND OK";
char buffer[128];
while (1) {
int nBytes = recv(connfd, buffer, sizeof(buffer), 0);
if (nBytes == -1) {
error(1, errno, "error read message");
} else if (nBytes == 0) {
error(1, 0, "client closed \n");
}
buffer[nBytes] = '\0';
if (strcmp(buffer, "quit") == 0) {
printf("client quit\n");
send(socket, Response, sizeof(Response), 0);
}
printf("received %d bytes: %s\n", nBytes, buffer);
}
这段代码从连接套接字中获取字节流,并且判断了出错和 EOF 情况;
如果对端发送来的字符是“quit”就回应“COMAAND OK”的字符流,乍看上去一切正常。
但仔细看一下,这段代码很有可能会产生下面的结果。
char buffer[128];
buffer[128] = '\0';
通过 recv 读取的字符数为 128 时,就会这样的结果。因为 buffer 的大小只有 128 字节,最后的赋值环节,产生了缓冲区溢出的问题。
缓冲区溢出:
- 是指计算机程序中出现的一种内存违规操作。
- 本质是计算机程序向缓冲区填充的数据,超出了原本缓冲区设置的大小限制,导致了数据覆盖了内存栈空间的其他合法数据。
- 这种覆盖破坏了原来程序的完整性,使用过游戏修改器的同学肯定知道,如果不小心修改错游戏数据的内存空间,很可能导致应用程序产生如“Access violation”的错误,导致应用程序崩溃。
解决方法:
留下 buffer 里的一个字节,以容纳后面的’\0’。
int nBytes = recv(connfd, buffer, sizeof(buffer)-1, 0);
有趣的现象:
我们发送过去的字符串,调用的是sizeof,那也就意味着,Response 字符串中的’\0’是被发送出去的,而我们在接收字符时,则假设没有’\0’字符的存在。
为了统一,我们可以改成如下的方式,使用 strlen 的方式忽略最后一个’\0’字符。
send(socket, Response, strlen(Response), 0);
第二个例子
第 16 讲中提到了对变长报文解析的两种手段:
- 使用特殊的边界符号,例如 HTTP 使用的回车换行符;
- 将报文信息的长度编码进入消息。
在实战中,我们也需要对这部分报文长度保持警惕。
size_t read_message(int fd, char *buffer, size_t length) {
u_int32_t msg_length;
u_int32_t msg_type;
int rc;
rc = readn(fd, (char *) &msg_length, sizeof(u_int32_t));
if (rc != sizeof(u_int32_t))
return rc < 0 ? -1 : 0;
msg_length = ntohl(msg_length);
rc = readn(fd, (char *) &msg_type, sizeof(msg_type));
if (rc != sizeof(u_int32_t))
return rc < 0 ? -1 : 0;
if (msg_length > length) {
return -1;
}
/* Retrieve the record itself */
rc = readn(fd, buffer, msg_length);
if (rc != msg_length)
return rc < 0 ? -1 : 0;
return rc;
}
在进行报文解析时,第 15 行对实际的报文长度msg_length和应用程序分配的缓冲区大小进行了比较,如果报文长度过大,导致缓冲区容纳不下,直接返回 -1 表示出错。
千万不要小看这部分的判断,试想如果没有这个判断,对方程序发送出来的消息体,可能构建出一个非常大的msg_length,而实际发送的报文本体长度却没有这么大,这样后面的读取操作就不会成功,如果应用程序实际缓冲区大小比msg_length小,也产生了缓冲区溢出的问题。
第三个例子
举例:
如果我们需要开发一个函数,这个函数假设报文的分界符是换行符(\n);
一个简单的想法是每次读取一个字符,判断这个字符是不是换行符。
这里有一个这样的函数,这个函数的最大问题是工作效率太低,要知道每次调用 recv 函数都是一次系统调用,需要从用户空间切换到内核空间,上下文切换的开销对于高性能来说最好是能省则省。
size_t readline(int fd, char *buffer, size_t length) {
char *buf_first = buffer;
char c;
while (length > 0 && recv(fd, &c, 1, 0) == 1) {
*buffer++ = c;
length--;
if (c == '\n') {
*buffer = '\0';
return buffer - buf_first;
}
}
return -1;
}
解决方法:
第二个版本,这个函数**一次性读取最多 512 字节到临时缓冲区,之后将临时缓冲区的字符一个一个拷贝到应用程序最终的缓冲区中,**这样的做法明显效率会高很多。
size_t readline(int fd, char *buffer, size_t length) {
char *buf_first = buffer;
static char *buffer_pointer;
int nleft = 0;
static char read_buffer[512];
char c;
while (length-- > 0) {
if (nleft <= 0) {
int nread = recv(fd, read_buffer, sizeof(read_buffer), 0);
if (nread < 0) {
if (errno == EINTR) {
length++;
continue;
}
return -1;
}
if (nread == 0)
return 0;
buffer_pointer = read_buffer;
nleft = nread;
}
c = *buffer_pointer++;
*buffer++ = c;
nleft--;
if (c == '\n') {
*buffer = '\0';
return buffer - buf_first;
}
}
return -1;
}
总结
在网络编程中,是否做好了对各种异常边界的检测,将决定我们的程序在恶劣情况下的稳定性;
所以,我们一定要时刻提醒自己做好应对各种复杂情况的准备,这里的异常情况包括缓冲区溢出、指针错误、连接超时检测等。
思考题
1、我们在读数据的时候,一般都需要给应用程序最终缓冲区分配大小,这个大小有什么讲究吗?
最终缓冲区的大小应该比预计接收的数据大小大一些,预防缓冲区溢出。
2、你能分析一下,我们文章中的例子所分配的缓冲是否可以换成动态分配吗?比如调用 malloc 函数来分配缓冲区?
完全可以动态分配,但是要记得在return前释放缓冲区