MyTetra Share
Делитесь знаниями!
Как работает мьютекс (mutex)? Простейший пример использования мьютекса в C++
Время создания: 17.12.2024 10:54
Автор: Xintrea
Текстовые метки: c++, си++, мутекс, мьютекс, mutex, блокировка, защита, синхронизация, ресурс, спин-лок, спинлок, spin-lock, spinlock, многопоточность, поток, процесс, параллельное, программирование
Раздел: Компьютер - Программирование - Язык C++ (Си++)
Запись: xintrea/mytetra_syncro/master/base/1734422075c12ugstkaz/text.html на raw.github.com

В C++ мьютекс (mutex, от mutual exclusion) используется для обеспечения синхронизации потоков и предотвращения одновременного доступа к общим данным из разных потоков. Он позволяет одному потоку захватить ресурс и блокировать доступ к нему для других потоков, пока работа с ресурсом не завершится.



Что такое "ресурс", хорошо написано в этой статье: Как работает спин-лок? Пример простейшего спин-лока на C++.



Основные этапы работы мьютекса:



  • Блокировка. Когда поток вызывает метод lock() (или try_lock()), то мьютекс "захватывается". Это означает, что другие потоки, пытающиеся выполнить блокировку этого мьютекса, будут заблокированы до тех пор, пока мьютекс не будет освобожден;
  • Разблокировка. После завершения работы с защищенным ресурсом поток вызывает метод unlock(), освобождая мьютекс. Это позволяет другим потокам получить доступ к ресурсу.



Работа мьютекса сопровождается гарантиями атомарности. Все операции блокировки/разблокировки мьютекса выполняются атомарно, что предотвращает состояние гонки (race condition).



Поддержка мьютексов в C++ Standard Library


C++ предоставляет мьютексы в заголовке <mutex>. Основные варианты мьютексов следующие:



  • std::mutex: Простейший тип мьютекса;
  • std::recursive_mutex: Позволяет одному потоку захватывать мьютекс несколько раз (удобно при вызовах рекурсивных функций);
  • std::timed_mutex: Поддерживает методы для тайм-аута захвата мьютекса (try_lock_for и try_lock_until);
  • std::shared_mutex: (начиная с C++17) Позволяет одновременно читать нескольким потокам, но писать — только одному.



Пример использования


Ниже приведен простейший пример использования std::mutex.



#include <iostream>

#include <thread>

#include <mutex>


// Глобальный мьютекс

std::mutex mtx;


// Общий ресурс

int sharedCounter = 0;


// Функция для потоков, увеличивающая счетчик

void incrementCounter(int iterations, const std::string& threadName) {

for (int i = 0; i < iterations; ++i) {


// Мьютекс захватывается перед доступом к общему ресурсу

mtx.lock();


// Критическая секция

++sharedCounter;

std::cout << threadName << " counter " << sharedCounter << std::endl;


mtx.unlock(); // Мьютекс освобождается

}

}


int main() {

const int iterations = 5;


// Создаем два потока

std::thread t1(incrementCounter, iterations, "Thread 1");

std::thread t2(incrementCounter, iterations, "Thread 2");


// Дожидаемся завершения потоков

t1.join();

t2.join();


std::cout << "Final value: " << sharedCounter << std::endl;


return 0;

}



Вывод этого кода будет выглядеть так:



Thread 1 counter 1

Thread 2 counter 2

Thread 1 counter 3

Thread 2 counter 4

Thread 1 counter 5

Thread 2 counter 6

Thread 1 counter 7

Thread 2 counter 8

Thread 1 counter 9

Thread 2 counter 10

Final value: 10



В этом коде мьютекс mtx используется для защищиты доступа к критической секции, в которой происходит изменение глобальной переменной sharedCounter, и вывод на консоль ее нового значения. При работе примера нужно обратить внимание, что каждый поток выполняет 5 итераций, всего 2 потока. Увеличение переменной на единицу происходит в разных потоках, но ничего не путается, и переменная увеличивается ровно на единицу в любой итерации любого потока. Это говорит о том, что начальное значение переменной при каждой итерации успешно атомарно изменено и уложено в память на предыдущей итерации, независимо от того, в каком потоке это производилось.



Важно для понимания


В вышеприведенном примере мьютекс не просто так объявлен в глобальной области видимости. Это сделано для того, чтобы продемонстрировать, что блокировка должна происходить, опираясь на один и тот же объект мьютекса, который один для всех потоков.


