使用Netty处理Java中成千上万个连接的原理 -DZone性能

20-02-16 banq

C10K问题是代表一万个并发处理连接的术语。为此,我们经常需要更改已创建的网络套接字的设置以及Linux内核的默认设置,监视  TCP发送/接收缓冲区和队列的使用,  尤其是将我们的应用程序调整为合适的选项来解决这个问题。

在今天的文章中,我将讨论如果我们要构建可处理数千个连接的可伸缩应用程序,则需要遵循一些通用原则。如果您想从应用程序和底层系统中获得一些见识,我将参考Netty Framework,TCP和Socket内部以及一些有用的工具。

原则1:确保您的应用适合C10K问题

如上所述,当我们需要在最少的上下文切换和低内存占用的情况下尽可能多地利用CPU时,则需要使进程中的线程数非常接近于给定专用处理器的数量。

请牢记这一点,唯一可能的解决方案是选择一些非阻塞业务逻辑或具有很高CPU / IO处理时间比例(但是已经很危险)的业务逻辑。

有时,在您的应用程序堆栈中识别此行为不是很容易,将需要重新排列应用程序/代码,添加其他外部队列(RabbitMQ)或主题(Kafka),使用分布式系统,缓冲任务并能够从中拆分非阻塞代码。

但是,根据我的经验,由于以下原因,值得重写我的代码并使之更加不受阻塞:

  • 我将我的应用程序分为两个不同的应用程序,即使它们共享相同的“域”,它们也很可能不会共享相同的部署和设计策略(例如,应用程序的一部分是可以使用线程池实现的REST端点,基于HTTP的服务器,第二部分是队列/主题的使用者,该队列/主题使用非阻塞驱动程序将某些内容写入DB。
  • 我能够以不同的方式缩放这两个部分的实例数,因为负载/ CPU /内存很可能完全不同。

使用适当的工具:

  • 我们保持线程数量尽可能少。不要忘记不仅检查服务器线程,还检查应用程序的其他部分:队列/主题使用者,DB驱动程序设置,日志记录设置(使用异步微批处理)。始终进行线程转储dump,以查看在您的应用程序中创建了哪些线程以及创建了多少线程(不要忘记使其在负载下进行,否则您的线程池将不会被完全初始化,其中很多都是延迟创建线程的)。我总是从线程池中为我的自定义线程命名(找到受害者并调试代码要容易得多)。
  • 请注意,如果堵塞发生在对其他服务的HTTP / DB调用,我们可以使用反应式客户端,该客户端自动为传入的响应注册回调。考虑使用更适合服务2服务通信的协议,例如RSocket。
  • 检查您的应用程序中包含的线程数是否一直很少。它指的是您的应用程序是否具有有限的线程池,并且能够承受给定的负载。

如果您的应用程序具有多个处理流,请始终验证其中哪些正在阻塞以及哪些是非阻塞。如果阻塞流的数量很大,那么您几乎肯定需要使用不同线程(来自预定义线程池)处理每个请求。在这种情况下,请考虑将基于线程池的HTTP Server与工作程序一起使用,在该服务器上,所有请求都与一个非常大的线程池放在不同的线程上以提高吞吐量。

原理2:缓存连接,而不是线程

该原理与HTTP Server编程模型的主题紧密相关  。主要思想不是将连接绑定到单个线程,而是使用一些库,这些库支持稍微复杂但更有效的读取TCP方法。

这并不意味着TCP连接绝对是免费的。最关键的部分是  TCP握手。因此,您应该始终使用持久连接(如设置Nginx的keep-alive)。如果仅将一个TCP连接用于发送一条消息,则将支付8个TCP段的开销(连接和关闭连接= 7个段)。

接受新的TCP连接

如果我们处在无法使用持久连接的情况下,那么很可能在很短的时间内就会产生大量已创建的连接。必须将这些已创建的连接排队,并等待接受我们的应用程序。

在上图中,我们可以看到积压了SYN和LISTEN。在  SYN Backlog中, 我们可以找到仅等待使用TCP Handshake进行确认的连接。但是,在LISTEN  Backlog列表中, 我们已经完全初始化了连接,即使使用仅等待应用程序接受的TCP发送/接收缓冲区也是如此。请阅读SYN Flood DDoS攻击。 

如果我们承受着很大的负担,并且有很多传入连接,那么实际上存在一个问题,负责接受连接的应用程序线程可能很繁忙:对已经连接的客户端执行IO。

new ServerBootstrap()
     .channel(EpollServerSocketChannel.class)
     .group(bossEventLoopGroup, workerEventLoopGroup)
     .localAddress(8080)
     .childOption(ChannelOption.SO_SNDBUF, 1024 * 1024)
     .childOption(ChannelOption.SO_RCVBUF, 32 * 1024)
     .childHandler(new CustomChannelInitializer());

在上面的代码段(Netty Server配置API)中,我们可以看到  bossEventLoopGroup 和   workerEventLoopGroup 。虽然   workerEventLoopGroup 默认情况下是使用CPU数量* 2个线程/事件循环创建的, 用于执行IO操作,但  bossEventLoopGroup 其中一个线程用于接受新连接。但是在这种情况下,如果由于在ChannelHandlers中执行I / O或执行更长的操作,因此接受新的连接可能会堵塞挨饿 。

如果遇到完全LISTEN Backlog问题,则可以增加bossEventLoopGroup中的线程数   。我们可以很容易地测试我们的过程是否能够承受传入连接的负载。我修改了测试应用程序Websocket-Broadcaster  以连接2万个客户端,并多次运行以下命令: 

$ ss -plnt sport = :8081|cat
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 42 128 *:8081 *:* users:(("java",pid=7418,fd=86))
$ ss -plnt sport = :8081|cat
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 128 *:8081 *:* users:(("java",pid=7418,fd=86))
$ ss -plnt sport = :8081|cat
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 20 128 *:8081 *:* users:(("java",pid=7418,fd=86))
$ ss -plnt sport = :8081|cat
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 63 128 *:8081 *:* users:(("java",pid=7418,fd=86))
$ ss -plnt sport = :8081|cat
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 128 *:8081 *:* users:(("java",pid=7418,fd=86))

  • Send-Q:LISTEN Backlog的总大小
  • Recv-Q:LISTEN Backlog列表中的当前连接数

修改
LISTEN Backlog的总大小:

# Current default size of LISTEN Backlog
# Feel free to change it and test SS command again
cat /proc/sys/net/core/somaxconn
128

TCP发送/接收缓冲区

但是,当连接就绪时,最贪婪的部分是TCP发送/接收缓冲区  ,该缓冲区用于将应用程序写入的字节传输到基础网络堆栈。这些缓冲区的大小可以通过应用程序设置:

new ServerBootstrap()

     .channel(EpollServerSocketChannel.class)

     .group(bossEventLoopGroup, workerEventLoopGroup)

     .localAddress(8080)

     .childOption(ChannelOption.SO_SNDBUF, 1024 * 1024)

     .childOption(ChannelOption.SO_RCVBUF, 32 * 1024)

     .childHandler(new CustomChannelInitializer());

有关Java中的Socket Options的更多信息,请查看  StandardSocketOptions  类。较新版本的Linux可以与TCP拥塞窗口配合使用,自动调整缓冲区以达到当前负载的最佳大小。

 在进行任何自定义大小调整之前,请阅读“  TCP缓冲区大小调整”。较大的缓冲区可能会导致内存浪费,另一方面,较小的缓冲区可能会限制读取器或写入器的应用程序,因为将没有空间将字节传输到网络堆栈或从网络堆栈传输字节。

为什么缓存Thread是一个坏主意?

Java Thread是一个非常昂贵的对象,因为它一对一映射到了内核线程(希望Loom项目来得早可以拯救我们)。在Java中,我们可以使用-Xss 选项限制线程的堆栈大小,该  选项默认设置为1MB。这意味着一个线程占用1MB的虚拟内存,实际的RSS(居民集大小)等于堆栈的当前大小;内存在开始时并未完全分配并映射到物理内存(这经常被误解,如本文中所展示的:  Java线程需要多少内存?)。通常(根据我的经验),如果我们真的不使用某些贪婪的框架或递归,则线程的大小以数百千字节(200-300kB)为单位。这种内存属于本机内存。我们可以在“本  机内存跟踪”中进行跟踪。

$ java -XX:+UnlockDiagnosticVMOptions -XX:NativeMemoryTracking=summary /
-XX:+PrintNMTStatistics -version
openjdk version "11.0.2" 2019-01-15
OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.2+9)
OpenJDK 64-Bit Server VM AdoptOpenJDK (build 11.0.2+9, mixed mode)
Native Memory Tracking:
Total: reserved=6643041KB, committed=397465KB
-                 Java Heap (reserved=5079040KB, committed=317440KB)
                            (mmap: reserved=5079040KB, committed=317440KB)
