Сетевые сервисы: Классическая архитектура против многопоточной

Что выгоднее создавать: процессы выполнения или потоки выполнения?

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

Крис Херборт, внештатный сотрудник, независимый писатель

Photo of Chris HerborthКрис Херборт (Chris Herborth) уже более 10 лет пишет об операционных системах и программировании. Он выигрывал награды как старший технический писатель. Если он не играет с сыном Алексом или просто проводит время с женой, Крис посвящает свое свободное время написанию статей и исследованию видео игр (то есть, игре).



15.01.2009

Введение

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

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

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

Расщепление процессов с использованием fork()

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

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

Рисунок 1. Расщепленные процессы
Рисунок 1. Расщепленные процессы

В листинге 1 показан код программы, использующей fork().

Листинг 1. Код программы, использующей fork().
#include <unistd.h>
#include <sys/types.h>

int main( int argc, char **argv )
{
    pid_t child = 0;

    child = fork();
    if( child < 0 ) {
        /* произошла ошибка! */
        handle_error();
    } else if( child == 0 ) {
        /* родительский процесс. */
		
        handle_parent_duties();
    } else {
        /* дочерний процесс. */
        handle_child_duties();
    }

    return 0;
}

Каковы же недостатки fork()?

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

Теперь, после того как были рассмотрены причины, по которым не следует использовать fork(), стоит познакомиться с потоками и рассмотреть несколько примеров исходного кода.

Легковесные потоки

Из-за недостатков fork() UNIX-разработчики начали создавать более специализированные решения, которые в подходящих ситуациях были бы более оптимальны, чем устаревший метод fork(). В конечном счете, были созданы легковесные потоки управления. Под процессами теперь понимается один или несколько потоков выполнения, а не просто поток инструкций.

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

Рисунок 2. Новые потоки в процессе
Рисунок 2. Новые потоки в процессе
Листинг 2. Код программы, реализующей данный подход
#include <pthread.h>
#include <stddef.h>

void *thread_function( void *data )
{
    /* делаем что-нибудь полезное. */
    return NULL;
}

int main( int argc, char **argv )
{
    pthread_t threadId = 0;
    pthread_attr_t attr;

    pthread_attr_init( &attr );
    pthread_attr_setdetachstate( &attr, PTHREAD_CREATE_DETACHED );
	
    (void)pthread_create( &threadId, &attr, &thread_function, NULL );

    /* решаем другие задачи в то же время, когда работает поток. */
    return 0;
}

Как можно заметить, для создания потока требуется немного больше усилий, нежели при использовании метода fork(), но вместе с тем данный подход предоставляет гораздо больше возможностей в плане гибкости и управления.

Каковы недостатки потоков?

  • Необходимо использовать потокобезопасные библиотеки или осторожно организовывать вызовы функций, которые сохраняют промежуточное состояние между вызовами. В UNIX есть функции, специально предназначенные для сохранения промежуточного состояния; например функция rand(), которая сохраняет внутренние данные в статической переменной, и имеет потокобезопасный аналог rand_r() для систем, которые поддерживают POSIX-потоки. Потокобезопасные библиотеки в большинстве случаев доступны для повсеместного использования. Поэтому никаких проблем не возникнет при условии, что мы не будем забывать о необходимости использования потокобезопасных функций. А если забудем об этом, то приложение будет работать неадекватно, а ошибка будет трудноуловимой.
  • Необходимость отладки взаимной блокировки потоков. Данная ошибка может быть вызвана некорректным использованием методик синхронизации (например, мьютексов и условных переменных), и ее отладка может оказаться трудной задачей.
  • Необходимо избавиться от дурных привычек при программировании, например, от использования глобальных и статических переменныхе внутри функций.

Теперь, когда мы рассмотрели структуры программы с методом fork() и структуру программы с потоками, давайте создадим завершенную программу для иллюстрации каждого из подходов.

Пара слов о создаваемом программном коде

Ссылку для скачивания программного кода, используемого в этой статье, можно найти в разделе Материалы для скачивания. При помощи команды File>Import можно импортировать проекты с исходным кодом, использующие make, в Eclipse. Eclipse является превосходной платформой и интегрированной средой разработки с большим количеством плагинов от сторонних разработчиков. Более подробная информация об Eclipse приведена в разделе Ресурсы.

Программный код

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

  • длинные операции ввода/вывода;
  • сетевые службы;
  • анализ информации;
  • криптография;
  • сжатие данных;

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

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

Программа с fork()

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

