Thread-safe weak reference


#1

If I understand juce::WeakReference correctly, its use case is for when the referenced object’s deletion and usages do not happen in concurrent threads.

Is there a recommended solution or standard class for a thread-safe variant of it? I.e where you scope the usages with a lock to make sure that the object isn’t deleted while you use it.

For now I’m using this small utility that I created:

// SafeRef.h
/////////////
#pragma once

// This module provides a weak-reference mechanism,
// which unlike JUCE's WeakRef allows for safe locked access.

#include "JuceHeader.h"

template<typename T>
class SafeRef : public ReferenceCountedObject
{
public:
    typedef ReferenceCountedObjectPtr<SafeRef> Ptr;

    SafeRef (T* owner = nullptr)
        : owner_ (owner)
    {
    }

    ~SafeRef()
    {
        // If owner_ wasn't reset to nullptr then the user forgot to call reset()
        // in their destructor.
        jassert (owner_ == nullptr);
    }

    void reset (T* owner = nullptr)
    {
        ScopedWriteLock l (lock);
        owner_ = owner;
    }

    // A scoped read-only access to the reference.
    // For additional write access one may use additional ScopedWriteLock on the reference's `lock`.
    class ScopedAccess
    {
    public:
        ScopedAccess (SafeRef::Ptr& ref, bool tryLock = false)
            : ref_ (*ref)
        {
            if (tryLock)
                didLock = ref_.lock.tryEnterRead();
            else
            {
                ref_.lock.enterRead();
                didLock = true;
            }
            owner_ = didLock ? ref_.owner_ : nullptr;
        }

        ~ScopedAccess()
        {
            if (didLock)
                ref_.lock.exitRead();
        }

        T* operator*()
        {
            return owner_;
        }

        T* operator->()
        {
            return owner_;
        }

        operator bool() const
        {
            return owner_ != nullptr;
        }

    private:
        SafeRef& ref_;
        bool didLock;
        T* owner_;
    };

    // The lock is public, so owner can use `enterWrite` etc with it freely.
    ReadWriteLock lock;

private:
    T* owner_;
};

Cheers, Yair


#2

I don’t think you can ever do this with an intrusive reference counted pointer. In your example, you can still end up in dodgy territory if you enter the sub-class destructor, then take a ScopedAccess. You might technically get away with this but I think it’s UB to use an object that is currently being destructed. (At the very least it’s difficult to reason about).

If you want to use juce::ReferenceCountedObject I think you need to make sure all your objects are deleted on a single thread which everyone agrees on. That way you know if an object is partially deleted.

But to be honest, the safest bet is to use std::shared_ptr and std::weak_ptr. They use atomic ref counts to ensure you can only dereference through a valid std::shared_ptr, even if obtained via a std::weak_ptr.
The only downside to this is that the memory storing the object will only be freed after all the weak_ptrs have been destroyed. This means you may need to poll them occasionally to reset them.


#3

I guess my example class is not documented enough and caused some misunderstanding. Here’s how I use it:

I don’t inherit from SafeRef. A class X which needs weak-reference support contains a SafeRef<X>::Ptr member which it initialises in its constructor and invalidates (with a ref->reset() call) in its constructor (this is similar to having a juce::WeakReference<X>::Master).

Objects referencing the mentioned X also have a SafeRef<X>::Ptr member and use SafeRef<X>::ScopedAccess whenever they want to read from the referenced object.


#4

Yeah, that’s what I thought but you can still end up using the object during destruction?
Is this the kind of thing you had in mind? (It’s quite tricky to come up with a short threading, example…)

struct TestSafeObject
{
    TestSafeObject() = default;
    ~TestSafeObject()
    {
        // [1]
        safeRef.reset();
    }

    SafeRef<TestSafeObject> safeRef { this };
    using TestScopedAccess = SafeRef<TestSafeObject>::ScopedAccess;
};

// Elsewhere
std::thread thread;

{
    TestSafeObject testSafeObject;
    auto thread = std::move (std::thread ([testSafeRef = testSafeObject.safeRef]
                                          {
                                              if (auto access = TestSafeObject::TestScopedAccess (testSafeRef)) // [3]
                                              {
                                                  // Do some stuff [4]
                                              }
                                          }));
} // [2]

thread.join();

In the above, it’s possible that [2] will be reached, enter testSafeObject's destructor but not reset the safeRef, then [3] happens on the background thread giving a valid access object.
Then [4] is being called whilst testSafeObject is sitting in its destructor waiting on the lock.


As I said, this is extremely unlikely to happen and depending on your vtable, compiler, scheduler and which way the wind is blowing may or may not actually cause problems…

However, I think if [4] calls virtual methods of TestSafeObject, you can end up with problems.

(Disclaimer, above code typed directly in and not tested)


#5

It should be SafeRef<TestSafeObject>::Ptr.

This way, after the referenced object is destroyed, the actual SafeRef<TestSafeObject> is still alive and well (but it knows that the referenced object itsn’t available because its owner_ member is nullptr). The SafeRef object will be destroyed when both the reference-able object and all of its referencers are destroyed.


#6

Ok, so if I modify my example to the following:

struct TestSafeObject
{
    TestSafeObject() = default;
    ~TestSafeObject()
    {
        // [1]
        safeRef->reset();
    }

    SafeRef<TestSafeObject>::Ptr safeRef { new SafeRef<TestSafeObject> (this) };
    using TestScopedAccess = SafeRef<TestSafeObject>::ScopedAccess;
};

// Elsewhere
std::thread thread;

{
    TestSafeObject testSafeObject;
    auto thread = std::move (std::thread ([testSafeRef = testSafeObject.safeRef]
                                          {
                                              auto access = TestSafeObject::TestScopedAccess (testSafeRef);

                                              if (access) // [3]
                                              {
                                                  // Do some stuff [4]
                                                  access->blahblahblah();
                                              }
                                          }));
} // [2]

thread.join();

I think that how you intend it to be used?

But the problem remains, you can still end up in the situation where TestSafeObject is being destructed but the safeRef member is still valid so grants an owner_ that is in the middle of its destructor.

It’s a small race, and possibly benign (there’s debate over whether any race can be benign though) but the only way to avoid any race is to have external pointer with strong and weak counts. That way, it’s impossible to start destructing the object before the ref count gets to 0.
As soon as you move the weak-pointer management inside the class, there’s no way to tell it to invalidate the pointer before you’ve entered the destructor.


Maybe take a look as std::enable_shared_from_this https://en.cppreference.com/w/cpp/memory/enable_shared_from_this
if you want to be able to get a std::shared_ptr out of an existing object?


#7

As the reset() call happens as the very first thing in the destructor, owner_ would only be at the beginning of its destructor rather than in the middle, without any destruction of it or its members yet to take place.


#8

Yeah, I guess it’s technically safe, it just seems a little bit error prone to require that users of the class call reset as the first thing in their most derived class. That kind of thing is easy to forget.


#9

Your suggestion for using std::shared_ptr is good. But if I understand correctly, I cannot do that for my use-case externally to the object, because I’m implementing an existing API where I need to create a new object that is a sub-class of some given class… Perhaps I could used std::shared_ptr rather than juce::ReferenceCountedObject for my access-management, but it still has to be contained in my object iiuc…


#10

Yeah, if you’re adding this to an existing API things could be tricker. I’m not familiar with std::shared_ref though and can’t find it on cppreference.com?


#11

oops that was a typo… :slight_smile: