近期实现了一个linux epoll 服务器, 大致功能就是接收客户端数据,分析数据,按字段存入数据库, 是一个短链接服务, 从中收获良多,这里谈一下, 服务器开发中常遇问题和解决方案。
源码: https://github.com/BambooAce/Repository/tree/master/network/server_epoll 大致模型
至于epoll 模型原理和使用网上有很多教材, 推荐man page epoll 这里详细描述了epoll的用法,或者 http://blog.csdn.net/yusiguyuan/article/details/24833339 。
这里就谈一谈以下几点:
1. 客户端与服务器 定义何种消息格式?
2. 如何避免恶意的长期链接?
3. 网络延迟造成的数据不完整怎样解决?
4. 使用阻塞还是非阻塞?
5. read write 读写 使用过程中的问题.
1. 客户端与服务器 定义何种消息格式?
正如我们常见的http协议 , 在服务器响应的时候在其头部会有正文的长度:
Content-Length: 24255
Accept-Ranges: bytes
Date: Tue, 22 Aug 2017 07:14:55 GMT
在一些字符形式的传输过程中,也要在消息头中加入正文段的长度,当然这里若是传输文件可以除外, 这样做的目的就是:可以保证数据完整性, 即使数据被分为多个数据包被发送,或者发生了粘包现象 都有办法可以读到正确数据, 这里一次连消息头都读不完整的情况很少, 一般消息头都非常短。
在游戏开发过程中常常传输二进制, 二进制的好处就在于数据短,但是要求客户端和服务器端对齐方式是一致的, 另外也要有简单的消息头, 包含整个消息的长度, 即使是二进制也存在数据不完整,粘包现象。二进制结构体包括定长结构体 和变长结构体, 这里定长, 只要对齐方式相同可直接转为相关结构体; 若是变长结构体, 那么就要在消息体的各个字段中加入长度信息 如下:
struct package{
int strlen;
char *str;
int str2len;
char *str2;
}
这里的strlen 和 str2len 都是下面字符串的长度信息, 这里注意的是char 指针类型 , 并不能够直接将结构体数据发送,send的时候要将全部结构体内容存入buff, 再将buff 发送至服务器。 对于接收数据,要按照结构体严格解析, 如先读取四个字节读出strlen大小, 再读strlen长度的字符串以此类推。变长结构体确实有点复杂。
在二进制传输中, 还有大端小端问题, 不过现在的主流机器都是小端。
这里我们可以总结为无论是字符流还是二进制, 包含消息长度信息是十分必要的。
2. 如何避免恶意的长期链接?
一个连接怎样定义为恶意的长期连接, 无论是长连接和短连接都会发生这样的问题, 比如一个客户端只 connect 然后就sleep 或者 大批量的connect 后就陷入阻塞, 那么服务器accept返回大量的描述符,占用系统资源,然而他们并不发送数据, 当文件描述符被占用完时, 就造成了一种拒绝服务攻击, 为避免这种恶意连接, 在服务器端要对每个连接保存客户端信息,如 { {文件描述符, 客户端IP, 客户端端口号},连接时间 }
struct SingleConn{
struct sockaddr_in cliaddr;
int clifd;
bool header;//以下四个与数据拼接有关
int size;
char *data;
int haveRecved;
};
typedef struct sc{
SingleConn sgc; //客户端 文件描述符信息 IP PORT
time_t t_time; //客户端连接上的时间
}SC;
当服务器accept成功之后, 就记录这一个连接并将记录放入类似链表的数据结构中。
for(;;)
{
int res = select/poll/epoll(,,, timeout)
if(res)
{
delete_unnormal();
}
if(lisfd 可读){
int clifd = accept()
Record_client(clifd): //这里会记录客户端信息 连接上的时间。
delete_unnormal();
}
if(其他可读){
read()
delete_unnormal();
if(read == 0)
{
delete_client();
close(clifd);
}
}
if(其他可写){
write();
delete_unnormal();
}
}
其中delete_unnormal() 删除异常连接在每个阶段都会被调用, 其实现就是: 得到当前时间, 遍厉链表中所有客户端连接信息,拿当前时间 - 每个客户端的连接时间 看是超过timeout,若某个客户端超过了这个时间, 就将其踢掉。若客户端发送过数据就将连接时间变为发送数据的时间。 这样对于短链接是十分有效的, 对于长连接来说, 一般刚连接上都会发送消息头信息, 可以在结构体中加入已经收到过信息的标志位, 若发送过消息头就直接跳过。
typedef struct sc{
SingleConn sgc; //客户端 文件描述符信息 IP PORT
time_t t_time; //客户端连接上的时间
int flags;//是否发送过数据
}SC;
如上方法基本上就可以解决了恶意的connect, 这里说明以下当踢除恶意连接时,要发送RST,不要close掉连接, 这样的话服务器端才不会产生大量的TIME_WAIT. 遍厉所有连接判断是否超时, 消耗并不算大。
void send_RST(int clifd)
{
struct linger lger;
lger.l_onoff = 1;
lger.l_linger = 0;
if(clifd){
setsockopt(clifd, SOL_SOCKET, SO_LINGER, &lger, sizeof(lger));
close(clifd);
}
}
3. 网络延迟造成的数据不完整怎样解决?
struct {
struct sockaddr_in cliaddr;
int clifd;
bool header;//以下四个与数据拼接有关
int size;
char *data;
int haveRecved;
};
服务器端用类似以上结构保存连接, 就可实现发送数据不完整时完成拼接, 这里的header 是指 是否受到了消息头, size指消息头里面正文的长度, haveRecved 就是指这个连接已经收到了多少数据, data即使保存不完整数据的结构, 待数据完整后将数据交给其他部分处理。
int fillIntoData(char *buff, int len)
{
if(size < (haveRecved + len)){ //数据过大则截取
memcpy(data+haveRecved, buff, size - haveRecved);
haveRecved = size;
return 1;
}
if(data == NULL)
{
data = (char *)malloc(size);
}
memcpy(data + haveRecved, buff, len);
haveRecved += len;
if(haveRecved == size)
return 1;
return 0;
}
大致模型如下:
read() 读取客户端数据
check(data) //简单检查数据是否有消息头
if() //如果数据包含头并不完整
{
存入客户端结构体的data内
}
else if()//如果不是头
{
if()//如果接收过头
{
拼接数据
}
else //没有接收到头信息
{
踢除这个连接 清空buff
}
}
如若是短链接, 网络不好数据发送了一半就超时了,踢除连接的同时要清理data。
4. 使用阻塞还是非阻塞?
至于这部分的话, 非阻塞的好处还是大于阻塞的
详细可参考: http://blog.csdn.net/hzhsan/article/details/23650697
5. read write 读写 使用过程中的问题.
这里主要说一说非阻塞情况下的 read 和 write
如下:
ssize_t /* Read "n" bytes from a */
readn(int fd, void *vptr, size_t n)
{
size_t nleft;
ssize_t nread;
char *ptr;
ptr = vptr;
nleft = n;
while (nleft > 0) {
if ( (nread = read(fd, ptr, nleft)) < 0) {
if (errno == EINTR)
nread = 0; /* and call read() again */
else
return(-1);
} else if (nread == 0)
break; /* EOF */
nleft -= nread;
ptr += nread;
}
return(n - nleft); /* return >= 0 */
}
《unix 网络编程》书中的readn 写法, 这样写的原因就时并不是一下就可以将全部数据读出, 如果接收数据buffer 不够时, 读出数据就比要求数据要少, 所以要这样做直到读取规定大小。
但是考虑如下情况:
服务是长连接, read 的size_t n 一直没有够, 比如说发到n的一半不发送了也不断开连接, 想象一下, 接下来就会一直阻塞在read那里, 单线程的话, 服务器就不再处理其他连接了, 一直阻塞直到发送数据达到n 返回, 那么可以将之改为非阻塞:
while (nleft > 0) {
if ( (nread = read(fd, ptr, nleft)) < 0) {
if (errno == EINTR)
nread = 0;
else if(errno == EAGAIN)
continue;
else
return(-1);
} else if (nread == 0)
break; /* EOF */
nleft -= nread;
ptr += nread;
}
这里改为了非阻塞, 但是应对以上情况也是会陷入循环之中从而不能处理其他连接。此函数退出情况只有中断, 满足了size , nread == 0 也就是收到FIN, 但是对于长连接而言没能满足size 也没有断开, 这就陷入了尴尬的地步。
对于循环read模型
while(1)
{
int size = read();
if(size == 0)
{
break;
}
}
若如这种模型用于传输大文件, 那么也会造成其他连接不能及时处理的情况。 若是小数据流:
if(events[i].events && EPOLLIN)
{
REREAD:
int size = read(fd, buff, 1024);
if(errno == EINTR)
goto REREAD;
if(size){
//数据接收拼接
}
if(size == 0)
{
close(fd);
}
}
在拥有消息头的情况下,这种方式无论长连接短连接, 数据达不到size不至于陷入循环, 但是用此模型传输大文件,效率又太低。。。 适用于较小的流。
总之 IO事件处理方式要根据具体流量来定义, 不能够因单个阻塞, 或在陷入循环不能及时处理其他连接。