운영체제 기본/프로세스 간 통신

프로세스마다 주소 공간을 나누면 한 프로그램의 잘못된 쓰기가 다른 프로그램의 사적인 메모리로 곧바로 번지는 일을 막을 수 있다. 하지만 이미지 편집기와 별도 변환 작업처럼 함께 일하는 프로그램들은 요청과 결과를 주고받아야 한다. 프로세스 간 통신 (Interprocess Communication, IPC)은 이 경계를 유지하면서 데이터와 사건을 교환하는 수단이다.

통신 경계와 데이터 전달

프로세스 A의 포인터 값만 B에게 보내서는 같은 객체를 읽을 수 없다. 각 주소 공간의 매핑이 다르므로 같은 주소가 다른 물리 페이지를 가리키거나, B에게는 유효하지 않을 수 있다. 데이터를 별도 통신 경로로 전달하거나, 두 프로세스가 접근할 수 있는 공유 영역을 명시적으로 만들어야 한다.

이미지 변환 요청에는 세 가지 정보가 필요하다. 처리할 이미지 데이터, 어떤 변환을 원하는지 나타내는 요청, 그리고 작업이 끝났다는 통지다. 이들을 한 메시지에 담을 수도 있고, 큰 이미지 데이터는 공유 메모리에 두고 작은 요청과 완료 통지만 따로 전달할 수도 있다. 데이터가 어디에 있는가와 언제 읽거나 바꿔도 되는가는 별개의 문제다.

파이프와 메시지

파이프 (Pipe)는 한쪽에서 쓴 바이트를 다른 쪽이 읽는 통신 통로다. Unix 계열의 일반적인 파이프는 읽기 끝과 쓰기 끝을 파일 디스크립터로 제공한다. 저장장치의 파일은 아니지만, 프로그램은 열린 대상의 핸들과 읽기·쓰기 연산으로 접근한다.[1]

프로세스 A                       프로세스 B
요청 바이트 → write → [파이프 버퍼] → read → 요청 해석

파이프는 바이트 스트림이므로 한 번 쓴 범위가 한 번 읽은 범위와 일치하지 않는다. 예를 들어 A가 요청 두 개를 연이어 쓰면 B가 한 번에 둘을 읽거나, 요청 하나를 여러 번에 나누어 읽을 수 있다. 길이 필드나 구분자처럼 요청의 끝을 알아낼 규칙이 필요하다. 이 문제는 부분 입출력과 연결된다.

반면 메시지 큐 (Message Queue)는 메시지 단위를 유지해 송수신한다. 수신자는 바이트 흐름에서 경계를 다시 찾는 대신 메시지를 받아 처리한다. 큐가 메시지를 고르는 순서는 구현의 규칙에 달려 있다. 예를 들어 POSIX 메시지 큐는 우선순위를 사용할 수 있다. 메시지 크기와 개수에는 한도가 있으므로 메시지 경계를 보존한다고 저장 공간이 무제한이 되는 것은 아니다.[2]

소켓 (Socket)도 통신의 양 끝을 표현하는 수단이다. 같은 시스템의 프로세스 사이에서 사용할 수 있고, 네트워크를 통한 통신에도 사용된다. 스트림 소켓은 바이트 흐름, 데이터그램 소켓은 메시지 단위를 제공하므로 소켓이라는 이름만으로 전달 단위를 정할 수는 없다.[3]

대기와 흐름 제어

수신할 데이터가 없으면 읽는 쪽은 조건이 바뀔 때까지 기다릴 수 있다. 반대로 보내는 쪽이 너무 빠르면 유한한 버퍼가 가득 찬다. 이때 송신을 기다리게 하거나, 비블로킹 요청에 지금은 진행할 수 없다는 결과를 돌려줄 수 있다. 이러한 역압 (Backpressure)은 느린 수신자 때문에 대기열과 메모리가 무한히 늘어나는 것을 막는다.[1]

기다리는 동안 CPU를 계속 쓰는 것이 아니라 해당 실행 흐름을 재우고, 데이터나 빈 공간이 생겼을 때 다시 실행 가능하게 할 수 있다. 깨우기와 실제 실행은 다르므로 상대가 데이터를 보냈다고 수신자가 즉시 실행되는 것은 아니다.

