内容来自于《Java高并发核心编程》卷1:NIO、Netty、Redis、ZooKeeper

《Java高并发核心编程》卷1:内容介绍

第1~4章从操作系统的底层原理开始,浅显易懂地揭秘高并发IO的底层原理,并介绍如何让单体Java应用支持百万级的高并发;从传统的阻塞式OIO开始,细致地解析Reactor高性能模式,介绍高性能网络开发的基础知识。这些非常底层的原理知识和基础知识非常重要,是开发过程中解决Java实际问题必不可少的。

第5~8章重点讲解Netty。目前Netty是高性能通信框架皇冠上当之无愧的明珠,是支撑其他众多著名的高并发、分布式、大数据框架底层的框架。这几章从Reactor模式入手,以“四两拨千斤”的方式为大家介绍Netty原理。同时,还将介绍如何通过Netty来解决网络编程中的重点难题,如Protobuf序列化问题、半包问题等。

第9~12章从TCP、HTTP入手,介绍客户端与服务端、服务端与服务端之间的高性能HTTP通信和WebSocket通信。这几章深入浅出地介绍TCP、HTTP、WebSocket三大常用的协议,以及如何基于Netty实现HTTP、WebSocket高性能通信。

第13章对ZooKeeper进行详细的介绍。除了全面地介绍Curator API之外,还从实战的角度出发介绍如何使用ZooKeeper设计分布式ID生成器,并对重要的SnowFlake算法进行详细的介绍。另外,还结合小故事以图文并茂的方式浅显易懂地介绍分布式锁的基本原理。

第14章从实战开发层面对Redis进行介绍,详细介绍Redis的5种数据类型、客户端操作指令、Jedis Java API。另外,还通过spring-data-redis来完成数据分布式缓存的实战案例,详尽地介绍Spring的缓存注解以及涉及的SpEL表达式语言。

第15章通过CrazyIM项目为大家介绍一个亿级流量的高并发IM系统模型,这个高并发架构的系统模型不仅仅限于IM系统,通过简单的调整和适配就可以应用于当前主流的Java后台系统。

《Java高并发核心编程》卷1:自序摘抄

目前学习技术的途径大致有三种:
(1)阅读博文;通过途径1(阅读博文)获得的知识,往往过于碎片化,难成体系。这种途径更适用于了解技术趋势、解决问题时进行资料查阅。
(2)观看视频;通过途径2(观看视频)获取知识时,需要耗费大量的时间,而且很多视频是填鸭式的知识灌输。所以,途径2更适用于初学者。对于有经验、能动性高的Java工程师来说,途径2的效率太低,需要大量的时间成本。
(3)阅读书籍。通过途径3(阅读书籍)获取知识有一个显著的优势:书籍能以很小的体积承载巨量知识,而且所承载的是系统化、层次化的知识。

上述三种途径各有优劣,鉴于Java高并发所涉及的核心技术比较多,包括Spring Cloud、Nginx、JUC、JMM、Kafak、ElasticSearch等,将已出版的、在写的、规划中的图书整合成一个高并发核心编程的图书系列,大致清单如下:

  • (1)《Netty、Redis、ZooKeeper高并发实战》:从操作系统底层的IO原理、Reactor高并发模式入手,介绍Java分布式、高并发通信原理,并指导大家进行高并发IM实战。此书已于2019年8月出版,由于内容略微单薄,特进行内容的完善和升级,升级版进行了书名的变更,新书名为《Java高并发核心编程 卷1:NIO、Netty、Redis、ZooKeeper》。
  • (2)《Spring Cloud、Nginx高并发核心编程》[1]:涵盖Spring Cloud、Nginx的核心原理和编程知识,并指导大家编写一个高并发的秒杀实战程序。此书已于2020年10月出版。
  • (3)《Java高并发核心编程 卷1:NIO、Netty、Redis、ZooKeeper》:介绍Reactor模式、Netty、ZooKeeper、Redis、TCP、HTTP、ebSocket、NIO等Java高性能通信的核心原理和编程知识,并指导大家编写一个高并发的分布式IM实战程序——CrazyIM。此卷即为本书,作为《Netty、Redis、ZooKeeper高并发实战》一书的升级版,对上一版本的内容进行了大量的优化和扩充。和上一版本相比,此卷知识量更大,所以学习价值也更高。
  • (4)《Java高并发核心编程 卷2:多线程、锁、JMM、JUC、高并发设计模式》:聚焦Java高并发基础知识,内容包括多线程、线程池、JMM内存模型、JUC并发包、AQS同步器、高并发容器类、高并发设计模式等。
  • (5)《Java高并发核心编程 卷3》(最终书名待定):覆盖Kafka、RocketMQ、ElasticSearch等重要的高并发中间件的核心原理和编程知识。

《Java高并发核心编程》卷1:第1章高并发必备技能摘抄

随着5G应用、多终端应用、物联网应用、工业互联应用、大数据应用、人工智能应用的飞速发展,高并发开发时代已然到来,能够驾驭高并发和大数据的物联网架构师、高并发架构师、大数据架构师、Java高级工程师在人才市场也随之成为“香饽饽”,Netty、Redis、ZooKeeper、高性能HTTP服务器组件(如Nginx)、高并发Java组件(JUC包)等则成为广大Java工程师所必须掌握的开发技能。

Netty高并发异步IO框架介绍

Netty是JBOSS提供的一个Java开源框架,是基于NIO的客户端/服务器编程框架,既能快速开发高并发、高可用、高可靠的网络服务器程序,也能开发高可用、高可靠的客户端程序。

的NIO是指非阻塞输入输出(Non-Blocking IO),也称非阻塞IO

Netty用例

消息中间件:Kafka、RocketMQ
开源搜索引擎:ElasticSearch
Hadoop RPC框架:Hadoop的Avro
微服务框架:Dubbo

Netty机制及特点:

Netty之所以受青睐,是因为它提供了异步的、事件驱动的网络应用程序框架和工具。作为一个异步框架,Netty的所有IO操作都是异步非阻塞的,通过Future-Listener机制,用户可以方便地主动获取或者通过通知机制获得IO操作结果。
与JDK原生NIO相比,Netty提供了十分简单易用的API,因而非常适合网络编程。Netty主要是基于NIO来实现的,在Netty中也可以提供阻塞IO的服务。
Netty之所以这么火,与它的巨大优点是密不可分的,大致可以总结如下:

  • API使用简单,开发门槛低。
  • 功能强大,预置了多种编解码功能,支持多种主流协议。
  • 定制能力强,可以通过ChannelHandler对通信框架进行灵活扩展。
  • 性能高,与其他业界主流的NIO框架相比,Netty的综合性能最优。
  • 成熟、稳定,Netty修复了在JDK NIO中所有已发现的Bug,业务开发人员不需要再为NIO的Bug而烦恼。
  • 社区活跃,版本迭代周期短,发现的Bug可以被及时修复。

