[pintos]threads 개념을 알아보자
threads를 구현하면서 얻은것
먼저 들어가기에 앞서 카이스트 깃북 핀토스 과정인 threads 부분과 크래프톤 정글 OS수업의 내용 정리임을 밝힙니다. 같은 과목을 듣는 분들을 위해 쓴 글입니다. 개념과 그림 위주로 정리했고, 과제 해답 코드는 포함하지 않았습니다.
Pintos Project 1: 스레드를 구현하며 배운 운영체제의 기본 원리#
1. 시작하며#
Pintos Project 1을 시작하기 전에는 스레드를 단순히 프로그램의 실행 단위 정도로 이해하고 있었다. 스레드가 여러 개 존재하면 여러 작업을 동시에 처리할 수 있고, 운영체제가 실행 순서를 관리한다는 정도였다.
하지만 실제로 스레드를 구현하기 시작하니 생각보다 고려해야 할 것이 많았다.
스레드를 어떻게 생성하고 실행하는지부터, 실행 중인 스레드를 언제 교체할지, 잠든 스레드를 어떻게 깨울지, 여러 스레드가 공유 자원에 접근할 때 발생하는 문제를 어떻게 해결할지까지 모두 연결되어 있었다.
특히 어려웠던 점은 개별 함수의 역할을 이해하는 것과 운영체제 전체의 흐름을 이해하는 것이 서로 다른 문제라는 사실이었다. 함수 하나만 보면 어렵지 않아 보이더라도, 다른 함수와 연결되는 순간 실행 순서와 상태 변화까지 고려해야 했다.
이번 글에서는 먼저 수업에서 배운 운영체제의 기본 역할을 짚고, 그 내용이 Pintos Project 1의 스레드 구현에서 어떻게 드러났는지 순서대로 정리해 보려고 한다.
- 과제 항목을 가로로 나열: Alarm Clock → Priority Scheduling → Priority Donation
- 모든 과제 아래에 공통 기반을 적는다: 스레드 상태, 스케줄러, 타이머 인터럽트, 동기화
2. 배경: 운영체제는 무엇을 하는가#
수업에서는 운영체제가 필요한 이유를 다음과 같이 설명했다.
- 하드웨어 추상화: 응용 프로그램이 장치마다 다른 제어 방법을 직접 알아야 한다면, 모든 프로그램이 같은 제어 코드를 가져야 한다. 운영체제는 드라이버를 맡아 응용 프로그램이 하드웨어 세부 사항을 몰라도 되도록 한다. 하드디스크, SSD, USB 같은 저장장치도 운영체제가 모두 “파일”이라는 형태로 보여 준다.
- 자원 공유: CPU는 시간을 나누어 쓰고, 메모리는 공간을 나누어 쓴다.
- 보호와 분리: 응용 프로그램끼리, 그리고 응용 프로그램과 커널이 서로 간섭하지 않도록 한다.
이 역할을 한 줄로 줄이면 프로세스 추상화가 된다. 여러 프로그램이 각각 컴퓨터를 한 대씩 쓰는 것처럼 보이게 만드는 것이다. 응용 프로그램은 하드웨어를 직접 건드리지 못하고, 시스템 콜을 통해서만 커널에 요청한다.

3. 스레드는 어떻게 생성되고 실행될까?#
3-1. 스레드의 기본 구조#
스레드는 실행의 흐름을 나타내는 단위다. 하나의 프로세스 안에서도 여러 스레드가 존재할 수 있으며, 각 스레드는 자신만의 실행 상태와 스택을 가진다.
Pintos에서는 struct thread를 통해 스레드의 정보를 관리한다. 스레드 구조체에는 스레드 식별자, 상태, 우선순위, 스택과 스케줄링에 필요한 정보 등이 들어간다.
여기서 중요한 것은 스레드가 단순히 함수 하나를 의미하는 것이 아니라는 점이다. 함수를 실행하려면 현재 어디까지 실행했는지, 어떤 스택을 사용하는지, 다음에 어떤 명령어를 실행해야 하는지 등의 정보가 필요하다. 운영체제는 이러한 정보를 바탕으로 스레드의 실행을 관리한다.

