sonyps4.ru

Легковесные процессы: анатомическое исследование программных потоков в Linux. Что такое легковесные процессы

ЛАБОРАТОРНАЯ РАБОТА №3

МНОГОЗАДАЧНОЕ ПРОГРАММИРОВАНИЕ В LINUX

1. Цель работы: Ознакомиться с компилятором gcc, методикой отладки программ, функциями работы с процессами.

2. Краткие теоретические сведения.

Минимальным набором ключей компилятора gcc являются - Wall (выводить все ошибки и предупреждения) и - o (output file):

gcc - Wall - o print_pid print_pid. c

Команда создаст исполняемый файл print_pid.

Стандартная библиотека C (libc, реализованная в Linux в glibc), использует возможности многозадачности Unix System V (далее SysV). В libc тип pid_t определен как целое, способное вместить в себе pid. Функция, которая сообщает pid текущего процесса, имеет прототип pid_t getpid (void) и определена вместе с pid_t в unistd. h и sys/types. h).

Для создания нового процесса используется функция fork:

pid_t fork(void)

Вставляя задержку случайной длины при помощи функций sleep и rand, можно нагляднее увидеть эффект многозадачности:

это заставит программу "заснуть" на случайное число секунд: от 0 до 3.

Чтобы в качестве дочернего процесса вызвать функцию, достаточно вызвать ее после ветвления:

// если выполняется дочерний процесс, то вызовем функцию

pid=process(arg);

// выход из процесса

Часто в качестве дочернего процесса необходимо запускать другую программу. Для этого применяется функции семейства exec:

// если выполняется дочерний процесс, то вызов программы


