暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

NO.62 凭证取货: Future模式

技术夜未眠 2020-03-21
283


00、引言


以下文字选自作者读小学时教科书里的一篇课文 —— 华罗庚《统筹方法》:


统筹方法,是一种安排工作进程的数学方法。它的实用范围极广泛,在企业管理和基本建设中,以及关系复杂的科研项目的组织与管理中,都可以应用。 


怎样应用呢?主要是把工序安排好。 


比如,想泡壶茶喝。当时的情况是:开水没有;水壶要洗,茶壶,茶杯要洗;火已生了,茶叶也有了。怎么办? 


办法甲:洗好水壶,灌上凉水,放在火上;在等待水开的时间里,洗茶壶、洗茶杯、拿茶叶;等水开了,泡茶喝。 


办法乙:先做好一些准备工作,洗水壶,洗茶壶茶杯,拿茶叶;一切就绪,灌水烧水;坐待水开了泡茶喝。 


办法丙:洗净水壶,灌上凉水,放在火上,坐待水开;水开了之后,急急忙忙找茶叶,洗茶壶茶杯,泡茶喝。 


哪一种办法省时间?我们能一眼看出第一种办法好,后两种办法都窝了工。

 

这是小事,但这是引子,可以引出生产管理等方面的有用的方法来。 


水壶不洗,不能烧开水,因而洗水壶是烧开水的前提。没开水、没茶叶、不洗茶壶茶杯,就不能泡茶,因而这些又是泡茶的前提。它们的相互关系,可以用左边的箭头图来表示:

箭头上的数字表示,这一行动所需要的时间,例如15表示从把水放在炉上到水开的时间是15分钟。 


从这个图上可以一眼看出,办法甲总共要16分钟(而办法乙、丙需要20分钟)。如果要缩短工时、提高工作效率,应当主要抓烧开水这个环节,而不是抓拿茶叶等环节同时,洗茶壶茶杯、拿茶叶总共不过4分钟,大可利用「等水开」的时间来做。 


是的,这好像是废话,卑之无甚高论。有如走路要用两条腿走,吃饭要一口一口吃,这些道理谁都懂得。但稍有变化,临事而迷的情况,常常是存在的。在近代工业的错综复杂的工艺过程中,往往就不是像泡茶喝这么简单了。任务多了,几百几千,甚至有好几万个任务。关系多了,错综复杂,千头万绪,往往出现「万事俱备,只欠东风」的情况。由于一两个零件没完成,耽误了一台复杂机器的出厂时间。或往往因为抓的不是关键,连夜三班,急急忙忙,完成这一环节之后,还得等待旁的环节才能装配。 


洗茶壶,洗茶杯,拿茶叶,或先或后,关系不大,而且同是一个人的活儿,因而可以合并成为:

用数字表示任务,上面的图形可以写成为:  

( 1-洗水壶 2-烧开水 3-洗茶壶茶杯、拿茶叶 4-泡茶)  


看来这是「小题大做」,但在工作环节太多的时候,这样做就非常必要了。 


这里讲的主要是时间方面的事,但在具体生产实践中,还有其它方面的许多事。而我们利用这种方法来考虑问题,是不无裨益的。 


当然,这种方法,需要通力合作,因而在社会主义制度下能更有效地发挥作用。


上文中我们可以看出,在任务目标、内容不变的情况下,通过调整、优化任务步骤的执行顺序(本质上是通过实现任务间的并行,提升了任务的执行效率。即:在烧开水的过程中,同时开展了洗茶壶、洗茶杯、拿茶叶等工作环节),缩短了任务总体耗时。请注意:这里并没有减少任何任务、也没有减低完成任务的目标。


让我们的思绪从日常生活切换到代码世界。对上述提到的“统筹方法”进行凝练与提升,至少可以从以下两个维度提升我们的软件表现:

  • 把一个业务分解为若干任务步骤,通过合理的任务编排,让更多的任务可以并发执行,提升程序性能。

  • 把一些耗时的任务通过以异步、非阻塞的线程方式在后台进行运行,给用户以及时的反馈,避免无效等待,可以给用户一种响应及时的“假象”,提升用户体验。


当我们需要调用一个函数方法func( )时,如果这是一个耗时操作(如上文中的“烧开水”动作),那么我们就需要等待。但有时候,我们可能并不急着要结果。因此,我们可以让调用者立即返回,不必进行无效等待,让在后台慢慢处理func( )这个操作。对于立即返回的调用者来说,则可以先处理一些其他任务,在真正需要数据的场合再去尝试获得需要的数据。


我们再次以“用户网购下单的简要流程”为例对上述思想进行阐述,如图Fig.1为例。当我们在网上进行下单时,后台的库存中心会查询商品库存,在库存充足的情况下会减少相应的商品库存,然后在订单中心中会依次生成订单与订单详情,最后通过消息中心以短信方式告知用户下单成功。当然,完整的流程远比下图复杂,如果我们按照串行的方式执行上述步骤,下单操作必将是一个耗时操作,在流量洪峰的情况下更是如此。让用户在屏幕面前苦苦等待下单操作的状态反馈,显然不是一个很好的用户体验。

