[Перевод] Кунг-фу стиля Linux: простые конвейеры

В былые времена компьютеры делали по одному делу за раз. В буквальном смысле. В компьютер загружали перфокарты, или перфоленту, или что-нибудь другое, и нажимали на кнопку. Компьютер читал программу, выполнял её и выдавал результаты. Потом он «засыпал», пребывая в таком состоянии до тех пор, пока ему не дадут новых входных данных.

hxwikjz7eiuk9snfi5bzh39r9ba.jpeg

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

Все переводы серии


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

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

Сейчас как никогда актуально написание программных решений, состоящих из нескольких программ. У такого подхода есть множество сильных сторон. Например, однажды мне попалась программа, которая выполняет множество вычислений, а потом тратит часы на то, чтобы вывести результаты. Я запустил печать в виде отдельных заданий, рассчитанных на разные принтеры, и смог сэкономить примерно 80% рабочего времени. А это, когда я начинал работу, были примерно сутки. Но даже если не говорить о производительности, изоляция процессов напоминает бескомпромиссную инкапсуляцию. То, что делается в программе A, не должно влиять на программу B. Мы изолируем код в модулях и объектах, но можем пойти дальше и изолировать его в процессах.

Обоюдоострый меч


Но у изоляции кода в процессах тоже есть проблема. Она, полагаю, актуальна для тех случаев, когда две программы взаимодействуют, когда им нужно каким-то образом друг на друга влиять. Для организации «диалога» таких программ можно просто воспользоваться файлом, но такой подход печально известен своей неэффективностью. В результате операционные системы, вроде Linux, дают нам IPC — механизм межпроцессного взаимодействия. Так же, как некоторые части объекта можно сделать общедоступными, можно открыть доступ к чему-то, находящемуся внутри одной программы, другим программам.

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

А что если этого не происходит? Именно так работала моя система для создания отчётов. Серьёзные вычисления, которые занимали часы на компьютере Sequent с множеством процессоров, выполнялись в одном процессе. Когда приходило время вывода данных на печать, я делал форки множества подпроцессов. У каждого из них была полная копия данных, которые я впоследствии рассматривал как данные, предназначенные только для чтения, и которые я выводил на печать. Это — один из подходов к взаимодействию между процессами.

Ещё один подходов к межпроцессному взаимодействию заключается в использовании конвейеров. Взглянем на следующую конструкцию:

cat data.txt | sort | more


Здесь создают три процесса. Один берёт данные из текстового файла. Он отправляет эти данные в конвейер, подключённый к программе sort. А она, в свою очередь, выводит их в другой конвейер, подключённый к программе more.

Одностороннее движение


Конвейеры, вроде тех, о которых мы только что говорили — это нечто вроде дорог с односторонним движением. Но можно создавать именованные конвейеры, поддерживающие двусторонние коммуникации. Всё это можно сделать в командной оболочке — команда mknod позволяет создавать именованные конвейеры. Но оба вида конвейеров доступны и из программного кода. Так, для создания обычных конвейеров можно прибегнуть к функции popen, а для создания именованных — к вызову API mknod.

Есть и другие методы, позволяющие организовать межпроцессный диалог:

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


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

Можно написать такой код:

while (busy) ; // ждём busy==0
busy=1;
do_stuff();
busy=0;


Смотрится всё это очень хорошо. Правда? Нет, совсем не хорошо. Этот цикл while где-то в недрах процессора выглядит примерно так:

while_loop2384: TST busy ; установить флаги для busy
                JNZ while_loop2384 ; если нет флага нуля, осуществить переход
                MOV busy,#1 ; поместить 1 в busy


Почти всегда эта конструкция будет работать совершенно нормально. Почти всегда. А что произойдёт, если будет выполнена инструкция TST, а затем моя программа уйдёт в «сон», а другая программа сможет выполнить тот же код? Или — что если другой процессор выполняет тот же самый код в то же самое время? Такое может случиться. В результате обе программы увидят, что busy равно нулю. Обе установят busy в 1 и продолжат работу. Это недопустимо.

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

Путешествие по конвейеру


Вот одна задача, несколько искусственная. Если выполнить в Linux команду df — можно получить список всего, что смонтировано в системе, а так же — увидеть характеристики всего этого. Но в этом списке будут и такие сущности, как корневая директория и файл подкачки. Что если надо прочитать сведения только о петлевых устройствах и вывести данные в том же формате, в котором они обычно выводятся? Конечно, есть множество способов решения этой задачи. Можно прочитать сведения о петлевых файлах из /etc/mtab, а потом прочитать другие данные из /sys или из каких-то других мест, где они могут быть. Похоже, для решения этой задачи надо будет немало потрудиться.

