Tistory View

리눅스

fork() 사용법과 주의 사항

What should I do? 2023. 5. 29. 01:31
반응형

fork()함수는 자신의 프로세스를 복사해서 또 다른 자식프로세스를 만들어내는 것이다. 

찍어먹는 포크?

동작방식

fork()는 새로운 프로세스를 만든다. 새로운 프로세스를 만드는 것은 컴퓨터의 입장에서는 굉장히 느린 작업이다. Processing을 위한 context로 만들어야 하고, 메모리도 할당해야 하고, standard IO도 할당을 해야하는 등 시작을 위한 준비작업이 상당히 많다. 하지만 fork() 함수는 메모리를 스~윽 복사해서 context(*1)만 만들어 실행 시켜버리기 때문에 일일이 새로운 프로세스를 만드는 작업 중 일부만을 수행한다. 거의 thread를 만드는 것에 조금 더 추가되는 수준의 적은 부하만 발생하게 된다. linux가 발전하면서 처음에는 부하가 컸지만, 별별 테크닉들이 커널에 추가되면서 이 정도까지 부하가 줄어든 것이다.

심지어 fork()함수로 자식프로세스가 만들어 질 때는 부모프로세스의 메모리를 복사하지도 않는다. 자식이나 부모가 메모리의 내용을 변경하는 순간에 복사가 일어나게 된다. 거의 구현된 모습은 아름답기도 하다.

 

기본 사용법

fork() 함수가 수행이 되면 새로운 자식 프로세스도 이 함수의 리턴된 시점부터 시작된다.(좀 정확히는 fork함수 내의 어딘가 겠지만..)

리턴값으로 실행되는 코드가 부모프로세스에서 실행되는 것인지, 자식프로세스에서 실행되는 것인지를 구분할 수 있다.

void do_fork() {
    pid_t childPid = fork();

    if( childPid < 0 ) {
        fprintf( stderr, "fork failure\n" );
        return;
    }

    if( childPid != 0 ) { // parent
        int wstatus;
        printf( "parent[%d] : child pid=%d\n", (int)getpid(), (int)childPid );
        printf( "parent[%d] : wait.. child done\n", (int)getpid() );
        waitpid( childPid, &wstatus, 0 );

        if( WIFEXITED( wstatus ) ) {
            printf( "child exit normally. with returning %d\n", WEXITSTATUS(wstatus) );
        }
        else {
            printf( "child exit abnormally.\n" );
        }
    }
    else { // child
        printf( "child[%d]  : child pid=%d\n", (int)getpid(), (int)getpid() );
        sleep(2); // do something
        exit(3);  // return code 3 for testing
    }
}

실행의 결과

fork()함수로 자식이 성공적으로 만들어 졌다면,

부모프로세스는 : 자식프로세스의 pid값을 리턴 받게 된다. 실패 했다면 -1을 리턴 한다.

자식프로세스는 : 0값을 리턴받게 된다. 따라서 단순히 if문만으로 현재 수행되는 프로세스를 구분할 수 있다. 당연히 자식프로세스에서는 getpid()함수를 통해서 pid값을 알아낼 수 있다.

 

좀비(defunct)프로세스가 생성될 수 있다. 

자식은 자기할일 다하고 그냥 종료하면 되지만, 부모프로세스의 경우 자식이 종료되는 것을 관리 해줘야 한다.

자식프로세스가 종료되었는 데, 이 자식의 종료를 부모에서 처리해주지 않으면 자식은 defunct(좀비)프로세스가 되어 버린다. 이 청소와 마무리 작업을 해주는 것이 waitpid()함수며, 이 함수는 자식의 종료값(*2)을 분석해서 제대로 처리되었는 지 확인 할 수 있다.

좀비 프로세스

좀비 프로세스를 보면 VSS, RSS등 모든 메모리는 정리가 되었지만, 종료값처리 등을 위한 최소한의 정보만을 유지해야 하고 있기 때문에, 완전히 종료되지 못하고 있다. 

 

waitpid 함수는 2~3개 정도 존재하는 데, 특정 자식을 기다리거나, 자식중에 아무 것이나 기다리는 함수다. 필요에 따라 꼭 호출해 줘야 한다.