-                     Class (reserved=1056864KB, committed=4576KB)
                            (classes #426)
                            (  instance classes #364, array classes #62)
                            (malloc=96KB #455)
                            (mmap: reserved=1056768KB, committed=4480KB)
                            (  Metadata:   )
                            (    reserved=8192KB, committed=4096KB)
                            (    used=2849KB)
                            (    free=1247KB)
                            (    waste=0KB =0,00%)
                            (  Class space:)
                            (    reserved=1048576KB, committed=384KB)
                            (    used=270KB)
                            (    free=114KB)
                            (    waste=0KB =0,00%)
-                    Thread (reserved=15461KB, committed=613KB)
                            (thread #15)
                            (stack: reserved=15392KB, committed=544KB)
                            (malloc=52KB #84)
                            (arena=18KB #28)

 大量线程的另一个问题是庞大的  Root Set。例如,我们有4个CPU和200个线程。在这种情况下,我们仍然只能在给定的时间运行4个线程,但是如果所有200个线程已经在忙于处理某个请求,我们将为已经在Java Heap上分配但无法创建任何对象的对象付出巨大的代价:线程必须等待CPU空闲的时间。

所有已分配且仍在使用的对象是Live Set一部分,Live Set是在垃圾收集周期中必须遍历且无法收集的可访问对象。

为什么Root Set 很重要?

当前只有4个线程在CPU上运行并且其余线程仅在CPU Run Queue中等待时,红点可以表示任何时间点  。任务的完成并不意味着到目前为止分配的所有对象仍然处于活动状态,并且也并不都是活动集的一部分  ,它们已经变为垃圾,等待下一个GC周期被删除。这种情况有什么不好?

  • Live Set太大:每个线程都会在Java Heap上保留分配的活动对象,这些对象仅等待CPU取得一些进展。当我们调整堆大小时,我们需要牢记这一点,这种方式将以非常低的效率使用大量内存,特别是如果我们要设计处理大量请求的小型服务时。   
  • 由于更大的Root Set而导致GC暂停更大:更大的Root Set对我们的垃圾收集器意味着复杂的工作。 现代GC首先从识别Root Set(也称为快照快照或初始标记)开始 ,主要是通过活动线程保持堆外部分配的可访问对象(但不仅是线程),然后同时遍历对象图/查找当前Live Set的参考。根root设置越大,GC识别和遍历它的工作就越多,此外,初始标记通常是称为“世界停止” 的阶段。并发遍历庞大的对象图还会导致较大的延迟和GC本身的较差的可预测性(GC必须更早地开始并发阶段以使其在堆满之前,这也取决于分配率)。
  • 升级到老一代:较大的“ Live Set”还将影响将给定对象视为活动对象的时间。即使保持该对象的线程大部分时间都花在了CPU之外,这也增加了将该对象提升为旧一代(或至少提升为生存空间)的机会。

原则3:停止生成垃圾

如果您真的想编写一个承受巨大负担的应用程序,那么您需要考虑所有对象的分配,并且不要让JVM浪费任何单个字节。Netty给我们带来了  ByteBuffers 和ByteBuf 。这是非常先进的,因此非常简短。

ByteBuffers是JDK的byte持有者,有两个选项HeapByteBuffer (在堆上分配的字节数组)和  DirectByteBuffer(堆外内存),DirectByteBuffers可以直接传递给本机OS功能来执行I / O,换句话说,当您使用Java执行I / O时,您可以传递一个引用DirectByteBuffer (带有偏移量和长度)。 

这可以在许多用例中提供帮助。

假设有1万个连接,并且希望向所有连接广播相同的字符串值。没有理由传递字符串并导致将相同的字符串10k次映射到byte,甚至更糟的是,为每个客户端连接生成新的字符串对象,并使用相同的字节数组污染堆;相反,我们可以生成自己的DirectByteBuffer 并将其提供给所有连接,通过JVM将其传递给操作系统。

但是,有一个问题。DirectByteBuffer 分配非常昂贵。因此,在JDK中,每个进行I / O的线程都为此内部缓存了一个DirectByteBuffer 。

那么,为啥需要HeapByteBuffer? HeapByteBuffer 在分配方面要便宜得多。如果考虑到上面的示例,我们至少可以省去第一步-将字符串编码为字节数组(而不是将其编码10k次),然后我们可以依靠它的DirectByteBuffer自动缓存机制  ,而不必为每个新的字符串消息分配新 的DirectByteBuffer付出高昂成本,否则我们将需要在业务代码中开发自己的缓存机制。 

何时使用没有缓存的DirectByteBuffer 和使用带有自动缓存的HeapByteBuffer ?需要权衡。

上面还提到了Netty的ByteBuf机制。实际上也是ByteBuffers概念;但是,我们可以享受基于2个索引的便利API(一个用于读取,一个用于写入)。另一个区别是回收内存。DirectByteBuffer 是基于JDK Cleaner  类。

这意味着我们需要运行GC,否则我们将耗尽本机内存。对于非常优化的应用程序,可能不会出现问题,因为它们不会在堆上分配,这意味着不会触发任何GC。然后,我们需要依靠显式GC(System#gc())进行救援,并为下一个本机分配回收足够的内存。

Netty ByteBuf 可以创建两种版本:池化和非池化,本机内存的释放(或将缓冲区放回池中)是基于引用计数机制的。这是某种额外的手动工作。当我们想减少参考计数器时我们需要编写,但是它解决了上面提到的问题。

原理4:衡量您在高峰时段产生的负荷类型如果您想深入了解TCP层,那么我强烈建议:

使用  bpftrace, 我们可以编写一个简单的程序并获得快速结果,并能够调查问题。这是socketio-pid.bt的示例,  显示了根据PID粒度传输了多少字节。

#!/snap/bin/bpftrace
#include <linux/fs.h>
BEGIN
{
      printf("Socket READS/WRITES and transmitted bytes, PID: %u\n", $1);
}
kprobe:sock_read_iter,
kprobe:sock_write_iter
/$1 == 0 || ($1 != 0 && pid == $1)/
{
       @kiocb[tid] = arg0;
}
kretprobe:sock_read_iter
/@kiocb[tid] && ($1 == 0 || ($1 != 0 && pid == $1))/
{
       $file = ((struct kiocb *)@kiocb[tid])->ki_filp;
       $name = $file->f_path.dentry->d_name.name;
       @io[comm, pid, "read", str($name)] = count();
       @bytes[comm, pid, "read", str($name)] = sum(retval > 0 ? retval : 0);
       delete(@kiocb[tid]);
}
kretprobe:sock_write_iter
/@kiocb[tid] && ($1 == 0 || ($1 != 0 && pid == $1))/
{
       $file = ((struct kiocb *)@kiocb[tid])->ki_filp;
       $name = $file->f_path.dentry->d_name.name;
       @io[comm, pid, "write", str($name)] = count();
       @bytes[comm, pid, "write", str($name)] = sum(retval > 0 ? retval : 0);
       delete(@kiocb[tid]);
}
END
{
       clear(@kiocb);
}

我可以看到五个称为server-io-x的 Netty线程,每个线程代表一个事件循环。每个事件循环都有一个连接的客户端,应用程序使用Websocket协议将随机生成的字符串消息广播到所有连接的客户端。

@bytes  —读/写字节的总和

@io  — 总共有多个读/写操作(1条读消息表示Websocket握手)

./socketio-pid.bt 27069
Attaching 6 probes...
Socket READS/WRITES and transmitted bytes, PID: 27069
@bytes[server-io-3, 27069, read, TCPv6]: 292
@bytes[server-io-4, 27069, read, TCPv6]: 292
@bytes[server-io-0, 27069, read, TCPv6]: 292
@bytes[server-io-2, 27069, read, TCPv6]: 292
@bytes[server-io-1, 27069, read, TCPv6]: 292
@bytes[server-io-3, 27069, write, TCPv6]: 1252746
@bytes[server-io-1, 27069, write, TCPv6]: 1252746
@bytes[server-io-0, 27069, write, TCPv6]: 1252746
@bytes[server-io-4, 27069, write, TCPv6]: 1252746
@bytes[server-io-2, 27069, write, TCPv6]: 1252746
@io[server-io-3, 27069, read, TCPv6]: 1
@io[server-io-4, 27069, read, TCPv6]: 1
@io[server-io-0, 27069, read, TCPv6]: 1
@io[server-io-2, 27069, read, TCPv6]: 1
@io[server-io-1, 27069, read, TCPv6]: 1
@io[server-io-3, 27069, write, TCPv6]: 1371
@io[server-io-1, 27069, write, TCPv6]: 1371
@io[server-io-0, 27069, write, TCPv6]: 1371
@io[server-io-4, 27069, write, TCPv6]: 1371
@io[server-io-2, 27069, write, TCPv6]: 1371

原则5:吞吐量和延迟之间的平衡

如果考虑应用程序性能,很可能最终会在吞吐量和延迟之间进行权衡。这种权衡涉及所有编程领域,JVM领域的一个著名示例是Garbage Collector:您是否要在某些批处理应用程序中专注于使用ParallelGC的吞吐量,还是需要低延迟的大多数并发GC,例如ShenandoahGC或ZGC?

但是,在这一部分中,我将重点介绍可以由我们基于Netty的应用程序或框架驱动的另一种折衷类型。假设我们有将消息推送到连接的客户端的WebSocket服务器。我们真的需要尽快发送特定消息吗?还是可以等待更长的时间,然后创建一批包含五个消息并一起发送的消息?

Netty实际上支持完全涵盖此用例的刷新机制。假设我们决定使用批处理将syscall摊销到20%,并牺牲延迟以支持整体吞吐量。

请查看我的JFR Netty Socket Example

如果您想了解有关Java Flight Recorder的更多信息,请阅读我的文章  使用Java Flight Recorder挖掘套接字。

原则6:紧跟新趋势并不断尝试 

...

点击标题见原文

 

  

 

                   

1
猜你喜欢