Skip to content
Published on

[운영체제] 03. 프로세스: 개념, 생성, 통신

Authors

프로세스 개념

프로세스(Process)는 실행 중인 프로그램이다. 프로그램이 디스크에 저장된 수동적 존재라면, 프로세스는 메모리에 올라가 실행되는 능동적 존재다.

프로세스 메모리 구조

[프로세스의 메모리 레이아웃]

높은 주소
+------------------+
|     스택(Stack)   |  지역 변수, 함수 매개변수, 반환 주소
|        |         |  (아래로 성장)
|        v         |
|                  |
|        ^         |
|        |         |
|     (Heap)     |  동적 할당 메모리 (malloc, new)
|                  |  (위로 성장)
+------------------+
|   데이터(Data)    |  전역 변수, 정적 변수
+------------------+
|   텍스트(Text)    |  프로그램 코드 (기계어 명령)
+------------------+
낮은 주소

프로세스 상태

프로세스는 실행 중 다음 상태를 거친다.

[프로세스 상태 전이도]

                    디스패치
         +--------+-------->+---------+
         |  준비   |         |  실행    |
         | (Ready) |<--------| (Running)|
         +--------+ 타이머   +---------+
           ^    ^   인터럽트    |     |
           |    |              |     |
 admitted  |    | I/O 완료     | I/O | exit
           |    |   이벤트     |요청  |
           |    |              |     |
         +---+  +--------+    |   +--------+
         |새로|  | 대기    |<---+   | 종료    |
         |생성|  |(Waiting)|        |(Terminated)
         +---+  +--------+        +--------+
  • 새로 생성(New): 프로세스가 생성되는 중
  • 준비(Ready): CPU 할당을 기다리는 상태
  • 실행(Running): CPU에서 명령을 실행 중
  • 대기(Waiting): I/O 또는 이벤트 완료를 기다리는 상태
  • 종료(Terminated): 실행이 완료된 상태

프로세스 제어 블록 (PCB)

각 프로세스는 OS 내에서 PCB(Process Control Block)로 표현된다.

// Linux 커널의 task_struct 구조체 (간략화)
struct task_struct {
    volatile long state;          // 프로세스 상태
    pid_t pid;                    // 프로세스 ID
    pid_t tgid;                   // 스레드 그룹 ID

    struct mm_struct *mm;         // 메모리 관리 정보
    struct files_struct *files;   // 열린 파일 테이블
    struct thread_struct thread;  // CPU 레지스터 상태

    unsigned int policy;          // 스케줄링 정책
    int prio;                     // 우선순위
    cpumask_t cpus_allowed;       // 실행 가능한 CPU

    struct task_struct *parent;   // 부모 프로세스
    struct list_head children;    // 자식 프로세스 목록
    // ... 수백 개의 추가 필드
};

PCB에는 프로세스 상태, 프로그램 카운터, CPU 레지스터, 스케줄링 정보, 메모리 관리 정보, I/O 상태 정보 등이 포함된다.


프로세스 스케줄링

스케줄링 큐

OS는 여러 큐를 사용하여 프로세스를 관리한다.

[프로세스 스케줄링 큐]

  준비  (Ready Queue)
  +-----+  +-----+  +-----+  +-----+
  | PCB |->| PCB |->| PCB |->| PCB |---> CPU
  +-----+  +-----+  +-----+  +-----+

  디스크 대기 큐
  +-----+  +-----+
  | PCB |->| PCB |---> 디스크 컨트롤러
  +-----+  +-----+

  네트워크 대기 큐
  +-----+
  | PCB |---> 네트워크 인터페이스
  +-----+

컨텍스트 스위치 (Context Switch)

CPU가 다른 프로세스로 전환할 때, 현재 프로세스의 상태를 저장하고 새 프로세스의 상태를 복원해야 한다.

[컨텍스트 스위치]

프로세스 P0        운영체제         프로세스 P1
  실행 중                            유휴
    |                                 |
    |--인터럽트/시스템콜-->           |
    |              PCB0에 상태 저장    |
    |              PCB1에서 상태 복원   |
    |                        -------->|
    유휴                       실행 중  |
    |                                 |
    |              <--인터럽트/시스템콜-|
    |              PCB1에 상태 저장    |
    |              PCB0에서 상태 복원   |
    |<---------                       |
  실행 중                            유휴

컨텍스트 스위치 시간은 순수한 오버헤드다. 하드웨어에 따라 1~1000 마이크로초 정도 소요된다.


프로세스 연산

프로세스 생성: fork()

