들어가며

  • 이 글은 MIT에서 제공하는 과목인 Operating System Engineering(6.S081)을 학습하며, 파이프가 동시성 개념에서 어떤 역할을 하는지 느낀 바를 정리한 내용입니다.
  • 학습용으로 사용되는 xv6에서 primes 과제를 수행하며, 각 소수마다 별도의 프로세스를 만들고 파이프를 통해 숫자를 전달하는 구조를 구현했습니다. (MIT 과제이므로 코드는 따로 공개하지 않았습니다.)

파이프 이해하기

파이프의 개념 및 역할

파이프(pipe)는 프로세스 간 통신(Inter-Process Communication, IPC)를 다루는 한 방식입니다. 파이프는 한 프로세스의 출력을 다른 프로세스의 입력으로 직접 연결하여 데이터를 전달합니다. 따라서 하나의 파이프는 한쪽 끝을 입력(읽기)으로 삼고 다른 쪽 끝을 출력(쓰기)으로 삼습니다.

파이프를 통해 전달되는 데이터는 바이트 스트림(byte stream) 형태로 이용됩니다. 따라서 파이프를 통해서 다양한 종류의 데이터를 유연하고 효율적으로 처리할 수 있습니다. 파이프는 데이터를 변환할 필요가 없으며, 어떤 종류의 데이터든 바이트로 표현될 수 있기 때문입니다. 대신 데이터를 읽는 프로세스는 데이터의 특성에 맞게 데이터를 해석해야 합니다.

실생활으로 파이프 이해하기

파이프라는 이름에서 드러나듯이, 실제로 삶과 밀접한 파이프를 떠올려보면 이해하기 쉽습니다. 대표적인 예시로 정수기에서 뜨거운 물을 마시고자 하는 예시가 있습니다. 여기에서 바이트 스트림은 ‘물’이라고 생각하면 쉽습니다.

첫 시작을 정수 과정 이후라고 생각하겠습니다.

  1. 파이프
    • 출발: 외부 수도관
    • 끝: 필터가 있는 급수관 입구
    • 바이트 스트림: 물
  2. 프로세스: 정수 필터
    • 정수 필터는 파이프로부터 물(바이트 스트림)을 받아서 물의 불순물을 제거함
    • 여기에서 정수 필터는 물의 불순물 수준을 파악하고, 걸러내는 역할 수행
  3. 파이프
    • 시작: 정수된 물이 저장된 공간
    • 끝: 온수 가열 장치 입구
    • 바이트 스트림: 물
  4. 프로세스: 온수 가열 장치
    • 온수 가열 장치는 파이프로부터 물(바이트 스트림)을 받아서 뜨거운 물로 변환
    • 여기에서 온수 가열 장치는 물의 온도를 해석하고, 특정 온도까지 가열시키는 역할 수행
  5. 파이프
    • 시작: 온수 탱크 출수 대기 지점 (온수 가열 장치 이후에 물이 나오는 지점)
    • 끝: 출수구
    • 바이트 스트림: 물

위 예시에서 파이프는 단순히 ‘물’이라는 바이트 스트림을 전달하는 역할만 할 뿐, 어떠한 해석이나 변환도 거치지 않음에 주목할 필요가 있습니다. 파이프는 단방향으로 데이터를 전송하고, 해석하고 변환하는 역할은 프로세스가 다룹니다. 정수 필터와 온수 가열 장치처럼요.

동시성 - CSP 모델 이해하기

동시성 살펴보기

concurrency refers to the ability of a system to execute multiple tasks through simultaneous execution or time-sharing (context switching), sharing resources and managing interactions. https://en.wikipedia.org/wiki/Concurrency_(computer_science)

위키피디아에서 정의하는 동시성(concurrency)의 핵심적인 특성을 살펴보겠습니다.

  1. 시스템이 여러 작업을 실행할 수 있는 능력
    • 시스템이 여러 독립적이나 상호 연관된 작업(프로세스, 스레드, 함수 호출 등)을 다룰 수 있음
  2. 동시적인 실행이나 시분할(컨텍스트 스위칭)
    • 동시적인 실행: 여러 개의 프로세서나 장치에서 여러 작업이 말 그대로 동시에 실행됨 (병렬성과 연관)
    • 시분할(컨텍스트 스위칭): 단일 CPU 코어에서 각 작업을 아주 짧은 시간 동안 번갈아가며 실행함
  3. 자원 공유
    • 여러 작업이 메모리, 파일, 네트워크 연결 등의 시스템 자원을 공유함
  4. 상호작용 관리
    • 자원을 공유하거나 서로 영향을 미치는 작업 사이의 데이터 불일치, 경합, 교착 등의 문제를 조정하고 관리함

