Безопасность потоков в С++


Оглавление (нажмите, чтобы открыть):

Безопасность потоков в С++

Для вывода данных используеся оператор . Этот опрератор определен для всех встроенных типов C++ и некоторых классов, входящих в стандартную библиотеку. Для вывода перевода строки можно использовать специальный объект endl .

Примеры использования потока вывода:

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

Некоторые методы класса ostream:

  • put(char c) — записать символ с в поток
  • write(const char* s, streamsize n) — записать первые n элементов массива s в поток (streamsize представляет целое число со знаком, например, int)
  • flush() — записать значение из буфера
  • close() — закрытие потока

Потоки ввода (istream)

Для ввода используется оператор >> . Он также определен для всех встроенных типов и некоторых классов стандартной библиотеки.

Оператор >> также можно определить для своих классов:

Некоторые методы класса istream:

  • get() — считать следующий символ
  • get(char *buf, streamsize n) — считать максимум n-1 символ и поместить в массив buf
  • get(char *buf, streamsize n, char delim) — считывание символов до символа-разделителя delim (разделитель не считывается и остается в потоке)
  • getline(char *buf, streamsize n)
  • getline(char *buf, streamsize n, char delim)
  • peek() — считывает следующий символ, но оставляет его в потоке
  • ignore(streamsize n = 1, int delim = EOF) — извлекает символы из потока до тех пор, пока их число меньше n или пока не встретился символ delim
  • putback(char c) — добавляет символ с в текущую позицию потока
  • unget() — возвращает последний считанный символ в поток

Форматирование

Для управления форматом вывода можно устанавливать специальные флаги потока методом setf(ios_base::fmtflags f) . Но удобнее пользоваться манипуляторами — специальными функциями, реализованными в заголочных файлах , (они по умолчанию включены в ) и .

Основные манипуляторы ввода/вывода:

  • boolalpha — стороковое представление логических значений
  • noboolalpha — числовое представление логических значений
  • showbase — включает вывод 0 перед восьмеричными и 0x перед шестнадцатеричными числами
  • noshowbase — выключает вывод 0 и 0x
  • dec — вывод чисел в десятичной системе счисления
  • oct — в восьмеричной
  • hex — в шестнадцатеричной
  • uppercase — заглавные буквы в записи шестнадцатеричных чисел и чисел с плавающей запятой в научной записи
  • nouppercase — строчные буквы в записи чисел
  • skipws — пропуск символов-разделителей ( ‘ ‘, ‘\t’, ‘\n’, и т.п. )
  • noskipws — выключение пропуска разделителей
  • setw(int n) — определяет минимальное количество символов, которые выведутся следующей операцией вывода
  • setfill(char c) — символ-заполнитель
  • left — выравнивание поля по левому краю
  • right — выравнивание поля по правому краю
  • internal — выравнивание поля по ширине
  • scientific — научная запись для чисел с плавающей запятой
  • fixed — фиксированная точность для чисел с плавающей запятой
  • setprecision — точность вывода чисел (по умолчанию равна 6)
  • endl — запись \n и очистка буфера
  • ends — запись \0
  • flush — очистка буфера потока
  • ws — прочитать и проигнорировать символы-разделители

Сотояние потока

Каждый поток istream или ostream имеет связанное с ним состояние.

Методы проверки состояния:

  • good() — можно выполнить следующую операцию
  • eof() — конец потока
  • fail() — следующая операция не выполнится
  • bad() — поток испорчен

Стандартные потоки — iostream

Для реализации стандартного ввода/вывода в библиотеку C++ включен заголовочный файл iostream , содержащий следующие предопределенные объекты потоков:

  • cin — стандартный поток ввода (соответствует потоку C stdin)
  • cout — стандартный поток вывода (соответствует stdout)
  • cerr — стандартный поток вывода ошибок (соответствует stderr)
  • clog — стандартный поток вывода журнала (соответствует stderr)

Файловые потоки — fstream

Файловые потоки расположены в заголовочном файле . ifstream — поток ввода, ofstream — поток вывода.

Имя файла передается потоку либо в конструкторе, либо через вызов метода open .

Строковые потоки — sstream

Строковые потоки расположены в заголовочном файле .

istringstream — поток ввода. Строка передается потоку в конструкторе.

ostringstream — поток вывода. Строку-результат возвращает метод str() .

Запуск метода класса в новом потоке

04.04.2015, 19:17

Выполнение метода в отдельном потоке
Всем привет. Я вообщем то пользуюсь RAD Studio XE но смысл тот же самый Вообщем есть.

Использование метода класса как функции в качестве типизированного аргумента в методе класса
Использование метода класса как функции в качестве типизированного аргумента в методе класса .

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

Функция-член класса в отдельном потоке
люди я столкнулся со странной проблемой. Мне нужно запустить функцию член класса в отдельном.

Безопасное программирование потоков в симуляторах с использованием PIN

Я использую аппаратный симулятор, который использует инструменты PIN для выполнения рабочей нагрузки. В качестве рабочей нагрузки я использую следующий код. Хотя это работает на Ubuntu с -lpthread флаг, он останавливается на симуляторе, когда дело доходит до присоединения к потокам.

Я думаю, что в этом коде есть что-то небезопасное, что может выдержать нативная ОС, а симулятор — нет. Каков наиболее подходящий способ кодировать это?

main.h:

main.cc:

execute_parallel() содержит 2 цикла for для создания и присоединения потоков. manual_schedule() имеет развернутую версию того же кода. При выполнении по PIN-коду оба работают хорошо, пока не будет включена функция соединения первого потока. Когда происходит соединение, оно останавливается и остается таким же навсегда, без каких-либо сигналов или ошибок. Выполняя на Ubuntu с -lpthread флаг, он работает отлично и генерирует результаты.

Что может быть наиболее безопасным и подходящим способом реализации pthreads в этой ситуации?

редактировать

Я заметил, что программа зависает перед чтением payload_texts[args->index1] , Добавление мьютекса помогло продолжить работу в этой точке. Также это работало одно время должным образом. Теперь он недетерминирован, в нескольких исполнениях одного и того же двоичного файла он редко заканчивается должным образом. Я думаю, что должна быть причина тупика внутри функции jaccard_visit. Я изменил это следующим образом:

Решение

Наконец, я сделал это, выполнив следующие изменения в функции, которую выполняет каждый поток (jaccard_visit):

  1. Обернуть операции чтения глобальных переменных с помощью mutex_lock
  2. Удалить вызовы функций и реализовать их встроенными
  3. Избегайте использования set, вместо этого используйте строку или массив символов
  4. Отдельный пункт назначения и источник строковых функций

Рекомендации по безопасности для C++ Security Best Practices for C++

В этой статье содержатся сведения об инструментах и методиках обеспечения безопасности. This article contains information about security tools and practices. Использование этих инструментов не дает полной гарантии защиты от хакерских атак, но при этом существенно снижает шансы на успех таких атак. Using them does not make applications immune from attack, but it makes successful attacks less likely.

Средства безопасности в Visual C++ Visual C++ Security Features

Эти функции безопасности встроены в Microsoft C++ компилятора и компоновщика: These security features are built into the Microsoft C++ compiler and linker:

/guard (включение защиты потока управления) /guard (Enable Control Flow Guard)
Указывает компилятору на необходимость анализа потока управления для целевых объектов косвенного вызова во время компиляции и последующей вставки кода для проверки целевых объектов во время выполнения. Causes the compiler to analyze control flow for indirect call targets at compile time, and then to insert code to verify the targets at runtime.

/GS (проверка безопасности буфера) /GS (Buffer Security Check)
Указывает компилятору на необходимость вставки кода, обнаруживающего переполнения, в функции, которыми могут воспользоваться злоумышленники. Instructs the compiler to insert overrun detection code into functions that are at risk of being exploited. При обнаружении переполнения выполнение программы прекращается. When an overrun is detected, execution is stopped. По умолчанию этот параметр включен. By default, this option is on.

/SAFESEH (образ содержит безопасные обработчики исключений) /SAFESEH (Image has Safe Exception Handlers)
Указывает компоновщику на необходимость включения в выходной образ таблицы, содержащей адрес каждого обработчика исключений. Instructs the linker to include in the output image a table that contains the address of each exception handler. Во время выполнения операционная система проверяет по этой таблице, действительно ли используются только допустимые обработчики исключений. At run time, the operating system uses this table to make sure that only legitimate exception handlers are executed. Это помогает предотвратить запуск обработчиков исключений, внедряемых злоумышленниками в среду выполнения. This helps prevent the execution of exception handlers that are introduced by a malicious attack at run time. По умолчанию этот параметр выключен. By default, this option is off.


/ NXCOMPAT, /NXCOMPAT (совместимо с предотвращением выполнения данных) эти параметры компилятора и компоновщика включить совместимость Предотвращение выполнения данных (DEP). /NXCOMPAT, /NXCOMPAT (Compatible with Data Execution Prevention) These compiler and linker options enable Data Execution Prevention (DEP) compatibility. Функция DEP защищает ЦП от исполнения страниц, не содержащих кода. DEP guards the CPU against the execution of non-code pages.

/analyze (анализ кода) /analyze (Code Analysis)
Этот параметр компилятора запускает анализ кода с целью выявления потенциальных проблем безопасности, таких как переполнение буфера, отмена инициализации памяти, разыменование нулевого указателя и утечки памяти. This compiler option activates code analysis that reports potential security issues such as buffer overrun, un-initialized memory, null pointer dereferencing, and memory leaks. По умолчанию этот параметр выключен. By default, this option is off. Дополнительные сведения см. в разделе анализ кода для C/C++ Обзор. For more information, see Code Analysis for C/C++ Overview.

/DYNAMICBASE (использование технологии ASRL) /DYNAMICBASE (Use address space layout randomization)
Этот параметр компоновщика позволяет собрать исполняемый образ, который можно загрузить в другое расположение в памяти в начале выполнения. This linker option enables the building of an executable image that can be loaded at different locations in memory at the beginning of execution. Этот параметр также делает расположение стека в памяти значительно менее прогнозируемым. This option also makes the stack location in memory much less predictable.

Среда выполнения повышенной безопасности Security-Enhanced CRT

В библиотеку времени выполнения C (CRT) были добавлены безопасные версии функций, которые могут создать угрозу безопасности, например функция копирования непроверенной строки strcpy . The C Runtime Library (CRT) has been augmented to include secure versions of functions that pose security risks—for example, the unchecked strcpy string copy function. Поскольку устаревшие, небезопасные версии этих функций использовать не рекомендуется, во время компиляции они выводят предупреждения. Because the older, nonsecure versions of these functions are deprecated, they cause compile-time warnings. Рекомендуется использовать безопасные версии этих функций CRT, а не подавлять вывод предупреждений компилятора. We encourage you to use the secure versions of these CRT functions instead of suppressing the compilation warnings. Для получения дополнительной информации см. Security Features in the CRT. For more information, see Security Features in the CRT.

Библиотека SafeInt SafeInt Library

Библиотека SafeInt помогает предотвращать переполнение емкости целочисленных переменных и другие ошибки, которые могут возникнуть во время выполнения математических операций приложением. SafeInt Library helps prevent integer overflows and other exploitable errors that might occur when the application performs mathematical operations. SafeInt Библиотека включает в себя класс SafeInt, класс SafeIntExceptionи несколько функции SafeInt. The SafeInt library includes the SafeInt Class, the SafeIntException Class, and several SafeInt Functions.

Класс SafeInt защищает от ошибок переполнения емкости целочисленных переменных и деления на нуль, которые могут использоваться в злонамеренных целях. The SafeInt class protects against integer overflow and divide-by-zero exploits. Его можно использовать для обработки сравнений значений различных типов. You can use it to handle comparisons between values of different types. Он предоставляет две политики обработки ошибок. It provides two error handling policies. Политика по умолчанию класса SafeInt заключается в выдаче исключения класса SafeIntException , сообщающего о причинах невозможности выполнения математической операции. The default policy is for the SafeInt class to throw a SafeIntException class exception to report why a mathematical operation cannot be completed. Вторая политика заключается в том, что класс SafeInt останавливает выполнение программы. The second policy is for the SafeInt class to stop program execution. Также можно определить пользовательскую политику. You can also define a custom policy.

