[운영체제] DeadLock | 멀티 스레딩 환경에서 발생하는 문제

 

멈춰버린 프로세스, 원인은 무엇일까?

지난번 세마포어(Semaphore)를 정리하며 공유 자원에 대한 동시 접근을 제어하는 방법을 알아봤다. 하지만 여러 스레드나 프로세스가 얽혀 자원을 요청하는 과정에서, 예상치 못한 문제가 발생하곤 한다. 마치 차들이 서로 꼬리를 물고 멈춰버린 교차로처럼, 모든 프로세스가 다른 프로세스가 끝나기만을 기다리며 영원히 멈춰버리는 상황을 마주하게 된 것이다.

이 현상을 운영체제에서는 '교착 상태(Deadlock)'라고 부른다. 단순히 뮤텍스 락(Mutex Lock)을 순서대로 잘 걸기만 하면 해결될 줄 알았는데, 막상 문제가 발생하니 어디서부터 잘못되었는지 파악하기가 쉽지 않았다. 이번 시간에는 교착 상태가 정확히 무엇인지, 어떤 조건에서 발생하는지, 그리고 이를 어떻게 해결할 수 있는지에 대해 깊이 있게 정리해보고자 한다.


교착 상태(Deadlock) 파헤치기

1. 교착 상태란 무엇인가?

교착 상태는 두 개 이상의 프로세스(또는 스레드)가 서로가 가진 자원을 기다리며 블록(block)된 상태를 의미한다. 각자 자원을 하나씩 점유한 상태에서, 상대방이 점유한 자원을 요청하며 무한정 기다리는 것이다. 결국 어느 프로세스도 다음 단계로 진행하지 못하고 영원히 대기하게 된다.

가장 흔한 예시는 두 개의 스레드가 두 개의 락(Lock)을 서로 엇갈리게 요청하는 경우다.

  • 스레드 T0락 L1을 획득하고, 다음으로 락 L2를 기다린다.

  • 스레드 T1락 L2를 획득하고, 다음으로 락 L1을 기다린다.

이 상황을 그림으로 그려보면 명확해진다. T0L1을 점유(Hold)하고 L2를 원하며(Wanted by), T1L2를 점유하고 L1을 원한다. 결국 서로가 서로의 발목을 잡는 순환 고리가 형성되어, 영원히 풀리지 않는 상태에 빠지게 된다.

Example of DeadLock


2. 교착 상태 발생의 네 가지 조건

교착 상태는 다음 네 가지 조건이 모두 동시에 충족될 때 발생한다. 즉, 이 중 하나라도 깨트리면 교착 상태를 예방할 수 있다는 의미가 된다.

  1. 상호 배제 (Mutual Exclusion)

    • 자원은 한 번에 하나의 프로세스만 사용할 수 있다. 다른 프로세스가 사용하려면, 이전 프로세스가 자원을 해제할 때까지 기다려야 한다. (e.g., 뮤텍스 락)

  2. 점유 및 대기 (Hold and Wait)

    • 프로세스가 최소 하나의 자원을 점유한 상태에서, 다른 프로세스에 할당된 자원을 추가로 요청하며 대기한다.

  3. 비선점 (No Preemption)

    • 프로세스가 점유한 자원은 해당 프로세스가 작업을 마친 후 자발적으로 해제할 때까지 강제로 빼앗을 수 없다.

  4. 순환 대기 (Circular Wait)

    • 각 프로세스가 다음 프로세스가 요구하는 자원을 가지고 있는, 꼬리에 꼬리를 무는 순환 형태의 대기열이 형성된다. (P0 -> P1 -> P2 -> ... -> P0)


3. 교착 상태 처리 방법

교착 상태를 다루는 방법은 크게 세 가지로 나뉜다. 대부분의 최신 운영체제는 세 번째 방법을 사용하는데, 이는 꽤 흥미로운 지점이다.

1) 교착 상태 예방 (Prevention)

