[CS:APP] Chap 8 : Exceptional Control Flow

Exceptional Control Flow : 예외적인 제어흐름

프로그램의 각 명령어는 메모리의 특정 주소에 위치하며, 이러한 전이를 control transfer, 제어 이동이라고 한다.

  • 이러한 제어 이동의 연속이 바로 제어 흐름이다.
  • 가장 단순한 경우는 지금 하는 명령어와 다음 해야 할 것이 메모리 상에서 인접해있는 부드러운(smooth) 흐름이다.
    • 일반적으로 이러한 부드러운 흐름에서 벗어나 바로 다음이 해야 할 일이 아닌 jump, call, return과 같은 익숙한 명령어들로 인해 발생한다.
    • 이러한 명령어들은 프로그램 내 변수로 표현되는 내부 상태 변화에 대응 할 수 있도록 프로그램이 반응하게 해주는 필수적 메커니즘이다.

하지만 시스템은 내부 프로그램 변수로 포착되지 않거나, 프로그램 실행과 직접적으로 관련이 없는 시스템 상태의 변화에도 반응 할 수 있어야한다. 예를 들어, 하드웨어 타이머가 정기적으로 작동하고 이 이벤트를 처리해야 한다. 네트워크 어댑터로부터 패킷이 도착하면 메모리에 저장해야 한다.

프로그램이 디스크에서 데이터를 요청하면, 데이터가 준비 될 때까지 대기해야 한다.
부모 프로세스가 자식 프로세스를 생성했을 경우, 자식이 종료되면 부모는 그 사실을 통보받아야 한다.

현대 시스템은 이러한 상황에 대해 제어 흐름을 갑작스럽게 변경함으로써 반응한다.

  • 일반적으로 이러한 갑작스러운 제어 흐름 변경을 예외적 제어 흐름(Exceptional Control Flow, ECF)이라고 부른다.
    • ECF는 컴퓨터 시스템의 모든 수준에서 발생 할 수 있다. 예를 들어,
    • 하드웨어 수준에서는 하드웨어가 감지한 이벤트가 예외 처리기로의 갑작스러운 Control Transfer를 발생 시킬 수 있다.
      • 운영체제 수준에서는 커널이 사용자 프로세스 간 제어를 전환(context switch)함으로써 제어 흐름을 변경 할 수 있다.
      • 애플리케이션 수준에서는 한 프로세스가 다른 프로세스에 시그널을 보내면 수신자의 시그널 핸들러로 제어가 갑작스럽게 전환된다.
      • 하나의 프로그램 내부에서도 오류에 반응하여 일반적인 스택 기반 흐름을 우회하고 다른 함수의 임의 위치로 점프하는 비지역 점프(nonlocal jump)를 수행 할 수 있다.
      • 핀트는 갑작스럽게인데, abrupt changes 가 원문이다 :
        • 느닷없이 발생했다는건 아니고, 기존의 흐름이나 논리적 구조를 따르지 않고 정형적이지 않은 방식으로 transfer가 일어났음을 강조하는 용어로 이해해야 한다.

프로그래머로써 ECF를 이해하는 것이 왜 중요할까?

  • 시스템 개념을 이해하는데에 도움이 된다.
    • ECF는 운영체제가 입출력, 프로세스. 가상 메모리 등을 구현하는 데 사용하는 기본 메커니즘이다.
      핵심 개념을 이해하기 위해, 우선 ECF를 이해해야 한다.
  • 응용 프로그램이 운영체제와 상호작용 하는 방식을 이해하는데 도움이 된다.
    • 프로그램은 트랩, 또는 system call 이라는 ECF의 한 형태를 이용해 운영체제에 서비스를 요청한다.
    • 예를 들어, 디스크에 데이터를 쓰거나 네트워크로부터 데이터를 읽거나, 새 프로세스를 생성하거나, 현재 프로세스를 종료하는 등의 동작은 시스템 호출을 통해 수행된다.
  • 흥미로운 응용 프로그램 작성에 도움이 된다.
    • 운영체제는 응용 프로그램에 새로운 프로세스를 생성하거나, 종료를 대기하거나, 예외 상황을 다른 프로세스에 알리거나 등의 기능을 제공하는 강력한 ECF 메커니즘을 제공한다.
  • 동시성을 이해하는데 도움이 된다.
    • ECF는 컴퓨터 시스템에서 동시성을 구현하기 위한 기본 메커니즘이다.
    • 예를 들어, 프로그램 실행중에 인터럽트되는 예외 처리기, 시간 상 겹쳐서 실행되는 프로세스와 쓰레드, 실행 중인 프로그램을 인터럽트하는 시그널 핸들러 등이 모두 동시성의 예이다.
  • 소프트웨어 예외 처리를 이해하는 데 도움이 된다.
    • C++, Java 류의 언어는 try, catch, throw 와 같은 문장으로 예외 처리를 제공한다. 이는 오류 발생 시 일반적인 스택 규율을 따르지 않고 비지역 점프를 수행하도록 한다.
      • 이런 비지역 점프가 응용 프로그램 수준의 ECF이며, C언어에서는 setjmp, longjmp 함수를 통해 제공된다.
      • 이러한 저 수준 함수 이해를 기반해, 고 수준의 소프트웨어 예외 처리가 어떻게 구현되는지도 이해 할 수 있다.

8.1 예외들

예외는 Excpetional Control Flow의 한 형태로, 하드웨어와 운영체제가 같이 구현한다.

  • 하드웨어가 일부 구현을 담당하기 때문에, 구체적인 동작 방식은 시스템마다 다를 수 있다.
  • 다만, 기본적인 개념은 모든 시스템에서 동일하다. 우린 여기서 예외, 예외 처리에 대한 일반적인 이해에 이어, 종종 혼란스러울 수 있는 이 측면을 명확하게 이해 해보자.

예외란?

  • 프로세서의 상태 변화에 반응하여 제어 흐름이 갑작스럽게 바뀌는 것을 말한다. 위의 그림은, 이 개념의 기본 구조를 보여준다.
    • 그림에서 프로세서는 I_curr을 실행중이다. 이 때 프로세서 내부의 상태에는 중요한 변화가 발생하는데, 이 변화를 event라고 부른다. 이 이벤트는 현재 실행 중인 명령어와 직접적으로 관련이 있다
      • 가상 메모리 page fault 발생 시.
      • 산술 오버플로우 발생 시.
      • 0으로 나누는 연신 시도 시.
    • 또는, 현재 명령어랑 별로 상관 없는 사건일수도 있다.
      • 시스템 타이머 만료 시.
      • I/O 요청 완료 시.
  • 어떤 경우가 됐던 프로세서가 해당 이벤트의 발생을 감지하면, 예외 처리를 위해 간접적인 프로시저 호출 (indirect procedure call)을 수행한다.
    • 이 호출은 exception table 이라 불리는 점프 테이블을 통해 이루어지며, 해당 이벤트를 처리하기 위해 설계된 운영체제의 exception handler으로 제어를 넘긴다.

예외 처리기가 이벤트를 처리한 후에는, 해당 이벤트의 종류에 따라 다음 세 가지 중 하나의 일이 발생한다.

  1. 예외 처리기가 현재 명령어 I_curr로 제어를 되돌린다.
    • 즉, 이벤트가 발생 했을 때 실행 중이던 명령어로.
  1. 예외 처리기가 다음 명령어 I_next로 제어를 넘긴다.
    • 예외가 발생하지 않았다면 실행되었을 명령어로.
  2. 예외 처리가 중단된 프로그램을 종료한다.

8.1.1 Exception Handling

예외는 하드웨어와 소프트웨어가 밀접한 협력을 통해 처리해야하니 이해하기 어려울 수 있다. 어떤 구성요소가 뭘 하는지 헷갈리기 쉽기 때문이다. 이제 예외 처리에서 하드웨어와 소프트웨어가 맡는 역할 분담을 좀 더 자세히 살펴보자.

  • 시스템에서 발생 가능한 각 예외 유형에는 고유한 0 이상의 정수 예외 번호(exception number)가 할당된다.
    • 이 번호들 중 일부는 프로세서 설계자가, 나머지는 운영체제 커널 설계자가 정하게 된다. 예를 들어 프로세서가 정하는 예외는 이런게 있다.
      • 0으로 나누기
      • page fault
      • 메모리 접근 위반
      • break point : debug only
      • 산술 오버플로우
    • 운영체제가 정하는 예외에는 다음과 같은 것들이 있다 :
      • system call
      • 외부 I/O 장치로부터의 호출
  • 컴퓨터가 부팅 될 때, 운영체제는 exception table이라 불리는 점프 테이블을 할당하고 초기화한다. 그림 8.2에 위치한 테이블을 보자. k번 항목은 예외 번호 k에 대응하는 핸들러의 주소를 저장한다.
  • 실행 시간에는, 어떤 프로그램이 실행 중일 때 프로세서가 이벤트 발생을 감지하고, 그에 대응하는 예외 번호 k를 결정하게 된다.
    그림 8.3이 그러한 계산을 수행하는 과정이 있다.
    • 이후 프로세서는 예외를 발생시키며, 예외 테이블의 k번 항목을 통해 간접적인 프로시저 호출을 수행하여 해당 핸들러로 제어를 이동시킨다.
  • 예외는 프로시저 호출과 유사하겠지만, 다음과 같은 중요한 차이가 있는데 :
    • 일반적인 프로시저 호출처럼, 프로세서는 분기하기전에 반환주소를 스택에 저장한다.
      • 다만, 예외의 종류에 따라, 이 반환 주소는 다음 중 하나가 될 수 있다 :
        • 이벤트가 발생했을 당시 실행 중이던 현재 명령어
        • 예외가 없었다면 실행되었을 다음 명령어
    • 프로세서는 또한 예외가 끝난 후 프로그램을 재개 할 수 있도록 일부 프로세서 상태를 추가로 스택에 저장한다.
      • 예를 들어, x86-64 시스템에서는 조건 코드등을 담은 EFLAGS 레지스터도 스택에 푸시하게 된다.
    • 사용자 프로그램에서 커널로 제어가 넘어갈 때는, 위와 같은 정보들이 사용자 스택이 아닌 커널 스택에 저장이 된다.
    • 예외 핸들러는 커널 모드에서 실행되며, 이 모드는 시스템 자원 전체에 접근 할 수 있는 권한을 갖는다.

일단 하드웨어가 예외를 트리거하면, 그 이후 작업은 예외 핸들러라는 소프트웨어가 담당한다.

  • 뭘 트리거 한다는거야
    • 하드웨어가 어떤 이벤트를 감지하고, 그에 따라 제어 흐름을 예외 핸들러 쪽으로 바꾼다는 뜻이다.

핸들러가 이벤트 처리를 마친 후에는, 다음을 수행하는 특수 명령어인 return from interrupt (인터럽트 복귀) 명령어를 실행하여 제어를 원래 프로그램에 돌려 줄 수 있다.

