Запуск дочернего процесса и чтение его вывода в linux

18.10.2020
Задача: программно запускать безголовый браузер, и получать из него данные. Все просто, за исключением некоторых мелочей. Сделать это все надо в linux.

С процессами под linux столкнулся впервые ...

Для тех кому интересны детали задачи:

  • безголовый браузер нужен потому что нужно выдергивать всю страницу целиком, даже в тех случаях когда ее содержимое составляется на javascript, к тому же была необходимость обходить блокировку со стороны cloudflare
  • в качестве безголового браузера был выбрать phantomjs, который в общем справляется со своей работой, но слегка устаревший + дебаг стремный, но пока нужен именно он (тянуть всякие chrome очень не хочется) (upd: все-таки перешел на nodejs + puppetter)
  • безголовый браузер может не отвечать несмотря на истекший таймаут, поэтому контроль времени работы должен быть на хост программе

Дочерний процесс

Можно воспользоваться простой функцией popen, но не смог найти как в этом случае узнать состояние процесса, а ждать окончания таймаута нерационально. Кстати интернеты подсказали что popen внутри использует fork.

Функция fork создает "разветвление программы" позволяя в текущей программе параллельно запустить дочерний процесс:

•••
cpp
int iPid = fork(); if(iPid == 0) { //код для дочернего процесса ... exit(0); } else { //код для родительского процесса, отслеживание состояния дочернего, обработка вывода и прочее }

Чтобы в дочернем процесс запустить внешнюю программу из командной оболочки можно воспользоваться функцией system.

Вывод процесса

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

•••
cpp
int aPipe[2]; pipe(aPipe);

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

•••
cpp
int aPipe[2]; pipe(aPipe); pid = fork(); if (pid == 0) { close(aPipe[0]); //закрытие дескриптор чтения dup2(aPipe[1], 1); //направялем вывод дочернего процесса в stdout ... exit(0); } else { close(aPipe[1]); //закрываем дескриптор записи //считываем из дескриптора чтения std::string sOut; char ch; while(read(aPipe[0], &ch, 1)) sOut += ch; close(aPipe[0]); //закрываем дескриптор чтения }

Кстати, посимвольное чтение из дескриптора чтения сделал из-за того что пачки символов считывались некорректно, в чем дело не разобрался.

Свой таймаут

Как таковой таймаут не нужен, он использован лишь потому что на данный момент мной не найдена нормальная альтернатива phantomjs, который имеет свойство в очень редких случаях тихо висеть. Однако здесь используется механизм состояния процесса, что интересно :)

В родительском процессе, для контроля таймаута необходимо использовать состояние процесса, потому что бессмысленно ждать весь таймаут (процесс может закончить работу раньше). Это может сделать функция waitpid c WNOHANG:

•••
cpp
int iStatus = 0; int iResWait = waitpid(iPid, &iStatus, WNOHANG); if(iResWait == iPid && WIFEXITED(iStatus)) { //процесс завершился }

Итоговая функция

•••
cpp
bool RunHeadlessBrowser(const char *sURL, uint32_t uiTimeout, std::string &sOut) { const uint32_t uiMinTimeOut = 5; if(uiTimeout < uiMinTimeOut) uiTimeout = uiMinTimeOut; std::string sCmd = "phantomjs --ssl-protocol=any --ignore-ssl-errors=true get-page.js "; sCmd += sURL; int iPid, iStatus, iResWait; int aPipe[2]; pipe(aPipe); iPid = fork(); if (iPid == 0) { close(aPipe[0]); //закрытие дескриптора чтения dup2(aPipe[1], 1); //направялем вывод дочернего процесса в stdout system(sCmd.c_str()); exit(0); } else { uint32_t uiPastTime=0; while (true) { ++uiPastTime; iResWait = waitpid(iPid, &iStatus, WNOHANG); if(iResWait == iPid && WIFEXITED(iStatus)) break; if(uiTimeout >= uiPastTime) { kill(iPid, 0); break; } sleep(1); } close(aPipe[1]); //закрываем дескриптор записи //считываем из дескриптора чтения char ch; while(read(aPipe[0], &ch, 1)) sOut += ch; close(aPipe[0]); //закрываем дескриптор чтения return true; } }

Вариант проще

После публикации поступил комментарий:

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

Здесь сказано что такая ситуация невозможна, так как дочерний процесс станет "зомби" и ядро все еще будет хранить о нем минимальную информацию пока не будет вызывана wait функция:

A child that terminates, but has not been waited for becomes a "zombie". The kernel maintains a minimal set of information about the zombie process (PID, termination status, resource usage information) in order to allow the parent to later perform a wait to obtain information about the child.

Однако, комментатор подсказал что можно в команде терминала установить таймаут выполнения процесса. Разберем этот момент :)

В начале команды для терминала введем (установка таймаута на 5 секунд): timeout 5s

Из описания timeout видно что по истечению таймаута процесс будет завершен с кодом 124, однако system вернет 1, поэтому дочерний процесс должен учесть этот момент:

•••
cpp
int iRet = system(sCmd.c_str()); exit((iRet == 0 ? 0 : 1));

А родительский процесс должен отреагировать на ошибочное завершение дочернего процесса (в данном случае все просто: если дочерний процесс завершился с ошибкой то вернуть false):

•••
cpp
iResWait = waitpid(iPid, &iStatus, 0); if(WIFEXITED(iStatus) != 0 && WEXITSTATUS(iStatus) != 0) return false;
Теперь можно выкинуть самописный таумаут :)

Но так как теперь нам в общем-то не надо контролировать время выполнения процесса, то все можно уложить в такую простую функцию:

•••
cpp
bool RunHeadlessBrowser(const char *sURL, uint32_t uiTimeout, std::string &sOut) { const uint32_t uiMinTimeOut = 5; if(uiTimeout < uiMinTimeOut) uiTimeout = uiMinTimeOut; std::string sCmd = "/crawler/phantomjs --ssl-protocol=any --ignore-ssl-errors=true /crawler/get-page.js "; std::stringstream ssCmd; ssCmd << "timeout " << uiTimeout << "s " << "/crawler/phantomjs --ssl-protocol=any --ignore-ssl-errors=true /crawler/get-page.js " << sURL; sCmd = ssCmd.str(); FILE *pP = popen(sCmd.c_str(), "r"); char ch; while(fread(&ch, 1, 1, pP)) sOut += ch; return (pclose(pP) == 0); }

Стоило изначально так сделать, но по ходу выяснения был приобретен бесценный опыт :)