- 큰 상자 하나에 항목을 나열한다: 식별 정보, 상태, 우선순위, 스택 정보, 스케줄링 정보
- 상자 옆에 “커널은 이 구조체로 스레드를 관리한다”라고 적는다.
3-2. 스레드 생성 과정#
스레드 생성 과정을 이해하기 위해서는 다음과 같은 질문을 생각해 볼 수 있다.
- 새로운 스레드를 실행하려면 어떤 정보가 필요할까?
- 새로운 스레드의 메모리와 스택은 어디에 준비될까?
- 생성된 스레드는 언제 실제로 실행될까?
스레드를 생성한다고 해서 생성 함수가 곧바로 새로운 스레드의 작업을 실행하는 것은 아니다.
일반적으로 새로운 스레드의 관리 정보를 초기화하고, 실행에 필요한 환경을 준비한 다음, 스케줄러가 선택할 수 있도록 실행 대기 상태에 등록한다. 이후 스케줄러가 해당 스레드를 선택하면 CPU를 할당받아 실행된다.
즉, 스레드 생성과 스레드 실행은 구분해서 이해해야 한다.
3-3. 스레드 상태와 스케줄링#
Pintos에서 스레드의 상태는 크게 다음과 같이 이해할 수 있다.
| 상태 | 의미 |
|---|---|
THREAD_RUNNING | 현재 CPU에서 실행 중인 상태 |
THREAD_READY | 실행할 준비가 되어 CPU 할당을 기다리는 상태 |
THREAD_BLOCKED | 특정 조건이 충족될 때까지 실행할 수 없는 상태 |
THREAD_DYING | 종료되어 정리되는 상태 |
스레드가 실행할 준비가 되어 있다고 해서 반드시 즉시 실행되는 것은 아니다. 여러 스레드가 준비 상태에 있다면 스케줄러가 실행할 스레드를 선택해야 한다.
스케줄러는 단순히 스레드를 실행하는 기능이 아니라, 어떤 스레드에 CPU를 할당할지 결정하는 역할을 한다. 이후 우선순위 스케줄링과 우선순위 기부를 구현하면서 이 선택 과정이 다른 기능들과 얼마나 밀접하게 연결되어 있는지도 알게 되었다.

- 원 네 개: READY, RUNNING, BLOCKED, DYING
- 화살표
- READY → RUNNING: 스케줄러가 선택
- RUNNING → READY: 실행을 양보하거나 선점당함
- RUNNING → BLOCKED: 기다릴 조건이 생김 (잠들기, 락 대기 등)
- BLOCKED → READY: 조건이 충족되어 깨움
- RUNNING → DYING: 실행 종료
4. 문맥 교환#
스레드를 구현하면서 중요하게 이해해야 했던 개념 중 하나는 문맥 교환이다.
문맥 교환은 현재 실행 중인 스레드의 실행 상태를 보존하고, 다른 스레드의 실행 상태를 복원하여 CPU의 실행 대상을 바꾸는 과정이다.
예를 들어 스레드 A가 실행 중인데 스케줄러가 스레드 B를 실행하기로 결정했다고 생각해 보자.
이때 단순히 A의 실행을 멈추고 B의 함수를 호출하는 것만으로는 충분하지 않다. A가 나중에 다시 실행될 때 이전에 실행하던 지점부터 이어서 실행할 수 있어야 하기 때문이다.
수업에서 본 함수 호출을 떠올리면 이 필요성이 분명해진다. 함수를 호출하면 돌아갈 주소가 스택에 저장되고, 함수가 끝나면 그 주소를 꺼내 원래 위치로 돌아간다. 문맥 교환에서도 같은 원리가 필요하다. 다만 돌아갈 대상이 함수가 아니라 다른 스레드다.
이를 위해 레지스터와 스택 포인터 등 실행을 이어 가는 데 필요한 정보가 적절하게 보존되고 복원되어야 한다.
문맥 교환을 이해하면서 스레드의 상태를 관리하는 코드와 실제 CPU 실행 흐름을 전환하는 코드를 구분해서 바라볼 필요가 있다는 점을 배웠다. 또한 스레드 상태를 변경하는 것과 CPU에서 실행되는 스레드가 바뀌는 것은 밀접하지만 동일한 사건은 아니라는 점도 중요했다.

