developing.ru - клуб программистов Здесь может быть и ваша реклама.
developing.ru >технология COM >
Михаил Безверхов
aspid@developing.ru

А процесс - это химия....и физика

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

Естественно, что самое очевидное решение проблемы постоянной загрузки процессора - попытаться в это время переключить процессор на выполнение другой программы, которой, может быть именно в этот момент и не нужно никакого ввода-вывода, а нужен счёт. И, коль скоро у нас процессор может исполнять более одной программы "одновременно", то появляется и такая абстракция как "поток". Поток, "завёрнутый" в своё адресное пространство называется "процесс" и предполагается, что все потоки в системе, независимо от того, какому процессу они принадлежат, исполняются параллельно и независимо друг от друга. Теоретически не запрещено (и на практике реализуется), чтобы какие-то потоки разделяли общее адресное пространство - тогда получается "многопоточный процесс".

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

Основное же свойство потока, влияющее на всё его применение - его автономность. Как следствие, программирование многопоточных приложений значительно сложнее программирования приложений "обычных", в которых программист может быть твёрдо уверен в том, что и где он сделал. В приложении, где есть более одного потока такой уверенности уже нет - поскольку потоки исполняются параллельно и независимо может оказаться так, что один поток только что прочитал значение переменной X в надежде её изменить и записать на место, а другой - её уже переписал в этот промежуток времени. Ищи, программист, почему в переменную записывалось одно значение, а потом прочиталось совсем другое! Причём, ситуация оказывается значительно хуже, чем наличие "простой баги" - та хоть повторяется при всех прогонах программы совершенно одинаково. А как соотнесутся потоки в процессе параллельного исполнения - не знает никто, поэтому очень вероятна ситуация "пропадающей ошибки", которая будет время от времени появляться по совершенно непонятной причине.

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

Чудес на свете не бывает - сделать так, чтобы из одной и той же ячейки памяти каждый поток выбирал "своё" невозможно. Сделать упорядочение потоков можно только одним способом - в момент, когда поток готов прочитать и/или записать какие-то разделяемые между потоками данные необходимо исключить доступ к ним всех других потоков - на то время, пока этим занимается данный поток. Чтобы никакой другой поток не мог в это время вмешаться и что-то своё вписать. Но поток - это всего-навсего последовательность исполняемых команд, ей нельзя приказать "не делай". Зато - её можно остановить.

Делается это посредством использования специального объекта, называемого обычно "синхронизатор". Синхронизатор представляет собой специальный системный объект, умеющий останавливать исполнение потока, т.е. отнимать у потока процессор. Доступ к синхронизатору осуществляется парой системных функций - одна из которых захватывает синхронизатор, а другая - освобождает этот захват. Функции эти могут быть вызваны из любого потока. Обычный сценарий такой синхронизации весьма прост - первый поток вызывает функцию захвата синхронизатора и продолжает развиваться дальше, как ни в чём не бывало. А синхронизатор - запоминает идентификатор потока, его захватившего. Если уже захваченный синхронизатор попытается захватить второй поток, то тут этот поток и остановится - синхронизатор переключит потоки в системе и взведёт флаг, что второму (и всем прочим, которые будут вызывать функцию захвата данного синхронизатора) потоку процессорного времени выделять не следует - второй поток просто не будет получать процессорного времени. Он будет стоять до тех пор, пока первый поток не вызовет из себя функцию освобождения синхронизатора - тогда синхронизатор сбросится, освободит флаг какого-либо потока из числа остановленных в нём и ... вновь окажется в захваченном состоянии, только уже - вторым потоком. Потом и второй поток освободит синхронизатор и т.д. - важно, что в промежутках между вызовами захвата синхронизатора и его освобождения гарантированно будет исполняться только какой-то один поток. Который уже точно может считать, что он и только он один изменяет данные в этом промежутке исполнения.

