Взаимодействие потоков на двухпроцессорных машинах

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

static int i = 10;

main() {
    new Thread(thread_run1).start();
    new Thread(thread_run2).start();
    waitForThreads();
    print("The value of i: " + i);
}

thread_run1 {
    i++;
}

thread_run2 {
    i--;
}

Затем профессор спросил, какова ценность i после миллиона миллиардов прогонов. (Если, по сути, это когда-либо будет что-то отличное от 10.) Студенты, незнакомые с многопоточными системами, ответили, что в 100% случаев оператор print() всегда будет сообщать i как 10.

На самом деле это было неверно, поскольку наш профессор продемонстрировал, что каждый оператор увеличения/уменьшения был фактически скомпилирован (в сборку) как 3 оператора:

1: move value of 'i' into register x
2: add 1 to value in register x
3: move value of register x into 'i'

Таким образом, значение i может быть 9, 10 или 11. (Я не буду вдаваться в подробности.)

Мой вопрос:

Я так понял (есть ли?), что набор физических регистров зависит от процессора. При работе с двухпроцессорными машинами (обратите внимание на разницу между двухъядерными и двухпроцессорными) каждый ЦП имеет собственный набор физических регистров? Я предполагал, что ответ положительный.

На однопроцессорной (многопоточной) машине переключение контекста позволяет каждому потоку иметь собственный виртуальный набор регистров. Поскольку на двухпроцессорной машине есть два физических набора регистров, не может ли это привести к еще большему потенциалу для условий гонки, поскольку вы можете буквально иметь два потока, работающих одновременно, в отличие от «виртуальной» одновременной работы на однопроцессорном компьютере? Машина с процессором? (Виртуальная одновременная работа в связи с тем, что состояния регистров сохраняются/восстанавливаются при каждом переключении контекста.)

Чтобы быть более конкретным - если вы запускали это на машине с 8 процессорами, каждый процессор с одним потоком, исключались ли условия гонки? Если вы расширите этот пример, чтобы использовать 8 потоков, на машине с двумя процессорами, каждый из которых имеет 4 ядра, увеличится или уменьшится вероятность условий гонки? Как операционная система предотвращает одновременный запуск step 3 ассемблерных инструкций на двух разных процессорах?


person Craig Otis    schedule 03.04.2011    source источник


Ответы (3)


Да, появление двухъядерных процессоров привело к быстрому сбою значительного числа программ со скрытой гонкой потоков. Одноядерные процессоры многозадачны благодаря планировщику, который быстро переключает контекст потоков между потоками. Что устраняет класс ошибок многопоточности, связанных с устаревшим кешем ЦП.

Однако приведенный вами пример может дать сбой и на одном ядре. Когда планировщик потока прерывает поток, как только он загрузил значение переменной в регистр, чтобы увеличить его. Просто он не будет сбоить так часто, потому что вероятность того, что планировщик просто прервет поток, не так уж велика.

В операционной системе есть функция, позволяющая этим программам в любом случае хромать, а не падать в течение нескольких минут. Вызывается «сопоставление процессоров», доступно как параметр командной строки AFFINITY для start.exe в Windows, SetProcessAfinityMask() в winapi. Просмотрите класс Interlocked для вспомогательных методов, которые атомарно увеличивают и уменьшают переменные.

person Hans Passant    schedule 03.04.2011
comment
Выбрано в качестве ответа на разницу между многопроцессорным и многоядерным процессором, а также примечание о сбое на одном ядре. Даже не подумал об этом, хорошая мысль. - person Craig Otis; 04.04.2011

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

Существуют дополнительные причины потенциальных проблем, связанных с моделями памяти: шаг 1 может не на самом деле получить последнее значение i, а шаг 3 может не сразу записать новое значение. из i таким образом, который могут видеть другие потоки.

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

person Jon Skeet    schedule 03.04.2011
comment
Конечно, я знаком с обоими методами синхронизации (мьютекс, семафор, синхронизированные блоки) в дополнение к структурам более высокого уровня, таким как методы Java Collections.synchronizedMap/Set/List/Collection. - Мне просто интересно, будет ли плохо написанная программа работать по-разному на двухпроцессорной машине по сравнению с двухъядерной машиной. - Думаю, после всей моей бессвязности я действительно просто искал ответ на свой последний вопрос, на который, я думаю, вы ответили. :) - person Craig Otis; 03.04.2011

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

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

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

В конечном счете, состояние гонки (или взаимоблокировка, активная блокировка и т. д.) связано с дизайном кода, а не с аппаратным обеспечением, на котором он работает. Аппаратное обеспечение может иметь значение в том, какие шаги вам нужно предпринять для обеспечения выполнения условий, но соответствующие различия имеют мало общего с простым количеством процессоров. Скорее, они касаются таких вещей, как уступки, когда у вас есть не просто одна машина с несколькими процессорами, а несколько машин с совершенно разными адресными пространствами, поэтому вам, возможно, придется предпринять дополнительные шаги, чтобы гарантировать, что при записи значения в память, она становится видимой для ЦП на других машинах, которые не могут видеть эту память напрямую.

person Jerry Coffin    schedule 03.04.2011