Многопоточные приложения - это уже довольно обыденно, и никого ими, скажем прямо, не удивишь. Тем не менее, для начинающих программистов эта тема довольно остра, и написать многопоточное приложение для многих из них - довольно непростая задача. Впрочем, разговор о многопоточности, я так думаю, заинтересует не только новичков...
Вступление
Конечно, газета - это не учебник, а потому, наверное, не стоит так уж подробно объяснять, что такое поток и в чём именно состоит смысл многопоточности. Хотя, пожалуй, несмотря на всё это, о некоторых преимуществах многопоточных приложений перед однопоточными вспомнить, наверное, стоило бы.
В общем-то, суть замены одного потока выполнения приложения на несколько независимых потоков понятна: она требуется в тех случаях, когда вам нужно выполнить в фоновом режиме какие-то довольно-таки ресурсоёмкие операции, которые не должны, в целом, влиять на стабильность и скорость работы головного приложения. Классический пример - это проверка орфографии в различных приложениях, так или иначе связанных с вводом пользователями текста с помощью клавиатуры. Ещё один вариант, при котором вам наверняка понадобится прибегнуть к многопоточности, - это приложение, которое должно выполнять несколько равноправных задач одновременно, как Цезарь. Такое приложение также просто обязано использовать многопоточную модель в тех случаях, когда хотя бы одна из задач, выполняемых приложением, может его потенциально "подвесить". Классический пример здесь - это, наверное, сервер приложений либо какой-нибудь другой сервер. Многопоточность в наше время многоядерных процессоров и многопроцессорных систем также способна существенно поднять производительность приложения, что также, согласитесь, довольно-таки неплохо.
Само собой, платить за всё это приходится сложностью приложения. Если при работе одного потока в рамках одного процесса мы могли вообще не напрягаться по поводу распределения приоритетов в выполнении, прерывании выполнения и прочих подобных вещей по причине банального отсутствия необходимости во всём этом, то при переходе от одного потока к хотя бы двум уже возникают некоторые проблемы. Потоками нужно управлять, зачастую их необходимо прерывать после выполнения той задачи, над которой трудился какой-то конкретный поток, нужно организовывать совместный доступ потоков к тем ресурсам, которые невозможно продублировать для каждого из них в отдельности. Однако это всё не гарантирует правильной работы приложения, в целом, даже при идеальной реализации управляющей потоками логики.
Много потоков vs. много процессов
Многопоточность, к сожалению, вовсе не гарантирует вам хорошей работы приложения при зависании какого-то одного из потоков. Почему? В общем-то, если говорить о чистой теории, то всё хорошо. В реальности же в каждом приложении есть такие узкие места, где потоки сходятся. И когда потоки работают в общем адресном пространстве, то никто не мешает одному из потоков влезть на "чужую территорию", после чего всё уж точно встанет колом.
В качестве варианта реализации приложений, в которых необходимо решить проблему надёжности при одновременном выполнении разных задач, которые решаются приложением, используется разделение приложения даже не на несколько отдельных потоков, а на несколько отдельных процессов. Так, например, сделали разработчики вызвавшего совсем недавно много шума браузера Chrome. Там каждая вкладка - это не отдельный поток, а отдельный процесс, а потому, теоретически, при подвисании одной вкладки остальные вовсе не должны этого замечать, и вкладку эту можно убить таким образом, что сам браузер с остальными открытыми вкладками ничуть не пострадает. Аналогичным образом работают и некоторые современные серверы, также отказавшиеся от поддержки нескольких потоков в рамках одного своего процесса в пользу поддержки нескольких процессов.
Что ж, надо сказать, что эту идею вполне можно признать удачной, однако лишь для относительно ограниченного числа приложений. Во-первых, всё-таки это довольно расточительно - создавать и убивать процессы дольше, чем потоки, да и всяческих ресурсов (хотя бы тех же дескрипторов) требуется больше. Во-вторых, создание приложения, работающего одновременно с несколькими процессами, технически сложнее для большинства современных средств программирования, чем создание просто многопоточного приложения. Почему так? Да потому, что, например, в той же Java есть встроенные средства создания многопоточных приложений, а вот средств создания приложений, "расползшихся" на несколько процессов, в языке до сих пор нет. И вряд ли это минус самого языка - всё-таки, многопоточность для подавляющего большинства приложений очень хорошее решение, и, что самое главное, оно поддерживается как языками программирования, так и разработчиками операционных систем, которые, в общем-то, задумывали процесс как единицу приложения. Так что в вопросе, что лучше, многопоточное приложение или же приложение многопроцессное, сложно дать однозначный ответ.
Мы с вами сейчас, тем не менее, начав разговор под флагом именно классической многопоточности, продолжим его именно в том же ключе.
Проблема многопоточных приложений
Как я уже говорил, при разработке многопоточных приложений возникает ряд задач, которые даже принципиально не могли возникнуть при разработке однопоточных приложений. Проблемы эти, в общем-то, не такие уж не решаемые, поскольку существует ряд специальных и проверенных средств их разрешения. Вот об этих проблемах, равно как и об упомянутых средствах, мы с вами сейчас и будем вести речь.
Большая проблема, в общем-то, всего одна - одновременный доступ к некоторым ресурсам, которые используются ими в приложении совместно. Решается эта проблема путём введения специального вида объектов, которые управляют взаимодействием потоков. Я говорю об объектах, потому что львиная доля всех многопоточных приложений сегодня пишется на объектно-ориентированных языках программирования, хотя, конечно, смысл объектов от того, что они превратятся в обычные флаги в процедурно-ориентированных языках (том же Си, к примеру) не слишком, скажем прямо, изменится.
Итак, значит, наконец-то, собственно, о проблеме. Представьте, что у нас имеется некоторый объект в программе, который имеет смысл глобального, но при этом доступ к нему должен быть осуществлён из нескольких различных потоков, и при этом нужно избежать конфликтов между ними. Хорошим примером является связной список, редактируемый одновременно несколькими потоками. Если не обеспечить синхронизацию, то вполне можно получить ситуацию, когда один поток удаляет элемент из списка, а второй в то же самое время пытается что-нибудь в этот же самый элемент записать. Налицо конфликт, которого необходимо избежать. Избегают его путём синхронизации - фактически, перевода приложения на каком-то этапе его выполнения из многопоточного режима в однопоточный, то есть один поток будет делать то, что ему нужно, а остальные станут ждать, пока он закончит.
Первый вариант синхронизации (ну, или не вариант, а разновидность - в принципе, не так уж и принципиально, как тут сказать) - это мьютексы, или, если говорить по-русски, взаимные исключения. Конечно, в рамках борьбы за чистоту русского языка было бы лучше использовать именно второй термин, однако на деле обычно говорят именно о мьютексах. В общем-то, работу мьютекса мы уже разобрали выше. Если в деталях, то мьютекс работает так: как только какой-либо поток добирается в рамках своего выполнения до области действия мьютекса, тот сразу устанавливается в закрытое состояние (его ещё называют неотмеченным), после чего остальным потокам вход в зону действия этого мьютекса становится запрещён. Если поток выходит из мьютекса, то, соответственно, меняется и состояние этого объекта синхронизации, которое становится неотмеченным, то есть, по-русски говоря, открытым для всех остальных потоков. При этом, правда, есть вероятность того, что потоки начнут соревноваться за доступ к ресурсам, а то и вовсе уйдут в дедлок (то есть, станут заниматься ожиданием освобождения ресурсов, ими же самими и занятыми). Но, несмотря на эти неприятности, которые решаются грамотным проектированием многопоточных приложений и применением специальных защитных алгоритмов, мьютексы применяются очень и очень широко в силу своей концептуальной простоты и следующей из неё напрямую простоты реализации. В некоторых источниках мьютекс называется критической секцией. Это терминологическое различие очень важно в программировании под Windows, однако когда вы будете писать многопоточные приложения с использованием Windows API, вы сами сможете узнать, в чём именно заключается различие между данными объектами. Что касается не Windows, а UNIX (сиречь POSIX-систем), то в них мьютексы называются фьютексами, хотя там тоже есть свои тонкости, о которых, опять-таки, лучше разузнать перед тем, как программировать многопоточные приложения на POSIX API.
Второй вариант синхронизации - это семафоры. В общем-то, можно сказать, что семафор - это расширенный мьютекс, или же, наоборот, что мьютекс - это частный вид семафора (бинарный - только на один процесс). Семафор аналогичным образом ограничивает вхождение потоков в какую-либо область программного кода, но при этом делает это таким образом, что количество потоков, которым в неё можно войти, не ограничивается одним. Для этого семафор использует внутренний счётчик потоков, который увеличивается на единицу при вхождении потока в область действия семафора и уменьшается на ту же самую единицу тогда, когда поток и семафор расстаются. При достижении максимального числа потоков все остальные, желающие попасть туда, будут ожидать, пока вакантное место не освободится. При этом входить в семафор они должны будут именно в том порядке, в котором запрашивали разрешение на вход. Семафоры также не решают проблемы дедлоков, и при этом ещё добавляют проблему синхронизации действий со счётчиком. Эта проблема, однако, тоже вряд ли может быть отнесена к разряду фундаментальных, поскольку действенные методы её решения существуют и применяются уже довольно давно.
В общем-то, пока что никаких кардинально отличающихся от семафоров объектов для синхронизации многопоточных приложений человечество пока что не придумало. В асинхронных системах, по своей природе событийно-ориентированных, зачастую также используется синхронизация с помощью сообщений о соответствующих событиях, при этом обработчик события неявно (или явно) всё равно использует семафорный механизм.
Резюме
Ну вот, в общем-то, почти всё, что можно было рассказать о многопоточных приложениях, не углубляясь при этом в детали реализации и не пытаясь рассказать что-либо для конкретных многопоточных сред и конкретных инструментов программирования. Почему я решил обойтись без конкретики, можете спросить вы. Что ж, спорить не буду, вопрос хороший и правильный. Но всё дело в том, что принципы создания многопоточных приложений, по существу, одинаковы для всех операционных систем (или виртуальных машин, если мы говорим о таких вариантах кросс-платформенной реализации), а также и для всех языков программирования. И гораздо полезнее и интереснее обсудить именно их, а не конкретную реализацию какого-либо многопоточного приложения.
Хотя, конечно, в программировании без практики попросту никуда, и для того, чтобы успешно писать многопоточные приложения, конечно же, одних только знаний о том, как они устроены и как должны функционировать, совершенно недостаточно. Поэтому будет очень хорошо, если вы, в том случае, если вы еще не писали многопоточных приложений, самостоятельно напишете какое-нибудь несложное многопоточное приложение, вооружившись учебником по программированию на любом знакомом вам языке.
Вадим СТАНКЕВИЧ
Горячие темы