Redis高并发缓存利器

Redis介绍

Redis是Remote Dictionary Server(远程字典服务器)的缩写,最初是作为数据库的工具来使用的,是目前使用广泛、高效的开源缓存。Redis使用C语言开发,将数据保存在内存中,可以看成是一款纯内存的数据库,所以它的数据存取速度非常快。一些经常用并且创建时间较长的内容可以缓存到Redis中,而应用程序能以极快的速度存取这些内容。举例来说,如果某个页面会经常被访问到,而创建页面时需要多次访问数据库,造成网页内容的生成时间较长,那么就可以使用Redis将这个页面缓存起来,从而减轻网站的负担,降低网站的延迟。

Redis特点及场景

(1)速度快。不需要等待磁盘的IO,而是在内存之间进行数据存储和查询,速度非常快。当然,缓存的数据总量不能太大,因为受到物理内存空间大小的限制。
(2)丰富的数据结构,有String、List、Hash、Set、SortedSet五种类型。
(3)单线程,避免了线程切换和锁机制的性能消耗。
(4)可持久化。支持RDB与AOF两种方式,将内存中的数据写入外部的物理存储设备。
(5)支持发布/订阅。
(6)支持Lua脚本。
(7)支持分布式锁。在分布式系统中,不同的节点需要访问同一个资源时,往往需要通过互斥机制来防止彼此干扰,并且保证数据的一致性。在这种情况下,需要用到分布式锁。分布式锁和Java的锁用于实现不同线程之间的同步访问,原理上是类似的。
(8)支持原子操作和事务。Redis事务是一组命令的集合。一个事务中的命令要么都执行,要么都不执行。如果命令在运行期间出现错误,不会自动回滚。
(9)支持主-从(Master-Slave)复制与高可用(Redis Sentinel)集群(3.0版本以上)。
(10)支持管道。Redis管道是指客户端可以将多个命令一次性发送到服务器,然后由服务器一次性返回所有结果。管道技术的优点是,在批量执行命令的应用场景中,可以大大减少网络传输的开销,提高性能。

Redis通过键-值对(Key-Value Pair)的形式来存储数据,类似于Java中的Map(映射)。Redis的Key(键)只能是String(字符串)类型,Value(值)则可以是String类型、Map类型、List(列表)类型、Set(集合)类型、SortedSet(有序集合)类型。

Redis的主要应用场景是缓存(数据查询、短连接、新闻内容、商品内容等)、分布式会话(Session)、聊天室的在线好友列表、任务队列(秒杀、抢购、12306等)、应用排行榜、访问统计、数据过期处理(可以精确到毫秒)。

ZooKeeper高并发分布式利器

单体应用在达到性能瓶颈之后,就必须靠分布式集群解决高并发问题,而集群的分布式架构和集群节点之间的交互一定少不了可靠的分布式协调工具,ZooKeeper就是目前极为重要的分布式协调工具。
雅虎的开发人员就试图开发一个通用的无单点问题的分布式协调框架。此框架的命名过程也是非常有趣的。在项目初期给这个项目命名时,准备和很多项目一样,按照雅虎公司的惯例使用动物的名字来命名(例如著名的Pig项目)。在探讨取什么名字的时候,研究院的首席科学家Raghu Ramakrishnan开玩笑说:“再这样下去,我们这儿就变成动物园了。”此话一出,大家纷纷表示新框架就叫动物园管理员吧,于是ZooKeeper(动物园管理员)诞生了。而ZooKeeper正好是用来协调分布式环境的不同节点的,形象地说,可以理解为协调各个以动物命名的分布式组件,所以ZooKeeper也就“名副其实”了。

ZooKeeper的特点及优势

ZooKeeper的核心优势是实现了分布式环境的数据一致性,简单地说:每时每刻我们访问ZooKeeper的树结构时,不同的节点返回的数据都是一致的。也就是说,对ZooKeeper进行数据访问时,无论是什么时间,都不会引起“脏读”“幻读”“不可重复读”问题。
“脏读”“幻读”“不可重复读”是数据库事务的概念,当然,ZooKeeper也可以被理解为一种简单的分布式数据库

  • “脏读”是指一个事务中访问到了另外一个事务未提交的数据。
  • “不可重复读”是指在一个事务内根据同一个条件对数据进行多次查询,但是结果却不一致,原因是其他事务对该数据进行了修改。
  • “幻读”是指当两个完全相同的查询执行时,第二次查询所返回的结果集和第一次查询所返回的结果集不相同,原因也是另外一个事务新增、删除了第一个事务结果集中的数据。

“不可重复读”和“幻读”的区别是:“不可重复读”关注的重点在于记录的更新操作,对同样的记录,再次读取后发现返回的数据值不一样了;“幻读”关注的重点在于记录新增或者删除操作(数据条数发生了变化),同样的条件第一次和第二次查询出来的记录数不一样。

Nginx/RPC高并发的通信/调用技术

QPS在十万级的Web应用架构大致图

Nginx是一个强大的Web服务器软件,用于处理高并发的HTTP请求和作为反向代理服务器进行负载均衡,具有高性能、轻量级、内存消耗少、强大的负载均衡能力等优势。
alt text
对于十万级流量的系统应用而言,其架构一般可以分为三层:服务层、接入层、客户端层。
服务层一般执行的是Java应用程序,可以细分为传统的单体应用和目前主流的Spring Cloud分布式应用。传统的单体Java应用执行在Tomcat服务器上,目前主流的Spring Cloud微服务应用执行在内嵌的Tomcat服务器上。
接入层主要完成鉴权、限流、反向代理和负载均衡等功能。由于在静态资源、登录验证等简单逻辑的处理性能上Nginx和Tomcat不可同日而语(一般在10倍以上),因此接入层基本上都是使用Nginx + Lua扩展作为接入服务器。另外,为了保证Nginx接入服务器的高可用,会搭建有冗余的接入服务器,然后使用KeepAlived中间件进行高可用监控管理并且虚拟出外部IP,供外部访问。

QPS在百千万级的Web应用架构大致图

对于十万级QPS流量的Web应用,如果流量增长到百万级,可以对接入层Nginx进行横向扩展,甚至可以引入LVS进行负载均衡。
alt text
对于千万级QPS的Web应用,除了服务层的独立Tomcat或者Spring Cloud微服务节点需要进行不断的横向扩展之外,还需要进行以下两大增强:
(1)引入LVS负载均衡层,进行请求分发和接入层的负载均衡。
(2)引入DNS服务器的负载均衡,可以在域名下面添加多个IP,由DNS服务器进行多个IP之间的负载均衡,甚至可以按照就近原则为用户返回最近的服务器IP地址。
总之,如何抵抗十万级甚至千万级QPS访问洪峰,涉及大量的开发知识、运维知识。对于开发人员来说,并不一定需要掌握太多的操作系统层面(如LVS)运维知识,主要原因是术业有专攻,一般会有专业的运维人员去解决系统的运行问题。但是对千万级QPS系统中所涉及的高并发方面的开发知识,则是开发人员必须掌握的。
在十万级甚至千万级QPS的Web应用架构中,如何提高平台内部的接入层Nginx和服务层Tomcat(或者其他Java容器)之间的HTTP通信能力,涉及高并发HTTP通信这个核心技术问题,这是本书后面章节会从TCP、HTTP层面出发所重点剖析和解读的问题。

微服务的高并发RPC技术Feign

分布式微服务架构目前已经成为Java应用的主流架构,在接入层同样会与Nginx结合,所以常常都是Nginx + Spring Cloud架构。
alt text
微服务Provider实例之间的RPC在Spring Cloud全家桶技术体系中是由Feign基于Ribbon完成的,并由Hystrix组件提供RPC的熔断、回退、限流等保护。

《Java高并发核心编程》卷1:第2章高并发IO底层原理

IO读写的基本原理

操作系统的内核空间与用户空间

为了了避免用户进程直接操作内核,保证内核安全,操作系统将内存(虚拟内存)划分为两部分:一部分是内核空间(Kernel Space),另一部分是用户空间(User-Space)。在Linux系统中,内核模块运行在内核空间,对应的进程处于内核态;用户程序运行在用户空间,对应的进程处于用户态。

操作系统的核心是内核程序,它独立于普通的应用程序,既有权限访问受保护的内核空间,也有权限访问硬件设备,而普通的应用程序并没有这样的权限。

内核空间总是驻留在内存中,是为操作系统的内核保留的。应用程序不允许直接在内核空间区域进行读写,也不允许直接调用内核代码定义的函数。每个应用程序进程都有一个单独的用户空间,对应的进程处于用户态,用户态进程不能访问内核空间中的数据,也不能直接调用内核函数,因此需要将进程切换到内核态才能进行系统调用。

操作系统的内核态与用户态

内核态进程可以执行任意命令,调用系统的一切资源,而用户态进程只能执行简单的运算,不能直接调用系统资源,那么问题来了:
用户态进程如何执行系统调用呢?答案是:用户态进程必须通过系统调用(System Call)向内核发出指令,完成调用系统资源之类的操作

操作系统的内核缓冲区与用户进程缓冲区

缓冲区的目的是减少与设备之间的频繁物理交换操作系统会对内核缓冲区进行监控,等待缓冲区达到一定数量的时候,再进行IO设备的中断处理,集中执行物理设备的实际IO操作,通过这种机制来提升系统的性能。至于具体什么时候执行系统中断(包括读中断、写中断)则由操作系统的内核来决定,应用程序不需要关心。

用户程序进行IO的读写依赖于底层的IO读写,基本上会用到底层的read和write两大系统调用。虽然在不同的操作系统中read和write两大系统调用的名称和形式可能不完全一样,但是它们的基本功能是一样的。
操作系统层面的read系统调用并不是直接从物理设备把数据读取到应用的内存中,write系统调用也不是直接把数据写入物理设备。上层应用无论是调用操作系统的read还是调用操作系统的write,都会涉及缓冲区。具体来说,上层应用通过操作系统的read系统调用把数据从内核缓冲区复制到应用程序的进程缓冲区,通过操作系统的write系统调用把数据从应用程序的进程缓冲区复制到操作系统的内核缓冲区
alt text

简单来说,应用程序的IO操作实际上不是物理设备级别的读写,而是缓存的复制。read和write两大系统调用都不负责数据在内核缓冲区和物理设备(如磁盘、网卡等)之间的交换。这个底层的读写交换操作是由操作系统内核(Kernel)来完成的。所以,在应用程序中,无论是对socket的IO操作还是对文件的IO操作,都属于上层应用的开发,它们在输入(Input)和输出(Output)维度上的执行流程是类似的,都是在内核缓冲区和进程缓冲区之间进行数据交换

内核缓冲区与应用缓冲区在数量上也不同。在Linux系统中,操作系统内核只有一个内核缓冲区。每个用户程序(进程)都有自己独立的缓冲区,叫作用户缓冲区或者进程缓冲区。在大多数情况下,Linux系统中用户程序的IO读写程序并没有进行实际的IO操作,而是在用户缓冲区和内核缓冲区之间直接进行数据的交换。

系统调用Read/write/socket示例

以read系统调用为例,看一下一个完整输入流程的两个阶段:
应用程序等待数据准备好。
从内核缓冲区向用户缓冲区复制数据。
如果是读取一个socket(套接字),那么以上两个阶段的具体处理流程如下:
第一个阶段,应用程序等待数据通过网络到达网卡,当所等待的分组到达时,数据被操作系统复制到内核缓冲区中。这个工作由操作系统自动完成,用户程序无感知。
第二个阶段,内核将数据从内核缓冲区复制到应用的用户缓冲区。
再具体一点,如果是在Java客户端和服务端之间完成一次socket请求和响应(包括read和write)的数据交换,其完整的流程如下:

  • 客户端发送请求:Java客户端程序通过write系统调用将数据复制到内核缓冲区,Linux将内核缓冲区的请求数据通过客户端机器的网卡发送出去。在服务端,这份请求数据会从接收网卡中读取到服务端机器的内核缓冲区。
  • 服务端获取请求:Java服务端程序通过read系统调用从Linux内核缓冲区读取数据,再送入Java进程缓冲区。
  • 服务端业务处理:Java服务器在自己的用户空间中完成客户端的请求所对应的业务处理。
  • 服务端返回数据:Java服务器完成处理后,构建好的响应数据将从用户缓冲区写入内核缓冲区,这里用到的是write系统调用,操作系统会负责将内核缓冲区的数据发送出去。
  • 发送给客户端:服务端Linux系统将内核缓冲区中的数据写入网卡,网卡通过底层的通信协议将数据发送给目标客户端。

四种主要的IO模型

常见的IO模型有四种。

同步阻塞IO

阻塞与非阻塞
阻塞IO指的是需要内核IO操作彻底完成后才返回到用户空间执行用户程序的操作指令。“阻塞”指的是用户程序(发起IO请求的进程或者线程)的执行状态。可以说传统的IO模型都是阻塞IO模型,并且在Java中默认创建的socket都属于阻塞IO模型

