Добавил:
Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:

Рихтер Дж., Назар К. - Windows via C C++. Программирование на языке Visual C++ - 2009

.pdf
Скачиваний:
6266
Добавлен:
13.08.2013
Размер:
31.38 Mб
Скачать

Глава 6. Базовые сведения о потоках.docx 169

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

Visual Studio IDE автоматически компилирует исходный код на C# и Visual Basic .NET, пока вы продолжаете ввод ее текста. При этом в окне редактора ошибочные фрагменты кода выделяются подчеркиванием, а IDE выводит соответствующие предупреждения и сообщения об ошибках, если навести указатель мыши на подчеркнутый фрагмент.

Электронные таблицы пересчитывают данные в фоновом режиме.

Текстовые процессоры разбивают текст на страницы, проверяют его на орфографические и грамматические ошибки, а также печатают в фоновом режиме.

Файлы можно копировать на другие носители тоже в фоновом режиме.

Веб-браузеры способны взаимодействовать с серверами в фоновом режиме.

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

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

Check Spelling и Check Grammar в текстовых процессорах.

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

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

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

170 Часть II. Приступаем к работе

программу и одновременно пользоваться текстовым процессором (довольно часто я так и работаю). Если в компьютере установлено два процессора, то сборка выполняется на одном из них, а документ обрабатывается на другом. Иначе говоря, какого-либо падения производительности не наблюдается. И кроме того, если компилятор из-за той или иной ошибки входит в бесконечный цикл, на остальных процессах это никак не отражается. (Конечно, о программах для MS-DOS и 16разрядной Windows речь не идет.)

И в каких случаях потоки не создаются

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

Потоки — вещь невероятно полезная, когда ими пользуются с умом. Увы, решая старые проблемы, можно создать себе новые. Допустим, вы разрабатываете текстовый процессор и хотите выделить функциональный блок, отвечающий за распечатку, в отдельный поток. Идея вроде неплоха: пользователь, отправив документ на распечатку, может сразу вернуться к редактированию. Но задумайтесь вот над чем: значит, информация в документе может быть изменена при распечатке документа? Как видите, теперь перед вами совершенно новая проблема, с которой прежде сталкиваться не приходилось. Тут-то и подумаешь, а стоит ли выделять печать в отдельный поток, зачем искать лишних приключений? Но давайте разрешим при распечатке редактирование любых документов, кроме того, который печатается в данный момент. Или так: скопируем документ во временный файл и отправим на печать именно его, а пользователь пусть редактирует оригинал в свое удовольствие. Когда распечатка временного файла закончится, мы его удалим — вот и все.

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

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

действия пользователя.

 

 

Несколько потоков пользовательского

интерфейса

в одном процессе

можно обнаружить в таких приложениях,

как Windows

Explorer. Он создает

Глава 6. Базовые сведения о потоках.docx 171

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

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

Ваша первая функция потока

Каждый поток начинает выполнение с некоей входной функции. В первичном потоке таковой является Jmain, или _tWinMain. Если вы хотите создать вторичный поток, в нем тоже должна быть входная функция, которая выглядит примерно так:

DWORD WINAPI ThreadFunc(PVOID pvParam){ DW0RD dwResult = 0;

return(dwResult);

}

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

А теперь поговорим о самых важных вещах, касающихся функций потоков

В отличие от входной функции первичного потока, у которой должно быть одно из четырех имен: main, vmain, WinMain или wWinMain (за исключением случаев, когда ключом /ENTRY: компоновщика другая задана как входная), — функцию потока можно назвать как угодно. Однако, если в программе несколько функций потоков, вы должны присвоить им разные имена, иначе компилятор или компоновщик решит, что вы создаете несколько реализаций единственной функции.

Поскольку входным функциям первичного потока передаются строковые параметры, они существуют в ANSI- и Unicode-версиях: main wmain и WinMain wWinMain. Но функциям потоков передается единственный параметр, смысл которого определяется вами, а не операционной системой. Поэтому здесь нет проблем с ANSI/Unicode.

Функция потока должна возвращать значение, которое будет использоваться как код завершения потока. Здесь полная аналогия с библиотекой

172 Часть II. Приступаем к работе

С/С++: код завершения первичного потока становится кодом завершения процесса.

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

Вот вы и узнали, как должна быть реализована функция потока. Теперь рас-

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

Функция CreateThread

Мы уже говорили, как при вызове функции CreateProcess появляется на свет первичный поток процесса. Если вы хотите создать дополнительные потоки, нужно вызвать из первичного потока функцию CreateThread:

HANDLE CreateThread(

PSECURITY_ATTRIBUTES psa,

DW0RD cbStackSize,

PTHREAD_START_ROUTIME pfnStartAddr,

PVOID pvParam,

DWORD dwCreateFlags,

PDWORD pdwThreadID);

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

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

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

Итак, общее представление о функции CreateThread вы получили. Давайте рассмотрим все ее параметры.

Глава 6. Базовые сведения о потоках.docx 173

Параметр psa

