使用Epoll实现简单的Echo Server和Client,附带性能分析和性能数据(一)

背景介绍

IO多路复用以其高性能、高并发、低系统开销等特性成为了后端Server框架开发的必备技术。
本文给出一个使用Epoll的Echo Server实现,读者很容易就可以将其扩展到自己的模块中去。
同时本文也给出了使用Epoll的客户端实现,读者可将其改造为一个性能压测工具。相比于别的Epoll示例,本文客户端实现使用单线程IO多路复用,也支持多线程,只是在Echo Server本机测试这种IO压力不大的CPU压力场景下,由于CPU线程切换的开销存在,性能并不如单线程的好。
所有代码只保留最必要保留的部分,对其中一些操作做了详细的注释,避免踩坑。

代码地址

由于代码会更新,实际代码与本文用于测试的代码会有行数不一致的情况,性能差别应该不会太大。

https://github.com/godmoon/MoonLib/blob/master/src/CppNet.cpp
https://github.com/godmoon/MoonLib/blob/master/src/CppNet.h
https://github.com/godmoon/MoonLib/blob/master/gtest/src/CppNetTest.cpp

代码说明

代码在gtest测试框架下运行,实现功能如下:
主线程启动服务端线程和1个客户端线程(支持多个客户端线程),每个客户端线程和服务端线程都管理一个Epoll池。客户端线程中的Epoll池管理多个客户端连接。
每个客户端连接以网络序发送当前微秒级别UTC时间戳到服务端,每个请求8个字节,服务端收到包后将数值加1并且返回,客户端对比发送的数据和接收的数据,正确则进行成功计数,否则进行失败计数。
主线程每隔一秒打印上一秒成功和失败的请求数以及成功率,运行指定秒数后,通知客户端结束,所有客户端线程结束后,通知服务端线程结束,主线程结束。

测试结果

在我本地Ubuntu14.04虚拟机上测试30秒结果大约为17~20W/s的请求量:

压测过程中服务器状态

在压测过程中,要考虑到性能瓶颈在哪儿,在Linux下,3大性能指标:磁盘IO,CPU,网络,下面我们逐一看看性能瓶颈在哪儿。

  • 磁盘IO
    代码中并没有显式对磁盘进行操作,在压测过程中通过命令”dstat -cdlmnpsy”得到结果如下:

    查看-dsk/total-列,发现并没有大量的磁盘读写,磁盘很平静,符合理论预期。
  • CPU
    由于测试属于本机压测,网络不存在太大问题,猜测性能瓶颈很有可能出现在CPU,压测环境的CPU为4核i5-3470,虚拟机也分配了4个核,所以虚拟机理论上可以充分利用主机的CPU资源。
    通过命令”top”可以看到压测进程”MoonLibTest”的CPU占用保持在190%以上,如下所示:

    这是因为客户端和服务端各为一个线程,每个线程可以占用一个核的100%的资源,加起来最多占用200%,在top页面按下大写的”H”,或者shift+h,可以看到线程的资源占用情况:

    其中前2个占用CPU 99%左右的线程分别为客户端和服务端,最后一个线程52941为主线程,代码中为启动客户端线程的线程,目前阻塞在thread::join()处,不占用CPU。
    所以目前单进程情况下,客户端和服务端基本已经处于CPU满负荷状态。
  • 网络
    对于网络这块的分析,由于陷入了一些误解,导致用了好几个小时来解决问题。下面完整讲一下分析过程,前面的部分基本上都是错的,后面会逐渐纠正前面的认识错误。
    一开始,我错误地认为由于是本机压测,每个包收发各8个字节,20W请求,猜想网卡吞吐量应该是
    8Bytes*200000/s=1600000Bytes/s,差不多1.5MB/s
    结果通过命令”dstat -N lo”可以查到当前环路网卡lo的数据吞吐量如下所示:

    结果显示网卡流量居然有23MB/s,出乎意料,难道是dstat命令统计错了?再通过命令”sar -n DEV 1 100″得到如下结果:

    查看rxkB/s列和txkB/s列,果然是将近23MB/s。
    通过网络数据的原始值/proc/net/dev查看,执行命令”while true;do cat /proc/net/dev;sleep 1;done”,也得到一样的结果。
    通过命令”sudo tcpdump -i lo port 20000 -w ~/tmp/test.cap”抓包,才想起来我居然忘了包头,每个包有Mac帧,IP头,TCP头以及数据,这四部分分别占用字节数如下:
    Mac帧:14字节
    IP头:20字节(无选项)
    TCP头:20字节+选项12字节=32字节
    数据:8字节
    应答包和上述一样,所以请求包和应答包应该是各74字节,在计算1KB以上数据的大包时包头大小可忽略,但是8字节的小包,包头长度就是数据长度的好几倍,不可忽略包头大小。
    这样算下来吞吐量应该是
    74Bytes*200000/s=14800000Bytes/s,约为14MB/s
    但是以上结果仍然与实际测试值23MB/s有冲突,而且差距还不小。
    还有一个疑问出现在包量的统计上,通过上面sar命令可以看到包量接收和发送均为40W/s左右,基本上是测试结果的2倍,这个问题我思考了很久,最后才想到,程序中统计请求量,是客户端一次收发完整请求,算一次请求量,每秒20W请求量,而对于网卡,一次请求量有一收一发,也就是2个包,因此包量是40W/s,符合理论分析。
    那么流量呢?也一样,一次请求,一收一发,都是走的本地环回网卡lo,于是一次请求对应2个包,统计的流量应该是单个包的翻倍,那么发送和接收的吞吐量按照计算应该是14MB/s*2=28MB/s,然而和实际的23MB/s的数据仍然有冲突。
    在寻找原因的过程中,无意中使用到iptraf这个工具,通过这个工具,查看到包大小为60字节:

    一开始我并没有注意到60字节这个数据,只以为是统计误差,于是我手工telnet到本机的一个服务随便发了点小数据,通过抓包得到了3个包,查看Length字段得到3个包大小分别为158、181和66字节:

    理论上统计的lo网卡接收数据量应该增加:
    158+181+66=405字节
    通过测试前后得到的lo网卡接收字节数(也可以通过ifconfig命令查看到)

    发现统计的接收字节数却只增加了:
    1681288224-1681287861=363字节
    二者差值为:
    405-363=42字节
    发现什么了吗?3个包差距42字节,平均每个包统计少了14字节,而联系到前面的包大小实际74字节,iptraf显示60字节,也是少统计14字节,而14字节刚好是Mac帧长度。可以猜测是lo网卡的流量统计并未计算Mac帧的长度14字节。但我并未找到相关文档对此进行说明,也不确定非lo网卡是否会计算Mac帧,这个留到后面补充。
    至此,重新计算理论流量:
    60Bytes*200000/s*2=24000000Bytes/s,约为22.8MB/s,和实际值相符。
    性能瓶颈是否在网络上,暂时还不确定,因为还不知道本地环回网络处理的性能瓶颈是多少。可以通过提高数据包大小来测一下吞吐量是否提升。后面有方法了再补充。

  • 结论
    从上面的分析,可以得到瓶颈最可能出现在CPU上,如果提高服务端线程数,使服务端运行在多个CPU核,可能可以提高每秒请求量。