Каждая функция класса SafeInt защищает от злонамеренного использования ошибок одной из математических операций. Each SafeInt function protects one mathematical operation from an exploitable error. Можно использовать параметры двух различных типов без преобразования их к одному типу. You can use two different kinds of parameters without converting them to the same type. Для защиты множества математических операций используйте класс SafeInt . To protect multiple mathematical operations, use the SafeInt class.

Checked Iterators Checked Iterators

Проверяемый итератор обеспечивает принудительное соблюдение ограничений контейнера. A checked iterator enforces container boundaries. По умолчанию при превышении ограничений проверяемого итератора создается исключение, и выполнение программы прекращается. By default, when a checked iterator is out of bounds, it generates an exception and ends program execution. Проверяемый итератор обеспечивает другие уровни реагирования, зависящие от значений, назначенных в определениях препроцессора, таких как определяет _SECURE_SCL_ВЫЗЫВАЕТ и _ИТЕРАТОР_Отладка_уровень. A checked iterator provides other levels of response that depend on values that are assigned to preprocessor defines such as _SECURE_SCL_THROWS and _ITERATOR_DEBUG_LEVEL. Например, в _ИТЕРАТОР_Отладка_LEVEL = 2, проверяемый итератор выполняет комплексные проверки правильности в режиме отладки, который становятся доступными с помощью утверждений. For example, at _ITERATOR_DEBUG_LEVEL=2, a checked iterator provides comprehensive correctness checks in debug mode, which are made available by using asserts. Дополнительные сведения см. в разделе проверяемые итераторы и _ИТЕРАТОР_Отладка_уровень. For more information, see Checked Iterators and _ITERATOR_DEBUG_LEVEL.

Анализ управляемого кода Code Analysis for Managed Code

Инструмент анализа управляемого кода, также известный как FxCop, выполняет проверку сборок на соответствие рекомендациям, изложенным в Правилах разработки приложений платформы .NET Framework. Code Analysis for Managed Code, also known as FxCop, checks assemblies for conformance to the.NET Framework design guidelines. FxCop анализирует код и метаданные в каждой сборке и выявляет дефекты по следующим направлениям: FxCop analyzes the code and metadata in each assembly to check for defects in the following areas:

Разработка библиотек Library design

Соглашения об именах Naming conventions

Средство проверки приложений Windows Windows Application Verifier

Application Verifier (AppVerifier) может помочь выявить потенциальные проблемы совместимости, стабильности и безопасности приложений. The Application Verifier (AppVerifier) can help you identify potential application compatibility, stability, and security issues.

Средство AppVerifier контролирует использование операционной системы приложением. The AppVerifier monitors how an application uses the operating system. Он следит за файловой системой, реестром, памятью и API-интерфейсами во время работы приложения, а при обнаружении ошибок выдает рекомендации по исправлению исходного кода. It watches the file system, registry, memory, and APIs while the application is running, and recommends source-code fixes for issues that it uncovers.

Можно использовать AppVerifier в следующих целях: You can use the AppVerifier to:

Тестирование приложения на наличие возможных проблем совместимости, возникающих в результате типичных ошибок программирования. Test for potential application compatibility errors that are caused by common programming mistakes.

Исследовать приложения для определения утечек памяти. Examine an application for memory-related issues.

Выявлять потенциальные проблемы безопасности в приложениях. Identify potential security issues in an application.

Учетные записи пользователей Windows Windows User Accounts

Для разработчиков и в конечном счете пользователей использование учетных записей Windows, относящихся к группе «Администраторы», создает повышенный риск для безопасности. Using Windows user accounts that belong to the Administrators group exposes developers and—by extension—customers to security risks. Дополнительные сведения см. в разделе Запуск от имени участника группы «Пользователи» и влияет на приложения способ контроля учетных записей (UAC). For more information, see Running as a Member of the Users Group and How User Account Control (UAC) Affects Your Application.

Рекомендации для каналов на стороне упреждающего выполнения Guidance for Speculative Execution Side Channels

Сведения о том, как для идентификации и решения от уязвимостей упреждающего исполнения на стороне канала оборудования в программном обеспечении C++, см. в разделе материалы для разработчиков C++ для каналов на стороне выполнения наблюдающая. For information about how to indentify and mitigate against speculative execution side channel hardware vulnerabilities in C++ software, see C++ Developer Guidance for Speculative Execution Side Channels.

C++ — Потоки в Win32

Любой поток (thread) состоит из двух компонентов:
— объекта ядра, через который ОС управляет потоком. Там же хранится статистическая информация о потоке.
— Стека потока, который содержит параметры всех функций и локальные переменные, необходимые потоку для выполнения кода.
Потоки всегда создаются в контексте какого-либо процесса, и вся их жизнь проходит только в его границах. На практике это означает, что потоки исполняют код и манипулируют данными в адресном пространстве процесса. Если два или более потока выполняются внутри одного процесса, они делят одно адресное пространство.

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

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

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

Создание потока.

Первичный поток, который присутствует в программе, начинает свое выполнение с главной функции потока типа WinMain.

Для создания вторичного потока необходимо создать и для него входную функцию, которая выглядит примерно так:

DWORD WINAPI ThreadFunc(PVOID pParam)
<
DWORD dwResult = 0;
.
return dwResult;
>

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

Когда поток закончит свое исполнение, он вернет управление системе, память, отведенная под его стек, будет освобождена, а счетчик пользователей его объекта ядра «поток» уменьшится на 1. Когда счетчик обнулится, этот объект ядра будет разрушен.

Для создания своего потока необходимо использовать функцию CreateThread:

HANDLE CreateThread(
LPSECURITY_ATTRIBUTES lpThreadAttributes,
DWORD dwStackSize,
LPTHREAD_START_ROUTINE lpStartAddress,
LPVOID lpParameter,
DWORD dwCreationFlags,
LPDWORD lpThreadId);

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

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

CreateThread — это Windows-функция, создающая поток. Если вы пишете код на С/С++ не вызывайте ее. Вместо нее Вы должны использовать _beginthreadex из библиотеки Visual C++. Почему это так важно в наших следующих выпусках.

Параметры функции CreateThread.

LpThreadAttributes — является указателем на структуру LPSECURITY_ATTRIBUTES. Для присвоения атрибутов защиты по умолчанию, передавайте в этом параметре NULL.

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

LpStartAddress — указатель на адрес входной функции потока.
LpParameter — параметр, который будет передан внутрь функции потока.

DwCreationFlags — принимает одно из двух значений: 0 — исполнение начинается немедленно, или CREATE_SUSPENDED — исполнение приостанавливается до последующих указаний.

LpThreadId — Адрес переменной типа DWORD в который функция возвращает идентификатор, приписанный системой новому потоку.

Поток можно завершит четырьмя способами:

— функция потока возвращает управление (рекомендуемо);
— поток самоуничтожается вызовом функции ExitThread;
— другой поток процесса вызывает функцию TerminateThread;
— завершается процесс, содержащий данный поток.

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

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

— любые С++ объекты, созданные данным потоком, уничтожаются соответствующими деструкторами;
— система корректно освобождает память, которую занимал стек потока;
— система устанавливает код завершения данного потока. Его функция и возвращает;
— счетчик пользователей данного объекта ядра (поток) уменьшается на 1.

При желании немедленно завершить поток изнутри используют функцию ExitThread(DWORD dwExitCode).

При этом освобождаются все ресурсы ОС, выделенные данному потоку, но С С++ ресурсы (например, объекты классов С++) не очищаются. Именно поэтому не рекомендовано завершать поток, используя эту функцию.

Если же вы ее использовали, то кодом возврата потока будет тот параметр, который вы передадите в данную функцию.

Как и для CreateThread для библиотеки Visual C++ существует ее аналог _endthreadex, который и стоит использовать. Об причинах в следующем выпуске.

Если появилась необходимость уничтожить поток снаружи, то это моет сделать функция TeminateThread.

Эта функция уменьшит счетчик пользователей объекта ядра (поток) на 1, однако при этом не разрушит и не очистит стек потока. Стек будет существовать, пока не завершится процесс, которому принадлежит поток. При задачах, постоянно создающих и уничтожающих потоки, это приводит к потере памяти внутри процесса.

При завершении процесса происходит следующее.
Завершение потока происходит принудительно. Деструкторы объектов не вызываются, и т.д. и т.д.

При завершении потока по такой причине, связанный с ним объект ядра (поток) не освобождается до тех пор, пока не будут закрыты все внешние ссылки на этот объект.

Правильная многопоточность: «Да» — плавности, «нет» — блокировкам!

Содержание статьи

Известно, что приложения бывают однопоточные и многопоточные. Single thread софт кодить легко и приятно: разработчику не надо задумываться о синхронизации доступа, блокировках, взаимодействии между нитями и так далее. В многопоточной среде все эти проблемы часто становятся кошмаром для программиста, особенно если опыта работы с тредами у него еще не было. Чтобы облегчить себе жизнь в будущем и сделать multi thread приложение надежным и производительным, нужно заранее продумать, как будет устроена работа с потоками. Эта статья поможет твоему мозгу увидеть правильное направление!

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

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

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

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

Асинхронный вызов функции

С выходом C++11 жить стало проще. Теперь для создания своего треда не надо использовать сложные API Майкрософт или вызывать устаревшую _beginthread. В новом стандарте появилась нативная поддержка работы с потоками. В частности, сейчас нас интересует класс std::thread, который является не чем иным, как STL представлением потоков. Работать с ним — одно удовольствие, для запуска своего кода достаточно лишь передать его в конструктор std::thread в виде функционального объекта, и можно наслаждаться результатом.

Стоит также отметить, что мы можем дождаться, когда поток закончит свою работу. Для этого нам пригодится метод thread::join, который как раз и служит для этих целей. А можно и вовсе не дожидаться, сделав thread::detach. Наш предыдущий однопоточный пример может быть преобразован в multi thread всего лишь добавлением одной строки кода.

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

Специально для таких случаев в STL есть замечательные std::async и std::future — шаблонная функция и класс, которые позволяют запустить код асинхронно и получить по запросу результат его работы. Если переписать предыдущий пример с использованием новых примитивов, то мы получим приблизительно следующее:

В std::async мы передали флаг std::launch::async, который означает, что код надо запустить в отдельном потоке, а также нашу функцию Foo. В результате мы получаем объект std::future. После чего мы опять продолжаем заниматься своими делами и, когда нам это понадобится, обращаемся за результатом выполнения Foo к переменной f, вызывая метод future::get.


Пример использования std::thread

Хакер #174. Собираем квадрокоптер

Выглядит все идеально, но опытный программист наверняка задаст вопрос: «А что будет, если на момент вызова future::get функция Foo еще не успеет вернуть результат своих действий?» А будет то, что главный поток остановится на вызове get до тех пор, пока асинхронный код не завершится.

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

Concurrent Queue

В примере с future::get мы фактически использовали мьютекс. Во время попытки получения значения из std::future код шаблонного класса проверял, закончил ли свою работу поток, запущенный с помощью std::async, и если нет, то ожидал его завершения. Для того чтобы один поток никогда не ждал, пока отработает другой, умные программисты придумали потокобезопасную очередь.

Любой кодер знает такие структуры данных, как вектор, массив, стек и так далее. Очередь — это одна из разновидностей контейнеров, работающая по принципу FIFO (First In First Out). Thread-safe очередь отличается от обычной тем, что добавлять и удалять элементы можно из разных потоков и при этом не бояться, что мы одновременно попробуем записать или удалить что-нибудь из очереди, тем самым с большой долей вероятности получив падение программы или, что еще хуже, неопределенное поведение.

Мастер Йода рекомендует:  Всё о парсинге сайтов на Python

Concurrent queue можно использовать для безопасного выполнения кода в отдельном потоке. Выглядит это примерно так: в потокобезопасную очередь мы кладем функциональный объект, а в это время в рабочем потоке крутится бесконечный цикл, который на каждой итерации обращается к очереди, достает из нее переданный ей код и выполняет его. Чтобы лучше понять, можно взглянуть на код:

Теперь наш код выполняется в очереди, в другом потоке. Более того, мы можем отправить на выполнение не только Foo, но и другие функции, которые выполняются слишком долго. При этом мы не будем каждый раз создавать отдельный thread для каждой функции.

Документация шаблонного класса std::feature

Но мы опять забыли про возвращаемое значение. Одним из способов решения этой проблемы будет обратный вызов. Мы должны передавать в рабочую очередь не только код, требующий выполнения, но и callback-функцию, которая будет вызвана вычисленным значением в качестве параметра.

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