5. Alarm Clock: 시간을 기준으로 스레드 깨우기#
5-1. 기존 방식의 문제#
Alarm Clock 과제의 핵심은 일정 시간이 지난 후 스레드를 다시 실행할 수 있도록 만드는 것이다.
예를 들어 어떤 스레드가 현재 시점부터 10틱 뒤에 실행되기를 원한다고 가정하자. 운영체제는 해당 스레드를 바로 실행하지 않고, 지정된 시간이 될 때까지 기다리도록 관리해야 한다.
이 과정에서 중요한 것은 기다리는 스레드를 어떤 방식으로 관리할 것인지다.
단순히 시간을 확인하기 위해 계속 실행하는 방식은 CPU 자원을 낭비할 수 있다. 과제 안내에서도 시간이 될 때까지 yield를 반복 호출하는 루프, 즉 **바쁜 대기(busy waiting)**를 금지하고 있다.
수업에서 배운 폴링과 인터럽트의 차이가 이 과제의 출발점이다. 폴링은 CPU가 계속 상태를 확인하는 방식이고, 인터럽트는 신호가 왔을 때만 처리하는 방식이다.

따라서 스레드를 대기 상태로 전환하고, 깨워야 할 시점이 되었을 때 타이머 인터럽트를 통해 다시 실행 가능한 상태로 만들어야 한다.
5-2. Sleep List와 Timer#
Alarm Clock을 구현하면서 중요한 자료구조는 잠든 스레드를 관리하는 리스트였다.
스레드가 잠들 때는 언제 깨어나야 하는지 기록하고, 타이머 인터럽트가 발생할 때 현재 시간이 해당 스레드의 목표 시각에 도달했는지 확인한다. 이를 통해 다음과 같은 흐름을 만들 수 있다.
- 스레드가 목표 기상 시각을 계산한다.
- 스레드를 대기 목록에 등록한다.
- 스레드를 블록 상태로 전환한다.
- 타이머 인터럽트가 발생할 때 기상 시각을 확인한다.
- 기상 시각이 된 스레드를 깨워 READY 상태로 전환한다.
이 과정을 통해 시간 기반 대기는 단순한 시간 비교 문제가 아니라, 스레드 상태와 리스트 관리, 타이머 인터럽트가 연결된 문제라는 사실을 알 수 있었다.

5-3. 리스트를 정렬하는 이유#
잠든 스레드를 기상 시각 순서대로 관리하면 다음에 깨워야 할 스레드를 찾기 쉬워진다. 매번 모든 스레드를 확인하는 대신, 가장 먼저 깨어나야 하는 스레드부터 확인할 수 있기 때문이다.

다만 리스트를 정렬하는 것만으로 모든 문제가 해결되는 것은 아니다. 같은 시각에 여러 스레드가 깨어나야 하는 경우도 고려해야 한다.
실제로 Alarm Clock 관련 테스트를 진행하면서 여러 스레드가 서로 다른 시각에 잠들거나 동시에 깨어나는 상황을 확인하는 것이 중요했다.
인터럽트 핸들러는 이 리스트를 읽고 고치기 때문에, 일반 스레드와 같은 리스트를 함께 쓰는 상황도 조심해야 한다. 과제 안내에서도 인터럽트를 끄는 구간은 가능한 짧게 유지하라고 권장한다.
여기서 얻은 인사이트는 자료구조의 선택이 단순한 코드 작성 방식의 문제가 아니라, 운영체제가 작업을 처리하는 효율과 정확성에 영향을 준다는 것이다.
6. 우선순위 스케줄링: 우선순위에 따라 실행하기#
6-1. 우선순위란 무엇일까?#
우선순위 스케줄링은 실행 준비가 된 스레드 중 우선순위가 높은 스레드를 먼저 선택하는 방식이다.
Pintos에서는 스레드마다 우선순위가 있으며, 스케줄러는 이 값을 바탕으로 다음 실행 대상을 결정한다.
여기서 한 가지 구분해야 할 개념이 있다. 스레드가 가지고 있는 우선순위와, 현재 실행 중인 스레드가 실제로 CPU를 차지하고 있는지는 서로 다른 문제다.
우선순위가 높은 스레드가 준비 상태가 되었다면, 운영체제는 스케줄링 규칙에 따라 해당 스레드가 적절한 시점에 실행될 수 있도록 해야 한다.

