Отправляет email-рассылки с помощью сервиса Sendsay
  Все выпуски  

Многопоточное программирование. Часть 1


Домашняя страница www.devdoc.ru

DevDoc - это новые статьи по программированию каждую неделю.

Заходи и читай!

Домашняя страница Письмо автору Архив рассылки Публикация статьи

Выпуск №30

Здравствуйте уважаемые подписчики, сегодня в номере:

  • От Автора
  • Результаты опроса
  • Статья "Многопоточное программирование. Часть 1"

От Автора

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

Проведение конкурса откладывается. Уже практически все готово и я объявлю о начале конкурса дополнительно.

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

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

У меня убедительная просьба, если у вас появляются замечания по материалу или вы хотите увидеть ответы на свои вопросы относительно многопоточности – оставляйте комментарии к статьям или пишите мне напрямую. Я не смогу ответить всем персонально, однако включу ответы на все вопросы в последующие статьи.

Результаты опроса

Опросы постоянно проводятся на сайте www.devdoc.ru.

Результаты опроса
Как давно вы занимаетесь программированием?

1До 1 года
12% ( 5 )
 
2От 1 до 2 лет
12% ( 5 )
 
3От 2 до 5 лет
24% ( 10 )
 
4Свыше 5 лет
40% ( 17 )
 
5Я не программист
12% ( 5 )
 

Всего голосов: 42
Последний голос отдан: Вторник - 25 Сентября 2007 - 15:41:14


Постоянная ссылка на статью (с картинками): http://www.devdoc.ru/index.php/content/view/multi_thread_1.htm

Автор: Кудинов Александр
Последняя модификация: 2007-09-25 08:48:07

Многопоточное программирование. Часть 1

Введение

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

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

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

Даже если вы работали с потоками, думаю, что базовая часть будет Вам интересной. Ведь помимо API и общих принципов мы рассмотрим несколько вещей, которые “спрятаны” в недрах документации.

Базовые сведения о потоках

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

Поток можно рассматривать как часть программы, которая может выполняться “одновременно” с другими частями. Скажем у нас есть две функции:

void foo() {}
void bar() {}

Обе они могут выполняться “одновременно”. Т.е. код одной функции вообще не будет “замечать”, что рядом с ним выполняется еще одна. Я сознательно беру слово “одновременно” в кавычки. В компьютере стоит конечное число процессоров, как правило не больше 4-х. Даже если каждый из них двухядерный – они смогут выполнять одновременно не более 8 задач. А потоков в системе может быть несколько сотен. Очевидно, что они не могут выполняться одновременно. В ОС Windows реализована вытесняющая многозадачность. Т.е. потоки выполняются по очереди в течение определенного времени. Это время называется квантом. Его размер может зависить от многих факторов, в том числе от настроек ОС, приоритета потока, загруженности процессора и т.п. После того, как один поток отработал свой квант времени – ОС его останавливает и начинает выполнение другого потока. Переключение потоков происходит очень быстро, поэтому на глаз совсем не видно, что они работают по очереди. Создается впечатление, что каждый поток работает все время, “одновременно” с другими.

При запуске программы ОС всегда создает один поток – главный. Именно в нем программа начинает свое выполнение. В зависимости от типа программы и настроек компилятора в главном потоке может выполняться функция main, WinMain, _tmain или функция, заданная пользователем.

Поток - это объект ядра ОС, который используется для получения/установки свойств потока, управления потоком и планирования времени выполнения. С каждым потоком связаны следующие компоненты:

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

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

Для чего используются потоки?

Поток определяет последовательность исполнения кода в процессе. При старте процесса система создает главный поток. Обычно он начинается со стартового кода CRT библиотеки (для С++), который после первичной инициализации вызывает функцию WinMain или аналогичную. Главный поток живет до тех пор, пока самая первая функция в стеке не завершила свое выполнение.

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

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

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

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

Надо очень хорошо понимать, за счет чего многопоточность дает выигрыш в быстродействии. На этот счет существует масса заблуждений. Вернемся опять к конвертации видео. Скажем, у нас есть 2 видео-файла, которые нам надо преобразовать в другой формат. Операция преобразования очень трудоемкая и требует массу процессорного времени. В идеале – чем мощнее процессор, тем выше скорость преобразования. Как вы думаете, что быстрее: выполнять конвертацию файлов по очереди или в разных потоках? Тут нет однозначного ответа. Если в вашем распоряжении один процессор – то последовательное преобразование немного быстрее! Имейте это ввиду, когда слышите дифирамбы о производительности многопоточных приложений. Все просто! Ведь потоки выполняются по очереди. И ОС тратит дополнительное время на переключение между ними. Даже при работе одного потока – CPU будет загружен. Т.е раз нет простоев, то нет и выигрыша от дополнительного потока. Если же в компьютере есть несколько процессоров – то выгоднее использовать потоки. В каждом случае надо взвешивать все за и против.

