FutureTask 中 COMPLETING状态的设计哲学

178 阅读10分钟

为什么需要 COMPLETING

1. 保证结果设置和状态更新的原子性

FutureTask 的设计初衷是确保线程安全性结果一致性COMPLETING 是一个短暂的中间状态,用来确保:

  • 任务完成后,首先安全地写入结果(或异常)。
  • 然后再将状态更新为 NORMALEXCEPTIONAL

通过引入 COMPLETING 状态,可以分阶段地完成任务的收尾工作,避免直接从 NEWNORMAL 的状态跳转可能引发的问题。

2. 解决并发访问问题

如果没有 COMPLETING,在高并发场景下可能会发生以下问题:

  • 一个线程正在完成任务(写入结果),但状态还未更新到 NORMAL
  • 另一个线程检测到任务完成(因为它看到状态改变),却可能读取到不完整的结果。

COMPLETING 的作用就是防止其他线程认为任务已经完成,但结果还未写入。

3. 线程安全的等待机制

FutureTask 提供了阻塞方法 get(),等待任务完成并获取结果。如果没有 COMPLETING 状态,get() 调用可能会在任务结果还未完全写入时返回,导致读取到未定义的结果。

FutureTask 中的状态更新过程

以下是任务完成时的关键代码:


protected void set(V v) {
    if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
        outcome = v;  // 设置任务结果
        UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // 更新状态为 NORMAL
        finishCompletion(); // 唤醒等待线程
    }
}

可以看到:

  1. 状态从 NEW 改为 COMPLETING 时,任务结果尚未设置。
  2. 确保结果(outcome)写入后,状态再更新为 NORMAL

这一步分离了“结果写入”和“完成标记”,利用 COMPLETING 作为中间状态避免了潜在的竞争问题。

核心问题:状态更新和结果设置的“竞态条件”

在并发环境中,如果没有 COMPLETING 状态,任务完成的流程可能会出问题。以下是关键问题点:

假设没有 COMPLETING 状态,直接从 NEW 跳到 NORMALEXCEPTIONAL

  • 任务结果还未完全设置,但状态已被标记为完成 (NORMALEXCEPTIONAL)。
  • 其他线程可能看到状态已完成,就会立即去读取结果,而此时结果可能尚未写入(或不完整)。
  • 导致读取到未定义的结果,甚至程序崩溃。

关键点

  • 状态改变(任务完成)和 结果写入outcome 的设置)是两个操作。
  • 这两个操作需要严格按顺序完成,但如果没有一个“中间状态”进行过渡,就容易发生线程间的竞态条件

COMPLETING 的解决方案

通过引入 COMPLETING,可以将状态更新拆分为两个阶段:

  1. 状态从 NEW 转为 COMPLETING,告诉其他线程“任务快完成了,但结果还没完全准备好”。
  2. 确保结果写入成功后,再将状态改为最终的 NORMALEXCEPTIONAL

这样,其他线程在看到 COMPLETING 时,就知道需要等待结果完全写入,而不会直接读取。


COMPLETING 状态的精髓:分离“结果写入”和“状态完成”的责任

1. 状态转换的两阶段性

COMPLETING 是一种 信号,用来标记:

任务已经执行完毕,结果正在写入中。请稍等,马上就好!

它保证了:

  • 第一阶段:结果写入是独立且线程安全的,不会受到其他线程干扰。
  • 第二阶段:任务标记为完成后,其他线程可以放心读取结果。

没有 COMPLETING,状态转换会直接跳过第一阶段,导致线程读取未写入完全的结果。

2. 并发场景下的一致性

在多线程环境中,状态和结果的更新必须遵循以下约束:

  • 状态从 NEW 到最终状态必须是线性且可预测的。
  • 其他线程只能在任务完成后读取结果,不能提前。

COMPLETING 的引入确保了这两个约束不会被打破。


举个类比:快递派送过程

想象你在等待一个快递,任务的状态转换可以用以下类比表示:

  • NEW:快递正在路上(任务未开始执行)。
  • COMPLETING:快递员正在派送快递,但你还没签收(结果正在写入中)。
  • NORMAL:你签收了快递(任务完成,结果可用)。
  • EXCEPTIONAL:快递丢了(任务失败)。

