포스트

(Linux) 1. 프로세스 간 통신 — 주소공간이 만드는 문제, 뮤텍스와 세마포어, 링버퍼와 메시지 큐, 공유메모리와 소켓

한 프로그램 안에서는 변수 하나면 끝나는 일이 프로세스가 갈리는 순간 왜 어려워지는지 정리한다 — 프로세스와 쓰레드가 무엇을 공유하고 무엇을 따로 갖는지, count++ 한 줄이 깨지는 이유와 뮤텍스·세마포어를 짝지어 거는 순서, 메시지 큐·링버퍼·공유메모리가 데이터 단위와 복사 횟수에서 갈리는 지점, TCP 바이트 스트림에 경계가 없다는 말의 실제 의미, 그리고 같은 구조를 Windows 로 옮길 때 무엇이 그대로이고 무엇이 바뀌는지까지.

(Linux) 1. 프로세스 간 통신 — 주소공간이 만드는 문제, 뮤텍스와 세마포어, 링버퍼와 메시지 큐, 공유메모리와 소켓

이번 장에서는 프로세스 간 통신을 다루겠다. 같은 프로그램 안에서는 전역변수 하나로 끝나던 일이 프로세스가 갈리는 순간 왜 별도의 수단을 필요로 하는지, 그 수단들이 서로 무엇이 다른지, 어느 것을 언제 쓰는지를 순서대로 정리하겠다. 마지막에 같은 구조를 Windows 로 옮긴 사례를 붙였다.


1. 프로세스와 쓰레드

IPC(Inter-Process Communication)가 왜 필요한지는 프로세스가 무엇인지에서 바로 나온다. 세 가지를 구분하는 데서 시작한다.

 무엇인가상태
프로그램디스크에 저장된 실행 파일정적. 메모리도 자원도 할당되지 않았다
프로세스실행 중인 프로그램커널이 독립된 가상 주소공간과 자원(PID, 파일 디스크립터)을 붙여 준 단위
쓰레드프로세스 안의 실행 흐름그 주소공간을 공유하는 여러 개의 실행 지점

프로세스끼리는 서로의 메모리를 볼 수 없다. 커널이 각 프로세스에 독립된 가상 주소공간을 주기 때문이다. A 프로세스의 포인터 값을 B 에 넘겨 봐야 B 에서는 전혀 다른 곳을 가리키거나 아예 매핑되지 않은 주소다. 이 한 줄이 IPC 라는 별도의 수단이 필요한 이유의 전부다.

무엇을 공유하고 무엇을 따로 갖는가

쓰레드는 다르다. 같은 프로세스 안에 있으므로 대부분을 공유한다.

영역쓰레드끼리
Code (실행 코드)공유
Data (전역변수·정적변수)공유
Heap (동적 할당)공유
파일 디스크립터공유
Stack따로
레지스터따로

그래서 쓰레드끼리는 전역변수 하나로 바로 데이터를 주고받는다. 복사도 커널 경유도 없다. 대신 두 쓰레드가 같은 변수를 동시에 건드리면 값이 깨지므로 동기화가 필수다(3절).

스택과 레지스터가 왜 따로인지는 쓰레드의 정의에서 나온다. 쓰레드는 실행 흐름이고, 실행 흐름의 정체가 곧 레지스터다. PC(Program Counter)가 지금 코드의 어디를 실행 중인지를, SP(Stack Pointer)가 내 스택이 어디까지 쌓였는지를 담는다. 송신 쓰레드가 함수 A 안에 있고 수신 쓰레드가 함수 B 안에 있으려면 PC 가 두 개여야 한다. 하나뿐이면 두 흐름이 아니라 한 흐름이다.

물리적으로 레지스터 세트는 코어당 하나뿐이므로, “따로 갖는다”의 실제 구현은 컨텍스트 스위치다. 커널이 현재 쓰레드의 레지스터 전부를 커널 안의 쓰레드 구조체에 저장하고 다음 쓰레드 것을 복원한다. 리눅스에서는 task_struct 안의 thread_struct 가 그 자리다.

리눅스는 둘을 같은 것으로 본다

커널 입장에서 프로세스와 쓰레드는 둘 다 task_struct 하나다. clone() 을 부를 때 무엇을 공유할지 플래그로 지정하는 것만 다르다.

