1. 이 글의 주제

 

Java의 Process 클래스로 실행한 서브프로세스가 hang 상태에 빠졌던 현상을 해결했던 경험을 나눕니다.

 

 

2. 결론 (TL;DR)

 

  2-1) 문제 현상 발생 과정

  • 서브프로세스가 순서대로 작업을 처리하면서 표준 출력의 버퍼에 쌓은 데이터가 처리되지 않아서 버퍼가 가득 찼습니다.
  • 이 상태에서 서브프로세스가 추가로 출력을 시도하니 버퍼에 빈 공간이 생길 때까지 기다리는 상태가 되었고, 해당 시점부터 프로그램이 사실상 실행을 멈추게 되었습니다.
  • 이러한 문제는 서브프로세스를 Java의 'Process' 클래스로 생성 및 실행했을 때만 발생하였고, 부모 프로세스가 셸스크립트이거나 C로 작성한 프로그램일 때에는 발생하지 않았습니다.

  2-2) 해결 방법

  • 서브프로세스의 표준 출력을 리디렉션 시켜서 버퍼에 내용이 쌓이지 않도록 하였습니다.
  • ProcessBuilder의 redirectOutput 메서드를 사용할 수 있습니다.
  • 또는, 서브프로세스의 표준출력(즉, 부모 프로세스 입장에서는 입력 스트림)을 받아다가 읽어서 처리해도 됩니다.

 

3. 배경

 

  3-1). Java 프로그램 (부모 프로세스)

  • 'subproc'이라는 프로세스를 리스트 형식의 데이터를 파라미터로 주면서 실행하는 프로그램입니다.
  • 프로세스 생성에는 Java API 'ProcessBuilder'를 사용합니다.
  • 코드는 문제 현상의 배경 설명을 위해 단순하게 재구성되었습니다.
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;

public class ParentProc {

    public static void main(String[] args) throws IOException, InterruptedException
    {
        ProcessBuilder processBuilder = new ProcessBuilder("./subproc", "'1,2,3,4,5,6,7,8,9,10'");

        Process process = processBuilder.start();

        int exitCode = process.waitFor();
        System.out.println("Subprocess exited with code: " + exitCode);
    }
}

 

 

3-2) C 프로그램 (서브프로세스)

  • 리스트 형식의 데이터를 파라미터로 받아서 각 요소에 대한 통신 작업을 처리하는 프로그램입니다.
  • 코드는 문제 현상의 배경 설명을 위해 단순하게 재구성되었습니다.
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    char *data = argv[1];
    char data_copy[512] = { 0, };
    char buffer[1024 * 10 + 1] = { 0, };
    const char *DELIMITER = ",";
    char *token = NULL;

    snprintf(data_copy, 1024 * 10, "%s", data);
    memset(buffer, 'a', 1024 * 10);

    token = strtok(data, DELIMITER);
    while (token != NULL)
    {
        printf("  %s\n", token);
        token = strtok(NULL, DELIMITER);
        printf("%s", buffer); // 각 요소에 대해 작업을 처리하면서 printf 로그를 출력하는 부분
        sleep(5);
    }

    return 0;
}

 

 

4. 발생한 문제 현상

  • 서브프로세스가 인자로 받은 리스트 데이터의 일부에 대해서만 작업을 처리하고 나서, 특정 시점부터 작업을 더 이상 진행하지 못하고 멈추는 현상이 발견되었습니다.
  • 예를 들어 1번부터 10번 사용자에 대한 작업을 순서대로 처리해야 하는데, 4번 사용자까지만 처리되고 이후 사용자에 대한 데이터는 전혀 처리하지 못 하는 현상이 발생한 것입니다.
  • 강제로 종료시키거나(kill), 실행 시 타임아웃을 설정하지 않는 이상 프로세스가 종료되지 않는 hang 상태에 빠졌습니다.

 

5. 문제 해결 과정

 

  5-1) 해결 단서: 프로그램 콜스택

  • gdb(GNU Debugger)로 서브프로세스의 콜스택을 조회해 보니, printf 함수 호출에서 멈추어 있는 것이 확인됩니다.
  • 참고로, 아래 gdb 로그는 현상 재연을 위해 Ubuntu 컨테이너에서 실행한 결과입니다.

서브프로세스의 PID 조회

 

gdb로 현재 실행 중인 서브프로세스에 attach 하여 현재 콜스택을 확인 (참고: gdb -p {PID} 로 특정 프로세스에 attach 할 수 있음)

 

프로그램이 printf 에서 멈출만한 이유를 검색해 보니, '출력 스트림의 버퍼가 꽉 차지는 않았는지' 확인해 보라는 조언을 찾을 수 있었습니다. '출력 스트림도 Linux 환경에서 파일처럼 접근할 수 있을 것 같다'는 추정을 바탕으로, 스트림의 버퍼에 존재하는 데이터의 양(바이트 개수)을 조회하는 프로그램을 작성하였습니다.

 

 

  5-2) 디버깅 프로그램

  • PID, FD(file descriptor)를 파라미터로 받아서, 해당 파일의 입력 버퍼에 있는 데이터 길이(바이트 개수)를 출력하는 프로그램입니다.
  • 입력 버퍼에 쌓인 데이터 크기를 조회하기 위해서 리눅스 ioctl 시스템 함수와 FIONREAD 옵션을 사용합니다.
  • ioctl은 디바이스 입출력 제어 및 정보 취득을 위해 사용되는 시스템 함수입니다. 
  • 표준출력의 file descriptor 값은 1이므로, 다음과 같이 실행합니다. -> ./debugproc {서브프로세스 PID} 1
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>

int main(int argc, char *argv[])
{
    pid_t ps_pid = atoi(argv[1]);
    int ps_fd = atoi(argv[2]);
    int fd;
    int readable_bytes = -1;
    int pipe_size = 0;

    char proc_fd_path[64];
    snprintf(proc_fd_path, sizeof(proc_fd_path), "/proc/%d/fd/%d", ps_pid, ps_fd);

    fd = open(proc_fd_path, O_RDONLY | O_NONBLOCK);
    if (fd < 0)
    {
        perror("open proc fd failed");
        return 1;
    }

    if (ioctl(fd, FIONREAD, &readable_bytes) < 0)
    {
        perror("ioctl FIONREAD failed");
    }
    else
    {
        printf("Bytes available to read: %d\n", readable_bytes);
    }

    return 0;
}

 

 

디버깅 프로그램으로 서브프로세스의 버퍼 상태를 확인해 보니, 할당된 크기인 65536 바이트를 모두 채운 뒤 그 값이 줄어들지 않는 것을 확인할 수 있었습니다. 시간이 아무리 많이 지나도 수동으로 종료하지 않는 이상 동일한 상태를 유지하였습니다.

 

 

 

6. 해결책

 

문제 현상은 Java 프로그램이 ProcessBuilder로 서브프로세스의 출력을 제대로 처리하지 않아서 발생한 것이었으며, 이에 따라서 취한 해결책은 매우 단순했습니다: 서브프로세스의 stdout을 리디렉션 시켜 출력이 폐기되도록 하면 끝이었어요.

 

실제 문제가 발생한 서브프로그램은 Java 프로그램이 셸스크립트를 실행 -> 셸스크립트가 특정 프로그램을 실행하는 구조였기 때문에, 셸스크립트에서 프로그램을 실행하는 명령어에 stdout, stderr를 모두 /dev/null 로 리디렉션 처리하였습니다.

 

Java 프로그램 수준에서의 리디렉션 처리는 저도 이 글을 작성하면서 알게 되었는데, 다음과 같은 방법으로 가능합니다.

public static void main(String[] args) throws IOException, InterruptedException
{
    ProcessBuilder processBuilder = new ProcessBuilder("./subproc", "'1,2,3,4,5,6,7,8,9,10'");

    processBuilder.redirectOutput(ProcessBuilder.Redirect.INHERIT);
    Process process = processBuilder.start();

    int exitCode = process.waitFor();
    System.out.println("Subprocess exited with code: " + exitCode);
}
  • 서브프로세스의 출력(부모프로세스 입장에서는 InputStream)을 받아다가 Java 프로그램 단에서 읽어서 처리하는 방법
public static void main(String[] args) throws IOException, InterruptedException
{
    ProcessBuilder processBuilder = new ProcessBuilder("./subproc", "'1,2,3,4,5,6,7,8,9,10'");
    Process process = processBuilder.start();

    BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
    String line;
    while ((line = reader.readLine()) != null)
    {
        System.out.println(line);
    }

    int exitCode = process.waitFor();
    System.out.println("Subprocess exited with code: " + exitCode);
}

 

 

7. 시사점

 

필요에 따라 Java 프로그램에서 프로그래머의 통제 범위 밖에 있는 프로그램이나 스크립트를 실행해야 하는 경우가 있습니다. 해당 프로그램이 어떤 식으로 출력을 처리하고 있는지가 호출자에게 알려지지 않는 경우가 있는 것입니다.

 

그런데 Java의 ProcessBuilder는 서브프로세스의 출력/에러 스트림에 대한 기본(디폴트) 처리 옵션을 따로 지정하지 않는 것으로 보입니다. 참고로 C로 작성한 프로그램이나 셸스크립트로 subproc을 호출할 경우, 별도의 설정 없이도 서브프로세스의 출력이 부모프로세스로 전달되는 것을 확인할 수 있었습니다.

 

Java ProcessBuilder 사용 시 이러한 특징을 고려하여, 외부 프로그램을 호출할 때에는 그 프로그램의 출력 스트림, 에러 스트림을 프로그래머가 직접 주의하여 처리할 필요가 있을 것 같습니다.

 


 

제 글에는 언제나 오류가 있을 수 있습니다.

오류나 문의 사항은 댓글로 부탁 드립니다.

 

+ Recent posts