Потоки не всегда приносят пользу. При неграмотном применении они могут создавать значительные проблемы. Так например, если каждое окно пользовательского интерфейса создавать в отдельном потоке – вы получите больше вреда, чем пользы. Потоки ВСЕГДА усложняют разработку программы в той или иной степени. Поэтому не надо создавать потоки по каждому возможному поводу. Если можно обойтись без потоков и при этом достичь желаемого результата с минимальными усилиями – обходитесь без потока. О технике создания многопоточных приложений, синхронизации и т.п. мы поговорим в следующих статьях.

Создание и работа с потоками

Каждый поток начинает свое выполнение с некоторой входной функции. У функции должен быть следующий прототип:

DWORD WINAPI ThreadProc(PVOID pPararn);

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

DWORD WINAPI ThreadProc(PVOID pPararn);
{ 
 return 0; 
}

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

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

Когда счетчик объекта ядра обнуляется – система его удаляет. Получается, что объект ядра может жить дольше, чем сам поток. Это сделано для того, чтобы остальные части программы могли получать доступ к информации о потоке, даже если его уже не существует. Например, если надо узнать код завершения потока.

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

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

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

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

Создание потока в Windows происходит с помощью вызова API фукнции:

HANDLE CreateThread(PSECURITY_ATTRIBUTES psa, DWORD cbStack, 
  PTHREAD_START_ROUTINE pfnStartAddr, PVOID pvParam, DWORD tdwCreate, PDWORD pdwThreadID);

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

Замечание! CreateThread - это WinAPI функция. Если вы пишите на Visual С++, то вместо нее для создания потока рекомендуется использовать beginthreadex из CRT библиотеки. Мы рассмотрим различие между этими функциями позже.

Параметры функции CreateThread следующие:

  • psa – указатель на структуру SECURITY_ATTRIBUTES. Если вы хотите, чтобы потоку были присвоены параметры защиты по умолчанию – передайте сюда NULL.
  • cbStack – размер стека потока. Если параметр равен нулю – используется размер по умолчанию. Если вы передаете не нулевое значение, система выберет большее между настройками текущего исполняемого файла и вашим значением. Этот параметр резервирует только адресное пространство, а физическая память выделяется по мере необходимости.
  • pfnStartAddr – это указатель на потоковую функцию. Прототип функции мы рассмотрели выше.
  • pParam – произвольное значение. Этот параметр идентичен параметру потоковой функции. CreateThread передаст этот параметр в потоковую функцию. Это может быть число, либо указатель на структуру данных. Можно создавать несколько потоков с одной и той же потоковой функцией. Каждому потоку можно передавать свое значение.

    Внимание, не передавайте сюда указатель на локальные переменные! Т.к. родительский поток работает одновременно с новым – локальные переменные могут выйти из области видимости и разрушиться компилятором. В то время, как новый поток будет пытаться получить к ним доступ.

  • tdwCreate – дополнительные параметры создания потока. Может принимать значение 0 если надо начать выполнение потока немедленно, либо CREATE_SlJSPENDED. В последнем случае система выполняет всю инициализацию, но не запускает выполнение потока. Поток можно запустить в любой момент, вызвав WinAPI функцию ResumeThread.
  • pdwThreadID – указатель на переменную, которая на выходе будет содержать идентификатор потока. Windows 2k+ позволяет передавать сюда NULL, если Вам не нужно это значение. Однако я рекомендую всегда передавать адрес переменной для совместимости с более ранними ОС.

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

Поток может завершиться в следующих случаях:

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

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

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

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

Завершение потока принудительным образом извне (TerminateThread, завершение процесса) может вызвать проблемы не только с корректным освобождением ресурсов, но и с логикой работы программы. Например, “убиенный” поток не освободит доступ к занятым ресурсам и объектам синхронизации. В результате остальная часть программы может повести себя непредсказуемым образом.

Продолжение следует....

В следующей статье:

  • Описание внутренностей потока
  • Использование CRT функций в многопоточном приложении

Если вам нравиться эта рассылка рекомендуйте ее своим друзьям. Подписаться можно по адресу http://subscribe.ru/catalog/comp.soft.prog.devdoc

Copyright (C) Kudinov Alexander, 2006-2007

Перепечатка и использование материалов запрещена без писменного разрешения автора.


В избранное