十二、分布式系统开发(一)

0 阅读1小时+

1.1 全栈工程师如何看待从单体到分布式的架构演进?

全栈工程师处于“前端-后端-运维”的交叉视角,对从单体到分布式的架构演进有着更立体的理解——既不仅关注技术栈的堆砌,而是业务规模、团队协作、系统韧性三者共同驱动的必然选择,同时也伴随着复杂度的转移与平衡。以下从全栈视角解析这一演进的核心逻辑:

一、演进的本质:从“简单耦合”到“可控复杂”

全栈工程师往往是架构演进的“亲历者”:早期单体架构中,前后端代码可能在一个仓库、一个部署包中,数据库单库单表,开发效率高但扩展受限;随着业务增长(用户量、数据量、功能模块激增),单体架构的“牵一发而动全身”成为瓶颈,分布式架构通过拆分与解耦解决这一问题。

  • 单体架构的“甜蜜期”
    全栈工程师能快速上手(前后端交互直接通过内部API),部署简单(打包一个JAR/WAR即可),调试方便(本地启动一个服务即可跑通全流程)。适合MVP验证、小团队协作(10人以内)、功能稳定的场景。

  • 分布式架构的“必要性”
    当业务进入增长期(日活10万+、功能模块20+),全栈工程师会直观感受到单体的痛点:

    • 前端迭代依赖后端发布(改一个按钮可能需要全量部署);
    • 后端某模块bug(如订单系统)会拖垮整个应用(包括笔记、用户等无关模块);
    • 数据库成为瓶颈(读写压力集中,单表数据量过亿)。
      此时分布式架构通过“微服务拆分”“读写分离”“缓存集群”等手段,将复杂度分散到多个独立单元,实现“局部故障不影响全局”“按需扩展”。

二、全栈视角下的核心挑战:复杂度的“转移”而非“消失”

分布式架构解决了单体的扩展性问题,但全栈工程师更能体会到复杂度从“代码层”转移到“跨层协作”

  1. 前后端交互:从“直接调用”到“服务发现+网关”

    • 单体时代:前端直接调用/api/note/create(后端Controller接口);
    • 分布式时代:前端请求先经过网关(如Spring Cloud Gateway),由网关路由到“笔记微服务”,中间涉及服务注册发现(Nacos/Eureka)、负载均衡。
    • 全栈应对:前端需理解网关路由规则,处理服务降级/熔断的异常(如显示“服务暂时不可用”);后端需设计统一的API规范(如RESTful、Swagger文档),避免前端对接混乱。
  2. 数据一致性:从“单库事务”到“分布式事务”

    • 单体时代:一个@Transactional注解即可保证“创建笔记+扣减积分”的原子性;
    • 分布式时代:笔记服务、积分服务分属不同数据库,需用TCC、SAGA等方案保证最终一致性。
    • 全栈应对:后端需设计补偿机制(如积分扣减失败时回滚笔记发布);前端需处理“中间状态”(如显示“发布中,请稍后刷新”)。
  3. 部署与监控:从“单机部署”到“容器化+链路追踪”

    • 单体时代:java -jar app.jar即可部署,日志查看tail -f app.log
    • 分布式时代:需用Docker/K8s编排多个服务实例,通过SkyWalking追踪跨服务调用链路,用Prometheus监控各节点性能。
    • 全栈应对:后端需编写Dockerfile,前端需理解CI/CD流程(如Jenkins自动部署),共同参与监控告警规则设计(如前端接口超时告警)。

三、演进的“度”:拒绝“为分布式而分布式”

全栈工程师因接触业务全链路,更能判断架构演进的“性价比”,避免陷入“过度设计”:

  • 小业务用单体,快速验证
    若产品处于早期(如日活1万以内),全栈工程师应优先选择单体架构(如Spring Boot+Thymeleaf应用),用分层、分包、模块化(而非微服务)实现代码隔离,保留未来拆分的可能性。

  • 中大型业务分步演进,而非“一步到位”
    演进路径通常是:
    「单体应用(模块化)」→「垂直拆分(按业务线拆分为用户服务、内容服务)」→「水平拆分(内容服务拆分为笔记服务、评论服务)」→「引入中间件(缓存、消息队列、网关)」。
    全栈工程师需在每一步评估投入产出:例如,先通过“读写分离”解决数据库压力,而非直接上分库分表;先用Redis单机缓存,再考虑集群。

  • 关注“用户体验”这一最终标尺
    分布式架构的技术优化(如缓存、CDN)最终要体现为前端的“更快加载”“更少卡顿”;而分布式带来的复杂性(如服务超时)也需前端通过“骨架屏”“重试机制”等手段掩盖,全栈协作才能让用户感知不到架构的变迁。

四、全栈工程师的角色:架构演进的“翻译官”与“平衡者”

  • 技术到业务的“翻译”:将分布式技术术语(如“服务熔断”)转化为产品语言(“避免某功能故障导致整个APP崩溃”),帮助团队判断优先级。
  • 前后端的“桥梁”:例如,后端引入消息队列异步处理图片上传后,前端需调整交互(显示“上传中”动画),全栈工程师可协调双方制定交互规范。
  • 复杂度的“平衡者”:在“技术完美”与“业务可用”间取舍——例如,初期可用“本地消息表”实现简单的分布式事务,而非直接上Seata框架增加学习成本。

总结

从全栈视角看,单体到分布式的演进不仅仅是“技术升级”,而是业务发展到一定阶段的“必然选择”与“妥协艺术”:既要通过拆分解决扩展性问题,又要通过全栈协作控制复杂度,最终服务于“用户体验”与“业务增长”。全栈工程师的价值,正在于既能理解技术细节,又能站在业务视角把握演进的节奏与边界。

1.2 分布式系统概述,帮助全栈工程师构建分布式系统认知框架

对于全栈工程师而言,分布式系统并非单纯的“后端技术集合”,而是贯穿“前端交互-后端服务-数据存储-运维部署”的全链路协同体系。构建分布式系统认知,核心是理解**“如何将复杂业务拆分为独立单元,并通过规则让单元协同工作,同时解决拆分带来的新问题”**。以下从“核心定义-本质特征-核心组件-全栈视角挑战-演进逻辑”五个维度,搭建全栈工程师的分布式系统认知框架。

什么是分布式系统?

分布式系统的本质是**“多节点协作完成单节点无法胜任的任务”**,但需先明确两个关键前提:

  • 物理分散:节点(服务器、数据库、缓存实例等)不在同一台物理设备上,可能跨机房、跨地域(如阿里云上海节点+北京节点);
  • 逻辑统一:用户/前端感知不到“多节点”的存在,认为是一个完整系统(例如用户刷小红书笔记,不会察觉笔记来自“北京内容服务节点”,评论来自“上海评论服务节点”)。

对比单体系统:单体系统是“单节点包含所有功能”(如一个JAR包包含笔记、用户、评论模块,一个数据库存储所有数据),而分布式系统是“多节点各司其职,通过网络交互协作”。

分布式系统常用术语

分布式系统是一门新兴的学科。为了方便后续章节的学习,先了解下分布式系统中常用的术语。

1. 节点

节点(Node)是指一个可以独立按照分布式协议完成一组逻辑的程序个体。在具体的工程项目中,一个节点往往是一个操作系统上的进程。节点是一个完整的、不可分的整体,是执行分布式任务的最小的单元。

在这个高可用的分布式系统中,相同功能的程序往往会部署到不同的节点中。这种模式也称为“副本”。

2. 副本

副本(Replica)指在分布式系统中为数据或服务提供的冗余。

对于数据副本而言,是指在不同的节点上持久化同一份数据,当出现某一个节点的存储的数据丢失时,可以从副本上读到数据。数据副本是分布式系统解决数据丢失异常的唯一手段。

对于服务副本而言,是指数个节点提供某种相同的服务,这种服务一般并不依赖于节点的本地存储,其所需数据一般来自其他节点。服务副本也称为“服务实例”。

例如,GFS系统的同一个chunk的数个副本就是典型的数据副本,而Map Reduce系统的Job Worker则是典型的服务副本。

图2-1展示的是一个微服务架构图,其中相同的服务会有多个服务实例(服务副本)。

图2-1 微服务架构图中的服务副本

3. 集群

相同功能程序的副本,统称为该功能的集群。

4. 通信

节点与节点之间是完全独立、相互隔离的,节点之间传递信息的唯一方式是网络通信(Communication)。图1-1中带箭头的直线表示了节点之间的消息通信。

消息通信可以是双向的或者是单向的。使用双箭头表示网络双向可达,而某些节点间只有单箭头表示网络单向可达,而某些节点间没有箭头表示网络完全不可达。

5. 存储

节点可以通过将数据写入某台机器的本地存储(Storage)设备来保存数据。通常的存储设备可以是磁盘、SSD、文件,也可以是关系型数据库、NoSQL数据库、文件存储系统等。

6. 状态

如果某个节点负责存储、读取数据,则该节点为有状态的节点,反之称为无状态的节点。如果某个节点A存储数据的方式是将数据通过网络发送到另一个节点B,由节点B负责将数据存储到节点B的本地存储设备,那么不能认为节点A是有状态的节点,而只有节点B是有状态的节点。

7. 异常

异常主要是针对某个节点而言的。异常可能是由网络故障引起的,也可能是程序自身引起的。

需要注意的是,在高可用的分布式系统中,单个节点的异常,并不一定会影响整个分布式系统。分布式系统往往会设计一定的容错性。

8. 性能

无论是分布式系统还是单机程序,都会对性能(Performance)有有所要求。对于不同的系统,关注点有所差异。常见的性能指标有:

  • 吞吐能力,指系统在某一时间可以处理的数据总量,通常可以用系统每秒处理的总的数据量来衡量;
  • 响应延迟,指系统完成某一功能需要使用的时间;
  • 并发能力,指系统可以同时完成某一功能的能力,通常也用QPS(query per second,每秒请求数)来衡量。

上述三个性能指标往往会相互制约,追求高吞吐的系统,往往很难做到低延迟;系统平均响应时间较长时,也很难提高QPS。

9. 一致性

分布式系统为了提高可用性,总是不可避免的使用副本的机制,从而引发副本一致性的问题。

根据具体的业务需求的不同,分布式系统总是提供某种一致性模型,并基于此模型提供具体的服务。

分布式系统的3个本质特征

全栈工程师需从“前后端协同视角”理解这些特征——它们既是分布式的优势,也是所有问题的根源:

1. 去中心化:从“单点依赖”到“多节点协作”

  • 核心逻辑:没有任何一个节点是“绝对核心”,每个节点只负责部分功能(如笔记服务只处理笔记CRUD,用户服务只处理登录注册),节点间通过网络协议(HTTP、RPC、MQ)通信。
  • 全栈视角体现
    • 前端:调用“创建笔记”接口时,无需关心背后是1个还是10个笔记服务实例(网关会自动路由);
    • 后端:某笔记服务实例宕机,其他实例会接管请求,用户感知不到中断;
    • 风险点:节点间通信依赖网络,一旦网络延迟/中断,会出现“服务不可用”或“数据不一致”(如前端点击“发布笔记”,后端笔记服务成功但通知评论服务失败)。

2. 可扩展性:按需扩容,应对业务增长

  • 核心逻辑:单体系统只能“整机扩容”(给服务器加CPU/内存),而分布式系统支持“按需扩容”——哪个模块压力大,就扩哪个模块的节点。
  • 全栈视角体现
    • 后端:小红书笔记模块日活激增时,只需新增10个笔记服务实例,无需动用户、评论服务;数据库读写压力大时,可单独扩读库节点;
    • 前端:通过CDN(分布式内容分发网络)将静态资源(图片、JS/CSS)部署到全国节点,用户访问时自动拉取最近节点的资源,加载速度从500ms降至100ms;
    • 关键指标:水平扩展(加节点)而非垂直扩展(加硬件),是分布式系统应对高并发的核心手段。

3. 容错性:局部故障不影响全局

  • 核心逻辑:单体系统“一损俱损”(数据库宕机则整个应用不可用),分布式系统通过“冗余设计”实现“局部故障隔离”。
  • 全栈视角体现
    • 后端:Redis缓存集群中,一个节点宕机,其他从节点会自动切换为主节点,缓存服务不中断;
    • 前端:调用评论接口时,若评论服务临时故障,前端可触发“降级策略”(显示“评论加载中,稍后重试”而非整个页面崩溃);
    • 关键设计:冗余备份(如数据库主从复制)、故障检测(如心跳机制)、自动恢复(如K8s重启故障容器)是容错的三大支柱。

2.1 线程与进程原理解析,高并发应用的基石

3.1.1 什么是线程

在早期的计算机操作系统中,能拥有资源和独立运行的基本单位是进程。然而随着计算机技术的发展,进程出现了很多弊端,一是由于进程是资源拥有者,创建、撤消与切换存在较大的时空开销,因此需要引入轻量型进程;二是由于对称多处理机(Symmetric Multi-Processor,SMP)出现,可以满足多个运行单位,而多个进程并行开销过大。

因此在上世纪80年代,出现了能独立运行的基本单位—线程(Thread)。

