揭秘单机高性能网络模型,让你的系统轻松应对海量用户访问!
1 传统网络模型
1.1 PPC 和 prefork
Process per connection
一种常见的多进程并发服务器架构。在该架构中,对每个客户端连接请求创建一个新子进程,来处理该客户端的请求和响应。即当有多个客户端连接时,服务端就会启动多个子进程进行并发处理,从而提高服务器的并发性和处理性能。
ppc模型中,在父进程中监听客户端连接请求,并接受客户端连接。每当有一次新的客户端连接请求被接受时,就会创建一个新子进程。该子进程会**地处理从客户端发来的数据,并发送响应给客户端。在完成请求处理后,该子进程将关闭与客户端之间的连接,并退出进程。
该模型的优点包括简单易懂,代码可读性强,且对于各种类型的网络应用程序都适用。然而,缺点也显而易见,即每次新建进程会消耗系统资源,如果连接数过多,将会导致系统开销增大。此外,也需要考虑如何管理子进程中可能出现的异常情况,并防止出现僵尸进程或者资源浪费等问题。
processes are forked beforeconnection)
不是在新连接建立时,而是提前 fork 子进程。
优点:实现简单
缺点:
- PPC:fork 代价高,性能低
- 父子进程通信要用 IPC
- os的上下文切换会限制并发连接数,一般几百
案例:
- 世界上第一个 web 服务器 CERN httpd 采用 PPC 模式
- Apache MPM prefork 模式,默认256个连接
1.2 TPC 和 prethread
1.2.1 Thread per connection
一种常见的网络编程模型,它的基本思想是为每个客户端连接创建一个新的线程来处理该连接的数据传输。
在 Thread per connection 模型中,服务器端程序会为每个客户端连接创建一个新的线程,该线程会负责处理该连接的所有数据传输。当客户端连接结束时,该线程也会被销毁。这种模型的优点是代码简单易于实现,并且能够同时处理多个连接,不会出现阻塞的情况。
然而,Thread per connection 模型也存在一些缺点:
- 为每个客户端连接创建一个新的线程会占用大量的系统资源,因此无法处理大量的并发连接
- 由于每个线程都是独立的,因此线程之间的协作和同步需要额外的开销和复杂度
- 如果客户端连接数量过多,可能会导致服务器崩溃或者性能下降
因此,在实际应用中,Thread per connection 模型通常适用于连接数量较少且数据传输量较小的情况。对于连接数量较多或者数据传输量较大的应用场景,通常需要采用其他更高效的并发模型来处理。
Thread per connection 模型在处理大量连接时,会出现同步阻塞的问题,主要是在如下流程:
- Accept:当有新的连接请求到来时,服务器会创建一个新的线程来处理该连接。如果同时有大量连接请求到来,服务器的线程池可能会很快耗尽资源,导致新的连接无法被接受或者已有的连接无法被处理。
- Read:当某个连接在读取数据时发生阻塞,它所对应的线程也会被阻塞,无法处理其他连接的请求。如果有大量连接同时发送请求,那么服务器的线程池可能会很快被占满,导致新的连接无法被接受或者已有的连接无法被处理。
Thread per connection 模型在读取数据时一定会阻塞吗?
不一定。若某个连接所对应的线程在读取数据时,数据已准备好,该线程可以立即读取数据并处理请求,而不会阻塞。但若数据没准备好或数据量很大,该线程就需要等待数据准备好或者读取完毕,这时就会出现阻塞。注意合理设置缓冲区大小、调整读取数据的方式等因素,以尽可能避免阻塞。
读取数据时可以设置成非阻塞模式,可避免阻塞造成的线程资源浪费和性能问题。当数据没有准备好时,读取操作会立即返回一个错误码,而不是一直等待数据准备好。这样,线程可以继续处理其他连接的请求,直到数据准备好后再进行读取操作。但就类似死循环了,对 CPU 消耗很大。
1.2.2 thread are created beforeconnection
优点
- 实现简单
- 无需IPC,线程间通信即可
- 无需 fork,线程创建代价低
缺点
- 线程互斥和共享比 PPC/prefork 要复杂
- 某个线程故障可能导致整个进程退出
- OS的上下文切换会限制并发连接数,一般几百,但比 PPC/prefork 要多
案例
Apache 服务器 MPM worker模式就是prethread 模式的变种(多进程+prethread),默认支持16 x 25 = 400 个并发处理线程。
MySQL
Apache 为何将 prethread 改为多进程 +prethread?
考虑到缺点中的第二点。
2 Reactor
基于多路复用的事件响应网络编程模型。
多路复用
多个连接复用同一个阻塞对象(所以不是说就不会阻塞了),如 Java 的 Selector、epoll 的 epoll fd (epoll create 函数创建)
事件响应
不同的事件分发给不同的对象处理,Java 的事件有 OP_ACCEPT、OP _CONNECT、OP_READ OP_WRITE。
优缺点
- 实现比传统网络模型要复杂一些
- 支持海量连接
为什么复用同一个阻塞对象就能够支持海量连接?
- 避免创建过多的线程、进程
- 避免频繁上下文切换
所以在有大量连接数,但不是每个都十分活跃时,性能优势明显。
复用同一个阻塞对象可以支持海量连接,主要是因为阻塞对象的等待状态是由操作系统内核维护的,而不是由应用程序维护。当一个连接处于阻塞状态时,它会被加入到阻塞对象的等待队列中,等待数据准备好后再进行读取。如果有多个连接处于阻塞状态,它们会被加入到同一个阻塞对象的等待队列中。
由于阻塞对象的等待状态是由操作系统内核维护的,因此可以使用多路复用技术来监视多个连接的状态,并在连接有数据可读时通知应用程序进行相应的处理。通过复用同一个阻塞对象,可以让多个连接共享同一个等待队列,从而避免了为每个连接创建单独的等待队列所带来的额外开销和资源浪费。
因此,复用同一个阻塞对象可以支持海量连接,并且可以大大提高服务器的并发性能和稳定性。不过,在实际应用中,还需要结合其他高级技术(例如线程池、异步 IO 等)来进一步提高服务器的性能和响应速度 。
3 Reactor 模式1- 单 Reactor 单进程/线程
3.1 示意图
3.2 优点
- 实现简单,无线程互斥和通信
- 无上下文切换,某些场景下性能可以做到很高
3.3 缺点
- 只有一个进程,无法发挥多核 CPU 的性能,只能采取部署多个系统来利用多核 CPU,但这样会带来运维复杂度
- Handler 在处理某个连接上的业务时,整个进程无法处理其他连接的事件,可能导致性能瓶颈
3.4 案例
Redis。所以 V 若超过 10kb,性能急剧下降。所以禁用 big key,此时得考虑 memcache 了。
4 Reactor模式2 - 单Reactor多线程
4.1 示意图
这里的 handler 没有业务处理了,放到 processor:
- 主线程中,Reactor 对象通过 select 监控连接事件,收到事件后通过 dispatch 进行分发
- 若是连接建立的事件,则由 Acceptor 处理,Acceptor 通过 accept 接受连接,并创建一个 Handler 来处理连接后续的各种事件
- 如果不是连接建立事件,则 Reactor 会调用连接对应的 Handler (第2步中创建的 Handler) 来进行响应
- Handler 只负责响应事件,不进行业务处理: Handler 通过 read 读取到数据后,会发给 Processor 进行业务处理
- Procesor 会在独立的子线程中完成真正的业务处理,然后将响应结果发给主进程的 Handler 处理,Handler 收到响应后通过 send将响应结果返回给 client
4.2 优点
- 充分利用了多核 CPU 的优势,性能高
4.3 缺点
- 多线程数据共享和访问比较复杂
- Reactor这一个 I/O 线程承担了所有事件的监听和响应,只在主线程中运行,瞬时高并发时会成为性能瓶颈
4.4 为何没有“单 Reactor 多进程”模式?
单 Reactor 多进程模式的缺陷主要有以下几点:
- 进程间通信的开销较大:在单 Reactor 多进程模式中,主进程需要将连接请求分发给多个子进程处理,因此需要进行进程间通信。这种通信的开销较大,会影响系统的性能。子进程在将数据发回主进程时需要 IPC 通信,效率低且处理麻烦。
- 系统资源占用较多:由于每个子进程都需要复制一份主进程的代码和数据,因此会占用更多的系统资源。
- 进程管理复杂:在单 Reactor 多进程模式中,需要管理多个子进程的生命周期和资源分配,这增加了系统的复杂性和维护成本。
- 可扩展性受限:由于每个子进程都需要复制一份主进程的代码和数据,因此在扩展时会受到系统资源的限制。同时,由于进程间通信的开销较大,也会影响系统的可扩展性。
因此,虽然单 Reactor 多进程模式在一些场景下可以提高系统的并发性能和可靠性,但它也存在一些缺点,已经逐渐被更轻量级、更高效的单 Reactor 多线程模式所取代。
4.5 子线程池业务处理结果如何给到 handler 线程呢?
在单 Reactor 多线程模型中,主线程(也称为 Reactor 线程)负责监听连接请求,并将连接请求分发给子线程池中的工作线程进行处理。当工作线程处理完业务后,需要将处理结果返回给主线程进行响应。
一种常见的实现方式是使用线程安全的队列来实现工作线程和主线程之间的通信。具体来说,工作线程将处理结果封装成一个任务对象,并将任务对象放入一个线程安全的队列中。主线程在监听连接请求的同时,也会不断地从该队列中获取任务对象,并进行相应的响应处理。
使用线程安全的队列时,需要确保队列的线程安全性和高效性,避免出现死锁、竞争条件等问题。同时,在多个工作线程同时向队列中添加任务时,也需要考虑线程同步和互斥访问等问题,以确保数据的一致性和正确性。
5 Reactor模式3 - 多Reactor多进程/线程
也称为“多进程/线程池模式”。主进程负责监听所有的连接请求,然后将连接请求分发给多个子进程/线程处理。每个子进程/线程都有自己的事件循环和I/O线程,可以独立地处理连接请求和数据传输。
与单Reactor多进程模式相比,多Reactor多进程/线程模式可更好利用多核CPU性能,并更灵活控制系统资源的分配。同时,由于每个子进程/线程只需要处理一部分连接请求和数据传输,可提高系统的并发性能和可靠性。
多Reactor多进程/线程模式也存在一些缺点。其中最大的问题是进程/线程间通信的开销较大,会影响系统的性能。同时,在多进程模式中,需要管理多个子进程的生命周期和资源分配,增加了系统的复杂性和维护成本。在多线程模式中,需要处理线程安全等问题,也会增加系统的复杂性。因此,在选择网络编程模式时,需要根据具体的场景和需求进行选择。
5.1 示意图
- 主进程的mainReactor对象通过 select 监控连接建立事件,收到事件后通过 Acceptor 接收,将新的连接分配给某个子进程
- 子进程的 subReactor将 mainReactor 分配的连接加入连接队列进行监听,并创建一个 Handler 用于处理连接的各种事件
- 当有新的事件发生时,subReactor 会调用连接对应的 Handler (即第2步中创建的 Handler) 来进行响应
- Handler 完成read-业务处理-send的完整业务流程
5.2 优点
充分利用了多核 CPU 的优势,性能高;2.实现简单,父子进程(线程)交互简单,subReactor 子进程(线程)间无互斥共享或通信。
5.3 缺点
没有明显的缺点。
5.4 案例
Memcached、Netty、Nginx 等注意:实现细节都有一些差异,例如Memcached 用了事件队列、Nginx是了进程 accept。
5.5 Redis 6.0 后多线程写同一个 key 怎么处理?
Redis 6.0 后引入了多线程写入同一个 key 的功能,这个功能叫做多版本并发控制(MVCC)。在 MVCC 中,每个线程都会为写入同一个 key 的操作创建一个版本号,这样就可以避免并发写入时的冲突问题。
当多个线程同时写入同一个 key 时,Redis 会选择其中一个线程作为主线程,其他线程则会被阻塞。主线程将写入操作的请求加入到一个队列中,并为该操作创建一个版本号。其他线程则会等待主线程完成操作后,再根据主线程创建的版本号进行写入。
需要注意的是,多版本并发控制功能需要在 Redis 编译时启用,并且只对部分命令(如 SET、HSET、ZADD 等)生效。在使用多版本并发控制功能时,需要特别注意数据一致性和性能问题。如果写入操作比较频繁,可能会导致版本号过多,从而影响系统的性能。因此,在实际应用中,需要根据具体的场景和需求进行选择。
Netty 代码示例
// 主 reactor
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
// 子 reactor
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(serverInitializer);
b.bind(port).sync().channel().closeFuture().sync();
logger.info("TcpServer start, listening port:" + port);
} catch (InterruptedException e) {
e.printStackTrace();
System.exit(1);
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
6 Proactor 模式
Proactor 模式是一种常见的异步 I/O 编程模式,用于处理高并发的网络通信。在 Proactor 模式中,主线程负责监听所有的 I/O 事件,当有事件发生时,主线程将事件的处理任务交给一个或多个工作线程池处理,自己则继续监听其他事件。
Proactor 模式的主要特点包括:
- 异步处理:在 Proactor 模式中,I/O 事件的处理是异步进行的,主线程不会阻塞等待 I/O 完成,而是将任务交给工作线程池处理。
- 事件驱动:Proactor 模式是一种事件驱动的编程模式,主线程会监听所有的 I/O 事件,并在事件发生时触发相应的处理任务。
- 高并发:由于 Proactor 模式采用了异步处理和事件驱动的方式,可以处理大量的并发连接和请求,提高系统的并发性能和可靠性。
需要注意的是,Proactor 模式需要使用操作系统提供的异步 I/O 接口(如 Windows 的 IOCP、Linux 的 epoll 等),并且需要使用线程池等技术来管理工作线程。在实际应用中,需要根据具体的场景和需求进行选择。
6.1 示意图
Proactor Initiator 负责创建 Proactor 和 Handler,并将Proactor 和 Handler 都通过 Asynchronous OperationProcessor 注册到内核
Asynchronous Operation Processor 负责处理注册请求并完成 I/O 操作
Asynchronous Operation Processor 完成I/O 操作后通知 Proactor
Proactor 根据不同的事件类型回调不同的 Handler 进行业务处理
Handler 完成业务处理,Handler 也可以注册新的Handler 到内核进程。
优点
1.理论上性能要比 Reactor 更高一些,但实测性能差异不大
缺点
- 操作系统实现复杂,Linux 目前对 Proactor 模式支持并不成熟
- 程序调试复杂
案例
Windows IOCP。
7 网络模型对比
阻塞、非阻塞:有无事件时,是怎么做的
同步、异步:事件来了后,你是怎么做的,同步就是用户进程读数据,异步就是多线程读数据再回调写到 handler。
Actor 模型
Akka 是一个基于 Actor 模型的并发编程框架,而不是 Proactor 模式。虽然 Actor 模型和 Proactor 模式都是用于处理高并发的编程模式,但它们有着不同的实现方式和特点。
在 Actor 模型中,所有的并发处理都是通过 Actor 进行的。每个 Actor 都有自己的状态和行为,可以接收消息、处理消息,并向其他 Actor 发送消息。Actor 之间的通信是异步的、非阻塞的,可以实现高效的并发处理。
与之相比,Proactor 模式更多地关注于 I/O 事件的处理。在 Proactor 模式中,主线程负责监听所有的 I/O 事件,并将事件的处理任务交给工作线程池处理。Proactor 模式采用了异步处理和事件驱动的方式,可以处理大量的并发连接和请求,提高系统的并发性能和可靠性。
需要注意的是,Actor 模型和 Proactor 模式都是常见的高并发编程模式,在实际应用中可以根据具体的场景和需求进行选择。
8 网络模型实战技巧
多Reactor多线程是目前已有技术中接近完美的技术方案。
- 所有场景都支持
- 所有平台
- 性能和 Proactor 接近
直接用开源框架,不要自己实现,如Netty、libevent(memcached 网络框架)、libuv (node.js 底层网络框架)。
9 总结
测验
判断
- PPC/prefork 等传统网络模型不支持海量连接 √
- 单 Reactor 单进程模式因为没有上下文切换,性能会很高 × 不能利用多核,会阻塞后续请求
- 单 Reactor 多进程模式一样可行 × 进程之间传输 fd 无意义
- 多 Reactor 多线程是接近完美的网络模型,而 Proactor 是性能最高的网络模型 √
- 如果技术实力足够,可以自己开发网络模型,这样会更可控一些 ×
文章转载自公众号: JavaEdge