공유 자원의 동기화 방법

설현아·2025년 5월 9일

동일한 프로세스 내에 있는 쓰레드는 메모리 자원을 공유한다.(스택 영역은 제외하고) 메모리 자원을 공유하면 보다 빠르고 간단하게 데이터를 관리할 수 있다. IPC라는 복잡한 장치가 없어도 데이터에 접근할 수 있기 떄문이다.

하지만 이럴 경우, 문제가 생긴다.
하나의 자원에 여러 쓰레드가 동시에 접근하는 경우이다.

하나의 예를 들어보자.
은행에서는 계좌를 관리한다. 어떤 계좌의 잔액에는 여러 쓰레드가 접근할 수 있다.

현재 잔액(balance)이 5만원이라고 할 때, 쓰레드 1(T1)에서는 1만원을 입금하고, 쓰레드 2(T2)에서는 1만원을 출금하려고 한다.
우리가 예상한 결과는 잔액(balance) 5만원이다.

하지만 T1, T2가 동시에 수행되면 어떤 결과로 나타날까?

CPU는 데이터를 수정하기 위해서 Register를 통해 읽어오고, 연산한 이후에 데이터를 다시 쓴다는 점을 상기하며 따라가보자.

  1. T1 → Register1 = balance
  2. T1 → Register1 = Register1 + 10000
  3. (T2 인터럽트)
  4. T2 → Register2 = balance
  5. T2 → Register2 = Register2 - 10000
  6. T2 → balance = Register2 → 잔액은 40,000원
  7. T1 → balance = Register1 → 잔액은 60,000원

이렇게 Thread1의 수행이 종료되기 전에, 새로운 Thread2의 요청에 따른 인터럽트가 발생하면 연산이 엉망이 된다.

경쟁 조건 Race Condition

위와 같이,
잔액이라는 공유 자원에 여러 쓰레드가 동시에 접근하여, 비동기적 실행 순서로 인해 결과가 달라지는 경우를 race condition 이라고 한다.

운영체제(자세히 말하면 커널)는 이러한 race condition 문제를 해결해야 한다.

동기화 Synchronization

위의 경쟁 조건을 해결하는 방안은 동기화이다. lock, mutex, semaphore 등으로 보호할 수 있다.
더 간단히 말하자면, T1이 작업 중일 때는 T2가 접근하지 못하도록 막는 것이다.

가장 간단하게는 이런 생각을 할 수 있다.
하나의 쓰레드가 동작 중일 때, 아무도 끼어들지 않으면 완전하게 이 문제가 해결될 수 있겠다!

비선점형 커널을 쓰면 어때?

동기화가 단순해지지만 이건 초강력하게 비추천한다. race condition을 막기 위해 전체 CPU 인터럽트를 비활성화하면 사용자는 마우스를 클릭하거나 키보드로 입력을 해도 바로바로 되지 않을 것이다. 긴 커널 작업 중 다른 스레드가 CPU를 점유하지 못해 반응성이 저하될 수 있다.

그렇다면 인터럽트가 불가능한 상태를 두면 어떨까?

T1이 실행 되다가 예기치 않은 에러를 마주하며 종료 되었다고 가정하자.
현재 T1이 실행 중인 상태이기 때문에 인터럽트가 불가하다. T1이 종료 되었음에도 다른 작업을 수행할 수 없게 된 것이다.

혹은 T1이 우선순위가 낮은 작업이라고 하자. T1이 수행 되던 중에 선착순 이벤트 공지를 받게 되어, 빨리 선착순 클릭해야한다! 그런데 T1의 작업 시간이 하루 종일이다.. 이런 경우에는 무척이나 난감할 것이다.

다른 소프트웨어적인 방법을 떠올리자.

임계구역 Critical Section

