운영체제 아주 쉬운 세가지 이야기 10주차(락)

2025. 11. 11. 19:30개념 공부

목차


1) 왜 락이 필요한가: 레이스와 원자성

멀티스레드에서 counter = counter + 1은 보통 load; add; store 세 단계로 분해됩니다. 중간에 스케줄러/인터럽트가 끼면 다른 스레드가 같은 변수에 손대어 증분 손실이 발생합니다. 은 이 세 단계를 하나의 원자 구간(임계구역)으로 만들어 레이스를 차단합니다.

// 개념: 임계구역 보호
lock(L);
counter = counter + 1;   // Critical Section
unlock(L);

2) POSIX 뮤텍스 빠른 감

#include <pthread.h>

pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;  // 정적 초기화
// 또는 동적: pthread_mutex_init(&m, NULL);

void* worker(void*){
  pthread_mutex_lock(&m);
  // 공유 데이터 갱신
  pthread_mutex_unlock(&m);
  return NULL;
}
  • 정적 초기화: 상수 매크로로 즉시 사용. 설정 단순.
  • 동적 초기화: pthread_mutex_init(속성 지정 가능). 해제는 pthread_mutex_destroy.
  • fine-grained로 자료구조별 락을 분리해 병렬성 극대화.

3) 락을 평가하는 3축

  • 정합성(Correctness): 상호배제 보장?
  • 공정성(Fairness): 대기 스레드가 굶지 않나?
  • 성능(Performance): 무경합/경합/다중 CPU에서 오버헤드?

4) 스핀락 계보: 실패한 플래그 ⟶ TAS ⟶ CAS ⟶ LL/SC

4.1 단순 플래그 실패 사례

while(flag){}; flag=1; 형태는 테스트와 설정이 분리되어 있어 인터럽트 타이밍에 따라 동시 진입이 발생합니다(오류).

4.2 Test-and-Set(TAS) 스핀락

int test_and_set(int* p){
  int old = *p;
  *p = 1;
  return old;
}

void lock(volatile int* f){
  while (test_and_set((int*)f) == 1) { /* spin */ }
}
void unlock(volatile int* f){ *f = 0; }
  • 장점: 올드값 테스트+설정을 원자로 묶어 상호배제 보장.
  • 단점: 단일 CPU에서 홀더가 선점되면 타 대기자들이 타임슬라이스를 통째로 낭비하며 스핀.

4.3 Compare-and-Swap(CAS)

int cas(int* p, int expected, int newv){
  int old = *p;
  if (old == expected) *p = newv;
  return old;
}

void lock(int* f){
  while (cas(f, 0, 1) != 0) { /* spin */ }
}

의미는 TAS와 유사하지만 “기대값일 때만” 바꿉니다. 더 강력한 원자 프리미티브로 락-프리 구조에도 활용됩니다.

4.4 LL/SC (Load-Linked / Store-Conditional)

// 의사 코드
while (true) {
  while (LL(flag) == 1) {}         // 0 될 때까지 대기
  if (SC(flag, 1) == success) break;  // 누가 끼어들었으면 실패
}

중간에 다른 저장이 끼면 SC가 실패하여 재시도. 현대 아키텍처 일부에서 제공.


5) 티켓 락: 공정성을 보장하는 스핀락

typedef struct { int ticket; int turn; } lock_t;

void lock(lock_t* L){
  int my = fetch_and_add(&L->ticket); // 내 차례 번호
  while (L->turn != my) { /* spin */ } // 순번 대기
}
void unlock(lock_t* L){ L->turn++; }
  • 장점: 선착순(FIFO)로 기아(starvation) 방지.
  • 단점: 여전히 스핀이라 단일 CPU 경합에 비효율.

6) 스핀 말고 잠자기: 큐 + park/unpark, guard, futex

6.1 큐 기반 락(개념)

핵심 아이디어는 “대기자는 큐에 줄 서고 잠든다”입니다. 락 보유자가 풀 때 다음 대기자만 깨운다. 이때 내부 상태(플래그, 큐) 업데이트를 보호하려 guard(짧은 스핀락)를 사용합니다.

// 요지: guard로 내부 상태 보호 + 대기자는 park(), 해제 시 next를 unpark()
struct lock { int flag; int guard; queue q; };

lock():
  while (TAS(guard)==1) {}       // 내부 진입 보호
  if (flag==0) { flag=1; guard=0; return; }
  q.push(self); guard=0; park();  // 큐에 넣고 "잠자기"

unlock():
  while (TAS(guard)==1) {}
  if (q.empty()) { flag=0; }
  else { unpark(q.pop()); }      // 락을 직접 "핸드오버"
  guard=0;
  • guard: 락 내부 메타데이터(플래그/큐) 갱신을 원자적으로 만드는 아주 짧은 스핀 보호.
  • 주의: park() 전에 반드시 guard를 풀어야 함. 순서가 바뀌면 깰 기회를 잃고 영원히 잠듦(wakeup/wait race). 일부 OS는 setpark() 같은 보조 호출로 이 경합을 완화.

6.2 Linux futex 감각