예제에서 자식프로세스에서 실행되는 코드에 exit(3) 함수를 빼버리면 콜스택을 타고 돌아가게 될텐데, 돌아가면서 수행되는 코드들이 정확히 동작한다는 보장이 없다. 예를 들어, 열려 파일의 경우 데이터를 부모와 자식이 둘 다 써버리면 그 데이터는 깨져 버리게 될 것이다. 메모리에서 문제가 발생한다면, 소위 "똥싸고 죽는" segment fault는 빈번히 일어나게 되고, 어떻게 작살(?)이 날지 모르기 때문에 자식프로세스는 정확히 할 일만 하고 exit로 끝내줘야 한다.

자식의 종료값 다루기

waitpid함수를 통해 자식의 종료값을 받아 올 수 있다. 하지만 자식이 시그널로 인해 죽을 수도 있고, 수행이 정확히 되었는지 확인을 할 필요가 있다. 

필자가 주로 사용하는 waitpid함수의 원형은 다음과 같다.

pid_t waitpid(pid_t pid, int *wstatus, int options);

여기서 wstatus변수를 통해 자식프로세스의 리턴 값을 받아 올 수 있다.

일단 자식이 시그널 맞고 뒈졌을 수도 있으니, 올바르게 수행 되었는 지 확인 할 필요가 있다.

그리고 자식프로세스의 결과 값을 가지고 온다.

WIFEXITED 매크로가 올바르게 수행 되었는 지(자식이 끝까지 문제 없이 수행 되었는 지)확인하는 매크로고, WEXITSTATUS 매크로가 자식프로세스의 리턴값(*2)을 가지고 오는 함수다..

 

위의 예제에서 자식프로세스에서 exit(3)한 "3"의 값을 받아오는 것을 확인 할 수 있다.

 

근데 문제는 wstatus라는 변수가 int형이다. 32비트에서는 32비트고 64비트에서는 64비트인데... exit의 파라미터 또한 

int형이다. 하지만 이 변수에는 signal 맞고 죽었는 지와 다른 정보도 가지고 있다. exit의 값을 온전히 전달할 수 없다는 이야기다. 프로세스의 종료값(*2)은 8bits로만 써야 한다는 것이다. 즉 -128 ~ + 127까지만 써야 한다. 나머지는 싹 날아가게 된다. 필자는 리턴값으로 자식에서 처리된 바이트수를 넘기려 했는 데, 이 크기는 127정도야 훌쩍 넘어가기 때문에 exit로는 쓸 수가 없었다. 필자같은 실수를 하지 말기를...

부모프로세스가 먼저 끝난다면.. 으응?

자식은 여전히 실행 중인데 부모가 먼저 끝나게 된다면, 자식은 좀비프로세스가 되고 init프로세스(*3)의 자식으로 되어버린다. init프로세스는 waitpid함수를 실행시켜서 자식을 깨끗하게 없애버린다. 이 작업은 순식간에 일어난다. 이 처리방식은 바꿀 수도 있는 데, 보통 부모프로세스가 깔끔히 처리하도록 코드를 짜는 것이 좋으니, 더 이상 설명할 필요가 없을 것 같다. 보다 더 자세한 사항을 알고 싶으면 fork의 매뉴얼 페이지를 잘 찾아보기 바란다.

자식프로세스와의 통신, File Descriptor는 전부 상속된다.

자식프로세스도 프로세스기때문에 SystemV를 이용하여 통신을 할 수 있다. 하지만 더 쉬운 방법으로 pipe를 만들어 데이터를 주고 받을 수 있다.

