异步 C++ 代码中,一个很常见的写法是先捕获 weak_ptr,任务真正执行时再提升成 shared_ptr

auto weak = weak_from_this();
worker_->post([weak] {
  if (auto self = weak.lock()) {
    self->doWork();
  }
});

它解决了“任务排队期间对象已经析构”的悬垂指针问题,却引入了一个常被忽略的事实:最后一个 shared_ptr 在哪条线程释放,对象就可能在哪条线程析构。

如果对象内部正好拥有当前 worker,析构函数又会停止并 join 这条线程,就形成了 self-join:工作线程等待自己退出。不同线程库对此可能表现为永久死锁、抛出异常或主动 abort。

更复杂的系统里,这个问题还会与同步跨线程调用、持锁回调和退出时序叠加,形成三条甚至更多线程参与的等待环。仅仅“把 mutex 换成 recursive_mutex”或“多捕获一个 shared_ptr”都不会解决根因。

一、shared_ptr 只表达所有权,不表达析构线程

引用计数的语义是:强引用数量降到零时销毁对象。它没有规定销毁必须发生在创建线程、主线程或 owner 线程。

下面是一段最小化时序:

Main thread                     Worker thread
-----------                     -------------
post task
drop external shared_ptr
                                task starts
                                weak.lock() -> last shared_ptr
                                doWork()
                                task ends
                                release last shared_ptr
                                ~Service()
                                ~Worker()
                                worker.join()  // join itself

代码表面上所有访问都受 shared_ptr 保护,没有 use-after-free;真正的错误是把对象析构和 worker 的退出绑定在一起,却没有约束析构发生的执行上下文。

因此,应当把两个问题分开:

  1. 任务执行时对象是否还活着?
  2. 对象最终在哪里、以什么顺序销毁?

weak_ptr::lock() 只回答第一个问题。

二、self-join 为什么不是偶发的小概率问题

一个拥有线程的对象通常会这样析构:

Worker::~Worker() {
  requestStop();
  if (thread_.joinable()) {
    thread_.join();
  }
}

这段代码只有在析构必然发生于其他线程时才安全。只要最后一个 owner 可能在 worker task 中释放,self-join 就是合法可达的状态,而不是理论边界。

下面几种场景尤其常见:

  • 外部 owner 在任务执行前刚好释放;
  • 退出登录或页面关闭时,主线程清理了主要引用;
  • 回调内部临时提升的 shared_ptr 成为最后一个引用;
  • 一个成员 listener 反向持有 owner,解除监听时改变最后引用所在位置;
  • 异常分支提前返回,局部强引用在 worker 上释放;
  • 定时任务或延迟任务比 owner 的业务生命周期更长。

线上表现也未必总是“卡死”。线程库可能检测到对当前线程 join,直接触发终止;有时析构链中先访问了正在拆除的资源,则表现为 crash。排查时要把析构栈上的 worker/looper 看成强信号。

三、一个能暴露问题的最小复现

class Service : public std::enable_shared_from_this<Service> {
 public:
  static std::shared_ptr<Service> create() {
    auto p = std::shared_ptr<Service>(new Service);
    p->worker_.start();
    return p;
  }

  void runLater() {
    std::weak_ptr<Service> weak = shared_from_this();
    worker_.post([weak] {
      if (auto self = weak.lock()) {
        self->step();
      }
      // self may be the last owner; ~Service runs here.
    });
  }

  ~Service() {
    worker_.stopAndJoin();
  }

 private:
  void step();
  Worker worker_;
};

测试时,调用 runLater() 后立刻释放外部 shared_ptr,就能显著放大问题。这里加一个 if (worker_.isCurrentThread()) 再绕过 join,通常仍不够:不 join 就直接析构 Worker,底层线程可能继续访问已经释放的成员。

所以“检测 self-join 后 detach”只能作为极少数无状态线程的兜底,不是通用修复。被 detach 的线程与对象内存之间仍可能存在依赖。

四、首选方案:线程不由业务对象独占生命周期

最稳妥的架构是把 executor/线程池的生命周期提升到业务对象之外:

Process / subsystem owner
      │ owns

Executor pool ────────────────┐
      │ schedules             │ outlives all tasks
      ▼                       │
Business object <─────────────┘

业务对象析构时只取消自己的任务和状态,不销毁当前执行线程。线程池由更高层 owner 在确认所有业务任务退出后统一停止。

这类似 GCD、协程调度器或通用线程池的思路:业务提交的是任务,线程只是执行资源。任务可能在多个工作线程间复用,线程回收由独立管理者负责。只要 executor 的生命周期严格长于 task 和业务对象,就不会出现对象在 worker 上销毁 worker 本身。

优点:

  • 消除 self-owned thread 的结构性风险;
  • 降低频繁创建线程的成本;
  • 统一优先级、监控和退出;
  • 更容易实现任务取消与 drain。

代价是需要隔离不同业务的阻塞任务,并避免一个模块长期占用共享线程。

五、保留独占线程时,使用两阶段关闭

如果对象确实必须拥有独立线程,不要把全部停止逻辑塞进析构函数。定义显式协议:

class Service {
 public:
  void close() {
    bool expected = false;
    if (!closing_.compare_exchange_strong(expected, true)) return;
    worker_.requestStop();
  }

  void join() {
    assert(!worker_.isCurrentThread());
    worker_.join();
  }

  ~Service() {
    assert(closing_ && "close() must be called before destruction");
    assert(!worker_.joinable() && "join() must finish before destruction");
  }
};

由外层 owner 在安全线程按顺序执行:

stop accepting work
    -> cancel pending tasks
    -> request worker stop
    -> wait in-flight tasks
    -> join from owner thread
    -> release final shared_ptr

这比“析构时自动做所有事”啰嗦,但它让停止时机、等待线程和所有权释放变得可验证。析构函数变成断言最后状态,而不是承担一个可能阻塞的复杂协议。

close 必须幂等

页面退出、业务 owner 清理、异常回滚可能同时调用 close()。状态转换应只发生一次,重复调用只返回:

Running -> Closing -> Draining -> Joined -> Destroyed

进入 Closing 后拒绝新任务;已经排队的任务根据业务语义执行、取消或丢弃,但必须给等待者明确结果。

六、用专用 Reaper 线程回收 worker

如果现有架构难以立刻改成线程池,可以引入独立的回收线程:业务对象在任意线程失去最后引用时,不直接销毁 worker,而是把待回收资源转交给 reaper。

void WorkerOwner::dispose() {
  auto worker = std::move(worker_);
  GlobalReaper::instance().enqueue([worker = std::move(worker)]() mutable {
    worker->requestStop();
    worker->join();
    worker.reset();
  });
}

reaper 必须满足:

  • 生命周期长于所有业务 worker;
  • 永远不是被回收 worker 本身;
  • 有界队列和退出 drain;
  • 回收任务不得依赖已经析构的业务对象;
  • 进程退出时有明确的停止顺序。

也可以使用自定义 deleter 把最终析构调度到指定 executor:

auto service = std::shared_ptr<Service>(
    new Service,
    [](Service* p) {
      destructionExecutor().post([p] { delete p; });
    });

但要小心:如果 destruction executor 已经退出,任务可能永远不执行;若 deleter 捕获了即将销毁的全局对象,也会制造新的生命周期环。因此它应属于稳定的基础设施,并提供同步降级或关机阶段的明确策略。

七、不要在持锁状态下同步跨执行器调用

self-join 是一条线程等待自己,更常见的死锁是多线程形成闭环。例如:

Main thread
  holds/waits context A
  └─ sync call Worker 1

Worker 1
  holds resource mutex
  └─ sync call Worker 2

Worker 2
  needs resource mutex
  └─ waits forever

或更具体地表示为等待图:

T_main  -> context on T_1
T_1     -> mutex held by T_2
T_2     -> context on T_1

三个节点已经构成环。recursive_mutex 只允许同一线程重复获取同一把锁,无法解决 T1 与 T2 之间的互相等待。

工程上应建立两条规则:

  1. 持有业务 mutex 时,不做同步跨线程/跨队列调用;
  2. executor 之间规定单向依赖层级,低层不能同步回调高层。

如果操作必须跨线程,把同步接口改为异步,先释放锁,再由结果回调继续状态机:

Data snapshot;
{
  std::lock_guard lock(mutex_);
  snapshot = makeSnapshotLocked();
}

worker_->post([snapshot = std::move(snapshot)] {
  process(snapshot);
});

复制一份小快照通常比把锁和线程依赖耦合在一起更安全。若数据很大,可以转移不可变对象或使用版本号,而不是在等待期间持锁。

八、weak_ptr 捕获的正确边界

weak_ptr 仍然是有用工具,只是要理解它解决的范围:

  • 防止排队任务无条件延长对象生命;
  • 任务执行前检查 owner 是否仍存在;
  • 允许关闭后自然忽略旧任务。

它不解决:

  • 最后引用在哪条线程释放;
  • worker 是否属于 owner;
  • doWork() 内的锁顺序;
  • task 与 close 并发时的状态竞争;
  • 异步操作的完成回调是否恰好一次。

一个更完整的任务入口应同时检查生命周期状态:

worker.post([weak, token] {
  auto self = weak.lock();
  if (!self) return;
  if (self->closing_.load(std::memory_order_acquire)) return;
  if (!self->generation_.matches(token)) return;
  self->doWork();
});

generation 可以让旧一轮业务会话排队的任务在新一轮对象复用时失效。

九、怎样排查线上析构死锁或 crash

9.1 先取得所有线程栈

单个崩溃线程不够。死锁需要看等待关系:哪条线程在 join、哪条在 mutex/futex、哪条在同步派发。把线程、持有资源和等待资源画成图,寻找环。

9.2 在析构与引用边界留下证据

调试版本可记录:

  • 对象 ID、创建线程和析构线程;
  • worker thread ID;
  • close/requestStop/join/destructor 的时间和调用栈摘要;
  • 最后一次任务入队、开始和结束;
  • 当前状态和在途任务数。

不要尝试在线上记录每次 shared_ptr 增减,成本太高且噪声巨大。围绕状态转换和任务边界打点更有价值。

9.3 主动断言错误上下文

Worker::~Worker() {
  assert(std::this_thread::get_id() != thread_.get_id());
  assert(!thread_.joinable());
}

尽早在测试阶段失败,比在线上随机 abort 更容易定位。还可以用 ThreadSanitizer 查数据竞争,用压力测试反复执行“post 后立即 close”和“多个线程同时 close”。

十、设计检查表

  • 业务对象是否直接拥有正在执行自己的线程?
  • 最后一个强引用可能在哪些线程释放?
  • 析构函数是否执行阻塞操作或 join
  • 退出是否有显式的 close -> drain -> join -> destroy 顺序?
  • close() 是否幂等,进入 closing 后是否拒绝新任务?
  • worker 上的任务是否可能成为最后一个 owner?
  • 持锁期间是否存在同步跨线程调用?
  • executor 之间是否有清晰的依赖方向?
  • 日志能否还原任务、状态和销毁时序?

shared_ptr 让异步对象更容易写对,但它不是线程生命周期管理器。一个可靠的异步系统,需要把“对象是否活着”“任务是否可执行”“线程何时退出”“对象在哪销毁”分别建模。只要析构仍然承担隐式的 stop-and-join,最后一个引用落在哪条线程,就会继续成为系统中的随机变量。