困扰我快一个月的BUG

昨天终于解决了...

1.背景:

提供音视频服务的业务随着用户量的上涨,UDP丢包逐渐加剧。其实包量也不大,高峰期每秒不超过10W的UDP包,不应该丢包的。

而前辈们的网络底层代码是单线程的,因此决定将网络线程独立出来,以提高效率。

2.多线程策略
  学过操作系统的应该知道,生产者和消费者的例子。只需要知道1:1的例子即可,这种情形下,可以使用无锁环形缓冲区,避免了mutex的代价(尽管事实上mutex的代价并不大)。
  对于每一个socket,从逻辑线程角度看(也就是你写的那个运行main的线程),有两个对应的缓冲区:
  recvbuffer和sendbuffer.每个buffer有一个读游标和一个写游标,生产者修改写游标,消费者修改读游标,两个游标之间的buffer就是有效数据。
  对于recvbuffer,逻辑线程是消费者,网络线程是生产者;
  对于sendbuffer,反之。
3.环形缓冲区结构
 很简单,如图。
 1345038772_87.png
说明:之所以让缓冲区设置为2的幂,是因为缓冲区回绕时,这样计算游标:
pos = (pos + offset) & (m_maxSize - 1);
如果m_maxSize 不是2的幂,就只能用%运算符.
操作:不详说了,要注意环形缓冲区需要空一个元素,来区别满或者空。
为空的条件:readpos == writepos;
为满的条件:((writepos + 1) & (m_maxSize -1)) == readpos;
如果不空出一个元素位置,是无法区别满或空的.
当发生缓冲区回绕时候,如图,蓝色为有效数据:
1345039399_21.png
  
遇到这种情况,我们无法使用sendto/recvfrom或recv/send函数,因为它只能处理连续缓冲区。
因此应该使用writev/readv, recvmsg/sendmsg,支持iovec、所谓的聚集写/分散读。
最终选用了recvmsg/sendmsg,后面介绍原因。
如果说recv/send的功能是sendto/recvfrom的真子集,
那么sendto/recvfrom是recvmsg/sendmsg的真子集。
4. 如何无锁
读写游标仅仅是int变量,如果只有一个写者,是可以用Interlocked系列函数保证读写安全的。
在X86系列CPU,该系列函数会在总线上维持一个硬件信号,阻止其它CPU访问同一个内存地址。(这句话摘抄自windows核心编程);
linux这一系列函数是__sync_add_and_fetch等3个,详情查资料。
5.多线程的开销
  一定有人怀疑性能,性能。
  都是杞人忧天。
  多和。
  就算单CPU,依然不能和单线程网络库同日而语。
  还有就是缓冲区之间的拷贝;
  明确指出:用户空间的0拷贝。
  (recvmsg这1次从内核到用户空间的拷贝不算,因为是绝对无法避免的,除非用windows的IOCP)
6.介绍UDP收发包(不介绍TCP,TCP面向连接,收发非常简单,而且部门几乎不用)
之前只写过TCP, 这次由于丢包问题,临时花了两三天添加了UDP支持,但是测试花了至少两星期以保证稳定性。
单线程收发包的简单性:
我们recvfrom一个包,对包处理,回包,只需要回recvfrom得到的地址即可,或者一个我们指定的地址。
发包也是,一个包与一个地址对应,easy.
多线程收发包的复杂性:
(1).网络线程收到很多包,逻辑线程一一处理。这些包对应的来源地址,肯定不能从recvfrom里获得了。
因此,网络线程每收到一个包,不仅仅需要将包放入接收缓冲区,还需要将地址写在包的尾部。
且这个地址一定要对应用程序透明,如果应用程序不指定地址直接调用SendPacket(my_packet),那么网络线程自动采用该包的来源地址作为目的地址(这也就是一个回包)。
(2).收到的包需要标明长度
 要做到0拷贝和健壮性(如果逻辑线程解包错误,无法正确调整读游标怎么办?)
 因此要给出消息长度,网络库要保证在每一个包解析之后,调整读游标;
 该长度对于应用程序透明,你无须关心。
 recvmsg是既支持获取对方地址,又支持分散读的函数,因此选用它。
(3).发包
 你需要为每一个包指定地址。
 如果是广播,你需要指定地址数目,后面跟上地址串,再后面是包的长度,包的内容。
 格式如下:
 nAddr + addr_1 + addr_2 + .. + addr_n + nPacketLen + packetContent.
