int loops = *((int *) arg);

int loc, j;

for (j = 0; j < loops; j++) {

loc = glob;

loc++;

glob = loc;

}

return NULL;

}

int

main(int argc, char *argv[])

{

pthread_t t1, t2;

int loops, s;

loops = (argc > 1)? getInt(argv[1], GN_GT_0, "num-loops"): 10000000;

s = pthread_create(&t1, NULL, threadFunc, &loops);

if (s!= 0)

errExitEN(s, "pthread_create");

s = pthread_create(&t2, NULL, threadFunc, &loops);

if (s!= 0)

errExitEN(s, "pthread_create");

s = pthread_join(t1, NULL);

if (s!= 0)

errExitEN(s, "pthread_join");

s = pthread_join(t2, NULL);

if (s!= 0)

errExitEN(s, "pthread_join");

printf("glob = %d\n", glob);

exit(EXIT_SUCCESS);

}

threads/thread_incr.c

Если запустить программу из листинга 30.1, указав, что каждый поток должен инкрементировать переменную 1000 раз, все должно выглядеть нормально:

$ ./thread_incr 1000

glob = 2000

Что же произошло? Первый поток, скорее всего, успел завершить всю свою работу до того, как стартовал второй поток. Если существенно увеличить объем работы, мы увидим совсем другой результат:

$ ./thread_incr 10000000

glob = 16517656

В конце этой последовательности переменная glob должна быть равна 20 миллионам. Проблема здесь кроется в способе выполнения потоков (см. также рис. 30.1).

1. Поток 1 копирует текущее значение glob в свою локальную переменную loc. Будем считать, что это значение равно 2000.

2. Временной отрезок, отведенный планировщиком для потока 1, исчерпывается, после чего начинает выполнение поток 2.

3. Поток 2 выполняет множество итераций, в которых он копирует текущее значение glob в свою локальную переменную loc, инкрементирует эту переменную и передает результат обратно в glob. В первой из этих итераций значение, полученное из glob, будет равно 2000. Представим, что на момент исчерпания времени, отведенного потоку 2, значение glob увеличилось до 3000.

4. Поток 1 получает еще один временной отрезок и продолжает выполнение с того места, на котором он был прерван. Имея в переменной loc ранее скопированное значение glob (шаг 1), равное 2000, он теперь инкрементирует loc и передает результат (2001) обратно в glob. На этом этапе результат инкрементации значения потоком 2 теряется.

Рис. 30.1. Два потока, инкрементирующих глобальную переменную без синхронизации

Если несколько раз запустить программу из листинга 30.1 с одним и тем же аргументом командной строки, можно увидеть, что выводимое значение glob существенно варьируется:

$ ./thread_incr 10000000

glob = 10880429

$ ./thread_incr 10000000

glob = 13493953

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

На первый взгляд может показаться, что проблема легко решается путем замены трех инструкций функции threadFunc() внутри цикла for одним-единственным выражением:

glob++; /* или ++glob; */

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

Чтобы избежать проблем, которые возникают при попытке обновления разделяемой переменной из разных потоков, следует использовать мьютекс (от mutual exclusion — «взаимное исключение»); это позволит гарантировать, что только один поток имеет доступ к переменной в определенный промежуток времени. В целом мьютексы можно использовать для обеспечения атомарного доступа к любым разделяемым ресурсам, но чаще всего они применяются для защиты общих переменных.

Мьютекс имеет два состояния: закрытое (блокированное) и открытое (разблокированное). В любой момент времени максимум один поток может удерживать мьютекс закрытым. Попытки закрыть уже закрытый мьютекс либо отклоняются, либо приводят к ошибке — в зависимости от того, как выполнялось закрытие. Когда поток закрывает мьютекс, он становится его владельцем. Только владелец мьютекса может его открыть. Это улучшает структуру кода, в котором используются мьютексы, а также позволяет проделывать с ними некоторые оптимизации. Благодаря концепции владения вместо «закрыть» и «открыть» иногда применяются термины «приобрести» и «освободить».

В целом для каждого разделяемого ресурса (который может состоять из нескольких связанных между собой переменных) устанавливается отдельный мьютекс. Для доступа к такому ресурсу каждый поток использует такую последовательность действий:

• закрыть мьютекс для разделяемого ресурса;

• получить доступ к разделяемому ресурсу;

• открыть мьютекс.

Перейти на страницу:

Похожие книги