看大佬如何用 30+图片揭秘 8 大主流服务器程序线程模型:
最近拍的照片比较少,不知道配什么图好,于是自己画了一个,凑合着用,让大家见笑了。
本文我们来探索一下主流的各种应用服务器的网络处理模型,看看大家都是怎么设计网络程序的。在本文中,我会从 Node.js、Apache Server、Nginx、Netty、Redis、Tomcat、MySQL、Zuul 等常用的服务器程序,给大家逐一分析,分析各种服务器程序的性能,心中有数,才能手中有术,从此性能是熟客。
虽然涉及到很多底层知识,各种框架的原理,但是我都会尽量配上直白易懂的图文,方便大家理解。
1、Node.js
我们继续讲讲 Node.js 的运行模式,揭开它高性能背后的实现机制。
1.1、Node.js 运行模式
Node.js 是单线程的 Event Loop:
V8 引擎解析 JS 脚本,调用 Node API;
libuv 库执行 Node API,会先将 API 请求封装成事件,放入事件队列, Event Loop 线程处于空闲状态时,就开始遍历处理事件队列中的事件 ,对事件进行处理:如果不是阻塞任务,直接处理得到结果,通过回调函数返回给 V8;如果是阻塞任务,则从 Worker 线程池中取出一个线程,交给线程处理,最终线程把处理结果设置到事件的结果属性中,把事件放回事件队列,等待 Event Loop 线程执行回调返回给 V8 引擎。
其中请求的任务会被封装成如下的结构:
varevent= createEvent({
params:request.params, // 传递请求参数
result:null, // 存放请求结果
callback:function(){} // 指定回调函数
});
复制代码
当然了,在客户端请求到 Node.js 服务器的时候,肯定会有一个创建已连接套接字的过程,然后把这个已连接套接字描述符与具体的执行代码关联起来,这样再异步处理完成之后,才知道要响应给哪个客户端。
1.2、Node.js 异步案例
以上的运行模式说明还是需要结合例子来说明比较好理解。
如果没有通过回调函数进行异步处理,我们可能会写出如下代码:
var result = db.query("select * from t_user");
// do something with result here...
console.log("do something else...");
复制代码
这个代码在执行查询 result 的时候,查询速度可能很慢,等待查询出结果后,才可以执行后面的 console.log 操作,因为这是在一个线程上执行的。
但是 Node.js 不是这么玩的,Node.js 的运行模式下,只有一个 Event Loop 线程,如果这个线程被阻塞,这将导致无法接收新的请求。为了避免这种情况,我们按照 Node.js 的回调方式重写代码:
db.query("select * from t_user", function(rows) {
var result = rows;
// do something with result here...
});
console.log("do something else...");
复制代码
现在,Node.js 可以异步处理查询请求了,并且把 查询请求委托给 Worker Thread,等待 Worker Thread 得到查询结果之后,再把结果连同回调匿名函数封装成事件发布到事件队列,等待 Event Loop 线程执行该回调函数 。这样 console.log 代码就可以立刻得到执行,而不会因为查询请求导致被阻塞住了。
1.3、Node.js 并发模型优缺点
从以上分析可知,Node.js 通过事件驱动,把阻塞的 IO 任务丢到线程池中进行异步处理,也就是说,Node.js 适合 I/O 密集型任务。
但是,如果碰到 CPU 密集型任务的时候,Node.js 中的 EventLoop 线程就会自己处理任务,这样会导致在事件队列中的 CPU 密集型任务没有处理完,那么后面的任务就不会被执行到了,从而导致后续的请求响应变慢。
如下图,本来 socket2 和 socket3 很快就可以处理完的,但是由于 socket1 的任务一直占用着 CPU 时间,导致 socket2 和 socket3 都不能及时得到处理,从表现上看,就是响应变慢了。
如果 CPU 是单核的还好,充分的利用了 CPU 内核,但是如果 CPU 是多核的,这种情况就会导致其他内存处于闲置状态,造成资源浪费。
所以,Node.js 不适合 CPU 密集型任务。
Node.js 适合请求和响应内容小,无需大量计算逻辑的场景,这能够充分发挥 Node.js 运行模式的优势。类似的场景有聊天程序。
2、Apache
Apache 于 1995 年首次发布,并迅速占领了市场,成为世界上最受欢迎的 Web 服务器。配合世界上最好的语言——PHP 搭建网站,在那个年代可谓是打遍天下无敌手。
这里我们来探讨下 Apache Web 服务器使用的两个工作模型:
Apache MPM Prefork:用于实现多进程模型;
Apache MPM Worker:用于实现多线程模型;
Apache 使用到了Multi Processing Module模块(MPM)来实现多进程或者多线程处理器。
2.1、Apache MPM Prefork
一句话总结:Prefork 是一个非线程型的、预派生的 MPM。
这种模型是每个请求一个进程的模型,由一个父进程创建了许多子进程,这些子进程等待请求的到达并且进行处理,每个请求均由单独的进程进行处理。
需要注意的是,每个进程都会使用 RAM 和 CPU 等系统资源,使用的 RAM 数量都是相等的。如果同时有很多请求,那么 apache 会产生很多子进程,这将导致大量的资源利用率。
2.2、Apache MPM Worker
一句话总结:Worker 是支持混合的多线程多进程的 MPM。如下图:
子进程借助内部固定数量的线程来处理请求,该数量由配置文件中的参数“ThreadsPerChild指定。
该模型一般使用多个子进程,每个子进程有多个线程,每个线程在某个确定的时间只处理一个连接,消耗内存较少。这种 Apache 模型可以用较少的系统资源来满足大量请求,因为这种模型下,有限数量的进程将为许多请求提供服务。
PHP 攻城狮提问题:为啥 mod_php 中不能使用 MPM Worker?
即使是一个请求用一个线程,Apache 在高并发场景下,运行效率也是很差的。因为,如果一个请求需要数据库中的一些数据以及磁盘中的文件等涉及到 IO 操作的处理,则该线程将进入等待。因此, Apache 中的某些线程(Worker 模式)或者进程(Prefork 模式)只是停下来下来等待某些任务完成,这些线程或者进程吃掉了系统资源。
而接下来我们介绍对并发场景处理更高效的主角:Nginx, 从根本上说,Apache 和 Nginx 差别很大。Nginx 的诞生是为了解决 Apache 中的 c10k 问题。
想象以下,从猪圈里冲出一群猪,Apache Server 能够抵挡得住吗,也许不行,但是,Nginx,一定可以。这就是 Nginx 的强大之处。
3、Nginx
Nginx 是一种开源 Web 服务器,自从最初作为 Web 服务器获得成功以来,现在还用作反向代理,HTTP 缓存和负载均衡器。
Nginx 旨在提供低内存使用率和高并发性。Nginx 不会为每个 Web 请求创建新的流程,而是使用异步的,事件驱动的方法,在单个线程中处理请求。
3.1、Nginx 的进程模型
3.1.1、Nginx 的进程数
我们在操作系统各种启动 Nginx 之后,一般会发现几个 Nginx 进程,如下图:
这里有一个 master 进程,3 个 workder 进程。为什么启动 Nginx 会有 3 个 worker 进程呢,这是因为我们在配置文件中指定了工作进程数:
worker_processes 3;
复制代码
3.2、进程模型
Nginx 是多进程模型,在启动 Nginx 之后,以 daemon 的方式在后台运行,后台进程包含一个 Master 进程和多个 worker 进程,模型如下:
CM: Cache Manager, CL Cache Loader
复制代码
Master 进程主要用于管理 Worker 进程,主要负责如下功能:
接收外接信号;
向 Worker 进程发送信号;
监控 Worker 进程运行状态;
Workder 进程异常退出,会自动创建新的 Worker 进程。
Worker 进程主要用于处理网络事件,我们一般设置的 Worker 进程数为机器的 CPU 核数,以最有效的利用硬件资源。为此,可以进行如下配置:
worker_processes auto;
复制代码
通过使用共享缓存来实现子进程的缓存,会话持久性,限流,会话日志等。
3.3、工作原理
大致来说,Master 进程执行以下步骤:
socket();
bind();
listen();
fork();
复制代码
fork 出若干个 Worker 进程,Worker 仅执行以下步骤:
accept(); // accept_mutex锁
register IO handler;
epoll() or kqueue();
handle_events();
...
复制代码
accept_mutex 锁作用:保证同一时刻只有一个 Worker 进程在 accept 连接,从而解决惊群问题。当客户连接到达时候,只有成功获取到了锁的进程才会执行 accept。
惊群问题:一个程序派生出 N 个子进程,它们各自调用 accept 并因此而被投入内核睡眠。当第一个客户连接到达的时候,所有 N 个子进程均被唤醒,这是因为所有子进程所用的监听描述符指向了同一个 socket 结构。尽管有 N 个子进程被唤醒,但是只有最先运行的子进程获得那个客户连接,其余的 N-1 个子进程继续恢复睡眠。
我们重点来看看 Worker 进程的工作原理。
3.3.1、Worker 进程工作原理
每个 Worker 进程都是运行于非阻塞、事件驱动的 Reactor 模型。
一个客户端请求在服务端的大致处理流程如下图所示:
单线程版本的 Reactor 模型:
而 Worker 进程中基本的处理逻辑则如上图所示:
当一个请求到达 Workder 进程的之后,Accept 接收新连接,把新连接 IO 读写事件注册到同步事件多路解复用器;
执行 dispatch 调用多路解复用器阻塞等待 IO 事件;
分发事件到特定的 Handler 中处理;
3.3.2、如何处理繁重的工作?
与 Node.js 类似,Nginx 中也会有一些繁重的工作。比如第三方模块中使用了阻塞调用,有时候该模块开发人员都没有意识到这个阻塞调用的缺点,如果直接在 Worker 进程中执行,就会导致整个事件处理周期都被阻塞了,必须等待操作完成才可以继续处理后续的事先。显然,这不是我们期望的效果。
使用线程池机制解决繁重工作或者第三方阻塞操作性能问题
以下操作可能导致 Nginx 进入阻塞状态:
处理冗长占用大量 CPU 的处理;
阻塞访问资源,如硬盘资源,互斥量,或者系统调用等,或者以同步方式从数据库获取数据
...
以上这些情况都需要执行比较长的时间,遇到这种情况,Nginx 会将需要执行很长时间的任务放入线程池处理队列中,通过线程池异步处理这些任务:
通过引入线程池,从而消除了对 Worker 进程的阻塞,将 Nginx 的性能提升到了新的高度。更加重要的是,以前那些与 Nginx 不兼容的第三方类库,都可以相对容易的使用,并且不影响 Nginx 的性能。
我们在更新完 Nginx 的配置之后,一般执行以下命令即可:
nginx -s reload
复制代码
这行命令会检查磁盘上的配置,并向主进程发送 SIGNUP 信号。
主进程收到 SIGNUP 信号时,会执行如下操作:
重新加载配置,并派生一组新的工作进程,这组新的工作进程立即开始接受连接并处理流量;
指示旧的工作进程正常退出,工作进程停止接收新连接,当前的每个请求处理完成之后,旧的工作进程就会关掉,一旦所有的连接关闭,工作进程就将退出。
这种重新加载配置过程可能导致 CPU 的内存使用量小幅度提升,但是这个性能牺牲是值得的。
3.5、优雅的升级
Nginx 的二进制升级过程也实现了不停服的效果。
升级过程与政策重新加载配置的方法类型,新的 Nginx 主进程与原始主进程并行运行,他们共享监听套接字,两个进程都处于活动状态,他们各自的工作进程都在处理流量,然后可以可以指示旧的 Master 和 Worker 进程正常退出。
3.5、Nginx 的优势
在每个请求一个进程,阻塞式的连接方法中,每个连接都需要大量额外的资源开销,并且会导致频繁的上下文切换;可以尽可能消耗少的内存,每个连接几乎没有额外的开销,Nginx 进程数可以设置为 CPU 核心数,上下文切换相对较少。
那么问题来了,我们自己写网络程序的时候,有没有可以帮助我们提高网络性能的程序框架呢?有,那就是大名鼎鼎的 Netty,接下来就来说他。
4、Netty
4.1、Netty 主从 Reactor 模式
Netty 也不例外,是基于 Reactor 模型设计和开发的。
Netty 采用了主从 Reactor 模式,主 Reactor 只负责建立连接,获取已连接套接字,然后把已连接套接字的 IO 事件转给从 Reactor 线程进行处理。
我们先来大致讲讲 Netty 中的几个与 Reactor 有关的抽象概念:
Selector:可以理解为一个 Reactor 线程,内部会通过 IO 多路复用感知事件的发生,然后把事件交代给 Channel 进行处理;
Channel:注册到 Selector 中的对象,代表 Selector 监听的事件,如套接字读写事件;
具体上,Netty 抽象出了以下模型进行实现 Reactor 主从模式:
Netty 基于 Pipeline 管道的模式来处理 Channel 事件,从 Netty 的使用 API 中也可以了解到。
4.2、Netty 主从 Reactor+Worker 线程池模式
为了降低具体业务逻辑对从 Reactor 的影响,我们可以单独把业务逻辑处理放到一个线程池中处理,这样无论是对于监听套接字的事件处理,还是对于已连接套接字事件的处理,都不会因为业务处理程序而导致阻塞了,如下图所示,更详细的说明参考我的博客IT 宅()或者公众号Java 架构杂谈(itread)中的文章更新 网络编程范式:高性能服务器就这么回事 | C10K,Event Loop,Reactor,Proactor :
我们可以通过创建一个 DefaultEventExecutorGroup 线程池来处理业务逻辑。
大致程序框架如下图所示:
// 声明一个bossGroup作为主Reactor,本质是一个线程池,每个线程是一个EventLoop
EventLoopGroup bossGroup = new NioEventLoopGroup();
// 声明一个workerGroup作为从Reactor,本质是一个线程池,每个线程是一个EventLoop
EventLoopGroup workerGroup = new NioEventLoopGroup();
// 构建业务处理Group
DefaultEventExecutorGroup defaultEventExecutorGroup =
new DefaultEventExecutorGroup(10,
new ThreadFactory() {
private AtomicInteger threadIndex = new AtomicInteger(0);
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "BusinessThread-" + this.threadIndex.incrementAndGet());
}
});
try {
// 创建服务端启动类
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
...
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) throws Exception {
// 在pipeline中添加自定义的handler
ch.pipeline().addLast(defaultEventExecutorGroup, new BizHandler());
}
});
ChannelFuture future = bootstrap.bind(port).sync();
future.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
复制代码
Netty 是基于 NIO 的,而 Java 中的 NIO 是 JDK1.4 开始支持,内部是基于 IO 多路复用实现的,具体的实现思路不再详细说,底层都是 IO 复用技术,通过 Channel 借助于 Buffer 处理感知到的 IO 事件。
有了 NIO 为啥还要有 Netty?
这是两个不同层次的东西,NIO 只是一个 IO 类库,实现同步非阻塞 IO,而 Netty 是基于 NIO 实现的高性能网络框架,基于主从 Reactor 设计的。
NIO 类库 API 复杂,需要处理多线程编程,自己写 Reactor 模式,并且客户端断线重连、半包读写,失败缓存、网络阻塞和异常码流等问题处理起来难度很大。而 Netty 对于 NIO 遇到的这些问题都做了很好的封装,主要优点体现在:
API 使用简单;
封装度高,功能强大,提供多种编码解码器,解决了 TCP 拆包粘包问题;
基于 Reactor 模式实现,性能高,无需再自己实现 Reactor 了;
商用项目多,经历过很多考验,社区活跃...
5、Redis
相信大家已经听过无数遍“Redis 是单线程的”这句话了,Redis 真的是单线程的吗,又是如何支撑那么大的并发量,并且运用到了这么多的互联网应用中的呢?
其实,Redis 的单线程指的是 Redis 内部会有一个主处理线程,充分利用了非阻塞、IO 多路复用模型,实现的一个 Reactor 架构。但是在某些情况下,Redis 会生成线程或者子进程来执行某些比较繁重的任务。
5.1、Redis 线程模型
还是那个 Reactor 模型,只不过我们再次踏入了不同的国界,于是又出现了一种新的的表述方式。
Redis 基于 Reactor 模型开发了网络事件处理器,这个处理器被称为文件事件处理器。不过叫什么不重要,重要的是原理都是一样的。以下是 Redis 的线程模型:
这个图基本上涵盖了 Redis 进程处理的主要事情:
客户端 A 发起请求建立连接,监听套接字 Server Socket 建立连接之后,产生一个 AE_READABLE 事件;
该事件被 IO 多路复用处理,放入事件队列,最终被文件事件分派器分派给了连接应答处理器进行处理:连接应答处理器处理新连接,将 FD1 套接字的 AE_READABLE 事件与命令请求处理器关联起来。
客户端 A 最终在客户端生成一个已连接套接字 FD1;
客户端 A 发送命令请求,产生一个 AE_READABLE 事件,该事件被 IO 多路复用处理,放入事件队列,最终被文件事件分派器分派给了命令请求处理器进行处理:命令请求处理器执行客户端 FD1 套接字命令操作,得到结果,将结果写入到套接字的回复缓冲区中,准备好响应给客户端;同时将 FD1 套接字的 AE_WRITABLE 事件与命令回复处理器关联;
当 FD1 套接字准备好写的时候,会产生一个 AE_WRITABLE 事件,该事件被 IO 多路复用处理,放入事件队列,最终被事件分派器分派给了命令回复处理器进行处理:命令回复处理器把结果输出响应给客户端的 FD1 已连接套接字;然后将 FD1 套接字的 AE_WRITABLE 事件跟命令回复处理器解除关联。
大致一个交互流程就这样完成了,是不是很简单呢。
5.2、为啥 Redis 单线程也这么高效?
前面已经讲了这么多 Reactor 模式的好处,相信大家心里也有个底了,大致总结下:
Redis 是纯内存操作的,所以处理速度非常快,这同时也跟 Redis 高效的数据结构有关,不过本文重点是讲网络相关的,数据结构不展开讲;
Redis 的瓶颈不在 CPU,而是在内存和网络;
单线程反而避免了上下文切换的开销。
对于开发人员来说,最关注的一点就是:单线程降低了开发的复杂度,再也不需要处理各种静态条件了,就连 Hash 的惰性 Rehash,Lpush 等线程不安全的命令都可以进行无锁编程了。
5.3、Redis 真的是单线程的吗?
我再问一句大家,Redis 真的是单线程的吗,从 Reactor 模型上来说,单线程肯定会存在瓶颈的;
为此,Redis 引入了多线程机制。
Redis 4.0 初步引入多线程
在 Redis 4.0 中,Redis 开始使具有更多线程。这个版本仅限于在后台删除对象,其中包括非阻塞的删除操作。UNLINK 操作,只会将键从元数据中删除,并不会立刻删除数据,真正的删除操作会在一个后台线程异步执行。
Redis 6.0 真正引入多线程
虽然基于 Reactor 模型,单线程也可以支持很大的并发量,但是要是 IO 读写多了,待处理的已连接套接字多了,需要执行的命令也多了,那么,单线程依旧是瓶颈,这个时候我们就要引入主从 Reactor 模型,甚至主从 Reactor 模型+Worker 线程池了。
在 Redis 6.0 中,如果要开启多线程,可以进行设置:
io-threads 线程数
io-threads-do-reads yes // 默认IO线程只会用于写操作,如果要在读操作和协议解析的时候启用IO线程,则可以设置该选项为yes,但是Redis团队声称它并没有多大帮忙
复制代码
不过呢,Redis 为了避免产生线程并发安全的问题,在执行命令阶段仍然是单线程顺序执行的,只是在网络数据读写和协议解析阶段才用到了多线程。
为了进一步了解这个特性,我们可以阅读以下 redis.conf 配置文件的说明。在这里,这个特性被命名为:THREADED I/O,下面是翻译整理自里面的一些说明。
THREADED I/O
Redis 大多是单线程的,但是有一些线程操作,例如 UNLINK,执行缓慢的 I/O 访问等是在后台线程上执行的操作。
将 io-threads 设置为 1 只会像传统一样只启用单线程。
使用 8 个以上的线程不会有太大帮助,并且建议实际存在性能问题的时候才使用 IO 线程,否则就没有必要使用了。
启用 IO 线程后,我们仅将 IO 线程用于写操作,即对 write(2)系统调用进行线程化并将客户端缓冲区传输到套接字。 但是,也可以使用以下配置指令通过以下方式启用读取线程和协议解析:
io-threads-do-reads yes
复制代码
通常,线程读取没有太大帮助。
Redis 用的是类似单线程版的 Reactor + IO 线程池(Worker 线程池),不过与我们前面提到的单线程 Reactor + Worker 线程池模式有所不同,再回顾下 Reactor + Worker 线程池模式:
Redis 是在所谓的 Reactor 线程(主线程)中把 IO 读事件一批一批地交给 IO 线程池进行读取,读取完毕之后,统一执行所有请求的命令,然后才是一次性把所有请求的响应写到 socket,如下图所示:
等待队列中的待处理时间平均分给每个 IO 线程,IO 线程池只是负责 IO 读写和解析数据,IO 线程池充分利用了 CPU 多核处理的能力,提高了 IO 读写速度。
Redis 6.0 真的是单线程的吗?
6、Tomcat
作为一个 Java 程序员,怎么能不认识 Tomcat 呢,Tomcat 的线程模型又是怎样的?不用往下看,我们都能猜出 Tomcat 肯定会利用 Reactor 模式来优化网络处理,不过这个优化过程却是跟随者技术的发展慢慢演变的。
6.1、Tomcat 整体架构
Tomcat 是 HTTP 服务器,同时还是一个 Servlet 容器,可以执行 Java Servlet,并将 JavaServer Pages(JSP)和 JavaServerFaces(JSF)转换为 Java Servlet。
我们先来看看 Tomcat 各个组件的整体架构。Tomcat 采用了分层和模块化的体系结构,如下所示,这个结构有点像套娃,一层套一层的,这也同时是 Tomcat server.xml 配置文件的层级结构:
Server 是顶层组件,代表着一个 Tomcat 实例,在配置文件中一般如下:
<Server port="8005" shutdown="SHUTDOWN"> ...... </Server>
复制代码
Server 下面可以包含多个 Service,每个服务都有自己的 Container 和 Connector。
6.1.1、Container
Container 用于管理各种 Servlet,处理 Connector 传过来的 Request 请求。
大家可以看到,Container 内部若隐若现的好像还有内幕..是的,上图中我把内幕隐藏起来了,接口 Container 内部,我们可以看到这样的结构:
Container 最顶层是 Engine,一个 Service 只能有一个 Engine,用来管理多个站点;
Host 代表一个站点,一个 Engine 下可以有多个 Host;
Context 代表一个应用,一个 Host 下面可以有多个应用;
Wrapper 封装 Servlet,每个应用都有很多 Servlet,这个是大家最熟悉的了。
6.1.2、Connector
Connector 用于处理请求,处理 Socket 套接字,把原始的网络数据包装成 Request 对象给 Container 进行处理,并封装 Response 对象用于响应套接字输出。
如上图,一个 Service 可以有多个 Connector, 每个 Connector 实现不同的连接协议,通过不同的端口提供服务。
这里已经看到我们要关注的重点了,是的,Connector 就是处理网络的关键模块,这个模块的效率直接决定了 Tomcat 的性能!!!
接下来,我们打开 Connector 潘多拉的盒子,看看里面究竟有什么不可告人的秘密。
话不多说,我直接上图,这么爽快不断附图片的博客还真不多,IT 宅()的Java 架构杂谈算一个,重点来了,这里我们先列出传统的 BIO 运行模型的组件图:
其中ProtocolHandler中主要的组件有:
EndPoint
Processor
复制代码
不过既然知道 EndPoint 是直接负责对接套接字 Api 的,那我们就知道了核心的网络编程性能关键就在 EndPoint 这个组件里面,在这里可以使用各种 IO 编程范式来进行网络性能优化。EndPoint 里面又有几个抽象概念:
Acceptor
Handler
AsyncTimeout
复制代码
既然 EndPoint 组件是网络处理关键的性能所在,我们就重点来看看这块的设计吧。
6.2、Tomcat 连接器性能分析
首先来看看传统的 BIO 线程模型。
6.2.1、Tomcat 之 BIO 线程模型
BIO 线程模型即传统的以多线程处理请求的方式获取到一个新的已连接套接字之后,都丢到线程池里面,交给一个线程处理,从读取 IO 数据,处理业务,到响应 IO 数据都是在同一个线程中处理。如下图,我只把相关的组件给画出来:
如上图,Acceptor 线程获取到新的已连接套接字之后,直接把新的已连接套接字交给 Executor 线程池进行处理。
这种模式,受能够创建线程数的限制,导致不能支撑很大并发,并且越多的因 IO 导致阻塞的线程,会导致越多的线程上下文切换,浪费了系统资源。
接下来我们看看 NIO 线程模型,该模型基于主从 Reactor + Worker 线程池网络编程模型。
6.2.2、Tomcat 之 NIO 线程模型
对应的实现类为:Http11NioProtocol,同步非阻塞 IO 实现的 HTTP/1.1 协议处理器,Tomcat 8 默认采用该模式。
以下是该模型的组件架构图:
其中 Poller 线程中维护了一个 Selector 对象,用来实现基于 NIO 网络事件处理。
基于 NIO 的 Tomcat,避免了由于 IO 导致的阻塞,减少了线程开销,以及线程上下文切换开销,能够支撑更大的并发量。
6.2.3、Tomcat 之 NIO2 线程模型
Http11Nio2Protocol:异步 IO 实现的 HTTP/1.1 协议处理器,Tomcat 8 之后开始支持,基于 Java 的 AIO API 实现的异步 IO。
相关组件架构图如下:
对应的异步 IO 处理类是 Nio2EndPoint,获取已连接套接字的类为 Nio2Acceptor。
由于 IO 异步化了,所以 Nio 中的 Poller 类也就没有了存在的必要。不管是 accept 获取已连接套接字还是 IO 读写,都改为了异步处理,当可以做 IO 操作的时候,会由 Java 异步 IO 框架调用对应 IO 操作的CompletionHandler类进行后续处理。
这里的 SocketProcessor 实现了 Runnable 接口,其中的 run 方法即是原本丢给 Worker 线程处理的,包括 IO 读写。但是现在,SocketProcessor 再也不需要多一次 IO 操作的系统调用开销了。
6.2.4、Tomcat 之 APR 线程模型
我们再简要介绍下,APR,对应的实现为Http11AprProtocol:apr(Apache Portable Runtime/Apache 可移植运行时),是一个高度可移植的库,它是 Apache HTTP Server 2.x 的核心。
在 Tomcat 中使用 APR 库,其实就是在 Tomcat 中使用 JNI 的方式来读取文件以及进行网络传输,可以大大提升 Tomcat 对静态文件的处理性能。如果服务开启了 HTTPS 的话,也可以提升 SSL 的处理性能。
7、MySQL
7.1、MySQL 线程模型
首先我们来看一个参数:thread_handling,一个控制 MySQL 连接线程的参数,它有以下三个取值:
no-threads
one-thread-per-connection
loaded-dynamically
复制代码
看起来,MySQL 并没有使用 Reactor 或者 Proactor 优化网络 IO 效率。
那么我们就来看看传统的一个请求创建一个线程的模型下,MySQL 内部是如何工作的吧,如下是该线程模型工作图示:
连接请求:客户端请求 MySQL 服务器,默认的 ,由 MySQL 服务器的 TCP 3306 接口进行接收消息,传入的连接请求被排队;
Receiver Thread(接收线程):接受线程负责处理排队的连接请求,accept 到请求之后,创建一个用户线程,让用户线程进一步处理后续逻辑;线程缓存:如果在线程缓存中能够找到接收线程,则可以重用线程缓存中的线程,否则,新建线程。如果创建 OS 线程的成本很高,那么线程缓存对于连接速度起到很大的帮助作用。
THD:thread/connection descriptor,对于每个客户端连接,我们使创建一个单独的线程,并且为该线程提供 THD 数据结构,作为线程/连接描述符,每个连接线程对应一个 THD。THD 在连接建立的时候创建,在连接断开的时候删除。
7.2、限制 MySQL 并发效率的因素
限制 MySQL 并发效率的主要因素主要有互斥锁、数据库锁或 IO。
互斥锁:一般为了保护共享内部数据结构的时候,都会创建一个互斥锁,确保任何时间只有一个线程在操作内部数据结构,但是互斥锁却导致了其他线程必须排队等待。
数据库锁:数据库锁,从某种程度上来说,数据库锁和数据库语义相关,一次更难避免(而 InnoDB 具有多版本并发控制,所以它在避免锁方面比较擅长。
磁盘和网络 IO:由于 MySQL 数据库是存储在硬盘的,执行 SQL 的过程中,不可避免的从磁盘加载数据页,此时线程会进入等待状态。线程并发性将受到 IO 容量的限制。
为什么 MySQL 没有使用 Reactor 模式优化 IO?
关于这个问题,我想主要有以下原因:MySQL 的架构设计,就决定了在通过索引查找数据的过程中,需要不断地加载数据页,采用 Reactor 模式,编码复杂度将更高。
鲁迅说:真的不考虑以下其他的数据库吗?
7.3、InnoDB 对并发流量的守卫战
基于以上提及的 MySQL 性能问题,InnoDB 存储引擎做了一些防守:在有助于最大程度地减少线程之间的上下文切换的情况下,InnoDB可以使用多种技术来限制并发线程数。当InnoDB从用户会话接收到新请求时,如果同时执行的线程数已超预定义限制,则新请求将休眠一小段时间,然后再次尝试。睡眠后无法重新安排的请求被放入先进/先出队列,并最终得到处理。
涉及的参数:
innodb_thread_concurrency:InnDB 存储引擎最大并发线程数,如果为 0,则表示不限制;
innodb_thread_sleep_delay:超过最大并发线程数,请求线程需要等待 innodb_thread_sleep_delay 毫秒后才可以再次重试。
innodb_concurrency_tickets:一旦请求线程进入到了 InnoDB,会获取到 innodb_concurrency_tickets 次通行证,代表该线程可以直接进 InnoDB 而不需要检查的次数。
通过这种设计, 尽可能地让一次查询请求尽快地完成(如一次 join 查询操作,可能包含多个 InnoDB 查询请求),而不会导致频繁的 InnoDB 线程上下文切换开销 。
8、Zuul
既然是 Netflix 开源的微服务网关,先来看看 Zuul 1 的性能情况。
这是一个多线程的系统架构。Zuul 1 是基于 Servlet 构建的。IO 操作是通过从线程池中获取一个线程来执行 IO 来完成的,在执行 IO 操作的过程中,请求线程被阻塞。
当后端延迟增加或者由于错误而导致请求重试,活动的链接和请求线程数就会增加,这种情况下可能会导致服务负载激增,为了抵御这些风险,于是便有了 Hystrix 熔断器,用于提供过载保护。
Zuul 2 内部也是用到了事件循环。在异步的运行方式下,通常每个 CPU 内核对应一个线程,用于处理所有的请求和响应,请求和响应通过事件和回调进行处理。
因为每个连接不用创建新的线程,只需要付出文件描述符和监听器的成本,所以连接的成本很低。
在异步模式下,队列中的连接和事件的增加成本远低于线程堆积的成本。但是假设后端处理不过来,响应时间还是会不可避免的增加。
以上就是有关 8 大主流服务器的学习笔记,希望可以对大家学习有所帮助,喜欢的小伙伴可以帮忙转发+关注,感谢大家!LZ 也会不定时地更新干货,以此来帮助大家的学习,充实自己!