6-2. 우선순위 변경과 선점#
우선순위 스케줄링을 구현할 때는 단순히 스레드를 우선순위 순서로 정렬하는 것만으로 충분하지 않다.
예를 들어 낮은 우선순위의 스레드가 실행 중인데 더 높은 우선순위의 스레드가 READY 상태가 되었다고 생각해 보자. 이때 운영체제가 높은 우선순위의 스레드를 적절하게 실행하도록 하지 않으면, 우선순위 스케줄링의 의도와 실제 동작이 달라질 수 있다.
또한 스레드의 우선순위가 실행 중에 변경되는 경우에도 변경된 값이 스케줄러에 반영되어야 한다.
이 과정에서 우선순위 값 자체를 관리하는 것과 그 값에 따라 실행 순서를 조정하는 것은 서로 다른 책임이라는 점을 이해할 수 있었다.
7. 우선순위 기부#
7-1. 우선순위 역전 문제#
우선순위 기부는 Pintos Project 1에서 특히 흥미로웠던 개념이다.
다음과 같은 상황을 생각해 보자.
- 낮은 우선순위의 스레드 L이 락을 획득한다.
- 높은 우선순위의 스레드 H가 같은 락을 획득하려고 한다.
- H는 L이 락을 해제할 때까지 기다려야 한다.
- 그런데 L보다 우선순위가 높은 다른 스레드들이 계속 실행된다.
이 상황에서는 높은 우선순위의 스레드 H가 낮은 우선순위의 스레드 L 때문에 기다리는 문제가 발생한다. 이를 우선순위 역전(Priority Inversion)이라고 한다.

7-2. 우선순위 기부의 핵심#
이 문제를 완화하기 위해 높은 우선순위의 스레드가 락을 보유한 스레드에 자신의 우선순위를 일시적으로 기부할 수 있다.
그러면 락을 보유한 스레드가 더 높은 우선순위로 실행될 수 있고, 락을 빠르게 해제하여 기다리는 스레드가 진행할 가능성이 높아진다.
중요한 점은 기부받은 우선순위가 스레드의 원래 우선순위를 영구적으로 대체하는 것은 아니라는 사실이다. 락을 해제한 뒤에는 기부 상황을 고려하여 우선순위를 복원해야 한다.

7-3. 구현 과정에서 배운 점#
우선순위 기부를 구현하면서 느낀 점은 기능 하나를 추가하더라도 기존 스케줄러의 여러 부분이 영향을 받을 수 있다는 것이었다.
우선순위를 계산하는 부분만 수정하면 끝나는 것이 아니라, 락을 획득하고 해제하는 시점, 스레드가 대기하는 시점, 우선순위를 복원하는 시점까지 일관되게 고려해야 한다.
또한 하나의 스레드가 여러 락을 보유하고 있거나, 락을 기다리는 스레드가 또 다른 락을 기다리는 상황에서는 우선순위 기부가 연쇄적으로 발생할 수 있다.

