Многозадачное программирование в Linux. Принципы работы в UNIX-подобных ОС на примере Linux

Совместно с университетом.


У меня, натурально, было 10 минут, поэтому изложение — галопом по европам, многое упрощено, многое упущено.

Немного истории

Относительно подробную историю создания ядра Linux можно найти в известной книге Линуса Торвальдса «Just for fun». Нас из неё интересуют следующие факты:

    Ядро создал в 1991 году студент университета Хельсинки Линус Торвальдс;

    В качестве платформы он использовал ОС Minix, написанную его преподавателем Эндрю Таненбаумом, запущенную на персональном компьютере с процессором Intel 80386;

    В качестве примера для подражания он использовал ОС семейства Unix, а в качестве путеводителя — сначала стандарт POSIX, а затем просто исходные коды программ из комплекта GNU (bash, gcc и пр).

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

В частности, известно, что Unix-системы в своё время разделились на два лагеря: потомки UNIX System V Release 4 (семейство SVR4) против потомков Berkley Software Distribution v4.2 (BSD4.2). Linux по большей части принадлежит к первому семейству, но заимствует некоторые существенные идеи из второго.

Ядро в цифрах

  • Около 30 тыс. файлов
  • Около 8 млн. строк кода (не считая комментариев)
  • Репозиторий занимает около 1 Гб
  • linux-2.6.33.tar.bz2: 63 Mb
  • patch-2.6.33.bz2: 10Mb, около 1.7 млн изменённых строк
  • Около 6000 человек, чей код есть в ядре

Об архитектуре ядра

Все (или почти все) процессоры, которыми когда-либо интересовались производители Unix-подобных ОС, имеют аппаратную поддержку разделения привелегий. Один код может всё (в т.ч. общаться напрямую с оборудованием), другой — почти ничего. Традиционно говорят о «режиме ядра» (kernel land) и «режиме пользователя» (user land). Различные архитектуры ядер ОС различаются прежде всего подходом к ответу на вопрос: какие части кода ОС должны выполняться в kernel land, а какие — в user land? Дело в том, что у подавляющего большинства процессоров переключение между двумя режимами занимает существенное время. Выделяют следующие подходы:

    Традиционный: монолитное ядро. Весь код ядра компилируется в один большой бинарный файл. Всё ядро исполняется в режиме ядра;

    Противоположный, новаторский: микроядро. В режиме ядра выполняются только самые необходимые части, всё остальное — в режиме пользователя;

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

    И, конечно, всевозможные варианты гибридных архитектур.

Ядро Linux начиналось как монолитное (глядя на существовавшие тогда Unix-ы). Современное Linux-ядро модульное. По сравнению с микроядром монолитное (или модульное) ядро обеспечивает существенно бо́льшую производительность, но предъявляет существенно более жёсткие требования к качеству кода различных компонентов. Так, в системе с микроядром «рухнувший» драйвер ФС будет перезапущен без ущерба для работы системы; рухнувший драйвер ФС в монолитном ядре — это Kernel panic и останов системы.

Подсистемы ядра Linux

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

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

Системные вызовы

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

Здесь стоит отметить некоторую разницу в подходе к проектированию API ядра в Unix-системах с одной стороны и в Windows и других идеологических потомках VMS с другой. Дизайнеры Unix предпочитают предоставить десять системных вызовов с одним параметром вместо одного системного вызова с двадцатью параметрами. Классический пример — создание процесса. В Windows функция для создания процесса — CreateProcess() — принимает 10 аргументов, из которых 5 — структуры. В противоположность этому, Unix-системы предоставляют два системных вызова (fork() и exec()), первый — вообще без параметров, второй — с тремя параметрами.

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

Управление памятью

Ядро Linux использует в качестве минимальной единицы памяти страницу. Размер страницы может зависеть от оборудования; на x86 это 4Кб. Для хранения информации о странице физической памяти (её физический адрес, принадлежность, режим использования и пр) используется специальная структура page размером в 40 байт.

Ядро использует возможности современных процессоров для организации виртуальной памяти. Благодаря манипуляциям с каталогами страниц виртуальной памяти, каждый процесс получает адресное пространство размером в 4Гб (на 32х-разрядных архитектурах). Часть этого пространства доступна процессу только на чтение или исполнение: туда отображаются интерфейсы ядра.

