LODY/정리

리눅스 시스템 프로그래밍 / 공유 메모리와 세마포어

공유 메모리와 세마포어

🟣 복사를 없애고 싶다

메시지 큐는 편했지만 데이터가 매번 두 번 복사됐다. 보내는 프로세스의 메모리에서 커널로 한 번, 커널에서 받는 프로세스로 또 한 번. 작은 메시지면 무시할 만하지만, 프레임 버퍼처럼 큰 데이터를 초당 수십 번 주고받으면 이 복사가 그대로 병목이 된다.

복사를 없애는 방법은 단순하다. 데이터를 옮기지 말고, 두 프로세스가 같은 메모리를 직접 보게 하면 된다. 이것이 공유 메모리다. IPC 도구 중 가장 빠르다. 커널을 거치지 않고 프로세스가 메모리에 직접 읽고 쓰기 때문이다. 대신 가장 위험하다. 커널이 중간에서 순서를 조율해 주지 않으므로, 그 조율을 프로그래머가 떠안는다. 이 장은 그 빠름과 위험을 함께 다룬다.


🟣 mmap: 파일을 메모리에 펼치다

공유 메모리로 가기 전에 mmap을 먼저 본다. mmap은 파일을 프로세스의 주소 공간에 통째로 매핑한다. 매핑하고 나면 파일을 read·write로 읽는 게 아니라, 그냥 메모리 포인터로 접근한다. 배열에 값을 대입하듯 파일 내용을 바꾸고, 커널이 알아서 실제 파일에 반영한다.

프로세스 주소 공간(포인터로 접근)매핑된 페이지디스크 파일p[i] = ...커널이 반영

mmap의 값어치는 큰 파일을 다룰 때 드러난다. 1GB 파일을 read로 읽으면 그 데이터가 커널 버퍼를 거쳐 내 버퍼로 복사된다. mmap으로 매핑하면 복사 없이 필요한 페이지만 접근하는 순간 로드된다. 파일 입출력을 메모리 접근으로 바꾸는 이 발상이, 파일 대신 이름 없는 익명 메모리를 매핑하는 쪽으로 확장되면 곧 공유 메모리가 된다.


🟣 공유 메모리: 하나의 물리 페이지, 두 개의 주소

공유 메모리는 같은 물리 메모리 페이지를 두 프로세스의 주소 공간에 동시에 매핑한다. 각자 자기 주소로 접근하지만 가리키는 실제 페이지는 하나다. A가 그 페이지에 쓴 값을 B가 즉시 본다. 중간 복사가 없다.

프로세스 A가상주소 0x7f..a0프로세스 B가상주소 0x5c..40같은 물리 페이지매핑매핑

여기서도 앞 장의 이름 붙이기 문제가 되풀이된다. System V 공유 메모리는 정수 키(shmget)로 세그먼트를 얻고, POSIX 공유 메모리는 /myshm 같은 이름(shm_open)을 열어 mmap으로 매핑한다. POSIX 쪽은 결국 앞서 본 mmap과 같은 도구로 수렴한다. 파일이든 익명 메모리든 공유 세그먼트든, "주소 공간에 무언가를 펼친다"는 하나의 동작으로 정리되는 셈이다.


🟣 공짜가 아니다: 경쟁 상태

공유 메모리의 위험은 예제를 돌려 보면 바로 드러난다. 두 프로세스가 공유된 카운터를 각각 십만 번씩 1 증가시키면 결과가 이십만이 되어야 한다. 그런데 실제로는 그보다 작은, 매번 다른 값이 나온다.

원인은 counter++가 원자적이지 않기 때문이다. 이 한 줄은 실제로 세 단계다. 메모리에서 값을 읽고, 1을 더하고, 다시 쓴다. A가 값을 읽은 직후 B가 끼어들어 같은 값을 읽으면, 둘 다 같은 숫자에 1을 더해 쓴다. 두 번 증가했어야 할 값이 한 번만 오른다. 이렇게 여러 실행 흐름이 공유 자원에 끼어들어 결과가 순서에 따라 달라지는 상황이 경쟁 상태다.

커널이 중간에서 순서를 조율해 주던 파이프나 메시지 큐에는 없던 문제다. 공유 메모리는 그 조율을 빼서 빨라진 대신, 조율의 책임을 프로그래머에게 넘긴 것이다. 그 책임을 이행하는 도구가 세마포어다.


🟣 세마포어: 순서를 강제하는 정수 하나

