Внешний вид сайта:

Потоки vs процессы

Сейчас процессоры стали многоядерными. Т.е. физический процессор в количестве одной штуки на самом деле работает как два процессора. Чтобы постоянно не говорить одно и то же, далее я буду считать процессорное ядро эквивалентом процессора. Т.е. под «однопроцессорной машиной» будет подразумеваться машина с одним одноядерным процессором, а под «многопроцессорной машиной» будет подразумеваться либо многопроцессорная машина, либо машина с одним многоядерным процессором.

Примеры случаев использования потоков

Пример на использование потоков №1

Нужно ускорить выполнение программы за счёт разбиения одной большой задачи на несколько независимых подзадач, которые можно исполнять параллельно. Тупой искусственный пример. Есть массивы A, B, C по миллиону элементов. В цикле от 0 до 999999 надо выполнить операцию "A[i] = B[i] + C[i]". Каждая итерация цикла является независимой по отношению к другим итерациям цикла. Поэтому процесс вычисления можно разбить на два цикла: от 0 до 499999 и от 500000 до 999999. Циклы эти можно разнести по двум разным потокам, в результате чего они будут исполняться параллельно и скорость вычисления увеличится почти в два раза. "Почти", потому что на создание и удаление потока есть накладные расходы, которые являются константными по времени. А потому чем длиннее цикл, тем меньшую долю в общем времени будут занимать накладные расходы. Обратное тоже верно. А потому если, например, изначальный цикл был на 10 итераций, то при разбиении его на два потока по 5 итераций будет работать гораздо медленнее, т.к. накладные расходы на создание потока будут гораздо выше, чем ускорение за счёт параллельного вычисления.

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

При разбиении на потоки с целью ускорить программу нужно понимать следующее. После того, как вычисления закончатся, нужно все потоки вычисления засинхронизировать. Т.е. в программе будет точка, в которой нужно будет дождаться окончания работы всех потоков. И пока потоки не завершатся, исполнение программы дальше не пойдёт. Поэтому потоки должны быть сбалансированными по времени. Т.е если один поток будет работать 1 секунду, а второй 10 секунд, то первый поток будет 9 секунд вхолостую ждать завершения второго потока и все вычисления отработают за 10 секунд против 11 секунд без разбиения на потоке. Т.е. ускорение будет не в два раза, а всего на 10%

Вычисления можно разбивать и на большее количество подзадач. Мы цикл из 1000000 итераций разбили на 2 подцикла по 500000 итераций, но могли бы разбить, например, на 4 цикла по 250000 итераций. То, насколько широко нужно рапараллеливать программу, зависит от того, сколько процессоров в системе. Т.е. на двухпроцессорной машине программа, разбитая на 4 потока, будет работать с такой же скоростью, как и программа, разбитая на 2 потока (на самом деле чуть медленнее из-за накладных расходов). "Правильно" написанная программа должна в run-time вычислять, сколько процессоров в системе и автоматически разбивать себя на нужное количество потоков. Но можно обойтись и разбиением на фиксированное количество потоков, т.к. технически код программы будет выглядеть намного проще и понятнее. При этом на однопроцессорной и машине код будет работать чуть медленнее, чем без разбиения на потоки, а на двухпроцессорной - чуть медленнее, чем удвоенная скорость на однопроцессорной машине. Зато на четырх и более процессорах программа будет работать чуть медленнее, чем учетверённая скорость на однопроцессорной машине. Когда мы разбивали программу на 4 потока, мы не знали, на какой машине будет она работать. Но зато наша программа готова к работе на многопроцессорных машинах, а в отсутствии многопроцессорности она будет работать с небольшим замедлением

Пример на использование потоков №2

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

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

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

Пример на использование потоков №3

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

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

Примеры случаев использования процессов

Пример на использование процессов №1

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

Программу можно было устроить следующим образом. Запоминать каждое клиентское соединение, запоминать каждое соединение с сервером, запоминать, какому серверному соединению соответствует какое клиентское соединение и при поступление данных с серверного соединения перекидывать их на соответствующее клиентское соединение, а при поступлении данных с клиентского соединения перекидывать их на соответствующее серверное соединение.

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

Но потом пришла мысль, что можно поступить гораздо проще. Все соединения являются полностью независимыми друг от друга. А потому можно их развести по разным процессам. И тогда программа получается предельно простой. Дождались клиентского соединения, после чего fork'нулись. Родительский процесс остался ожидать следующего клиентского соединения. А дочерний процесс подсоединился к серверу и начал пробрасывать данные между единственным сервером и единственным клиентом (т.е. не надо запоминать динамические списки и поддерживать их). В случае проблем или подвисаний с любой из сторон программа просто завершается и всё (при этом другие клиентские соединения в других программах успешно работают).

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

Комментарии

Нет комментариев. Ваш будет первым!