同步与异步
简单来说,可以将同步与异步看成发起IO请求的两种方式。
同步IO是指用户空间(进程或者线程)是主动发起IO请求的一方,系统内核是被动接收方
异步IO则反过来,系统内核是主动发起IO请求的一方,用户空间是被动接收方。

同步阻塞IO(Blocking IO)指的是用户空间(或者线程)主动发起,需要等待内核IO操作彻底完成后才返回到用户空间的IO操作。在IO操作过程中,发起IO请求的用户进程(或者线程)处于阻塞状态。
alt text
举个例子,在Java中发起一个socket的read操作的系统调用,流程大致如下:
(1)从Java进行IO读后发起read系统调用开始,用户线程(或者线程)就进入阻塞状态。
(2)当系统内核收到read系统调用后就开始准备数据。一开始,数据可能还没有到达内核缓冲区(例如,还没有收到一个完整的socket数据包),这时内核就要等待。
(3)内核一直等到完整的数据到达,就会将数据从内核缓冲区复制到用户缓冲区(用户空间的内存),然后内核返回结果(例如返回复制到用户缓冲区中的字节数)。
(4)直到内核返回后用户线程才会解除阻塞的状态,重新运行起来。
阻塞IO的特点是在内核执行IO操作的两个阶段,发起IO请求的用户进程(或者线程)被阻塞了。
阻塞IO的优点是:应用程序开发非常简单;在阻塞等待数据期间,用户线程挂起,基本不会占用CPU资源。
阻塞IO的缺点是:一般情况下会为每个连接配备一个独立的线程,一个线程维护一个连接的IO操作。在并发量小的情况下,这样做没有什么问题。在高并发的应用场景下,阻塞IO模型需要大量的线程来维护大量的网络连接,内存、线程切换开销会非常巨大,性能很低,基本上是不可用的。

同步非阻塞IO

非阻塞IO(Non-Blocking IO,NIO)指的是用户空间的程序不需要等待内核IO操作彻底完成,可以立即返回用户空间去执行后续的指令,即发起IO请求的用户进程(或者线程)处于非阻塞状态,与此同时,内核会立即返回给用户一个IO状态值。

阻塞和非阻塞的区别是什么呢?
阻塞是指用户进程(或者线程)一直在等待,而不能做别的事情;
非阻塞是指用户进程(或者线程)获得内核返回的状态值就返回自己的空间,可以去做别的事情。
在Java中,非阻塞IO的socket被设置为NONBLOCK模式。

同步非阻塞IO指的是用户进程主动发起,不需要等待内核IO操作彻底完成就能立即返回用户空间的IO操作。在IO操作过程中,发起IO请求的用户进程(或者线程)处于非阻塞状态。

同步非阻塞IO也可以简称为NIO,但是它不是Java编程中的NIO。Java编程中的NIO(New IO)类库组件所归属的不是基础IO模型中的NIO模型,而是IO多路复用模型。
alt text
举个例子,发起一个非阻塞socket的read操作的系统调用,流程如下:
(1)在内核数据没有准备好的阶段,用户线程发起IO请求时立即返回。所以,为了读取最终的数据,用户进程(或者线程)需要不断地发起IO系统调用。
(2)内核数据到达后,用户进程(或者线程)发起系统调用,用户进程(或者线程)阻塞。内核开始复制数据,它会将数据从内核缓冲区复制到用户缓冲区,然后内核返回结果(例如返回复制到的用户缓冲区的字节数)。
(3)用户进程(或者线程)读到数据后,才会解除阻塞状态,重新运行起来。也就是说,用户空间需要经过多次尝试才能保证最终真正读到数据,而后继续执行。
同步非阻塞IO的特点是应用程序的线程需要不断地进行IO系统调用,轮询数据是否已经准备好,如果没有准备好就继续轮询,直到完成IO系统调用为止。
同步非阻塞IO的优点是每次发起的IO系统调用在内核等待数据过程中可以立即返回,用户线程不会阻塞,实时性较好。
同步非阻塞IO的缺点是不断地轮询内核,这将占用大量的CPU时间,效率低下。
总体来说,在高并发应用场景中,同步非阻塞IO是性能很低的,也是基本不可用的,一般Web服务器都不使用这种IO模型。在Java的实际开发中,不会涉及这种IO模型,但是此模型还是有价值的,其作用在于其他IO模型中可以使用非阻塞IO模型作为基础,以实现其高性能。

IO多路复用-异步阻塞IO

如何避免同步非阻塞IO模型中轮询等待的问题呢?答案是采用IO多路复用模型。
为了提高性能,操作系统引入了一种新的系统调用,专门用于查询IO文件描述符(含socket连接)的就绪状态。在Linux系统中,新的系统调用为select/epoll系统调用。通过该系统调用,一个用户进程(或者线程)可以监视多个文件描述符,一旦某个描述符就绪(一般是内核缓冲区可读/可写),内核就能够将文件描述符的就绪状态返回给用户进程(或者线程),用户空间可以根据文件描述符的就绪状态进行相应的IO系统调用。
IO多路复用(IO Multiplexing)属于一种经典的Reactor模式实现,有时也称为异步阻塞IO,Java中的Selector属于这种模型。
目前支持IO多路复用的系统调用有select、epoll等。几乎所有的操作系统都支持select系统调用,它具有良好的跨平台特性。epoll是在Linux 2.6内核中提出的,是select系统调用的Linux增强版本。在IO多路复用模型中通过select/epoll系统调用,单个应用程序的线程可以不断地轮询成百上千的socket连接的就绪状态,当某个或者某些socket网络连接有IO就绪状态时就返回这些就绪的状态(或者说就绪事件)。

举个例子来说明IO多路复用模型的流程。发起一个多路复用IO的read操作的系统调用,流程如下:
(1)选择器注册。首先,将需要read操作的目标文件描述符(socket连接)提前注册到Linux的select/epoll选择器中,在Java中所对应的选择器类是Selector类。然后,开启整个IO多路复用模型的轮询流程。
(2)就绪状态的轮询。通过选择器的查询方法,查询所有提前注册过的目标文件描述符(socket连接)的IO就绪状态。通过查询的系统调用,内核会返回一个就绪的socket列表。当任何一个注册过的socket中的数据准备好或者就绪了就说明内核缓冲区有数据了,内核将该socket加入就绪的列表中,并且返回就绪事件。
(3)用户线程获得了就绪状态的列表后,根据其中的socket连接发起read系统调用,用户线程阻塞。内核开始复制数据,将数据从内核缓冲区复制到用户缓冲区。
(4)复制完成后,内核返回结果,用户线程才会解除阻塞的状态,用户线程读取到了数据,继续执行。
IO多路复用模型的read系统调用流程如图所示
alt text