1
2
3
4
5
/* 쓰레드 — 주소공간·파일 디스크립터·시그널 핸들러를 공유한다 */
clone(fn, stack, CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND | CLONE_THREAD, arg);

/* 프로세스 — 아무것도 공유하지 않는다. fork() 가 부르는 형태 */
clone(fn, stack, SIGCHLD, arg);

pthread_create 는 앞의 형태를, fork 는 뒤의 형태를 부른다. 프로세스와 쓰레드는 종류가 다른 물건이 아니라 공유 범위를 어디까지 열어 두었느냐의 차이다. 이렇게 보면 IPC 수단들이 하는 일도 한 문장으로 정리된다 — 공유를 닫아 둔 채로 시작한 두 task 에게 커널이 공유할 창구를 하나 열어 주는 것이다.


2. 통신 수단은 거리가 정한다

무엇을 쓸지는 취향이 아니라 얼마나 멀리 보내야 하는가 하나로 갈린다.

거리주소공간수단특징
같은 프로세스 안 (쓰레드 간)공유전역변수, 공유 자료구조가장 빠르다. 주소만 넘기면 된다. 동기화 필수
같은 PC, 다른 프로세스분리메시지 큐, 공유메모리, 파이프, 유닉스 도메인 소켓반드시 커널이 만든 통로를 거친다
다른 PC분리 + 물리적으로 다름TCP/UDP 소켓네트워크를 탄다. 순서·유실을 따져야 한다

첫 줄은 IPC 라고 부르지 않는다. 주소공간을 공유하므로 통신할 것이 없고, 포인터 하나 넘기면 끝이다. 대신 한 쓰레드가 죽으면 프로세스 전체가 같이 죽는다.

둘째 줄부터가 IPC 다. 주소공간이 분리돼 있으니 양쪽이 모두 닿을 수 있는 제3의 장소가 반드시 필요하고, 그것을 마련해 줄 수 있는 것은 커널뿐이다. 메시지 큐는 커널 공간에 큐를 만들어 주고, 공유메모리는 같은 물리 메모리를 양쪽 주소공간에 붙여 준다. 무엇을 고르든 커널에 등록된 이름이나 키로 서로를 찾는다는 구조는 같다.

셋째 줄은 커널조차 공유하지 않으므로 프로토콜이 필요하다. 8절에서 다룬다.


3. 왜 동기화가 필요한가

쓰레드끼리 전역변수로 바로 주고받을 수 있다는 것은 장점이자 문제다.

count++ 은 한 줄이 아니다

1
s_nRingCount++;

C 로는 한 줄이지만 CPU 는 메모리 값을 직접 계산하지 못한다. 반드시 레지스터로 꺼내 와서 계산하고 다시 써넣는다.

mov  eax, [s_nRingCount]   ; ① 메모리 → 레지스터
add  eax, 1                ; ② 레지스터에서 계산
mov  [s_nRingCount], eax   ; ③ 레지스터 → 메모리

컨텍스트 스위치는 명령어 사이 어디서든 일어난다. 1절에서 본 대로 레지스터는 쓰레드마다 따로이므로, 각자 옛날 값을 손에 든 채 잘릴 수 있다.

시점쓰레드 1쓰레드 2메모리의 count
mov eax, [count] → eax = 5 5
← 여기서 스위치mov eax, [count] → eax = 55
 add, mov → 저장6
add eax,1 (자기 eax 는 5) → 저장 6

두 번 증가시켰는데 결과는 1 증가다. 이것이 lost update 이고, 원자적이지 않은 연산을 여럿이 동시에 건드리면 언제든 일어난다. 값이 깨질 뿐 에러는 나지 않으므로 발견도 늦다.

임계구역

여럿이 동시에 들어가면 안 되는 코드 구간을 임계구역(critical section)이라 한다. 위 예에서는 count++ 세 줄이 그것이고, 링버퍼라면 head·tail·count 를 함께 고치는 구간 전체가 임계구역이다. 한 번에 하나만 들어가게 만드는 것이 동기화의 첫 번째 일이다.


4. 뮤텍스와 세마포어

이름이 나란히 나오지만 하는 일이 다르다. 하나는 자물쇠고 하나는 카운터다.

뮤텍스 — 하나만 들어간다

