Содержание


Пример разработки простого многопоточного сетевого сервера

Часть 5. Способы параллельной обработки сетевых запросов (процессы, потоки и их комбинации)

Comments

Серия контента:

Этот контент является частью # из серии # статей: Пример разработки простого многопоточного сетевого сервера

Следите за выходом новых статей этой серии.

Этот контент является частью серии:Пример разработки простого многопоточного сетевого сервера

Следите за выходом новых статей этой серии.

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

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

Простая обработка

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

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

Многопроцессная обработка

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

while (handleConnectionsIsTrue) {

    connectedSocket = accept(...);

    if (0 < connectedSocket) {

        pid = fork();

        if (0 < pid) {

            // контекст процесса-родителя, можно вернуться к ожиданию

            storeNewChildPID(pid);

            close(connectedSocket);

        }

        else if (0 == pid) {

            // контекст процесса-потомка, можно обрабатывать соединение

            handleNewClientConnection(connectedSocket);

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

            stopChildCorrectly();

            exit(EXIT_SUCCESS);

        }

        else {

            // ошибка порождения процесса, надо отреагировать

        }

    }

    else {

        // в работе accept(2) случилась ошибка, надо отреагировать

    }

}

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

При изменении состояния процесса-потомка (приостановлен, возобновлён или завершён) ядро отправляет родителю сигнал SIGCHLD, который можно обрабатывать в контексте родителя и предпринимать соответствующие действия. Обработку этого сигнала надо включать явно, поскольку по умолчанию он игнорируется. По приходу сигнала можно запускать обход списка сохранённых pid потомков, на каждый вызывать, например, функцию waitpid(2) в неблокирующем режиме и смотреть на результат. Можно избежать такого перебора списка передачей родителю из потомка (перед его завершением) pid этого потомка через какой-либо из методов IPC (Inter Process Communication) - канал (pipe), сокету, файл или разделямую память. В этом случае есть "возможность" получить сразу несколько pid в результате почти одновременного завершения сразу нескольких потомков, из-за чего несколько сигналов могут "слиться" в один. Тем не менее, если ваше приложение работает сразу с множеством потомков, даже такое "пакетное" получение pid предпочтительнее перебора всего списка на каждое появление сигнала. Побочным эффектом использования процессов для распараллеливания обработки является относительная независимость этой самой обработки между процессами, так как у каждого процесса своё собственное изолированное пространство памяти и свои ошибки. Всё это означает, что критический сбой в одном из процессов обычно не приводит к краху всей программы. В зависимости от ваших пристрастий и опыта изолированность памяти процессов может быть и минусом, поскольку благодаря ей у процессов изначально нет общих переменных или каких-то ещё структур данных, и для обмена информацией между ними придётся прикладывать дополнительные усилия и организовывать его при помощи методов IPC. При этом потребуется уделить внимание и синхронизации (для обхода проблемы «писателей» и «читателей»), чтобы данные при таком обмене не портились. Простым решением этой проблемы может быть использование каналов (pipes), но и у них есть свои ограничения. Наиболее производительным при существенных объёмах (сотни килобайт и больше) выглядит несинхронный обмен данными через разделяемую память, но при условии, что данные используются по месту хранения, без дополнительного копирования из разделяемой памяти куда-то ещё, а это потребует внимательного и аккуратного проектирования и, скорее всего, усложнит приложение.

Многопоточная обработка

В настоящее время в GNU/Linux потоки отличаются от процессов в основном набором свойств и вещами вроде вызова функций семейства execve(2), exec(3) в одном из потоков. Т.е. и процессы, и потоки являются объектами планирования для планировщика ядра, могут независимо друг от друга блокироваться, получать сигналы и т.д. Собственно, даже порождение процессов и потоков происходит схожим образом при помощи функции clone(2) перечислением нужных флагов, определяющих свойства порождаемого объекта (подробнее можно посмотреть в мануале к этой функции).

Тем не менее, это всё-таки детали конкретной реализации, а взаимоотношения между процессами и потоками остались прежними:

  • потоки можно представлять как отдельные нити каната или жилы кабеля, но при этом потоки всегда работают в контексте какого-то процесса: потоков без процессов не бывает (кроме как в самом ядре);
  • потоки одного процесса совместно используют (разделяют) адресное пространство этого процесса; это значит, что потоки могут работать с данными друг друга без использования IPC, просто средствами языка программирования как с обычными переменными (с учётом области видимости, разумеется) внутри единой программы;
  • некоторые сигналы (вроде SIGSEGV) вызывают принудительное завершение всего процесса (появление SIGSEGV связано с обнаружением нарушения адресного пространства, а это влияет на все потоки процесса), тогда как появление SIGSEGV в одном процессе обычно не влияет на другой процесс;
  • функции вроде execve(2) запускают замещение кода всего текущего процесса (вместе с уничтожением всех его потоков) кодом из указанного исполняемого файла.

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

Отреагировать на входящее соединение порождением потока можно, например, так (это тоже только иллюстрация, а не полноценный код):

void* threadFunction(void*) {

    // контекст потока-потомка;
    // здесь организована обработка запроса, выход из этой функции 
	// приведёт к завершению потока, поэтому всякие действия вроде 
	// закрытия файлов и освобождения памяти надо
    // выполнять не выходя из этой функции

}

...

pthread_t* threadId;

int result = 0;

...

while (handleConnectionsIsTrue) {

    connectedSocket = accept(...);

    if (0 < connectedSocket) {

        result = pthread_create(threadId, ...);

        if (0 == result) {

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

            close(connectedSocket);

            storeNewChildTID(threadId);

        }

        else {

            // ошибка порождения потока, надо отреагировать;
            // result содержит код ошибки

        }

    }

    else {

           // в работе accept(2) случилась ошибка, надо отреагировать

    }

}

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

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

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

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

Важным моментом в разработке многопоточных приложений является поточная безопасность (thread safety) вызываемых в потоках функций: безопасность или надёжность при одновременном выполнении функции в контексте нескольких потоков одного процесса. Обеспечить это можно, например, при помощи уже упомянутой выше критической секции и/или других способов:

  • повторновходимость (re-entrancy): в общем случае невозможно предсказать как будет спланирована работа процессов и потоков планировщиком ядра (в какой последовательности, на какое время) и может случиться так, что один поток будет приостановлен во время выполнения кода какой-то функции и управление будет передано другому потоку, который выполнит ту же функцию, а затем будет возвращено обратно приостановленному потоку. Повторновходимыми являются функции, при выполнении кода которых потоки не заметят такой "подмены". С другой стороны, неповторновходимые функции можно использовать как "индикаторы фактов планирования" (хоть и не со 100% срабатыванием). Добиться повторновходимости можно использованием только локальных переменных и исключением работы с глобальными переменными на изменение.
  • локальное хранение: перед работой со всем нужными данными уже в контексте потока нужно сначала сделать их копии в локальные для поточной функции переменные, которые для каждого потока свои собственные.
  • атомарность: есть ряд операций, при выполнении которых ядро гарантирует непрерывность, т. е. если они начаты, то они обязательно будут завершены в контексте текущего потока без его приостановки; как правило это элементарные операции вроде инкремента/декремента.

Процессно-поточные комбинации

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

Заключение

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

Смешанный подход можно применить когда нужно объединить оба упомянутых варианта в одном приложении.

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


Ресурсы для скачивания


Похожие темы


Комментарии

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

static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Linux, Open source
ArticleID=525128
ArticleTitle=Пример разработки простого многопоточного сетевого сервера: Часть 5. Способы параллельной обработки сетевых запросов (процессы, потоки и их комбинации)
publish-date=09212010