OpenHarmony 异步回调晚到了怎么办?用请求编号和状态机守住结果边界
请求已经超时,成功回调却刚刚到达;页面已经退出,后台任务仍在返回数据;用户连续点击两次,上一轮结果覆盖了下一轮状态。这些问题看起来不同,实际都需要回答三个问题:结果属于哪次请求,现在还允许接收吗,接收对象还活着吗?
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 调度、投递失败、接口注销时序或设备运行。这些仍需在真实接入层补充验证。
处理异步回调时,可以沿着固定顺序检查:编号是否属于本次请求 → 是否还在等待 → 是否超过期限 → 接收对象与数据是否有效 → 是否在约定线程提交终态。 每一层都有明确职责,迟到、重复和取消之间的交错就更容易解释,也更容易复现。
更多推荐

所有评论(0)