이 명령어는 다음을 수행한다.

  • 저장된 프로세서 상태를 다시 컨트롤/데이터 레지스터에 복원한다.
  • 예외가 사용자 프로그램을 중단시켰다면 사용자 모드로 복귀한다.
  • 중단되었던 프로그램으로 제어를 반환한다.

8.1.2 Classes of Exceptions

 

예외의 종류들이라는 번역을 생각하는게 맞겠다.

    • 인터럽트
      • 비동기적 예외를 말한다.
      • I/O 장치에서 발생하고, 현재 CPU가 실행 중인 명령과는 상관없이 외부에서 발생한다.
      • 작동 과정
        • I/O 장치가 인터럽트 핀에 신호를 보낸다.
        • 동시에, 어떤 장치에서 발생했는지 식별하는 예외 번호도 시스템 버스를 통해 전달된다.
        • 현재 명령이 끝나면 CPU는 :
          • 인터럽트 핀을 확인해서 예외 번호를 읽고, 해당하는 인터럽트 핸들러를 호출한다.
        • 핸들러 처리가 끝나면, 그 다음 명령어로 복귀한다.
      • 효과
        • 실행 흐름을 잠깐 탈선했다가 원래대로 복귀하는 구조
        • CPU는 “인터럽트가 없었던 것 처럼” 다음 명령어를 계속 실행한다.

  • 트랩
    • 동기적 예외를 말한다.
    • 의도적으로 사용자 프로그램이 시스템 서비스 요청을 위해 발생시키는 것이다.
    • 사용 목적
      • 커널에 요청한다. read, write, forkl, execve, exit
      • 트랩은 시스템 콜을 구현하는 방식이다.
    • 작동 과정
      • 사용자 프로그램이 syscall 명령을 실행 한다.
      • CPU가 해당 트랩 번호에 연결된 예외 핸들러로 점프한다.
      • 커널이 요청을 해석하고 해당 시스템 콜 루틴을 실행한다.
      • 처리 후, 다음 명령어로 복귀한다.

사용자 함수 호출과의 차이

항목 일반 함수호출 시스템 콜(trap)
실행 모드 유저 모드 커널 모드
접근 권한 제한됨 전체 시스템 리소스 접근 가능
스택 사용자 스택 사용 커널이 따로 가진 스택 사용
수행 가능한 명령어 제한적 특권 명령어 실행 가능

 

  • 폴트 : Fault
    • 복구 가능한 오류가 발생 했을 때 생기는 동기적 예외이다.
    • 예외 핸들러가 오류를 수정 한 후, 같은 명령어를 다시 실행 할 수 있다.
    • 처리 흐름
      • CPU가 오류를 감지한다.
      • 해당 오류에 맞는 Fault 핸들러로 제어를 넘긴다.
      • 핸들러가 오류를 수정 할 수 있으면, 문제가 되었던 명령어(I_curr)로 되돌아가 재실행한다.
      • 오류를 수정 할 수 없다면, 커널의 about 루틴으로 제어 이동 후 프로그램을 종료한다.
    • 대표적 예제로 Page Fault가 있다.
      • 가상 주소가 실제 메모리에 없는 경우 발생한다.
      • 핸들러가 디스크에서 해당 페이지를 로드하고, 명령어를 재실행하면 정상 실행된다.

  • Abort
    • 치명적인 복구 불가능 오류 발생 시
    • 일반적으로, 하드웨어 수준의 심각한 오류가 해당된다.
    • 처리 흐름
      • CPU가 치명적인 오류를 감지한다
      • Abort 핸들러가 실행된다 → 즉시 프로그램 종료 루틴을 호출한다
      • 프로그램에 제어를 절대 다시 주지 않는다
    • 대표 예제
      • 메모리 패리티(parity) 에러
      • DRAM, SRAM의 비트 손상

8.1.3 Linux/x86-64 에서의 예외들

  • 운영체제마다 다를 수 있다고 했고 실제로 리눅스에서도 좀 차이가 있다.
  • x86-64에서의 예외 번호 체계
    • 전체 범위는 0 ~ 255, 256개의 예외 번호
  • [0 ~ 31] : 하드웨어 예외
    • 여기는 인텔이 정해둔 표준 예외이다. 어떤 x86-64 CPU를 쓰던 항상 동일한 의미를 갖는다.
    • 사진에서 볼 수 있는 0, 13, 14, 18이 해당한다.
    • 이들은 시스템 동작 중 문제가 발생 했을 때 하드웨어가 직접 트리거하며, 운영체제가 설치한 예외 테이블의 고정 위치에 있는 핸들러로 점프한다.
  • [32 ~ 255] : 소프트웨어 정의 예외
    • 여기는 운영체제가 자유롭게 사용한다.

Faults and Aborts in Linux/x86-64

예외 중에서도 복구 가능 / 불가능 한 상황을 다룬다.

  • Divide Error (예외 번호 0)
    • 0으로 나누거나, 결과가 피연산자 범위를 초과 할 때 발생한다.
    • 즉시 프로그램을 종료하는 것이 처리 방식이다.
    • 리눅스에서는 Floating point exception 출력
  • General Protection Fault (예외 번호 13)
    • 존재하지 않는 가상 주소 접근 시도, 읽기 전용 영역에 쓰기를 시도하는 경우 발생한다.
    • 복구하지 않고 프로그램을 종료하는 것이 처리 방식이다.
    • Segmentation Fault
  • Page Fault (예외 번호 14)
    • 현재 메모리에 없는 페이지 접근인 경우 발생한다.
    • 커널의 페이지 폴트 핸들러가 디스크에서 페이지를 메모리로 로딩하고, 같은 명령어를 재시도 함으로써 처리한다.
    • 사용자 프로그램은 실행을 이어간다.
  • Machine Check (예외 번호 18)
    • 치명적인 하드웨어 오류가 주 발생 원인이다.
    • 복구가 불가함으로 판단하고 즉시 종료한다.
    • 핸들러는 프로그램으로 돌아가지 않는다.

Linux/x86-64 System Calls

  • 시스템 콜은 사용자 프로그램이 운영체제 커널에게 서비스를 요청 할 때 사용하는 인터페이스이다. 트랩을 통해 커널 모드로 전환된다.
  • 시스템 콜 구조
    • 특수 명령어 syscall 수행 → 트랩 발생 → 커널로 진입
    • 커널은 내부 시스템 콜 테이블에서 번호에 맞는 루틴을 호출한다.
  • 인자 전달 규약을 보자. (당연히 x86-64/리눅스 기준)
    • %rax, 시스템 콜 번호
    • %rdi, %rsi %rdx, %r10, %r8, %r9 | 첫번째 부터 1 ~ 6번째 인자.
    • %rax에 반환값이 저장된다.
    • %rcx, %r11 (파괴됨)이 클로버 레지스터
      • 파괴됨이 뭐냐?

8.2 프로세스들

이 책의 1챕터 일부 내용을 다시 인용하겠다 :

현대 시스템에서 프로그램을 실행하면, 우리는 프로그램이 시스템에서 유일하게 실행 중인 것처럼 보이는 환상을 경험합니다.
우리의 프로그램은 프로세서와 메모리를 독점적으로 사용하는 것처럼 보입니다.
프로세서는 프로그램의 명령어를 하나씩 차례대로 실행하는 것처럼 보이고,
프로그램의 코드와 데이터는 시스템 메모리에서 유일한 객체인 것처럼 보입니다.
이러한 환상은 바로 프로세스라는 개념을 통해 제공됩니다.

프로세스란 무엇인가?

프로세스는 실행 중인 프로그램의 인스턴스로 정의된다. 시스템 내의 각 프로그램은 반드시 프로세스라는 컨텍스트에서 실행된다.
이 컨텍스트는 프로그램이 올바르게 실행되기 위해 필요한 상태를 포함한다.

  • 이 상태는 프로그램의 코드와 데이터, 스택, 일반 레지스터의 내용, 프로그램 카운터, 환경 변수 및 열린 파일 디스크립터 등을 포함한다.

프로세스의 생성

사용자가 실행 가능한 객체 파일의 이름을 셸에 입력하면, 셸은 새로운 프로세스를 생성하고 해당 프로그램을 새로운 프로세스의 컨텍스트 내에서 실행한다.

  • 응용 프로그램도 새로운 프로세스를 생성하여 자신만의 코드나 다른 애플리케이션을 그 프로세스의 컨텍스트 내에서 실행 할 수 있다.

운영체제의 구현

프로세스가 프로그램에 제공하는 주요 추상화는 다음과 같다 :

  1. 독립적인 논리적 제어 흐름 : 프로그램은 마치 독점적으로 프로세서를 사용하는 것 처럼 보인다.
  2. 개인적인 주소 공간 : 프로그램은 마치 독점적으로 메모리를 사용하는 것 처럼 보인다.

운영체제는 각 프로그램이 독립적으로 실행되는 것처럼 보이게 하며, 이를 통해 다중 프로세스가 동시에 실행되는 환경에서 프로그램 간의 충돌을 방지한다. 이 추상화들은 프로그램이 다른 프로세스의 영향을 받지 않도록 독립적인 실행 환경을 제공한다.

결론적으로,
프로세스 추상화는 운영체제가 여러 프로그램을 동시에 처리 할 수 있게 하며, 각 프로그램이 독립적으로 실행되는 것처럼 보이게 한다.

 

8.2.1 논리적 Control Flow

프로세스는 각 프로그램에게 자신이 프로세서를 독점적으로 사용하는 것처럼 보이게 하는 환상을 제공한다.
실제로 많은 다른 프로그램이 동시에 시스템에서 실행되고 있음에도 불구하고 말이다.

만약 디버거를 사용해 프로그램을 한 줄 단위로 실행시켜보면 Program Counter 값들이 해당 프로그램의 실행 파일이나 런타임 중에 동적으로 연결된 공유 객체 안의 명령어들에만 대응 된다는 것을 알 수 있다.

  • 이러한 일련의 PC 값들의 흐름을 논리적 제어 흐름 혹은 간단히 논리 흐름이라고 부른다.
  • 위의 그림을 통해 예시를 보자. 세 개의 프로세스가 실행되고있다.
  • 프로세서의 단일한 물리적 제어 흐름은 세 개의 논리 흐름으로 나뉜다. 각각은 하나의 프로세스에 해당한다.
  • 각 세로 줄은 한 프로세스의 논리 흐름 일부를 나타낸다. 각각은 하나의 프로세스에 해당한다. 각 세로 줄은 한 프로세스의 논리 흐름 일부를 나타낸다.
    • 먼저 A가 잠시 실행되고, 그 다음 B가 실행되어 완료된다.
    • 이어서 C가 실행되다가, 다시 A가 실행되어 완료되며, 마지막으로 C가 실행되어 완료된다.