MUTual EXclusion, 상호배제. 임계구역에 한 번에 하나만 들어가게 하는 자물쇠다.

1
2
3
pthread_mutex_lock(&lock);
/* 임계구역 — 이 안은 나 혼자다 */
pthread_mutex_unlock(&lock);

핵심 성질은 소유권이다. 잠근 쓰레드만 풀 수 있다. 그래서 “지금 이 자물쇠의 주인이 누구인가”가 커널(또는 라이브러리)에 기록되고, 우선순위 역전 방지 같은 기법이 여기서 나온다.

세마포어 — 몇 개 남았는지 센다

자원이 몇 개 남았는지를 세는 카운터다. wait 로 하나 가져가고(카운터 감소), post 로 하나 돌려준다(카운터 증가). 카운터가 0 이면 wait 하는 쪽이 대기한다.

1
2
sem_wait(&sem);   /* 카운터 -1. 0 이면 여기서 블록 */
sem_post(&sem);   /* 카운터 +1. 기다리던 쪽 하나를 깨운다 */

소유권이 없다. A 쓰레드가 wait 한 것을 B 쓰레드가 post 해도 된다. 이 성질 때문에 세마포어는 잠금이 아니라 신호(signal)로 쓰인다.

 뮤텍스세마포어
의미자물쇠 (0 또는 1)카운터 (0 이상)
소유권있다. 잠근 쪽만 푼다없다. 누구나 post 할 수 있다
쓰는 목적임계구역 보호자원 개수 세기, 순서 맞추기
전형적 사용데이터를 만지는 동안만질 것이 있는지 기다릴 때

이진 세마포어(초기값 1)를 자물쇠처럼 쓸 수도 있지만, 소유권이 없어 실수하기 쉽다. 보호할 데이터가 있으면 뮤텍스, 개수를 세야 하면 세마포어로 나눠 쓰는 편이 안전하다.

둘을 짝지어 거는 순서

생산자-소비자 구조에서는 둘 다 필요하다. 버퍼를 만지는 동안 보호할 자물쇠 하나, 빈 칸과 찬 칸의 개수를 셀 세마포어 두 개다.

1
2
sem_init(&sem_empty, 0, N);   /* 빈 슬롯 = N 개에서 시작 */
sem_init(&sem_full,  0, 0);   /* 찬 슬롯 = 0 개에서 시작 */

생산자와 소비자는 정확히 대칭이다.

1
2
3
4
5
6
/* 생산자 */                          /* 소비자 */
sem_wait(&sem_empty);                 sem_wait(&sem_full);
pthread_mutex_lock(&lock);            pthread_mutex_lock(&lock);
    /* 버퍼에 넣는다 */                    /* 버퍼에서 꺼낸다 */
pthread_mutex_unlock(&lock);          pthread_mutex_unlock(&lock);
sem_post(&sem_full);                  sem_post(&sem_empty);

이 순서가 핵심이다. 세마포어를 먼저 기다리고 그다음에 뮤텍스를 잡는다. 순서를 뒤집어 뮤텍스를 먼저 잡고 그 안에서 빈 칸을 기다리면, 버퍼가 가득 찼을 때 생산자가 자물쇠를 쥔 채로 대기한다. 소비자는 칸을 비우려면 같은 자물쇠가 필요한데 생산자가 쥐고 있으니 들어갈 수 없다. 서로가 서로를 기다리는 데드락이다.

뮤텍스를 잡는 구간이 버퍼를 실제로 만지는 몇 줄뿐인 것도 의도적이다. 기다리는 일은 자물쇠 밖에서 하고, 자물쇠 안에서는 짧게 끝낸다.


5. 링버퍼

3절과 4절이 “동시에 건드리는 문제”였다면, 이제 무엇을 주고받을지의 그릇이 필요하다.

head, tail, count

고정 크기 배열을 원형으로 돌려 쓰는 자료구조다. 쓰는 위치(tail)와 읽는 위치(head)를 따로 두고, 끝에 닿으면 0 으로 돌아간다.

1
2
3
ring[tail] = msg;
tail = (tail + 1) % CAPACITY;   /* 끝에 닿으면 0 으로 */
count++;

배열 크기가 고정이므로 할당·해제가 없고, 넣고 빼는 비용이 항상 일정하다. 인덱스 두 개만 움직이면 되기 때문에 임계구역도 짧다.