如果没有“派送中”(COMPLETING)这个状态,快递公司可能直接告诉你快递已签收,但实际上你还没收到快递。这显然是不合理的。


实际代码中的流程拆解

来看 FutureTask 中任务完成的核心逻辑:

protected void set(V v) {
    if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
        outcome = v;  // 设置结果
        UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // 标记任务完成
        finishCompletion(); // 唤醒等待线程
    }
}

分解成两步:

  1. compareAndSwapInt(NEW, COMPLETING)

    • 先将状态改为 COMPLETING,表示任务结果正在写入中,其他线程知道暂时不能读取结果。
  2. outcome = v;putOrderedInt(this, stateOffset, NORMAL)

    • 先写入结果,然后将状态改为 NORMAL,确保任务完成时结果已经完全可用。

如果直接跳过 COMPLETING 会怎样?

假设直接从 NEWNORMAL

  1. 当状态变为 NORMAL 时,结果(outcome)可能还未写入。
  2. 其他线程看到状态已完成,可能直接读取到未定义的结果。
  3. 导致数据不一致,出现难以调试的并发问题。

我们来详细看看,如果没有 COMPLETING 状态,直接采用“先设置 outcome 再改状态”的方案,可能会发生什么问题:

假设场景

  1. 线程 A 执行任务,设置了 outcome 的值。
  2. 线程 A 还没来得及更新状态到 NORMAL
  3. 线程 B 调用了 get() 方法,看到状态还未改变,可能会阻塞等待,或者判断出错。

问题点

  • 线程 B 对任务状态和结果的观察会产生模糊性,即:
    结果已经写入,但状态未更新,其他线程无法感知结果已可用

  • 在高并发环境中,任务的完成状态需要保证是原子的,也就是:

    • 当状态标记为完成时,结果必须是可用且一致的。
    • 其他线程看到状态改变,应该能够确定结果已完全设置。

如果没有 COMPLETING 状态,这种“结果写入和状态更新”之间的非原子行为会引发并发错误。


为什么引入 COMPLETING 可以解决问题?

分离阶段

引入 COMPLETING 状态后,任务完成被分为两个阶段:

  1. 阶段 1:从 NEW 转为 COMPLETING

    • 任务进入一个“结果正在写入中”的状态,其他线程可以感知到任务即将完成,但结果尚未完全可用。
  2. 阶段 2:从 COMPLETING 转为 NORMALEXCEPTIONAL

    • 结果已完全写入,状态标记为完成。

这种分阶段的处理确保了任务的状态转换是清晰且可预测的。

防止状态提前暴露

  • 如果没有 COMPLETING 状态,结果设置可能在状态更新之前完成,其他线程会因为未感知到状态更新而误判任务未完成。
  • COMPLETING 的作用是一个过渡信号,告诉其他线程“任务结果正在生成,请稍等”,避免了这种误判。

实际实现中的一致性保障

来看 FutureTask 的完整实现:


protected void set(V v) {
    if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
        outcome = v;  // 设置结果
        UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // 更新状态为 NORMAL
        finishCompletion(); // 唤醒等待的线程
    }
}
  1. NEW 转为 COMPLETING

    • 在状态更新之前,先锁定当前任务状态,告诉其他线程结果正在写入中。
    • 避免其他线程误以为任务还未开始完成。
  2. 写入结果

    • outcome 设置为任务的最终结果。
  3. COMPLETING 转为 NORMAL

    • 在确保结果已完全写入后,状态才标记为完成。
    • 保证结果和状态对所有线程来说是同步可见的。

为什么不能直接跳过 COMPLETING

核心问题是:直接跳过 COMPLETING,会让状态和结果之间的关系变得模糊。具体来说:

  1. 状态更新的原子性问题

    • 状态从 NEW 直接到 NORMAL,结果在中途设置,这两个操作并不是一个原子操作。
    • 如果没有一个明确的过渡状态,其他线程在结果设置未完成时可能会错误地读取任务状态。
  2. 并发安全问题

    • 在多线程环境下,其他线程可能会同时观察任务的状态和结果。
      如果没有 COMPLETING 状态,任务完成的边界变得模糊,会增加出错的可能性。
  3. 清晰的状态流转

    • 引入 COMPLETING 状态,明确了任务从开始到完成的每一个阶段,便于其他线程判断任务是否已完成。