在用户进程进行IO就绪事件的轮询时,需要调用选择器的select查询方法,发起查询的用户进程或者线程是阻塞的。当然,如果使用了查询方法的非阻塞的重载版本,发起查询的用户进程或者线程也不会阻塞,重载版本会立即返回。

IO多路复用模型的特点是:IO多路复用模型的IO涉及两种系统调用,一种是IO操作的系统调用,另一种是select/epoll就绪查询系统调用。IO多路复用模型建立在操作系统的基础设施之上,即操作系统的内核必须能够提供多路分离的系统调用select/epoll。和NIO模型相似,多路复用IO也需要轮询。负责select/epoll状态查询调用的线程,需要不断地进行select/epoll轮询,以找出达到IO操作就绪的socket连接。
IO多路复用模型与同步非阻塞IO模型是有密切关系的,具体来说,注册在选择器上的每一个可以查询的socket连接一般都设置成同步非阻塞模型,只是这一点对于用户程序而言是无感知的。
IO多路复用模型的优点是一个选择器查询线程可以同时处理成千上万的网络连接,所以用户程序不必创建大量的线程,也不必维护这些线程,从而大大减少了系统的开销。与一个线程维护一个连接的阻塞IO模式相比,这一点是IO多路复用模型的最大优势。
通过JDK的源码可以看出,Java语言的NIO组件在Linux系统上是使用epoll系统调用实现的。所以,Java语言的NIO组件所使用的就是IO多路复用模型。
IO多路复用模型的缺点是,本质上select/epoll系统调用是阻塞式的,属于同步IO,需要在读写事件就绪后由系统调用本身负责读写,也就是说这个读写过程是阻塞的。要彻底地解除线程的阻塞,就必须使用异步IO模型

异步IO

异步IO(Asynchronous IO,AIO)指的是用户空间的线程变成被动接收者,而内核空间成为主动调用者。在异步IO模型中,当用户线程收到通知时,数据已经被内核读取完毕并放在了用户缓冲区内,内核在IO完成后通知用户线程直接使用即可。
异步IO类似于Java中典型的回调模式,用户进程(或者线程)向内核空间注册了各种IO事件的回调函数,由内核去主动调用。
异步IO模型的基本流程是:用户线程通过系统调用向内核注册某个IO操作。内核在整个IO操作(包括数据准备、数据复制)完成后通知用户程序,用户执行后续的业务操作。
在异步IO模型中,在整个内核的数据处理过程(包括内核将数据从网络物理设备(网卡)读取到内核缓冲区、将内核缓冲区的数据复制到用户缓冲区)中,用户程序都不需要阻塞。
异步IO模型的流程如图所示。
alt text
举个例子,发起一个异步IO的read操作的系统调用,流程如下:
(1)当用户线程发起了read系统调用后,立刻就可以去做其他的事,用户线程不阻塞。
(2)内核开始IO的第一个阶段:准备数据。准备好数据,内核就会将数据从内核缓冲区复制到用户缓冲区。
(3)内核会给用户线程发送一个信号(Signal),或者回调用户线程注册的回调方法,告诉用户线程read系统调用已经完成,数据已经读入用户缓冲区。
(4)用户线程读取用户缓冲区的数据,完成后续的业务操作。
异步IO模型的特点是在内核等待数据和复制数据的两个阶段,用户线程都不是阻塞的。用户线程需要接收内核的IO操作完成的事件,或者用户线程需要注册一个IO操作完成的回调函数。正因为如此,异步IO有的时候也被称为信号驱动IO。
异步IO模型的缺点是应用程序仅需要进行事件的注册与接收,其余的工作都留给了操作系统,也就是说需要底层内核提供支持。
理论上来说,异步IO是真正的异步输入输出,它的吞吐量高于IO多路复用模型的吞吐量。就目前而言,Windows系统下通过IOCP实现了真正的异步IO。在Linux系统下,异步IO模型在2.6版本才引入,JDK对它的支持目前并不完善,因此异步IO在性能上没有明显的优势。
大多数高并发服务端的程序都是基于Linux系统的。因而,目前这类高并发网络应用程序的开发大多采用IO多路复用模型。大名鼎鼎的Netty框架使用的就是IO多路复用模型,而不是异步IO模型

Linux下的高并发配置限制

Linux下的高并发配置限制就是Linux操作系统中文件句柄数的限制。在生产环境Linux系统中,基本上都需要解除文件句柄数的限制。原因是Linux系统的默认值为1024,也就是说,一个进程最多可以接受1024个socket连接,这是远远不够的。
文件句柄也叫文件描述符
在Linux系统中,文件可分为普通文件、目录文件、链接文件和设备文件。文件描述符(File Descriptor)是内核为了高效管理已被打开的文件所创建的索引,是一个非负整数(通常是小整数),用于指代被打开的文件。所有的IO系统调用(包括socket的读写调用)都是通过文件描述符完成的。

在Linux下,通过调用ulimit命令可以看到一个进程能够打开的最大文件句柄数量。这个命令的具体使用方法是:

1
ulimit -n

ulimit命令是用来显示和修改当前用户进程的基础限制命令,-n选项用于引用或设置当前的文件句柄数量的限制值,Linux系统的默认值为1024。
理论上,1024个文件描述符对绝大多数应用(例如Apache、桌面应用程序)来说已经足够,对于一些用户基数很大的高并发应用则是远远不够的。一个高并发的应用面临的并发连接数往往是十万级、百万级、千万级,甚至像腾讯QQ一样的上亿级。
文件句柄数不够,会导致什么后果呢?当单个进程打开的文件句柄数量超过了系统配置的上限值时会发出“Socket/File:Can’t open so many files”的错误提示。
所以,对于高并发、高负载的应用,必须调整这个系统参数,以适应并发处理大量连接的应用场景。可以通过ulimit来设置这两个参数,方法如下:
ulimit -n 1000000
在上面的命令中,n的值设置越大,可以打开的文件句柄数量越大。建议以root用户来执行此命令。
使用ulimit命令有一个缺陷,即该命令只能修改当前用户环境的一些基础限制,仅在当前用户环境有效。也就是说,在当前的终端工具连接当前shell期间,修改是有效的,一旦断开用户会话,或者说用户退出Linux,它的数值就又变回系统默认的1024了。并且,系统重启后,句柄数量会恢复为默认值。
ulimit命令只能用于临时修改,如果想永久地把最大文件描述符数量值保存下来,可以编辑/etc/rc.local开机启动文件,在文件中添加如下内容:

1
ulimit -SHn 1000000