if (execl("./file","file",arg, NULL)<0) {

printf("ERROR while start process\n");

else printf("process started (pid=%d)\n", pid);

// выход из процесса

Часто родительскому процессу необходимо обмениваться информацией с дочерними или хотя бы синхронизироваться с ними, чтобы выполнять операции в нужное время. Один из способов синхронизации процессов - функции wait и waitpid:

#include

#include

pid_t wait(int *status) - приостанавливает выполнение текущего процесса до завершения какого-либо из его процессов-потомков.

pid_t waitpid (pid_t pid, int *status, int options) - приостанавливает выполнение текущего процесса до завершения заданного процесса или проверяет завершение заданного процесса.

Если необходимо узнать состояние порожденного процесса при его завершении и возвращенное им значение, то используют макрос WEXITSTATUS, передавая ему в качестве параметра статус дочернего процесса.

status=waitpid(pid,&status, WNOHANG);

if (pid == status) {

printf("PID: %d, Result = %d\n", pid, WEXITSTATUS(status)); }

Для изменения приоритетов порожденных процессов используются функции setpriority и. Приоритеты задаются в диапазоне от -20 (высший) до 20 (низший), нормальное значение - 0. Заметим, что повысить приоритет выше нормального может только суперпользователь!

#include

#include

int process(int i) {

setpriority(PRIO_PROCESS, getpid(),i);

printf("Process %d ThreadID: %d working with priority %d\n",i, getpid(),getpriority(PRIO_PROCESS, getpid()));

return(getpriority(PRIO_PROCESS, getpid()));

Для уничтожения процесса служит функция kill:

#include

#include

int kill(pid_t pid, int sig);

Если pid > 0, то он задает PID процесса, которому посылается сигнал. Если pid = 0, то сигнал посылается всем процессам той группы, к которой принадлежит текущий процесс.

sig - тип сигнала. Некоторые типы сигналов в Linux:

SIGKILL Этот сигнал приводит к немедленному завершению процесса. Этот сигнал процесс не может игнорировать.

SIGTERM Этот сигнал является запросом на завершение процесса.

SIGCHLD Система посылает этот сигнал процессу при завершении одного из его дочерних процессов. Пример:

if (pid[i] == status) {

printf("ThreadID: %d finished with status %d\n", pid[i], WEXITSTATUS(status));

else kill(pid[i],SIGKILL);

3. Методические указания.

3.1. Для ознакомления с опциями компилятора gcc, описанием функций языка С используйте инструкции man и info.

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

3.3. В файловом менеджере Midnight Commander имеется буфер команд, вызываемый сочетанием клавиш - H, перемещение по которому производится стрелками управления курсором (вверх и вниз). Для вставки команды из буфера в командную строку используется клавиша , для редактирования команды из буфера - клавиши <- и ->, и .


3.4. Помните, что текущая директория не содержится в path, поэтому из командной строки необходимо запускать программу как "./print_pid". В MC достаточно навести курсор на файл и нажать .

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

3.6. Для протоколирования результатов выполнения программ целесообразно использовать перенаправление вывода с консоли в файл: ./test > result. txt

3.7. Для доступа к файлам, созданным на сервере Linux, применяйте протокол ftp, клиентская программа которого имеется в Windows 2000 и встроена в файловый менеджер FAR. При этом учетная запись и пароль те же, что и при подключении по протоколу ssh.

4.1. Ознакомиться с опциями компилятора gcc, методикой отладки программ.

4.2. Для вариантов заданий из лабораторной работы №1 написать и отладить программу, реализующую порожденный процесс.

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

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

5. Варианты заданий. См. варианты заданий из лабораторной работы №1

6. Содержание отчета.

6.1. Цель работы.

6.2. Вариант задания.

6.3. Листинги программ.

6.4. Протоколы выполнения программ.

7. Контрольные вопросы.

7.1. Особенности компиляции и запуска С-программ в Linux.

7.2. Что такое pid, как его определить в операционной системе и программе?

7.3. Функция fork - назначение, применение, возвращаемое значение.

7.4. Как запустить на выполнение в порожденном процессе функцию? Программу?

7.5. Способы синхронизации родительского и дочерних процессов.

7.6. Как узнать состояние порожденного процесса при его завершении и возвращенное им значение?

7.7. Как управлять приоритетами процессов?

7.8. Как уничтожить процесс в операционной системе и программе?

Оригинал: Light-Weight Processes: Dissecting Linux Threads
Авторы: Vishal Kanaujia, Chetan Giridhar
Дата публикации: 1 Августа 2011 г.
Перевод: А.Панин
Дата публикации перевода: 22 октября 2012 г.

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

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

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

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

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

Несколько любопытных особенностей

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

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

Как программные потоки реализованы в Linux?

Linux позволяет разрабатывать и использовать многопоточные приложения. На пользовательском уровне реализация потоков в Linux соответствует открытому стандарту POSIX (Portable Operating System Interface for uniX - Переносимый интерфейс операционных систем Unix), обозначенному как IEEE 1003. Библиотека пользовательского уровня (glibc.so в Ubuntu) предоставляет реализацию API POSIX для потоков.

В Linux программные потоки существуют в двух отдельных пространствах - пространстве пользователя и пространстве ядра. В пространстве пользователя потоки создаются при помощи POSIX-совместимого API библиотеки pthread . Эти потоки пространства пользователя неразрывно связаны с потоками пространства ядра. В Linux потоки пространства ядра воспринимаются как "легковесные процессы". Легковесный процесс является единицей основной среды исполнения. В отличие от различных вариантов UNIX, включая такие системы, как HP-UX и SunOS, в Linux не существует отдельной системы для работы с потоками. Процесс или поток в Linux рассматривается как "задача" (task) и использует одинаковые внутренние структуры (ряд структур struct task_structs ).

Для ряда потоков процесса, созданных в пространстве пользователя, в ядре существует ряд связанных с ними легковесных процессов. Пример иллюстрирует это утверждение: #include #include #include Int main() { pthread_t tid = pthread_self(); int sid = syscall(SYS_gettid); printf("LWP id is %dn", sid); printf("POSIX thread id is %dn", tid); return 0; }

При помощи утилиты ps можно получить информацию о процессах, а также о легковесных процессах/потоках этих процессов: kanaujia@ubuntu:~/Desktop$ ps -fL UID PID PPID LWP C NLWP STIME TTY TIME CMD kanaujia 17281 5191 17281 0 1 Jun11 pts/2 00:00:02 bash kanaujia 22838 17281 22838 0 1 08:47 pts/2 00:00:00 ps -fL kanaujia 17647 14111 17647 0 2 00:06 pts/0 00:00:00 vi clone.s

Что такое легковесные процессы?

Легковесным процессом является процесс, поддерживающий работу потока пространства пользователя. Каждый поток пространства пользователя неразрывно связан с легковесным процессом. Процедура создания легковесного процесса отличается от процедуры создания обычного процесса; у пользовательского процесса "P" может существовать ряд связанных легковесных процессов с одинаковым идентификатором группы (group ID). Группировка позволяет ядру производить разделение ресурсов (ресурсы включают в себя адресное пространство, страницы физической памяти (VM), обработчики сигналов и дескрипторы файлов). Это также позволяет ядру избежать переключений контекста при работе с этими процессами. Исчерпывающее разделение ресурсов является причиной наименования этих процессов легковесными.

Как Linux создает легковесные процессы?

В Linux создание легковесных процессов осуществляется при помощи нестандартизированного системного вызова clone() . Он похож на вызов fork() , но с более широкими возможностями. Вообще, вызов fork() реализуется при помощи вызова clone() с дополнительными параметрами, указывающими на ресурсы, которые будут разделены между процессами. Вызов clone() создает процесс, при этом дочерний процесс разделяет с родительским элементы среды исполнения, включая память, дескрипторы файлов и обработчики сигналов. Библиотека pthread также использует вызов clone() для реализации потоков. Обратитесь к файлу исходного кода ./nptl/sysdeps/pthread/createthread.c в директории исходных кодов glibc версии 2.11.2.

Создание своего легковесного процесса

Продемонстрируем пример использования вызова clone() . Посмотрите на исходный код из файла demo.c , приведенный ниже:

#include #include #include #include #include #include #include //стек размером 64kB #define STACK 1024*64 // Дочерний поток выполнит эту функцию int threadFunction(void* argument) { printf("child thread entering\n"); close((int*)argument); printf("child thread exiting\n"); return 0; } int main() { void* stack; pid_t pid; int fd; fd = open("/dev/null", O_RDWR); if (fd < 0) { perror("/dev/null"); exit(1); } // Резервирование памяти для стека stack = malloc(STACK); if (stack == 0) { perror("malloc: could not allocate stack"); exit(1); } printf("Creating child thread\n"); // Вызов clone() для создания дочернего потока pid = clone(&threadFunction, (char*) stack + STACK, SIGCHLD | CLONE_FS | CLONE_FILES |\ CLONE_SIGHAND | CLONE_VM, (void*)fd); if (pid == -1) { perror("clone"); exit(2); } // Ожидание завершения дочернего потока pid = waitpid(pid, 0, 0); if (pid == -1) { perror("waitpid"); exit(3); } // Попытка записи в файл закончится неудачей, так как поток // закрыл файл if (write(fd, "c", 1) < 0) { printf("Parent:\t child closed our file descriptor\n"); } // Освободить память, используемую для стека free(stack); return 0; }

Программа demo.c позволяет создавать потоки по своей сути тем же способом, что и библиотека pthread . Тем не менее, прямое использование вызова clone() нежелательно, поскольку в случае неправильного использования разрабатываемое приложение может завершиться с ошибкой. Синтаксис функции clone() в Linux представлен ниже: #include int clone (int (*fn) (void *), void *child_stack, int flags, void *arg);

Первым аргументом является функция потока; она вызывается во время запуска потока. После того, как вызов clone() успешно завершается, функция fn начинает исполняться одновременно с вызывающим процессом.

Следующим аргументом является указатель на участок памяти для стека дочернего процесса. За шаг до вызова fork() и clone() от программиста требуются действия по резервированию памяти и передаче указателя для использования ее в качестве стека дочернего процесса, так как родительский и дочерний процесс делят между собой страницы памяти - они включают в себя и стек. Дочерний процесс может вызвать функцию, отличную от родительского процесса, поэтому и требуется отдельный стек. В нашей программе мы резервируем этот участок памяти в куче при помощи функции malloc() . Размер стека был установлен равным 64 Кб. Так как стек на архитектуре x86 растет вниз, необходимо симулировать аналогичное поведение, используя выделенную память с конца участка. По этой причине мы передаем следующий адрес функции clone() : (char*) stack + STACK

Следующий аргумент flags особо важен. Он позволяет указать, какие ресурсы необходимо разделять с созданным процессом. Мы выбрали битовую маску SIGCHLD | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_VM , описанную ниже:

  • SIGCHLD : поток отправляет сигнал SIGCHLD родительскому процессу после завершения. Установка этого параметра позволяет родительскому процессу использовать функцию wait() для ожидания завершения всех потоков.
  • CLONE_FS : Разделять информацию о файловых системах между родительским процессом и потоком. Информация включает в себя корень файловой системы, рабочую директорию и значение umask.
  • CLONE_FILES : Разделять таблицу файловых дескрипторов между родительским процессом и потоком. Изменения в таблице отображаются в родительском процессе и всех потоках.
  • CLOSE_SIGHAND : Разделять таблицу обработчиков сигналов между родительским процессом и потомком. И снова, если родительский процесс или один из потоков изменит обработчик сигнала, изменение будет отображено на таблицах других процессов.
  • CLONE_VM : Родительский процесс и потоки работают в одном пространстве памяти. Все записи в память или отображения, сделанные одним из них, доступны другим процессам.

Последним параметром является аргумент, передаваемый функции (threadFunction ), в нашем случае это файловый дескриптор.

Пожалуйста, обратитесь к примеру работы с легковесными процессами demo.c , опубликованному нами ранее.

Поток закрывает файл (/dev/null ), открытый родительским процессом. Поскольку родительский процесс и поток делят таблицу файловых дескрипторов, операция закрытия файла затрагивает и родительский процесс, что приводит к неудаче при последующем вызове write() . Родительский процесс ожидает завершения работы потока (момента приема сигнала SIGCHLD ). После этого он освобождает зарезервированную память и возвращает управление.

Скомпилируйте и запустите программу обычным образом; вывод должен быть аналогичен приведенному ниже: $gcc demo.c $./a.out Creating child thread child thread entering child thread exiting Parent: child closed our file descriptor $

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

Процессы в UNIX

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

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

Виртуальная память реализуется и автоматически поддерживается ядром ОС UNIX.

Типы процессов

В операционных системах UNIX выделяется три типа процессов: системные , процессы-демоны и прикладные процессы .

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

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

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



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

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

Атрибуты процесса

Процесс в UNIX имеет ряд атрибутов, позволяющих операционной системе управлять его работой. Основные атрибуты:

· Идентификатор процесса (PID) , позволяющий ядру системы различать процессы. Когда создается новый процесс, ядро присваивает ему следующий свободный (т.е. не ассоциированный ни с каким процессом) идентификатор. Присвоение идентификатора обычно происходит по возрастающий, т.е. идентификатор нового процесса больше, чем идентификатор процесса, созданного перед ним. Если идентификатор достигает максимального значения (обычно – 65737), следующий процесс получит минимальный свободный PID и цикл повторяется. Когда процесс завершает работу, ядро освобождает использовавшийся им идентификатор.

· Идентификатор родительского процесса (PPID) – идентификатор процесса, породившего данный процесс. Все процессы в системе, кроме системных процессов и процесса init , являющегося прародителем остальных процессов, порождены одним из существующих или существовавших ранее процессов.

· Поправка приоритета (NI) – относительный приоритет процесса, учитываемый планировщиком при определении очередности запуска. Фактическое же распределение процессорных ресурсов определяется приоритетом выполнения (атрибут PRI ), зависящим от нескольких факторов, в частности от заданного относительного приоритета. Относительный приоритет не изменяется системой на всем протяжении жизни процесса, хотя может быть изменен пользователем или администратором при запуске процесса с помощью команды nice . Диапазон значений инкремента приоритета в большинстве систем – от -20 до 20. Если инкремент не задан, используется стандартное значение 10. Положительный инкремент означает снижение текущего приоритета. Обычные пользователи могут задавать только положительный инкремент и, тем самым, только снижать приоритет. Пользователь root может задать отрицательный инкремент, который повышает приоритета процесса и, тем самым, способствует его более быстрой работе. В отличии от относительного приоритета приоритет выполнения процесса динамически изменяется планировщиком.

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

· Реальный (UID) и эффективный (EUID) идентификаторы пользователя . Реальным идентификатором пользователя данного процесса является идентификатор пользователя, запустившего процесс. Эффективный идентификатор служит для определения прав доступа процесса к системным ресурсам (в первую очередь к ресурсам файловой системы). Обычно реальный и эффективный идентификаторы совпадают, т.е. процесс имеет в системе те же права, что и пользователь, запустивший его. Однако существует возможность задать процессу более широкие права, чем права пользователя, путем установки бита SUID , когда эффективному идентификатору присваивается значение идентификатора владельца выполняемого файла (например, пользователя root ).

· Реальный (GID) и эффективный (EGID) идентификаторы группы. Реальный идентификатор группы равен идентификатору основной или текущей группы пользователя, запустившего процесс. Эффективный идентификатор служит для определения прав доступа к системным ресурсам от имени группы. Обычно эффективный идентификатор группы совпадает с реальным. Но если для выполняемого файла установлен бит SGID , такой файл выполняется с эффективным идентификатором группы-владельца.

Многопоточность в программировании является важным механизмом в наше время. Поэтому я решил посвятить несколько статей этой теме.

В семействах ОС Windows — каждая программа запускает один процесс выполнения, в котором находится как минимум один поток (нить). В процессе может находиться множество потоков, между которыми делится процессорное время. Один процесс не может напрямую обратиться к памяти другого процесса, а потоки же разделяют одно адресное пространство одного процесса. То есть в Windows — процесс это совокупность потоков.

В Linux же немного по-другому. Сущность процесса такая же, как и в Windows — это исполняемая программа со своими данными. Но вот поток в Linux является отдельным процессом (можно встретить название как «легковесный процесс», LWP). Различие такое же — процесс отдельная программа со своей памятью, не может напрямую обратиться к памяти другого процесса, а вот поток, хоть и отдельный процесс, имеет доступ к памяти процесса-родителя . LWP процессы создаются с помощью системного вызова clone() с указанием определенных флагов.

Но также имеется такая вещь, которая называется «POSIX Threads» — библиотечка стандарта POSIX, которая организует потоки (они же нити) внутри процесса. Т.е тут уже распараллеливание происходит в рамках одного процесса.

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

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

Я рассмотрю два варианта «распараллеливания» программы — создания потока/нити с помощью функций из pthread.h (POSIX Threads), либо создание отдельного процесса с помощью функции fork().

Сегодня рассмотрим потоки из библиотеки pthread.

Шаблон кода для работы с потоками выглядит следующим образом:

#include //потоковая функция void* threadFunc(void* thread_data){ //завершаем поток pthread_exit(0); } int main(){ //какие то данные для потока (для примера) void* thread_data = NULL; //создаем идентификатор потока pthread_t thread; //создаем поток по идентификатору thread и функции потока threadFunc //и передаем потоку указатель на данные thread_data pthread_create(&thread, NULL, threadFunc, thread_data); //ждем завершения потока pthread_join(thread, NULL); return 0; }

#include

//потоковая функция

//завершаем поток

pthread_exit (0 ) ;

int main () {

//какие то данные для потока (для примера)

void * thread_data = NULL ;

//создаем идентификатор потока

pthread_t thread ;

//создаем поток по идентификатору thread и функции потока threadFunc

//и передаем потоку указатель на данные thread_data

pthread_create (& thread , NULL , threadFunc , thread_data ) ;

//ждем завершения потока

pthread_join (thread , NULL ) ;

return 0 ;

Как видно из кода, сущность потока воплощена в функции, в данном случае, threadFunc. Имя такой функции может быть произвольным, а вот возвращаемый тип и тип входного аргумента должны быть строго void*. Данная функция будет выполняться в отдельном потоке исполнения, поэтому необходимо с особой осторожностью подходить к реализации данной функции из-за доступа к одной и той же памяти родительского процесса многими потоками. Завершение достигается несколькими вариантами: поток достиг точки завершения (return, pthread_exit(0)), либо поток был завершен извне.

Создание потока происходит с помощью функции pthread_create(pthread_t *tid, const pthread_attr_t *attr, void*(*function)(void*), void* arg), где: tid — идентификатор потока, attr — параметры потока (NULL — атрибуты по умолчанию, подробности в man), function — указатель на потоковую функцию, в нашем случае threadFunc и arg — указатель на передаваемые данные в поток.

Функция pthread_join ожидает завершения потока thread. Второй параметр этой функции — результат, возвращаемый потоком.

Попробуем по этому шаблону написать программу, которая выполняет что-то полезное. Например, попытаемся сложить две матрицы и сохранить результат в третьей результирующей матрице. Для решения данной задачи уже необходимо подумать о правильном распределении данных между потоками. Я реализовал простой алгоритм — сколько строк в матрице, столько потоков. Каждый поток складывает элементы строки первой матрицы с элементами строки второй матрицы и сохраняет результат в строку третей матрицы. Получается, что каждый поток работает ровно со своими данными и таким образом исключается доступ одного потока к данным другого потока. Пример программы с использованием потоков pthread выгляди следующим образом:

#include #include #include #include //размеры матриц #define N 5 #define M 5 //специальная структура для данных потока typedef struct{ int rowN; //номер обрабатываемой строки int rowSize; //размер строки //указатели на матрицы int** array1; int** array2; int** resArr; } pthrData; void* threadFunc(void* thread_data){ //получаем структуру с данными pthrData *data = (pthrData*) thread_data; //складываем элементы строк матриц и сохраняем результат for(int i = 0; i < data->rowSize; i++) data->resArr[i] = data->array1[i] + data->array2[i]; return NULL; } int main(){ //выделяем память под двумерные массивы int** matrix1 = (int**) malloc(N * sizeof(int*)); int** matrix2 = (int**) malloc(N * sizeof(int*)); int** resultMatrix = (int**) malloc(N * sizeof(int*)); //выделяем память под элементы матриц for(int i = 0; i < M; i++){ matrix1[i] = (int*) malloc(M * sizeof(int)); matrix2[i] = (int*) malloc(M * sizeof(int)); resultMatrix[i] = (int*) malloc(M * sizeof(int)); } //инициализируем начальными значениями for(int i = 0; i < N; i++){ for(int j = 0; j < M; j++){ matrix1[i][j] = i; matrix2[i][j] = j; resultMatrix[i][j] = 0; } } //выделяем память под массив идентификаторов потоков pthread_t* threads = (pthread_t*) malloc(N * sizeof(pthread_t)); //сколько потоков - столько и структур с потоковых данных pthrData* threadData = (pthrData*) malloc(N * sizeof(pthrData)); //инициализируем структуры потоков for(int i = 0; i < N; i++){ threadData[i].rowN = i; threadData[i].rowSize = M; threadData[i].array1 = matrix1; threadData[i].array2 = matrix2; threadData[i].resArr = resultMatrix; //запускаем поток pthread_create(&(threads[i]), NULL, threadFunc, &threadData[i]); } //ожидаем выполнение всех потоков for(int i = 0; i < N; i++) pthread_join(threads[i], NULL); //освобождаем память free(threads); free(threadData); for(int i = 0; i < N; i++){ free(matrix1[i]); free(matrix2[i]); free(resultMatrix[i]); } free(matrix1); free(matrix2); free(resultMatrix); return 0; }

#include

#include

#include

#include

//размеры матриц

#define N 5

#define M 5

//специальная структура для данных потока

typedef struct {

int rowN ; //номер обрабатываемой строки

int rowSize ; //размер строки

//указатели на матрицы

int * * array1 ;

int * * array2 ;

int * * resArr ;

} pthrData ;

void * threadFunc (void * thread_data ) {

//получаем структуру с данными

pthrData * data = (pthrData * ) thread_data ;

//складываем элементы строк матриц и сохраняем результат

for (int i = 0 ; i < data -> rowSize ; i ++ )

data -> resArr [ data -> rowN ] [ i ] = data -> array1 [ data -> rowN ] [ i ] + data -> array2 [ data -> rowN ] [ i ] ;

return NULL ;

int main () {

//выделяем память под двумерные массивы

int * * matrix1 = (int * * ) malloc (N * sizeof (int * ) ) ;

int * * matrix2 = (int * * ) malloc (N * sizeof (int * ) ) ;

int * * resultMatrix = (int * * ) malloc (N * sizeof (int * ) ) ;

//выделяем память под элементы матриц

for (int i = 0 ; i < M ; i ++ ) {

matrix1 [ i ] = (int * ) malloc (M * sizeof (int ) ) ;

matrix2 [ i ] = (int * ) malloc (M * sizeof (int ) ) ;

resultMatrix [ i ] = (int * ) malloc (M * sizeof (int ) ) ;

//инициализируем начальными значениями

for (int i = 0 ; i < N ; i ++ ) {

for (int j = 0 ; j < M ; j ++ ) {

matrix1 [ i ] [ j ] = i ;

matrix2 [ i ] [ j ] = j ;

resultMatrix [ i ] [ j ] = 0 ;

//выделяем память под массив идентификаторов потоков

pthread_t * threads = (pthread_t * ) malloc (N * sizeof (pthread_t ) ) ;

//сколько потоков - столько и структур с потоковых данных

pthrData * threadData = (pthrData * ) malloc (N * sizeof (pthrData ) ) ;

//инициализируем структуры потоков

for (int i = 0 ; i < N ; i ++ ) {

threadData [ i ] . rowN = i ;

Продолжаем тему многопоточности в ядре Linux. В прошлый раз я рассказывала про прерывания, их обработку и tasklet’ы, и так как изначально предполагалось, что это будет одна статья, в своем рассказе о workqueue я буду ссылаться на tasklet’ы, считая, что читатель уже с ними знаком.
Как и в прошлый раз, я постараюсь сделать мой рассказ максимально подробным и детальным.

Статьи цикла:

  1. Многозадачность в ядре Linux: workqueue

Workqueue

Workqueue - это более сложные и тяжеловесные сущности, чем tasklet’ы. Я даже не буду пытаться описать здесь все тонкости реализации, но самое важное, надеюсь, я разберу более или менее подробно.
Workqueue, как и tasklet’ы, служат для отложенной обработки прерываний (хотя их можно использовать и для других целей), но, в отличие от tasklet’ов, выполняются в контексте kernel-процесса, соответственно, они не обязаны быть атомарными и могут использовать функцию sleep(), различные средства синхронизации и т.п.

Давайте сначала разберемся, как в целом организован процесс обработки workqueue. На картинке он показан очень приближенно и упрощенно, как все происходит на самом деле, подробно описано ниже.

В этом темном деле замешаны несколько сущностей.
Во-первых, work item (для краткости просто work) - это структура, описывающая функцию (например, обработчик прерывания), которую мы хотим запланировать Его можно воспринимать как аналог структуры tasklet. Tasklet’ы при планировании добавлялись в очереди, скрытые от пользователя, теперь же нам нужно использовать специальную очередь - workqueue .
Tasklet’ы разгребаются функцией-планировщиком, а workqueue обрабатывается специальными потоками, которые зовутся worker’ами.
Worker ’ы обеспечивают асинхронное исполнение work’ов из workqueue. Хотя они вызывают work’и в порядке очереди, в общем случае о строгом, последовательном выполнении речи не идет: все-таки здесь имеют место вытеснение, сон, ожидание и т.д.

Вообще, worker’ы - это kernel-потоки, то есть ими управляет основной планировщик ядра Linux. Но worker’ы частично вмешиваются в планирование для дополнительной организации параллельного исполнения work’ов. Про это подробнее пойдет ниже.

Чтобы очертить основные возможности механизма workqueue, я предлагаю изучить API.

Про очередь и ее создание

alloc_workqueue(fmt, flags, max_active, args...)
Параметры fmt и args - это printf-формат для имени и аргументы к нему. Параметр max_activate отвечает за максимальное число work’ов, которые из этой очереди могут исполняться параллельно на одном CPU.
Очередь можно создать со следующими флагами:
  • WQ_HIGHPRI
  • WQ_UNBOUND
  • WQ_CPU_INTENSIVE
  • WQ_FREEZABLE
  • WQ_MEM_RECLAIM
Особое внимание следует уделить флагу WQ_UNBOUND . По наличию этого флага очереди делятся на привязанные и непривязанные.
В привязанных очередях work’и при добавлении привязываются к текущему CPU, то есть в таких очередях work’и исполняются на том ядре, которое его планирует. В этом плане привязанные очереди напоминают tasklet’ы.
В непривязанных очередях work’и могут исполняться на любом ядре.

Важным свойством реализации workqueue в ядре Linux является дополнительная организация параллельного исполнения, которая присутствует у привязанных очередей. Про нее подробнее написано ниже, сейчас скажу, что осуществляется таким образом, чтобы использовалось как можно меньше памяти, и чтобы при этом процессор не простаивал. Реализовано это все с предположением, что один work не использует слишком много тактов процессора.
Для непривязанных очередей такого нет. По сути, такие очереди просто предоставляют work’ам контекст и запускают их как можно раньше.
Таким образом, непривязанные очереди следует использовать, если ожидается интенсивная нагрузка на процессор, так как в таком случае планировщик позаботится о параллельном исполнении на нескольких ядрах.

По аналогии с tasklet’ами, work’ам можно присваивать приоритет исполнения, нормальный или высокий. Приоритет общий на всю очередь. По умолчанию очередь имеет нормальный приоритетом, а если задать флаг WQ_HIGHPRI , то, соответственно, высокий.

Флаг WQ_CPU_INTENSIVE имеет смысл только для привязанных очередей. Этот флаг - отказ от участия в дополнительной организации параллельного исполнения. Этот флаг следует использовать, когда ожидается, что work’и будут расходовать много процессорного времени, в этом случае лучше переложить ответственность на планировщик. Про это подробнее написано ниже.

Флаги WQ_FREEZABLE и WQ_MEM_RECLAIM специфичны и выходят за рамки темы, поэтому подробно на них останавливаться не будем.

Иногда есть смысл не создавать свои собственные очереди, а использовать общие. Основные из них:

  • system_wq - привязанная очередь для быстрых work’ов
  • system_long_wq - привязанная очередь для work’ов, которые предположительно будут исполняться долго
  • system_unbound_wq - непривязанная очередь

Про work’и и их планирование

Теперь разберемся с work’ами. Сначала взглянем на макросы инициализации, декларации и подготовки:
DECLARE(_DELAYED)_WORK(name, void (*function)(struct work_struct *work)); /* на этапе компиляции */ INIT(_DELAYED)_WORK(_work, _func); /* во время исполнения */ PREPARE(_DELAYED)_WORK(_work, _func); /* для изменения исполняемой функции */
В очереди work’и добавляются с помощью функций:
bool queue_work(struct workqueue_struct *wq, struct work_struct *work); bool queue_delayed_work(struct workqueue_struct *wq, struct delayed_work *dwork, unsigned long delay); /* work будет добавлен в очередь только по истечению delay */
Вот на этом стоит остановиться поподробнее. Хотя мы в качестве параметра указываем очередь, на самом деле, work’и кладутся не в сами workqueue, как это может показаться, а в совершенно другую сущность - в список-очередь структуры worker_pool. Структура worker_pool , по сути, самая главная сущность в организации механизма workqueue, хотя для пользователя она остается за кулисами. Именно с ними работают worker’ы, и именно в них содержится вся основная информация.

Теперь посмотрим, какие пулы есть в системе.
Для начала пулы для привязанных очередей (на картинке). Для каждого CPU статически выделяются два worker pool: один для высокоприоритетных work’ов, другой - для work’ов с нормальным приоритетом. То есть, если ядра у нас четыре, то привязанных пулов будет всего восемь, не смотря на то, что workqueue может быть сколько угодно.
Когда мы создаем workqueue, у него для каждого CPU выделяется служебный pool_workqueue (pwq). Каждый такой pool_workqueue ассоциирован с worker pool, который выделен на том же CPU и соответствует по приоритету типу очереди. Через них workqueue взаимодействует с worker pool.
Worker’ы исполняют work’и из worker pool без разбора, не различая, к какому workqueue они принадлежали изначально.

Для непривязанных очередей worker pool’ы выделяются динамически. Все очереди можно разбить на классы эквивалентности по их параметрам, и для каждого такого класса создается свой worker pool. Доступ к ним осуществляется с помощью специальной хэш-таблицы, где ключом служит набор параметров, а значением, соответственно, worker pool.
На самом деле у непривязанных очередей все немножко сложнее: если у привязанных очередей создавались pwq и очереди для каждого CPU, то здесь они создаются для каждого узла NUMA , но это уже дополнительная оптимизация, которую в деталях рассматривать не будем.

Всякие мелочи

Еще приведу несколько функций из API для полноты картины, но подробно о них говорить не буду:
/* Принудительное завершение */ bool flush_work(struct work_struct *work); bool flush_delayed_work(struct delayed_work *dwork); /* Отменить выполнение work */ bool cancel_work_sync(struct work_struct *work); bool cancel_delayed_work(struct delayed_work *dwork); bool cancel_delayed_work_sync(struct delayed_work *dwork); /* Удалить очередь */ void destroy_workqueue(struct workqueue_struct *wq);

Как worker’ы справляются со своей работой

Теперь, как мы познакомились с API, давайте попробуем подробнее разобраться, как это все работает и управляется.
У каждого пула есть набор worker’ов, которые разгребают задачи. Причем, количество worker’ов меняется динамически, подстраиваясь под текущую ситуацию.
Как мы уже выяснили, worker’ы - это потоки, которые в контексте ядра выполняют work’и. Worker достает их по порядку один за другим из ассоциированного с ним worker pool, причем work’и, как мы уже знаем, могут принадлежать к разным исходным очередям.

Worker’ы условно могут находиться в трех логических состояниях: они могут быть простаивающими, запущенными или управляющими.
Worker может простаивать и ничего не делать. Это, например, когда все work’и уже исполняются. Когда worker переходит в это состояние, он засыпает и, соответственно, не будет исполняться до тех пор, пока его не разбудят;
Если не требуется управление пулом и список запланированных work’ов не пуст, то worker начинает исполнять их. Такие worker’ы условно будем называть запущенными .
Если же необходимо, worker берет на себя роль управляющего пулом. У пула может быть либо только один управляющий worker, либо не быть его вообще. Его задача - поддерживать оптимальное число worker’ов на пул. Как он это делает? Во-первых, удаляются worker’ы, которые достаточно долго простаивают. Во-вторых, создаются новые worker’ы, если выполняются сразу три условия:

  • еще есть задачи на выполнение (work’и в пуле)
  • нет простаивающих worker’ов
  • нет работающих worker’ов (то есть активных и при этом не спящих)
Однако, в последнем условии есть свои нюансы. Если очереди пула непривязанные, то учет запущенных worker’ов не осуществляется, для них это условие всегда истинно. То же самое справедливо и в случае выполнения worker’ом задачи из привязанной, но с флагом WQ_CPU_INTENSIVE , очереди. При этом, в случае привязанных очередей, так как worker’ы работают с work’ами из общего пула (который один из двух на каждое ядро на картинке выше), то получается, что некоторые из них учитываются как работающие, а некоторые - нет. Из этого же следует, что выполнение work’ов из WQ_CPU_INTENSIVE очереди может начаться не сразу, зато сами они не мешают исполняться другим work’ам. Теперь должно быть понятно, почему это флаг так называется, и почему он используется, когда мы ожидаем, что work’и будут выполняться долго.

Учет работающих worker’ов осуществляется прямо из основного планировщика ядра Linux. Такой механизм управления обеспечивает оптимальный уровень параллельности (concurrency level), не давая workqueue создавать слишком много worker’ов, но и не заставляя work’и без нужды ждать слишком долго.

Те, кому интересно, могут посмотреть функцию worker’а в ядре, называется она worker_thread().

Со всеми описанными функциями и структурами можно подробнее ознакомиться в файлах include/linux/workqueue.h , kernel/workqueue.c и kernel/workqueue_internal.h . Также по workqueue есть документация в Documentation/workqueue.txt .

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

Таким образом, мы рассмотрели механизмы отложенной обработки прерываний в ядре Linux - tasklet и workqueue, которые представляют собой особую форму многозадачности. Про прерывания, tasklet’ы и workqueue можно почитать в книге "Linux Device Drivers " авторов Jonathan Corbet, Greg Kroah-Hartman, Alessandro Rubini, правда, информация там временами устаревшая.



Загрузка...