컴퓨터 프로그램에서 실행 제어라 하면 조건과 분기, 반복, 예외 처리 같은 것들이 있는데.. 절차형 프로그래밍 언어에서는 이런 게 예약어와 블록 구조 같은 걸로 표현되고, 단순 함수 호출이나 연산은 위에서부터 아래로 순차적으로 수행되는 편이다.

그런데 C 언어는 타 언어었으면 예약어를 써서 구현되었을 실행 흐름 제어도 다 함수로 구현되는 경우가 종종 있다. 몇 가지 예를 들면 이렇다. 코드 정적 분석 프로그램 같은 걸 만든다면 이런 건 함수 차원에서 예외적으로 다뤄져야 한다.
C가 저수준이라는 소리를 괜히 듣는 게 아닌 듯하다.

1. signal

시스템 차원에서 인터럽트가 발생했을 때 실행될 콜백 함수를 지정한다. Ctrl+C나 Ctrl+Break가 눌리는 것도 이런 상황에 포함되나, Windows의 경우 C 표준을 준수하느라 함수는 동일해도 Ctrl 키 인터럽트는 다른 인터럽트와는 꽤 다른 방식으로 따로 처리된다. (Windows API에 SetConsoleCtrlHandler이라는 함수가 있음) 사실, Windows는 자체적인 예외 처리 함수 지정 메커니즘도 제공한다.
현대의 언어라면 다 try ... catch로 처리했을 사항들이다. SIG* 상수들은 catch 구문에다 별도의 값이나 타입으로 전달되고 말이다.

2. setjmp/longjmp

C 언어에 이런 함수도 존재한다는 걸 처음 알았을 때 굉장히 놀랐었다. goto는 한 함수 안에서만 분기가 가능하지만 얘는 아예 함수의 경계를 초월하여 이전의 setjmp 실행 직후 상황으로 분기를 시켜 주기 때문이다. 이 함수는 다음과 같이 사용하면 된다. 개념적으로 운영체제의 '시스템 복원'을 생각해도 쉽게 이해할 수 있다.

#include <setjmp.h>
jmp_buf jb;

void Func(int n)
{
    printf("%d\n", n);
    if(n==5) longjmp(jb, 0); else Func(n+1);
}

int main()
{
    if(setjmp(jb)) {
        puts("recursion interrupted.");
    }
    else {
        puts("OK, try");
        Func(0);
    }
    return 0;
}

jmp_buf라는 버퍼 자료형을 선언한다. 얘는 배열에 대한 typedef이며, 함수의 인자로 전달될 때는 자동으로 포인터처럼 취급된다. 그렇기 때문에 setjmp, longjmp의 인자로 전달할 때 &를 붙일 필요가 없으며, 그리고 안 붙이더라도 언제나 내부 컨텐츠는 call by reference처럼 취급된다. jb는 매번 함수의 인자로 전달할 게 아니라면 그 특성상 전역변수로 선언해 놓는 게 속 편하다.

그럼, setjmp를 호출하여 되돌아가고 싶은 지점에 대한 스냅샷을 만든다. 스냅샷을 만든 직후에는 setjmp의 리턴값이 0인 것으로 간주된다. 그래서 위의 코드에서는 "OK, try"가 먼저 출력되고 Func가 호출된다.

나중에 Func가 굉장히 복잡하게 실행된 뒤에 이것들을 몽땅 한 큐에 종료해야겠다 싶으면 longjmp를 호출한다. 그러면 얘는 아까 setjmp를 호출한 곳에서 함수가 0이 아닌 값이 리턴된 상황으로 모든 컨텍스트가 '원상복귀' 된다. 그래서 "recursion interrupted"가 출력되고 실행이 끝난다.

구체적인 리턴값은 longjmp의 인자에다가 줄 수 있다. 다만, 여기에다가 0을 지정하면 setjmp가 처음 호출되어 0이 리턴된 것과 구분이 되지 않기 때문에 setjmp의 리턴값이 1인 것으로 값이 일부러 보정된다.

위의 코드는 예외 처리 구문을 사용한 다음 코드와 실행 결과가 완전히 동일하다. 이번에도 try, catch가 답이다. 언어 차원에서 예약어를 동원해서 구현했을 기능이 그냥 함수로 처리되어 있다는 얘기가 바로 이런 의미이다.

void Func(int n)
{
    printf("%d\n", n);
    if(n==5) throw 1; else Func(n+1);
}

int main()
{
    try {
        puts("OK, try");
        Func(0);
    }
    catch(int e) {
        puts("recursion interrupted.");
    }
}

setjmp/longjmp는 언어 차원에서 제공되는 기능이 아니다 보니, 저렇게 함수들을 이탈할 때 C++ 객체들의 소멸자 함수 처리가 제대로 되지 않는다는 한계도 있다. 가변 인자만큼이나 C와 C++의 기능이 서로 충돌하는 지점이다.
그래도 얘는 시스템 프로그래밍 차원에서 고유한 용도가 있다 보니, 이들 함수가 현대의 컴파일러에서 deprecate됐다거나, 뭔가 기능이 보강된 *_s 버전이 생겼다거나 하지는 않다.

3. fork

새로운 실행 주체를 생성하는 함수라는 점에서 Windows의 CreateProcess나 CreateThread와 얼추 비슷하다. 그러나 생성하는 방식은 완전히 다르다.
Windows에서는 프로세스를 생성할 때 파일명을 주며, 그 프로세스는 완전히 처음부터 다시 실행된다. 그리고 스레드는 콜백 함수를 지정해서 생성하며, 그 콜백 함수의 실행이 끝나면 스레드 역시 종료되어 사라진다.