어떤 자원이 이미 사용 중이라면 그 사용이 종료될 때까지 다른 쓰레드에서 접근할 수 없도록 설정해주면 되겠다는 아이디어다.
그리고 이렇게 ‘여러 쓰레드가 공유하는 데이터에 접근하는 구역’을 임계구역 critical section이라고 한다.

임계구역은 동시에 두 개 이상의 쓰레드가 접근할 수 없다. 운영체제는 임계구역에 어떠한 쓰레드가 점유하고 있다면 다른 쓰레드는 대기하도록 만들어야 한다.

임계구역의 성질

  1. 상호 배제 mutual exclusion - 한 번에 하나의 쓰레드만 접근 가능
  2. 진행 progress - 하나는 진입 가능해야 함
  3. 한정된 대기 bounded waiting - 무한 대기를 하지 않아야 함

이 임계구역을 어떻게 안전하게 지켜줄 것인지를 고민하여 여러 동기화 Synchronization 방안이 제안되었다.

① turn 사용

쓰레드 식별자로 turn을 지정한다. 각 쓰레드는 자기 자신 turn일 때 자원에 접근한다.

// Thread A
while (turn != A);
    /* Critical Section */
turn = B;
/* Remainder Section */
// Thread B
while (turn != B);
		/* Critical Section */
turn = A;
/* Remainder Section */

이 방법은 bounded waiting을 위반한다.

turn == A 일 때, A가 예기치 못하게 중단된다면, 다른 쓰레드는 평생 실행되지 않는다.

② flag[] 사용

쓰레드 식별자를 원소하는 값을 T/F로 하여 접근을 제어한다. flag[A] == true 라면 자원에 접근한다.

// Thread A
flag[A] = 1;
while (flag[B]);
		/* Critical Section */
flag[A] = 0;
/* Remainder Section */ 
// Thread B
flag[B] = 1;
while (flag[A]);
		/* Critical Section */
flag[B] = 0;
/* Remainder Section */ 

이 방법은 progress를 위반한다.

A, B가 동시에 자원에 접근했을 때, flag[A]flag[B] 모두 true가 되어 어느 쓰레드도 critical section에 진입할 수 없다.

③ turn & flag[] 모두 사용

// Thread A
flag[A] = 1;
while (flag[B] && turn == B);
		/* Critical Section */
flag[A] = 0;
turn = A;
/* Remainder Section */ 
// Thread B
flag[B] = 1;
while (flag[A] && turn == A);
		/* Critical Section */
flag[B] = 0;
turn = B;
/* Remainder Section */ 

이 경우에는 다음과 같이 mutual exclusion을 위반한다.

교착 상태 Dead Lock

알다시피, 하나의 프로그램은 하나의 자원만을 사용하지 않는다. 어떤 자원을 사용할 때는 선후관계가 있어, 첫 자원의 연산을 수행한 이후 두번째 자원의 연산을 수행해야 한다.

이런 상황이 양 쪽으로 동시에 발생하면 어떻게 되는걸까?


말 그대로 Dead Lock 죽음의 자물쇠.. 죽음의 굴레에 갇히게 된다.
이 교착 상태를 해소하기 위해, 그리고 임계 구역을 지키기 위한 방법을 하나씩 살펴보자.

Mutex (Mutual Exclusion)

공유 자원에 접근하는 주체에 Lock을 부여하고, 작업이 완료되면 Lock을 반환하는 방법이다.

// 초기 상태: 자원 1개 (즉, 락이 비어 있음)
int available = 1;

방법

  • Busy Wait (Spin Lock)
    • available이 1이 될 때까지 CPU를 계속 돌며 기다림 (비효율적일 수 있음)
    • 간단하지만 CPU 자원을 낭비할 수 있음
  • Blocking with Wait Queue (데이터 구조 기반)
    • available == 0일 경우, 현재 스레드를 대기열에 넣고 block
    • 락이 해제되면 대기 중인 스레드를 하나 깨움
    • 운영체제의 세마포어나 조건 변수 등이 이 구조를 기반으로 함