count 를 따로 두는 이유가 있다. head == tail 이라는 상태가 비었을 때와 가득 찼을 때 모두 성립하기 때문이다. 인덱스 두 개만으로는 둘을 구분할 수 없어서, 개수를 세거나 한 칸을 비워 두는 관례를 쓴다.

가득 차면 어떻게 하는가

여기서 두 갈래로 갈리는데, 이름은 같은 링버퍼여도 성질이 전혀 다르다.

 가득 찼을 때쓰는 곳
덮어쓰기형가장 오래된 것을 덮고 head 를 민다. 유실을 허용한다로그 버퍼, 오디오·영상 프레임. 최신 값이 중요할 때
막는 형 (유한 큐)소비자가 칸을 비울 때까지 생산자가 기다린다. 유실이 없다메시지 큐. 한 건도 잃으면 안 될 때

4절의 빈 슬롯 세마포어가 하는 일이 정확히 뒤쪽이다. 인덱스는 이미 0 으로 돌아가 있지만 그 칸에 아직 안 읽힌 데이터가 있으므로, 세마포어가 0 이 되어 생산자를 막는다.

링버퍼는 IPC 수단이 아니다

혼동하기 쉬운데, 링버퍼는 데이터를 쌓아 두는 방법이지 프로세스 사이로 데이터를 옮기는 수단이 아니다. 어디에 두느냐에 따라 쓰임이 달라진다.

  • 전역변수에 두면 → 쓰레드 간 큐
  • 공유메모리에 두면 → 프로세스 간 큐 (7절)

6. 메시지 큐

여기서부터가 진짜 IPC 다. 커널이 메시지 단위로 보관해 주는 큐이고, 보내는 쪽과 받는 쪽이 동시에 떠 있지 않아도 된다.

System V 메시지 큐

1
2
3
4
int qid = msgget(key, IPC_CREAT | 0666);          /* 큐를 얻는다 (없으면 만든다) */
msgsnd(qid, &msg, size, 0);                       /* 넣는다 */
msgrcv(qid, &msg, size, mtype, 0);                /* 뺀다 */
msgctl(qid, IPC_RMID, NULL);                      /* 지운다 */

msgget 은 파일의 open() 에 대응한다. key 가 경로, 반환값 qid 가 파일 디스크립터, IPC_CREATO_CREAT 자리다.

플래그동작
없음 (0)있을 때만 연다. 없으면 ENOENT
IPC_CREAT있으면 그 큐를, 없으면 새로 만들어서 준다
IPC_CREAT \| IPC_EXCL반드시 새로 만든다. 이미 있으면 EEXIST

IPC_CREAT 한 플래그 덕분에 송신과 수신 중 누가 먼저 떠도 상관없다. 먼저 부른 쪽이 만들고 나중 쪽이 붙는다. 하위 9비트는 파일과 같은 접근 권한이라 | 0666 을 빼먹으면 모드 0 인 큐가 만들어져 정작 msgsndEACCES 로 막힌다.

key 는 정수이고 보통 ftok("/some/path", 'A') 로 만든다. 양쪽이 같은 값을 만들어 내야 같은 큐에 붙는다. 마지막 인자 msgflgIPC_NOWAIT 를 주면 대기 없이 EAGAIN 으로 즉시 실패한다 — 블로킹이냐 논블로킹이냐를 여기서 고른다.

경계가 보존된다는 것

메시지 큐의 성질 중 가장 중요한 것이다. 커널이 메시지 하나를 노드 하나로 만들어 큐에 매달기 때문에, 넣은 크기 그대로 한 덩어리로 나온다. 100 바이트를 msgsnd 하면 받는 쪽에서 100 바이트가 통째로 나오고, 쪼개지지도 다음 메시지와 붙지도 않는다.

mtype(메시지 타입)으로 골라 받을 수도 있다. msgrcv 의 해당 인자에 0 을 주면 아무거나, 양수면 그 타입만, 음수면 절댓값 이하 중 가장 작은 타입을 가져온다. 우선순위 큐처럼 쓸 수 있다.

용량과 수명 — 커널에 있다는 것의 대가

데이터가 커널 메모리에 쌓이므로 커널이 상한을 건다. 커널 메모리는 스왑으로 밀려나지 않고 시스템 전체가 나눠 쓰는 자원이라, 한 프로세스가 무한정 쌓으면 시스템 전체가 위험해지기 때문이다.

리눅스 기본
msgmnb큐 하나에 담을 수 있는 총 바이트16384 (16 KB)
msgmax메시지 하나의 최대 크기8192 (8 KB)
msgmni시스템 전체 큐 개수32000

ipcs -l 로 확인하고 sysctl 이나 /proc/sys/kernel/ 로 바꾼다. 가득 차면 msgsnd 가 자리가 날 때까지 대기한다.

수명도 파일 디스크립터와 다르다. System V 큐는 만든 프로세스가 죽어도 커널에 남는다. ipcs -q 로 보이고 ipcrm 으로 지워야 사라진다. 닫는다는 개념이 없어서, 지우지 않으면 계속 점유한다.

POSIX 메시지 큐

같은 이름의 다른 API 다. 이름을 파일 경로처럼 쓰고, 디스크립터를 돌려주며, 메시지 우선순위를 지원한다.

1
2
3
4
mqd_t mq = mq_open("/myqueue", O_CREAT | O_RDWR, 0666, &attr);
mq_send(mq, buf, len, priority);
mq_receive(mq, buf, len, &priority);
mq_close(mq);  mq_unlink("/myqueue");

/dev/mqueue 에 마운트되어 ls 로 보이고, 디스크립터라서 select/poll 에 걸 수 있다. 새로 짜는 코드라면 이쪽이 편하다. System V 쪽은 오래된 코드와 이식성 때문에 여전히 많이 보인다.


7. 공유메모리

같은 물리 메모리를 두 프로세스의 주소공간에 각각 붙이는 방식이다. 붙고 나면 그냥 포인터로 읽고 쓴다.

1
2
3
4
5
int  shmid = shmget(key, size, IPC_CREAT | 0666);   /* 얻는다 */
void *addr = shmat(shmid, NULL, 0);                 /* 내 주소공간에 붙인다 */
/* ... addr 을 그냥 포인터로 쓴다 ... */
shmdt(addr);                                        /* 뗀다 */
shmctl(shmid, IPC_RMID, NULL);                      /* 지운다 */

POSIX 쪽은 shm_open + mmap 조합을 쓴다.

복사 횟수

세 수단의 성능 차이는 여기서 갈린다. 전제는 1절의 그것이다 — 프로세스는 다른 프로세스의 메모리를 직접 만질 수 없으므로 둘 다 닿는 곳을 반드시 한 번 거쳐야 한다.

1
2
3
메시지 큐   내 변수 ──msgsnd──▶ 커널 큐 ──msgrcv──▶ 상대 변수      2 회
링버퍼      내 변수 ──write───▶ 슬롯 (상대도 이 칸을 본다)          1 회
공유메모리  애초에 같은 메모리를 보고 있다                          0 회
  • 메시지 큐 2 회 — 둘 다 닿는 곳이 커널 공간뿐이라 거기를 경유한다. 유저→커널, 커널→유저.
  • 링버퍼 1 회 — 슬롯이 곧 상대가 읽는 자리라 거기 쓰면 끝이다.
  • 공유메모리 0 회 — 안 쓴다는 뜻이 아니라, 쓰는 그 자리가 곧 도착지라 옮겨 담는 단계가 없다는 뜻이다. 구조체 포인터로 잡고 그 위에서 계산하면 복사가 아예 안 생긴다.

복사가 곧 시간이고, 커널을 거치면 시스템 콜 비용(모드 전환)까지 붙는다. 대용량을 자주 주고받을수록 차이가 커진다.

대신 아무것도 해 주지 않는다

공유메모리에서 커널이 하는 일은 “이 물리 메모리를 네 주소공간에도 붙여 준다”까지다. 그 안에 무엇이 몇 바이트씩 들어 있는지는 커널의 관심 밖이다.

  • 경계가 없다. 100 바이트를 썼는데 상대가 30 바이트만 읽어도 아무 일도 일어나지 않는다
  • 동기화가 없다. 두 프로세스가 같은 자리를 동시에 쓰면 그대로 깨진다
  • 대기가 없다. 데이터가 도착했는지 알려 주지 않으므로 직접 알려야 한다