void do_fork_2Pipe() {

    pid_t childPid = -1;
    int fdP2C[2];
    int fdC2P[2];

    pipe( fdP2C );
    pipe( fdC2P );

    childPid = fork();
    if( childPid < 0 ) {
        fprintf( stderr, "fork failure\n" );
        return;
    }

    if( childPid != 0 ) { // parent

        int wstatus;
        char buf[16];
        size_t nread;
        printf( "parent[%d] : child pid=%d\n", (int)getpid(), (int)childPid );
        
        write( fdP2C[1], "1234", 4 );

        nread = read( fdC2P[0], buf, sizeof(buf) - 1);
        if( nread > 0 ) {
            buf[nread] = '\0';
            printf( "parent[%d] : data from child=%s\n", (int)getpid(), buf );
        }
        else {
            printf( "parent[%d] : read data failure\n", (int)getpid() );
        }

        waitpid( childPid, &wstatus, 0 );
    }
    else { // child
        char buf[16];
        size_t nread;
        printf( "child[%d]  : child pid=%d\n", (int)getpid(), (int)getpid() );
        nread = read( fdP2C[0], buf, sizeof(buf) - 1 );
        if( nread > 0 ) {
            buf[nread]= '\0';
            printf( " child[%d] : data form parent=%s\n", (int)getpid(), buf );
        }

        write( fdC2P[1], "abcd", 4 );

        exit(0);  // return code 3 for testing
    }
}

데이터를 서로 주고 받기 위해 pipe를 2개 만든다. pipe에 의해 생성된 fd들은 자식에게 그대로 상속되어 서로간의 데이터를 주고 받을 수 있다. 

pipe를 1개만 만들면 부모가 쓰고, 다시 부모가 읽어버려 자식은 읽을 수가 없게 되기 때문에 pipe를 2개 만들어야 한다. 만약 한쪽으로만 데이터를 보내는 상황이라면 1개만 만들어도 된다.

위의 예제는 잘 작동하지만, 좀 더 의미있는 코드를 만들어 보자. pipe는 1개만 만들어 부모는 자식에게 데이터를 보내고, 자식은 읽어서 출력하는 코드를 작성해보자.

void do_fork_PipeStall() {

    pid_t childPid = -1;
    int fdP2C[2];
    
    pipe( fdP2C );
    
    childPid = fork();
    if( childPid < 0 ) {
        fprintf( stderr, "fork failure\n" );
        return;
    }

    if( childPid != 0 ) { // parent

        int wstatus;
        char buf[16];
        size_t nread;
        printf( "parent[%d] : child pid=%d\n", (int)getpid(), (int)childPid );
        
        write( fdP2C[1], "1234", 4 );
        close( fdP2C[1]);

        waitpid( childPid, &wstatus, 0 );
    }
    else { // child
        char buf[16];
        size_t nread;
        printf( "child[%d]  : child pid=%d\n", (int)getpid(), (int)getpid() );
        while( true ) {
            nread = read( fdP2C[0], buf, sizeof(buf) - 1 );
            if( nread > 0 ) {
                buf[nread]= '\0';
                printf( " child[%d] : data form parent=%s\n", (int)getpid(), buf );
            }
            else {
                break;
            }
        }
        
        exit(0);
    }
}

부모에서 데이터를 보낸 후 끝(eof)이라는 것을 알리기 위해 close로 닫아 버린다. 하지만 자식은 여전히 read에서 멈춰있게 된다. 이유는 자식도 fdP2C[1]을 상속받기 때문이다. 자식프로세스에서 이 fdPC2[1]이 여전히 열려 있기 때문에 eof가 발생하지 않는다. 따라서 필요없는 fd를 모두 닫아줘야 이런 문제가 발생하지 않게 된다. 이와 같은 이유로 부모도 쓰지않는 모든 fd를 닫아 줘야 한다.

void do_fork_PipeNotStall() {

    pid_t childPid = -1;
    int fdP2C[2];
    
    pipe( fdP2C );
    
    childPid = fork();
    if( childPid < 0 ) {
        fprintf( stderr, "fork failure\n" );
        return;
    }

    if( childPid != 0 ) { // parent

        close( fdP2C[0] ); // <-------- important

        int wstatus;
        char buf[16];
        size_t nread;
        printf( "parent[%d] : child pid=%d\n", (int)getpid(), (int)childPid );
        
        write( fdP2C[1], "1234", 4 );
        close( fdP2C[1]);

        waitpid( childPid, &wstatus, 0 );
    }
    else { // child

        close( fdP2C[1] ); // <-------- important

        char buf[16];
        size_t nread;
        printf( "child[%d]  : child pid=%d\n", (int)getpid(), (int)getpid() );
        while( true ) {
            nread = read( fdP2C[0], buf, sizeof(buf) - 1 );
            if( nread > 0 ) {
                buf[nread]= '\0';
                printf( " child[%d] : data form parent=%s\n", (int)getpid(), buf );
            }
            else {
                break;
            }
        }
        
        exit(0);
    }
}