Конечно, обычная команда df даёт нам почти то, что нужно. На самом деле, можно просто воспользоваться конвейером в командной оболочке и получить желаемое:

df | grep '^/dev/loop'


Подход это рабочий, но выходные данные окажутся оформленными не так, как хотелось бы. В моей системе, например, первым номером идёт /dev/loop3, а последним — /dev/loop0. И нет внятного объяснения тому, почему 4 размещается между 8 и 14. В результате — мне нужно всё это отсортировать. Но передача этих данных команде sort, опять же, с помощью конвейера, особой пользы не приносит, так как sort сортирует строки в алфавитном порядке. Тут можно подумать о команде sort с флагом -n. Но и это нам не поможет, так как число находится в конце строки. Конечно, можно применить какую-нибудь странную конструкцию с cut или sed, что, возможно, позволит всё это исправить, но при таком подходе задача неоправданно усложняется. Предлагаю просто взять и написать код на C.

На первом шаге работы мы просто сделаем так, чтобы команда df вывела бы всё, что она выводит, после чего перехватим эти данные. Так как мы хотим обрабатывать выходные данные — нам надо получить их из конвейера. Организовать это можно с помощью функции popen (ниже мы доработаем этот код):

#include 
int main(int argc, char * argv[]) {
// Этот фрагмент кода читает выходные данные DF
   FILE * result = popen("df", "r"), * sort;
   int i;
   if (!result) {
      perror("Can't open df");
      return 1;
   }
   while (!feof(result)) {
     int c = getc(result);
     if (c != EOF) putchar(c);
   }
 pclose(result);
 return 0;
}


Половина решения


Теперь половина задачи решена. Если у нас есть символы — можно заниматься их сортировкой и фильтрацией, но… притормозим немного. Я — человек ленивый. Поэтому давайте прибегнем к помощи командной оболочки. Вот мой план. Я знаю, что мне нужны лишь строки, начинающиеся с /dev/loop. Поэтому давайте действовать так:

  • Считываем всю строку за раз.
  • Если это не /dev/loop-строка — отбрасываем её.
  • Если это — /dev/loop-строка — сохраняем её в массив, но отсекаем от неё /dev/loop.
  • После того, как в нашем распоряжении оказываются все строки — просим командную оболочку их отсортировать, а после сортировки возвращаем строкам /dev/loop.


Это достаточно просто:

#include 
#include 
 
char buffer[4097];
char * lines[512];
unsigned int maxline = 0;
 
int main(int argc, char * argv[]) {
// Этот фрагмент кода читает выходные данные DF в массив lines (с некоторыми модификациями)
   FILE * result = popen("df", "r"), * sort;
   int i;
   if (!result) {
      perror("Can't open df");
   return 1;
   }
   while (!feof(result)) {
 // получаем строку от df
     char * rc = fgets(buffer, sizeof(buffer), result);
// сохраняем только строки, начинающиеся с /dev/loop
    if (rc && !strncmp(buffer, "/dev/loop", 9)) {
// нет места
       if (maxline >= sizeof(lines) / sizeof(char * )) {
       fprintf(stderr, "Too many loops\n");
       return 2;
     }
   lines[maxline++] = strdup(buffer + 9); // копируем только число и оставшиеся элементы строки
   // тут надо проверить lines[maxline[1]] на null
   }
 }
 pclose(result);
 
// Теперь мы собираемся вывести sort
// Команда sed работает с /dev/loop в начале строки после сортировки
 sort = popen("sort -n | sed 's/^/\\/dev\\/loop/'", "w");
 if (!sort) {
   perror("Can't open sort");
   return 3;
  }
// отправляем каждую из строк в конвейер (учитывайте, что порядок тут, на самом деле, не особенно важен ;-)
 for (i = 0; i < maxline; i++)
   fputs(lines[i], sort);
 pclose(sort);
 return 0;
}


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

Возможно, у вас возникнет вопрос о том, можно ли просто запустить что-то вроде команды sort, одновременно и передавая ей входные данные, и читая её выходные данные. Сделать это можно, но не при помощи popen. Вызов popen — это всего лишь удобная обёртка вокруг pipe и fork. Если вам нужно и передавать программе данные, и читать результаты её работы, понадобится напрямую (дважды) воспользоваться вызовом pipe, а потом запустить sort или что-то в этом роде. Возможно, я ещё об этом напишу.

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

Пишете ли вы программы, автоматизирующие сложные Linux-задачи?

oug5kh6sjydt9llengsiebnp40w.png

© Habrahabr.ru