ch19. inter process communication
프로세스 통신,
커널이 마련한 통로들
서로 다른 프로세스들은 직접 만날 수 없습니다. 파이프, 세마포어, 메시지 큐, 공유 메모리 등 커널이 정교하게 마련한 통로를 통해서만 데이터를 주고받고 동작을 동기화할 수 있죠. 이 노트에서는 각 통로가 커널 내부에서 어떤 객체들로 구성되는지 그 흐름을 낱낱이 파헤쳐 봅니다.
큰 그림: 프로세스들은 왜 커널을 통해 대화해야 할까?WHY IPC EXISTS
프로세스 A가 생성한 데이터를 프로세스 B가 곧바로 읽어야 할 때, B가 A의 메모리를 직접 들여다보면 어떨까요? 직관적으로는 효율적일 것 같지만, 운영체제는 이를 철저히 통제합니다.
각 프로세스는 자신만의 독립적인 주소 공간을 갖습니다. 다른 프로세스의 메모리를 함부로 읽고 쓰도록 내버려 두면, 시스템의 보안과 안정성 모두가 무너지고 맙니다. 이것이 바로 오늘 다룰 핵심입니다. 프로세스들은 서로 직접 소통하는 대신, 커널이 안전하게 마련해 둔 통로를 통해 만납니다. 커널이 중간에서 우체국이나 창고, 도로, 그리고 신호등 같은 중재자 역할을 하는 셈이죠.
생산자-소비자 구조에 찰떡같이 어울리는 단방향 바이트 스트림입니다. ls | more 명령어가 대표적인 예입니다.
파일시스템에 명시적인 이름으로 존재하여, 서로 관련이 없는 프로세스들도 약속된 장소에서 만날 수 있게 해줍니다.
데이터를 직접 나르기보다는, 여러 프로세스가 공유 자원에 접근하는 순서와 타이밍을 조율하는 신호등 역할을 합니다.
커널 안에 마련된 우편함에 메시지 타입(라벨)을 붙여 넣고, 원하는 타입만 쏙쏙 골라 읽어가는 방식입니다.
동일한 page frame들을 여러 프로세스가 각자의 주소 공간에 매핑하여 직접 접근합니다. 복사 비용이 없어 대용량 데이터 처리에 유리하지만, 별도의 동기화 작업이 필수적입니다.
이번 강의는 단순히 프로세스들이 통신한다는 표면적인 이야기가 아닙니다. 우체국의 창구 설계, 보관함 체계, 대기열 관리, 심지어 재난 시 복구 시스템까지 파헤쳐보는 깊이 있는 시간입니다. 겉보기에는 단순한 시스템 콜(read(), write(), semop(), shmat() 등) 같지만, 무대 뒤에서는 inode, file object, wait queue, page frame, address_space 같은 커널 객체들이 쉴 새 없이 움직이고 있습니다.
물론 파일 락(file lock)을 활용해 임시 파일로 프로세스를 동기화할 수도 있습니다. 하지만 디스크를 거치는 파일시스템 접근은 비용이 매우 큽니다. 그래서 커널은 디스크 접근을 우회하거나, 겉으로만 파일처럼 보일 뿐 실제로는 메모리 기반의 빠른 내부 구조를 사용하는 전용 IPC 메커니즘을 다양하게 제공하는 것입니다.
핵심 O/X 퀴즈
1. User Mode 프로세스들이 서로 데이터를 안전하게 주고받으려면 보통 커널이 제공하는 통신 메커니즘을 거쳐야 한다.
2. 파일 락과 임시 파일을 사용하면 항상 가장 빠른 프로세스 통신이 가능하다.
3. 공유 메모리는 여러 프로세스가 동일한 물리 메모리에 접근하게 해주는 IPC 방식이다.
파이프의 기본 개념: ls | more 안에서는 무슨 일이 벌어질까?PIPE BASICS
파이프는 유닉스 계열에서 가장 고전적이면서도 여전히 강력한 프로세스 통신 방식입니다. 원리는 명쾌합니다. 한 프로세스가 파이프에 밀어 넣은 데이터를 커널이 맡아두었다가, 다른 프로세스가 꺼내어 읽을 수 있게 해주는 것입니다. 흐름은 철저히 한 방향(단방향)으로만 흐릅니다.
파이프는 말 그대로 길쭉한 수도관과 같습니다. 한쪽 끝에서 물을 흘려보내면, 반대쪽 끝에서 물이 나옵니다. 그리고 이 수도관 전체를 커널이 관리하고 유지해 줍니다.
우리가 셸에서 ls | more를 실행할 때, 셸은 내부적으로 두 개의 자식 프로세스를 생성합니다. 이때 ls의 표준 출력은 모니터 화면이 아닌 파이프의 입구로 연결되고, more의 표준 입력은 키보드가 아닌 파이프의 출구로 연결됩니다. 사용자는 무심코 | 기호 하나를 입력했을 뿐이지만, 무대 뒤에서는 파일 디스크립터 복제, 프로세스 생성, 입출력 방향 재지정이라는 일련의 작업이 순식간에 일어납니다.
파이프는 겉보기에 파일과 비슷하지만, 일반적인 파일과는 완전히 다릅니다. 프로세스 입장에서는 파일 디스크립터를 얻어 read()와 write()를 호출하니 파일처럼 느껴집니다. 하지만 파이프는 하드 디스크에 기록되는 실체가 없습니다. 오직 커널 내부의 메모리 버퍼가 데이터를 잠시 보관하며 흘려보낼 뿐입니다.
물론 ls > temp 이후에 more < temp를 실행해 임시 파일로 같은 결과를 낼 수도 있습니다. 그러나 파이프를 사용하면 명령어가 훨씬 직관적이고 간결해지며, 디스크 I/O를 발생시키지 않아 속도도 훨씬 빠릅니다. 이처럼 파이프는 생산자-소비자 패턴에 완벽하게 부합합니다.
다만 제약도 있습니다. 일반적인 익명 파이프(Anonymous Pipe)는 pipe()를 호출한 부모 프로세스와 이를 상속받은 자식 프로세스처럼, 핏줄이 이어진 관계에서만 사용할 수 있습니다. 이 한계를 극복하기 위해 등장한 것이 바로 FIFO입니다.
핵심 O/X 퀴즈
1. 파이프는 한 프로세스가 쓴 데이터를 커널이 중간에서 전달해 다른 프로세스가 읽게 해주는 단방향 통신 수단이다.
2. `ls | more`를 실행하면 시스템은 반드시 디스크에 임시 파일을 생성하여 데이터를 넘겨준다.
3. 익명 파이프는 통상적으로 부모와 자식 프로세스처럼 파일 디스크립터를 상속받는 관계에서만 쓸 수 있다.
파이프 사용 과정: pipe(), fork(), dup2(), execve()PIPE SETUP FLOW
파이프를 생성하려면 프로세스가 pipe() 시스템 콜을 호출해야 합니다. 이 함수는 성공 시 크기가 2인 배열에 두 개의 파일 디스크립터를 담아 돌려주는데, 하나는 파이프의 출구(읽기 끝), 다른 하나는 입구(쓰기 끝) 역할을 합니다.
pipe()를 호출해 파이프를 생성합니다. 이때 읽기 끝은 3번, 쓰기 끝은 4번 디스크립터로 할당되었다고 가정해 보겠습니다.ls를 실행할 첫 번째 자식과 more를 실행할 두 번째 자식을 차례로 만듭니다. 두 자식은 부모(셸)가 가진 파일 디스크립터들을 고스란히 상속받습니다.ls를 맡은 자식은 dup2(4, 1)을 호출합니다. 이는 "4번 디스크립터(파이프 쓰기)를 표준 출력(1번) 자리에 복제하라"는 뜻입니다. 이후 불필요해진 3번, 4번을 닫고 execve()로 ls 프로그램을 덮어씌웁니다.more를 맡은 자식은 dup2(3, 0)을 호출해 파이프의 읽기 끝을 표준 입력(0번)으로 바꿉니다. 마찬가지로 3번, 4번을 닫고 execve()로 more를 실행합니다.프로그램 자신은 파이프의 존재를 전혀 모릅니다. ls는 여느 때처럼 그저 표준 출력에 화면을 그리듯 데이터를 쏟아내고, more 역시 표준 입력에서 텍스트를 읽어 들일 뿐입니다. 운영체제와 셸이 막후에서 파일 디스크립터를 교묘하게 바꿔치기해 둔 덕분이죠. 이것이 바로 유닉스가 자랑하는 입출력 추상화의 진수입니다.
POSIX 표준에 따르면 파이프는 기본적으로 단방향(half-duplex)입니다. 양방향으로 대화하려면 파이프 두 개를 엮어야 합니다. 만약 매번 pipe(), fork(), dup2() 등을 직접 호출하는 것이 번거롭다면, C 라이브러리에서 제공하는 popen()과 pclose()를 활용하면 됩니다. 이 함수들은 내부적으로 복잡한 연결 과정을 대신 처리하고 다루기 쉬운 FILE * 포인터를 던져줍니다.
파이프 자체를 만드는 것도 중요하지만, 실질적인 마법은 파일 디스크립터 상속과 입출력 재지정에서 완성됩니다. 파이프가 튼튼한 수도관이라면, dup2()는 그 수도관을 프로그램의 기본 입출력 밸브에 딱 맞게 조립해 주는 배관공의 렌치와 같습니다.
핵심 O/X 퀴즈
1. `pipe()` 시스템 콜은 호출 성공 시 읽기 전용과 쓰기 전용 파일 디스크립터 두 개를 한꺼번에 반환한다.
2. `ls | more` 환경에서 `ls` 소스 코드 안에는 파이프 통신을 위한 별도의 전용 API 호출이 포함되어 있어야 한다.
3. C 라이브러리의 `popen()`은 번거로운 파이프 설정 및 자식 프로세스 생성 과정을 캡슐화한 편의 함수다.
파이프의 커널 자료구조: 파일처럼 보이게 만드는 장치들pipe_inode_info · pipe_buffer · pipefs
사용자 프로세스 입장에서 파이프는 그저 파일 디스크립터로 접근하는 대상일 뿐입니다. 그렇다면 커널은 이 환상을 유지하기 위해 보이지 않는 곳에서 무엇을 만들어낼까요? 정답은 **하나의 inode 객체와 두 개의 file 객체(읽기용, 쓰기용)**를 생성하는 것입니다.
- nrbufs
- 현재 파이프 버퍼 안에 유효한 데이터가 채워진 개수를 나타냅니다.
- curbuf
- 다음에 읽어 들일 차례가 된 pipe buffer의 인덱스를 가리킵니다.
- bufs[16]
- Linux 2.6.11 이후 도입된 16개의 pipe buffer 배열입니다. 각각의 버퍼는 페이지 프레임 하나를 기반으로 데이터를 품습니다.
- readers / writers
- 현재 이 파이프를 물고 있는 읽기/쓰기 프로세스가 존재하는지를 나타내는 상태 플래그입니다.
- wait
- 파이프를 읽거나 쓰기 위해 조건이 맞기를 기다리며 잠든 프로세스들의 대기 큐입니다.
- waiting_writers
- 버퍼 공간이 꽉 차서 쓰기를 멈추고 잠든 writer 프로세스의 수입니다.
이 16개의 pipe buffer는 마치 **원형 버퍼(Circular Buffer)**처럼 영리하게 동작합니다. 글을 쓰는 writer는 빈 버퍼를 찾아 데이터를 채워 넣고, 읽는 reader는 curbuf가 가리키는 버퍼에서 차례대로 데이터를 가져갑니다. 새 데이터를 쓸 위치는 (curbuf + nrbufs) % 16으로 깔끔하게 계산되죠. 커다란 물통 하나만 쓰는 것보다, 16개의 컵을 회전 트레이에 올려두고 차례차례 비우고 채우는 방식이 메모리 활용과 유연성 면에서 훨씬 효율적입니다.
- page
- 실제 데이터가 담겨 있는 메모리의 페이지 프레임 주소입니다.
- offset
- 해당 페이지 내에서 의미 있는 데이터가 시작되는 오프셋 위치입니다.
- len
- 유효한 데이터의 길이(바이트 크기)를 나타냅니다.
- ops
- 이 버퍼를 메모리에 매핑하거나 해제할 때 사용할 메서드 함수들의 테이블입니다.
여러 프로세스가 파이프에 동시에 달려들면 데이터가 꼬일 수 있으므로, 커널은 inode 구조체 내부에 있는 i_sem 세마포어로 파이프 데이터를 단단히 보호합니다. 나아가, 커널은 pipefs라는 특수한 파일시스템을 띄워 파이프를 VFS(가상 파일 시스템) 계층과 자연스럽게 엮어냅니다. 일반 사용자의 눈에는 보이지 않는 투명한 파일시스템이지만, 이 덕분에 파이프는 여느 파일과 똑같은 커널 인터페이스의 혜택을 누릴 수 있습니다.
핵심 O/X 퀴즈
1. 커널은 파이프를 VFS 객체처럼 다루기 위해 1개의 inode 객체와 2개의 file 객체를 생성한다.
2. Linux 2.6.11 버전 이후부터는 버퍼링 유연성을 높이기 위해 16개의 페이지 기반 pipe buffer를 배열로 사용한다.
3. pipefs는 루트 디렉터리에 항상 마운트되어 있어 `ls` 명령어로 누구나 쉽게 안을 들여다볼 수 있다.
파이프 생성과 파괴: do_pipe()가 만드는 것들LIFECYCLE
응용 프로그램에서 pipe() 시스템 콜을 부르면, 커널 공간에서는 sys_pipe()가 응답하며 실질적인 뼈대 구축은 do_pipe() 함수가 전담합니다.
pipe_inode_info를 만들어 inode의 i_pipe 필드에 매달아 줍니다. 처음이므로 curbuf와 nrbufs는 0, readers와 writers 카운트는 각각 1로 세팅됩니다.O_RDONLY로 고정하고, file operation 구조체는 파이프 읽기 전용 함수들로 채워 넣습니다.O_WRONLY로 설정하고, 쓰기 전용 함수들로 매핑된 객체를 준비합니다.그렇다면 이렇게 만들어진 파이프는 언제 사라질까요? 프로세스가 사용을 마치고 파일 디스크립터를 닫으면 fput()이 호출되며 참조 카운트가 하나씩 줄어듭니다. 이 카운트가 완전히 0으로 떨어져야 pipe_release()가 실행됩니다. 읽기용과 쓰기용 채널 양쪽이 모두 완전히 닫혀야만, 커널은 그제야 버퍼가 쥐고 있던 물리적 페이지 프레임들을 시스템에 반환합니다. 만약 한쪽이라도 아직 열려있다면 대기 큐에서 잠자고 있는 상대편 프로세스를 깨워 파이프의 상태 변화를 알리게 됩니다.
파이프의 생명주기는 회의실 예약 시스템과 아주 비슷합니다. 회의실을 처음 대관한 사람이 먼저 나갔다고 해서 곧바로 불을 끄고 문을 잠가버리면 안 됩니다. 안에 아직 남아있는 사람들이 회의를 계속할 수 있으니까요. **마지막 사람까지 완전히 방을 나서고 문이 닫혀야만** 비로소 청소를 하고 불을 끌 수 있는 것과 같은 이치입니다. VFS의 참조 카운터(usage counter)가 바로 이 '마지막 사람'을 확인하는 깐깐한 문지기 역할을 수행합니다.
핵심 O/X 퀴즈
1. `do_pipe()` 함수는 내부적으로 파이프를 위한 inode 객체 하나와 그를 가리키는 읽기/쓰기용 file 객체 두 개를 생성한다.
2. 파이프를 처음 연 부모 프로세스가 종료되면, 자식 프로세스가 아직 사용 중이라도 파이프는 강제로 소멸된다.
3. 읽기 끝과 쓰기 끝 파일 디스크립터가 모두 닫혀 참조 카운트가 0이 되어야만 파이프가 점유하던 메모리 페이지가 회수된다.
파이프에서 읽기: 언제 기다리고, 언제 0을 돌려주는가?pipe_read() SEMANTICS
프로세스가 파이프의 읽기 쪽 디스크립터에 대고 read()를 호출하면 커널은 pipe_read() 함수로 응답합니다. 사용자가 요청한 바이트 수를 n, 현재 버퍼 안에 읽지 않고 남아있는 유효한 바이트 수를 p라고 할 때, 파이프의 동작은 당시의 미묘한 상황에 따라 완전히 달라집니다.
- p = 0, writer 있음, blocking
- 버퍼는 비었지만 아직 데이터를 보내줄 작가가 있으므로, 데이터가 올 때까지 대기 큐에서 얌전히 잠을 잡니다.
- p = 0, writer 있음, nonblocking
- 데이터는 없지만 기다릴 여유도 없으므로 즉시
-EAGAIN에러 코드를 뱉고 돌아옵니다. - p = 0, writer 없음
- 보내줄 사람마저 떠났으므로 파이프는 마침내 말랐습니다. 정상적인 종료를 뜻하는 0(EOF)을 반환합니다.
- p > 0
- 요청량(n)과 남은 량(p) 중 작은 값만큼 사용자 메모리로 복사한 뒤, 각종 버퍼 상태 변수들을 알맞게 업데이트합니다.
pipe_read()의 세밀한 처리 과정은 다음과 같습니다: i_sem 세마포어를 잡아 동시성 문제를 막고 → nrbufs로 데이터 유무를 파악합니다 → 데이터가 0이면 기다릴지 말지 결정하고 → 데이터가 있다면 curbuf 위치의 페이지를 매핑(map)하여 → 데이터를 유저 공간으로 밀어 넣은 뒤 → 매핑을 해제(unmap)합니다 → 읽은 만큼 offset과 len을 조절하고 → 만약 버퍼 하나가 완전히 비워지면 curbuf를 다음 칸으로 넘기고 nrbufs를 1 줄입니다 → 마지막으로 세마포어를 풀며 공간이 생기길 기다리던 쓰기 프로세스를 깨워줍니다.
세 가지 핵심 원칙: ① 버퍼가 비어있는데 상대방(writer)마저 연결을 끊었다면 미련 없이 0(EOF)을 반환한다. ② 버퍼가 비었더라도 아직 상대방이 살아있다면 블로킹 모드에서는 믿고 기다려준다. ③ 논블로킹 모드는 상대방의 생존 여부와 무관하게 데이터가 없으면 즉시 에러와 함께 돌아온다.
more 명령어가 데이터를 읽으려는데 파이프라인 앞단의 ls가 디스크 로딩 탓에 멈칫거린다면, more는 잠시 기다려 줍니다. 하지만 ls가 탐색을 모두 마치고 스스로 종료해 쓰기 채널이 완전히 닫히면, more 역시 read 반환값으로 0을 넘겨받고 "아, 이제 끝이구나" 하고 화면 출력을 마무리하는 것입니다.
핵심 O/X 퀴즈
1. 파이프가 비어 있고 데이터를 더 써줄 쓰기 프로세스도 남아있지 않다면, `read()` 호출은 0을 반환한다.
2. O_NONBLOCK 플래그를 켠 논블로킹 상태에서는 파이프가 비어있을 경우 데이터가 도착할 때까지 대기 큐에서 잠들게 된다.
3. 데이터를 무사히 읽어 들인 후에는 원형 버퍼의 상태(offset, len, curbuf 등)를 정확하게 갱신해야 한다.
파이프에 쓰기: 원자성, 공간 부족, Broken pipepipe_write() SEMANTICS
프로세스가 파이프의 쓰기 쪽에 대고 데이터를 밀어 넣으려 할 때 호출되는 함수가 바로 pipe_write()입니다. 이때 커널은 크게 세 가지 중요한 이슈를 점검합니다. **읽어줄 프로세스가 살아있는가, 버퍼에 남은 공간은 충분한가, 그리고 쓰기 작업의 원자성(Atomicity)을 보장할 수 있는가**입니다.
readers 카운트를 살핍니다. 만약 읽을 프로세스가 하나도 없다면 허공에 대고 쓰는 꼴이 되므로, 해당 프로세스에 강렬한 SIGPIPE 시그널을 날리고 -EPIPE 오류를 던집니다. 우리가 종종 마주하는 악명 높은 "Broken pipe" 에러가 바로 여기서 시작됩니다.nrbufs가 최대 한도인 16에 도달했는지 확인해 새 버퍼를 열 준비를 합니다.-EAGAIN을 뱉고 물러납니다. 반대로 블로킹 모드라면 대기 큐에 몸을 뉘인 채, 반대편 reader가 데이터를 부지런히 빼내어 버퍼에 숨통을 트여줄 때까지 잠들어 기다립니다.nrbufs를 올립니다. 작업이 끝나면 세마포어를 탁 풀고 대기 큐에서 잠자던 reader들을 힘차게 흔들어 깨웁니다.쓰기 원자성(Atomicity)의 철칙: POSIX 표준은 4096바이트 이하의 짤막한 쓰기 요청은 중간에 쪼개지지 않고 통째로 처리될 것을 엄격하게 요구합니다. 즉, 여러 프로세스가 앞다투어 같은 파이프에 데이터를 들이붓더라도, 4096바이트 이하의 덩어리는 중간에 다른 데이터와 지저분하게 뒤섞이지 않고 온전히 제 형태를 유지해야 합니다. 다만 이 기준을 넘어서는 거대한 쓰기 요청은 커널 재량에 따라 여러 조각으로 나뉘어 섞일 위험이 존재합니다.
여러 사람이 좁은 우체통에 편지를 구겨 넣는 상황을 상상해 보세요. 작은 편지 봉투(4096바이트 이하)는 아무리 밀치고 싸워도 온전한 형태 하나로 우체통에 쏙 들어갑니다(원자성 보장). 봉투가 반쯤 찢어져 남의 편지와 뒤죽박죽 섞이는 일은 없죠. 하지만 엄청나게 큰 백과사전 한 질을 통째로 밀어 넣으려 한다면, 어쩔 수 없이 낱장으로 분리해 욱여넣어야 하고, 그 사이사이에 다른 사람의 엽서가 끼어들 여지가 생기는 것과 비슷합니다.
핵심 O/X 퀴즈
1. 쓰기 작업을 시도할 때 파이프를 열어둔 읽기 프로세스가 하나도 없다면, 커널은 `SIGPIPE` 시그널을 날린다.
2. 4096바이트 이하의 작은 쓰기 요청이라도, 여러 프로세스가 동시에 쓴다면 데이터가 마구잡이로 뒤섞일 수 있다.
3. 16개의 파이프 버퍼가 모두 꽉 찬 상태에서 O_NONBLOCK 플래그를 달고 들이대면 `-EAGAIN` 에러를 맞게 된다.
FIFO: 이름이 있는 파이프가 필요한 이유NAMED PIPE
FIFO는 흔히 named pipe라는 이름표를 달고 불립니다. 일반적인 익명 파이프가 지닌 가장 치명적인 약점은 "가계도가 이어지지 않은 완전히 남남인 프로세스들끼리는 이미 만들어진 파이프의 존재조차 알 길이 없다"는 점이었습니다. FIFO는 파일시스템의 디렉터리 트리에 명시적인 '이름'으로 당당히 존재하기 때문에 이 딜레마를 깔끔하게 해결해 냅니다.
파이프가 부모가 자식에게만 몰래 쥐여주는 임시 비밀 전화선이라면, FIFO는 누구나 주소를 알고 찾아갈 수 있도록 건물 외벽에 떡하니 설치된 고정 우편함과 같습니다. 주소(이름)와 열쇠(권한)만 있다면 누구든 와서 편지를 넣고 뺄 수 있죠.
실제 백엔드 시스템을 떠올려봅시다. 데이터베이스 서버가 늘 백그라운드에서 깨어 있습니다. 서버는 기동할 때 특정 디렉터리(예: /tmp/db_req)에 FIFO 파일을 하나 만들어 둡니다. 나중에 언제든 클라이언트 프로그램이 실행되면, 그저 약속된 경로의 FIFO를 파일 열듯 열어서 요청을 들이밀면 됩니다. 이처럼 FIFO의 명시적 이름은 서로 일면식도 없는 프로세스들이 안전하게 조우할 수 있는 굳건한 약속 장소가 되어줍니다.
오해하기 쉬운 점이 있습니다. 파일시스템에 버젓이 존재한다고 해서 FIFO에 쓰인 데이터가 덜컥 하드 디스크에 쓰이는 것은 결코 아닙니다. 데이터는 디스크를 스쳐 지나지 않고 오로지 열려 있는 커널 메모리 버퍼를 통해서만 쏜살같이 오고 갑니다. 리눅스 2.6 커널 구조를 뜯어보면 FIFO와 파이프는 뿌리가 같습니다. 둘 다 동일한 pipe_inode_info 구조체를 다루며, 읽기와 쓰기를 담당하는 핵심 엔진 역시 똑같이 pipe_read()와 pipe_write()를 공유합니다.
- 이름과 서식지
- 파이프: 숨겨진 pipefs에만 서식하며 사용자 눈에는 보이지 않음. FIFO: 일반 파일시스템 트리에 명확한 경로와 이름으로 상주.
- 초대받은 자들
- 파이프: 부모에게서 디스크립터를 물려받은 혈맹 프로세스들. FIFO: 경로를 알고 접근 권한만 있으면 누구든 환영.
- 엔진부 구현
- 양쪽 모두 pipe_inode_info, pipe_read(), pipe_write()라는 동일한 심장을 공유.
- 개방의 미학
- FIFO는 일반 파이프와 달리, 반대편 상대방(reader나 writer)이 도착할 때까지 open 단계에서 우직하게 기다려주는(blocking) 매력이 있음.
핵심 O/X 퀴즈
1. FIFO는 디렉터리 경로상에 이름이 뚜렷하게 남는 named pipe의 일종이다.
2. 일반 파일처럼 경로가 존재하므로, FIFO에 기록한 데이터는 전원 종료 시에도 디스크에 고스란히 영구 보존된다.
3. 리눅스 커널의 깊은 곳에서는 FIFO와 파이프가 `pipe_read()` 및 `pipe_write()` 함수를 사이좋게 나눠 쓴다.
FIFO 생성과 열기: mkfifo(), mknod(), 그리고 open의 미묘한 규칙fifo_open() BLOCKING RULES
원하는 곳에 FIFO를 심으려면 프로세스에서 mknod() 시스템 콜이나 POSIX가 챙겨주는 mkfifo() 래퍼 함수를 부르면 됩니다. 뼈대가 세워진 FIFO는 이제 여느 파일과 다름없이 open(), read(), write(), close()의 친숙한 패턴으로 가지고 놀 수 있습니다.
사용자가 FIFO에 대고 open()을 찌르면 VFS는 지체 없이 fifo_open() 커널 함수를 호출합니다. 이 함수는 동시성 제어용 i_sem을 꽉 쥔 채, 아직 i_pipe 구조체가 없다면 새로 뚝딱 만들어 붙입니다. 그런 뒤 호출자가 요청한 접근 모드(읽기 전용, 쓰기 전용 등)에 맞춰 파일 연산(f_op) 테이블의 기어를 철컥 변속해 줍니다.
- O_RDONLY (읽기 전용)
- 읽기 기능은 켜지고 쓰기 기능은 막힙니다. 블로킹 모드라면 누군가 쓰러 올 때까지 얌전히 기다립니다. 논블로킹 모드라면 쿨하게 즉시 열립니다.
- O_WRONLY (쓰기 전용), 블로킹
- 읽기 기능은 막히고 쓰기 엔진이 가동됩니다. 반대편에서 읽어줄 프로세스가 문을 두드릴 때까지 무던히 대기합니다.
- O_WRONLY (쓰기 전용), 논블로킹
- 읽어줄 상대방이 아직 나타나지 않았다면, 차갑게
-ENXIO에러를 던지며 개방을 거부합니다. - O_RDWR (읽기/쓰기)
- 읽기와 쓰기 양방향 밸브가 모두 열리며, 딱히 누구를 기다릴 필요 없이 즉각 성공합니다.
일반 파이프가 애초부터 두 사람이 짝을 지어 쥐고 있는 무전기 한 쌍이라면, FIFO는 구청 로비에 마련된 민원 전용 창구입니다. 창구 직원이 출근해 셔터를 올렸어도 민원인이 올 때까지 느긋하게 멍때릴 수(blocking) 있고, 반대로 민원인이 서류 뭉치를 들고 급히 뛰어왔는데 직원이 없다면 욕설(에러)을 내뱉고 돌아가 버릴 수도(nonblocking) 있는 섬세한 동기화의 묘미가 있습니다.
fifo_open()은 열리는 모드에 발맞춰 readers, writers 카운트를 성실하게 올려줍니다. 새로운 독자가 등장하면 기다리다 지친 작가를 흔들어 깨우고, 반대로 새로운 작가가 등장하면 목빠지게 기다리던 독자를 일깨웁니다. 요컨대 FIFO는 단순히 데이터를 나르는 파이프를 넘어, 열리는(open) 그 순간부터 이미 두 프로세스의 만남을 조율하는 정교한 동기화 무대인 셈입니다.
핵심 O/X 퀴즈
1. POSIX 표준의 `mkfifo()` 함수는 내부적으로 `mknod()` 시스템 콜을 활용해 특수 파일을 빚어낸다.
2. FIFO를 쓰기 전용이면서 논블로킹 모드(O_WRONLY | O_NONBLOCK)로 열고자 할 때, 읽는 프로세스가 없다면 `-ENXIO`가 발생한다.
3. FIFO를 읽기 전용 모드로 열더라도 내부적으로는 `pipe_write()` 함수 포인터가 기본 장착되어 있다.
System V IPC의 공통 구조: key, identifier, persistenceIPC RESOURCE MODEL
이제 파이프와 FIFO의 세계를 지나, 더욱 다채로운 기능을 자랑하는 System V IPC로 넘어가 보겠습니다. 세마포어, 메시지 큐, 공유 메모리라는 각기 다른 개성의 도구들이 하나의 거대한 철학 아래 가족으로 묶여 있습니다.
지속성(persistent)의 법칙: System V IPC 자원들은 프로세스가 필요에 의해 생성하더라도, 생성한 프로세스가 숨을 거두었다고 해서 함께 순장되지 않습니다. 누군가 명시적으로 철거 명령을 내리거나 시스템 전원이 차단되지 않는 한, 커널 메모리 한편에 끈질기게 살아남아 자리를 지킵니다.
- IPC key (키)
- 프로그래머가 직접 고안해 내는 32비트 고유 암호입니다. 마치 파일시스템의 절대 경로처럼 바깥세상에서 자원을 지칭하는 간판 역할을 합니다. 보통
ftok()함수로 파일 경로와 프로젝트 ID를 비벼서 그럴듯하게 만들어냅니다. - IPC identifier (식별자)
- 커널이 자원을 생성하거나 연결할 때 발급해 주는 실무용 32비트 티켓 번호입니다. 우리가 파일을 열면 얻게 되는 파일 디스크립터(FD)와 완벽히 대응되는 개념이며, 실제 읽고 쓰는 모든 함수는 이 식별자를 요구합니다.
- IPC_PRIVATE
- 미리 짠 키를 공유하지 않고 독고다이로 자원을 만든 뒤, 커널이 내려준 식별자만 동료 프로세스에게 몰래 전달하는 은밀한 방식입니다.
- IPC_CREAT
- 해당 키를 가진 자원이 없다면 무에서 유를 창조해 냅니다.
- IPC_EXCL
- IPC_CREAT와 콤비로 쓰이며, 만약 이미 자원이 떡하니 버티고 있다면 과감하게 실패 처리하여 덮어쓰기 대참사를 막습니다.
- IPC_NOWAIT
- 바빠 죽겠으니 기다리지 않고 바로 결과(혹은 에러)만 달라는 논블로킹 선언입니다.
- IPC_RMID
- 수명을 다한 IPC 자원을 커널의 세계에서 완전히 소거(Remove)합니다.
- IPC_STAT / IPC_SET
- 자원의 현재 상태와 권한을 훔쳐보거나(STAT), 소유권 등 메타데이터를 내 입맛대로 주무릅니다(SET).
커널은 식별자 번호가 너무 뻔하게 돌려막기 되는 것을 몹시 꺼립니다. 리눅스 2.6에서는 대략 identifier = s × M + i라는 교묘한 공식을 씁니다. s는 빙글빙글 도는 시퀀스, M은 한계치(IPCMNI = 32768), i는 실제 배열 슬롯의 인덱스입니다. 커널 심층부에는 세마포어, 큐, 공유메모리 삼형제를 통제하기 위한 ipc_ids 통제소가 각각 존재하며, 개별 자원들은 소유자와 권한 마스크를 품은 kern_ipc_perm이라는 방패를 반드시 두르고 있습니다.
핵심 O/X 퀴즈
1. System V IPC가 빚어낸 자원들은 생명력이 질겨서, 생성한 프로세스가 죽어버려도 시스템에 그대로 남는다.
2. IPC Key와 IPC Identifier는 동의어이며, 두 값 모두 개발자가 소스 코드에 직접 하드코딩해서 정한다.
3. `IPC_RMID`라는 상수는 메시지 큐, 공유 메모리, 세마포어를 가리지 않고 공통적으로 자원을 파괴할 때 쓰인다.
IPC 세마포어: 신호등, 원자적 묶음, 그리고 SEM_UNDOFigure 19-1
IPC 세마포어는 메시지나 파일처럼 데이터를 싣고 나르는 용도가 아닙니다. 여러 프로세스가 탐내는 공유 자원 앞을 지키며, 누가 언제 들어갈지 깐깐하게 조율하는 스마트 신호등입니다. 내부 카운터가 양수이면 쾌속 통과, 0이 되면 멈춰 서서 기다려야 한다는 아주 원초적인 규칙을 따릅니다.
하지만 IPC 세마포어는 커널 내부에서 쓰이는 일반 세마포어보다 구조가 훨씬 복잡합니다. 단순한 정수 카운터 하나 달랑 있는 게 아니라, 여러 개의 원시 세마포어(primitive semaphore)들이 옹기종기 모여 거대한 배열 세트를 이룹니다. 덕분에 식별자 하나만 까서 여러 개의 복잡한 공유 자원을 한방에 동시에 걸어 잠글 수 있는 막강한 파워를 뽐냅니다.
- kern_ipc_perm
- 공통적으로 둘러야 하는 IPC 권한 및 소유자 정보가 가장 먼저 자리 잡습니다.
- sem_base
- 배열로 엮인 실제 원시 세마포어들의 포인터입니다. 각 칸에는 현재 카운트 값(semval)과 마지막으로 손댄 프로세스의 PID(sempid)가 새겨집니다.
- sem_pending
- 신호등이 파란불로 바뀌기만을 목 빠지게 기다리며 잠든 불쌍한 요청들의 이중 연결 리스트입니다.
- undo
- 만일의 사태를 대비해, 프로세스가 저지른 행적을 반대로 돌릴 수 있는
SEM_UNDO복구 정보와 직결됩니다.
SEM_UNDO의 기적: 락을 잔뜩 걸어둔 프로세스가 갑작스레 치명타를 맞고 비정상 종료(Segfault 등)해 버리면, 다른 프로세스들은 영원한 교착 상태(Deadlock)에 빠집니다. 커널은 이를 막기 위해 semop() 호출 시 SEM_UNDO 플래그를 달아두면, 카운터를 얼마나 깎고 더했는지 비밀 장부에 적어 둡니다. 프로세스가 최후를 맞이할 때(exit_sem()), 커널이 유품을 정리하며 세마포어 값을 정확히 반대로 롤백시켜 줍니다.
IPC 세마포어는 그저 빨간불/파란불 켜지는 동네 신호등이 아닙니다. 차량 충돌(비정상 종료)이 발생했을 때 알아서 현장을 수습하고 우회로를 뚫어주는 최첨단 자동 복구 관제 센터(SEM_UNDO)를 내장하고 있는, 무겁고도 믿음직한 교차로 시스템입니다.
핵심 O/X 퀴즈
1. System V IPC 세마포어 식별자 하나를 발급받으면, 그 안에 여러 개의 개별 세마포어 카운터를 담아 묶음으로 운용할 수 있다.
2. 락을 쥔 프로세스가 벼락을 맞아 죽어버릴 경우를 대비해, 커널이 장부를 적었다가 자동으로 락을 풀어주는 마법의 키워드는 `SEM_UNDO`다.
3. 세마포어의 대기 큐(pending request queue)에는 처리가 완전히 성공하여 끝난 이력들만 보관된다.
IPC 메시지 큐: 커널 안의 우편함Figure 19-2
메시지 큐는 이름 그대로 프로세스들이 덩어리진 메시지 단위로 데이터를 주고받을 수 있게 해주는 훌륭한 비동기 통신 장치입니다. 한 녀석이 메시지를 정성껏 포장해 큐에 던져두고 떠나면, 받을 녀석은 한가할 때 스윽 와서 꺼내 갑니다. 당연하게도, 누군가 꺼내 간 편지는 커널의 우편함에서 깔끔하게 파쇄되어 사라집니다.
여기서 메시지는 고정된 규격의 헤더와 자유로운 길이의 본문 텍스트로 구성되는데, 가장 짜릿한 기능은 바로 메시지 타입(message type)이라는 정수형 라벨표입니다. 받는 프로세스는 우체통을 통째로 엎는 대신, 자신이 관심 있는 타입 번호만 콕 짚어서 선택적으로 낚아챌 수 있습니다.
- t = 0
- 라벨 따위는 신경 쓰지 않고, 그냥 큐의 맨 앞에 있는 가장 오래된 먼지 쌓인 편지부터 꺼냅니다.
- t > 0
- 타입 번호가 정확히 t와 일치하는 편지 중 가장 일찍 도착한 녀석만 쏙 뽑아냅니다.
- t < 0
- 마이너스를 붙이면 우선순위 폭격 모드가 됩니다. t의 절댓값보다 작거나 같은 타입 번호들 중에서, 가장 번호가 낮은(가장 VIP인) 편지를 최우선으로 건네줍니다.
- q_perm
- 역시나 System V의 상징, 공통 보안 및 권한 검사 구조체입니다.
- q_messages
- 현재 큐에 주렁주렁 매달린 메시지들의 연결 리스트입니다. 각 헤더(msg_msg)는 타입(m_type)과 시간(m_ts)을 꽉 쥐고 있습니다.
- q_senders
- 우편함이 터져나가 더 이상 편지를 구겨 넣지 못하고 문 앞에서 기다리다 지쳐 잠든 발송인들의 대기열입니다.
- q_receivers
- 내가 찾는 타입의 편지가 아직 안 왔다며 우체국 창구 앞에서 진치고 잠든 수신인들의 대기열입니다.
- q_qbytes
- 이 우체통이 삼킬 수 있는 최대 바이트 용량 제한(기본 16384바이트)입니다.
커널은 메모리 관리의 달인답게, 메시지를 메모리 페이지 하나(약 4KB)에 꽉꽉 눌러 담으려 애씁니다. 헤더(msg_msg) 공간을 살짝 떼어내고 나면 대략 4072바이트의 본문을 첫 페이지에 채울 수 있죠. 만약 그보다 말이 많은 장문의 메시지라면, msg_msgseg라는 이음새를 달아 다음 페이지로 줄줄이 엮어냅니다. 시스템의 기본 제약(메시지 최대 크기 8192, 큐 최대 크기 16384)이 갑갑하다면, 관리자 권한으로 /proc/sys/kernel/msgmax 등을 수정해 우체통을 확장할 수 있습니다.
파이프가 데이터의 경계가 없는 끝없는 물줄기(바이트 스트림)라면, 메시지 큐는 예쁘게 포장된 소포 상자(단위)가 컨베이어 벨트를 타는 것과 같습니다. 게다가 소포마다 바코드가 찍혀 있어서, 우체국 창구에 대고 "가장 먼저 온 소포 주세요", "청구서 바코드 찍힌 소포만 골라 주세요", 혹은 "1번부터 5번 바코드 중에 제일 급한 놈으로 하나 내어주세요"라고 입맛대로 요청하는 호사를 누릴 수 있습니다.
핵심 O/X 퀴즈
1. System V IPC 메시지 큐를 쓰면 데이터 덩어리에 타입(라벨) 번호를 달아 보낼 수 있고, 읽는 쪽에서는 원하는 타입만 골라서 수령할 수 있다.
2. 메시지 큐에서 누군가 편지를 한 통 읽어갔다 하더라도, 커널은 사본을 남겨두어 다른 프로세스도 계속해서 읽을 수 있게 보장한다.
3. 우체통 용량이 가득 차 더 이상 들어갈 공간이 없을 때, 발송 프로세스는 에러 대신 블로킹 모드로 잠들며 공간이 비기를 기다릴 수 있다.
IPC 공유 메모리: 가장 빠르지만 가장 조심스러운 통신Figure 19-3 · demand paging · swap
공유 메모리 방식을 사용하면, 여러 프로세스가 동일한 물리적 page frame들을 각자의 가상 주소 공간에 매핑하여 직접 접근할 수 있습니다. 데이터를 커널 버퍼로 복사해 넣고 다시 빼내는 지루한 과정(Context Switch 및 Memory Copy)이 통째로 생략되므로, 전달할 데이터 덩치가 클수록 속도 이득은 상상을 초월합니다.
빛이 강하면 그림자도 짙은 법입니다. 공유 메모리가 뻥 뚫린 '공동 작업대'라면, 세마포어는 이 작업대에 달려드는 사람들의 '순서표'입니다. 여러 프로세스가 눈치 없이 같은 메모리 구조체를 동시에 쓰려고 덤벼들면 데이터가 처참히 꼬이는 경쟁 상태(Race Condition)가 벌어집니다. 따라서 공유 메모리를 쓸 때는 반드시 세마포어나 뮤텍스 같은 든든한 동기화 자물쇠를 세트로 챙겨야 합니다.
shmem_nopage() 핸들러가 출동하여, 페이지 캐시나 스왑 영역을 뒤지거나 buddy system에서 새 물리 페이지를 뜯어와서 그제야 실제 매핑을 완성해 줍니다.shmctl(IPC_RMID)의 몫입니다.- shm_file
- shm이라는 껍데기 특수 파일시스템이 만들어낸 VFS 파일 객체의 포인터입니다. 공유 메모리가 VFS의 축복을 받게 해주는 결정적 다리입니다.
- address_space
- inode가 품고 있는
address_space객체를 통해, 물리적 page frame들이 페이지 캐시라는 거대한 그물망과 끈끈하게 연결됩니다. - vm_area_struct
shmat()이 불릴 때 프로세스의 메모리 맵에 찰칵 끼워지는 VMA 객체입니다. 이 객체의vm_file포인터가shm_file을 정확히 가리킵니다.
이 거대한 공유 덩어리는 시스템 메모리가 쪼들릴 때 어떻게 될까요? 공유 메모리 페이지는 디스크의 일반 파일과 엮여있지 않아 툭 버릴 수 없고(비동기화), IPC의 철칙에 따라 시스템 재부팅 전까지는 무조건 보존(persistent)되어야 합니다. 그래서 메모리 압박이 심해지면, 커널은 shmem_writepage()를 부랴부랴 가동하여 페이지를 잠시 스왑 공간(Swap Area)으로 대피시킵니다. 훗날 누군가 다시 접근하면 스왑-인(Swap-in) 마법을 통해 페이지가 생생하게 복원되는 놀라운 구조를 갖추고 있습니다.
공유 메모리는 압도적으로 빠르지만, 그 이면에는 리눅스 커널의 모든 하위 시스템이 총동원되는 경이로운 복잡성이 숨어 있습니다. IPC 식별자 체계, 껍데기 파일시스템, VFS 객체, 가상 메모리 매핑, 페이지 캐시, 스왑 캐시, 페이지 폴트, 그리고 요구 페이징(Demand Paging) 기법까지. 공유 메모리를 한 겹 한 겹 벗겨내다 보면 리눅스가 메모리와 파일을 얼마나 예술적으로 버무려 놓았는지 감탄하게 됩니다.
핵심 O/X 퀴즈
1. `shmat()`을 호출하는 즉시, 공유 메모리 영역의 모든 물리적 Page Table Entry(PTE)가 가득 채워지며 메모리가 할당된다.
2. 공유 메모리에 연결(attach)했던 모든 프로세스가 연결을 끊고 나가면, 커널은 눈치껏 공유 메모리를 폭파시키고 페이지를 회수한다.
3. 공유 메모리는 데이터를 주고받는 속도가 타의 추종을 불허하지만, 경쟁 상태를 막기 위해 세마포어 같은 동기화 기법을 필수로 곁들여야 한다.
POSIX 메시지 큐: 더 단순한 파일 기반 인터페이스mqueue filesystem
POSIX 메시지 큐는 앞서 배운 System V IPC 메시지 큐와 철학은 결을 같이 하지만, 현대적인 시스템에 걸맞은 몇 가지 치명적인 장점으로 무장하고 있습니다. 우선순위(priority) 정렬 지원, 메시지 도착 시 비동기 알림(asynchronous notification), 대기 시간 설정(timeout), 그리고 파일 디스크립터에 찰떡같이 녹아든 직관적인 인터페이스가 그 핵심입니다.
- mq_open(name, flags)
- 슬래시(
/)로 시작하는 그럴듯한 문자열 이름으로 큐를 열거나 창조합니다. 반환값은 엉뚱하게도 IPC 식별자가 아닌, 파일 디스크립터(queue descriptor)입니다. - mq_send() / mq_timedsend()
- 메시지를 우체통에 밀어 넣습니다.
timed버전은 타임아웃을 걸어 "3초만 기다려보고 안 되면 취소!" 같은 스마트한 제어가 가능합니다. - mq_receive() / mq_timedreceive()
- 메시지를 빼옵니다. 무조건 기다리지 않고 지정된 시간이 지나면 깔끔하게 포기하고 돌아옵니다.
- mq_notify()
- 텅 빈 큐에 첫 메시지가 떨어지는 순간, 시그널을 날리거나 스레드를 띄워 달라고 커널에 예약(구독)을 겁니다. "편지 오면 벨 눌러주세요" 방식의 고효율 이벤트 기반 통신입니다.
- mq_close()
- 내 손에 쥐어진 디스크립터를 닫고 손을 뗍니다. 흥미롭게도 시스템 콜 레벨에
mq_close란 함수는 없으며, 일반 파일 닫는close()가 이 역할을 고스란히 떠맡습니다. - mq_unlink()
- 디스크립터를 닫는 수준을 넘어, 큐 자체의 숨통을 끊어 시스템에서 영원히 지워버립니다.
커널에 mq_close()라는 별도의 시스템 콜이 없다는 사실은 꽤나 충격적입니다. mq_open()이 넘겨주는 큐 디스크립터가 진짜배기 파일 디스크립터(FD)이기 때문이죠. 덕분에 mq_close()는 내부적으로 아무렇지 않게 일반 close()를 재활용합니다. POSIX 메시지 큐가 VFS라는 거대한 체계에 얼마나 쫀득하게 밀착되어 있는지 보여주는 최고의 명장면입니다.
리눅스 2.6 커널은 POSIX 메시지 큐를 구현하기 위해 mqueue라는 앙증맞은 특수 파일시스템을 돌립니다. 큐가 하나 생길 때마다 inode도 하나씩 알을 깨고 나옵니다. 큐의 심장부인 mqueue_inode_info 구조체에는 밀린 메시지들의 연결 리스트, 우선순위별로 줄 세우는 정렬 로직, mq_notify()를 위한 예약 정보 등이 야무지게 담겨 있습니다. 개별 메시지의 데이터 구조 자체는 System V 시절의 msg_msg를 그대로 재활용하여 커널의 코드 중복을 피했습니다.
mq_close()를 부른다고 큐가 산산조각 나는 것은 아닙니다. 워드 프로세서에서 close()를 눌렀다고 하드디스크의 문서 파일이 삭제되지 않는 것과 완전히 똑같은 이치입니다. 큐 자체를 세상에서 지워버리려면 mq_unlink()라는 폭파 스위치를 눌러야 합니다. 이런 엄격한 분리야말로 POSIX 큐가 유닉스의 '모든 것은 파일이다'라는 철학을 얼마나 뼛속까지 닮아 있는지 증명해 줍니다.