데드락 (deadlock)
어떤 스레드 (또는 프로세스)가 필요로 하는 자원을 다른 대기 스레드에서 점유하고 있고, 대기 스레드 역시 다른 스레드가 작업을 끝내기를 기다리고 있기 때문에 대기 상태가 영원히 끝나지 않는 현상 (서로가 서로를 기다리고 있음)
여러 개의 자원이 있는 시스템에서는 경쟁 스레드 (동기화가 필요한 스레드들)끼리 자원을 공유함.
이러한 자원들은 하나의 타입 (type)으로 묶을 수 있는데, 내부에는 여러 동일 인스턴스 (identical instance)로 이루어져 있음.
ex) CPU는 코어 수에 따라 한 번에 제공할 수 있는 자원의 수가 달라짐. 코어가 4개인 경우 4개의 자원을 한 번에 제공할 수 있고, 그 이상인 경우 한 코어의 작업이 끝날 때까지 대기함.
자원의 타입이 같은 경우 그 자원이 포함하는 인스턴스의 개수는 중요하지 않음. (몇 개의 자원 타입이 있는지 중요하지 않다는 의미)
스레드는 주로 요청 (request) - 사용 (use) - 반납 (release)의 순서로 자원을 사용하는데, '사용' 단계에서 한 번에 여러 자원을 사용할 수 있음.
데드락이 발생하는 예시는 6.8에서 잠깐 다룬 적이 있음.
(데드락이 발생하기 쉬운 코드)
pthread_mutex_t first_mutex;
pthread_mutex_t second_mutex;
pthread_mutex_init(&first_mutex, NULL);
pthread_mutex_init(&second_mutex, NULL);
void* do_work_one(void* param)
{
pthread_mutex_lock(&first_mutex);
pthread_mutex_lock(&second_mutex);
/* 작업 수행 */
pthread_mutex_unlock(&second_mutex);
pthread_mutex_unlock(&first_mutex);
pthread_exit(0);
}
void* do_work_two(void* param)
{
pthread_mutex_lock(&second_mutex);
pthread_mutex_lock(&first_mutex);
/* 작업 수행 */
pthread_mutex_unlock(&first_mutex);
pthread_mutex_unlock(&second_mutex);
pthread_exit(0);
}
6.8에서 소개한 예시를 코드로 옮긴 것임.
다음 네 가지 조건을 모두 만족해야만 데드락이 발생함. -> 확률상 데드락은 그렇게 자주 발생하지도 않고, 데드락이 발생하는 예제를 만들기도 까다로움.
데드락을 조금 더 쉽고 명확하게 표현하기 위해 자원 할당 그래프 (resource-allocation graph, RAG)를 그릴 수 있음.
자원 할당 그래프는 방향성을 갖는 방향 그래프 (directed graph, digraph)임.
자원 할당 그래프의 정점 (vertex) 집합은 두 가지로 나뉨.
각 정점들을 잇는 간선 (edge)은 다음과 같음.
first_mutex
자원은 thread_one
스레드에, second_mutex
자원은 thread_two
스레드에 할당되어있는 상황.
이때 thread_one
이 second_mutex
를, thread_two
가 first_mutex
를 요청하게 되면 전체 그래프에 순환 (cycle)이 생김. 이러한 상황이 데드락 상황임.
이렇듯 자원 할당 그래프를 그려보면 데드락 상황을 쉽게 파악할 수 있음.
자원 할당 그래프 예시
1)
자원을 나타내는 정점 내부에 있는 점의 개수는 그 자원의 인스턴스 개수를 나타냄.
순환 구조가 발견되지 않으므로 데드락이 발생하지 않음.
2)
2개의 순환 구조가 존재하여 데드락이 발생하였음.
3)
1개의 순환 구조가 존재함.
but 스레드가 요청한 자원의 인스턴스가 2개이고 한 인스턴스가 순환 구조에 걸려 있지 않은 스레드에 할당되어있기 때문에 가 작업을 끝내고 자원을 반납한다면 은 자원을 받아 작업을 수행할 수 있음. 따라서 순환 구조는 존재하지만 데드락이 발생하지 않음.
결론
자원 할당 그래프에 순환 구조가 있으면 데드락이 절대 발생하지 않지만, 순환 구조가 있는 경우 발생할 수도, 발생하지 않을 수도 있음.
데드락을 처리하는 방법은 대략 세 가지 정도가 있음.
데드락은 앞서 설명한 4가지 조건 (상호 배제, 점유 대기, 비선점, 순환 대기)을 모두 만족해야 발생하는 상황이었음. 1가지 조건이라도 만족하지 않으면 데드락은 발생하지 않을 것임.
데드락 방지는 4가지 중 최소 1개의 조건이 절대 만족하지 않도록 막는 방법임.
1) 상호 배제
최소 1개 이상의 자원이 공유가 불가능해야 한다는 조건이었는데, 모든 자원이 공유 가능하면 이 조건은 만족하지 않음. -> 근데 이거 현실적으로 가능한 얘기임?
몇몇 자원 중에는 본질적으로 공유가 불가능한 자원도 존재함.
ex) 뮤텍스 락을 스레드끼리 공유한다? 말이 되지 않음. 다른 스레드가 접근하지 못하도록 하기 위해 만든 것이 뮤텍스 락인데, 이걸 공유하게 되면 뮤텍스 락을 사용하는 이유가 없어짐.
2) 점유 대기
스레드가 자원을 점유한 상태로 대기하고 있기 때문에 발생하는 문제였음. 스레드가 자원을 요청할 때 기존에 점유하고 있던 자원을 모두 반납한 뒤 요청하는 것을 강제하면 문제 해결.
but 이는 매우 비효율적인 시스템임.
ex) 1억 개의 파일을 open하고 있는 스레드가 있다고 가정. 그런데 1개의 새로운 파일에 접근하기 위해 1억 개나 되는 파일을 모두 close해야 하고, 또 파일 접근이 끝난 후에는 다시 1억 개의 파일을 open해야 함. 비효율적이고 쓸데없는 과정.
3) 비선점
다른 스레드가 점유하고 있는 자원을 선점하여 주도권을 뺏을 수 있도록 하자는 개념.
그런데 선점당한 스레드가 어떤 작업을 하고 있었는지도 모른 채로 그냥 뺏어버리면 예상치 못한 문제가 생길 수도 있음. 잘 사용하지 않는 해법.
4) 순환 대기
4가지 해법 중 그나마 실용적인 해법임.
각 리소스마다 순서를 배정하여 자원을 요청할 때 기존에 점유하고 있던 자원보다 낮은 순서의 자원은 요청할 수 없도록 하여 순환 구조가 발생하지 않도록 막는 것.
이렇게 하면 데드락이 발생하지 않는다는 것을 증명할 수 있지만, 반대로 기아 상태 (starvation)이 발생할 위험성이 높아짐. (우선순위를 준 것과 마찬가지이기 때문)
심지어 데드락이 발생하지 않는다는 것을 완벽히 보장할 수도 없음.
from
에서 to
로 돈을 송금하는 상황을 가정.
순환 대기 조건을 막기 위해 순서를 부여하여 무조건 lock1
을 획득한 후 lock2
를 획득하도록 구현했으나 여기서도 데드락이 발생할 수 있음.
계좌 A, B에 대해 A->B (T1), B->A (T2)로의 송금이 동시에 발생했다면?
T1이 lock1
을 획득한 상황에서 T2가 lock2
를 획득하려고 하면 T1이 lock1
을 반납할 수 없으므로 순환 구조가 생김.
1, 2, 3번 조건은 본질적으로 불가능한 해결법이고, 4번은 실용적이긴 하지만 문제가 많은 해결법임. 따라서 완벽하게 데드락을 방지하는 것은 사실상 불가능함. (멀티스레딩의 장점을 다 없애버림)
스레드의 자원 요청을 받기 전에 발생 가능한 데드락이 있는지 확인할 수 있음. 이를 위해서는 자원이 어떻게 요청되었는지에 대한 정보가 필요함.
ex) , 2개의 자원을 갖는 시스템에서 스레드 가 을 점유한 상태로 () 를 요청 ()하고, 스레드 는 를 점유한 상태로 () 을 요청 ()하는 상황 -> 그대로 놔두면 순환 구조가 발생하여 데드락이 발생할 위험이 있으므로 , 요청이 들어왔을 때 이를 거절 (reject)할 수 있음.
몇 가지 사전지식이 있으면 알고리즘을 통해 시스템이 데드락 상태에 절대 빠지지 않도록 할 수 있음.
필요한 자원의 최대 개수를 알고 있고, 사용 가능하며 이미 할당되어있는 자원이나 앞으로 요구할 자원의 수를 알고 있으면 이를 데드락 회피에 활용할 수 있음.
안전 상태 (safe state)
자원을 각 스레드에 최대치까지 할당할 수 있으며, 스레드의 실행 순서가 정해져 있어 데드락이 발생하지 않는 상태
스레드에 안전 순서 (safe sequence)가 존재하여 정해진 순서에 따라 모든 자원을 각 스레드에 할당할 수 있음. 이 안전 순서가 데드락 회피의 핵심.
안전 상태는 데드락이 발생하지 않는 상태임. 역으로 말하면 안전하지 않은 상태 (unsafe state)는 데드락이 발생할 가능성이 있는 상태를 말함. (발생하지 않을 수도 있음)
따라서 스레드를 최대한 안전 상태에 있게 하는 것이 데드락 회피의 주요 개념.
회피 알고리즘은 시스템이 항상 안전 상태에 있도록 구현되어 데드락을 회피해야 함.
시스템 초기에는 안전 상태에서 시작함. (점유 대기를 수행하기 때문) 이후 자원 요청이 들어올 때마다 상태를 판단하여 자원을 할당할지, 할당하지 않을지 결정함. (시스템이 안전 상태에 있는 경우 자원 할당을 허용하고, 그렇지 않은 경우 요청을 거부함)
자원 할당 그래프가 오직 1개의 인스턴스를 갖는 자원으로 구성되어 있다고 가정.
이때 데드락 회피를 위해 요청 가능 간선 (claim edge)을 추가할 수 있음.
은 스레드가 미래에 자원을 요청할 수도 있음을 의미함.
claim edge를 자원 할당 그래프에 추가하여 순환 구조가 발생하는지 판단하면 시스템이 안전 상태에 있는지 확인할 수 있음.
만약 claim edge를 추가한 자원 할당 그래프에 순환 구조가 없다면 안전 상태에 있다는 뜻으로, 추후 그 요청이 들어왔을 때 허용해줄 수 있음.
반대로 순환 구조가 발견된 경우 이를 허용하면 안전하지 않은 상태에 들어갈 수 있으므로 즉시 허용하지 않음. (잠시 대기)
자원의 인스턴스가 여러 개인 경우 RAG에서 순환 구조를 파악하는 것만으로는 데드락을 확인할 수 없음. (순환 구조가 있어도 데드락이 발생하지 않는 경우가 있기 때문) -> 이 경우 은행원 알고리즘 (banker's algorithm)을 사용함. but RAG에 비해 비효율적이고 복잡함.
을 스레드의 개수, 을 자원의 개수라고 가정.
안전 알고리즘 (safety algorithm)
- Work, Finish는 각각 m, n의 길이를 갖는 벡터. Work = Available, Finish[i] =
false
로 초기화함.- 다음 두 조건을 만족하는 인덱스 i를 탐색함. (i를 찾을 수 없는 경우 4번으로 이동)
a. Finish[i] ==false
b. Need Work- Work = Work + Allocation
Finish[i] =true
이후 2번으로 이동.- 모든 i에 대해 Finish[i] ==
true
이면 시스템은 안전 상태에 들어와 있는 것임.
자원 요청 알고리즘 (resource-request algorithm)
- Request Need이면 2번으로 가고, 그 외의 경우 스레드가 최대 요청 (maximum claim)을 넘어선 것이므로 에러가 발생함.
- Request Available인 경우 3번으로 가고, 그 외의 경우 자원이 사용 가능하지 않으므로 T 스레드는 기다려야 함.
- 시스템은 다음과 같이 상태를 수정하여 요청한 자원을 T 스레드에 할당한 것처럼 보이게 함.
Available = Available - Request
Allocation = Allocation + Request
Need = Need - Request
예시
5개의 스레드, 3개의 자원이 있고, 각 자원 A, B, C는 A = 10, B = 5, C = 7개의 인스턴스를 갖는다고 가정.
Allocation은 각 스레드가 현재 할당 중인 자원, Max는 앞으로 필요한 총 자원의 수임. (ex. 스레드는 B 자원을 1개 할당하고 있고, 5개가 필요함)
Available은 각 자원의 인스턴스의 개수에서 현재 할당 중인 자원의 수를 빼주면 됨. ->
Need[i][j] = Max[i][j] - Allocation[i][j]임. (최대로 요구할 수 있는 양과 현재 할당하고 있는 양의 차이)
여기서부터 안전 알고리즘을 수행함.
1. Work = Available = (3, 3, 2) / Finish = (false
, false
, false
, false
, false
)
2. i = 0 ~ i = 4까지 Need Work인 i 값을 찾음. (벡터의 모든 요소를 하나씩 비교함) i를 찾은 경우 Work = Work + Allocation로 값을 업데이트함. && Finish[i] = true
i = 0 -> (7, 4, 3) (3, 3, 2) == false
( 스레드의 요청은 현재 받아들일 수 없음.
i = 1 -> (1, 2, 2) (3, 3, 2) == true
-> Work = Work + Allocation = (3, 3, 2) + (2, 0, 0) = (5, 3, 2) && Finish[1] = true
i = 2 -> (6, 0, 0) (5, 3, 2) == false
i = 3 -> (0, 1, 1) (5, 3, 2) == true
-> Work = Work + Allocation = (5, 3, 2) + (2, 1, 1) = (7, 4, 3) && Finish[3] = true
i = 4 -> (4, 3, 1) (7, 4, 3) == true
-> Work = Work + Allocation = (7, 4, 3) + (0, 0, 2) = (7, 4, 5) && Finish[4] = true
(모든 Finish가 true
가 아니므로 i = 0으로 다시 돌아감)
i = 0 -> (7, 4, 3) (7, 4, 5) == true
-> Work = Work + Allocation = (7, 4, 5) + (0, 1, 0) = (7, 5, 5) && Finish[0] = true
i = 2 -> (6, 0, 0) (7, 5, 5) == true
-> Work = Work + Allocation = (7, 5, 5) + (3, 0, 2) = (10, 5, 7) && Finish[2] = true
최종 결과
Work = (10, 5, 7), Finish = (true
, true
, true
, true
, true
)
-> -> -> -> 의 순서로 스레드를 실행하면 안전성이 보장됨.
이 상황에서 스레드가 A 1개, C 2개를 요청한다면 Request = (1, 0, 2)임. 이 요청을 받아들일지 말지를 판단하기 위해 자원 요청 알고리즘을 사용함.
true
true
안전 알고리즘은 현재 상태가 안전 상태인지 확인하는 알고리즘이고, 자원 요청 알고리즘은 어떤 요청이 들어왔을 때 허용이 가능한지 확인하는 알고리즘임.
데드락 방지는 문제가 많고, 데드락 회피는 매 요청이 들어올 때마다 수행하기에는 시스템 입장에서 너무 무겁고 부담스러움. 이 때문에 최근에는 일단 데드락 발생을 허용하되, 지속적인 모니터링을 통해 데드락이 발생하면 즉시 시스템을 복구하는 방식을 많이 사용함.
인스턴스가 1개인 경우 (single instance)
자원 할당 그래프와 유사한 대기 그래프 (wait-for graph)를 사용함.
주기적으로 알고리즘을 호출하여 대기 그래프 내에 순환 구조가 있는지 확인함.
(a)는 자원 할당 그래프, (b)는 이에 대응하는 대기 그래프임.
자원 할당 그래프에서 자원에 해당하는 node를 제거하고, 각 스레드 사이의 관계만 남겨놓은 것이 대기 그래프.
인스턴스가 여러 개인 경우 (several instances)
자원 할당 그래프에서와 마찬가지로 대기 그래프만으로는 해결하기 어렵기 때문에 은행원 알고리즘과 비슷한 방식으로 진행함.
은행원 알고리즘과 동일하게 진행하되, Need를 Request로 바꿈.
데드락 탐지 알고리즘
- Work, Finish는 각각 m, n의 길이를 갖는 벡터. Work = Available 로 초기화하고, Allocation != 0인 경우 Finish[i] =
false
, 그 외의 경우true
로 초기화함.- 다음 두 조건을 만족하는 인덱스 i를 탐색함. (i를 찾을 수 없는 경우 4번으로 이동)
a. Finish[i] ==false
b. Request Work- Work = Work + Allocation
Finish[i] =true
이후 2번으로 이동.- 인 i에 대해 Finish[i] ==
false
인 i가 존재할 경우 시스템은 데드락이 발생할 수 있는 상태이며, 해당 스레드는 데드락에 걸린 상태임.
데드락 탐지 결과 실제로 데드락이 발생했다면, 단순히 "데드락 발생했음"을 알려주기만 할수도 있고, 중요한 시스템인 경우 즉시 복구도 진행할 수 있음.
데드락 복구의 2가지 방법