Показаны сообщения с ярлыком процессы UNIX. Показать все сообщения
Показаны сообщения с ярлыком процессы UNIX. Показать все сообщения

понедельник, 16 марта 2015 г.

Мастеринг связанных процессов в linux

Это развитие темы, поднятой в этой статье. Напомню, в ней был представлен способ гарантированного перезапуска сбоящего приложения, основанный на простой модели процессов мастер + воркер. Единственной задачей главного процесса (мастера) был немедленный перезапуск дочернего процесса (воркера) в случае завершения последнего в результате посылки ядром сигнала SIGSEGV. Новая задача будет сформулирована по-другому. Пусть у нас имеется два разных приложения. Нужно гарантировать, во-первых, что оба приложения будут выполняться одновременно, во-вторых, что перезапуск одного из приложений (после нормального завершения или получения сигнала) будет приводить к перезапуску второго приложения, и в-третьих, собственно перезапуск приложений в случае нормального завершения или получения заданных сигналов (пусть это будет SIGSEGV для определенности). Первые два условия означают, что гарантируется уникальность пар экземпляров двух приложений в любой момент времени: под связанностью процессов в заголовке статьи я подразумевал именно это. Условие связанности процессов может быть востребовано в случае, если один из них играет роль бэкенда, хранящего авторизационную информацию клиента, и общающегося с другим процессом — фронтэндом, непосредственно обслуживающим соединение с клиентом, через транспорт, не предоставляющий гарантий сохранения экземпляров взаимодействующих процессов, например TCP или UNIX-сокеты. Если условие связанности не будет выполняться, то перезапуск бэкенда приведет к утере авторизационной информации, в то время как клиентские сессии на фронтэнде останутся невредимы. Перезапуск фронтэнда в этом случае позволил бы перезагрузить переставшие быть валидными клиентские сессии. Эта проблема, на первый взгляд, кажется немного надуманной, но все же может возникнуть в реальности. Например, вам может понадобиться разработать приложение-бэкенд к сервису slapd, общающееся с последним через механизм slapd-sock. В этом случае slapd будет являться фронтэндом вашего приложения, который будет обязан перезапускать клиентские сессии (в рамках нашей задачи — “перезапускаться” сам) в случае завершения или падения бэкенда. В реальности перезапуском обеих частей нашего сервиса, как и прежде, будет заниматься мастер-процесс. Ниже я привожу исходный код соответствующей реализации, построчные комментарии ниже. Многие части полностью соответствуют коду из оригинальной статьи: их я комментировать не стану. Название файла с исходным кодом — main2.cpp.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
#include <unistd.h>
#include <sys/wait.h>
#include <sys/prctl.h>
#include <sys/time.h>
#include <string.h>
#include <signal.h>
#include <stdlib.h>
#include <errno.h>
#include <iostream>
#include <iomanip>

#ifdef MAXCYCLES
#define LOOPSTOPCOND      i < MAXCYCLES
#else
#define LOOPSTOPCOND
#endif

#ifndef PDEATHQUITSIGNAL
#define PDEATHQUITSIGNAL  SIGTERM
#endif

#ifndef CHILDLIFETIME
#define CHILDLIFETIME     4
#endif

static int  pid0( 0 );
static int  pid1( 0 );
static int  pid2( 0 );

static struct timeval  start;


static inline std::ostream &  tprint( std::ostream &  out = std::cout,
                                      const std::string &  delim = " | " )
{
    struct timeval  tv;
    gettimeofday( &tv, NULL );
    int  ms( ( tv.tv_sec - start.tv_sec ) * 1000 +
             ( tv.tv_usec - start.tv_usec ) / 1000 );
    return out << std::setw( 7 ) << float( ms ) / 1000 << delim;
}


static void  inth( int  sig )
{
    if ( getpid() != pid0 )
        exit( 0 );

    tprint() << "Master terminated by signal " << sig << std::endl;
    if ( pid1 > 0 )
    {
        tprint() << "Sending signal " << sig << " to worker 1" << std::endl;
        kill( pid1, sig );
        waitpid( pid1, NULL, 0 );
    }
    if ( pid2 > 0 )
    {
        tprint() << "Sending signal " << sig << " to worker 2" << std::endl;
        kill( pid2, sig );
        waitpid( pid2, NULL, 0 );
    }
    exit( 0 );
}


static void  setinth( void ( *handler )( int ) )
{
    struct sigaction  act;
    memset( &act, 0, sizeof( act ) );
    act.sa_handler = handler;

    int  ints[] = { SIGINT, SIGQUIT, SIGTERM, SIGHUP, 0 };

    for ( int *  s( ints ); *s != 0; ++s )
        sigaction( *s, &act, NULL );
}


int  main( int  argc, char **  argv )
{
    std::cout.precision( 3 );
    std::cout.setf( std::ios::fixed );

    gettimeofday( &start, NULL );

    pid0 = getpid();
    tprint() << "Master: " << pid0 << std::endl;

    setinth( inth );

    for ( int  i( 0 ); LOOPSTOPCOND; ++i )
    {
        if ( ( pid1 = fork() ) == 0 )    /* Worker process 1 */
        {
            pid2 = 0;
            if ( prctl( PR_SET_PDEATHSIG, PDEATHQUITSIGNAL ) == -1 )
            {
                tprint() << "Worker 1: failed to set parent death signal, "
                        "exiting" << std::endl;
                return 1;
            }
            setinth( SIG_DFL );

            tprint() << "(cycle " << i << ") Worker 1: " << getpid() <<
                    std::endl;
            sleep( CHILDLIFETIME );

            break;
        }

        if ( pid1 < 0 )
        {
            tprint() << "Failed to fork a worker 1 process, exiting" <<
                    std::endl;
            return 1;
        }

        if ( ( pid2 = fork() ) == 0 )    /* Worker process 2 */
        {
            pid1 = 0;
            if ( prctl( PR_SET_PDEATHSIG, PDEATHQUITSIGNAL ) == -1 )
            {
                tprint() << "Worker 2: failed to set parent death signal, "
                        "exiting" << std::endl;
                return 1;
            }

            tprint() << "(cycle " << i << ") Worker 2: " << getpid() <<
                    std::endl;

            char * const  cmd[] = { ( char * )"test_slapd",
                                    ( char * )"-d",
                                    ( char * )"0",
                                    ( char * )"-h",
                                    ( char * )"ldap://localhost:3333/",
                                    ( char * )"-f",
                                    ( char * )"nullslapd.conf",
                                    NULL };
            execve( "/usr/sbin/slapd", cmd, NULL );

            tprint() << "Failed to exec slapd process, exiting" << std::endl;
            return 1;
        }

        if ( pid2 < 0 )
        {
            tprint() << "Failed to fork a worker 2 process, exiting" <<
                    std::endl;
            return 1;
        }

        bool       respawn( false );
        siginfo_t  siginfo;

        if ( waitid( P_ALL, 0, &siginfo, WEXITED | WSTOPPED ) == -1 )
        {
            tprint() << "waitid() error '" << strerror( errno ) <<
                    "', exiting" << std::endl;
            return 1;
        }

        int        cpid( siginfo.si_pid == pid1 ? 1 :
                         ( siginfo.si_pid == pid2 ? 2 : -1 ) );

        if ( cpid == -1 )
        {
            tprint() << "Bad child pid " << siginfo.si_pid <<
                    ", exiting" << std::endl;
            return 1;
        }

        int *      ppid( cpid == 1 ? &pid1 : &pid2 );

        *ppid = 0;

        tprint() << "Worker " << cpid;

        if ( siginfo.si_code == CLD_KILLED || siginfo.si_code == CLD_DUMPED )
        {
            int  sig( siginfo.si_status );

            std::cout << " was signaled " << sig << std::endl;

            switch ( sig )
            {
            case SIGSEGV:
                respawn = true;
            default:
                break;
            }
        }
        else
        {
            std::cout << " exited with status " << siginfo.si_status <<
                    std::endl;
            respawn = true;
        }

        cpid = cpid == 1 ? 2 : 1;

        tprint() << "Sending quit signal to worker " << cpid << std::endl;

        ppid = cpid == 1 ? &pid1 : &pid2;
        kill( *ppid, PDEATHQUITSIGNAL );
        waitpid( *ppid, NULL, 0 );
        *ppid = 0;

        if ( ! respawn )
            break;
    }

    return 0;
}
В строках 26–28 объявлены глобальные переменные pid0, pid1 и pid2, которые в дальнейшем, в функции main(), будут инициализированы значениями PID мастер-процесса, воркера-бэкенда и воркера-фронтэнда соответственно. Их необходимо сделать глобальными, поскольку обработчик сигнала inth(), о котором речь пойдет ниже, нуждается в доступе к ним. Если вам не хочется засорять глобальное пространство имен, то поместите объявления этих переменных внутрь анонимного namespace — все же таки на C++ пишем! В строке 30 объявлена еще одна глобальная переменная start, которая будет инициализирована в функции main() текущим значением времени. Она объявлена глобальной, поскольку к ней требуется доступ из функции tprint(), расположенной в строках 33–41. Функция tprint() очень полезна, она выводит в выходной поток (предположительно std::cout или std::cerr) время, прошедшее с начала старта программы. В строках 44–63 определена функция inth()обработчик прерывания мастер-процесса. Эта функция посылает тот же сигнал прерывания sig, которым был прерван мастер-процесс обоим дочерним процессам. Но предварительно она проверяет, что вызвавший ее процесс является мастером, сравнивая вызов getpid() с pid0. Ниже вы увидите, что воркер-бэкенд устанавливает все сигналы, которые обрабатываются в inth() в значение по умолчанию SIG_DFL, а воркер-фронтэнд вызывает execve(), которая в конечном итоге делает то же самое. Спрашивается, зачем тогда нужна эта проверка? Как известно, новый процесс после вызова fork() наследует обработчики сигналов родителя, соответственно существует очень короткий промежуток времени между рождением процесса и установкой его собственных обработчиков сигналов, в течение которого, если этот новый процесс будет прерван, в нем будет вызван родительский обработчик inth(), а это очень плохо. Поэтому проверка на pid0 в обработчике inth() необходима. Собственно обработчик прерывания настраивается в функции setinth(), определенной в строках 66–76. Этот код оформлен в виде отдельной функции, поскольку нашему воркеру-бэкенду понадобится вернуть обработчики прерываний в исходные значения SIG_DFL. Переходим к функции main(). В строках 81–82 настраивается форматирование потока cout для вывода времени. В строке 84 инициализируется значение глобальной переменной start, которая будет использоваться для вычисления времени, прошедшего с начала старта программы, в функции tprint(). В строке 89 настраиваются обработчики прерывания мастер-процесса. В строках 93–109 внутри цикла for, перезапускающего воркер-процессы (см. оригинальную статью), находится код воркера-бэкенда. Его задача простая — установить сигнал смерти родителя с помощью вызова функции prctl(), восстановить обработчики прерывания по умолчанию с помощью вызова setinth(), вывести сообщение о своем старте и просто заснуть на время, определенное в секундах в макросе CHILDLIFETIME, который по умолчанию равен 4 и может быть задан во время компиляции. Волшебный вызов prctl() нужен для гарантированного завершения воркера в случае смерти мастера. Зачем, спросите вы. Ведь мы и так посылаем сигнал прерывания воркерам из обработчика inth(). Верно, но если мастер будет убит сигналом SIGKILL, этот обработчик вызван не будет, и воркеры перейдут процессу init. Данный вызов prctl() гарантирует посылку заданного сигнала, который настраивается нашим макросом PDEATHQUITSIGNAL, в случае смерти родителя, даже если тот был убит сигналом SIGKILL. В строках 111–116 — банальная проверка на правильную отработку fork(). Далее, в строках 118–143 идет код воркера-фронтэнда, который запускает экземпляр исполняемого файла /usr/sbin/slapd с помощью вызова execve(). Перед этим, как и в случае с воркером-бэкендом, устанавливается сигнал смерти родителя и выводится сообщение о старте. Переустановка обработчиков сигналов не требуется, поскольку вызов execve() устанавливает обработчики в значения по умолчанию. Функция execve() принимает список строк cmd, который будет передан как массив строк argv в функцию main() нового исполняемого кода. Если execve() будет выполнен успешно, то код, следующий за ним (строки 141–142), выполняться не будет, другими словами в этих строках находится код, отвечающий за обработку ошибки execve(). Итак, в массиве cmd находится список опций командной строки исполняемого файла /usr/sbin/slapd. Первый элемент — это имя процесса. Если бы мы запустили slapd из командной строки оболочки, оно бы соответствовало slapd, в нашем случае оно будет test_slapd. Остальные опции подобраны таким образом, чтобы slapd можно было запустить без отрыва от терминала (-d 0) обычному пользователю (-h ldap://localhost:3333/ -f nullslapd.conf). Пустой файл nullslapd.conf необходимо предварительно создать в текущей директории. Есть одна интересная тонкость. Если в опции cmd добавить -u <user>, то сигнал смерти родителя сбросится. То есть в случае посылки мастеру сигнала SIGKILL процесс test_slapd не завершится, а поменяет родителя на процесс init, а это не то, что мы ожидаем. Это связано с тем, что опция -u приводит к вызовам setgid() и setuid() внутри кода slapd, а это приводит к сбросу сигнала смерти родителя (см. man prctl). Единственный способ предотвратить это — пропатчить исходный код slapd. В строках 145–150 — банальная проверка на правильную обработку fork() для воркера-фронтэнда. Обратите внимание, что между двумя воркерами нет никакого взаимодействия: данный пример просто не рассчитан на такие подробности. Зато ниже идет код, который будет обрабатывать завершение одного из воркеров вследствие нормального выхода или прерывания сигналом. Главную работу выполняет функция waitid(), которая ожидает завершения любого из потомков мастер-процесса (строки 155–160). Переменная cpid инициализируется значением 1, если был завершен воркер-бэкенд (воркер 1), или 2, если был завершен воркер-фронтэнд (воркер 2). После определения завершившегося процесса соответствующей глобальной переменной pid1 или pid2 присваивается значение 0 для того, чтобы не возникло проблем в обработчике прерывания мастер-процесса inth(). В строках 178–197 идет обработка информации о завершившемся процессе подобная той, которая была в оригинальной статье. Только на этот раз мы присваиваем переменной respawn значение true и в том случае, если процесс завершился нормально. Кроме этого, макросы WIFSIGNALED и WTERMSIG не работают правильно с waitid(), поэтому вместо них производится прямая проверка полей si_code и si_status переменной siginfo. В строках 199–206 мы идентифицируем второй воркер, посылаем ему сигнал, установленный в макросе PDEATHQUITSIGNAL, ожидаем его завершения и присваиваем соответствующей глобальной переменной pid1 или pid2 значение 0. Понятно, зачем первый воркер-бэкенд просто завершает свою работу после заданного времени? Я хочу протестировать, что второй воркер-фронтэнд получит сигнал завершения и оба процесса будут перезапущены. Соберем программу test2 с числом перезапусков 4
g++ -g -DMAXCYCLES=4 -o test2 main2.cpp
, и запустим ее без прерываний.
./test2
  0.000 | Master: 28165
  0.000 | (cycle 0) Worker 2: 28167
  0.002 | (cycle 0) Worker 1: 28166
  4.002 | Worker 1 exited with status 0
  4.002 | Sending quit signal to worker 2
  4.007 | (cycle 1) Worker 1: 28221
  4.007 | (cycle 1) Worker 2: 28222
  8.007 | Worker 1 exited with status 0
  8.007 | Sending quit signal to worker 2
  8.011 | (cycle 2) Worker 1: 28276
  8.012 | (cycle 2) Worker 2: 28277
 12.012 | Worker 1 exited with status 0
 12.012 | Sending quit signal to worker 2
 12.018 | (cycle 3) Worker 1: 28331
 12.019 | (cycle 3) Worker 2: 28332
 16.019 | Worker 1 exited with status 0
 16.019 | Sending quit signal to worker 2
Все верно. Каждые четыре секунды воркер-бэкенд завершал работу, мастер посылал сигнал прерывания фронтэнду и перезапускал их обоих. Давайте на каком-либо этапе прервем мастер-процесс.
./test2
  0.000 | Master: 325
  0.000 | (cycle 0) Worker 1: 326
  0.001 | (cycle 0) Worker 2: 327
  4.001 | Worker 1 exited with status 0
  4.001 | Sending quit signal to worker 2
  4.008 | (cycle 1) Worker 1: 383
  4.009 | (cycle 1) Worker 2: 384
^C  5.465 | Master terminated by signal 2
  5.465 | Sending signal 2 to worker 1
  5.465 | Sending signal 2 to worker 2
Работает. А теперь давайте запустим test2, перейдем во второй терминал, узнаем PID воркера-бэкенда, и пошлем ему сигнал SIGSEGV. Только сначала пересоберем test2 с другим значением CHILDLIFETIME, а то я не буду успевать переключаться между терминалами.
g++ -g -DMAXCYCLES=4 -DCHILDLIFETIME=10 -o test2 main2.cpp
./test2
  0.000 | Master: 9834
  0.000 | (cycle 0) Worker 1: 9835
  0.001 | (cycle 0) Worker 2: 9836
 10.001 | Worker 1 exited with status 0
 10.001 | Sending quit signal to worker 2
 10.005 | (cycle 1) Worker 1: 9973
 10.005 | (cycle 1) Worker 2: 9974
Во втором терминале быстро, как только появилась запись о старте cycle 1, вводим
ps -ef | grep [t]est
lyokha    9834 27454  0 22:47 pts/4    00:00:00 ./test2
lyokha    9973  9834  0 22:48 pts/4    00:00:00 ./test2
lyokha    9974  9834  1 22:48 pts/4    00:00:00 test_slapd -d 0 -h ldap://localhost:3333/ -f nullslapd.conf
kill -SEGV 9973
Возвращаемся в первый терминал и смотрим остаток вывода test2.
 18.545 | Worker 1 was signaled 11
 18.545 | Sending quit signal to worker 2
 18.551 | (cycle 2) Worker 2: 10122
 18.554 | (cycle 2) Worker 1: 10121
 28.555 | Worker 1 exited with status 0
 28.555 | Sending quit signal to worker 2
 28.567 | (cycle 3) Worker 2: 10272
 28.570 | (cycle 3) Worker 1: 10271
 38.571 | Worker 1 exited with status 0
 38.571 | Sending quit signal to worker 2
Все верно. Ручные переключение во второй терминал, определение PID воркера-бэкенда и посылка ему сигнала заняли 8.5 секунд, так что четыре секунды мне бы явно не хватило. Можно еще поиграть разными способами. Например, послать сигнал прерывания или сигнал SIGKILL одному из воркеров, или убить мастер-процесс сигналом SIGKILL. В обоих случаях и мастер, и оба воркера должны благополучно завершиться.

пятница, 28 ноября 2014 г.

Перезапуск падучего приложения из самого приложения (спасение утопающих ...)

Представьте себе ситуацию, когда у вас есть некоторое серверное приложение, которое потенциально может падать (я не буду рассуждать о причинах: допустим, это новое, написанное вами приложение, которое вы хотите считать абсолютно надежным, но это не так). Предположим, что вам ни в коем случае нельзя допускать простоя службы, реализуемой этим приложением. Есть разные способы следить за работой программ и перезапускать их в случае надобности извне (например, cron). Но … Спасение утопающих — дело рук самих утопающих. Именно этот подход я и хочу продемонстрировать в самых общих чертах. Разумеется, вы должны обладать исходным кодом программы. В этом случае достаточно выделить исходный полезный код в отдельный дочерний процесс (worker), а исходный процесс превратить в надсмотрщика (master или supervisor), который будет перезапускать полезный дочерний процесс в случае его падения. Вот код, а пояснения ниже.
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
#include <iostream>

#ifdef MAXCYCLES
#define LOOPSTOPCOND i < MAXCYCLES
#else
#define LOOPSTOPCOND
#endif


int  main( int  argc, char **  argv )
{
    int  pid( 0 );

    std::cout << "Master: " << getpid() << std::endl;

    for ( int  i( 0 ); LOOPSTOPCOND; ++i )
    {
        if ( ( pid = fork() ) == 0 )    /* Worker process */
        {
            std::cout << "(cycle " << i << ") Worker: " << getpid() <<
                    std::endl;

            /* do segv if option -s was specified in command line */
            if ( argc > 1 && ! strcmp( argv[ 1 ], "-s" ) )
                int  a( *( ( int * )0 ) );

            break;
        }
        else                            /* Master process */
        {
            if ( pid < 0 )
            {
                std::cout << "Failed to fork a worker process, exiting" <<
                        std::endl;
                break;
            }

            int   status( 0 );
            bool  respawn( false );

            waitpid( pid, &status, 0 );

            if ( ! WIFSIGNALED( status ) )
                break;

            int  sig( WTERMSIG( status ) );

            std::cout << "Worker process was signaled " << sig <<  std::endl;

            switch ( sig )
            {
            case SIGSEGV:
                respawn = true;
            default:
                break;
            }

            if ( ! respawn )
                break;
        }
    }

    return 0;
}
Прежде всего нужно отметить, что код написан на C++, хотя ничто не мешает переписать его на C, если понадобится. Макросы MAXCYCLES и LOOPSTOPCOND нужны здесь только в демонстрационных целях: дабы ограничить число перезапусков воркеров величиной, заданной во время компиляции (MAXCYCLES). В релизной версии приложения цикл for скорее всего примет вид for ( ; ; ), так что эти макросы окажутся не нужны. Итак, цикл for нужен для перезапуска воркер-процессов в случае их падения. Внутри цикла for с помощью вызова fork() производится расщепление основного процесса, связанного с приложением, на две части — новый процесс (воркер) и старый (мастер). Как известно, fork() возвращает 0 в новом процессе, и какое-либо значение большее нуля — в старом. В новом процессе (блок после if ( ( pid = fork() ) == 0 )) мы выводим сообщение о старте воркера, вызываем его полезный код (в этом примере полезный код присутствует только в случае, когда в программу была передана опция командной строки -s: он приводит к посылке ядром сигнала SIGSEGV из-за разыменования нулевого указателя), и в конце обязательно ставим break;, поскольку воркер по-прежнему находится в цикле, и без его прерывания продолжит запускать новые воркеры, притворившись мастером! Внутри кода, относящегося к мастер-процессу (блок после else), прежде всего проверяется, что вызов функции fork() завершился успешно. Затем мастер ожидает завершения воркера с помощью вызова waitpid() и проверяет, каким образом тот завершился. Макрос WIFSIGNALED() позволяет установить, был ли воркер остановлен сигналом ядра или вышел самостоятельно. Во втором случае цикл for прерывается и мастер завершает свою работу. В случае, если воркер был остановлен сигналом, мы хотим узнать каким именно. Для этого предназначен макрос WTERMSIG(). Если это был сигнал SIGSEGV, то локальной переменной respawn присваивается истинное значение. Если ее значение остается ложным (когда воркер был прерван любым другим сигналом), то цикл завершается. Давайте посмотрим, как это работает (исходный файл я назвал main.cpp). Компилируем.
g++ -g -DMAXCYCLES=4 -o test main.cpp
Я ограничил число перезапусков четырьмя. Запускаем программу с нормальным завершением воркера.
./test 
Master: 6061
(cycle 0) Worker: 6062
Завершился воркер — завершился мастер. А теперь запустим программу с падучим воркером.
./test -s
Master: 6085
(cycle 0) Worker: 6086
Worker process was signaled 11
(cycle 1) Worker: 6152
Worker process was signaled 11
(cycle 2) Worker: 6154
Worker process was signaled 11
(cycle 3) Worker: 6156
Worker process was signaled 11
Мастер-процесс четыре раза перезапустил упавшие воркер-процессы, как и ожидалось. Я не стал касаться вопросов наследования ресурсов мастера при инициализации воркера и корректного завершения воркера при получении мастером сигнала прерывания — это отдельные интересные темы.

суббота, 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
, но эту неуклюжесть легко устранить введением вспомогательных функций.