В листинге 3 можно увидеть стандартные заголовки, необходимые для объявления функций и других идентификаторов, используемых в данной программе. При компоновке не нужно использовать никаких дополнительных библиотек, поскольку используются стандартные функции C. Будут использоваться 32 дочерних процесса; тогда с учетом родительского процесса над одной задачей будут работать 33 процесса. Кроме того, создается глобальная переменная для учета количества выполняющихся рабочих процессов. Эта переменная понадобится в конце функции main() для аккуратного завершения работы программы.

Листинг 3. Заголовки и объявления переменных
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>

#define FORK_WORKERS 32

volatile static int activeWorkers = 0;

Ниже представлена обычная функция work (листинг 4). Если представленный ниже программный код не интересен читателю, его можно заменить каким-нибудь более полезным программным кодом. Все компоненты функции fork_worker(), включая создание случайных чисел при помощи метода rand_r(), заслуживают отдельного рассмотрения. Поскольку rand() не является потокобезопасной функцией, в программном коде вместо нее используется функция rand_r(). Функция rand() поддерживает состояние системы при помощи внутренней статической переменной; для многопоточного приложения данный подход является ошибкой. Для программы, использующей fork(), наличие подобной статической переменной не столь важно, хотя и является признаком дурного тона в программировании.

Листинг 4. Функция, использующая fork()
void fork_worker( void )
{
    unsigned int randState = 0;
    int idx = 0;
	
    randState = (unsigned int)getpid();
	
    for( idx = 0; idx < ( FORK_WORKERS * FORK_WORKERS ); idx++ ) {
        int val = rand_r( &randState );
        if( val > randState ) {
            val++;
        } else {
            val--;
        }
    }
	
    exit( EXIT_SUCCESS );
}

Эту небольшую функцию вызывает обработчик сигналов SIGCHLD, который будет создан далее в функции fork(). Менеджер процессов в UNIX посылает сигнал SIGCHLD к родительскому процессу всякий раз, когда какой-либо из дочерних процессов завершает свою работу. Это дает шанс получить статус дочернего процесса в момент выхода при помощи функции wait(). Благодаря этому можно не допустить того, чтобы завершившийся дочерний процесс стал зомби-процессом. Зомби - это все, что осталось от программ после завершения их выполнения - может быть, только идентификатор процесса и код выхода или дополнительная информация, которую схэшировал менеджер процессов. Если не удалять зомби-процессы при помощи функций wait() и waitpid(), которые позволяют ожидать завершения определенного процесса, то таблица процессов переполнится, и создавать новые процессы будет невозможно.

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

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

Листинг 5. Обработчик сигнала
void worker_exitted( int sig )
{
    int status;
	
    activeWorkers--;
    (void)wait( &status );
}

После объявления нескольких переменных в листинге 6 будет показано, как использовать sigaction() для настройки обработчика сигналов таким образом, чтобы предотвратить появление зомби-процессов.

Цикл for содержит самый интересный участок выполнения программы. При вызове fork() родительский процесс разделяется на два идентичных процесса - сам родительский процесс и дочерний процесс. После расщепления родительский процесс получает от fork() идентификатор дочернего процесса, а сам дочерний процесс получает 0. Если в момент создания дочернего процесса происходит ошибка, возвращается отрицательный код ошибки.

Оператор if, который следует за вызовом fork(), обрабатывает возможные ошибки. Если их нет, и при этом текущий процесс является дочерним, то вызывается рассмотренная ранее функция fork_worker(). Если выполняется родительский процесс, то увеличивается счетчик выполняющихся дочерних процессов.

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

Листинг 6. Функция main
int main( int argc, char **argv )
{
    pid_t ppid = 0;
    int idx = 0;
    struct sigaction signalHandler;
    sigset_t mask;
	
    (void)sigemptyset( &mask );
    signalHandler.sa_handler = worker_exitted;
    signalHandler.sa_mask = mask;
    signalHandler.sa_flags = 0;

    (void)sigaction( SIGCHLD, &signalHandler, NULL );
	
    printf( "Launching %d child processes...\n", FORK_WORKERS );

    /* создание дочернего процесса */
	for( idx = 0; idx < FORK_WORKERS; idx++ ) {
        ppid = fork();
        if( ppid < 0 ) {
             printf( "Error creating child %d:  %s\n", idx, 
                     strerror( errno ) );
        } else if( ppid == 0 ) {
            /* это дочерний процесс. */
            fork_worker();
        } else {
            /* это родительский процесс. */
            printf( "Child %d's PID is %d\n", idx, ppid );
            activeWorkers++;
        }
    }

    sleep( 1 );

    /* ожидание, когда завершат работу дочерние процессы */
    printf( "Waiting for %d children to exit...\n", activeWorkers );
    while( activeWorkers > 0 ) {
        int status = 0;
        if( wait( &status ) == -1 ) {
            /* All children have exited already. */
            break;
        }
        activeWorkers--;
		
        if( WIFSIGNALED( status ) ) {
            printf( "Child exitted due to signal %d\n", WTERMSIG( status ) );
        } else {
            printf( "Child's exit status was %d\n", WEXITSTATUS( status ) );
        }
    }
	
    printf( "Done!\n" );
	
    return EXIT_SUCCESS;
}

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

