2019年3月19日星期二

TCP与RS-485接口数据转发效率优化

几个月前接手了一个任务,在x86处理器+Linux系统上,做一个TCP接口与RS-485总线之间双向数据转发的程序。具体来说,就是我这个程序作TCP Server, 开一堆端口,每个端口对应一个RS-485端口(为描述问题简单,此处简化,真实情况是8个TCP端口对应一个RS-485端口),当该TCP端口接收到数据后,立即将数据原样转发到对应的RS-485总线上;当某条RS-485总线上接收到数据后,立即将数据原样转发到对应的TCP端口上。
由于我的程序作TCP Server, 我只会用epoll机制,而RS-485总线也是IO, 也可以纳入epoll进行管理。可以说,epoll是Linux系统管理多个IO的最高效、最先进的手段,使用这个机制可以说无懈可击,只是很多不熟悉Linux编程的人不懂epoll而以。于是我就把另一个程序的epoll框架和RS-485初始化代码拿过来,花了一天的时间拼凑在一起,这个程序的第一个版本就做好了。
第一个版本交付后,出过几个小问题,有些是我的问题,有些不是我的问题,不是本文的重点,此处不提,专门讲转发效率优化。
使用我这个程序的同事(下文称为“用户”),有一次和我抱怨转发效率太低了。于是我在程序中加了一些带有时间戳的打印。通过打印可以看出,问题不是出在我这里。因为那时他们设定的RS-485总线波特率是9600bps. 算下来传输一个字节需要1ms。转发一个100字节的报文需要100ms左右,很正常。和用户解释后,用户请示领导,将波特率提高到了38400bps,就这样用了很久。后来用户和领导都来找过我,问我能否进一步提高转发速度,我当时认为我的epoll机制效率是最高的做法了,一直坚持回答无法进一步提高。
最近,用户又来找我,说一个报文从TCP端口发出来到TCP端口收到回复要200ms左右,感觉还是太慢了。我之前加的带时间戳的打印都是加在RS-485接口上,于是我又在网络接口上加了一些带时间戳的打印,看能否发现问题,或者证明自己没问题,结果发现了问题。
问题出在一个很恶心的需求。由于RS-485总线的速度远低于以太网接口,当RS-485总线接收到一些数据(通常是8个字节或者更少)后,如何处理呢?一种方法是直接将这些数据通过以太网和TCP协议转发出去,但这些数据往往是一个报文的一小段,TCP协议的另一端要支持几段数据的拼接,才能正常处理。另一种方法是先不发送,存在缓冲区,继续等待RS-485总线的下一波数据,直到RS-485总线上接收不到更多的数据后,才认为一个报文接收全了,然后一起通过TCP协议发送。这样TCP的另一端,只要支持最基本的报文处理机制即可。我既然使用了epoll机制进行报文转发,当然希望用第一种方式处理,这样我的程序最简单,效率也最高。但是用户选择了后面这种方式。于是我有了下面这种恶心的代码:
if (串口产生EPOLLIN事件)
{
    do
    {
       从串口读取数据存入缓冲区;
    }
    while (从串口读到了数据  && 缓冲区未满);
    设置TCP接口等待EPOLLOUT事件,以便转发数据;
}

为了尽可能从串口读到数据,我将串口设为阻塞模式,超时参数如下,网上讲解阻塞、非阻塞以及这两个参数的文章很多,此处不赘述。
options.c_cc[VMIN] = 0;
options.c_cc[VTIME] = 1;

这样,从我得知RS-485接口有了第一包数据开始,我的程序就阻塞在这里等待读取下一包,直到超时时间内读不到新的数据为止。我说这段程序恶心,就是因为epoll应该只阻塞在epoll_wait这个函数中,程序的其他部分不应该再进行阻塞。我这种写法,每次RS-485总线上的报文,最后都要等待RS-485接口阻塞超时一次之后才能发送出去,而这个超时时间最短就是100ms, 这样,每个RS-485报文都要白白耽搁100ms.
认识到这点后,我思考怎么提高它的效率。因为阻塞超时的最小间隔就是100ms, 无法进一步缩小,只好设置为非阻塞。因此代码改为如下结构
if (串口产生EPOLLIN事件)
{
    do
    {
        从串口读取数据存入缓冲区;
        if (读到了数据)
        {
            获取当前时间t1;
        }
        else
        {
            获取当前时间t2;
            if (t2 -t1 > 5ms)
            {
                break;
            }
        }
    }
    while (缓冲区未满);
    设置TCP接口等待EPOLLOUT事件,以便转发数据;
}

修改后,如果有5ms从串口读不到数据,就将已经收到的数据通过网络接口转发出去。本以为这样就把问题改好了,结果一测,还是200ms左右。通过调试打印可以看到,网络接口从收到数据发给RS-485接口,收到RS-485接口的返回值再转发给网络,整个过程只用了20ms左右,可是网络接口的另一侧却要等待200ms才能收到,为什么网络接口压着我的报文不马上发出去呢?
网上搜索学习相关文章才知道,由于TCP/IP协议的报头比较长,如果每次只发几个字节的话,考虑报头有几十个字节,报文的利用率极低。TCP协议底层有个Nagle算法,用于将相邻的报文尽量拼接在一起,提高发送效率。但是对于Telnet等把低时延放在第一位的应用,也可以关掉这个算法。方法是设置Socket参数
opt = 1;
setsockopt(socket_fd, IPPROTO_TCP, TCP_NODELAY, (const void*)&opt, sizeof(opt));

这样改完应该行了吧?结果一试还是不行,延迟仍然有200ms.反复对比网上的文章和我的代码,感觉自己写的没错啊。是不是那个测试软件本身有问题?于是在网上找了其他几个软件,发现这些还没有我原来的好用,很多打印的时间戳只能精确到秒。后来想起了强大的抓包软件Wireshark, 于是立即安装了一个查看。果然,这台电脑的网络接口上,从发出报文到接收到回复的报文,只有20ms左右,至于测试软件本身为什么显示200ms, 就不得而知了。
我将这个情况和用户进行了说明。用户用生产环境的软件一试,果然在20ms左右就能收到回馈。该问题解决。
其实这个问题还有进一步优化的空间,毕竟我在epoll的机制内进行了“死等”,有违epoll的设计初衷。我想可以改成这样:
if (串口产生EPOLLIN事件)
{
    从串口读取数据存入缓冲区;
    设置5ms超时定时器;
}
5ms定时器超时处理函数()
{
    设置TCP接口等待EPOLLOUT事件,以便转发数据;
}

这种方法感觉技术上可行。但由于最近忙其他工作,尚未尝试。

没有评论:

发表评论