Вопрос или проблема
Я никогда особо не задумывался о том, как оболочка на самом деле выполняет команды с использованием конвейеров. Мне всегда говорили, что “stdout одной программы перенаправляется в stdin другой,” как способ мышления о конвейерах. Поэтому, естественно, я думал, что в случае, скажем, A | B
, A
выполнится первой, потом B
получит stdout от A
и использует его в качестве входных данных.
Но я заметил, что когда люди ищут определенный процесс в ps
, они добавляют grep -v "grep"
в конец команды, чтобы убедиться, что grep
не появится в окончательном выводе.
Это означает, что в команде ps aux | grep "bash" | grep -v "grep"
подразумевается, что ps
знал, что grep
выполняется, и поэтому находится в выводе ps
. Но если ps
завершает выполнение до того, как его вывод переправляется в grep
, откуда он знал, что grep
выполнялся?
flamingtoast@FTOAST-UBUNTU: ~$ ps | grep ".*"
PID TTY TIME CMD
3773 pts/0 00:00:00 bash
3784 pts/0 00:00:00 ps
3785 pts/0 00:00:00 grep
Команды с конвейерами выполняются одновременно. Когда вы запускаете ps | grep …
, это вопрос случая (или деталей работы оболочки в сочетании с настройкой планировщика глубоко в недрах ядра), какая из команд ps
или grep
стартует первой, и в любом случае они продолжают выполняться одновременно.
Это очень часто используется, чтобы позволить второй программе обрабатывать данные по мере их поступления от первой программы, до того, как первая программа завершит свою операцию. Например,
grep pattern very-large-file | tr a-z A-Z
начинает отображать подходящие строки в верхнем регистре даже до того, как grep
закончит обрабатывать большой файл.
grep pattern very-large-file | head -n 1
отображает первую подходящую строку и может прекратить обработку задолго до того, как grep
завершит чтение своего входного файла.
Если вы где-то прочитали, что программы с конвейерами выполняются последовательно, бегите от этого документа. Программы с конвейерами выполняются одновременно и всегда так выполнялись.
Порядок выполнения команд на самом деле не имеет значения и не гарантируется. Оставляя в стороне архаичные детали pipe()
, fork()
, dup()
и execve()
, оболочка сначала создает канал, проводник для данных, которые будут перемещаться между процессами, а затем создает процессы, концы канала соединены с ними. Первый процесс, который выполняется, может блокироваться в ожидании ввода от второго процесса или блокироваться в ожидании, пока второй процесс начнет читать данные из канала. Эти ожидания могут быть произвольной длины и не имеют значения. Каким бы ни был порядок выполнения процессов, данные в конечном итоге передаются, и все работает.
Рискну показаться навязчивым, но ложное представление заключается в том, что
A | B
эквивалентно
A > временный_файл B < временный_файл rm временный_файл
Но, когда Unix был создан, и дети ездили на динозаврах в школу, диски были очень маленькими, и было обычным делом, что довольно безобидная команда потребляла все свободное пространство в файловой системе.
Если B
был чем-то вроде
grep some_very_obscure_string
,
окончательный вывод конвейера мог быть гораздо меньше, чем тот промежуточный файл.
Поэтому канал был разработан не как сокращение для модели “сначала запустить A, а затем запустить B с вводом из вывода A“, а как способ выполнения B
одновременно с A
и устранения необходимости хранения промежуточного файла на диске.
Обычно это выполняется под bash. Процессы работают и стартуют одновременно, но под оболочкой выполняются параллельно. Как это возможно?
- если это не последняя команда в конвейере, создать безымянный канал с парой сокетов
- форк
- в дочернем процессе переназначить stdin/stdout сокетам, если это необходимо (для первого процесса в конвейере stdin не переназначается, то же самое для последнего процесса и его stdout)
- в дочернем процессе выполнить заданную команду с аргументами, которые заменяют исходный код оболочки, но оставляют все открытые ими сокеты. ID дочернего процесса не изменится, потому что это один и тот же дочерний процесс
- одновременно с дочерним, но параллельно под главной оболочкой перейти к шагу 1.
система не гарантирует, как быстро будет выполнена exec и будет запущена заданная команда. это не зависит от оболочки, а от системы. Это потому что:
ps auxww| grep ps | cat
иногда показывает команду grep
и/или ps
, а иногда нет. Это зависит от того, как быстро ядро действительно запускает процессы, используя функцию exec системы.
Хотя все программы, кажется, стартуют одновременно, они не выполняются в реально одновременной манере. Я написал простую программу (ниже), которая может записывать 1 или более строк, ждать несколько секунд, читать 1 или более строк, или проверять, жив ли конец конвейера, и я получил следующее:
$ ./a.out w5 s1 w5 s1 w50 | ./a.out r | ./a.out r1 check r & ps -ef | grep a.out
[3] 10908
ale 10906 8066 0 10:24 pts/6 00:00:00 ./a.out w5 s1 w5 s1 w50
ale 10907 8066 0 10:24 pts/6 00:00:00 ./a.out r
ale 10908 8066 0 10:24 pts/6 00:00:00 ./a.out r1 check r
ale 10910 8066 0 10:24 pts/6 00:00:00 grep a.out
$ 2025-03-04T10:24:56.628434+01:00 pcale dummy pipe[10906]: exiting (read 0 bytes)
2025-03-04T10:24:56.628925+01:00 pcale dummy pipe[10907]: exiting (read 2769 bytes)
2025-03-04T10:24:56.629188+01:00 pcale dummy pipe[10908]: kill 10906: No such process
2025-03-04T10:24:56.629377+01:00 pcale dummy pipe[10908]: exiting (read 2769 bytes)
То есть, к моменту, когда третий экземпляр начинает чтение, первый уже завершился, несмотря на то время, которое он потратил спя. Это означает, что в какой-то момент все эти строки по 2,769 байтов хранятся вместе.
Увеличивая объем данных, как отметил Скотт, вступает в действие параллелизм.
$ ./a.out w5 s1 w5 s1 w150 | ./a.out r | ./a.out r1 check r & ps -ef | grep a.out
[3] 11254
ale 11252 8066 0 10:31 pts/6 00:00:00 ./a.out w5 s1 w5 s1 w150
ale 11253 8066 0 10:31 pts/6 00:00:00 ./a.out r
ale 11254 8066 0 10:31 pts/6 00:00:00 ./a.out r1 check r
ale 11256 8066 0 10:31 pts/6 00:00:00 grep a.out
$ 2025-03-04T10:31:13.002766+01:00 pcale dummy pipe[11252]: exiting (read 0 bytes)
2025-03-04T10:31:13.003068+01:00 pcale dummy pipe[11254]: kill 11252: still running
2025-03-04T10:31:13.003235+01:00 pcale dummy pipe[11253]: exiting (read 7569 bytes)
2025-03-04T10:31:13.003502+01:00 pcale dummy pipe[11254]: exiting (read 7569 bytes)
Я попытался определить методом дихотемии значение, которое инициирует буферизацию, но не смог. Оно меняется со временем.
Вывод протоколируется, и tail -f syslog
работает в фоновом режиме. closelog()
используется для сброса syslog, и вывод появляется внезапно.
Вот простая программа, если хотите с ней поиграть:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <unistd.h>
#include <syslog.h>
#include <errno.h>
#include <limits.h>
static void do_open(void)
{
openlog("dummy pipe", LOG_PID, LOG_USER);
}
int main(int argc, char *argv[])
{
do_open();
pid_t me = 1;
int wrote = 0, n;
long total_read = 0;
for (int i = 1; i < argc; ++i)
{
if (argv[i][0] == 'w' && (n = atoi(&argv[i][1])) > 0)
{
if (wrote == 0)
{
me = getpid();
setsid();
wrote = 1;
}
if (n --> 0)
printf("Pid: %d\n", me);
while (n --> 0)
puts("Lorem ipsum lorem ipsum lorem ipsum lorem ipsum");
}
else if (argv[i][0] == 's' && (n = atoi(&argv[i][1])) > 0)
sleep(n);
else if (argv[i][0] == 'r')
{
if (argv[i][1] == 0)
n = INT_MAX;
else
n = atoi(&argv[i][1]);
char buf[1024];
while (n --> 0)
{
char *p = fgets(buf, sizeof buf, stdin);
if (p == NULL)
break;
if (buf[0] == 'P')
sscanf(buf, "Pid: %d\n", &me);
total_read += strlen(buf);
if (!isatty(fileno(stdout)))
printf("%s", buf);
}
}
else if (strcmp(argv[i], "check") == 0)
{
if (me != 1)
{
n = kill(me, 0);
syslog(LOG_NOTICE, "kill %d: %s\n", me,
n? strerror(errno): "still running");
closelog();
do_open();
}
}
}
syslog(LOG_NOTICE, "exiting (read %'ld bytes)\n", total_read);
closelog();
return 0;
}
Вы спросили о порядке, и я думаю, что это очень важный аспект вопроса. Это не случайность (как Гилл пытается сказать в своем ответе).
Вот команда ps -ef
, перенаправленная в команду grep
:
$ ps -ef | grep .
...
alexis 37188 55443 0 20:17 pts/4 00:00:00 ps -ef
alexis 37189 55443 0 20:17 pts/4 00:00:00 grep --color=auto .
...
Примечание: Я удалил все остальные процессы из вывода, так как они не имеют значения для вопроса.
Как мы видим, в выводе есть ps -ef
и grep --color=auto .
Можете ли вы теперь ответить на вопрос?
Да. Команда ps
имеет PID 37,188, а команда grep
имеет PID 37,189. Очевидно, что они были созданы слева направо, и никакая оболочка не должна делать это иначе.
Технически, на языке C, мы создаем каналы с помощью функции pipe(2)
, которая дает нам два файловых дескриптора. Один будет использоваться как stdout
для ps
, а другой как stdin
для grep
. Достаточно просто удержать дескриптор файла для stdin
до того, как вы начнете ps
.
Кроме того, если вы посмотрите на конфигурацию вашей системы следующим образом:
$ getconf -a | grep PIPE_BUF
PIPE_BUF 4096
_POSIX_PIPE_BUF 4096
вы заметите, что эти два параметра определяют минимальный гарантированный размер канала в байтах. Начиная с Linux 2.6, размер по умолчанию составляет 64 Кб. Также абсолютный максимальный размер в байтах определяется в:
$ cat /proc/sys/fs/pipe-max-size
1048576
и мы видим, что это 1 Мб. Как только канал заполняется, процесс записи (ps
в нашем первом примере) блокируется до тех пор, пока данные не будут прочитаны процессом с другой стороны канала (grep
в нашем первом примере).
Другими словами, поскольку вывод ps
значительно меньше, чем размер канала:
$ ps -ef | wc
1132 10819 121435
(т.е. около 120 Кб вывода на моем компьютере в данный момент…)
Конвейер не будет заблокирован вообще.
Для потоковых данных более 1 Мб он действительно блокируется в какой-то момент. Если бы grep
не начался сразу, он бы никогда не стартовал, так как вызов write()
в первой команде был бы заблокирован.
Так что процессы запускаются очень быстро один за другим, но большую часть времени они работают параллельно (или одновременно, если у вас один процессор). То есть, команда ps
завершится первой. Это помечает канал как “завершенный” (вы получаете сигнал EOF
, читая данные из него), и так следующий инструмент знает, что он завершен, и также завершает работу, как только обработает последние несколько полученных байтов.
Наоборот, если процесс на правой стороне канала умирает рано (до того, как процесс на левой стороне закончит запись в канал), то процесс на левой стороне получает сигнал SIGPIPE
, как только он пытается записать данные в канал. Это сделано для того, чтобы гарантировать, что конвейер быстро прекращает работу, если какой-либо из процессов в нем завершает.
Ответ или решение
Вопрос о порядке выполнения конвейерных команд в терминале часто вызывает путаницу, и это значительно важнее, чем может показаться на первый взгляд. На уровне абстракции часто говорят, что вывод одной программы (stdout) «передается» на ввод другой программы (stdin) через так называемые конвейеры (pipes). Однако это упрощенное объяснение не учитывает существенные технические особенности процесса выполнения команд в Unix-подобных системах. Давайте разберемся, как это происходит на практике.
Теория
При выполнении команды, такой как A | B
, обе программы запускаются параллельно, а не последовательно. Об этом свидетельствует сам механизм Unix, где для создания конвейера используется системный вызов pipe()
, который создает два файловых дескриптора — один для чтения, другой для записи. Затем используется fork()
, чтобы создать дочерний процесс для каждой команды. В parent-процессе (в данном случае оболочке), осуществляется переназначение stdin и stdout посредством dup()
для использования конвейера в дочерних процессах. Каждая команда работает с одним концом конвейера: первый процесс пишет в него, а второй — читает.
Таким образом, процессы A и B запускаются одновременно и работают параллельно, обеспечивая потоковую обработку данных "на лету". Это означает, что порядок их запуска не имеет значения — фактически, система может решить запустить любую из этих команд первой в зависимости от конкретных условий.
Пример
Рассмотрим команду:
ps aux | grep "bash" | grep -v "grep"
Здесь ps aux
выполняет список всех процессов и передает результаты в grep "bash"
, который фильтрует процессы, связанные с bash
. Последний фильтр grep -v "grep"
исключает собственный процесс grep
из конечного результата.
В этой цепочке конвейеров все команды запускаются параллельно. В результате возможно, что ps aux
, уже запустивший процесс grep, также отобразит его в своем выводе на момент выполнения. Это объясняется тем, что второй и третий процессы могут начать выполнять свои действия до того, как первый процесс завершит свои операции.
Применение
На практике такой подход позволяет эффективно управлять ресурсами, особенно при обработке больших объемов данных. Например, при анализе крупного файла с использованием команды:
grep pattern very-large-file | tr a-z A-Z
данные начинают обрабатываться и передаваться на вторую команду (которая их переводит в верхний регистр) еще до того, как первая завершается. Это значительно уменьшает потребление временного диска и ускоряет процесс.
Кроме того, процессоры в современных системах имеют многопоточность, что позволяет физически выполнять несколько операций одновременно, увеличивая скорость и эффективность исполнения конвейерных команд.
Заключение
Таким образом, важно понимать, что конвейерные команды в Unix-подобных системах работают параллельно, а не последовательно. Это ключевой принцип, обеспечивающий оптимизацию работы системных ресурсов и встраивание команд в потоковую обработку данных, что особенно полезно при работе с большими файлами или массивами данных. Независимо от порядка, в котором команды запускаются, их взаимодействие через конвейеры позволяет обеспечить надежную и эффективную обработку данных в реальном времени.