суббота, 12 марта 2011 г.

Завершение всех процессов в подгруппе (на примере Tcl)

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

Для удобства использования программ были написаны интерфейсы на Tcl/Tk: большой интерфейс для работы с конфигурацией, графикой, с возможностью запуска обеих версий программ, и малый, который позволял запускать программу в пакетном режиме, следить за ее статусом с помощью прогрессбара (нужно отметить, что программы моделирования и на современных процессорах могут работать в течении суток, а то и недель), и останавливать ее работу при двойном нажатии на специальную кнопку Kill.

Внутри большого интерфейса интерактивная программа моделирования запускалась с помощью открытия канала на запись tcl-командой open, соответственно управление и выход из нее не составляли труда, например для прерывания выполнения программы достаточно было записать в канал, созданный open, последовательность Ctrl-C, а для выхода - последовательность quit. Малый интерфейс мог запускаться самостоятельно, или из большого интерфейса как фоновый процесс.

Поскольку пакетная программа не имела средств для взаимодействия с пользователем, то малый интерфейс запускал ее не с помощью open, а как активный (foreground) процесс. Архитектура запуска процессов малого интерфейса была такова:
  1. В начале работы интерфейса (практически одновременно):
    • Процесс диалога с прогрессбаром и кнопкой Kill (background). В этом диалоге:
      • Отдельный процесс, считывающий результаты программы моделирования, сохраняемые в файле на диске, интерпретирующий их и посылающий с помощью tcl-команды send команды родительскому процессу для отображения статуса выполнения на прогрессбаре (background). В случае прочтения специальной метки end, сигнализирующей о завершении пакетной программы моделирования, данный процесс посылает с помощью send своему родительскому процессу команду exit и завершается сам - такое развитие событий соответствует нормальному завершению работы программы.
    • Процесс пакетной программы моделирования (foreground)
  2. По завершении работы программы моделирования:
    • Процесс диалога, предоставляющего возможность записи результатов программы в базу данных.
Волшебная кнопка Kill должна по замыслу завершить все процессы, включая родительский - т.е. процесс с малым интерфейсом, по желанию пользователя до завершения работы программы моделирования.

Моим старым решением было удалить все процессы в группе процессов:
catch {exec kill 0}
Эта tcl-команда запускает обычную команду оболочки kill 0, что соответствует посылке сигнала TERM текущей группе процессов. Это решение прекрасно работает в случае, если малый интерфейс запускается из терминала, но, к сожалению, если он был запущен из большого интерфейса, то последний тоже завершается, что не является правильным поведением. Проблема в том, что вся совокупность процессов, запущенная как единая команда из терминала, рассматривается как единая группа процессов. Соответственно, kill 0 убивает все процессы-предки вплоть до того, который был запущен из терминала.

К сожалению, создать новую группу процессов на уровне интерфейса оболочки, похоже, не представляется возможным: функция для установки группы процессов setsid() существует только на уровне языка C.

Следующим вариантом было:
set ppid [string trim [exec ps -p [pid] --no-headers -o ppid]]
catch {exec kill -TERM -$ppid}
Здесь ищется pid родительского процесса с помощью обычной команды оболочки ps, команда string trim необходима, т.к. ps форматирует вывод и может предварять pid пробелами. В команде kill -TERM -$ppid формальное указание типа сигнала (-TERM) обязательно, т.к. мы используем отрицательное значение pid. Команда kill с отрицательным pid применяется для посылки сигнала всем процессам в группе процессов, соответствующей pid. Фактически, эта команда ничем не отличается от kill 0, кроме возможности формально указывать некоторую группу процессов, а не текущую, но в нашем случае это неважно. Этот вариант так же хорошо работает, если родительский процесс (малый интерфейс) был запущен из терминала, однако в случае с большим интерфейсом последний не завершается как раньше - на этот раз при нажатии на кнопку Kill  просто ничего не происходит. Дело в том, что при запуске малого интерфейса из большого процесс, соответствующий малому интерфейсу не образует группу, так как он был запущен не из терминала. Соответственно, команда kill -TERM -$ppid завершается аварийно, но, поскольку ее вызов обрамлен командой catch, пользователь не видит вообще никакой реакции.

Следующее решение оказалось окончательным и в итоге завершение пакетных программ нажатием на кнопку Kill заработало и в большом интерфейсе. Идея связана с рекурсивным завершением всех подпроцессов родительского процесса, начиная с нижних, включая подпроцессы процесса, активизировавшего завершение, но исключая его самого. После рассылки сигнала завершения всем указанным подпроцессам, посылается сигнал родительскому процессу, а затем процесс, активизировавший завершение, завершает свою работу с помощью exit. Рекурсивная функция rec_kill() выполняет первую часть алгоритма:
proc rec_kill pid {
    set procs [list]
    foreach x [split [exec ps -o pid,ppid ax | \
                           awk "{if ( \$2 == \"$pid\" ) { print \$1 }}"] \
                     "\n"] {
        lappend procs [string trim $x]
    }
    foreach x $procs {
        rec_kill $x
        if {$x != [pid]} {
            catch {exec kill -TERM $x}
        }
    }
    #puts $procs
}
В первом цикле foreach создается список дочерних процессов процесса-аргумента функции pid. Делается это с помощью следующей команды оболочки:
ps -o pid,ppid -ax | awk "{if ( \$2 == \$pid ) { print \$1 }}"
Во втором цикле происходит рекурсивный вызов rec_kill() самой себя и посылка сигнала завершения всем элементам-процессам, найденным в предыдущем foreach, если те не являются собственным процессом. Pid собственного процесса находится с помощью tcl-команды pid, которая стоит в квадратных скобках (этот синтаксический элемент Tcl называется командной подстановкой), обратите внимание, что эта команда в скобках не имеет никакого отношения к одноименному аргументу функции rec_kill().

Теперь в обработчике двойного клика кнопки Kill нужно выполнить следующее:
set ppid [string trim [exec ps -p [pid] --no-headers -o ppid]]
rec_kill $ppid
exec kill -TERM $ppid
exit
После вызова rec_kill() для родительского процесса (который не трогает собственно родительский процесс) посылаем сигнал для завершения родительскому процессу и выходим с помощью exit.

Одно важное замечание. Будьте очень внимательны с удалением родительского процесса. Родительский процесс может завершиться преждевременно и его pid в дочернем процессе изменится (например, станет 1). В нашем случае, если родительским процессом вдруг станет init, то возможны неприятные последствия, такие как неожиданное завершение пользовательской сессии, ввиду последующего каскадного удаления дочерних процессов init.

Update. Еще 2 важных замечания:
Касательно предыдущего замечания: для того чтобы обезопаситься от случайного завершения процесса, неожиданно ставшего родительским, достаточно в дочернем процессе посылать родителю некий пользовательский сигнал вместо TERM, например сигнал USR1, а в родительском процессе (в том, который нужно завершить) определить обработчик для этого сигнала. В Tcl это можно сделать с помощью команды trap из пакета Expect, для подключения которого в начале программы нужно добавить:
package require Expect
Собственно обработчик сигнала USR1:
trap {exit} USR1
Теперь, если родительский процесс завершится по какой-то причине преждевременно, то новый родительский процесс (скорее всего init) будет игнорировать этот сигнал. Однако это не спасает от каскадного удаления его дочерних процессов функцией rec_kill(). В простейшем случае, чтобы обезопаситься от удаления всех процессов сессии, нужно убедиться, что pid родительского процесса не равен 1.

Второе замечание: как было отмечено, родительский процесс (малый интерфейс) запускает по завершении активного процесса (программы моделирования) процесс-диалог для записи результатов в базу данных. Поскольку rec_kill(), которая посылает сигнал завершения программе моделирования вызывается раньше, чем посылка нашего нового сигнала USR1 (или, как раньше, TERM - это не принципиально), то возможен интересный race condition - малый интерфейс может успеть запустить этот процесс-диалог (так как активная задача была завершена, а сам процесс еще нет), а может и не успеть (если планировщик ОС раньше прибьет малый интерфейс после посылки сигнала из дочернего процесса). Чтобы избежать ненужного вызова процесса-диалога, следует сначала убить родительский процесс, а затем вызвать rec_kill() для его подпроцессов. Однако теперь ситуация осложняется тем, что, поскольку rec_kill() определяет дочерние процессы в своем теле, а сигнал завершения уже был послан родительскому процессу, то он может быть уже завершен и нас может ожидать еще один неприятный race condition, при котором rec_kill() не завершит ничего, либо завершит не те процессы. Чтобы избежать такого развития ситуации, нужно получить список дочерних процессов малого интерфейса до посылки ему сигнала USR1. Теперь обработчик двойного клика кнопки Kill будет выглядеть немного неуклюже:
set ppid [string trim [exec ps -p [pid] --no-headers -o ppid]]
if {$ppid == 1} {exit}
set procs [list]
foreach x [split [exec ps -o pid,ppid ax | \
                       awk "{if ( \$2 == \"$ppid\" ) { print \$1 }}"] \
                 "\n"] {
    lappend procs [string trim $x]
}
exec kill -USR1 $ppid
foreach x $procs {
    rec_kill $x
    if {$x != [pid]} {
        catch {exec kill -TERM $x}
    }
}
exit
, но эту неуклюжесть легко устранить введением вспомогательных функций.