운영체제란?
하드웨어와 소프트웨어 사이에서 중재자 역할로, 시스템 자원을 효율적으로 관리하고, 사용자에게 일관된 실행환경을 제공하는 소프트웨어.
운영체제가 필요한 이유?
앱이 하드웨어와 직접 통신해야 하는데, 복잡하고, 충돌 많고, 재사용 불가함.
OS가 자원 관리, 추상화, 인터페이스를 제공하기 때문에 효율적이고 안정적인 실행 가능.
운영체제의 커널의 핵심 역할 5가지?
1 프로세스 관리: 프로그램 생성/ 종료/ 스케줄링 - 스레드 수 관리, 컨텍스트 스위칭, 병렬성 제어
2 메모리 관리: 물리/ 가상 메모리 관리, 페이지 교체 - 메모리 누수 방지와 성능 튜닝
=> RAM(휘발) 대상, 프로세스의 안정적 실행을 위해 힘씀. 가상 메모리, 페이지 교체, 세그멘테이션, 페이징 등.
3 파일 시스템 관리: 디스크에 정보 저장, 디렉토리 관리, 접근 제어 - 로그 관리, 파일 캐시 최적화
=> 디스크(비휘발성) 대상, 데이터 영구 저장과 디렉토리 구조 관리.
4 I/O 시스템 관리: 디바이스 드라이버, 버퍼, 비동기 I/O 처리
5 시스템 콜 처리: 사용자 프로그램의 커널 기능 호출 중재
시스템 콜, 커널, 유저 프로그램 간의 흐름: 한 마디로 유저 => 커널은 무조건 시스템 콜!
OS에서 유저 프로그램이 커널의 기능이나 자원에 접근하려면 반드시 시스템 콜을 통해야 하며,
이는 read(), write()처럼 명시적으로 호출할 수도 있고, printf()나 malloc()처럼 라이브러리 함수 내부에서 암묵적으로 호출될 수도 있음. 결국, 유저 모드에서 커널 모드로 전환되는 유일한 공식 경로는 시스템 콜이며, 애플리케이션이 실행 중에 커널 기능이 필요해지는 순간마다 항상 시스템 콜이 사용됩니다.
이 과정에서 유저 모드에서 커널 모드로의 전환이 이루어지며, 이 구조가 OS의 보안성과 안정성을 지키는 매커니즘.
유저모드일 때는 chrome, python 등 일반 프로그램이 돌아가는데, 이때는 CPU가 제한된 명령만 실행 가능하지만,
커널모드가 되면 모든 명령이 가능함. 앱을 실행하거나 시스템 콜을 실행하여 커널을 일 시켰을 때 발생.
Trap vs. interrupt
CPU는 유저 모드와 커널 모드 모두에서 동작한다.
유저 모드일 때는 제한된 자원만 사용할 수 있지만, 여전히 CPU는 사용자 프로세스를 실행하며 동작 중이다.
기본적으로 시스템은 하나 이상의 프로세스가 유저 모드에서 실행되는 상태에서 시작한다.
그런데 실행 중인 프로세스가 운영체제의 기능을 필요로 하는 상황,
예를 들어 시스템 콜 호출이나 예외(예: 0으로 나누기, 페이지 폴트)가 발생하면
CPU는 해당 프로세스를 일시 중지하고, 트랩(trap)을 발생시켜 커널 모드로 전환한다.
커널은 트랩을 처리하는 핸들러를 실행한 뒤, 저장해두었던 프로세스의 상태를 복원하고 유저 모드로 복귀, 다시 해당 프로세스를 계속 실행시킨다.
반대로, 현재 유저 모드 혹은 커널 모드에서 어떤 코드가 실행 중일 때
키보드 입력, 마우스 클릭, 타이머 만료 등 외부 하드웨어 이벤트가 발생하면,
해당 장치는 인터럽트 라인(Interrupt Request Line, IRQ)을 통해 CPU에 신호를 보낸다.
CPU는 현재 작업을 잠시 중단하고, 인터럽트 벡터 테이블을 참조하여 ISR(인터럽트 서비스 루틴)을 호출한다.
인터럽트는 여러 개가 동시에 발생할 수 있기 때문에, 운영체제는 인터럽트에 우선순위를 설정하고, 그에 따라 처리 순서를 조정한다. 인터럽트 처리가 끝나면, CPU는 원래 수행하던 작업의 상태로 돌아가 다시 실행을 이어간다.
Context Switching
문맥 교환은, A 프로세스를 실행하다 중단하고, 그때까지의 cpu 상태를 PCB에 저장하고, B 프로세스의 컨텍스트를 PCB에서 꺼내서 cpu로 복원하고, B 프로세스를 실행하는 과정을 일컫는다.
참고로 각각의 프로세스는 하나의 PCB를 가진다. 모든 PCB는 커널메모리 공간에 저장된다. 유저 프로세스가 직접 접근 어렵고, 커널만 접근 가능함. 커널이 시켜서, 컨텍스트 저장/복원하는 주체는 CPU.
중단하고 복원할 때는, 자세히는 레지스터도 저장/복원하고, 캐시도 초기화하고 TLB 플러시와 파이프라인 플러시 등이 발생하기 때문에 꽤나 큰 비용이 든다는 거다. 그러나 그건 프로세스의 경우고, 스레드의 교환 시에는 같은 메모리 주소를 쓰고 PCB도 동일한 PCB를 쓰기 때문에 프로세스 문맥 교환 시 품이 훨씬 많이 드는 거다.
이때 PCB에 적히는 내용(즉, 문맥)은 다음과 같다 - Program Counter(다음으로 실행할 명령어 주소), 레지스터 값, 실행 상태, 스택 포인터 등등.
문맥 교환은 인터럽트 처리, 트랩 처리, 그리고 cpu 스케줄링 등의 상황에 발생한다.
파이프라이닝 vs. 슈퍼스칼라 vs. Out-of-order
Out-of-Order Execution은 파이프라이닝과 슈퍼스칼라 구조를 보완하고 확장하는 기법입니다.
파이프라이닝은 명령어를 세분화해 효율적으로 처리하고, 슈퍼스칼라는 여러 파이프라인을 통해 병렬 실행을 가능하게 하지만, 둘 다 명령어 간 의존성 때문에 병목이 발생할 수 있습니다.
Out-of-Order Execution은 실행 가능한 명령어를 먼저 처리하여
이런 병목을 제거하고, CPU 자원을 최대한 활용하게 해줍니다.
슈퍼스칼라 vs. 하이퍼스레딩
슈퍼스칼라는 하나의 스레드에서 발생하는 여러 명령어를 코어 내부의 여러 실행 유닛에 병렬로 분배해 실행함으로써 명령어 수준의 병렬성(ILP)을 활용하는 구조.
반면 하이퍼스레딩(SMT)은 하나의 코어가 여러 스레드의 문맥을 유지하고 유휴 자원에 다른 스레드를 끼워넣어 실행함으로써 스레드 수준의 병렬성(TLP)을 높이는 기술. 하나의 코어에 1개의 쓰레드라는 불문율을 깨고, 두 개 이상을 끼워넣는 것이기 때문에. 참고로 실제로 병렬화 하는 것 아니다? 실제로 실행되는 건 하나지만, 어느 하나가 쉴 때 다른 하나를 끼워넣는 형식이기 때문에 병렬처럼 보이는 것.
두 기술은 서로 독립적이지만, 고급 CPU에서는 동시에 함께 사용되며 코어 하나에서 다중 스레드 + 다중 명령어 실행이라는 복합적인 최적화를 이룸.
프로세스와 쓰레드의 차이
CPU 코어에서 실제로 실행되는 단위는 항상 스레드이며, 멀티프로세스는 서로 독립된 프로세스의 메인 스레드들이 코어에서 실행되는 방식이고, 멀티스레드는 하나의 프로세스 내부에서 여러 스레드가 동시에 실행되는 구조. 따라서 실행 단위는 동일하지만, 자원 공유 방식, 문맥 전환 비용, 설계 목적 등이 다름.
프로세스 > 쓰레드: 격리/ 보안/ 안정성을 위해. 서로 다른 앱은 서로 다른 환경에서 실행되어야 하니까.
=> 크롬 자체도 프로세스, 각 탭도 프로세스 - 브라우저, 렌더러, GPU, 네트워크 프로세스 등
예전에는 각 탭을 스레드로 만들었는데, 그러면 하나 죽으면 다 죽으니까 그렇게 안되게 함.
쓰레드 > 프로세스: 빠르고 가벼운 병렬 실행을 위해. 하나의 앱 안에서도 여러 일을 동시에 처리하기 위해 필요.
IPC 와 쓰레드 간의 소통방법?
프로세스 간 통신(IPC)은 완전히 분리된 주소 공간을 가진 프로세스들이 데이터를 주고받기 위해 사용되며,
Pipe, Message Queue, Shared Memory, Socket 등의 방식을 통해 OS를 경유하거나 네트워크를 이용해 통신합니다.
반면, 스레드 간 통신은 같은 주소 공간을 공유하기 때문에 변수, 큐 등을 통해 직접 통신할 수 있지만, Race Condition을 방지하기 위한 동기화 도구(Mutex, Semaphore 등)가 필수입니다.
IPC는 서로 독립적인 프로그램 간 통신, 스레드 통신은 한 프로그램 내의 병렬화/작업 분리에 주로 활용됩니다.
병렬 vs. 비동기
병렬은 동시에 여러 작업을 처리하여 처리량을 늘리는 방식, 비동기는 하나의 흐름 내에서 대기 시간이 발생하는 작업 중간에 다른 작업을 끼워 넣어 자원 활용도를 높이는 방식입니다.
병렬은 여러 코어를 활용하는 것이고, 비동기는 단일 코어에서도 가능하며, CPU 연산 중심이면 병렬, 대기 많은 작업이면 비동기를 선택하는 것이 일반적입니다.
Python과 C++에서의 병렬과 비동기 차이
Python은 비동기 프로그래밍에 강하고, 병렬처리는 멀티프로세싱 기반으로 우회적으로 처리한다.
C++은 비동기/병렬 모두 강력하게 지원하지만, 개발 복잡도가 높고 직접 구현이 많다.
비동기 웹 서버, 빠른 프로토타이핑엔 Python이 강하고, 고성능 시스템, 병렬 연산, 하드웨어 제어에는 C++이 적합하다.
GIL이 뭐냐면, 인터프리터가 한번에 하나의 스레드만 실행하도록 제한하는 락. python 내부의 가장 일반적인 구현체인 CPython에 존재함. 왜 존재하냐면, Python은 내부적으로 메모리 관리를 할 때, 참조 카운팅 방식을 사용하는데, 이건 하나의 쓰레드만 python을 실행할 수 있도록 보장하는 것임. 만약 여러 스레드가 동시에 수정하게 되면 레이스 컨디션이 발생할 수 있기 때문에.
그래서 python에서는 멀티쓰레딩은 어렵고, multiprocessing을 쓰거나, 아니면 NumPy, TensorFlow 등 C 기반 모듈은 GIL 우회하니 그걸 써도 될 것 같고.
멀티스레드에서 데이터 경쟁 막는 방법
멀티쓰레드 환경에서는 여러 쓰레드가 동시에 같은 자원에 접근할 수 있어서, 제대로 동기화하지 않으면 Race condition 발생. 이를 방지하기 위한 도구로는, mutex, semaphore가 있다. 그 둘은 목적이 다르다. 그 부분을 주목하자. 뮤텍스는 본질적으로 단일 자원 보호 전용 도구고, 세마포어는 자원 수를 유연하게 늘리고 줄이기 위한 컨트롤러임.
뮤텍스: "나 나올때까지 아무도 못 들어와!". lock과 unlock. 락 잡은 스레드만 unlock 가능하므로 불변성 보장.
=> 화장실에 하나 있는 똥칸
세마포어: "총 몇명까지는 들어와도 됨!". 이론적으로는 누구나 signal 보낼 수 있음. 그러나 그렇게 되면 소기의 목적에 부합하지 않으므로 실무에서는 자원을 반납하는 쓰레드가 signal을 호출하는 식으로 구현해야 함. 즉, 누구든 증가 가능 이 개념은 그럴 필요도 없고, 그렇게 쓰이지도 않는다는 것. 그럼 결국 lock이랑 뭐가 다르지? 라고 생각할 수 있음. 실제로 실무에서 쓰이는 세마포어는, 그 규모가 1이 되었을 때 즉, 바이너리 세마포어는 뮤텍스와 거의 똑같다고 보면 된다. 그러니까 초기에 나타난 개념으로써의 세마포어와 뮤텍스는 전혀 다르지만, 실무에서는 거의 똑같이 쓰인다는 것.
=> "실무에서의 세마포어"는 똥칸 여러개
데드락과 기아
데드락은 서로가 서로의 자원을 잡고 안 놓아주면서 서로의 자원을 탐내는 상태.
기아는 일반적으로 우선순위 기반 스케줄링에서 발생하며, 낮은 우선순위의 프로세스나 스레드가 높은 우선순위 작업들에 밀려 자원을 할당받지 못하고 무한히 대기하게 되는 상태.
이를 방지하기 위해 실무에서는 Aging 기법, Fair Queue, Time-sharing 정책 등을 활용하여 오래 기다린 작업이 결국 실행되도록 공정한 기회를 보장.
CPU 스케줄링
여러 프로세스/스레드 중 누가 CPU를 사용할지 결정하는 OS의 알고리즘. 기본적으로 하나의 코어는 하나의 쓰레드만 실행 가능하기에, 이들을 적절한 순서로 스케줄링하는 것이 매우 중요함.
FCFS: 우선순위 없이, 도착 순서대로 실행 => 긴 프로세스가 앞에 있으면 뒤 프로세스들 대기 길어짐
SJF: Burst time 짧은 순으로 실행 => 예측이 어렵기에 불완전한 판단, 그리고 starvation 발생 가능
Priority Scheduling: 우선순위 큐 사용하고, 우선순위 부여하고 aging 사용
Round Robin: 모든 프로세스에 동일 시간만큼 CPU 할당. time quantum 설정이 매우 중요.
MLFQ: SJF + RR +Feedback. SJF를 통해, 짧은 실행시간부터 긴 것까지 나눠서, 여러 개의 큐로 나눔. 우선순위 높은 큐부터 돌리되, 각각의 큐에는 RR형식을 차용함. 또한, 실행해보고 결과에 따라 큐 강등 혹은 승급시키는 feedback.
선점: OS가 CPU의 사용권을 선점해둔 경우 즉, CPU가 강제 종료할 수 있는 경우. 우선순위를 즉시 반영할 수 있지만, 예측 불가능하다는 단점. 그리고 상대적으로 오버헤드도 많이 발생함.
=> 선점형 SJF(SRTF), Round Robin, MLFQ
비선점: 프로세스가 끝날 때까지 기다려야 함. 예측 가능하지만, 우선순위 반영하기 어려움.
=> SJF, FCFS
구현하기 나름: 우선순위 스케줄링
멀티테넌트 환경에서 CPU 자원 배분 전략
멀티테넌시: 하나의 시스템이 여러 고객의 요청을 동시에 처리해야 하는 환경. 웹서버처럼!
일부 테넌트가 무거운 작업을 연속적으로 요청하면, 다른 테넌트의 응답이 지연되고 공정성 무너짐.
그러니까 실제로는, 스케줄링 알고리즘과 사용량 제한 정책을 결합해서 CPU 스케줄링이 되는 것임.
cgroups - Docker, K8s, 클라우드 환경의 기반 인프라에서의 자원 배분 전략
cgroups는 리눅스 커널 기능 중 하나. 하나의 프로세스 그룹에 자원 사용 제한, 우선순위, 모니터링 등 제공.
OS 입장에선 여러 프로세스가 CPU, 메모리, 네트워크 등 자원을 경쟁적으로 사용함. 그래서 이를 잘 배분하는 것이 중요.
Docker의 경우 컨테이너마다 CPU/메모리 제한 설정 시 내부적으로 cgroups 사용
가상 메모리
프로세스마다 독립된 메모리 공간을 제공하는 OS 기능. 실제 RAM보다 큰 주소 공간을 가상으로 제공함.
실제로는 물리 메모리에 더불어서 디스크의 공간을 조합해서 관리하는 거고, 사용자는 그냥 하나의 연속된 큰 주소 공간을 쓰는 것임. 32비트면 4GB, 64비트면 몇백~몇천 테라바이트(64개 중 실제 쓰는 건 50개 남짓이라, 2^50으로 계산해야 함)의 엄청나게 큰 공간.
가상 메모리가 만들어졌다는 가정 하에, 이를 어떻게 분류할 것인가는 페이징과, 세크멘테이션이 있다.
페이징 vs 세그멘테이션
페이징은 가상 메모리를 "고정 크기 블록"으로 나누는 방식. 페이지 번호 + 오프셋
프로세스의 주소 공간(가상) = Page 단위, 물리 메모리(실제)는 Frame 단위.
이 두개를 서로 매핑한 정보를 담은 게 Page Table. 그 PT의 캐시 역할하는 게 Translation Lookaside Buffer.
Page Table은 프로세스마다 독립적으로 하나씩 줌. 가상 메모리도 마찬가지. 그래서, 프로세스 A도 0x004의 메모리가 있을 수 있고 프로세스 B의 같은 숫자도 전혀 다른 곳을 가리킴.
세그멘테이션은 가상 메모리를 "논리적 단위"로 나누는 방식. 세그먼트 번호 + 오프셋. 코드, 데이터, 스택, 힙.
실무에서는 세그멘테이션 거의 없고, 페이징과 TLB가 활용된다고 함.
페이징에서 헷갈리면 안 되는 게, 프로그램이 1MB 크기다라고 하면 페이지 하나에 4KB니까 총 250 페이지가 필요한 것임. 하나의 프로그램이 하나의 페이지로 국한되는 것이 아님.
다만 처음부터 250 페이지가 모두 가상 메모리 상에 올라가는 것이 아님. 처음에는 실행에 필요한 것들만 올라간다 - demand paging. 예를 들어 시작하는 데에 필요한 코드 부분, 라이브러리 등만 올리고, 실행하는 과정에서 필요에 의해 추가하는 식으로 진행됨.
참고로 이런 식으로 마구 가상 메모리의 점유율을 하나의 프로세스가 늘려나가면 OS의 Frame Allocation Policy의 제한을 받게 됨.
페이지 폴트
중요한 포인트가, 프로세스가 메모리에 접근하는 방식 (프로세스는 물리 메모리 그런 거 신경 안 쓴다 - 그냥 가상 메모리가 진짜 메모리인 줄 아는 것)이 가상 메모리의 페이지 번호로 접근한다는 것이다.
그리고 Page Table은 처음부터, 실행될 프로그램의 총 크기가 얼마인지 알고, 그 크기만큼 테이블을 만들고 모두 valid = 0으로 둔다 (아직 가상 페이지에 없다는 뜻). 그래서 TLB 확인 후 (당연히 거기도 없다) Page Table에 봤더니 가상 페이지 번호에 해당하는 valid 가 0이면 page fault가 나는 것이다.
그러면 디스크에서 해당 페이지를 구해와서, 메모리에 빈 frame 있으면 거기에 복사하여 적재를 하고, 없으면 페이지 교체 알고리즘으로 선택된 기존 페이지 하나 원래 디스크 위치에 복사해서 내보내고 그 자리에 새 프레임의 복사값을 적재한다(page replacement(o), swapping(x)). 그리고 TLB도 업데이 트해주고, 페이지 테이블 frame 값 업데이트하고, valid 값도 1로 더한다.
참고로 swapping은 페이지 단위가 아니라, 전체 메모리 공간을 통째로 디스크로 내보내는, 과거에 썼던 방식이고 지금은 잘 안 씀.
페이지 교체 알고리즘
FIFO - 말 그래도. 구현 쉽지만 오래된 페이지가 꼭 필요없다는 보장이 없음.
LRU - stack, linked list를 변형하는 식으로 구현. 실제 성능 좋음.
Clock, LFU 등등,,,
Thrashing
페이지 교체가 너무 많이 일어나서, 페이지 교체처리만 하느라고 CPU가 실제 작업은 거의 못하는 상태. 실제 물리 메모리에 비해 너무 여러 개의 프로세스가 많은 양의 메모리를 필요로 하면서 동시에 실행되려고 할 때 발생함.
해결 방법은, 동시에 실행 중인 프로세스 수 줄이기, working set 늘려서 최소 페이지 수 보장해주기, 일정 page fault율 넘으면 프로세스 일시 중단 등이 있음.
* Linux vs. Unix
Unix가 AT&T에서 개발한 전통적인 OS고, 그 기반 위에서 새로이 만들어진 Linux는 오픈소스 커널.
그 기반으로 또 만든 Linux OS들 - ubuntu, CentOS 등이 비로소 커널 + 유틸리티 + 인터페이스로 구성된 완성체 OS!
* 코어가 정확히 뭐야?
CPU 안의 독립적인 실행 유닛 - 명령어를 fetch, decode, execute 하는 전체 파이프라인을 하나 갖고 있음.
즉, 하나의 코어는 하나의 프로세스를 실행시킬 수 있음. 그림으로 정리하면 아래와 같음.
맨날 여기까지만 하니까 파이프라이닝 등 더 마이크로하게 들어가면 헷갈리는 거였다. 코어를 좀 더 실제에 가깝게 그려보면, 아래와 같다. ALU도 execution unit의 일부라는 것.
저 아래의 IFU, IDU, execution units, LSU 등이 파이프라이닝 과정의 각각을 담당하고.
CU가 파이프라인 스케줄링 담당하고. 각 스테이지가 언제 어떤 명령어를 실행할지 클럭 단위로 제어함.
그런데 현대의 고급 CPU(슈퍼스칼라)에서는, CU 말고도 동적 스케줄링 회로 전체가 협업하여 스케줄링 수행한다 정도로 이해하자.
* out-of-order 실행은 뭐야?
코드에 작성된 순서 그대로 실행하는 게 아니라, 실행 가능한 것부터 먼저 실행해서 유휴 시간을 줄이는 기법. 걍 CPU 레벨에서의 비동기 개념이라고 보면 됨.
스케줄링이 매우 어려운데, 역시 /CU만으로는 어렵고, 이 역시 동적 스케줄링 회로가 협업해서 스케줄링한다.
이때 중요한 것은, 실행 과정은 바뀌더라도, 결과는 반드시 프로그램 작성 순서대로 반영되어야 함. 그래서 Reorder Buffer가 필요함.
* 사용자 메모리 공간이 아닌 커널 메모리 공간에 저장된다고 함. 무슨 차이일까?
운영체제는 CPU의 가상 메모리 시스템을 활용하여 메모리 공간을 유저 영역과 커널 영역으로 분리하고, 유저 모드에서 커널 메모리에 직접 접근하지 못하도록 하드웨어 수준에서 보호합니다.
유저 메모리 공간은 프로세스의 코드, 데이터, 스택, 힙 등을 포함하며, 커널 메모리 공간은 커널 코드, 시스템 자원, 드라이버, 커널 스택 등이 위치합니다. 커널에 접근하려면 반드시 시스템 콜 등의 메커니즘을 통해 커널 모드로 진입해야 하며, 이를 통해 시스템 안정성과 보안을 보장합니다.
* OS의 찐 커널 vs. Bash, PowerShell 등의 짭 커널 (shell임)
shell에 뭔가를 치면, 거기서 시스템 콜을 만들어서, 커널에 요청이 가능 식으로 진행되는 것임.
* 다단계 페이지 테이블
단일 테이블은 공간 낭비가 심하기 때문에, 계층적으로 쪼개어 필요한 부분만 메모리에 유지할 수 있음.
중요한 건, 단일이고 다단계고 하는 그 자체보다, 그냥 다단계는 단일과 매커니즘 자체가 다름. 단일은 처음부터 전체 테이블 다 만들지만, 다단계는 상위 테이블만 만들고 하위 테이블은 필요할 때만 동적으로 생성됨.
가상 주소를 여러 부분으로 나눔. 32비트고 2단계 페이지 테이블이라 하면, 상위 10개를 1단계 테이블 인덱스, 다음 10개를 2단계 테이블 인덱스라하고, 마지막 12개를 페이지 내 오프셋으로 나눈다.
p1을 바탕으로 1단계 테이블에서 p2 테이블 주소 찾기 => p2를 바탕으로 2단계 테이블에서 실제 프레임 주소 찾기 => offset은 페이지 내 실제 위치 이런 방식으로 구조가 짜임.
장점은, 안 쓰는 가상 주소에 대해서는 아예 테이블을 안 만든다는 점이 있고, 그렇게 메모리 절약됨.