Поддержка

КомпанияПродуктыПоддержкаСкачатьФорумКонтакты
Главная / Поддержка / Материалы (статьи) /

Многопоточное программирование в Linux

Особенности многопоточного программирования в Linux


  1. Системный вызов __clone.
  2. Системный вызов fork.
  3. Обработка сигналов.
    • Обработка SIGSEGV.
    • Установка обработчиков сигналов.
    • _exit() и exit()
  4. Учет системных ресурсов.
  5. Специальные функции.
  6. Эффективность.
  7. Альтернативы pthreads.



  1. Системный вызов __clone.

    В линуксе ( точнее в pthreads) используется одноуровневая система потоков, так называемая N-to-N ( или 1-1 ) реализация, которая мултеплексирует созданные пользователем потоки в такое же количество выполняемых потоков ядра. Однако, на самом деле, потоки в линуксе являются обыкновенными процессами, имеющими свой уникальный pid_t, стек, но разделяющие между собой определенные составляющие контекста процесса - его память , таблицу файловых дескрипторов, таблицу обработчиков сигналов. Такого рода процессы именуются "облегченными" (LWP - Light Weight Processes), и в линуксе для их создания ( как и для создания обыкновенных процессов ) используется системный вызов __clone(2).
    Сигнатура этого вызова такова:    int __clone(int (*fn) (void *arg), void *child_stack, int flags, void *arg)

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


    CLONE_VM

    Если CLONE_VM установлен, родитель и потомок выполняются в одном адресном пространтсве. В частности, запись в память выполненная в потомке или родителе, также видна в другом процессе. Общей является и работа с отображаемой памятью, выполняемая с помощью системных вызов mmap(2) и munmap(2).
    Если CLONE_VM не установлен, то потомок будет выполняться в своей отдельной копии адресного пространтсва родителя на время вызова __clone. Запись в память, а также отображение файлов в память теперь выполняется процессами независимо, как в случае с fork(2)

    CLONE_FS

    Если CLONE_FS установлен, родитель и потомок используют общую информацию о файловой системе. Сюда входит корневой каталог, текущий каталог и параметр umask процесса. Любой вызов chroot(2), chdir(2), or umask(2), выполненный либо потомком, либо родителем влияет также и на другой поцесс.
    Если CLONE_FS не установлен, для потомка создается копия информации о файловой системе родителя но момент вызова __clone. Вызовы chroot(2), chdir(2), umask(2) выполненные процессами не влияют на другой процесс.

    CLONE_FILES

    Если CLONE_FILES установлен, родитель и потомок будут использовать общую таблицу файловых дескриторов. Эти дескрипторы будут всегда ссылаться на одни и те же файлы и в родительском процессе и в потомке. Любой файловый дескриптор открытый в одном процессе может быть использован в другом. То же самое относится и к закрытию дескриптора с помощью close(2) или изменению его флпгов с помощью fcntl(2).
    Еслт CLONE_FILES не установлен, потомок получает копию файловых дескритпоров родителя на момент выполнения __clone. Все операции над фйловыми дескрипторами проводятся процессами независимо друг от друга.

    CLONE_SIGHAND

    Если CLONE_SIGHAND установлен, родитель и потомок используют общую таблицу обработчиков сигналов. Если потомок или родитель вызывают sigaction(2) чтобы изменить реакцию на сигнал, то эти изменения происходят и в другом процессе. Тем не менее, оба процесса имеют раздельные сигнальные маски. Таким образом, каждый из них может блокировать или разблокировать сигнал используя sigprocmask(2) не влияя на другой процесс.
    Если CLONE_SIGHAND не установлен, потомок получает копию таблицы обработчиков синалов на момент вызова __clone. Вызовы sigaction(2) выполненные позже в одном из процессов не влияют на другой.

    CLONE_PID

    Если CLONE_PID установлен, потомок получает такой же идентификатор процесса (process ID), что и у родителя. Но использование этого флага не рекомендуется, так как большая часть ПО все еще расчитывает на уникальность идентификаторов процессов.
    Если CLONE_PID не установлен, потомок получает свой уникальный идентификатор.

    Таким образом, для создания потока нужно задать flags как
    CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND.


    Например:
    #include <sched.h>
    #include <pthread.h>
    #include <stdio.h>
    #include <errno.h>
    #include <unistd.h>
    
    int thread1(void * thread_arg)
    {
        printf("thread1 started\n");
        for(int i=0;i<10;i++){
            sleep(1);
            printf("thread1: i=%d\n",i);
        }
        printf("thread1 finished\n");
    }
    
    char stack[10000];
    
    int main()
    {
        printf("main thread started\n");
    
        if(clone(thread1,(void*)(stack+10000-1),
            CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND,NULL) == -1) {
            
            perror("clone failed");
            exit(1);
        }
    
        for(int i=0;i<12;i++){
            sleep(1);
            printf("main thread: i=%d\n",i);
        }
        printf("main thread finished\n");
        return 0;
    }
    
                        

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

  2. Системный вызов fork.

    Согласно POSIX, системный вызов fork(2) создает новый процесс состоящий только из одного потока, а именно копии того, что вызвал fork(). ( В так называемых UI threads имеется версия fork() создающая полную копию процесса со всеми потоками). Здесь надо не забывать о том, что если в другом потоке какой-либо мутекс был заблокирован, то он останется навсегда заблокирован и в новом процессе. Чтобы избежать этого следует использовать функцию pthread_atfork(2).

  3. Обработка сигналов.

    Использование сигналов в многопоточном приложении является не очень хорошей идеей. Чем меньше их смешивать, тем лучше. Тем более в linuxthreads есть некоторые отклонения от стандарта POSIX.
    Как известно потоки создаются с помощью вызова clone(2), где в параметрах указывается, что потоки имеют общую таблицу обработчиков сигналов, однако потоки в линуксе, с точки зрения ядра - это процессы, а следовательно каждый сигнал имеет своей целью вполне конкретный идетификатор процесса. Попробуйте, например создать программу из двух потоков, в каждом из потоков запустить интервальный таймер процесса с помощью setitimer(2), например, ITIMER_REAL. По истечению заданного интервала ядро пошлет сигнал SIGALRM, который обработается в указанном вами обработчике, но особенность здесь в том, что у каждого потока свой таймер, соответсвенно SIGALRM будет посылаться обоим потокам, но обрабатываться одним обработчиком, и разобраться какой собственно таймер сработал не всегда бывает просто (можно использовать идетификаторы процессов, соответсвующие работающим потокам).

    Во время написания и отладки программы в случае возникновения ошибки сегментации ядро посылает процессу сигнал SIGSEGV, действием по-умолчанию для которого является завершение процесса с созданием core dump - образа памяти процесса. Этот файл удобно использовать для postmorten анализа, чтобы найти причину ошибки. В случае пногопоточного приложения иногда бывает так, что core dump не создается. Причиной для этого могут быть системные ограничения на размер core файла, устанавливаемые вызовом setrlimit(2). Однако стоит заменить обработчик SIGSEGV на следующий:

    void inchild_term_handler(int signum)
    {
        switch (signum) {
        case SIGSEGV:
            fprintf(stderr,"Seg fault. Core dumped to /tmp/core.");
            chdir("/tmp");
            signal(signum, SIG_DFL);
    	pthread_kill_other_threads_np();
            kill(getpid(),signum);
            break;    
        }
    }
                        

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



  4. Учет системных ресурсов.

    Здесь хочется напомнить то, о чем многие забывают работая с Posix threads вообще, а не только с Linuxthreads. Речь идет о thread cancelation, т.е. о прекращении выполнении потока из другого потока с помощью pthread_cancel(). При этом зачастую забывают о стековых обьектах, а при thread cancelation стек не раскручивается. Пример программы, содержащей ошибку приведен ниже. Здесь создается обьект класса A (переменная a) на стеке потока, но в результате вызова функции pthread_cancel() в первоначальном( родительском) потоке th1 завершается, а деструктор обьекта не вызывается.

    #include <sched.h>
    #include <pthread.h>
    #include <stdio.h>
    #include <errno.h>
    #include <unistd.h>
    
    class A
    {
    public:
        A(){ printf("A::A()\n"); }
        ~A(){ printf("A::~A()\n"); }
    };
    
    void * thread1(void * thread_arg)
    {
        A a;
        printf("thread1 started\n");
        //for(int i=0;i<10;i++){
        for(;;){
            sleep(1);
    	pthread_testcancel();
        }
    }
    
    int main()
    {
        pthread_t th1;
        if(pthread_create(&th1,NULL,thread1,NULL) != 0){
    	perror("pthread_create failed");
    	exit(1);
        }
        sleep(2);
        printf("Sending cancelation request to th1\n");
        pthread_cancel(th1);
        printf("Cancelation request sent, making join on th1\n");
        pthread_join(th1, NULL);
        printf("main thread finished\n");
        return0;
    }
    
    

    Выходом является использование динамической памяти с посчетом указателей и установкой cleanup handlers с помощью pthread_cleanup_push/pop.

    Кроме этого, мутекс, заблокированный потоком не разблокируется автоматически при принудительном завершении, что может привести к тупиковой ситуации, если какой-либо другой поток попытается заблокировать этот же мутекс. Разблокирование мутекса также следует "поручить" cleanup hadlers.

  5. Специальные функции.

    В библиотеке linuxthreads имеется несколько функций с суффиксом _np ( non portable ).
    pthread_cleanup_push_defer_np(3)
    pthread_cleanup_pop_restore_np(3)
    pthread_kill_other_threads_np(3)
    pthread_mutexattr_setkind_np(3)
    pthread_mutexattr_getkind_np(3)

  6. Эффективность.

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

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

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

    Наиболее частым среди начинающих программистов ( по моему мнению из-за простоты реализации ) подходом при создании сетевых приложений является модель один поток на соединение, когда каждое новое сетевое соединение представляет собой синхронный блокирующий сокет, обрабатываемый выделенным потоком.
    Простота данного подхода является также причиной довольно частого заблуждения о высокой эффективности данной модели. Однако это не так: поток, заблокировавшийся на сокете будет просто поедать время прцессора, не выполняя никаких полезных функций и главное отнимать время у потоков, сокеты которых имеют данные для считывания (или возможность для записи). Оговорка: если у вас компьютер с 1000-ю процессоров, или просто если у вас потоков меньше чем процессоров, то эта модель еще в принципе обеспечивает достаточную производительность.
    Если же вы хотите получить приложение, способное обслуживать тысячи одновременных соединений на обыкновенной машине с одним-двумя процессорами, то необходимо в первую очередь отказаться от использования блокирующих сокетов. При этом используется небольшое автоматически регулируемое (в некоторых предлах) количество потоков, каждый из которых работает с равным количесвом сокетов (например, максимум 64 дескриптора на поток). Выбор сокета производится с помощью системного вызова poll(2). Такая модель достаточно легко портируется на другие системы, однако, в линуксе имеется возможность не опрашивать сокеты, а поручить ядру информировать поток о определенном событии на дескриторе сокета - с помощью команды F_SETSIG вызова fcntl(2) можно установить сигнал, который приэтом событии будет отправляться ядром процессу. Отличие от существующей практически во всех юниксах возможности отправки сигнала SIGIO при установленном флаге O_ASYNC(FIOASYNC) состоит в том, что если обработчик сигнала будет установлен с помощью SA_SIGINFO в sigaction(2), то сокет, изменивший состояние будет указан в параметре si_fd структуры siginfo_t. Данный способ является непереносимым, однока более быстрым чем перебор набора структур pollfd в системном вызове poll, потому как перебор этих структур (а он присходит и в ядре, а потом и в потоке) может занимать немалую долю времени, выделенного ядром потоку (time_slice).

    К вопросу производительности следует также отнести и проблемы, описаные в пункте Учет системных ресурсов., так как использование стековых обьектов небезопасно с точки зрения thread cancelation, а использование динамических обьектов (выделение/освобождение памяти с помощью new/delete) менее эффективно. Здесь можно учитывать то, какие функции являются точками выхода ( cancelation points ) - в linuxthreads к таким относятся pthread_join(3), pthread_cond_wait(3), pthread_cond_timedwait(3), pthread_testcancel(3), sem_wait(3), sigwait(3), т.е. можно организовать выполнение программы таким образом, что стековые обьекты уже будут уничтожены к моменту попадание в cancelation point. Естественно, это относится к потокам с PTHREAD_CANCEL_DEFERRED флагом обработки cancelation request, а не PTHREAD_CANCEL_ASYNCHRONOUS.

  7. Альтернативы pthreads.

    На сайте www.gnu.org вы можете найти Pth - GNU Portable Threads. Эта библиотека представляет довольно полнофункциональную реализацию потоков. Однако, пожалуй главным ее недостатком является не привязанность к ядру операционной системы ( все таки portable ), что означает, что библиотека не может эффективно использовать возможности многопроцессорных систем.

© Алексей Кривошей.
Компания | Продукты | Поддержка | Скачать | Контакты
© 2001-2024 ФОСС-Он-Лайн. Все права защищены.