CSP 모델

CSP (Communicating Sequential Processes) 모델은 토니 호어(Tony Hoare)가 개발한 동시성 모델인데요. CSP는 시스템의 상호작용을 수학적으로 분석하기 위한 프로세스 대수(Process Calculi, Process Algebra) 방법론의 일부입니다. 2021-xv6-labs 에서는 Bell Labs and CSP Threads라는 글을 통해서 호어의 CSP를 간략하게 소개합니다.

순차 프로세스와 통신

CSP에서 시스템은 여러 개의 독립적인 ‘순차 프로세스’로 구성됩니다. 여기에서 프로세스는 외부의 영향을 받지 않는 자체적인 상태를 가지고, 순차적으로 프로세스 내부의 작업을 실행합니다.

그런데 순차 프로세스는 분명히 “외부의 영향을 받지 않는다”고 했는데, 어떻게 서로에게 영향을 미치는 시스템을 구성할 수 있을까요? 이는 채널을 이용해서 순차 프로세스가 통신을 진행하기 때문입니다.

CSP는 기본적으로 Unbuffered Channel을 이용하는데요. Unbuffered Channel은 데이터를 임시로 저장할 버퍼가 없기에 메시지의 송수신 과정에서 같은 시점에 채널에서 만나야만(rendezvous) 동기화가 진행됩니다. 여기에서 메시지를 보내는 프로세스는 메시지를 보낸 후 수신자가 해당 메시지를 실제로 받을 때까지 블로킹됩니다. 마찬가지로 메시지를 받는 프로세스도 보낼 메시지가 도착할 때까지 블로킹됩니다.

그 결과로 별도의 락이나 세마포어와 같은 복잡한 동기화 메커니즘 없이도 경합 상태를 방지할 수 있습니다.

고수준 추상화

동기화와 경합 상태를 관리하기 위해서 이용되는 락, 뮤텍스, 세마포어 등의 공유 메모리(shared memory) 모델은 동일한 메모리 공간 안에서 명시적인 관리를 필요로 합니다. 반면, 메시지 전달 기반의 채널은 서로 독립된 메모리 공간에서 채널을 통해서만 메시지를 주고 받습니다.

따라서 프로그래머는 채널이라는 고수준의 추상화 인터페이스를 통해서 프로세스의 로직과 채널을 통한 메시지 흐름에만 신경을 쓰면 됩니다. 공유 메모리 모델 기반의 방식이 자원 중심의 “무엇을 보호할 것인가?”에 초점을 둔다면, CSP의 채널은 통신 중심의 “데이터가 어떻게 흐를 것인가?”에 집중하는 셈입니다.

XV6 과제로 동시성 살펴보기

소수 구하기 - 소개

MIT 6.S081의 가장 첫 랩 과제 중 하나인 소수 구하기(primes)는 xv6에서 CSP 모델을 접하는 기회가 되는데요. xv6 환경에서 pipe와 fork 시스템 콜을 이용해, 에라토스테네스의 체(Sieve of Eratosthenes) 파이프라인을 구축하는 것이 목표입니다.

만약, 에라토스테네스의 체에 익숙하지 않으신 분은, 에라토스테네스의 체(Wikepedia)를 읽어 보시는 것도 좋겠습니다.

핵심적인 과제의 목표를 정리해보자면 다음과 같은데요.

  1. 파이프라인 구축 및 데이터 공급
    • 첫 번째 프로세스(생성자 프로세스)가 숫자 2부터 35까지를 생성하고 첫 번째 파이프를 통해 다음 프로세스로 전달
  2. 동적 프로세스 체인
    • 소수가 발견될 때마다 새로운 자식 프로세스가 생성되어 파이프라인에 추가되고 소수 출력
    • 자신의 부모 프로세스로부터 읽고, 자식 프로세스에게 새로운 파이프를 통해 필터링된 숫자를 전달
  3. 리소스 관리 및 종료
    • 운영체제 상 제한된 파일 디스크립터(FD) 수를 고려해, 사용하지 않는 모든 FD를 닫기
  4. 동기화 관리
    • 생성자 프로세스는 35까지 숫자를 모두 공급한 후, 파이프의 쓰기 쪽을 닫아 EOF를 파이프라인에 전달
    • 생성자 프로세스는 자신의 모든 하위 파이프라인 전체가 종료될 때까지 wait() 시스템 콜을 통해 대기

