2016년 11월 8일 화요일

fork된 child process에서 exit 문제

규모가 큰 프로젝트를 진행하다 보니 때로 예상하지 못한 SW 문제들로 고전하는 경우가 있다. 얼마전 저자가 담당하고 있는 라이브러리를 사용하고 있는 모듈(블록) 담당자로 부터 라이브러리 수정에 의한 SW 프로세스가 비정상 종료되는 문제를 전달 받았다.

언제나 그렇듯이 최종 수정측의 연관 담당자가 발생한 문제의 초도 분석을 진행하게 되므로 본 문제도 담당 모듈 담당자가 아닌 우리쪽 담당자에 의해서 최초 분석이 진행되었다. 로그 분석에 의하면 비정상 종료하는 프로세스는 fork된 자식 프로세스로 확인되었고, uct 파일과 map 파일 분석에 의해 프로세스 종료 시점의 call stack 정보도 확인할 수 있었다.문제의 원인을 간단히 요약해 보면 fork된 프로세스에서 전역 변수로 선언된 자원(mutex)에 접근하는 시점에 비정상 종료되었고 종료된 프로세스 코드에서 exit() 함수가 호출된 이후였다.

일반적으로 fork는 스레드 개념이 없던 시절에 만들어진 기능이기 때문에 thread-safe 하지 않다고 알려져 있다. 따라서 멀티스레드 환경에서 사용하려면 여러가지 고려사항을 체크할 필요가 있다. fork 함수에 대한 다음 표준 문서를 참조해 보자.
* 참조링크 http://pubs.opengroup.org/onlinepubs/000095399/functions/fork.html

요약해 보면 아래와 같다.

1) 멀티스레드 프로세스를 fork할 경우 fork를 호출한 스레드만을 포함하는 프로세스가 생성된다. 다른 스레드들은 생성되지 않는다.
2) 부모 프로세스의 생성되지 않은 스레드들이 할당한(allocation) 힙(heap)도 그대로 복사된다.
3) 부모 프로세스의 다른 스레드가 설정한 잠금(mutexes)이 그대로 복사된다.
4) 멀티스레드 환경에서 fork 수행시 파생되는 에러를 회피하기 위해 종료되거나 exec() 함수를 호출하기 전, 자식 프로세스는 async-signal-safe 함수를 호출해야 할 수 있다.

발췌문에 소개된 Async-signal-safe functions에 주목한다. _Exit() 와 _exit() 함수가 포함되어 있다. 정리해 보면 멀티스레드 환경에서 복제된 프로세스 종료시 exit() 함수 대신 Async-signal-safe 함수인 _Exit() 혹은 _exit() 함수를 호출해야 파생되는 에러를 방지할 수 있다는 의미이다.
* 참조링크 http://man7.org/linux/man-pages/man7/signal.7.html

같은 맥락에서 아래 unix programming guide 문서를 참조한다. 마찬가지로 복제된 프로세스에서 exit()가 아닌 _exit()를 호출하는 것이 왜 안전한 것인지 설명하고 있다. 특히, C++ 코드에서 전역 객체에 대한 소멸자 호출 문제를 언급하고 있음에 주목한다.
* 참조링크 http://www.unixguide.net/unix/programming/1.1.3.shtml

아래 그림을 통해 exit()와 Async-signal-safe 함수인 _exit()의 차이를 확인해 보자.
exit()는 _exit()와 달리 등록된 exit handler들을 호출하고, stdio buffer를 정리하는 것을 알 수 있다.

저자가 기술하고 있는 문제는 전역 객체의 소멸자가 호출하는 동작에 연관된 문제이다. 라이브러리 코드는 전역 변수로 글로벌 뮤텍스를 제공하였는데, 자식 프로세스가 종료(exit() 호출)되는 시점에 해당 자원을 해제 하기 위한 소멸자가 호출되면서 비정상 종료가 발생하였다.
C++에서 exit() 호출시 일어나는 동작을 정리하면 아래와 같다.
1. 전역 객체의 소멸자 호출(destructors of objects with static storage duration) 및 std::atexit 로 넘겨진 exit handler 호출
2. 입출력 스트림 비우기,flush and close
3. std::tmpfile 로 생성된 임시 파일 삭제
* 참조링크 http://en.cppreference.com/w/cpp/utility/program/exit
저자의 경우 문제를 요약해 보면 fork하여 프로세스가 복제되었을 때 이미 부모 프로세스에 의해서 글로벌 뮤텍스가 생성되어 있는 상태였고, 복제된 자식 프로세스에는 해당 뮤텍스에 대한 권한이 없으므로 접근시 문제를 일으키는 것이다(프로세스간 메모리가 공유되지 않으므로 복제된 프로세스에서는 해당 뮤텍스에 대한 현재 상태를 알 수 없음).
이로인해 파생되는 문제 현상으로 데드락(deadlock) 또는 본 경우와 같은 비정상 종료의 형태로 나타나는 것으로 확인된다.
* 데드락 케이스 : https://cppwisdom.quora.com/Why-threads-and-fork-dont-mix
* 비정상 종료 발생 케이스 : http://boost-users.boost.narkive.com/20fu9rBa/boost-recursive-mutex-destructor-call-in-child-forked-process

다행히 복제된 프로세스에서 글로벌 뮤텍스에 대한 접근 동작이 전역 객체의 소멸자 호출에 한정되어 있어 다음 두 가지 해결책을 도출할 수 있었다.

- 전역 객체(싱글톤 객체였다)의 생성을 힙영역에 수행하고 소멸자에서 해당 메모리 해제 동작을 수행하지 않도록 하여 글로벌 뮤텍스 접근이 없도록 함(메모리는 프로세스 종료시 OS에 의해 회수됨).
또는
- exit() 대신 _exit(), Async-signal-safe 함수, 호출로 복제된 프로세스에서 전역 객체에 대한 소멸자 호출이 발생하지 않도록 함.

댓글 없음:

댓글 쓰기