8.2.2 Concurrent Flows

  • 컴퓨터 시스템에서의 논리 흐름은 다양한 형태를 취한다 :
    • 예외 처리기, 프로세스, 시그널 처리기, 쓰레드, 자바 프로세스 등 모두가 해당이다.

동시성

  • 어떤 논리 흐름이 다른 흐름과 시간적으로 겹쳐서 실행된다면, 이를 concurrent flow이라 부르며, 이 두 흐름은 동시에 실행된다고 표현한다.
  • 좀 더 정확하게 말하면, 흐름 X, Y 두 개가 있고 다음 조건 중 하나라도 만족 하는 경우 서로 동시적이라고 할 수 있다 :
    • X가 Y가 시작한 이후에 시작되고, Y가 끝나기 이전에 시작되었거나
    • Y가 X가 시작한 이후에 시작되고, X가 끝나기 이전에 시작된 경우

예를 들어, 제일 가까운 8.12 그림을 보면 :

  • 프로세스 A와 B는 서로 동시적이다.
  • A와 C도 동시적이다. 그러나 B와 C는 그렇지 않다.
    • 왜냐하면 B의 마지막 명령어가 C의 첫 번째 명령어보다 먼저 실행되기 떄문이다.

이처럼 여러 흐름이 동시에 실행되는 현상 전체를 동시성이라고 부른다.

멀티태스킹과 타임 슬라이스

  • 프로세스가 다른 프로세스와 번갈아 가며 실행되는 방식을 멀티태스킹이라고 한다.
  • 각 프로세스가 자신만의 흐름 일부를 실행하는 시간 구간을 타임 슬라이스라고 부르며, 따라서 멀티태스킹은 타임슬라이싱이라고도 부른다.
    • 프로세스 A의 흐름이 두 개의 타임 슬라이스로 이루어져 있음을 위 그림에서 확인 할 수 있다.

병렬성

동시 흐름의 개념은 CPU 코어나 컴퓨터 갯수와는 무관하다. 같은 프로세서에서 실행되더라도 시간적으로 겹친다면 동시적이다.
하지만 떄로는 이 중에서도 특별한 parallel flow 만을 따로 구분해서 사용하는 것이 유용하다. 만약 두 개의 흐름이 서로 다른 CPU 코어나 컴퓨터에서 동시에 실행된다면, 이를 병렬 흐름, 병렬 실행, 혹은 병렬로 실행 중이다라고 표현한다.

 

8.2.3 Private Address Space

프로세스는 각 프로그램에 시스템의 주소 공간을 독점적으로 사용하는 듯한 환상을 제공한다.

  • n비트 주소를 사용하는 컴퓨터에서 주소 공간이란, 가능한 2^n 개의 주소들, 즉 0, 1 … 2^n - 1로 이루어진 집합이다.
  • 프로세스는 각 프로그램마다 고유한 개인 주소 공간 (Private Address Space)을 제공한다.
  • 이 공간이 ‘개인적’이라는 것은, 일반적으로 다른 어떤 프로세스도 해당 주소에 대응하는 메모리 바이트를 읽거나 쓸 수 없다는 의미이다.

  • 각 프로세스의 개인 주소 공간에 저장된 메모리의 내용은 서로 다를 수 있다, 하지만
    • 주소 공간의 전반적인 구조는 모든 프로세스에서 동일한 형태를 따른다.
    • 이 그림은 x86-64 Linux 프로세스의 주소 공간 구조를 보여준다.
      • 주소 공간의 하단 부분은 사용자 프로그램을 위해 예약되어 있다.
        • 여기에는 코드(code), 데이터(data), 힙(heap), 스택(stack) 세그먼트가 존재한다.
      • 특히 코드 세그먼트는 항상 주소 0x400000 에서 시작한다.
      • 주소 공간의 상단 부분은 커널을 위해 예약되어 있다.
        • 이 영역은 프로세스의 system call 등을 처리 할 때 커널이 사용하는 코드, 데이터, 스택을 포함한다.
     

 

8.2.4 User and Kernel Modes

운영체제 커널이 완전한 (= Process-level isolation이 보장된, 프로세스 단위의 구분?) 프로세스 추상화를 제공하기 위해..

  • 프로세서가 프로그램이 수행 할 수 있는 명령어와 접근 할 수 있는 주소 공간을 제한하는 메커니즘을 제공해야 한다.

대부분의 프로세서들은 이 기능을 제어 레지스터의 mode bit를 통해 구현된다.
이 비트는 현재 프로세스가 어떤 권한 수준(privilege level)에서 실행 중인지를 나타낸다.

  • 모드 비트가 설정되어 있으면, 해당 프로세스는 커널 모드 또는 supervisor mode로 실행된다.
    • 커널 모드에서는 모든 명령어를 실행 할 수 있으며, 시스템 내의 어떠한 메모리 주소에도 접근이 가능하다.
  • 모드 비트가 설정되어 있지 않으면, 프로세스는 유저 모드로 실행된다.
    • 유저 모드에서는 다음과 같은 작업이 금지된다 :
      • 프로세서를 중단(halt) 하는 명령어 실행
      • 모드 비트 자체를 변경
      • 입출력 연산 시작
      • 커널 주소 공간에 존재하는 코드나 데이터에 직접 접근
    • 이러한 작업을 시도하면 치명적인 보호 오류(fatal protection fault)가 발생한다.
    • 따라서 사용자 프로그램은 시스템 호출 인터페이스를 통해 간접적으로만 커널 코드나 데이터에 접근 할 수 있다.

일반적으로 애플리케이션 코드가 실행 중인 프로세스는 유저 모드로 시작한다.

  • 유저 모드에서 커널 모드로 전환하는 유일한 방법이 바로 예외가 발생하는 경우이다. 이 예외는 다음이 포함된다 :
    • 인터럽트, 오류, 트랩을 발생시키는 시스템 호출
  • 예외가 발생하면 제외가 예외 핸들러로 넘어가면서, 이때 프로세서가 모드를 유저 모드에서 커널 모드로 전환한다.
    • 핸들러는 커널 모드에서 실행되며, 작업이 끝난 뒤 프로그램 코드로 복귀 할 때에는 다시 유저 모드로 전환된다.

리눅스는 유저 모드 프로세스가 커널 내부 데이터를 확인 할 수 있도록 하는 독창적인 메커니즘을 제공한다.

  • 이것이 바로 /proc 파일 시스템이다.
    • /proc 은 많은 커널 데이터 구조의 내용을 텍스트 파일 형태로 계층 구조로 내보내며, 유저 프로그램은 이를 읽기만 가능하다.
    • 예를 들어..
      • /proc/cpuinfo : CPU 종류 및 관련 정보 확인
      • /proc/<process id>/maps : 특정 프로세스가 사용하는 메모리 세그먼트 정보 확인

 

8.2.5 Context Switches

운영체제 커널은 문맥 교환이라는 고수준의 예외적 제어 흐름 방식을 이용해 멀티태스킹을 구현한다.

  • 이 문맥교환 메커니즘은 저수준 예외 처리 메커니즘을 기반으로 구성된다.
  • 커널은 각 프로세스마다 하나의 문맥을 유지한다. 이 문맥은 선점(preempt)된 프로세스를 다시 시작하는 데 필요한 상태 정보로 구성된다. 여기에는 다음과 같은 항목들이 포함된다 :
    • general-purpose registers, 범용 레지스터
    • floating-point registers, 부동소수점 레지스터
    • Program Counter, 프로그램 카운터
    • user’s stack, 사용자 스택
    • status registers, 상태 레지스터
    • kernel’s stack, 커널 스택
    • 그리고 다음과 같은 커널 데이터 구조들
      • 주소 공간 정보를 담은 page table
      • 현재 프로세스의 정보를 담은 process table
      • 열린 파일 정보를 담은 file table

프로세스가 실행되는 도중 커널은 현재 실행 중인 프로세스를 선점하고, 이전에 선점된 다른 프로세스를 재시작하는 결정을 내릴 수 있다.

  • 이러한 결정을 스케줄링이라고 하며, 이는 커널 내부의 스케줄러 라는 코드에 의해 처리된다.
  • 커널이 새로운 프로세스를 실행하도록 선택하면, 우리는 해당 프로세스가 스케줄되었다.. 고 말한다.
    • 스케줄링된 프로세스를 실제로 실행하기 위해, 커널은 문맥 교환을 수행하게 된다.
    • 문맥 교환은 다음 세 가지 작업으로 이루어진다 :
      1. 현재 프로세스의 문맥을 저장한다.
      2. 이전에 선점되었던 다른 프로세스의 문맥을 복원한다.
      3. 복원된 프로세스에게 제어권을 넘긴다.
  • 문맥 교환은 커널이 사용자 프로세스를 위해 시스템 호출을 처리 중일 때도 발생 할 수 있다.
  • 예를 들어, 시스템 호출이 어떤 이벤트를 기다려야(blocking) 한다면, 커널은 현재 프로세스를 잠자게(sleep) 하고 다른 프로세스로 문맥 전환 할 수 있다.
    • read 시스템 호출이 디스크 접근을 필요로 하는 경우, 커널은 디스크로부터 데이터가 도착하기를 기다리는 대신, 다른 프로세스를 실행하도록 문맥 교환을 수행 할 수 있다.
  • sleep 시스템 호출은 호출한 프로세스를 명시적으로 잠재우는 요청이며, 이 역시 문맥 교환을 유발 할 수 있다.
  • 일반적으로는 시스템 호출이 블로킹되지 않더라도, 커널은 필요에 따라 문맥 교환을 수행 할 수 있다.

 

  • 문맥 교환은 인터럽트에 의해서도 발생 할 수 있다 :
    • 대부분의 시스템은 1ms / 10ms 마다 발생하는 주기적인 타이머 인터럽트는 발생시키는 메커니즘을 가지고 있다.
    • 타이머 인터럽트가 발생 할 때마다 커널은 현재 프로세스가 충분히 실행되었다고 판단하고 새로운 프로세스로 전환 할 수 있다.
  • 위 그림에서 프로세스 A와 B 사이의 문맥 교환 예시를 보여준다.
    1. 처음에 프로세스 A는 사용자 모드에서 실행 중이며, read 시스템 호출을 실행하면서 커널로 트랩된다.
    2. 커널은 디스크 컨트롤러부터의 DMA 전송 요청을 보낸다.
    3. 그리고 디스크 전송이 완료되었을 때 디스크가 인터럽트를 보내도록 준비한다.
    4. 디스크는 데이터를 가져오는 데 상대적으로 오랜 시간이 걸리므로, 커널은 기다리는 대신 프로세스 B로 전환한다.
  • 중점적으로 봐야할 것은
    • 처음에는 커널이 사용자 모드에서 프로세스 A의 명령을 실행하고 있다.
    • 이후에는 커널 모드에서 프로세스 A를 위한 작업을 수행한다.
    • 그리고 나서 커널 모드에서 프로세스 B를 위한 작업을 시작한다.
    • 마지막으로는 프로세스 B를 사용자 모드로 전환하여 실행한다.

