Условные переменные
Условные переменные
Условные переменные (или «condvars») очень похожи на ждущие блокировки, которые мы рассматривали выше. В действительности, ждущие блокировки — это надстройка над механизмом условных переменных, и именно поэтому в таблице, иллюстрировавшей использование ждущих блокировок, у нас встречалось состояние CONDVAR. Функция pthread_cond_wait() точно так же освобождает мутекс, ждет, а затем повторно блокирует мутекс, аналогично функции pthread_sleepon_wait().
Давайте опустим вступление и обратимся к нашему примеру о «производителе» и «потребителе» из раздела о ждущих блокировках, но вместо ждущих блокировок будем использовать условные переменные. А затем уже обсудим вызовы.
/*
* cp1.c
*/
#include <stdio.h>
#include <pthread.h>
int data_ready = 0;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t condvar = PTHREAD_COND_INITIALIZER;
void* consumer(void *notused){
printf("Это поток-потребитель... ");
while (1) {
pthread_mutex_lock(&mutex);
while (!data_ready) {
pthread_cond_wait(&condvar, &mutex);
}
// Обработать данные
printf("Потребитель: получил данные от производителя ");
data_ready = 0;
pthread_cond_signal(&condvar);
pthread_mutex_unlock(&mutex);
}
}
void* producer (void *notused) {
printf("Это поток-производитель... ");
while (1) {
// Получить данные от оборудования
// (мы имитируем это при помощи sleep(1))
sleep(1);
printf("Производитель: получил данные от h/w ");
pthread_mutex_lock(&mutex);
while (data_ready) {
pthread_cond_wait(&condvar, &mutex);
}
data_ready = 1;
pthread_cond_signal(&condvar);
pthread_mutex_unlock(&mutex);
}
}
main() {
printf(
"Начало примера с производителем и потребителем... ");
// Создать поток-производитель и поток-потребитель
pthread_create(NULL, NULL, producer, NULL);
pthread_create(NULL, NULL, consumer, NULL);
// Дать потокам немного повыполняться
sleep(20);
}
Этот пример в значительной степени похож на программу с применением ждущей блокировки, с небольшими отличиями (мы добавили несколько вызовов printf(), а также функцию main(), чтобы программа могла работать!) Первое отличие, которое бросается в глаза, — здесь использован новый тип данных, pthread_cond_t. Это просто декларация для условной переменной; мы назвали нашу условную переменную condvar.
Следующее, что видно из примера, — это то, что структура «потребителя» идентична таковой в предыдущем примере с ждущей блокировкой. Мы заменили функции pthread_sleepon_lock() и pthread_sleepon_unlock() на стандартные мутекс-ориентированные версии (pthread_mutex_lock() и pthread_mutex_unlock()). Функция pthread_sleepon_wait() была заменена на функцию pthread_cond_wait().
Основное различие здесь состоит в том, что библиотека ждущих блокировок имеет скрытый внутренний мутекс, а при использовании условных переменных мутекс передается явно. Последний способ дает нам больше гибкости.
И, наконец, обратите внимание на то, что мы использовали функцию pthread_cond_signal() вместо функции pthread_sleepon_signal() (опять же, с явной передачей мутекса).
Функции phtread*_signal() и pthread*_broadcast()
В разделе о ждущих блокировках мы обещали обсудить различие между функциями pthread_sleepon_broadcast() и pthread_sleepon_signal(). Заодно поговорим и о различии между двумя аналогичными функциями, имеющими отношение к условным переменным: pthread_cond_signal() и pthread_cond_broadcast().
В двух словах, функция в варианте «signal» разблокирует только один поток. Например, если бы несколько потоков находилось в ожидании по функции «wait», и некий поток вызвал бы функцию pthread*_signal(), то был бы разблокирован только один из ждущих потоков. Который из них? Тот, у которого наивысший приоритет. Если имеется два или более потоков с одинаковым приоритетом, порядок «пробуждения» будет не определен. Применение же варианта pthread*_broadcast() приведет к тому что будут разблокированы все ожидающие потоки.
Разблокировать все потоки может показаться излишним. Но с другой стороны, разблокировать только один (причем случайный поток тоже не совсем корректно.
Поэтому мы должны думать, где имеет смысл использовать какой вариант. Очевидно, что если у вас только один ждущий поток, как у нас и было во всех вариантах «потребителя», функция pthread*_signal() прекрасно справится — будет разблокирован один поток, и как раз тот, который нужно (потому что других просто нет).
В ситуации с несколькими потоками в первую очередь следует выяснить: а почему они ждут? Обычно на этот вопрос есть два ответа:
• все потоки рассматриваются как эквивалентные и реально образуют пул доступных потоков, готовых к обработке некоторого запроса;
• все потоки являются уникальными, и каждый из них ждет соблюдения своего специфического условия.
В первом случае мы можем представить себе, что код всех потоков имеет примерно следующий вид:
/*
* cv1.c
*/
#include <stdio.h>
#include <pthread.h>
pthread_mutex_t mutex_data = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cv_data = PTHREAD_COND_INITIALIZER;
int data;
thread1() {
for (;;) {
pthread_mutex_lock(&mutex_data);
while (data == 0) {
pthread_cond_wait(&cv_data, &mutex_data);
}
// Сделать что-нибудь
pthread_mutex_unlock(&mutex_data);
}
}
В этом случае абсолютно неважно, который именно из потоков получит данные — главное, чтобы хотя бы один сделал это и произвел над этими данными необходимые действия.
Однако, если ваш код подобен приведенному ниже, все будет несколько по-иному:
/*
* cv2.c
*/
#include <stdio.h>
#include <pthread.h>
pthread_mutex_t mutex_xy = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cv_xy = PTHREAD_COND_INITIALIZER;
int x, y;
int isprime(int);
thread1() {
for (;;) {
pthread_mutex_lock(&mutex_xy);
while ((x > 7) && (y != 15)) {
pthread_cond_wait(&cv_xy, &mutex_xy);
}
// Сделать что-нибудь
pthread_mutex_unlock(&mutex_xy);
}
}
thread2() {
for (;;) {
pthread_mutex_lock(&mutex_xy);
while (!isprime(x)) {
pthread_cond_wait(&cv_xy, &mutex_xy);
}
// Сделать что-нибудь
pthread_mutex_unlock(&smutex_xy);
}
}
thread3() {
for (;;) {
pthread_mutex_lock(&mutex_xy);
while (x != y) {
pthread_cond_wait(&cv_xy, &mutex_xy);
}
// Сделать что-нибудь
pthread_mutex_unlock(&mutex_xy);
}
}
В этом случае пробуждение одного потока ничего не даст! Здесь мы обязаны «разбудить» все три потока, чтобы каждый из них проверил соблюдение своего условия.
Это в полной мере отражает второй вариант ответа на наш вопрос «а почему они ждут?» Так как все потоки все ждут соблюдения различных условий (поток thread1() ждет, пока значение x не станет меньше или равно 7, или пока значение у не станет равным 15, поток thread2() ждет, пока значение x не станет простым числом, а поток thread3() ждет, пока x не станет равным у), у нас нет никакого выбора, кроме как «разбудить» все потоки «одновременно».
Ждущие блокировки в сравнении с условными переменными
Ждущие блокировки имеют одно основное преимущество в сравнении с условными переменными. Предположим, что вам надо синхронизировать множество объектов. Используя условные переменные, вы бы ассоциировали с каждым объектом отдельную условную переменную — если бы у вас было M объектов, вы, скорее всего, определили бы M условных переменных. При применении же ждущих блокировок соответствующие им условные переменные создаются динамически по мере постановки потоков на ожидание, поэтому в этом случае на M объектов и N блокированных потоков у вас было бы максимум N, а не M условных переменных.
Однако, условные переменные более универсальны, чем ждущие блокировки, и вот почему:
1. Ждущие блокировки в любом случае основаны на условных переменных.
2. Мутексы ждущих блокировок скрыты в библиотеке; условные переменные позволяют вам задавать его явно.
Первый пункт сам по себе достаточно убедителен. :-) Второй, однако, имеет еще и практический смысл. Когда мутекс скрыт в библиотеке, это означает, что он может быть только один на процесс, независимо от числа потоков в этом процессе или от количества переменных. Это может быть сильно ограничивающим фактором, особенно если принять во внимание, что вам придется использовать один-единственный мутекс для синхронизации доступа всех имеющихся потоков в процессе ко всем нужным им переменным!
Намного лучшая схема состоит в применении нескольких мутексов — по одному на каждый набор данных — и явно сопоставлять им условные переменные по мере необходимости. Как мощь, так и опасность этого подхода заключаются в том, что ни на этапе компиляции, ни на этапе выполнения не будет производиться никаких проверок, и вам придется самим следить за:
• блокировкой мутексов перед доступом к соответствующим переменным;
• применением правильного мутекса для каждой переменной;
• применением правильной условной переменной для соответствующих мутекса и переменной (данных).
Самый простой путь решения этих проблем — грамотно проектировать и тщательно проверять, а также заимствовать приемы объектно-ориентированного программирования (например, встраивать мутексы в структуры данных, создавать для обращения к структурам данных специализированные подпрограммы, и т.д.). Разумеется, то, в какой степени вы примените первый, второй, или оба варианта, будет зависеть не только от вашего стиля программирования, но и от требований производительности.
Ключевыми моментами при использовании условных переменных являются:
1. Мутексы следует использовать для проверки и изменения переменных.
2. Условные переменные следует использовать в качестве «точки встречи».
Ниже представлена иллюстрация этого:
Связь мутексов и условных переменных по схеме «один к одному»
Одно интересное замечание. Поскольку никаких проверок не выполняется, вы можете, например, связать один набор переменных с мутексом «MutexABC», другой — с мутексом «MutexDEF», и сопоставить обоим наборам переменных одну и ту же условную переменную «CondvarABCDEF»:
Связь мутексов и условных переменных по схеме «один ко многим».
Это весьма полезное свойство. Поскольку мутекс должен использоваться для «проверки и изменения» всегда, это подразумевает, что я должен буду выбрать правильный мутекс всякий раз, когда мне понадобится доступ к некоей переменной. Вполне логично — если я, скажем, проверяю переменную «С», то, очевидно, мне потребуется заблокировать мутекс «MutexABC». А что если я хочу изменить переменную «E»? Хорошо, перед этим я должен буду захватить мутекс «MutexDEF». Затем я ее изменяю и сообщаю об этом другим потокам через условную переменную «CondvarABCDEF», после чего освобождаю мутекс.
А теперь смотрите, что происходит. Толпа потоков, ждавших на условии «CondvarABCDEF», вдруг резко «просыпается» (по функции pthread_cond_wait()). Их функции ожидания немедленно пытаются повторно захватить мутекс. Критическим моментом здесь является то, что мутексов два. (В зависимости от того, изменения какой переменной поток ждал, его функция ожидания попытается захватить либо MutexABC, либо MutexDEF — прим. ред.) Это означает, что в SMP-системе возникли бы две конкурирующие очереди потоков, и в каждой потоки будут проверять как бы независимые переменные, используя при этом независимые мутексы. Круто, да?