3 września 2015

[C++11] Muteksy i współdzielenie danych między wątkami

Wstęp

Biblioteka standardowa C++11 wprowadza klasę std::lock_guard typu RAII implementującą idiom muteksu (blokuje muteks w konstruktorze i zwalnia go w destruktorze). Stosując muteksy należy przyswoić sobie dwie ważne zasady:
  1. Nie wolno zwracać lub zapisywać wskaźników lub referencji poza zasięgiem lock-a (blokady).
  2. Należy blokować zasoby tylko przez minimalny okres czasu, potrzebny do wykonania danej operacji.
Przykład poniżej pokazuje wykorzystanie std::lock_guard.
#include <iostream>
#include <string>
#include <thread>
#include <mutex>

using namespace std;

class Storage {
private:
    std::mutex interanl_mutex;
    std::string buff;

public:
    void increment(const std::string id) {
        std::lock_guard<std::mutex> guard(interanl_mutex);
        buff += id;
    }

    void show() {
        std::lock_guard<std::mutex> guard(interanl_mutex);
        std::cout << buff << endl;
    }
};

void update(const std::string id, int loop_counter, Storage& storage) {
    for (int i = 0; i < loop_counter; ++i) {
        std::this_thread::sleep_for(std::chrono::seconds(1));
        storage.increment(id);
    }
}

int main() {
    Storage s;
    std::thread t1{update, std::string("a"), 2, std::ref(s)};
    std::thread t2{update, std::string("b"), 3, std::ref(s)};

    t1.join();
    t2.join();

    s.show();
    return 0;
}
Wynik:
ababb

Deadlock - zakleszczenie

Z samym zagadnieniem można zapoznać się tutaj:
Kilka zasad dzięki, którym można uniknąć tego rodzaju problemów:
  1. Nie wolno czekać na inny wątek jeżeli istnieje szansa, że to on może czekać na nas.
  2. Nie wolno zakładać blokad, jeżeli jakaś została już założona. Jeżeli wątek potrzebuje kilku muteksów należy wykonać blokowanie jako pojedynczą operację (stosując np. std::lock)
  3. Jeżeli tworzona jest biblioteka, nie należy wołać kodu użytkownika trzymając jednocześnie blokadę na muteksach. Nigdy nie można być pewnym, czy kod użytkownika też nie założy swojej własnej blokady.
  4. Jeżeli nie można skorzystać z std::lock do założenia kilku blokad jednocześnie, należy pamiętać by blokady zakładać zawsze w tej samej kolejności.
  5. Nie wolno zakładać blokad, jeżeli kod posiada już blokadę na muteksa z niższego poziomu (można stworzyć hierarchical_mutex, który nie jest częścią standardu ale jest łatwy w implementacji [1])
W tym miejscu należy wspomnieć o nowej klasie std::unique_lock, którą można stosować tam gdzie należy odroczyć blokowanie muteksów, albo gdy istnieje potrzeba przetransferowania własności do innego obiektu. Ma ona podobny konstruktor do std::lock_guard.
Drugi parametr konstruktora może przyjmować kilka interesujących parametrów:
  • std::adopt_lock jest używany by zaznaczyć, że muteks został już zablokowany i std::lock_guard albo std::unique_lock powinien tylko zaadoptować własność, zamiast próbować go jeszcze raz blokować.
  • std::defer_lock występuje tylko dla std::unique_lock i mówi o tym że muteks nie powinien zostać zablokowany przez konstruktor. Można tą operację wykonać później wołając np. funkcję std::lock.
Dwa przykłady jak może wyglądać zakładanie kilku blokad jednocześnie:
std::mutex mutex1;
std::mutex mutex2;

std::lock(mutex1, mutex2);
std::lock_guard<std::mutex> lock1(mutex1, std::adopt_lock);
std::lock_guard<std::mutex> lock2(mutex2, std::adopt_lock);
Alternatywa z wykorzystaniem std::unique_lock:
std::mutex mutex1;
std::mutex mutex2;

std::unique_lock<std::mutex> lock1(mutex1, std::defer_lock);
std::unique_lock<std::mutex> lock2(mutex2, std::defer_lock);
std::lock(lock1, lock2);

Inicjowanie zasobów współdzielonych przez wątki

Często zachodzi potrzeba zainicjowania zmiennej tylko przez jeden z wątków. Zamiast samodzielnie tworzyć kod blokujący zasób i sprawdzający czy został on zainicjowany, biblioteka standardowa wprowadza specjalny mechanizm w postaci std::call_flag i std::call_once, które pozwalają na wykonanie takiej operacji (inicjowania) dokładnie raz w bezpieczny sposób.

W C++11 istnieje konieczność stosowania std::ref w przypadku argumentów do funkcji inicjującej, inaczej kompilator zaprotestuje. Zostało to naprawione w C++17.
#include <iostream>
#include <string>
#include <vector>
#include <thread>
#include <mutex>

using namespace std;

std::mutex vec_mutex;
std::once_flag vec_flag;

void show(std::string description, std::vector<int>& vec) {
    std::cout << description;
    for(const auto& v : vec)
        std::cout << v << " ";
    std::cout << std::endl;
}

void init(std::vector<int>& vec) {
    for(auto& v : vec)
        v = 0;
    show("Initialize (call_once):    ", vec);
}

void update(std::vector<int>& vec, int index) {
//  std::call_once(vec_flag, init, std::ref(vec));   // Działa w C++11
    std::call_once(vec_flag, init, vec);             // Działa w C++17
    std::lock_guard<std::mutex> guard(vec_mutex);
    vec[index] += 1;
}

int main() {
    std::vector<int> vec = {9, 9, 9};
    show("Creation (in main thread): ", vec);

    std::thread t1{update, std::ref(vec), 0};
    std::thread t2{update, std::ref(vec), 2};

    t1.join();
    t2.join();

    show("All thread finish:         ", vec);

    return 0;
}
Wynik:
Creation (in main thread): 9 9 9 
Initialize (call_once):    0 0 0 
All thread finish:         1 0 1 

Bibliografia

  • [1] Anthony Williams: C++ Concurency in Action. USA Manning publications Co., 2012. Rozdział 3, str. 33.

Brak komentarzy:

Prześlij komentarz