Еще один вариант использования concurrent queue

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

Заключение

Асинхронное программирование нынче в тренде. Пользовательские интерфейсы iOS и Windows Phone работают плавно и без лагов как раз из-за того, что в их основу заложены принципы, позволяющие избегать блокировок потоков в ожидании результатов работы тех или иных длительных действий. И чем дальше, тем более ярко будет выражено движение в сторону асинхронности работы ПО.

Анализ потокобезопасности в С++

Писать многопоточные приложения нелегко. Некоторые средства статического анализа кода позволяют помочь разработчикам, давая возможность чётко определить политики поведения потоков и обеспечить автоматическую проверку выполнения этих политик. Благодаря этому появляется возможность отлавливать состояния гонки потоков или их взаимной блокировки. Эта статья описывает инструмент анализа потокобезопасности С++ кода, встроенный в компилятор Clang. Его можно включить с помощью опции командной строки −Wthread−safety. Данный подход широко распространён в компании Google — полученные от его применения преимущества привели к повсеместному добровольному использованию данной технологии различными командами. Вопреки популярному мнению, необходимость в дополнительных аннотациях кода не стала бременем, а наоборот, дала свои плоды выражающиеся в упрощении поддержки и развития кода.

Предисловие

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

Средства статического анализа кода помогают разработчикам определить политики потокобезопасности и проверять их при сборке проекта. Примером таких политик могут быть утверждения «мьютекс mu всегда должен использоваться при доступе к переменной accountBalance» или «метод draw() должен вызываться только из GUI-потока». Формальное определение политик даёт два основных преимущества:

  1. Компилятор может показывать предупреждения в случае обнаружения нарушений политик. Нахождение ошибки на этапе компиляции значительно дешевле, чем отладка упавших юнит-тестов или, что ещё хуже, появление «плавающих» багов в продакшн-коде.
  2. Явно выраженные в коде спецификации потокобезопасности играют роль документации. Подобная документация очень важна для библиотек и SDK, поскольку программистам нужно знать, как их корректно использовать. Данную информацию, конечно, можно поместить в комментарии, однако практика показывает, что подобные комментарии имеют свойство устаревать, поскольку при обновлении кода они не всегда меняются синхронно.

Данная статья рассказывает о применении данного подхода в Clang, хотя изначально он был разработан для GCC, однако версия для GCC более не поддерживается. В Clang данная возможность реализована как предупреждение компилятора. В Google на данный момент вся кодовая база C++ компилируется с включенным по умолчанию анализом потокобезопасности.

Работает всё это следующим образом: в дополнению к типу переменной (int, float, и т.д.) программист может опционально определить как доступ к данной переменной должен контролироваться в многопоточной среде. Clnag использует для этого аннотации. Аннотации могут быть написаны либо в GNU-стиле атрибутов (то есть attribute ((. ))) или в стиле атрибутов С++11 (то есть [[. ]] ). Для переносимости атрибуты обычно спрятаны внутри макроса, который определён только если код компилируется с помощью Clang. Примеры в данной статье предполагают использования данного макроса. Настоящие имена атрибутов могут быть найдены в документации к Clang.

Код в примере ниже демонстрирует базовый случай применения технологии, на примере классического банковского аккаунта. Аттрибут GUARDED_BY требует использования мьютекса mu для чтения или записи баланса, что даст гарантию атомарности операций по его изменению. Аналогично, макрос REQUIRES требует от того, кто вызовет метод withdrawImpl, перед его вызовом заблокировать мьютекс mu — лишь после этого операция по изменению баланса в теле метода будет считаться безопасной.

В примере метод depositImpl() не имеет атрибута REQUIRES и не блокирует мьютекс mu перед изменением баланса, а значит компиляция данного кода покажет предупреждение о потенциальной ошибке в этом методе. Анализ потокобезопасности не проверяет, был ли мьютекс использован в методе, который вызвал depositImpl(), так что атрибут REQUIRES должен быть определён явно. Также мы получим предупреждение о потенциальной ошибке в методе transferFrom(), поскольку он должен использовать мьютекс b.mu, а использует this->mu. Анализ понимает, что это два разных мьютекса в двух разных объектах. И, наконец, ещё одно предупреждение ждёт нас в методе withdraw(), где мы забываем разблокировать мьютекс mu после изменения баланса. Каждой операции блокирования мьютекса должна соответствовать операция разблокирования; анализ также корректно определяет двойные блокировки и двойные разблокировки. Функция может, при необходимости, осуществить блокировку без разблокировки (или разблокировку без блокировки), но такое поведение должно быть аннотированно специальным образом.

Анализ потокобезопасности был изначально спроектирован для случаев, подобных вышеуказанному. Но требования использования мьютексов при доступе к определённым объектам — не единственное, что необходимо проверять для обеспечения надёжности. Другой часто распространённый сценарий — это назначение потокам определённых ролей, например «рабочий поток», «GUI-поток». Те же концепции, о которых мы говорили касаемо мьютексов, могут быть применены и к ролям потоков. В примере ниже мы видим некоторый класс Widget, который может быть использован из двух потоков. В одном из потоков происходит обработка событий (например, кликов мышью), а в другом — рендеринг. При этом метод draw() должен вызываться только из потока рендеринга, и никогда не задерживать работу потока, обрабатывающего пользовательские действия. Анализ предупредит, если метод draw() вызовется не из того потока. Далее в статье будет идти речь о мьютексах, но аналогичные примеры можно привести и для ролей потоков.

Базовые концепции

Анализ потокобезопасности в Clang построен на расчёте возможностей. Для чтения или записи определённой области памяти поток должен обладать возможностью (или правами) на это. Эту возможность можно представить себе как некий ключ или токен, который поток должен предоставить чтобы получить права на чтение или запись. Возможность может быть «уникальной» или «разделяемой». «Уникальная»» возможность не может быть скопирована, то есть только один поток может иметь к ней доступ в каждый момент времени. «Разделяемая» возможность может иметь несколько дубликатов, принадлежащих разным потокам. Анализ использует подход «один писательмного читателей», то есть для записи в определённую область памяти поток должен обладать «уникальной» возможностью, а вот для чтения этой же области у потока может быть как «уникальная», так и одна из «разделяемых» возможностей. Другими словами, много потоков могут читать область памяти одновременно, поскольку они могут разделять возможность, но только один поток в каждый момент времени может писать. Более того, поток не может писать в то время как другой поток читает данную область памяти, поскольку возможность не может быть одновременно «разделяемой» и «уникальной».

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

Уникальность и линейная логика

Линейная логика это формальная теория, которая может быть использована, например, для выражения логических утверждений вроде «Вы не можете иметь целый торт и в то же время уже его съесть». Уникальная, или линейная, переменная может быть использована ровно один раз. Её нельзя скопировать, использовать несколько раз или забыть использовать. Уникальный объект может быть создан в одной точке программы, а затем позже использован. Функции, имеющие доступ к объекту, но не использующие его, могут лишь передать его дальше. Например, если бы std::stringstream был линейным типом, программы писались бы следующим образом:

Обратите внимание на то, что каждая переменная потока использовалсь ровно один раз. Линейная система типов не знает о том, что ss и ss2 ссылаются на одни и те же данные, вызов . Аналогично, mu.unlock() неявно принимает и использует возможность типа Cap . Операции, которые читают или пишут данные, защищаемые мьютексом mu, следуют протоколу передачи возможностей: они принимают и используют неявный параметр типа Cap и создают неявный результат того же типа Cap .

Аннотации потокобезопасности

В этом разделе коротко описываются все основные аннотации, которые поддерживаются статическим анализом потокобезопасности в Clang.

GUARDED_BY(. ) и PT_GUARDED_BY(. )

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

REQUIRES(. ) и REQUIRES_SHARED(. )

REQUIRES — это атрибут функции. Он требует от вызывающего потока наличия «уникальной» возможности. Можно указать более одной возможности. REQUIRES_SHARED работает аналогично, но требуемая возможность может быть как «уникальной», так и «разделяемой». Формально REQUIRES определяет поведение функции таким образом, что она принимает возможность в виде неявного аргумента и возвращает её в виде неявного результата.

ACQUIRE(. ) и RELEASE(. )

Аттрибует ACQUIRE показывает, что функция создаёт «уникальную» возможность (или возможности), например, получая её от какого-то потока. Вызывающий эту функцию поток не должен передавать ей возможность, но получит её от функции, когда она вернёт результат. Атрибут RELEASE указывает, что функция использует «уникальную» возможность (например, отдавая её другому потоку). Вызывающий поток должен передать функции эту возможность, но не получит её обратно, когда функция вернёт результат.

ACQUIRE_SHARED и RELEASE_SHARED

Эти аттрибуты работают аналогично описанным выше, но создают и используют «разделяемые» возможности.

CAPABILITY(. )

Аттрибует CAPABILITY может быть применён к структуре, классу или typedef. Он показывает, что объект этого класса может быть использован для идентификации возможностей. Например, класс мьютекса в библиотеках Google определяется следующим образом:

Мьютексы это обычные С++ объекты. Однако, каждый мьютекс имеет ассоциированную с ним возможность. Методы lock() и unlock() создают и освобождают данную возможность. Заметьте, что Clang не делает попыток проверить, действительно ли данные методы выполняют соответствующие операции с мьютексом. Аннотации применяются лишь к интерфейсу класса мьютекса и выражают то, как различные его методы создают и используют возможности.

TRY_ACQUIRE(b, . ) и TRY_ACQUIRE_SHARED(b, . )

Эти аттрибуты функции или метода пробуют получить указанную возможность и возвращают true или false в зависимости от результата.

NO_THREAD_SAFETY_ANALYSIS

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

Негативные требования

Все описанные выше требования были «позитивными», т.е. оговаривалось, какая возможность должна присутствовать на момент вызова некоторой функции. Есть, однако, и «негативные» требования, описывающие каких возможностей в этот моет не должно быть. Позитивные требования позволяют избежать состояния гонки, в то время как негативные — помогают бороться с дедлоками. Многие реализации мьютексов не реэнтарабельны, поскольку сделать их реэнтерабельными возможно лишь ценой существенного падения производительности. Для таких мьютексов попытка второй раз вызвать операцию lock() приведёт к дедлоку. Для избежания дедлока мы можем в явном виде указать, что используемая в данный момент возможность не должна быть удерживаемой кем-то в данный момент. Данная «негативная возможность» выражается в виде оператора «!»:

Результаты и выводы

Анализ потокобезопасности С++ кода в данный момент широко используется в продуктах Google. Он включён по умолчанию, для каждой сборки каждого модуля. Более 20 000 файлов С++ кода имеют корректные аннотации согласно приведённым выше правилам, общее количество аннотаций достигает 140 000 и растёт с каждым днём. Использование данных аннотаций является в Google добровольным, и, соответственно, широкое распространение технологии является признаком того, что инженеры Google искренне считают её полезной.

Поскольку состояния гонки и взаимоблокировки — очень коварные вещи, Google использует и статический анализ кода, и средства динамического анализа, такие как Thread Sanitizer. Обнаружилось, что данные инструменты хорошо дополняют друг друга. Динамический анализ не требует аннотаций и, соответственно, может применяться шире. Он, однако, может детектировать проблемы лишь в тех путях выполнения кода, которые реально выполнялись по ходу анализа, а значит эффективность динамического анализа прямо зависит от тестового покрытия кода. Статический анализ не так гибок, но покрывает все возможные варианты исполнения кода. Кроме того, статический анализ выявляет проблемы ещё на этапе компиляции, что значительно эффективнее.

Несмотря на то, что необходимость ручного написания аннотаций может показаться недостатком, мы обнаружили, что аннотации значительно упрощают поддержку и развитие кода. Аннотации особенно широко применяются в библиотеках и API, поскольку там они служат также машинно-верифицируемой документацией. Разработчики и пользователи библиотек чаще всего относятся к разным командам, а значит тот, кто будет использовать библиотеку в реальном проекте не обязательно будет полностью понимать принятый в ней протокол управления многопоточностью. Документация может отсутствовать или быть устаревшей, а значит легко допустить ошибку. С использованием аннотаций протокол упраления средствами синхронизации становится частью API и компилятор будет предупреждать об ошибках в его использовании.

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