Параметр psa является указателем на структуру SECURITY_ATTRIBUTES. Если вы хотите, чтобы объекту ядра «поток» были присвоены атрибуты защиты по умолчанию (что чаще всего и бывает), передайте в этом параметре NULL. А чтобы дочерние процессы смогли наследовать описатель этого объекта, определите структуру SECURITY_ATTRIBUTES и инициализируйте ее элемент bInheritHandle значением TRUE (см. главу 3).

Параметр cbStackSize

Этот параметр определяет, какую часть адресного пространства поток сможет использовать под свой стек. Каждому потоку выделяется отдельный стек. Функция CreateProcess, запуская приложение, вызывает CreateThread, и та инициализирует первичный поток процесса. При этом CreateProcess заносит в параметр cbStackSize значение, хранящееся в самом исполняемом файле. Управлять этим значением позволяет ключ /STACK компоновщика:

/STACK:[reserve] [,commit]

Аргумент reserve определяет объем адресного пространства, который система должна зарезервировать под стек потока (по умолчанию — 1 Мб). Аргумент commit задает объем физической памяти, который изначально передается области, зарезервированной под стек (по умолчанию — 1 страница). По мере исполнения кода в потоке вам, весьма вероятно, понадобится отвести под стек больше одной страницы памяти. При переполнении стека возникнет исключение. (О стеке потока и исключениях, связанных с его переполнением, см. главу 16, а об общих принципах обработки исключений — главу 23.) Перехватив это исключение, система передаст зарезервированному пространству еще одну страницу (или столько, сколько указано в аргументе commit). Такой механизм позволяет динамически увеличивать размер стека лишь по необходимости.

Если вы, обращаясь к CreateThread, передаете в параметре cbStackSize ненулевое значение, функция резервирует всю указанную вами память. Ее объем определяется либо значением параметра cbStackSize, либо значением, заданным в ключе /STACK компоновщика (выбирается большее из них). Но передается стеку лишь тот объем памяти, который соответствует значению в cbStackSize. Если же вы передаете в параметре cbStack нулевое значение, CreateThread создает стек для нового потока, используя информацию, встроенную компоновщиком в ЕХЕ-файл.

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

174 Часть II. Приступаем к работе

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

Параметры pfhStartAddr и pvParam

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

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

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

DWORD WINAPI FirstThread(PVOID pvParam) {

//инициализируем переменную, которая содержится в стеке int x = 0;

DWORD dwThreadID;

//создаем новый поток

HANDLE hThread = CreateThread(NULL, 0, 8econdThread, (PV0ID) &x, 0, &dwThreadID);

//Мы больше не ссылаемся на новый поток,

//поэтому закрываем свой описатель этого потока.

CloseHandle(hThread);

//Наш поток закончил работу.

//ОШИБКА: его стек будет разрушен, но SecondThread

//может попытаться обратиться к нему.

return(0);

}

Глава 6. Базовые сведения о потоках.docx 175

DWORD WINAPI SecondThread(PVOID pvParam) {

//здесь выполняется какая-то длительная обработка...

//Пытаемся обратиться к переменной в стеке FirstThread.

//ПРИМЕЧАНИЕ: это может привести к ошибке общей защиты –

//нарушению доступа!

* ((int *) pvParam) * 5; ...

return(0);

}

Не исключено, что в приведенном коде FirstThread закончит свою работу до того, как SecondThread присвоит значение 5 переменной x из FirstThread. Если так и будет, SecondThread не узнает, что FirstThread больше не существует, и попытается изменить содержимое какого-то участка памяти с недействительным теперь адресом. Это неизбежно вызовет нарушение доступа: стек первого потока уничтожен по завершении FirstThread. Что же делать? Можно объявить x статической переменной, и компилятор отведет память для хранения переменной x не в стеке,

