Operating Systems: Three Easy Pieces: Remzi H. Arpaci-Dusseau and Andrea C. Arpaci-Dusseau
https://pages.cs.wisc.edu/~remzi/OSTEP/
본 글은 위 교재을 주교재로 한 학교 '운영체제(Operating Systems)' 수업을 들으며 공부한 내용을 정리한 글입니다.
해당 글은 개인 공부 및 교육 목적으로 작성하였으며, 일부 교재에 첨부된 사진(또는 교재에서 강의노트로 첨부된 사진)들을 포함하고 있습니다.
교재 출처 사진을 최소화하고자 블로그에는 핵심 사진을 제외하곤 옮기지 않은 사진들도 있습니다. 따라서 고의로 누락한 사진들이 존재하며, 해당 사진들을 언급하는 내용이 있을 수 있습니다. 누락한 사진들은 직접 교재를 참고해 주세요.
혹시 문제가 있다면, 댓글 남겨주시면 빠른 시일 내에 확인 후 적절한 조치를 취하겠습니다.
잠깐!! 이 글을 읽고 나서, 아래 글을 꼭 읽어보자.
정말 진심으로 잘 쓰인 글이라고 생각한다. 나도 해당 글을 보면서 더 많이 이해하고 공부할 수 있었다. 에디님께 감사의 말을 올리고 싶다.
https://velog.io/@eddy_song/process
Software stack
운영체제는 우리가 바로 하드웨어를 쓸 수 없으니까, 유저가 하드웨어를 쓰기 쉽게 해주는 인터페이스 느낌으로 이해하고 가자.
What happens when a program runs?
cpu는 우리의 휴먼 코드를 읽는 게 아니라, 그냥 instructions을 읽는 거임. 컴구 시간에 배운, cpu가 이해할 수 있는 명령 단위인 기계어! 우리의 printf도 여러가지 instruction들로 나뉘게 되지.
cpu의 관점에서는, instruction을 run하는 게 아니라, fetch, decode, execute하는 거임. 근데 cpu가 유저의 전체 의도를 아나? 아님. 그냥 그대로 execute만 하는 거임.
- 이 프로그램을 메모리에 로드하는 것?
- 이 프로그램이 비정상적으로 동작해서 제거하는 것?
- cpu에게 ‘이 프로그램을 다 읽었다’고 인지시키는 것?
⇒ 모두 OS의 역할임. CPU는 그냥 fetch, decode, excute만 함.
What happens when many programs run together?
요즘은 하나의 컴퓨터에 cpu가 많이 있지만, 우리는 하나의 cpu만 있다고 가정해보자. 메모리 유닛도 하나.
참고로, core랑 cpu를 구분해야함. cpu 안에 core가 있는 건데, core가 실제로 instructions을 execute하는 거고, cpu는 cells of core임.
이제, 여러 개의 프로세스들이 있다고 생각해보자.
- 그럼, 하나의 메모리 유닛이 여러 개의 프로세스들한테 공유되어 사용되는데, 메모리가 공유되면 변수값 같은 걸 같은 걸 쓰면 임계 구역 문제가 발생 가능할텐데 어떻게 해결해줘야 할까?
- 또한, 하나의 CPU가 프로세스들한테 공유되어 사용되는데, 거기 안에 코어가 여러 개 있을 수 있을 거임. 근데 코어 개수가 프로세스 개수보다 적으면, 코어를 어떻게 프로세스들한테 배정해줘야 할까?
- 우리의 키보드 마우스는 하나씩인데, 어떻게 여러 개의 프로세스들이 이 자원(키보드, 마우스)을 공유할 수 있을까?
- 만약 프로세스가 내 크롬 브라우저에 저장된 정보를 훔쳐가려 한다면, 누가 내 정보가 털리는 걸 막아줄 수 있을까?
이런 식으로 각 요소들을 어떻게 여러개의 객체(프로세스)들한테 배정하고 공유해줄 것인지 생각해봐야함.
어떻게 접근이 되게 할 것인가, 누가 어떤 순서로 실행이 되게 할 것인가 등등…
⇒ 이러한 process management를 OS가 담당함.
근데 이러한 OS의 작동 방식은 숨겨져 있어서, 우리 유저는 이런 작동 방식을 이해하지 못해도 그냥 OS를 통해 편하게 프로그램들을 사용할 수 있음.
Operating System(OS)
OS는 그래서 프로그램들 잘 작동하게(실행하게) 만들고, 메모리 잘 공유하게 만들고, 또 여러개의 장치들(스토리지, 메모리, CPU 등…)가 서로 잘 상호작용(interact)하도록 만들어줘야 함.
(학생질문) 여기서, 장치(device)의 뜻은 뭘까?
- 프로그램(프로세스)는 CPU의 core와 interact한다고 말하진 않음. 왜냐면 그냥 CPU의 core에서 그 프로그램이 돌아가고 있는 거기 때문임. 그냥 그것 뿐.
- 근데, 각 device들은 interact해야함. 왜냐하면 걔네들은 CPU 밖에서 돌아가고 있기 때문
system을 하는 사람들은 이렇게 정의를 내림.
- host: CPU가 포함되어있는 보드에 장착되어 있는 애들.
- device: 그 바깥쪽에 있는 모든 것들 .
Three Pieces
가상화(Virtualization)
단일 컴퓨터 자원을 여러 개의 가상 자원들로 추상화하는 것.
이 components가 여러 개의 프로세스들에 의해 사용될 때, 어떻게 할까? → 실제로는 공유가 되어 있는 거지만, 각각의 프로세스 관점에선 VR기기를 끼고 있어서 나만 쓰고 있다고 느끼도록 하는 것.
- 프로세스, cpu 스케쥴링, 가상 메모리에 관련된 내용.
동시성(Concurrency)
여러 작업을 빠르게 전환하여 동시에 실행되는 것처럼 보이게 하는 기술
원래는 저걸 concurrency라고 부르나, 이 하는 과정에서 생기는 임계 구역 침범 문제 또한 Concurrency Problem이라고 함.
어떤 프로그램이나 알고리즘이 순서에 상관없이 동시에 수행될 수 있다면 concurrent하다고 말함.
즉, 문제 없이 동시에 ‘잘’ 실행되는 것까지 concurrency에 포함되는 개념임.
- 쓰레드, 동기화(synchronization) 관련된 내용.
영속성(Persistence)
시스템이 예기치 않은 장애나 실패가 발생해도 데이터를 영구적으로 저장하고, 데이터의 정확성을 유지하는 것.
- storage와 file system에 관련된 내용.
Virtualization(가상화)
OS는 물리적인 자원들을 virtual form으로 변환시킴.
- 물리적인 자원이란 CPU, 메모리, Disk(storage) 등을 말함.
물리적인 자원은 하나밖에 없을 수 있지만, 그걸 가상의 폼로 변환시켜 엄청 많이 뻥튀기 시켜둠. 프로그램들은 이런 가상의 폼을 사용 가능함.
물리적인 자원을 직접적으로 프로그램에 사용하는 건 매우 어려움. 그래서 사용자가(프로그램이) 사용하기 쉽게 가상의 폼으로 바꿔서 제공하는 것임.
- 이런 가상의 폼은 더 일반적이고, 파워풀하고, 사용하기 쉬움.
어플리케이션(유저)는 기반 하드웨어들에 직접적으로 접근할 수 없음. 그저 가상화된 하드웨어를 보고 사용하는 것임.
OS는 이런 하드웨어와 어플리케이션(유저) 사이의 중개자 역할을 함. 유저의 요청 하드웨어에 전달해 주고, 하드웨어의 응답을 유저에게 전달해 줌.
Roles of OS
resource allocator
모든 프로그램들은 다 자원을 씀.
자원(resource) : 실행에 필요한 재료가 되는 것들. 실행에 필요한 것들.
OS는 어떤 프로세스가 얼만큼의 자원을 쓸 것인가 자원을 배정해주는 resource allocator의 역할을 함.
OS는 conflicting requestes들이 efficient하고 fair하게 자원을 사용할 수 있게 해줌. 여기서 중요한 건 fair임. 교수랑 학생이 자원을 동시에 쓰고 싶어하면, fair하게 자원을 할당해줘야지, 누구는 더 주고 누구는 덜 주고 하면 안됨.
근데, 이러한 fairness, 형평성을 중시하는 것이 OS의 목적만은 아님. 프로그램들이 서로 다른 중요도와 우선순위를 가질 수 있기 때문에, efficient도 신경써야함.
근데, 이 우선순위는 어떻게 구분할까?
용량은 아님. 왜냐면 더 가벼운 프로그램이 더 harm할 수도 있기 때문임. 교수가 생각할 땐 오히려 보통 더 큰 용량의 프로그램한테 더 작은 우선순위를 줌.
real time program(실시간성이 존재하는 프로그램)한테 더 높은 우선순위를 주는 경우가 많음. 다른 normal programs보다 약간 더 높은 우선순위를 주는 경우가 많음. CFS(completely fair scheduler)같은 전통적인 스케쥴러와 달리, 리눅스 커널에 최근에 VDF(virtual deadline first)라는 새로운 scheduler가 한 10년? 이전에 추가가 됐는데, 얘는 virtual deadline을 중요시함. 각 프로그램의 우선순위를 더 다양하게 주겠다는 의미임.
control program
OS는 프로그램들의 실행을 제어해서, 컴퓨터의 에러와 부적절한 사용을 예방하는 control program의 역할을 함. 예를 들어, 프로그램이 자신에게 할당되지 않은 메모리를 사용하거나, 다른 프로그램의 자원에 침해하는 것을 방지함.
System calls
모든 커널 함수들이 유저한테 오픈되어 있는 게 아님. 그런 일부 함수들은 막 쓰기엔 위험할 수 있음. 근데, 이런 위험한 커널 함수들을 유저(프로그램)가 그럼에도 불구하고 어떤 상황에서는 쓸 수가 있음. 있어야 함. 어떻게 그걸 할 수 있을까?
그거에 대한 대답은 시스템 콜임. 시스템 콜은 유저(프로그램)가 커널을 쓸 수 있게 해주는 인터페이스임. OS와 applications 사이의 interface.
오소입에서 배웠던 거 상기하기. 쉘은 시스템 유틸리티들로 시스템 콜을 호출하고(발생시키고), 이러한 시스템 콜이 커널로 전달되는거.
일반적인 OS는 몇백개의 시스템 콜을 가짐.
System Call Interface
만약 print를 한다고 하면, 유저 입장에서는 모르지만, 실제론 printf 함수에서 시스템 콜이 발생함.
C언어의 printf함수와 파이썬의 print함수의 차이가 뭐냐? → 이거 모 회사 질문이었음.
Sonnet 3.7 답변)
C의 printf와 Python의 print: OS와 시스템 콜 관점에서 비교
C언어의 printf 함수와 Python의 print 함수는 모두 표준 출력에 데이터를 출력하는 기능을 수행하지만, 내부 구현과 시스템 콜 호출 방식에는 중요한 차이가 있습니다.
기본 개념 비교
C의 printf:
- stdio.h 라이브러리의 일부
- 버퍼링된 출력 함수
- 포맷 지정자를 사용한 서식화 지원
- 저수준 시스템 콜인 write()를 간접적으로 호출
Python의 print:
- 내장 함수
- 객체의 문자열 표현을 표시
- 파이썬 3에서는 함수, 파이썬 2에서는 문장
- 내부적으로 여러 추상화 계층을 거쳐 최종적으로 시스템 콜 실행
시스템 콜 관점에서의 실행 흐름
C의 printf 실행 흐름:
- printf 함수가 호출됨
- 내부적으로 데이터를 사용자 공간 버퍼에 포맷팅
- 버퍼가 가득 차거나, 개행 문자를 만나거나, fflush()가 호출되면:
- write() 시스템 콜을 통해 커널에 데이터 전달
- 커널이 해당 파일 디스크립터(일반적으로 stdout, fd=1)에 데이터 작성
c
Copy
int main() {
printf("Hello, World!\\n"); // 내부적으로 write(1, "Hello, World!\\n", 14) 호출
return 0;
}
Python의 print 실행 흐름:
- print() 함수 호출
- Python 인터프리터가 객체를 문자열로 변환(객체의 __str__ 또는 __repr__ 메서드 호출)
- sys.stdout.write() 메서드로 데이터 전달 → 1단계
- io 모듈의 버퍼링 메커니즘을 통과
- 최종적으로 OS 레벨의 write() 시스템 콜 호출 → 2단계
python
Copy
print("Hello, World!") # 여러 추상화 계층을 거쳐 최종적으로 write() 호출
주요 차이점
- 추상화 수준:
- C의 printf는 비교적 저수준으로, 운영체제와 가까움
- Python의 print는 고수준 추상화로, 여러 계층을 통과함
- 버퍼링 처리:
- C는 라인 버퍼링, 완전 버퍼링, 버퍼링 없음 등 다양한 버퍼링 모드 제공
- Python은 기본적으로 라인 버퍼링을 사용하지만, 추상화 계층에서 관리됨
- 예외 처리:
- C의 printf는 오류 시 음수 값 반환
- Python의 print는 예외 발생 메커니즘 사용
- 포맷팅 방식:
- C는 printf 내에서 포맷 지정자 사용
- Python은 문자열 포맷팅과 print를 분리하여 처리 가능
시스템 콜 오버헤드
두 함수 모두 최종적으로 write() 시스템 콜을 사용하지만:
- Python의 print는 추가 추상화 계층으로 인해 일반적으로 C의 printf보다 많은 오버헤드 발생
- C의 printf는 버퍼링을 통해 시스템 콜 횟수를 최적화
- Python의 print는 개발자 편의성을 위한 기능으로, 성능보다 사용성 중시
성능이 중요한 상황에서는 C의 printf가 일반적으로 더 효율적이지만, Python의 print는 사용 편의성과 안전성 측면에서 장점이 있습니다.
printf를 호출하면 그게 구현되어있는 stdio 라이브러리에서, 내부적으로 write() 함수가 호출되어 write() 시스템 콜이 발생하는 거임.
우리 유저(어플리케이션)과 C언어 라이브러리들은 유저 공간임.
근데, 시스템 콜(인터페이스)와 커널 등 그 아래 부분은 커널 공간임.
우리는 커널 공간에 바로 접근할 수가 없음. 시스템 콜을 통해 커널에 접근함.
시스템 콜을 호출하면, 시스템 콜 인터페이스라는 시스템 콜을 호출하는 루틴으로 들어가서, 거기서 커널을 실행하는데, 참고로 아키텍쳐마다 커널이 어떻게 실행되냐하는 그 코드는 다른데, 겹치는 부분도 있음. 그래서 common kernel code도 있고, architecture dependent kernel code도 있음. 아키텍쳐마다 다름. 어셈블리어로 짜져 있음.
오소입에서도 봤던 그림! 커널의 세 가지 Subsystems가 잘 나와있다.
커널은 많은 기능을 하는데, 프로세스 관리는 뮤텍스 같은 것도 있고, 그 담에 protection은 kernel space를 보호하고, virtual space도 보호하고 하는 기능을 말함.
System call, User space은 일종의 추상화(Abstraction)
- 위에 가상화 설명하다가 갑자기 System call, User space 나와서 당황했을 수 있는데, System call과 User space는 추상화와 관련된 얘기고, 가상화 또한 이 추상화를 사용해 단일 컴퓨터 자원들을 여러 개의 가상 자원들로 추상화하는 것이므로 관련돼서 나온 거라고 생각하면 됨.
- 운영체제는 하드웨어와 소프트웨어 사이에 추상화 계층을 제공하는데,
- System Call은 유저가 커널을 쓸 수 있게 하는 인터페이스(또는 API)라고 했잖음?? 유저가 하드웨어 자원을 직접 다루지 않도록 추상화한 것임.
- User-space와 Kernel-space를 분리한 것도, 유저가 커널 공간에 바로 접근하지 못하도록, System call을 통해서만 커널 공간에 접근 가능하도록 추상화한 것.
- 그러나, System call이나 User-space가 직접적으로 가상화와 연관있는 개념은 아님. 추상화와 더 연관있는 개념이라고 봐야 함. 가상화는 하드웨어 자원을 여러 개의 가상 자원들로 추상화하는 것이 핵심 개념.
Virtualizing CPU
어떻게 OS가 CPU를 가상화할까??
만약 (single core) CPU가 한 대만 있고 가상화가 없으면, 이제 우리는 한 명밖에 CPU를 쓰지 못함.
- 근데, 어떤 프로세스는 1시간, 어떤 놈은 1분 걸리고… 이거 어케 스케쥴링하지??
⇒ 이를 해결하기 위해, CPU를 가상화시켜, 거의 무한개로 보이도록 CPU를 뻥튀기시킴.
⇒ 그래서 많은 프로그램들이 거의 동시에 실행되고(동작하고) 있는 것처럼 보이게 함.
여담으로, 만약 우리 50명 학생이 다 하나의 CPU를 공유하고 있다고 해보자. 그러면 50명이 느끼기에는, 각각의 50명은 모르고 CPU 하나만 아는 거임. 그러면 각 학생은 그냥 “아 내 일이 엄청 느리게 처리되네;;; 대충 평소의 1/50정도 속도로?”만 알지, 이 CPU를 50명이 공유하고 있다는 건 학생 입장에선 모름. 즉, 각 프로세스는 CPU가 내 job을 처리해줄 수 있는 상태인지는 모름.
Virtualizing CPU (Cont.)
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h>
#include <assert.h>
#include "common.h"
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "usage: cpu <string>\\n");
exit(1);
}
char *str = argv[1];
while (1) {
Spin(1); // Repeatedly checks the time and returns once it has run for a second
printf("%s\\n", str);
}
return 0;
}
저건 C언어 코드가 있다고 하면, 저기에 인자를 1을 넘기면, 1초마다 str을 출력할 거임. (Spin(1)은 1초동안 돌아가다 1초 지나면 함수 빠져나오는 놈, sleep(1)과 거의 동일)
단일 프로세스 실행 시
prompt> gcc -o cpu cpu.c -Wall
prompt> ./cpu "A"
A
A
A
ˆC
prompt>
멀티 프로세스 실행 시
prompt> ./cpu A & ; ./cpu B & ; ./cpu C & ; ./cpu D &
[1] 7353
[2] 7354
[3] 7355
[4] 7356
A
B
D
C
A
B
D
C
A
C
B
D
...
보면, 저렇게 여러개의 프로세스를 동시에 실행시켜도, 우리가 cpu core가 하나밖에 없지만, 모든 프로그램들이 한꺼번에 동시에 돌아가는 것으로 보임!! (일단 single core cpu 하나만 있다고 가정하고 가는듯)
→ 이게 concurrency에 따른 멀티태스킹 덕분이긴 한데, 여기선 CPU를 virtualizing하여 뻥튀기시킨 부분을 강조하고 있음.
단, A B C D의 출력 순서는 OS의 스케쥴링에 따라 달라짐. 즉, nondeterministic(비결정적)함. (시프실 때 배웠듯, 이 순서는 우리가 예측 불가능함.)
Virtualizing Memory
물리적 메모리(Physical Memory)는 바이트의 배열(an array of bytes)임. 물리적 메모리는 말 그대로 RAM을 말하는데, 운영 체제와 프로그램은 메모리에서 데이터를 읽거나 쓸 때, 특정 메모리 주소를 사용하여 메모리의 바이트 배열을 참조함.
프로그램(또는 프로세스)은 실행 중에 필요한 데이터 구조(변수, 배열, 객체 등)를 물리 메모리 안에 저장함. 우리가 변수 선언하면 그게 메모리에 저장되는 거 생각하면 됨.
- Read memory(메모리 읽기, load): 데이터에 접근하기 위한 주소를 명시함. ex. int x = 42; int y = x;
- Write memory(메모리 쓰기, store): 주어진 주소에 저장할 데이터를 명시함. ex. int x = 42; int y = 64; x = 12;
Virtualizing Memory (Cont.)
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include "common.h"
int main(int argc, char *argv[]) {
int *p = malloc(sizeof(int)); // a1: allocate memory
assert(p != NULL); // 확인: 메모리 할당이 실패하지 않았는지 확인
printf("(%d) address of p: %08x\\n", getpid(), (unsigned) p); // a2: 메모리 주소 출력
*p = 0; // a3: 메모리 첫 번째 위치에 0 저장
while (1) {
Spin(1); // 1초 대기 (Spin 함수는 아마 시간을 체크하는 함수일 것)
*p = *p + 1; // a4: 메모리에서 읽은 값을 1 증가시킴
printf("(%d) p: %d\\n", getpid(), *p); // a4: p의 값을 출력
}
return 0;
}
malloc 함수로 메모리를 할당하고 있는데, getpid로 프로세스 아이디를 가져오고 있고,
p를 1씩 증가시켜가며 pid와 p를 출력하고 있음.
단일 프로세스 실행 시
prompt> ./mem
(2134) memory address of p: 00200000
(2134) p: 1
(2134) p: 2
(2134) p: 3
(2134) p: 4
(2134) p: 5
ˆC
멀티 프로세스 실행 시
prompt> ./mem &; ./mem &
[1] 24113
[2] 24114
(24113) memory address of p: 00200000
(24114) memory address of p: 00200000
(24113) p: 1
(24114) p: 1
(24114) p: 2
(24113) p: 2
(24113) p: 3
(24114) p: 3
...
근데, 아까처럼 여러 개의 프로세스들을 동시에 실행시키면, 두 프로세스는 같은 메모리 공간을 참조하고 있기에(00200000) 임계 구역 문제가 발생하여 1 증가한 게 다른 프로세스에도 영향을 미치고 하지 않을까?
- 근데 결과를 확인해보면 그러고 있지 않음. 즉, 궁극적으로 두 프로그램은 같은 물리적 메모리 주소를 참조하고 있지 않다는 것임.
- 각 프로세스는 각자의 고유한 가상 메모리 공간을 가짐. 0x00200000는 가상 메모리 주소고, 두 프로세스가 둘 다 동일한 가상 메모리 주소를 참조하고 있는 거임. 그러나 이는 주소값이 동일할 뿐, 같은 가상 메모리 공간을 참조하고 있는 게 아님.
- 그리고 이 가상 메모리 공간이 실제 물리적 메모리 주소에 매핑되어 있음. MMU가 이러한 가상 메모리 공간을 실제 물리적 메모리 공간에 매핑해 줌.
(참고로, 매 실행 때마다 같은 주소가 나오진 않을 거임. 해킹 방지를 위해 매 실행마다 프로세스가 다른 가상 메모리 주소를 참조하게 컴퓨터가 설계되었기 때문. 이 기능을 끄면 저런 결과가 나온다고 책에 나와 있다.)
- 위와 같이, OS(는 CPU 내부에 내장된 부품인 MMU을 제어하여) 가상 메모리 주소(공간)을 실제 메모리 주소(공간)에 매핑함.
- 따라서, 각 프로세스의 메모리 참조는 다른 프로세스의 메모리 참조에 영향을 끼치지 않음.
- 물리적 메모리는 공유 자원이지만, 위처럼 OS에 의해 관리됨.
참고) 가상 메모리와 물리적 메모리가 매칭되는 방식
https://namu.wiki/w/%EA%B0%80%EC%83%81%20%EB%A9%94%EB%AA%A8%EB%A6%AC#s-3.3
가상 메모리의 주소 공간은 페이지(Page)라는 것으로 일정한 크기로 분할되어 있다. 페이지의 크기는 하드웨어에 의해 결정이 되며 운영체제는 이러한 페이지들을 페이지 테이블 안에 집어 넣어서 관리하고 있다. 프로세스마다 자신만의 페이지 테이블을 가지고 있다. (유저 메모리) 물론 운영체제도 역시 자신만의 페이지 테이블을 가지고 있다. (커널 메모리) 페이지 테이블은 운영체제에서 관리한다.
프로세스는 각자 독립된 가상 주소를 가진다. 예를 들어 A 프로세스는 0x1000라는 주소를 가질 수도 있고 동시에 B 프로세스는 0x1000라는 주소를 가질수도 있다. 만약 두 프로세스가 각자 0x1000라는 주소에 접근할 경우 CPU는 프로세스 (컨텍스트)마다 가지는 페이지 테이블을 참조한다.
페이지 테이블은 가상 주소와 물리 메모리의 실제 주소를 연결시켜주는 테이블이다. 가령 A 프로세스의 페이지 테이블에서 0x1000 주소에 대응되는 실제 주소는 0x30000 으로 나오고 B 프로세스의 페이지 테이블에서는 0x1000 주소에 대응되는 실제 주소가 0x40000인 것이다. 이렇게 되면 두 프로세스는 동일한 메모리 주소를 참조했지만 실제로는 MMU가 프로세스의 페이지 테이블을 참조해서 실제 물리 메모리 주소를 얻고 그 위치를 참조해서 가져오거나 쓰기를 한 것이다. 따라서 두 프로세스의 주소 공간이 겹치지 않고 독립적으로 실행될 수 있으며 프로그래머는 다른 프로세스의 주소 공간을 생각할 필요가 없어진다.
물리 메모리의 모든 내용은 계속 저장해놓지 않는다. 운영체제는 사용되지 않는 페이지를 디스크에 있는 페이징 파일로 옮기게 된다.
페이지 테이블에는 유효 비트라는게 있는데 이를 통해 페이지가 실제 물리 메모리에 존재하는지 알 수 있다. 프로세스가 가상 주소에 접근하게 되면 MMU가 페이지 테이블을 참조한 뒤 페이지를 확인해보는데 ‘페이지가 유효하지 않을 경우(=해당 페이지가 물리 메모리 내에 존재하지 않는 경우를 의미)’ 페이지 폴트 (Page Fault)라는 트립을 발생시킨다. 페이지 폴트을 감지한 운영체제는 CPU의 동작을 잠시 중단시킨 뒤 해당 페이지를 페이징 파일에서 가져와서 물리 메모리의 비어있는 공간에 적재시키고 페이지 테이블을 최신화시킨 뒤 CPU의 동작을 재개시킨다.
따라서 사용되지 않는 페이지를 페이징 파일로 옮기는 것과 물리 메모리에 없는 페이지를 페이징 파일에서 가져와서 물리 메모리에 적재하는 일은 운영체제가 자동으로 수행한다. 다만 페이지 폴트는 그 과정상 디스크에 접근하기 때문에 파일 입출력이 발생하므로 성능이 하락된다.
Problem of concurrency
OS는 동시에 여러개의 어플리케이션을 다뤄야 함.
각 어플리케이션의 인터럽트들을 다뤄야 하는데, 마우스 키보드 프로그램 등등… 이걸 어케 할까??
현대의 멀티쓰레딩 프로그램들도 이러한 동시성(concurrency) 문제를 보이고 있음. (시프실에서 한 거 기억하자)
Concurrency Example
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h> // pthread 관련 헤더파일 추가
#include "common.h"
volatile int counter = 0; // volatile로 선언, 최적화 방지
int loops;
void *worker(void *arg) {
int i;
for (i = 0; i < loops; i++) {
counter++;
}
return NULL;
}
int main(int argc, char *argv[]) {
if (argc != 2) {
fprintf(stderr, "usage: threads <value>\\n");
exit(1);
}
loops = atoi(argv[1]);
pthread_t p1, p2; // 두 개의 스레드 선언
printf("Initial value : %d\\n", counter);
// 스레드 두 개 생성
pthread_create(&p1, NULL, worker, NULL);
pthread_create(&p2, NULL, worker, NULL);
// 각 스레드가 종료될 때까지 기다림
pthread_join(p1, NULL);
pthread_join(p2, NULL);
printf("Final value : %d\\n", counter);
return 0;
}
여기서 메인 프로그램은 두 개의 스레드를 생성함.
스레드는 시프실에 공부한 걸 상기해보면, 위처럼 메모리의 텍스트, 데이터, 힙 영역을 서로 공유하지만, 스택 영역은 따로 가짐. 위 코드에서 각 스레드는 worker()함수를 각각 실행하게 됨.
Concurrency Example (Cont.)
Loops: 1000
prompt> gcc -o thread thread.c -Wall -pthread
prompt> ./thread 1000
Initial value : 0
Final value : 2000
두 개의 스레드가 각각 카운트를 하는데, 각각의 스레드가 1씩 1000번 증가시키니까 2000이 나와야 하지 않을까?? → ㅇㅇ 실제로 그렇게 나옴.
Loops: 100000
prompt> ./thread 100000
Initial value : 0
Final value : 143012 // huh??
prompt> ./thread 100000
Initial value : 0
Final value : 137298 // what the??
근데, 100000번 증가시켜면, 예상 결과 200000보다 더 적게 나오는 경우가 등장함!!!
Why is this happening?
이유는 각 스레드에서 지금 fetch, decode, execute, write(store)의 4 steps으로 돌아가기 때문에, 어느 한 스레드가 fetch하고 아직 write하기 전에, 다른 스레드가 fetch를 해 버리면, 두 스레드가 동일한 값을 write해버림!! 즉, write할 때 2가 증가돼야 하는데 1만 증가될 수 있는거임.
이거 생각해보면 당연. 컴구랑 시프실 때 배운 것들. 이러면 다른 하나의 프로세스에서 일어난 execute(increase)가 적용이 안 될 수 있음.
atomic operation: 여러 단계의 step들이 정의되어 있을 때, 각 step 사이에 다른 작업이 끼어들지 않고 하나의 연속된 작업으로 실행되는 것을 의미함. 즉, 각 작업이 완전히 끝날 때까지 다른 작업이 간섭할 수 없도록 보장하는 것임.
문제가 생긴 위의 4 steps는 atomically(아토믹하게) 실행되고 있지 않아서, 동시성(concurrency) 문제가 발생한 것임. 그러나 이런 atomic operation을 반영하는 건 매우 비용이 비쌈.
참고) Concurrency vs parallelism(sonnet 3.7)
우리가 위에서 든 C언어 스레드 예시는 단일 코어라는 가정 하의 설명임.
- 단일 코어에서의 멀티프로세스 또는 멀티스레딩(멀티스레드)((ex. 1-core-4-process or 1-core-1-process-4-threads)
- 시분할(time-sharing) 방식으로 CPU가 빠르게 프로세스(스레드) 간 전환
- 이것이 진정한 의미의 "동시성(concurrency)"
- 실제로는 한 번에 하나의 프로세스(스레드)만 실행되지만 빠른 전환으로 동시에 실행되는 것처럼 보임
- 멀티코어에서의 멀티프로세스 또는 멀티스레딩(멀티스레드)(ex. 4-core-4-process or 4-core-1-process-4-threads)
- 코어 수만큼은 실제로 프로세스(스레드)가 물리적으로 동시에 실행 가능
- 이것이 진정한 의미의 "병렬성(parallelism)"
- 프로세스(스레드) 수가 코어 수보다 많을 경우에만 일부 프로세스(스레드) 간 전환 발생
멀티프로세싱, https://doorbw.tistory.com/26
- 참고로, 멀티프로세싱은 다수의 CPU(또는 core)가 협력해서 task(=process, thread)를 처리하는 걸 말함. process(동사)를 여러 개의 processor가 한다는 의미로, process(명사)가 여러개라는 말이 아님. 주의가 필요함. 그에 비해 멀티스레딩은 보통 멀티스레드랑 같은 의미로 쓰임.
참고2) (동시성이든 병렬이든) 멀티스레드가 멀티프로세스보다 더 유리한 경우
(이건 동시성만 해당)멀티스레드가 멀티프로세스보다 컨텍스트 스위칭 오버헤드가 더 작음.
- 프로세스 켄턱스트 스위칭은 매번 CPU가 PCB에 있는 프로세스의 정보를 저장하고 복원해야 함. 즉, 페이지 테이블 등 여러 프로세스 정보들을 다 저장하고 복원해야 되기에, 오버헤드가 큼. 그러나 스레드는 같은 부모 프로세스의 메모리 주소 공간을 (스택 제외) 공유하기에, 스레드 컨텍스트 스위칭은 매번 CPU가 TCB에 있는 스레드의 정보만 저장하고 복원하면 되기에, 오버헤드가 더 작음.
멀티스레드가 멀티프로세스보다 개체 간 통신 오버헤드가 더 작음.
- 스레드 간 통신은 부모 프로세스의 data 영역에 있는 전역 변수를 사용하면 되는데, 프로세스 간 통신(IPC)에는 전역 변수를 공유하지 않으니 통신 오버헤드가 더 들어감.
스레드가 프로세스보다 생성에 필요한 오버헤드가 적음.
- 멀티프로세스는 프로세스 생성을 위한 시스템 콜이 필요한데(PCB 생성, 페이지 테이블 복사(생성) 등), 멀티스레드의 스레드 생성을 위한 시스템 콜이 더 오버헤드가 적음(스레드는 생성 시 TCB를 생성하며, 페이지 테이블은 복사(생성)하지 않음). 프로세스가 메모리 사용량이 더 많기도 하고.
참고3) 멀티스레드가 오히려 더 불리할 수 있는 경우
임계 구역 문제 발생 시 atomic(mutex) 처리를 위해 상당한 오버헤드가 발생 가능함.
- 스레드 간의 통신은 전역 변수를 주로 이용하는데, 당연하게도 임계 구역 문제가 발생할 수 있고, 이를 해결하기 위해 atomic(mutex)방식 사용이 필요한데 이 경우 오버헤드가 생각외로 많이 커질 수 있음.
참고4) Concurrency Problem는 multi-threading에만 해당되는 문제가 아님.
- 멀티 쓰레딩같은 경우는 전역변수가 공유되서 concurrency problem이 직관적으로 다가오는데, 멀티 프로세스 또한 I/O device나 IPC와 같은 여러 자원들이 공유되기에 concurrency problem이 여전히 발생 가능함.
Persistence
DRAM같은 장치들은 값들을 휘발성있게 저장함.
그래서 하드웨어와 소프트웨어는 데이터를 지속적으로 저장할 수 있어야함.
우리가 다룰 하드웨어와 소프트웨어들은 아래와 같음.
- 하드웨어: I/O 장치들 such as 하드디스크, SSD
- 소프트웨어:
- 파일 시스템은 디스크를 관리함.
- 파일 시스템은 유저가 만든 어떤 파일이든 저장할 책임이 있음.
Persistence(Cont.)
#include <stdio.h> // 표준 입출력 함수들 사용을 위한 헤더 파일
#include <unistd.h> // 시스템 호출 (close 등) 사용을 위한 헤더 파일
#include <assert.h> // assert 매크로 사용을 위한 헤더 파일
#include <fcntl.h> // 파일 제어 함수들 (open 등) 사용을 위한 헤더 파일
#include <sys/types.h> // 파일 디스크립터와 관련된 데이터 타입 사용을 위한 헤더 파일
int main(int argc, char *argv[])
{
// 파일 열기: /tmp/file을 쓰기 전용으로 열고, 없으면 생성하고, 내용은 비우기
int fd = open("/tmp/file", O_WRONLY | O_CREAT | O_TRUNC, S_IRWXU);
// 파일이 정상적으로 열렸는지 확인 (파일 디스크립터가 -1이 아니어야 함)
assert(fd > -1);
// "hello world\\n"를 파일에 13바이트만큼 씁니다.
int rc = write(fd, "hello world\\n", 13);
// 실제로 쓴 바이트 수가 13이어야 함을 확인
assert(rc == 13);
// 파일을 닫기
close(fd);
return 0;
}
// 참고로, assert()함수는 인자가 참이면 아무 일이 일어나지 않지만,
// 거짓이면 프로그램이 종료되고 에러 메시지가 출력됨.
/tmp/file이라는 파일(경로 포함)을 만들고, 거기에 ‘hello world\n’을 작성하는 프로그램.
open(), write(), close()는 OS의 일부분인 파일 시스템과 상호작용하는 시스템 호출로, 해당 함수들이 실행되면 파일 시스템으로 전달되어 해당 요청들이 수행됨. (여기서 파일 시스템은 일종의 시스템임. 소프트웨어라고 보긴 어렵다.)
메모리와 같은 디스크는 많은 양의 바이트 배열임.
OS는 디스크에 데이터를 쓰고 이들을 관리하기 위해 무엇을 할까??
- 데이터가 디스크의 어디에 저장될지 결정함.
예를 들어, 새로운 데이터를 저장할 때 파일 시스템은 해당 데이터를 저장할 빈 공간을 디스크에서 찾아 거기에 데이터를 저장해야겠다고 결정함.
- I/O 요청을 밑에 있는 저장 장치에 전달함.
이 과정에서 디스크 드라이브가 실제로 데이터를 저장하거나 읽는 작업을 수행함. I/O 요청은 디스크에 데이터를 기록하거나 읽는 명령으로, 운영 체제가 파일 시스템을 통해 생성한 명령임.
- 파일 시스템은 쓰기(write) 과정 중에 생기는 **시스템 충돌(System Crash)**을, 아래 3가지 기술을 통해 처리함. (ex. 갑자기 컴퓨터 전원선이 팍 나감)
저널링(Journaling)
디스크에 데이터를 실제로 쓰기 전에 **저널(log)**에 기록을 남김. 이렇게 기록된 저널을 통해 파일 시스템은 시스템이 크래시되었을 때, 안전하게 복구할 수 있음. 만약 시스템 크래시가 발생하면, 파일 시스템은 저널을 참조하여 마지막으로 기록된 상태를 복원함.
복사 후 쓰기(Copy-on-write)
데이터를 수정할 때 기존 데이터는 변경하지 않고 새로운 위치에 데이터를 쓴 후, 새로운 데이터를 참조하도록 하는 방식. 이렇게 하면 시스템 크래시가 발생하더라도 기존 데이터는 변경되지 않으므로 데이터 손실을 최소화할 수 있음.
쓰기 순서 관리(Ordering Writes)
파일 시스템은 여러 개의 데이터 쓰기 작업을 적절한 순서로 처리하여 데이터가 올바르게 기록되도록 함. 예를 들어, 파일 시스템은 데이터를 디스크에 쓸 때, 먼저 메타데이터를 쓰고, 그 후에 실제 데이터 블록을 쓸 수 있도록 순서를 관리함. 이 순서대로 쓰지 않으면 파일의 무결성이 깨질 수 있음.
OS Design Goals(OS 설계 목표)
추상화 구축(Build up abstraction)
- 추상화는 기본이자 핵심임!!
- A large program -> small pieces -> C -> assembly -> logic gates -> transistors: 앞으로 갈 수록 추상화, 뒤로 갈 수록 구체화(사실구체화란 용어는 이 의미는 아니긴 함)
- 복잡한 시스템의 세부 사항을 숨기고, 사용자가 시스템을 쓰기 더 쉽고 편하게 만들어줌.
- 예를 들어, 우리가 어셈블리 단계 프로그래머면, 우리가 트랜지스터를 고려할 필요는 없음.
높은 성능 제공(Provide high performance)
- OS의 오버헤드를 최소화함.
- OS는 과도한 오버헤드 없이 가상화를 제공할 수 있도록 분투해야함.
어플리케이션 간 보호(Protection between application)
- Isolation(격리): 어떤 어플리케이션이 문제를 일으키더라도, 다른 어플리케이션들 그리고 OS 자신을 해치지는 못하게 해야 함. 이를 위해 어플리케이션(프로세스) 간 격리가 필요함.
높은 정도의 신뢰성(High degree of reliability)
- OS는 반드시 non-stop으로 중단 없이 계속 실행되어야 함.
Other issues
- Energy-efficiency
- Security
- Mobility
'CS > 운영체제' 카테고리의 다른 글
[OSTEP] Ch3.1. Scheduling: turnaround time, FIFO, SJF, STCF. (0) | 2025.03.25 |
---|---|
[OSTEP] Ch2.4. Process: Interrupt and Context Switch (0) | 2025.03.17 |
[OSTEP] Ch2.3. Process: System Call and Trap (0) | 2025.03.17 |
[OSTEP] Ch2.2. Process: Process States (0) | 2025.03.17 |
[OSTEP] Ch2.1. Process: Process APIs (0) | 2025.03.17 |