이후 프로세스 B가 사용자 모드에서 어느 정도 실행되다가, 디스크로부터 데이터 전송 완료 인터럽트가 발생하면, 커널은 B의 실행을 중단하고 A로 문맥 교환을 수행한다.

  • 문맥이 복원된 프로세스 A는 read 시스템 호출 직후의 명령어부터 다시 실행되며, 이후 다음 예외가 발생 할 때까지 실행을 계속한다.

8.3 System Call Error Handling

유닉스 시스템 수준 함수들이 오류를 만나면 일반적으로는 -1을 반환한다.

  • 그리고 전역 정수 변수인 errno를 설정하여 무슨 오류가 발생했는지를 나타낸다.
  • 프로그래머는 항상 오류를 확인해야하지만 코드가 길어지고 읽기 어려워진다는 이유로 생략하는 경우가 많다.

예를 들어 Linux의 fork 함수를 호출 할 때 오류를 확인하는 방식이다 :

  • strerror 함수는 주어진 errno 값에 해당하는 오류 설명 문자열을 반환한다.
  • 이러한 코드의 복잡성을 줄이기 위해,다음과 같은 오류 보고 함수를 정의 할 수 있다.

 

 

void unix_error(char *msg) {
    fprintf(stderr, "%s: %s\n", msg, strerror(errno));
    exit(0);
}
  • 이 함수를 사용하면, fork 호출 코드를 더 줄일 수 있다.

 

 

if ((pid = fork()) < 0)
    unix_error("fork error");

코드 간결화를 위해서 에러 처리 래퍼 함수(error-handling wrapper)를 사용 할 수 있다.

  • 기본 함수 foo가 있을 때, 첫 글자를 대문자로 한 Foo 래퍼 함수를 정의한다. 얘는 다음을 수행한다 :
    • 기본 함수 foo를 호출한다. 오류가 있는지 확인하고, 있다면 적절히 종료해야한다.

 

 

pid_t Fork(void) {
    pid_t pid;

    if ((pid = fork()) < 0)
        unix_error("Fork error");
    return pid;
}

이제 한 줄에 호출을 할 수 있다.

 

 

pid = Fork();

8.4 Process Control

유닉스는 C 프로그램에서 프로세스 조작을 위한 여러 시스템 호출들을 제공한다.

8.4.1 Obtaining Process IDs

각 프로세스는 Process ID, 줄여서 PID라고 부르는 고유한 양의 정수를 가진다.

  • getpid : 호출한 프로세스 자신의 PID를 반환한다.
  • getppid : 호출한 프로세스를 생성한 부모 프로세스의 PID를 반환한다.

 

 

#include <sys/types.h>
#include <unistd.h>

pid_t getpid(void);
pid_t getppid(void);

두 함수 모두 pid_t 타입(실제로는 int)의 값을 반환한다.

8.4.2 Creating and Terminating Processes

프로그래머에서 프로세스는 다음 세 가지 중 한 상태에 있을 것이다:

  • Running
    • 프로세스가 CPU에서 실행 중이거나 실행 대기 중이다.
    • 실행 대기 중인경우, 곧 커널에 의해 스케줄리 되어 실행 될 예정인 상태이다.
  • Stopped
    • 프로세스의 실행이 일시 중단되어 커널이 스케줄링 하지 않는 상태이다.
    • 다음과 같은 signal을 받을 때 정지된다 :
      • SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU
    • SIGCONT 시그널을 받으면 다시 Running 상태로 전환된다.
  • Terminated
    • 프로세스가 영구히 종료된 상태이다. 다음 중 하나로 인해 종료된다 :
      1. 종료 동작이 기본인 시그널을 받은 경우
      2. main 함수에서 return한 경우
      3. exit 함수를 호출한 경우

 

 

#include <stdlib.h>
void exit(int status);
// This function does not return
  • exit() 함수
    • exit(status)는 현재 프로세스를 종료하면서 종료 상태 코드 status를 전달한다.
    • main() 함수에서 정수 값을 return 하는 것도 같은 방식으로 종료 상태를 전달한다.
  • 부모 프로세스가 자식 프로세스를 생성 할 때 fork() 함수를 이용한다.

 

 

#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);
  • 리턴 값
    • 0 → 자식 프로세스 내에서 반환
    • 자식의 PID → 부모 프로세스 내에서 반환
    • -1 → 실패하는 경우 반환

fork 후 부모와 자식의 차이점과 공통점

항목 부모 프로세스 자식 프로세스
PID 서로 다름 서로 다름
가상 주소 공간 내용은 동일하지만, 독립된 복사본이다  
파일 디스크럽터 동일한 복사본을 공유한다.  
실행 흐름 각자 독립적으로 병렬적으로 실행한다.  

가상 주소 공간이 복사되는건 맞는데, Linux는 Copy-On-Write 기법을 사용하여 효율적으로 관리한다

 

 

parent: x=0
child : x=2
  • 예시 프로그램의 결과를 보자. x = 1 이라는 초기 값을 가진 후에
    • 자식은 x += 1 → 2를 출력한다
    • 부모는 x -= 1 → 0을 출력한다
  • 출력 순서는 실행 환경에 따라 달라질 수 있다.
  • fork() 호출 전후로의 미묘한 특징 원리
    1. Call once, return twice
      • fork()는 부모에서 한 번 호출된다. 부모 프로세스에는 자식의 PID를, 자식 프로세스에는 0을 반환한다.
      • 이중 반환이라는 특성 때문에, 분기를 통해 부모/자식의 실행 경로를 나눌 수 있다.
      • 자식이 여러 개인 경우, fork()을 반복적으로 여러분 호출하는데, 누가 누구인지 헷갈리기 때문에 논리적인 추적이 필요하게 되었다.
    1. Concourrent execution
      • 부모와 자식은 완전히 별개의 프로세스로, 동시에 실행 될 수 있다.
      • 커널이 스케줄링 하기 때문에, 명령어 실행 순서는 매번 달라질 수 있다.
      • 따라서 출력 순서를 전제로 한 로직을 작성해서는 안된다. child에 대한 출력이 반드시 뒤에 따라온다고 전제해선 안된다는 것.
    1. Duplicate but separeate address spaces
      • fork() 직후, 부모와 자식의 가상 주소 공간은 동일하게 보인다.
        • 같은 스택, 같은 힙, 같은 코드, 같은 지역 변수 등.
        • int x = 1; 이라면 부모 자식 모두 x는 1에서 시작한다. 하지만 이는 복사본일 뿐이니, 서로 내용이 바뀌어도 같은 것을 참조하는게 아니니 별개의 값이다.
    1. Shared files
      • 파일 디스크립터는 복사본이 아니라 공유된다.
        • 예를 들어, stdout은 부모가 fork() 하기 전 열려 있었기 때문에, 자식도 그것을 그대로 공유한다.
        • 따라서 둘 다 printf()을 호출하면 같은 화면 | stdout에 출력된다.
      • 다만, 출력 타이밍이나 순서는 커널 스케줄링이나 버퍼링의 영향을 받는다.

사진에 있는 것은 fork에 대한 프로세스 그래프인데, 이것을 그려보는 것은 도움이 된다.

  • 이 그래프는 프로세스의 실행 흐름과 생성 구조를 시각적으로 표현해 주기 떄문에, 중첩된 fork()가 있는 경우 매우 유용하다.

프로세스 그래프

  • 정의 : fork()를 사용하는 프로그램의 명령 실행 순서를 부분 순서로 나타내는 유형 그래프이다.
  • 정점은 하나의 명령어 실행을 나타낸다. printf, fork, x = 1..
  • 간선은 이 명령이 저 명령보다 먼저 실행된다는 것을 의미한다.
  • 출력 정보나 변수 값등을 정점이나 간선에 라벨로 붙일 수 있다.
  • 시작 정점은 항상 main() 호출익, 끝 정점은 exit() 호출이다.

핵심 특징

  1. 병렬 프로세스 간의 실행 순서 시각화
    • 부모와 자식이 동시에 실행되기 때문에, 어떤 명령이 먼저 실행될지는 결정되어 있지 않다.
    • 즉, printf(”parent”)와 printf(”child”)의 실행 순서는 서로 교차가 가능하다.
  1. Topological Sort로 가능한 실행 순서를 확인하기
    • 그래프의 정점들을 왼쪽에서 오른쪽으로 나열했을 때, 모든 간선이 왼쪽에서 오른쪽으로 흐르면 → 유효한 실행 순서이다.
    • 이로써 다양한 출력 결과가 나올 수 있음의 근거가 된다.

Reaping Child Processes

프로세스가 종료된 이후에 바로 사라지는 것이 아니다.

  • 프로세스가 exit() 등을 포함한 다양한 방식으로 종료되면 즉시 시스템에서 제거되지 않는다.
  • 커널은 종료된 프로세스를 보존하고, 종료 상태 등을 포함한 다양한 정보를 부모에게 전달할 준비를 한다.
    • 이 상태의 프로세스를 좀비 프로세스라고 부른다.

좀비 프로세스

  • 종료는 되었지만 아직 부모가 수거(reap)하지 않은 상태의 프로세스를 말한다.
  • 실행되지는 않지만, 프로세스 테이블의 항목과 일부 시스템 자원을 점유하고 있다.

부모 프로세스가 먼저 종료되면?

  • 커널이 init process(PID 1)을 부모로 지정하여 자식들을 입양시킨다.
  • init은 모든 고아 자식 프로세스를 자동으로 수거하며, 따라서 시스템에 떠도는 좀비는 남지 않게 된다.
  • 예외로, 서버나 쉘 같은 장기 실행 프로세스는 직접 자식들을 수거해야 한다.

 

  • 부모가 자식을 수거하는 함수 : waitpid()

 

 

#include <sys/types.h>
#include <sys/wait.h>

pid_t waitpid(pid_t pid, int *status, int options);
// Returns: PID of child if OK, 0 (if WNOHANG), or−1 on error
  • 종료된 자식 프로세스를 수거하기 위한 함수이다.
  • 호출한 부모 프로세스는 다음 중 하나가 발생할 때까지 대기한다 :
    • 자식이 종료된 경우
    • 자식이 정지(SIGSTOP 등) 된 경우
    • 자식이 다시 실행(SIGCONT 등) 된 경우
  • 자식이 이미 종료된 상태라면 즉시 반환한다.
  • 반환값은 종료된 자식의 PID이다.
  • 이 호출을 통해 커널은 해당 자식 프로세스를 완전히 제거한다.

