Process Synchronization and Deadlock Management

1️⃣Mutexes and Semaphores
2️⃣Deadlock
3️⃣Practice Implementing Synchronization Techniques
1️⃣Mutexes and Semaphores
동기화 기법과 교착상태
오늘은 운영체제의 핵심 주제 중 하나인 동기화 기법과 교착상태에 대해 학습한다. 여러 프로세스가 동시에 실행되는 환경에서는 자원을 안전하게 공유하는 것이 매우 중요하다. 이번 강의에서는 프로세스 간의 실행을 조정하는 동기화 기법의 개념과 함께, 교착상태가 발생하는 원인과 그 해결 방법까지 함께 살펴본다.
이번 강의의 학습 목표는 다음과 같다. 먼저 뮤텍스(Mutex)와 세마포어(Semaphore)의 사용 방법과 두 기법의 차이점을 이해한다. 다음으로 생산자-소비자 문제(Producer-Consumer Problem)를 통해 동기화의 구조적 원리를 파악하고, 교착상태가 발생하는 조건을 이해한다. 마지막으로 뮤텍스와 세마포어를 직접 활용한 동기화 프로그램을 구현해보고, 실행 결과를 통해 실제 동작 방식을 확인해본다.
동기화가 필요한 이유? 헬스장 비유
동기화 개념을 이해하기 위해 먼저 일상적인 상황을 하나 떠올려보자. 헬스장에 러닝머신이 5대 있다고 가정한다. 회원들은 입장할 때마다 비어 있는 머신을 하나씩 골라 사용하게 된다.
여기서 두 가지 문제 상황을 생각해볼 수 있다. 첫째, 이미 누군가가 사용 중인 러닝머신에 다른 사람이 임의로 끼어들어 함께 사용하려 한다면 어떻게 될까? 당연히 두 사람 모두 제대로 운동을 할 수 없고, 최악의 경우 안전사고로 이어질 수 있다. 둘째, 5대가 모두 사용 중인 상황에서 새로운 회원이 들어왔다면 어떻게 해야 할까? 이 사람은 누군가가 머신을 다 쓰고 자리를 비울 때까지 기다리는 수밖에 없다.
이처럼 여러 사람이 동시에 한정된 자원을 사용하려 할 때, 아무런 규칙 없이 접근을 허용하면 충돌과 혼란이 발생한다. 운영체제에서도 마찬가지로, 여러 프로세스가 동시에 공유 자원에 접근하려 할 때 이를 조율하는 메커니즘이 반드시 필요하다. 이것이 바로 동기화가 필요한 근본적인 이유다.
교착상태란 무엇인가? 프린터와 스캐너 비유
이번에는 교착상태를 이해하기 위한 또 다른 상황을 살펴보자. 사무실에 프린터 1대와 스캐너 1대가 있다고 가정한다.
직원 A는 현재 프린터를 사용하고 있는 상태에서 스캔 작업도 함께 처리해야 하는 상황이다. 그래서 프린터를 점유한 채로 스캐너가 비워지기를 기다리고 있다. 한편 직원 B는 반대로 스캐너를 사용하고 있는 상태에서 출력 작업도 필요하여, 스캐너를 점유한 채로 프린터가 비워지기를 기다리고 있다.
결과적으로 직원 A는 프린터를 쥔 채 스캐너를 기다리고, 직원 B는 스캐너를 쥔 채 프린터를 기다리는 상황이 된다. 서로가 상대방이 점유한 자원을 기다리고 있기 때문에, 누구도 먼저 자원을 놓아주지 않는 한 두 작업 모두 영원히 진행되지 못한다. 이처럼 둘 이상의 주체가 서로 상대방의 자원을 기다리며 무한정 멈춰버리는 상태를 교착상태(Deadlock)라고 한다. 이번 강의에서는 이 교착상태가 왜 발생하는지, 그리고 어떻게 해결할 수 있는지를 중점적으로 다뤄볼 것이다.
뮤텍스(Mutex)와 임계영역(Critical Section)
뮤텍스(Mutex)와 임계영역(Critical Section) 앞서 살펴본 동시 접근 문제가 왜 발생하는지, 이제 뮤텍스(Mutex)와 세마포어(Semaphore)를 통해 본격적으로 알아보도록 하자. 여러 실행 흐름이 동시에 접근할 경우 문제가 생길 수 있는 코드 영역을 임계영역(Critical Section)이라고 한다. 이 임계영역을 보호하기 위해 사용하는 동기화 기법이 바로 뮤텍스(Mutex)이다. 뮤텍스는 여러 실행 흐름이 임계영역에 동시에 접근하지 못하도록, 한 번에 하나의 실행 흐름만 진입할 수 있도록 제한하는 역할을 한다. 따라서 여러 스레드(Thread)가 동시에 실행되더라도, 임계영역 안에서는 항상 단 하나의 스레드만 작업을 수행하게 된다. 여기서 중요한 점은, 뮤텍스(Mutex)의 목적이 자원의 개수를 관리하는 것이 아니라 접근 자체를 제어하는 것에 있다는 점이다. 뮤텍스(Mutex)는 잠금(Lock)과 해제(Unlock) 방식으로 동작한다. 잠금(Lock)이 설정되어 있는 동안에는 다른 스레드(Thread)가 해당 임계영역(Critical Section)에 진입할 수 없으며, 잠금(Lock)이 해제(Unlock)되어야만 다음 스레드(Thread)가 임계영역(Critical Section)에 진입할 수 있게 된다.
뮤텍스(Mutex)의 활용; 공유 데이터 보호
뮤텍스(Mutex)는 공유 데이터를 수정하는 구간을 보호할 때 사용된다. 여러 스레드(Thread)가 동일한 데이터를 동시에 변경하려 한다면 데이터 불일치(Data Inconsistency) 문제가 발생한다. 예를 들어 전역변수나 인덱스처럼 여러 실행 흐름이 함께 사용하는 데이터에 뮤텍스(Mutex)를 적용하게 된다.
특히 주의해야 할 경우는, 값을 읽고 → 수정하고 → 다시 저장하는 것처럼 하나의 연산이 여러 단계로 이루어지는 경우이다. 이때는 각 단계를 따로 보호하는 것이 아니라, 이 전체 구간을 하나의 묶음으로 보호해야 한다. 만약 이 구간이 보호되지 않으면, 중간에 다른 실행 흐름이 끼어들어 의도하지 않은 결과가 발생할 수 있기 때문이다.
여러 단계로 이루어진 연산이 외부에서 보았을 때 중단 없이 하나의 단위로 실행되어야 하는 성질을 원자성(Atomicity)이라고 하며, 뮤텍스(Mutex)는 이 원자성을 보장하기 위한 대표적인 수단이다. 또한 여러 스레드(Thread)가 동시에 공유 데이터에 접근하면서 발생하는 데이터 불일치(Data Inconsistency) 상황을 경쟁 조건(Race Condition)이라는 용어로도 표현한다.
뮤텍스(Mutex)의 동작 구조; Lock과 Unlock
뮤텍스(Mutex)는 Lock과 Unlock을 이용해 임계영역(Critical Section)을 보호하는 방식으로 동작한다.
먼저 Lock을 호출하면 잠금(Lock)을 획득한 경우에만 임계영역(Critical Section)에 접근이 가능하다. 만약 이미 다른 실행 흐름이 잠금(Lock)을 가지고 있는 경우에는 Lock을 호출한 지점에서 대기하게 된다. 임계영역(Critical Section)의 작업이 끝나면 Unlock을 호출하여 잠금을 해제(Unlock)하고, 그다음 실행 흐름이 임계영역(Critical Section)에 진입할 수 있도록 한다. 이처럼 Lock과 Unlock으로 임계영역(Critical Section)을 감싸는 구조를 통해 동시에 하나의 실행 흐름만 접근할 수 있도록 제어하게 된다.
이 구조를 코드로 살펴보면, 반복문 안에서 lock(mutex)를 호출하여 임계영역(Critical Section) 진입을 시도한다. 여기서 뮤텍스(Mutex) 변수란 실제로 잠그고 해제하는 대상이 되는 변수이며, Lock이란 해당 뮤텍스(Mutex)에 대해 잠금을 거는 동작이다. 이미 다른 실행 흐름이 이 뮤텍스(Mutex)를 점유하고 있는 경우에는 Lock을 호출하는 지점에서 대기하게 된다. 이후 shared_data++와 같이 공유 데이터를 수정하는 임계영역(Critical Section) 작업을 수행한 뒤, unlock(mutex)를 호출하여 동일한 뮤텍스(Mutex)에 대한 잠금을 해제(Unlock)하고 다음 실행 흐름이 진입할 수 있도록 한다. 즉 **Lock과 Unlock 사이에 위치한 구간이 임계영역(Critical Section)**이 되며, 이 구조를 통해 공유 데이터 수정 구간을 안전하게 보호하게 된다. Unlock을 호출하지 않고 임계영역(Critical Section)을 빠져나가게 되면 다른 실행 흐름이 영원히 대기 상태에 머무르게 되므로, Lock을 획득한 실행 흐름은 반드시 Unlock을 호출해야 한다.
뮤텍스(Mutex)의 설계 특징
뮤텍스(Mutex)는 상호 배제(Mutual Exclusion)를 위한 동기화(Synchronization) 기법으로, 여기서 핵심은 임계영역(Critical Section)의 범위를 어떻게 설정하느냐이다.
기본적으로 공유 데이터를 실제로 수정하는 구간만 임계영역(Critical Section)으로 설정하면 된다. 임계영역(Critical Section)의 범위를 잘못 설정하게 되면 동작 결과가 달라지기 때문에 이 범위 설정은 매우 중요하다. 임계영역(Critical Section)을 너무 넓게 설정하면 불필요한 구간까지 잠기게 되면서 불필요한 대기와 성능 저하가 발생할 수 있다. 반대로 임계영역(Critical Section)이 너무 좁으면 공유 데이터가 제대로 보호되지 않아 데이터 무결성(Data Integrity)이 깨질 수 있다.
참고로 임계영역(Critical Section)을 넓게 잡을수록 동시에 처리할 수 있는 작업의 양이 줄어드는데, 이를 병렬성(Parallelism) 저하라고 표현하기도 한다.
다음으로 중요한 점은 Lock과 Unlock은 반드시 쌍으로 사용해야 한다는 것이다. Lock만 수행하고 Unlock을 하지 않으면 다른 실행 흐름이 임계영역(Critical Section)에 영원히 진입하지 못한 채 계속 대기하게 되는 문제가 발생하기 때문이다. 교착상태(Deadlock)라고 한다.
세마포어(Semaphore) 초기값의 의미
세마포어(Semaphore)의 기본 개념과 값이 의미하는 바를 바탕으로, 이번에는 이 값을 실제로 어떻게 활용하는지 중심으로 살펴본다.
세마포어(Semaphore)는 정수형 변수 S로 표현하며, 이 값은 동시에 사용할 수 있는 자원의 개수를 의미한다. 따라서 초기값을 어떻게 설정하느냐에 따라 동작 방식이 달라지므로, 이 점을 이해하는 것이 중요하다.
S = 1인 경우, 하나의 실행 흐름만 자원에 접근할 수 있기 때문에 임계영역(Critical Section) 보호를 위한 이진 세마포어(Binary Semaphore)로 사용된다. S = N인 경우, 동시에 N개의 실행 흐름이 자원을 사용할 수 있기 때문에 여러 개의 자원을 동시에 관리하는 계수 세마포어(Counting Semaphore)로 사용된다. S = 0인 경우, 현재 사용할 수 있는 자원이 없다는 의미이므로 Wait 연산을 수행하는 실행 흐름은 대기 상태로 들어가게 된다.
세마포어(Semaphore)의 동작 과정
여러 프로세스(Process)가 동일한 자원을 동시에 요청하는 상황을 생각해보자. 사용 가능한 자원이 남아 있는 경우, 일부 프로세스(Process)는 자원을 획득하여 바로 실행을 진행하게 된다. 반면 자원이 모두 사용 중인 경우, 나머지 프로세스(Process)들은 자원을 획득하지 못하고 대기 상태로 들어가게 된다.
이때 세마포어(Semaphore) 값 S가 현재 사용 가능한 자원의 개수를 나타내며, 이 S값을 기준으로 실행을 계속할 수 있는 프로세스(Process)와 대기해야 하는 프로세스(Process)가 결정된다. 즉 S값이 0보다 크면 자원을 획득하고 실행을 이어갈 수 있으며, S값이 0이면 사용 가능한 자원이 없다는 의미이므로 해당 프로세스(Process)는 대기 상태로 진입하게 된다.
이진 세마포어(Binary Semaphore)와 계수 세마포어(Counting Semaphore)의 차이
이진 세마포어(Binary Semaphore)와 계수 세마포어(Counting Semaphore)는 둘 다 세마포어(Semaphore)이지만, 관리하는 대상과 목적이 서로 다르다.
먼저 이진 세마포어(Binary Semaphore)는 값의 범위가 0 또는 1, 즉 사용 가능하거나 사용 불가능하거나 두 가지 상태만 표현한다. 따라서 한 번에 하나의 실행 흐름만 진입할 수 있도록 제어할 때 적합하다. 임계영역(Critical Section)처럼 동시에 둘 이상이 진입하면 안 되는 구간에서 이진 세마포어(Binary Semaphore)를 사용하여 출입을 하나로 제한할 수 있으며, 이 때문에 뮤텍스(Mutex)와 유사한 상호 배제(Mutual Exclusion) 구조로 이해하면 된다.
반면 계수 세마포어(Counting Semaphore)의 값의 범위는 0 이상의 정수로, 자원이 여러 개 있을 때 현재 몇 개까지 사용 가능한지를 숫자로 관리한다. 예를 들어 동일한 프린터가 3대 있다면 최대 3개의 실행 흐름까지 동시에 자원을 사용할 수 있다는 의미이다. 이 경우 하나의 실행 흐름만 허용하는 것이 아니라, 정해진 개수만큼 동시에 접근할 수 있도록 제어하는 것이 핵심이다.
정리하자면, 이진 세마포어(Binary Semaphore)는 출입 자체를 하나로 제한하는 것에 초점이 있고, 계수 세마포어(Counting Semaphore)는 사용 가능한 자원의 개수를 세면서 관리하는 것에 초점이 있다. 이름은 비슷하지만 실제 목적과 동작 구조는 분명히 다르다.
앞서 언급했듯 뮤텍스(Mutex)는 잠금을 건 실행 흐름만이 해제(Unlock)할 수 있는 소유권(Ownership) 개념이 존재하는 반면, 이진 세마포어(Binary Semaphore)는 잠금을 걸지 않은 실행 흐름도 Signal 연산을 통해 해제할 수 있다는 점에서 구조적 차이도 있다.
계수 세마포어(Counting Semaphore)의 동작 구조
계수 세마포어(Counting Semaphore)는 여러 개의 동일한 자원을 여러 실행 흐름이 나누어 사용하는 상황에서 활용된다. 세마포어(Semaphore) 값 S는 현재 사용 가능한 자원의 개수를 나타내며, 이 값에 따라 실행 흐름이 자원을 획득하거나 대기 상태로 전환하게 된다.
즉 계수 세마포어(Counting Semaphore)는 여러 실행 흐름이 자원을 경쟁하는 상황에서, 자원의 사용을 순서대로 처리할 수 있도록 제어하는 구조라고 정리할 수 있다.
세마포어(Semaphore)를 이용한 실행 순서 제어
지금까지는 여러 실행 흐름이 동시에 자원에 접근하는 것을 막는 것에 초점을 두었다면, 이번에는 어떤 실행 흐름이 먼저 실행되어야 하는지를 제어하는 것이 핵심이다.
예를 들어 프로세스1(Process1)이 먼저 작업을 수행한 뒤 Signal 연산을 호출하면, 프로세스2(Process2)는 Wait 연산에서 대기하다가 Signal이 발생하는 순간 실행을 이어가게 된다. 즉 프로세스2(Process2)는 임의로 실행되는 것이 아니라, 프로세스1(Process1)의 작업이 끝난 이후에 실행되도록 순서가 연결되어 있는 것이다. 이처럼 세마포어(Semaphore)는 Wait와 Signal 연산을 이용하여 프로세스(Process) 간의 실행 순서를 명확하게 제어할 수 있다.
조건 동기화(Condition Synchronization)
지금까지 세마포어(Semaphore)를 이용한 실행 순서 제어를 살펴보았다면, 이번에는 실행 순서가 아닌 특정 조건이 만족되는 시점에서 실행을 제어하는 방식, 즉 조건 동기화(Condition Synchronization)를 살펴본다.
조건 동기화(Condition Synchronization)는 실행 흐름이 어떤 작업을 수행하기 전에 현재 자원의 상태나 조건이 만족되는지를 먼저 확인한다. 조건이 만족된 경우에만 실행을 진행하고, 충족되지 않는 경우에는 대기 상태로 들어가게 된다. 이후 상태가 변화하여 조건이 만족되면 그때 비로소 실행이 이어지게 된다.
여기서 중요한 점은, 조건 동기화(Condition Synchronization)란 자원을 보호하는 것이 아니라 언제 실행할 것인가를 제어하는 것에 목적이 있다는 점이다. 이는 상호 배제(Mutual Exclusion)와는 별도의 개념으로, 보호가 아닌 실행 시점 제어로 이해해야 한다.
예를 들어 버퍼(Buffer)가 비어 있는 경우, 소비자(Consumer)는 바로 실행하지 못하고 대기 상태에 머무른다. 버퍼(Buffer)에 데이터가 채워졌을 때에만 실행이 가능해지는 것, 이것이 바로 조건 동기화(Condition Synchronization)이다.
상태 기반 제어 필요성
그렇다면 실제로 어떤 상황에서 조건 동기화(Condition Synchronization)가 필요한지 살펴보자.
먼저 자원이 가득 찬 상태에서는 더 이상 데이터를 추가할 수 없다. 반대로 자원이 비어 있는 상태에서는 데이터를 꺼낼 수가 없다. 즉 어떤 작업을 수행할 수 있는지는 현재 자원의 상태에 따라 결정되며, 실행을 무조건 진행하는 것이 아니라 조건이 만족될 때만 수행하도록 제어해야 한다. 이처럼 자원의 상태를 기준으로 실행 여부를 결정하는 구조가 필요하게 된다. 만약 이러한 제어가 없다면 어떤 문제가 발생하는지는 이후에 구체적으로 살펴볼 것이다.
📌 이 상황은 앞서 언급한 생산자-소비자 문제(Producer-Consumer Problem)와 직접적으로 연결된다. 자원이 가득 찬 상태에서 데이터를 추가하려는 생산자(Producer)와, 자원이 비어 있는 상태에서 데이터를 꺼내려는 소비자(Consumer) 모두 조건이 만족될 때까지 대기해야 하며, 이 제어가 없을 경우 데이터 손실이나 잘못된 메모리 접근과 같은 심각한 문제로 이어질 수 있다.
바쁜 대기(Busy Waiting)의 문제점
바쁜 대기(Busy Waiting)의 문제점 이 예제는 생산자(Producer)와 소비자(Consumer)가 자원의 상태를 직접 확인하며 동작하는 구조이다. 코드를 살펴보면, 생산자(Producer) 부분에서는 while (counter == BUFFER_SIZE); 구문을 통해 버퍼(Buffer)가 가득 찬 상태인지를 계속 확인하고 있다. 소비자(Consumer) 부분에서는 while (counter == 0); 구문을 통해 버퍼(Buffer)가 비어 있는지를 계속 반복해서 확인하고 있다. 이처럼 조건이 만족될 때까지 아무런 작업도 하지 않고 계속 반복하며 상태를 확인하는 구조를 **바쁜 대기(Busy Waiting)**라고 한다. 이 방식은 조건이 충족되지 않는 동안에도 CPU가 쉬지 않고 계속 반복문을 실행하기 때문에, CPU 시간을 낭비하는 비효율적인 동작 방식이다.
📌바쁜 대기(Busy Waiting)는 스핀락(Spinlock)이라고도 불리며, 대기 시간이 매우 짧을 것으로 예상되는 경우에는 문맥 교환(Context Switching) 비용을 피할 수 있어 오히려 유리한 상황도 존재한다. 그러나 일반적인 경우에는 세마포어(Semaphore)나 조건 변수(Condition Variable)를 활용하여 대기 상태의 실행 흐름을 슬립(Sleep) 상태로 전환함으로써 CPU 자원을 보다 효율적으로 사용하는 것이 권장된다.
생산자-소비자 문제(Producer-Consumer Problem)의 구조
생산자-소비자 문제(Producer-Consumer Problem)는 하나의 공유 버퍼(Buffer)를 중심으로 이루어진다. 이 버퍼(Buffer)의 크기는 제한되어 있기 때문에 무한정 데이터를 넣거나 꺼낼 수 없다.
생산자(Producer)와 소비자(Consumer)는 이 버퍼(Buffer)에 동시에 접근하려 하는데, 이때 버퍼(Buffer)의 상태에 따라 가능한 작업이 달라지게 된다. 버퍼(Buffer)가 가득 찬 상태에서는 생산자(Producer)가 더 이상 데이터를 넣을 수 없고, 버퍼(Buffer)가 비어 있는 상태에서는 소비자(Consumer)가 데이터를 꺼낼 수 없다.
즉 이 문제의 핵심은 단순한 자원 접근이 아니라, 현재 버퍼(Buffer)의 상태에 따라 실행 여부가 결정되는 구조라는 점이다.
📌 생산자-소비자 문제(Producer-Consumer Problem)에서는 버퍼(Buffer)의 상태 제어 외에도 생산자(Producer)와 소비자(Consumer)가 동시에 버퍼(Buffer)에 접근할 때 발생하는 경쟁 조건(Race Condition) 역시 함께 해결해야 한다. 따라서 실제 구현에서는 조건 동기화(Condition Synchronization)와 함께 뮤텍스(Mutex) 또는 이진 세마포어(Binary Semaphore)를 통한 상호 배제(Mutual Exclusion)도 동시에 적용해야 한다.
원형 버퍼(Circular Buffer)와 생산자-소비자 구조
그림을 보면 하나의 원형 버퍼(Circular Buffer)를 기준으로 생산자(Producer)와 소비자(Consumer)가 동시에 접근하고 있다. 생산자(Producer)는 데이터를 버퍼(Buffer)에 삽입하고, 소비자(Consumer)는 데이터를 버퍼(Buffer)에서 꺼내게 된다. 이때 삽입 위치와 제거 위치를 각각 나타내는 포인터(Pointer)가 원형 구조를 따라 이동하게 된다.
여기서 중요한 점은, 이 두 작업이 독립적으로 보이지만 실제로는 버퍼(Buffer)라는 하나의 자원을 공유하고 있다는 것이다. 따라서 버퍼(Buffer)가 가득 찬 경우에는 생산자(Producer)의 작업이 멈추어야 하고, 버퍼(Buffer)가 비어 있는 경우에는 소비자(Consumer)의 작업이 멈추어야 한다. 결국 이 구조에서는 동시 접근 제어와 상태 기반 제어(State-based Control) 가 모두 필요하다는 점이 문제의 핵심이다.
📌 원형 버퍼(Circular Buffer)에서 삽입 위치를 나타내는 포인터를 쓰기 포인터(Write Pointer) 또는 in 포인터, 제거 위치를 나타내는 포인터를 제거 포인터 또는 out 포인터라고 부른다. 두 포인터가 같은 위치를 가리키는 경우 버퍼(Buffer)가 비어 있음을 의미하며, 쓰기 포인터(Write Pointer)가 읽기 포인터(Read Pointer)보다 한 칸 뒤에 위치하는 경우 버퍼(Buffer)가 가득 찬 상태를 의미한다.
상호 배제(Mutual Exclusion)가 없는 경우의 문제점
조건이 지켜지지 않으면 다음과 같은 문제들이 발생하게 된다.
먼저 생산자(Producer)와 소비자(Consumer)가 동시에 실행되면서 in/out 인덱스(Index)가 서로 충돌할 수 있다. 또한 카운터(Counter) 값을 동시에 수정하게 되면 증가와 감소 과정이 엇갈리면서 잘못된 값이 저장될 수 있다. 더 나아가 동일한 위치에 데이터가 덮어쓰기(Overwrite)되거나, 아직 처리되지 않은 데이터가 사라지는 문제도 발생할 수 있다.
이러한 문제들의 근본적인 원인은 하나의 연산이 여러 단계로 나누어 실행되는 동안 다른 실행 흐름이 끼어들었기 때문이다. 이 실행 단계들이 서로 교차되면서 의도하지 않은 결과가 발생하게 된다.
📌 여기서 설명하는 상황이 앞서 언급한 **경쟁 조건(Race Condition)**의 전형적인 사례이다. 특히 카운터(Counter) 값의 동시 수정 문제는
counter++와 같이 단순해 보이는 연산도 내부적으로는 읽기(Read) → 수정(Modify) → 쓰기(Write)의 세 단계로 이루어지기 때문에 발생한다. 이 세 단계가 원자적(Atomic)으로 처리되지 않으면, 그 사이에 다른 실행 흐름이 끼어들어 데이터 불일치(Data Inconsistency)가 발생할 수 있다.
상호 배제(Mutual Exclusion)가 없는 경우; 동기화의 필요성
지난 시간에 살펴보았듯이, counter++나 counter-- 연산은 하나의 명령어처럼 보이지만 실제로는 읽기(Read) → 수정(Modify) → 쓰기(Write)의 여러 단계로 나뉘어 실행된다. 그렇기 때문에 실행 흐름이 서로 섞이게 되면 값의 오류가 발생할 수 있다. 이 문제를 해결하기 위해 필요한 것이 바로 **동기화(Synchronization)**이다.
📌
counter++와 같은 연산이 여러 단계로 나뉘어 실행됨에도 하나의 명령어처럼 보이는 이유는 고수준 언어(High-level Language)에서 단일 구문으로 표현되기 때문이다. 그러나 실제 CPU가 처리하는 저수준(Low-level) 명령어 단계에서는 여러 단계로 분리되며, 이 사이에 다른 실행 흐름이 끼어들 수 있다. 이를 해결하는 동기화(Synchronization) 기법으로는 앞서 살펴본 뮤텍스(Mutex)와 세마포어(Semaphore)가 대표적이다. 여기서 저수준(Low-level)은 C, 어셈블리 같은 언어를 지칭하는 것이 아니라, CPU가 내부적으로 명령어를 처리하는 방식을 설명하는 표현이니 헷갈리지 말자.
정상 설계 구조; 동기화 설계의 핵심
그렇다면 동기화를 어떻게 구성해야 할까? 생산자-소비자 문제(Producer-Consumer Problem)에서는 공유 자원에 대한 접근 제어와 실행 조건 제어를 함께 다룰 수 있도록 역할을 나누어 설계해야 한다. 여기서는 다음 세 가지를 분리하여 사용하게 된다.
첫째, 뮤텍스(Mutex)는 버퍼(Buffer)에 대한 접근을 제어하여 동시에 하나의 실행 흐름만 접근할 수 있도록 한다. 둘째, empty는 비어 있는 버퍼(Buffer)의 개수를 나타내며 데이터를 삽입할 수 있는 조건을 제어한다. 셋째, full은 채워진 버퍼(Buffer)의 개수를 나타내며 데이터를 꺼낼 수 있는 조건을 제어한다.
즉 뮤텍스(Mutex)는 동시 접근을 막는 역할을 담당하고, empty와 full은 조건이 맞을 때만 실행되도록 하는 역할을 담당한다. 이처럼 상호 배제(Mutual Exclusion)와 조건 동기화(Condition Synchronization)를 분리하여 설계하는 것이 생산자-소비자 문제(Producer-Consumer Problem)에서 동기화 설계의 핵심이다.
📌
empty와full은 계수 세마포어(Counting Semaphore)로 구현되며, 일반적으로 버퍼(Buffer)의 크기가 N일 때empty의 초기값은 N,full의 초기값은 0으로 설정한다. 생산자(Producer)가 데이터를 삽입할 때마다empty는 감소하고full은 증가하며, 소비자(Consumer)가 데이터를 꺼낼 때마다 그 반대로 동작한다.
2️⃣ 교착상태(Deadlock)
교착상태(Deadlock)와 다중 자원 환경(Multi-resource Environment)
이번에는 여러 자원을 동시에 사용하는 환경에서 실행 흐름이 서로 얽히는 문제, 즉 **교착상태(Deadlock)**에 대해 살펴본다.
운영체제(Operating System)에서는 하나의 자원만 사용하는 것이 아니라 CPU, 메모리(Memory), 파일(File), 입출력 장치(I/O Device) 등 여러 자원이 함께 사용된다. 하나의 프로세스(Process)도 작업을 수행하는 동안 이 자원들을 한 번에 모두 사용하는 것이 아니라, 필요한 시점마다 순차적으로 요청하게 된다.
문제는 여러 프로세스(Process)가 동시에 실행될 때 발생한다. 각 프로세스(Process)가 비슷한 자원을 필요로 하다 보니, 결국 동일한 자원을 두고 서로 경쟁하는 상황이 발생하게 된다. 즉 시스템은 단순히 자원을 사용하는 구조가 아니라, 여러 자원이 서로 얽혀 있고 그 안에서 프로세스(Process)들이 경쟁하는 복잡한 구조를 가지게 된다.
**📌**프로세스(Process)가 자원을 사용하는 일반적인 순서는 요청(Request) → 사용(Use) → 해제(Release) 의 세 단계로 이루어진다. 교착상태(Deadlock)는 이 과정에서 여러 프로세스(Process)가 서로 상대방이 점유한 자원의 해제(Release)를 기다리며 무한정 대기하는 상황을 의미한다.
자원 요청 순서와 교착상태(Deadlock)의 발생 구조
프로세스(Process)마다 자원을 요청하는 순서는 서로 다를 수 있다. 예를 들어 어떤 프로세스(Process)는 먼저 CPU를 사용한 뒤 파일(File)을 요청하고, 다른 프로세스(Process)는 파일(File)을 먼저 사용한 뒤 CPU를 요청할 수도 있다.
이처럼 요청 순서가 다르면 이미 하나의 자원을 점유한 상태에서 다른 자원을 기다리는 상황이 발생할 수 있다. 문제는 이런 상황이 여러 프로세스(Process)에서 동시에 일어난다면, 자원에 대한 대기 상태가 서로 얽히게 된다는 점이다. 즉 단순한 자원 경쟁을 넘어서 프로세스(Process)들이 서로를 기다리는 구조, 다시 말해 교착상태(Deadlock)가 만들어질 수 있다.
**📌**이처럼 프로세스(Process)들이 서로를 기다리는 순환 구조를 **순환 대기(Circular Wait)**라고 하며, 이는 교착상태(Deadlock)가 발생하기 위한 네 가지 필요 조건 중 하나이다. 나머지 세 가지 조건인 상호 배제(Mutual Exclusion), 점유와 대기(Hold and Wait), 비선점(Non-preemption)과 함께 모두 충족될 때 교착상태(Deadlock)가 발생하게 된다.
교착상태(Deadlock)의 발생 구조; 프로세스 간 자원 점유와 대기
그림을 통해 교착상태(Deadlock)의 발생 흐름을 구체적으로 살펴보자.
프로세스A(Process A)는 스캐너(Scanner)를 할당받은 상태에서 CD 레코더(CD Recorder)를 추가로 요청하고 있다. 반대로 프로세스B(Process B)는 CD 레코더(CD Recorder)를 먼저 점유한 상태에서 스캐너(Scanner)를 요청하고 있다. 즉 각각 하나의 자원을 이미 점유한 상태에서 서로가 가지고 있는 다른 자원을 기다리고 있는 구조가 된다.
이처럼 자원을 하나씩 나누어 가진 상태에서 추가 자원을 요청하게 되면 프로세스(Process) 간의 대기 관계가 서로 얽히게 된다. 결국 이런 구조가 만들어지면 각 프로세스(Process)는 상대방이 자원을 놓아줄 때까지 기다리게 되고, 전체 실행이 멈추는 교착상태(Deadlock) 로 이어지게 된다.
📌 이 상황은 교착상태(Deadlock)의 네 가지 필요 조건 중 **점유와 대기(Hold and Wait)**와 **순환 대기(Circular Wait)**가 동시에 나타나는 전형적인 사례이다. 어느 한 프로세스(Process)도 먼저 자원을 자발적으로 놓아주지 않는 한 교착상태(Deadlock)는 스스로 해소되지 않게된다.
교착상태(Deadlock)의 정의
여러 프로세스(Process)가 서로의 자원을 기다리면서 더 이상 실행을 진행하지 못하는 상태를 교착상태(Deadlock)라고 한다. 각 프로세스(Process)는 이미 하나의 자원을 확보한 상태에서 추가 자원을 요청하는데, 그 자원이 다른 프로세스(Process)에 의해 점유되어 있으면 자신이 가지고 있는 자원을 놓지 못한 채 대기하게 된다. 결국 서로가 상대방의 자원 반납을 기다리는 순환적인 대기 구조가 만들어지게 되고, 이 상태에서는 누구도 다음 단계로 진행할 수 없는 완전한 교착상태(Deadlock)에 빠지게 된다.
📌 교착상태(Deadlock)가 발생하기 위해서는 다음 네 가지 조건이 동시에 충족되어야 한다. 첫째 상호 배제(Mutual Exclusion), 둘째 점유와 대기(Hold and Wait), 셋째 비선점(Non-preemption), 넷째 순환 대기(Circular Wait)이다. 이 중 하나라도 충족되지 않으면 교착상태(Deadlock)는 발생하지 않으며, 이 점이 교착상태(Deadlock) 예방 전략의 핵심 원리가 된다.
교착상태(Deadlock)의 전형적인 예제; Wait와 Signal 구조
다음 예제를 통해 교착상태(Deadlock)가 실제로 어떻게 발생하는지 살펴보자.
프로세스P1(Process P1)은 먼저 wait(S)를 통해 자원 S를 확보한 뒤, 추가로 자원 Q를 요청한다. 프로세스P2(Process P2)는 반대로 wait(Q)를 통해 자원 Q를 먼저 확보한 뒤, 자원 S를 추가로 요청한다.
문제는 이 두 프로세스(Process)가 동시에 실행될 때 발생한다. 프로세스P1(Process P1)은 S를 가진 채 Q를 기다리고, 프로세스P2(Process P2)는 Q를 가진 채 S를 기다리는 상황이 만들어진다. 이 경우 프로세스P1(Process P1)은 Q가 없어 더 이상 진행할 수 없고, 프로세스P2(Process P2)도 S가 없어 더 이상 진행할 수 없는 상태가 된다.
더 나아가 signal(Q)와 signal(S)와 같은 자원 반납 코드는 자신의 작업이 완료된 이후에야 실행될 수 있는데, 현재 두 프로세스(Process) 모두 Wait 상태에서 멈춰 있기 때문에 반납 코드까지 도달하지 못한다. 결국 자원은 반납되지 않고 두 프로세스(Process)는 서로를 기다리기만 하게 되는데, 이 구조가 바로 교착상태(Deadlock)의 전형적인 사례이다.
📌이 예제에서는 교착상태(Deadlock)의 네 가지 필요 조건이 모두 충족되어 있다. 자원 S와 Q는 동시에 둘 이상이 사용할 수 없으므로 상호 배제(Mutual Exclusion), 각 프로세스(Process)가 자원을 점유한 채 추가 자원을 기다리므로 점유와 대기(Hold and Wait), 점유된 자원을 강제로 빼앗을 수 없으므로 비선점(Non-preemption), 그리고 P1→Q→P2→S→P1으로 이어지는 **순환 대기(Circular Wait)**가 모두 성립한다.
교착상태(Deadlock)의 발생 조건
교착상태(Deadlock)는 아무 상황에서나 발생하는 것이 아니다. 특정 조건들이 동시에 만족될 때만 발생하며, 운영체제(Operating System)에서는 이 조건을 다음 네 가지로 정리한다. 상호 배제(Mutual Exclusion), 점유와 대기(Hold and Wait), 비선점(Non-preemption), 순환 대기(Circular Wait)이다. 여기서 중요한 점은, 이 네 가지 중 일부만 성립한다고 해서 곧바로 교착상태(Deadlock)가 발생하는 것은 아니라는 것이다. 반드시 네 가지 조건이 동시에 모두 성립해야만 프로세스(Process)들이 서로 자원을 기다리며 실행이 멈추는 교착상태(Deadlock)가 만들어질 수 있다. 즉 이 네 가지 조건은 교착상태(Deadlock)가 발생하는 구조를 분석하는 기준으로 이해하면 된다.
📌이 네 가지 조건은 1971년 E. W. Dijkstra가 아닌 E. G. Coffman이 정리한 것으로, 코프만 조건(Coffman Conditions)이라고도 부른다. 보완하자면, 이 네 가지 조건은 교착상태(Deadlock) 분석의 기준이 될 뿐만 아니라 교착상태 예방(Deadlock Prevention) 전략의 핵심 근거가 되기도 한다. 네 가지 조건 중 하나라도 성립하지 않도록 설계하면 교착상태(Deadlock) 자체를 원천적으로 막을 수 있기 때문이다.
교착상태(Deadlock)의 형성 흐름
교착상태(Deadlock)는 한순간에 갑자기 발생하는 것이 아니라 일정한 흐름을 거쳐 형성된다. 먼저 여러 프로세스(Process) 간의 자원 경쟁이 발생하고, 이후 자원을 점유한 채 추가 자원을 기다리는 상황이 만들어진다. 그 대기 관계가 **순환 구조(Circular Wait)**로 이어질 때, 최종적으로 교착상태(Deadlock)가 발생하게 된다. 각 단계의 구체적인 의미는 이어서 자세히 살펴볼 것이다.
교착상태(Deadlock) 발생 조건 1; 상호 배제(Mutual Exclusion)
첫 번째 조건은 **상호 배제(Mutual Exclusion)**이다. 이는 하나의 자원을 동시에 하나의 프로세스(Process)에서만 사용할 수 있는 경우를 의미한다. 즉 어떤 프로세스(Process)가 자원을 사용하고 있다면, 다른 프로세스(Process)는 그 자원이 해제될 때까지 기다려야 한다.
여기서 중요한 점은 모든 자원이 이런 성질을 가지는 것은 아니라는 것이다. 예를 들어 읽기 전용(Read-only) 파일처럼 여러 프로세스(Process)가 함께 사용할 수 있는 자원도 있다. 반면 프린터(Printer), 스캐너(Scanner), 테이프 드라이브(Tape Drive)처럼 한 번에 하나의 프로세스(Process)만 사용해야 하는 장치는 대표적인 상호 배제(Mutual Exclusion) 자원이다.
이런 자원이 존재하면 프로세스(Process)들은 자원을 두고 기다릴 수밖에 없으며, 그 기다림이 교착상태(Deadlock)의 출발점이 된다. 즉 상호 배제(Mutual Exclusion)는 단순히 혼자만 사용한다는 의미가 아니라, 다른 프로세스(Process)를 대기 상태로 만드는 구조를 형성한다는 점에서 교착상태(Deadlock)의 첫 번째 필요 조건이 된다.
**📌**상호 배제(Mutual Exclusion) 조건은 자원의 특성상 제거하기 어려운 경우가 많다. 프린터(Printer)나 스캐너(Scanner)와 같은 장치는 본질적으로 동시 사용이 불가능하기 때문에, 이 조건을 없애는 방식으로 교착상태(Deadlock)를 예방하는 것은 현실적으로 쉽지 않다.
교착상태(Deadlock) 발생 조건 2; 점유와 대기(Hold and Wait)
두 번째 조건은 **점유와 대기(Hold and Wait)**이다. 이는 프로세스(Process)가 하나 이상의 자원을 확보한 상태에서 추가 자원을 요청하는 상황을 의미한다. 즉 이미 자원을 가지고 있으면서 그 자원을 내려놓지 않은 채 다른 자원을 계속 요청하는 구조이다.
문제는 요청한 자원이 즉시 할당되지 않으면 현재 점유하고 있는 자원을 그대로 유지한 채 대기 상태에 머무르게 된다는 점이다. 이 상태가 여러 프로세스(Process)에서 동시에 발생하게 된다면, 각자가 자원을 쥔 채 서로가 가진 자원을 기다리는 구조가 만들어진다. 이처럼 자원을 점유한 채 추가 자원을 기다리는 구조가 교착상태(Deadlock)의 두 번째 필요 조건이 된다.
📌 점유와 대기(Hold and Wait) 조건을 예방하는 대표적인 방법으로는 두 가지가 있다. 첫째는 프로세스(Process)가 실행 전에 필요한 모든 자원을 한꺼번에 요청하는 방식이고, 둘째는 추가 자원이 필요할 경우 현재 점유 중인 자원을 모두 반납한 뒤 다시 요청하는 방식이다. 다만 두 방법 모두 자원 활용의 효율성이 떨어질 수 있다는 단점이 있다.
교착상태(Deadlock) 발생 조건 3; 비선점(Non-preemption)
세 번째 조건은 **비선점(Non-preemption)**이다. 이는 이미 할당된 자원을 외부에서 강제로 빼앗을 수 없다는 의미로, 운영체제(Operating System)는 자원을 사용 중인 프로세스(Process)로부터 임의로 자원을 회수할 수 없다. 따라서 자원을 사용하고 있는 프로세스(Process)가 작업을 끝내고 스스로 반납해야만 다른 프로세스(Process)가 그 자원을 사용할 수 있다.
문제는 해당 프로세스(Process)가 다른 자원을 기다리고 있는 상황에서는 자원을 쥔 채 계속 멈춰 있게 된다는 점이다. 예를 들어 프린터(Printer)를 사용 중인 작업은 중간에 빼앗을 수 없기 때문에 작업이 끝날 때까지 다른 프로세스(Process)들은 기다려야 한다. 이처럼 자원을 강제로 회수할 수 없다는 점이 대기 상태를 해소하지 못하게 만드는 조건이 된다.
📌 비선점(Non-preemption) 조건을 제거하는 방법으로는 프로세스(Process)가 새로운 자원을 요청할 때 즉시 할당받을 수 없다면 현재 점유 중인 모든 자원을 강제로 반납하게 하는 방식이 있다. 다만 이 방식은 프린터(Printer)처럼 작업이 중단되면 처음부터 다시 시작해야 하는 장치에는 적용하기 어렵다는 현실적인 한계가 있다.
교착상태(Deadlock) 발생 조건 4; 순환 대기(Circular Wait)
네 번째 조건은 **순환 대기(Circular Wait)**이다. 이는 프로세스(Process)들이 서로가 점유한 자원을 기다리는 관계가 원형 구조로 연결된 상태를 의미한다. 즉 한 프로세스(Process)는 다른 프로세스(Process)가 가진 자원을 기다리고, 그 프로세스(Process)는 또 다른 프로세스(Process)의 자원을 기다리는 식으로 이 관계가 끊어지지 않고 계속 이어지는 구조이다.
이때 이 순환 구조에서 한 곳이라도 끊기면 문제가 해결되지만, 원형으로 완전히 연결되어 있다면 누구도 진행할 수 없는 상태가 된다. 결국 이러한 순환 구조가 형성되는 순간 교착상태(Deadlock)가 만들어질 수 있다.
📌 순환 대기(Circular Wait) 조건을 예방하는 대표적인 방법으로는 자원에 순서를 부여하는 방식이 있다. 모든 프로세스(Process)가 자원을 반드시 정해진 순서대로만 요청하도록 강제하면 순환 구조 자체가 형성될 수 없기 때문이다. 이는 앞서 살펴본 네 가지 조건 중 현실적으로 가장 제어하기 쉬운 조건으로 알려져 있다.
순환 대기(Circular Wait)의 그래프 표현
앞서 살펴보았듯이 프로세스(Process)들이 서로 상대방의 자원을 기다리게 되면 대기 관계가 서로 연결되는 구조가 만들어진다. 이 대기 관계를 그래프로 표현하면 원형 구조가 나타나게 되는데, 이처럼 대기 관계가 원형으로 이어지는 구조를 순환 대기(Circular Wait)라고 한다. 이 순환 대기(Circular Wait)는 교착상태(Deadlock)가 발생하기 위한 핵심 조건이 된다.
📌 이처럼 프로세스(Process)와 자원의 대기 관계를 그래프로 표현한 것을 자원 할당 그래프(Resource Allocation Graph)라고 한다. 이 그래프에서 프로세스(Process)는 원(Circle)으로, 자원(Resource)은 사각형(Rectangle)으로 표현되며, 그래프 내에 사이클(Cycle)이 존재할 경우 교착상태(Deadlock)가 발생했거나 발생 가능성이 있다는 것을 시각적으로 확인할 수 있다.
자원 할당 그래프(Resource Allocation Graph)의 개념
앞서 살펴보았듯이 프로세스(Process) 간의 대기 관계가 서로 얽히게 되면 순환 구조가 만들어질 수 있다는 것을 확인하였다. 그렇다면 이런 대기 구조가 실제로 형성되었는지, 또 교착상태(Deadlock)가 발생했는지를 어떻게 확인할 수 있을까? 이때 사용하는 것이 바로 자원 할당 그래프(Resource Allocation Graph)이다. 자원 할당 그래프(Resource Allocation Graph)는 프로세스(Process)와 자원(Resource) 사이의 관계를 그림으로 나타내어, 누가 무엇을 기다리고 있고 이미 무엇을 가지고 있는지를 한눈에 파악하기 위한 도구이다. 특히 이 관계를 통해 대기 관계가 서로 얽혀 있는지, 즉 순환 관계(Circular Wait)가 존재하는지도 분석할 수 있게 된다. 따라서 자원 할당 그래프(Resource Allocation Graph)는 교착상태(Deadlock)의 발생 여부를 판단하기 위한 핵심 도구가 된다.
자원 할당 그래프(Resource Allocation Graph)의 구성 요소
자원 할당 그래프(Resource Allocation Graph)의 구성 요소를 살펴보면 다음과 같다. 프로세스(Process)는 원(Circle) 형태로 표현되고, 자원(Resource)은 사각형(Rectangle) 으로 표현된다. 즉 형태만 보아도 실행 주체인지 자원인지 바로 구분이 가능하도록 설계되어 있다. 이 둘을 연결하는 간선(Edge)은 방향에 따라 의미가 달라진다. 프로세스(Process)에서 자원(Resource)으로 향하는 요청 간선(Request Edge)은 해당 프로세스(Process)가 그 자원(Resource)을 원하고 있다는 뜻으로, 아직 자원을 받지 못한 채 기다리거나 요청 중인 상태를 나타낸다. 반대로 자원(Resource)에서 프로세스(Process)로 향하는 할당 간선(Assignment Edge)은 해당 자원(Resource)이 현재 프로세스(Process)에게 배정되어 있다는 뜻으로, 이미 사용 중인 상태임을 나타낸다.
📌 자원(Resource)을 나타내는 사각형 내부에는 자원의 인스턴스(Instance) 개수만큼 점(Dot)으로 표시하는 것이 일반적이다. 예를 들어 동일한 프린터(Printer)가 2대 있다면 사각형 안에 점이 2개 표시되며, 이를 통해 해당 자원(Resource)이 동시에 몇 개의 프로세스(Process)에 할당될 수 있는지를 한눈에 파악할 수 있게된다.
자원 할당 그래프(Resource Allocation Graph)의 간선 변화
자원 할당 그래프(Resource Allocation Graph)에서 간선(Edge)이 어떻게 만들어지는지 예제를 통해 살펴보자.
먼저 프로세스(Process)가 자원(Resource)을 요청하게 되면, 프로세스(Process)에서 자원(Resource)으로 향하는 **요청 간선(Request Edge)**이 생성된다. 이 상태는 아직 자원(Resource)을 받은 것이 아니라 해당 자원(Resource)을 기다리고 있는 상태를 의미한다. 그 다음으로 자원(Resource)이 실제로 프로세스(Process)에게 할당되면 간선의 방향이 바뀌어 자원(Resource)에서 프로세스(Process)로 향하는 **할당 간선(Assignment Edge)**이 생성된다.
즉 요청 상태에서는 프로세스(Process) → 자원(Resource) 방향이었다가, 할당이 이루어지면 자원(Resource) → 프로세스(Process) 방향으로 바뀌게 된다. 이처럼 간선의 방향 변화만으로도 현재 상태가 요청 중인지 이미 사용 중인지를 명확하게 구분할 수 있기 때문에, 자원 할당 그래프(Resource Allocation Graph)에서 간선의 방향은 상태를 나타내는 핵심 요소가 된다.
📌 프로세스(Process)가 자원(Resource) 사용을 마치고 반납하게 되면 할당 간선(Assignment Edge) 자체가 제거된다. 즉 간선의 방향이 다시 바뀌는 것이 아니라 간선이 완전히 사라지게 되며, 이를 통해 해당 자원(Resource)이 다시 사용 가능한 상태가 되었음을 그래프에서 확인할 수 있다.
자원 할당 그래프(Resource Allocation Graph)의 사이클(Cycle)과 교착상태(Deadlock)
자원 할당 그래프(Resource Allocation Graph)에서 가장 중요한 것은 사이클(Cycle), 즉 순환 구조가 형성되는지 여부이다. 프로세스(Process)와 자원(Resource) 사이의 요청 관계와 할당 관계가 연결되면서 하나의 원형태로 이어지게 되면 사이클(Cycle)이 형성된 상태가 된다.
이 사이클(Cycle) 안에서는 각 프로세스(Process)가 다른 프로세스(Process)가 점유한 자원(Resource)을 기다리는 구조가 만들어지게 되는데, 누구도 먼저 실행을 끝내지 못하고 자원(Resource)을 반납하지 못하는 상태가 된다. 이처럼 서로가 서로를 기다리는 구조가 만들어지게 되면 교착상태(Deadlock)가 발생할 수 있으며, 자원 할당 그래프(Resource Allocation Graph)에서 이런 사이클(Cycle)이 존재하는지 확인하는 것이 교착상태(Deadlock)를 판단하는 핵심 기준이 된다.
**📌**사이클(Cycle)이 존재한다고 해서 반드시 교착상태(Deadlock)가 발생한 것은 아니다. 자원(Resource)의 인스턴스(Instance)가 하나뿐인 경우에는 사이클(Cycle) 존재 자체가 교착상태(Deadlock)를 의미하지만, 자원(Resource)의 인스턴스(Instance)가 여러 개인 경우에는 사이클(Cycle)이 존재하더라도 교착상태(Deadlock)가 발생하지 않을 수 있다. 따라서 사이클(Cycle)은 교착상태(Deadlock)의 필요 조건이지 충분 조건은 아님을 알고있자.
자원 할당 그래프(Resource Allocation Graph)에서의 교착상태(Deadlock) 분석
다음 그림을 기준으로 자원 할당 상태를 하나씩 해석해보자.
먼저 프로세스P1(Process P1)을 보면 자원R2(Resource R2)를 하나 점유한 상태에서 자원R1(Resource R1)을 추가로 요청하고 있다. 이는 하나의 자원을 가진 상태에서 다른 자원을 기다리고 있는 구조이다. 다음으로 프로세스P2(Process P2)를 보면 자원R1(Resource R1)과 자원R2(Resource R2)를 하나씩 점유하고 있으면서 자원R3(Resource R3)를 추가로 요청하고 있다. 이미 여러 자원을 확보한 상황에서 또 다른 자원을 기다리고 있는 상황이다. 프로세스P3(Process P3)의 경우에는 자원R3(Resource R3)를 하나 점유하고 있지만 추가적인 요청은 없는 상황이다.
전체적인 관계를 살펴보면, 프로세스P1(Process P1)은 자원R1(Resource R1)을 기다리고 있고, 자원R1(Resource R1)은 프로세스P2(Process P2)에 할당되어 있다. 프로세스P2(Process P2)는 자원R3(Resource R3)를 기다리고 있고, 자원R3(Resource R3)는 프로세스P3(Process P3)에 할당되어 있는 구조이다. 즉 현재 대기 관계가 이어지고 있긴 하지만 완전한 순환 구조를 이루고 있지는 않은 상태이다. 따라서 이 상태는 교착상태(Deadlock)가 아니라 교착상태(Deadlock)로 진행될 가능성이 있는 중간 단계로 볼 수 있다.
📌 이처럼 교착상태(Deadlock)는 아니지만 자원 부족으로 인해 특정 프로세스(Process)가 계속해서 자원을 할당받지 못하는 상태를 **기아 상태(Starvation)**라고 한다. 현재 그림의 상태에서 프로세스P3(Process P3)가 자원R3(Resource R3)를 오랫동안 반납하지 않는다면, 프로세스P2(Process P2)가 자원R3(Resource R3)를 계속 기다리게 되는 기아 상태(Starvation)로 이어질 수 있다.
사이클(Cycle)이 존재하지만 교착상태(Deadlock)가 아닌 경우
이 그림은 사이클(Cycle)이 존재하지만 반드시 교착상태(Deadlock)라고는 볼 수 없는 경우이다.
흐름을 살펴보면 프로세스P1(Process P1) → 자원R1(Resource R1) → 자원R3(Resource R3) → 자원R2(Resource R2) → 프로세스P2(Process P2) → 프로세스P1(Process P1)으로 이어지는 사이클(Cycle)을 확인할 수 있다. 겉으로 보면 순환 구조가 존재하기 때문에 교착상태(Deadlock)처럼 보일 수 있다.
하지만 여기서 중요한 차이점은 자원의 인스턴스(Instance)가 여러 개 존재한다는 점이다. 예를 들어 자원R1(Resource R1)이나 자원R2(Resource R2)에 여러 개의 인스턴스(Instance)가 있다면, 대기 중인 프로세스(Process) 중 일부는 추가로 자원을 할당받을 수 있다. 따라서 모든 프로세스(Process)가 동시에 막히는 것이 아니라 일부는 계속 진행할 수 있고, 그 과정에서 자원이 반납되면 다른 프로세스(Process)들도 다시 실행될 수 있게 되어 대기 구조가 풀리게 된다.
즉 사이클(Cycle)의 존재만으로 항상 교착상태(Deadlock)라고 판단할 수는 없으며, 자원의 인스턴스(Instance) 개수를 함께 고려해야 교착상태(Deadlock) 여부를 정확하게 판단할 수 있다.
📌 자원(Resource)의 인스턴스(Instance)가 하나뿐인 경우에는 사이클(Cycle) 존재 자체가 교착상태(Deadlock)를 의미하지만, 인스턴스(Instance)가 여러 개인 경우에는 사이클(Cycle)이 존재하더라도 교착상태(Deadlock)가 발생하지 않을 수 있다. 따라서 사이클(Cycle)은 교착상태(Deadlock)의 필요 조건이지 충분 조건은 아니다.
교착상태(Deadlock) 처리 방법
교착상태(Deadlock)가 발생하게 되면 프로세스(Process)들이 서로 자원을 기다리면서 전체 시스템의 실행이 멈출 수 있다. 이런 상황이 계속 진행되면 시스템 자원이 낭비되거나 서비스가 중단되는 문제가 발생할 수 있어, 교착상태(Deadlock)를 예방하거나 발생 이후에도 적절히 처리할 수 있는 방법이 필요하다.
교착상태(Deadlock)는 한 가지 방법으로만 해결하는 것이 아니라 여러 가지 접근 방식으로 다룰 수 있으며, 시스템의 설계에 따라 적용하는 방법도 달라지게 된다. 교착상태(Deadlock)를 처리하는 방법은 크게 네 가지로 나뉜다.
첫째, **예방(Prevention)**은 교착상태(Deadlock)가 아예 발생하지 않도록 조건을 미리 제거하는 방식이다. 둘째, **회피(Avoidance)**는 현재 상태를 고려하면서 교착상태(Deadlock)로 이어지지 않도록 자원을 할당하는 방식이다. 셋째, **탐지(Detection)**는 교착상태(Deadlock) 발생을 허용한 뒤 이를 찾아내는 방식이다. 넷째, **복구(Recovery)**는 탐지된 이후 문제를 해결하는 방식이다.
즉 교착상태(Deadlock)는 발생 전에 막을 수도 있고, 발생 가능성을 피할 수도 있으며, 발생 후에 찾아서 해결할 수도 있는 여러 가지 전략이 존재한다. 각각의 방법에 대한 구체적인 내용은 이어서 살펴볼 것이다.
교착상태 예방(Deadlock Prevention)
교착상태 예방(Deadlock Prevention)은 교착상태(Deadlock)가 아예 발생하지 않도록 구조 자체를 바꾸는 방법이다. 앞서 살펴본 네 가지 조건 중 하나라도 성립하지 않도록 한다면 교착상태(Deadlock)는 발생할 수 없다. 따라서 시스템을 설계할 때부터 이 조건들이 동시에 만족되지 않도록 제한을 두게 된다.
예를 들어 프로세스(Process)가 필요한 자원을 모두 한 번에 요청하도록 한다면, 자원을 하나 점유한 채 다른 자원을 기다리는 점유와 대기(Hold and Wait) 상황을 막을 수 있다. 또는 자원 요청 순서를 미리 정해놓으면 서로가 서로를 기다리는 순환 대기(Circular Wait) 구조 자체가 만들어지지 않도록 할 수도 있다.
즉 교착상태 예방(Deadlock Prevention)은 실행 중에 해결하는 것이 아니라, 애초에 교착상태(Deadlock)가 발생할 수 없도록 설계 단계에서 차단하는 방식이다.
**📌**교착상태 예방(Deadlock Prevention)은 근본적인 해결책이 될 수 있지만 현실적인 한계도 존재한다. 모든 자원을 한 번에 요청하는 방식은 실제로 필요하지 않은 자원까지 미리 점유하게 되어 **자원 활용의 효율성(Resource Utilization)**이 크게 떨어질 수 있다. 또한 자원 요청 순서를 미리 고정하는 방식은 프로세스(Process)의 유연성을 제한할 수 있다는 단점이 있다.
교착상태 회피(Deadlock Avoidance)
교착상태 회피(Deadlock Avoidance)는 자원을 할당할 때마다 교착상태(Deadlock)로 이어질 가능성이 있는지를 미리 확인하는 방식이다. 즉 요청이 들어왔다고 해서 바로 자원을 할당하는 것이 아니라, 이 자원을 할당했을 때 시스템이 안전한 상태(Safe State)를 유지할 수 있는지를 먼저 판단하는 것이다. 여기서 말하는 안전한 상태(Safe State)란 모든 프로세스(Process)가 결국 작업을 끝낼 수 있는 실행 순서가 존재하는 상태를 의미한다. 만약 어떤 자원 할당이 안전한 상태(Safe State)를 깨뜨릴 가능성이 있다면 그 요청은 일단 보류하게 된다. 대표적인 방법으로는 뱅커스 알고리즘(Banker's Algorithm)이 있다. 뱅커스 알고리즘(Banker's Algorithm)은 항상 시스템이 안전한 상태(Safe State)를 유지하는 경우에만 자원을 할당하도록 설계되어 있다. 즉 교착상태 회피(Deadlock Avoidance)는 교착상태(Deadlock) 발생을 완전히 막는 것은 아니지만, 위험한 상태로 가지 않도록 미리 판단하고 조절하는 방식이라고 이해하면 된다.
📌 안전한 상태(Safe State)와 반대되는 개념으로 불안전한 상태(Unsafe State)가 있다. 불안전한 상태(Unsafe State)가 반드시 교착상태(Deadlock)를 의미하는 것은 아니지만, 교착상태(Deadlock)로 이어질 가능성이 있는 상태를 의미한다. 또한 뱅커스 알고리즘(Banker's Algorithm)은 1965년 E. W. Dijkstra가 고안한 알고리즘으로, 은행이 대출 가능 금액을 관리하는 방식에서 착안하여 이름이 붙여졌다.
교착상태 탐지(Deadlock Detection)
교착상태 탐지(Deadlock Detection)는 교착상태(Deadlock)의 발생 자체를 막는 것이 아니라, 일단 발생을 허용한 뒤 이를 찾아내는 방식이다. 시스템은 자원을 자유롭게 할당하면서 운영되지만, 주기적으로 현재 상태를 검사하여 교착상태(Deadlock)가 발생했는지를 확인하게 된다.
이때 자원 할당 그래프(Resource Allocation Graph)를 이용하여 프로세스(Process) 간의 대기 관계를 분석하고, 특히 그래프에서 사이클(Cycle)이 존재하는지를 확인하여 교착상태(Deadlock) 여부를 판단할 수 있다. 즉 교착상태 탐지(Deadlock Detection)는 예방하거나 회피하는 것이 아니라, 발생 이후에 이를 발견하는 방식이라고 이해하면 된다.
📌 교착상태 탐지(Deadlock Detection)는 자원을 자유롭게 할당하기 때문에 예방(Prevention)이나 회피(Avoidance)에 비해 **자원 활용의 효율성(Resource Utilization)**이 높다는 장점이 있다. 그러나 탐지 자체에도 시스템 자원이 소모되므로 탐지 주기를 너무 짧게 설정하면 오버헤드(Overhead)가 커질 수 있고, 너무 길게 설정하면 교착상태(Deadlock)가 오랫동안 지속될 수 있다는 단점이 있다.
교착상태 복구(Deadlock Recovery)
교착상태 복구(Deadlock Recovery)는 교착상태(Deadlock)가 실제로 발생한 이후에 이를 해소하는 방법이다. 즉 이미 프로세스(Process)들이 서로 기다리며 멈춰 있는 상태를 강제로 풀어야 하는 상황이다.
가장 직접적인 방법은 **프로세스 종료(Process Termination)**이다. 문제가 되는 프로세스(Process)를 하나씩 중단시킴으로써 자원이 해제되면서 대기 구조가 깨지는 방식이다. 또 다른 방법은 **자원 강제 회수(Resource Preemption)**이다. 특정 프로세스(Process)가 사용 중인 자원을 강제로 빼앗아 다른 프로세스(Process)에게 할당함으로써 교착상태(Deadlock)를 해소하는 방법이다. 또는 일부 프로세스(Process)를 선택적으로 중단시켜 전체 대기 구조를 끊는 방법도 사용된다.
즉 교착상태 복구(Deadlock Recovery)는 자연스럽게 해결되기를 기다리는 것이 아니라, 시스템이 직접 개입하여 강제적으로 교착 구조를 깨는 방식이라고 이해하면 된다.
**📌**프로세스 종료(Process Termination) 방식에는 두 가지 세부 전략이 있다. 교착상태(Deadlock)에 관련된 모든 프로세스(Process)를 한꺼번에 종료하는 방식과, 프로세스(Process)를 하나씩 종료하면서 교착상태(Deadlock)가 해소되었는지 확인하는 방식이다. 또한 자원 강제 회수(Resource Preemption) 시에는 어떤 프로세스(Process)의 자원을 빼앗을지 결정하는 희생자 선택(Victim Selection) 문제와, 자원을 빼앗긴 프로세스(Process)가 계속해서 희생자로 선택되는 기아 상태(Starvation) 문제도 함께 고려해야 한다.
3️⃣Practice Implementing Synchronization Techniques
뮤텍스(Mutex) 기반 동기화 실습; 경쟁 상태(Race Condition) 확인
본격적인 실습에 들어가기 전에, 지난 시간에 살펴보았던 경쟁 상태(Race Condition)를 실제 코드로 확인해볼 것이다.
여러 스레드(Thread)가 동시에 실행되면서 하나의 공유 자원(Shared Resource)에 접근하게 되면 실행 결과가 예상하는 값과 다르게 나올 수 있다. 이처럼 실행 순서에 따라 결과가 달라지는 현상을 **경쟁 상태(Race Condition)**라고 한다.
이번 실습에서는 먼저 뮤텍스(Mutex)를 사용하지 않은 상태에서 어떤 문제가 발생하는지 확인하고, 이후 뮤텍스(Mutex)를 적용하여 문제가 어떻게 해결되는지 비교해볼 것이다.
**📌**경쟁 상태(Race Condition)는 실행할 때마다 결과가 달라질 수 있기 때문에 디버깅(Debugging)이 매우 어렵다는 특징이 있다. 특정 환경에서는 문제없이 동작하다가 다른 환경에서는 오류가 발생하는 경우도 있어, 실습을 통해 직접 확인하는 것이 개념을 이해하는 데 효과적이다.
뮤텍스(Mutex) 미적용 코드; 경쟁 상태(Race Condition) 발생 확인
이 코드는 뮤텍스(Mutex)를 사용하지 않고 두 개의 스레드(Thread)가 전역변수 카운터(Counter)를 동시에 증가시키는 예제이다.
겉보기에는 각 스레드(Thread)가 for(int i = 0; i < 100000; i++) 구문을 통해 10만 번씩 증가시키므로 결과가 20만이 되어야 할 것처럼 보인다. 하지만 실행 결과는 매번 달라질 수 있다. 이는 count++ 증가 연산이 원자적(Atomic)으로 수행되지 않아 중간에 다른 스레드(Thread)가 끼어들 수 있기 때문이다. 즉 공유 변수(Shared Variable)에 대한 동시 접근으로 인해 **경쟁 상태(Race Condition)**가 발생한다는 것을 이 코드를 통해 확인할 수 있다.
📌
count++연산은 내부적으로 읽기(Read) → 수정(Modify) → 쓰기(Write)의 세 단계로 처리되는데, 두 스레드(Thread)가 동시에 같은 값을 읽어 각각 증가시킨 뒤 저장하면 한 번의 증가만 반영되는 갱신 손실(Lost Update) 문제가 발생한다. 이것이 최종 결과가 20만보다 작게 나오는 원인이 된다.
실습 결과; 경쟁 상태(Race Condition) 확인
실행 환경에 따라 겉으로는 정상적인 20만이 계속 나오는 경우가 있다. 이는 두 스레드(Thread)의 실행이 크게 겹치지 않고 한 스레드(Thread)가 대부분의 작업을 먼저 실행한 후 다른 스레드(Thread)가 실행되는 경우가 있기 때문이다. 또한 시스템의 부하가 낮거나 반복 횟수가 상대적으로 적은 경우에는 충돌이 눈에 띄게 발생하지 않을 수도 있다.
그러나 이런 경우에도 코드 자체가 항상 올바른 결과를 보장하지는 않는다. 반복 횟수를 계속 늘리면 예상했던 정상값이 아닌 더 낮은 값이 나오게 될 것이다. 이는 공유 변수(Shared Variable)에 대한 접근을 보호하지 않기 때문에 실행 순서에 따라 결과가 달라지는 **경쟁 상태(Race Condition)**가 발생할 수 있다는 점을 이 코드를 통해 확인할 수 있다.
📌 이처럼 경쟁 상태(Race Condition)는 항상 눈에 보이는 오류를 발생시키는 것이 아니라 특정 조건에서만 나타나는 경우가 많아 비결정적(Non-deterministic) 버그라고도 불립니다. 이런 특성 때문에 테스트 환경에서는 정상적으로 동작하다가 실제 운영 환경에서 예상치 못한 오류가 발생하는 경우가 생길 수 있어, 처음부터 뮤텍스(Mutex)와 같은 동기화(Synchronization) 기법을 적용하여 설계하는 것이 중요하다.
뮤텍스(Mutex) 적용; 경쟁 상태(Race Condition) 해결
앞선 예제에서 확인한 것처럼 여러 스레드(Thread)가 하나의 공유 자원(Shared Resource)에 동시에 접근하게 되면 실행 결과가 일정하지 않다는 것을 확인하였다. 이런 문제를 막기 위해서는 공유 자원(Shared Resource)에 대한 접근을 제어하는 동기화(Synchronization)가 필요하다.
이 문제를 해결하기 위해 뮤텍스(Mutex)를 사용해보도록 하자. 뮤텍스(Mutex)는 한 번에 하나의 실행 흐름만 임계영역(Critical Section)에 들어가도록 제한하는 방식으로, 여러 스레드(Thread)가 동시에 접근하면 문제가 되는 코드 영역을 하나의 스레드(Thread)만 실행하도록 보호하는 역할을 한다. 이때 임계영역(Critical Section)이란 여러 실행 흐름이 동시에 접근하면 문제가 발생하는 코드 구간을 의미한다. 뮤텍스(Mutex)는 Lock과 Unlock을 이용하여 이 임계영역(Critical Section)을 감싸는 방식으로 동작하게 된다.
**📌**뮤텍스(Mutex)를 적용하면 앞서 발생했던 갱신 손실(Lost Update) 문제가 해결되어 두 스레드(Thread)가 각각 10만 번씩 증가시킨 결과가 정확히 20만으로 나오게 된다. 다만 뮤텍스(Mutex)를 적용하면 한 번에 하나의 스레드(Thread)만 임계영역(Critical Section)에 진입할 수 있기 때문에, 미적용 상태에 비해 실행 시간이 다소 길어질 수 있다는 점도 함께 이해하면 좋다.
뮤텍스(Mutex) 구현; pthread 함수
실제 구현에서는 pthread(POSIX Thread)가 제공하는 뮤텍스(Mutex) 함수를 사용한다. 주요 함수는 다음 세 가지이다. 먼저 pthread_mutex_init()은 뮤텍스(Mutex)를 초기화하는 함수이다. 다음으로 pthread_mutex_lock()은 뮤텍스(Mutex)를 잠그는 함수로, 잠금(Lock)을 획득한 경우에만 임계영역(Critical Section)에 진입할 수 있다. 마지막으로 pthread_mutex_unlock()은 잠금을 해제하는 함수로, 다른 스레드(Thread)가 임계영역(Critical Section)에 들어갈 수 있도록 해준다. 이 세 가지 함수를 이용하여 임계영역(Critical Section)을 보호하게 된다.
📌 pthread(POSIX Thread)는 주로 C언어 기반의 Unix/Linux 환경에서 사용되는 스레드(Thread) 표준 인터페이스이다. 위 세 가지 함수 외에도 뮤텍스(Mutex) 사용이 끝난 후 자원을 해제하는 pthread_mutex_destroy() 함수도 함께 사용하는 것이 일반적이다. 초기화(Init)와 해제(Destroy)를 쌍으로 사용하는 것은 Lock과 Unlock을 쌍으로 사용하는 것만큼 중요하다.
뮤텍스(Mutex) 적용 코드 분석
코드를 살펴보면 다음과 같은 구조로 동작한다. 먼저 pthread_mutex_t m;으로 뮤텍스(Mutex) 변수를 선언하고, pthread_mutex_init(&m, NULL);을 통해 초기화를 수행한다. 그 다음 work 함수 안에서 공유 변수인 카운터(Counter)를 증가시키는 부분을 살펴보면, pthread_mutex_lock(&m);으로 뮤텍스(Mutex) Lock을 호출한 뒤 count++ 증가 연산을 수행하고, 곧바로 pthread_mutex_unlock(&m);으로 뮤텍스(Mutex) Unlock을 호출하고 있다. 즉 카운터(Counter)를 수정하는 구간만을 임계영역(Critical Section)으로 설정하여 보호하고 있는 것이다. 이렇게 하면 한 스레드(Thread)가 카운터(Counter)를 증가시키는 동안에는 다른 스레드(Thread)가 해당 구간에 진입할 수 없게 되어, 실행 순서가 섞이지 않고 항상 카운터(Counter) 값이 정확하게 나오는 것을 확인할 수 있다.
📌 이 코드에서 임계영역(Critical Section)을 count++ 연산만으로 최소화한 것은 올바른 설계이다. 앞서 배운 것처럼 임계영역(Critical Section)을 필요 이상으로 넓게 설정하면 불필요한 대기와 성능 저하가 발생할 수 있기 때문이다. 또한 실습 이후에는
pthread_mutex_destroy(&m);을 호출하여 뮤텍스(Mutex) 자원을 해제하는 것이 올바른 마무리이다.
뮤텍스(Mutex) 적용 코드 실습 결과
전역변수 int count = 0;은 두 스레드(Thread)가 함께 사용하는 공유 자원(Shared Resource)이고, pthread_mutex_t m;을 통해 이를 보호할 준비를 한다. main 함수에서는 mutex_init으로 뮤텍스(Mutex)를 초기화한 뒤 pthread_create를 통해 두 개의 스레드(Thread)를 생성하고 있다. 이 코드의 핵심은 work 함수이다. 반복문 안에서 카운터(Counter)를 증가시키기 전에 pthread_mutex_lock(&m);을 호출하고, count++ 수행 후에는 pthread_mutex_unlock(&m);을 호출하고 있다. 이처럼 카운터(Counter)를 수정하는 구간을 Lock과 Unlock으로 감싸 임계영역(Critical Section)으로 만들고 있다. 이렇게 하면 한 스레드(Thread)가 임계영역(Critical Section)을 수행하는 동안 다른 스레드(Thread)는 Lock을 얻지 못하고 대기하게 된다. 그 결과 실행 순서에 관계없이 결과가 항상 일정하게 유지되며, 경쟁 상태(Race Condition)가 발생하지 않는다.
📌 뮤텍스(Mutex) 적용 전후의 실행 시간을 비교해보면 뮤텍스(Mutex) 적용 후 실행 시간이 다소 길어지는 것을 확인할 수 있다. 이는 Lock과 Unlock 과정에서 발생하는 오버헤드(Overhead)와 대기 중인 스레드(Thread)가 순서를 기다리는 시간이 추가되기 때문이다. 즉 뮤텍스(Mutex)는 정확성을 보장하는 대신 성능의 일부를 희생하는 트레이드오프(Trade-off)가 존재한다.
세마포어(Semaphore) 기반 동기화 실습
뮤텍스(Mutex)와 달리 세마포어(Semaphore)는 실행 흐름의 순서를 제어하는 데도 사용할 수 있다. 즉 어떤 작업이 먼저 끝난 뒤에 다른 작업이 실행되도록 만들 수 있으며, 조건이 만족되지 않으면 해당 실행 흐름을 대기시키고 조건이 충족되었을 때 실행을 이어갈 수 있다.
지난 시간에는 세마포어(Semaphore)가 어떤 방식으로 동작하는지 개념을 중심으로 학습하였다면, 이번에는 실제 코드에서 세마포어(Semaphore)가 어떻게 사용되는지를 중심으로 살펴볼 것이다.
📌 뮤텍스(Mutex)와 세마포어(Semaphore)의 핵심적인 차이를 다시 한번 정리하면, 뮤텍스(Mutex)는 상호 배제(Mutual Exclusion), 즉 공유 자원(Shared Resource)에 대한 동시 접근을 막는 것이 주된 목적인 반면, 세마포어(Semaphore)는 이에 더해 실행 순서 제어와 **조건 동기화(Condition Synchronization)**까지 활용할 수 있다는 점에서 보다 넓은 범위의 동기화(Synchronization) 기법이라고 할 수 있다.
세마포어(Semaphore) 구현
기본 함수 세마포어(Semaphore)는 정수값을 기반으로 실행 흐름의 접근이나 실행 순서를 제어하는 동기화(Synchronization) 기법이다. 실제 구현에서는 Wait/Signal 대신 sem_wait와 sem_post 함수를 사용한다. 세마포어(Semaphore)를 사용할 때는 다음 세 가지 기본 함수를 사용하게 된다. 먼저 sem_init()은 세마포어(Semaphore)를 초기화하는 함수이다. sem_wait()는 세마포어(Semaphore)의 값을 감소시키면서 조건에 맞지 않으면 실행 흐름을 대기 상태로 전환한다. sem_post()는 세마포어(Semaphore)의 값을 증가시키면서 대기 중인 실행 흐름이 있다면 다시 실행되도록 한다. 이 세 가지 함수를 통해 실행 흐름의 순서를 제어할 수 있다.
📌뮤텍스(Mutex)의
pthread_mutex_destroy()와 마찬가지로 세마포어(Semaphore) 사용이 끝난 후에는sem_destroy()함수를 호출하여 세마포어(Semaphore) 자원을 해제하는 것이 올바른 마무리이다. 또한sem_wait()는 세마포어(Semaphore) 값이 0인 경우 즉시 대기 상태로 진입하며,sem_post()가 호출되어 값이 증가할 때까지 대기하게 된다.
세마포어(Semaphore)를 이용한 실행 순서 제어 예제
이 예제는 세마포어(Semaphore)를 이용해 두 스레드(Thread)의 실행 순서를 제어하는 구조이다. sem_init(&sem, 0, 0);에서 세마포어(Semaphore) 값을 0으로 설정하게 되면, sem_wait(&sem;)을 호출한 스레드(Thread)는 처음에 바로 실행되지 못하고 대기 상태로 진입하게 된다. 이후 스레드1(Thread 1)이 작업을 수행한 뒤 sem_post(&sem;)를 호출하는 순간, 대기하고 있던 스레드2(Thread 2)가 깨어나게 된다. 따라서 스레드2(Thread 2)는 스레드1(Thread 1)의 작업이 끝난 이후에만 실행될 수 있다. 실행 결과를 보면 스레드1(Thread 1)의 작업이 먼저 완료된 다음에 스레드2(Thread 2)가 실행되는 순서가 항상 유지된다. 즉 이 예제의 핵심은 세마포어(Semaphore)를 활용하여 스레드(Thread)의 실행 순서를 명확하게 제어할 수 있고, 그 결과 실행 흐름이 항상 일정하게 유지된다는 점이다.
📌 이 예제에서 세마포어(Semaphore)의 초기값을 0으로 설정하는 것이 핵심이다. 초기값이 0이기 때문에 스레드2(Thread 2)가
sem_wait()를 호출하는 순간 즉시 대기 상태로 진입하게 되며, 스레드1(Thread 1)이sem_post()를 호출하기 전까지는 절대 실행될 수 없다. 만약 초기값을 1 이상으로 설정했다면 스레드2(Thread 2)가 스레드1(Thread 1)보다 먼저 실행될 수도 있어 순서 제어가 이루어지지 않게된다.
세마포어(Semaphore)를 이용한 동기화 코드 실습
sem_t sem;으로 세마포어(Semaphore) 변수를 선언하고, main 함수에서 sem_init(&sem, 0, 0);을 통해 초기값을 0으로 설정한다. 이 초기값이 여기서 중요한데, 0으로 설정하게 되면 sem_wait를 호출하는 스레드(Thread)는 조건이 만족될 때까지 대기 상태에 들어가게 된다. 이 코드에는 두 개의 스레드(Thread)가 있다. T1을 보면 작업 시작을 출력한 뒤 sleep(1)로 일정 시간 실행되고, 작업이 끝나면 sem_post(&sem);을 호출한다. 반면 T2는 처음에 sem_wait(&sem);을 호출하기 때문에 초기에는 바로 실행되지 못하고 대기 상태에 머무르게 된다. 실행 결과를 보면 Thread1 작업 시작 → 작업 완료 → Thread2 실행 순서로 나타나는 것을 확인할 수 있다. 즉 스레드2(Thread 2)는 먼저 생성되더라도 바로 실행되지 않고, 스레드1(Thread 1)이 sem_post(&sem);를 호출한 이후에만 실행된다. 이처럼 세마포어(Semaphore)를 활용하면 특정 작업이 끝난 뒤에 다른 작업이 실행되도록 실행 순서를 명확하게 제어할 수 있다.
📌 이 예제에서 스레드2(Thread 2)가 스레드1(Thread 1)보다 먼저 생성되더라도
sem_wait(&sem;)으로 인해 반드시 스레드1(Thread 1)이 먼저 실행되는 구조가 보장된다. 이처럼 세마포어(Semaphore)의 초기값을 0으로 설정하는 방식은 앞서 배운 시그널링(Signaling) 패턴의 전형적인 사례로, 뮤텍스(Mutex)로는 구현하기 어려운 실행 순서 제어를 세마포어(Semaphore)로 간단하게 구현할 수 있다는 점이 핵심이다.
교착상태(Deadlock) 실습
이번 실습에서는 서로 다른 자원을 동시에 요청할 때 어떤 문제가 발생하는지를 확인한다.
각 실행 흐름이 하나의 자원을 점유한 상태에서 다른 자원을 추가로 요청하게 되면, 서로가 서로의 자원을 기다리는 상황이 만들어질 수 있다. 이 상태에서는 어떤 쪽도 먼저 자원을 내려놓지 않기 때문에 프로그램 실행이 더 이상 진행되지 않는, 즉 실행이 멈춰버리는 **교착상태(Deadlock)**가 발생하게 된다.
**📌**이번 실습에서 확인하게 될 교착상태(Deadlock)는 앞서 이론에서 살펴본 네 가지 필요 조건인 상호 배제(Mutual Exclusion), 점유와 대기(Hold and Wait), 비선점(Non-preemption), 순환 대기(Circular Wait)가 모두 충족되는 상황임을 코드를 통해 직접 확인할 수 있다. 실습을 통해 교착상태(Deadlock)가 발생하면 프로그램이 아무런 오류 메시지 없이 그냥 멈춰버리는 경우가 많아, 실제 디버깅(Debugging) 시 원인을 찾기 매우 어렵다는 점도 함께 이해하면 좋다.
교착상태(Deadlock) 발생 코드 분석
이 코드는 두 개의 스레드(Thread)가 서로 다른 뮤텍스(Mutex)를 반대 순서로 획득하면서 교착상태(Deadlock)를 발생시키는 예제이다.
스레드1(Thread 1)을 보면 pthread_mutex_lock(&m1);으로 m1을 먼저 잠근 뒤, sleep(1)로 잠시 대기한 후 pthread_mutex_lock(&m2);으로 m2를 잠그려고 한다. 반면 스레드2(Thread 2)는 pthread_mutex_lock(&m2);으로 m2를 먼저 잠근 뒤, sleep(1)로 잠시 대기한 후 pthread_mutex_lock(&m1);으로 m1을 잠그려고 한다.
이 상태에서 실행 순서를 살펴보면, 스레드1(Thread 1)이 m1을 먼저 점유하고 스레드2(Thread 2)가 m2를 먼저 점유하게 된다. 이후 스레드1(Thread 1)은 m2를 기다리고, 스레드2(Thread 2)는 m1을 기다리게 되는데, 결국 두 스레드(Thread) 모두 서로가 가진 자원을 기다리는 상태가 되어 어느 쪽도 더 이상 진행하지 못하게 된다. 이처럼 자원 획득 순서가 서로 엇갈리게 되면 교착상태(Deadlock)가 실제로 발생할 수 있다는 점을 이 코드를 통해 확인할 수 있다.
📌 이 코드에서
sleep(1)을 사용하는 이유는 두 스레드(Thread)가 각자 첫 번째 뮤텍스(Mutex)를 획득한 뒤 두 번째 뮤텍스(Mutex)를 요청하는 타이밍을 의도적으로 엇갈리게 만들어 교착상태(Deadlock)가 확실히 발생하도록 유도하기 위함이다. 이 교착상태(Deadlock)를 해결하는 가장 간단한 방법은 앞서 배운 것처럼 두 스레드(Thread)가 뮤텍스(Mutex)를 항상 같은 순서로 획득하도록 코드를 수정하는 것이다. 예를 들어 스레드2(Thread 2)도 m1을 먼저 잠그고 m2를 잠그도록 순서를 통일하면 순환 대기(Circular Wait) 조건이 깨져 교착상태(Deadlock)가 발생하지 않게된다.
교착상태(Deadlock) 발생 코드 실습 결과
이 코드에는 두 개의 뮤텍스(Mutex) m1과 m2가 있으며, 두 스레드(Thread)가 이 자원들을 서로 다른 순서로 획득하도록 작성되어 있다. T1은 pthread_mutex_lock(&m1); → sleep(1); → pthread_mutex_lock(&m2); 순서로, T2는 pthread_mutex_lock(&m2); → sleep(1); → pthread_mutex_lock(&m1);순서로 자원을 요청한다. 즉 두 스레드(Thread)가 서로 반대 순서로 자원을 요청하는 구조이다. 실행하게 되면 출력 순서는 스레드(Thread)의 실행 순서에 따라 달라지게 된다. 스레드1(Thread 1)의 Lock m1 → 스레드2(Thread 2)의 Lock m2 순으로 출력될 수도 있고, 반대로 스레드2(Thread 2)가 먼저 출력될 수도 있다. 여기서 중요한 점은 각 스레드(Thread)가 서로 다른 뮤텍스(Mutex)를 먼저 점유한 상태에서 다른 자원을 추가로 요청한다는 것이다. 이후 스레드2(Thread 2)의 m1 요청과 스레드1(Thread 1)의 m2 요청 출력까지만 보인 채 커서가 멈춰있는 것을 확인할 수 있는데, 이는 실행이 더 이상 진행되지 않고 멈춰있다는 것을 의미한다. 스레드1(Thread 1)이 m1을 가진 채로 m2가 풀리기를 기다리고, 스레드2(Thread 2)는 m2를 가진 채로 m1이 풀리기를 기다리는 순환 대기(Circular Wait) 구조가 만들어져 교착상태(Deadlock)가 발생한 것이다. 이를 해제하기 위해서는 Ctrl + C를 눌러 강제로 종료해야 한다. 이 코드는 자원 획득 순서가 엇갈리게 되면 프로그램 실행이 실제로 멈출 수 있다는 것을 직접 확인할 수 있는 예제이다.
📌Ctrl + C는 운영체제(Operating System)가 프로세스(Process)에 SIGINT 시그널(Signal)을 보내 강제 종료하는 방식으로, 교착상태(Deadlock)가 발생한 프로그램을 외부에서 강제로 종료하는 대표적인 방법이다.
교착상태(Deadlock) 해결 코드 분석; 자원 획득 순서 통일
이 코드는 두 스레드(Thread) 모두 동일한 순서로 뮤텍스(Mutex)를 획득하도록 수정된 예제이다.
스레드1(Thread 1)에서는 pthread_mutex_lock(&m1);으로 m1을 먼저 잠그고, pthread_mutex_lock(&m2);으로 m2를 잠근다. 스레드2(Thread 2)도 마찬가지로 pthread_mutex_lock(&m1);으로 m1을 먼저 잠그고, pthread_mutex_lock(&m2);으로 m2를 잠근다. 즉 두 스레드(Thread) 모두 같은 순서로 자원을 요청하게 된다.
이렇게 되면 한 스레드(Thread)가 m1을 점유한 상태에서 다른 스레드(Thread)는 m1을 얻기 위해 대기하게 되므로, m1과 m2를 동시에 나누어 점유하는 상황 자체가 발생하지 않는다. 실행 결과를 보면 두 스레드(Thread)가 서로를 기다리며 멈추는 것이 아니라 순서대로 정상적으로 실행되며, 교착상태(Deadlock) 없이 모든 작업이 끝까지 진행된다.
이 실습의 핵심은 자원 획득 순서를 통일하는 것만으로도 교착상태(Deadlock)를 효과적으로 방지할 수 있다는 것이다.
📌 이 해결 방법은 앞서 배운 교착상태(Deadlock) 예방(Prevention) 전략 중 순환 대기(Circular Wait) 조건을 제거하는 방식에 해당한다. 자원에 순서를 부여하고 모든 스레드(Thread)가 그 순서를 따르도록 강제함으로써 순환 구조 자체가 형성되지 않도록 한 것이다. 이처럼 교착상태(Deadlock) 해결은 복잡한 알고리즘 없이도 설계 단계에서 간단한 규칙을 적용하는 것만으로도 효과적으로 예방할 수 있게된다.
교착상태(Deadlock) 해결 코드 실습 결과
앞의 코드와 비교했을 때 가장 중요한 차이점은 두 스레드(Thread)가 자원을 획득하는 순서를 동일하게 맞췄다는 것이다. T1을 보면 pthread_mutex_lock(&m1);으로 m1을 먼저 잠근 뒤 sleep(1)로 잠시 대기하고, 그 다음 pthread_mutex_lock(&m2);로 m2를 잠근다. 스레드2(Thread 2)도 마찬가지로 pthread_mutex_lock(&m1);으로 m1을 먼저 잠그고, 그 다음 m2를 잠그도록 작성되어 있다. 즉 두 스레드(Thread)는 항상 같은 순서로 자원을 요청하고 있다.
이렇게 하면 한 스레드(Thread)가 m1을 점유하고 있을 때 다른 스레드(Thread)는 처음 단계에서부터 m1을 기다리게 되기 때문에, 서로 다른 자원을 하나씩 나눠 가진 채 상대방의 자원을 기다리는 상황 자체가 만들어지지 않는다. 따라서 순환 대기(Circular Wait) 구조가 형성되지 않아 교착상태(Deadlock)가 발생하지 않게 된다.
실행 결과를 보면 한 스레드(Thread)가 m1과 m2를 차례대로 획득한 후 작업을 마치고, 그 다음 스레드(Thread)가 같은 순서로 실행되는 것을 확인할 수 있다. 프로그램이 중간에 멈추지 않고 끝까지 정상적으로 실행된다는 점에서 자원 획득 순서를 통일하는 것만으로도 교착상태(Deadlock)를 효과적으로 방지할 수 있다는 것을 확인할 수 있는 예제였다.
📌 이 해결 방법은 교착상태(Deadlock) 예방(Prevention) 전략 중 순환 대기(Circular Wait) 조건을 제거하는 방식에 해당하며, 실제 현업에서도 가장 널리 사용되는 교착상태(Deadlock) 방지 기법 중 하나이다. 복잡한 알고리즘 없이도 자원 획득 순서를 일관되게 유지하는 것만으로 교착상태(Deadlock)를 원천적으로 차단할 수 있다는 점에서 설계 단계에서부터 반드시 고려해야 할 원칙이다.




