Неудачные пути решения проблемы критических участков кода
К аналогичным непредсказуемым результатам будет приводить и код, в котором предпринимается попытка защитить участок инкрементирования переменной путем опроса состояния флага.
while (Flag) Sleep (1000);
Flag = TRUE;
N++;
Flag = FALSE;
Даже в этом случае поток может быть вытеснен в процессе выполнения программы от момента тестирования значения флага до момента, когда его значение будет установлено равным TRUE; критический участок кода образуют два оператора, которые не защищены должным образом от параллельного доступа к ним двух и более потоков.
Другая разновидность попытки решения проблемы синхронизации выполнения потоками критического участка кода могла бы состоять в том, чтобы предоставить каждому потоку собственный экземпляр переменной N, например, так, как показано ниже:
DWORD WINAPI ThFunc(TH_ARGS pArgs) {
volatile DWORD N;
… N++; …
}
Однако такой подход ничем не лучше предыдущего, поскольку каждый поток имеет собственный экземпляр переменной в своем стеке, но может, например, требоваться, чтобы N представляло суммарное число действующих потоков. В то же время, этот тип решения необходим в тех случаях, когда каждый поток должен иметь собственный, независимый от других потоков экземпляр переменной. Эта методика часто встречается в наших примерах.
Заметьте, что проблемы подобного рода не ограничиваются случаем потоков одного процесса. С этими проблемами приходится сталкиваться также в случаях, когда два процесса разделяют общую память или изменяют один и тот же файл.
Класс памяти volatile
Даже если решить проблему синхронизации, все равно остается еще один скрытый дефект. Оптимизирующие компиляторы могут оставлять значение N в регистре, а не заносить его обратно в ячейку памяти, соответствующую переменной N. Попытка решения этой проблемы путем переустановки переключателей опций компилятора окажет отрицательное воздействие на скорость выполнения остальных участков программы. Правильное решение состоит в том, чтобы использовать определенный в стандарте ANSI С спецификатор памяти volatile, который гарантирует, что после изменения значения переменной оно будет сохраняться в памяти, а при необходимости будет всегда извлекаться из памяти. Ключевое слово volatile сообщает компилятору, что значение переменной может быть в любой момент изменено.
Если все, что требуется — это увеличение, уменьшение или обмен значениями переменных, как в нашем первом простом примере, то функций взаимоблокировки (interlocked functions) вам будет вполне достаточно. Функции взаимоблокировки проще в использовании, обеспечивают более высокое быстродействие по сравнению с другими возможными методами и не приводят к блокированию потоков. Двумя членами этого семейства функций, которые представляют для нас интерес, являются функции InterlockedIncrement и InterlockedDecrement. Обе функции применяются по отношению к 32-битовым целым числам со знаком.
Эти функции имеют ограниченную область применимости, но будут использоваться нами при любой удобной возможности.
Задача инкрементирования N, представленная на рис. 8.1, может быть реализована посредством единственной строки кода:
InterlockedIncrement(&N);
N — это целое число типа long со знаком, и функция возвращает его новое значение, несмотря на то что другой поток мог изменить значение N еще до того, как поток, вызвавший функцию InterlockedIncrement, успеет воспользоваться возвращенным значением.
Следует, однако, проявлять осторожность и, например, не вызывать эту функцию два раза подряд, если, значение переменной должно быть увеличено на 2, поскольку поток может быть вытеснен в промежутке между двумя вызовами функции. Вместо этого лучше воспользоваться функцией InterlockedExchangeAdd, описание которой приводится далее в настоящей главе.
Локальная и глобальная память
Суть другого требования, предъявляемого к корректному многопоточному коду, состоит в том, что глобальная память не должна использоваться для локальных целей. Так, применение функции ThFunc, приводившейся ранее в качестве примера, будет необходимым и уместным в тех случаях, когда поток должен располагать собственным экземпляром N. N может быть использовано для хранения временных результатов или размещения аргумента функции. Если же N размещается в глобальной памяти, то все процессы будут разделять единственный экземпляр N, что может стать причиной некорректного поведения программы, как бы тщательно вы ни планировали синхронизацию доступа к этой переменной. Ниже приводится пример подобного некорректного использования N. N должно быть локальной переменной, размещаемой в стеке функции потока.
Читать дальше