이 과제를 수행하고 나면, 확실히 pipe의 동작과 존재 이유를 알게 되는 학습 효과가 있습니다.

추가적으로, 이 과제는 UNIX의 창시자 중 한 명인 더글라스 맥클로이(Douglas Mcllroy)가 제안한 Coroutine prime number sieve과 논리적 구조가 비슷하며, 구현이 코루틴(coroutine)이냐 파이프냐에 따라 디테일이 달라지는 정도입니다.

소수 구하기 - 동작 분석

파이프라인의 구조와 동작

실제 파이프 기반의 xv6 구현에서는 다음과 같은 프로세스 체인이 형성되는데요. 여기에서 각 파이프의 연결은 일대일로 대응된다고 보시면 됩니다.

생성자 프로세스 → [파이프1] → 필터 프로세스(P=2) → [파이프2] → 필터 프로세스(P=3) → ... → 마지막 필터 프로세스
  • 생성자 프로세스: 2부터 35까지의 숫자를 첫 번째 파이프에 쓰기
  • 첫 번째 필터 (소수 2):
    • 파이프에서 숫자를 읽음
    • 받은 첫 번째 숫자(2)를 소수로 출력하고 새로운 자식 프로세스 생성
    • 2로 나누어떨어지지 않는 숫자만 다음 파이프로 전달
  • 두 번째 필터 (소수 3):
    • 앞 파이프에서 숫자를 읽음
    • 받은 첫 번째 숫자(3)를 소수로 출력하고 새로운 자식 프로세스 생성
    • 3으로 나누어떨어지지 않는 숫자만 다음 파이프로 전달
  • 동일한 패턴 반복

소수 구하기와 CSP 모델

소수 구하기에서 형성된 과정은 CSP 모델의 다음 특성과 대응됩니다.

  • 순차 프로세스: 각 필터 프로세스는 독립적으로 동작하며, 자신만의 상태(현재 소수 값)를 가짐
  • 통신 채널: 파이프가 CSP의 채널 역할을 수행하며, 소규모 버퍼를 가진 채널을 통해 순차적 메시지 전달을 구현
  • 블로킹 동기화: read()/write() 시스템 콜이 파이프 상태(버퍼 공간/데이터 존재)에 따라 블로킹되어, CSP의 채널 동기화 특성과 유사한 동작을 보임

과제를 수행하며 배운 점

1. 시스템 콜과 커널 이해하기

왜 파이프가 커널과 유저 스페이스 사이에서 중요한 역할을 하는지 이해할 수 있었습니다. 파이프는 운영체제에서 안전한 프로세스 간 통신을 위한 메커니즘으로, 특히 커널 스페이스와 유저 스페이스 간의 데이터 흐름을 일치시키는 설계임을 배울 수 있었습니다.

파이프의 커널 내부 동작:

  • pipe() 시스템 콜이 호출되면 커널은 새로운 파일 디스크립터 쌍을 생성
  • 각 프로세스의 파일 디스크립터 테이블에 읽기/쓰기 엔트리가 등록됨
  • 실제 데이터는 커널 공간의 버퍼에서 관리되며, 유저 스페이스 프로세스는 이를 직접 접근할 수 없음

데이터 흐름의 일치

파이프의 가장 중요한 특징은 커널과 유저 스페이스 간의 데이터 흐름이 자연스럽게 일치한다는 점입니다:

유저 스페이스: write(pipe[1], data) → 커널 버퍼 → read(pipe[0], buffer)

이런 구조에서 데이터는 항상 커널 버퍼를 거쳐서 이동합니다. 각 프로세스는 자신의 유저 스페이스에서 read()/write()를 호출하고, 커널이 버퍼 관리와 동기화를 자동으로 처리합니다. 따라서 추상화 레벨이 일관성 있게 유지됩니다.

이는 공유 메모리 방식과 대조적입니다. 공유 메모리에서는 프로세스가 직접 메모리에 접근하지만, 파이프에서는 모든 데이터 이동이 커널을 통해 일관된 인터페이스로 처리되는 까닭입니다.

특히 close() 시스템 콜의 역할이 기억에 남습니다. 단순히 파일 디스크립터를 닫는 것이 아니라, 파이프의 경우 읽기/쓰기 끝 중 하나가 닫히면 다른 쪽에서 EOF를 감지할 수 있게 되어있다는 점이 흥미로웠습니다.

