C++26: Ein Lock-freier Stack mit Atomic Smart Pointer

Der einfachste Weg, das in einem frĂĽheren Beitrag beschriebene Speicherleck zu beheben, ist die Verwendung eines std::shared_ptr.

vorlesen Druckansicht 49 Kommentare lesen
HolzwĂĽrfel mit dem Schriftzug C++

(Bild: SerbioVas/Shutterstock)

Lesezeit: 3 Min.
Von
  • Rainer Grimm

In meinem vorherigen Blogbeitrag habe ich die vollständige Implementierung eines Lock-freien Stacks gezeigt, der aber noch ein Speicherleck hatte. Diesmal zeige ich den Ansatz, um es zu beseitigen.

Modernes C++ – Rainer Grimm
Rainer Grimm

Rainer Grimm ist seit vielen Jahren als Softwarearchitekt, Team- und Schulungsleiter tätig. Er schreibt gerne Artikel zu den Programmiersprachen C++, Python und Haskell, spricht aber auch gerne und häufig auf Fachkonferenzen. Auf seinem Blog Modernes C++ beschäftigt er sich intensiv mit seiner Leidenschaft C++.

Es gibt zwei Möglichkeiten, atomare Operationen auf einen std::shared_ptr anzuwenden: In C++11 können die freien Atomic-Funktionen auf std::shared_ptr verwendet werden. Mit C++20 können Atomic Smart Pointers verwendet werden.

C++11

Die Verwendung von Atomic-Operationen auf std::shared_ptr ist mühsam und fehleranfällig. Man kann die Atomic-Operationen leicht vergessen, und alles ist möglich. Das folgende Beispiel zeigt einen Lock-freien Stack, der auf std::shared_ptr basiert.

// lockFreeStackWithSharedPtr.cpp

#include <atomic>
#include <future>
#include <iostream>
#include <stdexcept>
#include <memory>

template<typename T>
class LockFreeStack {
 public:
    struct Node {
        T data;
        std::shared_ptr<Node> next;
    };
    std::shared_ptr<Node> head;
 public:
    LockFreeStack() = default;
    LockFreeStack(const LockFreeStack&) = delete;
    LockFreeStack& operator= (const LockFreeStack&) = delete;
   
    void push(T val) {
        auto newNode = std::make_shared<Node>();
        newNode->data = val;
        newNode->next = std::atomic_load(&head);                                       // 1
        while( !std::atomic_compare_exchange_strong(&head, &newNode->next, newNode) ); // 2
    }

    T topAndPop() {
        auto oldHead = std::atomic_load(&head);                                       // 3
        while( oldHead && !std::atomic_compare_exchange_strong(&head, &oldHead, std::atomic_load(&oldHead->next)) ) {     // 4
            if ( !oldHead ) throw std::out_of_range("The stack is empty!");
        }
        return oldHead->data;
    }
};
   
int main(){

    LockFreeStack<int> lockFreeStack;
    
    auto fut = std::async([&lockFreeStack]{ lockFreeStack.push(2011); });
    auto fut1 = std::async([&lockFreeStack]{ lockFreeStack.push(2014); });
    auto fut2 = std::async([&lockFreeStack]{ lockFreeStack.push(2017); });
    
    auto fut3 = std::async([&lockFreeStack]{ return lockFreeStack.topAndPop(); });
    auto fut4 = std::async([&lockFreeStack]{ return lockFreeStack.topAndPop(); });
    auto fut5 = std::async([&lockFreeStack]{ return lockFreeStack.topAndPop(); });
    
    fut.get(), fut1.get(), fut2.get();
    
    std::cout << fut3.get() << '\n';
    std::cout << fut4.get() << '\n';
    std::cout << fut5.get() << '\n';

}

Diese Implementierung eines Lock-freien Stacks ähnelt der vorherigen Implementierung ohne Speicherrückgewinnung. Der Hauptunterschied besteht darin, dass die Knoten vom Datentyp std::shared_ptr sind. Alle Operationen std::shared_ptr werden atomar unter Verwendung der freien atomaren Operationen std::load (1) und (4) und std::atomic_compare_exchange_strong (2) und (3) durchgeführt. Die atomaren Operationen erfordern einen Zeiger. Ich möchte ausdrücklich betonen, dass die Leseoperation des nächsten Knotens in oldHead->next (4) atomar sein muss, da oldHead->next von anderen Threads verwendet werden kann.