以上示例增加了-S和-H两个命令选项。选项-S表示软性极限值,H表示硬性极限值。硬性极限值是实际的限制,就是最大可以是100万,不能再多了。软性极限值则是系统发出警告(Warning)的极限值,超过这个极限值,内核会发出警告。
普通用户通过ulimit命令可将软性极限值更改到硬性极限值的最大设置值。如果要更改硬性极限值,必须拥有root用户权限。
要彻底解除Linux系统的最大文件打开数量的限制,可以通过编辑Linux的极限配置文件/etc/security/limits.conf来做到。修改此文件,加入如下内容:

1
2
soft nofile 1000000 
hard nofile 1000000

soft nofile表示软性极限,hard nofile表示硬性极限。
举个实际例子,在使用和安装目前非常流行的分布式搜索引擎ElasticSearch时,必须修改这个文件,以增加最大的文件描述符的极限值。当然,在生产环境运行Netty时,也需要修改/etc/security/limits.conf文件来增加文件描述符数量的极限值。

《Java高并发核心编程》卷1:第3章Java NIO核心详解

Java NIO组件介绍

高性能的Java通信绝对离不开Java NIO组件,现在主流的技术框架或中间件服务器都使用了Java NIO组件,譬如Tomcat、Jetty、Netty
在1.4版本之前,Java IO类库是阻塞IO;
从1.4版本开始,引进了新的异步IO库,被称为Java New IO类库,简称为Java NIO。New IO类库的目标就是要让Java支持非阻塞IO,基于此,更多的人喜欢称Java NIO为非阻塞IO(Non-Blocking IO),称“老的”阻塞式Java IO为OIO(Old IO)。总体上说,NIO弥补了原来面向流的OIO同步阻塞的不
足,为标准Java代码提供了高速、面向缓冲区的IO。

Java NIO类库包含以下三个核心组件
Channel(通道)
Buffer(缓冲区)
Selector(选择器)
理解了第2章的四种IO模型,大家一眼就能识别出来Java NIO属于第三种模型——IO多路复用模型。只不过,Java NIO组件提供了统一的API,为大家屏蔽了底层的操作系统的差异。

NIO和OIO的对比

在Java中,NIO和OIO的区别主要体现在三个方面:
(1)OIO是面向流(Stream Oriented)的,NIO是面向缓冲区(Buffer Oriented)的。
在一般的OIO操作中,面向字节流或字符流的IO操作总是以流式的方式顺序地从一个流(Stream)中读取一个或多个字节,因此,我们不能随意改变读取指针的位置。在NIO操作中则不同,NIO中引入了Channel和Buffer的概念。面向缓冲区的读取和写入只需要从通道读取数据到缓冲区中,或将数据从缓冲区写入通道中。NIO不像OIO那样是顺序操作,它可以随意读取Buffer中任意位置的数据。
(2)OIO的操作是阻塞的,而NIO的操作是非阻塞的。即NIO使用了通道和通道的多路复用技术。
(3)OIO没有选择器(Selector)的概念,而NIO有选择器的概念。NIO的实现是基于底层选择器的系统调用的,所以NIO需要底层操作系统提供支持;而OIO不需要用到选择器。

Java NIO类库之通道

在OIO中,同一个网络连接会关联到两个流:一个是输入流(Input Stream),另一个是输出流(Output Stream)。Java应用程序通过这两个流不断地进行输入和输出的操作。
在NIO中,一个网络连接使用一个通道表示,所有NIO的IO操作都是通过连接通道完成的。一个通道类似于OIO中两个流的结合体,既可以从通道读取数据,也可以向通道写入数据。

Java NIO类库之选择器

在Java应用层面,如何实现对多个文件描述符的监视呢?需要用到一个非常重要的Java NIO组件——选择器。选择器可以理解为一个IO事件的监听与查询器。通过选择器,一个线程可以查询多个通道的IO事件的就绪状态。
从编程实现维度来说,IO多路复用编程的第一步是把通道注册到选择器中,第二步是通过选择器所提供的事件查询(select)方法来查询这些注册的通道是否有已经就绪的IO事件(例如可读、可写、网络连接完成等)。
由于一个选择器只需要一个线程进行监控,因此我们可以很简单地使用一个线程,通过选择器去管理多个连接通道。
与OIO相比,NIO使用选择器的最大优势是系统开销小。系统不必为每一个网络连接(文件描述符)创建进程/线程,从而大大减少了系统的开销。总之,一个线程负责多个连接通道的IO处理是非常高效的,这种高效来自Java的选择器组件Selector及其底层的操作系统IO多路复用技术的支持。

Java NIO类库之缓冲区

应用程序与通道的交互主要是进行数据的读取和写入。为了完成NIO的非阻塞读写操作,NIO为大家准备了第三个重要的组件—— Buffer。所谓通道的读取,就是将数据从通道读取到缓冲区中;所谓通道的写入,就是将数据从缓冲区写入通道中。缓冲区的使用是面向流进行读写操作的OIO所没有的,也是NIO非阻塞的重要前提和基础之一

详解NIO Buffer类

NIO的Buffer本质上是一个内存块,既可以写入数据,也可以从中读取数据。Java NIO中代表缓冲区的Buffer类是一个抽象类,位于java.nio包中。
NIO的Buffer内部是一个内存块(数组),与普通的内存块(Java数组)不同的是:NIO Buffer对象提供了一组比较有效的方法,用来进行写入和读取的交替访问。

NIO Buffer类的重要属性

Buffer类是一个抽象类,对应于Java的主要数据类型。在NIO中,有8种缓冲区类,分别是ByteBuffer、CharBuffer、DoubleBuffer、FloatBuffer、IntBuffer、LongBuffer、ShortBuffer、MappedByteBuffer。前7种Buffer类型覆盖了能在IO中传输的所有Java基本数据类型,第8种类型是一种专门用于内存映射的ByteBuffer类型。不同的Buffer子类可以操作的数据类型能够通过名称进行判断,比如IntBuffer只能操作Integer类型的对象。实际上,使用最多的是ByteBuffer(二进制字节缓冲区)类型

为了记录读写的状态和位置,Buffer类额外提供了一些重要的属性,其中有三个重要的成员属性:

  • capacity(容量)属性:Buffer类的capacity属性表示内部容量的大小。一旦写入的对象数量超过了capacity,缓冲区就满了,不能再写入了。
  • position(读写位置)属性:Buffer类的position属性表示当前的位置。position属性的值与缓冲区的读写模式有关。
  • limit(读写的限制)属性:Buffer类的limit属性表示可以写入或者读取的数据最大上限,其属性值的具体含义也与缓冲区的读写模式有关。
    除了以上capacity、position、limit三个重要的成员属性之外,Buffer还有一个比较重要的标记属性:mark(标记)属性。该属性的大致作用为:在缓冲区操作过程当中,可以将当前的position值临时存入mark属性中;需要的时候,再从mark中取出暂存的标记值,恢复到position属性中,重新从position位置开始处理。
    alt text