pid 인자를 작성 할 때

  • pid > 0 : 특정한 pid 한 개에 대해서만 기다린다.
  • pid == -1 모든 자식 프로세스를 대상으로 한다. 기본적으로 가장 많이 사용 된다.

옵션 없이 호출할경우 기본 동작

  • options = 0 이라면 :
    • 즉시 종료된 자식이 있으면 즉시 반환한다
    • 그렇지 않으면 블록되어 자식이 종료되기를 기다린다

옵션을 갖고 동작하는 경우

  • WNOHANG : 종료된 자식이 없으면 0 반환하고 기다리지 않는 즉시 반환
  • WUNTRACED : 정지된 자식도 기다린다. 예를 들어 SIGTSTP로 일시 중단된 경우도 감지
  • WCONTINUED : 정지 후 재개된 자식도 기다린다. SIGCONT로 재개된 자식까지 포함한다.

waitpid(-1, &status, WNOHANG | WUNTRACED); 같이 or 연산으로 조합이 가능하다.

종료 상태 해석 (status)

  • status는 자식의 종료 이유와 관련된 정보를 담고 있으며, 매크로 함수를 사용해 해석한다.
    • WIFEXITED(status)는 정상 종료한 경우 true
    • WEXITSTATUS(status)는 exit(n) 또는 return n 에서의 n값을 반환한다.
  • 시그널로 인한 종료 확인
    • WIFSIGNALED(status)는 시그널로 종료된 경우 true
    • WTERMSIG(status)는 종료를 유발한 시그널 번호를 반환한다.

정지 및 재개 여부를 확인하기

  • WIFSTOPPED(status)는 자식이 정지된 상태인 경우 true를 반환한다.
  • WSTOPSIG(status)는 정지시킨 시그널 번호를 반환한다.
  • WIFCONTINUED(status)는 SIGCONT로 재개된 경우 true를 반환한다.

오류 상황 처리

자식이 없는경우 -1를 반환하고 errno는 ECHILD를 띄운다. 신호로 인터럽트 된 경우, -1를 반환하고 EINTR를 반환한다.

 

wait() function은 waitpid()의 보다 간단한 버전인데, 자식 프로세스의 종료를 기다리는 것이 주 역할이다.

 

 

#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *statusp);
  • 기능 : 자식 프로세스의 종료를 기다리고, 종료 상태는 statusp 으로 전달한다.
  • 반환값 : 자식 프로세스의 PID 또는 오류 시 -1을 반환한다.
  • 사용법 : wait(&status)는 사실 waitpid(-1, &status, 0)와 동일하다. 즉, 모든 자식 프로세스가 종료되기를 기다린다.

기본적으로, wait()은 waitpid(-1, &status, 0)와 동일한 방식으로 작동하므로, statusp에 자식 프로세스의 종료 상태가 담기게 된다.

  • 이 때, statusp를 사용하여 자식이 정상적으로 종료되었는지, 시그널로 종료되었는지 등을 확인 할 수 있다.

여기서 waitpid 실제 사용을 확인 할 수 있다.

  1. 자식 프로세스 실행
    • 여기서 라인 12는 자식 프로세스에서만 실행되며, 부모 프로세스에서는 실행되지 않는다.
    • 이는 fork() 함수가 자식 프로세스를 생성한 후, 자식 프로세스는 fork() 이후의 코드를 실행하고, 부모 프로세스는 자신만의 fork() 이후 코드를 실행하기 때문이다. 즉, 부모와 자식은 각자 다른 흐름으로 코드를 실행한다.
  1. 자식 프로세스 기다리기
    • 부모는 라인 15에서 waitpid를 사용하여 자식 프로세스가 종료될때까지 기다린다. pid 인자가 -1로 설정되어 있기 때문에, 부모는 자식 프로세스가 종료될때까지 기다리게 된다.
    • 부모 프로세스는 각 자식 프로세스가 종료될 때마다 waitpid가 반환하며, 종료된 자식의 PID를 얻는다. 종료된 자식에 대한 상태를 확인하려면 부모가 waitpid 함수의 반환값을 이용해야한다.
  1. 자식 프로세스의 종료 순서
    • 프로그램을 실행하면 자식들이 어떤 순서로 종료되든지 상관없이 출력이 이루어진다. 이 출력 순서는 시스템의 실행 환경에 따라 달라질 수 있다.
    • 다시 말해, 자식들이 순차적으로 종료되는 순서가 비결정적이며, 시스템에 따라 달라진다. 이것은 예측하기 어렵다.
  1. 자식 프로세스를 생성한 순서대로 기다리기
    • 아래 있는 그림은 부모가 자식의 PID를 지정하고, 자식이 생성된 순서대로 waitpid를 호출하여 자식 프로세스가 생성된 순서대로 종료되기를 기다린다. 이 경우, 출력 순서가 결정적이 되어 자식들이 생성된 순서대로 종료되고, 그 순서대로 출력된다.

 

8.4.4 Putting Processes to Sleep

 

 

#include <unistd.h>
unsigned int sleep(unsigned int secs);
// Returns: seconds left to sleep

기능 : 현재 프로세스를 지정한 초 단위의 시간만큼 중지시킨다.
반환 값 : 남은 초를 반환한다. 즉, 정상 경과시 0 반환, 시그널로 인터럽트되면 남은 시간이 반환된다.
사용 예 : 시간 지연, 프로세스 간 타이밍 제어, 디버깅 등에 필요하다.

 

 

#include <unistd.h>
int pause(void);
// Always returns−1

기능 : 시그널이 수신될 떄까지 프로세스를 무기한 정지시킨다.
반환 값 : 항상 -1를 반환하며, errno는 EINTR로 설정된다. 시그널이 오면 깨면서 종료된다.
사용 예 : 프로세스를 명시적으로 대기 상태로 두고, 시그널을 통해 깨어나야 하는 상황에서 사용한다. (알람 시그널 등)

8.4.5 Loading and Running Programs

 

 

#include <unistd.h>
int execve(const char *filename, const char *argv[],
const char *envp[]);
// Does not return if OK; returns−1 on error
  • evecve 함수는 현재 프로세스의 메모리 공간을 완전히 새로운 프로그램으로 교체하는 시스템 호출이다.
    • 즉, 기존 코드, 데이터, 스택 등은 사라지고, 지정한 새로운 실행 파일로 덮어씌워진다.
  • 개요
    • filename : 실행할 실행 파일 경로
    • argv : 인자 리스트 (문자열 포인터 배열), argv[0]은 관례적으로 프로그램 이름을 말한다.
    • envp : 환경 변수 리스트, name=value 형태의 문자열 포인터 배열이다.
    • 반환값 : 성공시 반환되지 않으며, 실패 시 -1를 반환한다.

  • evecve 함수가 호출되면 새로운 프로그램이 현재 프로세스를 덮어쓰고, 그 프로그램의 start-up 코드가 실행되며 main 함수로 제어가 넘어간다.
    • 이 때 스택(Stack)의 구조가 중요하며, 아래에 정리한 내용은 그 구조와 동작을 단계적으로 설명한다.

 

 

// 메인 함수의 프로토 타입
int main(int argc, char **argv, char **envp);
// 또는
int main(int argc, char *argv[], char *envp[]);

구성 설명

구성 요소 설명
환경 변수 문자열들 "PATH=/bin" 등, 문자열 자체
인자 문자열들 "./hello", "arg1" 등
envp[] 포인터 배열 문자열들의 주소값을 가리키는 배열. 마지막은 NULL
argv[] 포인터 배열 인자 문자열들의 주소값을 가리킨다. 마지막은 NULL
__libc_start_main의 스택 프레임 실제 main() 호출을 담당하는 시작 함수의 프레임이다.
  • 이 스택 구조는 start-up code(__libc_start_main)가 설정해주며, main() 함수에 적절한 포인터 배열이 전달되도록 한다.
  • 전역 변수 environ은 envp[0]을 가리킨다.

관련 상수 및 규약

  • argv[argc] == NULL은 표준 규약이며, 이를 통해 인자 리스트 종료를 감지 할 수 있다.
  • 마찬가지로 envp[n] == NULL도 종료를 알린다.
  • 문자열들은 스택상 연속된 영역에 저장되며, 각각의 포인터들이 이를 가리키는 구조이다.

main 함수의 세 인자에 대해서 이어서 알아보자..

  • int argc, argv[] 배열 내 널이 아닌 포인터의 개수. 즉 인자의 개수를 말한다.
  • char **argv, 인자 문자열들의 포인터 배열. argv[0]은 프로그램 이름을 말한다.
  • char **enp, 환경 변수 문자열들의 포인터 배열이다. 각 문자열은 “KEY=VALUE” 형식이다.

이 세 인자는 x86-64 스택 규약에 따라 레지스터에 저장된 후, __libc_start_main에 의해 main()에 전달된다.

환경 변수 관련 함수들 (stdlib.h)

 

 

 #include <stdlib.h>
char *getenv(const char *name);
// Returns: pointer to name if it exists, NULL if no match
  • 환경 변수 “name=value” 중 “name”에 해당하는 항목을 찾고, value를 가리키는 포인터를 반환한다. 못 찾으면 NULL을 반환한다.

 

 

#include <stdlib.h>
int setenv(const char *name, const char *newvalue, int overwrite);
//Returns: 0 on success,−1 on error
  • 환경 변수 name을 value로 설정한다.
  • 만약 name 이 존재 할 경우,
    • overwrite == 0, 아무것도 하지 않는다. overwrite ≠ 0, 기존 값을 value로 덮어 쓴다.
  • 존재하지 않는 경우, 새로 추가한다.

 

 

void unsetenv(const char *name);
//Returns: nothing
  • 환경변수 “name=value”에서 해당 name을 제거한다.

 

8.4.6 Using fork and execve to Run Programs

간단한 Unix Shell의 작동 흐름을 보자.

  1. main 루틴의 구조
     
    • main 루프는 무한 루프이며, 사용자의 명령어를 반복적으로 받고 처리한다.

  1. eval 함수의 역할
    • parseline : 명령어를 argv[] 형식으로 분리한다.
    • builtin_command : exit, cd 같은 내장 명령어인지 확인한다.
    • fork : 자식 프로세스를 생성한다.
    • execve : 자식에서 새로운 프로그램을 실행한다.
    • wait : foreground 인 경우, 자식 종료까지 기다린다.

  1. parseline 함수의 핵심
  • &가 마지막 인자이면 백그라운드 실행이므로 1을 반환한다. 아니면 0을 반환하여 포그라운드를 실행한다.

실행 흐름을 정리하면 :

  • 사용자가 명령어를 입력한다.
  • 쉘이 명령어를 파싱한다. (parseLine 을 이용해서)
  • 내장 명령어만 바로 처리하거나, 아니면 fork()로 자식 프로세스를 생성한다.
  • 자식이 execve로 프로그램을 실행하고 부모는 foreground면 wait(), background면 대기 없이 진행한다.

 