acquire() 호출 시

if (available > 0) {
    available = 0; // Lock 획득
    /* ---- 임계 구역 진입 ---- */
} else {
    /* ---- busy wait이거나 block 상태로 진입 ---- */
}

release() 호출 시

available = 1; // 자원 반납
 /* ---- 대기 중인 스레드 하나를 깨움 (blocking 기반일 경우) ---- */

Semaphore

자원의 개수만큼만 스레드가 자원을 점유할 수 있도록 보장하는 방법이다.

struct semaphore {
    unsigned value;       // 자원 개수
    struct list waiters;  // 대기 중인 스레드 목록
};

sema_down() 호출 시

  1. value > 0이면:
    • 자원이 있으므로 value--
    • 대기하지 않고 통과
  2. value == 0이면:
    • 자원이 없음
    • 현재 스레드는 waiters 리스트에 들어가고 block 상태

sema_up() 호출 시

  1. waiters에 대기 중 스레드가 있으면:
    • 그 중 하나를 꺼내서 unblock
    • value는 그대로 (스레드가 자원을 받았기 때문)
  2. 대기 중 스레드가 없으면:
    • value++ (자원이 증가됨)

종류

  • 이진 세마포어 binary semaphore : 자원 개수가 1
  • 카운팅 세마포어 counting semaphore : 자원 개수를 여러 개 둘 수 있다.(프린터가 3개인 경우, value = 3;)
종류자원 개수예시
Binary Semaphorevalue = 0 또는 1 (락처럼 사용됨)Mutex 대용
Counting Semaphorevalue ≥ 0 (자원이 여러 개일 때)프린터 3대, 접속 제한 등

초기값

0 → 처음부터 자원을 획득하지 못하고 우선, 대기하도록 한다.

1 → 최초에 바로 자원을 획득할 수 있도록 한다.

Monitor

생산자-소비자 문제, reader-writer 문제 등에 쓰이는 고수준 동기화 구조이다.

Mutex 구조에 Condition Variable(조건 변수)을 추가로 고려한 방법으로, Lock으로 임계 구역을 보호하고, 조건 변수로 상태 변화에 따른 대기/알림을 처리한다.

빵집을 떠올려보자. 진열대에 빵이 가득 차 있으면
1. 제빵사는 할 일이 없어 대기한다.
2. 손님은 살 수 있다.

반면, 진열대에 빵이 비어있다.
1. 손님은 빵이 나오기를 기다린다.
2. 제빵사는 빵을 만들어야 한다.

이를 보면 제빵사 혹은 손님이 Lock을 획득했을 때, 조건을 판단하고 다음 Lock을 획득할 주체를 선택할 수 있다.

손님이 빵을 샀다면, 진열대가 빈다.
→ 제빵사는 이제 빈 진열대에 빵을 두기 위해 일을 한다.(제빵사에게 Lock을 준다.)

제빵사가 빵을 만들었다, 진열대가 차있다.
→ 손님은 이제 빵을 살 수 있다.(손님에게 Lock을 준다.)

모니터의 동작 흐름 (Lock + Condition Variable)

  1. 제빵사혹은 손님이 Lock을 획득
  2. 자원의 상태(빵 유무)를 조건 변수로 판단
  3. 조건에 맞지 않으면 조건 변수에 의해 wait() → 자동으로 Lock 반납
  4. 자원이 준비되면 signal()로 대기 중인 스레드 깨움
  5. 깨워진 스레드는 Lock을 다시 획득한 후 작업 재개

위에서 비유한 진열대, 제빵사, 손님은 다음과 같이 매핑된다.

  • 진열대(버퍼): 공유 자원
  • 제빵사: 생산자 (빵을 만듦)
profile
어서오세요! ☺️ 후회 없는 내일을 위해 오늘을 열심히 살아가는 개발자입니다.

0개의 댓글