Существенно, что процесс, работающий в пространстве пользователя, в большинстве случаев «не знает», где находятся его данные: в ОЗУ или в файле подкачки. Процесс может попросить у системы выделить ему память именно в ОЗУ, но система не обязана удовлетворять такую просьбу.

Управление процессами

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

В истории было известно два типа многозадачности:

Корпоративная многозадачность.

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

Вытесняющая многозадачность.

Ядро ОС выделяет каждому процессу определённый квант процессорного времени и «насильно» передаёт управление другому процессу по истечении этого кванта. Это создаёт накладные расходы на переключение режимов процессора и расчёт приоритетов, но повышает надёжность и производительность.

Переключение процессов в linux может производиться по наступлению двух событий: аппаратного прерывания или прерывания от таймера. Частота прерываний таймера устанавливается при компиляции ядра в диапазоне от 100Гц до 1000Гц. Аппаратные прерывания возникают чуть ли не чаще: достаточно двинуть мышку или нажать кнопку на клавиатуре, да и внутренние устройства компьютера генерируют прерывания. Начиная с версии 2.6.23, появилась возможность собрать ядро, не использующее переключение процессов по таймеру. Это позволяет снизить энергопотребление в режиме простоя компьютера.

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

Кроме многозадачности в режиме пользователя, ядро Linux использует многозадачность в режиме ядра: само ядро многопоточно.

Традиционные ядра Unix-систем имели следующую… ну если не проблему, то особенность: само ядро не было вытесняемым. Пример: процесс /usr/bin/cat хочет открыть файл /media/cdrom/file.txt и использует для этого системный вызов open() . Управление передаётся ядру. Ядро обнаруживает, что файл расположен на CD-диске и начинает инициализацию привода (раскручивание диска и пр). Это занимает существенное время. Всё это время управление не передаётся пользовательским процессам, т.к. планировщик не активен в то время, когда выполняется код ядра. Все пользовательские процессы ждут завершения этого вызова open() .

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

Сетевая подсистема

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

Сетевая подсистема Linux обеспечивает следующую функциональность:

    Абстракцию сокетов;

    Стеки сетевых протоколов (TCP/IP, UDP/IP, IPX/SPX, AppleTalk и мн. др);

    Маршрутизацию (routing);

    Пакетный фильтр (модуль Netfilter);

    Абстракцию сетевых интерфейсов.

В различных Unix-системах использовалось два различных прикладных интерфейса, обеспечивающих доступ к функциональности сетевой подсистемы: Transport Layer Interface (TLI) из SVR4 и sockets (сокеты) из BSD. Интерфейс TLI, с одной стороны, тесно завязан на подсистему STREAMS, отсутствующую в ядре Linux, а с другой — не совместим с интерфейсом сокетов. Поэтому в Linux используется интерфейс сокетов, взятый из семейства BSD.

Файловая система

Виртуальная файловая система (VFS)

С точки зрения приложений, в Unix-подобных ОС существует только одна файловая система. Она представляет собой дерево директорий, растущее из «корня». Приложениям, в большинстве случаев, не интересно, на каком носителе находятся данные файлов; они могут находиться на жёстком диске, оптическом диске, флеш-носителе или вообще на другом компьютере и другом континенте. Эта абстракция и реализующая её подсистема называется виртуальной файловой системой (VFS).

Стоит заметить, что VFS в ядре Linux реализована с учётом идей из ООП. Например, ядро рассматривает набор структур inode , каждая из которых содержит (среди прочего):

    Данные из on-disk inode (права доступа, размер файла и др);

    Указатель на структуру, описывающую драйвер ФС, к которой принадлежит данный inode;

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

Аналогично устроены структуры ядра, описывающие другие сущности ФС — суперблок, элемент каталога, файл.

Драйверы ФС

Драйверы ФС, как можно заметить из диаграммы, относятся к гораздо более высокому уровню, чем драйверы устройств. Это связано с тем, что драйверы ФС не общаются ни с какими устройствами. Драйвер файловой системы лишь реализует функции, предоставляемые им через интерфейс VFS. При этом данные пишутся и читаются в/из страницы памяти; какие из них и когда будут записаны на носитель — решает более низкий уровень. Тот факт, что драйверы ФС в Linux не общаются с оборудованием, позволил реализовать специальный драйвер FUSE, который делегирует функциональность драйвера ФС в модули, исполняемые в пространстве пользователя.

Страничный кэш