Fig.1 用户网购下单基本流程


为了提升用户体验,我们显然需要减少用户等待的无效时间,按照“统筹方法”的思想,当用户执行下单操作后,可以立即反馈一个下单状态告知用户,然后后台可根据业务关系,开启一个或几个线程处理其业务逻辑,等逻辑处理完成后,再返回一个正确的状态结果。这样做的好处是:一方面,用户不必在屏幕前苦等状态返回,提升了用户体验;另外,通过合理编排业务,利用并发技术提升任务执行效率。


01、Futrue模式


上文介绍的“统筹方法”思路,有一种被称为“Future并发设计模式”与之对应。在介绍Future模式之前,我们先来看一下我们采用传统的串行同步方法,如图Fig.2所示。用户在终端发出了一个操作请求,该请求在调用链上由于调用了一个耗时操作,那么需要相当长的一段时间才能返回。按照这种模式,用户需要一直等待,直到数据返回;随后,再进行其他任务(调用A、调用B)的处理。

Fig.2 传统串行同步程序调用流程


下面,我们利用“Future模式”对Fig.2中的调用流程进行改造,如图Fig.3所示。

Fig.3 并发程序Future模式异步调用流程


在Future模式中,用户仍然发出了同样的操作请求,但是“服务端程序Future”不等“后台数据Future”处理完成就立即快速给“客户端程序Future”返回一个“调用凭证”(注意不是真实的返回数据);如此,“客户端程序Future”就不用无效等待了,可以继续调用其他业务逻辑(如调用A、调用B),充分利用这段等待时间,提升了程序的运行性能与用户体验,这就是Future模式的核心所在


知识链接:同步vs异步、阻塞vs非阻塞

同步与异步:通常用来描述一次方法调用,侧重于描述是否等待返回结果

阻塞与非阻塞:相对于线程是否被阻塞,通常用于描述多线程间的相互影响。

上述重要概念的介绍与比较,请移步:NO.28  编写高并发程序:深挖洞


根据作者的项目经验,在大多数的情况下,利用异步非阻塞的编程技术往往会带来不错的性能表现与良好的用户体验。





02、自己动手实现一个Futrue模式


理解了Futrue模式的思想以后,我们可以就自己动手来实现一个自定义的Future模式。在实现之前,我们先对该模式中涉及的几位主角进行介绍:

  • Data:接口类。返回数据的接口。

  • FutureData:“凭证”类。该数据称为“Future数据”,是一个“虚假”的数据,可以快速构造并返回给调用端。该类是“Future模式”的核心。

  • RealData:"真实数据"类。该数据是调用端所正在需要的类,构造该数据通常是一个耗时操作。

  • Client:立即返回FutureData,并开启一个后台线程去构造RealData。

  • Main:测试客户端,用于调用Client。


具体实现见代码1,enjoy:

1

package weixin.test.blockqueue;


public class FutureDemo {


   //数据接口

   //真实数据、凭证数据均需要实现该接口

   interface Data{

public String getData();

   }

   //真实数据

   static class RealData implements Data{

private String data;

public RealData(String req){

   //模拟一个耗时操作,如果按照传统的同步方式将导致用户长时间无效等待

   StringBuffer sb = new StringBuffer();

   for(int i=0;i<10;i++){

       sb.append(req).append(i).append(";");

try{

   Thread.sleep(1000);

}catch(InterruptedException ex){

   ex.printStackTrace();

}

   }

   data = sb.toString();

}

public String getData(){

   return this.data;

}

   }

   //FutureData是Futrue模式的关键

   //它实际上是真实数据RealData的代理,封装了获取RealData的等待过程

   static class FutureData implements Data{

private RealData realData;

private boolean  isReady = false;

//设置真实的数据

public synchronized void setRealData(RealData rd){

   if(false == isReady){

this.realData = rd;

this.isReady = true;

//真实的数据已经准备好,可以通知getData

notifyAll();

   }

}

//返回真实的数据

@Override

public synchronized String getData() {

   while(false == isReady){

try{

   //一直等待真实数据构造好

   wait();

}catch(InterruptedException e){}

   }

   return realData.data;

       }

   }

   //封装获取真实数据的过程

   static class Client{

public Data request(final String req){

   final FutureData future = new FutureData();

   //真实数据的获取过程很慢,通过线程异步的方式

   new Thread(){

       public void run(){

   RealData realData = new RealData(req);

   future.setRealData(realData);

}

   }.start();

   //FutureData会很快返回

   return future;

}

   }

   //测试客户端

   public static void main(String[] args) {

Client client = new Client();

//不用等待,快速返回一个虚假数据

Data data = client.request("BTC");

System.out.println("获取数据完毕!");

//模拟调用其他业务

try{

   Thread.sleep(1000);

}catch(InterruptedException e){

   e.printStackTrace();

}

//在真正需要数据的时候,再次调用getData方法

//注意该方法是阻塞方法,只有真实的数据准备好了,才会返回

System.out.println("获取到的真实数据:" + data.getData());

   }

}


