请求已经超时,成功回调却刚刚到达;页面已经退出,后台任务仍在返回数据;用户连续点击两次,上一轮结果覆盖了下一轮状态。这些问题看起来不同,实际都需要回答三个问题:结果属于哪次请求,现在还允许接收吗,接收对象还活着吗?

Laval 社区的《OpenHarmony 回调函数使用心得:从接口声明到异步结果处理》讨论了回调接口、执行时机和数据生命周期。本文沿着这个思路,用一个独立构造的 C++17 教学示例,说明如何处理迟到结果、重复回调和对象销毁。

本文面向 OpenHarmony 原生模块开发者,以公开的 OpenHarmony 5.1 EventHandler 文档作为接入背景。示例使用标准 C++,不包含项目源码、业务接口、设备信息或开发日志;验证范围为桌面编译与确定性用例,未进行 OpenHarmony 板端集成验证。

1. 先约定:在哪个线程决定结果是否有效

如果成功回调在工作线程修改状态,超时任务在事件线程修改状态,取消又来自另一个线程,单独增加一个 bool finished 并不能解决并发访问。

这里采用一个明确的约束:同一接收对象的请求状态,只能由同一个所属线程读写。 其他线程先把结果交给该线程的队列,再执行状态判断。所属线程可以是专用事件线程,不一定是 UI 线程。

OpenHarmony 的 EventHandler 通过关联 EventRunner 处理消息和任务,可以承接这种串行处理方式。具体初始化、投递返回值和退出流程,应以对应版本接口为准。本文不把教学用的投递函数冒充为系统 API。参见 OpenHarmony 5.1 EventHandler 说明。

还需要约定超时的含义。本文选择:结果在所属线程被处理时,若已到截止时刻,就按超时处理。 这意味着,数据即使提前到达底层,只要排队太久,也可能被判超时。如果业务要求按底层接收时刻判断,需要设计可信的时间戳和事件排序规则,不能直接套用此策略。

2. 请求编号隔离新旧结果,状态机限制终态次数

只看“当前是否正在请求”还不够:旧请求超时后,新请求也可能处于等待状态,此时旧结果容易被误认为新结果。

给每次请求一个递增编号,并让结果回调、超时任务、取消操作都携带它。编号必须在发起请求前准备好;有些接口可能在调用期间同步触发回调。处理时同时检查:编号是否匹配,状态是否仍为 Pending。

输入事件接受条件状态变化
新请求当前没有等待中的请求生成新编号,进入 Pending
结果到达编号匹配且仍在等待,处理时尚未到期Done
结果迟到编号匹配且仍在等待,处理时已经到期TimedOut
超时任务编号匹配且仍在等待,已到截止时刻TimedOut
取消编号匹配且仍在等待Cancelled
旧编号或重复事件不满足接受条件忽略

这里的 Done 表示结果被接收,结果中的业务成功或失败仍需单独解释。取消先被处理时可以终止请求;截止时刻本身不会凭空执行代码,超时需要在结果处理或定时任务中被观察到。

下面是完整的状态门控类。它一次只容纳一个等待中的请求;若需要并发请求,可以为各请求分别维护状态,而不是直接共享这一份状态。

#include <chrono>
#include <cstdint>
#include <limits>
#include <optional>

// All methods must run on the same owner thread.
class RequestGate {
public:
    using Clock = std::chrono::steady_clock;
    using TimePoint = Clock::time_point;
    using Ticket = std::uint64_t;
    enum class State { Idle, Pending, Done, TimedOut, Cancelled };
    enum class Result { Ignored, Done, TimedOut, Cancelled };

    std::optional<Ticket> Begin(TimePoint deadline)
    {
        if (state_ == State::Pending || next_ == std::numeric_limits<Ticket>::max()) {
            return std::nullopt;
        }
        active_ = ++next_;
        deadline_ = deadline;
        state_ = State::Pending;
        return active_;
    }

    Result Complete(Ticket id, TimePoint now)
    {
        if (!Matches(id)) return Result::Ignored;
        if (now >= deadline_) return Finish(State::TimedOut, Result::TimedOut);
        return Finish(State::Done, Result::Done);
    }

    Result Expire(Ticket id, TimePoint now)
    {
        if (!Matches(id) || now < deadline_) return Result::Ignored;
        return Finish(State::TimedOut, Result::TimedOut);
    }

    Result Cancel(Ticket id)
    {
        if (!Matches(id)) return Result::Ignored;
        return Finish(State::Cancelled, Result::Cancelled);
    }

    State GetState() const { return state_; }

private:
    bool Matches(Ticket id) const
    {
        return state_ == State::Pending && id == active_;
    }
    Result Finish(State state, Result result)
    {
        state_ = state; // Commit the terminal state before notifying external code.
        return result;
    }
    Ticket next_ = 0;
    Ticket active_ = 0;
    TimePoint deadline_{};
    State state_ = State::Idle;
};

使用者先调用 Begin(),成功取得编号后才启动底层操作。完成、取消或超时函数返回 Ignored 时不发送终态通知,其他返回值才通知上层。通知前状态已经提交,避免上层回调重入、立即发起新请求时仍看到旧请求处于等待状态。