а в разделе данных приложения (application's data section).

Но тогда функция станет нереентерабельной. Иначе говоря, в этом случае вы не смогли бы создать два потока, выполняющих одну и ту же функцию, так как оба потока совместно использовали бы статическую переменную. Другое решение этой проблемы (и его более сложные варианты) базируется на методах синхронизации потоков, речь о которых пойдет в главах 8, 9 и 10.

Параметр dwCreateFlags

Этот параметр определяет дополнительные флаги, управляющие созданием потока. Он принимает одно из двух значений: 0 (исполнение потока начинается немедленно) или CREATE_SUSPENDED. В последнем случае система создает поток, инициализирует его и приостанавливает до последующих указаний.

Флаг CREATE_SUSPENDED позволяет программе изменить какие-либо свойства потока перед тем, как он начнет выполнять код. Правда, необходимость в этом возникает довольно редко. Одно из применений этого флага демонстрирует программа-пример JobLab из главы 5.

Параметр pdwThreadID

Последний параметр функции CreateThread — это адрес переменной типа DWORD, в которой функция возвращает идентификатор, приписанный системой новому потоку. (Идентификаторы процессов и потоков рассматривались в главе 4.) Вы можете (чаще всего так и поступают) передавать NULL в этом параметре. Так можно сообщить функции о том, что идентификатор потока вас не интересует.

176 Часть II. Приступаем к работе

Завершение потока

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

функция потока возвращает управление (рекомендуемый способ);

поток самоуничтожается вызовом функции ExitThread (нежелательный способ);

один из потоков данного или стороннего процесса вызывает функцию TerminateThread (нежелательный способ);

завершается процесс, содержащий данный поток (тоже нежелательно).

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

Возврат управления функцией потока

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

любые С++-объекты, созданные данным потоком, уничтожаются соответствующими деструкторами;

система корректно освобождает память, которую занимал стек потока;

система устанавливает код завершения данного потока (поддерживаемый объектом ядра «поток») — его и возвращает ваша функция потока;

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

Функция ExitThread

Поток можно завершить принудительно, вызвав:

VOID ExitThread(DWORD dwExitCode);

При этом освобождаются все ресурсы операционной системы, выделенные данному потоку, но С/С++-ресурсы (например, объекты, созданные из С++- классов) не очищаются. Именно поэтому лучше возвращать управление из функции потока, чем самому вызывать функцию ExitThread. (Подробнее на эту тему см. раздел «Функция ExitProcess» в главе 4.)

В параметр dwExitCode вы помещаете значение, которое система рассматривает как код завершения потока. Возвращаемого значения у этой функции нет, потому что после ее вызова поток перестает существовать.

Примечание. ExitThread — это Windows-функция, которая уничтожает поток. Но никогда не вызывайте ее, если вы пишете код на С/С++. Вместо нее вы должны использовать функцию _endthreadex из библиотеки Visual С++. (Если вы работаете с другим компилятором, он должен поддерживать свой эквивалент функции ExitThread.) Что именно делает _endthreadex и почему это так важно, я объясню потом.

Глава 6. Базовые сведения о потоках.docx 177

Функция TermlnateThread

Вызов этой функции также завершает поток:

BOOL TerminateThread(

HANDLE hThread,

DWORD dwExitCode):

В отличие от ExitThread, которая уничтожает только вызывающий поток, эта функция завершает поток, указанный в параметре hThread. В параметр dwExitCode вы помещаете значение, которое система рассматривает как код завершения потока. После того как поток будет уничтожен, счетчик пользователей его объекта ядра «поток» уменьшится на 1.

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

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

Примечание. Уничтожение потока при вызове ExitThread или возврате управления из функции потока приводит к разрушению его стека. Но если он завершен функцией TerminateThread, система не уничтожает стек, пока не завершится и процесс, которому принадлежал этот поток. Так сделано потому, что другие потоки могут использовать указатели, ссылающиеся на данные в стеке завершенного потока. Если бы они обратились к несуществующему стеку, произошло бы нарушение доступа. Кроме того, при завершении потока система уведомляет об этом все DLL, подключенные к процессу — владельцу завершенного потока. Но при вызове TerminateThread такого не происходит, и процесс может быть завершен некорректно. (Подробнее на эту тему см. главу 20.)

Если завершается процесс

Функции ExitProcess и TerminateProcess, рассмотренные в главе 4, тоже завершают потоки. Единственное отличие в том, что они прекращают выполнение всех потоков, принадлежавших завершенному процессу. При этом гарантируется высвобождение любых выделенных процессу ресурсов, в том числе стеков потоков. Однако эти две функции уничтожают потоки принудительно — так, будто для каждого из них вызывается функция TerminateThread. А это означает, что очистка проводится некорректно: деструкторы С++-объектов не вызываются, данные на диск не сбрасываются и т. д.

178 Часть II. Приступаем к работе

Как я уже говорил в начале главы, когда входная функция приложения возвращает управления, стартовый код библиотеки С/С++ вызывает ExitProcess. Поэтому в многопоточном приложении необходимо явно позаботиться о корректной остановке каждого потока до завершения главного потока. В противном случае все потоки «погибнут» внезапно и молча.

Что происходит при завершении потока

А происходит вот что:

Освобождаются все описатели User-объектов, принадлежавших потоку. В Windows большинство объектов принадлежит процессу, содержащему поток, из которого они были созданы. Сам поток владеет только двумя Userобъектами: окнами и ловушками (hooks). Когда поток, создавший такие объекты, завершается, система уничтожает их автоматически. Прочие объекты разрушаются, только когда завершается владевший ими процесс.

Код завершения потока меняется со STILL_ACTIVE на код, переданный в функцию ExitThread или TerminateThread.

Объект ядра «поток» переводится в свободное состояние.

Если данный поток является последним активным потоком в процессе, завершается и сам процесс.

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

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

Когда поток завершился, толку от его описателя другим потокам в системе в общем немного. Единственное, что они могут сделать, — вызвать функцию GetExitCodeThread, проверить, завершен ли поток, идентифицируемый описателем hThread, и, если да, определить его код завершения.

BOOL GetExitCodeThread(

HANDLE hThread,

PDWORD pdwExitCode);

Код завершения возвращается в переменной типа DWORD, на которую указывает pdwExitCode. Если поток не завершен на момент вызова GetExitCodeThread, функция записывает в эту переменную идентификатор STILL_ACTIVE (0x103). При успешном вызове функция возвращает TRUE. К использованию описателя для определения факта завершения потока мы еще вернемся в главе 9.

Соседние файлы в предмете Программирование на C++