03、Java世界中的Futrue模式


由于Futrue模式如此常用,本着不重新发明轮子的原则,JDK内部已为我们准备好了Futrue模式的实现;我们只需要进行简单的调用即可享受该模式带来的开发便利性。

Fig.4 JDK中的Future模式的基本结构


如图Fig.4所示,其中Future接口就是将来获取数据的契约/凭证,通过它可以获取真实的数据;RunnableFuture继承了Futrue和Runnable两个接口,其中run方法用于构造真实的数据。它有一个具体的实现类FutrueTask类。FutrueTask有一个内部类Sync,一些实质性的工作会委托Sync类实现。而Sync类最终会调用Callable接口,完成实际数据的构造工作。


Callable接口中的call方法会返回实际的数据。这个Callable接口也是这个Futrue框架和应用程序间的重要接口。我们在应用Futrue模式时,一方面通常需要实现自己的Callable对象;另外一方面,FutureTask类也可更加业务需要进行自定义。通常,我们会使用Callable构造一个FutureTask实例,并提交给线程池来执行。


JDK内置的Future框架的典型使用方法见代码2,老铁们可自行与代码1的自实现方式进行对比分析。enjoy:

2

package weixin.test.blockqueue;


import java.util.concurrent.Callable;

import java.util.concurrent.ExecutorService;

import java.util.concurrent.Executors;

import java.util.concurrent.FutureTask;


public class FutrueDemo2 {

   //结合业务场景,在call方法中实现获取真实数据的过程

   static class RealData implements Callable<String> {

private String data;


public RealData(String req) {

   data = req;

}


//获取真实数据

@Override

public String call() throws Exception {

   // 模拟一个耗时操作,如果按照传统的同步方式将导致用户长时间无效等待

   StringBuffer sb = new StringBuffer();

   for (int i = 0; i < 10; i++) {

       sb.append(data).append(i).append(";");

try {

   Thread.sleep(1000);

} catch (InterruptedException ex) {

   ex.printStackTrace();

}

   }

   return sb.toString();

}

   }


   // 测试客户端

   public static void main(String[] args) throws Exception {

       //创建一个FutureTask实例

FutureTask<String> future = new FutureTask<String>(new RealData("BTC"));

//创建一个线程池

ExecutorService exec = Executors.newFixedThreadPool(1);

//通过线程池来提交FutureTask实例,执行RealData的call方法

exec.submit(future);


System.out.println("获取数据完毕!");

// 模拟调用其他业务

try {

   Thread.sleep(1000);

} catch (InterruptedException e) {

   e.printStackTrace();

}


// 在真正需要数据的时候,调用get方法

// 注意该方法是阻塞方法,只有真实的数据准备好了,才会返回

System.out.println("获取到的真实数据:" + future.get());

   }

}


代码2对JDK的Futrue框架使用方法进行了说明,需要说明的是,Future框架中的Future接口除了提供get方法,也提供了其他实用方法:

  • boolean calcel(boolean):  取消任务

  • boolean isCancelled():任务是否已经取消

  • boolean isDone():任务是否已经完成

  • V get():取得返回对象,该方法为阻塞操作

  • V get(long timeout,TimeUnit unit):在超时时间范围内获取对象;该方法要么超时后返回null,要么在时间内返回返回。


04、小结


本文对并发模式——Futrue进行了原理性说明、自定义实现及其在JDK中实现进行了介绍,最后结合实例阐述了Futrue模式如何进行应用。后续文章中我们将对更多、功能更强大的并发编程模型进行介绍,敬请期待。


划重点

  1. Future模式的核心思想是异步调用。虽然它无法立即给出调用所需的数据;但是,它会返回给你一个契约/凭证;将来在你真正需要该数据时,你可以凭借这个凭证再次获取你所需要的信息。

  2. 结合“统筹方法”的思想,利用异步、非阻塞的并发编程技术,一方面,可以有效降低用户无效等待,提升了用户体验;另外,通过合理编排业务,实现任务并发执行,提升任务执行效率。



本文延伸阅读

上文1:NO.60 切换阶段任务 : Phaser进阶篇

上文2:NO.61 线程同步控制: 武器库

推荐1:习惯决定命运,高效程序员的习惯

推荐2:编写可读代码的艺术


近期,我和活跃在业界的一线技术老司机们共同开通了知识星球,——一个与公众号有别,但又一脉相承的技术圈、认知圈:公众号会一如既往地进行知识分享,知识星球则坚持关注解决问题与动手实践。问题很广、方法很多、思绪很快,希望我们能够在这里驻足思考、交流、沉淀、提升。


你负责认真,我们负责帮你解决问题,让改变发生;欢迎大家扫码加入我们的星球。期待 2020,在程序猿成长的道路上,彼此成就,共同进化!




文章转载自技术夜未眠,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论