운영체제 | 3. 동기화

2026. 10. 10. 19:42ㆍ크래프톤 정글/포스팅

예시 상황

게임 하나가 프로세스 하나이고, 그 안에 몬스터 처치 스레드, 아이템 획득 스레드, 로딩 스레드, 그리기 스레드가 있다. 이 스레드들은 메모리를 공유한다. 1편에서 스레드가 메모리를 공유하는 대가로 동기화라는 숙제를 떠안았다고 했는데, 이 글은 그 숙제를 푸는 이야기다.


1. 문제: 같은 변수를 두 스레드가 동시에 건드리면?

게임에 점수 변수 score가 있고 두 스레드가 점수를 더한다.

  • 몬스터 처치 스레드: score = score + 10
  • 아이템 획득 스레드: score = score + 5

score가 100일 때 한 번씩 실행되면 115가 되어야 한다. 그런데 가끔 110이나 105가 나온다.

원인: 코드 한 줄이 CPU에게는 세 단계다

score = score + 10은 CPU에게 다음 세 단계다.

  1. 메모리에서 score를 읽어서 레지스터에 올린다 (100)
  2. 레지스터 값에 10을 더한다 (110)
  3. 결과를 메모리의 score에 쓴다

이 과정에서 스레드는 타이머 인터럽트로 언제든 CPU를 뺏길 수 있다. 1번과 3번 사이에 뺏기면 다음과 같다.

순서 몬스터 스레드 아이템 스레드 메모리 내 score 변수
1 score 읽기 (100)   100
2 (CPU 뺏김) score 읽기 (100) 100
3   100 + 5 = 105 쓰기 105
4 (CPU 돌려받음) 100 + 10 = 110 쓰기   110

+5가 사라졌다. 몬스터 스레드가 읽어 온 옛날 값(100)은 레지스터에 있었고, 문맥 교환 때 TCB에 저장됐다가 돌려받을 때 그대로 복원되었다. 그 사이 메모리의 score가 바뀐 것은 레지스터가 알 수 없으므로, 옛날 값에 10을 더해 덮어쓴 것이다. 이를 경쟁 상태(race condition) 라고 한다. 실행 순서(타이밍)에 따라 결과가 달라지는 버그이며, 가끔만 발생해서 재현과 디버깅이 어렵다.

 

이때 임계 구역(critical section)은 공유 자원에 접근하는 코드 구간을 의미하며, 상호 배제(mutual exclusion)는 한 시점에 한 스레드만 임계 구역에 들어갈 수 있게 하는 것을 말한다. 

2. 해결책 1: 인터럽트 끄기

경쟁 상태의 원인이 "중간에 CPU를 뺏기는 것"이므로, 임계 구역 동안 CPU 인터럽트 플래그를 꺼서 뺏길 일을 없앤다.

인터럽트 끄기
score = score + 10    // 임계 구역
인터럽트 켜기

인터럽트 설정은 특권 명령어라서 커널만 할 수 있다. 핀토스에서는 intr_disable(), intr_set_level() 같은 함수를 쓴다.

한계

  • 유저 프로그램은 쓸 수 없다. (특권 명령어)
  • 오래 끄면 키보드, 타이머 같은 중요한 신호를 놓친다. 아주 짧은 구간에만 써야 한다.
  • 멀티코어에서는 다른 코어의 스레드를 막지 못한다.

그래서 더 일반적인 도구가 필요하다. 다만 이 방법은 아래 도구들을 만드는 가장 밑바닥 재료로 쓰인다.

3. 해결책 2: 세마포어

세마포어: 숫자 하나(지금 쓸 수 있는 허락의 개수)와 대기 리스트를 가진 구조체

struct semaphore {
    int value;              // 쓸 수 있는 허락의 개수
    struct list waiters;    // 기다리는 스레드(TCB)들의 리스트
}
  • down: value가 0보다 크면 1 줄이고 진행한다. 0이면 대기 상태로 들어가 잠든다.
  • up: value를 1 올리고, 기다리는 스레드가 있으면 하나를 깨워 준비 줄로 보낸다.

예시: 로딩이 끝나야 그릴 수 있다 (순서 맞추기)

  • 로딩 스레드: 디스크에서 맵 데이터를 읽어 메모리에 올린다.
  • 그리기 스레드: 올라온 맵 데이터를 보고 화면에 그린다.
  • 규칙: 맵 데이터가 다 올라온 뒤에야 그릴 수 있다.

세마포어를 "로딩 완료 신호"로 쓴다. 로딩이 안 끝났으니 시작 값은 0이다.

struct semaphore map_loaded;

void game_start() {
    sema_init(&map_loaded, 0);   // 개발자가 직접 만들고 시작 값을 정한다
    thread_create(loading_thread);
    thread_create(drawing_thread);
}

void loading_thread() {
    맵 데이터를 읽어서 메모리에 올림;
    sema_up(&map_loaded);        // 신호 올리기
}

void drawing_thread() {
    sema_down(&map_loaded);      // 신호 기다리기
    맵을 그림;
}

그리기 스레드가 먼저 실행된 경우

  1. 그리기 스레드가 down을 한다. 값이 0이므로 대기 상태가 된다. (TCB가 ready_list에서 빠져 waiters로 옮겨진다.)
  2. CPU는 로딩 스레드가 쓴다. 로딩을 마치고 up을 하면 값이 1이 되고, waiters의 그리기 스레드가 ready_list로 돌아온다. (대기 → 준비)
  3. 그리기 스레드가 CPU를 받아 down을 마치고(값은 다시 0) 맵을 그린다.