또한, 각 필터 프로세스가 독립적인 주소 공간을 가지며 병렬적으로 실행될 수 있는 프로세스 기반 동시성을 경험할 수 있었습니다.

2. 데이터를 생각하며 통신하기

제 경험 상에서 Java 어플리케이션 프로그래밍을 진행할 때에는 Thread를 많이 이용했는데요. 이번 과제에서는 운영체제 레벨에서 pipe를 다루며 데이터를 다른 방식으로 처리하는 사고력을 기를 수 있었습니다.

Java Thread 환경 vs 파이프 기반 접근:

Java에서 Thread를 사용할 때는 주로 “동시성 처리을 어떻게 다뤄야할까?”라는 관점에서 접근했습니다.

// 공유 메모리 기반 - 자원 중심 사고
private static List<Integer> sharedNumbers = Collections.synchronizedList(new ArrayList<>());
private static final Object lock = new Object();

public void addNumber(int num) {
    synchronized(lock) {  // 락으로 공유 자원 보호
        sharedNumbers.add(num);
    }
}
// 여러 스레드가 같은 리스트를 수정하려고 경쟁

하지만 파이프를 사용하면서는 “데이터가 어떻게 흘러가야 할까?”라는 관점으로 사고가 바뀌었습니다:

// 데이터 흐름 중심 사고 - 파이프 기반
int number;
while (read(left_pipe[0], &number, sizeof(number)) > 0) {
    // 각 프로세스는 자신만의 데이터를 순차적으로 처리
    if (number % prime != 0) {
        write(right_pipe[1], &number, sizeof(number));
        // 데이터가 자연스럽게 다음 단계로 흘러감
    }
}

코드 상에서도 보이지만, 스레드와 파이프 간 차이는 아래 비교를 통해서 잘 드러난다고 생각합니다.

  • 공유 vs 독립: Java에서는 여러 스레드가 같은 리스트를 공유하지만, 파이프에서는 각 프로세스가 독립적인 메모리 공간을 가짐

  • 경합 vs 순차: Java에서는 경합을 해결해야 하지만, 파이프에서는 데이터가 자연스럽게 순차적으로 흘러감

  • 보호 vs 흐름: Java에서는 “어떤 것을 보호할까?”를 고민하지만, 파이프에서는 “데이터가 어디로 흘러갈까?”에 집중함

파이프에서 더 나아가기

primes 과제에서 경험한 것은 단순히 “A → B → C” 형태의 순차적인 일대일 파이프 연결이었습니다. 하지만 파이프의 기본 특성을 이해하면, 이런 단순한 구성 요소들을 어떻게 조합하여 더 복잡한 통신 구조를 만들 수 있는지 명확해집니다.

핵심은 파이프가 제공하는 인터페이스가 장점이라는 점입니다. read()write()라는 두 개의 기본 연산만으로도 다양한 통신 패턴을 구성할 수 있지 않을까? 하는 흥미가 생겼습니다.

파이프의 확장성

파이프가 다양한 패턴으로 확장 가능한 이유는 다음과 같은 특성을 지니기 때문입니다.

단방향 데이터 흐름의 조합

  • 하나의 파이프는 단방향이지만, 여러 파이프를 조합하면 양방향이나 복잡한 통신 구성 가능
  • 읽기/쓰기 끝의 분리로 명확한 역할 구분과 계층적 구조 생성

블로킹 동기화의 자연스러운 확장

  • read()/write() 연산이 커널 레벨에서 자동으로 동기화를 제공
  • 복잡한 동기화 메커니즘 없이도 안전한 프로세스 간 통신 보장

파일 디스크립터의 추상화

  • 모든 파이프가 동일한 인터페이스(read()/write()) 제공
  • 이로 인해 파이프들을 조합하거나 중첩하는 것이 자연스러움

패턴 구성의 방법론

  • 연결 확장: 하나의 출력을 여러 입력으로 분기하거나, 여러 출력을 하나의 입력으로 합치는 방식
  • 계층 구성: 각 프로세스가 자신만의 입력/출력 파이프를 가지면서 전체적인 계층 구조 형성
  • 흐름 제어: 파이프의 열림/닫힘을 통한 데이터 흐름 제어와 조건부 전달

그렇다면 이제 파이프를 통해서 고차적인 통신 패턴을 구현해 봐야겠죠?

