Java纤程框架Quasar的设计与实现

540 阅读11分钟

线程与协程

线程是操作系统能够进行运算调度的最小单位,是进程中的实际运作单位,一个进程内可以包含多个线程,是资源调度的最小单位

要说为什么要有协程,首先就要说一下线程存在的问题

  1. CPU开销问题:

    1. 内核态和用户态的切换:线程的创建、切换发生在内核态(JVM调用操作系统内核中的TCB模块),其调度不由应用决定,而是内核决定。X86 CPU存在特权等级区分,用户态应用运行在Ring3上,而内核则运行在Ring0上。一次线程创建,甚至切换,都需要进入到内核态,切换到内核栈,完成相应的计算和资源分配后,再次回到用户态,切换线程栈,进而切换到新的线程,这一进一出就需要进行两次栈切换;操作系统切换线程,除了替换掉栈帧寄存器,还需要保存其他一系列用于调度和检查的资源,这使得单次进入内核的调用将会产生百ns甚至数us级别的开销;
    2. 抢占式调度:另外,内核并不确切知道当前线程在做什么,正在忙碌中的线程被换出,却切换到某个线程却发现线程还在等待IO这样的情况无法避免,于是多了很多无意义的切换。
  2. 内存占用问题:一个JVM线程占用1M的内存,这使得我们同时创建百万级线程是不可能的。

那什么是协程呢?简单来说,协程就是**轻量级的、协作式调度的、用户态的线程。**协程没有增加线程数量,而是在线程的基础之上通过分时复用的方式运行多个协程(站在了巨人的肩膀上)。

  1. 更少的CPU开销:

    1. 完全的用户态:协程的切换在用户态完成,只需要调整上下文即可,其切换几个寄存器和少量状态量的开销仅仅是数ns而已;
    2. 协作式调度:协程是应用自身实现的,协程可以在是程序确认需要换出时才会换出,换入也可以到应用确实结束等待时再换入,可以减少无意义的切换。
  2. 更少的内存占用:基于Quasar的协程占用400bytes

纤程,一种轻量级的线程

Quasar’s chief contribution is that of the lightweight thread, called fiber in Quasar. Fibers provide functionality similar to threads, and a similar API, but they’re not managed by the OS. They are lightweight in terms of RAM (an idle fiber occupies ~400 bytes of RAM) and put a far lesser burden on the CPU when task-switching. You can have millions of fibers in an application. If you are familiar with Go, fibers are like goroutines. Fibers in Quasar are scheduled by one or more ForkJoinPools.

Quasar的主要贡献是轻量级线程,在Quasar中被称为纤程(fiber),也就是协程,它提供了和线程类似的功能和API,但不由操作系统管理。对于RAM来说,fiber是轻量级的(相比每个线程占用1MB,一个空闲的fiber仅占用400bytes),并且在任务切换时对CPU的压力小得多,这使得我们可以在程序中使用数以百万的fiber。

Quasar的运行机制

纤程是一个Quasar在字节码级别创建的continuation,它可以捕获计算的瞬时状态并将其挂起,稍后从挂起点恢复,在调度上使用一个高效的、可以进行工作窃取的多线程调度器ForkJoinPool

那么Quasar是如何知道修改哪些字码呢?Quasar通过java-agent在运行时扫描哪些方法是可以中断的,在可中断的方法调用前后插入continuation逻辑,具体扫描过程如下:

当一个类被加载后,Quasar的instrumentation模块扫描类中可中断方法:对于每个可中断方法(f),查找方法内对其他可中断方法(g)的调用。这样我们形成了run()-f()-g()的调用链,在这个链的尾部,会存在一个对抛出SuspendExecution异常方法的调用,即Fiber.park()。对于可中断方法,Quasar框架在调用前后进行字节码的织入,用于保存和恢复fiber栈中局部变量的值,并记录方法g的调用是一个可能的挂起点。注意SuspendExecution异常不是一个真正的异常,而是fiber内部工作的机制。在编写fiber中运行的阻塞方法时,若声明抛出这个异常,该方法就是一个可中断方法。

中断与唤醒

如果方法g被阻塞,其所在纤程会捕获SuspendExecution异常,调用链上的方法对栈上的内容进行暂存,完成中断。当该纤程被唤醒后(其他纤程调用unpark),重复调用链的执行,但此时栈上的记录显示当前阻塞在调用g处,这时程序会跳转到方法g的所在行并进行调用,最后程序会到达实际的暂停点,即Fiber.park(),在这里程序会恢复执行。当方法g返回时,插入到f中的代码会从纤程的栈中恢复f的局部变量,从而实现该纤程上任务的恢复。所有情况都可以通过抛出SuspendExecution异常来实现吗?试想如果需要实现已有接口的方法,我们无法修改已有接口的定义。对此,Quasar框架提供了@Suspendable注解,如下面的代码所示,f是接口I定义的方法,我们在实现时调用了会抛出SuspendExecution异常的方法g,在不改变方法f定义的情况下,使用@SuspendExcution注解可以显示的标注出可中断的方法

