Поведение потока мьютексов

Я изучаю C. Я пишу приложение с несколькими потоками; Я знаю, что когда переменная распределяется между двумя или более потоками, лучше блокировать/разблокировать с помощью мьютекса, чтобы избежать взаимоблокировки и несогласованности переменных. Это очень ясно, когда я хочу изменить или просмотреть одну переменную.

int i = 0; /** Global */
static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

/** Thread 1. */
pthread_mutex_lock(&mutex);
i++;
pthread_mutex_unlock(&mutex);

/** Thread 2. */
pthread_mutex_lock(&mutex);
i++;
pthread_mutex_unlock(&mutex);

Это правильно, я думаю. Переменная i, в конце выполнения, содержит целое число 2.
Во всяком случае, есть некоторые ситуации, в которых я точно не знаю, куда положить два вызова функций.

Например, предположим, что у вас есть функция obtain(), которая возвращает глобальную переменную. Мне нужно вызвать эту функцию из двух потоков. У меня есть еще два потока, которые вызывают функцию set(), определенную несколькими аргументами; эта функция будет устанавливать одну и ту же глобальную переменную. Эти две функции необходимы, когда вам нужно что-то сделать до получения/установки var.

/** (0) */
/** Thread 1, or 2, or 3... */
if(obtain() == something) {

    if(obtain() == somethingElse) {
        // Do this, sometimes obtain() and sometimes set(random number) (1)   
    } else {
        // Do that, just obtain(). (2)
    }

} else {
    // Do this and do that (3)
    // If # of thread * 3 > 10, then set(3*10) For example. (4)
}
/** (5) */

Где мне нужно заблокировать, и где я должен разблокировать? Ситуация может быть, я думаю, еще сложнее. Я по достоинству оценю исчерпывающий ответ.

Спасибо заранее.
-Alberto

Ответ 1

Без защиты:

Операционная система может прерывать каждый из ваших потоков в любое время и передавать процессор другому. "Anytime" включает "между двумя инструкциями по сборке, которые фактически исходят от одной и той же команды C".

Теперь предположим, что ваша переменная занимает 64 бита в 32-разрядном процессоре. Это означает, что ваша переменная занимает два "слова" процессора. Чтобы записать его, процессор нуждается в двух инструкциях по сборке. То же самое для чтения. Если поток прерывается между ними, вы получаете проблемы.

Чтобы дать более ясный пример, я буду использовать аналогию двух десятичных цифр для представления двух бинарных 32-битных слов. Так скажите, что вы увеличиваете двузначное десятичное число в 1-значном процессоре. Чтобы увеличить с 19 до 20, вы должны прочитать 19, выполнить математику, а затем написать 20. Чтобы написать 20, вы должны написать 2, затем написать 0 (или наоборот). Если вы напишете 2, прервите его перед записью 0, число в памяти будет 29, что далеко не так, как было бы правильно. Затем другой поток начинает считывать неправильный номер.

Даже если у вас есть одна цифра, все еще проблема чтения-изменения-записи Blank Xavier объясняется.

С мьютексом:

Когда поток A блокирует мьютекс, поток A проверяет переменную mutex. Если он свободен, поток A записывает его как взятый. Он делает это с помощью атомарной инструкции, одной команды сборки, поэтому нет "промежутка" для прерывания. Затем он переходит к увеличению с 19 по 20. Его все равно можно прервать во время неправильного значения переменной 29, но это нормально, потому что теперь никто не может получить доступ к переменной. Когда поток B пытается заблокировать мьютекс, он проверяет переменную mutex, она берется. Таким образом, поток B знает, что он не может коснуться переменной. Затем он вызывает операционную систему, говоря: "Сейчас я отказываюсь от процессора". Thread B повторит это, если он снова получит процессор. И опять. Пока нить A наконец не вернет процессор, завершит то, что он делает, затем разблокирует мьютекс.

Итак, когда блокировать?

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

Существуют также проблемы с производительностью. Если вы блокируете/разблокируете каждую строку кода, вы теряете время блокировки/разблокировки. Если вы заблокируете/разблокируете только вокруг огромных блоков кода, то каждый поток будет долго ждать, пока другой освободит мьютекс.

Не действительно "всегда"

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

Ответ 2

Некоторые слова объяснения.

В примере кода увеличивается одна переменная.

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

Итак, мы читаем в нашем целом. Скажем, что целое число имеет длину 8 байт, а строка кэша также 8 байтов (например, современный 64-разрядный процессор Intel). Чтение необходимо в этом случае, так как нам нужно знать исходное значение. Итак, чтение происходит, и строка кэша входит в кеш L3, L2 и L1 (Intel использует инклюзионный кеш, все в L1 присутствует в L2, все в L2 присутствует в L3 и т.д.).

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

Если у нас есть один процессор с этой линией кэша в кеше и он увеличивает значение, любой другой процессор с копией этого значения будет иметь свою копию с недействительной.

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

Итак, все кажется хорошо до сих пор - как может быть, что нам нужна блокировка?

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

Таким образом, один из приращений был потерян.

Ответ 3

если у вас есть функция obtain(), должна быть функция release(), правильно? затем выполните блокировку в get() и разблокируйте в release().

Ответ 4

Вы должны удерживать блокировку вокруг всей операции, которая должна быть атомарной, то есть блок, который должен выполняться как одна неделимая операция.