请求编号只在同一个门控实例内唯一。不要把销毁前的裸编号转交给另一个新实例;编号与接收对象应一起构成路由依据。计数达到上限时示例拒绝新请求,避免回绕后重复使用旧编号。

这个门控保证的是:在约定线程和调用规则下,每次请求最多产生一次被接受的终态转换。 它不保证底层回调一定送达,也不保证上层业务副作用恰好执行一次。

3. 超时要检查截止时刻,不能只等定时任务执行

设截止时刻为 100 ms,事件线程正在忙,超时任务直到 120 ms 才获得执行机会。如果结果在 110 ms 被处理,而代码只判断“超时回调是否运行过”,就会把已经超过截止时刻的结果当作正常完成。

因此,Complete() 也检查截止时刻。结果迟到时直接进入 TimedOut,随后运行的超时任务只能得到 Ignored,避免重复通知。

示例选择 std::chrono::steady_clock,因为其时间点不会随着物理时间推进而倒退,也不会被校时调整;它适合计算这类相对期限。相关定义见 C++ 工作草案:steady_clock。生产接入时应在所属线程实际处理事件的位置获取 Clock::now(),测试则可传入明确的时间点,不必真实等待。

定时任务也必须携带原请求编号。否则旧请求留下的定时任务,可能在新请求启动后错误地结束新请求。如果定时机制可能提前唤醒,应按剩余时间重新安排检查;仅得到 Ignored 并不会自动补上下一次检查。

4. 编号正确,还要处理对象和数据的生命周期

状态机不能让已经销毁的对象重新有效,也不能延长底层缓冲区的生命周期。

一种常见接法是:对象由 shared_ptr 管理,异步任务持有 weak_ptr;底层结果若只在当前回调期间有效,应先复制为自己拥有的数据,再投递到所属线程。以下是接入形状,PostToOwner 和 Receiver 是待实现的适配层,不是 OpenHarmony API,也不是可直接编译的完整程序:

// weak、ticket 在发起请求前准备;不能捕获一个随后会变化的“当前编号”。
std::string ownedPayload = CopyResultWhileValid(rawResult);
bool queued = PostToOwner(
    [weak, ticket, payload = std::move(ownedPayload)] {
        if (auto self = weak.lock()) {
            // OnResult 在所属线程内检查 ticket、状态与当前时间。
            self->OnResult(ticket, payload);
        }
    });
if (!queued) {
    // 按队列失败或关闭协议处理;不能直接在回调线程操作 RequestGate。
}

weak.lock() 在对象仍存活时取得临时强引用,但不会让对象内部字段自动具备线程安全性,也不能代表对象在业务上仍处于可接收状态。已经取消但尚未销毁的对象,仍需经过状态机判断。参见 C++ 工作草案:weak_ptr 观察操作。

此外,异步任务不应引用已经离开作用域的局部变量。涉及外部通知时,还应避免持锁调用未知回调,以免发生重入和锁依赖问题。这两点分别对应 C++ Core Guidelines 的 F.53 与 CP.22。本文通过复制数据、串行更新状态和先提交终态再通知,减少这类接入风险。

取消也有两个层次:这里的 Cancel() 只让逻辑上不再接受结果,不负责停止底层 I/O、注销回调或等待执行中的回调退出。底层资源释放必须遵循实际接口的取消与注销约定。关停时,还要明确停止接收新请求、处理未完成状态、停止生产者和退出队列的顺序;如果队列已关闭却没有失败处理通道,状态机本身不能补救丢失的终态通知。

5. 用事件顺序验证边界,而不是靠 sleep 碰运气

本示例在桌面环境使用 C++17 编译,启用 -Wall -Wextra -Werror -pedantic,用显式时间点和模拟回调验证了以下 11 组场景:

场景验证结果
正常结果、重复结果、随后到来的超时任务只接收一次结果
等待过程中再次 Begin拒绝新请求,保留原截止时刻
恰好在截止时刻处理结果判为超时
迟到结果先于超时任务执行只产生一次超时终态
超时任务提前执行不消耗当前请求
超时任务先执行,结果随后到达结果被忽略
取消后到达结果,再次取消都不改变已选定的终态
新请求启动后,旧结果、旧定时任务和旧取消到达新请求不受影响
通知中立即启动下一次请求新请求可正常完成
模拟排队回调执行前接收对象销毁弱引用无法提升,不再访问对象
原始字符串离开作用域后执行模拟回调自有副本仍保持原内容

这些用例验证了门控逻辑和演示中的生命周期处理,没有覆盖实际 EventRunner 调度、投递失败、接口注销时序或设备运行。这些仍需在真实接入层补充验证。

处理异步回调时,可以沿着固定顺序检查:编号是否属于本次请求 → 是否还在等待 → 是否超过期限 → 接收对象与数据是否有效 → 是否在约定线程提交终态。 每一层都有明确职责,迟到、重复和取消之间的交错就更容易解释,也更容易复现。

Logo

社区规范:仅讨论OpenHarmony相关问题。

更多推荐