NIO Buffer类的重要方法

allocate()、put()、get()、flip()、rewind()、mark()、reset()、clear()
alt text

使用Buffer类的基本步骤

总体来说,使用Java NIO Buffer类的基本步骤如下:
(1)使用创建子类实例对象的allocate()方法创建一个Buffer类的实例对象。
(2)调用put()方法将数据写入缓冲区中。
(3)写入完成后,在开始读取数据前调用Buffer.flip()方法,将缓冲区转换为读模式。
(4)调用get()方法,可以从缓冲区中读取数据。
(5)读取完成后,调用Buffer.clear()方法或Buffer.compact()方法,将缓冲区转换为写模式,可以继续写入。

详解NIO Channel类

着重介绍Java NIO中最为重要的四种Channel:FileChannel、SocketChannel、ServerSocketChannel、DatagramChannel。
这四种通道涵盖了文件IO、TCP网络、UDP IO三类基础IO读写操作,对于以上四种通道,说明如下:
(1)FileChannel:文件通道,用于文件的数据读写。
FileChannel(文件通道)是专门操作文件的通道。通过FileChannel,既可以从一个文件中读取数据,也可以将数据写入文件中。特别申明一下,FileChannel为阻塞模式,不能设置为非阻塞模式。
(2)SocketChannel:套接字通道,用于套接字TCP连接的数据读写。
(3)ServerSocketChannel:服务器套接字通道(或服务器监听通道),允许我们监听TCP连接请求,为每个监听到的请求创建一个SocketChannel通道。
在NIO中,涉及网络连接的通道有两个:一个是SocketChannel,负责连接的数据传输;另一个是ServerSocketChannel,负责连接的监听。其中,NIO中的SocketChannel传输通道与OIO中的Socket类对应,NIO中的ServerSocketChannel监听通道对应于OIO中的ServerSocket类。
ServerSocketChannel仅应用于服务端,而SocketChannel同时处于服务端和客户端。所以,对于一个连接,两端都有一个负责传输的SocketChannel。
无论是ServerSocketChannel还是SocketChannel,都支持阻塞和非阻塞两种模式。
如何进行模式的设置呢?调用configureBlocking()方法,具体如下:

1
2
- socketChannel.configureBlocking(false)设置为非阻塞模式。
- socketChannel.configureBlocking(true)设置为阻塞模式。

在阻塞模式下,SocketChannel的连接、读、写操作都是同步阻塞式的,在效率上与Java OIO面向流的阻塞式读写操作相同。因此,在这里不介绍阻塞模式下通道的具体操作。在非阻塞模式下,通道的操作是异步、高效的,这也是相对于传统OIO的优势所在。
(4)DatagramChannel:数据报通道,用于UDP的数据读写。
在Java中使用UDP传输数据比TCP更加简单。和socket的TCP不同,UDP不是面向连接的协议。使用UDP时,只要知道服务器的IP和端口就可以直接向对方发送数据。在Java NIO中,使用DatagramChannel来处理UDP的数据传输。

详解NIO Selector类

选择器与注册

选择器是什么?选择器和通道的关系又是什么?
简单地说,选择器的使命是完成IO的多路复用,其主要工作是通道的注册、监听、事件查询。一个通道代表一条连接通路,通过选择器可以同时监控多个通道的IO(输入输出)状况。选择器和通道的关系是监控和被监控的关系。
选择器提供了独特的API方法,能够选出(select)所监控的通道已经发生了哪些IO事件,包括读写就绪的IO操作事件。
在NIO编程中,一般是一个单线程处理一个选择器,一个选择器可以监控很多通道。所以,通过选择器,一个单线程可以处理数百、数千、数万甚至更多的通道。在极端情况下(数万个连接),只用一个线程就可以处理所有的通道,这样会大量地减少线程之间上下文切换的开销。
通道和选择器之间的关联通过register(注册)的方式完成。调用通道的Channel.register(Selector sel,int ops)方法,可以将通道实例注册到一个选择器中。register方法有两个参数:第一个参数指定通道注册到的选择器实例;第二个参数指定选择器要监控的IO事件类型。
可供选择器监控的通道IO事件类型包括以下四种:
(1)可读:SelectionKey.OP_READ。
(2)可写:SelectionKey.OP_WRITE。
(3)连接:SelectionKey.OP_CONNECT。
(4)接收:SelectionKey.OP_ACCEPT。
以上事件类型常量定义在SelectionKey类中。如果选择器要监控通道的多种事件,可以用“按位或”运算符来实现。例如,同时监控可读和可写IO事件:

1
2
//监控通道的多种事件,用“按位或”运算符来实现 
int key = SelectionKey.OP_READ | SelectionKey.OP_WRITE ;

什么是IO事件?
这个概念容易混淆,这里特别说明一下。这里的IO事件不是对通道的IO操作,而是通道处于某个IO操作的就绪状态,表示通道具备执行某个IO操作的条件。例如,某个SocketChannel传输通道如果完成了和对端的三次握手过程,就会发生“连接就绪”(OP_CONNECT)事件;某个ServerSocketChannel服务器连接监听通道,在监听到一个新连接到来时,则会发生“接收就绪”(OP_ACCEPT)事件;一个SocketChannel通道有数据可读,就会发生“读就绪”(OP_READ)事件;一个SocketChannel通道等待数据写入,就会发生“写就绪”(OP_WRITE)事件。

SelectableChannel

并不是所有的通道都是可以被选择器监控或选择的。例如,FileChannel就不能被选择器复用。判断一个通道能否被选择器监控或选择有一个前提:判断它是否继承了抽象类SelectableChannel(可选择通道),如果是,就可以被选择,否则不能被选择。
简单地说,一个通道若能被选择,则必须继承SelectableChannel类。
SelectableChannel类是何方神圣呢?它提供了实现通道可选择性所需要的公共方法。Java NIO中所有网络连接socket通道都继承了SelectableChannel类,都是可选择的。FileChannel并没有继承SelectableChannel,因此不是可选择通道。

SelectionKey

通道和选择器的监控关系注册成功后就可以选择就绪事件,具体的选择工作可调用Selector的select()方法来完成。通过select()方法,选择器可以不断地选择通道中所发生操作的就绪状态,返回注册过的那些感兴趣的IO事件。换句话说,一旦在通道中发生了某些IO事件(就绪状态达成),并且是在选择器中注册过的IO事件,就会被选择器选中,并放入SelectionKey(选择键)的集合中。
SelectionKey是什么呢?简单地说,SelectionKey就是那些被选择器选中的IO事件。前面讲到,一个IO事件发生(就绪状态达成)后,如果之前在选择器中注册过,就会被选择器选中,并放入SelectionKey中;如果之前没有注册过,那么即使发生了IO事件,也不会被选择器选中。SelectionKey和IO的关系可以简单地理解为SelectionKey就是被选中了的IO事件。
在实际编程时,SelectionKey的功能是很强大的。通过SelectionKey,不仅可以获得通道的IO事件类型(比如SelectionKey.OP_READ),还可以获得发生IO事件所在的通道。另外,还可以获得选择器实例。

