本篇文章是我在学习高并发问题时接触到的网络I/O相关知识,比较底层且纯理论,整合以作参考。 下面长文预警。
高并发
基本表现为单位时间内系统能够同时处理的请求数
核心是对CPU资源的有效压榨。注意,有效很重要。
C10K问题
C10K问题本质上是操作系统的问题。对于Web1.0/2.0时代的操作系统而言, 传统的同步阻塞I/O模型都是一样的,处理的方式都是requests per second,并发10K和100的区别关键在于CPU。
创建的进程线程多了,数据拷贝频繁(缓存I/O、内核将数据拷贝到用户进程空间、阻塞), 进程/线程上下文切换消耗大, 导致操作系统崩溃,这就是C10K问题的本质!
一般解决方案是每个进程/线程同时处理多个连接。
网络编程模型的演变历史:
Fork进程 --> 进程池/线程池 --> 事件驱动(select->epoll)-->协程
- CPU上下文:保存在CPU寄存器和操作系统的程序计数器里面的进程加载信息,。
- 进程上下文:虚拟内存、栈、全局变量等用户空间的资源,以及内核堆栈、寄存器等内核空间的状态。
- 线程上下文:父进程的资源加上线上自己的私有数据就叫做。
进程和线程的切换,会产生CPU上下文切换和进程/线程上下文的切换,会消耗额外的CPU的资源。
协程需要上下文切换,但是不会产生 CPU上下文切换和进程/线程上下文的切换,因为这些切换都是在同一个线程中,即用户态中的切换。可以简单的理解为,协程上下文之间的切换,就是移动了一下你程序里面的指针,CPU资源依旧属于当前线程。
网络IO
1、阻塞与非阻塞
阻塞与非阻塞是描述进程在访问某个资源时,数据是否准备就绪的的一种处理方式。
当数据没有准备就绪时:
- 阻塞:线程持续等待资源中数据准备完成,直到返回响应结果。
- 非阻塞:线程直接返回结果,不会持续等待资源准备数据结束后才响应结果。
2、同步与异步
同步与异步是指访问数据的机制。
- 同步会主动请求并等待IO操作完成。
- 异步请求后可以继续处理其它任务,IO完毕后被通知。
3、比较阻塞、非阻塞与同步、异步
- 阻塞、非阻塞讨论对象是调用者;
- 同步、异步讨论对象是被调用者。
五种IO模型比较:
1、阻塞式IO:
优点:程序简单,在阻塞等待数据期间进程/线程挂起,基本不会占用 CPU 资源。 缺点:每个连接需要独立的进程/线程单独处理,当并发请求量大时为了维护程序,内存、线程切换开销较大,这种模型在实际生产中很少使用。
2、非阻塞式IO:
在非阻塞式 I/O 模型中,当所请求的 I/O 操作无法完成时返回一个错误,应用程序基于 I/O 操作函数将不断的轮询数据是否已经准备好,如果没有准备好,继续轮询,直到数据准备好为止。
优点:不会阻塞在内核的等待数据过程,每次发起的 I/O 请求可以立即返回,不用阻塞等待,实时性较好。 缺点:轮询将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低,所以一般 Web 服务器不使用这种 I/O 模型。
3、IO多路复用:
每个进程/线程同时处理多个连接。
Select/Poll,也会使进程阻塞,但通过复用器轮询检查通道,直到有数据可读或可写时才真正进行IO操作,通过复用选择器实现了伪异步IO。
优点:可以基于一个阻塞对象,同时在多个描述符上等待就绪,而不是使用多个线程(每个文件描述符一个线程),这样可以大大节省系统资源。 缺点:当连接数较少时效率相比多线程+阻塞 I/O 模型效率较低,可能延迟更大,因为单个连接处理需要 2 次系统调用,占用时间会有增加。
4、信号驱动式IO
在信号驱动式 I/O 模型中,应用程序使用套接口进行信号驱动 I/O,并安装一个信号处理函数,进程继续运行并不阻塞。
具体可以了解linux中的epoll模型。
比喻:鱼竿上系了个铃铛,当铃铛响,就知道鱼上钩,然后可以专心玩手机。 优点:线程并没有在等待数据时被阻塞,可以提高资源的利用率。 缺点:信号 I/O 在大量 IO 操作时可能会因为信号队列溢出导致没法通知。
5、AIO
异步I/O模型,由 POSIX 规范定义,应用程序告知内核启动某个操作,并让内核在整个操作(包括将数据从内核拷贝到应用程序的缓冲区)完成后通知应用程序。
这种模型与信号驱动模型的主要区别在于:信号驱动 I/O 是由内核通知应用程序何时启动一个 I/O 操作,而异步 I/O 模型是由内核通知应用程序 I/O 操作何时完成。
优点:异步 I/O 能够充分利用 DMA 特性,让 I/O 操作与计算重叠。 缺点:要实现真正的异步 I/O,操作系统需要做大量的工作。目前 Windows 下通过 IOCP 实现了真正的异步 I/O。
而在 Linux 系统下,Linux 2.6才引入,目前 AIO 并不完善,因此在 Linux 下实现高并发网络编程时都是以 IO 复用模型模式为主。
总结:
从上图中我们可以看出,越往后,阻塞越少,理论上效率也是最优。
这五种 I/O 模型中,前四种属于同步 I/O,因为其中真正的 I/O 操作(recvfrom)将阻塞进程/线程,只有异步 I/O 模型才与 POSIX 定义的异步 I/O 相匹配。
JAVA IO模型
BIO
- 一种同步的阻塞IO。IO在进行读写时,该线程将被阻塞,线程无法进行其它操作。
- Java IO中包含了许多InputStream、OutputStream、Reader、Writer的子类。这样设计的原因是让每一个类都负责不同的功能。
- 以传统BIO模型为基础,可以通过线程池的方式维护所有的IO线程,实现伪相对高效的线程开销及管理。
利用线程池实现,通过同一进程/线程来同时处理若干连接的思路而不是来一个任务就开一个线程去处理,可以实现一种伪异步IO。
NIO
Non-Blocking-IO(其实是New IO)一种同步非阻塞IO。
标准的BIO是基于字节流和字符流进行操作的,数据在管道里像水流一样流入或流出;而NIO是基于通道(Channel)和缓冲区(Buffer)进行操作的,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。
传统IO在读写过程中是不允许停止的,需要一直占用线程。对于一个一直有内容可读的文件不用切换线程这样效率的确不错,但是对于并发场景需要不停开启新的线程并且是很耗费资源的。
NIO,将内容写到一个Buffer中通过操作Bufer的标识(或者说游标),来改变其读或者写的状态,将它变成了分块的模式,通过通道channel进行数据传递,对内存中内容进行映射,读完了就结束,不需要一直占用线程。然而对于channel要有标识需要知道每个channel对应的谁写的谁读的问题,于是就有了Selector,通过regiser注册每个channel这样就可以知道哪个channel有任务就绪,再执行其他的操作,每个channel不是单独的占用线程.这样就更好的支持了并发。
Java NIO引入了选择器(Selector)的概念
支持非阻塞的IO读写和基于IO事件进行分发任务,同时支持对多个fd(文件描述符)的监听。Selector用于监听多个通道的事件(accept,connect,read,write)
因此单个线程可以监听多个Channel。
Channel、Buffer和Selector构成了NIO的核心组件。
三大核心组件:
Channel(通道)
可以通过配置Channel的阻塞行为,来实现非阻塞式的通道。
Channel中数据的读取是通过Buffer , 一种非阻塞的读取方式。
Buffer(缓冲区)
一个缓冲区,实际上是一个容器,一个连续数组。Channel提供从文件或网络读取数据的渠道,但是读写的数据都必须经过Buffer。
Selector(多路复用器)
Selector与Channel是相互配合使用的,将Channel注册在Selector上之后,才可以正确的使用Selector
Selector支持监听多个通道的事件。因此,单个线程可以监听多个数据通道。线程资源开销相对较小。
特点:
基于事件驱动:Selector支持对多个Channel的监听
统一的事件分派中心:dispatch
事件处理服务-> read & write
总结与比较
IO是面向流的,这意味着每次从流中读一个或多个字节,直至读取所有字节,它们没有被缓存在任何地方。此外,它不能前后移动流中的数据。如果需要前后移动从流中读取的数据,需要先将它缓存到一个缓冲区。
NIO是面向缓冲区的。NIO在数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。但是,需确保原缓冲区尚未处理的数据不被覆盖。
IO的各种流是阻塞的,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。
NIO主要是非阻塞模式,能使一个线程从某通道发送请求读取数据,但如果目前没有数据可用时,就什么都不会获取。写操作也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个channel,每个channel里是待完成的I/O请求。同时也可以设计线程池实现多线程selector模型
NIO支持非阻塞的IO读写和基于IO事件进行分发任务,同时支持对多个fd(文件描述符)的监听。
为什么使用NIO?
NIO只是优化了网络IO的读写,如果系统的瓶颈不在这里,比如每次读取的字节说都是500b,那么BIO和NIO在性能上没有区别。NIO模式是最大化压榨CPU,把时间片都更好利用起来。
对于操作系统来说,线程之间上下文切换的开销很大,而且每个线程都要占用系统的一些资源如内存。
因此,使用的线程越少越好。而I/O复用模型正是利用少量的线程来管理大量的连接。在对于维护大量长连接的应用里面更适合用基于I/O复用模型NIO,比如web qq这样的应用。所以我们要清楚系统的瓶颈是I/O还是CPU的计算。
NIO简单代码实现分析
public NIOServer(int port) throws Exception {
//1. 首先创建一个selector对象
selector = Selector.open();
//2.注册Channel到Selector中
serverSocket = ServerSocketChannel.open();
serverSocket.socket().bind(new InetSocketAddress(port));
serverSocket.configureBlocking(false); //设置非阻塞
serverSocket.register(selector, SelectionKey.OP_ACCEPT);
}
@Override
public void run() {
while (!Thread.interrupted()) {
try {
//1.阻塞等待准备就绪的事件列表
selector.select();
//2.访问已选择的键集合并轮询
Set selectedKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) {
keyIterator.remove();
dispatch((SelectionKey)(keyIterator.next()));
}
} catch (Exception e) {
}
}
}
private void dispatch(SelectionKey selectedKey) throws Exception {
if (selectedKey.isAcceptable()) { //发现ServerSocketChannel中有新连接建立
register(selectedKey);//,将其注册到关联的Selector和Channel中
} else if (selectedKey.isReadable()) {
read(selectedKey);//读事件处理
} else if (selectedKey.isWritable()) {
write(selectedKey);//写事件处理
}
}
private void register(SelectionKey key) throws Exception {
ServerSocketChannel server = (ServerSocketChannel) key
.channel();
// 获得和客户端连接的通道
SocketChannel channel = server.accept();
channel.configureBlocking(false);
//客户端通道注册到selector 上
channel.register(this.selector, SelectionKey.OP_READ);
}
补充阅读
FileChannel没有继承SelectableChannel(原因可以自己思考),不能切换非阻塞模式,而在Selector中,Channel必须是非阻塞的
一个SelectionKey键表示了一个特定的通道对象和一个特定的选择器对象之间的注册关系。
register() 方法的第二个参数,就是在通过Selector监听Channel时,在Channel中触发的已经就绪的事件。比如某个Channel成功连接到另一个服务器称为“ 连接就绪 ”。一个Server Socket Channel准备好接收新进入的连接称为“ 接收就绪 ”。一个有数据可读的通道可以说是“ 读就绪 ”。等待写数据的通道可以说是“ 写就绪 ”。
这四种事件用SelectionKey的四个常量来表示:
SelectionKey.OP_CONNECT SelectionKey.OP_ACCEPT SelectionKey.OP_READ SelectionKey.OP_WRITE
从Selector中选择Channel
Selector会维护注册过Channel集合,它们的注册关系被封装在**SelectionKey中。
Selector中的select方法可以选择已经准备就绪的事件通道,其中有三种方法:
int select() //阻塞到至少有一个通道在你注册的事件上就绪 int select(long timeout) //最长阻塞时间为timeout ms int selectNow() //非阻塞,有通道就绪立刻返回
方法的返回值是是自上次调用select()方法后有多少通道变成就绪状态,当返回值不为0时,则可以通过Selector的selectedKeys方法访问已选择的键集合,即
Set selectedKeys=selector.selectedKeys();
并且通过轮询,对与SelectionKey关联的Selector和Channel进行新连接注册或进行I/O操作,实现事件分发。
使用Selector的好处在于: 使用更少的线程来就可以来处理通道了, 相比使用多个线程,避免了线程上下文切换带来的开销。
事实上NIO已经解决了上述BIO暴露线程等待时间过长和连接限制的问题了,服务器的并发客户端有了量的提升,不再受限于一个客户端一个线程来处理,而是一个线程可以维护多个连接。
但这依然不是一个完善的Reactor Pattern。
首先Reactor 是一种设计模式,好的模式应该是支持更好的扩展性,显然以上的并不支持,另外好的Reactor Pattern 必须有以下特点:
- 更少的资源利用,通常不需要一个客户端一个线程
- 更少的开销,更少的上下文切换以及locking
- 能够跟踪服务器状态
- 能够管理handler 对event的绑定
下面深入了解一下Reactor模式的Java NIO实现
Reactor模式
The reactor design pattern is an event handling pattern for handling service requests delivered concurrently to a service handler by one or more inputs. The service handler then demultiplexes the incoming requests and dispatches them synchronously to the associated request handlers.
Reactor一种基于事件驱动的设计模式,是一种并发编程思想。
Reactor设计模式用于处理由一个或多个客户端并发传递给应用程序的的服务请求。事件分发器将请求分离和调度给应用程序,同步的、有序的处理同时接收多个服务请求。
在Reactor Pattern 中,将会定义以下三种角色:
- Reactor:Reactor 在一个单独的线程中运行,负责监听和将I/O事件分派给对应的Handler。 类似于公司的电话接线员,它接听来自客户的电话并将线路转移到适当的联系人;
- Acceptor:处理客户端新连接,并分派请求到处理器链中。
- Handlers:处理程序执行 I/O 事件要完成的实际事件。Reactor 通过调度适当的处理程序来响应 I/O 事件,由Handlers处理程序非阻塞操作。类似于客户想要与之交谈的公司中的实际官员。
Reactor和Handlers构成了Reactor的核心组件。
根据 Reactor 的数量和处理资源池线程的数量不同,有 3 种典型的实现:
- 单 Reactor 单线程
- 单 Reactor 多线程
- 主从 Reactor 多线程
单Reactor单线程
方案说明:
- Reactor 对象通过 Select 监控客户端请求事件,收到事件后通过 Dispatch 进行分发;
- 如果是建立连接请求事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接完成后的后续业务处理;
- 如果不是建立连接事件,则 Reactor 会分发调用连接对应的 Handler 来响应,完成 Read→业务处理→Send 的业务流程。
优点:模型简单,没有多线程、进程通信、竞争的问题,全部都在一个线程中完成。
缺点:
性能问题,只有一个线程,无法完全发挥多核 CPU 的性能。Handler 在处理某个连接上的业务时,整个进程无法处理其他连接事件,很容易导致性能瓶颈。
可靠性问题,线程意外跑飞,或者进入死循环,会导致整个系统通信模块不可用,不能接收和处理外部消息,造成节点故障。
使用场景:客户端的数量有限,业务处理非常快速,比如 Redis,业务处理的时间复杂度 O(1)。
单 Reactor 多线程
方案说明:
- Reactor 对象通过 Select 监控客户端请求事件,收到事件后通过 Dispatch 进行分发;
- 如果是建立连接请求事件,则由 Acceptor 通过 Accept 处理连接请求,然后创建一个 Handler 对象处理连接完成后续的各种事件;
- 如果不是建立连接事件,则 Reactor 会分发调用连接对应的 Handler 来响应;
- Handler 只负责响应事件,不做具体业务处理,通过 Read 读取数据后,会分发给后面的 Worker 线程池进行业务处理;
- Worker 线程池会分配独立的线程完成真正的业务处理,如何将响应结果发给 Handler 进行处理;
- Handler 收到响应结果后通过 Send 将响应结果返回给 Client。
优点:可以充分利用多核 CPU 的处理能力。 缺点:多线程数据共享和访问比较复杂;Reactor 承担所有事件的监听和响应,在单线程中运行,高并发场景下容易成为性能瓶颈。
主从Reactor 多线程
方案说明:
- Reactor 主线程 MainReactor 对象通过 Select 监控建立连接事件,收到事件后通过 Acceptor 接收,处理建立连接事件;
- Acceptor 处理建立连接事件后,MainReactor 将连接分配 Reactor 子线程给 SubReactor 进行处理;
- SubReactor 将连接加入连接队列进行监听,并创建一个 Handler 用于处理各种连接事件;
- 当有新的事件发生时,SubReactor 会调用连接对应的 Handler 进行响应;
- Handler 通过 Read 读取数据后,会分发给后面的 Worker 线程池进行业务处理;
- Worker 线程池会分配独立的线程完成真正的业务处理,如何将响应结果发给 Handler 进行处理;
- Handler 收到响应结果后通过 Send 将响应结果返回给 Client。
优点:
主线程与副线程的数据交互简单职责明确:主线程只需要接收新连接,副线程完成后续的业务处理。
主线程与副线程的数据交互简单:主线程只需要把新连接传给副线程,副线程无需返回数据。
这种模型在许多项目中广泛使用,包括 Nginx 主从 Reactor 多进程模型,Memcached 主从多线程,Netty 主从多线程模型的支持。
总结与比较
用比喻来理解:(餐厅常常雇佣接待员负责迎接顾客,当顾客入坐后,侍应生专门为这张桌子服务)
- 单 Reactor 单线程,接待员和侍应生是同一个人,全程为顾客服务;
- 单 Reactor 多线程,1 个接待员,多个侍应生,接待员只负责接待;
- 主从 Reactor 多线程,多个接待员,多个侍应生。
Reactor 模式具有如下的优点:
- 1)响应快,不必为单个同步时间所阻塞,虽然 Reactor 本身依然是同步的;
- 2)编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销;
- 3)可扩展性,可以方便的通过增加 Reactor 实例个数来充分利用 CPU 资源;
- 4)可复用性,Reactor 模型本身与具体事件处理逻辑无关,具有很高的复用性。
Reactor简单代码实现:
/**
* 多work 连接事件Acceptor,处理连接事件
*/
class MultiWorkThreadAcceptor implements Runnable {
// cpu线程数相同多work线程
int workCount =Runtime.getRuntime().availableProcessors();
SubReactor[] workThreadHandlers = new SubReactor[workCount];
volatile int nextHandler = 0;
public MultiWorkThreadAcceptor() {
this.init();
}
public void init() {
nextHandler = 0;
for (int i = 0; i < workThreadHandlers.length; i++) {
try {
workThreadHandlers[i] = new SubReactor();
} catch (Exception e) {
}
}
}
@Override
public void run() {
try {
SocketChannel c = serverSocket.accept();
if (c != null) {// 注册读写
synchronized (c) {
// 顺序获取SubReactor,然后注册channel
SubReactor work = workThreadHandlers[nextHandler];
work.registerChannel(c);
nextHandler++;
if (nextHandler >= workThreadHandlers.length) {
nextHandler = 0;
}
}
}
} catch (Exception e) {
}
}
}
/**
* 多work线程处理读写业务逻辑
*/
class SubReactor implements Runnable {
final Selector mySelector;
//多线程处理业务逻辑
int workCount =Runtime.getRuntime().availableProcessors();
ExecutorService executorService = Executors.newFixedThreadPool(workCount);
public SubReactor() throws Exception {
// 每个SubReactor 一个selector
this.mySelector = SelectorProvider.provider().openSelector();
}
/**
* 注册chanel
*
* @param sc
* @throws Exception
*/
public void registerChannel(SocketChannel sc) throws Exception {
sc.register(mySelector, SelectionKey.OP_READ | SelectionKey.OP_CONNECT);
}
@Override
public void run() {
while (true) {
try {
//每个SubReactor 自己做事件分派处理读写事件
selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> iterator = keys.iterator();
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
iterator.remove();
if (key.isReadable()) {
read();
} else if (key.isWritable()) {
write();
}
}
} catch (Exception e) {
}
}
}
private void read() {
//任务异步处理
executorService.submit(() -> process());
}
private void write() {
//任务异步处理
executorService.submit(() -> process());
}
/**
* task 业务处理
*/
public void process() {
//do IO ,task,queue something
}
}
首先,MainReactor线程会创建与CPU线程数相同的work线程处理器,一边处理新建立的连接,一边顺序获取SubReactor并注册SocketChannel给SubReactor线程;而每个SubReactor都有一个selector支持多个socketChaneel的监听,线程异步处理的IO读写任务都放在线程池,交由主线程分发工作......(不想分析了,自己研究上面的方案流程吧)
补充阅读:
在Linux中也有IO多路复用模型的接口实现,这里简单比较一下常见的select模式和epoll模式的特点。
select模式:适合连接少且活跃的场景
1.单个进程监测的fd受限制,默认下是1024个文件描述符; 2.轮询式检查每个fd可读可写状态,IO效率会随着描述符集合增大而降低;(在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中醒来)
3.可以采用一个父进程专门accept,父进程均衡的分配多个子进程分别处理一部分的连接,子进程采用select模型监测自己负责的fd的可读可写。
epoll模式:适合高并发场景
1.支持进程打开的最大文件描述符,很好的解决了C10K问题; 2.epoll通过在等待的描述符上注册回调函数,当事件发生时,回调函数负责把发生的事件存储在就绪事件链表中,最后写到用户空间; 3.使用mmap加速内核与用户空间的消息传递
既然逐个排查所有文件句柄状态效率不高,很自然的,如果调用返回的时候只给应用提供发生了状态变化(很可能是数据 ready)的文件句柄,进行排查的效率就会提高。epoll 采用了这种设计,适用于大规模的应用场景。实验表明,当文件句柄数目超过 10 之后,epoll 性能将优于 select 和 poll;当文件句柄数目达到 10K 的时候,epoll 已经超过 select 和 poll 两个数量级。
本文转自 https://blog.csdn.net/guanguandaren/article/details/114911348,如有侵权,请联系删除。