[OSTEP] 스터디 13주차 - 병행성 3 Part.2

앞선 학습에서 락(Lock), 컨디션 변수(Condition Variable), 세마포어(Semaphore) 같은 도구를 배웠다. 하지만 도구를 안다고 해서 결함 없는 프로그램을 짤 수 있는 것은 아니다. 이번 포스팅에서는 실제 상용 소프트웨어에서 빈번하게 발생하는 동시성 버그의 유형과, 쓰레드(Thread)의 대안으로 제시된 이벤트(Event) 기반 동시성 모델이 가진 현실적인 어려움에 대해 OSTEP 책 내용을 바탕으로 깊이 있게 파고든다.


1. 흔한 동시성 문제들 (Common Concurrency Problems)

Lu 등의 연구진이 MySQL, Apache, Mozilla, OpenOffice와 같은 대형 오픈소스 소프트웨어의 버그를 분석한 결과, 동시성 버그는 크게 비교착 상태(Non-Deadlock) 버그교착 상태(Deadlock) 버그로 나뉜다. 놀랍게도 전체 동시성 버그 중 약 70% 이상이 Deadlock이 아닌 Non-Deadlock 버그였다.

1.1. 비 교착 상태 버그 (Non-Deadlock Bugs)

이 유형의 버그는 주로 원자성 위반(Atomicity Violation)순서 위반(Order Violation) 두 가지가 대부분을 차지한다.

① 원자성 위반 (Atomicity Violation)

  • 정의: 더 이상 나눌 수 없는 작업 단위(Atomic Region)가 중간에 끼어든 다른 쓰레드에 의해 방해받아, 의도했던 원자성이 깨지는 현상이다.
  • 사례 (MySQL):
    코드에서 Thread 1이 proc_infoNULL이 아님을 확인하고 fputs를 호출하기 직전에, Thread 2가 끼어들어 NULL로 바꿔버리면 Thread 1은 충돌(Crash)하게 된다.
// Thread 1
if (thd->proc_info) {
    // (이 시점에 인터럽트 발생하여 Thread 2가 실행된다면?)
    fputs(thd->proc_info, ...);
}

// Thread 2
thd->proc_info = NULL;
  • 해결: 공유 변수를 참조하는 모든 구간에 락(Lock)을 걸어 검사(Check)와 사용(Use)이 끊기지 않고 원자적으로 실행되도록 보장해야 한다.

② 순서 위반 (Order Violation)

  • 정의: 두 쓰레드 간의 메모리 접근 순서가 의도한 순서(A가 항상 B보다 먼저 실행)대로 지켜지지 않는 현상이다.
  • 사례: Thread 2는 Thread 1이 mThread를 초기화했다고 가정하고 실행된다. 만약 Thread 2가 먼저 실행된다면 NULL 포인터 참조 등으로 오류가 발생한다.
// Thread 1: 초기화 수행
void init() {
    mThread = PR_CreateThread(mMain, ...);
    mThreadState = initialized;
}

// Thread 2: 사용
void mMain(...) {
    mState = mThread->State; // mThread가 초기화되지 않았다면?
}
  • 해결: 강제적인 순서 부여가 필요하다. 컨디션 변수(Condition Variable)세마포어(Semaphore)를 사용하여, Thread 2가 Thread 1의 작업 완료 신호를 기다리게 만들어야 한다.

1.2. 교착 상태 버그 (Deadlock Bugs)

교착 상태는 쓰레드들이 서로가 가진 자원(Lock)을 놓기만을 기다리며 영원히 멈춰버리는 현상이다.

① 데드락 발생의 4가지 필수 조건 (Coffman Conditions)

다음 네 가지 조건이 모두 만족되어야 데드락이 발생한다.

  1. 상호 배제 (Mutual Exclusion): 자원을 한 번에 하나의 쓰레드만 사용할 수 있다.
  2. 점유 및 대기 (Hold-and-wait): 자원을 가진 상태에서 다른 자원을 기다린다.
  3. 비선점 (No Preemption): 다른 쓰레드가 가진 자원을 강제로 뺏을 수 없다.
  4. 환형 대기 (Circular Wait): 자원 대기 관계가 꼬리를 물어 순환 고리를 형성한다.

② 해결 전략

