«Трубы, трубы звонят»

(Будьте готовы, здесь много метафор.)

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

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

Вход и выход

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

К тому времени, когда была разработана UNIX, разрозненная среда входов и выходов стала неустойчивой. Чтобы упростить процесс, UNIX был построен таким образом, что все входы и выходы представляли собой серию строк независимо от используемого устройства; теперь системы могли использовать любое устройство до тех пор, пока они отправляли данные в виде потока текстовых строк. С тех пор текстовые потоки стали основной частью программирования.

Путь Unix

Ключевой концепцией, лежащей в основе разработки программного обеспечения с момента распространения компьютеров, является философия UNIX, которую можно резюмировать следующим образом:

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

Объединение этих идей может быть лучше продемонстрировано на концептуальном примере. Допустим, мы хотим получить текст с веб-сервера, выполнить поиск по определенному слову и сохранить результаты в файл в локальной файловой системе. Вместо написания одной программы, которая делает все это, философия UNIX будет использовать три отдельные программы, каждая из которых обрабатывает HTTP-запрос, поиск и запись файловой системы. Мы использовали бы одну программу для загрузки данных, передачи результата второй программе, которая только выполняет поиск, извлекает результат и, наконец, запускает третью программу для записи на локальный диск. Потоки учитывают этот процесс как механизм, позволяющий передавать один результат в другую программу через конвейеры.

Приведенный выше пример записан как команда оболочки в UNIX-подобных системах следующим образом:

$ curl 'http://url.to.text' | grep search-term | tee result.txt

curl - это программа, которая позволяет отправлять HTTP-запросы с вашего терминала, grep выполняет поиск совпадений в тексте и tee сохраняет входящие данные в папку с файлом. Эти три программы связаны друг с другом символом |, который устанавливает конвейерное соединение между каждой из программ.

Приведенный выше код можно дополнительно упростить с помощью простого перенаправления, если результат поиска не нужно передавать по конвейеру. Вместо | tee используйте >.

Подключение к / от Node.js

Узел поддерживает потоковые интерфейсы. Например, это полностью верно:

$ curl -s https://gist.githubusercontent.com/brianjleeofcl/643dff9b431e598c288ca7e725ae4309/raw/54dfb0883cf522e8e525535beb54eb755c8fd258/script.js | node

Файл сценария, загруженный через curl, подается в узел. В приведенном выше примере URL-адрес указывает на файл сценария, который содержит один оператор console.log:

curl ... | node выполнит указанный выше файл в Node, который просто покажет консольное сообщение. Точно так же многие среды выполнения для других языков, таких как | python, | ruby или | bash, могут запускать код, который поступает из внешнего источника.

Будьте осторожны: выполнение скриптов из интернет-источников сопряжено с очевидными рисками. Если сценарий содержит вредоносное поведение, это может вызвать серьезные проблемы. Не выполняйте код, загруженный из Интернета, если только он не из надежного источника.

Интерфейс конвейера в оболочке требует, чтобы программы реализовывали три потока: стандартный ввод, стандартный вывод и стандартную ошибку, обычно известные как stdin, stdout и stderr. В Node три потока подключаются к объекту process.

В следующем примере у нас есть три файла: один читает изображение по пути к файлу, другой изменяет размер изображения с помощью ImageMagick, а третий записывает в файл по пути к файлу.

В каждом из этих файлов потоки передаются по конвейеру в другие потоки в строке 5. Мы рассмотрим, как эти потоки реализованы в следующем разделе; а пока обратите внимание на использование .pipe().

Чтобы увидеть, как выполняются эти файлы, давайте посмотрим на следующие команды оболочки:

В команде в строках 4–6 мы передаем результат процесса от устройства чтения изображений по конвейеру в манипулятор изображений, который затем передается по конвейеру в модуль записи файлов.

В этом случае есть три отдельных экземпляра Node на уровне процесса. Это позволяет операционной системе назначать каждый процесс соответствующим потокам, что является одним из способов обойти тот факт, что Node - это однопоточная среда выполнения. Обратите внимание, что здесь нет общей памяти, и код выполняется последовательно, а не одновременно.