interface I {
	int f();
}

class C implements I {
  @Suspendable
  public int f() {
    try {
      return g() * 2;
    } catch(SuspendExecution s) {
      // 实际上代码走不到这里
      throw new AssertionError(s);
    }
  }
}

为什么一定需要@SuspendExcution注解?正如在Quasar的运行机制中所提到的,instrumentation模块通过查找可中断方法来形成调用链,f既没有抛出SuspendExcution,也没有使用@SuspendExcution注解,导致了调用链断开,Quasar视其为异常。

有些方法被Quasar框架认定为是中断方法,包括:反射调用、lambda表达式,也有一些是不能被标记为中断的方法,如构造函数、类初始化器。

纤程间的通信

并发线程中进行通信的方式有两种:共享数据和消息传递。前者采用共享内存,使用同步方法防止写竞争来完成;后者采取的是线程之间的直接通信,不同的线程之间通过显式的发送消息来达到交互目的。

在消息传递方式有两个经典模型,分别是Go使用的CSP(Communicating Sequential Processes)模型和Akka/Erlang使用的Actor模型。二者的区别如下图所示

在Channel模型中,Worker之间不直接彼此联系,而是通过不同Channel进行消息发布和侦听。消息的发送者和接收者之间通过Channel松耦合,发送者不知道自己消息被哪个接收者消费了,接收者也不知道是哪个发送者发送的消息。在Actor模型中,Actor彼此之间直接发送消息,不需要经过什么中介,消息是异步发送和处理的。

通道

Quasar中的通道(Channel)类似Unix的Pipe,用于协程之间通讯和同步,用于纤程之间消息的传递。Channel接口继承了SendPort和ReceivePort接口,分别定义了向通道发送消息和接收消息的方法。一个通道由四个参数决定:

public static IntChannel newIntChannel(int bufferSize, OverflowPolicy policy, boolean singleProducer, boolean singleConsumer)
  • bufferSize:如果为正,表示内部缓冲区中保存的消息数;如果为0,表示传输通道,即没有内部缓冲区的通道;如果为-1,则是无界通道,具有无限缓冲区。

  • policy:指定有界Channel在其内部缓冲区溢出时的行为,类型及描述见下面的源码,不再赘述

    public final class Channels {
        /**
         * Determines how a channel behaves when its internal buffer (if it has one) overflows.
         */
        public enum OverflowPolicy {
            /**
             * The sender will get an exception (except if the channel is an actor's mailbox)
             */
            THROW,
            /**
             * The message will be silently dropped.
             */
            DROP,
            /**
             * The sender will block until there's a vacancy in the channel.
             */
            BLOCK,
            /**
             * The sender will block for some time, and retry.
             */
            BACKOFF,
            /**
             * The oldest message in the queue will be removed to make room for the new message.
             */
            DISPLACE
        }
      // ...
    
  • singleProducer:是否只有一个生产者

  • singleConsumer:是否只有一个消费者

下面介绍三种类型的通道,它们不是互斥的:

  1. Primitive Channels,为4种基本数据类型(int、long、float、double)提供的Channels的父类,这些通道不支持多消费者。

  2. Ticker Channels,指使用onset溢出策略创建的Channels,即policy=DISPLAY的Channel,每个纤程使用自己的消费者,按照自己的节奏消费,消息以发送的顺序被接收,如果消费太慢有可能丢失消息。

  3. Transforming Channels,指那些对消息进行处理的Channels,可以在发送端或消费端进行数据处理,包含了map、reduce、flatMap、filter等等,上面提到Channel接口继承了SendPort和ReceivePort接口,消息的发送和接收方法都在父接口中定义,因此这些对消息的不同处理也是通过SendPort和ReceivePort不同的实现类来定义的。

    class FilteringReceivePort<M> extends ReceivePortTransformer<M, M> implements ReceivePort<M> {
        // ...
        @Override
        protected M transform(M m) {
            return filter(m) ? m : null;
        }
        protected boolean filter(M m) {
            if (p != null)
                return p.apply(m);
            throw new UnsupportedOperationException();
        }
    }
    

Quasar中的Actor模型

Actor模型是一种计算模型,Actor是基本的计算单元,Actor之间是完全隔离的,不会共享任何变量,可用于消息传递。Java语言本身并不支持Actor模型,通常使用Akka类库,quasar-actor类库实现了基于纤程的Actor模型,使用时引入quasar-actors依赖。

<dependency>
	<groupId>co.paralleluniverse</groupId>
	<artifactId>quasar-actors</artifactId>
	<version>0.7.9</version>
</dependency>

在Quasar中,Actor可以被看做一个拥有单个通道的纤程,可以进行生命周期管理和错误处理,是构建容错程序的重要组成部分。Actor也是一个有良好的输入和输出的自包含执行单元,可以和其他的Actor传递消息(读写自己或其他Actor的MailBox)。ActorTest.java 演示了其基本用法

// 定义大小和溢出策略
static final MailboxConfig mailboxConfig = new MailboxConfig(10, Channels.OverflowPolicy.THROW);

static class Message {
  final int num;
  public Message(int num) {
    this.num = num;
  }
}

@Test
public void testReceive() throws Exception {
  ActorRef<Message> actor = new BasicActor<Message, Integer>(mailboxConfig) {
    // 当actor被派生后,doRun()方法就会运行
    @Override
    protected Integer doRun() throws SuspendExecution, InterruptedException { // 实现doRun()方法,用于对消息进行处理
      Message m = receive();
      // 处理消息并返回
      return m.num + 1;
    }
  }.spawn();
  // 向actor发送消息,其内容为15
  actor.send(new Message(15));
  // 获取返回值为16
  assertThat(LocalActor.<Integer>get(actor), is(16));
}

我们可以发现其写法和我们编写多线程任务是相似的,即将多线程任务编写完成后放入线程池中,获得Future,然后在使用这个Future拿到任务运行后的值

List<CompletableFuture<BalanceSummaryVO>> collect = listDashboard.stream().map(s -> CompletableFuture.supplyAsync(()-> asyncSumBalance(s),threadPool)).collect(Collectors.toList());
List<BalanceSummaryVO> listBalanceSummary = collect.stream().map(CompletableFuture::join).collect(Collectors.toList());

To make a system fault-tolerant we organise the software into a hierarchy of tasks that must be performed. The highest level task is to run the application according to some specification. If this task cannot be performed then the system will try to perform some simpler task. If the simpler task cannot be performed then the system will try to perform an even simpler task and so on. If the lowest level task in the system cannot be performed then the system will fail.

—— erlang-thesis第5章

作者阐述了他的let it crash思想,让Actor的管理者们来处理这些崩溃问题。比如一个Actor崩溃之后,管理者可以选择创建新的实例或者记录日志。每个Actor的崩溃或者异常信息都可以反馈到管理者那里,这就保证了Actor系统在管理每个Actor实例的灵活性。同样的,在Pon Pressler设计quasar-actor时,也表达了其对Joe这一理念的认可。

In fact, when using actors, it is often best to to follow the philosophy laid out by Joe Armstrong, Erlang’s chief designer, of “let it crash”. The idea is not to try and catch exceptions inside an actor, because attempting to catch and handle all exceptions is futile. Instead, we just let the actor crash, monitor its death elsewhere, and then take some action.

—— Quasar官方文档

因此其设计quasar-actor框架的错误处理原则为:Actor可以接收到另一个Actor死亡的消息和异常原因,通过linking或watching完成。

当Actor-1被Actor-2 watch后,若Actor-1死亡,会向Actor-2发送一个ExitMessage消息,Actor-2中的receive方法在处理消息时,调用filterMessage方法如下所示,其将LifecycleMessage的类(ExitMessage的超类)发送给handleLifecycleMessage方法进行处理。

protected Message filterMessage(Object m) {
  if (m instanceof LifecycleMessage) {
    return handleLifecycleMessage((LifecycleMessage) m);
  }
  return (Message) m;
}

我们依然使用*ActorTest.java* 中提供的例子展示handleLifecycleMessage() 的用法

@Test
public void testWatch() throws Exception {
  // actor1在100ms后结束(死亡)
  Actor<Message, Void> actor1 = spawnActor(new BasicActor<Message, Void>(mailboxConfig) {
    @Override
    protected Void doRun() throws SuspendExecution, InterruptedException {
      Fiber.sleep(100);
      return null;
    }
  });

  final AtomicBoolean handlerCalled = new AtomicBoolean(false);

  // actor2监听200ms超时
  Actor<Message, Void> actor2 = spawnActor(new BasicActor<Message, Void>(mailboxConfig) {
    @Override
    protected Void doRun() throws SuspendExecution, InterruptedException {
      Message m = receive(200, TimeUnit.MILLISECONDS);
      assertThat(m, is(nullValue()));
      return null;
    }

    // 处理LifecycleMessage(ExitMessage超类)相关消息
    @Override
    protected Message handleLifecycleMessage(LifecycleMessage m) {
      super.handleLifecycleMessage(m);
      handlerCalled.set(true);
      return null;
    }
  });

  // actor2监听actor1
  actor2.watch(actor1.ref());

  actor1.join();
  actor2.join();

  assertThat(handlerCalled.get(), is(true));
}