8.5 Signals

Linux Signal을 알면, 소프트웨어 수준의 예외 처리 방식을 배우게 된다.

  • 시그널이란?
    • 시그널은 프로세스 간의 비동기적인 인터럽트 통신 수단이다.
    • 커널 또는 다른 프로세스가 특정 프로세스에게 something happened!!! 라고 알려주는 방식이다.
    • 예를 들어 사용자가 Ctrl + C 를 눌렀을 때 커널은 현재 실행 중인 프로세스에 SIGINT 시그널을 보낸다.
  • 시그널의 예시는 위의 그림에서 확인 해 볼 수 있다.
    • 예를 들어 9번의 SIGKILL은 프로그램을 강제 종료시킨다.

 

동작 흐름을 요약하면 :

  • 시그널이 발생한다. 그럼 커널이 대상 프로세스에 시그널을 전달한다.
  • 프로세스는 해당 시그널에 대한 기본 동작을 수행하거나 등록한 핸들러 함수가 있다면 그것을 실행한다.

8.5.1 Signal Terminology

시그널의 전달 및 수신 과정의 동작 원리를 다룬다. 시그널 시스템의 핵심 메커니즘을 이해하는데에 중요하다.

시그널 전송에는 두 단계로 나뉜다 :

  1. 전송 | Sending
    • 시그널은 커널이 어떤 이벤트를 감지했을 때, 또는 프로세스가 kill 함수를 호출 했을 때 발생한다.
    • 0으로 나누면 SIGFPE, 자식이 종료되면 SIGCHLD, 사용자가 kill(pid, SIGINT) 호출 시 대상 프로세스에 SIGINT 전송.
    • 자기 자신에게도 시그널을 보내는 것이 가능하다.
  1. 수신 | Receiving
    • 시그널이 수신되는 시점은 커널이 프로세스의 실행을 중단하고, 시그널에 대응하는 동작을 즉시 수행 할 때이다.
    • 수신 시 가능한 반응이 여러가지 있다 :
      1. 무시, Ignore - 대부분의 시그널은 무시 할 수 있다.
      2. 기본 동작, Default - 예를 들어, 종료나 정지 등
      3. 핸들링, Catch - 사용자가 작성한 시그널 핸들러 함수를 실행한다.

Pending SIgnal : 보류중인 시그널

  • 시그널은 비동기적으로 전달되기 때문에 어떤 시점에서는 보류 중일 수도 있다.
  • 보류 중이라는 것은, 시그널이 커널에 의해 전송되었지만 아직 수신되지 않은 상태를 말한다.

중요 제약

  • 같은 종류의 시그널은 여러 개가 큐잉되지 않는다. 예를 들어 SIGINT가 두 번 보내졌으면, 한 번만 수신된다.
  • 각 프로세스는 pending 시그널들을 비트 벡터로 추적한다. (pending[k])
    • 시그널 k가 도착하면 pending[k] = 1, 수신되면 pending[k] = 0

Blocked Signal : 차단 시그널

  • 프로세스는 일부 시그널을 차단 할 수 있다.
    • 차단된 시그널은 도착은 하지만, 수신되는 것은 아니다.
    • 도착한 시그널은 pending 상태로 대기한다.
  • 이후에 프로세스가 그 시그널을 차단 해제하면 수신된다.

8.5.2 Sending Signals

Process Group

  • 모든 프로세스는 정확히 하나의 프로세스 그룹에 속한다.
  • 각 그룹은 양의 정수인 프로세스 그룹 ID, PGID로 식별된다.
  • PGID는 일반적으로 그룹 리더의 PID와 동일하다.
#include <unistd.h>

pid_t getpgrp(void);        // 현재 프로세스의 프로세스 그룹 ID 반환
pid_t getpgid(pid_t pid);   // 특정 PID의 프로세스 그룹 ID 반환
int setpgid(pid_t pid, pid_t pgid); // 프로세스의 그룹 ID를 설정, 성공 시 0, 실패 시 -1 반환

 

시그널은 개별 프로세스 뿐만 아니라 프로세스 그룹 전체에 전송 할 수 있다.

  • 예를 들어, 쉘에서 어떤 파이프라인 명령을 실행하면, 관련된 모든 프로세스들이 같은 그룹에 속하게 된다.
  • 이 때, 사용자가 Ctrl+C (SIGINT)를 입력하면 그 그룹 전체에 시그널이 전달된다.
      • /bin/kill -9 15213 : PID 15213번 프로세스에게 SIGKILL 시그널을 전송하고 → 즉시 종료시킨다
      • /bin/kill -9 -15213 : 프로세스 그룹 ID가 15213인 모든 프로세스에 SIGKILL을 전송한다./bin/kill 명령어로 시그널 보내기
    • /bin/kill과 쉘 내장 kill은 조금 다르다.

  • 키보드로 시그널 보내기
    • 쉘에서의 Job 개념
      • 쉘은 한 줄의 명령으로 생성된 프로세스 묶음을 하나의 job으로 관리한다.
      • 각 job은 하나의 프로세스 그룹으로 구성된다. 각 job은 다음 중 하나에 속한다 :
        • Foreground job : 사용자 입력을 직접 받는 작업이다.
        • Backgorund job : 백그라운드에서 실행된다. (& 기호를 사용한다)
      • 예를 들어, $ ls | sort 을 보자.
        • ls와 sort는 파이프로 연결된 두 개의 프로세스이다.
        • 쉘은 이 둘을 하나의 프로세스 그룹으로 묶는다.
        • 이 프로세스 그룹은 foreground job 이므로 키보드 입력에 반응한다.
  • 시그널과 Job 제어
    • 시그널은 프로세스 그룹 전체에 전송된다. 그래서 ls | sort 같은 명령에서 Ctrl+C를 누르면 둘 다 종료된다.
    • 시그널과 Job 제어
키보드 입력 전송 시그널 효과
Ctrl C SIGINT (2) foreground job을 중단한다 : 보통 종료
Ctrl Z SIGTSTP (20) foreground job을 일시 정지 한다 : stopped 상태
fg 없음 가장 최근 stopped job을 foreground로 복귀 시킨다.
bg 없음 가장 최근 stopped job을 background로 전환한다.

 

  • kill() 함수를 이용하여 프로세스 간 시그널을 전송하는 방법을 보자. /bin/kill의 C 버전으로 생각하자.
#include <sys/types.h>
#include <signal.h>
int kill(pid_t pid, int sig);
// Returns: 0 if OK,−1 on error
  • pid : 시그널을 받을 대상 프로세스를 식별한다.
    • 0보다 크면, 해당 PID를 가진 단일 프로세스를 향한 시그널을 보낸다.
    • 0과 같다면, 현재 프로세스 그룹 전체에 시그널을 보낸다. 물론 이것은 자기 자신을 포함한다.
    • 0보다 작다면, 절댓값이 PGID인 프로세스 그룹 전체에 시그널을 전송한다.
    • -1 이면, 자신을 제외한 전체 프로세스에 전송하나, 위험해서 잘 쓰는것은 아니다.
  • sig : 전송 할 시그널 번호, 예를 들어 SIGKILL, SIGINT 등

  • 여기서는 자식에게 kill을 보내는 예제를 확인 할 수 있다.

alarm()은 일정 시간이 지나면 프로세스 자신에게 SIGALRM 시그널을 보내도록 커널에 예약하는 기능이다.

#include <unistd.h>
unsigned int alarm(unsigned int secs);
// Returns: remaining seconds of previous alarm, or 0 if no previous alarm
  • secs 초가 지난 후, 커널이 호출한 프로세스에게 SIGALRM 시그널을 보낸다.
  • SIGALRM은 기본적으로 프로세스를 종료시키는 시그널이지만, 사용자가 시그널 핸들러를 지정하면 원하는 동작을 수행 할 수 있다.
  • secs가 0이면 예약된 알람을 취소한다.
  • 반환값은 취소된 이전 알람까지 남은 시간이며, 없었다면 0을 반환한다.

주의사항

  • 한 번에 하나의 알람만 예약 가능하다. 새 alarm() 호출 시, 이전 예약은 취소된다.
  • alarm()은 일회성이다. 주기적인 동작을 원한다면 setittimer() 사용이 필요하다.
  • SIGALRM 시그널을 받을 수 있도록 핸들러를 등록하지 않으면, 기본 동작인 프로세스 종료가 실행된다.

8.5.3 Receiving Signals

시그널 처리 흐름과 커널이 시그널을 전달하는 방식에 대해 논한다.

시그널 처리 흐름

  • 커널 모드에서 사용자 모드로 전환 될 떄
    • 커널은 프로세스 p의 수신 대기 중인 시그널(pending) 중에서 차단되지 않은 시그널이 있는지 확인한다.
    • 수식 : pending & ~blocked
    • 이 집합이 비어 있다면, 다음 명령어 Inext로 제어를 넘긴다.
    • 비어 있지 않다면, 커널은 그 중 하나(보통 가장 번호가 작은 시그널)을 선택하여 p에 시그널을 수신하도록 강제한다.
  • 시그널 수신 시 기본 동작. 시그널은 기본적으로 아래 네 가지 동작 중 하나를 유발한다:
    1. 프로세스 종료
    2. 프로세스 종료 + 코어 덤프 생성
    3. 프로세스 일시 정지 (SIGSTOP, SIGTSTP) 등
    4. 무시 (SIGCHLD) 등

 

 

#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
// Returns: pointer to previous handler if OK, SIG_ERR on error (does not set errno)
  • 시그널의 동작을 사용자 정의 함수로 대체 할 수 있다.
  • handler 인자의 의미
    • SIG_IGN : 해당 시그널을 무시한다.
    • SIG_DFL : 기본 동작으로 복원한다.
    • 함수 포인터 : 시그널을 받았을 때 실행 할 사용자 정의 함수 등록.
  • 시그널 핸들링의 흐름
    1. 시그널 발생
    2. 등록된 핸들러 함수 호출 (void handler(int signum))
    3. 핸들러가 종료되면, 일반적으로 인터럽트가 발생한 명렁어 다음으로 제어가 복귀된다.
  • 단, 일부 시스템 호출은 시그널에 의해 즉시 중단되고 오류 반환하기도 한다.

  • 시그널 핸들러는 중첩도 될 수 있다.
    • 기본 흐름으로..
    1. 메인 프로그램 실행 중에 시그널 s를 받으면 → 핸들러 S로 제어가 이동한다. 이 와중에 main 함수는 일시 중단된다
    2. S가 실행 중일 때, 다른 시그널 t를 수신하면 → 핸들러 T로 제어가 이동한다. 이 와중에 핸들러 S는 일시 중단된다.
    3. T의 실행이 끝나면, 다시 핸들러 S의 중단된 부분부터 재개한다.
    4. S가 끝나면 다시 메인 프로그램의 중단된 지점부터 재개한다.

