시그널과 프로세스 사이 통신 — 알림, 공유 메모리, 파이프
1. 따로 사는 프로세스끼리 말하기
3편에서 fork로 만든 부모와 자식은 복사된 뒤 메모리를 따로 쓴다고 했습니다. 한쪽이 변수를 바꿔도 다른 쪽은 모릅니다. 그런데 프로세스끼리 알려야 할 일은 많습니다. 자식이 끝났다고 부모에게 알려야 하고, 사용자가 멈추라고 하면 그 말을 프로세스에 전해야 하고, 계산한 결과를 다른 프로세스에 넘겨야 합니다. 이 글은 두 가지를 봅니다. 짧은 알림 하나를 보내는 시그널, 그리고 데이터를 실제로 주고받는 프로세스 사이 통신입니다.
2. 시그널은 번호 하나짜리 알림
시그널은 어떤 일이 일어났다고 프로세스에 알려 주는 아주 작은 메시지입니다. 들어 있는 정보는 몇 번 시그널인지, 그리고 그것이 도착했다는 사실, 딱 두 가지입니다. 내용을 싣지 않습니다. 보내는 쪽은 언제나 커널입니다. 다른 프로세스가 보내 달라고 부탁할 때도 실제로 전달하는 것은 커널입니다. 1편에서 본 인터럽트와 예외를 사용자 프로세스 쪽에서 볼 수 있게 만든 것, 하드웨어의 신호를 소프트웨어 크기로 줄인 것이라고 생각하면 됩니다.
자주 만나는 시그널
| 번호 | 언제 | 기본 동작 |
|---|---|---|
| 2 | 키보드에서 컨트롤 씨를 누름 | 프로세스를 끝냄 |
| 8 | 0으로 나누기처럼 잘못된 계산 | 끝내면서 그 순간의 메모리 상태를 파일로 남김 |
| 9 | 강제 종료 | 끝냄. 막거나 무시할 수 없음 |
| 11 | 허용되지 않은 메모리에 접근 | — |
| 14 | 타이머가 다 됨 | — |
| 17 | 자식이 멈추거나 끝남 (부모가 받음) | 무시 |
3. 시그널이 보내지는 두 경로
- 커널이 직접 사건을 발견하는 경우 — 프로세스가 0으로 나누었거나 자식 프로세스가 끝났거나 하는 일을 커널이 알아채고 해당 프로세스에 시그널을 보냅니다.
- 다른 프로세스가 부탁하는 경우 —
kill이라는 시스템 콜을 부르면서 받을 프로세스의 pid와 시그널 번호를 넘기면, 커널이 그 프로세스에 시그널을 전달합니다. 이름은kill이지만 끝내는 시그널만 보내는 것은 아닙니다. 어떤 번호든 보낼 수 있습니다. 셸에서도 같은 이름의 명령으로 할 수 있습니다.
4. 받는 쪽의 처리 — 기본 동작과 처리기
시그널마다 정해진 기본 동작이 있습니다. 끝내기, 메모리 상태를 남기고 끝내기, 무시하기, 멈추기, 멈춰 있었다면 다시 이어 가기, 이렇게 다섯 가지 중 하나입니다. 그런데 프로세스는 이 기본 동작을 바꿀 수 있습니다. 시그널이 오면 대신 실행할 함수를 미리 등록해 두는 것입니다. 이 함수를 시그널 처리기라고 합니다. 등록은 signal이라는 호출로, 시그널 번호와 처리기 함수를 넘기면 됩니다. 무시하라고 등록할 수도 있고 기본 동작으로 되돌릴 수도 있습니다. 단, 9번 강제 종료는 바꿀 수 없습니다.
컨트롤 씨로도 안 끝나는 프로그램
어떤 프로그램이 시작하자마자 2번 시그널의 처리기를 등록하고, 그다음 끝없이 돕니다. 사용자가 컨트롤 씨를 누르면 원래라면 프로그램이 바로 끝나야 합니다. 하지만 기본 동작이 처리기로 바뀌었기 때문에 커널은 프로그램을 끝내는 대신 처리기를 실행시킵니다. 처리기는 메시지를 출력하고, 잠시 뜸을 들인 뒤, 스스로 종료합니다. 처리기가 무엇을 하느냐에 따라 끝날 수도 있고 계속 돌 수도 있습니다. 타이머 시그널도 같은 식으로 씁니다. 1초 뒤에 14번을 보내 달라고 걸어 두고, 처리기에서 할 일을 한 다음 다시 1초를 거는 식입니다.
5. 시그널은 쌓이지 않는다
시그널이 보내졌지만 아직 처리되지 않은 상태를 대기 중이라고 합니다. 커널은 프로세스마다 시그널 번호별로 대기 중인지를 비트 하나로만 적어 둡니다. 이 비트를 대기 비트라고 합니다. 비트 하나이기 때문에 같은 번호의 시그널은 대기 중인 것이 최대 하나입니다. 이미 대기 중인데 같은 번호가 또 오면, 그 시그널은 그냥 버려집니다. 줄을 서지 않습니다. 몇 번 왔는지는 남지 않고, 왔다는 사실만 남습니다.
문제 1
부모가 자식 다섯을 만들었고, 다섯이 거의 동시에 끝났습니다. 부모는 17번 시그널의 처리기를 등록해 두었고, 그 처리기는 호출될 때마다 wait를 딱 한 번 불러 자식 하나를 거둡니다. 이 부모는 자식 다섯을 모두 거둘 수 있습니까. 거두지 못한다면 그 이유를 대기 비트로 설명하고, 어떻게 고쳐야 하는지 답해 보십시오.
풀이
자식이 끝날 때마다 17번 시그널이 하나씩, 모두 다섯 번 보내집니다. 첫 번째 시그널이 처리되는 동안 나머지 넷이 거의 동시에 도착합니다. 그런데 17번의 대기 비트는 하나뿐입니다. 넷 중 첫 번째가 비트를 세우고, 나머지 셋은 이미 대기 중이라 버려집니다. 그래서 처리기는 다섯 번이 아니라 두 번 정도만 실행되고, wait도 그만큼만 불립니다. 거두지 못한 자식들은 3편에서 본 좀비로 남습니다.
고치는 방법은 처리기 한 번에 여러 자식을 거두는 것입니다. 처리기 안에서 끝난 자식이 더 없을 때까지 wait를 반복해서 부르면, 시그널이 몇 개 버려져도 자식은 모두 거둬집니다.
6. 데이터를 주고받으려면
시그널은 번호 하나만 전합니다. 계산 결과나 문자열 같은 데이터를 넘기려면 다른 수단이 필요합니다. 서로 영향을 주고받으며 일하는 프로세스를 협력하는 프로세스라고 합니다. 협력하는 이유는 셋입니다. 같은 정보를 함께 보기 위해서, 일을 나눠 더 빨리 끝내기 위해서, 그리고 기능을 나눠 따로 만들기 위해서입니다. 협력하는 프로세스 사이의 데이터 교환을 프로세스 사이 통신이라고 부릅니다. 방식은 크게 두 가지입니다. 메모리 한 영역을 함께 쓰는 공유 메모리, 그리고 커널을 거쳐 메시지를 보내는 메시지 전달입니다.
7. 공유 메모리
공유 메모리는 메모리의 한 영역을 두 프로세스의 주소 공간에 함께 붙여 두는 방식입니다. 한쪽이 거기에 쓰면 다른 쪽이 바로 읽습니다. 프로세스마다 자기만의 주소 공간이 있다고 했는데, 그 일부를 일부러 겹쳐 놓는 것입니다. 한 번 붙여 두면 그다음 읽고 쓰는 일은 보통의 메모리 접근이라 커널을 거치지 않습니다. 그래서 빠릅니다.
대신 두 프로세스가 같은 칸을 동시에 건드리면 결과가 뒤엉킬 수 있습니다. 누가 먼저 쓰고 누가 나중에 읽을지 맞추는 일은 운영체제가 해 주지 않고 프로세스들이 스스로 해야 합니다. 이 순서 맞추기를 동기화라고 하고, 뒤의 글에서 따로 봅니다.
리눅스에서 붙이는 순서
- 이름을 붙여 공유 메모리를 만들거나 엽니다. 파일 서술자처럼 번호 하나를 받습니다.
- 그 크기를 정합니다. 예를 들어 4096바이트로 잡습니다.
- 그 영역을 자기 주소 공간에 붙입니다. 붙이고 나면 시작 주소 하나를 돌려받습니다.
- 그 주소에 문자열을 씁니다. 받는 쪽 프로세스도 같은 이름으로 열고 자기 주소 공간에 붙인 뒤 그 주소에서 읽습니다.
- 다 쓰고 나면 이름을 지워 영역을 거둡니다.
8. 파이프는 한 방향 통로
메시지 전달 쪽의 대표는 파이프입니다. 파이프는 두 프로세스를 잇는 관입니다. 한쪽 끝으로 쓰고 다른 쪽 끝으로 읽습니다. 가장 흔한 보통 파이프는 방향이 하나라서 쓰는 끝과 읽는 끝이 따로 있고, 부모와 자식 사이에서만 씁니다.
순서는 이렇습니다. 부모가 먼저 파이프를 만들면 파일 서술자 두 개를 받습니다. 하나는 읽는 끝, 하나는 쓰는 끝입니다. 그다음 fork를 부릅니다. 3편에서 본 대로 fork 전에 열어 둔 것은 부모와 자식이 함께 씁니다. 그래서 자식도 같은 파이프의 두 끝을 모두 갖게 됩니다. 쓰지 않을 끝은 닫을 수 있고, 파일 서술자를 닫으면 그 끝으로는 더 이상 읽거나 쓸 수 없습니다. 데이터는 공유 메모리와 달리 커널 안의 버퍼를 거쳐 건너갑니다.
문제 2
부모가 파이프를 만들고 fork를 불렀습니다. 이제 부모가 문자열을 쓰고 자식이 그것을 읽게 하려고 합니다. 부모와 자식은 각각 파이프의 어느 끝을 쓰고, 어느 끝을 닫아야 합니까.
풀이
fork 직후에는 부모도 자식도 두 끝을 모두 들고 있습니다. 보통 파이프는 방향이 하나이므로, 이 상태로는 누가 쓰고 누가 읽을지가 정해지지 않습니다. 부모는 보내는 쪽이니 쓰는 끝만 남기고 읽는 끝을 닫습니다. 자식은 받는 쪽이니 읽는 끝만 남기고 쓰는 끝을 닫습니다. 그러면 부모에서 자식으로 가는 한 방향 통로만 남습니다. 부모가 쓰는 끝으로 문자열을 넣으면 커널의 버퍼를 거쳐 자식의 읽는 끝으로 나옵니다. 다 쓰고 나면 부모는 쓰는 끝도 닫고, 자식은 다 읽은 뒤 읽는 끝을 닫습니다.
9. 이름 있는 파이프
보통 파이프에는 방향이 하나이고 부모와 자식 사이에서만 쓸 수 있다는 두 가지 제약이 있었습니다. 이름 있는 파이프는 이 둘을 풉니다. 파일 경로처럼 이름을 붙여 만들어 두면, 부모 자식 관계가 없는 프로세스도 그 이름으로 열어 쓸 수 있습니다. 여러 프로세스가 함께 쓸 수도 있고, 양쪽 방향으로 번갈아 주고받을 수도 있습니다. 유닉스 계열에서는 먼저 들어온 것이 먼저 나간다는 뜻에서 FIFO라고도 부릅니다. 예를 들어 두 프로그램이 같은 이름의 파이프를 열어, 한쪽이 한 줄 쓰면 다른 쪽이 읽고 답을 쓰는 식으로 대화를 주고받을 수 있습니다.
10. 무엇을 언제 쓰나
- 시그널 — 번호 하나만 전하는 알림입니다. 자식이 끝났다거나 멈추라는 요청처럼 사건을 알릴 때 씁니다.
- 공유 메모리 — 큰 데이터를 빠르게 나눌 때 씁니다. 커널을 거치지 않아 빠르지만 순서는 스스로 맞춰야 합니다.
- 파이프 — 커널이 순서를 지켜 주는 통로입니다. 들어간 순서대로 나오고, 읽을 것이 없으면 읽는 쪽이 기다립니다. 그 대신 매번 커널을 거쳐야 해서 공유 메모리보다 느립니다. 보통 파이프는 부모와 자식 사이, 이름 있는 파이프는 관계없는 프로세스 사이에 씁니다.
정리
프로세스끼리 알릴 것이 있으면 커널이 번호 하나짜리 시그널로 전하고, 받는 쪽은 기본 동작을 따르거나 처리기로 바꿉니다. 같은 번호는 쌓이지 않으니 처리기는 한 번에 여러 건을 처리하도록 짭니다. 데이터를 넘길 때는 메모리를 함께 쓰거나 파이프로 흘려보냅니다.
여기서 본 방법은 모두 서로 다른 프로세스 사이의 일이었습니다. 3편에서 본 대로 프로세스 하나는 제어 흐름 하나였습니다. 한 프로세스 안에서 여러 흐름을 동시에 돌리면 메모리를 처음부터 함께 쓰게 됩니다. 다음 글에서는 그 흐름, 스레드를 봅니다.