잘 실행되는 것을 알 수 있다. 하지만 이 것이 끝이 아니다.

File Descriptor는 전부 상속된다. 이는 악마가 숨어 있다.

 fork는 부모프로세스의 모든 fd를 자식프로세스에게 상속하기 때문에 부모에서 파일에 데이터를  미리 써놓고 자식에서는 그 데이터를 읽을 수도 있다. 자식프로세스에서는 상속받은 fd중 필요한 것을 제외하고는 전부 닫아 줘야 한다는 것은 이제 알고 있다. 하지만, 부모프로세스의 이미 만들어져 있는 fd(File Descriptor) 는 일일이 fork를 실행하는 함수에 전달해주지 않으면 어떤 fd가 열려있는 지 알 수가 없다. 또한 MultiThread를 이용한 프로그램이라면 수많은 fd가 생성될 수 있고, 이 경우는 어떤fd가 열려 있는 지 전달해 줄 방법조차 없다. 모두 닫아 주지 않으면, 위의 예제에서와 같은 일이 발생할 수 밖에없다. 따라서, 모두다 닫아 버리는 코드가 필요하게 된다.

 

리눅스의 프로세스는 열 수 있는 최대 fd갯수를 정해 놓고 있다. 이 값을 구해주는 함수는 getdtablesize()인데, 이 함수가 없다면 getrlimit()함수로 대체해서 쓰면 된다.

void do_fork_Pipe1() {

    pid_t childPid = -1;
    int fdP2C[2];
    
    pipe( fdP2C );
    
    childPid = fork();
    if( childPid < 0 ) {
        fprintf( stderr, "fork failure\n" );
        return;
    }

    if( childPid != 0 ) { // parent

        close( fdP2C[0] ); // <-------- important

        int wstatus;
        char buf[16];
        size_t nread;
        printf( "parent[%d] : child pid=%d\n", (int)getpid(), (int)childPid );
        
        write( fdP2C[1], "1234", 4 );
        close( fdP2C[1]);

        waitpid( childPid, &wstatus, 0 );
    }
    else { // child

        int maxFd = getdtablesize();
        if( maxFd < 0 ) {
            maxFd = 256;
        }
        for( int i = 3 ; i < maxFd ; i++ ) {
            if( fdP2C[0] != i ) {
                close( i );
            }
        }

        char buf[16];
        size_t nread;
        printf( "child[%d]  : child pid=%d\n", (int)getpid(), (int)getpid() );
        while( true ) {
            nread = read( fdP2C[0], buf, sizeof(buf) - 1 );
            if( nread > 0 ) {
                buf[nread]= '\0';
                printf( " child[%d] : data form parent=%s\n", (int)getpid(), buf );
            }
            else {
                break;
            }
        }
        close( fdP2C[0] );
        exit(0);
    }
}

자식프로세스에서 실행되는 부분을 보면 필요한 fd를 제외하고는 전부 close를 하는 코드를 볼 수 있다. 다소 느리게 느껴질 수도 있지만, 이 방법외에는 달리 방법이 없다. [다른 방법이 있는 것 같긴 한데..]

부모가 Multi-Thread라면..

fork로 인해 만들어지는 자식프로세스는 부모의 thread를 상속받지는 않는다. 자식은 fork후에 1개의 thread만이 존재하게 된다. 메모리 복사로 인해 thread정보는 있지만 이는 전부 의미없는 값일 뿐이다. 따라서 부모의 Thread는 신경쓰지말고 코드를 작성하면 된다.

공유메모리는 공유된 체로 쓸 수 있다.

