Статьи

Самостоятельная уловка

ioselect

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

Недавно я изучал кодовую базу Формана . Я подумал, что у него будет отличный корм для примеров программирования систем Unix на Ruby. Я не был разочарован.

Класс Foreman::Engine — это особенно хорошая учеба. Один из шаблонов, который он использует, называется трюком с самоконтролем. Это не специфичный для Ruby шаблон; Трюк с самоконтролем является частью правильного решения для обработки сигналов при смешивании с системным вызовом select (2). Следует отметить, что сервер Unicorn использует эту самую технику для обработки сигналов; это еще одно замечательное исследование в Unix системном программировании на Ruby.

Бригадир, упрощенный

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

В форме по умолчанию гем Foreman берет Procfile, который может содержать некоторые записи, подобные этой:

 web: bundle exec thin start job: bundle exec rake jobs:work 

затем возьмите эти команды, спавнируйте их в процессы и управляйте этими процессами. В этом примере Procfile, запуск foreman foreman start без параметров приведет к созданию одного дочернего процесса для каждого из этих типов приложений (например, web, job). Я собираюсь замять процесс создания процессов и просто поговорить об обработке сигналов.

Одна из возможностей, которую обеспечивает Foreman, заключается в том, что когда вы отправляете ему сигнал Unix, сообщающий ему о завершении, он передает этот сигнал процессам, которыми он управляет.

В сторону о сигналах

Unix-сигналы являются формой межпроцессного взаимодействия (IPC). Они позволяют асинхронно запускать некоторые действия в любом процессе в системе. Это может быть довольно полезно, но также создает некоторые интересные проблемы.

Вы использовали сигналы раньше, если когда-либо использовали команду kill (1). Например, вы почти наверняка сделали что-то вроде:

 kill -9 <pid> 

убить программу, которая не отвечает. Команда kill (1) вместе с нашим Process.kill в Ruby — это то, как мы посылаем сигнал процессу. При использовании kill -9 часть -9 фактически ссылается на определенный сигнал: сигнал KILL .

Это немного сбивает с толку, мы используем команду kill (1) для отправки сигнала KILL , но есть и другие сигналы, которые вы можете отправлять. Вот несколько примеров:

 $ kill -9 # same as $ kill -KILL $ kill -HUP # same as $ kill -1 $ kill -INT $ kill -TERM $ kill -QUIT 

KILL , HUP , TERM и QUIT являются примерами различных сигналов, которые вы можете отправить процессу. Эти сигналы могут быть отправлены любому запущенному процессу в любое время. Каждый процесс может по-разному обрабатывать сигналы с помощью обработчиков сигналов, но это проблема работы с сигналами: они могут быть доставлены в любое время.

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

Обработка сигналов

Способ, которым вы определяете специфическое для сигнала поведение, состоит в том, чтобы «перехватить» сигнал, указав некоторое поведение, которое должно выполняться при получении сигнала.

В Ruby мы делаем это с помощью Signal.trap следующим образом:

 Signal.trap("INT") { puts "received INT" exit } Signal.trap("QUIT") { puts "received QUIT" exit } Signal.trap("KILL") { puts "received KILL, but I refuse to go!" } Signal.trap("USR1") { puts "received USR1" puts "I think the time is #{Time.now.to_i}" } puts Process.pid sleep 

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

 $ kill -INT <pid> $ kill -USR1 <pid> 

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

Однако для сигнала KILL ваш обработчик не будет вызван. Сигнал KILL особенный, потому что он не может быть пойман в ловушку. Вот почему полезно убить не отвечающий процесс; он не может быть заблокирован или остановлен, чтобы остановить процесс раз и навсегда.

Сигналы USR1USR2 ) отличаются тем, что они не имеют традиционного поведения. Они буквально для вашего приложения, чтобы повесить дополнительное поведение.

Сигналы и повторный вход

Я упомянул, что хитрость в сигналах заключается в том, что они могут прервать вашу программу в любое время. Я думаю, это стоит повторить. Сигналы могут прервать вашу программу в любое время . Пока выполняется любой ваш код, может прийти сигнал и прервать его. Во время обработки одного сигнала другой сигнал (или тот же сигнал!) Может поступить во второй раз и прервать первый обработчик.

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

 trap("QUIT") { File.delete('ephemeral_file') exit } 

На первый взгляд это выглядит правильно, но истинная асинхронность сигналов создает проблему для этого кода. Если отправитель сигнала удовлетворен триггером, он может отправить сигнал QUIT дважды в быстрой последовательности. Когда это происходит, вполне возможно, что этот обработчик QUIT будет прерван после удаления файла, но до его выхода из процесса.

Чтобы это было легко воспроизвести, попробуйте добавить спящий режим между File.delete и exit , а затем дважды отправить сигнал QUIT подряд.

Если первый экземпляр обработчика QUIT зайдет так далеко (удаляя файл, но еще не завершив его), то при поступлении второго сигнала QUIT он направится к началу блока обработчика сигнала и снова попытается удалить файл. Это, конечно, приведет к ENOENT ошибки и грусти.

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

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

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

Но сначала, вот примерно, как Форман и Юникорн реализуют очереди сигналов.

 SIGNAL_QUEUE = [] [:INT, :QUIT, :TERM].each do |signal| Signal.trap(signal) { SIGNAL_QUEUE << signal } end # main loop loop do case SIGNAL_QUEUE.pop when :INT handle_int when :QUIT handle_quit when :TERM handle_term else the_usual end end 

И Единорог, и Форман имеют такую ​​петлю.

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

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

Изображение бегущего мастера

Чтобы понять состояние гонки, вам необходимо общее понимание того, как Форман работает с выходными данными дочерних процессов, которыми он управляет.

Foreman создает канал для каждого дочернего процесса, который ему нужен, и перенаправляет стандартный поток дочернего процесса в этот канал. Затем процесс Foreman отслеживает ввод всех этих каналов. Когда поступает какой-то ввод, он выводит его на консоль с тегом и цветом, характерными для процесса, из которого получен вывод.

Поэтому Форману нужен какой-то способ контролировать все эти каналы, чтобы видеть, когда поступают данные.

Ядро предоставляет системный вызов, чтобы сделать именно то, что нужно Форману. Системный вызов select (2) принимает набор файловых дескрипторов (например, каналы, файлы, сокеты), которые вы хотите отслеживать; когда эти файловые дескрипторы готовы к действию (данные доступны для чтения или больше буферного пространства для записи), select (2) возвращает только те файловые дескрипторы, которые готовы для вас.

Поэтому, если бы Foreman управлял 10 дочерними процессами, он бы отслеживал 10 каналов с помощью select (2). Если один из дочерних процессов записал какой-либо вывод в свой канал, select (2) вернет этот канал в Foreman, чтобы он мог предпринять правильные действия.

В Ruby файловые дескрипторы сопоставляются с объектами IO , а select (2) сопоставляется с IO.select . Это означает, что любой объект IO может быть передан в IO.select для мониторинга.

Опять же, я упрощаю вещи, но основной цикл Foreman выглядит примерно так: pipes представляют собой массив каналов, подключенных к stdout каждого дочернего процесса.

 # main loop loop do case SIGNAL_QUEUE.pop when :INT handle_int when :QUIT handle_quit when :TERM handle_term else ready = IO.select(pipes) process_outputs(ready[0]) end end 

Теперь мы заполнили блок else чем-то ближе к тому, что на самом деле делает Форман.

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

Состояние гонки

В этом фрагменте кода обработчики сигналов были определены до входа в основной цикл. Если сигнал QUIT поступит до ввода оператора case , все будет работать так, как ожидалось. Оператор case QUIT сигнал QUIT из очереди и обрабатывает его правильно.

Однако сигнал QUIT может достигать верхней части блока else . Помните, что сигналы могут прервать вашу программу в любое время . Если бы сигнал QUIT был отправлен непосредственно перед вызовом IO.select , он был бы должным образом отправлен в очередь сигналов, но тогда ваша программа вошла бы в блокирующий вызов IO.select , который мог бы блокироваться бесконечно. Если это произойдет, этот сигнал никогда не будет обработан, или, по крайней мере, обработка будет отложена.

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

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

Решение Self-Pipe

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

Я почти уверен, что вы знакомы с каналами в командной строке. Вы, вероятно, использовали их для объединения команд в конвейеры. Здесь мы говорим о программной конструкции, лежащей в основе каналов в вашей оболочке.

Труба — это просто однонаправленный поток байтов.

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

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

Вы можете создать трубу в Ruby следующим образом:

 reader, writer = IO.pipe 

Вызов IO.pipe возвращает два объекта IO , один для представления конца чтения канала, а другой для представления конца записи канала.

Традиционно каналы используются совместно с дочерними процессами для IPC (я кратко коснулся того факта, что Foreman делает это для отслеживания выходных данных дочерних процессов), но сам канал не используется совместно с другим процессом. Отсюда и название.

Итак, процесс Foreman создает канал, затем его обработчики сигнала будут записывать байт в канал, а также помещать запись в SIGNAL_QUEUE .

 SIGNAL_QUEUE = [] self_reader, self_writer = IO.pipe [:INT, :QUIT, :TERM].each do |signal| Signal.trap(signal) { # write a byte to the self-pipe self_writer.write_nonblock('.') SIGNAL_QUEUE << signal } end 

Затем, чтобы довести это до конца, главный вызов select также контролирует конец чтения этого канала. Если в какой-то момент поступит сигнал и в канал будет записан байт, вызов select будет активирован (так как он отслеживает само-канал), и сигнал будет обработан немедленно.

 # main loop loop do case SIGNAL_QUEUE.pop when :INT handle_int when :QUIT handle_quit when :TERM handle_term else ready = IO.select(pipes + [reader]) # drain the self-pipe so it won't be returned again next time if ready[0].include?(reader) reader.read_nonblock(1) end # continue to process the other pending data if (pipes & ready[0]).any? process_outputs(ready[0]) end end end 

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

Обратите внимание, что нам нужно было добавить немного дополнительной логики в последний блок else чтобы увидеть, возвращал ли select какие-либо каналы, которые указывали бы на то, что есть выход, который нужно обработать, а также чтобы опустошить self-pipe, чтобы он, опять же, был в нетронутое состояние, чтобы указать, когда сигнал прибыл.

окончание

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

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

Если вы хотите узнать больше, вот еще немного о трюке с самоконтролем, некоторых альтернативах, таких как pselect (2) и правильной обработке сигналов в Ruby:

Только что открылась регистрация на августовский выпуск моего онлайн-курса по Unix для Rubyists , и я раздаю бесплатный билет! Вы можете войти, чтобы получить шанс выиграть здесь .