Unix/Linux에서 프로세스는 fork() 시스템 콜로 생성한다. fork()는 호출한 프로세스의 복사본을 만든다.

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid;

    printf("fork() 호출 전 - PID: %d\n", getpid());

    pid = fork();  // 자식 프로세스 생성

    if (pid < 0) {
        // fork 실패
        perror("fork 실패");
        return 1;
    } else if (pid == 0) {
        // 자식 프로세스: fork()가 0을 반환
        printf("자식 프로세스 - PID: %d, 부모 PID: %d\n",
               getpid(), getppid());
    } else {
        // 부모 프로세스: fork()가 자식의 PID를 반환
        printf("부모 프로세스 - PID: %d, 자식 PID: %d\n",
               getpid(), pid);
        wait(NULL);  // 자식 종료 대기
        printf("자식 프로세스 종료됨\n");
    }

    return 0;
}
[fork() 동작]

부모 (PID 100)          fork()
     |                    |
     |                    |---> 자식 (PID 101)
     |                    |      부모의 주소 공간 복사
     |                    |      fork() 반환값 = 0
     | fork() 반환값 = 101|
     |                    |

fork()와 exec()의 조합

fork() 후 exec()을 호출하면 자식 프로세스가 새로운 프로그램을 실행한다.

#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // 자식: exec()으로 새 프로그램 실행
        // exec()은 현재 프로세스 이미지를 새 프로그램으로 교체
        printf("자식: ls 명령 실행\n");
        execlp("ls", "ls", "-la", NULL);

        // exec() 성공 시 아래 코드는 실행되지 않음
        perror("exec 실패");
    } else if (pid > 0) {
        // 부모: 자식 종료 대기
        int status;
        waitpid(pid, &status, 0);

        if (WIFEXITED(status)) {
            printf("자식 종료 코드: %d\n", WEXITSTATUS(status));
        }
    }

    return 0;
}

프로세스 종료

프로세스는 exit() 시스템 콜로 종료를 요청한다. 부모 프로세스는 wait()로 자식의 종료 상태를 수집한다.

  • 좀비 프로세스(Zombie): 종료되었지만 부모가 wait()를 호출하지 않은 프로세스
  • 고아 프로세스(Orphan): 부모가 먼저 종료된 프로세스. init(PID 1)이 새 부모가 됨

프로세스 간 통신 (IPC)

프로세스는 독립적(independent)이거나 협력적(cooperating)일 수 있다. 협력적 프로세스는 IPC가 필요하다.

공유 메모리 (Shared Memory)

여러 프로세스가 동일한 메모리 영역에 접근하여 데이터를 교환한다.

// POSIX 공유 메모리 - 생산자
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>
#include <string.h>

#define SHM_NAME "/my_shm"
#define SHM_SIZE 4096

int main() {
    // 공유 메모리 객체 생성
    int shm_fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0666);
    ftruncate(shm_fd, SHM_SIZE);

    // 메모리 매핑
    char *ptr = mmap(NULL, SHM_SIZE,
                     PROT_READ | PROT_WRITE,
                     MAP_SHARED, shm_fd, 0);

    // 데이터 쓰기
    const char *message = "Hello from producer!";
    memcpy(ptr, message, strlen(message) + 1);

    printf("생산자: 메시지 작성 완료\n");
    munmap(ptr, SHM_SIZE);
    return 0;
}
// POSIX 공유 메모리 - 소비자
#include <stdio.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <unistd.h>

#define SHM_NAME "/my_shm"
#define SHM_SIZE 4096

int main() {
    // 기존 공유 메모리 열기
    int shm_fd = shm_open(SHM_NAME, O_RDONLY, 0666);

    // 메모리 매핑
    char *ptr = mmap(NULL, SHM_SIZE,
                     PROT_READ,
                     MAP_SHARED, shm_fd, 0);

    // 데이터 읽기
    printf("소비자: %s\n", ptr);

    munmap(ptr, SHM_SIZE);
    shm_unlink(SHM_NAME);  // 공유 메모리 삭제
    return 0;
}

메시지 전달 (Message Passing)

OS 커널을 통해 프로세스 간 메시지를 주고받는다. 공유 메모리보다 느리지만 동기화가 내장되어 있다.

[IPC 모델 비교]

공유 메모리 모델:              메시지 전달 모델:

프로세스A   프로세스B          프로세스A   프로세스B
  |    \   /    |                |           |
  |     \ /     |                | send(M)   |
  |  공유 메모리 |                |---> 커널   |
  |     / \     |                |     |     |
  |    /   \    |                |     v     |
  |           |                  | receive(M)|
                                 |     ----->|