Нужно признать, что использование аннотаций имеет свою цену поддержки. Мы обнаружили, что около 50% предупреждений компилятора были спровоцированы не ошибками в коде, а ошибками вроде забытой, устаревшей или неверно использованной аннотацией (вроде отсутствия REQUIRES на методах getset). В этом плане аннотации потокобезопасности похожи на использование квалификатора const. Как смотреть на эти ошибки зависит от вашей точки зрения. В Google они считаются ошибками в документации. Поскольку API читается часто и многими инженерами — очень важно поддерживать публичные интерфейсы в актуальном состоянии. Если исключить случаи явно неверного использования аннотаций, оставшееся количество ложно-позитивных срабатываний достаточно низкое — менее 5%. Такие случаи в основном связаны с использованием доступа к одной и той же области памяти через разные указатели, условном использовании мьютексов, доступом к внутренним данным из конструктора объекта, где синхронизация ещё не нужна.

Безопасность потоков в С++

В предыдущей части [2] было показано, как запустить задачу (task) на потоке (thread), как конфигурировать поток и как передать данные в обоих направлениях (в поток и из потока). Также описывалось, каким образом локальные переменные делаются приватными для потока, и как ссылки могут использоваться между потоками совместно, чтобы можно было обмениваться данными между потоками через общие поля класса.

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

Конструкции синхронизации можно поделить на 4 категории:

Простые методы блокировки. Суть этих методов в ожидании, когда другой поток завершится. Ожидание может быть задано на определенный период времени. К методам простой блокировки относятся Sleep, Join и Task.Wait.

Конструкции критических секций (locking constructs). Это вводит ограничение, что определенная секция кода может исполняться в любой момент времени только ограниченным количеством потоков. Исключительная блокировка (exclusive locking) встречается чаще всего — она позволяет в любой момент времени только одному потоку осуществлять доступ к общим данным, при этом другие потоки не могут помешать доступу. Стандартные конструкции исключительной блокировки это lock (Monitor.Enter/Monitor.Exit, см. далее «Locking»), Mutex (см. далее) и SpinLock (см. [3]). Конструкции не исключительной блокировки (nonexclusive locking) это Semaphore, SemaphoreSlim (см. далее) и блокировки reader/writer (см. [4]).

Конструкции сигнализации (signaling). Они позволяют потоку приостановиться, пока не придет оповещение от другого потока, что устраняет необходимость не эффективного опроса (каких-то общих флагов или переменных). Есть два используемых обычно устройства сигнализации (signaling devices): обработчики ожидания события (event wait handles, см. далее) и методы and Wait/Pulse класса Monitor [5]. Framework 4.0 представляет классы CountdownEvent (см. далее) и Barrier [6].

Не блокирующие конструкции синхронизации. Они защищают доступ к общему полю путем вызова примитивов процессора. Библиотека CLR и язык C# предоставляют следующие не блокирующие конструкции: Thread.MemoryBarrier, Thread.VolatileRead, Thread.VolatileWrite [7], ключевое слово volatile [8] и класс Interlocked [9].

Блокировка важна для всех перечисленных категорий, кроме последней. Давайте кратко рассмотрим концепцию блокировки.

[Что такое блокировка]


Поток считается заблокированным, когда его выполнение приостановлено по какой-то причине, такой как засыпание (Sleep) или ожидание завершения другого потока с помощью Join или EndInvoke. Заблокированный поток немедленно уступает текущий квант процессорного времени, и с этого момента не использует процессор, пока не будет удовлетворено условие снятия блокировки (blocking condition). Вы можете проверить, заблокирован ли поток, с помощью его свойства ThreadState:

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

Когда поток блокируется или разблокируется, операционная система (точнее — её планировщик, sheduler) выполняет так называемое переключение контекста (context switch). Эта операция влечет трату процессорного времени в несколько микросекунд.

Разблокировка происходит одним из 4 способов (кнопка питания на системном блоке компьютера не считается!):

• Удовлетворено условие блокировки
• Истек таймаут операции (если был указан таймаут)
• Работа потока была прервана с помощью Thread.Interrupt [10]
• Работа потока была оборвана с помощью Thread.Abort [10]

Поток не считается заблокированным, если его выполнение приостановлено через (устаревший) метод Suspend [11].

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

О общем это очень затратный способ, бесполезно тратящий ресурс процессора: библиотека CLR и операционная система будут считать, что поток выполняет важное вычисление, и даст ему на это соответствующие выделенные ресурсы!

Иногда применяется гибрид между блокировкой и прокруткой с опросом:

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

Прокрутка с циклом опроса может быть редко эффективной только в тех случаях, когда Вы ожидаете удовлетворения условия в очень краткий промежуток времени (возможно в пределах нескольких микросекунд) потому что это позволяет избежать чрезмерной нагрузки на процессор и задержки на переключение контекста. Среда .NET Framework предоставляют для этого специальные методы и классы, что рассматривается в разделе описания параллельного программирования [3].

Состояния потока (ThreadState). Вы можете узнать, в каком из состояний находится поток, путем чтения свойства ThreadState. Это вернет флаги перечисления типа ThreadState, которые комбинируют 3 «слоя» данных по принципу побитного кодирования информации. Однако большинство этих бит избыточны, не используются или устарели. На диаграмме ниже показан один из «слоев»:

Следующий код преобразует ThreadState в одно из четырех наиболее полезных значений: Unstarted, Running, WaitSleepJoin и Stopped:

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

[Блокировка]

Исключительная, или монопольная блокировка (exclusive locking) используется, чтобы гарантировать, что только один поток в любой момент времени мог войти в определенную секцию кода. Есть две основные конструкции для exclusive locking, это lock и Mutex. Из этих двух конструкция lock работает быстрее и более удобен. Однако у Mutex есть ниша, в которой её блокировка может охватить приложения в различных процессах, работающих на компьютере (чем отличается процесс от потока см. [1]).

В этой секции мы начнем обсуждения с конструкции lock, и затем перейдем к рассмотрению Mutex и семафорам (для не исключительной блокировки, nonexclusive locking). Позже мы рассмотрим блокировки reader/writer [4].

Начиная с Framework 4.0 есть также структура SpinLock для сценариев кода, выполняющегося в условиях высокой конкуренции.

Начнем с примера следующего класса:

Этот класс не будет потокобезопасным: если Go был вызван двумя потоками одновременно, то есть возможность получения ошибки деления на 0, потому что _val2 установилась бы в 0 в одном потоке, в и в другом потоке в это же время мог бы выполняться оператор в параметре вызова Console.WriteLine.

Вот так блокировка lock может исправить эту проблему:

Только один поток может в любой момент времени заблокировать объект синхронизации lock (в этом примере объект синхронизации _locker). Любые претендующие на доступ к lock-участку кода будут заблокированы, пока блокировка не будет снята (т. е. пока выполнение не выйдет за пределы lock-участка кода). Такой участок кода также называют критической секцией. Если больше одного потока претендуют на доступ к региону кода lock, то они ставятся в очередь готовности (ready queue), и доступ к критической секции будет даваться по принципу FIFO, т. е. первым запросил доступ — первым получит доступ (некая проблема здесь заключается в нюансах поведения планировщика Windows и библиотеки CLR, в результате чего этот порядок предоставления доступа иногда нарушается). Исключительные блокировки, как иногда говорят, принуждает к применению строго последовательного доступа (serialized access) к участку кода, защищенному lock, потому что доступ со стороны одного потока никогда не может перекрыть доступ другого. В нашем примере логика защиты применена внутри метода Go method, когда осуществляется доступ к полям _val1 и _val2.

Поток блокируется, пока оспариваемый участок критического кода находится в состоянии ThreadState WaitSleepJoin. При рассмотрении Interrupt и Abort будет описано, как заблокированный поток может быть принудительно освобожден другим потоком. Это довольно мощный метод, который может использоваться для завершения потока.

Конструкция Назначение Работает между процессами? Загрузка (*)
lock (Monitor.Enter / Monitor.Exit) Гарантирует, что только один поток в любой момент времени может получить доступ к ресурсу или секции кода. 20 нс
Mutex ДА 1000 нс
SemaphoreSlim (добавлено в Framework 4.0) Гарантирует, что не более указанного количества потоков могут получить доступ к ресурсу или секции кода. 200 нс
Semaphore ДА 1000 нс
ReaderWriterLockSlim (добавлено в Framework 3.5) Позволяет нескольким читающим потокам существовать вместе с одним записывающим. 40 нс
ReaderWriterLock (сильно устарело) 100 нс

Примечание (*): время, которое тратится на блокировку и разблокировку конструкции на одном и том же потоке (подразумевая, что другие потоки не блокируются), как это было измерено на процессоре Intel Core i7 860.

Monitor.Enter и Monitor.Exit. Оператор lock на C# фактически является «синтаксическим сахаром», т. е. обертками над вызовами методов Monitor.Enter и Monitor.Exit с блоком try/finally. Это представляет программисту упрощенную версию того, что реально происходит внутри метода Go в предыдущем примере:

Вызов Monitor.Exit без предшествующего вызова Monitor.Enter на одном и том же объекте приведет к выбрасыванию исключения.

Перезагрузки lockTaken. Код, который мы только что продемонстрировали, на компиляторах C# версия 1.0, 2.0 и 3.0 будет транслироваться из оператора lock.

Однако в этом коде есть тонкая уязвимость. Рассмотрим (маловероятное) событие исключения, которое выбрасывается с реализацией Monitor.Enter между вызовом Monitor.Enter и блоком try (при этом возможно будет вызван Abort на этом потоке, либо выбрасывание исключение OutOfMemoryException). В таком сценарии блокировка может не произойти. Если блокировка произошла, то она не будет освобождена — потому что мы никогда не войдем в блок try/finally. Это приведет к пропущенной блокировке (leaked lock).

Для устранения этой опасности разработчики CLR 4.0 добавили следующую перезагрузку для Monitor.Enter:

Параметр lockTaken равен false после этого метода если (и только если) метод Enter выбросил исключение и блокировка lock не была взята.