void do_forkSharedMem() {

    pid_t childPid = -1;

    int    shmid = 0;
    void*  sharedPtr = nullptr;

    shmid = shmget(IPC_PRIVATE, 1024, IPC_CREAT | IPC_EXCL| 0664 );

    sharedPtr = shmat(shmid, nullptr, 0);
    assert( sharedPtr );

    childPid = fork();
    if( childPid < 0 ) {
        fprintf( stderr, "fork failure\n" );
        return;
    }

    if( childPid != 0 ) { // parent
        int wstatus;
        printf( "parent[%d] : child pid=%d\n", (int)getpid(), (int)childPid );
        printf( "parent[%d] : wait.. child done\n", (int)getpid() );
        
        waitpid( childPid, &wstatus, 0 );
        printf( "parent[%d] : sharedPtr = %s\n", (int)getpid(), (char*)sharedPtr );
    }
    else { // child
        printf( "child[%d]  : child pid=%d\n", (int)getpid(), (int)getpid() );
        strcpy( (char*)sharedPtr, "abcd" );
        shmdt(sharedPtr );
        exit(0);
    }

    shmdt(sharedPtr );
    shmctl(shmid, IPC_RMID, (struct shmid_ds*)NULL);

}

실행결과

자식에게 상속된 공유메모리에 abcd라는 데이터가 자식프로세스에 의해 쓰여지게 되고, 부모는 그값을 읽어서 확실해 공유되었음을 알 수 있다. 하지만, 되기는 되는 데, 이게 올바른 사용법인지는 잘 모르겠다.

exec**함수로 자식프로세스를 교체 시키기

fork와는 행동 방식과 exec의 행동방식은 다르다. 당연한 이야기지만, 늘 이 둘을 같이 공부하게 되니, 머리속에서 헤깔릴 수 밖에 없다. 

exec**함수들은 현재의 프로세스를 새로운 프로세스로 대체해버리는 기능이다. 우선 fork로 자식프로세스를 만들고 exec**관련함수로 프로세스를 대체해 버리면 현재 돌고 있는 부모프로세스는 그대로 두고 다른 프로세스를 생성할 수 있다는 뜻이다.

위에서 열심히 했던 pipe를 이용하여 프로세스간의 통신도 할 수 있다. popen()함수나 system()함수들이 이 방식을 사용한다. 

이는 fork처럼 fd는 교체될 자식프로세스에게 그대로 전달되기에 가능한 일이다.

실행시킬 자식프로세스의 코드는 다음과 같다.

#include <inttypes.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

int main( int argc, char* argv[] ) {
    
    int32_t v;
    size_t nread;
    
    nread = read( 0, &v, sizeof(v) );
    if( nread != sizeof(v) ) {
        exit(10);        
    }
    
    v++;

    write( 1, &v, sizeof(v) );
    return 0;
}

stdin[0]에서 4byte를 읽어서 +1을 한 후, stdout[1]로 출력을 하는 프로그램이다. 

부모에서 특정 숫자를 보내주면 자식은 +1하고 부모는 그 결과값을 받는 코드는 다음과 같다.

void do_fork_PipeExec1() {

    pid_t childPid = -1;
    int fdP2C[2];
    int fdC2P[2];
    
    pipe( fdP2C );
    pipe( fdC2P );
    
    childPid = fork();
    if( childPid < 0 ) {
        fprintf( stderr, "fork failure\n" );
        return;
    }

    if( childPid != 0 ) { // parent

        close( fdP2C[0] );
        close( fdC2P[1] );

        int wstatus;
        int32_t v = 5;
        size_t nread;
        printf( "parent[%d] : child pid=%d\n", (int)getpid(), (int)childPid );
        
        write( fdP2C[1], &v, sizeof(int32_t) );
        close( fdP2C[1]);
        waitpid( childPid, &wstatus, 0 );

        if( WIFEXITED( wstatus ) ) {
            printf( "child exit normally. with returning %d\n", WEXITSTATUS(wstatus) );
            
            read( fdC2P[0], &v, sizeof(int32_t));
            printf( "parent[%d] : data from child=%d\n", (int)getpid(), (int)v );
        }
        else {
            printf( "child exit abnormally.\n" );
        }

        close( fdC2P[0] );
    }
    else { // child

        printf( "child[%d]  : child pid=%d\n", (int)getpid(), (int)getpid() );

        //close( fdP2C[1] );
        //close( fdC2P[0] );

        dup2( fdP2C[0], 0 );
        dup2( fdC2P[1], 1 );
        
        int maxFd = getdtablesize();
        if( maxFd < 0 ) {
            maxFd = 256;
        }
        for( int i = 3 ; i < maxFd ; i++ ) {
            close( i );
        }
        
        execlp( "./echoplus", "./echoplus", nullptr );
        
        exit(-1);
    }
}