AbschlieĂźend folgt hier die Ausgabe des Programms.

Springen wir neun Jahre in die Zukunft und verwenden C++20.

C++20

C++20 unterstĂĽtzt partielle Spezialisierungen von std::atomic fĂĽr std::shared_ptr und std::weak_ptr. Die folgende Implementierung fĂĽgt die Knoten des Lock-freien Stacks in ein std::atomic<std::shared_ptr<Node>> ein.

// lockFreeStackWithAtomicSharedPtr.cpp

#include <atomic>
#include <future>
#include <iostream>
#include <stdexcept>
#include <memory>

template<typename T>
class LockFreeStack {
 private:
    struct Node {
        T data;
        std::shared_ptr<Node> next;
    };
    std::atomic<std::shared_ptr<Node>> head;           // 1
 public:
    LockFreeStack() = default;
    LockFreeStack(const LockFreeStack&) = delete;
    LockFreeStack& operator= (const LockFreeStack&) = delete;
   
    void push(T val) {                              // 2
        auto newNode = std::make_shared<Node>();
        newNode->data = val;
        newNode->next = head;
        while( !head.compare_exchange_strong(newNode->next, newNode) );
    }

    T topAndPop() {
        auto oldHead = head.load();
        while( oldHead && !head.compare_exchange_strong(oldHead, oldHead->next) ) {
            if ( !oldHead ) throw std::out_of_range("The stack is empty!");
        }
        return oldHead->data;
    }
};
   
int main(){

    LockFreeStack<int> lockFreeStack;
    
    auto fut = std::async([&lockFreeStack]{ lockFreeStack.push(2011); });
    auto fut1 = std::async([&lockFreeStack]{ lockFreeStack.push(2014); });
    auto fut2 = std::async([&lockFreeStack]{ lockFreeStack.push(2017); });
    
    auto fut3 = std::async([&lockFreeStack]{ return lockFreeStack.topAndPop(); });
    auto fut4 = std::async([&lockFreeStack]{ return lockFreeStack.topAndPop(); });
    auto fut5 = std::async([&lockFreeStack]{ return lockFreeStack.topAndPop(); });
    
    fut.get(), fut1.get(), fut2.get();
    
    std::cout << fut3.get() << '\n';
    std::cout << fut4.get() << '\n';
    std::cout << fut5.get() << '\n';

}

Der Hauptunterschied zwischen der vorherigen und dieser Implementierung besteht darin, dass der Knoten in einen std::atomic<std::shared_ptr<Node>> eingebettet ist (1). Folglich erstellt die Memberfunktion push (2) einen std::shared_ptr<Node> und der Aufruf head.load() in der Memberfunktion topAndPop gibt einen std::atomic<std::shared_ptr<Node>> zurĂĽck.

Hier ist die Ausgabe des Programms:

std::atomic<std::shared_ptr> ist nicht Lock-frei

Offen gestanden habe ich in den vorherigen Programmen mit atomaren Operationen auf einem std::shared_ptr und einem std::atomic geschummelt. Diese atomaren Operationen auf einem std::shared_ptr sind derzeit nicht Lock-frei. Darüber hinaus kann eine Implementierung von std::atomic einen Lockmechanismus verwenden, um alle teilweisen und vollständigen Spezialisierungen von std::atomic zu unterstützen.

Die Funktion atom.lock_free() fĂĽr ein std::atomic<std::shared_ptr<Node>> gibt false zurĂĽck.

 // atomicSmartPointer.cpp
 
#include <atomic>
#include <iostream>
#include <memory>

template <typename T>
struct Node {
    T data;
    std::shared_ptr<Node> next;   
};

int main() {    

    std::cout << '\n';

    std::cout << std::boolalpha;

    std::atomic<std::shared_ptr<Node<int>>> node;
    std::cout << "node.is_lock_free(): "  << node.is_lock_free() << '\n';

    std::cout << '\n';

}

Wir sind also wieder am Anfang und müssen uns in meinem nächsten Artikel um das Speichermanagement kümmern.

(rme)