что такое pipe screens что это
027 — исследования: сравнение эффективности X-пайпа, H-пайпа и Z-пайпа
Доброго времени суток, друзья, читатели и клиенты.
Сегодня расскажу вам о результатах проведенных исследований по сравнению эффективности X-пайпа, H-пайпа и Z-пайпа.
X-пайп и прочие его разновидности (H-пайп, Z-пайп) применяются на автомобилях с раздвоенной выхлопной системой. Соединение двух потоков необходимо для создания разряжения в соседнем потоке. Благодаря высокой скорости потока в одной трубе создается пониженное давление, которое утягивает за собой газы из соседней трубы, тем самым создавая разряжение и в ней. Это, в свою очередь, снижает сопротивление газам из соседнего потока, тем самым обеспечивая двигателю более благоприятные условия «дыхания».
Мне всегда было интересно сравнить эфективность различных инженерных изобретений в данной области.
И вот спустя 4 года в бизнесе по производству выпускных систем до этого дошли руки.
Итак, буду проводить расчеты 3-х различных вариантов исполнения:
1) X-пайп
2) H-пайп
3) Z-пайп
Все три варианта имеют по 2 трубы диаметром 3 дюйма (76.1 мм).
Мы будем пропускать поток воздуха через вход одной из двух труб, второй вход остается заглушенным. Воздух сможет выходить через оба выхода.
1) определение разряжения в области у входа во вторую трубу
2) определение распределения потока в процентах между двумя выходами
1) Массовый расход воздуха – 400 гр/с, что примерно соответствует мощности 500 л.с.
2) Давление выхлопных газов на входе в трубу – 2 бара
3) Температура гвыхлопных азов на входе в трубу – 620 С*
4) Давление у выходных отверстий – атмосферное, температура – 20 С*
5) Расстояние от начала трубы до конца (по прямой) – около 700 мм.
1) разряжение в области у входа во вторую трубу:
X-пайп — 4.405 кПа
H-пайп — 0.050 кПа
Z-пайп — 4.411 кПа
2) распределение потока между первым и вторым выходами (в процентах):
X-пайп — 60.0 / 40.0
H-пайп — 96.2 / 3.8
Z-пайп — 49.6 / 50.4
Остановлюсь подробнее на каждом варианте.
Для определения оптимального дизайна X-пайпа были проведены исследования более 20 различных вариантов исполнения. Изменениям подвергались взаимный угол двух труб (от 20 до 40 градусов) и сечение отверстия между двумя трубами (срез от 6 до 16мм от внешнего радиуса).
В итоге наибольшее разряжение (4.405 кПа) получилось при взаимном угле 30* и срезании труб на 15.5мм от внешнего радиуса. Худший результат разряжения (2.316 кПа) получился при взаимном угле 20* и срезании труб на 6мм от внешнего радиуса.
Таким образом, для создания максимального разряжения в X-пайпе, состоящем из двух параллельных труб с сонаправленными потоками, необходимо, чтобы существенная часть потока (до 40%) улетала в соседнюю трубу. Т.е. угол схождения не должен быть слишком низким, а отверстие не должно быть слишком малым.
Минусом такого дизайна является то, что на определенных оборотах двигателя потоки из соседних труб будут непременно сталкиваться друг с другом и создавать турбуленцию в области у отверстия X-пайпа.
Для определения оптимального дизайна H-пайпа были проведены исследования 10 различных вариантов исполнения. Изменениям подвергались диаметр (45 — 76.1мм), форма (круглая и овальная) и длина (10 — 80мм) соединительной трубы.
В итоге ни в одном из вариантов не удалось достичь существенного разряжения. Лучший результат получился при минимальной длине соединительной трубы (10мм) — около 0.05 кПа. Большинство же вариантов не показало разряжения совсем.
Таким образом, можно констатировать, что H-пайп не оказывает существенного влияния на эффективность работы выпускной системы. Маловероятно, что он не имеет никаких плюсов, иначе инженеры не применяли бы его на многих автомобилях. Возможно его функция состоит в другом — например в регулировании звука выхлопа. Также не исключаю, что в моих исследованиях упущены важные моменты. Однако при прочих равных, очевидно, что H-пайп не сравним по эффективности со своими аналогами.
Впервые я увидел подобный дизайн у известных ребят из ОАЭ. У них же увидел и название — Z-пайп (лично мне такое название кажется не совсем подходящим, я бы назвал QUAD-пайп). Суть состоит в том, что каждая из труб разделяется на две, одна из которых уходит в сторону другого потока, другая — остается на стороне своего потока. В данном случае мы использовали две трубы 54х1.5мм, которые в сумме дают то же сечение, то и одна труба 76.1х1.5мм.
Z-пайп показал лучший результат разряжения уже на первой итерации — 4.411 кПа. Потоки распределились между двумя выходами в соотношении 50 на 50.
Несмотря на то, что оптимальный дизайн X-пайпа показал практически такой же результат, Z-пайп имеет перед ним существенное преимущество — потоки никогда не сталкиваются друг с другом. При наступлении указанного в случае X-пайпа случая пересечения первого и второго потоков на определенных оборотах двигателя потоки будут двигаться в одном направлении, не создавая тем самым избыточной турбуленции и противодавления.
Минусами Z-пайпа являются трудоемкость производства и большие габариты, из-за чего его невозможно применить на некоторых автомобилях.
System.IO.Pipelines — малоизвестный инструмент для любителей высокой производительности
Введение
Итак, данная библиотека ставит целью ускорить работу с потоковой обработкой данных. Изначально она была создана и использовалась командой разработки Kestrel (кросс-платформенный веб-сервер для ASP.NET Core), но на данный момент поставляется через отдельный nuget-пакет.
Перед тем, как мы углубимся в тему, можно представить себе механизм библиотеки как улучшенный аналог MemoryStream. Проблема оригинального MemoryStream в излишнем числе копирований, что очевидно, если вспомнить, что внутри используется приватный массив байт в качестве буфера. Например, в методах Read и Write отчетливо видно копирование. Таким образом, для объекта, который мы хотим записать в стрим, будет создана копия во внутреннем буфере, а во время чтения потребителю будет доставлена копия внутренней копии. Звучит как не самое рациональное использование пространства.
System.IO.Pipelines не ставит целью заменить все стримы, это дополнительный инструмент в арсенале разработчика, пишущего высокопроизводительный код. Предлагаю ознакомиться с основными методами и классами, посмотреть, как они устроены внутри, и разобрать базовые примеры.
Начнем с внутреннего устройства, параллельно рассматривая простые фрагменты кода. После этого станет ясно, что и как работает, и как это следует использовать. При работе с System.IO.Pipelines стоит помнить, что базовая концепция состоит в том, что все операции чтения-записи должны проходить без дополнительных аллокаций. Но некоторые заманчивые на первый взгляд методы противоречат этому правилу. Соответственно, код, который вы так стараетесь ускорить, начинает выделять память под новые и новые данные, нагружая сборщик мусора.
Внутрянка библиотеки использует широчайшие возможности последних версий языка и райнтайма — Span, Memory, пулы объектов, ValueTask и тд. Туда стоит заглянуть как минимум за отличным примером использования этих возможностей в продакшене.
В свое время некоторые были недовольны реализацией стримов в C#, потому что один класс использовался и для чтения, и для записи. Но, как говорится, методов из класса не выкинешь. Даже если в какой-то стрим не поддерживал чтение/запись/перемещение указателя, в силу вступали свойства CanRead, CanWrite и CanSeek, что выглядело как небольшой костыль. Здесь же дела обстоят иначе.
Для работы с пайпами используются 2 класса: PipeWriter и PipeReader. Данные классы содержат примерно по 50 строк и являются псевдо-фасадами(не самым классическим его воплощениями, так как скрывается за ними единственный класс, а не множество) для класса Pipe, который содержит всю основную логику по работе с данными. Из публичных членов — 2 конструктора, 2 get-only свойства — Reader и Writer, метод Reset(), который сбрасывает внутренние поля к начальному состоянию, чтобы класс можно было переиспользовать. Остальные методы для работы вызываются с помощью псевдо-фасадов.
Для начала о классе Pipe
Экземпляр класса занимает 320 байт, что довольно много (почти треть килобайта, 2 таких объекта не смогли бы поместиться в памяти Манчестерского Марк I). Так что аллоцировать его в больших количествах — идея плохая. Тем более, что по смыслу объект предназначается для долгого использования. Использование пулов также вносит свой аргумент в пользу этого заявления. Ведь объекты, используемые в пуле будут жить вечно(во всяком случае в стандартном).
Заметим, что класс помечен как sealed и что он потокобезопасен — многие участки кода являются критической секцией и обернуты в локи.
Для начала работы следует создать экземпляр класса Pipe и получить с помощью упомянутых свойств объекты PipeReader и PipeWriter.
Рассмотрим методы для работы с пайплами:
Для записи через PipeWriter — WriteAsync, GetMemory/GetSpan, Advance, FlushAsync, Complete, CancelPendingFlush, OnReaderCompleted.
Для чтения через PipeReader — AdvanceTo, ReadAsync, TryRead, Complete, CancelPendingRead, OnWriterCompleted.
Как и сказано в упомянутом посте, класс использует односвязный список буферов. Но, очевидно, между PipeReader и PipeWriter они не передаются — вся логика заложена в одном классе. Данный список используется как для чтения, так и для записи. Более того, возвращаемые данные хранятся именно в этом списке.
Также имеются объекты, указывающие на начало данных для чтения (ReadHead и индекс), конец данных для чтения (ReadTail и индекс) и начало места для записи (WriteHead и количество записанных буферизованных байт). Здесь ReadHead, ReadTail и WriteHead — конкретный сегмент из списка, а индекс указывает на конкретную позицию внутри сегмента. Таким образом запись может начинаться с середины сегмента, захватывать весь следующий сегмент и завершаться на середине третьего. Данные указатели двигаются в различных методах.
Приступим к разбору методов PipeWriter
#1 ValueTask WriteAsync(ReadOnlyMemory source, CancellationToken cancellationToken)
Как раз тот заманчивый метод. Имеет весьма подходящую и модную сигнатуру — принимает ReadOnlyMemory, асинхронный. И многие могут соблазниться, особенно вспомнив о том, что Span и Memory — это так быстро и круто. Но не стоит обольщаться. Всё, что делает этот метод — копирует переданный ему ReadOnlyMemory во внутренний список. И под «копирует» понимается вызов метода CopyTo, а не копирование самого объекта. То есть все данные, которые мы хотим записать, буду скопированы, тем самым нагружая память. Этот метод стоит изучить лишь для того, чтобы убедится, что им лучше не пользоваться. Ну и, возможно, для некоторых редких ситуациях данное поведение является подходящим.
Тело метода является критической секцией, доступ к нему синхронизируется посредствам монитора.
Тогда может возникнуть вопрос, как записать что-то, если не через самый очевидный и единственный подходящий метод.
#2 Memory GetMemory(int sizeHint)
Метод принимает один параметр целочисленного типа. В нем мы должны указать, сколько байт мы хотим записать (или больше, но ни в коем случае не меньше). Данный метод проверяет, достаточно ли места для записи в текущем фрагменте памяти, хранящимся в _writingHeadMemory. Если достаточно, то в качестве Memory возвращается _writingHeadMemory. Если же нет, то для данных, записанных в буфер, но для которых не был вызван метод FlushAsync, он вызывается и выделяется еще один BufferSegment, который соединяется с предыдущим (вот и список). При отсутствии _writingHeadMemory, он инициализируется новым BufferSegment. И выделение очередного буфера является критической секцией и делается под локом.
Предлагаю взглянуть на такой пример. На первый взгляд может показаться, что компилятор (или рантайм) бес попутал.
Но все в этом примере объяснимо и просто.
При создании экземпляра Pipe мы можем передать ему в конструктор объект PipeOptions с опциями для создания.
В PipeOptions есть поле минимального размера сегмента по умолчанию. Еще не так давно оно было 2048, но данный коммит все изменил, теперь 4096. На момент написания статьи версия с 4096 была пререлизным пакетом, в последней релизной версии было значение 2048. Это объясняет поведение первого примера. Если вам критично использование меньшего размера для стандартного буфера, его можно указать в экземпляре типа PipeOptions.
Но во втором примере, где указан минимальный размер, длина все равно ему не соответствует. А это уже происходит потому, что создание нового BufferSegment происходит с использованием пулов. Одной из опций в PipeOptions является пул памяти. После этого для создания нового сегмента будет использоваться именно указанный пул. Если вы не указали свой пул памяти, будет использоваться стандартный ArrayPool, который, как известно, имеет несколько бакетов под разные размеры массивов (каждый следующий в 2 раза больше предыдущего) и при запросе на определенный размер, он ищет бакет с массивами подходящего размера (то есть ближайшего большего или равного). Соответственно, новый буфер почти наверно будет большего размера, чем вы запросили. Минимальный размер массива в стандартном ArrayPool (System.Buffers.TlsOverPerCoreLockedStacksArrayPool) — 16. Но не стоит переживать, ведь это пул массивов. Соответственно, в подавляющем большинстве случаев массив не оказывает нагрузки на сборщик мусора и будет использоваться повторно.
#2.5 Span GetSpan(int sizeHint)
Работает аналогично, давая Span из Memory.
Таким образом GetMemory() или GetSpan() — главные методы для записи. Они дают нам объект, в который мы может писать. Для этого нам не надо выделять память под новые массивы значений, можно писать напрямую во внутренню структуру. Какой из них использовать в основном будет зависеть от используемого вами API и асинхронности метода. Однако с учетом вышесказанного появляется вопрос. Как читатель узнает о том, сколько мы записали? Если бы использовалась всегда конкретная реализация пула, которая дает массив точно такого же размера, как запрошен, то читатель мог бы читать сразу весь буфер. Однако, как мы уже сказали, нам выделяется буфер с высокой долей вероятности большего размера. Это приводит к следующему необходимому для работы методу.
#3 void Advance(int bytes)
До жути простой метод. В качестве аргумента принимает количество записанных байт. Они увеличивают внутренние счетчики — _unflushedBytes и _writingHeadBytesBuffered, чьи имена говорят сами за себя. А также он обрезает _writingHeadMemory ровно под количество записанных байт (используя метод Slice). Поэтому после вызова этого метода нужно запрашивать новый блок памяти в виде Memory или Span, в прежний писать нельзя. И все тело метода является критической секцией и выполняется под локом.
Казалось бы, после этого читатель может получать данные. Но нужен еще один шаг.
#4 ValueTask FlushAsync(CancellationToken cancellationToken)
Метод вызывается после того, как мы записали в полученный Memory нужные данные и указали, сколько мы туда записали. Метод возвращает ValueTask, однако не является асинхронным (в отличие от его наследника StreamPipeWriter). ValueTask — специальный тип (readonly struct), используемый в случае, когда большинство вызовов не будет использовать асинхронность, то есть все необходимые данные будут доступны к моменту его вызова и метод будет завершаться синхронно. Внутри себя содержит либо данные, либо Task (в случае, если синхронно все же не получилось). Это зависит от состояния свойства _writerAwaitable.IsCompleted. Если поискать, что изменяет состояние этого объекта ожидания, мы увидим, что это происходит при условии, что количество необработанных (not consumed) данных (это не совсем то же самое, что и непрочитанных (not examined), будет объяснено далее) превышает определенный порог (_pauseWriterThreshold). По умолчанию — 16 размеров сегментов. При желании значение можно изменить в PipeOptions. Также данный метод запускает продолжение (continuation) метода ReadAsync, если тот был заблокирован.
Возвращает FlushResult, содержащий 2 свойства — IsCanceled и IsCompleted. IsCanceled показывает, был ли отменен Flush (вызов CancelPendingFlush). IsCompleted показывает, был ли завершен PipeReader (вызовом методов Complete() или CompleteAsync()).
Основная часть метода выполняется под Локом Скайуокером.
Остальные методы PipeWriter’a с точки зрения реализации интереса не представляют и применяются гораздо реже, поэтому будет дано лишь краткое описание.
#5 void Complete(Exception exception = null) или ValueTask CompleteAsync(Exception exception = null)
Помечает пайп как закрытый для записи. После завершения, при попытке использовать методы для записи будет выкинуто исключение. В случае, если PipeReader уже был завершен, завершается и весь экземпляр Pipe. Большая часть работы выполняется под локом.
#6 void CancelPendingFlush()
Как понятно из названия — завершает текущую операцию FlushAsync(). Присутствует лок.
#7 void OnReaderCompleted(Action callback, object state)
Выполняет переданный делегат, когда читатель завершается (Complete). Также есть лок.
В документации на текущий момент написано, что данный метод может не быть вызван на некоторых наследниках PipeWriter и будет убран в будущем. Поэтому не следует завязывать логику на данные методы.
Переходим к PipeReader
#1 ValueTask ReadAsync(CancellationToken token)
Здесь, подобно FlushAsync, возвращается ValueTask, что намекает, что метод в большинстве своем синхронный, но не всегда. Зависит от состояния _readerAwaitable. Как и с FlushAsync, необходимо найти, когда _readerAwaitable выставляется в незавершенный. Это происходит, когда PipeReader вычитал все из списка (или в нем остались данные, которые были помечены как прочитанные (examined) и нужно больше данных для продолжения). Что, собственно, и логично. Соответственно, можно сделать вывод, что желательно тонко подстраивать Pipe под вашу работу, выставлять все ее опции вдумчиво, основываясь на эмпирически выявленных статистиках. Грамотная настройка снизит вероятность асинхронной ветки выполнения и позволит более эффективно обрабатывать данные. Почти весь метод окружен локом.
Возвращает некий загадочный ReadResult. По факту это просто буфер + флаги показывающие статус операции (IsCanceled — был ли ReadAsync отменен и IsCompleted, показывающий, был ли PipeWriter закрыт). При этом IsCompleted — значение, указывающее, были ли вызваны методы PipeWriter Complete() или CompleteAsync(). Если данные методы были вызваны с указанием исключения, то оно будет выброшено при попытке чтения.
Буфер опять имеет загадочный тип — ReadOnlySequence. Это, в свою очередь, объект для содержания сегментов (ReadOnlySequenceSegment) начала и конца + индексы старта и конца внутри соответствующих сегментов. Что собственно напоминает структуру самого класса Pipe. Кстати, BufferSegment — наследник ReadOnlySequenceSegment, что наталкивает на мысль, что именно он там и используется. Благодаря этому можно как раз избавится от лишних выделений памяти на передачи данных от писателя к читателю.
Из буфера можно получить ReadOnlySpan для последующей обработки. Для полноты картины можно проверить, содержит ли буфер единственный ReadOnlySpan. В случае если содержит, нам не надо итерироваться по коллекции из одного элемента и мы можем получить его с помощью свойства First. В противном случае надо пробежаться по всем сегментам в буфере и обработать ReadOnlySpan каждого.
Тема для обсуждения — в классе ReadOnlySequence активно применяются nullable reference types и имеется goto (не для выхода из вложенности и не в сгенеренном коде) — в частности здесь
После обработки необходимо дать понять экземпляру Pipe, что мы прочитали данные.
#2 bool TryRead(out ReadResult result)
Синхронная версия. Позволяет получить результат в случае, если он есть. В случае, если его еще нет, в отличие от ReadAsync не блокируется, а возвращает false. Также в локе.
#3 void AdvanceTo(SequencePosition consumed, SequencePosition examined)
В этом методе можно указать, сколько байт мы прочитали, а сколько обработали. Прочитанные, но не обработанные данные будут возвращены при следующем чтении. Эта возможность может показаться странной на первый взгляд, но при обработке потока байт редко необходимо обрабатывать каждый байт по отдельности. Обычно обмен данными идет с помощью сообщений. Может возникнуть ситуация, что читатель при чтении получил одно целое сообщение и часть второго. Целое необходимо обработать, а часть второго оставить на следующий раз, чтобы она пришла вместе с оставшейся частью. Метод AdvanceTo принимает SequencePosition, который собственно является сегментом + индексом в нем. При обработке всего, что прочел ReadAsync, можно указать buffer.End. В противном случае придется явно создавать позицию, указывая сегмент и индекс, на котором была прекращена обработка. Под капотом лок.
Также в случае, если объем необработанной информации меньше, чем установленный порок(_resumeWriterThreshold), он запускает продолжение PipeWriter, если тот был заблокирован. По умолчанию этот порог — 8 объемов сегмента (половина порога блокировки).
#4 void Complete(Exception exception = null)
Завершает PipeReader. Если к этому моменту PipeWriter завершен, то завершается весь экземпляр Pipe. Лок внутри.
#5 void CancelPendingRead()
Позволяет отменить чтение, которое сейчас ожидается. Лок.
#6 void OnWriterCompleted(Action callback, object state)
Позволяет указать делегат для исполнения по завершении PipeWriter.
Как и аналогичный метод у PipeWriter, в документации стоит та же пометка, что будет убран. Лок под капотом.
Пример
Linux pipes tips & tricks
Pipe — что это?
Pipe (конвеер) – это однонаправленный канал межпроцессного взаимодействия. Термин был придуман Дугласом Макилроем для командной оболочки Unix и назван по аналогии с трубопроводом. Конвейеры чаще всего используются в shell-скриптах для связи нескольких команд путем перенаправления вывода одной команды (stdout) на вход (stdin) последующей, используя символ конвеера ‘|’:
grep выполняет регистронезависимый поиск строки “error” в файле log, но результат поиска не выводится на экран, а перенаправляется на вход (stdin) команды wc, которая в свою очередь выполняет подсчет количества строк.
Логика
Конвеер обеспечивает асинхронное выполнение команд с использованием буферизации ввода/вывода. Таким образом все команды в конвейере работают параллельно, каждая в своем процессе.
Размер буфера начиная с ядра версии 2.6.11 составляет 65536 байт (64Кб) и равен странице памяти в более старых ядрах. При попытке чтения из пустого буфера процесс чтения блокируется до появления данных. Аналогично при попытке записи в заполненный буфер процесс записи будет заблокирован до освобождения необходимого места.
Важно, что несмотря на то, что конвейер оперирует файловыми дескрипторами потоков ввода/вывода, все операции выполняются в памяти, без нагрузки на диск.
Вся информация, приведенная ниже, касается оболочки bash-4.2 и ядра 3.10.10.
Простой дебаг
Исходный код, уровень 1, shell
Т. к. лучшая документация — исходный код, обратимся к нему. Bash использует Yacc для парсинга входных команд и возвращает ‘command_connect()’, когда встречает символ ‘|’.
parse.y:
Также здесь мы видим обработку пары символов ‘|&’, что эквивалентно перенаправлению как stdout, так и stderr в конвеер. Далее обратимся к command_connect():make_cmd.c:
где connector это символ ‘|’ как int. При выполнении последовательности команд (связанных через ‘&’, ‘|’, ‘;’, и т. д.) вызывается execute_connection():execute_cmd.c:
PIPE_IN и PIPE_OUT — файловые дескрипторы, содержащие информацию о входном и выходном потоках. Они могут принимать значение NO_PIPE, которое означает, что I/O является stdin/stdout.
execute_pipeline() довольно объемная функция, имплементация которой содержится в execute_cmd.c. Мы рассмотрим наиболее интересные для нас части.
execute_cmd.c:
Таким образом, bash обрабатывает символ конвейера путем системного вызова pipe() для каждого встретившегося символа ‘|’ и выполняет каждую команду в отдельном процессе с использованием соответствующих файловых дескрипторов в качестве входного и выходного потоков.
Исходный код, уровень 2, ядро
Обратимся к коду ядра и посмотрим на имплементацию функции pipe(). В статье рассматривается ядро версии 3.10.10 stable.
fs/pipe.c (пропущены незначительные для данной статьи участки кода):
Если вы обратили внимание, в коде идет проверка на флаг O_NONBLOCK. Его можно выставить используя операцию F_SETFL в fcntl. Он отвечает за переход в режим без блокировки I/O потоков в конвеере. В этом режиме вместо блокировки процесс чтения/записи в поток будет завершаться с errno кодом EAGAIN.
Максимальный размер блока данных, который будет записан в конвейер, равен одной странице памяти (4Кб) для архитектуры arm:
arch/arm/include/asm/limits.h:
Для ядер >= 2.6.35 можно изменить размер буфера конвейера:
Максимально допустимый размер буфера, как мы видели выше, указан в файле /proc/sys/fs/pipe-max-size.
Tips & trics
Перенаправление и stdout, и stderr в pipe
или же можно использовать комбинацию символов ‘|&’ (о ней можно узнать как из документации к оболочке (man bash), так и из исходников выше, где мы разбирали Yacc парсер bash):
Перенаправление _только_ stderr в pipe
Shoot yourself in the foot
Важно соблюдать порядок перенаправления stdout и stderr. Например, комбинация ‘>/dev/null 2>&1′ перенаправит и stdout, и stderr в /dev/null.
Получение корректного кода завершения конвейра
По умолчанию, код завершения конвейера — код завершения последней команды в конвеере. Например, возьмем исходную команду, которая завершается с ненулевым кодом:
И поместим ее в pipe:
Теперь код завершения конвейера — это код завершения команды wc, т.е. 0.
Обычно же нам нужно знать, если в процессе выполнения конвейера произошла ошибка. Для этого следует выставить опцию pipefail, которая указывает оболочке, что код завершения конвейера будет совпадать с первым ненулевым кодом завершения одной из команд конвейера или же нулю в случае, если все команды завершились корректно:
Shoot yourself in the foot
Следует иметь в виду “безобидные” команды, которые могут вернуть не ноль. Это касается не только работы с конвейерами. Например, рассмотрим пример с grep:
Здесь мы печатаем все найденные строки, приписав ‘new_’ в начале каждой строки, либо не печатаем ничего, если ни одной строки нужного формата не нашлось. Проблема в том, что grep завершается с кодом 1, если не было найдено ни одного совпадения, поэтому если в нашем скрипте выставлена опция pipefail, этот пример завершится с кодом 1:
В больших скриптах со сложными конструкциями и длинными конвеерами можно упустить этот момент из виду, что может привести к некорректным результатам.