线程是程序执行流的最小单元。一个标准的线程由线程ID、当前指令指针(PC)、寄存器集合和堆栈组成。另外,线程是进程中的一个实体,是被系统独立调度和分派的基本单位。线程自己不拥有系统资源,只拥有一点儿在运行中必不可少的资源,但它可与同属一个进程的其他线程共享进程所拥有的全部资源。一个线程可以创建和撤消另一个线程,同一进程中的多个线程之间可以并发执行。由于线程之间的相互制约,致使线程在运行中呈现出间断性。

典型的线程拥有三种基本状态:

  • 就绪;
  • 阻塞;
  • 运行。

线程的状态图如图3-1所示。

图3-1 线程的状态图

就绪状态是指线程具备运行的所有条件,逻辑上可以运行,在等待处理机;运行状态是指线程占有处理机正在运行;阻塞状态是指线程在等待一个事件(如某个信号量),逻辑上不可执行。每一个程序都至少有一个线程,若程序只有一个线程,那就是程序本身。 线程是程序中一个单一的顺序控制流程,是进程内一个相对独立的、可调度的执行单元。在单个程序中同时运行多个线程完成不同的工作,称为多线程。多数情况下,多线程能提升程序的性能。

3.1.2 进程和线程的关系

进程和线程是并发编程的两个基本的执行单元。在大多数编程语言中,并发编程主要涉及线程。

一个计算机系统通常有许多活动的进程和线程。在给定的时间内,每个处理器中只能有一个线程得到真正的运行。对于单核处理器来说,处理时间是通过时间切片来在进程和线程之间进行共享的。

进程有一个独立的执行环境。进程通常有一个完整的、私人的基本运行时资源。特别是每个进程都有自己的内存空间。操作系统的进程表(Process table)存储了CPU寄存器值、内存映像、打开的文件、统计信息、特权信息等。进程一般定义为执行中的程序,也就是当前操作系统的某个虚拟处理器上运行的一个程序。多个进程并发共享同一个CPU以及其他硬件资源是透明的,操作系统支持进程之间的隔离。这种并发透明性需要付出相对较高的代价。

进程往往被视为等同于程序或应用程序。然而,用户看到的一个单独的应用程序可能实际上是一组合作的进程。大多数操作系统都支持进程间通信(Inter Process Communication,IPC),如管道和Socket。IPC不仅用于同个系统的进程之间的通信,也可以用在不同系统的进程之间进行通信。

线程有时被称为轻量级进程(Lightweight Process,LWP)。进程和线程都提供了一个执行环境,但创建一个新的线程比创建一个新的进程需要更少的资源。线程系统一般只维护用来让多个线程共享CPU所必须的最少量信息。特别是线程上下文(Thread Context)中一般只包含CPU上下文以及某些其他线程管理信息。通常忽略那些对于多线程管理不是完全必要的信息。这样单个进程中防止数据遭到某些线程不合法的访问的任务就完全落在了应用程序开发人员的肩上。线程不像进程那样彼此隔离以及受到操作系统的自动保护,所以在多线程程序开发过程中需要开发人员做更多的努力。

线程存在于进程中,每个进程都至少一个线程。线程共享进程的资源,包括内存和打开的文件。这使得工作变得高效,但也存在了一个潜在的问题—通信。关于通信的内容,会在后面章节中讲述。

现在多核处理器或多进程的计算机系统越来越流行。这大大增强了系统的进程和线程的并发执行能力。但即便是没有多处理器或多进程的系统中,并发仍然是可能的。关于并发的内容,会在后面章节中讲述。

3.1.3 线程和纤程

为了提高并发量,某些编程语言中提供了“纤程”(Fiber)的概念,比如Golang的goroutine,Erlang风格的actor。Java语言虽然没有定义纤程,但仍有一些第三方库可供选择,比如Quasar。纤程可以理解为是比线程更加细颗粒度的并发单元。同时,Java 19引入了类似的技术——虚拟线程(Virtual Threads)。不管是虚拟线程还是纤程,他们都是轻量级线程,其目的都是为了提高并发能力。

由于纤程是以用户方式代码来实现的,并不受操作系统内核管理,所以内核并不知道纤程,也就无法对纤程实现调度。纤程是根据用户定义的算法来调度的。因此,就内核而言,纤程采用了非抢占式调度方式,而线程是抢占式调度的。

一个线程可以包含一个或多个纤程。线程每次执行哪一个纤程的代码,是由用户来决定的。所以,对于开发人员来说,使用纤程可以获得更高的并发量,但同时也要面临着自己实现调度纤程的复杂度。

2.2 线程对象深度解析,建立安全可靠的代码编写规范

在面向对象语言开发过程中,每个线程都与Thread类的一个实例相关联。由于Java语言的流行,下文中的例子将用Java来实现和使用线程对象,以作为并发应用程序的基本原型。

3.2.1 定义和启动一个线程

Java中有两种创建Thread实例的方式。第一种是提供Runnable对象。

Runnable接口定义了一个方法run,用来包含线程要执行的代码。HelloRunnable示例如下所示。

public class HelloRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("Hello from a runnable!");
    }
    public static void main(String[] args) {
        (new Thread(new HelloRunnable())).start();
    }
}

第二种创建Thread实例的方式是继承Thread。Thread类本身是实现Runnable,虽然它的run方法什么都没干。HelloThread示例如下所示。

public class HelloThread extends Thread {
    @Override
    public void run() {
        System.out.println("Hello from a thread!");
    }
    public static void main(String[] args) {
        (new HelloThread()).start();
    }
}

请注意,这两个例子都是调用start方法来启动线程。

关于第一种方式,它使用Runnable对象,在实际应用中更普遍,因为Runnable对象可以继承Thread以外的类。第二种方式,在简单的应用程序中更容易使用,但受限于你的任务类必须是一个Thread的后代。本书推荐使用第一种方法,将Runnable任务从Thread对象分离来执行任务。这样不仅更灵活,而且它适用于高级线程管理API。

Thread类还定义了大量的方法用于线程管理。

3.2.2 暂停线程执行

Thread.sleep可以让当前线程执行暂停一个时间段,这样处理器的时间就可以给其他线程使用。

sleep有两种重载形式:一个是指定睡眠时间为毫秒级,另外一个是指定睡眠时间为纳秒级。然而,这些睡眠时间不能保证是精确的,因为它们是由操作系统提供的,并受其限制,因而不能假设sleep的睡眠时间是精确的。此外,睡眠周期也可以通过中断来终止,我们将在后面的章节中看到。

以下SleepMessages示例是使用sleep每隔4秒打印一次消息:

public class SleepMessages {

    public static void main(String[] args) throws InterruptedException
    {
        String importantInfo[] = {
                "Mares eat oats",
                "Does eat oats",
                "Little lambs eat ivy",
                "A kid will eat ivy too" };

        for (int i = 0; i < importantInfo.length; i++) {
        
            // 暂停4秒
            Thread.sleep(4000);
            
            // 打印消息
            System.out.println(importantInfo[i]);
        }
    }
}

请注意main声明抛出InterruptedException。当sleep是激活的时候,若有另一个线程中断当前线程时,则sleep抛出异常。由于该应用程序还没有定义另一个线程来引起中断,所以考虑捕捉InterruptedException。

3.2.3 中断线程

中断是表明一个线程应该停止它正在做和将要做的事。线程通过在Thread对象调用interrupt来实现线程的中断。为了中断机制能正常工作,被中断的线程必须支持自己的中断。

1. 支持中断

如何实现线程支持自己的中断?这要看是它目前正在做什么。如果线程调用方法频繁抛出InterruptedException异常,那么它只要在run方法捕获了异常之后返回即可。例如:

for (int i = 0; i < importantInfo.length; i++) {

    // 暂停4秒
    try {
        Thread.sleep(4000);
    } catch (InterruptedException e) {
    
        // 已经中断,无须更多信息
        return;
    }
    
    // 打印消息
    System.out.println(importantInfo[i]);
}

很多方法都会抛出InterruptedException,如sleep,被设计成在收到中断时立即取消它们当前的操作并返回。

若线程长时间没有调用方法抛出InterruptedException的话,那么它必须定期调用Thread.interrupted,该方法在接收到中断后将返回true。

for (int i = 0; i < inputs.length; i++) {

    heavyCrunch(inputs[i]);
    
    if (Thread.interrupted()) {
    
        // 已经中断,无须更多信息
        return;
    }
}

在这个简单的例子中,代码简单地测试该中断,如果已接收到中断线程就退出。在更复杂的应用程序中,它可能会更有意义地抛出一个InterruptedException:

if (Thread.interrupted()) {
    throw new InterruptedException();
}

2. 中断状态标志

中断机制是使用被称为中断状态的内部标志实现的。调用Thread.interrupt可以设置该标志。当一个线程通过调用静态方法Thread.interrupted来检查中断时,中断状态被清除。非静态isInterrupted方法用于线程来查询另一个线程的中断状态,而不会改变中断状态标志。

按照惯例,任何方法因抛出一个InterruptedException而退出都会清除中断状态。当然,它可能因为另一个线程调用interrupt而让那个中断状态立即被重新设置回来。

3.2.4 等待另一个线程完成

join方法允许一个线程等待另一个线程完成。假设t是一个正在执行的Thread对象,那么执行方法

t.join();

会导致当前线程暂停执行直到t线程终止。join允许程序员指定一个等待周期,与sleep一样,等待时间是依赖于操作系统的时间,同时不能假设join等待时间是精确的。

像sleep一样,join并通过InterruptedException退出来响应中断。

2.3 节点之间的通信,构建低延迟高可靠的通信链路

节点与节点之间是完全独立、相互隔离的,节点之间传递信息的唯一方式是通过网络进行通信。而网络往往是不可靠的,即一个节点可以向其他节点通过网络发送消息,但发送消息的节点无法确认消息是否被接收节点完整正确收到。因此,在分布式系统中往往需要考虑网络通信异常的问题。

常见的网络通信异常包含以下几种。

3.3.1 消息丢失

消息丢失是最常见的网络异常。对于常见的IP网络来说,网络层不保证数据报文的可靠传递,在发生网络拥塞、路由变动、设备异常等情况时,都可能发生发送的数据丢失。由于网络数据丢失的异常存在,直接决定了分布式系统的协议必须能处理网络数据丢失的情况。

依据网络质量的不同,网络消息丢失的概率也不同,甚至可能出现在一段时间内某些节点之间的网络消息完全丢失的情况。如果某些节点的直接的网络通信正常或丢包率在合理范围内,而某些节点之间始终无法正常通信,则称这种特殊的网络异常为“网络分化”(network partition)。网络分化是一类常见的网络异常,尤其当分布式系统部署在多个机房之间时。

3.3.2 消息乱序

消息乱序是指节点发送的网络消息有一定的概率不是按照发送时的顺序依次到达目的节点。通常由于常由于IP网络的存储转发机制、路由不确定性等问题,网络报文乱序也是一种常见的网络异常。这就要求设计分布式协议时,考虑使用序列号等机制处理网络消息的乱序问题,使得无效的、过期的网络消息不影响系统的正确性。

3.3.3 数据错误

网络上传输的数据有可能发生比特错误,从而造成数据错误。通常使用一定的校验码机制可以较为简单的检查出网络数据的错误,从而丢弃错误的数据。

3.3.4 不可靠的TCP

TCP协议为应用层提供了可靠的、面向连接的传输服务。该协议设计初衷就是在不可靠的网络之上建立可靠的传输服务。TCP协议出色设计体现在以下几个方面:

  • TCP协议通过为传输的每一个字节设置协议通过为传输的每一个字节设置顺序递增的序列号,由接收方在收到数据后按序列号重组数据并发送确认信息。当发现数据包丢失时,TCP协议重传丢失的数据包,从而解决了网络数据包丢失的问题和数据包乱序问题。
  • TCP协议为每个TCP数据段使用32位的校验和从而检查数据错误问题。
  • TCP协议通过设置接收和发送窗口的机制极大的提高了传输性能,解决了网络传输的时延与吞吐问题。TCP协议最为复杂而巧妙的是其几十年来不断改进的拥塞控制算法,使得TCP可以动态感知底层链路的带宽加以合理使用并与其他TCP链接分享带宽(TCP friendly)。

TCP协议在通常情况下是非常可靠的协议,然而在分布式系统的协议设计中不能认为所有网络通信都基于TCP协议则通信就是可靠的。一方面,TCP协议保证了TCP协议栈之间的可靠的传输,但无法保证两个上层应用之间的可靠通信。通常的,当某个应用层程序通过TCP的系统调用发送一个网络消息时,即使TCP系统调用返回成功,也仅仅只能意味着该消息被本机的TCP协议栈接受,一般这个消息是被放入了TCP协议栈的缓冲区中。再退一步讲,即使目的机器的TCP协议栈后续也正常收到了该消息,并发送了确认数据包,也仅仅意味着消息达到了对方机器的协议栈,而不能认为消息被目标应用程序进程接收到并正确处理了。当发送过程中出现宕机 等异常时,TCP协议栈缓冲区中的消息有可能被丢失从而无法被目标节点正确处理。更有甚者,在网络中断前,某数据包已经被目标进程正确处理,之后网络立刻中断,由于接收方的TCP协议栈发送的确认数据包始终被丢失,发送方的TCP协议栈也有可能告知发送进程发送失败。另一方面,TCP协议只能保证同一个TCP链接内的网络消息不乱序,TCP链接之间的网络消息顺序则无法保证。但在分布式系统中,一个节点向另一个节点发送数据,有可能是先后使用多个TCP链接发送,也有可能是同时并发多个TCP链接发送,那么发送进程不能认为先调用TCP系统调用发送的消息就一定会先于后发送的消息到达对方节点并被处理。

因此,在设计分布系统的网络协议时一定要考虑网络异常。有关通信的内容还会在下个章节继续探讨。

3.1 理解本地过程调用,初步掌握分布式体系调用技巧

进程间通信(Inter-Process Communication,IPC)指至少两个进程或线程间传送数据或信号的一些技术或方法。进程是计算机系统分配资源的最小单位。每个进程都有自己的一部分独立的系统资源,彼此是隔离的。为了能使不同的进程互相访问资源并进行协调工作,才有了进程间通信。这些进程可以运行在同一计算机上或网络连接的不同计算机上。进程间的通信技术包括消息传递、同步、共享内存和远程过程调用。进程间通信是一种标准的UNIX通信机制。

进程间通信可以分为本地过程调用和远程过程调用。

本节介绍本地过程调用。

4.1.1 本地过程调用的概念

本地过程调用(Local Procedure Call,LPC)是指被调用的过程(函数)与调用过程处于同一个进程中。典型的情况是,调用者通过执行某条机器指令把控制传给新过程,被调用过程保存机器寄存器的值,并在栈顶分配存放其本地变量的空间。

本地过程调用的基础是一种称为“端口(Port)”的进程间通信机制,类似于本地的Unix域的Socket。这种Port机制提供了面向报文传递(message passing)的进程间通信,而本地过程调用则是建立在这个基础上的高层机制,目的是提供跨进程的过程调用。注意这里所谓“跨进程的过程调用”不同于以前所说的“跨进程操作”。前者是双方有约定、遵循一定规程的、有控制的服务提供,被调用者在向外提供一些什么服务、即提供哪些函数调用方面是自主的,而后者则可以是在不知不觉之间的被利用、被操纵。前者是良性的,而后者可以是恶性的。

本地过程调用通常也被称为轻量过程调用或者本地进程间通信。在Windows Vista中,ALPC(Advanced Local Procedure Call,高级本地进程通信)替代了LPC。ALPC提供了一个高速可度量的通信机制,这样便于实现需要在用户模式下高速通信的用户模式驱动程序框架(UMDF,User-Mode Driver Framework)。

4.1.2 本地过程调用的实现

LPC是由内核的“端口”对象实现,这样可以确保安全(由访问控制表规定持有特定的安全标识符才可以访问)并可以验证链接另一端进程的身份。程序也可以对每一个信息设定安全标识符,并测试对应信息的变化,以实现每一条消息的安全性。

服务端和客户端之间典型的连接由下列过程表示:

  • 服务端进程建立命名服务器连接端口对象,并等待客户端连接;
  • 客户端通过向这一端口发送消息来建立连接;
  • 如果服务端同意建立连接,便会建立两个无名端口:
  • 客户端连接端口:客户线程由此向服务端发送数据;
  • 服务端连接端口:服务端由此向客户端发送数据;每个客户端都分配一个独立的接口;
  • 服务端持有一个服务连接端口的句柄,同时客户端也持有一个客户连接端口的句柄,这样进程间通信的通道就建立了。

让我们看看本地过程调用是如何在编程语言中实现的。考虑下面的C语言的调用:

count = read(fd, buf, nbytes);

其中,fd为一个整型数,表示一个文件。buf为一个字符数组,用于存储读入的数据。nbytes为另一个整型数,用于记录实际读入的字节数。如果该调用位于主程序中,那么在调用之前堆栈的状态如图4-1(a)所示。为了进行调用,调用方首先把参数反序压入堆栈,即最后一个参数先压入,如图4-1(b)所示。在 read 操作运行完毕后,它将返回值放在某个寄存器中,移出返回地址,并将控制权交回给调用方。调用方随后将参数从堆栈中移出,使堆栈还原到最初的状态。

图4-1 LPC的调用过程

本地过程调用支持以下三种交换信息的方式:

  • 针对较短信息(小于256字节):系统内核在进程间直接复制消息,从发送方的地址空间拷贝消息至系统地址空间,之后再将消息拷贝至接收方的地址空间。
  • 针对较长消息(大于256字节):这需要在发送方和接收方之间建立一个共享内存区域。发送方首先将消息存放在共享内存中,再向接收方发送一个通知(可以通过如上发送短消息的方式实现),之后再由接收方从共享内存中读取这一消息。
  • 当消息的数据量过大,难以放入共享内存时,服务端可以直接读取和写入客户端的地址空间。

本地过程调用在Windows NT及其衍生系统中得到了广泛应用。在Win32子系统中,LPC应用于客户端和子系统服务器之间的通信(CSRSS)。在Windows NT 3.51版本中引入了快速LPC以提高调用速度。然而由于NT4.0中将部分关键服务端移入内核模式(win32k.sys)以提高系统效能,这一方法已基本被摒弃。

本地安全认证子系统服务(LSASS),会话管理器(SMSS)以及服务控制管理器均使用LPC端口和客户进程直接通信。Winlogon和安全引用监视器与LSASS进程之间的通信同样使用了LPC。

在Windows系统中,高级本地过程调用(ALPC)拥有比以往的本地过程调用(LPC)更优的性能。因为LPC只能通过同步请求/应答机制通信,而ALPC还可以使用IOCP实现通信。这样,ALPC就可以在消息数量和进程数量间保持一定平衡,保证了端口的高速通信。此外,ALPC还允许信息的批量传输,减少了进程在用户模式和内核模式之间的切换次数。

3.2 快速掌握远程过程调用,突破分布式服务调用障碍

RPC是远程过程调用(Remote Procedure Call)的缩写形式。Birrell和Nelson在1984发表于ACM Transactions on Computer Systems的论文Implementing remote procedure calls对RPC做了经典的诠释。RPC是指计算机A上的进程,调用另外一台计算机B上的进程,其中A上的调用进程被挂起,而B上的被调用进程开始执行,当值返回给A时,A进程继续执行。调用方可以通过使用参数将信息传送给被调用方,而后可以通过传回的结果得到信息。而这一过程,对于开发人员来说是透明的。 远程过程调用采用客户机/服务器(C/S)模式。请求程序就是一个客户机,而服务提供程序就是一台服务器。和常规或本地过程调用一样,远程过程调用是同步操作,在远程过程结果返回之前,需要暂时中止请求程序。使用相同地址空间的低权进程或低权线程允许同时运行多个远程过程调用。

图4-2描述了并发环境下RPC的调用过程。

图4-2 RPC的调用过程

4.2.1 远程过程调用原理

RPC背后的思想是尽量使远程过程调用具有与本地调用相同的形式。假设程序需要从某个文件中读取数据,程序员在代码中执行read调用来取得数据。在传统的系统中,read例程由链接器从库中提取出来,然后链接器再将它插入目标程序中。read过程是一个短过程,一般通过执行一个等效的read系统调用来实现,即read过程是一个位于用户代码与本地操作系统之间的接口。

虽然read中执行了系统调用,但它本身依然是通过将参数压入堆栈的常规方式实现调用的。如图4-1(b)所示,程序员并不知道read干了什么。

RPC通过类似的途径来获得透明性。当read实际上是一个远程过程时(比如在文件服务器所在的机器上运行的过程),库中就放入read的另外一个版本,称为客户存根(client stub)。这种版本的read过程同样遵循图4-1(b)的调用次序,这点与原来的read过程相同。另一个相同点是其中也执行了本地操作系统调用。唯一不同点是它不要求操作系统提供数据,而是将参数打包成消息,而后请求将此消息发送到服务器,如图4-3所示。在对send的调用后,客户存根调用receive过程,随即阻塞自己,直到收到响应消息。

图4-3 客户与服务器之间的RPC原理

当消息到达服务器时,服务器上的操作系统将它传递给服务器存根(server stub)。服务器存根是客户存根在服务器端的等价物,也是一段代码,用来将通过网络输入的请求转换为本地过程调用。服务器存根一般先调用receive,然后被阻塞,等待消息输入。收到消息后,服务器将参数从消息中提取出来,然后以常规方式调用服务器上的相应过程(见图4-3)。从服务器角度看,过程好像是由客户直接调用的一样:参数和返回地址都位于堆栈中,一切都很正常。服务器执行所要求的操作,随后将得到的结果以常规的方式返回给调用方。以read为例,服务器将用数据填充read中第二个参数指向的缓存区,该缓存区是属于服务器存根内部的。

调用完后,服务器存根要将控制权交回给客户发出调用的过程,它将结果(缓存区)打包成消息,随后调用send将结果返回给客户。事后,服务器存根一般会再次调用receive,等待下一个输入的请求。

客户机器接收到消息后,客户操作系统发现该消息属于某个客户进程(实际上该进程是客户存根,只是操作系统无法区分二者)。操作系统将消息复制到相应的缓存区中,随后解除对客户进程的阻塞。客户存根检查该消息,将结果提取出来并复制给调用者,而后以通常的方式返回。当调用者在read调用进行完毕后重新获得控制权时,它所知道的唯一事情就是已经得到了所需的数据。它不知道操作是在本地操作系统进行的,还是远程完成的。

整个方法中,客户方可以简单地忽略不关心的内容。客户所涉及的操作只是执行普通的(本地)过程调用来访问远程服务,它并不需要直接调用send和receive。消息传递的所有细节都隐藏在双方的库过程中,就像传统库隐藏了执行实际系统调用的细节一样。 概况来说,远程过程调用包含如下步骤:

  • (1)客户过程以正常的方式调用客户存根。
  • (2)客户存根生成一个消息,然后调用本地操作系统。
  • (3)客户端操作系统将消息发送给远程操作系统。
  • (4)远程操作系统将消息交给服务器存根。
  • (5)服务器存根调将参数提取出来,而后调用服务器。
  • (6)服务器执行要求的操作,操作完成后将结果返回给服务器存根。
  • (7)服务器存根将结果打包成一个消息,而后调用本地操作系统。
  • (8)服务器操作系统将含有结果的消息发送给客户端操作系统。
  • (9)客户端操作系统将消息交给客户存根。
  • (10)客户存根将结果从消息中提取出来,返回给调用它的客户存根。

以上步骤就是客户过程将客户存根发出的本地调用转换成对服务器过程的本地调用,而客户端和服务器都不会意识到中间步骤的存在。

RPC的主要好处是双重的。首先,程序员可以使用过程调用语义来调用远程函数并获取响应。其次,简化了编写分布式应用程序的难度,因为RPC隐藏了所有的网络代码存根函数。应用程序不必担心一些细节,比如socket、端口号以及数据的转换和解析。在OSI参考模型中,RPC跨越了会话层和表示层。

4.2.2 如何实现远程过程调用

要实现远程过程调用,需考虑以下几个问题。

1. 如何传递参数

参数有两种,一种是值参数,一种是引用参数。

传递值参数比较简单,图4-4是一个简单RPC进行远程计算的例子。其中,远程过程add(i,j)有两个参数i和j,其结果是返回i和j的算术和。

图4-4 通过RPC进行远程计算的步骤

通过RPC进行远程计算的步骤如下:

  • (1)将参数放入消息中,并在消息中添加要调用的过程的名称或者编码。
  • (2)消息到达服务器后,服务器存根对该消息进行分析,以判明需要调用哪个过程,随后执行相应的调用。
  • (3)服务器运行完毕后,服务器存根将服务器得到的结果打包成消息送回客户存根,客户存根将结果从消息中提取出来,把结果值返回给客户端。

当然,这里只是做了简单的演示,在实际分布式系统中,还需要考虑其他情况,因为不同的机器对于数字、字符和其他类型的数据项的表示方式常有差异。比如整数型,就有Big Endian和Little Endian之分。

传递引用参数相对来说比较困难。单纯传递参数的引用(也包含指针)是完全没有意义的,因为引用地址传递给远程计算机,其指向的内存位置可能跟远程系统上完全不同。如果你想支持传递引用参数,就必须发送参数的副本,将它们放置在远程系统内存中,向它们传递一个指向服务器函数的指针,然后将对象发送回客户端,复制它的引用。如果远程过程调用必须支持引用复杂的结构,比如树和链表,它们需要将结构复制到一个无指针的表示里面(比如,一个扁平的树),并传输到远程端来重建数据结构。

2. 如何表示数据

在本地系统上不存在数据不相容的问题,因为数据格式总是相同的。而在分布式系统中,不同远程机器上可能有不同的字节顺序,不同大小的整数,以及不同的浮点表示。对于RPC,如果想与异构系统通信,我们就需要想出一个“标准”来对所有数据类型进行编码,并可以作为参数传递。例如,ONC RPC使用XDR(eXternal Data Representation)格式。这些数据表示格式可以使用隐式或显式类型。隐式类型是指只传递值,而不传递变量的名称或类型。常见的例子是ONC RPC的XDR和DCE RPC的NDR。显式类型指需要传递每个字段的类型和值。常见的例子是ISO标准ASN.1(Abstract Syntax Notation)、JSON(JavaScript Object Notation)、Google Protocol Buffers,以及各种基于XML的数据表示格式。

3. 如何选用传输协议

有些实现只允许使用一个协议(例如TCP)。大多数RPC实现支持几个,例如TCP、HTTP等,并允许用户选择。

4. 出错时会发生什么