파이프로 만드는 간이 Pub-Sub 패턴 (예고)

Pub-Sub 패턴과 파이프 제약

Pub-Sub(Publish–Subscribe) 패턴은 생산자(Producer)가 데이터를 발행(Publish)하고, 다수의 독립적인 소비자(Subscriber)들이 독립적으로 구독(Subscribe)하는 구조입니다.

하지만 Pub-Sub 패턴을 아래와 같은 차이가 존재하기에 파이프로만 구현하기에는 쉽지 않습니다.

특징 Pub-Sub (이상적인 모델) 파이프 요구사항
발행-구독 관계 1:N (하나의 메시지를 모두가 받음) 1:1 (단일 쓰기 → 단일 읽기) 메시지 복제 및 라우팅 로직 필요
메시지 지속성 모든 구독자가 수신할 때까지 유지됨 읽는 순간 버퍼에서 소멸 중앙 브로커 프로세스의 중개 필수
비동기성 기본적으로 논블로킹 통신 선호 기본적으로 블로킹 I/O I/O 멀티플렉싱 (select/poll) 도입 필수

블로킹 I/O에 기반하는 파이프만으로는 완전한 의미의 Pub-Sub 패턴을 구현할 수는 없겠습니다.

게다가 파이프는 1대1 연결만 가능하면서, 파일 디스크립터를 통해 읽어낼 시 버퍼가 비워지면서 내용 전달이 불가능해진다는 제한도 가지고 있습니다.

파이프의 read()write()만을 사용하여 Pub-Sub 패턴의 1:N 통신을 구현하려면, 역할을 하는 중개 프로세스를 설계하고, 멀티플렉싱 기술을 도입해야 합니다.

I/O 멀티플렉싱은 하나의 프로세스가 여러 개의 I/O 채널을 동시에 감시하고, 데이터가 준비된 채널에 대해서만 I/O 작업을 수행할 수 있도록 하는 기능입니다. 여기에서 I/O 채널은 파일 디스크립터가 되겠습니다.

어떻게 구현해야 할까?

파이프의 제약을 극복하고 Pub-Sub의 1:N 통신과 비동기성을 확보하기 위해, read()write()를 제어하는 중앙 브로커 프로세스를 설계해야 합니다. 이 브로커를 통해서, 파이프라는 단순한 통로가 Pub-Sub 아키텍처로 변하겠습니다.

다음 글에서는 이 브로커의 구체적인 구현과 설계를 집중적으로 다뤄보겠습니다. 특히, 간이 Pub-Sub 시스템의 뼈대를 구축하는 방법도 코드를 통해 자세히 알아보겠습니다.

단일 프로세스에서 FIFO 입력 파이프와 다수의 구독 출력 파이프를 효율적으로 감시하고 복제하는 SELECT 기반의 I/O 멀티플렉싱 로직을 이용할 예정입니다.

마무리

XV6의 primes 과제를 통해 파이프를 이용한 프로세스 간 통신과 CSP 모델의 동시성 개념을 실질적으로 경험할 수 있었습니다.

파이프라는 단순한 IPC 메커니즘이 어떻게 복잡한 동시성 문제를 해결할 수 있는지, 그리고 메시지 전달 기반의 프로그래밍 모델이 가지는 장점을 확인할 수 있었습니다. 물론 공유 메모리 기반 모델이 그렇듯, 메시지 전달 기반의 모델도 장단이 있는 것은 당연합니다.

또한, 운영체제 레벨에서 CSP의 핵심 아이디어가 수학적 알고리즘으로 변환되는 과정을 직접 C 코드로 작성하는 것이 인상 깊습니다. 각 프로세스가 독립적인 상태를 유지하면서도 채널을 통해 동기화된 통신을 이루게 하기 위해, 직접 필요한 시스템 콜을 찾아서 호출하고, fork() 및 wait()의 동작을 분석하고, 파일 디스크립터의 구조를 살펴볼 수 있었습니다.

  • 다음 글: 파이프의 제약을 넘어 Pub-Sub으로

다음 글에서는 파이프의 read()write()를 핵심 도구로 사용하면서도, 다수의 구독자에게 메시지를 복제하고 비동기성 문제를 해소하는 구체적인 구현 전략을 다룹니다.

이는 단순한 IPC 활용을 넘어, I/O 멀티플렉싱 기술을 활용한 고차원적인 동시성 시스템 설계로 나아가는 제 학습 과정이기도 합니다.