따라서 우선순위 기부는 단순히 우선순위 변수에 더 큰 값을 넣는 문제가 아니다. 락의 소유 관계, 대기 관계, 원래 우선순위, 기부받은 우선순위를 어떻게 관리할지 함께 설계해야 한다.
특히 우선순위가 기대한 값으로 복원되지 않거나 특정 테스트에서 스레드의 실행 순서가 달라지는 문제는 전체 흐름을 확인해야 해결할 수 있었다. 이 경험을 통해 동기화 기능은 개별 함수만 이해해서는 충분하지 않고, 여러 함수 사이의 상태 변화와 실행 순서를 함께 이해해야 한다는 사실을 배웠다.
8. 테스트 실패를 통해 배운 디버깅#
Pintos를 구현하면서 테스트 결과는 중요한 학습 도구였다.
테스트가 실패하면 처음에는 해당 기능을 구현한 함수에 문제가 있다고 생각하기 쉽다. 하지만 실제 원인은 다른 부분에 있을 수도 있다. 예를 들어 테스트에서 기대한 출력이 나오지 않는다면 다음과 같은 가능성을 구분해야 한다.
- 스레드가 예상한 상태로 전환되지 않았는가?
- 스레드가 실행 대기 목록에 올바르게 등록되었는가?
- 스케줄러가 기대한 시점에 실행 대상을 변경했는가?
- 락이나 세마포어의 대기 및 깨우기 순서가 올바른가?
- 우선순위 변경이 실제 스케줄링에 반영되었는가?
이렇게 문제를 작은 단위로 나누어 생각하면 막연하게 코드를 수정하는 대신, 어떤 상태가 예상과 달라졌는지 확인할 수 있다.
테스트 실패 → 기대한 상태 전이가 일어났는가? → 대기 목록에 올바르게 들어갔는가? → 스케줄러가 기대한 시점에 실행 대상을 바꿨는가? → 락·세마포어의 대기 및 깨우기 순서가 맞는가? → 우선순위 변경이 선택에 반영되었는가?또한 하나의 테스트를 통과했다고 해서 구현이 완성된 것은 아니라는 점도 배웠다. 일반적인 상황에서는 정상적으로 동작하더라도, 동시에 여러 스레드가 깨어나거나 우선순위가 실행 중에 바뀌거나 여러 락이 연결되는 상황에서는 문제가 발생할 수 있기 때문이다.
따라서 테스트 결과를 단순한 PASS와 FAIL로만 보는 것이 아니라, 구현의 어떤 가정을 검증하는 테스트인지 이해하는 것이 중요했다.
9. 이번 주차에서 배운 가장 중요한 것#
Pintos Project 1을 진행하면서 배운 내용을 정리하면 다음과 같다.
첫째, 운영체제의 기능은 서로 독립적이지 않다. 스레드의 상태, 스케줄러, 타이머 인터럽트, 동기화 기능은 서로 연결되어 있다. 한 부분의 변경이 다른 부분의 동작에 영향을 줄 수 있다.
둘째, 자료구조를 선택할 때는 사용 목적과 동작 흐름을 함께 고려해야 한다. 스레드 목록을 어떤 기준으로 관리할지, 어떤 시점에 삽입하고 제거할지에 따라 구현의 효율과 정확성이 달라진다.
셋째, 코드를 읽는 것과 처음부터 설계하는 것은 다르다. 이미 작성된 코드를 보면 각 함수의 역할을 이해할 수 있더라도, 빈 파일에서 어떤 자료구조가 필요한지, 어떤 상태를 저장해야 하는지, 어떤 함수부터 구현해야 하는지 결정하는 것은 별개의 능력이다. 이번 프로젝트를 통해 이 차이를 체감했고, 앞으로는 코드를 작성하기 전에 요구사항과 상태 변화부터 정리하는 습관을 들이고 싶다.
넷째, 테스트 실패는 구현의 흐름을 다시 확인할 기회다. 기대하지 않은 결과가 나왔을 때 무작정 코드를 수정하기보다는, 어떤 조건에서 어떤 상태가 되어야 하는지 먼저 확인해야 한다.
수업에서 강조한 추상화도 같은 맥락에서 다시 보였다. 추상화는 세부 사항을 감춰 프로그램을 쉽게 만들지만, 운영체제를 구현하는 사람은 그 세부 사항을 정확히 알아야 한다. 무조건 단순화하는 것이 아니라 문제를 푸는 데 필요한 것만 남겨야 한다는 점이 이번 과제에서 가장 실감 났다.
10. 마치며#
Pintos Project 1을 통해 스레드가 어떻게 생성되고 실행되는지부터, 시간을 기준으로 대기하는 기능과 우선순위 스케줄링, 우선순위 기부까지 살펴보았다.
처음에는 각각의 기능이 서로 다른 과제처럼 느껴졌지만, 구현을 진행할수록 하나의 공통된 질문으로 연결된다는 것을 알게 되었다.
운영체제는 여러 실행 흐름을 어떤 상태로 관리하고, 어떤 기준으로 실행하며, 서로 충돌하지 않도록 어떻게 조정할까?
스레드의 상태를 관리하는 일, CPU를 할당하는 일, 잠든 스레드를 깨우는 일, 공유 자원을 보호하는 일은 모두 이 질문과 연결되어 있었다.
아직 모든 코드를 스스로 설계할 수 있는 수준은 아니지만, 적어도 개별 함수의 동작만 보는 것에서 벗어나 함수 사이의 관계와 전체 실행 흐름을 이해하려고 노력하게 되었다. 앞으로도 테스트를 통과시키는 데서 끝나지 않고, 왜 이러한 자료구조와 동기화 방식이 필요한지 설명할 수 있도록 공부해 나가고 싶다.
참고#
- Pintos Project 1 문서: https://casys-kaist.github.io/pintos-kaist/project1/introduction.html
- 정글 운영체제 수업 강의 내용 (KAIST 권영진 교수 강의 기반)