Стандартные примитивы синхронизации могут ввести излишние ограничения для простых многопоточных систем, особенно для потоков, которые интенсивно синхронизируются друг с другом. Одна из возможных альтернатив - использование interlocked операций.
Interlocked (взаимосвязанные, взаимоблокировочные) операции первоначально были задуманы как механизм синхронизации низкого уровня для симметричных многопроцессорных систем с разделяемой памятью. Во многопроцессорных системах общая память представляет собой чрезвычайно эффективный путь обмена данными между процессами и потоками. Необходимо обеспечить методы решения проблем атомарности, когда два или более процессоров пытаются использовать один и тот же участок памяти. Почти все современные процессоры поддерживают соответствующие операции взаимоблокировки. Эти такие операции, посредством которыхо процессор может прочитать значение из памяти, модифицировать его, а затем записать атомарно, причем гарантируется, что другие процессоры не получит доступ к тому же участку памяти, а процессор, выполняющий операцию, не будет прерван. Win32 обеспечивает следующие операции взаимоблокировки:
Так почему бы не использовать такие операции всегда? Хорошим примером может служить замок (spin lock). Допустим, есть желание создать что-то подобное критической секции. Однако в критической секции может быть очень мало кода, а доступ к нему нужен очень часто. В таких случаях, полноценный объект синхронизации может оказаться излишним. Spin lock (замок) позволяет нам добиться аналогичного результата, и работает примерно так. Поток захватывает замок, если при выполнении приращения InterlockedIncrement значение Lock будет нулевым. Если эта величина больше нуля, то замок захвачен другим потоком, и требуется новая попытка. Вызов Sleep нужен, чтобы один поток не ждал впустую слишком долго, пока поток с более низким приоритетом удерживает замок. Для простых планировщиков, если приоритеты потоков равны, то вызов Sleep может и не потребоваться. Операция блокировки необходима, поскольку если поток выполняет чтение значения из памяти, увеличение его, сравнение, а затем записывает назад, то два потока могут захватить замок одновременно.
Излишние действия почти исключены, поскольку лишь несколько инструкций CPU требуется для входа и выхода из замка, а потоку не приходится ждать. Если потокам нужно ожидать существенное время, то процессор работает впустую, так что это подходит только для создания небольших критических секций. Замки полезны для реализации критических секций, которые сами являются частью структур синхронизации. Коллективные данные внутри примитивов синхронизации или планировщиков часто защищены блокировкой подобного типа: часто это необходимо, поскольку примитивы синхронизации уровня OС не могут быть использованы, чтобы осуществлять примитивы синхронизации уровня OС. У такой блокировки имеются те же проблемы с конкуренцией потоков, как и у мьютексов, но отличие состоит в том, что зацикливание происходит не путем замораживания (deadlock), а динамически (livelock). Это несколько худшая ситуация, потому что хотя "блокированные" потоки не выполняют полезного кода, они работают в бесконечном цикле, расходуя время процессора и понижая производительность всей системы. Замки нельзя использовать как семафоры, чтобы "приостанавливать" потоки.
При достаточной аккуратности возможно создать замок, который является атомарным, вообще не прибегая к взаимоблокировке, при условии, что прерывания будут происходить только между инструкциями CPU. Рассмотрим код. Сначала обратим внимание на код на Паскале, чтобы понять основную идею. У нас есть замок - целое число в памяти. При попытке войти в замок мы сначала увеличиваем значение в памяти. Затем читаем значение из памяти в локальную переменную, и проверяем, как и раньше, больше ли оно нуля. Если это так, то кто-то еще обладает этим замком, и мы возвращаемся к началу, в противном случае мы захватываем замок.
Самое важное в этом наборе операций то, что при определенных условиях переключение потоков может произойти в любой момент времени, но потокобезопасность все же сохранится. Первое приращение замка является косвенным приращением регистра. Значение всегда находится в памяти, и приращение атомарно. Затем мы читаем значение замка в локальную переменную. Это действие не атомарное. Значение, прочитанное в локальную переменную, может отличаться от результата приращения. Тем не менее, хитрость состоит в том, что поскольку приращение выполняется перед действием чтения, происходящие конфликты потоков всегда будут приводить к слишком высокому прочитанному значению, а не к слишком низкому: в результате конфликтов потоков можно узнать, свободен ли замок.
Часто полезно писать подобные действия на ассемблере, чтобы быть полностью уверенным, что правильные значения останутся в памяти, а не кешируются в регистрах. Компилятор Delphi (по крайней мере Delphi 4), при передаче замка как var-параметра, и с использованием локальной переменной, генерирует корректный код, который будет работать на однопроцессорных машинах. На многопроцессорных машинах косвенные приращения и декременты регистра не атомарны. Эта проблема решена в ассемблерной версии кода путем добавления префикса lock перед инструкциями, которые имеют дело с замком. Этот префикс указывает процессору исключительно заблокировать шину памяти на все время выполнения инструкции, таким образом этими операции становятся атомарными.
Плохо только, что хотя это и теоретически правильно, виртуальная машина Win32 не позволяет процессам пользовательского уровня исполнять инструкции с префиксом lock. Программисты, предполагающие действительно применять этот механизм, должны использовать его только в коде с привилегиями нулевого кольца (ring 0). Другая проблема состоит в том, что поскольку эта версия блокировки не вызывает Sleep, потоки способны монополизировать процессор, пока они ожидают снятия блокировки, а это гарантирует зависание машины.
Одна из альтернатив для семафоров - использовать два новых вида примитивов: eventcounts и sequencers. Оба они содержат счетчики, но, в отличие от семафоров, с момента их создания счетчики неограниченно возрастают. Некоторым очень нравится идея того, что можно различить 32-е и 33-е появление события в системе. Значения этих счетчиков сделаны доступными для использующих их потоков, и могут быть использованы процессами для упорядочения операций. Счетчики событий поддерживают три действия:
Одно из преимуществ этого типа примитива синхронизации состоит в том, что операции Advance и Ticket могут быть очень просто реализованы с использованием взаимоблокирующих инструкций сравнения и обмена. Оставим это читателю как несколько более трудное упражнение.
MessageWaits. Когда приложения Delphi ожидают выхода из потоков, главный поток VCL постоянно блокирован. Потенциально эта ситуация может вызывать проблемы, потому что поток VCL не может обрабатывать сообщения. Win32 предоставляет функцию MsgWaitForMultipleObjects для преодоления этих проблем. Поток, осуществляющий ожидание сообщения, блокируется до тех пор, пока или объект синхронизации не перейдет в сигнализированное состояние, или сообщение не будет помещено в очередь сообщений потока. Это означает, что вы можете заставить главный поток VCL ожидать рабочие потоки,и в то же время позволить ему отвечать на сообщения Windows. Хорошую статью по данной теме можно найти по адресу: http://www.midnightbeach.com/jon/pubs/MsgWaits/MsgWaits.html
© Martin Harvey
2000.