Вот корректный шаблон использования (в который C# 4.0 будет транслировать оператор lock):

TryEnter. Также предоставляет метод TryEnter, который позволяет задать таймаут либо в миллисекундах, либо через TimeSpan. Этот метод вернет true, если блокировка была получена, или false, если блокировка не была получена из-за таймаута метода. TryEnter может быть также вызван без аргумента, что «проверяет» блокировку lock, таймаут произойдет немедленно, если блокировка не может быть получена надлежащим способом.

Как и метод Enter, метод TryEnter перезагружен в CLR 4.0, чтобы принять аргумент lockTaken.

Выбор объекта синхронизации. Любой объект, видимый каждому из участвующих в общей работе потоков, может использоваться в качестве синхронизирующего объекта согласно одному жесткому правилу: это должен быть ссылочный тип (reference type). Синхронизирующий объект обычно имеет область доступа private (потому что это помогает инкапсулировать логику блокировки) и обычно это поле экземпляра или статическое поле. Синхронизирующий объект может иметь двойное назначение, т. е. он может быть встроен в защищаемый объект, как это делает поле _list в следующем примере:

Поле, выделенное для этой цели (такое как _locker в предыдущем примере), позволяет точное управление областью действия и гранулярностью блокировки. Объект текущего содержимого (containing object, this) — или даже его тип — также может использоваться в качестве объекта синхронизации:

Мастер Йода рекомендует:  Как массивы и списки работают на Python

Недостаток такого способа блокировки в том, что Вы не инкапсулируете отдельно логику блокировки, и становится сложнее защититься от глухой блокировки (deadlocking, см. ниже) и излишней блокировки (excessive blocking). Блокировка на типе также может просочиться через границы домена приложения (в пределах одного и того же процесса).

Также Вы можете реализовать блокировку lock на локальных переменных, захваченных lambda-выражениями или anonymous-методами.

Блокировка не ограничивает каким-либо образом доступ к самому объекту. Другими словами, x.ToString() не будет блокироваться, потому что другой поток вызвал lock(x); оба потока должны вызвать lock(x), чтобы блокировка произошла.

Когда применять блокировку? Как основное правило, Вам нужна блокировка вокруг доступа к любой записываемой общей переменной (writable shared field). Даже в простейшем случае — операция присваивания одиночного поля — нужно учитывать синхронизацию. В следующем классе ни метод Increment, ни метод Assign не будут потокобезопасными:

Ниже показаны потокобезопасные версии для Increment и Assign:

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

Блокировка и атомарность. Если группа переменных всегда читается и записывается в пределах одной и той же блокировки lock, можно сказать, что эти переменные читаются и записываются атомарно. Предположим, что поля x и y всегда читаются и назначаются внутри блокировки на объекте locker:

Можно сказать, что к x и y осуществляется атомарный доступ, потому выполнение кода в пределах блокировки не может быть разделено или вытеснено действиями в другом потоке, в результате чего посторонние действия никак не могут отдельно повлиять на x или y так, что результат вычислений станет недостоверным. Вы никогда не получите ошибку деления на 0, так как доступ к x и y реализован в одной исключительной блокировке (exclusive lock).

Атомарность, предоставленная блокировкой lock, нарушается, если произойдет выбрасывание исключения внутри блока lock. Например:

Если исключение сработало в результате вызова GetBankFee(), то банк потерял бы деньги. В этом случае нам нужно избегать проблем путем вызова GetBankFee заранее. Решение подобной проблемы для более сложных случаев — реализация логики отката (rollback) с помощью блока catch или finally.

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

Вложенные блокировки. Поток можно повторно блокировать на одном и том же объекте вложенным (реентрантным) способом:

В этих сценариях объект разблокируется только когда произойдет выход из самого внешнего оператора lock, или будет выполнено соответствующее количество операторов Monitor.Exit.

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

Поток может быть заблокирован только на первом (самым внешним) операторе lock.

Глухие блокировки (deadlock). Глухая, или «мертвая» блокировка deadlock произойдет, когда два потока взаимно ждут освобождения ресурса, захваченного другим потоком, в результате ничего не происходит. Ниже приведена самая простая иллюстрация этой ситуации с двумя блокировками lock:

Программист может «наколбасить» и более сложные цепочки глухой блокировки с участием трех и большего количества потоков.

Библиотека CLR в стандартном окружении хоста не работает наподобие сервера SQL Server, не определяет автоматически глухие блокировки и не представляет автоматическое средство исправления таких блокировок путем останова одного из участников глухой блокировки. Глухая блокировка потоков заставляет их на неопределенное время прервать свое выполнение, если конечно Вы не предусмотрели таймаут блокировки. В итерации хоста SQL CLR, однако, deadlock-и автоматически определяются и выбрасывается (перехватываемое catch) исключение в одном из потоков, участвующих в глухой блокировке.

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

Например, Вы можете нечаянно заблокировать private-поле в своем классе x, не зная, что Ваш вызывающий код (или код, вызвавший вызывающего) уже заблокирован на поле b в классе y. Между тем другой поток делает обратное, создавая deadlock. Ирония тут в том, что проблема усиливается «хорошими» (изначально подразумеваемыми) шаблонами объектно-ориентированного стиля программирования, потому что принцип «скрывай детали кода внутри объектов» создает цепочки взаимосвязей, которые не очевидны для программиста, пока код не начнет выполняться в реальном времени.

Популярные советы избежать мертвых блокировок типа «блокируйте объекты в правильном порядке» тяжело применить на практике, хотя они могут помочь в простых случаях, примеры которых мы описывали. Лучшая стратегия — особенно внимательно применять блокировки вокруг вызова методов в объектах, которые могут ссылаться обратно на Ваш собственный объект. Тщательно взвесьте необходимость блокировки вокруг вызова методов в других классах, часто это делается, однако иногда — что мы рассмотрим позже — есть и другие опции реализации. Больше полагаясь на декларативность [13] и параллелизм обработки данных [14], не изменяемые типы (immutable types, см. далее) и не блокирующие конструкции синхронизации [12], можно снизить необходимость в блокировках.

Есть еще один способ почувствовать проблему: когда Вы вызываете другой код, содержащий блокировку, происходит скрытая инкапсуляция этой блокировки. Это не приведет к ошибке в библиотеке CLR или .NET Framework, но является фундаментальным ограничением блокировки в целом. Проблемам блокировки посвящены многие исследовательские проекты, включая Software Transactional Memory.

Другой сценарий мертвой блокировки возникнет, когда вызывается Dispatcher.Invoke (в приложении WPF) или Control.Invoke (в приложении Windows Forms) во время активной блокировки. Если случилось так, что UI запустил другой метод, который ждет на той же блокировке, то произойдет deadlock. Это часто можно исправить простым вызовом BeginInvoke вместо Invoke. Альтернативно Вы можете освободить свою блокировку перед вызовом Invoke, хотя это не будет работать, если вызывающий код запустил блокировку. Invoke и BeginInvoke будут рассматриваться далее в секции «Rich Client Applications и Thread Affinity».

Производительность. Блокировка работает быстро: Вы можете ожидать, что захват и освобождение критической секции lock займет меньше 20 наносекунд на поколении компьютеров 2010 года, если к этой секции блокировки не было конкурентного доступа. Если же был случай конкурентного доступа, то последующие затраты на переключение контекста введут трату процессорного времени примерно около микросекунды, хотя этот интервал может быть больше, если учесть время, за которое смена состояния потока будет реально обработана планировщиком. Вы можете избежать трат на переключение контекста с помощью класса SpinLock [3] — если блокировка происходит очень коротко.

Блокировка может снизить конкурентность, если блокировка удерживается слишком долго. Это также может повысить шансы возникновения deadlock.

Mutex. Мьютекс подобен C# lock, но он может работать между несколькими процессами. Другими словами, Mutex может действовать как в пределах компьютера, так и в пределах приложения.

Захват и освобождение Mutex в случае отсутствия конкурентных попыток доступа занимает несколько микросекунд — примерно в 50 раз медленнее, чем работает критическая секция lock.

С классом Mutex можно вызвать метод WaitOne для блокировки и ReleaseMutex для разблокировки. Закрытие или избавление от (disposing) Mutex автоматически освободит его. Так же, как и с оператором lock, Mutex может быть освобожден только в том потоке, который получил этот Mutex.

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

Если приложение запущено по управлением службы терминала (Terminal Services) от имени определенного пользователя, то видимый в пределах всего компьютера Mutex обычно виден только для приложений в пределах одной терминальной сессии (т. е. только в сессии этого пользователя терминала). Чтобы сделать мьютекс видимым для всех сессий терминала, снабдите его имя префиксом Global\.

Семафор. Это объект, который похож на ночной клуб: у него есть определенная емкость, принудительно отслеживаемая вышибалой на входе. Будучи заполненным, клуб больше не может принять посетителей, что создает очередь снаружи. Тогда для каждой уходящей персоны зайдет одна персона из головы очереди. Конструктор семафора требует минимум 2 аргументов: количество доступных в настоящий момент мест и общая емкость семафора.

Семафор с емкостью, равной 1, работает подобно Mutex или lock, за исключением того, что у семафора нет «владельца» — он не обращает внимания на потоки (thread-agnostic). Любой поток может вызвать Release на Semaphore, в то время как с Mutex или lock, только один поток может получить блокировку и освободить её.


Есть две подобных по функционалу версии этого класса: Semaphore и SemaphoreSlim. Последний был представлен в Framework 4.0, и он оптимизирован для удовлетворения повышенных требований на малые задержки в параллельном программировании [14]. Также он полезен в традиционной многопоточности, потому что позволяет указать билет отмены (cancellation token [15]) для ожидания. Однако это нельзя использовать для сигнализации между процессами.

Semaphore вводит трату времени процессора около 1 микросекунды в вызове WaitOne или Release; SemaphoreSlim тратит на это около четверти микросекунды.

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

Результат работы этого примера:

Если вместо оператора Sleep выполняется интенсивный дисковый ввод/вывод, то этот семафор улучшит общую производительность программы путем ограничения конкурентной активности с жестким диском.

Semaphore, если он именован, может работать между процессами точно так же, как и Mutex.

[Безопасность потоков (Thread Safety)]

Программа или метод считается потокобезопасным (thread-safe) если он не вводит никакой неопределенности в работе кода при наличии сценария многопоточности. Безопасность потоков достигается главным образом путем блокировки и уменьшения возможности по взаимодействию между потоками.

Типы общего назначения (general-purpose types) редко ориентированы на многопоточность по следующим причинам:

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

Следовательно, рационально потокобезопасность реализовать только в том месте, где она действительно должна быть, чтобы правильно обработать определенный сценарий многопоточности.

Однако здесь есть несколько способов «обмана», чтобы безопасно запустить большие и сложные классы в многопоточном окружении. Один из них — жертвовать гранулярностью путем обертки одной монопольной блокировкой больших секций кода — даже доступ ко всем объекту — принуждая применить последовательный доступ к объекту на верхнем уровне. Такая тактика фактически важна, если Вы хотите использовать не безопасный по отношению к многопоточности сторонний код (или большинство Framework-типов) в многопоточном контексте. Прием должен просто использовать одинаковую монопольную блокировку (exclusive lock), чтобы защитить доступ ко всем свойствам, методам и полям на не безопасном по отношению к потокам объекте. Это решение хорошо работает, если все методы объекта выполняются быстро (иначе будут иметь место большие блокировки).

Примитивные типы, к которым относятся некоторые типы .NET Framework, будучи инстанцированными, являются потокобезопасными только при доступе read-only. Ответственность за правильное их использование для потокобезопасности лежит на разработчике, обычно это верно для монопольных блокировок (исключение составляют сборки в библиотеке System.Collections.Concurrent).

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

Конечный этап реализации безопасности потоков состоит в использовании режима автоматической блокировки. Библиотека .NET Framework это точно делает, если вы разделяете ContextBoundObject на подклассы и применяете атрибут Synchronization для этого класса. Тогда каждый раз, когда вызывается метод или происходит обращение к свойству такого объекта, автоматически берется блокировка на весь объект, пока не выполнится полностью метод или не завершится доступ к свойству. Хотя это упрощает обеспечение безопасности потоков, возникают собственные проблемы: мертвые блокировки (deadlock), которые иначе не произошли бы, ухудшение параллелизма и непреднамеренная реентрантность. По этим причинам ручная блокировка обычно лучший выбор — по крайней мере пока не станет доступным упрощенный режим автоматической блокировки.

Безопасность потоков и типы .NET Framework. Блокировку можно использовать, чтобы превратить не безопасный по отношению к потокам код в потокобезопасный. Хорошее применение для этого библиотека .NET Framework: почти все её не примитивные типы, будучи инстанцированными, не являются потокобезопасными (для чего-то большего, чем доступ только на чтение). И все же они могут быть использованы в многопоточном коде, если любой доступ к любому имеющемуся объекту осуществляется через блокировку lock. Ниже приведен пример, где два потока одновременно добавляют элемент в одну и ту же коллекцию List, и затем делают перечисление списка в цикле:

В этом примере мы осуществляем блокировку на самом объекте _list. Если бы у нас было 2 взаимосвязанных списка, мы должны были бы выбрать общий объект, на котором можно было бы осуществить блокировку (мы могли бы выбрать для этого один из этих списков, но лучше использовать для блокировки отдельное, независимое поле).

Перечисление коллекций .NET также не является потокобезопасным в том смысле, что будет выброшено исключение, если список модифицируется во время процесса его перечисления. Вместо того, чтобы блокироваться на время до завершения перечисления, в этом примере мы просто сначала делаем копию элементов в массив. Это дает возможность избежать чрезмерной блокировки, если процесс перечисления списка может занять много времени (другое решение — использовать блокировку reader/writer [4]).

Блокировки вокруг потокобезопасных объектов. Иногда также нужно делать блокировку при доступе к потокобезопасным объектам. Для иллюстрации представим, что Framework-класс List был действительно потокобезопасным, и мы хотим добавить элемент в его список:

Независимо от того, является список потокобезопасным или нет, этот оператор определенно не является потокобезопасным! Для обеспечения потокобезопасности весь оператор if должен быть обернут в блокировку, чтобы предотвратить вытеснение в промежутке между проверкой и добавлением нового элемента. Та же самая блокировка затем нужна в любом месте, где мы модифицируем этот список. Например, следующий оператор также нуждается в обертке идентичной блокировкой (чтобы гарантировать, что процесс модификации не вытеснит предыдущий оператор):

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

Блокировка вокруг доступа к коллекции может привести к чрезмерной блокировке в среде высокой конкуренции потоков. Для этого Framework 4.0 предоставляет потокобезопасные очередь (queue), стек (stack) и словарь (dictionary).

Статические члены класса. Обертывание доступа к объекту вокруг пользовательской блокировки работает только если все конкурентные потоки учитывают и используют блокировки. Это может не иметь место, если объект широко доступен. Самый худший случай — статический (объявленный с ключевым словом static) член класса со снятым ограничением на доступ (объявлен с типом доступа public). Для примера представим: если статическое свойство DateTime.Now структуры DateTime, было бы не потокобезопасным, то два конкурентных доступа к нему дадут ошибочный результат или выбрасывание исключения. Единственный способ бороться с этим через внешнюю блокировку мог бы состоять в том, чтобы заблокировать доступ к самому типу — lock(typeof(DateTime)) — перед вызовом DateTime.Now. Это работало бы, только если все программисты согласились бы поступать подобным образом (что маловероятно). Кроме того, блокировка типа создает собственные проблемы.

По этой причине статические члены структуры DateTime (структура и класс на C# это по сути одно и то же) должны быть тщательно реализованы для обеспечения потокобезопасности. Ото общий шаблон поведения кода библиотеки .NET Framework: static-члены потокобезопасны; члены экземпляров (instance members) не потокобезопасны. Следование этому шаблону также целесообразно при написании типов для публичного использования, чтобы не создавать невозможные проблемы с безопасностью потоков. Другими словами, путем реализации статических методов потокозащищенными Вы программируете код типа, чтобы не исключать безопасность потоков для пользователей этого типа.

Безопасность для использования в потоках статических методов это то, что Вы должны кодировать специально: оно не произойдет само собой, на основании объявления метода статическим!

Безопасность для потоков при доступе только на чтение. Реализация типов потокобезопасными для конкурентного доступа только на чтение (где это возможно) выгодна, поскольку это означает, что пользователи могут избежать чрезмерной блокировки. Многие типы из библиотеки .NET Framework следуют этому принципу: коллекции (collections), например, потокобезопасны для конкурентного доступа потоков на чтение.

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

Безопасность потоков для только чтения одна из причин, по которой перечислители (enumerators) отделены от перечислений (enumerables): два потока могут одновременно перечислять элементы коллекции, потому что каждый получает отдельный объект перечислителя.

При отсутствии документации приходится платить предположением, что по своей природе любой метод работает только на чтение. Хороший пример это класс Random: когда Вы вызываете Random.Next(), его внутренняя реализация требует, чтобы это действие обновило внутреннее приватное значение seed. Таким образом, Вы должны либо делать блокировку вокруг использования класса Random, или поддерживать раздельные экземпляры этого класса для каждого потока.

Безопасность потоков в серверах приложений (Application Servers). Серверы приложений требуют многопоточности для обработки одновременных запросов от клиентов. Приложения WCF, ASP.NET и Web Services неявно используют многопоточность; то же самое верно для приложений сервера Remoting, которые используют сетевой канал, такой как TCP или HTTP. Это означает, что когда Вы пишете код на стороне сервера, то должны учитывать безопасность потоков, если есть возможность взаимодействия среди потоков при обработке запросов клиентов. К счастью, такая возможность редка; типичный класс сервера либо не сохраняет состояния (stateless, не имеет полей), либо содержит модель активации, которая создает отдельный экземпляр объекта на каждый потупивший запрос от клиента. Взаимодействие обычно возникает только через статические поля, иногда используемые для кэширования в памяти частей базы данных в целях улучшения производительности.

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

Если этот метод вызывался часто, то Вам следует улучшить производительность путем кэширования результатов в статическом словаре (static Dictionary). Вот решение, учитывающее безопасность потоков:

Мы должны, как минимум, делать блокировку вокруг чтения и обновления словаря для гарантии безопасности потоков. В этом примере мы выбрали практический компромисс в блокировке между простотой и производительностью. Наш дизайн потенциально создает очень малую потенциальную не эффективность: если 2 потока одновременно вызовут этот метод с одним и тем же ранее не запрашиваемым id, то метод RetrieveUser был бы вызван дважды — и словарь получил бы ненужное обновление. Блокировка вокруг всего метода предотвратила бы это, но создала бы еще меньшую эффективность: весь кэш был бы заблокирован на время вызова RetrieveUser, в течение этого времени другие потоки блокировались бы при запросе любого пользователя.

Rich Client Applications и Thread Affinity. Обе библиотеки Windows Presentation Foundation (WPF) и Windows Forms следуют моделям на основе родственности потоков (thread affinity). Хотя каждая из библиотек имеет отдельную реализацию, обе очень похожи по своим функциям.

Объекты, которые создают rich client, основаны главным образом на DependencyObject в случае применения WPF, или на Control в случае Windows Forms. Эти объекты имеют родственность потоков. Это означает, что только поток, который который объект инстанцирует, может впоследствии получить доступ к его членам. Нарушение правила приведет либо к не предсказуемому поведению, либо к выбросу исключения.

Положительно то, что Вам не нужно делать блокировку вокруг доступа к объекту UI. Недостаток же в том, что если нужно вызвать член объекта X, который был создан другим потоком Y, то Вы должны перенаправить (marshal) запрос на вызов объекту Y. Вы явно можете делать это следующим образом:

• В WPF вызовите Invoke или BeginInvoke на элементе объекта Dispatcher.
• В Windows Forms вызовите Invoke или BeginInvoke на элементе управления (control).

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

Предположим, что у нас есть окно, которое содержит текстовое поле ввода (text box) с именем txtMessage, содержимое которого мы хотим обновлять рабочим потоком. Вот пример для WPF:

Подобный код используется для Windows Forms, за исключением что мы вместо Dispatcher.Invoke вызовем метод Invoke (принадлежащий классу формы Form):

Framework предоставляет две конструкции для упрощения этого процесса:

• BackgroundWorker [16]
• Продолжения для Task [17]

Рабочие потоки против потоков UI. Полезно думать о приложениях rich client, что они имеют две категории потоков: потоки графического интерфейса пользователя (UI threads) и рабочие потоки (worker threads). Потоки UI инстанцируют (и впоследствии «владеют») элементами графического интерфейса UI; рабочие потоки не инстанцируют и не владеют элементами UI. Worker-потоки обычно выполняют длинные вычисления, такие как выборка/получение данных (если бы эти вычисления были короткими, то надобности в рабочих потоках не было бы).

Большинство приложений rich client имеют один поток UI (который также является главным потоком приложения) и этот поток периодически порождает рабочие потоки — либо напрямую, либо с использованием класса BackgroundWorker [16]. Эти рабочие потоки маршалируют свои обращения обратно в главный поток UI, чтобы обновлять состояние органов управления или чтобы сообщать о прогрессе выполнения операции.

Итак, когда у приложения может быть несколько потоков UI? Основной такой сценарий возникает, когда у Вас приложение с несколькими окнами верхнего уровня, что часто называют приложением Single Document Interface (SDI); пример такого приложения Microsoft Word. Каждое окно SDI обычно показывает само себя как отдельное «приложение» на панели задач, и часто работает функционально изолированно от других окон SDI. Путем назначения каждому такому окну своего собственного потока UI, приложение может более отзывчивым.

Immutable-объекты. Не мутируемый (immutable) объект это такой объект, который нельзя изменить — снаружи или внутри. Поля immutable-объекта обычно декларируются как read-only, и они полностью инициализируются в момент конструирования объекта.

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

Один из шаблонов использования немутируемых объектов — инкапсуляция группы связанных полей для минимизации задержек блокировок. Чтобы привести простой пример, предположим, что у нас есть два поля, и мы хотим читать/записывать эти поля атомарно:

Вместо того, чтобы реализовать блокировку вокруг этих полей, мы могли бы определить следующий immutable-класс:

Тогда мы могли бы определить одно поле такого типа на объекте блокировки:

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

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

Технически последние 2 строки кода потокобезопасны благодаря предшествующей блокировке, выполняющей неявный барьер памяти (см. [7] в 4 части этой документации).

Обратите внимание, что это свободный от блокировки способ обеспечить целостность группы связанных полей. Но это не предотвратит данные от изменения, когда Вы впоследствии работаете с ними — для этого обычно нужна блокировка. В 5 части этой документации мы рассмотрим больше примеров немутируемости для упрощения многопоточности, включая PLINQ [13].

Также можно безопасно назначить новый объект ProgressStatus на основе его предыдущего значения (например, можно «инкрементировать» значение PercentComplete) — без блокировки больше одной строки кода. Фактически мы можем делать это без использования одиночной блокировки через использования явные барьеров памяти Interlocked.CompareExchange и ожидание в цикле (spin-wait). Это продвинутая техника, которую мы опишем позже в секции, посвященной параллельному программированию [3].

[Сигнализация с обработкой ожидания события]

Обработка ожидания события (event wait handle) используется для сигнализации. Это способ обмена состоянием, когда один поток ждет поступления оповещения от другого. Event wait handle это самый простой вариант конструкций сигнализации, и он не связан с событиями C#. Event wait handle доступны через три функции: AutoResetEvent, ManualResetEvent и (из Framework 4.0) CountdownEvent. Первые два основаны на общем классе EventWaitHandle откуда они наследуют весь свой функционал.

Конструкция Назначение Работает между процессами? Загрузка (*)
AutoResetEvent Позволяет потоку однократно разблокироваться, когда будет получен сигнал от другого потока. ДА 1000 нс
ManualResetEvent Позволяет потоку разблокироваться навсегда, когда он получил сигнал от другого потока (до сброса). ДА 1000 нс
ManualResetEventSlim (добавлено в Framework 4.0) 40 нс
CountdownEvent (введено в Framework 4.0) Позволяет потоку разблокироваться, когда он получил заранее определенное количество сигналов. 40 нс
Barrier (добавлено в Framework 4.0) Реализует барьер выполнения потока. 80 нс
Wait и Pulse Позволяет потоку блокироваться до момента удовлетворения пользовательского условия. 120 нс для Pulse

Примечание (*): время, которое тратится на сигнал и ожидание в конструкции на одном и том же потоке (подразумевая, что другие потоки не блокируются), как это было измерено на процессоре Intel Core i7 860.

AutoResetEvent. AutoResetEvent похож на билетный турникет: установка в него билета позволяет пройти через него одному человеку. Префикс auto в имени класса отражает факт, что открытие турникета автоматически закрывается, или сбрасывается (reset) после выполнения через него нескольких шагов. Поток ожидает на турникете, или блокируется, путем вызова WaitOne (ждет этого «one» турникета, пока он открывается), и билет вставляется путем вызова метода Set. Если несколько потоков вызовут WaitOne, то позади турникета растет очередь (как и в случае с блокировками, справедливость первого доступа по отношению к моменту постановки в очередь может иногда нарушаться из-за нюансов реализации операционной системы). Билет может поступить от одного потока; другими словами, любой (не заблокированный) поток с доступом к объекту AutoResetEvent может установить Set на нем для освобождения одного заблокированного потока.

Вы можете создать AutoResetEvent двумя способами. Первый использует его конструктор:

Замечание: передача true в этот конструктор эквивалентно немедленному вызову Set на объекте. Второй способ создает AutoResetEvent следующим образом:

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

Пример выведет следующее:

Если Set был вызван, когда нет ни одного потока ожидающего потока, то дескриптор ожидания остается открытым, пока какой-нибудь поток не вызовет WaitOne. Это поведение помогает избежать гонок между потоками в голове очереди турникета при определении потока, вставляющий билет («Упс, билет был вставлен слишком быстро, неудача, теперь нужно ждать неопределенно долго!»). Однако повторяющиеся вызовы Set на турникете, на котором нет ожидания, не даст возможности пройти всей компании потоков: пройдет только один, и все предыдущие «билеты» будут потрачены впустую.

Мастер Йода рекомендует:  Использование регулярных выражений в Python для новичков

Вызов Reset на AutoResetEvent закрывает турникет (если он открыт) без ожидания или блокировки.

WaitOne принимает не обязательный параметр timeout, и он вернет false если ожидание окончилось по причине истечения таймаута и сигнал не за это время не был получен.

Как только Вы закончили работу с дескриптором ожидания, можете вызвать его метод Close, чтобы освободить ресурсы операционной системы. Альтернативно Вы можете просто бросить все ссылки на дескриптор ожидания и позволить сборщику мусора когда-нибудь позже выполнить работу по утилизации дырок (мусора) в памяти (дескрипторы ожидания реализуют шаблон расформирования, который вызывается через финализирующий метод Close). Это один из нескольких сценариев, где возможно приемлемо полагаться на такое поведение, потому что дескрипторы ожидание слабо загружают операционную систему (асинхронные делегаты [2] полагаются на тот же самый механизм для реализации своего дескриптора ожидания IAsyncResult).


Дескрипторы ожидания освобождаются автоматически, когда выгружается из памяти домен приложения.

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

Решение для главного потока в том, чтобы главный поток ждал, пока рабочий поток не будет готов к приему нового сигнала. Это можно реализовать с другим AutoResetEvent следующим образом:

Вывод этого примера:

В примере использовалось сообщение null, чтобы дать указание рабочему потоку завершиться. С потоками, которые работают в бесконечном цикле, важно предусмотреть стратегию завершения!

Очередь генератор/потребитель. Очередь producer/consumer является общим требованием обмена данными между потоками. Вот как этот работает:

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

Достоинство этой модели в том, что присутствует прецизионное управление, сколько рабочих потоков может быть запущено одновременно. Это позволяет Вам ограничить не только траты процессорного времени, но и других ресурсов. Например, если задачи выполняют интенсивный дисковый ввод/вывод, у Вас может быть только один рабочий поток, чтобы избежать исчерпание ресурсов операционной системы для других приложений. Другой тип приложения может иметь 20 рабочих потоков. Вы также можете добавлять или удалять рабочие потоки во время во время жизни очереди. Пул потоков CLR сам по себе является разновидностью очереди producer/consumer.

Очередь producer/consumer обычно содержит элементы, над которыми выполняется (одинаковая) обработка. Например, элементами данных могут быть имена файлов, и обработка может шифровать эти файлы.

В примере ниже мы используем один AutoResetEvent для подачи сигнала рабочему потоку, когда он ожидает на пустой очереди (другими словами, ожидание происходит, когда вся работа выполнена). Мы завершим рабочий поток постановкой в очередь null-задачи:

Чтобы гарантировать потокобезопасность, здесь мы используем блокировку для защиты доступа к коллекции Queue . Мы также явно закрываем дескриптор ожидания его методом Dispose, поскольку мы могли потенциально создать и уничтожить множество экземпляров этого класса во время жизни приложения.

Вот метод главного потока, которым тестируется очередь:

Вывод этого примера:

Framework 4.0 предоставляет новый класс BlockingCollection [18], который реализует функционал очереди producer/consumer. Наша написанная вручную очередь producer/consumer все еще актуальна — она не только показывает AutoResetEvent и безопасность потоков, но также является базой для более сложных структур. Например, если Вы хотите получить ограниченную очередь блокировки (с ограничением на количество поставленных в очередь задач), и также хотите поддерживать отмену (и удаление) поставленных в очередь элементов, этот код предоставит отличную начальную точку для программирования. Пример очереди producer/consume будет впоследствии использоваться при обсуждении Wait и Pulse [19].

ManualResetEvent. ManualResetEvent функционирует как обычные ворота. Вызов Set открывает ворота, позволяя любому количеству потоков вызвать WaitOne чтобы пройти через них. Вызов Reset закрывает ворота. Потоки, которые вызвали WaitOne на закрытых воротах, будут заблокированы; когда ворота откроются в следующий раз, они все запустятся одновременно. Кроме этих отличий, ManualResetEvent работает наподобие AutoResetEvent.

С AutoResetEvent Вы можете сконструировать ManualResetEvent двумя способами:

Framework 4.0 предоставил другую версию ManualResetEvent, которая называется ManualResetEventSlim. Она оптимизирована для коротких времен ожидания — с возможностью как опции работать с циклом ожидания на установленное количество итераций. Также это более эффективная управляемая (managed) реализация, позволяющая остановить Wait через CancellationToken [15]. Однако это нельзя использовать для сигнализации между процессами. ManualResetEventSlim не подкласс WaitHandle; однако он предоставляет свойство WaitHandle, которое возвратит основанный на WaitHandle объект, который будет вызван (с профилем производительности традиционного дескриптора ожидания).

Ожидание сигнализации AutoResetEvent или ManualResetEvent занимает около 1 микросекунды (предполагая, что нет блокирования).

ManualResetEventSlim и CountdownEvent (см. ниже) могут быть в 50 раз быстрее для сценариев короткого ожидания, из-за того, что они не полагаются на операционную систему и разумно используют циклы прокрутки.

Однако в большинстве сценариев потери от классов сигнализации сами по себе не создают узкое место. Исключение составляет код с жестким одновременным выполнением, что будет обсуждаться в части 5 (см. [14]) этой документации.

ManualResetEvent полезен для разрешения в одном потоке, чтобы разблокировать множество других потоков. Обратный сценарий обрабатывается CountdownEvent.

CountdownEvent. Позволяет Вам ждать больше одного потока. Этот класс был введен в Framework 4.0, и для него разработана эффективная, полностью управляемая (managed) реализация.

Если Ваша программа работает на предыдущей версии .NET Framework, то не все еще потеряно! Позже мы покажем, как написать аналог CountdownEvent с использованием Wait и Pulse [20].

Для использования CountdownEvent инстанцируйте этот класс с количеством потоков (или счетчиком, count), которые Вы хотите ждать:

Вызов Signal декрементирует count; вызов Wait блокирует поток, пока count не дойдет до 0. Пример:

Проблемы, для которых предназначен CountdownEvent, можно иногда решить проще путем использования конструкций структурированного параллелизма, которые рассматриваются в части 5 [14] (PLINQ и класс Parallel).

Вы можете добавлять увеличение значения для счетчика CountdownEvent вызовом AddCount. Однако если счетчик уже достиг нуля, AddCount выбросит исключение: Вы не можете «отменить» сигнал события CountdownEvent путем вызова AddCount. Чтобы избежать возможности срабатывания исключения, используйте вместо этого другой метод TryAddCount, который вернет false, если счет достиг до 0.

Для отмены сигнала события обратного счета вызовите Reset: это одновременно отменит сигнал конструкции и сбросит счетчик к своему оригинальному значению.

Наподобие ManualResetEventSlim, CountdownEvent публикует свойство WaitHandle для сценариев, где некоторый другой класс или метод ожидает объект, основываясь на WaitHandle.

Создание EventWaitHandle для взаимодействия между процессами. Конструктор EventWaitHandle позволяет создавать «именованный» EventWaitHandle, который может работать между несколькими процессами (чем процесс отличается от потока, см. [2]). Имя это просто строка, и у неё может быть любое значение, которое не содержит нежелательного конфликта с каким-то другим именем! Если это имя уже используется на компьютере, где работают процессы, то Вы получите ссылку на тот же самый нижележащий EventWaitHandle; иначе операционная система создаст новый. Пример:

Если каждое из двух приложений запустят этот код, то они могут обмениваться сигналами друг с другом: дескриптор ожидания (wait handle) может работать на всех потоках обоих процессов.

Дескрипторы ожидания и Thread Pool. Если в Вашем приложении есть несколько потоков, которые тратят большинство своего времени на дескрипторе ожидания (wait handle), то можно уменьшить трату ресурсов вызовом ThreadPool.RegisterWaitForSingleObject. Этот метод принимает делегата, который выполняется, когда прошел сигнал дескриптора ожидания. Пока идет ожидание, это не связывает поток:

Пример выведет следующее:

Когда прошел сигнал на дескриптор ожидания (или когда истек таймаут), делегат запустится на потоке пула.

В дополнение к дескриптору ожидания и делегату RegisterWaitForSingleObject принимает объект «черного ящика», который передается Вашему методу делегата (наподобие ParameterizedThreadStart), а также таймаут в миллисекундах (–1 означает ожидание без таймаута) и bool-флаг, показывающий, однократный ли это запрос или повторяющийся.

RegisterWaitForSingleObject в частности важен в сервере приложения, который должен обрабатывать множество одновременных запросов. Предположим, что Вам нужно блокировать выполнение на ManualResetEvent и просто ждать WaitOne:

Если 100 клиентов вызовут этот метод, то 100 потоков сервера были бы заняты на время блокирования. Замена _wh.WaitOne на RegisterWaitForSingleObject позволяет методу выполнить немедленный возврат, не теряя потоки:

Объект данных, переданный в Resume, позволяет продолжить обработку любых текущих данных.

WaitAny, WaitAll и SignalAndWait. В дополнение к методам Set, WaitOne и Reset это статические методы класса WaitHandle, решающие более сложные задачи синхронизации. Методы WaitAny, WaitAll и SignalAndWait выполняют операции сигнализации и ожидания на нескольких дескрипторах. Дескрипторы ожидания могут быть разных типов (включая Mutex и Semphore, поскольку они также выводятся из абстрактного класса WaitHandle). ManualResetEventSlim и Countdown также могут принять участие в этих методах через их свойства WaitHandle.

WaitAll и SignalAndWait имеют странное соединение с устаревшей (legacy) архитектурой COM: эти методы требуют, чтобы вызывающая сторона была в многопоточном окружении — модель, меньше всего подходящая для функциональной совместимости. Главный поток приложения WPF или Windows Forms, например, в этом режиме не может взаимодействовать с буфером обмена (clipboard). Мы кратко рассмотрим альтернативы ниже.

WaitHandle.WaitAny ждет любого из массива дескрипторов ожидания; WaitHandle.WaitAll атомарно ждет всех имеющихся дескрипторов. Это означает следующее, если Вы ждете на двух AutoResetEvents:

• WaitAny никогда не закончится «защелкиванием» обоих событий.
• WaitAll никогда не закончится «защелкиванием» только одного события.

SignalAndWait вызовет Set на одном WaitHandle, и затем вызовет WaitOne на другом WaitHandle. После сигнализации первого дескриптора произойдет переход на начало очереди в ожидании второго дескриптора; это помогает ему успешно выполниться (хотя операция не будет реально атомарной). Вы можете думать про этот метод как «замену» одного сигнала на другой, и использование его на паре EventWaitHandles, чтобы установить два потока на «встречу» в одном моменте времени. Этот трюк выполнит AutoResetEvent или ManualResetEvent. Первый поток выполнит следующее:

В то же время другой поток выполнит противоположное:

Альтернативы WaitAll и SignalAndWait. WaitAll и SignalAndWait не будут работать в одном потоке. К счастью, есть альтернативы. В случае SignalAndWait редко когда надо использовать иго семантику перехода по очереди: в нашем примере «встречи» было бы допустимо просто вызывать Set на первом дескрипторе ожидания, и затем вызвать WaitOne на другом, если бы дескрипторы ожидания использовались только для встречи. В классе Barrier [6] мы рассмотрим другой вариант для реализации встречи потоков.

В случае WaitAll при некоторых ситуация альтернативой будет использование метода Invoke класса Parallel, который будет рассматриваться в части 5 [14]. Также мы рассмотрим продолжение задачи и продолжения (Tasks и continuations), и посмотрим, как TaskFactory из ContinueWhenAny предоставляет альтернативу для WaitAny.

Во всех других сценариях ответом будет низкоуровневый подход, который решает все проблемы сигнализации: Wait и Pulse [5].

[Контексты синхронизации]

Альтернативой для ручной блокировки является блокировка декларативная. Путем наследования из ContextBoundObject и применения атрибута Synchronization Вы инструктируете библиотеку CLR применить блокировку автоматически. Пример:

Результат работы этого примера:

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

Как это работает? Подсказка находится в атрибуте пространства имен Synchronization: System.Runtime.Remoting.Contexts. ContextBoundObject можно рассматривать как «remote» (дальний) объект, что означает перехват всех его вызовов. Чтобы этот перехват был возможен, когда мы инстанцируем AutoLock, CLR в действительности вернет прокси — объект с теми же методами и свойствами, что и объект AutoLock, который работает как промежуточный. Именно через это и происходит автоматическая блокировка. В целом перехват занимает около микросекунды для каждого вызова метода.

Автоматическая синхронизация не может использоваться ни для защиты статических членов типа, ни для классов, которые не были унаследованы от ContextBoundObject (например, Windows Form).

Блокировка применяется внутри точно так же. Вы можете ожидать, что следующий пример выведет тот же самый результат, как и предыдущий пример:

Обратите внимание, что здесь вставлен оператор Console.ReadLine. Поскольку только один поток может выполнить код в любой момент времени в объекте этого класса,, то три новых потока останутся заблокированными в методе Demo, пока метод Test не завершится, это потребовало для завершения ReadLine. В результате мы получили тот же результат, что и ранее, но только после нажатия на клавишу Enter. Это хороший метод потокобезопасности, подходящий для устранения проблем любой полезной многопоточности в классе.

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

Контекст синхронизации может быть расширен вне области действия одного объекта. По умолчанию, если синхронизируемый объект инстанцирован из кода другого объекта, то оба они используют один и тот же контекст (другими словами, одну большую блокировку!). Это поведение можно поменять указанием целочисленного флага в конструкторе атрибута Synchronization, используя константы, определенные в классе SynchronizationAttribute:

Константа Назначение
NOT_SUPPORTED Эквивалентно не использованию атрибута Synchronized.
SUPPORTED Подсоединяется к существующему контексту синхронизации, если инстанцирован из другого объекта синхронизации, иначе остается не синхронизированным.
REQUIRED (по умолчанию) Подсоединяется к существующему контексту синхронизации, если инстанцирован из другого объекта синхронизации, иначе создает новый контекст.
REQUIRES_NEW Всегда создает новый контекст синхронизации.

Таким образом, если объект класса SynchronizedA инстанцирует объект класса SynchronizedB, они получат отдельные контексты синхронизации, если SynchronizedB был декларирован так:

Чем больше область действия контекста синхронизации, тем проще им управлять, но тем менее это полезно для параллелизма. Кроме того, отдельные контексты синхронизации вводят deadlock-и. Пример:

Поскольку каждый экземпляр Deadlock создан внутри Test — не синхронизированном классе — каждый экземпляр получает свой собственный контекст синхронизации, и таким образом собственную блокировку. Когда два объекта вызывают друг друга, у deadlock займет много времени (если быть точным, то 1 секунду). Проблема была бы особенно коварной, если бы классы Deadlock и Test были бы написаны разными командами программистов. Это в отличие от явных блокировок, где deadlock-и обычно более очевидны.

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

У реентрантности есть однако более зловещий оттенок в автоматических режимах блокировки. Если атрибут Synchronization примененный с аргументом реентрантности, равен true:

то блокировка контекста синхронизации будет временно освобождена, когда выполнение покинет этот контекст. В предыдущем примере это препятствовало бы возникновению deadlock, что очевидно было бы желательно. Однако побочный эффект состоит в том, что во время этого промежуточного периода любой поток может вызвать любой метод оригинального объекта (повторно войти в контекст синхронизации, «re-entering»), в результате произойдут все сложности многопоточного выполнения, которых стараются избежать в первую очередь. Это проблема реентрантности.

Из-за того, что [Synchronization(true)] применяется на уровне класса, этот атрибут превращает любой метод, выходящий из контекста, в троянского коня для реентрантности.

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

Это высвечивает фундаментальную уязвимость автоматической синхронизации: расширенная область, к которой применена блокировка, может на самом деле вводить трудности, которые иначе могли и не возникать. К этим сложностям относятся deadlock, реентрантность и урезанный параллелизм выполнения, тогда ручная блокировка может быть более приемлемой не только для простых сценариев.

C++ потоки в WIN32


Потоки всегда создаются в контексте какого-либо процесса, и вся их жизнь проходит только в его границах. На практике это означает, что потоки исполняют код и манипулируют данными в адресном пространстве процесса. Если два или более потока выполняются внутри одного процесса, они делят одно адресное пространство.
Любой поток (thread) состоит из двух компонентов:

  • объекта ядра, через который ОС управляет потоком. Там же хранится статистическая информация о потоке.
  • Стека потока, который содержит параметры всех функций и локальные переменные, необходимые потоку для выполнения кода.

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

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

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

    Для создания вторичного потока необходимо создать и для него входную функцию, которая выглядит примерно так: Код

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

    Когда поток закончит свое исполнение, он вернет управление системе, память, отведенная под его стек, будет освобождена, а счетчик пользователей его объекта ядра «поток» уменьшится на 1. Когда счетчик обнулится, этот объект ядра будет разрушен.

    Для создания своего потока необходимо использовать функцию CreateThread: Код

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

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

    CreateThread — это Windows-функция, создающая поток. Если вы пишете код на С/С++ не вызывайте ее. Вместо нее Вы должны использовать _beginthreadex из библиотеки Visual C++. Почему это так важно в наших следующих выпусках.

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

    LpStartAddress — указатель на адрес входной функции потока.

    LpParameter — параметр, который будет передан внутрь функции потока.

    DwCreationFlags — принимает одно из двух значений: 0 — исполнение начинается немедленно, или CREATE_SUSPENDED — исполнение приостанавливается до последующих указаний.

    LpThreadId — Адрес переменной типа DWORD в который функция возвращает идентификатор, приписанный системой новому потоку.

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

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

  • любые С++ объекты, созданные данным потоком, уничтожаются соответствующими деструкторами;
  • система корректно освобождает память, которую занимал стек потока;
  • система устанавливает код завершения данного потока. Его функция и возвращает;
  • счетчик пользователей данного объекта ядра (поток) уменьшается на 1.

    При желании немедленно завершить поток изнутри используют функцию ExitThread(DWORD dwExitCode).

    При этом освобождаются все ресурсы ОС, выделенные данному потоку, но С С++ ресурсы (например, объекты классов С++) не очищаются. Именно поэтому не рекомендовано завершать поток, используя эту функцию.

    Если же вы ее использовали, то кодом возврата потока будет тот параметр, который вы передадите в данную функцию.

    Как и для CreateThread для библиотеки Visual C++ существует ее аналог _endthreadex, который и стоит использовать. Об причинах в следующем выпуске.

    Если появилась необходимость уничтожить поток снаружи, то это моет сделать функция TeminateThread.

    Эта функция уменьшит счетчик пользователей объекта ядра (поток) на 1, однако при этом не разрушит и не очистит стек потока. Стек будет существовать, пока не завершится процесс, которому принадлежит поток. При задачах, постоянно создающих и уничтожающих потоки, это приводит к потере памяти внутри процесса.

    При завершении процесса происходит следующее.

    Завершение потока происходит принудительно. Деструкторы объектов не вызываются, и т.д. и т.д.

    При завершении потока по такой причине, связанный с ним объект ядра (поток) не освобождается до тех пор, пока не будут закрыты все внешние ссылки на этот объект.

    Ссылки по теме

    Популярные статьи
    Информационная безопасность Microsoft Офисное ПО Антивирусное ПО и защита от спама Eset Software


    Бестселлеры
    Курсы обучения «Atlassian JIRA — система управления проектами и задачами на предприятии»
    Microsoft Office 365 для Дома 32-bit/x64. 5 ПК/Mac + 5 Планшетов + 5 Телефонов. Подписка на 1 год. Электронный ключ
    Microsoft Windows 10 Профессиональная 32-bit/64-bit. Все языки. Электронный ключ
    Microsoft Office для Дома и Учебы 2020. Все языки. Электронный ключ
    Курс «Oracle. Программирование на SQL и PL/SQL»
    Курс «Основы TOGAF® 9»
    Microsoft Windows Professional 10 Sngl OLP 1 License No Level Legalization GetGenuine wCOA (FQC-09481)
    Microsoft Office 365 Персональный 32-bit/x64. 1 ПК/MAC + 1 Планшет + 1 Телефон. Все языки. Подписка на 1 год. Электронный ключ
    Windows Server 2020 Standard
    Курс «Нотация BPMN 2.0. Ее использование для моделирования бизнес-процессов и их регламентации»
    Антивирус ESET NOD32 Antivirus Business Edition
    Corel CorelDRAW Home & Student Suite X8

    О нас
    Интернет-магазин ITShop.ru предлагает широкий спектр услуг информационных технологий и ПО.

    На протяжении многих лет интернет-магазин предлагает товары и услуги, ориентированные на бизнес-пользователей и специалистов по информационным технологиям.

    Хорошие отзывы постоянных клиентов и высокий уровень специалистов позволяет получить наивысший результат при совместной работе.

    C++ — Потоки в Win32

    Любой поток (thread) состоит из двух компонентов:
    — объекта ядра, через который ОС управляет потоком. Там же хранится статистическая информация о потоке.
    — Стека потока, который содержит параметры всех функций и локальные переменные, необходимые потоку для выполнения кода.
    Потоки всегда создаются в контексте какого-либо процесса, и вся их жизнь проходит только в его границах. На практике это означает, что потоки исполняют код и манипулируют данными в адресном пространстве процесса. Если два или более потока выполняются внутри одного процесса, они делят одно адресное пространство.

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

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

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

    Создание потока.

    Первичный поток, который присутствует в программе, начинает свое выполнение с главной функции потока типа WinMain.

    Для создания вторичного потока необходимо создать и для него входную функцию, которая выглядит примерно так:

    DWORD WINAPI ThreadFunc(PVOID pParam)
    <
    DWORD dwResult = 0;
    .
    return dwResult;
    >

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

    Когда поток закончит свое исполнение, он вернет управление системе, память, отведенная под его стек, будет освобождена, а счетчик пользователей его объекта ядра «поток» уменьшится на 1. Когда счетчик обнулится, этот объект ядра будет разрушен.

    Для создания своего потока необходимо использовать функцию CreateThread:

    HANDLE CreateThread(
    LPSECURITY_ATTRIBUTES lpThreadAttributes,
    DWORD dwStackSize,
    LPTHREAD_START_ROUTINE lpStartAddress,
    LPVOID lpParameter,
    DWORD dwCreationFlags,
    LPDWORD lpThreadId);

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

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

    CreateThread — это Windows-функция, создающая поток. Если вы пишете код на С/С++ не вызывайте ее. Вместо нее Вы должны использовать _beginthreadex из библиотеки Visual C++. Почему это так важно в наших следующих выпусках.

    Параметры функции CreateThread.

    LpThreadAttributes — является указателем на структуру LPSECURITY_ATTRIBUTES. Для присвоения атрибутов защиты по умолчанию, передавайте в этом параметре NULL.

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

    LpStartAddress — указатель на адрес входной функции потока.
    LpParameter — параметр, который будет передан внутрь функции потока.

    DwCreationFlags — принимает одно из двух значений: 0 — исполнение начинается немедленно, или CREATE_SUSPENDED — исполнение приостанавливается до последующих указаний.

    LpThreadId — Адрес переменной типа DWORD в который функция возвращает идентификатор, приписанный системой новому потоку.

    Поток можно завершит четырьмя способами:

    — функция потока возвращает управление (рекомендуемо);
    — поток самоуничтожается вызовом функции ExitThread;
    — другой поток процесса вызывает функцию TerminateThread;
    — завершается процесс, содержащий данный поток.

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

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

    — любые С++ объекты, созданные данным потоком, уничтожаются соответствующими деструкторами;
    — система корректно освобождает память, которую занимал стек потока;
    — система устанавливает код завершения данного потока. Его функция и возвращает;
    — счетчик пользователей данного объекта ядра (поток) уменьшается на 1.

    При желании немедленно завершить поток изнутри используют функцию ExitThread(DWORD dwExitCode).

    При этом освобождаются все ресурсы ОС, выделенные данному потоку, но С С++ ресурсы (например, объекты классов С++) не очищаются. Именно поэтому не рекомендовано завершать поток, используя эту функцию.

    Если же вы ее использовали, то кодом возврата потока будет тот параметр, который вы передадите в данную функцию.

    Как и для CreateThread для библиотеки Visual C++ существует ее аналог _endthreadex, который и стоит использовать. Об причинах в следующем выпуске.

    Если появилась необходимость уничтожить поток снаружи, то это моет сделать функция TeminateThread.

    Эта функция уменьшит счетчик пользователей объекта ядра (поток) на 1, однако при этом не разрушит и не очистит стек потока. Стек будет существовать, пока не завершится процесс, которому принадлежит поток. При задачах, постоянно создающих и уничтожающих потоки, это приводит к потере памяти внутри процесса.

    При завершении процесса происходит следующее.
    Завершение потока происходит принудительно. Деструкторы объектов не вызываются, и т.д. и т.д.

    При завершении потока по такой причине, связанный с ним объект ядра (поток) не освобождается до тех пор, пока не будут закрыты все внешние ссылки на этот объект.

    Добавить комментарий