지난 시간 락(Lock)에 대해 다루며 멀티 스레드 환경에서 상호 배제 (Mutual Exclusion)와 공정성, 성능까지 챙길 수 있는 방법에 대해 알아보았다. 하지만 락(Lock)만으로는 복잡한 스레드 간의 협력 시나리오를 구현하기 어렵다는 사실을 마주했다. 특정 조건이 만족될 때까지 스레드를 잠들게 하거나, 여러 개의 자원을 효율적으로 관리해야 하는 상황이 바로 그것이다. 이 기록은 복잡한 멀티스레드 환경에서 데이터 접근을 동기화하고 고전적인 병행성 문제를 해결하는 데 사용되는 조건 변수(Condition Variables) 및 세마포어(Semaphores)에 대한 심층 학습 내용이다.
1. 조건 변수 (Condition Variables)
조건 변수는 스레드가 실행을 계속하기 전에 특정 조건이 참인지 확인하고자 할 때 유용하게 사용되는 동기화 도구이다. 단순히 while 루프를 돌며 조건을 확인하는 스핀(spin) 방식은 CPU 자원을 극심하게 낭비하기 때문에, 조건이 만족될 때까지 스레드를 효율적으로 '잠들게' 하는 메커니즘이 필요하다.
1.1 기본 개념 및 구성
조건 대기 (Waiting on the condition): 스레드는 원하는 실행 상태가 아닐 때, 명시적인 큐에 자신을 넣고 잠들게 된다.
조건 시그널링 (Signaling on the condition): 다른 스레드가 상태를 변경했을 때, 대기 중인 스레드 중 하나를 깨워 실행을 계속할 수 있도록 허용한다.
구성 요소: 조건 변수는 일반적으로 세 가지 요소와 함께 사용된다.
조건 변수
c: 스레드를 잠재우고 깨우는 큐 역할상태 변수: 동기화의 대상이 되는 조건 (예:
isDone,count)락
L: 상태 변수를 읽고 수정하는 임계 구역을 보호하기 위한 뮤텍스
1.2 구현과 핵심 주의사항 (POSIX Threads 기준)
주요 루틴으로는 pthread_cond_wait()과 pthread_cond_signal()이 있다. wait 함수는 호출 시 인자로 받은 락을 자동으로 해제하고 잠드는 중요한 특징이 있다. 스레드가 깨어날 때는 자동으로 락을 다시 획득한다.
| Definition of Condition Variable |
실패 사례로 배우는 주의점:
상태 변수의 부재: 만약 자식 스레드의 완료 여부를 나타내는
done같은 상태 변수가 없다면 어떻게 될까? 자식이 부모보다 먼저 실행되어signal을 보냈다고 가정해보자. 아직 대기 큐에 들어가지 않은 부모는 이signal을 받지 못하고, 나중에wait를 호출하며 영원히 잠들게 된다.signal은 일회성이며, 대기 중인 스레드가 없으면 그냥 소멸하기 때문이다.Importance of the state variable done while이 아닌if사용:wait를 호출하기 전에if (done == 0)으로 한 번만 검사하는 것도 위험하다. 검사와wait호출 사이의 아주 짧은 순간에 다른 스레드가 끼어들어 상태를 변경하고signal을 보낼 수 있는 경쟁 조건이 발생한다. 또한, 운영체제는 때때로 아무런signal이 없어도 스레드를 깨우는 '스퓨리어스 웨이크업(Spurious Wakeup)' 현상을 일으킬 수 있다. 따라서 깨어난 직후,while루프를 통해 조건이 정말로 만족되었는지 반드시 다시 확인해야 한다.Poor implementation
// 올바른 조건 변수 사용법
pthread_mutex_lock(&m);
while (done == 0) {
pthread_cond_wait(&c, &m); // 락을 놓고 잠들었다가, 깨어나면 다시 락을 잡음
}
pthread_mutex_unlock(&m);
1.3 커버링 컨디션 (Covering Conditions)
때로는 깨어난 스레드가 원하는 조건을 만족하지 못할 수도 있다. 예를 들어, 메모리 할당자에서 100바이트를 기다리는 스레드 A와 10바이트를 기다리는 스레드 B가 잠들어 있을 때, 다른 스레드가 50바이트를 해제했다고 가정하자. signal을 받은 A가 깨어나도 여전히 100바이트는 부족하므로 다시 잠들어야 한다.
이 경우, pthread_cond_signal() 대신 pthread_cond_broadcast()를 사용하는 것이 해결책이 될 수 있다. broadcast는 대기 중인 모든 스레드를 깨운다. 비록 불필요한 스레드까지 깨우는 비용이 발생하지만, 각 스레드가 while 루프를 통해 자신의 조건을 확인하고 적합한 스레드(위 예시에서는 B)가 작업을 이어갈 수 있게 된다.
| Covering Condition |
2. 세마포어 (Semaphores)
세마포어는 정수 값으로 조작되는 동기화 객체이며, sem_wait()과 sem_post() 두 가지 원자적 루틴으로 조작된다. 조건 변수와 달리, 세마포어는 내부에 정수 카운트를 가지고 있어 자원의 개수를 관리하는 데 특히 유용하다.
2.1 세마포어의 기본 루틴
세마포어의 창시자인 다익스트라는 sem_wait을 P() (네덜란드어 Proberen, '검사하다/감소하다'), sem_post를 V() (Verhogen, '증가하다')라고 불렀으며, 각각 down, up으로 불리기도 한다.
초기화:
sem_init(&s, 0, initial_value)로 세마포어를 초기화한다.두 번째 인자
0은 현재 프로세스 내의 스레드 간에 공유됨을 의미한다. (0이 아닌 값을 주면 여러 프로세스 간에 공유 가능)initial_value는 사용 가능한 자원의 개수를 나타낸다.
sem_wait()(P() 또는 down): 자원을 요청하는 과정이다.호출되면 세마포어의 내부 정수 값을 원자적으로 1 감소시킨다.
값이 0 이상이면(즉, 감소시키기 전 값이 1 이상이었으면) 자원이 있다는 뜻이므로, 스레드는 즉시 실행을 계속한다.
값이 음수가 되면(즉, 감소시키기 전 값이 0이었으면) 사용할 수 있는 자원이 없다는 뜻이므로, 스레드는 잠들고 대기 큐로 들어간다. (리눅스 구현에서는 세마포어의 음수 값의 절댓값이 대기 중인 스레드의 수가 된다.)
sem_post()(V() 또는 up): 자원을 반납하는 과정이다.호출되면 세마포어의 내부 정수 값을 원자적으로 1 증가시킨다.
값을 증가시킨 후, 만약 대기 큐에 잠들어 있는 스레드가 있다면(즉, 값을 증가시키기 전이 음수였다면) 그중 하나를 깨운다.
post로 증가된 값은 사라지지 않고 내부에 '기억'된다는 점에서 조건 변수의 signal과 결정적으로 다르다. signal은 기다리는 스레드가 없으면 소멸하지만, post는 기다리는 스레드가 없어도 카운트를 증가시켜 미래의 wait 호출이 기다림 없이 통과할 수 있게 해준다.
2.2 이진 세마포어 (Binary Semaphores)
세마포어를 1로 초기화하면, 한 번에 하나의 스레드만 wait를 통과할 수 있으므로, 이는 뮤텍스 락과 동일하게 동작한다. 이를 특별히 이진 세마포어라고 부른다.
| Binary Semaphore |
3. 고전적인 동기화 문제 해결
3.1 생산자/소비자 문제 (Bounded-Buffer Problem)
이 문제는 공유된 유한한 크기의 버퍼(저장 공간)를 두고, 생산자(Producer) 스레드는 데이터를 버퍼에 넣고 소비자(Consumer) 스레드는 버퍼에서 데이터를 빼가는 상황을 모델링한다. 멀티스레드 웹 서버에서 한 스레드는 HTTP 요청을 받아 작업 큐(버퍼)에 넣고(생산자), 다른 스레드 풀이 큐에서 작업을 꺼내 처리(소비자)하는 것이 대표적인 예다. grep foo file.txt | wc 와 같은 커맨드라인 파이프도 grep이 생산자, wc가 소비자가 되는 동일한 구조다.
핵심 과제는 다음과 같다.
버퍼가 가득 차 있으면, 생산자는 버퍼에 빈 공간이 생길 때까지 기다려야 한다.
버퍼가 비어 있으면, 소비자는 버퍼에 데이터가 채워질 때까지 기다려야 한다.
여러 생산자나 소비자가 동시에 버퍼에 접근하여 데이터가 깨지거나 유실되는 일이 없어야 한다. (상호 배제)
세마포어를 이용한 해결 과정의 진화:
초기 시도: 동기화 없는 접근 아무런 동기화 장치 없이
put()과get()을 호출하면 어떻게 될까? 생산자가 너무 빨라 소비자가 데이터를 가져가기도 전에 버퍼를 덮어쓰거나(V#0이V#5로 덮어써지는 문제), 여러 생산자가 동시에 같은 위치(buffer[fill])에 데이터를 쓰려는 경쟁 조건이 발생한다.Simply Call put() and get() 2단계: 조건 동기화 도입
empty(빈 버퍼 슬롯 수, 초기값 MAX)와full(채워진 슬롯 수, 초기값 0) 두 개의 세마포어를 도입하여 '기다리는' 문제를 해결할 수 있다. 생산자는sem_wait(&empty)를 호출해 빈 슬롯이 생길 때까지 기다리고, 소비자는sem_wait(&full)을 호출해 데이터가 채워지길 기다린다. 하지만 이 역시 여러 스레드가put()이나get()함수 내부의 코드(예:buffer[fill] = value; fill++;)를 동시에 실행하는 경쟁 조건은 해결하지 못한다.3단계: 상호 배제 추가, 그리고 교착 상태 경쟁 조건을 해결하기 위해
mutex(초기값 1) 세마포어를 추가하여put()과get()전체를 임계 구역으로 만들었다고 가정하자. 만약 코드를 다음과 같이 작성하면 치명적인 문제가 발생한다.// 잘못된 순서로 락을 사용하는 소비자 sem_wait(&mutex); // 1. 락을 먼저 잡는다. sem_wait(&full); // 2. 버퍼가 비어있으면 여기서 잠든다. // ...만약 버퍼가 비어있는 상황에서 소비자가 실행되어
mutex를 획득한 직후,sem_wait(&full)에서 잠들어버리면 어떻게 될까? 소비자는mutex를 쥔 채로 잠들기 때문에, 버퍼를 채워줄 생산자는mutex를 얻지 못해 영원히 대기하게 된다. 전형적인 교착 상태(Deadlock)이다. (이건 다음 시간의 주제!)올바른 해결책: 락 순서 변경 교착 상태를 피하려면, 임계 구역의 범위를 최소화하고 락을 잡는 순서를 변경해야 한다. 즉, 잠들 가능성이 있는
sem_wait(&empty)나sem_wait(&full)를 먼저 호출하고, 공유 버퍼에 접근하는 짧은 순간에만mutex를 사용해야 한다.// 생산자 (올바른 순서) sem_wait(&empty); // 1. 빈 공간이 있는지 확인 (잠들 수 있음) sem_wait(&mutex); // 2. 임계 구역 진입을 위해 락 획득 put(); sem_post(&mutex); sem_post(&full); // 3. 채워진 슬롯이 생겼다고 알림 // 소비자 (올바른 순서) sem_wait(&full); // 1. 채워진 공간이 있는지 확인 (잠들 수 있음) sem_wait(&mutex); // 2. 임계 구역 진입을 위해 락 획득 get(); sem_post(&mutex); sem_post(&empty); // 3. 빈 슬롯이 생겼다고 알림
조건 변수를 이용한 해결
조건 변수를 사용해서도 이 문제를 우아하게 해결할 수 있다. 세마포어의 empty, full과 유사한 역할을 하는 empty_cv, fill_cv 두 개의 조건 변수와, 버퍼의 상태를 나타내는 count 변수, 그리고 이들을 보호할 mutex가 필요하다.
// 조건 변수를 사용한 생산자
pthread_mutex_lock(&mutex);
while (count == MAX) { // 버퍼가 가득 찼으면
pthread_cond_wait(&empty_cv, &mutex); // empty_cv를 기다리며 잠든다.
}
put(i);
pthread_cond_signal(&fill_cv); // 버퍼가 채워졌다고 소비자에게 알린다.
pthread_mutex_unlock(&mutex);
while 루프를 사용함으로써 '스퓨리어스 웨이크업'에도 안전하게 동작하며, 세마포어 해결책과 동일한 동기화 로직을 구현할 수 있다.
3.2 읽기-쓰기 락 (Reader-Writer Locks)
읽기 작업은 데이터 구조를 변경하지 않으므로 여러 스레드가 동시에 수행해도 안전하다. 쓰기 작업은 배타적으로 수행되어야 한다. 이 특성을 활용해 동시성을 높이는 것이 읽기-쓰기 락이다.
구현: 세마포어와 카운터를 이용해 구현할 수 있다.
writelock세마포어로 쓰기 작업이나 첫 번째 읽기 작업을 막고,readers카운터로 현재 읽기 중인 스레드 수를 추적한다.첫 번째 리더: 읽기 락을 요청한 첫 번째 스레드는
writelock을 획득하여 이후의 쓰기 스레드 진입을 막는다. (읽는 동안에는 쓰기 접근 불가)마지막 리더: 읽기를 마친 마지막 스레드는
writelock을 해제하여 대기 중인 쓰기 스레드가 진입할 수 있도록 한다. (모든 읽기가 끝난 후에야 쓰기 가능)한계: 이 방식은 읽기 스레드가 계속 들어오는 경우 쓰기 스레드가 무한히 대기하는 기아(starvation) 상태를 유발할 수 있다.
3.3 식사하는 철학자들 문제 (The Dining Philosophers Problem)
5명의 철학자가 원탁에 앉아 있고, 각 철학자 사이에 포크가 하나씩 놓여있다. 식사를 하려면 양쪽 포크를 모두 집어야 한다.
교착 상태 발생: 모든 철학자가 동시에 자신의 왼쪽 포크를 집는다면 어떻게 될까? 각자 포크 하나씩을 들고 오른쪽 포크가 놓이기를 영원히 기다리는 순환 대기(circular wait)가 발생하여 교착 상태에 빠진다.
해결책: 의존성 깨뜨리기: 이 순환 고리를 깨는 것이 핵심이다. 간단한 해결책 중 하나는 철학자 중 한 명(예: 4번 철학자)만 다른 철학자들과 반대 순서로 포크를 집도록 하는 것이다. 즉, 다른 철학자들은
왼쪽 -> 오른쪽순으로 포크를 집지만, 4번 철학자만오른쪽 -> 왼쪽순으로 집게 한다. 이 작은 비대칭성이 순환 대기 조건을 깨뜨려 교착 상태를 방지한다.
결론
동기화 메커니즘을 학습하며 느낀 점은, 단순히 API를 아는 것을 넘어 각 메커니즘이 어떤 상황에 적합하고, 잘못 사용했을 때 어떤 미묘한 버그나 교착 상태를 유발할 수 있는지 깊이 이해하는 것이 중요하다는 것이다. 특히 생산자/소비자 문제나 식사하는 철학자들 같은 고전적인 문제들은 병행성 프로그래밍의 핵심적인 어려움과 해결 전략을 명확하게 보여주는 훌륭한 예시라는 생각이 들었다. 앞으로 시스템을 설계할 때 이러한 원리들을 기반으로 더 견고한 코드를 작성할 수 있도록 노력해야겠다.
추천글:
[운영체제] Lock | 단순 Mutual Exclusion을 위한 Test-And-Set부터 성능까지 챙긴 Futex까지
[개념정리] 운영체제 | Concurrency를 이해하기 위한 빌드업, 프로세스와 스레드
[운영체제] 운영체제란? | Virtualization, Concurrency, Persistence 핵심 개념 이해