14. Concurrency - Bugs
- Haram Lee
- 2026-05-25
- studies / 26-1 / operating-systems
Concurrency: Bugs
Reading
- 이 단원은 Concurrency Bugs를 다룬다.
- 앞 단원들에서 thread, lock, semaphore, condition variable을 배웠다면, 14단원에서는 concurrency 상황에서 실제로 어떤 bug가 생기는지를 배운다.
- 주요 주제는 다음과 같다.
- Atomicity bug
- Ordering bug
- Deadlock
- Deadlock prevention
- 즉 이 단원은 “동기화 도구를 왜 조심해서 써야 하는가”를 bug 유형 중심으로 정리하는 단원이다.
Today’s Topics
- concurrency bug는 multi-threaded program에서 흔히 발생한다.
- 이 단원에서 다루는 대표적인 concurrency bug는 다음 세 가지다.
- Atomicity bug
- 원래 atomic하게 실행되어야 하는 코드 조각이 중간에 끼어들기를 허용해서 생기는 bug
- Ordering bug
- 어떤 작업이 다른 작업보다 먼저 일어나야 하는데, 순서가 보장되지 않아서 생기는 bug
- Deadlock
- 여러 thread가 서로 필요한 resource를 기다리며 영원히 멈추는 bug
- Atomicity bug
- 그리고 deadlock을 막는 방법으로 deadlock prevention을 배운다.
Concurrency Study from 2008
- Lu et al.의 2008년 연구는 실제 큰 software project의 concurrency bug를 분석했다.
- 대상은 다음과 같은 major project들이었다.
- MySQL
- Apache
- Mozilla
- OpenOffice
- 500K개 이상의 bug report 중 concurrency bug sample을 분석했다.
- 주요 bug 유형은 다음과 같이 나뉜다.
- atomicity bug
- ordering bug
- deadlock
- other
- 이 연구가 보여주는 핵심은 concurrency bug가 이론적인 문제가 아니라 실제 대형 software에서도 자주 발생한다는 점이다.
- 특히 atomicity bug와 ordering bug는 lock이나 condition variable을 잘못 쓰거나 아예 쓰지 않을 때 쉽게 생긴다.
Atomicity Bug
- atomicity bug는 함께 실행되어야 하는 연산들이 쪼개져서 interleaving될 때 발생한다.
- 즉 programmer는 어떤 코드 조각이 한 번에 실행된다고 기대하지만, 실제로는 thread switch 때문에 중간에 다른 thread가 끼어든다.
- 그 결과 shared state가 예상과 다르게 변한다.
- atomicity bug의 핵심은 다음이다.
- check와 use가 분리되어 있다.
- read와 write가 분리되어 있다.
- 여러 statement가 하나의 logical operation인데 보호되지 않는다.
- 예를 들어
if (ptr != NULL)로 확인한 뒤 그 pointer를 사용하는 사이에 다른 thread가ptr = NULL로 바꾸면 문제가 생긴다.
Atomicity Bug in MySQL
- MySQL의 예시는 다음과 같다.
- Thread 1은
thd->proc_info가 NULL이 아닌지 확인한 뒤 사용한다.
c
if (thd->proc_info) {
...
fputs(thd->proc_info, ...);
...
}- Thread 2는 같은 값을 NULL로 만든다.
c
thd->proc_info = NULL;- 문제는 Thread 1의 check와 use가 atomic하지 않다는 것이다.
- 가능한 실행 순서는 다음과 같다.
- Thread 1이
thd->proc_info != NULL임을 확인한다. - 그 직후 Thread 2가
thd->proc_info = NULL을 실행한다. - Thread 1이 다시 실행되어
fputs(thd->proc_info, ...)를 호출한다.
- Thread 1이
- 이때 Thread 1은 NULL pointer를 사용할 수 있다.
- 즉
test와use사이에 다른 thread가 shared variable을 바꾸면 bug가 생긴다. - 따라서
thd->proc_info에 대한 check와 사용은 하나의 atomic region으로 묶여야 한다.
Fix Atomicity Bugs with Locks
- atomicity bug는 lock으로 고칠 수 있다.
- 핵심은 shared data를 확인하고 사용하는 전체 구간을 같은 lock으로 보호하는 것이다.
- Thread 1은 다음처럼 lock을 잡고 check와 use를 모두 수행해야 한다.
c
mutex_lock(&lock);
if (thd->proc_info) {
...
fputs(thd->proc_info, ...);
...
}
mutex_unlock(&lock);- Thread 2도 같은 lock을 잡고 shared variable을 수정해야 한다.
c
mutex_lock(&lock);
thd->proc_info = NULL;
mutex_unlock(&lock);- 중요한 점은 두 thread가 같은 lock을 써야 한다는 것이다.
- Thread 1만 lock을 잡거나 Thread 2만 lock을 잡으면 보호가 되지 않는다.
- lock은 shared state에 대한 모든 접근을 같은 규칙으로 감쌀 때 의미가 있다.
- 이 방식은 check-use sequence를 critical section으로 만들어 atomicity를 보장한다.
Ordering Bug
- ordering bug는 반드시 먼저 일어나야 하는 작업이 늦게 일어나거나, 나중에 일어나야 하는 작업이 먼저 일어날 때 발생한다.
- 즉 atomicity bug가 “중간에 끼어들면 안 되는 코드 조각”의 문제라면, ordering bug는 “실행 순서”의 문제다.
- 예를 들어 다음과 같은 상황이 있다.
- Thread 1이 data structure를 초기화한다.
- Thread 2가 그 data structure를 사용한다.
- 이때 Thread 2는 반드시 Thread 1의 initialization 이후에 실행되어야 한다.
- 하지만 synchronization이 없으면 Thread 2가 먼저 실행될 수 있다.
- 그러면 uninitialized value를 읽거나 invalid pointer를 사용할 수 있다.
Ordering Bug in Mozilla
- Mozilla의 예시는 다음과 같다.
- Thread 1은 thread를 만든다.
c
void init()
{
...
mThread = PR_CreateThread(mMain, ...);
...
}- Thread 2는
mThread를 사용한다.
c
void mMain(...)
{
...
mState = mThread->State;
...
}- 문제는 Thread 2가
mThread가 완전히 초기화되기 전에 실행될 수 있다는 점이다. - Thread 1이
mThread값을 설정하기 전에 Thread 2가mThread->State를 읽으면 문제가 생긴다. - 즉 여기서 필요한 것은 mutual exclusion만이 아니다.
- 중요한 것은 다음 순서다.
- Thread 1이
mThread를 초기화한다. - 그 다음 Thread 2가
mThread를 읽는다.
- Thread 1이
- 이 순서를 보장하지 않으면 ordering bug가 생긴다.
Fix Ordering Bugs with Condition Variables
- ordering bug는 condition variable로 고칠 수 있다.
- 핵심은 “초기화가 끝났는지”를 나타내는 state variable을 두는 것이다.
- 예를 들어
mtInit이라는 flag를 둔다. - Thread 2는
mtInit == 1이 될 때까지 기다린다.
c
void mMain(...)
{
...
mutex_lock(&mtLock);
while (mtInit == 0)
cond_wait(&mtCond, &mtLock);
mutex_unlock(&mtLock);
mState = mThread->State;
...
}- Thread 1은
mThread를 만든 뒤mtInit = 1로 바꾸고 signal을 보낸다.
c
void init()
{
...
mThread = PR_CreateThread(mMain, ...);
mutex_lock(&mtLock);
mtInit = 1;
cond_signal(&mtCond);
mutex_unlock(&mtLock);
...
}- 여기서 mutex는
mtInit상태를 보호한다. - condition variable은 Thread 2가 “초기화 완료”라는 event를 기다리게 한다.
while을 쓰는 이유는 깨어난 뒤에도 조건이 여전히 참인지 다시 확인해야 하기 때문이다.- 즉 condition variable은 ordering constraint를 표현하는 데 적합하다.
Atomicity Bug vs Ordering Bug
- atomicity bug와 ordering bug는 비슷해 보이지만 초점이 다르다.
- atomicity bug는 여러 동작이 하나처럼 실행되어야 하는데, 중간에 다른 thread가 끼어드는 문제다.
- 예: check 후 use 사이에 값이 바뀜
- 해결: lock으로 critical section 보호
- ordering bug는 A가 B보다 먼저 일어나야 하는데 그 순서가 보장되지 않는 문제다.
- 예: initialization 전에 use가 발생함
- 해결: condition variable, semaphore 등으로 순서 보장
- 정리하면 다음과 같다.
| 구분 | 핵심 문제 | 대표 해결 |
|---|---|---|
| Atomicity bug | 중간에 끼어들면 안 되는 구간이 쪼개짐 | Lock |
| Ordering bug | 먼저 일어나야 하는 event 순서가 보장되지 않음 | Condition variable |
| Deadlock | 서로 resource를 기다리며 진행 불가 | Prevention / ordering |
Deadlock
- deadlock은 여러 thread나 process가 서로 필요한 resource를 기다리며 영원히 진행하지 못하는 상태다.
- 가장 직관적인 예시는 교통 deadlock이다.
- 네 방향에서 차가 교차로에 진입한다.
- 각 차가 앞을 막고 있다.
- 모두가 다른 차가 먼저 빠지기를 기다린다.
- 아무도 움직이지 못한다.
- thread에서도 같은 일이 생긴다.
- 예를 들어 두 lock
a,b가 있다고 하자. - Thread 1은 다음 순서로 lock을 잡는다.
c
lock(a);
lock(b);
/* do some work */
unlock(b);
unlock(a);- Thread 2는 반대 순서로 lock을 잡는다.
c
lock(b);
lock(a);
/* do some work */
unlock(a);
unlock(b);- 가능한 실행 순서는 다음과 같다.
- Thread 1이
a를 잡는다. - Thread 2가
b를 잡는다. - Thread 1은
b를 기다린다. - Thread 2는
a를 기다린다.
- Thread 1이
- Thread 1은 Thread 2가
b를 풀어야 진행할 수 있다. - Thread 2는 Thread 1이
a를 풀어야 진행할 수 있다. - 둘 다 기다리기만 하므로 deadlock이다.
System Model
- deadlock을 일반화하기 위해 system을 resource들의 집합으로 본다.
- resource type은 다음처럼 표현할 수 있다.
R1, R2, ..., Rm
- resource의 예시는 다음과 같다.
- CPU cycles
- memory space
- I/O devices
- locks
- files
- 각 resource type
Ri는 여러 instance를 가질 수 있다.- 예를 들어 같은 종류의 printer가 여러 대 있을 수 있다.
- 같은 종류의 memory block이 여러 개 있을 수 있다.
- process는 resource를 다음 순서로 사용한다.
- Request
- Use
- Release
- deadlock은 request 단계에서 서로가 가진 resource를 기다리며 멈추는 문제다.
Conditions for Deadlock
- deadlock이 발생하려면 네 가지 조건이 모두 필요하다.
- 이를 deadlock의 necessary conditions라고 한다.
- 네 조건은 다음과 같다.
- Mutual exclusion
- Hold and wait
- No preemption
- Circular wait
- 이 네 조건 중 하나라도 깨면 deadlock은 발생하지 않는다.
- deadlock prevention은 바로 이 아이디어를 사용한다.
- 즉 deadlock을 막기 위해 네 조건 중 하나를 의도적으로 제거한다.
Mutual Exclusion
- mutual exclusion은 한 번에 하나의 process/thread만 resource를 사용할 수 있다는 조건이다.
- 예를 들어 lock은 동시에 여러 thread가 잡을 수 없다.
- printer도 한 순간에는 하나의 job만 출력할 수 있다.
- 이런 resource는 non-sharable resource다.
- 반대로 read-only file처럼 여러 process가 동시에 읽어도 되는 resource는 sharable resource다.
- deadlock은 주로 non-sharable resource에서 발생한다.
- mutual exclusion이 없다면 여러 process가 같은 resource를 동시에 사용할 수 있으므로 기다림이 줄어든다.
- 하지만 모든 resource에서 mutual exclusion을 없앨 수는 없다.
- 예를 들어 lock이나 write 가능한 shared data는 mutual exclusion이 필요하다.
Hold and Wait
- hold and wait은 process가 이미 어떤 resource를 들고 있으면서, 추가 resource를 기다리는 조건이다.
- 예를 들어 Thread 1이 lock
a를 잡은 상태에서 lockb를 기다리는 상황이다. - 이때 Thread 1은
a를 놓지 않고b를 기다린다. - Thread 2가
b를 잡은 상태에서a를 기다리면 deadlock이 생길 수 있다. - 즉 “잡은 것을 놓지 않고 다른 것을 기다리는 것”이 deadlock의 중요한 원인이다.
No Preemption
- no preemption은 resource를 강제로 빼앗을 수 없다는 조건이다.
- 어떤 thread가 lock을 잡고 있으면 OS나 runtime이 강제로 그 lock을 회수하지 않는다.
- resource는 그것을 들고 있는 process/thread가 자발적으로 release해야 한다.
- lock은 대표적으로 no preemption 성격을 가진 resource다.
- 만약 resource를 강제로 빼앗을 수 있다면 deadlock을 풀 수 있다.
- 하지만 lock이나 critical section을 강제로 중간에 빼앗으면 data structure invariant가 깨질 수 있다.
- 그래서 많은 synchronization resource는 preemption이 어렵다.
Circular Wait
- circular wait은 기다림의 고리가 생기는 조건이다.
- 예를 들어 다음과 같은 상황이다.
- P0은 P1이 가진 resource를 기다린다.
- P1은 P2가 가진 resource를 기다린다.
- P2는 P0이 가진 resource를 기다린다.
- 이처럼 원형의 dependency가 생기면 아무도 진행하지 못한다.
- lock 예시에서는 다음과 같다.
- T1은 lock A를 잡고 lock B를 기다린다.
- T2는 lock B를 잡고 lock A를 기다린다.
- circular wait은 deadlock prevention에서 가장 실용적으로 많이 다루는 조건이다.
- lock acquisition order를 정하면 circular wait을 막을 수 있다.
Resource-Allocation Graph
- deadlock을 분석할 때 resource-allocation graph를 사용할 수 있다.
- graph는 vertex와 edge로 구성된다.
- vertex는 두 종류다.
- process vertex
P = {P1, P2, ..., Pn}
- resource vertex
R = {R1, R2, ..., Rm}
- process vertex
- edge도 두 종류가 있다.
- request edge
Pi → Rj- process
Pi가 resourceRj를 요청하고 기다리는 상태
- assignment edge
Rj → Pi- resource
Rj가 processPi에 할당된 상태
- request edge
- 이 graph를 보면 deadlock 가능성을 시각적으로 확인할 수 있다.
Basic Facts
- resource-allocation graph에 cycle이 없으면 deadlock은 없다.
- graph에 cycle이 있으면 deadlock 가능성이 있다.
- 단, resource instance 수에 따라 해석이 달라진다.
- 각 resource type에 instance가 하나뿐이면 cycle은 deadlock을 의미한다.
- 각 resource type에 instance가 여러 개 있으면 cycle이 있어도 반드시 deadlock은 아닐 수 있다.
- 즉 cycle은 deadlock의 중요한 신호지만, resource instance 수에 따라 정확한 판단이 달라진다.
Methods for Handling Deadlocks
- deadlock을 다루는 방법은 크게 네 가지다.
- 첫째, deadlock prevention이다.
- deadlock의 necessary condition 중 하나를 제거한다.
- 이 단원에서 주로 다루는 방법이다.
- 둘째, deadlock avoidance다.
- resource request를 받을 때마다 승인할지 거절할지 판단한다.
- system이 deadlock state로 들어가지 않도록 미리 피한다.
- 추가적인 resource usage 정보가 필요하다.
- 셋째, deadlock detection and recovery다.
- deadlock이 발생하는 것을 허용한다.
- 이후 deadlock을 감지하고 복구한다.
- 넷째, ignore deadlock이다.
- deadlock 문제를 무시하고, 발생하지 않는다고 가정한다.
- UNIX를 포함한 많은 OS가 실용적으로 이 방식을 사용한다.
- 이 강의에서는 주로 deadlock prevention을 다룬다.
Deadlock Prevention
- deadlock prevention의 핵심은 deadlock의 네 필요조건 중 하나를 제거하는 것이다.
- 네 조건은 다음과 같다.
- mutual exclusion
- hold and wait
- no preemption
- circular wait
- deadlock은 네 조건이 모두 만족되어야 발생한다.
- 따라서 하나만 깨도 deadlock을 예방할 수 있다.
- 하지만 모든 조건을 깨는 것이 현실적으로 쉬운 것은 아니다.
- 예를 들어 mutual exclusion은 shared writable data를 보호하기 위해 필요한 경우가 많다.
- no preemption도 lock에서는 강제로 깨기 어렵다.
- 그래서 실제로 가장 실용적인 방법은 보통 circular wait을 막는 것이다.
- 즉 lock ordering을 정하고, 모든 code가 그 순서를 지키게 한다.
Preventing Mutual Exclusion
- mutual exclusion을 제거하면 deadlock 조건 하나가 사라진다.
- sharable resource에는 mutual exclusion이 필요하지 않다.
- 예: read-only file
- 하지만 non-sharable resource에는 mutual exclusion이 필요하다.
- 예: lock
- 예: write 가능한 shared data
- mutual exclusion을 줄이는 한 가지 전략은 lock을 없애고 atomic primitive를 사용하는 것이다.
- 예를 들어
Compare-And-Swap을 사용하면 간단한 update를 lock 없이 처리할 수 있다.
c
int CompAndSwap(int *addr, int expected, int new);- 이 함수는
*addr값이expected일 때만new로 바꾼다. - 성공하면 1, 실패하면 0을 반환한다고 생각할 수 있다.
- lock을 쓰는 add는 다음과 같다.
c
void add(int *val, int amt)
{
mutex_lock(&m);
*val += amt;
mutex_unlock(&m);
}- CAS를 쓰면 lock 없이 retry loop로 만들 수 있다.
c
void add(int *val, int amt)
{
int old;
do {
old = *val;
} while (!CompAndSwap(val, old, old + amt));
}old는while조건에서도 보여야 하므로 loop 밖에서 선언해야 한다.- 이 방식은 lock을 없애므로 deadlock 가능성을 줄인다.
- 하지만 lock-free code는 구현과 검증이 어렵다.
Wait-Free Algorithms
- mutual exclusion을 줄이는 또 다른 예는 wait-free algorithm이다.
- linked list insert를 생각해보자.
- lock을 사용하는 방식은 다음과 같다.
c
void insert(int val)
{
node_t *n = malloc(sizeof(*n));
n->val = val;
lock(&m);
n->next = head;
head = n;
unlock(&m);
}- CAS를 사용하면 다음처럼 lock 없이 만들 수 있다.
c
void insert(int val)
{
node_t *n = malloc(sizeof(*n));
n->val = val;
do {
n->next = head;
} while (!CompAndSwap(&head, n->next, n));
}- 동작은 다음과 같다.
- 현재 head를 읽어
n->next에 저장한다. - head가 여전히 그 값이면 head를
n으로 바꾼다. - 중간에 다른 thread가 head를 바꿨으면 실패하고 다시 시도한다.
- 현재 head를 읽어
- 이 방식은 lock을 사용하지 않으므로 deadlock 위험이 줄어든다.
- 하지만 ABA 문제, memory reclamation 문제 등 복잡한 문제가 생길 수 있다.
- 따라서 실무에서는 lock-free algorithm이 항상 쉬운 선택은 아니다.
Preventing Hold-and-Wait
- hold-and-wait을 막으려면 process가 resource를 요청할 때 이미 다른 resource를 들고 있지 않게 해야 한다.
- 방법은 크게 두 가지로 생각할 수 있다.
- 첫째, process가 실행을 시작하기 전에 필요한 모든 resource를 한 번에 요청하게 한다.
- 둘째, process가 어떤 resource도 들고 있지 않을 때만 새 resource를 요청하게 한다.
- lock 관점에서는 다음과 같은 전략이 가능하다.
- 필요한 모든 lock을 한 번에 잡는다.
- 이후에는 lock을 release할 수는 있지만, 다시 새 lock을 acquire하려면 모든 lock을 놓은 뒤 다시 시작해야 한다.
- 이를 구현하기 위해 meta lock을 사용할 수 있다.
c
lock(&meta);
lock(&L1);
lock(&L2);
...
unlock(&meta);
/* critical section */
unlock(...);metalock은 여러 lock을 잡는 과정 자체를 serialize한다.- 그러면 두 thread가 서로 다른 순서로 lock을 잡다가 꼬이는 상황을 줄일 수 있다.
- 하지만 단점도 있다.
- concurrency가 크게 줄어든다.
- 모든 resource를 미리 알아야 한다.
- 필요한 resource가 동적으로 결정되는 경우 어렵다.
Preventing No Preemption
- no preemption을 막으려면 resource를 강제로 회수할 수 있어야 한다.
- lock에서는 일반적으로 강제 회수가 어렵다.
- 대신 비슷한 효과를 내는 방식으로
trylock을 사용할 수 있다. - 예를 들어 A를 잡은 뒤 B를 잡으려 했는데 실패하면 A를 풀고 처음부터 다시 시도한다.
c
top:
lock(A);
if (trylock(B) == -1) {
unlock(A);
goto top;
}
...- 이 방식은 다음 상황을 막는다.
- A를 잡은 상태로 B를 영원히 기다림
- B를 못 잡으면 A를 놓고 다시 시작하므로 hold-and-wait도 완화된다.
- 하지만 단점도 있다.
- 계속 실패하면 livelock처럼 보일 수 있다.
- retry 비용이 커질 수 있다.
- 코드가 복잡해진다.
- 그래도 blocking lock 대신 trylock을 쓰면 deadlock 가능성을 줄일 수 있다.
Preventing Circular Wait
- circular wait을 막는 가장 대표적인 방법은 total ordering을 부여하는 것이다.
- 모든 resource type에 순서를 정한다.
- 그리고 모든 process/thread가 resource를 항상 증가하는 순서로 요청하게 한다.
- 예를 들어 lock order를 다음처럼 정했다고 하자.
- A before B
- B before C
- 그러면 code는 A를 잡은 뒤 B를 잡을 수 있다.
- 하지만 B를 잡은 상태에서 A를 잡으면 안 된다.
- 전략은 다음과 같다.
- 어떤 lock이 어떤 lock보다 먼저 잡혀야 하는지 결정한다.
- 그 순서를 문서화한다.
- 모든 code가 그 순서를 따르게 한다.
- 이 방식은 system에 distinct layer가 있을 때 잘 작동한다.
- 예를 들어 file system layer, memory layer, page table layer처럼 계층이 있으면 lock order를 정하기 쉽다.
- 실제 kernel code에서도 lock ordering은 매우 중요하다.
Lock Ordering
- lock ordering은 deadlock prevention에서 가장 실용적인 방법이다.
- 핵심은 모든 code path가 lock을 같은 순서로 잡는 것이다.
- 예를 들어 어떤 code는
A → B순서로 lock을 잡고, 다른 code는B → A순서로 잡으면 deadlock 위험이 생긴다. - 따라서 global lock order를 정해야 한다.
- 예시는 다음과 같다.
- 먼저
mmap_sem - 그다음
i_mmap_rwsem - 그다음
page_table_lock - 그다음
i_pages lock
- 먼저
- 실제 Linux kernel도 lock ordering을 주석으로 문서화한다.
- lock ordering은 단순하지만 강력하다.
- 다만 system이 커질수록 모든 lock 관계를 관리하기 어려워진다.
- 그래도 현실적으로 deadlock prevention에서 가장 많이 쓰이는 방법 중 하나다.
Deadlock Avoidance
- deadlock avoidance는 prevention과 다르다.
- prevention은 deadlock 조건 자체를 깨는 방식이다.
- avoidance는 각 resource request를 받을 때 이 요청을 승인해도 안전한지 판단한다.
- 즉 system이 unsafe state로 들어가지 않게 한다.
- 대표적인 예는 banker’s algorithm이다.
- 하지만 avoidance는 추가 정보가 필요하다.
- 각 process가 앞으로 어떤 resource를 얼마나 요청할지
- system의 available resource
- 현재 allocation 상태
- 일반-purpose OS에서는 이런 정보를 미리 알기 어렵다.
- 그래서 이 강의에서는 deadlock avoidance를 자세히 다루지 않는다.
Deadlock Detection and Recovery
- deadlock detection and recovery는 deadlock이 생기는 것을 허용한다.
- 대신 주기적으로 deadlock이 있는지 검사한다.
- deadlock이 발견되면 recovery를 수행한다.
- recovery 방법은 다음과 같을 수 있다.
- process를 kill한다.
- resource를 강제로 회수한다.
- rollback한다.
- 이 방식은 database system 등에서는 사용될 수 있다.
- 하지만 OS kernel lock deadlock에서는 recovery가 쉽지 않다.
- kernel 내부 deadlock은 system 전체가 멈추는 심각한 문제가 될 수 있다.
Ignore Deadlock
- 많은 OS는 deadlock을 완전히 해결하지 않고 사실상 무시하는 전략을 사용한다.
- “deadlock이 드물게 발생한다고 보고, 발생하면 reboot하거나 debugging한다”는 식이다.
- UNIX 계열 OS도 실용적으로 이 접근을 사용하는 경우가 많다.
- 이유는 deadlock을 완벽히 예방하거나 회피하려면 비용이 크고 구조가 복잡해지기 때문이다.
- 물론 kernel 개발에서는 lock ordering이나 coding convention으로 deadlock을 최대한 줄인다.
- 즉 deadlock을 이론적으로 완전히 없애기보다는, 실용적인 규칙으로 위험을 낮춘다.
Atomicity Bug 해결 전략
- atomicity bug를 막으려면 “논리적으로 하나의 작업”을 하나의 critical section으로 묶어야 한다.
- 예시는 다음과 같다.
- check와 use를 같은 lock 안에 둔다.
- read-modify-write를 같은 lock 안에 둔다.
- data structure invariant가 깨지는 구간을 lock으로 보호한다.
- 중요한 원칙은 다음이다.
- shared variable을 읽고 쓰는 모든 thread가 같은 lock을 사용해야 한다.
- lock을 일부 code path에만 적용하면 보호가 깨진다.
- lock 범위가 너무 작으면 중간 상태가 노출될 수 있다.
- 따라서 correctness가 의심될 때는 concurrency를 조금 더 제한하는 편이 안전하다.
Ordering Bug 해결 전략
- ordering bug를 막으려면 필요한 event 순서를 명시적으로 표현해야 한다.
- 대표적인 도구는 다음과 같다.
- condition variable
- semaphore
- join
- barrier
- 예를 들어 “초기화가 끝난 뒤 사용해야 한다”는 조건은 다음처럼 표현한다.
initializedflag를 둔다.- 사용자는
initialized == 1이 될 때까지 기다린다. - 초기화 thread는
initialized = 1로 바꾸고 signal한다.
- condition variable을 쓸 때는 반드시 state variable과 mutex를 함께 사용해야 한다.
- CV signal은 history가 없기 때문에 flag 없이 signal만 쓰면 lost wakeup이 생길 수 있다.
- 따라서 ordering bug를 고칠 때는 event 자체보다 event를 나타내는 state를 보호하는 것이 중요하다.
Deadlock 해결 전략
- deadlock을 줄이는 실용적인 전략은 다음과 같다.
- 첫째, lock을 가능한 한 적게 잡는다.
- 불필요한 nested lock을 피한다.
- 둘째, lock hold time을 줄인다.
- lock을 잡은 채 오래 걸리는 작업을 하지 않는다.
- 셋째, lock order를 정한다.
- 모든 code path가 같은 순서로 lock을 잡게 한다.
- 넷째, 필요하면 trylock을 사용한다.
- 실패하면 들고 있던 lock을 풀고 retry한다.
- 다섯째, lock-free나 wait-free 구조를 고려한다.
- 단, 복잡도가 높으므로 신중해야 한다.
- 여섯째, lock order와 lock의 보호 대상 data를 문서화한다.
- 어떤 lock이 어떤 invariant를 보호하는지 명확해야 한다.
- 가장 현실적인 방법은 lock ordering을 정하고 지키는 것이다.
Deadlock Prevention Summary
- Mutual exclusion 제거
- 가능하면 lock-free 또는 atomic primitive를 사용한다.
- Hold-and-wait 제거
- 필요한 lock을 한 번에 모두 잡는다.
- meta lock을 사용할 수 있다.
- No preemption 제거
trylock실패 시 잡고 있던 lock을 풀고 다시 시도한다.
- Circular wait 제거
- 모든 lock에 global ordering을 부여하고 항상 같은 순서로 잡는다.
Summary
- 이 단원은 concurrency bug의 대표 유형을 다룬다.
- 주요 bug는 다음 세 가지다.
- atomicity bug
- ordering bug
- deadlock
- atomicity bug는 함께 실행되어야 하는 코드가 중간에 끊겨서 발생한다.
- atomicity bug는 lock으로 critical section을 보호해 해결할 수 있다.
- ordering bug는 특정 작업 순서가 보장되지 않아서 발생한다.
- ordering bug는 condition variable이나 semaphore로 event ordering을 보장해 해결할 수 있다.
- deadlock은 process/thread들이 서로의 resource를 기다리며 영원히 멈추는 상태다.
- deadlock의 네 가지 필요조건은 다음과 같다.
- mutual exclusion
- hold and wait
- no preemption
- circular wait
- deadlock prevention은 이 네 조건 중 하나를 제거하는 방식이다.
- mutual exclusion을 없애려면 lock-free atomic primitive를 사용할 수 있다.
- hold-and-wait을 없애려면 필요한 resource를 한 번에 요청하게 할 수 있다.
- no preemption을 완화하려면 trylock 실패 시 들고 있던 resource를 풀고 retry할 수 있다.
- circular wait을 막으려면 total lock ordering을 정하면 된다.
- 실제로 가장 실용적인 deadlock prevention 방법은 lock ordering이다.
- 이 단원의 핵심은 concurrency bug는 timing에 따라 드러나기 때문에 찾기 어렵고, 따라서 처음부터 atomicity, ordering, lock order를 명확히 설계해야 한다는 것이다.