세마포어는 커널이 지키는 정수 하나와, 그 값을 원자적으로 다루는 두 연산으로 이뤄진다. 값을 줄이며 자원을 얻는 연산(P, wait)과, 값을 늘리며 자원을 놓는 연산(V, signal)이다. 값이 0이면 P는 다른 쪽이 V로 값을 올려 줄 때까지 멈춘다. 이 멈춤이 순서를 강제한다.

값을 1로 시작하는 이진 세마포어는 잠금(lock)처럼 쓴다. 공유 카운터를 건드리기 전에 P로 값을 0으로 내려 문을 잠그고, 다 쓰면 V로 1로 올려 연다. 한 번에 한 프로세스만 그 구간(임계 구역)에 들어가므로 아까의 끼어듦이 사라진다. 값을 N으로 시작하면 최대 N개까지 동시 접근을 허용하는 계수 세마포어가 된다. 연결 풀처럼 "동시에 N개까지만"이 필요한 자리에 맞는다.

프로세스 A세마포어 = 1임계 구역(공유 카운터)프로세스 B(P에서 대기)P: 1→0입장P: 0 → 멈춤

세마포어를 처음 배울 때는 이 정수 하나가 어떻게 순서를 만드는지 잘 안 잡혔다. 카운터 예제에서 세마포어를 빼면 값이 틀리고, 끼우면 정확히 이십만이 나오는 걸 눈으로 보고 나서야 감이 왔다. 틀린 결과를 직접 만들어 보는 것이 동기화를 이해하는 가장 빠른 길이었다.


🟣 공유 메모리 위의 캐시, 그리고 Redis

공부가 여기서 실무로 이어졌다. 여러 프로세스가 같은 데이터를 자주 읽는다면, 그 데이터를 공유 메모리에 한 벌 올려 두고 다 같이 읽으면 된다. 이것이 캐시의 가장 원초적인 형태다. 각자 디스크나 네트워크에서 다시 읽는 대신, 빠른 공유 메모리에서 꺼낸다.

이 발상을 프로세스 경계를 넘어, 나아가 기계 경계를 넘어 일반화한 것이 Redis다. Redis는 데이터를 메모리에 들고 있는 별도 프로세스이고, 여러 클라이언트가 네트워크로 그 메모리를 공유한다. 공유 메모리가 "한 기계 안 프로세스들의 공유 캐시"였다면, Redis는 "여러 기계의 공유 캐시"다. 앞 장에서 커널 메시지 큐가 ZeroMQ로 확장됐던 것과 똑같은 도약이다.

동기화 문제도 함께 올라간다. 공유 메모리에서 세마포어로 경쟁 상태를 막았듯, Redis에서도 여러 클라이언트가 같은 키를 동시에 고칠 때 원자적 연산이나 분산 잠금이 필요하다. 이름과 규모만 달라졌을 뿐 공유 자원에는 반드시 순서 조율이 따라온다는 원리는 그대로였다. CS 이론에서 배운 경쟁 상태가 실무의 캐시 설계에서 똑같은 얼굴로 다시 나타나는 걸 확인한 지점이었다.


🟣 정리

  • 공유 메모리는 같은 물리 페이지를 두 주소 공간에 매핑해 복사 없이 데이터를 공유한다. IPC 중 가장 빠르지만 순서 조율을 프로그래머가 떠안는다.
  • mmap은 파일이나 익명 메모리를 주소 공간에 펼쳐 입출력을 메모리 접근으로 바꾼다. 공유 메모리는 그 연장선이다.
  • 공유 자원에 여러 흐름이 끼어들면 경쟁 상태가 생긴다. counter++가 원자적이지 않다는 사실이 그 씨앗이다.
  • 세마포어는 원자적 정수 하나로 임계 구역을 지킨다. 틀린 결과를 직접 재현해 보는 것이 이해의 지름길이었다.
  • 공유 메모리 위의 캐시라는 발상은 그대로 Redis로 확장된다. 규모가 커져도 "공유 자원에는 순서 조율이 따른다"는 원리는 변하지 않는다.

여기까지가 프로세스가 데이터를 나누는 방법이었다. 파이프에서 메시지 큐로, 다시 공유 메모리와 세마포어로 내려오며 "격리된 프로세스가 어떻게 통하는가"라는 처음의 질문에 답했다. 남은 것은 통신이 아니라 알림이다. 프로세스에게 "지금 무슨 일이 생겼다"를 어떻게 전할 것인가. 다음은 시그널이다.