가장 근본적인 해결책으로, 앞서 말한 네 가지 발생 조건 중 하나를 원천적으로 차단하는 방식이다.

  • 순환 대기 조건 깨기: 자원 획득 순서를 강제하는 방법이다. 모든 자원에 고유한 번호(순서)를 부여하고, 모든 스레드가 그 순서에 따라서만 자원을 요청하도록 규칙을 정하는 것이다. 예를 들어, 시스템에 L1L2라는 락이 있다면, '반드시 L1을 먼저 획득한 후에만 L2를 획득할 수 있다'는 규칙을 설정한다. 이렇게 하면 한 스레드는 L1 -> L2 순으로, 다른 스레드는 L2 -> L1 순으로 락을 획득하려다 발생하는 순환 고리를 원천적으로 차단할 수 있다. 이 방법은 전체 시스템의 락 전략에 대한 신중한 설계가 필요하지만, 매우 효과적인 예방책이다.

  • 점유 및 대기 조건 깨기: 필요한 모든 자원을 한 번에, 원자적으로(atomically) 획득하는 전략이다. 특정 작업을 시작하기 전에 필요한 모든 락을 한꺼번에 요청하고, 모두 획득할 수 있을 때만 작업을 시작한다. 만약 하나라도 획득할 수 없다면, 이미 획득한 락이 있더라도 모두 반납하고 다시 시도해야 한다. 이 방식은 스레드가 자원을 일부만 점유한 채 다른 자원을 기다리는 상황 자체를 없애준다. 하지만 두 가지 단점이 있다. 첫째, 특정 코드 블록에 어떤 락들이 필요한지 미리 모두 파악해야 하므로 유연성이 떨어진다. 둘째, 필요 이상으로 오랫동안 많은 자원을 점유하게 되므로 시스템의 전반적인 동시성(concurrency)을 저해할 수 있다.

  • 비선점 조건 깨기: '다 가질 수 없으면, 가진 것도 포기하라'는 전략이다. 스레드가 특정 자원을 점유한 상태에서 추가로 다른 자원을 요청했는데 즉시 할당받을 수 없다면, 현재 가지고 있던 자원까지 모두 반납하고 처음부터 다시 시작하는 방식이다. pthread_mutex_trylock()과 같은 함수를 활용해 구현할 수 있다. 이 함수는 락을 획득할 수 있으면 즉시 성공을 반환하고, 아니면 기다리지 않고 바로 실패를 반환한다. 이를 이용해 "L1 획득 -> trylock(L2) 시도 -> 실패 시 L1 반납 후 처음부터" 와 같은 로직을 짤 수 있다. 하지만 이 방식은 두 스레드가 계속해서 서로의 락을 획득하고 반납하기를 반복하며 실질적인 진전을 이루지 못하는 라이브락(Livelock) 상태에 빠질 수 있다. 이를 해결하기 위해 락 획득 실패 시, 랜덤한 시간 동안 대기한 후 재시도하는 방법을 추가할 수 있다.

  • 상호 배제 조건 깨기: 애초에 '락(Lock)'을 사용하지 않는 방식으로 문제를 해결하는 것이다. 락 없이도 여러 스레드가 공유 데이터를 안전하게 수정할 수 있도록 설계된 'Lock-Free' 자료구조를 사용하는 방법이다. 이는 하드웨어가 제공하는 Compare-And-Swap(CAS)과 같은 원자적 연산을 통해 구현된다.(저번에 다룬 바 있다) CAS 연산은 특정 메모리 주소의 값이 '예상하는 값'과 일치할 경우에만 '새로운 값'으로 변경하는 것을 원자적으로 보장한다. 스레드는 이 연산을 통해 자신이 값을 읽은 후에 다른 스레드가 값을 변경하지 않았음을 확인하고 안전하게 값을 업데이트할 수 있다. 이 방식은 락으로 인한 교착 상태를 완전히 배제할 수 있지만, 구현이 매우 복잡하고 특정 스레드가 계속해서 업데이트에 실패하는 기아(Starvation) 상태가 발생할 가능성이 있다.


2) 교착 상태 회피 (Avoidance)

