Показаны сообщения с ярлыком P/Invoke. Показать все сообщения
Показаны сообщения с ярлыком P/Invoke. Показать все сообщения

среда, 9 апреля 2008 г.

InterlockedOr/And/ExchangeAdd в .Net

Программистам C++ через WinAPI доступно много различных Interlocked функций на все случаи жизни. Выбор этих функций в .Net невелик: Increment, Decrement, Add, Exchange, CompareExchange и почти бесполезный Read. В большей части случаев этих методов вполне достаточно, да и более 80% программистов вообще не знают об атомарных функциях и не используют их. Не так давно я столкнулся с задачей, в которой нужны были атомарные бинарные операции InterlockedOr и InterlockedAnd. О самой этой задаче я расскажу в другой статье, а в этой рассмотрим, как можно получить в C# доступ к этим функциям, а также к экзотической функции InterlockedExchangeAdd


Логично предположить, что нужные нам функции скрываются в модуле kernel32.dll, как и другие Interlocked*. Как культурные люди мы не лезем в этот файл через Far/Total Commander (хотя и делаем так зачастую :)), а изучим его через Dependency Walker из комплекта Visual Studio (Common7\Tools\Bin\Depends.Exe).

Результат выглядит приблизительно так:



Возможно, на других системах вид будет немного другой, но на моей Windows 2008 RC 2 в списке наблюдаются только основные Interlocked функции. Забегу немного вперед и скажу, что в kernel32.dll для платформы x64 они вообще отсутствуют. Если же мы посмотрим в kernel32.lib от Microsoft Platform SDK (в этот раз обычным текстовым или бинарным редактором), то увидим, что InterlockedOr нет и там.

Ответ можно найти в файле WinBase.h:

#if !defined (InterlockedOr)

#define InterlockedOr InterlockedOr_Inline

LONG
FORCEINLINE
InterlockedOr_Inline (
__inout LONG volatile *Target,
__in LONG Set
)
{
LONG i;
LONG j;

j = *Target;
do {
i = j;
j = InterlockedCompareExchange(Target,
i | Set,
i);

} while (i != j);

return j;
}

#endif


Как видим, битовые атомарные операции реализованы в виде цикла с InterlockedCompareExchange и ничего не мешает нам сделать то же самое в C#. Вот пример реализации InterlockedOr с сохранением оригинального синтаксиса:


public static int InterlockedOr(ref int Target, int Set)
{
int i;
int j = Target;
do
{
i = j;
j = Interlocked.CompareExchange(ref Target, i | Set, i);
}
while (i != j);

return j;
}