Эта подсистема ядра оперирует страницами виртуальной памяти, организованными в виде базисного дерева (radix tree). Когда происходит чтение данных с носителя, данные читаются в выделяемую в кэше страницу, и страница остаётся в кэше, а драйвер ФС читает из неё данные. Драйвер ФС пишет данные в страницы памяти, находящиеся в кэше. При этом эти страницы помечаются как «грязные» (dirty). Специальный поток ядра, pdflush , регулярно обходит кэш и формирует запросы на запись грязных страниц. Записанная на носитель грязная страница вновь помечается как чистая.

Уровень блочного ввода-вывода

Эта подсистема ядра оперирует очередями (queues), состоящими из структур bio . Каждая такая структура описывает одну операцию ввода-вывода (условно говоря, запрос вида «записать вот эти данные в блоки ##141-142 устройства /dev/hda1»). Для каждого процесса, осуществляющего ввод-вывод, формируется своя очередь. Из этого множества очередей создаётся одна очередь запросов к драйверу каждого устройства.

Планировщик ввода-вывода

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

    Сортировка: планировщик старается ставить подряд запросы, обращающиеся к находящимся близко секторам диска;

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

В современном ядре доступно несколько планировщиков: Anticipatory, Deadline, CFQ, noop. Существует версия ядра от Con Kolivas с ещё одним планировщиком — BFQ. Планировщики могут выбираться при компиляции ядра либо при его запуске.

Отдельно следует остановиться на планировщике noop. Этот планировщик не выполняет ни сортировки, ни слияния запросов, а переправляет их драйверам устройств в порядке поступления. На системах с обычными жёсткими дисками этот планировщик покажет очень плохую производительность. Однако, сейчас становятся распространены системы, в которых вместо жёстких дисков используются флеш-носители. Для таких носителей время поиска сектора равно нулю, поэтому операции сортировки и слияния не нужны. В таких системах при использовании планировщика noop производительность не изменится, а потребление ресурсов несколько снизится.

Обработка прерываний

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

Тут существует определённая тонкость. Дело в том, что во время работы обработчика прерывания планировщик задач не активен. Это не удивительно: предполагается, что обработчик прерывания работает непосредственно с устройством, а устройство может требовать выполнения каких-то действий в жёстких временных рамках. Поэтому если обработчик прерывания будет работать долго, то все остальные процессы и потоки ядра будут ждать, а это обычно недопустимо.

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

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

В качестве примера можно рассмотреть обработчик прерывания от сетевой карты, сообщающего, что принят ethernet-пакет. Этот обработчик обязан сделать две вещи:

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

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

Соответственно, первое выполняет верхняя половина обработчика, а второе — нижняя.

Драйвера устройств

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

    От устройства — через зарегистрированные драйвером обработчики прерываний;

    От различных частей ядра — через API, который определяется конкретной подсистемой ядра и самим драйвером.

Процессы в 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 , такой файл выполняется с эффективным идентификатором группы-владельца.

ЛАБОРАТОРНАЯ РАБОТА №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. Как уничтожить процесс в операционной системе и программе?

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

В семействах ОС 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 ;

Наберите в своей оболочке следующую команду:

−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−

На экран будут выведен список всех работающих в системе процессов. Если хотите посчитать количество процессов, наберите что-нибудь, наподобие этого:

−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−

$ ps -e --no-headers | nl | tail -n 1

74 4650 pts/0 00:00:00 tail

−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−

Первое число - это количество работающих в системе процессов. Пользователи KDE могут воспользоваться программой kpm, а пользователи Gnome - программой gnome-system-monitor для получения информации о процессах. На то он и Linux, чтобы позволять пользователю делать одно и то же разными способами.

Возникает вопрос: «Что такое процесс?». Процессы в Linux, как и файлы, являются аксиоматическими понятиями. Иногда процесс отождествляют с запущенной программой, однако, это не всегда так. Будем считать, что процесс - это рабочая единица системы, которая выполняет что-то. Многозадачность - это возможность одновременного сосуществования нескольких процессов в одной системе.

Linux - многозадачная операционная система. Это означает, что процессы в ней работают одновременно. Естественно, это условная формулировка. Ядро Linux постоянно переключает процессы, то есть время от времени дает каждому из них сколько-нибудь процессорного времени. Переключение происходит довольно быстро, поэтому нам кажется, что процессы работают одновременно.

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

К каждому процессу в системе привязана пара целых неотрицательных чисел: идентификатор процесса PID (Process IDentifier) и идентификатор родительского процесса PPID (Parent Process IDentifier). Для каждого процесса PID является уникальным (в конкретный момент времени), а PPID равен идентификатору процесса-родителя. Если ввести в оболочку команду ps -ef, то на экран будет выведен список процессов со значениями их PID и PPID (вторая и третья колонки соотв.). Пример работы такой команды:

−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−

UID PID PPID C STIME TTY TIME CMD

root 1 0 0 17:08? 00:00:00 /sbin/init

root 2 0 0 17:08? 00:00:00

root 3 2 0 17:08? 00:00:00

root 4 2 0 17:08? 00:00:00

root 5 2 0 17:08? 00:00:00

root 6 2 0 17:08? 00:00:00

root 7 2 0 17:08? 00:00:00

root 8 2 0 17:08? 00:00:00

root 9 2 0 17:08? 00:00:00

root 10 2 0 17:08? 00:00:00

root 11 2 0 17:08? 00:00:00

root 12 2 0 17:08? 00:00:00

root 13 2 0 17:08? 00:00:00

root 14 2 0 17:08? 00:00:00

root 15 2 0 17:08? 00:00:00

root 16 2 0 17:08? 00:00:00

root 17 2 0 17:08? 00:00:00

root 18 2 0 17:08? 00:00:00

root 19 2 0 17:08? 00:00:00

df00 16389 16387 0 20:10 pts/1 00:00:00 /bin/bash

df00 17446 2538 0 20:26? 00:00:00

df00 18544 2932 0 20:41 pts/2 00:00:00 /bin/bash -l

df00 19010 18544 0 20:48 pts/2 00:00:00 ps -ef

−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−

Надо отметить, что процесс init всегда имеет идентификатор 1 и PPID равный 0. Хотя в реальности процесса с идентификатором 0 не существует. Дерево процессов можно также представить в наглядном виде при помощи опции --forest программы ps:

−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−

2? 00:00:00 kthreadd

3 ?00:00:00 \_ migration/0

4 ?00:00:00 \_ ksoftirqd/0

5 ?00:00:00 \_ watchdog/0

6 ?00:00:00 \_ migration/1

7 ?00:00:00 \_ ksoftirqd/1

8 ?00:00:00 \_ watchdog/1

9 ?00:00:00 \_ events/0

10 ?00:00:00 \_ events/1

11 ?00:00:00 \_ cpuset

12 ?00:00:00 \_ khelper

13 ?00:00:00 \_ netns

14 ?00:00:00 \_ async/mgr

15 ?00:00:00 \_ kintegrityd/0

16 ?00:00:00 \_ kintegrityd/1

18544 pts/2 00:00:00 \_ bash

16388 ?00:00:00 \_ gnome-pty-helpe

16389 pts/1 00:00:00 \_ bash

−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−

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

Использование getpid() и getppid()

Процесс может узнать свой идентификатор (PID), а также родительский идентификатор (PPID) при помощи системных вызовов getpid() и getppid().

Системные вызовы getpid() и getppid() имеют следующие прототипы:

−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−

pid_t getpid (void);

pid_t getppid (void);

−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−

Для использования getpid() и getppid() в программу должны быть включены директивой #include заголовочные файлы unistd.h и sys/types.h (для типа pid_t). Вызов getpid() возвращает идентификатор текущего процесса (PID), а getppid() возвращает идентификатор родителя (PPID). pid_t - это целый тип, размерность которого зависит от конкретной системы. Значениями этого типа можно оперировать как обычными целыми числами типа int.

Рассмотрим теперь простую программу, которая выводит на экран PID и PPID, а затем «замирает» до тех пор, пока пользователь не нажмет .

−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−

#include

#include

#include

pid_t pid, ppid;

pid = getpid ();

ppid = getppid ();

printf («PID: %d\n», pid);

printf («PPID: %d\n», ppid);

fprintf (stderr, «Press to exit...»);

−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−

Проверим теперь, как работает эта программа. Для этого откомпилируем и запустим ее:

−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−

$ gcc -o getpid getpid.c

Press to exit...

−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−

Теперь, не нажимая , откроем другое терминальное окно и проверим, правильность работы системных вызовов getpid() иgetppid():

−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−

$ ps -ef | grep getpid

df00 19724 19702 0 20:58 pts/3 00:00:00 ./main

df00 19856 18544 0 21:00 pts/2 00:00:00 grep --colour=auto main

−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−−