그래서 실무에서 공유메모리는 혼자 쓰이지 않는다. 경계는 직접 만들고(길이를 앞에 붙이거나 고정 크기 칸으로 나누고), 동기화는 뮤텍스나 세마포어를 얹는다. 고정 크기 칸으로 나누는 쪽이 5절의 링버퍼다.

공유메모리 + 링버퍼 + 세마포어 = 메시지 큐를 직접 만든 것이다. 커널의 메시지 큐가 안에서 해 주던 일(경계 유지, 대기, 용량 제한)을 밖에서 조립한 셈이고, 대신 복사가 2회에서 0~1회로 줄고 용량 한도를 내가 정한다.


8. 소켓 — 다른 PC 로 넘어가면

여기서부터는 커널조차 공유하지 않는다. 프로토콜이 필요하다.

TCP 와 UDP

구분TCPUDP
연결3-way handshake 로 맺고 시작없음. 주소만 알면 바로 보낸다
신뢰성커널이 보장 (재전송·순서·중복 제거)없음. 필요하면 앱이 직접
데이터 단위바이트 스트림 — 경계가 없다데이터그램 — 보낸 크기 그대로
통신 상대1 : 11 : N (브로드캐스트·멀티캐스트)
헤더20 byte8 byte
쓰는 곳파일 전송, 명령/제어, 로그 수집센서, 음성·영상 스트리밍

“신뢰성을 커널이 보장한다”는 구체적으로 넷이다. 번호를 붙이고, 잘 받았다고 답을 주고, 답이 안 오면 다시 보내고, 상대와 네트워크가 감당할 속도로 조절한다. 시퀀스 번호·ACK·재전송 타이머·체크섬·흐름 제어·혼잡 제어가 전부 커널 안에서 돌아가고, 앱은 sendrecv 만 부른다.

한 가지 단서가 있다. TCP 가 보장하는 것은 “바이트가 순서대로 상대 커널의 수신 버퍼까지 도착했다”이지 “상대 앱이 읽어서 처리했다”가 아니다. 처리 완료를 확인하려면 애플리케이션 레벨 응답을 따로 설계해야 한다.

바이트 스트림에는 경계가 없다

TCP 를 처음 쓸 때 가장 많이 걸리는 지점이다.

1
send(sock, buf, 4, 0);

인자는 넷이다 — 소켓, 데이터 주소, 바이트 수, 옵션. TCP 는 연결 때 상대가 정해져 있어 주소가 없고, UDP 의 sendto 는 매번 목적지를 적는다. 표의 “1:1 대 1:N”이 이 인자 하나의 차이다.

여기서 4“4바이트짜리 메시지를 보내라”가 아니라 “커널 송신 버퍼에 4바이트를 넣어라” 다. 몇 바이트씩 넣었는지는 상대에게 전달되지 않는다. 커널이 MSS 와 혼잡 상태를 보고 쪼개거나 합치기 때문에, send(4) 를 세 번 한 것이 recv 한 번에 12바이트로 올 수도 있고 2 + 10 으로 나뉠 수도 있다.

그래서 프레이밍은 애플리케이션의 몫이다. 방법은 셋이다.

  1. 길이를 앞에 붙인다 ([길이 4바이트][본문]) — 가장 일반적
  2. 구분자를 넣는다 (개행 등)
  3. 고정 길이로 정한다

그리고 sendrecv 는 요청한 크기를 다 처리해 주지 않는다. 반환값이 실제로 처리한 바이트 수이므로, 다 채울 때까지 반복하는 루프가 사실상 필수다.

1
2
3
4
5
6
7
8
cursor = buf;
remain = total;
while (remain > 0) {
    n = recv(sock, cursor, remain, 0);
    if (n <= 0) break;          /* 0 = 상대 정상 종료, -1 = 에러 */
    cursor += n;                /* 받은 만큼 밀고 */
    remain -= n;                /* 남은 만큼 다시 받는다 */
}

n == 0 이 “상대가 정상 종료했다(FIN)”라는 뜻을 따로 갖는 것도 기억할 만하다. 송신 루프는 같은 모양이지만 0 에 특별한 의미가 없어 조건이 달라진다.

