История двух нитей

5 июля, 16:11 / программирование, параллелизм, собеседование

На собеседовании в ИТ-компании было предложено ответить на следующий вопрос.

Задача. Дано такой код:

	static int counter = 0;

	void worker()
	{
		for (int i = 1; i <= 10; i++)
			counter++;
	}

Процедуру worker() запускают из двух нитей. Какое значение будет содержать переменная counter по завершении работы обеих нитей и почему?

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

Я не называю нити потоками, что бы не путать потоки выполнения (thread) и потоки данных (stream).

Вариант ответа № 1. Переменная counter будет содержать число 20 — это самый желаемый результат, но и самый неверный ответ.

Проблема в том, что данный код никак не защищён от рассинхронизации. Каждая нить работает независимо друг от друга и не подозревает, что общие данные (переменная counter) могут быть изменены кем-то ещё. Поэтому более правильный ответ такой: это небезопасный код и значение переменной counter будет неопределённым.

Вариант № 2. Всё же зная основы работы процессора, ОС и компилятора можно более точно предположить какой результат мы получим.

Подпрограмма worker() достаточно простая. Поэтому компилятор на этапе оптимизации может развернуть for-цикл в такую конструкцию:

	counter += 10;

Теперь код настолько тривиален, что первая нить может успеть закончить его выполнение ещё до того как запустится вторая нить. Таким образом мы получим число 20.

Возможен и такой случай. Нити прочтут из оперативной памяти значение переменной counter (ноль) и занесут его в регистр процессора. Так как каждая нить работает в своём контексте процессора, то у каждой есть «свой независимый» набор регистров. Таким образом первая нить в своём контексте увеличит значение регистра с нуля до десяти и потом занесёт полученный результат обратно в память. А потом вторая нить сделает тоже самое и перезапишет ту десятку своей. В результате получим число 10.

Значения 10 и 20 наиболее вероятны в реальной практике, но и это ещё не полный ответ.

Вариант № 3. Давайте представим потенциально реальную, но максимально неэффективную схему работы. Так каждая итерация цикла будет одинаково повторять три атомарных действия: чтение значения counter в регистр процессора, инкремент этого регистра, запись нового значения из регистра обратно в память. (Реализацию самого цикла я не рассматриваю, так как она не играет никакой роли.) Плюс к этому переключение контекста выполнения нитей будет происходить тогда, когда этого хочется нам. На таких условиях мы можем получить результат от 2 до 20.

Теперь на временной шкале в виде таблицы рассмотрим возможность получения двойки. Шаг 0 — изначальное состояние. Операции «→n», «n+» и «n→» соответственно представляют чтение из памяти в регистр, инкремент регистра и запись значения регистра в память в контексте нити № n. Строка «цикл» показывает номер итерации. Изменения значений на текущем шаге выделены для лучшего восприятия.

Шаг 0 1 2 3 4 5 27 28 29 30 31 32 33 34 35 57 58 59 60
Операция →1 1+ →2 2+ 2→ →2 2+ 2→ 1→ →2 2+ →1 1+ 1→ →1 1+ 1→ 2→
Нить № 1 Регистр 0 1 1 1 1 1 1 1 1 1 1 1 2 2 9 10 10 10
Цикл 1 1 1 1 1 1 1 1 1 1 1 1 2 2 2 10 10 10 10
Нить № 2 Регистр 0 1 1 8 9 9 9 1 2 2 2 2 2 2 2 2
Цикл 1 1 1 1 1 1 9 9 9 9 10 10 10 10 10 10 10 10 10
Память 0 0 0 0 0 1 8 8 9 1 1 1 1 1 2 9 9 10 2

Шаги 1—2: первая нить заносит в регистр ноль и увеличивает его на единицу. 3—29: управление передаётся второй нити, которая тоже считывает ноль и выполняет девять полных итераций. 30: первая нить завершает первую итерацию цикла и заносит посчитанную единицу в память (перетирая девятку). 31—32: вторая нить начинает последнюю (десятую) итерацию, читает и инкриминирует единицу. 33—59: первая нить полностью выполняет девять оставшихся итераций и заканчивает свою работу. 60: вторая нить записывает в память посчитанную двойку и заканчивает свою работу.

Комментарии

Комментирование временно отключено.

Димка 5 июля, 20:26
Макс, ты запудрил мну моск своей табличькой -- рисуй во флеше :)
Илья Барков 14 июля, 21:04
Я, в общем-то, совсем не системный программист. Но по моему мнению, в приведенном коде нельзя получить однозначный результат. А все прочие варианты - это допущения, используя которые при написании кода, можно получить неожиданную проблему в самый неожиданный момент. Такой код - потенциально опасный, и приводить его для тестирования, имхо, неправильно.