12.1프로세스 기반 병행 프로그래밍
병행성(concurrency)이란 여러 흐름이 시간상 겹쳐서 진행되는 것이다. 꼭 물리적으로 동시일 필요는 없다. CPU 한 개에서도 운영체제가 빠르게 스위칭하면 사용자 눈에는 동시처럼 보이니, 그것도 병행이다. 가장 오래되고 가장 안전한 병행 모델은 프로세스(process)를 여러 개 띄워 일감을 나누는 것이다. 8장에서 본 fork가 그 출발점이고, 11장에서 만든 에코 서버를 살짝 손보면 곧장 다중 클라이언트 서버가 된다.
12.1.1프로세스 기반 병행 서버 (accept → fork)
가장 단순한 병행 서버의 골격은 이렇다. 부모 프로세스는 듣기 소켓(listenfd) 위에서 무한히 accept를 돌리고, 새 연결이 들어올 때마다 fork로 자식을 만들어 그 연결을 떠넘긴다.
int listenfd = open_listenfd(port);
signal(SIGCHLD, sigchld_handler); // 좀비 회수용
while (1) {
int connfd = accept(listenfd, ...);
if (fork() == 0) {
close(listenfd); // 자식은 듣기 소켓 불필요
echo(connfd); // 클라이언트 처리
close(connfd);
exit(0);
}
close(connfd); // 부모는 통신 소켓을 닫는다
}
핵심 장면은 connfd를 누가 닫느냐다. fork 직후 자식과 부모는 같은 파일 디스크립터 테이블을 공유한다. 정확히는 같은 항목을 가리키는 두 개의 참조가 생긴다. 자식이 통신을 끝내고 connfd를 닫아도, 커널 입장에서는 부모가 들고 있는 참조 카운트가 1 남아 있어 진짜로는 닫히지 않는다. 그래서 부모는 즉시 connfd를 닫아야 한다. 안 그러면 디스크립터가 줄줄 새고, 곧 EMFILE 한도에 부딪힌다.
또 하나의 함정은 자식 프로세스가 죽을 때 남기는 좀비(zombie)다. exit한 자식의 종료 상태를 부모가 거두지(reap) 않으면 커널은 그 프로세스 테이블 슬롯을 못 비운다. 좀비가 누적되면 새 fork가 실패하기 시작한다. 해결책은 SIGCHLD 시그널 핸들러를 등록해두고, 그 안에서 waitpid를 호출해 일괄 회수하는 것이다.
void sigchld_handler(int sig) {
int olderrno = errno;
while (waitpid(-1, NULL, WNOHANG) > 0)
; // 처리 가능한 모든 좀비 한꺼번에 거두기
errno = olderrno;
}
WNOHANG 플래그는 "회수할 자식이 없으면 즉시 0 반환"을 의미한다. 시그널 핸들러는 반드시 비차단으로 짠다. 또 시그널이 합쳐서 한 번에 도착할 수 있으니, if가 아니라 while로 모든 자식을 거둔다. 이게 시그널 핸들러의 황금률 중 하나다.
메모시그널 핸들러 안에서는 async-signal-safe 함수만 호출할 수 있다. printf는 안전하지 않고, write는 안전하다. errno를 백업/복원하는 이유도 핸들러 도중 waitpid가 errno를 건드릴 수 있기 때문. 이 자잘한 규칙을 지키지 않으면 디버그 불가능한 산발 크래시가 생긴다.
12.1.2프로세스의 장단점 (격리 좋음, 통신 비용 큼)
프로세스 모델의 장점은 명확하다. 각 프로세스는 자기만의 가상 주소 공간을 갖는다. 한 자식이 이상한 포인터로 메모리를 박살내도 다른 자식은 멀쩡하다. 부모도 안전하다. 보안적으로도 격리가 강하다. 한 자식이 해킹당해도 같은 머신의 다른 클라이언트 정보는 보이지 않는다. 코드 자체도 단순하다. 공유 자료가 없으니 동기화도 필요 없다.
단점도 명확하다. 첫째, 프로세스 사이 통신(IPC)이 비싸고 번거롭다. 파이프, 소켓페어, 공유 메모리, 시그널 — 어느 것도 변수 하나 읽고 쓰는 것만큼 가볍지 않다. 둘째, fork 자체가 무겁다. 페이지 테이블을 복사하고(현대 리눅스는 copy-on-write로 줄였지만 여전히 비용이 있다), 디스크립터 테이블을 복제하고, 자식 PID 등록까지 — 한 번 fork 하는 데 수백 마이크로초가 든다. 동시 연결 수가 수천 단위로 늘어나면 fork만 하다 시간을 다 쓴다.
그래서 등장한 대안이 다음 두 절이다. (1) 프로세스 하나에서 여러 연결을 비동기적으로 다루는 I/O 멀티플렉싱, (2) 같은 주소 공간 안에서 여러 흐름을 만드는 스레드.
12.2I/O 멀티플렉싱 기반 병행 프로그래밍
프로세스나 스레드 없이도, 한 프로세스 안에서 여러 디스크립터를 동시에 감시할 수 있을까? 답은 "그렇다"이고 그 도구가 select, poll, 그리고 좀 더 빠른 epoll(리눅스), kqueue(BSD/macOS)다. 이 모델을 이벤트 주도(event-driven) 또는 I/O 멀티플렉싱이라 부른다.
12.2.1select/poll로 만든 이벤트 주도 서버
select의 아이디어는 단순하다. "다음 디스크립터들 중 하나라도 읽을 준비가 되거나 쓸 준비가 되면 깨워줘." 커널은 그 디스크립터들을 한꺼번에 감시하다가 첫 이벤트가 오면 반환한다.
fd_set read_set, ready_set;
FD_ZERO(&read_set);
FD_SET(listenfd, &read_set);
int maxfd = listenfd;
while (1) {
ready_set = read_set; // select가 비트맵을 변경하므로 매번 복사!
select(maxfd + 1, &ready_set, NULL, NULL, NULL);
// (1) 새 연결 들어왔는가?
if (FD_ISSET(listenfd, &ready_set)) {
int connfd = accept(listenfd, ...);
FD_SET(connfd, &read_set);
if (connfd > maxfd) maxfd = connfd;
}
// (2) 기존 연결 중 읽을 준비된 게 있는가?
for (int fd = 0; fd <= maxfd; fd++) {
if (fd != listenfd && FD_ISSET(fd, &ready_set)) {
// 한 번에 한 줄(또는 가능한 만큼)만 읽고 처리
handle_one_chunk(fd);
}
}
}
여기서 매번 ready_set = read_set으로 복사하는 게 핵심 포인트다. select는 인자로 받은 비트맵을 "준비된 디스크립터만 1로 남기고 나머지를 0으로" 덮어쓰기 때문에, 다음 호출 전에 원본이 필요하면 매번 새로 만들어야 한다. 헷갈리기 좋은 인터페이스다.
또 한 가지: 한 콜백에서 한 디스크립터의 데이터를 끝까지 읽으려 하면 안 된다. 그 사이 다른 클라이언트는 굶는다. 한 청크만 처리하고 즉시 루프 위로 돌아가야 한다. 그래야 모든 클라이언트가 공평하게 대접받는다. 이 지점이 이벤트 주도 코드의 가장 큰 정신적 부담이다. 흐름이 자연스럽게 한 클라이언트 처리에 머물지 않고, 매번 "잠깐, 이만큼만 처리하고 다시 돌아오자"의 콜백 스타일로 바뀌어야 하니까.
select의 본질적 한계는 두 가지다. 첫째, 매 호출마다 비트맵 전체를 커널에 보내고 다시 받아온다. 디스크립터가 늘수록 O(N) 비용이다. 둘째, 비트맵의 크기가 보통 1024(FD_SETSIZE)로 묶여 있어 그 이상의 디스크립터는 못 감시한다. poll은 비트맵 대신 배열을 쓰는데 한도 문제는 풀어주지만 O(N) 스캔은 여전하다. 진짜 해결책은 리눅스의 epoll이나 BSD의 kqueue — 커널이 변경분만 알려주는 방식이라 동시 디스크립터 10만 개도 거뜬하다. nginx, redis, node.js의 이벤트 루프가 다 이 위에서 돈다.
12.2.2I/O 멀티플렉싱 장단점 (성능 좋음, 코드 복잡)
장점: 프로세스나 스레드 컨텍스트 스위치 비용이 거의 없다. 한 프로세스에서 모두 처리하니 디버깅도 단일 흐름이라 그나마 추적이 쉽다. 메모리 사용량도 작다. 동시 1만 연결도 가뿐히 처리한다.
단점: 코드가 콜백 지옥처럼 흩어진다. 한 클라이언트의 처리 상태를 자체 자료구조(상태 머신)에 담아두고, 이벤트가 올 때마다 어디서 멈췄는지 복원하며 진행해야 한다. "한 줄 읽고 → DB 조회 → 응답 전송"이라는 직관적인 절차가, 이벤트 모델에서는 "상태 A에서 데이터 도착 → 상태 B로 전이 → DB 콜백 등록 → 콜백에서 상태 C로 → 쓰기 가능 이벤트에서 응답 전송"의 그래프로 풀려버린다. 또 한 콜백이 무거운 계산을 하면 다른 클라이언트가 굶는다. CPU-바운드 작업과는 잘 안 맞는다.
이 코드 복잡도를 언어 차원에서 풀어준 게 async/await이고, 라이브러리 차원에서 풀어준 게 libevent, libuv, asyncio 같은 것들이다. 본질은 모두 select/epoll 위의 추상화다.
12.3스레드 기반 병행 프로그래밍
스레드는 프로세스의 격리를 포기하는 대신 가벼움과 통신 편의를 얻은 모델이다. 한 프로세스 안에 여러 실행 흐름을 두고, 모두가 같은 주소 공간을 공유한다. 변수 하나로 통신할 수 있으니 IPC가 사라진다. 대신 그 공유가 만드는 새로운 함정 — 경쟁 조건과 데드락 — 이 등장한다.
12.3.1스레드 실행 모델 (한 프로세스 안의 동시 흐름, 공유 주소 공간)
한 프로세스가 시작될 때 자동으로 만들어지는 흐름이 메인 스레드(main thread)다. 메인 스레드는 다시 다른 스레드를 만들 수 있고, 그 스레드도 또 다른 스레드를 만들 수 있다. 이 관계는 부모-자식이라기보다 동등한 형제(peer)에 가깝다. 각 스레드는 자기만의 스레드 ID(TID), 프로그램 카운터(PC), 레지스터, 그리고 스택을 갖는다. 그러나 코드, 전역 변수, 힙, 열린 파일 디스크립터는 같은 프로세스 안의 모든 스레드가 공유한다.
비유하자면, 프로세스가 한 채의 집이라면 스레드는 그 집에 사는 사람들이다. 각자 책상(스택)과 머릿속(레지스터)은 따로지만, 거실 가구(전역 변수)와 부엌(힙)은 공유한다. 한 사람이 거실 책을 옮기면 다른 사람도 같은 변화를 본다. 편리하지만, 두 사람이 같은 책을 동시에 들면 책이 떨어진다. 이게 경쟁 조건이다.
12.3.2Posix 스레드 (pthread)
POSIX 스레드(흔히 pthread)는 유닉스 계열 시스템의 표준 스레드 API다. <pthread.h>를 인클루드하고 컴파일 시 -lpthread(또는 -pthread)를 붙이면 된다. 핵심 함수는 다섯 개다: pthread_create, pthread_exit, pthread_join, pthread_detach, pthread_self. 자물쇠나 조건 변수까지 포함하면 더 많지만, 기본 골격은 이 다섯 개로 충분하다.
12.3.3스레드 생성 (pthread_create)
#include <pthread.h>
void *thread_routine(void *arg) {
int id = *(int *)arg;
printf("Hello from thread %d\n", id);
return NULL;
}
int main(void) {
pthread_t tid;
int arg = 42;
pthread_create(&tid, NULL, thread_routine, &arg);
pthread_join(tid, NULL);
return 0;
}
네 인자 의미: (1) 만들어진 TID를 받아갈 곳, (2) 스레드 속성(보통 NULL로 기본값), (3) 스레드가 실행할 함수 포인터, (4) 함수에 전달할 인자(void *). 함수 시그니처는 반드시 void *(*)(void *) 꼴이어야 한다. 스레드는 이 함수에서 반환하면 자동으로 종료된다.
함정스레드 함수에 인자를 넘길 때 흔한 실수. 루프 변수 i의 주소를 그대로 넘기면, 스레드들이 같은 주소를 가리키게 되어 결국 다 같은 값을 보게 된다. pthread_create(&tid, NULL, fn, &i)를 루프에서 돌리면, 모든 스레드가 마지막의 i 값을 본다. 해결: 매 반복마다 새 메모리(malloc한 정수 슬롯)에 값을 복사해 넘기거나, 정수를 캐스팅으로 직접 인자에 박아 넣는다((void *)(intptr_t)i).
12.3.4스레드 종료 (pthread_exit, return)
스레드 종료 방식은 셋이다. (1) 스레드 함수에서 return으로 빠져나오기 — 가장 자연스럽다. (2) 스레드 안에서 명시적으로 pthread_exit(retval) 호출 — 호출 스택 어디서든 즉시 종료. (3) 다른 스레드가 pthread_cancel(tid)로 취소 요청 — 권장하지 않는다(자원 누수 위험). 또 어떤 스레드든 exit(...)를 호출하면 프로세스 전체가 죽으니 주의.
12.3.5종료된 스레드 회수 (pthread_join)
스레드도 좀비처럼 죽은 뒤 자원을 누가 거둬야 한다. pthread_join(tid, &retval)은 그 스레드가 끝날 때까지 기다리고, 종료 값(void *)을 받아오며, 자원을 회수한다. 한 스레드는 정확히 한 번만 join될 수 있다. join하지 않은 스레드는 자원이 새서 메모리 누수처럼 행세한다.
12.3.6스레드 분리 (pthread_detach)
모든 스레드를 join할 필요는 없다. 결과를 받아올 필요 없는 "fire-and-forget" 작업이라면, 만들면서 분리(detach)해두자.
void *worker(void *arg) {
pthread_detach(pthread_self()); // 자기 자신을 분리
handle_request((int)(intptr_t)arg);
return NULL;
}
분리된 스레드는 종료 즉시 자원이 회수된다. 누구도 join할 수 없고, join하면 오류다. 서버에서 클라이언트마다 워커 스레드를 띄우는 패턴에서는 거의 항상 분리해서 쓴다.
12.3.7스레드 초기화 (pthread_once)
전역 자료구조를 정확히 한 번만 초기화하고 싶을 때 — 그러나 어느 스레드가 그 일을 할지 모를 때 — 쓰는 도구다.
pthread_once_t once = PTHREAD_ONCE_INIT;
static int *cache;
void init_cache(void) { cache = malloc(SIZE); memset(cache, 0, SIZE); }
void *worker(void *arg) {
pthread_once(&once, init_cache); // 정확히 한 번만 init_cache 실행
use_cache(...);
return NULL;
}
여러 스레드가 동시에 pthread_once를 호출해도 init_cache는 한 번만 돈다. 나머지는 그 한 번이 끝날 때까지 기다린다. 직접 자물쇠로 짜려면 까다로운 코드를 라이브러리가 대신 처리해준다.
12.3.8스레드 기반 병행 서버
while (1) {
int connfd = accept(listenfd, ...);
pthread_t tid;
int *connfdp = malloc(sizeof(int));
*connfdp = connfd;
pthread_create(&tid, NULL, worker, connfdp);
}
void *worker(void *vargp) {
int connfd = *((int *)vargp);
pthread_detach(pthread_self());
free(vargp); // 인자 메모리 해제
echo(connfd);
close(connfd);
return NULL;
}
fork 서버와 닮았지만 가볍다. 인자로 connfd 정수를 직접 넘기는 대신 malloc한 슬롯에 담아 넘기는 이유는 위에서 언급한 "공유 변수 함정"을 피하기 위해서다. 워커가 자기 몫의 인자를 안전히 가져간 뒤 free하는 책임을 지는 패턴이다.
12.4스레드 프로그램의 공유 변수
스레드 코드가 어려운 이유의 절반은 "이 변수, 진짜 공유되는 건가?"가 헷갈리는 데에서 온다. 정리해두자.
12.4.1스레드 메모리 모델 (각 스레드: 사적 레지스터/스택, 공유: 코드/데이터/힙/공유 라이브러리)
한 프로세스의 가상 주소 공간은 코드 영역, 초기화된/안 된 전역(데이터) 영역, 힙, 공유 라이브러리 매핑 영역, 그리고 여러 개의 스택으로 구성된다. 스레드마다 자기 스택이 따로 있고, 자기 레지스터/PC 값을 따로 갖는다. 이건 진짜로 사적이다. 그 외 — 코드/전역/힙 — 은 모두 같은 주소를 가리키므로 공유된다.
12.4.2변수의 메모리 매핑 (전역, static 지역, 일반 지역)
C 변수가 어디 사느냐는 선언 위치에 따라 결정된다.
| 선언 위치/속성 | 저장 영역 | 공유 여부 |
| 함수 밖의 일반 변수 (전역) | 데이터 영역 | 모든 스레드가 공유 |
함수 안의 static 변수 | 데이터 영역 | 모든 스레드가 공유 |
| 함수 안의 일반 지역 변수 (자동 변수) | 그 스레드의 스택 | 원칙적으로 사적 |
| 지역 변수의 주소를 다른 스레드에 넘긴 경우 | 같은 스택 | 실질적으로 공유 (위험) |
malloc으로 받은 메모리 | 힙 | 주소 아는 스레드들이 공유 |
중요한 함정은 마지막 두 줄이다. 일반 지역 변수는 "그 스레드의 스택에 산다"는 점에서 사적이지만, 그 주소를 포인터로 다른 스레드에 넘기면 실질적으로 공유 변수가 된다. 게다가 그 변수를 만든 스레드가 함수에서 빠져나가면 스택이 사라지므로, 다른 스레드가 들고 있던 포인터는 댕글링이 된다. 이게 흔한 버그 한 종류다.
void *worker(void *arg) {
char buf[1024]; // 이 스레드 스택의 자동 변수
other_thread_use(&buf); // 위험! 워커가 끝나면 buf 사라짐
return NULL;
}
12.4.3공유 변수 (다른 스레드가 참조 가능한 모든 인스턴스)
책에서 정의하는 "공유 변수"는 사실상 다른 스레드가 그 인스턴스를 참조할 수 있느냐로 결정된다. 같은 변수 선언이라도 인스턴스가 여러 개일 수 있다. 예: 함수 안의 일반 지역 변수는 그 함수가 두 스레드에서 동시에 실행되면 인스턴스가 두 개다. 따라서 한 스레드가 자기 인스턴스를 보는 건 사적이지만, 주소를 넘기면 공유로 변한다. 결국 "선언만 보지 말고 주소가 어디로 새는지 추적하라"가 핵심 원칙이다.
12.5세마포어로 스레드 동기화
공유 변수가 있다는 사실 자체가 문제는 아니다. 문제는 여러 스레드가 동시에 그것을 변경할 때다. 카운터 하나 늘리는 것조차 안전하지 않다는 사실을 먼저 보고, 그걸 어떻게 길들이는지 본다.
12.5.1진행 그래프 (progress graph)와 안전/위험 영역
두 스레드의 실행을 2차원 평면으로 그려본다. 가로축은 스레드 1의 진행, 세로축은 스레드 2의 진행. 한 점은 "스레드 1이 i번째 명령, 스레드 2가 j번째 명령에서 멈춘 상태"를 나타내고, 실행 궤적은 (0,0)에서 출발해 오른쪽이나 위로만 한 칸씩 가는 계단 모양 경로다.
두 스레드가 같은 공유 변수를 건드리는 임계 구역(critical section)이 있다고 하자. 두 임계 구역이 시간상 겹치는 영역이 진행 그래프 위에서는 하나의 직사각형으로 보인다. 이 직사각형이 위험 영역(unsafe region)이다. 궤적이 그 직사각형 안을 통과하면 경쟁 조건이 발생한다. 안전한 궤적은 이 직사각형을 위/아래나 왼쪽/오른쪽으로 우회한다.
이 그림이 알려주는 것은 두 가지다. 첫째, 우리가 할 일은 "어떤 스케줄링이 일어나도" 위험 영역을 통과하지 않게 만드는 것. 둘째, 그러려면 한 스레드가 임계 구역에 들어가 있을 때 다른 스레드가 진입하지 못하도록 막는 도구가 필요하다는 것. 그 도구가 세마포어다.
12.5.2세마포어 (P/V 연산, sem_wait/sem_post)
세마포어(semaphore)는 음이 아닌 정수를 들고 있는 객체다. 두 가지 원자적 연산이 정의된다. 옛 이름은 P와 V(다익스트라가 네덜란드어 단어 첫 글자에서 따왔다), POSIX 이름은 sem_wait와 sem_post다.
- P(s) =
sem_wait(&s): s > 0이 될 때까지 기다린 다음 s--를 실행한다. 이 두 동작이 원자적으로 일어난다.
- V(s) =
sem_post(&s): s++. 만약 0에서 1로 올라갔다면, 기다리고 있던 스레드 중 하나를 깨운다.
시각화하면 세마포어는 카운터다. 초기값 1로 만든 세마포어 위에서 두 스레드가 P를 부르면, 첫 번째는 1→0으로 통과하고, 두 번째는 0이라 막혀 잠든다. 첫 번째가 V를 부르면 0→1이 되며 잠든 두 번째가 깨어난다. 항상 음수가 되지 않는다는 세마포어 불변식이 핵심이다.
#include <semaphore.h>
sem_t mutex;
sem_init(&mutex, 0, 1); // 초기값 1, 같은 프로세스 안에서만 사용
sem_wait(&mutex); // P: 임계 구역 진입
... 공유 자료 만지기 ...
sem_post(&mutex); // V: 임계 구역 탈출
12.5.3상호 배제용 세마포어 (binary semaphore = mutex)
세마포어 초기값을 1로 두면 카운터는 0과 1만 오간다. 이게 이진 세마포어 또는 뮤텍스(mutex)다. 한 번에 한 스레드만 임계 구역에 들어갈 수 있다는 가장 기본적인 보장이다.
왜 단순히 cnt += 1도 안전하지 않은지 어셈블리로 보자. 컴파일하면 보통 이렇게 풀린다.
movl cnt(%rip), %eax # load: 레지스터로 cnt 읽기
addl $1, %eax # modify: 레지스터에서 +1
movl %eax, cnt(%rip) # store: 결과를 cnt에 다시 쓰기
이 세 명령 사이 어디서든 OS는 컨텍스트를 다른 스레드로 넘길 수 있다. 두 스레드 T1, T2가 동시에 cnt += 1을 1만 번씩 한다고 하자. 정답은 2만 번 후 cnt = 20000. 그러나 T1이 load를 마치고 modify를 끝내기 전에 T2가 같은 값을 load하면, 둘 다 같은 값에 1을 더해 같은 값을 store한다. 결과적으로 한 번 늘어야 할 카운터가 늘지 않는다. 두 스레드를 잠깐만 돌려도 cnt가 1만 5천 근처에서 멈추는 걸 볼 수 있다.
// 안전한 카운터 증가
sem_wait(&mutex);
cnt += 1;
sem_post(&mutex);
이걸로 load-modify-store가 한 덩어리로 묶인다. 다른 스레드는 P에서 막히고, 끝난 뒤 들어온다. 결과는 정확히 2만이 된다.
주의뮤텍스는 공짜가 아니다. 락 한 번에 수십~수백 사이클이 든다. 임계 구역을 가능한 한 짧게 만드는 게 성능의 첫 규칙이다. "긴 함수 전체를 락으로 감싸지 말 것." 그리고 가능하면 락 자체를 피하는 자료구조(원자 연산만 쓰는 큐, lock-free 컬렉션)를 고려하자. 다만 그건 진짜 어려운 영역이라 표준 라이브러리에 맡기는 게 보통은 옳다.
12.5.4공유 자원 스케줄링 (생산자-소비자, reader-writer)
세마포어가 단순한 상호 배제를 넘어, 자원의 가용성을 신호하는 데에도 쓰인다. 대표 예가 생산자-소비자(producer-consumer) 패턴이다.
유한 크기 버퍼가 있고, 한 스레드는 데이터를 채워 넣고(생산자), 다른 스레드는 꺼내 처리한다(소비자). 버퍼가 꽉 차면 생산자는 기다려야 하고, 비면 소비자가 기다려야 한다. 이 조정을 두 개의 세마포어 slots(빈 슬롯 수)와 items(차 있는 슬롯 수)로 해결한다.
sem_t mutex; // 버퍼 자체에 대한 상호 배제
sem_t slots; // 비어있는 슬롯 수, 초기 N
sem_t items; // 채워진 슬롯 수, 초기 0
// 생산자
void produce(item v) {
sem_wait(&slots); // 빈 슬롯 하나 확보
sem_wait(&mutex); // 버퍼 만지기 직전
insert(v);
sem_post(&mutex);
sem_post(&items); // 새 아이템 알림
}
// 소비자
item consume(void) {
sem_wait(&items); // 아이템 하나 도착 대기
sem_wait(&mutex);
item v = remove();
sem_post(&mutex);
sem_post(&slots); // 빈 슬롯 하나 알림
return v;
}
핵심은 slots와 items가 합쳐서 항상 버퍼 크기 N을 유지한다는 것. 또 sem_wait(&slots)를 mutex 안쪽에 두면 안 된다. 빈 슬롯이 없을 때 mutex를 잡은 채 잠들면 소비자가 mutex를 못 잡고, 영원히 깨어나지 못한다 — 데드락이다. 항상 "자원 신호 세마포어"를 먼저, "상호 배제 세마포어"를 나중에 잡는다.
독자-기록자(reader-writer) 문제도 비슷한 결의 문제다. 한 자료를 여러 스레드가 읽기만 할 때는 동시에 읽어도 되지만, 누가 쓰는 동안엔 아무도 손대면 안 된다. 라이브러리 함수 pthread_rwlock_t가 이걸 직접 제공하므로 직접 짜기보다는 그걸 쓰자.
12.5.5정리: prethreaded 병행 서버 (사전 생성 스레드 풀 + 작업 큐)
매 연결마다 스레드를 새로 만드는 12.3.8의 패턴은 가볍긴 해도 여전히 생성/종료 비용이 든다. 한 발짝 더: 처음에 N개의 워커 스레드를 미리 만들어두고, 메인 스레드(마스터)는 들어온 연결을 공유 큐에 넣기만 한다. 워커들은 큐에서 일감을 꺼내 처리한다. 이게 prethreaded 또는 스레드 풀 모델이다.
// 큐는 위 생산자-소비자와 동일한 세 세마포어로 보호
// slots, items, mutex
// 마스터(메인 스레드)
while (1) {
int connfd = accept(listenfd, ...);
sbuf_insert(&sbuf, connfd); // 큐에 enqueue (sem_wait/post 내부)
}
// 워커 (N개)
void *worker(void *arg) {
pthread_detach(pthread_self());
while (1) {
int connfd = sbuf_remove(&sbuf); // 큐에서 dequeue
echo(connfd);
close(connfd);
}
return NULL;
}
이 모델의 장점: 스레드 생성 오버헤드가 없고, 동시 연결 수가 폭증해도 워커 수가 고정이라 컨텍스트 스위치가 안정적이다. 단점: 풀 크기를 잘못 잡으면(너무 작으면 큐가 길어지고, 너무 크면 컨텍스트 스위치가 늘어나고) 성능이 무너진다. 보통 CPU 코어 수의 1~2배에서 시작해 워크로드를 보고 조정한다.
12.6병렬성을 위한 스레드 사용 (성능 측정 모델: speedup, efficiency)
지금까지의 병행성은 "겹쳐서 진행되는 흐름들"이었다. 그 흐름이 정말로 동시에 다른 코어 위에서 돌면 병렬(parallel)이라고 부른다. 병렬은 항상 병행이지만, 그 반대는 아니다.
여러 코어를 쓸 수 있는 환경에서, p개의 스레드로 작업을 나눴을 때 얼마나 빨라졌는지 측정하는 두 지표가 있다.
- 속도 향상비(speedup)
Sp = T1 / Tp. 1코어로 돌릴 때 시간 대비 p코어로 돌릴 때 시간의 비. 이상적으로는 p에 비례한다(linear speedup).
- 효율성(efficiency)
Ep = Sp / p. 1이면 완벽한 선형, 보통 0.5~0.9 사이가 현실적.
현실에서 효율성을 갉아먹는 요소는 많다. (1) 직렬화된 부분 — 어차피 한 스레드만 할 수 있는 작업(예: 파일 입출력 일부, 락 안의 임계 구역). 이걸 암달의 법칙(Amdahl's law)으로 정량화한다: 직렬 비율이 5%면 무한 코어를 동원해도 최대 20배. (2) 동기화 오버헤드 — 락 경합. (3) 캐시 일관성 — 한 코어가 변경한 캐시 라인을 다른 코어가 읽으려면 무효화/공유 프로토콜이 일어나는데 이게 의외로 비싸다. 특히 같은 캐시 라인 안 다른 변수들끼리도 충돌하는 거짓 공유(false sharing)가 흔하다. (4) 작업 분할 불균형 — 한 워커만 무거운 일을 받으면 나머지가 놀게 된다.
실제 측정해보면, 코어 4개 머신에서 효율성 0.7이면 양호한 편이고, 0.9면 잘 짠 편이다. 1.0 가까이 나온다면 측정을 의심해봐야 한다. 보통 그건 캐시 효과 같은 외부 요인 덕에 단일 스레드 버전이 너무 느렸던 경우다.
12.7기타 병행성 이슈
12.7.1스레드 안전성 (4가지 thread-unsafe 클래스)
한 함수가 여러 스레드에서 동시에 호출되어도 항상 옳게 동작하면 스레드 안전(thread-safe)하다고 한다. 안전하지 않은 함수는 보통 네 부류다.
- 공유 변수를 보호 없이 만지는 함수.
cnt += 1 같은 것. 락으로 둘러싸지 않으면 위험하다.
- 호출 사이에 상태를 유지하는 함수.
rand()가 대표 예. 내부에 시드 변수를 들고 있어 호출마다 그걸 읽고 쓴다. 두 스레드가 동시에 부르면 시드가 꼬인다.
- 정적/전역 버퍼에 결과를 저장하고 그 포인터를 반환하는 함수.
ctime, strtok, gethostbyname이 그렇다. 두 스레드가 동시에 부르면 한쪽 결과가 다른 쪽 결과로 덮어쓰여 사라진다.
- 다른 스레드 안전하지 않은 함수를 부르는 함수. 전염성. 한 함수의 안전성은 그 함수가 호출하는 모든 함수의 안전성에 의존한다.
12.7.2재진입성 (reentrant function)
스레드 안전성보다 더 강한 성질이 재진입(reentrant)이다. 함수가 어떤 공유 변수도 만지지 않으면 — 즉 모든 데이터가 인자나 지역 변수에서만 흐르면 — 락 없이도 여러 스레드에서 안전하게 부를 수 있다. 재진입 함수는 자동으로 스레드 안전하다. 반대는 거짓: 락으로 보호하면 스레드 안전하지만 재진입은 아니다.
재진입의 진짜 가치는 시그널 핸들러에서도 안전하다는 점이다. 핸들러는 메인 흐름을 중단시키며 끼어드는데, 만약 메인이 어떤 함수의 임계 구역에 있을 때 핸들러가 같은 함수를 호출하면 재귀처럼 들어가게 된다. 락은 같은 스레드의 재호출도 막을 수 있어 데드락이 나거나(non-recursive mutex), 안 막더라도 자료 구조가 일관되지 않은 중간 상태에서 호출되어 깨질 수 있다. 재진입 함수만이 그 상황에서 안전하다.
12.7.3기존 라이브러리 함수 사용 (gethostbyname → getaddrinfo, ctime → ctime_r)
표준 C 라이브러리에는 정적 버퍼를 쓰는 오래된 함수들이 많다. 이들 옆에는 보통 _r(reentrant) 접미사가 붙은 새 버전이 함께 제공된다. 호출자가 버퍼를 직접 넘기는 형태라 스레드 안전하다.
| 안전하지 않음 | 대체 (재진입/스레드 안전) |
rand() | rand_r(unsigned *seed) |
ctime(time_t *) | ctime_r(time_t *, char *buf) |
strtok(char *, char *) | strtok_r(char *, char *, char **saveptr) |
gethostbyname(...) | getaddrinfo(...) (구조적으로 다름, 더 권장) |
localtime(...) | localtime_r(...) |
현대 코드에서 gethostbyname은 단순히 스레드 안전성 문제만이 아니라 IPv6 지원 부재 같은 이유로도 폐기 권고다. 11장에서 본 getaddrinfo로 거의 모든 경우를 대체할 수 있다.
12.7.4경쟁 조건 (race)
경쟁 조건(race condition)은 두 스레드가 어떤 자원에 접근하는 순서에 결과가 의존하는데, 그 순서가 보장되지 않는 상태를 말한다. 카운터 예가 그 가장 작은 형태였다. 더 미묘한 형태도 많다.
전형적인 한 가지: 메인 스레드가 워커에게 인자를 넘기면서 i의 주소를 그대로 넘기고, 메인은 i++로 다음 반복으로 가버린다. 워커가 그 사이 자기 인자를 읽으면 이미 변한 값을 본다. 결과는 워커가 언제 깨어나느냐에 따라 매번 달라진다. 이건 비결정성(non-determinism)을 코드에 심는 일이다. 경쟁 조건은 디버그하기가 끔찍하다. 재현이 어렵고, 디버거가 끼어들면 타이밍이 변해 사라지기 일쑤다.
예방의 원칙은 (1) 공유 자료를 식별하고, (2) 가능한 한 그 자료를 줄이고, (3) 남은 자료에 대해 모든 접근을 적절히 보호하는 것이다. 락, 원자 연산, 메시지 패싱 — 도구는 많지만 인식이 첫걸음이다.
12.7.5데드락 (Coffman 조건)
데드락(deadlock)은 둘 이상의 스레드가 서로가 가진 자원을 기다리며 영원히 멈춰버리는 상황이다. 1971년 Coffman이 정리한 네 가지 조건이 모두 성립할 때 데드락이 가능하다.
- 상호 배제(Mutual Exclusion): 자원을 한 번에 한 스레드만 보유할 수 있다.
- 점유와 대기(Hold and Wait): 자원을 가진 채로 다른 자원을 기다린다.
- 비선점(No Preemption): 보유한 자원은 강제로 빼앗을 수 없고, 자발적 반납만 가능하다.
- 순환 대기(Circular Wait): 스레드들이 원형으로 서로의 자원을 기다린다. T1이 T2의 자원을, T2가 T3의 자원을, ..., Tn이 T1의 자원을 기다리는 식.
전형적 시나리오. 락 A, B가 있고 두 스레드가 있다.
// T1 // T2
sem_wait(&A); sem_wait(&B);
sem_wait(&B); ←──┐ sem_wait(&A); ←──┐
... 작업 ... │ ... 작업 ... │
sem_post(&B); │ sem_post(&A); │
sem_post(&A); │ sem_post(&B); │
│ │
T1은 B 기다림 T2는 A 기다림
(T2가 잡고 있음) (T1이 잡고 있음)
가장 단순한 회피책은 모든 스레드가 락을 같은 순서로 잡는 것이다. 위 예에서 두 스레드 모두 "A 먼저, 그다음 B" 순서를 따르면, 누구든 A를 잡으면 그 사람이 B도 결국 잡고 풀 수 있다. 락 순서를 코드 컨벤션으로 못 박아두는 게 가장 흔하고 가장 효과적인 데드락 예방법이다.
두 번째 회피책은 pthread_mutex_trylock 같은 비차단 시도 함수를 쓰는 것이다. B를 잡지 못하면 A를 풀고 잠시 대기 후 처음부터 다시. 하지만 이는 활동성 문제(라이브락(livelock): 둘 다 양보만 하다 못 끝남)를 만들 수 있어, 백오프(backoff)와 함께 신중히 설계해야 한다.
실전 팁한 임계 구역 안에서 다른 락을 잡거나 콜백을 호출해야 한다면 빨간불을 켜자. 콜백이 어떤 락을 잡을지 모르는 상황에서 락을 들고 콜백하면 데드락 위험이 폭증한다. 가능하면 락을 잡은 채로 외부 코드(콜백/I/O/대기)를 부르지 말 것.
12.8요약
이번 장은 한 줄로 요약하면 이렇다: "동시"는 공짜가 아니다. 우리는 세 가지 방식을 봤다. 프로세스 — 격리는 좋지만 무겁다. I/O 멀티플렉싱 — 가볍지만 코드가 콜백 그래프로 꼬인다. 스레드 — 가볍고 직관적이지만 공유로 인한 함정이 새로 생긴다. 어떤 모델도 만능은 아니라서, 워크로드의 성격(I/O 바운드인지 CPU 바운드인지, 격리가 얼마나 중요한지, 동시 연결 규모가 얼마인지)에 따라 골라야 한다.
- 프로세스 서버는 fork-accept 패턴이 단순하고 안전하다.
SIGCHLD 핸들러로 좀비를 거두고, 자식이 받은 connfd는 부모가 즉시 닫는다.
- I/O 멀티플렉싱은 한 프로세스에서 수많은 디스크립터를 다룰 수 있게 해주지만, 각 클라이언트의 상태를 자체 자료구조로 관리하는 상태 머신 사고가 필요하다. 큰 규모에선
epoll/kqueue로 가야 한다.
- 스레드는 같은 주소 공간을 공유한다. 전역,
static 지역, 힙은 공유. 일반 지역 변수는 사적이지만, 그 주소가 새 나가면 사실상 공유가 된다.
- 경쟁 조건은 비결정성을 심는다. 세마포어로 임계 구역을 보호하라. 단순 카운터 증가도 보호 없이는 깨진다.
- 생산자-소비자 같은 자원 스케줄링 문제는 카운팅 세마포어 두 개와 mutex 하나의 조합으로 깔끔하게 해결된다.
slots와 items는 합쳐서 항상 N을 유지한다.
- 스레드 안전성 ≠ 재진입성. 후자가 더 강하고, 락 없이 안전하다. 시그널 핸들러에서도 안전한 건 재진입 함수뿐이다. 라이브러리는
_r 접미사 버전을 우선 쓰자.
- 데드락은 Coffman의 4조건을 모두 만족할 때만 가능하다. 가장 효과적인 예방은 락을 항상 같은 순서로 잡기.
- 병렬 성능은 speedup과 efficiency로 측정한다. 암달의 법칙이 알려주듯 직렬 부분 5%만 있어도 무한 코어로도 20배가 한계다. 거짓 공유, 락 경합, 작업 분할 불균형이 효율성을 갉아먹는 주범.
이 장의 내용은 단순한 학습용이 아니다. 현대 서버 프로그래밍, 게임 엔진, 데이터베이스 내부, GUI 이벤트 루프, 모바일 앱의 백그라운드 작업 — 거의 모든 실전 시스템이 여기 나온 패턴들 위에 서 있다. 코드 한 줄이 안전한지 의심하는 습관, 임계 구역을 짧게 유지하는 본능, 락 순서 컨벤션을 정리하는 규율이 이번 장의 진짜 결과물이다. 처음 만나는 멀티스레드 버그가 며칠을 잡아먹어도, 이 장의 도구를 들고 있으면 결국 잡힌다. 잘 잡았다.
꿀팁스스로 점검: (1) fork 후 부모가 connfd를 닫지 않으면 어떤 일이 생기나? (2) 두 스레드가 락 없이 같은 카운터를 1000만 번씩 늘리면 결과는 왜 2000만보다 작아지는가? (3) 데드락 4조건 중 가장 깨기 쉬운 건 무엇이며, 그걸 어떻게 깨는가? 세 질문에 답할 수 있다면 이 장은 머릿속에 자리잡은 것이다. 막힌다면 해당 절을 다시 펼쳐보자.