Потоки

Несмотря на то что многопоточная версия программы выглядит более сложной, на самом деле она более проста. API-интерфейс POSIX-потоков значительно более гибок, чем устаревшие функции fork() и wait().

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

Также можно определить EOK, поскольку не все заголовки errno.h содержат определение значения "no error" (листинг 7).

Листинг 7. Заголовочные файлы и определения
#include <errno.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
 
#define THREAD_WORKERS 32

#if !defined(EOK)
#  define EOK 0
#endif

В листинге 8 приведена функция thread_worker(), которая почти идентична функции fork_worker(), рассмотренной ранее. Будет использоваться функция rand_r(), поэтому при генерации случайных чисел другие потоки никак не переплетутся с состоянием системы, которое получается вызовом функции rand(). Для каждой библиотечной функции существует "_r"-версия, которая сохраняет состояние системы между своими вызовами.

Листинг 8. Поток
void *thread_worker( void *data )
{
    unsigned int randState = 0;
    int idx = 0;
	
    randState = (unsigned int)getpid();
	
    for( idx = 0; idx < ( THREAD_WORKERS * THREAD_WORKERS ); idx++ ) {
        int val = rand_r( &randState );
        if( val > randState ) {
            val++;
        } else {
            val--;
        }
    }
	
    return NULL;
}

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

Вызов функции pthread_create() создает новый поток и затем запускает его на выполнение. Эта последняя деталь может быть особенно важной, если перед продолжением выполнения потока необходимо установить дополнительные параметры; используйте мьютекс или другие инструменты синхронизации для блокировки нового потока.

Завершающий цикл for (листинг 9) перебирает все идентификаторы потоков (thread IDs) и использует вызов метода pthread_join() для ожидания завершения окончания работы каждого потока и его выхода. Ничего особенного для получения значения, с которым поток завершает свое выполнение, не требуется.

Листинг 9. Функция main
int main( int argc, char **argv )
{
    pthread_t threadIds[THREAD_WORKERS];
    int idx = 0;
    int retval = 0;

    printf( "Creating %d child threads...\n", THREAD_WORKERS );

    /* создание и запуск дочернего потока */
    for( idx = 0; idx < THREAD_WORKERS; idx++ ) {
        retval = pthread_create( &(threadIds[idx]), NULL, 
                                 &thread_worker, NULL );
								  
        printf( "Child %d's thread ID is %d\n", idx, (int)threadIds[idx] );
    }
	
    sleep( 1 );
	
    /* ожидание до тех пор, пока завершатся дочерние потоки */
    for( idx = 0; idx < THREAD_WORKERS; idx++ ) {
        int status = 0;
        retval = pthread_join( threadIds[idx], (void *)&status );
		
        if( retval == 0 ) {
            printf( "Thread %d's exit status was %d\n", 
					(int)threadIds[idx], status );
        } else {
            printf( "Error joining thread %d\n",
                    (int)threadIds[idx] );
        }
    }
	
    printf( "Done!\n" );
	
    return EXIT_SUCCESS;
}

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

Заключение

Существует одна проблема, с которой приходится столкнуться при разработке сетевого сервиса для UNIX: как продолжать устанавливать входящие соединения и одновременно обслуживать уже подключившихся клиентов. Как же решить эту проблему?

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

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


Загрузка

ОписаниеИмяРазмер
Проект для Eclipse 3.1, демонстрирующий fork()es-forkWork.zip  ( HTTP | FTP | Download Director Помощь )23KB
Проект для Eclipse 3.1, демонстрирующий потокиes-threadWork.zip  ( HTTP | FTP | Download Director Помощь )23KB

Ресурсы

Научиться

Получить продукты и технологии

  • IBM trial software: ознакомительные версии программного обеспечения для разработчиков, которые можно загрузить прямо со страницы сообщества developerWorks.(EN)

Обсудить

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=AIX и UNIX, Open source
ArticleID=363689
ArticleTitle=Сетевые сервисы: Классическая архитектура против многопоточной
publish-date=01152009