其他讨论
  • 在写本文之前,我也在网上找了很多Epoll的典型用法,但是很多文章存在以下问题:
    1. 很多文章都是复制粘贴的代码,并没有给出性能数据和性能数据的对比,也没有对一些参数的使用做详细解释。
    2. Epoll只应用在服务端,客户端使用多线程或者只是发送请求接收应答,没有体现出Epoll带来的性能提升。如果对于本文的服务端使用多线程阻塞方式客户端压测,大约只能达到4W/s的性能数据(我在这里曾经纠结了很久,一直怀疑是我的服务端写的有问题,完全没想到性能瓶颈在客户端,直到我用我的客户端代码去压测Redis只有4W/s左右的性能,而同样的环境下,官方基于Epoll的压测工具有12W/s左右的性能),典型值应该是10W/s以上。
  • 不同的线程、客户端数量对于测试结果的影响比较大,一般客户端数量从1开始递增,达到一定程度,由于服务端的性能受限,压测数据便不再增加,此时再增加客户端数量会导致额外开销从而使性能略微降低,比如本文中使用30个客户端能达到20W/s的性能数据,如果使用100个客户端则只有19W/s左右的数据,如果达到300个客户端,则性能数据降低到15W/s左右:

    客户端数量每秒请求量(万)
    11
    59
    1019
    10020
    30015
    50013
    100011
    50009
  • 本文测试的客户端为长连接方式,如果为短连接方式,每次请求会增加额外的TCP握手,性能会到多少呢?后面有时间补充。
  • 几个特别注意的点:
    1. 发送基础类型的数据,要使用网络序。本机测试可能没问题,不过哪天跨平台就可能产生服务端和客户端主机序不一致的问题,所以在发送网络数据时最好养成良好的习惯。遇到这种情况建议使用Protobuf等更加通用的协议。
    2. 服务端对于监听的端口要设置REUSEADDR,防止服务重启时端口处于TIME_WAIT状态而无法重新监听。
    3. 服务端要忽略或者手工处理SIGPIPE信号,防止客户端关闭后,服务端往Socket中写入数据导致服务端收到此信号导致服务挂掉。
    4. Epoll实现要监听EPOLLRDHUP:要考虑到对端断开连接的情况,对端断开连接的话,2.6.17及之后的内核版本会抛出一个EPOLLRDHUP|EPOLLIN事件,之前的版本会抛出EPOLLIN事件,此时通过read读取的返回值为0,所以可以用以下两种方式检测对端关闭:
      1. 2.6.17及之后的内核版本:监听EPOLLRDHUP和EPOLLIN事件同时发生,稳妥起见,可以在此事件中判断read()==0
      2. 2.6.17及之前的内核版本,在EPOLLIN事件中检测read()==0
    5. 服务端对于监听的端口以及链接端口要设置成非阻塞,避免服务端进程被阻塞,可能读者会有疑问所有的读写都是在Epoll告诉程序可读可写之后才进行的为什么还会阻塞呢?考虑这种情况:当写入缓冲区只剩下1K,而要写入的数据有2K的时候,阻塞状态会阻塞直到2K数据全部写完,而非阻塞状态只会写入1K并返回,所以非阻塞状态下的写入需要考虑写了一半数据的情况,本文中代码并未考虑这种情况。
    6. 由于本文提供的是多线程程序示例,客户端和服务端处于不同的线程中,最终压测的性能还受到CPU核心数的影响,可以采用设置CPU亲和力(Linux下使用taskset命令)的方法将不同的线程固定到不同的CPU核心上来提高性能,这个网上有一些资料,我后面再专门写一篇文章来表达一下我的看法。
anyShare分享到:

原文地址:http://godmoon.wicp.net/blog/index.php/post_184.html,转载请注明出处

Moon发表于2016年2月20日
打赏作者

您的支持将鼓励我们继续创作!

[微信] 扫描二维码打赏

[支付宝] 扫描二维码打赏

发布者

sytzz

学会用简单的语言将复杂的问题说清楚。

发表评论

电子邮件地址不会被公开。