커널 개입 최소화                 커널이 매 전송마다 개입

파이프 (Pipes)

파이프는 두 프로세스 간의 통신 채널이다.

일반 파이프 (Ordinary Pipe)

부모-자식 관계의 프로세스 간에서만 사용 가능하며 단방향이다.

#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/wait.h>

int main() {
    int fd[2];  // fd[0]: 읽기 끝, fd[1]: 쓰기 끝
    pid_t pid;
    char buffer[256];

    // 파이프 생성
    if (pipe(fd) == -1) {
        perror("파이프 생성 실패");
        return 1;
    }

    pid = fork();

    if (pid == 0) {
        // 자식: 파이프에서 읽기
        close(fd[1]);  // 쓰기 끝 닫기

        int bytes = read(fd[0], buffer, sizeof(buffer));
        printf("자식이 받은 메시지: %s (%d bytes)\n", buffer, bytes);

        close(fd[0]);
    } else {
        // 부모: 파이프에 쓰기
        close(fd[0]);  // 읽기 끝 닫기

        const char *msg = "안녕하세요, 자식 프로세스!";
        write(fd[1], msg, strlen(msg) + 1);
        printf("부모가 보낸 메시지: %s\n", msg);

        close(fd[1]);
        wait(NULL);
    }

    return 0;
}

이름 있는 파이프 (Named Pipe / FIFO)

부모-자식 관계가 아닌 임의의 프로세스 간에도 사용 가능하다.

# 셸에서 이름 있는 파이프 사용
mkfifo /tmp/my_pipe

# 터미널 1: 쓰기
echo "Hello via named pipe" > /tmp/my_pipe

# 터미널 2: 읽기
cat /tmp/my_pipe

클라이언트-서버 통신

소켓 (Sockets)

소켓은 통신의 끝점(endpoint)이다. IP 주소와 포트 번호의 조합으로 식별된다.

// TCP 서버 예시
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>

int main() {
    int server_fd, client_fd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t addr_len = sizeof(client_addr);
    char buffer[1024];

    // 소켓 생성
    server_fd = socket(AF_INET, SOCK_STREAM, 0);

    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(8080);

    // 바인드 및 리슨
    bind(server_fd, (struct sockaddr *)&server_addr,
         sizeof(server_addr));
    listen(server_fd, 5);

    printf("서버 대기 중 (포트 8080)...\n");

    // 클라이언트 연결 수락
    client_fd = accept(server_fd,
                       (struct sockaddr *)&client_addr,
                       &addr_len);

    // 데이터 수신 및 응답
    int bytes = recv(client_fd, buffer, sizeof(buffer), 0);
    buffer[bytes] = '\0';
    printf("수신: %s\n", buffer);

    const char *response = "서버 응답: 메시지 수신 완료";
    send(client_fd, response, strlen(response), 0);

    close(client_fd);
    close(server_fd);
    return 0;
}

원격 프로시저 호출 (RPC)

RPC는 네트워크를 통해 원격 시스템의 함수를 마치 로컬 함수처럼 호출하는 메커니즘이다.

[RPC 동작 과정]

클라이언트                              서버
    |                                    |
    |-- 1. 로컬 함수 호출 형태 --------  |
    |      (스텁이 매개변수 마셜링)       |
    |                                    |
    |-- 2. 네트워크를 통해 전송 -------->|
    |                                    |-- 3. 서버 스텁이
    |                                    |      언마셜링
    |                                    |
    |                                    |-- 4. 실제 함수 실행
    |                                    |
    |<-- 5. 결과 반환 ------------------|
    |      (스텁이 결과 언마셜링)         |
    |                                    |

RPC에서 주의할 점은 다음과 같다.

  • 데이터 표현: 클라이언트와 서버의 데이터 형식이 다를 수 있으므로 XDR(External Data Representation) 같은 표준 형식 사용
  • 바인딩: 클라이언트가 서버를 찾는 방법. 고정 포트 또는 랑데뷰 데몬(포트 매퍼) 사용
  • 실행 의미론: "최대 한 번(at most once)" 또는 "정확히 한 번(exactly once)" 보장

정리

프로세스는 OS의 핵심 추상화 단위다. fork()와 exec()으로 프로세스를 생성하고 새 프로그램을 실행한다. 프로세스 간 통신에는 공유 메모리, 메시지 전달, 파이프, 소켓, RPC 등 다양한 메커니즘이 있으며, 각각 성능과 편의성 측면에서 장단점이 있다.