这是一次晦涩难懂的场景之旅,转移了人们的注意力,也是一场投票与EPOLL的较量。我花了几天时间分析和优化一些Elixir代码,结果发现性能问题的真正解决方案是Erlang VM中的一个模糊设置,该设置控制IO轮询子系统使用哪个系统调用,并且我的代码级优化无关紧要。由于在随后的Erlang/OTP版本中重写了IO轮询,这一特定设置变得多余,但这仍然是一个宝贵的教训。
早在2018年,我们就发布了MQTT协议适配器。就像我们的Pusher和PubNub适配器一样,它是作为一种药剂服务实现的,它位于我们正常服务前端的前面,可以在Alive自己的协议之间进行实时转换。
在发布之前,我们决定进行一些负载测试,以计算保持大量大部分空闲的MQTT连接(由拥有大量物联网设备的客户驱动)的每个实例的CPU成本。
有了15K的空闲MQTT连接,在我们的一个常见实例(AWS m5.Large)上,CPU使用率达到了60%,主要来自适配器(其余的来自正常的智能前端进程)。所以我们开始试着找出这是为什么。
有很多方法可以让您从BEAM(Erlang VM)中获取性能数据。首先是观察器,您可以使用它来查看所有正在运行的进程(Erlang进程,而不是系统进程),按减少量(≈函数调用,CPU使用情况的代理)或按内存使用情况排序。还有各种跟踪工具,可以跟踪来自特定进程的所有调用、任何进程对给定函数的所有调用,等等。(内置工具可能有点低级,但有各种更用户友好的包装器可用)。
仅从观察者的角度看,我看不到任何明显的东西;似乎没有进程使用过多的资源。我真正想要的是跟踪整个系统,使用 所有函数调用,然后根据这些数据生成火焰图,以了解到底是什么在消耗所有的cpu。使用传统的束跟踪对每个函数调用执行此操作将非常昂贵;跟踪会增加太多开销,无法对每个函数调用执行此操作。幸运的是,Scott Fritchie有一个很棒的项目叫做eflame2 https://github.com/slfritchie/eflame(在Vlad Ki的eflam https://github.com/proger/eflame),的基础上构建),它对光束进行一些试验性的更改,使其能够支持时间采样的跟踪数据:每隔(比方说)100ms生成一个堆栈跟踪,并用它来构建火焰图。
结果有点令人费解。尽管观察到的CPU使用率仍然很高,但它显示了每个火焰记录仪的绝大部分时间都在睡眠中。
有经验的游戏者现在可能在想";啊哈!我打赌这是一个sbwt问题!";默认情况下,BEAM进程调度器在运行完要调度的Erlang进程时,会旋转几毫秒以停止操作系统调度器调度不同的操作系统进程,以便在确实需要完成新工作时减少延迟。这称为调度程序忙等待。如果梁是在 - 上运行的机器上运行的唯一重要进程,这可能是合理的行为,这种情况通常是这样的。但在我们的情况下,它不是,它必须很好地与我们的前端进程,以及其他进程。这种行为经常会让那些试图将Erlang/Elixir与其他语言进行基准测试的人大吃一惊,即使在平均延迟很低的情况下,也会使BEAM看起来消耗了大量CPU。(我非常肯定,正是这个错误导致了这样的结论:在Stressgrid的这篇博客文章中,Elixir在提供令人惊讶的一致性能的同时,几乎饱和了所有36个内核。),我很确定,正是这个错误导致了这样的结论:在Stressgrid的这篇博客文章中,Elixir几乎饱和了所有36个内核。通过将+sbwt NONE(';t';=Threshold)传递给Erlang运行时,可以很容易地禁用它。
所以我耸耸肩,暂时把休眠放在一边,把它们作为测量工件,把它们从跟踪输出中过滤出来,生成一些火焰图,看看在所有非空闲的样本中,哪些是最大的CPU消耗者。
其中一个来源是:在响应来自我们巧妙前端的websocket ping时,加密的.strong_Random_bytes。适配器与我们的客户端库连接到前端的方式相同:通过WebSocket,每个连接对应一个。
(使用WebSockets连接在同一机器上运行的两个服务似乎是一个奇怪的选择:原因是转换后的连接看起来就像一个普通的外部WebSocket,与普通的外部WebSocket连接相比,巧妙的前端不必为通过MQTT适配器的连接做任何特殊或不同的事情。)。
前端每隔15秒向每个客户端发送一次WebSocket ping,主要是作为检测(并允许客户端检测)连接是否真正仍处于活动状态的一种方式。在发送消息(甚至只是一个PONG)时,称为掩码的WebSocket功能要求客户端生成一个随机的掩码 - 随机字节,这些字节与有效负载进行异或运算。这样做的目的是为了挫败对代理的缓存中毒攻击。对于在同一实例上运行的两个服务之间的WebSocket,这当然是不必要的。我禁用了掩码(使用elixir-socket,这与将ask:nil作为选项传递给Socket.Web.send一样简单)。
CPU使用率的另一个来源是响应来自客户端的MQTT ping。当前代码对它们的响应方式与它对所有消息的响应方式相同:将它们解码为Hulaaki消息,将该消息传递给主MQTT.FromClient.handle()函数,该函数对消息进行模式匹配并发送ping响应:
不是很多,但是对于数以万计的连接,每分钟做几次,这一切都会积少成多。优化这一点只需向牧场处理程序添加一个特殊情况,以便在调用正常的MQTT消息传递逻辑之前识别ping并使用PONG响应。使用Elixir的二进制模式匹配,这非常简单:
@ping<;<;192,0>;>;@pong<;<;208,0>;>;def handle_info({:tcp,socket,@ping},state)[email protected](socket,@pong){:无回复,%{state|last_active:now()}}enddef handle_info({:tcp,socket,data},state)do//处理所有其他数据包。
(对于大多数消息,我们必须处理分布在多个TCP数据包中的消息;对于两个字节的消息,这不是问题。)
我继续往前走,从火焰图上看着其他(较小但仍然很突出)的CPU用户。URI.encode_query花了相当多的时间在URI.encode_www_form;上,我用一个自定义的查询字符串编码器替换了它,这个编码器不用费心了,因为我知道我已经在传递URI安全的字符串了。Timex.Time.now()/elapsed()(用于跟踪连接上的最后一次活动时间)显示了相当多的内容;我将其替换为:erlang.system_time(:sec)。诸若此类。
而在回顾这一切有点绝望的微观优化之后,我是…。嗯,捕捉现在更多睡眠的火焰图占据了主导地位, - 已经将其他所有 都归档到了最低限度-但系统cpu仍然远远高于它应该达到的水平。
然后,在某个时候,我偶然发现了2011年关于+K标志(Erlang运行时的参数,如+sbwt)的邮件列表线程。参考手册指出,如果仿真器支持内核轮询功能,则启用或禁用内核轮询功能。默认值为FALSE(禁用)。";
结果发现,在禁用此功能的情况下,BEAM使用轮询系统调用来检查Linux上传入的IO事件;在启用它的情况下,它使用EPOLL。Julia Evans发表了一篇很棒的博客文章,阐述了它们之间的区别;简而言之,对于监听大量大部分空闲套接字上的事件而言,EPOLL的效率要高得多。
启用+K true是我一直在寻找的魔术开关。启用它后,IDLE-MQTT负载测试中的束CPU使用率下降了一个数量级。
因此,所有的波束内分析和跟踪,所有我仔细优化的MQTT ping响应等等都完全是无关紧要的,都是在转移视线。BEAM将几乎所有的CPU周期都花在IO轮询上,只有一小部分实际执行字节码,而我所有的时间都花在优化这一小部分上。
有人可能会问,为什么这不是违约。显然存在这样的用法,Poll可能比EPOLL(极其活跃的套接字)更有优势,但是一般的建议似乎是绝大多数人应该使用EPOLL。该邮件列表线程中的一个人认为这不是默认设置,因为某些操作系统(如Windows)不支持内核轮询,但这并没有太多意义 - 没有理由为了与没有epoll/kqueue/等的平台保持一致而削弱那些有epoll/kqueue/等的平台。(特别是因为我怀疑是否有很多人在Windows服务器上运行BEAM)。
令人高兴的是,Erlang/OTP团队似乎一直在沿着类似的思路思考,而 巧合的是, 在我发现这一点后不久,他们宣布在OTP21中,IO轮询实现将被完全重写。IO轮询将从调度程序线程移至专用IO线程,内核空间轮询(在Linux上使用EPOLL)将成为默认设置(实际上+K开关将被完全删除;除非在编译时禁用EPOLL,否则将始终使用EPOLL)。Erlang/OTP 21在2018年6月如期发布,这个特殊的问题为大家从源头上解决了。
这都是两年前的事了。我把我的微优化放在: 中,虽然微调微不足道,但它们确实起到了一些作用。我最近没有机会使用适配器;Elixir/Erlang中的构建服务的问题是它们很少出错,所以我没有借口去处理它们。MQTT适配器继续悄无声息地运行,允许任何MQTT 3.1.1客户端库进行连接,以便与系统的其余部分进行灵活的互操作。