相比于本地过程调用,远程过程调用出错的机会更多。由于本地过程调用没有过程调用失败的概念,项目使用远程过程调用必须准备测试远程过程调用的失败或捕获异常。

5. 远程调用的语义是什么

调用一个普通的过程语义很简单:当我们调用时,过程被执行。远程过程完全一次性调用成功是非常难以实现的。执行远程过程可以有如下结果:

  • 如果服务器崩溃或进程在运行服务器代码之前就死了,那么远程过程会被执行0次;
  • 如果一切工作正常,远程过程会被执行1次;
  • 如果服务器返回服务器存根后在发送响应前就崩溃了,远程过程会被执行1次或者多次。客户端接收不到返回的响应,可以决定再试一次,因此出现多次执行函数。如果没有再试一次,函数执行一次;
  • 如果客户机超时和重新传输,那么远程过程会被执行多次。也有可能是原始请求延迟了,两者都可能执行或不执行。

RPC系统通常会提供至少一次或最多一次的语义,或者在两者之间选择。如果需要了解应用程序的性质和远程过程的功能是否安全,可以通过多次调用同一个函数来验证。如果一个函数可以运行任何次数而不影响结果,这是幂等(idempotent)函数,如每天的时间、数学函数、读取静态数据等。否则,它是一个非幂等(nonidempotent)函数,如添加或修改一个文件。

6. 远程调用的性能怎么样

毫无疑问,一个远程过程调用将比常规的本地过程调用慢得多,因为产生了额外的步骤以及网络传输本身存在延迟。然而,这并不应该阻止我们使用远程过程调用。

7. 远程调用安全吗

使用RPC,我们必须关注各种安全问题:

  • 客户端发送消息到远程过程,这个过程是可信的吗?
  • 客户端发送消息到远程计算机,这个远程机器是可信的吗?
  • 服务器如何验证接收的消息来自合法的客户端?服务器如何识别客户端?
  • 消息在网络中传播时如何防止被其他进程嗅探?
  • 如何防止消息在客户端和服务器的网络传播中被其他进程拦截和修改?
  • 协议能防止重播攻击吗?
  • 如何防止消息在网络传播中被意外损坏或截断?

8. 远程过程调用的优点

远程过程调用有诸多优点:

  • 不必担心传输地址问题。服务器可以绑定到任何可用的端口,然后用RPC名称服务来注册端口。客户端将通过该名称服务来找到对应的端口号所需要的程序。而这一切对于程序员来说是透明的。
  • 系统可以独立于传输提供者。自动生成服务器存根使其可以在系统上的任何一个传输提供者上可用,包括TCP和UDP,而这些,客户端是可以动态选择的。当代码发送以后,接收消息是自动生成的,而不需要额外的编程代码。
  • 应用程序在客户端只需要知道一个传输地址—名称服务,负责告诉应用程序去哪里连接服务器函数集。
  • 使用函数调用模型来代替socket的发送/接收(读/写)接口。用户不需要处理参数的解析。

4.2.3 远程过程调用API

任何RPC实现都需要提供一组支持库。

  • 名称服务操作:注册和查找绑定信息(端口、机器)。允许一个应用程序使用动态端口(操作系统分配的)。
  • 绑定操作:使用适当的协议建立客户机/服务器通信(建立通信端点)。
  • 终端操作:注册端点信息(协议、端口号、机器名)到名称服务并监听过程调用请求。这些函数通常被自动生成的主程序—服务器存根(骨架)所调用。
  • 安全操作:系统应该提供机制保证客户端和服务器之间能够相互验证,两者之间提供一个安全的通信通道。
  • 国际化操作(可能):目前,有一小部分RPC包支持转换包括时间格式、货币格式和特定于语言的字符串的功能。
  • 封送处理/数据转换操作:函数将数据序列化为一个普通的的字节数组,通过网络进行传递,并能够重建。
  • 存根内存管理和垃圾收集:存根可能需要分配内存来存储参数,特别是模拟引用传递语义。RPC包需要分配和清理任何这样的内存。它们也可能需要为创建网络缓存区而分配内存。RPC包支持对象,RPC系统需要跟踪远程客户端是否仍有引用对象或一个对象是否可以删除。
  • 程序标识操作:允许应用程序访问(或处理)RPC接口集的标识符,这样的服务器提供的接口集可以被用来交流和使用。
  • 对象和函数的标识操作:允许将远程函数或远程对象的引用传递给其他进程,并不是所有的RPC系统都支持。

所以,判断一种通信方式是否是RPC,就看它是否提供上述的API。

4.2.4 远程过程调用发展历程

以下远程过程调用发展历程。

1. 第一代RPC

Sun公司是第一个提供商业化RPC库和RPC编译器。在1980年代中期Sun计算机提供RPC,并在Sun Network File System(NFS)上得到支持。该协议被主要以Sun和AT&T为首的Open Network Computing(开放网络计算)作为一个标准来推动。这是一个非常轻量级RPC系统,可在大多数POSIX和类POSIX操作系统中使用,包括Linux、SunOS、OS X和各种发布版本的BSD。这样的系统被称为Sun RPC或ONC RPC。该阶段的其他代表产品还有DCE RPC。

2. 第二代RPC支持对象

面向对象的语言开始在1980年代末兴起,很明显,当时的Sun ONC和DCE RPC系统都没有提供任何支持,诸如从远程类实例化远程对象、跟踪对象的实例或提供支持多态性。现有的RPC机制虽然可以运作,但它们仍然不支持自动、透明的方式的面向对象编程技术。该阶段的主要产品有微软DCOM(COM+)、CORBA、Java RMI。

3. 第三代RPC以及Web Services

传统RPC解决方案可以工作在互联网上,但问题是,它们通常严重依赖于动态端口分配,往往要进行额外的防火墙配置。Web Services成为一组协议,允许服务被发布、发现,并用于技术无关的形式。即服务不应该依赖于客户的语言、操作系统或机器架构。该阶段的代表产品有XML-RPC、SOAP、Microsoft .NET Remoting、JAX-WS等。

3.3 掌握常用网络I/O模型,建立系统级性能优化思维

I/O操作主要是操作系统来完成的。根据UNIX的设计,共有五种类型的I/O模型:

  • 阻塞I/O;
  • 非阻塞I/O;
  • I/O复用(select和poll);
  • 信号驱动I/O(SIGIO);
  • 异步I/O(Posix.1aio_系列函数)。

上述模型或多或少的影响了其他操作系统的I/O模型设计。

4.3.1 阻塞I/O模型

阻塞I/O模型是指,当请求无法立即完成则保持阻塞。主要分为以下两个阶段:

  • 阶段1:等待数据就绪。网络I/O的情况就是等待远端数据陆续抵达;磁盘I/O的情况就是等待磁盘数据从磁盘上读取到内核态内存中。
  • 阶段2:数据复制。出于系统安全,用户态的程序没有权限直接读取内核态内存,因此内核负责把内核态内存中的数据复制一份到用户态内存中。

阻塞I/O模型如图4-5所示。

图4-5 阻塞I/O模型

本节中将recvfrom函数视为系统调用。一般recvfrom实现都有一个从应用程序进程中运行到内核中运行的切换,一段事件后再跟一个返回到应用进程的切换。

图4-5中,进程阻塞的整段时间是指从调用recvfrom开始到它返回的这段时间,当进程返回成功指示时,应用进程开始处理数据报。

4.3.2 非阻塞I/O模型

非阻塞I/O模型处理流程如下:

  • socket设置为NONBLOCK(非阻塞)就是告诉内核,当所请求的I/O操作无法完成时,不要将进程睡眠,而是立刻返回一个错误码(EWOULDBLOCK),这样请求就不会阻塞;
  • I/O操作函数将不断地测试数据是否已经准备好,如果没有准备好,继续测试,直到数据准备好为止。整个I/O请求的过程中,虽然用户线程每次发起I/O请求后可以立即返回,但是为了等到数据,仍需要不断地轮询、重复请求,这是对CPU时间的极大浪费。
  • 数据准备好了,从内核复制到用户空间。

非阻塞I/O模型如图4-6所示。

图4-6 非阻塞I/O模型

一般很少直接使用这种模型,而是在其他I/O模型中使用非阻塞I/O这一特性。这种方式对单个I/O请求的意义不大,但给I/O复用铺平了道路。

4.3.3 I/O复用模型

I/O复用会用到select或者poll函数,在这两个系统调用中的某一个上阻塞,而不是阻塞于真正的I/O系统调用。函数也会使进程阻塞,但是和阻塞I/O所不同的是,这两个函数可以同时阻塞多个I/O操作。而且可以同时对多个读操作、多个写操作的I/O函数进行检测,直到有数据可读或可写时,才真正调用I/O操作函数。

I/O复用模型如图4-7所示。

图4-7 I/O复用模型

从流程上来看,使用select函数进行I/O请求和同步阻塞模型没有太大的区别,甚至还多了添加监视socket,以及调用select函数的额外操作,效率更差。但是,使用select最大的优势是用户可以在一个线程内同时处理多个socket的I/O请求。用户可以注册多个socket,然后不断地调用select来读取被激活的socket,即可达到在同一个线程内同时处理多个I/O请求的目的。而在同步阻塞模型中,必须通过多线程的方式才能达到这个目的。

I/O复用模型使用了Reactor设计模式实现了这一机制。

调用select/poll该方法由一个用户态线程负责轮询多个socket,直到某个阶段1的数据就绪,再通知实际的用户线程执行阶段2的复制操作。通过一个专职的用户态线程执行非阻塞I/O轮询,模拟实现了阶段1的异步化。

在Java领域,著名的网络编程框架Netty就是采用了Reactor模型。

4.3.4 信号驱动I/O(SIGIO)模型

首先,我们允许socket进行信号驱动I/O,并通过调用sigaction来安装一个信号处理函数,进程继续运行并不阻塞。当数据准备好时,进程会收到一个SIGIO信号,可以在信号处理函数中调用recvfrom来读取数据报,并通知主循环数据已准备好被处理,也可以通知主循环,让它来读取数据报。

信号驱动I/O(SIGIO)模型如图4-8所示。

图4-8 信号驱动I/O(SIGIO)模型

该模型的好处是,当等待数据报到达时,可以不阻塞。主循环可以继续执行,只是等待信号处理程序的通知;或者数据已准备好被处理,或者数据报已准备好被读。

4.3.5 异步I/O模型

异步I/O是POSIX规范定义的。通常,这些函数会通知内核来启动操作并在整个操作(包括从内核复制数据到我们的缓存中)完成时通知我们。

该模式与信号驱动I/O(SIGIO)模型的不同点在于,驱动I/O(SIGIO)模型告诉我们I/O操作何时可以启动,而异步I/O模型告诉我们I/O操作何时完成。

调用aio_read函数,告诉内核传递描述字、缓存区指针、缓存区大小、文件偏移,然后立即返回,我们的进程不阻塞于等待I/O操作的完成。当内核将数据复制到缓存区后,才会生成一个信号,来通知应用程序。

异步I/O模型如图4-9所示。

图4-9 异步I/O模型

异步I/O模型使用了Proactor设计模式实现了这一机制。异步I/O模型会告知内核,当整个过程(包括阶段1和阶段2)全部完成时,通知应用程序来读数据。

4.3.6 几种I/O模型的比较

前四种模型的区别是阶段1不相同,阶段2基本相同,都是将数据从内核复制到调用者的缓存区。而异步I/O的两个阶段都不同于前四个模型。几种I/O模型的比较如图4-10所示。

图4-10 异步I/O模型

同步I/O操作引起请求进程阻塞,直到I/O操作完成。异步I/O操作不引起请求进程阻塞。上面前四个模型—阻塞I/O模型、非阻塞I/O模型、I/O复用模型和信号驱动I/O模型都是同步I/O模型,而异步I/O模型才是真正的异步I/O。

3.4 理解I/O操作中的常用术语,消除分布式开发沟通壁垒

前面一节我们介绍了常用的I/O模型。在I/O操作中的我们经常会遇到常用术语:

  • 阻塞和非阻塞
  • 同步与异步

那么这些术语有怎么样的联系和区别呢?

4.4.1 阻塞和非阻塞

阻塞和非阻塞描述的是用户线程调用内核I/O操作的方式:

  • 阻塞是指I/O操作需要彻底完成后才返回到用户空间;
  • 非阻塞是指I/O操作被调用后立即返回给用户一个状态值,无须等到I/O操作彻底完成。

以生活中家庭主妇为例子。家庭主妇往往要做非常多的家务,比如煮开水、拖地等。阻塞是指,家庭主妇先去煮开水,她必须要等到水开了才能离开去做其他家务。非阻塞是指,家庭主妇先去把水煮上,不用等水开就离开去拖地了,当然,在拖地过程中,她会时不时停下来去检查下水是否已经开了。

非阻塞的好处是家庭主妇的能力得到了释放,可以多任务(煮开水、拖地)并发了。

4.4.2 同步与异步

同步和异步描述的是用户线程与内核的交互方式:

  • 同步是指用户线程发起I/O请求后需要等待或者轮询内核I/O操作完成后才能继续执行;
  • 异步是指用户线程发起I/O请求后仍继续执行,当内核I/O操作完成后会通知用户线程,或者调用用户线程注册的回调函数。

还是以生活中家庭主妇为例子。在阻塞和非阻塞的例子中,家庭主妇需要会时不时停下拖地这个动作,而去检查下水是否已经开了(轮询),这其实一定程度上是一种浪费,因为拖地这个任务经常被打断了。那么是否有一种机制,当开水煮好后再通知主妇过去关火呢?这就是异步的好处,异步相当于水壶上的报警器,当水煮好后,水壶会发出提示声,以告知家庭主妇水已经煮好了,此时家庭主妇才停下拖地去关火。

异步的好处是减少了轮询。

4.4.3 总结

一个I/O操作其实分成了两个步骤:发起I/O请求和实际的I/O操作。

阻塞I/O和非阻塞I/O的区别在于第一步,发起I/O请求是否会被阻塞,如果阻塞直到完成那么就是传统的阻塞I/O,如果不阻塞,那么就是非阻塞I/O。

同步I/O和异步I/O的区别就在于第二个步骤是否阻塞,如果实际的I/O读写阻塞请求进程,那么就是同步I/O。

3.5 实战:在Java中实现阻塞I/O模型

Java语言从初创之日起,就是为网络而生的。随着互联网应用的发展,Java也被越来越多的企业所采用。接下来将演示了如何基于Java实现常用网络I/O模型。

执行以下命令进行初始化项目原型:

mvn archetype:generate -DgroupId=com.waylau.java.demo -DartifactId=java-io-demo -DarchetypeArtifactId=maven-archetype-quickstart -DarchetypeVersion=1.5 -DinteractiveMode=false

本节先演示在Java中实现阻塞I/O模型的网络应用。

4.5.1 开发Echo协议的服务器

早期的Java提供java.net包用于开发网络应用,这类API也被称为Java OIO(Old-blocking I/O,阻塞I/O)。以下演示了使用java.net包及java.io来开发Echo协议的客户端及服务器的过程。

提示:Echo协议是指把接收到的信息按照原样返回,其重要的作用是用于检测和调试网络。这个协议可以基于TCP/UDP协议用于服务器检测端口7有无信息。有关该协议内容详见tools.ietf.org/html/rfc862

以下是使用原生java.net包来开发Echo协议的服务器的示例:

package com.waylau.java.demo.net;

import java.io.IOException;
import java.net.ServerSocket;

/**
 * BlockingEchoServer 阻塞IO Echo服务器
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/25
 **/
public class BlockingEchoServer {

    private static final int DEFAULT_PORT = 7;

    public static void main(String[] args) {
        // 解析传参进来的端口号
        int port;
        try {
            port = Integer.parseInt(args[0]);
        } catch (Exception e) {
            port = DEFAULT_PORT;
        }

        // 创建ServerSocket
        try (var serverSocket = new ServerSocket(port)) {
            System.out.println("BlockingEchoServer启动,监听端口:" + port);

            // 接受客户端连接,生成Socket实例
            while (true) {
                try (var clientSocket = serverSocket.accept()) {

                    System.out.println("接收到客户端连接:" + clientSocket.getInetAddress());
                    while (true) {
                        var input = clientSocket.getInputStream();
                        var output = clientSocket.getOutputStream();
                        var buffer = new byte[1024];

                        // 读取客户端的信息
                        var bytesRead = input.read(buffer);
                        if (bytesRead == -1) {
                            break;
                        }

                        // 发送信息给客户端
                        output.write(buffer, 0, bytesRead);

                        System.out.println("Echo:" + new String(buffer, 0, bytesRead));
                        System.out.println("Echo完毕");
                        System.out.println("--------------------------------------------------------------------------------");
                        System.out.println("等待下一个客户端连接...");
                        System.out.println("--------------------------------------------------------------------------------");
                    }
                } catch (IOException e) {
                    System.out.println("BlockingEchoServer异常:" + e.getMessage());
                }
            }
        } catch (IOException e) {
            System.out.println("BlockingEchoServer启动异常,监听端口:" + port);
            System.out.println(e.getMessage());
        }
    }
}

上述例子BlockingEchoServer实现了Echo协议。BlockingEchoServer主要是使用了java.net包中Socket和ServerSocket类库。这两个类库主要是用于开发基于TCP协议的应用。如果是想要开发UDP协议的应用,则需要使用DatagramSocket类。

ServerSocket用于服务器端,而Socket是建立网络连接时使用的。在客户端连接服务器成功时,客户端以及服务器两端都会产生一个Socket实例,通过操作这个实例,来完成所需的会话。对于一个网络连接来说,Socket是平等的,并没有差别,不会因为在服务器端或在客户端而产生不同的级别,不管是Socket还是ServerSocket,他们的工作都是通过Socket类和其子类来完成的。

运行BlockingEchoServer,可以看到控制台输出内容如下:

BlockingEchoServer启动,监听端口:7

4.5.2 开发Echo协议的客户端

以下是使用原生java.net包来开发Echo协议的客户端的示例:

package com.waylau.java.demo.net;

import java.io.IOException;
import java.net.UnknownHostException;

/**
 * BlockingEchoClient 阻塞IO Echo客户端
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/25
 **/
public class BlockingEchoClient {
    private static final int DEFAULT_PORT = 7;
    public static final String DEFAULT_HOST = "localhost";

    public static void main(String[] args) {
        // 解析传参进来的服务名、端口号
        String host;
        int port;
        try {
            host = args[0];
            port = Integer.parseInt(args[1]);
        } catch (Exception e) {
            host = DEFAULT_HOST;
            port = DEFAULT_PORT;
        }

        // 创建Socket
        try (var socket = new java.net.Socket(host, port)) {
            System.out.println("BlockingEchoClient启动,连接:" + host + ":" + port);
            var input = socket.getInputStream();
            var output = socket.getOutputStream();
            var reader = new java.util.Scanner(System.in);
            while (true) {
                System.out.print("请输入:");
                var line = reader.nextLine();
                output.write(line.getBytes());
                output.flush();
                var buffer = new byte[1024];

                var bytesRead = input.read(buffer);
                if (bytesRead == -1) {
                    break;
                }

                System.out.println("Echo:" + new String(buffer, 0, bytesRead));
                System.out.println("--------------------------------------------------------------------------------");
                System.out.println("等待下一个输入...");
                System.out.println("--------------------------------------------------------------------------------");
            }
        } catch (UnknownHostException e) {
            System.out.println("BlockingEchoClient异常:" + e.getMessage());
            System.out.println("请检查服务名是否正确!");
            System.exit(1);
        } catch (IOException e) {
            System.out.println("IO异常:" + e.getMessage());
            System.exit(1);
        }
    }
}

BlockingEchoClient的Socket的使用与BlockingEchoServer的Socket的使用基本类似。如果你本地的JDK版本是11以上,则可以跳过编译阶段直接运行源码,命令如下:

$ java BlockingEchoClient.java localhost 7

提示:从JDK 11开始,可以直接运行启动Java源码文件。有关Java的最新特性,可见笔者所著的《Java核心编程》。

当BlockingEchoClient客户端与BlockingEchoServer服务器建立了连接之后,客户端就可以与服务器进行交互了。

当我们在客户端输入“hello!”字符时,服务器也会将“hello!”发送回客户端,客户端输入的任何内容,服务器也会原样返回。

BlockingEchoClient控制台输出内容如下:

请输入:hello!
Echo:hello!
--------------------------------------------------------------------------------
等待下一个输入...
--------------------------------------------------------------------------------
请输入:yes!
Echo:yes!
--------------------------------------------------------------------------------
等待下一个输入...
--------------------------------------------------------------------------------
请输入:

BlockingEchoServer控制台输出内容如下:

接收到客户端连接:/127.0.0.1
Echo:hello!
Echo完毕
--------------------------------------------------------------------------------
等待下一个客户端连接...
--------------------------------------------------------------------------------
Echo:yes!
Echo完毕
--------------------------------------------------------------------------------
等待下一个客户端连接...
--------------------------------------------------------------------------------

4.5.3 java.net包API的缺点

BlockingEchoClient和BlockingEchoServer代码只是一个简单的示例,如果是要创建一个复杂的客户端-服务器协议仍然需要大量的样板代码,并且要求开发者必须掌握相当多的底层技术细节才能使它整个流畅地运行起来。Socket和ServerSocket类库的API只支持由本地系统套接字库提供的所谓的阻塞函数,因此客户端与服务器的通信是阻塞的,并且要求每个新加入的连接,必须在服务器中创建一个新的Socket实例。这极大消耗了服务器的性能,并且也使得连接数受到了限制。

BlockingEchoClient客户端与BlockingEchoServer服务器所实现的方式是阻塞的。

那么Java是否可以实现非阻塞的I/O程序呢?答案是肯定的,且听下回分解。

4.6 实战:在Java中实现非阻塞I/O模型

从Java 1.4开始,Java提供了NIO(New I/O),用来替代标准Java I/O API(指1.1.1节所描述的早期的Java网络编程API)。Java NIO也被称为“Non-blocking I/O”,提供了非阻塞I/O的方式,用法与标准I/O有非常大的差异。

Java NIO提供了以下几个核心概念。

  • 通道(Channel)和缓冲区(Buffer):标准的I/O基于字节流和字符流进行操作的,而NIO是基于通道和缓冲区进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。
  • 非阻塞I/O(Non-blocking I/O):Java NIO可以让你非阻塞的使用I/O,例如,当线程从通道读取数据到缓冲区时,线程还是可以进行其他事情。当数据被写入到缓冲区时,线程可以继续处理它。从缓冲区写入通道也类似。
  • 选择器(Selector):Java NIO引入了选择器的概念,选择器用于监听多个通道的事件(比如:连接打开,数据到达)。因此,单个的线程可以监听多个数据通道,这极大提升了单机的并发能力。

Java NIO API位于java.nio包下。以下是Java NIO版本实现的支持Echo协议的客户端及服务器。

4.6.1 开发NIO版本的Echo服务器

以下是使用原生Java NIO API来开发Echo协议的服务器的示例:

package com.waylau.java.demo.net;

import java.io.IOException;
import java.net.ServerSocket;

/**
 * BlockingEchoServer 阻塞IO Echo服务器
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/25
 **/
public class BlockingEchoServer {

    private static final int DEFAULT_PORT = 7;

    public static void main(String[] args) {
        // 解析传参进来的端口号
        int port;
        try {
            port = Integer.parseInt(args[0]);
        } catch (Exception e) {
            port = DEFAULT_PORT;
        }

        // 创建ServerSocket
        try (var serverSocket = new ServerSocket(port)) {
            System.out.println("BlockingEchoServer启动,监听端口:" + port);

            // 接受客户端连接,生成Socket实例
            while (true) {
                try (var clientSocket = serverSocket.accept()) {

                    System.out.println("接收到客户端连接:" + clientSocket.getInetAddress());
                    while (true) {
                        var input = clientSocket.getInputStream();
                        var output = clientSocket.getOutputStream();
                        var buffer = new byte[1024];

                        // 读取客户端的信息
                        var bytesRead = input.read(buffer);
                        if (bytesRead == -1) {
                            break;
                        }

                        // 发送信息给客户端
                        output.write(buffer, 0, bytesRead);

                        System.out.println("Echo:" + new String(buffer, 0, bytesRead));
                        System.out.println("Echo完毕");
                        System.out.println("--------------------------------------------------------------------------------");
                        System.out.println("等待下一个客户端连接...");
                        System.out.println("--------------------------------------------------------------------------------");
                    }
                } catch (IOException e) {
                    System.out.println("BlockingEchoServer异常:" + e.getMessage());
                }
            }
        } catch (IOException e) {
            System.out.println("BlockingEchoServer启动异常,监听端口:" + port);
            System.out.println(e.getMessage());
        }
    }
}

上述例子NonBlokingEchoServer实现了Echo协议,ServerSocketChannel与ServerSocket的职责类似。相比较而言,ServerSocket读和写操作都是同步阻塞的,在面对高并发的场景时,需要消耗大量的线程来维持连接。CPU在大量的线程之间频繁切换,性能损耗很大。一旦单机的连接超过1万,甚至达到几万的时候,服务器的性能会急剧下降。

NIO的Selector却很好地解决了这个问题,用主线程(一个线程或者是 CPU 个数的线程)保持住所有的连接,管理和读取客户端连接的数据,将读取的数据交给后面的线程处理,后续线程处理完业务逻辑后,将结果交给主线程发送响应给客户端,这样少量的线程就可以处理大量连接的请求。

在上述NonBlokingEchoServer例子中,使用Selector注册Channel,然后调用它的select()方法。这个select()方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件。事件包括例如有新连接进来(OP_ACCEPT),数据接收(OP_READ)等。

运行,可以看到控制台输出内容如下:

BlockingEchoServer启动,监听端口:7
4.6.2 开发NIO版本的Echo客户端

以下是使用原生NIO API来开发Echo协议的客户端的示例:

package com.waylau.java.demo.net;

import java.io.IOException;
import java.net.UnknownHostException;

/**
 * BlockingEchoClient 阻塞IO Echo客户端
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/25
 **/
public class BlockingEchoClient {
    private static final int DEFAULT_PORT = 7;
    public static final String DEFAULT_HOST = "localhost";