Если объявить мьютекс внутри функции incrementCounter(), которая выполняется параллельно в двух потоках, то в параллель бы создавалось два экземпляра мьютекса, которые никак не связаны друг с другом, и никакой синхронизации потоков в этом случае не происходило бы. Об этом обязательно надо помнить и следить за тем, где расположен сам объект мьютекса, чтобы он обеспечивал синхронизацию.


Кроме того, из простых примеров кода может показаться, что один мьютекс используется только в одном месте параллельного кода. Но это не обязательно так. Вполне может существовать код, который по различным причинам может использовать один и тот же мьютекс в разных параллельных частях программы.



Использование вспомогательных классов при работе с мьютексами


В примере выше видно, что для избежания бесконечного ожидания разблокировки мьютекса, необходимо чтобы каждая блокировка сопровождалась разблокировкой. В простом примере за этим легко уследить, но когда код становится сложным, разветвленным, с досрочным выходом из функции, возможны ошибки программиста, когда разблокировка не произошла. Причем это состояние может возникнуть именно при определенных условиях, которые происходят редко, например при определенной комбинации исходных данных. И тогда поиск первопричины проблемы превращается в трудно решаемую задачу.


Чтобы обеспечить гарантированную разблокировку мьютекса, можно использовать вспомогательный класс, который инициализируется ссылкой на мьютекс, и у которого в конструкторе прописана блокировка мьютекса, а в деструкторе стоит команда разблокировки. Такой объект при выходе из области видимости будет разрушаться, что автоматически вызовет разблокировку мьютекса.


В C++ имеется класс std::lock_guard. При создании объекта он автоматически в конструкторе вызывает метод lock() переданного ему мьютекса, а в деструкторе и вызывает метод unlock() для мьютекса.


Использование класса std::lock_guard показано ниже.



for (int i = 0; i < iterations; ++i) {

// Мьютекс захватывается перед доступом к общему ресурсу

// Захват происходит через класс std::lock_guard

std::lock_guard<std::mutex> locker(mtx);


// Критическая секция

++sharedCounter;

std::cout << threadName << " counter " << sharedCounter << std::endl;

}



В этом коде разблокировка мьютекса происходит неявно в конце каждой итерации цикла, так как объект locker выходит из области видимости в конце итерации, и у него срабатывает деструктор, который вызывает unlock() для мьютекса mtx.


Кстати, следует напомнить, что синтаксис "... locker(mtx);" - это синтаксис инициализации создаваемого объекта. В данном случае объект locker инициализируется передаваемой ему ссылкой на объект mtx.


Любители C++ часто любят зашифровать код и смешать понятия, поэтому в примерах в интернете можно встретить такую команду:



std::lock_guard<std::mutex> lock(mtx);



Здесь написан не хитроумный вызов метода lock() неизвестного временного объекта с передачей ему ссылки на объект mtx. Здесь просто создается объект типа std::lock_guard<std::mutex> с именем lock, который инициализируется ссылкой на объект mtx.



Как устроено ожидание разблокировки внутри мьютекса?


Реализация мьютекса обычно комбинирует активное ожидание (spin-lock) и блокировку потока. В случае многозадачной ОС, как правило, вначале на короткое время включается spin-lock, а в случае, если время ожидания становится большим, происходит блокировка потока: поток помещается в очередь ожидания (внутри ядра ОС), освобождая процессорные ресурсы.


Когда поток "засыпает" (suspended), ОС освобождает процессорные ресурсы для других потоков. Как только мьютекс освобождается, ОС "будит" один из потоков, ожидающих мьютекс.


Другими словами:



  • Если ресурс заблокирован на короткий срок: поток выполняет активное ожидание (крутит цикл spin-lock).
  • Если ресурс заблокирован на длительный срок: поток переходит в режим ожидания через системный вызов, освобождая процессор. Это делает ОС (например, через API вроде pthread_mutex_lock).
  • Поток не прерывается самопроизвольно и не завершает свою работу.
  • Поток останавливается только если мьютекс передает управление ядру ОС, чтобы сэкономить ресурсы.



Таким образом, не само ядро управляет потоками с мьютексом, а сам мьютекс при определенных условиях передает управление ядру, чтобы поток, в котором он находится, перешел в состояние suspended.



Итого


Мьютексы эффективно синхронизируют потоки, предотвращая различные проблемы синхронизации потоков (например, состояние гонки). Важно грамотно их использовать мьютексы. Неправильное их использование может привести к взаимным блокировкам (deadlock) или высокой задержке выполнения кода программы.


Так же в этом разделе:
 
MyTetra Share v.0.65
Яндекс индекс цитирования