Pull to refresh

Linux pipes tips & tricks

Reading time 8 min
Views 175K

Pipe — что это?


Pipe (конвеер) – это однонаправленный канал межпроцессного взаимодействия. Термин был придуман Дугласом Макилроем для командной оболочки Unix и назван по аналогии с трубопроводом. Конвейеры чаще всего используются в shell-скриптах для связи нескольких команд путем перенаправления вывода одной команды (stdout) на вход (stdin) последующей, используя символ конвеера ‘|’:
cmd1 | cmd2 | .... | cmdN

Например:
$ grep -i “error” ./log | wc -l
43

grep выполняет регистронезависимый поиск строки “error” в файле log, но результат поиска не выводится на экран, а перенаправляется на вход (stdin) команды wc, которая в свою очередь выполняет подсчет количества строк.

Логика


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

Размер буфера начиная с ядра версии 2.6.11 составляет 65536 байт (64Кб) и равен странице памяти в более старых ядрах. При попытке чтения из пустого буфера процесс чтения блокируется до появления данных. Аналогично при попытке записи в заполненный буфер процесс записи будет заблокирован до освобождения необходимого места.
Важно, что несмотря на то, что конвейер оперирует файловыми дескрипторами потоков ввода/вывода, все операции выполняются в памяти, без нагрузки на диск.
Вся информация, приведенная ниже, касается оболочки bash-4.2 и ядра 3.10.10.

Простой дебаг


Утилита strace позволяет отследить системные вызовы в процессе выполнения программы:
$ strace -f bash -c ‘/bin/echo foo | grep bar’
....
getpid() = 13726                   <– PID основного процесса
...
pipe([3,  4])                       <– системный вызов для создания конвеера
....
clone(....) = 13727                <– подпроцесс для первой команды конвеера (echo)
...
[pid 13727] execve("/bin/echo",  ["/bin/echo",  "foo"],  [/* 61 vars */] 
.....
[pid 13726] clone(....) = 13728    <– подпроцесс для второй команды (grep) создается так же основным процессом
...
[pid 13728] stat("/home/aikikode/bin/grep",   
...
Видно, что для создания конвеера используется системный вызов pipe(), а также, что оба процесса выполняются параллельно в разных потоках.

Много исходного кода bash и ядра

Исходный код, уровень 1, shell


Т. к. лучшая документация — исходный код, обратимся к нему. Bash использует Yacc для парсинга входных команд и возвращает ‘command_connect()’, когда встречает символ ‘|’.
parse.y:
1242 pipeline:   pipeline ‘|’ newline_list pipeline
1243             { $$ = command_connect ($1,  $4,  ‘|’); }
1244     |       pipeline BAR_AND newline_list pipeline
1245             {
1246               /* Make cmd1 |& cmd2 equivalent to cmd1 2>&1 | cmd2 */
1247               COMMAND *tc;
1248               REDIRECTEE rd,  sd;
1249               REDIRECT *r;
1250 
1251               tc = $1->type == cm_simple ? (COMMAND *)$1->value.Simple : $1;
1252               sd.dest = 2;
1253               rd.dest = 1;
1254               r = make_redirection (sd,  r_duplicating_output,  rd,  0);
1255               if (tc->redirects)
1256               {
1257                   register REDIRECT *t;
1258                   for (t = tc->redirects; t->next; t = t->next)
1259                       ;
1260                   t->next = r;
1261               }
1262               else
1263                   tc->redirects = r;
1264 
1265               $$ = command_connect ($1,  $4,  ‘|’);
1266             }
1267     |       command
1268             { $$ = $1; }
1269     ;
Также здесь мы видим обработку пары символов ‘|&’, что эквивалентно перенаправлению как stdout, так и stderr в конвеер. Далее обратимся к command_connect():make_cmd.c:
194 COMMAND *
195 command_connect (com1,  com2,  connector)
196      COMMAND *com1,  *com2;
197      int connector;
198 {
199   CONNECTION *temp;
200 
201   temp = (CONNECTION *)xmalloc (sizeof (CONNECTION));
202   temp->connector = connector;
203   temp->first = com1;
204   temp->second = com2;
205   return (make_command (cm_connection,  (SIMPLE_COM *)temp));
206 }
где connector это символ ‘|’ как int. При выполнении последовательности команд (связанных через ‘&’, ‘|’, ‘;’, и т. д.) вызывается execute_connection():execute_cmd.c:
2325     case ‘|’:
...
2331       exec_result = execute_pipeline (command,  asynchronous,  pipe_in,  pipe_out,  fds_to_close);

PIPE_IN и PIPE_OUT — файловые дескрипторы, содержащие информацию о входном и выходном потоках. Они могут принимать значение NO_PIPE, которое означает, что I/O является stdin/stdout.
execute_pipeline() довольно объемная функция, имплементация которой содержится в execute_cmd.c. Мы рассмотрим наиболее интересные для нас части.
execute_cmd.c:
2112   prev = pipe_in;
2113   cmd = command;
2114
2115   while (cmd && cmd->type == cm_connection &&
2116   cmd->value.Connection && cmd->value.Connection->connector == ‘|’)
2117     {
2118       /* Создание конвеера между двумя командами */
2119       if (pipe (fildes) < 0)
2120       { /* возвращаем ошибку */ }
.......
           /* Выполняем первую команду из конвейера,  используя в качестве
              входных данных prev — вывод предыдущей команды,  а в качестве
              выходных fildes[1] — выходной файловый дескриптор,  полученный
              в результате вызова pipe() */
2178       execute_command_internal (cmd->value.Connection->first,  asynchronous, 
2179         prev,  fildes[1],  fd_bitmap);
2180 
2181       if (prev >= 0)
2182           close (prev);
2183 
2184       prev = fildes[0];    /* Наш вывод становится вводом для следующей команды */
2185       close (fildes[1]);
.......
2190       cmd = cmd->value.Connection->second;  /* “Сдвигаемся” на следующую команду из конвейера */
2191     }
Таким образом, bash обрабатывает символ конвейера путем системного вызова pipe() для каждого встретившегося символа ‘|’ и выполняет каждую команду в отдельном процессе с использованием соответствующих файловых дескрипторов в качестве входного и выходного потоков.

Исходный код, уровень 2, ядро


Обратимся к коду ядра и посмотрим на имплементацию функции pipe(). В статье рассматривается ядро версии 3.10.10 stable.
fs/pipe.c (пропущены незначительные для данной статьи участки кода):
/*
    Максимальный размер буфера конвейера для непривилегированного пользователя.
    Может быть выставлен рутом в файле /proc/sys/fs/pipe-max-size
  */
  35 unsigned int pipe_max_size = 1048576; 
   /*
     Минимальный размер буфера конвеера,  согласно рекомендации POSIX
     равен размеру одной страницы памяти,  т.е. 4Кб
    */
  40 unsigned int pipe_min_size = PAGE_SIZE;

 869 int create_pipe_files(struct file **res,  int flags)
 870 {
 871     int err;
 872     struct inode *inode = get_pipe_inode();
 873     struct file *f;
 874     struct path path;
 875     static struct qstr name = {. name = “” };
         /* Выделяем dentry в dcache */
 881     path.dentry = d_alloc_pseudo(pipe_mnt->mnt_sb,  &name);
         /* Выделяем и инициализируем структуру file. Обратите внимание
            на FMODE_WRITE,  а также на флаг O_WRONLY,  т.е. эта структура
            только для записи и будет использоваться как выходной поток
            в конвеере. К флагу O_NONBLOCK мы еще вернемся. */
 889     f = alloc_file(&path,  FMODE_WRITE,  &pipefifo_fops);
 893     f->f_flags = O_WRONLY | (flags & (O_NONBLOCK | O_DIRECT));
         /* Аналогично выделяем и инициализируем структуру file для чтения
            (см. FMODE_READ и флаг O_RDONLY) */
 896     res[0] = alloc_file(&path,  FMODE_READ,  &pipefifo_fops);
 902     res[0]->f_flags = O_RDONLY | (flags & O_NONBLOCK);
 903     res[1] = f;
 904     return 0;
 917 }
 918
 919 static int __do_pipe_flags(int *fd,  struct file **files,  int flags)
 920 {
 921     int error;
 922     int fdw,  fdr;
         /* Создаем структуры file для файловых дескрипторов конвеера
            (см. функцию выше) */ 
 927     error = create_pipe_files(files,  flags);
         /* Выбираем свободные файловые дескрипторы */
 931     fdr = get_unused_fd_flags(flags);
 936     fdw = get_unused_fd_flags(flags);
 941     audit_fd_pair(fdr,  fdw);
 942     fd[0] = fdr;
 943     fd[1] = fdw;
 944     return 0;
 952 }
     /* Непосредственно имплементация функций
        int pipe2(int pipefd[2],  int flags)... */
 969 SYSCALL_DEFINE2(pipe2,  int __user *,  fildes,  int,  flags)
 970 {
 971     struct file *files[2];
 972     int fd[2];
         /* Создаем структуры для ввода/вывода и ищем свободные дескрипторы */
 975     __do_pipe_flags(fd,  files,  flags);
         /* Копируем файловые дескрипторы из kernel space в user space */
 977     copy_to_user(fildes,  fd,  sizeof(fd));
         /* Назначаем файловые дескрипторы указателям на структуры */
 984     fd_install(fd[0],  files[0]);
 985     fd_install(fd[1],  files[1]);
 989 }
     /* ...и int pipe(int pipefd[2]),  которая по сути является
        оболочкой для вызова pipe2 с дефолтными флагами; */
 991 SYSCALL_DEFINE1(pipe,  int __user *,  fildes)
 992 {
 993     return sys_pipe2(fildes,  0);
 994 }
Если вы обратили внимание, в коде идет проверка на флаг O_NONBLOCK. Его можно выставить используя операцию F_SETFL в fcntl. Он отвечает за переход в режим без блокировки I/O потоков в конвеере. В этом режиме вместо блокировки процесс чтения/записи в поток будет завершаться с errno кодом EAGAIN.

Максимальный размер блока данных, который будет записан в конвейер, равен одной странице памяти (4Кб) для архитектуры arm:
arch/arm/include/asm/limits.h:
 8 #define PIPE_BUF    PAGE_SIZE
Для ядер >= 2.6.35 можно изменить размер буфера конвейера:
fcntl(fd,  F_SETPIPE_SZ,  <size>)
Максимально допустимый размер буфера, как мы видели выше, указан в файле /proc/sys/fs/pipe-max-size.

Tips & trics


В примерах ниже будем выполнять ls на существующую директорию Documents и два несуществующих файла: ./non-existent_file и. /other_non-existent_file.

  1. Перенаправление и stdout, и stderr в pipe

    ls -d ./Documents ./non-existent_file ./other_non-existent_file 2>&1 | egrep “Doc|other”
    ls: cannot access ./other_non-existent_file: No such file or directory
    ./Documents
    
    или же можно использовать комбинацию символов ‘|&’ (о ней можно узнать как из документации к оболочке (man bash), так и из исходников выше, где мы разбирали Yacc парсер bash):
    ls -d ./Documents ./non-existent_file ./other_non-existent_file |& egrep “Doc|other”
    ls: cannot access ./other_non-existent_file: No such file or directory
    ./Documents
    

  2. Перенаправление _только_ stderr в pipe

    $ ls -d ./Documents ./non-existent_file ./other_non-existent_file 2>&1 >/dev/null | egrep “Doc|other”
    ls: cannot access ./other_non-existent_file: No such file or directory
    
    Shoot yourself in the foot
    Важно соблюдать порядок перенаправления stdout и stderr. Например, комбинация ‘>/dev/null 2>&1′ перенаправит и stdout, и stderr в /dev/null.


  3. Получение корректного кода завершения конвейра

    По умолчанию, код завершения конвейера — код завершения последней команды в конвеере. Например, возьмем исходную команду, которая завершается с ненулевым кодом:
    $ ls -d ./non-existent_file 2>/dev/null; echo $?
    2
    
    И поместим ее в pipe:
    $ ls -d ./non-existent_file 2>/dev/null | wc; echo $?
          0       0       0
    0
    
    Теперь код завершения конвейера — это код завершения команды wc, т.е. 0.

    Обычно же нам нужно знать, если в процессе выполнения конвейера произошла ошибка. Для этого следует выставить опцию pipefail, которая указывает оболочке, что код завершения конвейера будет совпадать с первым ненулевым кодом завершения одной из команд конвейера или же нулю в случае, если все команды завершились корректно:
    $ set -o pipefail
    $ ls -d ./non-existent_file 2>/dev/null | wc; echo $?
          0       0       0
    2
    
    Shoot yourself in the foot
    Следует иметь в виду “безобидные” команды, которые могут вернуть не ноль. Это касается не только работы с конвейерами. Например, рассмотрим пример с grep:
    $ egrep “^foo=[0-9]+” ./config | awk ‘{print “new_”$0;}’
    
    Здесь мы печатаем все найденные строки, приписав ‘new_’ в начале каждой строки, либо не печатаем ничего, если ни одной строки нужного формата не нашлось. Проблема в том, что grep завершается с кодом 1, если не было найдено ни одного совпадения, поэтому если в нашем скрипте выставлена опция pipefail, этот пример завершится с кодом 1:
    $ set -o pipefail
    $ egrep “^foo=[0-9]+” ./config | awk ‘{print “new_”$0;}’ >/dev/null; echo $?
    1
    
    В больших скриптах со сложными конструкциями и длинными конвеерами можно упустить этот момент из виду, что может привести к некорректным результатам.


  4. Присвоение значений переменным в конвейере

    Для начала вспомним, что все команды в конвейере выполняются в отдельных процессах, полученных вызовом clone(). Как правило, это не создает проблем, за исключением случаев изменения значений переменных.
    Рассмотрим следующий пример:
    $ a=aaa
    $ b=bbb
    $ echo “one two” | read a b
    
    Мы ожидаем, что теперь значения переменных a и b будут “one” и “two” соответственно. На самом деле они останутся “aaa” и “bbb”. Вообще любое изменение значений переменных в конвейере за его пределами оставит переменные без изменений:
    $ filefound=0
    $ find . -type f -size +100k |
        while true
        do
            read f
            echo “$f is over 100KB”
            filefound=1
            break          # выходим после первого найденного файла
        done
    $ echo $filefound;
    
    Даже если find найдет файл больше 100Кб, флаг filefound все равно будет иметь значение 0.
    Возможны несколько решений этой проблемы:
    • использовать
      set -- $var
      

      Данная конструкция выставит позиционные переменные согласно содержимому переменной var. Например, как в первом примере выше:
      $ var=”one two”
      $ set -- $var
      $ a=$1   # “one”
      $ b=$2   # “two”
      
      Нужно иметь в виду, что в скрипте при этом будут утеряны оригинальные позиционные параметры, с которыми он был вызван.
    • перенести всю логику обработки значения переменной в тот же подпроцесс в конвейере:
      $ echo “one” | (read a; echo $a;)
      one 
      
    • изменить логику, чтобы избежать присваивания переменных внутри конвеера.
      Например, изменим наш пример с find:
      $ filefound=0
      $ for f in $(find . -type f -size +100k)  # мы убрали конвейер,  заменив его на цикл
          do
              read f
              echo “$f is over 100KB”
              filefound=1
              break
          done
      $ echo $filefound;
      
    • (только для bash-4.2 и новее) использовать опцию lastpipe
      Опция lastpipe дает указание оболочке выполнить последнюю команду конвейера в основном процессе.
      $ (shopt -s lastpipe; a=”aaa”; echo “one” | read a; echo $a)
      one
      
      Важно, что в командной строке необходимо выставить опцию lastpipe в том же процессе, где будет вызываться и соответствующий конвейер, поэтому скобки в примере выше обязательны. В скриптах скобки не обязательны.

Дополнительная информация


Tags:
Hubs:
+89
Comments 36
Comments Comments 36

Articles