Многие пользователи Bash знают о существании со-процессов, появившихся в 4-й версии Bash'a. Несколько меньшее количество знает о том, что сопроцессы в Bash не какая-то новая фича, а древний функционал KornShell'a появившийся ещё в реализации ksh88 в 1988 году. Ещё меньшее количество пользователей shell'ов умеющих сопроцессить знают синтаксис и помнят как это делать. Вероятно, я отношусь к четвёртой группе — знающих о сопроцессах, периодически умеющих ими пользоваться но так и не понимающих «зачем?». Я говорю «периодически», так как иногда я освежаю в голове их синтаксис, но к тому моменту, когда мне кажется что «вот тот случай когда можно применить co-proc» я уже напрочь забываю о том как это делать.

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

В заголовке статьи у нас 3 вопроса. Пойдём по порядку.

Что?


Что же такое co-process? Со-процессинг — это одновременное выполнение двух процедур, одна из которых считывает вывод другой. Для его реализации необходимо предварительно запустить фоновый процесс выполняющий функционал канала. При запуске фонового процесса его stdin и stdout присваиваются каналам связанными с пользовательскими процессами. Соответственно, один канал для записи, второй для чтения. Пояснять это проще на примерах, поэтому сразу перейдём ко второму вопросу.

Как?


Реализации со-процессов в шеллах разнятся. Я остановлюсь на 3-х известных мне реализациях в ksh, zsh и bash. Рассмотрим их в хронологическом порядке. Хоть это и не имеет прямого отношения к вопросам статьи, отмечу, что все нижеприведённые примеры сделаны на

$ uname -opr
FreeBSD 10.1-STABLE amd64

Ksh

$ `echo $0` --version
  version         sh (AT&T Research) 93u+ 2012-08-01

Синтаксис

cmd |&

кажется мне наиболее логичным. Здесь для выполнения команды cmd в фоновом режиме мы используем специальную операцию |&, выражающую соответвенно:

— "&" — фоновый процесс;
— "|" — каналы.

Запускаем фоновый процесс:

$ tr -u a b |&
[2]     6053

Убедимся, что он жив:

$  ps afx | grep [6]053
 6053  4  IN        0:00.00 tr -u a b

UPD 2016.08.15 21:15
Не совсем корректная в данном контексте команда. Не исправляю дабы не нарушить логику комментариев.
Правильнее так:

$ tr -u a b |&
[2]     6053
$ ps -p 6053
  PID TT  STAT    TIME COMMAND
6053  4  SN   0:00.00 tr -u a b

Спасибо ZyXI

Теперь мы можем общаться с нашим фоновым процессом. Пишем:

$ print -p abrakadabra1
$ print -p abrakadabra2
$ print -p abrakadabra3

и читаем:

$ read -p var; echo $var
bbrbkbdbbrb1
$ read -p var; echo $var
bbrbkbdbbrb2
$ read -p var; echo $var
bbrbkbdbbrb3

или так:

$ print abrakadabra1 >&p
$ print abrakadabra2 >&p
$ print abrakadabra3 >&p
$ while read -p var; do echo $var; done
bbrbkbdbbrb1
bbrbkbdbbrb2
bbrbkbdbbrb3

Закрываем «конец» трубы для записи:

$ exec 3>&p 3>&-

и для чтения:

$ exec 3<&p 3<&-

Zsh

$ `echo $0` --version
zsh 5.2 (amd64-portbld-freebsd10.1)

Синтаксис со-процессов в zsh не слишком отличается от ksh, что не удивительно, т.к. в его man'е сказано «zsh most closely resembles ksh».

Основным отличием является использование ключевого слова coproc вместо оператора |&. В остальном всё очень похоже:

$ coproc tr -u a b
[1] 22810
$ print -p abrakadabra1
$ print abrakadabra2 >&p
$ print -p abrakadabra3
$ read -ep
bbrbkbdbbrb1
$ while read -p var; do echo $var; done
bbrbkbdbbrb2
bbrbkbdbbrb3

Для закрытия каналов чтения/записи можно воспользоваться идиомой exit:

$ coproc exit
[1] 23240
$
[2]  - done       tr -u a b
$
[1]  + done       exit

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

$ coproc tr -u a b
[1] 24981
$ print -p aaaaa
$ read -ep
bbbbb
$ coproc tr -u a d
[2] 24982
$
[1]  - done       tr -u a b
$ print -p aaaaa
$ read -ep
ddddd
$

В ksh мы бы просто получили:

$ tr -u a b |&
[1]     25072
$ tr -u a d |&
ksh93: process already exists

Несмотря на эту возможность рекомендуется, всегда явно убивать фоновый процесс, особенно, при использовании «setopt NO_HUP».

Здесь же стоит упомянуть, что иногда мы можем получить неожиданные результаты связанные с буферизацией вывода, именно поэтому в приведённых выше примерах мы используем tr с опцией -u.

$ man tr | col | grep "\-u"
     -u      Guarantee that any output is unbuffered.

Хоть это и не имеет оношения исключительно к со-процессам продемонстрирую это поведение примером:

$ coproc tr a b
[1] 26257
$ print -p a
$ read -ep
^C
$
[1]  + broken pipe  tr a b

Буфер не полон и мы ничего не получаем из нашей трубы. Заполним его «доверху»:

$ coproc tr a b
[1] 26140
$ for ((a=1; a <= 4096 ; a++)) do print -p 'a'; done
$ read -ep
b

Разумеется, если данное поведение нас не устраивает, его можно изменить, например используя stdbuf

$ coproc stdbuf -oL -i0 tr a b
[1] 30001
$ print -p a
$ read -ep
b

Bash

$ `echo $0` --version
GNU bash, version 4.3.42(1)-release (amd64-portbld-freebsd10.1)

Для запуска со-процесса в bash также как и в zsh используется зарезервированное слово coproc, но в отличии от рассмотренных выше shell'ов доступ к сопроцессу осуществляется не с помощью >&p и <&p, а посредством массива $COPROC:

${COPROC[0]} для записи;
${COPROC[1]} для чтения.

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

$  coproc tr -u a b
[1] 30131
$ echo abrakadabra1 >&${COPROC[1]}
$ echo abrakadabra2 >&${COPROC[1]}
$ echo abrakadabra3 >&${COPROC[1]}
$ while read -u ${COPROC[0]}; do printf "%s\n" "$REPLY"; done
bbrbkbdbbrb1
bbrbkbdbbrb2
bbrbkbdbbrb3

а закрытие дескрипторов:

$ exec {COPROC[1]}>&-
$ cat <&"${COPROC[0]}"
[1]+  Done                    coproc COPROC tr -u a b

Если имя COPROC по каким-то причинам не устраивает можно указать свое:

$ coproc MYNAME (tr -u a b)
[1] 30528
$ echo abrakadabra1 >&${MYNAME[1]}
$ read -u ${MYNAME[0]} ; echo $REPLY
bbrbkbdbbrb1
$  exec {MYNAME[1]}>&- ; cat <&"${MYNAME[0]}"
[1]+  Done                    coproc MYNAME ( tr -u a b )

Зачем?


Прежде чем попытаться ответить зачем нужны сопроцессы подумаем можно ли реализовать их функционал в shell'ах которые не имеют coproc «из коробки». Например в таком:

$ man sh | col -b | grep -A 4 DESCRIPTION
DESCRIPTION
     The sh utility is the standard command interpreter for the system.  The
     current version of sh is close to the IEEE Std 1003.1 (``POSIX.1'') spec-
     ification for the shell.  It only supports features designated by POSIX,
     plus a few Berkeley extensions.
$ man sh | col -b | grep -A 1 -B 3 AUTHORS
     This version of sh was rewritten in 1989 under the BSD license after the
     Bourne shell from AT&T System V Release 4 UNIX.

AUTHORS
     This version of sh was originally written by Kenneth Almquist.

Именованные каналы никто не отменял:

$ mkfifo in out
$ tr -u a b <in >out &
$ exec 3> in 4< out
$ echo abrakadabra1 >&3
$ echo abrakadabra2 >&3
$ echo abrakadabra3 >&3
$ read var <&4 ; echo $var
bbrbkbdbbrb1
$ read var <&4 ; echo $var
bbrbkbdbbrb2
$ read var <&4 ; echo $var
bbrbkbdbbrb3

Пожалуй, с некоторой натяжкой, можно сказать, что это же реализуемо с помощью псевдотерминалов, но развивать эту тему не стану.

Ну и зачем же нужны сопроцессы? Я процитирую выдержку из перевода статьи Mitch Frazier:
Пока я не могу придумать никаких <...> задач для со-процессов, по крайней мере не являющихся надуманными.

И в действительности я лишь один раз смог с относительной пользой применить со-процессы в своих скриптах. Задумка была реализовать некий «persistent connect» для доступа к MySQL.
Выглядело это примерно так:

$ coproc stdbuf -oL -i0 mysql -pPASS
[1] 19743
$ printf '%s;\n' 'select NOW()' >&${COPROC[1]}
$ while read -u ${COPROC[0]}; do printf "%s\n" "$REPLY"; done
NOW()
2016-04-06 13:29:57

В остальном все мои попытки использовать coproc действительно были надуманными.

Спасибо
Хочется поблагодарить Bart Schaefer, Stephane Chazelas, Mitch Frazier чьи комментарии, письма и заметки помогли в написании статьи.
Поделиться с друзьями
-->

Комментарии (36)


  1. Evengard
    13.08.2016 01:24

    Хммм, возможно с помощью этого можно будет упростить некоторые мои монструозные скрипты… Интересная идея. А то та монструозность что есть вообще чудо что работает.


    1. arvitaly
      13.08.2016 05:46
      -1

      Вы не пробовали https://github.com/shelljs/shelljs?


      1. ivlis
        13.08.2016 20:28
        +7

        У меня уже Chromium с Atom.io сжирают 80% оперативы, если ещё shell будет на js, то в чём же мне killall chromium && killall atom выполнять?


  1. Habra-Mikhail
    13.08.2016 02:33
    -1

    Мне кажется что когда вы отправляете сообщение, то получаете какую-то абракадабру:

    Отправляете:
    $ print -p abrakadabra1

    Получаете:
    $ read -p var; echo $var
    bbrbkbdbbrb1

    У вас буква a сломалась


    1. Moon_darker
      13.08.2016 04:40
      +5

      Очевидно, в фоне выполняется

      tr -u a b

      который используется для замены всех a на b. Так что всё работает как и ожидается :)


      1. Habra-Mikhail
        13.08.2016 11:22
        +1

        Простите, не заметил. Что-то часто я при чтении отвлекаться начал


  1. romy4
    13.08.2016 13:06
    -7

    какая версия tr?
    для (GNU Coreutils) 8.21 опции -u нет, поэтому пример, где «процедура записи/чтения» не отработает, а результат будет виден только после закрытия дескрипторов.


    1. simpleadmin
      13.08.2016 13:30
      +4

      какая версия tr?

      https://www.freebsd.org/cgi/man.cgi?tr
      поэтому пример, где «процедура записи/чтения» не отработает

      Вам стоить прочитать всю статью, а не урывок в конце и решение найдётся.


      1. romy4
        13.08.2016 13:41
        -10

        Да ну, прям всю статью? И что конкретно я должен был прочитать? А man к функциям я читать умею и справку вызывать.

        man tr
        DESCRIPTION
        Translate, squeeze, and/or delete characters from standard input, writing to standard output.

        -c, -C, --complement
        use the complement of SET1

        -d, --delete
        delete characters in SET1, do not translate

        -s, --squeeze-repeats
        replace each input sequence of a repeated character that is listed in SET1 with a single occurrence of that character

        -t, --truncate-set1
        first truncate SET1 to length of SET2

        --help display this help and exit

        --version
        output version information and exit


        1. simpleadmin
          13.08.2016 13:50
          +7

          Хамить я тоже умею, заметили?

          То что это Ваш стиль общения исключительно Ваше дело.

          А статью Вам действительно стоит прочитать всю, тогда Вы найдёте ответ на:
          вы нигде не упоминали, что это под freebsd.

          Хоть это и не имеет прямого отношения к вопросам статьи, отмечу, что все нижеприведённые примеры сделаны на

          $ uname -opr
          FreeBSD 10.1-STABLE amd64
          


          И на
          И что конкретно я должен был прочитать?

          Хотя бы
          Разумеется, если данное поведение нас не устраивает, его можно изменить, например используя stdbuf

          $ coproc stdbuf -oL -i0 tr a b
          


          1. romy4
            13.08.2016 14:22
            -10

            Зеркальный ответ на ваше изначальное хамство.


  1. kemsky
    14.08.2016 03:10

    Тема зачем? не раскрыта.


    1. cleaner_it
      14.08.2016 03:50

      Тема раскрыта) Автор честно сказал, что не знает ответа на этот вопрос.

      PS:
      Ищущий да найдёт. И отпишется, чтобы мы тоже знали)


      1. gnomeby
        14.08.2016 11:41

        Если так получилось, что:
        * Никак нельзя сделать не на баше
        * Подготовка данных разбита на шаги
        * А выполнение при этом не бесплатное (т.е. занимает время)

        То это неплохой способ уменьшить общее время выполнения скрипта.

        Как простой пример — persistent connection к БД.


        1. simpleadmin
          14.08.2016 12:14

          Как простой пример — persistent connection к БД.

          Андрей, хоть это и единственное найденное применение описанное в статье, на самом деле я при первой возможности ушёл от этого решения.
          Причины:
          — умирание сопроцесса при «битых» запросах (как следствие необходимость проверки жизнеспособности сопроцесса);
          — потеря неизвлечённых данных при этом;
          — геморойная обработка данных при извлечении и сопоставление их с запросом.
          Всё-таки работа с БД — это не стихия *sh.


  1. saboteur_kiev
    14.08.2016 12:35

    Хм. Но зачем нужны сопроцессы, если этот же функционал реализовывается немного проще, во ВСЕХ шеллах при помощи pipe:
    процесс 1
    mkpipe testpipe
    echo abrakahabra >> testpipe

    процесс 2
    cat testpipe


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


    1. chaturanga
      14.08.2016 13:09
      +1

      mkpipe

      Извините, а что это за зверь такой?


      1. saboteur_kiev
        14.08.2016 14:28

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

        Ох сорри, mkfifo, опечатался.

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


    1. simpleadmin
      14.08.2016 14:42
      +1

      mkpipe testpipe

      Вероятно речь об mkfifo?

      echo abrakahabra >> testpipe
      cat testpipe

      Это только каналы ввода/вывода, а где фоновый процесс?

      Вариант реализации посредством mkfifo в статье приведен
      Заголовок спойлера
      $ mkfifo in out
      $ tr -u a b <in >out &
      $ exec 3> in 4< out
      $ echo abrakadabra1 >&3
      $ echo abrakadabra2 >&3
      $ echo abrakadabra3 >&3
      $ read var <&4 ; echo $var
      bbrbkbdbbrb1
      $ read var <&4 ; echo $var
      bbrbkbdbbrb2
      $ read var <&4 ; echo $var
      bbrbkbdbbrb3
      


  1. ZyXI
    14.08.2016 19:31
    +1

    У FIFO есть много недостатков:


    1. FIFO нужно где?то создать. Предположительно, вам придётся заморочиться с mktemp -d.
    2. FIFO нужно потом ещё и удалить. Причём удалить независимо от того, успешно ли завершился скрипт. Хотя, в принципе, можно удалить сразу после exec.
    3. FIFO не должен быть перехвачен другой программой: к примеру, другим процессом того же скрипта. Он особенно не должен быть доступен для других пользователей. Ещё один повод использовать именно mktemp -d.

    Последний (и, кажется, единственный) раз, когда я попытался использовать сопроцессы в zsh я пожалел, что сопроцесс может быть только один (и ещё вроде были какие?то проблемы). FIFO я не использовал вообще никогда, если мне нужно что?то подобное я лучше либо перепишу всё так, что мне это не нужно будет (обычно process substitution и стандартного перенаправления через | хватает за глаза), либо перепишу на Python.


  1. izyk
    15.08.2016 16:20

    Здравствуйте.
    В bash Linux — если сопроцессор завершается до начала считывания, то теряем вывод полученный от него через "${COPROC[0]}".

    $ ls -Al
    total 20360
    -rw-rw-r--. 1 ilia ilia 20834292 авг 15 15:46 1
    drwxrwxr-x. 2 ilia ilia     4096 авг 15 15:10 proba
    drwxrwxr-x. 2 ilia ilia     4096 авг 15 15:10 proba2
    drwxrwxr-x. 2 ilia ilia     4096 авг 15 15:10 proba3
    
    $ coproc ls -Al .
    [1] 16739
    [1]+  Done                    coproc COPROC ls --color=auto -Al .
    
    $ echo "${COPROC[0]}"
    
    

    ${COPROC[0]} — больше нет, получить неоткуда. Неудобно. Легче что-то другое придумать, у тех же FIFO такого нет.
    А если в канал не помещается весь вывод, то уже лучше, сопроцессор ждет освобождения места в буфере для продолжения и не завершается. Но хвост вывода можем потерять.
    $ coproc ls -alR /
    [1] 16797
    
    $ echo "${COPROC[0]}"
    63
    


    1. ZyXI
      15.08.2016 17:59

      Выглядит как ошибка, в man bash я предупреждений по этому поводу не нашёл. zsh и ksh таким не страдают, так что никаких оправданий вроде «а у этих такая же фигня» не может быть.


      Вы пробовали с этим вопросом пойти в bug tracker bash’а?


      1. izyk
        15.08.2016 18:35

        Вы пробовали с этим вопросом пойти в bug tracker bash’а?

        Нет, не пробовал, т.к. об этой штуке узнал пару часов назад. Для меня не нужная вещь. Хотя выглядит действительно как ошибка. На всякий случай оставлю, вдруг кому-нибудь пригодится:
        $ cat /etc/redhat-release 
        CentOS Linux release 7.2.1511 (Core) 
        
        $ bash --version
        GNU bash, version 4.2.46(1)-release (x86_64-redhat-linux-gnu)
        
        $ uname -a
        Linux  3.10.0-327.28.2.el7.x86_64 #1 SMP Wed Aug 3 11:11:39 UTC 2016 x86_64 x86_64 x86_64 GNU/Linux
        


        1. ZyXI
          15.08.2016 19:33

          У меня то же самое в bash-4.3_p42-r1, Gentoo amd64. Хотя это не последняя версия в portage: есть ещё «нестабильная» 4.3_p46 и «замаскированная» 4.4_rc1.


  1. potan
    15.08.2016 18:13
    +1

    ps afx | grep [6]053

    Этот прием надо запомнить!


    1. ZyXI
      15.08.2016 18:19
      +2

      Не нужно, вы не в BSD. ps -p 6053. Если в совете предполагается пропустить вывод ps через grep на linux, то, скорее всего, этот совет не оптимальный и grep на самом деле трогать не нужно.


      1. ZyXI
        15.08.2016 18:21

        Хотя, судя по man ps (BSD) там тоже должно работать.


        1. simpleadmin
          15.08.2016 18:23
          +2

          Вилимо имелось ввиду преобразование символьного класса и исключение лишней строки из вывода grep


          1. ZyXI
            15.08.2016 20:06

            Я знаю, зачем в строке grep []. Я не знаю, зачем вообще нужен grep здесь, если есть ps -p, позволяющий обойтись без grep. И есть ps -C позволяющий то же самое для нахождения конкретных процессов по имени исполняемого файла. grep нужен в редких случаях, когда у вас 100500 процессов вида myapp --foo bar и вам хочется отыскать среди них PID того, что запущен с --foo baz. Или когда вы представляете, что вам нужно лишь приблизительно (к примеру, браузеры имеют нехорошую привычку запускаться одной командой (e.g. vivaldi), а в списке висеть в виде другой (vivaldi-bin в примере), но это относится не только к vivaldi и даже не только к chromium-based).


            ps -p 6053 вам покажет данные только для процесса с PID 6053 и безо всяких паразитных строчек (не считая заголовка), которые нужно будет исключать всякими хаками. Или покажет только заголовок, если процесс уже завершился (заголовок можно убрать, если нужно: ps hp 6053 или ps --no-headers -p 6053).


            Ещё: вот будет у вас в фоне запущен mpv с именем серии с хэшем, содержащим 6053, вы его увидите, несмотря на ваш трюк с grep. И процесс с PID 16053 увидите. И процесс, который работает 6053 часов. И процесс на псевдотерминале pts/6053. С PID 6053 такое маловероятно, но PID бывают и маленькими — они вообще назначаются циклически или случайно (в зависимости от настроек ядра ОС). При использовании ps -p 6053 ничего из вышеперечисленного вы не увидите.


            Ну и, конечно, ps -p 6053 требует меньше нажатий клавиш, чем ps afx | grep [6]053. Не забудьте также, что в zsh с такими привычками вы имеете хорошие шансы увидеть zsh: no matches found: [6]053. Bash тоже можно так настроить. Трюк пропадёт в никуда, если в каталоге есть файл 6053. При других настройках оболочки ваша команда выдаст


            Использование: grep [ПАРАМЕТР]… ШАБЛОН [ФАЙЛ]…
            Запустите «grep --help» для получения более подробного описания.

            ([6]053 было раскрыто в «ничего», потому что такого файла нет). Из?за возможности таких фокусов никогда не полагайтесь на то, что провальные glob expression’ы будут заменены на самих себя. Хотите передать grep [6]053 — пишете '[6]053'.


            1. simpleadmin
              15.08.2016 20:19

              Павел, Ваша экспрессия «в никуда».
              Я лишь усомнился, что Михаила заинтересовал "|" и ещё более сомнительно, что его заинтересовал man ps.

              ps -p 6053 вам покажет данные только для процесса с PID 6053

              Спасибо, конечно, но я тоже знаю о существании ключа "-p" у ps.
              При этом много чаще пользуюсь именно символьным преобразованием, так как это универсальнее.


              1. simpleadmin
                15.08.2016 20:31

                Да, извините, Николай!


              1. ZyXI
                15.08.2016 20:33

                В статье лучше написать ps -p. В любом случае, символьное преобразование взять в кавычки.


                1. simpleadmin
                  15.08.2016 20:37

                  Согласен и с тем и с тем, но десятилетняя привычка и большая вольность с закавычиванием в c-шеллах делают свое дело.


                  1. ZyXI
                    15.08.2016 21:06

                    У меня привычки куда как дальше от работающих «по?умолчанию»: вашу команду я бы написал как ps afx G '[6]053' и меня бы никто не понял (включая любую ненастроенную оболочку): zsh имеет такую замечательную вещь, как «глобальные alias’ы», позволяющую заменять не только команды. Конкретно G раскрывается в | grep*, есть и другие вроде alias -g L='| less'. Разумеется, это дало два набора привычек: «для скриптов» (там alias’ы не работают, не говоря уж о том, что часть скриптов мне приходится писать для POSIX sh, а не zsh) и «для печати в командной строке». При общении предпочитается именно последнее.


                    А у скриптов есть и ещё одна особенность: во?первых, лучше написать всё так, чтобы не вылавливать потом баги, чем написать какое?нибудь while ps afx | grep "[6]053" и внезапно обнаружить «бесконечный цикл» на ровном месте, т.к. какой?то процесс попал на pts/6053. Во?вторых, PID в этом случае окажется в переменной и доставать по отдельности первый и остальные символы будет как?то сложновато (я к тому, что решение, оперирующее с цельным значением предпочтительнее решения, предполагающего разбиение его на части: в POSIX нет ни индексации строк (${PID[1]} или ${PID:0:0}), ни даже ${PID//sub/rep}).


                    * С некоторых пор (после этой статьи) я немного подумал и теперь он раскрывается в | nocorrect noglob grep (то же, но zsh не будет пытаться раскрыть [6]053).


                    1. simpleadmin
                      15.08.2016 21:25

                      Убедили :) Сделал update статьи. Спасибо!


    1. simpleadmin
      15.08.2016 19:00
      +1

      Если интересно, то как это работает:
      https://habrahabr.ru/post/229501/#comment_7770191