로딩이 먼저 끝난 경우

  1. 로딩 스레드가 up을 해서 값이 1이 된다.
  2. 나중에 그리기 스레드가 down을 하면 값이 1이므로 기다리지 않고 바로 통과한다.

어느 스레드가 먼저 실행되든(스케줄러가 정한다) 그리기는 로딩이 up한 뒤에만 진행된다.

시작 값이 1이면? "완료 신호가 이미 하나 있다"는 뜻이 되어, 그리기 스레드가 기다리지 않고 통과해 빈 화면이 나온다.

시작 값을 정하는 기준

세마포어의 시작 값 = 처음부터 쓸 수 있는 허락의 개수

용도 시작 값 예시
순서 맞추기 0 로딩이 끝나야 그리기
상호 배제 1 한 번에 한 스레드만
개수 제한 N 동시에 3명까지만 접속

세마포어는 누가 만들고 어디에 있나

  • 세마포어는 OS가 자동으로 만들어 주는 것이 아니라, 개발자가 코드로 선언하고 sema_init으로 초기화한다. 시작 값도 개발자가 정한다.
  • 메모리 속 일반 데이터이고, 위 예시에서는 전역 변수라서 프로세스의 데이터 영역에 있다.
  • 같은 프로세스의 스레드들은 메모리를 공유하므로, 같은 구조체를 가리켜야 신호가 전달된다. 각자 다른 세마포어를 가리키면 up해도 상대는 영원히 기다린다.
  • 구조체의 value와 waiters는 스레드가 직접 고치는 것이 아니라 sema_down, sema_up 함수(커널 코드)가 고친다. 유저 프로그램이 쓸 때는 시스템 콜을 거친다. (시스템 콜은 별도 글 참고)

3-5. 세마포어 내부에서 인터럽트를 끄는 이유

down 안의 코드도 score = score + 10과 같은 문제가 있다.

while (value == 0) { 대기 }
value--

확인과 감소 사이에 CPU를 뺏기면 두 스레드가 모두 통과할 수 있다.


  스레드 A 스레드 B value
1 value 확인: 1이니 통과   1
2 (CPU 뺏김) value 확인: 1이니 통과 1
3   value-- 0
4 (돌아옴) value--   -1

자리가 1개인데 둘 다 통과했다. 그래서 이 구간만 인터럽트를 잠깐 꺼서 확인과 감소가 한 덩어리로 실행되게 한다. 끝나면 다시 켠다. 기다릴 때는 인터럽트를 끈 채 계속 확인하지 않고 대기 리스트에 들어가 잠든다.

 

4. 해결책 3: 락

struct lock {
    struct thread *holder;      // 지금 락을 쥔 스레드
    struct semaphore semaphore; // 값이 1인 세마포어
}
  • 락: 임계 구역에 들어가기 전에 획득(acquire), 나올 때 해제(release) 하는 열쇠. 한 번에 한 스레드만 가질 수 있고, 잡은 스레드만 풀 수 있다.
  • 핀토스의 락은 값이 1인 세마포어 위에 주인 기록을 더한 구조다.
  • lock_acquire: 세마포어 down 후 holder = 현재 스레드
  • lock_release: holder = NULL로 비우고 세마포어 up
lock_acquire(score_lock)    // 열쇠를 못 얻으면 대기 상태로 잠든다
score = score + 10          // 임계 구역
lock_release(score_lock)    // 열쇠를 돌려놓는다

 

락은 일을 시작할 때가 아니라 임계 구역에 들어가기 직전에 잡고, 나올 때 푼다.

락과 세마포어의 차이

  락 세마포어
주인 기록 있음 (holder) 없음
값을 올리는(푸는) 쪽 잡은 그 스레드만 아무 스레드나 (up)
값의 범위 사실상 0 또는 1 0~N
어울리는 용도 상호 배제 (score 보호) 순서 맞추기, 개수 제한, 상호 배제
  • 순서 맞추기는 down하는 쪽(그리기)과 up하는 쪽(로딩)이 서로 다른 스레드이므로, 잡은 스레드만 풀 수 있는 락으로는 할 수 없다.
  • score 보호는 같은 스레드가 잡고 같은 스레드가 푸는 전형적인 상호 배제이므로 락이 어울린다.
  • 락에는 holder가 있어서 “H가 기다리는 락을 누가 쥐고 있나”를 알 수 있고, 그 holder에게 우선순위를 기부할 수 있다. 그래서 핀토스 과제는 우선순위 기부를 락에 대해서만 요구한다. (2편의 우선순위 역전, 우선순위 상속과 연결)

한 줄 요약

스레드가 메모리를 공유하면 읽기-계산-쓰기가 중간에 끊겨 경쟁 상태가 생긴다. 임계 구역에 한 스레드만 들어가게(상호 배제) 하는 도구로, 밑바닥의 인터럽트 끄기 위에 세마포어(숫자 + 대기 리스트, 순서 맞추기와 개수 제한)와 락(값 1인 세마포어 + 주인 기록, 상호 배제)이 있다.

핀토스 과제와의 연결

  • Alarm Clock: 자는 스레드를 대기 상태로 만들고 리스트로 관리한다. 리스트를 만지는 동안 인터럽트를 끄는 구간이 등장한다.
  • Priority Scheduling: 락, 세마포어, 조건 변수를 기다리는 스레드 중 가장 높은 우선순위가 먼저 깨어나야 한다.
  • Priority Donation: 락의 holder에게 기다리는 스레드의 우선순위를 기부한다. 이 글의 락 구조(holder, waiters)가 그 바탕이다.