«Программист».
В этой статье я хочу рассказать об использовании многопоточности в Windows-приложениях и о всегда возникающей при этом проблеме синхронизации потоков.
По данной теме существует большое количество материалов, в том числе и на русском языке. Однако, как я заметил, заглядывая в различные программистские конференции, при работе с потоками начинающие программисты все равно постоянно наступают на одни и те же «грабли». Кроме того, тема, на мой взгляд, очень интересная. Исходя из этого, я и решил написать эту статью. Материал мне знаком не понаслышке и мне хотелось бы по возможности поделиться собственным опытом в этой области.
Статья адресована в первую очередь тем, кто еще не использовал многопоточность на практике или только начал знакомиться с ней. Я постараюсь представить перед читателем общую картину, не вдаваясь в мелкие детали вроде описания конкретных функций API и другой справочной информации, которую вы легко найдете в MSDN. Основная цель статьи – помочь новичку сориентироваться в теме, но при этом обратить внимание на тонкие моменты. Быть может, и более опытные люди смогут пополнить свои знания о некоторых тонкостях разработки многопоточных приложений.
Хотя многозадачность и многопоточность, несомненно, являются фундаментальными принципами программирования, их реализация в различных системах зачастую различается не только конкретным API, но и самим подходом к их организации. Поэтому речь здесь пойдет исключительно о многопоточности в Windows-системах.
Как я уже сказал, тема многопоточности очень интересна и обширна. Мне хотелось создать достаточно полную картину, и рассказать о многих интересных моментах. В результате статья получилась большой, она состоит из четырех частей. В первой части, которую вы сейчас читаете, мы поговорим о двух важнейших сущностях многозадачности: потоке и процессе. Мы обсудим как «теоретические основы», скрывающиеся за этими понятиями, так и «практические приемы» работы с процессами и потоками. Во второй части речь пойдет о различных стратегиях построения многопоточных приложений, а также о том, почему неизбежно возникает задача синхронизации работы различных потоков. Третья часть будет посвящена основному средству синхронизации Windows: объектах синхронизации ядра. Это универсальный и очень мощный механизм, однако Windows предоставляет и дополнительные механизмы синхронизации. О них речь пойдет в последней, четвертой части.
Итак, для начала вспомним, что же означают термины «поток» (thread) и «процесс» (process).
Наиболее фундаментальным понятием является поток (thread). Под словом «поток» имеется в виду «поток команд», то есть последовательность инструкций, которые считывает и исполняет процессор. Все современные ОС позволяют запустить много потоков, которые будут исполняться параллельно. Таким образом, если вам нужно, чтобы ваша программа одновременно работала над несколькими задачами, проще всего поручить каждую задачу отдельному потоку.
Конечно же, один процессор может одновременно исполнять лишь один поток инструкций. Поэтому «подлинная» многопоточность, когда потоки команд исполняются действительно параллельно и независимо, возможна только в многопроцессорной машине. Обычно же число потоков в системе гораздо больше числа процессоров (которых как правило всего-то один). Поэтому операционной системе приходится эмулировать многопоточность, заставляя процессор поочередно переключаться между потоками. Время работы процессора разбивается на небольшие интервалы, обычно называемые квантами. Когда квант времени заканчивается, выполнение текущего потока приостанавливается, содержимое регистров процессора сохраняется в специальной области памяти, и он переключается на обработку следующего потока. Когда очередь снова дойдет до этого потока, содержимое регистров будет полностью восстановлено и работа потока продолжится так, как будто она вовсе и не прерывалась. Таким образом, переключение потоков происходит совершенно незаметно для них самих.
Хочу особо обратить внимание, что программист не может напрямую управлять переключением потоков. У него нет возможности, скажем, дать команду «переключиться на исполнение вот этого потока». Механизмы синхронизации, о которых речь пойдет ниже, управляют потоками косвенным образом. Так задумано специально, чтобы скрыть от потоков, действительно ли они исполняются параллельно разными процессорами или по очереди одним. Именно это свойство, прозрачность механизма распределения процессорного времени, и позволяет легко и в полной мере использовать достоинства многопроцессорных систем.
Рассмотрим свойства потока. С каждым из них связан свой набор значений регистров процессора, называемый контекстом потока (thread context). Кстати, его можно получить функцией GetThreadContext, впрочем, это можно понадобиться разве что при создании отладчика. Но кроме этого система создает еще ряд других объектов, однозначно связанных с каждым потоком. Важнейший из них (после контекста) – конечно же, стек (создание своего стека для каждого потока возможно благодаря тому, что в контексте потока сохраняется, в том числе, и регистр указателя стека, ESP). Для нас это важно, поскольку именно в стеке создаются и хранятся все локальные или автоматические переменные . Поскольку у каждого потока свой стек, то у каждого потока имеется свой собственный экземпляр локальной переменной, доступный только ему. Значит, синхронизация при работе с такими переменными не требуется.
Можно, конечно, передать каким-либо образом другому потоку указатель на локальную переменную, но вряд ли это будет хорошим решением. Время жизни локальной переменной ограничено, значит, вам придется проследить, чтобы чужой поток закончил работу с переменной до того, как текущий завершит функцию, в которой эта переменная определена и автоматически удалит ее. В отличие от локальных переменных, у глобальной или статической переменной всегда имеется лишь один экземпляр, доступный сразу всем потокам. Поэтому их удобно использовать для обмена данными между потоками, хотя необходимость в синхронизации доступа к ним все равно необходима. Однако с ними связана и большая проблема. Если функцию, использующую только локальные переменные, можно использовать в многопоточной среде без каких-либо изменений, то с функциями, использующими глобальные переменные, так уже не получится. «Старый стиль» программирования, рассчитанный на однопоточную среду, часто предполагает использование глобальных переменных для связи между различными функциями. Например, стандартная библиотека C (C run-time library, RTL) использует глобальную переменную errno, куда заносится код ошибки в том случае, если системный вызов закончился неудачно. Теперь представим себе, что после того, как один поток вызвал системную функцию, но до того, как успел прочитать код ошибки, другой поток успел вызвать другую системную функцию. В результате, первый поток будет введен в заблуждение: первая функция могла завершиться успешно, а вторая нет, или в обеих произошли ошибки, но код их совершенно разный. В общем, результат непредсказуем. Обратите внимание и на то, что такая ошибка, очевидно, будет плавающей, то есть возникать не всегда, а от случая к случаю. Этим очень неприятным свойством обладают практически все ошибки, связанные с синхронизацией. Кстати, по этой причине отладку и особенно тестирование многопоточных приложений крайне желательно вести на многопроцессорной машине, где подобные ошибки обычно проявляются гораздо активней.
Таким образом, разработка многопоточных приложений требует особого стиля программирования. Одно из основных правил при этом можно сформулировать следующим образом: избегайте по возможности глобальных переменных, используйте их лишь для связи между потоками. К сожалению, бывают случаи, когда без глобальной переменной все-таки не обойтись. Часто это случается, когда необходимо перенести какой-нибудь код, рассчитанный на однопоточную среду, в многопоточное приложение. Для таких ситуаций в Win32 предусмотрен механизм под названием «локальная память потока» (thread local storage, TLS). Он позволяет создать глобальную переменную, которая, тем не менее, подобно локальной, имеет отдельный экземпляр для каждого потока. Есть два способа работы с TLS: с помощью функций API (TlsAlloc, TlsGetValue, TlsSetValue, TlsFree) или используя поддержку компилятора (в Visual C++ для этого используется атрибут __declspec(thread)). К сожалению, и у того и другого подхода есть серьезные ограничения! Проблема первого состоит в том, что число TLS-слотов, выделяемых одному процессу, ограничено. Со вторым проблемы возникают при использовании таких переменных в DLL.
Обратите внимание, что для иллюстрации того, как не надо проектировать программы, рассчитанные на работу в многопоточной среде, я взял пример не из какой-нибудь школьной поделки, а из стандартной библиотеки языка C, которая используется практически в каждой программе! И это является большой проблемой для разработчиков компиляторов. Дело в том, что язык C начал развиваться еще в те далекие времена, когда о многопоточности и не слышали. Поэтому стандартная библиотека проектировалась в «старом» стиле. И теперь для того, чтобы приспособить ее к новым требованиям, системным программистам приходится использовать TLS и прочие уловки. В результате Visual C++, например, поставляется с двумя версиями RTL: для однопоточных и многопоточных приложений. Последняя универсальна, но и гораздо более объемна по сравнению с первой. У нее в свою очередь тоже два варианта: статическая библиотека и динамическая (та самая MSVCRT.DLL, знакомая не только программистам, но и многим пользователям). Теоретически, однопоточную библиотеку можно использовать и в многопоточном приложении при условии, что никакие другие потоки кроме главного не будут обращаться к ней. Однако я настоятельно рекомендую не пытаться так делать. Слишком легко проглядеть какое-нибудь неявное обращение к стандартной библиотеке и получить в результате непонятные ошибки. Все равно сэкономите вы не так много.
В Windows с потоком связан еще ряд системных объектов, например очередь APC и очередь оконных сообщений. О последней мы поговорим более подробно в четвертой части, когда речь пойдет о синхронизации с использованием функции окна.
В наследство от UNIX Windows достались также «волокна» (fiber). В UNIX они появились раньше настоящих потоков, а в Windows, наоборот, волокна были введены главным образом для облегчения переноса UNIX'овых приложений. Волокно – это в некотором роде «неполноценный» поток. В отличие от настоящих потоков, программист должен сам управлять переключением процессора с исполнения одного волокна на другое. Отсюда очевидно, что волокна не могут исполняться параллельно разными процессорами, то есть преимущества многопроцессорности сразу теряются. По сути, если поддержка многопоточности осуществляется на аппаратном уровне, то волокна – это всего лишь программная эмуляция многопоточности. Поэтому целесообразность использование их для каких-либо других целей, кроме как портирования UNIX-приложений, лично мне представляется сомнительной.
Итак, мы вспомнили, а кто-то, может быть, и впервые узнал, что такое «поток». Но с этим понятием неразрывно связано другое: «процесс». Недаром соответствующий раздел в MSDN так и называется «Processes and Threads»
Процесс – это объединение нескольких потоков. А объединяет эти потоки единое виртуальное адресное пространство. В этом пространстве размещаются код и данные приложения (обычно это один exe- и несколько dll-модулей). Именно единство этого пространства и делает обмен данными между потоками приложения предельно простым. Наоборот, адресные пространства различных процессов независимы и отделены друг от друга (хотя, используя проекции файла в память (memory mapped file), можно создать область памяти, которая будет доступна совместно нескольким процессам). Другими словами, один и тот же виртуальный адрес в разных процессах соответствует разным физическим адресам. Поэтому по одному и тому же виртуальному адресу в разных процессах могут находиться совершенно разные данные, при этом модификация данных в одном процессе никак не отразится на данных в другом. В первую очередь это сделано для повышения надежности всей системы, чтобы ошибка в одном приложении не могла привести к порче данных, принадлежащих другому. Таким образом, процесс – это несколько потоков (как минимум один) плюс единое виртуальное адресное пространство.
Поскольку все потоки процесса работают в едином адресном пространстве, обмен данными между ними крайне прост, однако при этом требуется согласовывать их работу над совместными данными. Собственно, под термином «синхронизация», как правило, имеют в виду именно согласование работы потоков, принадлежащих одному процессу. Этому и будут посвящены следующие части данной статьи. Хотя некоторые из описанных далее приемов можно использовать и для синхронизации потоков принадлежащих разным процессам, в основном согласование их работы связано с «механизмами взаимосвязи процессов» (inter-process communications, IPC). Действительно, трудно представить ситуацию, когда нам потребовалось бы согласовывать движение потоков без необходимости обмена данными между ними. А для этого, если потоки работают в разных адресных пространствах, требуются специальные механизмы, носящие обобщенное название IPC (проекции файлов в память – один из них).
Любопытно, что идея запустить несколько потоков в едином адресном пространстве пришла в голову программистам далеко не сразу. В UNIX'ах, например, до самого недавнего времени понятия потока вообще не было, притом, что многозадачность была заложена в эту систему с самого начала! Изначально у них один процесс - один поток. С этим связаны и проблемы стандартной библиотеки C, о которых я упомянул, ведь долгое время язык C развивался именно в UNIX'овой однопоточной среде. Впрочем, современные версии этой системы поддерживают многопоточность. Хотя, по-видимому, эта идея у них до конца так и не прижилась, юниксовые программисты по-прежнему предпочитают запустить несколько процессов, нежели несколько потоков в одном процессе. Именно так, например, работает знаменитый web-сервер Apache.
Итак, процесс – это набор потоков, работающих в едином адресном пространстве. Очевидно, что само по себе адресное пространство без потоков смысла не имеет. Поэтому процесс считается завершенным, как только завершатся все его потоки.
Перейдем теперь от теории к практике, а именно к тому, как реально использовать многопоточность в своих программах. Начнем с ответа на, казалось бы, банальный вопрос: как запустить и остановить поток и процесс?
Пожалуй, более-менее просто дело обстоит лишь с запуском процесса. Для этого есть API-функция CreateProcess. Ей нужно передать путь к exe-файлу, который нужно запустить (плюс еще с десяток не очень интересных сейчас параметров, о которых вы прочтете в документации). CreateProcess проделывает большую работу: создает новое виртуальное адресное пространство, проецирует туда наш файл, а также все статически прилинкованные к нему динамические библиотеки и библиотеки, прилинкованные к этим библиотеками, и запускает первичный поток процесса. Этот поток сначала вызывает функции DllMain всех загруженных DLL, и только затем переходит к точке входа в exe-файл. Обратите внимание на то, что точка входа – это, как правило, совсем не привычная WinMain. Дело в том, что компилятор и линковщик (если их специально не попросить об обратном) в качестве точки входа подставляют свою собственную функцию (в Visual С++ она называется WinMainCRTStartup), которая выполняет некую подготовительную работу по инициализации стандартной библиотеки, по умолчанию включаемой в любой исполняемый модуль, и только затем вызывает WinMain. На выходе из WinMain эта же функция деинициализирует стандартную библиотеку, то есть освобождает ресурсы и т.п. В C++ роль этой функции еще больше. Вы никогда не задумывались над тем, кто вызывает конструкторы и деструкторы глобальных объектов? Да, ответственность за это ложится именно WinMainCRTStartup. Таким образом, ваша программа начинает выполняться еще до вызова WinMain и не заканчивается с выходом из нее. Кстати, все это относится и к DllMain, вместо нее линковщик тоже подставляет библиотечную функцию, которая в свою очередь вызывает вашу DllMain.
С запуском нового потока в существующем процессе (а первый поток, как я уже сказал, запускается системой при создании процесса) уже связана одна тонкость. В Platform SDK, в разделе, посвященном процессам и потокам, говорится, что для запуска нового потока предназначена API-функция CreateThread. Этой функции надо передать адрес, с которого начнется исполнение потока, иначе говоря, указатель на функцию потока и, как и в случае с CreateProcess, кучу дополнительных параметров. В рамках Platform SDK это утверждение, наверно, можно считать правильным, поскольку там идет речь лишь о Win32 API, но не о конкретных языках программирования. Но дело в том, что стандартная библиотека С (ее многопоточный вариант), должна подготовиться к запуску нового потока, инициализировать свои внутренние структуры данных (об истоках этих проблем я уже рассказывал). И точно так же как с WinMain, выполнение потока должно начаться со специальной библиотечной процедуры, которая уже вызовет вашу функцию потока. Чтобы это произошло, вместо CreateThread следует использовать функцию стандартной библиотеки _beginthreadex. Ее параметры и возвращаемое значение практически полностью соответствуют CreateThread, хотя есть и небольшие различия. В случае неудачи одна из них возвращает NULL, а другая -1. Хэндл потока у API функции описан как HANDLE, а у С-функции, как unsigned long, хотя на самом деле это одна и та же величина. Существует еще функция _beginthread (без «ex»), но она является атавизмом и использовать ее не рекомендуется. Заметьте, в однопоточной версии стандартной библиотеки функции _beginthread и _beginthreadex вообще отсутствуют. Итак, чтобы стандартная библиотека С могла правильно работать, для запуска нового потока следует использовать _beginthreadex.
Теперь перейдем к вопросу о том, как поток правильно завершить. Прежде всего, хочу обратить особое внимание на то, что для этого никогда нельзя использовать функцию TerminateThread! Удивительно, но, несмотря на то, что это написано не только в MSDN, у Рихтера, но и в любой мало-мальски приличной книге по программированию под Windows, юные «чайники» с завидным упорством продолжают пытаться использовать эту функцию. Во всяком случае, читая различные программистские конференции в FIDO и интернете, я постоянно встречаю советы использовать TerminateThread. Более того, я с удивлением прочитал этот совет даже в этом, в целом уважаемом мною журнале! Именно поэтому я хочу отметить это особо.
Почему же в обычном приложении нельзя использовать TerminateThread, и для чего же эта функция все-таки существует? Все дело в том, что TerminateThread предназначена не для завершения, а для аварийной остановки потока. Она существует лишь на «пожарный случай», когда завершить поток корректно нет возможности, например, если он банально зациклился. Поэтому место этой функции (вместе с TerminateProcess) – лишь в специальных системных приложениях вроде менеджера задач (Task Manager) или отладчика. И даже в таких приложениях к этим функциям стоит прибегать лишь в крайнем случае. В подтверждение этого могу обратить внимание на интересный факт: в MSDN можно найти специальную статью о том, каким образом отладчик (уж ему-то, казалось бы, естественно воспользоваться TerminateThread) может убить чужой поток, не прибегая к TerminateThread! Таким образом, если вы используете TerminateThread для уничтожения собственных потоков, это автоматически означает, что ваше приложение работает в аварийном режиме! Соответственно ожидать стабильной и надежной работы от него не приходится.
Чем же грозит насильственное «убийство» потоков? В первую очередь утечкой ресурсов. Почти в каждой программе нам приходится получать от системы различные ресурсы, которые по окончании работы необходимо освободить: память, файлы, объекты графического интерфейса, COM-интерфейсы и т.п. Поэтому если поток прекратил работу раньше времени, то функции, освобождающие эти объекты, не будут вызваны, и ресурсы будут висеть мертвым грузом до самого окончания работы, причем в лучшем случае заказавшего их процесса, а в худшем случае всей операционной системы. Надо заметить, что теоретически Windows должна следить за своими процессами и сама освобождать по окончании процесса те ресурсы, которые он сам забыл освободить. Однако в системах семейства 95 этот механизм реализован не полностью: скажем, открытый файл она закроет, а вот объекты графического ядра уже не освободит. И даже в NT-системах, где контроль за ресурсами реализован достаточно строго, могут встречаться ситуации, где корректно освободить ресурс может только сам процесс. Еще больше неприятностей могут доставить эксклюзивно используемые ресурсы, работать с которыми одновременно может только один процесс или поток. Если такой ресурс вовремя не освободить, им не сможет пользоваться вообще никто!
Еще раз обращу внимание на стандартную библиотеку C. Она не только сама заказывает некоторые ресурсы при запуске потока/процесса, но еще и устроена так, что должна отслеживать запуск/завершение каждого потока в процессе. Поэтому поток должен завершаться так, чтобы стандартная библиотека правильно отработала это событие.
Наконец, если свой код вы знаете и контролируете, то вряд ли кто сможет предсказать, какие возникнут побочные эффекты, если поток будет уничтожен во время вызова API-функции. Впрочем даже если ваш рабочий поток не обращается к системе, добиться, чтобы его аварийная остановка не вызывала побочных эффектов обойдется вам значительно дороже, чем просто использовать штатный механизм завершения потока.
Как же все-таки правильно завершить поток? В Platform SDK сказано, что для этого он сам должен вызвать API-функцию ExitThread. Правда здесь, как и в случае с CreateThread, оказывается, что на самом деле вместо нее следует использовать ее эквивалент из стандартной библиотеки _endthreadex (которая внутри себя вызовет ExitThread). Но, пожалуй, все-таки самый надежный способ завершить поток – это просто дать функции потока завершиться и вернуть управление системе. Как и в случае с WinMain, исполнение потока начинается с некоего системного кода, которой уже в свою очередь вызывает функцию, указатель на которую вы передали _beginthreadex (или непосредственно CreateThread, если вы не пользуетесь стандартной библиотекой). Как только ваша функция завершит работу, системный код вызовет _endthreadex (или соответственно сразу ExitThread) автоматически. Почему же я, тем не менее, не советую явно вызывать _endthreadex (хотя и ни в коем случае не утверждаю, что так делать нельзя)? Не забывайте, что перед завершением поток должен освободить все занятые ресурсы. Что касается ресурсов, используемых стандартной библиотекой, их освободит _endthreadex. Но практически любая программа сама использует ресурсы, и за их освобождение ответственность несете вы сами. В большинстве случаев во избежание путаницы освобождать ресурсы удобно в той же самой функции, в которой они были выделены. Поэтому если _endthreadex будет вызвана в одной из вложенных функций, то ресурсы, выделенные в вызывающих функциях, останутся не освобожденными. И не говорите, что вы точно знаете, что сейчас там у вас никаких ресурсов не выделяется! С развитием программы необходимость в этом вполне может появиться. А программировать всегда надо стараться так, чтобы потом не пришлось все переделывать. Поэтому лично я и считаю явное использование _endthreadex дурным тоном.
Кстати, при программировании на C++ освобождение ресурсов и прочая «чистка» чаще всего происходит в деструкторах . Это удобно, поскольку деструкторы локальных переменных вызываются автоматически при выходе из процедуры, в которой они были созданы. Но если в ней будет вызвана _endthreadex, этого не произойдет! Поэтому с этой функцией стоит быть аккуратным даже в процедуре самого верхнего уровня.
Обратите внимание, что корректно завершиться поток может лишь самостоятельно. Он должен сам вызвать ExitThread (неважно, сделает он это явно или неявно), предварительно освободив ресурсы и т.п. Таким образом, если, скажем, у вас есть набор рабочих потоков, которые необходимо остановить перед выходом из приложения, вы должны позаботиться о механизме, с помощью которого управляющий, «главный» поток передаст рабочим потокам сигнал о том, что «пора закругляться». Например, для этого может оказаться достаточным просто сделать специальную глобальную переменную (флаг «остановиться»), которую рабочие потоки будут периодически проверять в цикле (надо заметить, редкий рабочий поток обходится без цикла). По моему опыту, часто удобно бывает использовать «событие» (event). Почему, я расскажу в третьей части, когда речь пойдет об используемых для синхронизации объектах ядра и о событиях в частности. Там же я расскажу, каким образом главный поток может убедиться, что все рабочие потоки завершились, прежде чем заканчивать работу приложения.
В заключение должен упомянуть функцию CloseHandle. Иногда начинающие ошибочно полагают, что эта функция может остановить поток или процесс. Нет, эта функция предназначена всего лишь для того, чтобы закрывать описатели или «хэндлы» , возвращаемые функциями CreateThread, CreateProcess и некоторыми другими. Хэндлы как потока, так и процесса, остаются в силе даже посте того, как поток или процесс был завершен. Как мы увидим, это дает возможность легко проверить из другого потока, работает ли еще поток или уже завершился, и даже точно отследить момент его завершения. Поэтому для того, чтобы освободить системные ресурсы, хэндл потока должен быть закрыт явным образом. Для этого и нужна функция CloseHandle. Впрочем, если вы вызовете ее еще до завершения потока, вы всего лишь лишитесь описателя, но никак не повлияете на сам поток.
Все что касается завершения потока можно буквально слово в слово повторить и про процесс. Хотя есть функция API ExitProcess и соответствующая ей библиотечная exit, самый надежный способ корректно закончить процесс – это просто дать завершиться всем потокам процесса.
Таким образом, при работе, как с отдельными потоками, так и с процессами в целом необходимо уделять внимание, чтобы их запуск и остановка производились корректно. Для этого, во-первых, необходимо использовать специальные функции RTL. Во-вторых, корректно завершиться поток или процесс может только самостоятельно. Поэтому к аварийной остановке потоков и процессов следует прибегать только в случае крайней необходимости.
Итак, мы вспомнили, что такое поток и процесс и обсудили тонкости запуска и остановки потоков. В следующей части я расскажу о различных стратегиях проектирования многопоточных приложений, после чего мы перейдем непосредственно к теме синхронизации потоков.
Внимание!!! Если вы хотите перепечатать эту статью или её часть на на своём сайте, в печтном издании, где либо ещё, большая просьба, согласуйте пожалуйста это с автором! Связаться со мной можно по адресу: