faible_ptr en singleton non thread-safe
J'écris une fonction qui renvoie un shared_ptr à un singleton. Je veux que l'objet singleton soit détruit lorsque toutes les références ont disparu. Ma solution s'appuie sur cette réponse acceptée qui utilise un statique weak_ptr
et mutex
, mais mon test de sécurité des threads ne réussit pas de manière cohérente.
Voici un exemple complet qui illustre le problème.
#include <gtest/gtest.h>
#include <atomic>
#include <mutex>
#include <memory>
#include <vector>
#include <thread>
using namespace std;
// Number of instances of this class are tracked in a static counter.
// Used to verify the get_singleton logic (below) is correct.
class CountedObject {
private:
static atomic<int> instance_counter;
public:
CountedObject() {
int prev_counter = instance_counter.fetch_add(1);
if (prev_counter!= 0)
// Somehow, 2 objects exist at the same time. Why?
throw runtime_error("Constructed " + to_string(prev_counter + 1) +
" counted objects");
}
~CountedObject() {
instance_counter.fetch_sub(1);
}
static int count() {
return instance_counter.load();
}
};
atomic<int> CountedObject::instance_counter{0};
// Returns reference to a singleton that gets destroyed when all references
// are destroyed.
template <typename T>
std::shared_ptr<T> get_singleton() {
static mutex mtx;
static weak_ptr<T> weak;
scoped_lock lk(mtx);
shared_ptr<T> shared = weak.lock();
if (!shared) {
shared.reset(new T, [](T* ptr){
scoped_lock lk(mtx);
delete ptr;
});
weak = shared;
}
return shared;
}
// This test passes consistently.
TEST(GetSingletonTest, SingleThreaded) {
ASSERT_EQ(CountedObject::count(), 0);
auto ref1 = get_singleton<CountedObject>();
auto ref2 = get_singleton<CountedObject>();
ASSERT_EQ(CountedObject::count(), 1);
ref1.reset();
ASSERT_EQ(CountedObject::count(), 1);
ref2.reset();
ASSERT_EQ(CountedObject::count(), 0);
}
// This test does NOT pass consistently.
TEST(GetSingletonTest, MultiThreaded) {
const int THREAD_COUNT = 2;
const int ITERS = 1000;
vector<thread> threads;
for (int i = 0; i < THREAD_COUNT; ++i)
threads.emplace_back([ITERS]{
// Repeatedly obtain and release references to the singleton.
// The invariant must hold that at most one instance ever exists
// at a time.
for (int j = 0; j < ITERS; ++j) {
auto local_ref = get_singleton<CountedObject>();
local_ref.reset();
}
});
for (auto& t: threads)
t.join();
}
Sur mon système (ARM64 Linux, g++ 7.5.0), le test multithread échoue généralement :
[==========] Running 2 tests from 1 test case.
[----------] Global test environment set-up.
[----------] 2 tests from GetSingletonTest
[ RUN ] GetSingletonTest.SingleThreaded
[ OK ] GetSingletonTest.SingleThreaded (0 ms)
[ RUN ] GetSingletonTest.MultiThreaded
terminate called after throwing an instance of 'std::runtime_error'
what(): Constructed 2 counted objects
Aborted (core dumped)
J'ai omis cout
les messages du code par souci de brièveté, mais séparément j'ai ajouté des messages pour déboguer ce qui se passe :
[thread id, message]
...
547921465808 Acquired lock
547921465808 Weak ptr expired, constructing
547921465808 Releasing lock
547929858512 Acquired lock
547929858512 Weak ptr expired, constructing
terminate called after throwing an instance of 'std::runtime_error'
what(): Constructed 2 counted objects
Aborted (core dumped)
Il semble que le thread 1 détermine que le singleton a expiré et le reconstruit. Ensuite, le thread 2 se réveille et détermine également que le singleton a expiré, même si le thread 1 vient de le repeupler - comme si le thread 2 fonctionnait avec une version "obsolète" du pointeur faible.
Comment puis-je rendre l'affectation à weak
partir du fil 1 immédiatement visible pour le fil 2 ? Cela peut-il être réalisé avec C++20 atomic<weak_ptr<T>>
? Mon projet est limité à C++17, donc malheureusement ce n'est pas une option pour moi.
J'apprécie ton aide!
Solution du problème
L'affectation sous verrou sera visible par tout autre thread vérifiant le weak_ptr
sous verrou.
La raison pour laquelle plusieurs instances existent est que l'ordre des opérations du destructeur ne garantit pas le contraire. !weak.lock()
n'est pas équivalent à "avec certitude, il n'y a pas d'instances de cet objet". Cela équivaut à l'inverse de "avec certitude, il existe une instance de cet objet". Il peut y avoir une instance, car le décompte de références est décrémenté avant que l'effaceur n'ait la possibilité d'agir.
Imaginez cette séquence:
- Thread 1:
~shared_ptr
est appelé. - Thread 1 : Le nombre de références fortes est décrémenté.
- Thread 1: Il compare égal à zéro et le suppresseur commence.
- Thread 2:
get_singleton
est appelé. - Thread 2: Le verrou est acquis.
- Thread 2:
!weak.lock()
est satisfait, nous construisons donc une nouvelle instance. - Thread 1:... en attendant, nous attendons d'acquérir la serrure...
- Thread 2 : la nouvelle instance est construite et assignée, et le verrou est libéré.
- Thread 1 : Maintenant, le suppresseur peut acquérir le verrou et supprimer l'instance d'origine.
Commentaires
Enregistrer un commentaire