Преимущества трубопроводов очевидны в строках 8–10 и 12–13. В каждой из этих команд модифицируются части общего процесса: первая имеет другой ввод, который загружается с использованием curl, а вторая имеет другой вывод, который отправляет измененное изображение на веб-сервер. Во всех примерах код, обрабатывающий манипуляции с изображениями, идентичен. Код можно использовать повторно, поскольку он не зависит от того, как данные предоставляются для выполнения своих задач: пока соответствующий ввод передается в stdin, а результат передается в stdout, он работает.

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

Потоки в Node.js

Реализация потоковой передачи в оболочке была настолько мощной и эффективной, что стала широко реализовываться и в программном обеспечении. Node здесь не исключение.

Прежде чем мы рассмотрим детали реализации, давайте еще раз подумаем о метафоре потока. У потока по определению есть две точки: восходящая и нисходящая. Определив эти две точки, мы должны соединить верхний и нижний поток, чтобы обеспечить плавный дренаж без каких-либо переливов в любой точке.

В Node.js встроенный класс stream имеет два отдельных интерфейса, чтобы отличать восходящий поток от нисходящего: readableStream и writableStream. readableStream разрешает чтение данных, а writeStream - запись данных. Это означает, что экземпляры данных должны быть извлечены из экземпляра readableStream в экземпляр writableStream, завершая направление потока данных. Все экземпляры readableStream имеют .pipe(stream) метод, который принимает в качестве аргумента экземпляр writableStream. Используя в качестве примера stdin и stdout,

process.stdin.pipe(process.stdout));

создает процесс, в котором данные передаются немедленно. Некоторые потоки реализуют как readableStream, так и writableStream, что означает, что в некоторых случаях можно объединить несколько вызовов .pipe(stream).

Потоки, которые реализуют как чтение, так и запись, - это потоки Duplex и Transform. Эти потоки часто реализуются как пакеты шифрования / дешифрования или сжатия / распаковки.

В дополнение к .pipe(stream), все потоки в узле являются расширениями класса eventEmitter, что означает, что мы можем прикреплять прослушиватели событий непосредственно к потокам, используя .on(...) или .once(...). Это важно, потому что информация о текущем состоянии потока передается процессу с помощью событий, которые используются, чтобы сигнализировать процессу, что он должен получить и использовать данные.

Есть два метода чтения из потоков. Когда readableStream имеет данные, он генерирует событие readable; на своем слушателе процесс принимает данные с помощью .read() метода. Поскольку данные не извлекаются и не удаляются из внутреннего буфера до вызова метода, это считается «приостановленным режимом» чтения потока. Это поведение по умолчанию отключается при вызове .pipe(), следовательно, данные передаются непосредственно в writableStream без каких-либо промежуточных шагов, или когда добавляется прослушиватель для события data, который делает данные доступными в функции обратного вызова. Приостановленный режим используется по умолчанию, поскольку контролировать приток со стороны процесса относительно легче; никакие данные не будут поступать в процесс без вызова .read(), в отличие от потоковых потоков, которые автоматически вызывают слушателя в каждом экземпляре данных и могут быть включены и выключены только с помощью .pause() и .resume().

С другой стороны, запись в writableStream - довольно простой процесс: используйте .write(data) для отправки данных в поток, используйте .end(data) для отправки последнего бита данных и выключите поток.

Примечание: вы не можете закрыть stdout. process.stdout.end() закинуть с Error: process.stdout cannot be closed. Скорее всего, если у процесса больше нет вывода для потоковой передачи, вам может потребоваться выйти из процесса через process.exit(0). В противном случае процесс естественным образом останавливается по истечении цикла обработки событий.

Чтобы собрать все это вместе, давайте рассмотрим два способа переписать process.stdin.pipe(process.stdout):

Поскольку простая передача данных из ввода напрямую в вывод ничего не дает, имеет смысл написать код, который обрабатывает данные перед их записью; соответствующие места для манипуляции данными отмечены в комментариях.

Приведенные выше примеры объясняют еще одно преимущество использования потоков: обработка обрабатывает более мелкие фрагменты данных. Гораздо эффективнее записать небольшую часть большого файла в память, обработать ее, отправить по потоку и выполнить сборку мусора перед повторением процесса. Альтернативой может быть одновременная запись всех данных в память, что замедляет весь процесс и, в худшем случае, создает нехватку памяти.

Резюме

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

Далее: дочерние процессы

Re sourc es