운영체제 기본/프로세스와 스레드: 두 판 사이의 차이
스레드별 호출 스택이 필요한 이유와 C 예시·스택 도식 보강 |
잔글 x86-64 각론 경로 변경에 따른 본문·목차·틀 링크 갱신 |
||
| (같은 사용자의 중간 판 3개는 보이지 않습니다) | |||
| 65번째 줄: | 65번째 줄: | ||
함수 호출에 필요한 메모리 묶음을 '''스택 프레임(Stack Frame)'''이라고 한다. 프레임에는 호출별 지역 데이터와 보존할 레지스터, 필요에 따라 반환 주소 등이 놓인다. 어떤 값은 레지스터에 남고 어떤 값은 스택에 저장되는지에 대한 구체적인 배치는 호출 규약과 컴파일러가 결정한다.<ref name="stack-frame">[https://riscv-non-isa.github.io/riscv-elf-psabi-doc/#_procedure_calling_convention RISC-V ELF psABI, Procedure Calling Convention], 스택 포인터와 호출별 레지스터 보존 규약.</ref> | 함수 호출에 필요한 메모리 묶음을 '''스택 프레임(Stack Frame)'''이라고 한다. 프레임에는 호출별 지역 데이터와 보존할 레지스터, 필요에 따라 반환 주소 등이 놓인다. 어떤 값은 레지스터에 남고 어떤 값은 스택에 저장되는지에 대한 구체적인 배치는 호출 규약과 컴파일러가 결정한다.<ref name="stack-frame">[https://riscv-non-isa.github.io/riscv-elf-psabi-doc/#_procedure_calling_convention RISC-V ELF psABI, Procedure Calling Convention], 스택 포인터와 호출별 레지스터 보존 규약.</ref> | ||
< | <pre> | ||
int double_value(int value) | int double_value(int value) | ||
{ | { | ||
| 77번째 줄: | 77번째 줄: | ||
return result + partial; | return result + partial; | ||
} | } | ||
</ | </pre> | ||
A가 <code>calculate(10)</code>, B가 <code>calculate(20)</code>을 실행한다고 하자. A의 <code>partial</code>은 11이고 B의 <code>partial</code>은 21이다. 둘 다 <code>double_value()</code>를 실행하는 순간에도, 함수가 끝난 뒤 각자의 <code>calculate()</code> 호출로 돌아가 자신이 보관한 값을 사용해야 한다. 호출 관계를 스택 프레임으로 나타내면 다음과 같다. | A가 <code>calculate(10)</code>, B가 <code>calculate(20)</code>을 실행한다고 하자. A의 <code>partial</code>은 11이고 B의 <code>partial</code>은 21이다. 둘 다 <code>double_value()</code>를 실행하는 순간에도, 함수가 끝난 뒤 각자의 <code>calculate()</code> 호출로 돌아가 자신이 보관한 값을 사용해야 한다. 호출 관계를 스택 프레임으로 나타내면 다음과 같다. | ||
| 84번째 줄: | 84번째 줄: | ||
공유하는 코드: calculate(), double_value() | 공유하는 코드: calculate(), double_value() | ||
A의 호출 스택 | A의 호출 스택 B의 호출 스택 | ||
+ | +----------------------+ +----------------------+ | ||
| | | A caller | | B caller | | ||
+ | +----------------------+ +----------------------+ | ||
| calculate(10) | | calculate(10) | | calculate(20) | | ||
| partial = 11 | | partial = 11 | | partial = 21 | | ||
+ | +----------------------+ +----------------------+ | ||
| double_value(11) | | double_value(11) | | double_value(21) | | ||
| | | SP_A -> current frame| | SP_B -> current frame| | ||
+ | | return to A | | return to B | | ||
+----------------------+ +----------------------+ | |||
각자의 호출 순서에 따라 프레임을 만들고 제거 | 각자의 호출 순서에 따라 프레임을 만들고 제거 | ||
</pre> | </pre> | ||
| 164번째 줄: | 165번째 줄: | ||
== 관련 각론 == | == 관련 각론 == | ||
* [[운영체제 기본/ | * [[운영체제 기본/Linux/ELF 로딩/execve와 실행 이미지 교체|execve와 실행 이미지 교체]]: 프로세스 생성과 구분되는 실행 이미지 교체, 유지되는 상태와 초기화되는 상태. | ||
* [[운영체제 기본/x86-64 | * [[운영체제 기본/Linux/x86-64/BIOS 부팅/PID 1 생성|PID 1 생성]]: 기존 부트 태스크와 새 실행 문맥이 갈라지는 과정. | ||
== 참고 문헌 == | == 참고 문헌 == | ||
<references /> | <references /> | ||
2026년 11월 1일 (일) 02:23 기준 최신판
프로그램을 실행하려면 명령어뿐 아니라 데이터를 보관할 메모리, 현재 실행 위치, 열린 파일, CPU를 사용할 기회가 필요하다. 프로세스(Process)는 프로그램의 실행에 필요한 주소 공간과 자원을 묶는 단위이고, 스레드(Thread)는 그 안에서 명령을 순서대로 실행하는 흐름이다. 하나의 프로세스에는 하나 이상의 스레드가 있으며, 여러 스레드가 같은 실행 환경을 공유할 수 있다.
프로그램과 프로세스
디스크에 저장된 실행 파일은 코드와 초기 데이터를 담는다. 이 파일을 실행하면 운영체제는 메모리에 실행 환경을 마련하고, 파일을 읽거나 장치를 사용하는 데 필요한 상태를 관리한다. 실행 파일이 같더라도 실행할 때마다 별도의 프로세스가 만들어질 수 있다.
같은 편집기를 두 번 실행하여 서로 다른 문서를 열었다고 하자. 두 프로세스는 같은 프로그램의 코드를 실행하지만, 편집 중인 내용과 열린 파일, 메모리 할당 상태는 따로 관리한다. 한쪽에서 바꾼 일반 변수의 값이 다른 쪽에 그대로 반영되지 않는다. 각 프로세스가 별도의 가상 주소 공간(Virtual Address Space)을 갖기 때문이다. 같은 숫자의 주소라도 서로 다른 메모리를 가리킬 수 있다.[1]
프로세스에는 주소 공간 외에도 열린 파일을 찾는 정보, 실행에 사용하는 각종 자원과 관리 정보가 있다. 운영체제는 이 묶음을 이용해 자원을 회수하고 다른 프로세스와 분리한다. 어떤 작업을 지금 CPU에서 실행할지는 그 프로세스에 속한 스레드의 상태를 보고 결정한다.
스레드와 실행 상태
스레드 하나로 실행되는 프로그램에서는 입력 처리, 계산, 결과 출력이 하나의 실행 흐름을 따라 이어진다. 함수를 호출하면 호출된 함수로 진행하고, 반환하면 호출한 위치로 돌아온다. 함수가 많다는 이유만으로 스레드가 여러 개 생기지는 않는다.
CPU가 이 흐름을 멈췄다가 이어가려면 다음 명령의 위치와 계산 중이던 값, 함수 호출 관계를 알아야 한다. 이를 위해 스레드마다 다음과 같은 실행 문맥(Execution Context)을 관리한다.[2]
- 프로그램 카운터(Program Counter, PC): 이어서 실행할 명령의 위치.
- 레지스터(Register): 연산 중인 값과 함수 인자 등 CPU의 실행 상태.
- 스택(Stack)과 스택 포인터(Stack Pointer, SP): 함수 호출과 반환, 호출별 지역 변수 등을 위한 메모리와 그 위치.
- 스케줄링 상태: 실행 중인지, 실행 가능한지, 입출력이나 다른 조건을 기다리는지에 관한 정보.
실행 중인 스레드의 레지스터 값은 CPU에 있고, 다른 스레드를 실행할 때는 다시 복원할 수 있도록 보관한다. 스레드마다 PC와 스택이 있으므로 같은 함수를 실행하더라도 서로 다른 위치에 있을 수 있고, 함수 인자와 지역 변수의 값도 다를 수 있다.
공유 자원과 스레드별 상태
여러 스레드를 사용하는 프로세스는 실행 흐름을 여러 개 두되, 대부분의 실행 환경을 함께 사용한다. 같은 프로세스의 스레드는 코드, 전역 데이터, 힙을 포함하는 주소 공간을 공유한다. Unix 계열에서는 열린 파일을 찾는 파일 디스크립터 테이블도 공유하므로 한 스레드가 연 파일을 다른 스레드가 사용할 수 있다.[3]
프로세스 P ├─ 공유 주소 공간 │ ├─ 코드 · 전역 데이터 · 힙 │ ├─ 스레드 A의 스택 │ └─ 스레드 B의 스택 ├─ 공유 파일 디스크립터 테이블 ├─ 스레드 A: PC · SP · 레지스터 · 실행/대기 상태 └─ 스레드 B: PC · SP · 레지스터 · 실행/대기 상태 프로세스 Q ├─ 별도 주소 공간 · 파일 디스크립터 테이블 └─ 스레드 C: PC · SP · 레지스터 · 실행/대기 상태
스택을 따로 사용한다는 것과 메모리 접근이 서로 차단된다는 것은 다르다. A와 B의 스택도 같은 주소 공간 안에 있다. 유효한 포인터가 있으면 다른 스레드의 스택에 놓인 객체에도 접근할 수 있으므로, 객체의 수명과 동기화를 지켜야 한다. 반면 별도 프로세스 Q의 사적인 메모리는 보통 P의 포인터만으로 접근할 수 없다. 프로세스 사이에 같은 메모리를 쓰려면 공유 매핑 등 별도의 연결이 필요하다.
| 구분 | 서로 다른 프로세스 | 같은 프로세스의 여러 스레드 |
|---|---|---|
| 주소 공간 | 기본적으로 분리 | 공유 |
| 실행 위치와 레지스터 | 각 프로세스의 스레드마다 보유 | 스레드마다 보유 |
| 함수 호출 스택 | 각 스레드가 별도로 사용 | 각 스레드가 별도로 사용하지만 주소 공간은 공유 |
| 데이터 전달 | 통신이나 명시적 공유가 필요 | 같은 객체에 직접 접근 가능 |
| 잘못된 메모리 쓰기의 영향 | 다른 프로세스의 사적 메모리와 격리 | 같은 주소 공간의 다른 스레드에 전파 가능 |
스레드별 호출 스택
주소 공간을 공유해도 함수 호출의 진행 상태까지 함께 사용할 수는 없다. 스레드 A가 함수를 호출한 뒤 다른 함수를 실행하는 동안, 스레드 B는 같은 함수의 다른 호출을 실행하거나 먼저 반환할 수 있다. 각 흐름은 자신이 돌아갈 위치와 아직 끝나지 않은 호출의 데이터를 유지해야 한다.
함수 호출에 필요한 메모리 묶음을 스택 프레임(Stack Frame)이라고 한다. 프레임에는 호출별 지역 데이터와 보존할 레지스터, 필요에 따라 반환 주소 등이 놓인다. 어떤 값은 레지스터에 남고 어떤 값은 스택에 저장되는지에 대한 구체적인 배치는 호출 규약과 컴파일러가 결정한다.[4]
int double_value(int value)
{
return value * 2;
}
int calculate(int input)
{
int partial = input + 1;
int result = double_value(partial);
return result + partial;
}
A가 calculate(10), B가 calculate(20)을 실행한다고 하자. A의 partial은 11이고 B의 partial은 21이다. 둘 다 double_value()를 실행하는 순간에도, 함수가 끝난 뒤 각자의 calculate() 호출로 돌아가 자신이 보관한 값을 사용해야 한다. 호출 관계를 스택 프레임으로 나타내면 다음과 같다.
공유하는 코드: calculate(), double_value()
A의 호출 스택 B의 호출 스택
+----------------------+ +----------------------+
| A caller | | B caller |
+----------------------+ +----------------------+
| calculate(10) | | calculate(20) |
| partial = 11 | | partial = 21 |
+----------------------+ +----------------------+
| double_value(11) | | double_value(21) |
| SP_A -> current frame| | SP_B -> current frame|
| return to A | | return to B |
+----------------------+ +----------------------+
각자의 호출 순서에 따라 프레임을 만들고 제거
두 스레드가 같은 스택 저장 영역과 위치를 사용하면 B의 함수 호출이 아직 사용 중인 A의 프레임을 덮어쓸 수 있다. 지역 데이터나 복귀에 필요한 상태가 바뀌면 A는 계산을 올바르게 이어갈 수 없다. SP만 별도 레지스터에 보관해도 두 SP가 같은 저장 영역을 가리킨다면 이 문제는 해결되지 않는다. 독립적인 실행 흐름에는 독립적으로 보존되는 호출 스택과 SP가 필요하다.[3]
이는 CPU가 하나일 때도 마찬가지다. A를 멈추고 B를 실행하는 동안에도 A의 미완료 호출들은 살아 있다. A의 PC와 레지스터를 복원할 때 그 호출 데이터도 그대로 남아 있어야 한다. 다만 스택 영역을 나누는 것은 실행 상태를 보존하기 위한 배치이며, 같은 프로세스의 다른 스레드가 그 메모리에 접근하지 못하도록 격리하는 것은 아니다.
단일 스레드와 다중 스레드
문서 편집기가 입력을 처리하다가 오래 걸리는 검색 작업을 수행한다고 하자. 단일 스레드가 검색 루프를 실행하는 동안에는 같은 흐름에서 입력을 처리할 수 없다. 작업을 잘게 나누어 번갈아 처리하는 설계도 가능하지만, 입력 담당 스레드와 검색 담당 스레드를 나누면 각각의 진행 위치를 독립적으로 유지할 수 있다.
새 스레드는 정해진 함수에서 실행을 시작하고, 기존 스레드는 자신의 실행을 계속한다. 보통 함수 호출처럼 새 작업이 반환할 때까지 반드시 기다리는 구조가 아니다. 결과가 필요한 지점에서는 완료를 기다리거나 통지를 받아야 한다.[5]
예를 들어 입력 스레드가 문서 내용을 수정하고 검색 스레드가 같은 내용을 읽는다면, 두 스레드가 무엇을 언제 볼지 정해야 한다. 락으로 접근을 조정하거나 검색용 사본을 만드는 방법이 있다. 실행 흐름을 나누는 편리함에는 공유 데이터 관리 비용이 따라온다.
단일 스레드: 입력 처리 → 검색 작업 → 다음 입력 처리
다중 스레드: 입력 스레드 → 입력 처리 → 입력 처리 → ...
검색 스레드 → 검색 작업 ─────────→ 완료 통지
(문서 데이터 공유에는 동기화 필요)
스레드가 둘이라고 항상 두 작업이 동시에 CPU에서 실행되는 것은 아니다. 논리 CPU 하나에서는 번갈아 진행하는 동시성(Concurrency)을 얻고, 여러 논리 CPU에 배치되면 같은 시각에 실행하는 병렬성(Parallelism)도 얻을 수 있다. 스레드를 늘려도 CPU 수나 공유 락, 입출력 장치가 병목이면 처리량이 늘지 않을 수 있다.
실행 파일과 실행 문맥
실행 문맥을 새로 만드는 일과 실행할 프로그램을 바꾸는 일도 구분된다. Unix 계열에서는 프로세스를 생성하는 연산과 기존 프로세스의 실행 이미지를 교체하는 연산이 분리되어 있다. 생성과 실행을 나누면 새 프로그램을 시작하기 전에 입출력 연결이나 실행 환경을 구성할 수 있다. 대신 어떤 상태를 상속하고 어떤 상태를 초기화할지 명확한 규칙이 필요하다. [6][7]
프로세스 격리와 통신 비용
여러 요청을 처리하는 서버를 생각해 보자. 요청별 작업을 서로 다른 주소 공간에 두면 한 작업의 잘못된 메모리 쓰기가 다른 작업의 사적인 메모리를 직접 덮어쓰지 않는다. 요청 하나의 실패가 다른 요청으로 번지는 범위를 줄일 수 있다.
대신 결과나 공통 데이터를 주고받을 통신 경로가 필요하다. 메시지에 담을 데이터 표현을 정해야 하고, 구현에 따라 복사와 직렬화, 커널 경계 통과 비용이 발생한다. 공유 메모리로 복사를 줄일 수 있지만, 그 영역에는 다시 접근 규칙과 동기화가 필요하다.
같은 프로세스 안에서 스레드를 늘리면 주소 공간과 열린 파일 등을 공유하므로 포인터로 같은 객체를 다룰 수 있다. 연결과 전달이 간단해지는 대신, 잘못된 쓰기와 공유 상태의 오염도 다른 스레드에 영향을 준다. 스레드별 스택은 독립적인 실행 상태이지, 서로 접근할 수 없는 보호 경계는 아니다. [3]
따라서 프로세스와 스레드의 선택은 생성 비용만으로 결정되지 않는다. 공유할 데이터의 양, 장애를 격리할 범위, 통신 비용, 동기화 복잡도를 함께 고려해야 한다. 별도 프로세스도 같은 파일이나 공유 메모리를 변경한다면 경쟁 문제가 생긴다.
CPU 공유와 컨텍스트 스위치
실행 가능한 흐름이 CPU보다 많으면 순서를 정해야 한다. 운영체제는 한 스레드의 문맥을 보관하고 다른 스레드의 문맥을 복원해 실행을 넘긴다. 이것이 컨텍스트 스위치(Context Switch)이다. 논리 CPU 하나에서는 여러 흐름이 번갈아 진행하고, 여러 논리 CPU에서는 병렬 실행도 가능하다.
실행 중인 작업이 자발적으로 CPU를 내놓게 하는 방식은 전환 지점이 명확하다. 그러나 오래 실행하거나 양보하지 않는 작업이 다른 작업을 지연시킬 수 있다. 선점은 운영체제가 실행을 중단시켜 다른 작업에 기회를 주는 방식이다. 응답성과 공정성을 확보하는 데 유리하지만, 프로그램은 예상하지 못한 지점에서 실행 순서가 바뀔 수 있음을 전제로 해야 한다.
일반적인 선점형 시분할에서는 타이머 인터럽트가 실행 중인 작업의 협조 없이 CPU 제어권을 되찾을 기회를 만든다. 인터럽트가 발생할 때마다 반드시 다른 스레드로 전환하는 것은 아니다.
전환 간격을 짧게 하면 실행 가능한 다른 작업이 빨리 기회를 얻을 수 있다. 반면 문맥 저장·복원 비용이 늘고, 캐시와 주소 변환 캐시를 다시 채우는 비용이 발생할 수 있다. 길게 실행시키면 이런 비용을 줄일 여지가 있지만 짧은 작업의 응답 시간이 길어질 수 있다.
처리량, 응답 시간, 공정성, 마감 시간 준수는 서로 다른 목표다. 대화형 작업과 일괄 계산, 실시간 작업에 같은 선택이 최선일 수는 없다. [8]
입출력 대기와 깨우기
네트워크 응답이 도착하지 않았거나 필요한 입출력이 끝나지 않았다면 CPU를 더 준다고 작업이 진행되지는 않는다. 이때 실행 흐름을 대기 상태로 두고, 진행할 수 있는 다른 작업에 CPU를 주는 편이 자원 사용에 유리하다.
입출력을 기다리며 잠든 작업은 무엇이 다시 실행 가능하게 만드는가? 장치가 인터럽트로 완료를 통지하는 구성에서는 커널이 완료 상태를 확인하고 해당 요청을 기다리던 작업을 깨운다. 타이머 인터럽트가 선점 기회를 만드는 것과, 장치 인터럽트가 대기 조건의 변화를 알리는 것은 역할이 다르다. 장치의 상태를 폴링하여 완료를 알아내는 방식도 있다.
상태 전이는 다음과 같다.
실행 가능 ── 선택 ──> 실행 중
↑ │
└──── 선점 ────────┤
│ 입출력·사건 대기
v
대기 중
│ 입출력 완료 등
└────────> 실행 가능
깨우기는 대기 중인 작업을 실행 가능 상태로 바꾸는 일이다. 장치 인터럽트가 발생했다고 곧바로 그 작업으로 컨텍스트 스위치를 하는 것은 아니다. 다시 실행 가능한 후보가 되었으며, 실제 실행 시점은 스케줄링에 달려 있다. 따라서 작업의 완료 시간에는 계산 시간뿐 아니라 실행 기회를 기다리는 시간과 외부 조건을 기다리는 시간도 포함된다.
대기를 사용하는 설계는 다른 작업에 CPU를 돌려줄 수 있지만, 멈추고 깨우는 처리와 스케줄링 비용이 있다. 아주 짧은 대기라면 잠시 상태를 반복 확인하는 편이 유리할 수도 있다. 이 선택과 공유 상태의 정확성 문제는 동시성에서 다룬다.
관련 각론
- execve와 실행 이미지 교체: 프로세스 생성과 구분되는 실행 이미지 교체, 유지되는 상태와 초기화되는 상태.
- PID 1 생성: 기존 부트 태스크와 새 실행 문맥이 갈라지는 과정.
참고 문헌
- ↑ MIT PDOS, xv6: Operating system organization, 프로세스의 실행 환경과 격리.
- ↑ Microsoft Learn, About Processes and Threads, 프로세스 자원과 스레드 실행 문맥.
- ↑ 3.0 3.1 3.2 pthreads(7), 공유하는 프로세스 자원과 스레드별 상태.
- ↑ RISC-V ELF psABI, Procedure Calling Convention, 스택 포인터와 호출별 레지스터 보존 규약.
- ↑ pthread_create(3), 새 스레드의 시작과 실행 순서.
- ↑ fork(2).
- ↑ execve(2).
- ↑ sched(7).