Точно так же реализуется и InterlockedAnd с заменой i | Set на i & Set. Как именно работает этот код я тут объяснять не буду, отослав вопрошающих к первоисточникам, литературе, или к дзен-поиску (http://dzen.yandex.ru/).

А мы же вернемся к экзотической функции InterlockedExchangeAdd. Эта функция добавляет указанное значение к переменной и возвращает ее старое значение. Отличие от InterlockedAdd минимально, а если взглянуть на .Net Framework через призму декомпилятора, то мы увидим, что сам Interlocked.Add реализован именно через ExchangeAdd добавлением слагаемого:

public static int Add(ref int location1, int value)
{
return (ExchangeAdd(ref location1, value) + value);
}


Это утверждение верно и в обратную сторону: мы можем вычесть из результата Interlocked.Add слагаемое и получить такой же результат, как при вызове InterlockedExchangeAdd. Этот метод универсален и для повседневного использования я рекомендую именно его (например, при портировании кода с С++ с сохранением синтаксиса и имен используемых методов).
Тут бы и время закончить статью, но в ходе подготовки материалов для второй части статьи "Многопоточность и свойства" (первая часть: http://blog.runserver.net/2008/03/blog-post_28.html) я столкнулся с задачей, в которой нужно атомарной функцией добавить некое значение к переменной, передающейся по указателю int * (или же IntPtr), а не по ссылке ref int. Нормального метода преобразования указателя в ссылку в C# не существует, потому пришлось обратиться к P/Invoke. Сайт http://pinvoke.net предлагает нам следующее описание:

[DllImport("kernel32.dll")]
static extern int
InterlockedExchangeAdd(ref int Addend, int Value);


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

[DllImport("kernel32.dll")]
static extern int
InterlockedExchangeAdd(int* Addend, int Value);


Метод пригоден к использованию, все с ним хорошо, кроме одной "мелочи": в x64 режиме мы получаем System.EntryPointNotFoundException, сообщающий, что точка входа с таким именем не найдена в kernel32.dll. Ранее я уже упоминал, что Interlocked методы в x64 версии kernel32.dll попросту отсутствуют. Снова обратившись к WinBase.h мы видим, что в этот раз методы не прячутся под inline реализации, а переадресованы на intrinsic функции (http://msdn2.microsoft.com/en-us/library/191ca0sk.aspx). Где именно находится их реализация, я не нашел. Это ни .lib файлы от Platform SDK, ни системные библиотеки. Будем считать, что их реализация вшита в компилятор, или куда-либо еще. Единственный вариант, который я нашел, это создание собственной DLL, которая экспортирует функцию-враппер:

extern "C" __declspec(dllexport) LONG
interlockedExchangeAdd(LONG volatile * target, LONG value)
{
return InterlockedExchangeAdd(target, value);
}


Сложность еще заключается в том, что необходимо будет держать две разные библиотеки: для x86 и x64 режима. Я обошел это ограничение таким образом:

[DllImport("kernel32.dll",
EntryPoint = "InterlockedExchangeAdd")]
private static extern int
NativeInterlockedExchangeAdd(int * target, int value);

[DllImport("InterlockedWrapper_x64.dll",
EntryPoint = "interlockedExchangeAdd")]
private static extern int
CustomInterlockedExchangeAdd(int * target, int value);

public static int
InterlockedExchangeAdd(int* target, int value)
{
if (IntPtr.Size == 4) // x86
return NativeInterlockedExchangeAdd(target, value);
else
return CustomInterlockedExchangeAdd(target, value);
}


Таким образом, в x86 режиме используется kernel32.dll, а в x64 - наша собственная библиотека-враппер.

Update: Блог не поддерживает длинные строки, потому я поставил несколько дополнительных переносов для повышения читабельности.

Дальше..

понедельник, 24 марта 2008 г.

.Net Thread Pool и IOCP

В программировании часто перед нами встает задача выполнить какое-то действие "параллельно" с текущим. Методов решения может быть море: использование дополнительных потоков, откладывание задачи "на потом" (вручную или с помощью APC), использование каких-либо очередей задач и другие варианты асинхронного выполнения.
Нас же интересует именно Thread Pool в .Net. Его использование зачастую считается хорошим тоном, а некоторые методы CLR используют его неявно для своих целей (в т.ч. System.Net.Sockets и делегаты в асинхронных вызовах). Недостаток у него я усматриваю лишь один: универсальность. Все люди разные, но на заводах шьют одежду по общим меркам и размерам. Каждая книга уникальна, но для нее определяют категорию и ставят на полку с другими, похожими..

Так и наш Thread Pool пытается решать все задачи единым подходом: задача поступает в очередь и через какое-то время выбирается один из свободных потоков для ее выполнения. Если свободных потоков нет, то с некоторой периодичностью создаются новые.
Проблема в том, что нас не всегда может устроить тот факт, что все задачи равноправны и все выполнятся "как можно быстрее". Нас изредка волнует, насколько затормозят работу БД 25 задач извлечения данных, выполненных одновременно, нам хочется указать, что такая-то задача может быть выполнена после всех остальных. Конечно, в 95% случаев это не имеет значения, но когда мы говорим о time-critical приложениях, таких как сервера онлайн-игр, это надо учитывать. Те же 25 обращений к БД эффективны лишь в том случае, если на сервере БД есть 25 процессоров, способных выполнить эти запросы, иначе задачи либо поступят там в очередь выполнения, либо будут выполняться в 25 разных потоках, что может привести к увеличению накладных расходов на блокировки, совместное использование жесткого диска и пр. Практика показывает, что 25 таких задач почти всегда быстрее выполнятся последовательно, чем параллельно.

Посмотрим в сторону кастомизабельности Thread Pool. Он позволяет указывать количество потоков (SetMaxThreads/SetMinThreads), а также сам рассчитывает нужное кол-во потоков исходя из количества процессоров:

There is one thread pool per process. The thread pool has a default size of 25 worker threads per available processor, and 1000 I/O completion thread.


Количество потоков дет нам возможность управления над выполнением: совсем "тяжелые" задачи есть смысл выполнять в 2-3 потока, не критичные по времени - в одном потоке, а "легкие", в которых точно нет блокировок, правильно выполнять в таком же количестве потоков, сколько и процессоров в системе. К сожалению, это все было бы возможно, если бы можно было использовать несколько пулов с разными параметрами. Самое начало цитаты не оставляет нам выбора - надо придумывать что-то собственное.
Тут есть смысл посмотреть в сторону приятнейшей технологии IOCP: Input Output Completion Ports. Как видно из названия, разрабатывалась она для облегчения асинхронного выполнения операций ввода/вывода. Нам же важно, что технология эта позволяет ставить задачи в очередь и выполнять их с помощью указанного количества потоков. А именно, мы сами создаем потоки для IOCP и вызываем в них метод GetQueuedCompletionStatus. На этом выполнение потока блокируется и дальнейшую судьбу его решит уже ядро системы. Ожидающие потоки ставятся в LIFO (Last In First Out) очередь и возобновляются тогда, когда для них есть задачи. Использование именно LIFO позволяет использовать одни потоки чаще, чем остальные, что уменьшает расходы на переключение между ними.

Реализация собственного Thread Pool с IOCP довольно проста. Для операций надо будет извлечь с помощью P/Invoke четыре функции из kernel32.dll:
  • CreateIoCompletionPort
  • CloseHandle
  • GetQueuedCompletionStatus
  • PostQueuedCompletionStatus
При создании собственного пула надо инициализировать объект IOCP через вызов метода CreateIoCompletionPort, потом создать кужное количество потоков, в которых в цикле будет вызываться GetQueuedCompletionStatus и затем использовать PostQueuedCompletionStatus для постановки задачи в очередь. Саму задачу можно передать в виде ссылки на делегат. Делается это без особых сложностей самой CLR (если при описании P/Invoke указать делегат как параметр к PostQueuedCompletionStatus), либо вручную через класс Marshal, или же через GCHandle. ToIntPtr/GCHandle. FromIntPtr.
После окончания использования IOCP надо закрыть методом CloseHandle.

Такой подход дает нам возможность использовать любое количество собственных Thread Pool с любым количеством потоков.

Дальше..