그러나 fork는 지금 나 자신과 메모리 구조와 스택 프레임, 내부 상태 문맥 같은 게 완~전히 동일한 프로세스가 하나 또 실행된다. 그래서 fork를 처음 호출한 기존 프로세스는 fork의 리턴값이 nonzero인 것으로 간주되어 실행이 계속되며, 새로 생성된 프로세스는 리턴값이 0인 것처럼 간주되어 실행이 계속된다. 굉장히 신기한 결과인데, 함수의 디자인 방식이 setjmp와 미묘하게 비슷하다고 볼 수도 있는지는 잘 모르겠다.

//공통 처리 진행 후,
if(fork()==0) {
    //분기된 자식 프로세스 문맥. 하지만 공통 부분에서 만들어 뒀던 변수들에 접근 가능함.
}
else {
    //'공통'을 실행하던 부모 프로세스 문맥
}

(뭐, 정확히는 실행이 성공하면 양수가 돌아오고, 실패하면 음수가 돌아오니 이건 마치 GetMessage의 리턴값만큼이나 주의할 필요는 있다.)

Windows API에는 저렇게 모든 실행 문맥을 그대로 복제해서 자신의 분신 프로세스를 만드는 함수가 존재하지 않는다. 프로그램들 내부에 포인터들까지 있다는 점까지 감안하면 정말 주소 공간이 문자 단위로 정확하게 일치해야 할 텐데, 그걸 그대로 복제하는 건 성능 오버헤드가 크지 않겠나 하는 생각도 든다.

fork는 프로세스를 생성하는 놈이다 보니 CreateProcess와 마찬가지로 비동기적으로 실행된다. 앞서 소개한 signal도 인터럽트 함수가 이론적으로 비동기적으로 실행될 수 있다. set/longjmp는 하는 일은 기괴해도 그래도 프로세스/스레드를 넘나드는 물건은 아니니 대조적이다.

그래서 signal 핸들러나 fork를 사용하는 코드에서는 주의해야 할 점이 있는데, 버퍼를 사용하는 고수준 IO 함수를 사용해서는 안 된다. 쉽게 말해 Hello, world를 찍을 때도 간단하게 printf나 puts를 쓰지 말고 write(1, "Hello", 5)라고 좀 번거로운 방법을 써야 한다. 비동기적인 환경에서 여러 실행 단위가 고수준 IO에 동시에 접근하면 출력이 꼬일 수 있기 때문이다.

먼 옛날 대학 시절, 시스템 프로그래밍 숙제를 하던 시절에 어떤 수강생이 뭘 잘못 건드렸는지 자식 프로세스를 무한 생성하는 삽질을 했고, 이 때문에 학과 서버가 몽땅 다운되어서 수강생들이 과제를 할 수가 없어지는 사태가 벌어졌다. 이 정도면 그 학생뿐만 아니라 계정별로 자원 할당 한계 관리를 제대로 하지 않은 서버 관리자에게도 책임이 있지 않나 생각이 든다만, 어쨌든 이것 때문에 과제의 듀(제출 기한)까지 불가피하게 연장된 적이 있었다.

이것 때문에 빡친 모 친구의 메신저 대화명은 "포크 삽질하는 놈 포크로 찍어 버린다 -_-"였던 것을 본인은 지금도 기억하고 있다.

Posted by 사무엘

2016/07/21 08:39 2016/07/21 08:39
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1252

Trackback URL : http://moogi.new21.org/tc/trackback/1252

Comments List

  1. Lyn 2016/07/22 09:30 # M/D Reply Permalink

    fork 가 프로세스를 복제하는건 이미 OS 에 구현되어 있는(물론 윈도우도 구현 되어 있습니다... OS에 있는 함수를 내 맘대로 후킹해서 수정하더라도 수정 한 프로세스에만 영향을 받는 이유기도 하구요) COW 를 기반으로 하기 때문에 오버헤드가 크다고 보긴 좀 어려울 듯 합니다. 그냥 링크만 연결 해 놨다가 블럭이 바뀌면 그때 재할당만 해주면 되니 ㅎㅎ

    1. 사무엘 2016/07/22 10:43 # M/D Permalink

      디스크에 존재하는 실행 EXE/DLL 이미지야 메모리 맵드 파일 방식으로 로딩되고 레퍼런스 카운트 + 링크 + 수정 시에만 재할당 방식인 게 잘 알려져 있습니다만, 실행 후에 운용되는 스택과 힙 등 메모리 전반이 다 그런 방식인지에 대해서는 지금까지 딱히 생각을 안 하고 있었네요.
      하긴, 제일 밑에 있는 가상 메모리 페이지들이 근본적으로 애초부터 그런 식으로 관리돼 온 거라면 수긍이 가겠어요. fork도 POSIX 규격의 일부일 테고.. 무슨 말씀인지 잘 알겠습니다. ^^

Leave a comment
« Previous : 1 : ... 1012 : 1013 : 1014 : 1015 : 1016 : 1017 : 1018 : 1019 : 1020 : ... 2128 : Next »

블로그 이미지

그런즉 이제 애호박, 단호박, 늙은호박 이 셋은 항상 있으나, 그 중에 제일은 늙은호박이니라.

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2024/03   »
          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
31            

Site Stats

Total hits:
2619905
Today:
2904
Yesterday:
1544