통신 상대의 종료도 규약에 포함해야 한다. 파이프의 모든 쓰기 끝이 닫히면 남은 데이터를 읽은 뒤 읽기 끝에서 EOF를 관찰한다. 읽기 끝이 모두 닫힌 파이프에 쓰면 Unix 계열에서는 SIGPIPE 시그널이 발생할 수 있다. 데이터를 보낼 수 없는 상태와 프로그램에 그 사건을 알리는 방식이 만나는 예다.[1]

시그널과 사건 통지

시그널 (Signal)은 프로세스나 스레드에 사건을 알리는 운영체제의 통지 수단이다. 다른 프로세스의 종료 요청, 자식 프로세스의 상태 변화, 실행 중 발생한 오류 등이 원인이 된다. Unix 계열의 SIGTERM은 종료 요청, SIGCHLD는 자식 상태 변화, SIGSEGV는 잘못된 메모리 접근을 알리는 대표적인 예다. 시그널은 IPC에도 쓰이지만 모든 시그널이 다른 프로세스에서 온 메시지는 아니다.[4]

하드웨어 예외는 CPU가 커널로 제어를 넘기는 사건이고, 시그널은 운영체제가 프로그램에 통지하는 수단이다. 메모리 접근 예외를 커널이 복구하지 못하면 시그널로 이어질 수 있지만, 커널이 페이지를 준비하고 명령을 다시 실행하면 오류 시그널 없이 진행할 수도 있다. 하드웨어 인터럽트와 시그널도 같은 것이 아니다.

시그널의 발생과 전달은 구분된다. 전달을 잠시 차단한 시그널은 보류 (Pending) 상태로 남을 수 있다. Linux에서는 커널이 사용자 실행으로 돌아가는 경로 등에서 전달 가능한 시그널을 확인하고, 등록된 처리 방식에 따라 동작한다. 사용자 핸들러가 실행되는 경우에도 커널 모드가 아니라 해당 스레드의 사용자 모드 코드로 실행된다.[4]

  • 기본 동작: 시그널 종류에 정해진 종료·정지·계속·무시 등의 동작을 따른다.
  • 무시: 해당 시그널을 처리하지 않도록 지정한다. 전달을 미루는 차단과는 다르다.
  • 핸들러: 프로그램이 등록한 함수로 사건을 처리한다. SIGKILL과 SIGSTOP은 핸들러를 등록하거나 차단·무시할 수 없다.

핸들러는 기존 코드의 실행 중간에 들어올 수 있다. 그 코드가 printf 같은 함수의 내부 상태를 갱신하던 중이었다면, 핸들러에서 같은 함수를 다시 부르는 것이 안전하지 않을 수 있다. 핸들러에서는 비동기 시그널 안전 (Async-Signal-Safe) 조건을 만족하는 작업만 짧게 수행하고, 나머지 처리는 일반 실행 경로로 넘기는 방식이 필요하다.[5]

시그널은 대량 데이터 전송보다 사건 통지에 적합하다. 특히 Linux의 표준 시그널 (Standard Signal)은 같은 종류가 보류된 동안 여러 번 발생해도 그 횟수를 일반 메시지 큐처럼 모두 보관하지 않는다. 완료 통지를 받았다는 사실만으로 처리할 결과가 정확히 하나라고 가정해서는 안 된다. 실제 상태나 대기열을 확인해야 한다.[4]

공유 메모리와 동기화

큰 이미지를 매번 통신 버퍼에 복사하면 데이터 양에 비례한 비용이 생긴다. 공유 메모리 (Shared Memory)는 서로 다른 주소 공간이 같은 물리 페이지를 가리키게 하여, 참여자들이 그 영역을 직접 읽고 쓰게 한다. 매핑을 마련한 뒤에는 데이터 접근마다 송수신 시스템 콜과 커널 버퍼를 거치는 복사를 반복할 필요가 없다. 최초 매핑, 페이지 폴트, 캐시 일관성과 동기화의 비용까지 없어지는 것은 아니다.[6]