자식프로세스는 그냥 stdin/out을 이용하기 때문에 자신이 콘솔에서 실행되었는지, 다른 프로세스를 대체하는지는 검사하지 않는다. 단순히 stdin/out만을 쓴다. 대부분의 프로그램이 이렇게 만들어져 있다. 따라서 부모는 stdin[0]과 stdout[1]을 지정해줘야 한다. dup2함수가 하는 일이 이 교체작업이다.

dup2( fdP2C[0], 0 );

fdP2C[0]을 stdin[0번]으로 교체하는 코드다. 기존의 stdin은 소멸되고 fdP2C[0]가 0번으로 바뀌게 된다.

이 바뀐 fd 0번이 exec..함수를 타고 그대로 echoplus는 실행 되게 된다.

실행결과

프로세스 교체실패

exec**함수가 꼭 호출이 된다는 포장은 없다. 자식프로세스의 exit(-1)코드는 "echoplus"라는 프로그램이 실행된다면 exit(-1)가 절대 실행되지 않는다. exec**함수는 프로세스를 교체하는 것이기에 자식프로세스의 종료로 그냥 끝나게 된다. 

교체시에 이미 fork로 복제된 메모리는 존재하지도 않는다. 따라서 이 코드는 실행될 수도 없다. 하지만 exec**함수가 실패할 경우 부모에서 제대로 수행되었는 지 확인하기위해 (-1)값을 돌려주는 코드는 다른 문제를 미연에 방지하게 해준다.

O_CLOEXEC

fork시에는 fd가 늘 복사되서 자식에게 전달되지만, exec**함수로 실행되는 실행파일에는 전달하지 않는 방법도 있다. open함수의 경우 O_CLOEXEC를 추가로 지정해주면 exec**함수는 이 flag가 있는 fd는 모두 닫아 버린후에 실행을 시켜준다. 이렇게 하면 위에서의 악마를 미연에 방지할 수 있다. 이미 열려있는 fd에는 아래와 같이 추가적인 작업을 해주면 된다.

fcntl(fd, F_SETFD, FD_CLOEXEC);

하지만, 아직도 이 악마를 다 처리 했다고는 할 수 없다. 써드파티 라이브러리를 쓸 경우 fd를 구해낼 방법이 없을 수 있고, 구할 수 있다고 해도, fcntl...함수가 호출되기 이전에 fork가 실행되어 버리면 무용지물이 된다. 따라서 위에서 모든 fd를 닫아주는 코드는 언제나 필요하게 된다.

 

exec**와 공유메모리

당연한 이야기지만 exec는 메모리의 내용이 완전히 바뀌게 된다. 따라서, 상속된 공유메모리는 전달될 수 없다. 공유메모리를 사용하고 싶다면, shmid값을 환경변수나 실행 파라미터등 다른 방법으로 전달해 주면 된다.


*1 : 실제 프로그램 코드가 도는 것과 그 관련정보를 Context라고 한다. 

*2 : exit함수에 지정하거나 exec***함수로 프로세스를 변경한 경우 main함수의 return값

*3 : init프로세스는 모든 프로세스의 조상이다. 리눅스에서 생성되는 최초의 프로세스이며 0 또는 1의 pid값을 주로 갖고 있다.

반응형
Replies
NOTICE
RECENT ARTICLES
RECENT REPLIES
Total
Today
Yesterday
LINK
«   2024/06   »
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
Article Box