А остальные потоки - будут в это время простаивать. Поскольку программа никогда не состоит только из разделяемых между потоками данных, есть и данные к которым по логике программы может обращаться только какой-то один поток, поскольку обращение к разделяемым данным составляет только небольшую часть времени развития потока, то и остановка потоков для синхронизации в правильно спроектированной многопоточной программе - не больше, чем неизбежное зло на небольшой промежуток времени. Либо, напротив, многопоточная программа может быть сконструирована так, что поток всё своё время проводит уткнувшись в синхронизатор, который в данном случае обозначает какое-либо событие. Как только это событие происходит - синхронизатор освобождается, поток получает развитие, обрабатывает событие, вновь взводит синхронизатор и утыкается в него опять. Пример второго подхода демонстрирует, например, функция Win32 GetMessage - она представляет собой вход в синхронизатор, который управляется прибытием в очередь потока оконного сообщения. Пока этих сообщений нет - поток, вызвавший функцию GetMessage на ней и засыпает - ему не выделяется процессорного времени, он просто стоит. Но, как только в очередь прибудет оконное сообщение - синхронизатор освободится, функция выдаст ему прибывшее сообщение и пропустит его развиваться дальше. Поскольку GetMessage вызывается в бесконечном цикле (в Windows хорошо известном, как main message loop), то обработавший сообщение поток снова вернётся к вызову GetMessage. Если на момент её вызова в очереди уже окажется сообщение, то синхронизатор будет свободен - поток и не будет засыпать, а, получив это сообщение, просто свободно пойдёт в новую итерацию цикла обработки сообщений.

Для удобства программирования в операционной системе обычно имеется несколько типов синхронизаторов. Одни из них действительно пропускают один и только один поток, другие - могут пропускать не более, чем заранее установленное их количество, третьи - действуют не только в отношении потоков своего процесса, но и вообще в отношении любого потока в системе и позволяют синхронизировать уже процессы. Имеется также и несколько вариантов захвата/освобождения синхронизаторов - можно ждать освобождения только одного синхронизатора, а можно - ждать, пока одновременно не окажется свободно их некоторое количество. Всё это позволяет охватить всё множество событий, которое по логике программы должно позволить продолжать развиваться некоторым потокам, а другие - заставить стоять.

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

О сказанном последним можно сделать и небольшое отступление - порядок захвата синхронизаторов весьма существенен и приводит к специфической ошибке, которая называется deadlock. Она известна только в многопоточной среде и средств борьбы с ней (кроме правильного программирования, естественно) не установлено. Представим себе, что у процесса есть два ресурса, защищённых синхронизаторами A и B и два потока X и Y. Программа же составлена так, что поток X последовательно захватывает ресурс A и пытается захватить ресурс B, а поток Y - напротив, захватывает сначала ресурс B, а потом захватывает ресурс A. Естественно, что потом захват обоих ресурсов потоки освобождают. Пока у нас исполняется только один поток, то проблем нет. Но вот поток X захватил ресурс A и пытается захватить ресурс B, а в это время поток Y уже захватил ресурс B... Поток X, естественно, остановится - он будет ждать, пока поток Y не освободит ресурс B. Но программа составлена так, что захвативший ресурс B поток Y должен захватить ресурс A, а тот - уже захвачен потоком X пока он не уснул в синхронизаторе B. Останавливается и поток Y - ждать, пока поток X не освободит синхронизатор A. Но X - сам стоит и освободить синхронизатор A сможет не ранее, чем захватит B. В результате - встают оба потока, при всём том, что монопольно - правильно будет исполняться любой. Правильной же последовательностью захвата-освобождения, которая не вызовет этой ошибки взаимной блокировки будет только такая в которой все потоки всегда захватывают синхронизаторы в одной и той же последовательности - сначала A, потом B, либо наоборот, главное, чтобы эта последовательность во всех потоках была одна и та же. Но сделать так, имея в виду, что существенна именно последовательность захвата, а не последовательность исполняемого кода - не всегда очевидно.

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

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

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

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

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

предыдущий выпускархив и оглавлениеследующий выпуск
Авторские права © 2001 - 2004, Михаил Безверхов
Публикация требует разрешения автора

разделы сайта

форум

технология COM

оптимизация сайтов


© 2000-2004 Клуб программистов developing.ru