UDP 는 경계가 보존되는 대신, 받는 버퍼가 데이터그램보다 작으면 나머지가 잘려 나가고 복구할 수 없다.


9. 같은 구조를 Windows 로 옮기면

리눅스에서 배운 구조를 Windows 로 옮기면 무엇이 그대로이고 무엇이 바뀌는지가 개념 정리에 오히려 도움이 된다. 실제로 옮겨 본 결과다.

API 대응

하는 일LinuxWindows
쓰레드 생성pthread_create_beginthreadex (CRT 자원 정리 때문에 CreateThread 보다 낫다)
쓰레드 간 잠금pthread_mutex_tCRITICAL_SECTION
세마포어sem_init / sem_wait / sem_postCreateSemaphore / WaitForSingleObject / ReleaseSemaphore
프로세스 간 잠금pthread_mutex_t (PTHREAD_PROCESS_SHARED) 또는 sem_open네임드 뮤텍스 CreateMutex
공유메모리shmget / shmatCreateFileMapping / MapViewOfFile
메시지 큐msgget / msgsnd / msgrcv없다
정리shmdt / shmctl(IPC_RMID)UnmapViewOfFile / CloseHandle

Windows 에는 System V 메시지 큐에 해당하는 것이 없다. 그래서 7절의 조합(공유메모리 + 링버퍼 + 세마포어)으로 직접 만들어야 한다. 앞에서 “메시지 큐를 직접 만든 것”이라고 정리한 구조가 그대로 설계도가 된다.

유저 모드 잠금과 커널 오브젝트

가장 헷갈리는 지점이다. “커널 객체냐”와 “이름이 있냐”는 서로 다른 축이다.

 어디에 사는가이름프로세스 간
CRITICAL_SECTION유저 모드 구조체없음(불가)불가능
이름 없는 뮤텍스/세마포어커널없음원칙적으로 프로세스 안
네임드 뮤텍스/세마포어커널있음가능

CRITICAL_SECTION 은 커널 오브젝트가 아니라 그 프로세스 주소공간에 있는 구조체다. 이름도 핸들도 없어 다른 프로세스가 찾아올 수단이 없고, 구조체를 공유메모리에 올려 놔도 안 된다. 안에 든 핸들과 소유 쓰레드 정보가 그 프로세스에서만 유효한 값이기 때문이다. 그래서 프로세스 간에는 CreateMutex 로 만든 네임드 뮤텍스를 쓴다.

리눅스도 사정이 같다. 그냥 pthread_mutex_t 는 프로세스 간에 쓸 수 없고, 공유메모리에 두고 PTHREAD_PROCESS_SHARED 속성을 주거나 sem_open 이름 있는 세마포어를 자물쇠로 쓴다.

반대 방향은 어떨까. 같은 프로세스 안에서 네임드 뮤텍스를 써도 동작은 한다. 다만 경합이 없어도 매번 커널을 왕복해 느리고(CRITICAL_SECTION 은 무경합이면 유저 모드에서 끝난다), 이름이 시스템 전역이라 남의 프로그램과 충돌할 수 있다. 프로세스 안에서 커널 뮤텍스가 필요하다면 이름만 빼고 쓰면 된다.

한 가지 Windows 가 유리한 점이 있다. 네임드 뮤텍스는 소유 프로세스가 죽으면 커널이 회수해서 다음 대기자에게 WAIT_ABANDONED 를 돌려준다. CRITICAL_SECTION 에는 이런 개념이 없어 소유 쓰레드가 죽으면 그대로 데드락이다.

수명 관리가 반대다

 Linux (System V)Windows
만든 프로세스가 죽으면커널에 남는다마지막 핸들이 닫히면 사라진다
확인ipcs커널 오브젝트 네임스페이스
정리ipcrm 또는 IPC_RMID자동 (참조 카운트)

리눅스는 명시적으로 지워야 하고 잊으면 남는다. Windows 는 자동으로 정리되는 대신, 모든 프로세스가 닫는 순간 안에 있던 데이터도 같이 사라진다. 한쪽이 큐에 쌓아 두고 나갔다가 나중에 받으려는 시나리오에서 차이가 드러난다.

포팅에서 실제로 걸린 것들

소켓 쪽은 함수 이름이 거의 같아 쉬워 보이지만, 컴파일은 통과하는데 값이 달라지는 항목이 함정이다.