以上这些设计,导致CircularBuffer比较难写,需要小心。
因为我们不能每写入/读出一次就要调整写/读游标,要保证逻辑上的完整性。
初写时犯过一次这样的错误,程序运行了一天,突然就崩了.
sendmsg是既支持填写对方地址,又支持聚集写的函数,因此选用它。
7. 发包要小心
UDP和TCP不一样,没流控,不能狂send,会丢包的。
但不是很熟悉UDP的sendto是否会返回EAGAIN,应该会的。(补充:确实会,可以注册EPOLL_OUT)
目前做了这个检测,发生时就停止sendto,转而epoll_wait;
也做了一些人为限制,比如某一次send,如果累计达到5000个包,或者1.5MB字节,也转epoll_wait。
不做限制,会有严重的发送丢包。
8.推荐
学习陈硕先生的muduo(只支持TCP)。
好了,现在开始说BUG
双线程程序确实使得丢包率几乎将为0;但是程序运行几天就有可能挂掉,不得不撤回。
检查core文件发现,在逻辑线程解析消息时,有个assert语句,假定取到的消息长度是正确的。(如果网络库没有bug,这个assert一定为true)
但此时明显发现取到的长度信息是一个很大的数,查看16进制,发现该值来自于消息体;
也就是网络线程收消息时把接收缓冲区写乱了?
先是猜疑自己某些地方会导致竞争发生。但仔细检查了几遍,确定没有。
之后对CircularBuffer做了各种临界测试,也通过了;
疑点转移到了atomic系列函数。因为我对int是直接做的读取,并没有atomic_get;
我认为这样是没错的,读取一定是原子的,可以读到旧值,但绝不会读到乱值。
找不到原因,core文件看不出名堂,事情就阻塞了。
直到昨天读代码时,突然发现了疑点:
bool DatagramSocket::OnReadable()
{
    while (true)
    {
        BufferSequence  buffers;
        m_recvBuf.GetSpace(buffers, sizeof(int)); // 预写4字节长度信息
        if (0 == buffers.count) 
        {
            ERR << "Recv buf is not enough, logical thread is too slow";
            return true;
        }

        sockaddr_in   addr;

        msghdr  msg;
        msg.msg_name = &addr;
        msg.msg_namelen = sizeof(addr);
        msg.msg_iov     =  buffers.buffers;
        msg.msg_iovlen  =  buffers.count;
        msg.msg_control =  0;
        msg.msg_controllen =  0;

        const int  nBytes = ::recvmsg(m_localSock, &msg, 0);

        if (-1 == nBytes  && (EAGAIN == errno || EWOULDBLOCK == errno))
        {
            return true;
        }

        if (nBytes < 0)
        {
            ERR << __PRETTY_FUNCTION__
                << " udp fd "
                << m_localSock
                << "OnRead error : "
                << nBytes
                << ",errno = "
                << errno;
            return  true;
        }
        else
        {
            m_recvBuf.PushDataAt(&nBytes, sizeof(nBytes), 0);
            m_recvBuf.PushDataAt(&addr, sizeof(addr), sizeof(nBytes) + nBytes);
            m_recvBuf.AdjustWritePtr(sizeof(nBytes) + nBytes + sizeof(addr));
        }
    }
    return  true;
}

该函数是在EPOLL_IN发生时触发。

由于收包量非常大,且包体偏大,高峰期是会出现接收缓冲区满的情形。
而上面代码有多处漏洞:

1.误解了recvmsg。
当提供的缓冲区小于一个UDP数据报大小时候,UDP报被截断,recvmsg仍然返回被截断后的字节数;
而我一直以为是返回-1,没做处理。

2.忽略了环形缓冲区的PushDataAt的返回值。
push失败会返回false,这时不要去调整写游标,会导致缓冲区错乱,这就是bug根源!
我也是这时想起之前的3次core有个共同特点:writepos比readpos稍稍大一些。
其实就是这时缓冲区上溢了!
之所以没有判断缓冲区满,我以为设置的16MB足够用。。。太想当然了

HOHO,以后绝不要轻易忽略非void函数的返回值。。。
附修改后的代码:
bool DatagramSocket::OnReadable()
{
    while (true)
    {
        BufferSequence  buffers;
        m_recvBuf.GetSpace(buffers, sizeof(int)); // 预写4字节长度信息
        if (0 == buffers.count) 
        {
            ERR << "Recv buf is not enough, logical thread is too slow";
            return true;
        }

        sockaddr_in   addr;

        msghdr  msg;
        msg.msg_name = &addr;
        msg.msg_namelen = sizeof(addr);
        msg.msg_iov     =  buffers.buffers;
        msg.msg_iovlen  =  buffers.count;
        msg.msg_control =  0;
        msg.msg_controllen =  0;

        const int  nBytes = ::recvmsg(m_localSock, &msg, 0);

        if (-1 == nBytes  && (EAGAIN == errno || EWOULDBLOCK == errno))
        {
            return true;
        }

        if (nBytes < 0)
        {
            ERR << __PRETTY_FUNCTION__
                << " udp fd "
                << m_localSock
                << "OnRead error : "
                << nBytes
                << ",errno = "
                << errno;
            return  true;
        }
        else if (msg.msg_flags & MSG_TRUNC)
        {
            ERR << "msg truncated! only get bytes " << nBytes;
        }
        else
        {
            if (!m_recvBuf.PushDataAt(&nBytes, sizeof(nBytes), 0) ||
                !m_recvBuf.PushDataAt(&addr, sizeof(addr), sizeof(nBytes) + nBytes))
            {
                ERR << "Recv buffer is overflow!";
                return true;
            }
             m_recvBuf.AdjustWritePtr(sizeof(nBytes) + nBytes + sizeof(addr));
        }
    }
    return  true;
}


  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值