为什么不需要 volatile 修饰 outcome

1. COMPLETING 状态与内存屏障

FutureTask 中,状态的更新(如从 NEWCOMPLETING,再到 NORMAL)是通过 compareAndSwapInt(CAS 操作)实现的。CAS 操作隐含了一种 内存屏障(Memory Barrier) ,其效果是:

  • 保证在状态更新前,写入的所有变量对其他线程可见。
  • 保证在状态更新后,其他线程看到的状态是最新的。

因此,outcome 的值在设置完成后,通过状态更新的内存屏障,对其他线程是可见的。

2. 状态检查的 happens-before 关系

FutureTask 中,其他线程在读取 outcome 之前,通常会先检查任务的状态。以下两种情况都能确保可见性:

  • 当状态为 NORMALEXCEPTIONAL
    此时任务已经完成,状态更新保证了 outcome 的写操作对其他线程可见。
  • 调用 get()
    如果任务未完成,get() 方法会阻塞直到状态变为完成(NORMALEXCEPTIONAL),此时状态更新后的内存屏障确保了 outcome 的值对读取线程可见。

3. 结果读取前的 happens-before 关系

无论是通过阻塞(get() 方法)还是非阻塞的方式检查状态,FutureTask 都通过 显式同步机制 建立了 outcome 的写入和读取之间的 happens-before 关系:

  • 写入 outcome 之前,状态会从 NEW 转为 COMPLETING
  • 状态从 COMPLETING 转为 NORMALEXCEPTIONAL 后,其他线程可以读取 outcome
  • 状态的更新和 outcome 的写入在同一个线程中按顺序发生,其他线程通过内存屏障观察到状态变化时,outcome 的值一定已经写入。

代码中的关键路径分析

以下是 FutureTask 完成任务时的核心逻辑:


protected void set(V v) {
    if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
        outcome = v;  // 设置结果
        UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // 更新状态为 NORMAL
        finishCompletion(); // 唤醒等待线程
    }
}

流程说明:

  1. compareAndSwapInt 将状态从 NEW 转为 COMPLETING

    • 这一步含有内存屏障,确保之前的写入(包括 outcome 的设置)对其他线程可见。
  2. outcome = v

    • 设置任务的结果,此时结果写入内存。
  3. putOrderedInt 将状态设置为 NORMAL

    • 这是一个有序写(Ordered Write),保证 outcome 的写入发生在状态更新之前。
    • 其他线程通过状态检查(状态变为 NORMAL)时,一定能够看到最新的 outcome

为什么不用 volatile

  1. volatile 的开销

    • volatile 会引入更多的内存屏障,可能对性能产生额外影响。
    • FutureTask 的设计中,状态更新本身已经隐含了内存屏障,没必要额外使用 volatile
  2. 内存屏障已确保可见性

    • compareAndSwapIntputOrderedInt 操作已经提供了足够的同步保证。
    • 这些操作隐式建立了 outcome 的写入和状态读取之间的 happens-before 关系。
  3. 减少复杂性

    • volatile 的引入可能会让代码显得更复杂,而通过状态更新的内存屏障设计,可以更自然地保证 outcome 的可见性。

其他线程如何读取 outcome

以下是 get() 方法的实现简化版:


public V get() throws InterruptedException, ExecutionException {
    int s = state;
    if (s <= COMPLETING) // 如果任务未完成
        s = awaitDone(false, 0L); // 等待完成
    return report(s);
}

private V report(int s) throws ExecutionException {
    Object x = outcome;
    if (s == NORMAL)
        return (V)x;
    if (s >= CANCELLED)
        throw new CancellationException();
    throw new ExecutionException((Throwable)x);
}

关键点:

  1. 等待完成的机制

    • 如果状态未完成,get() 会阻塞,直到状态变为 NORMALEXCEPTIONAL
    • 阻塞完成后,状态的变化会通过内存屏障确保对读取线程可见。
  2. 读取结果

    • 状态变为 NORMAL 后,结果 outcome 已经写入,且对当前线程可见。