항목LinuxWindows
소켓 핸들intSOCKET(UINT_PTR). x64 에서 64비트
송·수신 타임아웃setsockopt(SO_RCVTIMEO, struct timeval)DWORD 밀리초. timeval 을 넘기면 엉뚱한 값이 된다
닫기close()closesocket()
초기화없음WSAStartup / WSACleanup
에러errnoWSAGetLastError()
select() 첫 인자maxfd + 1무시됨
MSG_NOSIGNALSIGPIPE 억제용SIGPIPE 자체가 없어 불필요
마이크로초 대기usleep()없다. Sleep 또는 QPC 스핀

SO_RCVTIMEO 가 대표적이다. 타입 오류 없이 컴파일되고 엉뚱한 타임아웃이 걸린다. 소켓 핸들이 int 가 아닌 것도 같은 종류의 함정이다.


10. 정리

질문
IPC 는 왜 필요한가커널이 프로세스마다 독립된 가상 주소공간을 주기 때문이다. 포인터를 넘겨도 상대에게는 의미가 없다
쓰레드는 무엇을 따로 갖는가스택과 레지스터. 실행 흐름의 정체가 레지스터(PC·SP)라서, 공유하면 흐름이 하나가 된다
무엇을 쓸지 어떻게 고르는가거리가 정한다. 같은 프로세스면 전역변수, 같은 PC 면 메시지 큐·공유메모리, 다른 PC 면 소켓
왜 동기화가 필요한가count++ 조차 load·add·store 세 단계라, 중간에 스위치되면 값이 사라진다
뮤텍스와 세마포어의 차이뮤텍스는 소유권이 있는 자물쇠, 세마포어는 소유권이 없는 카운터다. 보호에는 뮤텍스, 개수 세기에는 세마포어
둘을 같이 쓸 때 순서는세마포어를 먼저 기다리고 뮤텍스를 잡는다. 뒤집으면 자물쇠를 쥔 채 대기해 데드락이 된다
메시지 큐의 장점은경계와 순서를 커널이 지켜 주고, 상대가 없어도 미리 넣어 둘 수 있다. 대신 복사 2회와 용량 한도가 따른다
공유메모리의 장점은복사가 0 회다. 대신 경계·동기화·대기를 전부 직접 만들어야 한다
링버퍼는 IPC 수단인가아니다. 데이터를 쌓는 방법이다. 전역변수에 두면 쓰레드 간 큐, 공유메모리에 두면 프로세스 간 큐가 된다
TCP 로 4바이트를 보내면4바이트짜리 메시지가 아니라 4바이트가 버퍼에 들어갈 뿐이다. 경계는 앱이 만들어야 한다
Windows 로 옮기면메시지 큐가 없어 공유메모리 + 링버퍼 + 세마포어로 조립한다. 프로세스 간 잠금은 네임드 뮤텍스여야 한다

참고 자료

  • W. R. Stevens, S. A. Rago, Advanced Programming in the UNIX Environment, Addison-Wesley — 프로세스와 쓰레드, 파이프·FIFO, System V 와 POSIX IPC 전반
  • W. R. Stevens, UNIX Network Programming, Volume 2: Interprocess Communications, Prentice Hall — 메시지 큐·세마포어·공유메모리를 System V 와 POSIX 양쪽으로 비교
  • W. R. Stevens, B. Fenner, A. M. Rudoff, UNIX Network Programming, Volume 1: The Sockets Networking API, Addison-Wesley — TCP/UDP 소켓, 부분 송수신과 프레이밍
  • R. Love, Linux Kernel Development, Addison-Wesley — task_structclone(), 프로세스와 쓰레드를 커널이 어떻게 같은 것으로 다루는가
  • M. Kerrisk, The Linux Programming Interface, No Starch Press — 리눅스 시스템 콜 전반. IPC 한도값과 /proc 인터페이스
  • Linux man-pages — msgget(2), msgsnd(2), shmget(2), sem_overview(7), pthreads(7), clone(2), ipcs(1), ipcrm(1)
  • Microsoft Learn, Synchronization / File Mapping / WinsockCRITICAL_SECTION 과 커널 오브젝트의 차이, 네임드 오브젝트 수명, Winsock 과 BSD 소켓의 차이
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.