1.2.Vert.x基础

1. 故事背景

**「思」**我一直在尝试用更加直观的表达来介绍技术中的一些细节,既不丢失它应该有的严谨,又不会让读者觉得枯燥,那么最好的办法就是我们换一种打开方式去激活它;其实我们可以把自己创建的任何一个对象当做一个有生命的精灵来对待,也许用打比方的办法最容易,使用“隐喻”的方式来描述软件概念也是不错的一种选择,毕竟:这个世界是可以形式化的

    我们的故事从一只叫做Tom的小猫(Tomcat)开始,最初开发Java应用的Web服务器以Tomcat为主,它使用了同步阻塞式的I/O模型来处理请求,在这种模型下,当服务器接收到一个请求后,这个接收请求线程会去处理该请求,执行相关任务,如果它需要访问服务端I/O资源,那么调用者只有等待执行结果——这种方式引起的噩梦是:作为请求发送者的客户端程序需要一直等待结果。这似乎是我们最容易体会的“Hung现象”,虽然最终请求回来了,但总感觉不是那么顺畅,有些别扭。

    2009年的某天,Node.js觉醒了,就像开了挂的一种新武器在Linux社区发布,将一个古老的理论模型(姑且算古老)——事件驱动模型释放出来了,也许在Tomcat时代,没有人会想到事件驱动模型可以这么玩,而这个模型被Node.js成功应用于Web^1(World Wide Web)中。也许它的初衷并不是为了Web应用,在Web 2.0中Ajax^2(Asynchronous Javascript And XML)风靡的时代,人类从来都没有停止过将JavaScript应用到服务端程序的想法,Node.js就是这样一个角色,而且开启它的钥匙,也许是(真的也许)2008年Chrome浏览器中发布的V8引擎,由于这个引擎使得JavaScript执行效率大大提升,这把火使得JavaScript脚本逆袭了服务端。

    随着Node.js的霸主地位飙升,也有更多的人发现了Node.js的不足:“人都是多疑善嫉的动物,看到完美的东西都喜欢鸡蛋里面挑骨头。”——越是曝晒在大众眼球中的东西,成长也会越快。

  • Node.js使用了JavaScript(后简称JS)脚本编写服务端程序,由于JS是一种对编程要求很高的脚本,懂得它很简单、可要精通它就变得很难,JS的可靠性并不高,所以使得Node.js在运行过程中,很容易因为程序本身的问题导致服务端死掉(有可能是一次数组越界)。

  • Node.js是单进程、单线程模式,且只支持单核CPU(不知道新版如何了?),它不能充分利用多核CPU服务器的优势,一旦这个进程崩掉,那么整个Web服务端就崩掉了——有点千里之堤毁于蚁穴的味道。

  • 对于CPU密集型的应用,Node.js会不堪重负,主要原因也是由于JS是单线程的,如果存在长时间运行的计算,CPU的时间片无法在有效周期内释放,这使得后续的I/O请求无法继续发起。

    高手总是可以撒豆成兵,实际上Node.js上述的缺点是可以通过代码本身的健壮性来弥补的,但回到另外一个事实就是:真正掌握了JS这门语法的高手并不多(不要把懂当做精通),即使如此,这些缺陷无法掩盖Node.js作为某个时代霸主的事实:

  • 它采用了事件驱动模型、纯异步编程,本身就是为了网络服务设计的,JS这种语言天生具有事件循环、闭包、匿名函数的特质,这些特质为事件驱动、异步编程提供了良好的土壤。

  • JS是一种上手极容易的语言,很多前端工程师也可以参与到服务端的开发中,它打通了前端和后端的芥蒂(语法一致性)。

  • Node.js采用了非阻塞的IO请求处理,使得相对紧缺的系统资源下同样可以发挥其高性能特质、以及负载能力,对于依赖其他IO装置的中间层,它是非常合适的。

  • Node.js非常轻量,且模块化方式很优雅(npm万能),当它面向分布式部署环境下的数据密集型应用时,堪称相对完美的解决方案。

    在如今这种时代,移动网络、社交网络、电商成为了主流,使得服务提供商的客户端请求激增,这也是那只“小猫”显得虚疲的原因,这样的时代背景下,Node.js一枝独秀走在了最前边。扯了这么多,似乎和Vert.x没有任何关系,好的,那么让我们尝试在这样的背景下去解释Vert.x。

    在解释Vert.x时,我们不得不追溯到它的始源之地:

Eclipse Vert.x is a toolkit for building reactive applications on JVM. ——Vert.x website

    这句概括中,我们似乎和它已经很近了,揭开它的面纱,我们丢几个问题以及标签出来理解它。

  • Framework?Vert.x是框架么?是,也不是,严格意义上它是一个Toolkit(工具箱),并不是一个框架,但它提供了不同解决方案所需的工具,包括对框架本身的支持——啰嗦一句,Vert.x不仅能提供RESTful Web服务相关解决方案(可能初识它的大部分工程师都是奔着用它做外包项目来的),还包括游戏后端、实时监控等——这个工具箱都提供了。

  • Web Server?Vert.x是Web服务器么?是,也不是,它还是Toolkit,但是Vert.x本身是基于纯异步Java服务器Netty的,这个工具箱内嵌了这个服务器,Vert.x具有天生高性能的特征,也算是一个服务器(其实是因为有了Netty所以才说它是服务器,准确说是封装了Netty的服务器)。

  • Web Container?Vert.x是Web容器么?是,也不是,无奈的告诉你:它依旧是Toolkit,但是由于Vert.x本身拥有很多不同的子项目,对不同领域而言,它的某些子项目提供了不同用途的Web容器——比如使用Vert.x-Web您可以快速搭建RESTful Web服务容器,所以它姑且算容器。

    您也许会觉得奇怪,为什么模棱两可,一会儿是,一会儿不是?——因为Vert.x就是一个工具箱,与其单纯地觉得它是哪一种,不如把它当做宝贝如意,每次当您遇到问题时,可以念一句:如意如意,按我心意,快快显灵。

    既然是工具箱,它就有“功能”一说。回到官方网站的介绍看看它的特点^3

  • Scale:Vert.x本身是事件驱动、非阻塞纯异步IO模型,这意味着您可以使用很少的线程资源处理大量并发请求(先不要提co-routine,请控制一下您野马一样的思维)。

  • Polyglot:对的,您没看错,它是Polyglot的,同时提供了Java、JavaScript、Groovy、Ruby、Ceylon、Scala、Kotlin这些语言的版本,为什么支持这么多语言,引用一句微服务时代的话:服务的第一个目标就是语言异构,实际上Vert.x在微服务的路上毫不逊色,而且您可以根据自己的专长选择合适的语言做Vert.x开发(这才是最自由的)。

  • Fun:是的,Vert.x就是一个函数,和传统的应用容器不同,它让您可以很容易使用函数式编程思维编写相关程序——我想您现在终于明白为什么我们花了一整章节告诉您什么是函数式编程了。参考《1.1.一切从FP开始》。

    除开这些明显的特征,官方还有么?有,请恕我懒惰,总要有点原汁原味的东西才行^4

  • Vert.x is *lightweight *- Vert.x core is around 650kB in size.

  • Vert.x is fast. Here are some independent numbers.

  • Vert.x is not an application server. There's no monolithic Vert.x instance into which you deploy applications. You just run your apps wherever you want to.

  • Vert.x is modular - when you need more bits just add the bits you need and nothing more.

  • Vert.x is *simple but not simplistic. * Vert.x allows you to create powerful apps, simply.

  • Vert.x is an ideal choice for creating light-weight, high - performance, micro-services.

2. 基本概念

    主角登场[^5],对!深入到Vert.x后,主角一定不是Vert.x单词本身,若要掌握Vert.x就需要从基本概念入手,虽然本章读起来会有一种“您好,世界(Hello World)”的味道,但为了老少皆宜,我们尽可能愉快地去阅读这份故事。

  • Verticle

  • Event Bus

    Vert.x中若掌握了上边两个核心概念,那么您就可以“俯瞰”它了,先不要提集群模式(Cluster)下的io.vertx.core.Vertx实例,我们一步一步来,逐陆记又怎么会只让您窥见冰山一角这么乏味?

    大部分网络库以及应用框架都是依赖于简单的线程模型,每一个网络客户端都会创建一个连接线程,这个线程会和服务端保持连接(注意这里不是长连接的概念),直到“任务完成”——断开连接;Java的Web开发中传统的Servlet、以及使用java.iojava.net中的类编写的程序就是如此操作。通常传统的线程模型使用的是同步I/O模型(Synchronous I/O),随着并发请求量的增加,这种模型越发不能满足大规模的请求需求,所以I/O模型开始转换成异步I/O模型(Asynchronous I/O),Vert.x基础库中的I/O模型就是异步的。


「同步和异步」

    同步(Synchronous),就是在发出一个功能调用时,在没有得到结果之前,该调用就不返回。简单说就是必须一个任务一个任务地做,等前一件做完了才能做下一件事。

    异步(Asynchronous),异步的概念和同步是相对的,当一个异步过程调用发起后,调用者同样不能立即得到结果,实际处理这个调用的部件在完成后,通过状态、通知和回调通知调用者。而调用者在发起调用后可做其他事,因为被调用者会通过状态、通知、回调三种途径来通知调用者。

    以Web请求为例,同步和异步调用流程如下:

    上边的调用模式是一致的,唯一的不同就是红色部分客户的Client什么都不能干,而绿色部分客户端Client可以干它自己的事,这个时候它只需要等待响应回来即可。

    同步和异步的字面理解在这里是面向“数据”的,如果使用同步请求,那么数据本身会在该调用过程维持同步,因为客户端任何时候都可以拿到服务端的最新数据,并且是在最新数据得到过后才继续执行;而异步模式下,客户端在不用等待服务端响应的过程中,实际上拿不到最新的数据,它的最新数据必须是在异步回调过后才拿到,如果这个过程中的数据依赖于请求处理结果,那么异步模式的场景就显得不合适了,所以异步编程中有一个话题就是如何对异步代码做编排


2.1. Verticle

    在Vert.x中一个部署单元通常称为一个Verticle,很多人将Verticle理解成“一个”,而我更倾向于理解成**“一类”**——因为在部署过程中,同一类的Vertcile可以部署多个实例(instances参数),而每一个Verticle的实例由线程池中的一个线程来维护,这个线程池在Vert.x称为Event Loop——事件循环,而这个Verticle会处理对应的事件Event,这个事件可以是接收网络数据、定时事件、其他Verticle发送过来的消息,Event Loop就是一个异步编程模型。

    事件循环^6Event Loop)——一个老生常谈的话题,一般一个事件循环通常被定义成如下模式:

while(queue.waitForMessage()){
    queue.processNextMessage();
}

    如果没有接收到任何消息,则等待同步消息到达。而事件循环在这里并不是死循环,它存在如下特征:

  • 执行到完成:每个消息完整执行后,其他消息才被执行,当您分析您的程序时,这点提供了一个优秀的特性,就是每一个函数在运行时,它不能被强占,并在其他代码运行之前完全执行——这种模型的缺点就是如果这个消息处理需要的时间太长,Web应用无法处理用户交互,比较好的做法是“消息裁剪”。

  • 添加消息(事件):每个事件会和一个对应的事件监听器绑定,当它被触发时,消息则会被随机添加;若没有事件监听器,那么该事件会丢失,所以事件的追加主要依赖事件监听器来完成。

  • 零延迟:零延迟并不意味着回调立即执行,在零延迟调用setTimeout时,并不是过了给定时间后马上执行回调,它等待的时间还会取决于消息队列中等待的消息数量。

    当Verticle实例线程在Event Loop上执行时,Vert.x的官方提供了一个大原则:

The Golden Rule - Don't Block the Event Loop

    Vert.x原生的API都是非阻塞式的,这种API的调用并不会阻塞Event Loop,所有接收到的事件都会在不阻塞Event Loop的情况下被执行,如果确实需要有同步代码执行,并且该代码会阻塞Event Loop,Vert.x中也提供了处理它的方式(后边会讲到),但在官方教程中,建议我们不要在Vert.x中直接写阻塞式代码。这些阻塞式代码一般包含下边几种:

  • 从Socket读取数据。

  • 将数据写入磁盘(写文件)。

  • 发送消息并且等待该消息的回复。

  • 使用同步API访问数据库(Zero的前身就在代码中访问H2数据库遇到了Block的问题)。

  • Thread.sleep()。

  • 等待锁。

  • 执行需要消耗时间的复杂运算。

  • 等待Mutex或Monitor。

    推荐编程过程不要阻塞(Block)它,否则您会收到警告信息。当您在代码中写了上述类型的代码,这个线程将会等待阻塞操作的结果,此时Event Loop什么都不能做——这样做会导致内存和CPU的大量开销。

    我们看看Vert.x中被block后常见的警告信息:

Mar 19, 2018 2:59:45 PM io.vertx.core.impl.BlockedThreadChecker
WARNING: Thread Thread[vert.x-eventloop-thread-0,5,main] has been blocked for 2815 ms, time limit is 2000
Mar 19, 2018 2:59:46 PM io.vertx.core.impl.BlockedThreadChecker
WARNING: Thread Thread[vert.x-eventloop-thread-0,5,main] has been blocked for 3816 ms, time limit is 2000

    默认情况下,Vert.x中一旦阻塞(Block)的时间超过2s,它会在日志中输出上述警告信息,这种情况很容易重现:

package io.vertx.up._01.block;

import io.vertx.core.AbstractVerticle;

public class BlockVerticle extends AbstractVerticle {

    @Override
    public void start() {
        try {
            // 休眠4s
            Thread.sleep(4000);
        } catch (final Exception ex) {
            ex.printStackTrace();
        }
    }
}

    每个Event Loop都会附加到一个线程,默认情况下,Vert.x每个CPU核心线程都会附加2个事件循环——这样导致的直接结果是:通常一个Verticle总是在一个线程上处理事件,因此不需要线程调度来操作一个Verticle的状态。VertxOptions中的原始代码为:

public static final int DEFAULT_EVENT_LOOP_POOL_SIZE = 2 * CpuCoreSensor.availableProcessors();

    CpuCoreSensor这个类会去操作系统中读取可用的CPU核信息,Vert.x内部实现原理如:

  • 对于Windows/Mac而言:调用Java的API:Runtime.getRuntime().availableProcessors()

  • 对于Linux操作系统而言:直接从/proc/self/status文件中读取可用CPU核的信息。

**「注」**软亲和性意味着进程不会在处理器之间频繁迁移,而硬亲和性则意味着进程需要在您指定的处理器上执行。Linux调度程序同时提供软硬CPU亲和性机制,虽然它尽力通过一种软亲和性试图使进程尽量在同一个处理器上运行,但是它允许用户强制指定这个进程无论如何都必须在指定的处理器上运行。而硬CPU亲和性则保存在进程位掩码标志cpus_allowed中,该掩码标志的每一位对应一个系统可用的处理器,所以Linux操作系统会自己处理这种情况。

    简单说,Vertx实例一旦被创建后,它就可以在该实例中发布(Deployment)对应的Verticle实例了,对于Standard类型的Verticle而言,就像前线的哨兵,一旦有事件发生,它就会去接收该事件,只是事件的处理——理论上可以由它自身完成,也可以由Worker类型的Verticle完成。这里先不讲解Verticle的三种类型(这里提到了Standard/Worker),只是希望读者对Verticle有一个最基本的概念。

    每个类型的Verticle可以通过传入的配置进行发布:

    上图是Vertx实例在内部发布Verticle的流程图。网络上的请求又称为事件,一旦事件发生,哨兵就坐不住了,Verticle中的线程会接收该事件,那么这里就会引发一个问题——回到最初,我们说过Verticle是一类线程,不是一个线程,发布的线程数量和配置中的instances参数息息相关,那么当一个事件到达时,某一类的Verticle究竟选择哪个线程来处理呢?

Verticle在接收请求时选择线程采用了Round Robin轮询算法(轮转法),它会依次从线程中找到对应的线程来处理。

    如启动一个Verticle,设置instances=4,部署的线程如下:

vert.x-eventloop-thread-216
vert.x-eventloop-thread-317
vert.x-eventloop-thread-014
vert.x-eventloop-thread-115

    当您用不同的客户端发送请求时,则会出现下边结果(关闭浏览器,发送请求,然后关闭,再发送,然后再继续,主要是保证每次请求使用不同的会话发送):

vert.x-eventloop-thread-216
vert.x-eventloop-thread-317
vert.x-eventloop-thread-014
vert.x-eventloop-thread-115

    从上边的规律可以看到,实际上如果只是单个请求的线程轮询,那么线程ID的顺序会和启动时候部署的线程顺序一致,Verticle会轮询选择对应的Event Loop绑定的线程来接收请求,并且每个线程会在Vert.x中形成一个请求接收的线程队列,一般的轮转法如下:

  • 将系统中所有的就绪线程按FCFS(First Come First Served)原则排成一个队列。

  • 每次调度时CPU分派队首线程,让其执行一个时间片,如果时间片结束(同一会话请求完成),则执行时钟中断。

  • 调度程序暂停当前线程的执行,将其送到就绪队列末尾,并通过上下文切换执行当前队列首的线程。

    比较有意思的是,Vert.x并不是按照请求本身数量来进行线程轮询的,而是根据会话进行,当您不关闭浏览器,反复发送一个请求,您会发现接收请求的线程一直不会变化(线程ID不改变),只有关闭了浏览器(中断会话)后,开启新的会话,Vert.x中的线程才会执行切换。

    最后需要补充的是Vertx实例一旦执行发布动作后,Verticle自身的生命周期将会被触发,简单的start/stop周期。