데드락을 해결하는 방법은 위 4가지 조건 중 하나를 깨트리는 것이다.

  1. 순환 대기(Circular Wait) 제거 (가장 실용적):
    • 모든 락에 전역적인 순서(Total Ordering)를 부여한다. 예를 들어, 항상 L1을 획득한 후에만 L2를 획득하도록 코드를 작성하면 순환 대기가 발생하지 않는다.
  2. 점유 및 대기(Hold-and-wait) 제거:
    • 필요한 모든 락을 처음에 원자적으로 한꺼번에 획득한다. 이를 위해 전역적인 prevention 락을 사용할 수 있지만, 병행성이 저하되는 단점이 있다.
  3. 비선점(No Preemption) 제거:
    • pthread_mutex_trylock()과 같은 인터페이스를 사용한다. 락 획득에 실패하면, 가지고 있던 락을 모두 놓고 다시 시도한다. 단, 무한히 시도만 반복하는 라이브락(Livelock)에 빠질 위험이 있다.
  4. 상호 배제(Mutual Exclusion) 제거:
    • 락을 사용하지 않는 Wait-free 자료구조를 사용한다. 하드웨어 명령인 Compare-And-Swap (CAS) 등을 이용해 락 없이 원자적 연산을 수행한다. 구현이 매우 복잡하다는 단점이 있다.

2. 이벤트 기반 동시성의 어려움 (Difficulty of Using Events)

쓰레드 기반 프로그래밍의 대안으로, 이벤트 기반 동시성(Event-based Concurrency)이 있다. 이는 select()나 poll() 같은 시스템 콜을 이용해 이벤트 루프(Loop)를 돌며 작업을 처리하는 방식이다. 락(Lock)이 필요 없고 스케줄링을 개발자가 제어할 수 있다는 장점이 있지만, 치명적인 단점이 있다.

1) 멀티 코어 활용의 어려움 (Multiple CPUs)

이벤트 루프는 기본적으로 싱글 쓰레드에서 돌아간다. 따라서 현대적인 멀티 코어 CPU의 성능을 활용하지 못한다. 이를 해결하려면 여러 개의 이벤트 핸들러를 병렬로 실행해야 하는데, 이러면 결국 공유 데이터 보호를 위해 다시 락(Lock)을 써야 한다. 즉, 이벤트 방식의 최대 장점(락이 필요 없음, 데드락 없음)이 사라지게 된다.

2) 블로킹 시스템 콜의 문제 (Blocking System Calls)

이벤트 핸들러 내에서 디스크 I/O 같은 블로킹(Blocking) 작업을 수행하면 전체 시스템이 멈춘다. 쓰레드 방식에서는 한 쓰레드가 멈춰도 OS가 다른 쓰레드를 실행시키면 되지만, 싱글 쓰레드 이벤트 루프에서는 서버 전체가 멈춰버린다. 따라서 모든 I/O를 비동기(Asynchronous) 방식으로 처리해야 하는데, 이는 코드 복잡도를 크게 높인다.

3) 암시적 블로킹 (Implicit Blocking)

비동기 I/O를 사용하더라도 피할 수 없는 블로킹이 있다. 바로 페이지 폴트(Page Fault)다. 이벤트 핸들러가 접근하려는 메모리가 디스크(Swap)에 있다면, OS는 해당 페이지를 메모리로 로드할 때까지 프로세스를 멈춘다(Block). 이는 프로그래머가 제어할 수 없는 영역이며, 서버 성능에 큰 타격을 줄 수 있다.

4) 상태 관리의 복잡성 (State Management)

쓰레드 기반 코드에서는 함수의 지역 변수나 실행 흐름 정보가 스택(Stack)에 자동으로 저장된다. 하지만 이벤트 방식에서는 I/O 작업을 요청하고 리턴해버리므로, 다음 이벤트(I/O 완료)가 발생했을 때 이어서 작업하기 위해 이전 상태를 수동으로 저장(Manual Stack Management)해야 한다. 이를 위해 Continuation 같은 복잡한 기법을 사용해야 한다.

5) API 변화에 대한 취약성

코드 내의 어떤 라이브러리 함수가 내부적으로 블로킹 방식으로 변경되거나 동작이 바뀌면, 이벤트 루프 전체가 영향을 받는다. 쓰레드 방식보다 라이브러리나 시스템 API의 변화에 훨씬 민감하게 반응하며, 유지보수가 어렵다.


요약 (Summary)

  • 동시성 버그는 데드락보다 원자성 위반이나 순서 위반 같은 Non-Deadlock 버그가 더 흔하다. 이를 막기 위해 락과 컨디션 변수를 적재적소에 활용해야 한다.
  • 데드락을 막기 위한 가장 실용적인 방법은 락 획득에 전역적인 순서(Ordering)를 정하는 것이다.
  • 이벤트 기반 모델은 락이 필요 없다는 장점이 있지만, 블로킹 문제, 멀티 코어 활용의 한계, 수동 상태 관리의 복잡함 때문에 만능 해결책은 아니다.

결국 동시성 프로그래밍에서 "은탄환(Silver Bullet)"은 없다. 상황에 따라 쓰레드와 락을 신중하게 사용하거나, 이벤트 방식을 사용할 때의 제약 사항을 명확히 이해하고 접근하는 것이 중요하다.