选择器使用流程

选择器的使用主要有以下三步:
(1)获取选择器实例。选择器实例是通过调用静态工厂方法open()来获取的
(2)将通道注册到选择器实例。要实现选择器管理通道,需要将通道注册到相应的选择器上
(3)选出感兴趣的IO就绪事件(选择键集合)。

《Java高并发核心编程》卷1:第4章鼎鼎大名的Reactor模式

Reactor模式的重要性

高性能网络编程都绕不开Reactor模式。很多著名的服务器软件或者中间件都是基于Reactor模式实现的。例如,“全宇宙有名的、高性能”的Web服务器Nginx就是基于Reactor模式的;如雷贯耳的Redis,作为高性能的缓存服务器之一,也是基于Reactor模式的;目前热门的在开源项目中应用极为广泛的高性能通信中间件Netty,还是基于Reactor模式的。

单线程Reactor模式介绍

alt text
Reactor模式有点类似事件驱动模式。
在事件驱动模式中,当有事件触发时,事件源会将事件分发到Handler(处理器),由Handler负责事件处理。Reactor模式中的反应器角色类似于事件驱动模式中的事件分发器(Dispatcher)角色。
Reactor模式由Reactor线程、Handlers处理器两大角色组成,两大角色的职责分别如下:
(1)Reactor线程的职责:负责响应IO事件,当检测到一个IO事件时将其发送给相应的Handler处理器去处理。这里的IO事件就是NIO中选择器查询出来的通道IO事件。
(2)Handlers处理器的职责:非阻塞的执行业务处理逻辑。与IO事件(或者选择键)绑定,负责IO事件的处理,完成真正的连接建立、通道的读取、处理业务逻辑、负责将结果写到通道等。

单线程Reactor模式的缺点

单线程Reactor模式是基于Java的NIO实现的。相对于传统的多线程OIO,Reactor模式不再需要启动成千上万条线程,避免了线程上下文的频繁切换,服务端的效率自然是大大提升了。
在单线程Reactor模式中,Reactor和Handler都在同一条线程中执行。这样,带来了一个问题:当其中某个Handler阻塞时,会导致其他所有的Handler都得不到执行。在这种场景下,被阻塞的Handler不仅仅负责输入和输出处理的传输处理器,还包括负责新连接监听的AcceptorHandler处理器,可能导致服务器无响应。这是一个非常严重的缺陷,导致单线程反应器模型在生产场景中使用得比较少。
除此之外,目前的服务器都是多核的,单线程Reactor模式模型不能充分利用多核资源。总之,在高性能服务器应用场景中,单线程Reactor模式实际使用的很少。

多线程Reactor模式介绍

Reactor和Handler挤在单个线程中会造成非常严重的性能缺陷,可以使用多线程来对基础的Reactor模式进行改造和演进。

多线程版本的Reactor模式演进

多线程Reactor的演进分为两个方面:
(1)升级Handler。既要使用多线程,又要尽可能高效率,则可以考虑使用线程池。
(2)升级Reactor。可以考虑引入多个Selector(选择器),提升选择大量通道的能力。
总体来说,多线程版本的Reactor模式大致如下:
(1)将负责数据传输处理的IOHandler处理器的执行放入独立的线程池中。这样,业务处理线程与负责新连接监听的反应器线程就能相互隔离,避免服务器的连接监听受到阻塞。
(2)如果服务器为多核的CPU,可以将反应器线程拆分为多个子反应器(SubReactor)线程;同时,引入多个选择器,并且为每一个SubReactor引入一个线程,一个线程负责一个选择器的事件轮询。这样充分释放了系统资源的能力,也大大提升了反应器管理大量连接或者监听大量传输通道的能力。
多线程版本反应器MultiThreadEchoServerReactor的逻辑模型如图所示
alt text

Reactor模式的优缺点

在总结Reactor模式的优点和缺点之前,先看看Reactor模式和其他模式的对比,加强一下对它的理解。
(1)Reactor模式和生产者消费者模式对比
二者的相似之处:在一定程度上,Reactor模式有点类似生产者消费者模式。在生产者消费者模式中,一个或多个生产者将事件加入一个队列中,一个或多个消费者主动从这个队列中拉取(Pull)事件来处理。
二者的不同之处:Reactor模式是基于查询的,没有专门的队列去缓冲存储IO事件,去缓冲存储IO事件,查询到IO事件之后,反应器会根据不同IO选择键(事件)将其分发给对应的Handler来处理。
(2)Reactor模式和观察者模式对比
二者的相似之处:在Reactor模式中,当查询到IO事件后,服务处理程序使用单路/多路分发(Dispatch)策略,同步分发这些IO事件。观察者模式(Observer Pattern)也被称作发布/订阅模式,它定义了一种依赖关系,让多个观察者同时监听某一个主题(Topic)。这个主题对象在状态发生变化时会通知所有观察者,它们能够执行相应的处理。
二者的不同之处:在Reactor模式中,Handler实例和IO事件(选择键)的订阅关系基本上是一个事件绑定到一个Handler,每一个IO事件(选择键)被查询后,反应器会将事件分发给所绑定的Handler,也就是一个事件只能被一个Handler处理;在观察者模式中,同一时刻、同一主题可以被订阅过的多个观察者处理。
最后,总结一下Reactor模式的优点和缺点。作为高性能的IO模式,Reactor模式的优点如下:

  • 响应快,虽然同一反应器线程本身是同步的,但是不会被单个连接的IO操作所阻塞。
  • 编程相对简单,最大限度避免了复杂的多线程同步,也避免了多线程各个进程之间切换的开销。
  • 可扩展,可以方便地通过增加反应器线程的个数来充分利用CPU资源。

Reactor模式的缺点如下:

  • Reactor模式增加了一定的复杂性,因而有一定的门槛,并且不易于调试。
  • Reactor模式依赖于操作系统底层的IO多路复用系统调用的支持,如Linux中的epoll系统调用。如果操作系统的底层不支持IO多路复用,Reactor模式不会那么高效。
  • 在同一个Handler业务线程中,如果出现一个长时间的数据读写,就会影响这个反应器中其他通道的IO处理。例如,在大文件传输时,IO操作就会影响其他客户端的响应时间。对于这种操作,还需要进一步对Reactor模式进行改进。

<1-4章完>

参考文献或转载相关: