fork() 에뮬레이션
fork() 에뮬레이션 (perlfork)
이 문서는 Perl의 fork() 에뮬레이션을 다뤄요. 진짜 fork()가 없는 시스템(예: Windows)에서 Perl이 fork()를 어떻게 흉내 내는지, 그리고 그 과정에서 어떤 제약이 생기는지 옆에서 설명해 드려요.
Perl은 Unix 시스템 호출과 같은 이름의 fork() 키워드를 제공해요. fork() 시스템 호출이 가능한 대부분의 Unix 계열 플랫폼에서는 Perl의 fork()가 그냥 그 시스템 호출을 불러요.
반면 Windows처럼 fork() 시스템 호출이 없는 일부 플랫폼에서는 Perl을 빌드할 때 인터프리터 수준에서 fork()를 에뮬레이션할 수 있어요. 이 에뮬레이션은 Perl 프로그램 수준에서 진짜 fork()와 최대한 호환되도록 설계됐지만, 운영체제 입장에서 보면 이렇게 만들어진 pseudo child "프로세스"들이 모두 같은 실제 프로세스 안에 살기 때문에 생기는 중요한 차이가 몇 가지 있어요.
본문
SYNOPSIS
NOTE: As of the 5.8.0 release, fork() emulation has considerably
matured. However, there are still a few known bugs and differences
from real fork() that might affect you. See the "BUGS" and
"CAVEATS AND LIMITATIONS" sections below.
Perl은 Unix 시스템 호출과 같은 이름의 fork() 키워드를 제공해요. fork() 시스템 호출이 가능한 대부분의 Unix 계열 플랫폼에서는 Perl의 fork()가 그저 그걸 호출해요.
Windows처럼 fork() 시스템 호출이 없는 일부 플랫폼에서는 Perl을 인터프리터 수준에서 fork()를 에뮬레이트하도록 빌드할 수 있어요. 에뮬레이션은 Perl 프로그램 수준에서 진짜 fork()와 최대한 호환되도록 설계됐지만, 운영체제 입장에서 이렇게 만들어진 모든 pseudo child "프로세스"가 같은 실제 프로세스 안에 살기 때문에 생기는 중요한 차이가 있어요.
이 문서는 fork() 에뮬레이션의 기능과 한계에 대한 일반적인 개요를 제공해요. 여기서 다루는 문제들은 진짜 fork()가 있고 Perl이 그것을 쓰도록 설정된 플랫폼에는 적용되지 않는다는 점을 기억하세요.
DESCRIPTION
fork() 에뮬레이션은 Perl 인터프리터 수준에서 구현돼요. 일반적으로 말하면 fork()를 실행하면 실제로 실행 중인 인터프리터와 그 모든 상태를 복제하고, 복제된 인터프리터를 별도의 스레드에서 실행해요. 새 스레드는 부모에서 fork()를 호출한 지점 바로 다음부터 실행을 시작하죠. 이 자식 "프로세스"를 구현하는 스레드를 pseudo-process라고 부를게요.
fork()를 호출한 Perl 프로그램 관점에서는 이 모든 것이 투명하게 설계돼 있어요. 부모는 이후에 어떤 프로세스 조작 함수에서도 쓸 수 있는 pseudo-process ID를 들고 fork()에서 돌아오고, 자식은 자신이 자식 pseudo-process임을 알리기 위해 0이라는 값을 들고 fork()에서 돌아와요.
fork된 pseudo-process에서 다른 Perl 기능의 동작
대부분의 Perl 기능은 pseudo-process 안에서 자연스럽게 동작해요.
$$ 또는 $PROCESS_ID
이 특수 변수는 pseudo-process ID로 올바르게 설정돼요. 특정 세션 안에서 pseudo-process를 식별하는 데 쓸 수 있어요. 이 값은 어떤 pseudo-process들이 다른 것들보다 먼저 wait()-되었으면 재활용될 수 있다는 점을 유의하세요.
%ENV
각 pseudo-process는 자신의 가상 환경을 유지해요. %ENV의 수정은 가상 환경에 영향을 주고, 해당 pseudo-process 안에서, 그리고 그 pseudo-process에서 시작된 프로세스(또는 pseudo-process)들 안에서만 보여요.
chdir()와 파일 이름을 받는 다른 모든 내장 함수
각 pseudo-process는 현재 디렉터리에 대한 자신의 가상 관념을 유지해요. chdir()로 현재 디렉터리를 바꾸는 것은 그 pseudo-process 안에서, 그리고 그것에서 시작된 프로세스(또는 pseudo-process)들 안에서만 보여요. pseudo-process의 모든 파일·디렉터리 접근은 가상 작업 디렉터리를 실제 작업 디렉터리로 적절히 매핑해요.
wait()와 waitpid()
wait()와 waitpid()에 fork()가 반환한 pseudo-process ID를 넘길 수 있어요. 이 호출들은 pseudo-process의 종료를 제대로 기다리고 그 상태를 반환해요.
kill()
kill('KILL', ...)로 fork()가 반환한 ID를 넘겨 pseudo-process를 종료할 수 있어요. pseudo-process에 대한 kill의 결과는 예측 불가능하고, 실행 중인 스레드가 종료될 때 운영체제가 프로세스 리소스의 무결성을 보장하지 않을 수 있으므로 극한 상황이 아니면 쓰지 말아야 해요. pseudo-process를 구현하는 프로세스가 차단돼 Perl 인터프리터가 멈출 수 있어요. pseudo-process에 kill('KILL', ...)을 쓰면 대개 메모리 누수를 일으킬 수 있다는 점도 주의하세요. pseudo-process를 구현하는 스레드가 자원을 정리할 기회를 얻지 못하기 때문이에요.
kill('TERM', ...)도 pseudo-process에 쓸 수 있지만, pseudo-process가 시스템 호출에 차단돼 있을 때(예: 소켓 연결을 기다리거나, 데이터가 없는 소켓을 읽으려 할 때) 시그널은 전달되지 않아요. Perl 5.14부터 부모 프로세스는 kill('TERM', ...)으로 시그널된 자식들이 프로세스 종료 시 데드락에 빠지지 않도록 그들의 종료를 (자동으로) 기다리지 않아요. 자식이 스스로 정리할 시간을 확보하려면 waitpid()를 명시적으로 호출해야 하는데, 그러면 자식이 I/O에서도 차단되지 않도록 해야 할 책임도 있죠.
exec()
pseudo-process 안에서 exec()를 호출하면 실제로 요청한 실행 파일을 별도 프로세스로 띄우고, 그 프로세스가 끝날 때까지 기다린 뒤 같은 종료 상태로 종료돼요. 이는 실행 중인 실행 파일 안에서 보고되는 프로세스 ID가 이전의 Perl fork()가 반환했을 값과 다를 수 있음을 뜻해요. 마찬가지로 fork()가 반환한 ID에 적용하는 어떤 프로세스 조작 함수든 exec()를 호출한 대기 중인 pseudo-process에 영향을 주지, exec() 뒤에 그 pseudo-process가 기다리는 실제 프로세스에는 영향을 주지 않아요.
pseudo-process 안에서 exec()가 호출되면 외부 프로세스가 반환된 후에도 DESTROY 메서드와 END 블록은 여전히 호출돼요.
exit()
exit()는 항상 실행 중인 pseudo-process만 종료하고, 그 전에 미결인 자식 pseudo-process들을 자동으로 wait()해요. 이는 전체 프로세스가 모든 실행 중인 pseudo-process가 종료돼야 종료된다는 뜻이에요. 열린 파일핸들과 관련된 몇 가지 한계는 아래를 보세요.
파일·디렉터리·네트워크 소켓에 열린 핸들
pseudo-process에서는 열린 모든 핸들이 dup()-되기 때문에, 한 프로세스에서 어떤 핸들을 닫아도 다른 프로세스에는 영향을 주지 않아요. 몇 가지 한계는 아래를 보세요.
자원 한계 (Resource limits)
운영체제의 눈에는 fork() 에뮬레이션으로 만든 pseudo-process들이 같은 프로세스의 단순한 스레드일 뿐이에요. 즉 운영체제가 부과하는 프로세스 수준 한계가 모든 pseudo-process에 합쳐져 적용돼요. 여기에는 열린 파일·디렉터리·소켓 핸들 수에 대한 한계, 디스크 공간 사용 한계, 메모리 크기 한계, CPU 사용률 한계 등이 포함돼요.
부모 프로세스 죽이기
부모 프로세스가 죽으면(Perl의 kill() 내장 함수로든 외부 수단으로든) 모든 pseudo-process도 함께 죽고 전체 프로세스가 종료돼요.
부모 프로세스와 pseudo-process의 수명
정상적인 흐름에서 부모 프로세스와 그것이 시작한 모든 pseudo-process는 각자의 pseudo-자식들이 끝나기를 기다린 뒤에 종료돼요. 즉 부모와, 그것이 만든 pseudo-자식 중 다시 pseudo-부모인 것들은 자기 pseudo-자식들이 종료된 뒤에야 종료돼요.
Perl 5.14부터 부모는 kill('TERM', ...)으로 시그널된 자식에 대해서는 자동으로 wait()하지 않아요. 자식이 I/O에서 차단돼 시그널을 결코 받지 못하는 데드락을 피하기 위해서예요.
CAVEATS AND LIMITATIONS
BEGIN 블록
BEGIN 블록 안에서 호출된 fork() 에뮬레이션은 완전히 올바르게 동작하지 않을 수 있어요. 포크된 복사본은 BEGIN 블록의 내용을 실행하지만, BEGIN 블록 이후의 소스 스트림 파싱을 계속하지는 않아요. 예를 들어 다음 코드를 보세요:
BEGIN {
fork and exit; # fork child and exit the parent
print "inner\n";
}
print "outer\n";
이 코드는 기대한:
inner
outer
대신 다음만 출력해요:
inner
이 한계는 파싱 도중 Perl 파서가 쓰는 스택을 복제·재시작하는 데서 오는 근본적인 기술적 어려움 때문에 생겨요.
열린 파일핸들
fork() 시점에 열려 있던 파일핸들은 모두 dup()-돼요. 따라서 부모와 자식에서 파일을 독립적으로 닫을 수 있지만, dup()-된 핸들이 여전히 같은 seek 포인터를 공유한다는 점을 주의하세요. 부모에서 seek 위치를 바꾸면 자식에서도 바뀌고 그 반대도 마찬가지예요. 서로 다른 seek 포인터가 필요한 파일은 자식에서 별도로 여는 것으로 피할 수 있어요.
일부 운영체제, 특히 Solaris와 Unixware에서는 자식 프로세스가 exit()를 호출하면 부모의 열린 파일핸들을 flush하고 닫아서 파일핸들을 손상시켜요. 이런 시스템에서는 대신 _exit() 호출을 권해요. _exit()는 Perl에서 POSIX 모듈로 사용할 수 있어요. 자세한 내용은 시스템 man 페이지를 참고하세요.
열린 디렉터리 핸들
Perl은 열린 디렉터리 핸들을 모두 스트림 끝까지 완전히 읽어요. 그다음 seekdir()로 원래 위치로 돌아가고, 이후의 모든 readdir() 요청은 캐시 버퍼에서 처리돼요. 즉 부모 프로세스가 가진 디렉터리 핸들이나 자식 프로세스가 가진 핸들 모두 fork() 호출 이후 디렉터리에 생긴 변경을 보지 못해요.
rewinddir()도 Windows에서 비슷한 한계가 있어서 readdir()이 디렉터리를 다시 읽도록 강제하지 않아요. 새로 연 디렉터리 핸들만 디렉터리의 변경을 반영해요.
파이프 여는 fork() open()은 아직 미구현
open(FOO, "|-")와 open(BAR, "-|") 구조는 아직 구현되지 않았어요. 이 한계는 새 코드에서 파이프를 명시적으로 만들어 쉽게 우회할 수 있어요. 다음 예시는 fork한 자식에게 쓰는 방법을 보여줘요:
# simulate open(FOO, "|-")
sub pipe_to_fork ($) {
my $parent = shift;
pipe my $child, $parent or die;
my $pid = fork();
die "fork() failed: $!" unless defined $pid;
if ($pid) {
close $child;
}
else {
close $parent;
open(STDIN, "<&=" . fileno($child)) or die;
}
$pid;
}
if (pipe_to_fork('FOO')) {
# parent
print FOO "pipe_to_fork\n";
close FOO;
}
else {
# child
while (<STDIN>) { print; }
exit(0);
}
이건 자식에게서 읽는 예시예요:
# simulate open(FOO, "-|")
sub pipe_from_fork ($) {
my $parent = shift;
pipe $parent, my $child or die;
my $pid = fork();
die "fork() failed: $!" unless defined $pid;
if ($pid) {
close $child;
}
else {
close $parent;
open(STDOUT, ">&=" . fileno($child)) or die;
}
$pid;
}
if (pipe_from_fork('BAR')) {
# parent
while (<BAR>) { print; }
close BAR;
}
else {
# child
print "pipe_from_fork\n";
exit(0);
}
파이프를 여는 fork() open() 구조는 향후 지원될 거예요.
XSUB이 유지하는 전역 상태
자신의 전역 상태를 유지하는 외부 서브루틴(XSUB)은 올바르게 동작하지 않을 수 있어요. 그런 XSUB은 서로 다른 pseudo-process의 전역 데이터 동시 접근을 보호하는 잠금을 유지하거나, fork() 호출 시 자연스럽게 복사되는 Perl 심볼 테이블에 모든 상태를 유지해야 해요. 확장이 상태를 복제할 기회를 제공하는 콜백 메커니즘이 가까운 시일 내에 제공될 거예요.
더 큰 애플리케이션에 내장된 인터프리터
Perl 인터프리터를 내장하고 Perl 코드 조각을 평가할 수 있는 Perl API를 호출하는 애플리케이션에서 실행될 때 fork() 에뮬레이션은 기대대로 동작하지 않을 수 있어요. 에뮬레이션이 Perl 인터프리터 자신의 데이터 구조만 알고 있고 포함하는 애플리케이션의 상태는 전혀 모르기 때문이에요. 예를 들어 애플리케이션 자체 호출 스택에 담긴 상태는 손이 닿지 않아요.
확장의 스레드 안전성
fork() 에뮬레이션은 코드를 여러 스레드에서 실행하므로, 스레드 안전하지 않은 라이브러리를 호출하는 확장은 fork()를 호출할 때 안정적으로 동작하지 않을 수 있어요. Perl의 스레딩 지원이 네이티브 fork()가 있는 플랫폼에서도 점차 널리 채택됨에 따라 그런 확장들은 스레드 안전을 위해 고쳐질 것으로 기대돼요.
PORTABILITY CAVEATS
이식 가능한 Perl 코드에서는 fork한 프로세스에 kill(9, $child)를 쓰면 안 돼요. fork한 프로세스 죽이기는 안전하지 않고 예측 불가능한 결과를 낳아요. 위의 "kill()"을 보세요.
BUGS
pseudo-process ID가 음수 정수인 것이 정수 -1에서는 무너져요. wait()와 waitpid() 함수가 이 숫자를 특별하게 취급하기 때문이에요. 현재 구현의 암묵적 가정은 시스템이 사용자 스레드에 대해 스레드 ID 1을 절대 할당하지 않는다는 것이에요. pseudo-process ID의 더 나은 표현이 향후 구현될 거예요.
특정 경우에 pipe(), socket(), accept() 연산자가 만든 OS 수준 핸들이 pseudo-process에서 정확히 중복되지 않는 것 같아요. 이것은 어떤 상황에서만 발생하지만, 발생하면 파이프 핸들의 읽기·쓰기 끝 사이 데드락이나 소켓 핸들을 통한 데이터 송수신 불능으로 이어질 수 있어요.
이 문서는 어떤 면에서 불완전할 수 있어요.
AUTHOR
동시 인터프리터 지원과 fork() 에뮬레이션은 Microsoft Corporation의 자금 지원으로 ActiveState가 구현했어요.
이 문서는 Gurusamy Sarathy [email protected]가 작성하고 유지보수해요.