2.2. Event Bus

    Verticle是Vert.x中代码部署的技术单元,而Event Bus则是不同的Verticle通过异步消息通信的主要工具。假设我们有一个接收Http请求的Verticle和一个数据库访问用的Verticle,这种情况下,Event Bus允许接收Http请求的Verticle和执行SQL查询的Verticle相互通信,并生成对应的响应。

    不仅如此,Event Bus还支持三种不同的通信模式:

  • 点对点消息发送【Point To Point】

  • 请求/响应模式【Request Response】

  • 订阅/发布模式【Publish Subscribe】广播消息

    更有意思的一点是,Event Bus并不局限于在同一个JVM进程中实现Verticle相互之间的通信:

  • 当网络集群激活时,Event Bus会自动转换成“分布式”角色,不同集群节点之间的Verticle实例可以通过Event Bus实现通信。

  • Event Bus可直接基于TCP协议和第三方应用通信。

  • Event Bus可以和其他的消息服务进行桥接(如AMQP、Stomp等)。

  • SockJS可以让浏览器端的JavaScript和服务端的Event Bus实现直接的消息通信。

2.3. 再谈Verticle

    在基础章节,我们不深入Verticle,比如前文提到的Worker类型和Standard类型的相关细节,我们先回头来看看Verticle究竟是什么,它和EventBus、Vertx实例之间是什么关系?

    一个JVM机创建了一个Vertx实例,那么JVM中就拿到这个Vertx实例了,这个实例如果什么也不做,您将会收到:

Process finished with exit code 0

    也许实例化它的代码是这样的:

package io.vertx.up._01.block;

import io.vertx.core.Vertx;
import io.vertx.core.impl.VertxFactoryImpl;
import io.vertx.core.spi.VertxFactory;

public class VertxLanucher {
    public static void main(final String[] args) {
        final VertxFactory factory = new VertxFactoryImpl();
        final Vertx vertx = factory.vertx();
    }
}

    所以,Vertx实例需要有动作,因为只有它有了动作,才会真正让Vert.x运行,它和Verticle之间的关系是:Vertx实例可以发布(Deploy)Verticle,也可以撤销(Undeploy),也许这里就要分道了:

  • 第一个分水岭是worker属性和multiThread属性,这两个属性会将Verticle直接分成三个大类:Standard/Worker/Multi-Thread Worker

  • 第二个分水岭是您定义的Java类,通常从io.vertx.core.AbstractVerticle直接继承,代码内部不可控制自己发布的数量。

  • 第三个分水岭是instances参数,该参数会针对您所写的某一类Verticle执行发布,instances是多少,那么就可以帮着您创建多少实例,这个时候真正的Verticle实例就是一个线程,也就是我们真正意义上的Thread。

    用一个表格来说明,也许更清晰:

    根据上边的表格统计,共写了四个Java类,合计发布了21个实例,10个在Worker Pool中,另外11个在Event Loop中,也就是说正常情况下,这里应该有21个线程部署出来。但是实际上,最终的线程数量 > 21。为什么?因为对于Multi-Thread类型的Verticle实例还会在内部创建线程,它的原话为:

These run using a thread from the worker pool. An instance can be executed concurrently by more than one thread.

    也就是说,Multi-Thread类型的Verticle实例有可能和最终线程不是一一对应的,但不论怎么说,通过这样一张表格,您应该可以理解Verticle实例的在整个Vert.x中的定位了么?严格来讲,它的线程计算公式应该为:

result = ( > 4 ) + 6 + 10 + 默认值1
> 21个线程

    这里的默认值在源代码中有说明,就不阐述,后边的故事我们还会遇到Verticle,到时候通过案例来说明这些Verticle的细枝末节,本章只是为了帮助读者理解基础概念。最后,从图中可以看到,当Worker和Standard类型的Verticle相互通信的时候,就会使用Event Bus来完成。

3. 总结

    本章节,我们理解了Vert.x最基础的两个核心概念Verticle和Event Bus,只要您分清Vertx实例和Verticle实例,那么对于Vert.x就算入门了,至于Event Bus(里面涉及的理论还比较多,包括Reactor设计模式、以及事件驱动模型的理论),即使现在Event Bus对您而言很新,您也不用去关心,让我们娓娓道来——对的,我们会在后续的示例中高频使用,所以经过本章节的教程,您应该基本了解Vert.x的概貌了。

    有些地方使用机器翻译将Verticle直译成了“垂直”,这种不思考的翻译方法真的很“锤子”。

[^5]: 《A gentle guide to asynchronous programming with Eclipse Vert.x for Java developers》http://vertx.io/docs/guide-for-java-devs/

最后更新于