C++ std::shared_ptr自定义删除器线程安全与调用时机问题
不能在当前代码无额外约定的前提下,直接认定自定义删除器调用时所有A::update()操作已执行完成,是否安全完全取决于SomeConcurrentMap的实现语义和getA()的正确性。
问题背景
[编辑补充:以下为更具体的示例]
假设我有一个线程安全对象(所有公有成员均通过mutex保护),以及一个带自定义删除器的
shared_ptr,代码示例如下:
class A { public: void update(int x); void print_sum(); ... }; class AContainer { private: SomeConcurrentMap<string, shared_ptr<A>> aMap; public: void newA(string name) { aMap.emplace(name, shared_ptr<A>(new A, [](A *p){ p->print_sum(); delete p; })); } void finalizeA(string name) { aMap.erase(name); } shared_ptr<A> getA(string name) const { // fixme 处理键不存在的场景... return aMap.find(name).second; } }; void someFunctionInSomeThread(const AContainer &cont, string name, int c) { // fixme 处理键不存在的场景... cont.getA(name)->update(c); }
已知前提:
- 类A的所有操作都受内部mutex保护
SomeConcurrentMap本身是线程安全的
使用场景:
- 可从任意线程调用
AContainer::newA() - 多个线程可多次调用
someFunctionInSomeThread() - 可从任意线程调用
AContainer::finalizeA(),该操作可能与上述someFunctionInSomeThread()的调用并行执行
预期目标:A::print_sum()会在finalizeA()执行完成、且所有正在运行的A::update()操作全部结束后才被调用。
核心疑问:是否可以安全认定,当自定义删除器中p->print_sum()被调用时,目标对象上的所有A::update()操作都已经执行完成?
详细分析
满足安全要求的前提
如果SomeConcurrentMap符合线程安全容器的通用设计约定,getA()能安全返回目标shared_ptr的独立副本——也就是容器保证,在调用方完成对返回值的拷贝构造之前,对应key的节点不会被并发的erase()操作销毁,那么结论是成立的,原因如下:
- 所有调用
update()的线程,必须先通过getA()拿到一个独立的shared_ptr<A>副本,这一过程会让shared_ptr的引用计数+1,只要该副本存活,对象就不会被销毁。 - 根据C++标准,
getA()返回的临时shared_ptr的生命周期会持续到包含它的全表达式(即cont.getA(name)->update(c)整行代码)执行结束,也就是update()调用返回后,临时shared_ptr才会析构,引用计数-1。 shared_ptr的引用计数增减是原子操作,自带同步语义:当最后一个持有对象的shared_ptr析构、引用计数归0时才会执行自定义删除器,所有之前的shared_ptr拷贝、析构操作都和删除器的调用存在happens-before关系。此时所有正在运行的update()必然已经执行完成,删除器中调用print_sum()完全符合预期目标。
存在竞态风险的场景
当前代码没有对SomeConcurrentMap的find()语义做明确约定,getA()的写法存在触发未定义行为的可能:
如果SomeConcurrentMap::find()仅在查找节点的过程中持有内部锁,找到节点后立刻释放锁,再返回指向节点内部shared_ptr的引用,那么后续拷贝shared_ptr构造返回值的过程是没有锁保护的。此时如果其他线程并发调用erase()销毁对应节点,就会出现悬空引用,拷贝shared_ptr的行为完全不可控,属于典型的数据竞争。
这种未定义行为可能导致引用计数没有正确累加,出现对象提前销毁、删除器被调用时仍有线程在执行update()的问题,完全无法保证预期逻辑生效。
内容的提问来源于stack exchange,提问作者Uri Simchoni