프로세스 A의 주소 공간        공유 물리 페이지        프로세스 B의 주소 공간
가상 주소 0x400000 ────────→ [ 이미지 데이터 ] ←──────── 가상 주소 0x900000
                                같은 내용

두 프로세스의 가상 주소가 같을 필요는 없다. 따라서 공유 영역 안의 객체를 서로 참조할 때에도 한 프로세스의 포인터 값을 그대로 저장하기보다, 공유 영역 시작에서의 오프셋처럼 양쪽이 해석할 수 있는 표현이 필요하다. 파일·익명 메모리를 공유 매핑하는 구체적인 방법은 Linux의 메모리 매핑에서 다룬다.

공유되었다는 사실은 읽고 쓰는 순서를 보장하지 않는다. A가 이미지의 절반만 바꿨는데 B가 읽으면 이전 내용과 새 내용이 섞일 수 있다. 같은 카운터를 동시에 증가시키면 레이스 컨디션도 생긴다. 프로세스 사이에서 동작하도록 마련한 뮤텍스·세마포어나 적절한 원자 연산 규약이 필요하다. 세마포어 (Semaphore)는 사용 가능한 자원이나 사건의 수를 관리하며, 그 수가 부족한 작업은 기다리게 할 수 있는 동기화 수단이다.[6]

이미지 슬롯 하나를 교환한다면 다음처럼 데이터 소유권과 알림을 연결할 수 있다. 두 세마포어는 프로세스 간 공유용으로 구성하고, empty를 1, ready를 0으로 시작한다.

생산자 A                         소비자 B
empty 대기                       ready 대기
공유 슬롯에 이미지 전체 기록       공유 슬롯의 이미지 처리
ready 알림                       empty 알림

A는 B가 읽는 동안 슬롯을 덮어쓰지 않고, B는 A가 완성한 뒤에만 읽는다. 단순한 알림과 달리 이 동기화 규약은 슬롯의 사용 가능 상태와 메모리 접근 순서를 함께 연결한다. 참여자가 도중에 종료되면 기다림이 영원히 이어질 수 있으므로, 상대의 실패와 공유 객체의 수명도 설계해야 한다.

쓰기 시 복사 (COW)와 쓰기 가능한 공유 메모리도 구분한다. COW는 쓰기 시 독립된 내용을 유지하려는 방식이고, IPC용 공유 메모리는 상대에게 변경이 보이도록 같은 내용을 공유하는 방식이다. 물리 페이지를 공유하는 이유가 다르다.

통신 방식의 선택

방식 전달하는 것 주된 설계 문제
시그널 사건 통지 처리 시점과 핸들러 안전성, 통지와 실제 상태의 구분
파이프·스트림 순서 있는 바이트 흐름 메시지 경계, 부분 입출력, 버퍼와 역압
메시지 큐 경계를 가진 메시지 크기·개수 한도, 순서와 대기 규칙
공유 메모리 같은 저장 영역 동기화, 소유권, 수명과 상대의 실패

작은 요청과 응답은 메시지로 전달하면 경계를 명확히 하기 쉽다. 큰 데이터를 반복해서 주고받는 경우에는 공유 메모리로 복사를 줄일 수 있지만 동기화 책임이 커진다. 큰 데이터는 공유 메모리에 두고, 제어 요청과 완료는 별도 통로로 주고받는 혼합 설계도 가능하다. 어느 하나가 항상 빠르거나 안전한 것이 아니라 데이터 크기, 전달 빈도, 기다림과 장애 처리의 복잡도를 함께 비교해야 한다.

참고 문헌

  1. ↑ 1.0 1.1 1.2 Linux man-pages: pipe(7), 파이프의 입출력과 바이트 스트림.
  2. ↑ Linux man-pages: mq_overview(7), 메시지 단위 송수신과 우선순위.
  3. ↑ Linux man-pages: socket(2), 통신 도메인과 소켓 유형.
  4. ↑ 4.0 4.1 4.2 Linux man-pages: signal(7), 시그널의 생성·전달·처리.
  5. ↑ Linux man-pages: signal-safety(7), 핸들러의 재진입과 안전한 함수.
  6. ↑ 6.0 6.1 Michael Kerrisk, POSIX Shared Memory, 공유 매핑과 세마포어를 이용한 동기화.