    public static void main(String[] args) {
        // 解析传参进来的服务名、端口号
        String host;
        int port;
        try {
            host = args[0];
            port = Integer.parseInt(args[1]);
        } catch (Exception e) {
            host = DEFAULT_HOST;
            port = DEFAULT_PORT;
        }

        // 创建Socket
        try (var socket = new java.net.Socket(host, port)) {
            System.out.println("BlockingEchoClient启动,连接:" + host + ":" + port);
            var input = socket.getInputStream();
            var output = socket.getOutputStream();
            var reader = new java.util.Scanner(System.in);
            while (true) {
                System.out.print("请输入:");
                var line = reader.nextLine();
                output.write(line.getBytes());
                output.flush();
                var buffer = new byte[1024];

                var bytesRead = input.read(buffer);
                if (bytesRead == -1) {
                    break;
                }

                System.out.println("Echo:" + new String(buffer, 0, bytesRead));
                System.out.println("--------------------------------------------------------------------------------");
                System.out.println("等待下一个输入...");
                System.out.println("--------------------------------------------------------------------------------");
            }
        } catch (UnknownHostException e) {
            System.out.println("BlockingEchoClient异常:" + e.getMessage());
            System.out.println("请检查服务名是否正确!");
            System.exit(1);
        } catch (IOException e) {
            System.out.println("IO异常:" + e.getMessage());
            System.exit(1);
        }
    }
}

NonBlockingEchoClient的SocketChannel的使用与NonBlokingEchoServer的SocketChannel的使用基本类似。启动客户端,命令如下:

$ java NonBlockingEchoClient.java localhost 7

当NonBlockingEchoClient客户端与NonBlokingEchoServer服务器建立了连接之后,客户端就可以与服务器进行交互了。

当我们在客户端输入“a”字符时,服务器也会将“a”发送回客户端,客户端输入的任务内容,服务器也会原样返回。

NonBlokingEchoServer控制台输出内容如下:

接收到客户端连接:/127.0.0.1
Echo:hello!
Echo完毕
--------------------------------------------------------------------------------
等待下一个客户端连接...
--------------------------------------------------------------------------------
Echo:yes!
Echo完毕
--------------------------------------------------------------------------------
等待下一个客户端连接...
--------------------------------------------------------------------------------

3.7 实战:在Java中实现异步I/O模型

从Java 1.7开始,Java提供了AIO(异步I/O)。Java AIO也被称为“NIO.2”,提供了异步I/O的方式,用法与标准I/O有非常大的差异。

Java AIO则采用“订阅-通知”模式,即应用程序向操作系统注册I/O监听,然后继续做自己的事情。当操作系统发生I/O事件,并且准备好数据后,再主动通知应用程序,触发相应的函数。

和同步I/O一样,Java的AIO也是由操作系统进行支持的。微软的Windows系统提供了一种异步I/O技术——IOCP(I/O CompletionPort,I/O完成端口),而在Linux平台下并没有这种异步I/O技术,所以使用的是epoll对异步I/O进行模拟。

Java AIO API同Java NIO一样,都是位于java.nio包下。以下是Java AIO版本实现的支持Echo协议的客户端及服务器。

4.7.1 实战:开发AIO版本的Echo服务器

以下是使用原生Java AIO API来开发Echo协议的服务器的示例:

package com.waylau.java.demo.aio;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousServerSocketChannel;
import java.nio.channels.AsynchronousSocketChannel;
import java.nio.channels.CompletionHandler;

/**
 * AsyncEchoServer 异步IO Echo服务器
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/25
 **/
public class AsyncEchoServer {
    private static final int DEFAULT_PORT = 7;

    public static void main(String[] args) {
        // 解析传参进来的端口号
        int port;
        try {
            port = Integer.parseInt(args[0]);
        } catch (Exception e) {
            port = DEFAULT_PORT;
        }

        // 创建AsynchronousServerSocketChannel实例
        try (var asyncServerSocketChannel = AsynchronousServerSocketChannel.open()) {
            System.out.println("AsyncEchoServer启动,监听:" + port);
            asyncServerSocketChannel.bind(new java.net.InetSocketAddress(port));

            // 设置参数
            asyncServerSocketChannel.setOption(java.net.StandardSocketOptions.SO_REUSEADDR, true);
            asyncServerSocketChannel.setOption(java.net.StandardSocketOptions.SO_RCVBUF, 1024);

            // 使用CompletionHandler处理异步连接
            acceptConnection(asyncServerSocketChannel);

            // 保持主线程运行
            Thread.currentThread().join();

        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        }
    }

