异步 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 的退出绑定在一起,却没有约束析构发生的执行上下文。
因此,应当把两个问题分开:
- 任务执行时对象是否还活着?
- 对象最终在哪里、以什么顺序销毁?
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 之间的互相等待。
工程上应建立两条规则:
- 持有业务 mutex 时,不做同步跨线程/跨队列调用;
- 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,最后一个引用落在哪条线程,就会继续成为系统中的随机变量。