교착 상태 예방이 너무 엄격한 제약이라고 생각될 때 사용하는 방법이다. 프로세스의 자원 요청 정보를 미리 파악하여, 앞으로 교착 상태를 유발할 가능성이 있는 자원 할당은 애초에 하지 않는 방식이다. '은행원 알고리즘(Banker's Algorithm)'이 대표적인 예다. 하지만 어떤 프로세스가 미래에 어떤 자원을 요청할지 미리 알아야 한다는 전제가 있어, 일반적인 운영체제 환경에서는 실용성이 떨어진다.

DeadLock Avoidance


3) 교착 상태 탐지 및 회복 (Detection & Recovery)

이 방법은 교착 상태 발생을 허용하되, 주기적으로 시스템을 검사하여 교착 상태를 '탐지'하고, 발견되면 '회복'하는 전략이다. 자원 할당 그래프에서 사이클이 있는지 확인하여 교착 상태를 탐지하고, 발견되면 관련 프로세스를 강제 종료하거나 할당된 자원을 선점하여 교착 상태를 해결한다. 데이터베이스 시스템에서 종종 사용되는 방식이다.


4) 현실: 문제를 무시하기

놀랍게도 Windows, Linux, macOS 같은 대부분의 범용 운영체제는 교착 상태를 적극적으로 처리하지 않는다. 교착 상태가 드물게 발생하며, 이를 예방하거나 탐지하는 데 드는 오버헤드가 더 크다고 보기 때문이다. 따라서 교착 상태를 방지하는 책임은 온전히 프로그래머에게 넘어온다. 결국 우리가 꼼꼼하게 락 순서를 지키고, 자원 점유 시간을 최소화하는 등 안전한 코드를 작성해야 한다는 결론에 이르게 된다.


책임은 결국 프로그래머에게

교착 상태는 여러 프로세스가 한정된 자원을 놓고 경쟁할 때 발생할 수 있는 필연적인 문제 중 하나라는 생각이 들었다. 운영체제는 이를 해결하기 위한 여러 이론적인 방법(예방, 회피, 탐지)을 제시하지만, 현실의 범용 OS는 성능과 실용성을 이유로 대부분의 책임을 프로그래머에게 넘긴다.

결국 시스템의 동작 원리를 깊이 이해하고, 자원을 요청하고 해제하는 로직을 신중하게 설계하는 것이 얼마나 중요한지 다시 한번 깨닫게 되었다. 특히 순환 대기를 피하기 위해 락을 획득하는 순서를 명확히 정의하는 습관은 반드시 지켜야 할 원칙인 것 같다. 다음에는 메모리 관리 기법에 대해 더 깊이 파고들어 볼 예정이다.



추천글:

[운영체제] 고급 동기화 메커니즘 | Condition Variable과 Semaphore

[운영체제] Lock | 단순 Mutual Exclusion을 위한 Test-And-Set부터 성능까지 챙긴 Futex까지

[개념정리] 운영체제 | Concurrency를 이해하기 위한 빌드업, 프로세스와 스레드

hyeon_B

안녕하세요! AI 기술을 이용해 더 나은 세상을 만들어 나가고 싶은 과기원생 Hyeon이라고 합니다. 저는 앞으로 인공지능 시대에는 지식을 '활용'하는 능력이 중요해질 것이라고 생각합니다. 대부분의 일들은 인공지능이 뛰어난 모습을 보이지만, 인공지능은 데이터로 부터 연관관계를 학습하기 때문에 지식들을 새로 통합해서 활용하는 능력이 부족합니다. 인공지능이 뉴턴 전에 만들어졌다면 사과가 떨어지는 이유에 대답하지 못했을 것이고, 아인슈타인 전에 만들어졌다면 중력이 어떻게 생기는지 설명하지 못했을 것입니다. 따라서 앞으로 우리는 '본질'을 탐구하고 그 본질로부터 다른 곳에 적용하며 인공지능을 현명하게 활용해야 할 것입니다. 함께 인공지능 시대를 준비합시다!

댓글 쓰기

다음 이전

POST ADS1

POST ADS 2