futex는 사용자 공간 값을 조건으로 커널 대기열을 활용합니다. futex_wait(addr, expected)는 메모리가 기대값일 때만 잠들고, futex_wake(addr)가 하나를 깨웁니다. “빠른 경로(무경합)”는 유저공간 원자 연산으로 끝내고, “느린 경로(경합)”만 커널 진입하므로 효율적.


7) 투-페이즈 락: 잠깐 스핀 후 수면

락이 “곧” 풀릴 상황에서는 스핀이 이득일 수 있습니다. 1단계 짧게 스핀 → 실패 시 2단계 수면(park/futex). 경합·CPU수·워크로드에 따라 실효가 갈리므로 측정이 중요합니다.


8) 우선순위 역전과 회피

낮은 우선순위 스레드가 락을 잡고 있고 높은 우선순위 스레드가 그 락을 기다리는데, 중간 우선순위 스레드가 CPU를 독점하면 높은 스레드가 영원히 기다리는 역전이 생길 수 있습니다. 해결은 우선순위 상속 혹은 수면 기반 락, 또는 동일 우선순위 정책입니다.


9) 실무 패턴: Pthreads 베스트 프랙티스

9.1 뮤텍스 초기화 전략

  • 정적 초기화: PTHREAD_MUTEX_INITIALIZER — 간단, 리소스 수명 관리 쉬움.
  • 동적 초기화: pthread_mutex_init/destroy — 특성 지정(재귀/에러체크 등). 해제 누락 위험은 있지만 라이브러리/RAII로 관리.

9.2 조건 변수로 생산자–소비자

pthread_mutex_t m = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t  not_empty = PTHREAD_COND_INITIALIZER;
queue<Job> q;

void produce(Job j){
  pthread_mutex_lock(&m);
  q.push(j);
  pthread_cond_signal(&not_empty);   // 하나 깨움
  pthread_mutex_unlock(&m);
}
Job consume(){
  pthread_mutex_lock(&m);
  while(q.empty())                   // <-- 반드시 while!
    pthread_cond_wait(&not_empty, &m);
  Job j = q.front(); q.pop();
  pthread_mutex_unlock(&m);
  return j;
}
  • 왜 while? 스퍼리어스 웨이크업/여러 소비자 경쟁 재확인.
  • 타임아웃 의존 제거, 상태 변수로 대기 조건을 항상 재검증.

9.3 성능 팁

  • 임계구역 짧게: 복사-갱신(COW), 사전계산, 배치 적용.
  • 락 분할: 자료구조별 뮤텍스, 샤딩(키 해시로 버킷 잠금).
  • 원자 연산: 단순 카운터/플래그는 std::atomic<...> 고려.
  • 핫 루프에 스핀 금지: 단일 CPU 경합은 수면 기반으로.

10) 데드락 회피와 설계 체크리스트

  • 4조건: 상호배제, 보유-대기, 비선점, 환형대기.
  • 주문형 회피: 고정 락 순서, try-lock + backoff, 타임아웃, 큰 락으로 단순화(성능과 트레이드오프).
  • 리뷰 체크: 임계구역 안에서 블록킹 I/O/긴 연산/콜백 호출 금지.

11) 면접 예상 질문 & 미니 퀴즈

한 줄 스택

  1. TAS vs CAS vs LL/SC 차이와 공통점은? — 모두 원자성 제공, CAS/LLSC가 더 일반적.
  2. 티켓 락의 장점/단점? — FIFO 공정성 / 스핀 비용.
  3. futex의 “빠른 경로”란? — 유저공간 원자 연산으로 끝나 커널 진입 회피.
  4. priority inversion 해결 기법? — 우선순위 상속, 수면 기반 락, 동일 우선순위.
  5. 조건 변수에서 while이 필수인 이유? — 스퍼리어스 웨이크업/조건 재확인.

미니 퀴즈(정답 접기/펼치기)

Q1. guard는 왜 필요한가?

락 내부 메타데이터(플래그/대기열) 갱신을 짧은 원자 구간으로 보호하기 위해서입니다. 대기자 등록과 깨움이 섞이면 새는 깨움, 영구수면 등 경쟁이 생깁니다.

Q2. park 전 guard를 풀지 않으면 무슨 일이?

홀더가 바로 unpark를 호출해도 guard 경쟁 때문에 신호가 유실되어 대기자가 영원히 잠드는 경합이 생깁니다(so-called wakeup/wait race). setpark() 같은 보조 호출로 완화합니다.

Q3. 단일 CPU에서 스핀락이 비효율적인 이유?

락 보유자가 선점되면 나머지 스레드가 한 타임슬라이스를 통째로 소비하며 스핀합니다. 수면 기반/투-페이즈가 적합합니다.


마무리: 어떤 걸 가져가야 하나

  • 문제의식: 원자성·임계구역·레이스 재현·측정.
  • 스핀 vs 수면: 워크로드/CPU 수에 맞는 선택(티켓, futex, two-phase).
  • 공정성/기아: FIFO(티켓락)나 대기큐로 통제.
  • 실무 원칙: while-wait, 짧은 임계구역, 락 순서, atomic의 적절한 사용.