바로 위에 있는 8.31에서 해당 내용을 확인 해 볼 수 있다 : 이것이 가능한 이유는..

  • 리눅스는 시그널 핸들러 실행 시, 기존 실행 흐름을 스택 프레임에 보존한다.
  • 새 시그널 핸들러는 새로운 스택 프레임에서 실행되므로 중첩이 가능하다.
  • 핸들러 간 전이는 함수 호출과 비슷하지만 비동기적으로 발생한다.

하지만, 이런 중첩은 프로그램에 의도하지 않은 부작용이나 경쟁 조건을 유발 할 수 있다.

  • 따라서 복잡한 핸들링이 필요한 경우에는 sigaction()을 사용하여
    • 시그널 마스크 설정
    • 재진입 방지
    • 일관성 있는 제어 등을 적용하는 것이 좋다.

8.5.4 Blocking and Unblocking Signals

Implicit Blocking | 암묵적 블로킹

  • 커널이 자동으로 동일한 시그널의 중복 수신을 일시적으로 차단하는 메커니즘이다.
  • 예시 상황
    • 어떤 시그널 s에 대한 핸들러 S가 실행 중일 때,
    • 동일한 시그널 s가 다시 도착하면 → pending 상태로만 저장되고, 바로 처리되지 않는다.
    • 핸들러 S의 실행이 끝나야만 해당 시그널을 다시 처리 할 수 있게 된다.
  • 의의
    • 재진입 방지 : 핸들러가 다시 핸들러를 호출하는 일이 없도록 자동으로 보호한다.

Explicit Blocking | 명시적 블로킹

  • 프로그래머가 원하는 시그널을 코드로 직접 차단하거나 해제 할 수 있도록 한 메커니즘이다.
  • 사용 함수는 sigprocmask를 포함한 관련 시그널 집합 함수들을 통해서 가능하다.

  • sigismember를 제외한 함수들은 0이 ok, -1이 오류를 리턴하며, sigismember는 멤버인 경우 1, 0이면 아니고, -1이면 오류이다.
  • sigprocmask는 매개변수를 세개 쓴다 : int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
    • how : 차단 방식을 지정하는 매크로이다. (SIG_BLOCK, SIG_UNBLOCK, SIG_SETMASK)
      • SIG_BLOCK : set에 포함된 시그널들을 현재 차단 집합에 추가한다. → blocked = blocked | set
      • SIG_UNBLOCK : set에 포함된 시그널들을 차단 집합에서 제거한다. → blocked = blocked & ~set
      • SIG_SETMASK : 차단 집합을 set으로 완전히 대체한다. → blocked = set
    • set : 차단하거나 해제 할 시그널 집합이다.
    • oldset : 이전의 시그널 차단 상태를 저장 할 포인터이다. (NULL이 아닐 경우)

8.5.5 Writing Signal Handlers

시그널 핸들러는 프로세스 내부에서 비동기적으로 실행되기 떄문에, 일반적인 함수와는 다른 특수한 제약이 있다 :

  1. 동시성 문제 (Concurrency)
    • 시그널 핸들러는 메인 프로그램과 동시에 실행 될 수 있다
    • 전역 변수에 대한 경쟁 조건이 생길 수 있다.
  1. 예측이 어렵다 (Non-intuitive Semantics)
    • 어떤 시점에 어떤 시그널이 수신될지 정확히 예측하기 어렵다.
    • 수신 중인 시그널이 중첩될 수 있으며, 블로킹 여부도 중요하다.
  1. 시스템 간 차이 (Portability Issues)
    • Unix 계열 시스템들 사이에서도 시그널 처리 방식에 세부 차이가 있다.
      • signal()의 동작은 시스템마다 조금씩 다르다.

안전하고 이식성 있는 시그널 핸들러를 작성하기 위한 기본 지침

  • 핸들러를 최대한 간단하게 유지한다.
    • 가장 안전한 접근 방식은 핸들러에서 최소한의 작업만 수행하는 것이다.
    • 보통 전역 플래그 하나만 설정하고 바로 리턴하기로 한다.

대부분의 표준 라이브러리 함수는 signal handler에서 안전하지 않다.async-signal-safe 함수만 호출하라.

    • 사용 가능한 함수는 위 목록이다.
     
  • 문자열 출력이 필요한 경우 별도로 정의된 Safe I/O (SIO..) 패키지를 사용한다.
    • sio_putl(long val) : long 출력
    • sio_puts(const char *s) : 문자열 출력
    • sio_error(const char *msg) : 메시지 출력후 _exit(1)로 종료한다.
    • 이들은 내부적으로 write만 사용하며, 모든 전처리는 로컬 변수만 사용하는 재진입 함수 기반으로 구현되어 있다.

 

  • errno 값을 보존하라.
    • 많은 시스템 호출은 오류 시 errno 값을 설정한다.
    • 핸들러 내부에서 errno를 변경하면 메인 프로그램의 오류 처리 로직에 영향을 줄 수 있다.
    • 안전하게 하려면 다음처럼 처리하자 : 핸들러 진입시 errno를 저장하고 반환 직전에 복원하는 것이다.
    • 단, 핸들러에서 _exit으로 프로세스를 종료하는 경우 복원이 불필요하다.
       

  • 공유 전역 자료구조 접근 시 모든 시그널 블록을 접근에서 보호하자.
    • 메인 프로그램이 자료구조를 처리하던 중 시그널이 오면, 핸들러가 불완전한 상태를 볼 수 있다 → 이것은 예기치 않은 동작으로 이어지기 쉽다.
    • 자료접근 전 후에 sigprocmask 로 시그널을 임시 차단함으로써 해결 할 수 있다.
  • 공유 전역 변수는 volatile로 선언하자.
    • 컴파일러는 변수가 코드 내에서 수정되지 않으면 레지스터에 캐시하여 최적화한다.
    • 시그널 핸들러에서 값을 변경해도, 메인 루틴은 이를 못 볼 수 있다.
    • 해결 방법 : 변수 앞에 volatile 키워드를 사용한다. volatile int sigflag;
    • volatile은 시그널의 비동기성만 해결하며, 경쟁 조건을 막지는 못한다. 신호 차단과 병행 사용이 필요하다.
  • 시그널 플래그 변수는 sig_atomic_t로 선언한다.
    • 시그널 핸들러가 값을 기록하는 단순 플래그는 원자적 접근이 가능해야 한다.
    • 일반 정수형은 시그널 처리 중 읽기/쓰기 동시 접근 시 문제가 생길 수 있다.
    • 해결 방법은 sig_atomic_t 를 사용하여 정의상 원자적 접근을 보장하게끔하는 것이다.

정말 아쉬운 것은 시그널이 큐잉되지 않는다는 것이다.

  • 비트 벡터 기반 펜딩 관리
    • 커널은 각 시그널 타입 당 하나의 펜딩 비트만을 유지한다.
    • 따라서 동일한 시그널이 여러 번 발생해도, “한 번 발생했다”는 사실만 저장이 된다.
  • 예시로 SiGCHLD 핸들러를 사용한 자식 프로세스 회수 건을 보자.
    • 부모 프로세스는 SIGCHLD 핸들러를 설치하고, 자식들을 생성한다.
    • 각 자식은 독립적으로 종료하며, 종료 시 부모에게 SIGCHLD가 적용된다.
    • 부모는 핸들러에서 한 번의 wait()만 수행하여 자식을 회수한다.
  • 여기서 문제가 발생하는 것이, 핸들러 내에서 시그널 손실이 발생하는 경우이다.
    • 어떻게 발생하느냐?
      1. 첫 번째 자식 종료 → SIGCHLD → 핸들러 실행 → 자식 회수
      2. 두 번째 자식이 종료되면서 SIGCHLD가 발생 → 핸들러 실행 중이므로 펜딩 처리됨
      3. 세 번째 자식 종료 → 이미 펜딩 중인 SIGCHLD 존재 → 새로운 시그널은 무시됨
      4. 핸들러 종료 후 커널이 펜딩된 SIGCHLD 처리 → 두 번째 자식만 추가로 회수
      5. 세 번째 자식은 회수되지 않고 좀비 상태로 남음
  • 해결책은 핸들러에서 모든 종료 자식을 반복적으로 회수하는 것이다.
    • 핸들러에서 자식이 더 이상 없을 때까지 waitpid(-1, …, WNOHANG)을 반복 호출하여 한 번의 시그널 수신으로 여러 자식의 종료 처리를 가능하게 한다. 

 

  • 이 코드로 문제 상황을 보자. 여기서는 signal1 프로그램의 실패인 경우이다.
    • 부모는 SIGCHLD 핸들러를 등록한 뒤, 자식 3명을 생성한다.
    • 자식들은 각각 종료하며 SIGCHLD가 부모에게 전송된다.
    • 부모는 핸들러 내에서 한 번의 wait()만 호출하고, 그 후 sleep()을 통해 지연 처리한다.
    • 그동안 자식들은 추가 종료된다. → 시그널은 한 번만 펜딩 되고, 나머지는 유실된다.
  • 결과적으로, 자식 3 중 2만 회수되고, 한 명은 좀비 상태로 남았다.
  • 원인을 분석하면 : 시그널은 누적되지 않는다
    • 커널은 같은 종류의 시그널을 한 번만 펜딩 할 수 있다.
    • 두 번째 SIGCHLD가 펜딩 중인 상태에서, 세 번째 시그널이 오면 버려진다.
    • 핸들러는 두 번 호출되지만, 자식 셋 중 두 명만 회수된다.
    • 해결책은 핸들러 내에서 모든 자식을 반복적으로 회수 하는 것이다. 이 위에 있는 프로그램을 보자.
      • waitpid(-1, …, WHOHANG)은 종료된 자식이 없으면 0을 반환한다.
      • 이 방식은 한 번의 시그널 수신으로 여러 자식을 회수 할 수 있다.

  • 신호 처리의 이식성과 관련된 플랫폼 간 차이점에 대해 다뤄보자. (8.38)
    • 여기서의 문제는 플랫폼 간 시그널 처리의 차이이다. Unix 계열 시스템들은 시그널 처리에 대한 구현 방식이 일관되지 않다. 특히 구형 시스템일수록 아래 두 가지 문제가 일어나기 쉽다 :
      1. 시그널 핸들러가 자동으로 제거된다.
        • 어떤 시스템에서는 signal() 을 이용해 등록한 핸들러가 한 번 실행된 후 자동으로 기본값으로 복구된다.
        • 즉, 다음번 같은 시그널이 오면 핸들러가 다시 호출되지 않는다.
        •  해결법
          • 표준적이고 이식성 높은 sigaction()을 사용한다.
          • sigaction()은 핸들러가 자동으로 유지되며, 동작을 명확히 제어 할 수 있다.
      1. 시스템 콜이 시그널에 의해 중단된다.
        • 오래 걸릴 수 있는 시스템 콜은 시스템 처리 중 중단 될 수 있다.
        • 이 때, 콜은 오류로 리턴되고 errno가 EINTR로 설정된다.
        • 개발자는 직접 해당 시스템 콜을 다시 호출하는 코드를 작성해야했다.
        •  해결법
          • sigaction()을 사용 할 때 SA_RESTART 플래그를 설정하면 대부분의 시스템 콜이 자동으로 재시작 된다.
  • 다만, sigaction()은 강력하지만 설정이 복잡하다. 이를 감싸서 간단하게 만든 것이 Signal() wrapper 함수이다.
    • Signal()은 다음을 보장한다 :
      • sigaction() 사용 : 이식성 있는 시그널 등록 방식을 사용한다.
      • SA_RESTART 설정 : 인터럽트 된 시스템 콜 자동 재시작
      • 핸들러 자동 재설치 : signal() 처럼 반복해서 설치 할 필요가 없다.
      • 호출 법이 signal() 과 동일 : 기존 코드와의 호환성을 유지한다.