    // 递归接受连接的辅助方法
    private static void acceptConnection(AsynchronousServerSocketChannel serverChannel) {
        serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Void>() {
            @Override
            public void completed(AsynchronousSocketChannel clientChannel, Void attachment) {
                try {
                    System.out.println("接收到客户端连接:" + clientChannel.getRemoteAddress());
                    System.out.println("等待客户端发送信息...");
                    System.out.println("--------------------------------------------------------------------------------");

                    // 继续接受下一个连接(真正实现并发)
                    acceptConnection(serverChannel);

                    // 为当前客户端开始读取数据
                    handleClient(clientChannel);

                } catch (IOException e) {
                    e.printStackTrace();
                }
            }

            @Override
            public void failed(Throwable exc, Void attachment) {
                System.out.println("接受客户端连接失败: " + exc.getMessage());
            }
        });
    }

    // 处理客户端数据的辅助方法
    private static void handleClient(AsynchronousSocketChannel clientChannel) {
        readClientData(clientChannel, ByteBuffer.allocate(1024));
    }

    private static void readClientData(AsynchronousSocketChannel clientChannel, ByteBuffer buffer) {
        clientChannel.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
            @Override
            public void completed(Integer result, ByteBuffer attachment) {
                if (result > 0) {
                    attachment.flip();
                    byte[] data = new byte[attachment.remaining()];
                    attachment.get(data);

                    System.out.println("Echo:" + new String(data));

                    // 回写数据,将attachment作为附件传递(而不是null)
                    clientChannel.write(ByteBuffer.wrap(data), attachment, new CompletionHandler<Integer, ByteBuffer>() {
                        @Override
                        public void completed(Integer result, ByteBuffer attachment) {
                            System.out.println("--------------------------------------------------------------------------------");
                            System.out.println("Echo完毕");
                            System.out.println("--------------------------------------------------------------------------------");
                            System.out.println("等待客户端发送信息...");
                            System.out.println("--------------------------------------------------------------------------------");

                            // 继续读取更多数据
                            attachment.clear();
                            readClientData(clientChannel, attachment);
                        }

                        @Override
                        public void failed(Throwable exc, ByteBuffer attachment) {
                            System.out.println("写入数据失败: " + exc.getMessage());
                            try {
                                clientChannel.close();
                            } catch (IOException e) {
                                e.printStackTrace();
                            }
                        }
                    });
                } else if (result == -1) {
                    try {
                        clientChannel.close();
                        System.out.println("客户端已关闭");
                        System.out.println("--------------------------------------------------------------------------------");
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }

            @Override
            public void failed(Throwable exc, ByteBuffer attachment) {
                System.out.println("读取数据失败: " + exc.getMessage());
                try {
                    clientChannel.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        });
    }

}

上述例子AsyncEchoServer实现了Echo协议,AsynchronousServerSocketChannel与ServerSocketChannel的职责类似。相比较而言,AsynchronousServerSocketChannel实现了异步的I/O,而无需再使用Selector,因此整体代码比之ServerSocketChannel要简化很多。

运行,可以看到控制台输出内容如下:

AsyncEchoServer启动,监听:7

4.7.2 实战:开发AIO版本的Echo客户端

以下是使用原生AIO API来开发Echo协议的客户端的示例:

package com.waylau.java.demo.aio;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.util.concurrent.ExecutionException;

/**
 * AsyncEchoClient 异步IO Echo客户端
 *
 * @author <a href="https://waylau.com">Way Lau</a>
 * @version 2025/08/25
 **/
public class AsyncEchoClient {
    private static final int DEFAULT_PORT = 7;
    public static final String DEFAULT_HOST = "localhost";

    public static void main(String[] args) {
        // 解析传参进来的服务名、端口号
        String host;
        int port;
        try {
            host = args[0];
            port = Integer.parseInt(args[1]);
        } catch (Exception e) {
            host = DEFAULT_HOST;
            port = DEFAULT_PORT;
        }

        // 创建AsynchronousSocketChannel
        try (var channel = java.nio.channels.AsynchronousSocketChannel.open()) {
            System.out.println("AsyncEchoClient启动,连接:" + host + ":" + port);

            // 等待连接完成
            var connectFuture = channel.connect(new InetSocketAddress(host, port));
            // 等待连接建立完成
            connectFuture.get();

            var scanner = new java.util.Scanner(System.in);

            while (true) {
                System.out.print("请输入:");
                var line = scanner.nextLine();

                if (line == null || line.isEmpty()) {
                    continue;
                }

                // 写入数据
                var writeFuture = channel.write(java.nio.ByteBuffer.wrap(line.getBytes()));
                // 等待写入完成
                writeFuture.get();

                // 读取响应
                var buffer = java.nio.ByteBuffer.allocate(1024);
                var readFuture = channel.read(buffer);
                // 等待读取完成
                int bytesRead = readFuture.get();

                if (bytesRead > 0) {
                    buffer.flip();
                    byte[] data = new byte[buffer.remaining()];
                    buffer.get(data);
                    System.out.println("Echo:" + new String(data));
                    System.out.println("--------------------------------------------------------------------------------");
                    System.out.println("Echo完毕");
                    System.out.println("--------------------------------------------------------------------------------");
                } else if (bytesRead == -1) {
                    System.out.println("服务器连接已关闭");
                    break;
                }

                System.out.println("等待下一个输入...");
                System.out.println("--------------------------------------------------------------------------------");
            }
        } catch (ExecutionException e) {
            System.out.println("操作执行异常: " + e.getMessage());
        } catch (InterruptedException e) {
            System.out.println("操作被中断: " + e.getMessage());
        } catch (IOException e) {
            System.out.println("IO异常: " + e.getMessage());
        }
    }
}

AsyncEchoClient的AsynchronousSocketChannel的使用与NonBlockingEchoClient的SocketChannel的使用基本类似。启动客户端,命令如下:

$ java AsyncEchoClient.java localhost 7

当AsyncEchoClient客户端与AsyncEchoServer服务器建立了连接之后,客户端就可以与服务器进行交互了。

当我们在客户端输入“hello!”字符时,服务器也会将“hello!”发送回客户端,客户端输入的任务内容,服务器也会原样返回。

AsyncEchoServer控制台输出内容如下:

接收到客户端连接:/127.0.0.1:7394
等待客户端发送信息...
--------------------------------------------------------------------------------
Echo:hello!
--------------------------------------------------------------------------------
Echo完毕
--------------------------------------------------------------------------------
等待客户端发送信息...
--------------------------------------------------------------------------------
Echo:yes!
--------------------------------------------------------------------------------
Echo完毕
--------------------------------------------------------------------------------
等待客户端发送信息...

3.8 理解分布式下的事件驱动,突破同步编程思维定式

在GUI编程中,事件是非常常见的。比如,用户在界面点击了按钮,就会发送一个“点击”事件,而相应的会有一个处理“点击”事件的事件处理器会来处理该事件。因此,

所谓事件驱动,简单地说就是你点什么按钮(即产生什么事件),电脑执行什么操作(即调用什么函数)。当然事件也不仅限于用户的操作,事件驱动的核心自然是事件。从事件角度说,事件驱动程序的基本结构是由一个事件收集器、一个事件发送器和一个事件处理器组成。事件收集器专门负责收集所有事件,包括来自用户的(如鼠标、键盘事件等)、来自硬件的(如时钟事件等)和来自软件的(如操作系统、应用程序本身等)。事件发送器负责将收集器收集到的事件分发到目标对象中。事件处理器做具体的事件响应工作,它往往要到实现阶段才完全确定。对于框架的使用者来说,他们唯一能够看到的是事件处理器。这也是他们所关心的内容。

4.8.1 事件驱动编程

事件驱动编程通常只是用一个执行过程,CPU之间不是并发的,在处理多任务的时候,事件驱动编程是使用协作式处理任务,而不是多线程的抢占式。事件驱动简洁易用,只需要注册感兴趣的事件,在回调中设计逻辑就可以了。在调用的过程中,事件循环器(Event Loop)在等待事件的发生,跟着调用处理器。事件处理器不是抢占式的,处理器一般只有很短的生命周期。

1. 事件驱动编程的优势

  • 在大部分的应用场景中,事件编程优与多线程编程。
  • 相对与多线程编程来讲,事件驱动编程比较容易,复杂度低,是开发者乐于接受的。
  • 大多数的GUI框架,都是使用事件驱动编程架构的。每一个事件会绑定一个处理器,这些事件通常是点击按钮、选择菜单等等。处理器来实现具体的行为逻辑。
  • 事件驱动经常使用在I/O框架中,可以很好的实现I/O复用。很多高性能的I/O框架都是使用事件驱动模型的,例如:Mina、Netty、Node.js[1]
  • 易于调试。时间依赖只和事件有关系,而不是内部调度。问题容易暴露。

2. 事件驱动编程的劣势

  • 如果处理器占用时间较长,那会阻塞应用程序的响应。

  • 无法通过时间来维护本地状态,因为处理器必须返回。

  • 通常在单CPU环境下,比多线程编程要快,因为没有锁的因素,没有线程切换的损耗。CPU不是并发的,这样的话就不适合用在一些科学计算的应用中。

4.8.2 事件循环(Event Loop)的实现

事件循环(Event Loop)是一个程序结构,用于等待和发送消息和事件。事件驱动编程的代码核心就是事件循环器,在Linux下推荐使用epoll实现,在其它没有epol的系统上可以使用kqueue/ports/poll/select实现。

下图是事件循环的工作示例图。事件循环器不断接受来自客户端(Client)的请求,事件循环器把请求转交给注册了某类事件的工作线程(Worker)处理。

图4-11 事件循环的工作示例图

根据实现的方式不同,在网络编程中基于事件驱动主要有两种设计模式:Reactor和Proactor。

4.8.3 Reactor模型

首先来回想一下普通函数调用的机制:

  • 程序调用某函数->函数执行

  • 程序等待->函数返回结果

  • 控制权返回给程序->程序继续处理

和普通函数调用的不同之处在于:应用程序不是主动的调用某个API完成处理,而是恰恰相反,应用程序需要提供相应的接口并注册到Reactor上,如果相应的事件发生,Reactor将主动调用应用程序注册的接口,这些接口又称为“回调函数”。

用“好莱坞原则”来形容Reactor再合适不过了:不要打电话给我们,我们会打电话通知你。

举个例子:你去应聘某某公司,面试结束后。

  • “普通函数调用机制”公司HR比较懒,不会记你的联系方式,那怎么办呢,你只能面试完后自己打电话去问结果;有没有被录取啊,还是被拒了;

  • “Reactor”公司HR就记下了你的联系方式,结果出来后会主动打电话通知你:有没有被录取啊,还是被拒了;你不用自己打电话去问结果,事实上也不能,因为你没有HR的联系方式。

以下是Reactor示意图。

图4-12 Reactor示意图

1. Reactor模型的优点

Reactor模型是编写高性能网络服务器的必备技术之一,它具有如下的优点:

  • 响应快,不必为单个同步时间所阻塞,虽然Reactor本身依然是同步的;
  • 编程相对简单,可以最大程度的避免复杂的多线程及同步问题,并且避免了多线程/进程的切换开销;
  • 可扩展性,可以方便的通过增加Reactor实例个数来充分利用CPU资源;
  • 可复用性,Reactor框架本身与具体事件处理逻辑无关,具有很高的复用性。

2. Reactor模型框架

使用Reactor模型,必备的几个组件:事件源、Reactor框架、事件多路复用机制和事件处理程序,先来看看Reactor模型的整体框架,接下来再对每个组件做逐一说明。

  • 事件源:Linux上是文件描述符,Windows上就是Socket或者Handle了,这里统一称为“句柄集”;程序在指定的句柄上注册关心的事件,比如I/O事件。

  • 事件多路复用机制:由操作系统提供的I/O多路复用机制,比如select和epoll。程序首先将其关心的句柄(事件源)及其事件注册到多路复用机制上。当有事件到达时,事件多路复用机制会发出通知“在已经注册的句柄集中,一个或多个句柄的事件已经就绪”。程序收到通知后,就可以在非阻塞的情况下对事件进行处理了。

  • Reactor。是事件管理的接口,内部使用事件多路复用机制注册、注销事件;并运行事件循环,当有事件进入“就绪”状态时,调用注册事件的回调函数处理事件。

  • 事件处理程序。事件处理程序提供了一组接口,每个接口对应了一种类型的事件,供Reactor在相应的事件发生时调用,执行相应的事件处理。通常它会绑定一个有效的句柄。

使用Reactor模型后,事件控制流是什么样子呢?可以参见下面的序列图。

图4-13 Reactor序列图

我们分别以读操作和写操作为例来看看Reactor中的具体步骤:

  • 应用程序注册读就绪事件和相关联的事件处理器;

  • 事件分离器等待事件的发生;

  • 当发生读就绪事件的时候,事件分离器调用第一步注册的事件处理器;

  • 事件处理器首先执行实际的读取操作,然后根据读取到的内容进行进一步的处理。

写入操作类似于读取操作,只不过第一步注册的是写就绪事件。

4.8.4 Proactor模型

我们来看看Proactor模型中读取操作和写入操作的过程:

  • 应用程序初始化一个异步读取操作,然后注册相应的事件处理器,此时事件处理器不关注读取就绪事件,而是关注读取完成事件,这是区别于Reactor的关键。

  • 事件分离器等待读取操作完成事件。

  • 在事件分离器等待读取操作完成的时候,操作系统调用内核线程完成读取操作(异步I/O都是操作系统负责将数据读写到应用传递进来的缓冲区供应用程序操作),并将读取的内容放入用户传递过来的缓存区中。这也是区别于Reactor的一点。

  • 事件分离器捕获到读取完成事件后,激活应用程序注册的事件处理器,事件处理器直接从缓存区读取数据,而不需要进行实际的读取操作。

Proactor中写入操作与读取操作类似,只不过感兴趣的事件是写入完成事件。

从上面可以看出,Reactor和Proactor模型的主要区别就是真正的读取和写入操作是有谁来完成的,Reactor中需要应用程序自己读取或者写入数据,而Proactor模型中,应用程序不需要进行实际的读写过程,它只需要从缓存区读取或者写入即可,操作系统会读取缓存区或者写入缓存区到真正的I/O设备。

图4-14 Proactor示意图

[1]有关Netty和Node.js方面的内容,可以参阅笔者所著的《Netty原理解析与开发实战》《Node.js企业级应用开发实战》

4.1 线程与并发核心原理解读

计算机用户想当然地认为他们的系统在一个时间内可以做多件事。比如,用户一边用浏览器下载视频文件,一边可以继续在浏览器上浏览网页。可以做这样的事情的软件称为并发软件(Concurrent Software)。

计算机实现多个程序的同时执行,主要基于以下原因:

  • 资源利用率。某些情况下,程序必须要等待其他外部的某个操作完成,才能往下继续执行,而在等待的过程中,该程序无法执行其他任何工作。因此,如果在等待的同时可以运行另外一个程序,将无疑提高资源的利用率。
  • 公平性。不同的用户和程序对于计算机上的资源有着同等的使用权。一种高效的运行方式是通过粗粒度的时间分片(Time Slicing)使得这些用户和程序能共享计算机资源,而不是一个程序从头运行到尾,然后再启动下一个程序。
  • 便利性。通常来说,在计算多个任务时,应该编写多个程序,每个程序执行一个任务并在必要时相互通信,这比只编写一个程序来计算所有任务更加容易实现。

并发与并行

那么并发与并行到底是如何区别的呢?

The Practice of Programming一书的作者Rob Pike对并发与并行做了如下描述:

并发是同一时间应对(dealing with)多件事情的能力;并行是同一时间动手做(doing)多件事情的能力。 并发(concurrency)属于问题域(problem domain),并行(parallelism)属于解决域(solution domain)。并行和并发的区别在于有无状态,并行计算适合无状态应用,而并发解决的是有状态的高性能;有状态要着力解决并发计算,无状态要着力并行计算,云计算要能做到这两种计算自动伸缩扩展。

上述的描述貌似有点抽象。举一个生活中的例子,某些人工作很忙那么家务活会请钟点工来打扫卫生。一个钟点工在一个小时可以帮你扫地、抹桌子、洗菜、做饭。客户并不关心哪件活先干哪件活后做,客户所关心的是,付费的这一个小时,需要看到所有活都完成。在客户看来,这一个小时内,所有的活都是一个钟点工干的,这就是并发。

再举一例子,客户的亲戚要来看望客户,还有不到一个小时亲戚就要登门了。那么平时要花一个小时才能做完的家务,如何才能提前做完呢?答案是加人。请多几个钟点工一起来做事,比如某个钟点工专门扫地,某人专门抹桌子,某人专门负责洗菜、做饭。这样三人同时开工,就能缩短整体的时间,这就是并行。

从计算机角度来说,单个CPU是需要被某个任务独占的,就如同终点工,在扫地的同时,就不能做抹桌子的动作。如果她想去抹桌子,就需要将手头的扫地任务先停下来。当然,由于多个任务是不断的切换的,因此,在外界看来,就有了“同时”执行多个任务的错觉。

现代的计算机大多是多核(CPU)的,因此,多个CPU同时执行任务,就实现了任务的并行。

线程与并发

早期的分时系统中,每个进程以串行化方式执行指令,并通过一组I/O指令来与外部设备通信。每条被执行的指令都有相应的“下一条指令”,程序中的控制流就是按照指令集的规则来确定的。

串行编程模型的优势是直观性和简单性,因为它模仿了人类的工作方式:每次只做一件事,做完再做其他事情。例如,早上起床后,先穿衣,然后下楼,吃早饭。在编程语言中,这些现实世界的动作可以进一步被抽象为一组粒度更细的动作。例如,喝茶的动作可以被细化为:打开橱柜,挑选茶叶,将茶叶倒入杯中,查看茶壶的水是否够,不够要加水,将茶壶放在火炉上,点燃火炉,然后等水烧开,等等。在等水烧开这个过程中包含了一定程序的异步性。例如,在烧水过程中,你可以干等,也可以做其他事情,比如开始烤面包,或者看报纸(这就是另一个异步任务),同时留意水是否烧开了。但凡做事高效的人,总能在串行性和异步性之间找到合理的平衡,程序也是如此。

线程允许在同一个进程中同时存在多个线程控制流。线程会共享进程范围内的资源,例如内存句柄和文件句柄,但每个线程都有各自的程序计数器、栈以及局部变量。线程还提供了一种直观的分解模式来充分利用操作系统中的硬件并行性,而在同一个程序中的多个线程也可以被同时调度到多个CPU上运行。

毫无疑问,多线程编程使得程序任务并发成为了可能。而并发控制主要是为了解决多个线程之间资源争夺等问题。并发一般发生在数据聚合的地方,只要有聚合,就有争夺发生,传统解决争夺的方式采取线程锁机制,这是强行对CPU管理线程进行人为干预,线程唤醒成本高,新的无锁并发策略来源于异步编程、非阻塞I/O等编程模型。

4.2 常见并发风险深度解析:避免90%的生产环境异常单

多线程并发会带来如下的问题:

  • 安全性问题。在没有充足同步的情况下,多个线程中的操作执行顺序是不可预测的,甚至会产生奇怪的结果。线程间的通信主要是通过共享访问字段及其字段所引用的对象来实现的。这种形式的通信是非常有效的,但可能导致两种错误:线程干扰(thread interference)和内存一致性错误(memory consistency errors)。
  • 活跃度问题。一个并行应用程序的及时执行能力被称为它的活跃度(liveness)。安全性的含义是“永远不发生糟糕的事情”,而活跃度则关注于另外一个目标,即“某件正确的事情最终会发生”。当某个操作无法继续执行下去,就会发生活跃度问题。在串行程序中,活跃度问题形式之一就是无意中造成的无限循环(死循环)。而在多线程程序中,常见的活跃度问题主要有死锁、饥饿以及活锁。
  • 性能问题。在设计良好的并发应用程序中,线程能提升程序的性能,但无论如何,线程总是带来某种程度的运行时开销。而这种开销主要是在线程调度器临时关起活跃线程并转而运行另外一个线程的上下文切换操作(Context Switch)上,因为执行上下文切换,需要保存和恢复执行上下文,丢失局部性,并且CPU时间将更多地花在线程调度而不线程运行上。当线程共享数据时,必须使用同步机制,而这些机制往往会抑制某些编译器优化,使内存缓存区中的数据无效,以及增加贡献内存总线的同步流量。所以这些因素都会带来额外的性能开销。

5.2.1 死锁(Deadlock)

死锁是指两个或两个以上的线程永远被阻塞,一直等待对方的资源。

下面是一个Java编写的死锁的例子。

Alphonse和Gaston是朋友,都很有礼貌。礼貌的一个严格的规则是,当你给一个朋友鞠躬时,你必须保持鞠躬,直到你的朋友鞠躬回给你。不幸的是,这条规则有个缺陷,那就是如果两个朋友同一时间向对方鞠躬,那就永远不会完了。这个示例应用程序中,死锁模型是这样的:

package com.waylau.java.demo.concurrency;

public class Deadlock {

	public static void main(String[] args) {
		final Friend alphonse = new Friend("Alphonse");
        final Friend gaston = new Friend("Gaston");
        new Thread(new Runnable() {
            public void run() {
                alphonse.bow(gaston);
            }
        }).start();
        
        new Thread(new Runnable() {
            public void run() {
                gaston.bow(alphonse);
            }
        }).start();
	}
	
	static class Friend {
        private final String name;

        public Friend(String name) {
            this.name = name;
        }

        public String getName() {
            return this.name;
        }

        public synchronized void bow(Friend bower) {
            System.out.format("%s: %s" + "  has bowed to me!%n", 
            		this.name, bower.getName());
            bower.bowBack(this);
        }

        public synchronized void bowBack(Friend bower) {
            System.out.format("%s: %s" + " has bowed back to me!%n", 
            		this.name, bower.getName());
        }
    }

}

当它们尝试调用bowBack时两个线程将被阻塞。无论是哪个线程,也永远不会结束,因为每个线程都在等待对方鞠躬。这就是死锁了。

5.2.2 饥饿(Starvation)

饥饿描述了一个线程由于访问足够的共享资源而不能执行程序的现象。这种情况一般出现在共享资源被某些“贪婪”线程占用,而导致资源长时间不被其他线程可用。例如,假设一个对象提供一个同步的方法,往往需要很长时间返回。如果一个线程频繁调用该方法,其他线程若也需要频繁地同步访问同一个对象则通常会被阻塞。

5.2.3 活锁(Livelock)

一个线程常常处于响应另一个线程的动作,如果其他线程也常常响应该线程的动作,那么就可能出现活锁。与死锁的线程一样,程序无法进一步执行。然而,线程是不会阻塞的,它们只是会忙于应对彼此的恢复工作。现实中的例子是,两人面对面试图通过一条走廊:Alphonse移动到他的左侧给Gaston让路,而Gaston移动到他的右侧想让Alphonse过去,两个人同时让路,但其实两人都挡住了对方,他们仍然彼此阻塞。

下面就介绍几种解决并发问题的常用方法。

5.3 解决并发风险之道

同步和原子访问,是解决并发风险的两种重要方式。

5.3.1 同步(Synchronization)

同步是避免线程干扰和内存一致性错误的常用手段。下面就用Java代码来演示这几种问题,以及如何用同步解决这类问题。

1. 线程干扰

下面描述当多个线程访问共享数据时错误是如何出现的。考虑下面的一个简单的类Counter:

public class Counter {
    private int c = 0;

    public void increment() {
        c++;
    }

    public void decrement() {
        c--;
    }

    public int value() {
        return c;
    }
}

其中的increment方法用来对c加1;decrement方法用来对c减1。然而,多个线程中都存在对某个Counter对象的引用,那么线程间的干扰就可能导致出现我们不想要的结果。

线程间的干扰出现在多个线程对同一个数据进行多个操作的时候,也就是出现了“交错”。这就意味着操作是由多个步骤构成的,而此时,在这多个步骤的执行上出现了叠加。

Counter类对象的操作貌似不可能出现这种“交错(interleave)”,因为其中的两个关于c的操作都很简单,只有一条语句。然而,即使是一条语句也会被虚拟机翻译成多个步骤。在这里,我们不深究虚拟机具体将上面的操作翻译成了什么样的步骤。只需要知道即使简单的C++这样的表达式也会被翻译成三个步骤:

  • (1)获取c的当前值。
  • (2)对其当前值加1。
  • (3)将增加后的值存储到c中。

表达式c--也会被按照同样的方式进行翻译,只不过第二步变成了减1,而不是加1。

假定线程A中调用increment方法,线程B中调用decrement方法,而调用时间基本上相同。如果c的初始值为0,那么这两个操作的“交错”顺序可能如下所示。

  • (1)线程A:获取c的值。
  • (2)线程B:获取c的值。
  • (3)线程A:对获取到的值加1,其结果是1。
  • (4)线程B:对获取到的值减1,其结果是-1。
  • (5)线程A:将结果存储到c中,此时c的值是1。
  • (6)线程B:将结果存储到c中,此时c的值是-1。

这样线程A计算的值就丢失了,也就是被线程B的值覆盖了。上面的这种“交错”只是其中的一种可能性。在不同的系统环境中,有可能是 B 线程的结果丢失了,或者是根本就不会出现错误。由于这种“交错”是不可预测的,线程间相互干扰造成的bug是很难定位和修改的。

2. 内存一致性错误

下面介绍通过共享内存出现的不一致的错误。

内存一致性错误发生在不同线程对同一数据产生不同的“看法”。导致内存一致性错误的原因很复杂,超出了本书的描述范围。庆幸的是,程序员并不需要知道出现这些原因的细节。我们需要的是一种可以避免这种错误的方法。

避免出现内存一致性错误的关键在于理解happens-before关系。这种关系是一种简单的方法,能够确保一条语句对内存的写操作对于其他特定的语句都是可见的。为了理解这点,我们可以考虑如下的示例。假设定义了一个简单的int类型的字段并对其进行初始化:

int counter = 0;

该字段由两个线程共享:A和B。假定线程A对counter进行了自增操作:

counter ++;

然后,线程B打印counter的值:

System.out.println(counter);

如果以上两条语句是在同一个线程中执行的,那么输出的结果自然是1。但是如果这两条语句是在两个不同的线程中,那么输出的结构有可能是0。这是因为没有保证线程A对counter的修改对线程B来说是可见的。除非程序员在这两条语句间建立了一定的happens-before关系。

我们可以采取多种方式建立这种happens-before关系。使用同步就是其中之一,这点我们将会在下面的小节中看到。 到目前为止,我们已经看到了两种建立这种happens-before的方式:

  • 当一条语句中调用了Thread.start方法,那么每一条和该语句已经建立了happens-before的语句都和新线程中的每一条语句有这种happens-before。引入并创建这个新线程的代码产生的结果对该新线程来说都是可见的。
  • 当一个线程终止了并导致另外的线程中调用Thread.join的语句返回,那么此时这个终止了的线程中执行了的所有语句都与随后的join语句的所有语句建立了这种happens-before。也就是说,终止了的线程中的代码效果对调用join方法的线程来说是可见的。

3. 同步方法

Java编程语言中提供了两种基本的同步用语:同步方法(synchronized methods)和同步语句(synchronized statements)。同步语句相对而言更为复杂一些,我们将在下一小节中进行描述。本节重点讨论同步方法。 我们只需要在声明方法的时候增加关键字synchronized即可:

public class SynchronizedCounter {
    private int c = 0;

    public synchronized void increment() {
        c++;
    }

    public synchronized void decrement() {
        c--;
    }

    public synchronized int value() {
        return c;
    }
}

如果count是SynchronizedCounter类的实例,设置其方法为同步方法会有两个效果:

  • 首先,不可能出现对同一对象的同步方法的两个调用的“交错”。当一个线程在执行一个对象的同步方式的时候,其他所有调用该对象的同步方法的线程都会被挂起,直到第一个线程对该对象操作完毕。
  • 其次,当一个同步方法退出时,会自动与该对象的同步方法的后续调用建立happens-before关系。这就确保了对该对象的修改对其他线程是可见的。

同步方法是一种简单的可以避免线程相互干扰和内存一致性错误的策略:如果一个对象对多个线程都是可见的,那么所有对该对象的变量的读写都应该是通过同步方法完成的(一个例外就是final字段,它在对象创建完成后是不能被修改的,因此,在对象创建完毕后,可以通过非同步的方法对其进行安全地读取)。这种策略是有效的,但是可能导致“活跃度问题”。这点我们会在后面进行描述。

4. 内部锁和同步

同步是构建在被称为“内部锁(intrinsic lock)”或者是“监视锁(monitor lock)”的内部实体上的。在API中通常被称为“监视器(monitor)”。内部锁在两个方面都扮演着重要的角色:保证对对象状态访问的排他性,建立对象可见性相关的happens-before关系。 每一个对象都有一个与之相关联动的内部锁。按照传统的做法,当一个线程需要对一个对象的字段进行排他性访问并保持访问的一致性时,它必须在访问前先获取该对象的内部锁,然后才能访问之,最后释放该内部锁。在线程获取对象的内部锁到释放对象的内部锁的这段时间,我们说该线程拥有该对象的内部锁。只要有一个线程已经拥有了一个内部锁,其他线程就不能再拥有该锁了。其他线程在试图获取该锁的时候被阻塞了。 当一个线程释放了一个内部锁,那么就会建立起该动作和后续获取该锁之间的happens-before关系。

5. 同步方法中的锁

当一个线程调用一个同步方法的时候,它就自动地获得了该方法所属对象的内部锁,并在方法返回的时候释放该锁。即使由于出现了没有被捕获的异常而导致方法返回,该锁也会被释放。

我们可能会感到疑惑:当调用一个静态的同步方法的时候会怎样?静态方法是和类相关的,而不是和对象相关的。在这种情况下,线程获取的是该类的类对象的内部锁。这样对于静态字段的方法来说,这是由和类的实例的锁相区别的另外的一个锁来进行操作的。

6. 同步语句

另外一种创建同步代码的方式就是使用同步语句。和同步方法不同,使用同步语句必须指明要使用哪个对象的内部锁:

public void addName(String name) {
    synchronized(this) {
        lastName = name;
        nameCount++;
    }
    nameList.add(name);
}

在上面的示例中,方法addName需要对lastName和nameCount的修改进行同步,还要避免同步调用其他对象的方法(在同步代码段中调用其他对象的方法可能导致“活跃度”中描述的问题)。如果没有使用同步语句,那么将不得不使用一个单独、未同步的方法来完成对nameList.add的调用。

在改善并发性时,巧妙地使用同步语句能起到很大的帮助作用。例如,我们假定类MsLunch有两个实例字段,c1和c2,这两个变量绝不会一起使用。所有对这两个变量的更新都需要进行同步。但是没有理由阻止对c1的更新和对c2的更新出现交错—这样做会创建不必要的阻塞,进而降低并发性。此时,我们没有使用同步方法或者使用和this相关的锁,而是创建了两个单独的对象来提供锁。

public class MsLunch {
    private long c1 = 0;
    private long c2 = 0;
    private Object lock1 = new Object();
    private Object lock2 = new Object();

    public void inc1() {
        synchronized(lock1) {
            c1++;
        }
    }

    public void inc2() {
        synchronized(lock2) {
            c2++;
        }
    }
}

采用这种方式时需要特别小心,我们必须绝对确保相关字段的访问交错是完全安全的。

7. 重入同步(Reentrant Synchronization)

回忆前面提到的:线程不能获取已经被别的线程获取的锁。但是线程可以获取自身已经拥有的锁。允许一个线程能重复获得同一个锁就称为重入同步(reentrant synchronization)。它是这样的一种情况:在同步代码中直接或者间接地调用了还有同步代码的方法,两个同步代码段中使用的是同一个锁。如果没有重入同步,在编写同步代码时需要额外小心,以避免线程将自己阻塞。

5.3.2 原子访问(Atomic Access)

下面介绍另外一种可以避免被其他线程干扰的做法的总体思路—原子访问。

在编程中,原子性动作就是指一次性有效完成的动作。原子性动作是不能在中间停止的:要么一次性完全执行完毕,要么就不执行。在动作没有执行完毕之前,是不会产生可见结果的。

通过前面的示例,我们已经发现了诸如c++这样的自增表达式并不属于原子操作。即使是非常简单的表达式也包含了复杂的动作,这些动作可以被解释成许多别的动作。然而,的确存在一些原子操作:

  • 对几乎所有的原生数据类型变量(除了long和double)的读写以及引用变量的读写都是原子的。
  • 对所有声明为volatile的变量的读写都是原子的,包括long和double类型。

原子性动作是不会出现交错的,因此,使用这些原子性动作时不用考虑线程间的干扰。然而,这并不意味着可以移除对原子操作的同步。因为内存一致性错误还是有可能出现的。使用volatile变量可以降低内存一致性错误的风险,因为任何对volatile变量的写操作都和后续对该变量的读操作建立了happens-before关系。这就意味着对volatile类型变量的修改对于别的线程来说是可见的。更重要的是,这意味着当一个线程读取一个volatile类型的变量时,它看到的不仅仅是对该变量的最后一次修改,还看到了导致这种修改的代码带来的其他影响。

使用简单的原子变量访问比通过同步代码来访问变量更高效,但是需要程序员更多细心的考虑,以避免内存一致性错误。这种额外的付出是否值得完全取决于应用程序的大小和复杂度。

5.4 提升系统并发能力:让资源争用成本降低80%

除了使用多线程外,还有以下方式可以提升系统的并发能力。

5.4.1 无锁化设计提升并发能力

加锁是为了避免在并发环境下,同时访问共享资源产生的风险问题。那么,在并发环境下,必须需要加锁?答案是否定的。并非所有的并发都需要加锁。适当的降低锁的粒度,甚至是采用无锁化的设计,更能提升并发能力。

比如,JDK中的ConcurrentHashMap,巧妙采用了桶粒度的锁,避免了put和get中对整个map的锁定,尤其在get中,只对一个HashEntry做锁定操作,性能提升是显而易见。

又比如,程序中可以合理考虑业务数据的隔离性,实现无锁化的并发。比如,程序中预计会有2个并发任务,那么每个任务可以对所需要处理的数据进行分组。任务1去处理尾数为0到4的业务数据,任务2处理尾数为5到9的业务数据。那么,这两个并发任务所要处理的数据,就是天然是隔离的,也就无需加锁。

5.4.2 缓存提升并发能力

有时,为了提升整个网站的性能,我们会将经常需要访问数据缓存起来,这样,在下次查询的时候,能快速的找到这些数据。缓存系统往往有着比传统的数据存储设备(如关系型数据库)更快的访问速度。

缓存的使用与系统的时效性有着非常大的关系。当我们的系统时效性要求不高时,则选择使用缓存是极好的。当系统要求的时效性比较高时,则并不适合用缓存。

5.4.3 更细颗粒度的并发单元

在前面章节中,我们也讨论了,线程是操作系统内核级别最小的并发单元了。虽然相比于进程而言,创建线程的开销要小很多,但当在高并发场景下,创建大量的线程仍然会耗费系统大量的资源。为此,某些编程语言提供了更细颗粒度的并发单元,比如纤程,类似于Golang的goroutine,Erlang风格的actor。相比于线程,纤程可以轻松实现百万的并发量,而且占用更加少的硬件资源。

Java语言虽然没有定义纤程,但仍有一些第三方库可供选择,比如Quasar。读者有兴趣的话,可以参阅Quasar的在线手册(docs.paralleluniverse.co/quasar/)。