8.5.6 Synchronizing Flows to Avoid Nasty Concurrency Bugs

  • 병행 프로그래밍에서의 레이스 조건과 이를 시그널 처리 차원에서 어떻게 다루게 되는지를 보자.

  • 이 코드에서의 문제 상황은 Race Condition이다.
    • 부모 프로세스가 fork() 호출 직후 자식 프로세스를 job list에 추가 해야 한다.
    • 자식 프로세스가 먼저 종료되면 커널은 SIGCHLD를 부모에 보낸다.
    • 이 시점에 부모가 아직 addjob()을 호출하지 않았다면,
      • 핸들러는 deletejob()을 호출하지만 아무것도 제거하지 못한다.
      • 이후 부모는 addjob()을 호출하여 유령 job을 job list에 추가한다.
    • 이렇게 되면 삭제되지 않는 잘못된 항목이 job list에 남게된다.
    • 이것이 바로 레이스 조건인데, addjob()과 deletejob()의 순서가 엇갈릴 수 있다.
      • 이것을 시그널 블로킹을 통한 순서 보장을 통해 해결 할 수 있다.
        • fork() 이전에 SIGCHLD를 블록했기에 → 자식이 죽어도 핸들러가 실행되지 않는다.
        • addjob() 이후에 시그널을 언블록함 → 그제서야 핸들러가 실행된다.
        • 이로써 항상 addjob()이 deletejob() 보다 먼저 실행됨을 보장 할 수 있다.

 

8.5.7 Explicitly Waiting for Signals

  • Spin Loop with Shared Global Variable
    • 상황
      • 부모는 자식이 종료될 때까지 기다려야 한다. (포그라운드 작업)
      • 자식이 종료되면 SIGCHLD 핸들러가 pid 전역변수를 설정한다. 부모는 pid ≠ 0이 될 때까지 반복루프를 돌며 기다린다.
    • 문제점
      • Spin loop는 CPU 자원을 낭비함.
      • 시도 1: pause() 사용 시, while 조건과 pause 사이의 시그널 수신으로 인해 영원히 대기하는 race가 발생할 수 있음.
      • 시도 2: sleep() 사용 시, 응답성이 떨어짐. 시그널 타이밍에 따라 불필요하게 오래 기다릴 수 있음.
      • 결론: 이 방식들은 각각 성능 낭비 또는 레이스 조건을 일으킬 수 있음.

  • Using sigsuspend to Wait Signal
    • sigsuspend(&mask); 를 사용해서 해결 할 수 있다.
    • 주요 특징
      • sigprocmark + pause를 원자적으로 처리한다.
      • sigsuspend()이 호출되는 경우,
        • 현재 시그널 마스크를 mask로 교체하고, 시그널을 받을 때까지 잠든다.
        • 그리고 시그널 핸들러가 종료되면 깨어나고, 이전 마스크로 자동 복원된다.
    • 장점
      • pause()와 달리 rase condition 방지가 가능하다.
      • sleep() 보다 응답성이 뛰어나다.
      • SIGCHLD를 일시적으로 언블록 해서 핸들러가 실행되도록 유도한다.
      • 핸들러가 pid를 설정하면 루프가 종료된다.
  • 이 기법이 실질적으로 Unix/Linux 환경에서 안전하게 시그널을 기다리는 표준적인 기법이다.

8.6 Nonlocal Jumps

C언어는 비지역적 점프라는 형태의 사용자 수준 예외적 제어 흐름을 제공한다.

  • 이는 현재 실행 중인 함수들 사이에서, 일반적인 호출-복귀 순서를 따르지 않고 직접 제어를 이동시키는 방식이다.
  • setjmp와 longjmp 함수를 통해 구현된다.
  • setjmp 함수는 현재의 호출 환경은 env 버퍼에 저장된다.
    • 이 호출 환경에는 Program Counter, Stack Pointer, 일반 목적 레지스터 등이 포함된다.
    • 나중에 longjmp를 통해 이 환경으로 돌아올 수 있다.

주의 할 점은 setjmp의 반환값을 변수에 저장하는 방식은 사용하지 말아야 한다는 것이다.

  • rc = setjmp(env);
    • 대신, switch나 조건문에서 직접 사용하는 것은 안전하다.

 

 

#include <setjmp.h>
void longjmp(jmp_buf env, int retval);
void siglongjmp(sigjmp_buf env, int retval);
/* 반환하지 않음 */
  • longjmp 함수는 env에 저장된 호출 환경을 복원한 후, 해당 setjmp로 점프하여 제어를 돌려준다.
    이 때 setjmp는 retval 값을 반환하며, 이 값은 0이 아닌 값이어야한다.
  • setjmp는 한 번 호출되지만 여러 번 반환 할 수 있다 :
    • 처음 호출 될 때 0을 반환하고, 이후 대응하는 longjmp가 발생할 때는 지정된 비영(非零) 값을 반환한다.
      반면, longjmp는 한 번 호출되고 절대 복귀하지 않는다.

비지역적 점프의 중요한 응용 사례는 깊게 중첩된 함수 호출 내에서 오류를 감지 했을 때, 바로 공통된 오류 처리 지점으로 제어를 돌리는 것이다. 예를 들어, 중첩된 함수 중 어딘가에서 오류가 발생하면, 복잡한 콜 스택을 일일이 되감지 않고도 longjmp를 통해 빠르게 오류 처리 루틴으로 이동 할 수 있다.

  • 이게 그 예시이다.
    • 메인 루틴은 먼저 setjmp를 호출해 호출 환경을 저장한 후 foo 함수를 호출하고, foo는 다시 bar를 호출한다.
      • 만약 foo나 bar에서 오류가 발생하면, 이들은 longjmp를 통해 setjmp로 점프하여 오류를 신속하게 처리한다.
      • 이 때 setjmp는 오류 타입을 나타내는 비영 값을 반환하며, 이를 통해 오류의 원인을 구분하고 하나의 위치에서 처리 할 수 있게 된다.
      • 단, longjmp는 중간 함수를 건너뛰고 제어를 이동시킨다. 따라서 중간 함수 내에서 할당한 자원들을 해제할 기회를 놓쳐 메모리 누수와 같은 부작용이 생길 수 있다. 자원 관리에 유의하자.

  • 또 다른 중요한 비지역 점프의 활용 사례는 시그널 핸들러 안에서 인터럽트 된 지점으로 복귀하는 대신 특정 코드 위치로 분기하는 것이다.
    • 위 코드가 이 기법을 보여주는 간단한 예제이다.
    • 이 프로그램은 시그널과 비지역 점프를 사용해, 사용자가 키보드에서 Ctrl+C를 입력할 때마다 소프트 재시작을 수행한다.
    • sighsetjmp와 siglongjmp 함수는 각각 setjmp, longjmp의 시그널 버전으로, 시그널 핸들러에서도 사용 할 수 있도록 설계되어 있다.
  • 프로그램이 처음 시작 될 때, sigsetjmp 호출은 호출 환경과 시그널 컨텍스트(보류 중이거나 블록된 시그널 벡터 포함)를 저장한다. 이후 main 루틴은 무한한 처리 루프에 들어간다. 사용자가 Ctrl+C를 누르면 커널이 프로세스에 SIGINT 시그널을 보낸다. 이 시그널은 프로세스에 의해 포작되고, 시그널 핸들러가 실행된다.
  • 이 핸들러는 인터럽트 된 루프로 돌아가지 않고, 대신 비지역 점프를 통해 프로그램의 시작 지점인 main 루틴 앞으로 분기한다.
    • 이 프로그램을 실제 시스템에서 실행해보면 다음과 같은 출력 결과를 볼 수 있다 :
    linux> ./restart
    starting
    processing...
    processing...
    Ctrl+C
    restarting
    processing...
    Ctrl+C
    restarting
    processing...
  • 여기서 주목 해야 할 것은 두 가지이다 :
    1. 경쟁 조건을 피하기 위해서는, sigsetjmp를 호출한 이후에 시그널 핸들러를 설치해야 한다.
      • 그렇지 않으면, 시그널 핸들러가 sigsetjmp가 아직 호출되지 않은 시점에 실행 될 수 있고, 그 경우 siglongjmp는 유효하지 않은 호출 환경으로 점프하게 된다.
    1. sigsetjmp와 siglongjmp는 async-signal-safe 함수에는 포함되어있지 않다. 왜냐하면 siglongjmp가 임의의 코드로 점프 할 수 있기 때문이다.
      • siglongjmp로 도달 가능한 경로에 있는 함수는 반드시 signal-safe 함수여야 한다.

8.7 프로세스 조작을 위한 도구들

strace : 프로그램과 그 자식 프로세스가 호출하는 시스템 콜을 추적해준다.
ps : 현재 시스템에 존재하는 프로세스를 좀비 프로세스를 포함하여 목록 형태로 보여준다.
top : 현재 실행 중인 프로세스들의 자원 사용 현황을 실시간으로 보여준다.
pmap : 하나의 프로세스에 대해 메모리 매핑 정보를 보여준다.
/proc : 수많은 커널 데이터 구조의 내용을 ASCII 텍스트 형태로 노출하는 가상 파일 시스템이다.

'컴퓨터 이론 > CS:APP' 카테고리의 다른 글

컴퓨터가 프로그램을 뚝딱 만드는 과정  (4) 2025.08.15
[CS:APP] Chap 9.1 - 9.8 : Virtual Memory  (0) 2025.04.23
[CS:APP] 링커 7.1 - 7.14  (0) 2025.04.18
[CS:APP] Chap 3.10 - 11  (1) 2025.04.09
[CS:APP] Chap 3.7 - 9  (1) 2025.04.09