테마 선택

📚 지식 노트

AI·하드웨어·주변기기·게임·컴퓨터 시스템·이론 컴퓨터 과학·수학·개발과 운영·보안·응용 분야에 대해 알아 두면 좋은 내용을 정리한 노트다. 위에서 분야를 고르고 아래에서 노트를 고른다.

운영체제

프로그램 여러 개가 CPU 하나와 메모리 한 벌을 나눠 쓰면서도 각자 컴퓨터를 혼자 쓰는 것처럼 보이게 하는 장치들 — 시스템 콜, 프로세스와 스레드, 스케줄링, 가상 메모리, 동기화, 파일 시스템, 인터럽트 — 를 작은 예를 직접 계산한 그림으로 따라간다.

목차 (13)
  1. 운영체제가 하는 일
  2. 커널 모드와 시스템 콜
  3. 프로세스와 스레드
  4. CPU 스케줄링
  5. 가상 메모리: 페이징과 TLB
  6. 페이지 교체: 누구를 내보낼까
  7. 동기화: 경쟁 상태, 락, 교착 상태
  8. 프로세스 간 통신(IPC)
  9. 파일 시스템: inode와 저널링
  10. 입출력: 인터럽트와 DMA
  11. 부팅: 전원에서 로그인까지
  12. 흔한 오해
  13. 핵심 자료

운영체제가 하는 일

운영체제(OS)는 하드웨어와 프로그램 사이에 앉아 두 가지 일을 한다. 하나는 추상화다. 디스크의 섹터 번호 대신 "파일", 물리 메모리 주소 대신 "내 주소 공간", CPU 코어 대신 "내 프로그램이 돈다"는 깔끔한 모습을 보여 준다. 다른 하나는 자원 관리와 보호다. CPU 시간·메모리·장치를 여러 프로그램에 나눠 주고, 한 프로그램이 다른 프로그램이나 커널을 망가뜨리지 못하게 막는다.

그 중심이 커널이다. 커널은 늘 메모리에 있으며 가장 높은 권한으로 돈다. 셸·브라우저·게임은 커널 위에서 낮은 권한으로 돌다가, 하드웨어가 필요할 때 커널에 부탁한다. Linux·Windows·macOS의 커널은 스케줄러·메모리 관리·파일 시스템·드라이버를 한 주소 공간에 담은 모놀리식(또는 그에 가까운 혼합형)이고, 드라이버·파일 시스템을 사용자 공간 프로세스로 빼낸 마이크로커널(seL4, QNX)은 더 안전한 대신 메시지를 주고받는 비용이 든다.

응용 프로그램, 시스템 콜 경계, 커널의 다섯 부분, 하드웨어가 위에서 아래로 쌓인 그림사용자 모드커널 모드하드웨어브라우저게임셸·명령어시스템 콜: read · write · fork · mmap …프로세스스케줄러메모리관리파일시스템장치드라이버네트워크스택CPU · RAM · 디스크 · 네트워크 카드 · 키보드
운영체제의 층. 응용 프로그램은 시스템 콜이라는 좁은 문으로만 커널에 부탁하고, 하드웨어는 커널만 직접 만진다.

커널 모드와 시스템 콜

보호는 CPU가 해 준다. CPU에는 권한 수준이 있어서(x86의 링 0·3, Arm의 EL0·EL1), 사용자 모드에서는 페이지 테이블을 바꾸거나 장치 포트에 쓰거나 인터럽트를 끄는 특권 명령을 실행할 수 없다. 시도하면 CPU가 예외를 일으켜 커널로 넘어간다.

그래서 프로그램이 파일을 읽으려면 시스템 콜을 한다. 시스템 콜 번호와 인자를 레지스터에 넣고 syscall(x86-64)이나 svc(Arm) 명령을 실행하면, CPU가 권한을 올리면서 커널이 미리 정해 둔 입구로 뛴다(트랩). 커널은 인자를 검사하고 일을 한 뒤 결과를 레지스터에 넣어 사용자 모드로 돌아온다. 프로그램이 마음대로 커널 안 아무 곳으로나 뛸 수 없다는 점이 핵심이다. 보통은 C 라이브러리의 read() 같은 함수가 이 과정을 감싸고, Linux에는 시스템 콜이 수백 개 있다.

read 호출이 사용자 모드에서 커널 모드로 내려가 sys_read를 실행하고 다시 사용자 모드로 돌아오는 흐름사용자커널read(fd, buf, n)syscall 명령트랩·레지스터 저장sys_read 실행결과 받음모드가 바뀔 때마다 레지스터 저장·권한 검사 비용이 든다
read() 한 번의 왕복. 위 칸은 사용자 모드, 아래 칸은 커널 모드다. 함수 호출보다 비싸서, 작은 read를 수천 번 하는 대신 버퍼에 모아 한 번에 읽는다.

권한 경계를 넘는 다른 길도 있다. 0으로 나누기·없는 페이지 접근 같은 예외, 장치가 거는 인터럽트(아래 입출력 절)도 같은 방식으로 커널에 들어간다. 이 경계가 뚫리면 어떻게 되는지는 보안 › 시스템 보안의 메모리 안전성·샌드박스 이야기로 이어진다.

프로세스와 스레드

프로세스는 실행 중인 프로그램 하나다. 자기만의 주소 공간(코드·데이터·힙·스택), 열린 파일 목록, 권한을 가진다. 커널은 프로세스마다 상태(실행·준비·대기), 저장된 레지스터, 페이지 테이블 위치 등을 담은 PCB(프로세스 제어 블록)를 둔다. 유닉스에서는 fork()로 자신을 복제하고 exec()로 새 프로그램을 덮어써 프로세스를 만든다. 복제가 비싸 보이지만, 메모리는 실제로 쓰기 전까지 함께 쓰다가 쓰는 순간 복사한다(copy-on-write).

프로세스가 끝나도 커널은 PID와 종료 상태를 조금 남겨 두고, 부모가 wait()로 거둬 가기를 기다린다. 아직 거두지 않은 죽은 자식이 좀비다. 메모리는 거의 안 쓰지만 프로세스 표의 칸을 차지해, 부모가 끝없이 자식을 만들고 거두지 않으면 새 프로세스를 못 만들게 된다. 부모가 먼저 죽은 고아 프로세스는 init(PID 1, 또는 지정된 subreaper)이 양자로 받아 대신 거둔다(wait(2)). 좀비는 이미 죽은 것이라 kill로 없앨 수 없고, 부모가 거두게 하거나 부모를 끝내야 한다.

스레드는 한 프로세스 안의 실행 흐름이다. 같은 프로세스의 스레드들은 코드·데이터·힙을 함께 쓰고, 스택과 레지스터(프로그램 카운터 포함)만 따로 가진다. 그래서 스레드끼리는 포인터 하나로 데이터를 주고받을 만큼 빠르지만, 같은 변수를 동시에 고치는 위험(아래 동기화 절)도 함께 진다. 함수 호출마다 쌓이는 스택 프레임은 알고리즘 › 기초의 재귀와 호출 스택에서 다룬다.

왼쪽은 주소 공간이 따로인 프로세스 두 개, 오른쪽은 코드·데이터·힙을 공유하고 스택만 따로인 스레드 세 개프로세스 A코드데이터힙스택·레지스터프로세스 B코드데이터힙스택·레지스터주소 공간이 각자 따로한 프로세스 안의 스레드 3개코드 (함께 씀)데이터 (함께 씀)힙 (함께 씀)스택 1PC·레지스터스택 2PC·레지스터스택 3PC·레지스터메모리 공유: 빠르지만 경쟁 위험
프로세스는 주소 공간을 따로 가져 한쪽이 죽어도 다른 쪽은 멀쩡하다(브라우저가 탭마다 프로세스를 나누는 이유). 스레드는 메모리를 함께 써서 가볍지만 서로를 망가뜨릴 수 있다.

CPU 코어 하나는 한순간에 한 흐름만 돈다(CPU 노트의 SMT는 예외적으로 둘). 여러 프로그램이 동시에 도는 것처럼 보이는 것은 커널이 아주 자주 바꿔 끼우기 때문이다. 이 바꿔 끼우기가 문맥 교환(context switch)이다. 돌던 흐름의 레지스터를 PCB에 저장하고, 다음 흐름의 레지스터를 복원하고, 프로세스가 바뀌면 페이지 테이블도 바꾼다. 같은 프로세스의 스레드끼리는 페이지 테이블을 안 바꿔도 되어 더 싸다. 교환 자체는 수 μs 수준이지만, 새 흐름이 캐시와 TLB를 다시 데우는 간접 비용이 더 클 때가 많다.

A가 실행되다 커널이 끼어들어 상태를 저장·복원하고 B가 실행되는 시간 축프로세스 A커널프로세스 B타이머 인터럽트B로 복귀A 실행저장·선택·복원B 실행A커널 칸은 누구의 일도 아니다 — 전환 한 번에 보통 수 μs, 캐시도 식는다
문맥 교환. 타이머 인터럽트가 A를 멈추면 커널이 A를 저장하고, 스케줄러가 B를 골라 복원한다. 커널이 쓰는 시간은 누구의 일도 아닌 순수 비용이다.

CPU 스케줄링

준비된 흐름이 코어보다 많으면 스케줄러가 다음에 누구를 돌릴지 고른다. 목표가 서로 부딪친다. 일을 빨리 끝내는 반환 시간, 키를 누르면 바로 반응하는 응답 시간, 모두에게 고른 몫을 주는 공정성, 전환 비용을 줄이는 처리량이다. 한 번 잡으면 끝까지 쓰는 비선점 방식과, 타이머 인터럽트로 중간에 빼앗는 선점 방식이 있다. 데스크톱·서버 OS는 모두 선점형이다.

  • FCFS(선착순): 단순하지만 긴 일 뒤에 짧은 일이 줄 서면 모두 오래 기다린다(호위 효과).
  • SJF(짧은 일 먼저): 일이 함께 도착했다면 평균 대기 시간이 가장 짧다고 증명된 방식이다. 문제는 버스트 길이를 미리 알 수 없다는 것과, 짧은 일이 계속 오면 긴 일이 굶는다는 것이다. 남은 시간이 가장 짧은 일로 바로 갈아타는 선점판은 SRTF다.
  • 라운드 로빈: 정해진 시간 조각(퀀텀)씩 돌아가며 쓴다. 응답성이 좋다. 퀀텀이 너무 짧으면 전환 비용이 커지고, 너무 길면 FCFS가 된다.
FCFS, SJF, 라운드 로빈의 간트 차트와 평균 대기 시간 비교 단계 그림P1 도착 0 · 버스트 7P2 도착 1 · 버스트 4P3 도착 2 · 버스트 1P4 도착 3 · 버스트 30123456789101112131415P1↓P2↓P3↓P4↓↓ 도착 시각FCFSP1P2P3P4대기 시간: P1 0 · P2 6 · P3 9 · P4 9평균 대기 6.00 · 평균 반환 9.75SJF(비선점)P1P3P4P2대기 시간: P1 0 · P2 10 · P3 5 · P4 5평균 대기 5.00 · 평균 반환 8.75라운드 로빈(퀀텀 2)P1P2P3P1P4P2P1P4P1대기 시간: P1 8 · P2 6 · P3 2 · P4 8평균 대기 6.00 · 평균 반환 9.75평균 반환 시간(끝난 시각 − 도착)FCFS9.75SJF(비선점)8.75라운드 로빈(퀀텀 2)9.75
1 / 5

프로세스 네 개가 0~3초에 차례로 도착한다. CPU는 하나다. 같은 일을 세 방식으로 나눠 본다.

CPU 스케줄링. 도착 시각과 필요한 CPU 시간(버스트)이 같은 프로세스 네 개를 세 방식으로 나눴다. 대기 시간 = 끝난 시각 − 도착 − 버스트.

실제 OS는 버스트 길이를 모르니 과거 행동으로 추측한다. 교과서의 대표가 MLFQ(다단계 피드백 큐)다. 새 일은 가장 높은 큐에서 시작하고, 퀀텀을 다 쓰면 CPU를 많이 쓰는 일로 보고 한 단계 내린다. 금방 입출력을 기다리러 가는 대화형 프로그램은 위에 남아 빠르게 반응한다. 낮은 큐가 굶지 않도록 주기적으로 모두 맨 위로 올린다.

우선순위가 다른 큐 세 개. 퀀텀을 다 쓴 작업은 아래 큐로 내려가고 주기적으로 맨 위로 올라간다새 작업은 맨 위로 →Q0 높음AB퀀텀 짧음Q1 중간C퀀텀 2배Q2 낮음DE퀀텀 가장 김다 쓰면 아래로다 쓰면 아래로주기적으로 모두 맨 위로(굶주림 방지) · 입출력 위주 작업은 위에 남는다
MLFQ. 짧게 쓰고 비키는 일(키 입력 처리)은 위에, 오래 계산하는 일은 아래에 모인다. 길이를 몰라도 SJF를 흉내 내는 셈이다.

Linux는 다른 길을 갔다. 2.6.23(2007)부터 쓰인 CFS는 태스크마다 가중치(nice 값)로 나눈 가상 실행 시간(vruntime)을 세고, 그 값이 가장 작은 태스크를 레드-블랙 트리에서 꺼내 돌린다. 덜 받은 쪽이 늘 먼저라 몫이 고르게 맞춰진다. 6.6(2023)부터는 이를 EEVDF가 대신한다. 각 태스크가 받아야 할 몫과 실제로 받은 몫의 차이(lag)가 0 이상인 태스크 가운데 가상 마감 시각이 가장 이른 것을 고른다. 공정성은 그대로 두면서, 짧은 시간 조각을 원하는 지연에 민감한 태스크가 더 빨리 차례를 받게 했다. 6.12에는 BPF로 스케줄러를 직접 짜 넣는 sched_ext도 들어왔다.

가상 메모리: 페이징과 TLB

프로그램이 쓰는 주소는 가상 주소다. CPU 안의 MMU가 메모리에 접근할 때마다 이를 실제 RAM 위치(물리 주소)로 바꾼다. 덕분에 프로세스마다 0번지부터 시작하는 자기만의 주소 공간을 가지고, 남의 메모리는 아예 주소로 가리킬 수 없으며, RAM보다 큰 주소 공간도 쓸 수 있다.

바꾸는 단위가 페이지(보통 4 KiB)다. 가상 주소의 위쪽 비트는 페이지 번호, 아래 12비트(2¹² = 4096)는 페이지 안의 오프셋이다. 페이지 번호를 페이지 테이블에서 찾아 물리 프레임 번호로 바꾸고, 오프셋은 그대로 붙인다. 표의 항목에는 프레임 번호 말고도 있음/없음, 읽기·쓰기·실행 허용, 접근·수정 여부 비트가 있다. 없는 페이지에 닿으면 페이지 폴트 예외가 나고, 커널이 디스크에서 읽어 오거나(스왑·파일 매핑) 잘못된 접근이면 프로세스를 끝낸다(segmentation fault).

가상 주소 0x1ABC가 페이지 테이블을 거쳐 물리 주소 0x9ABC로 바뀌는 그림가상 주소 0x1ABC페이지 1오프셋 0xABC페이지 테이블페이지 0 → 프레임 5페이지 1 → 프레임 9페이지 2 → 프레임 2페이지 3 → 프레임 7TLB: 최근 변환 캐시적중이면 표를 안 읽는다오프셋은 그대로물리 주소 0x9ABC프레임 9오프셋 0xABC페이지 4 KiB = 2^12 → 아래 12비트(16진 세 자리)가 오프셋
가상 주소 0x1ABC를 4 KiB 페이지로 나누면 페이지 1, 오프셋 0xABC다. 페이지 테이블이 1 → 프레임 9를 알려 주니 물리 주소는 0x9ABC다. 수치는 변환 함수로 계산했다.

왜 크기가 같은 페이지로 나눌까. 프로그램마다 필요한 크기만큼 연속된 덩어리를 주면(옛 세그먼트 방식, 힙 할당기), 주고 돌려받기를 되풀이하는 사이 빈 공간이 작은 조각으로 흩어져 합치면 충분한데도 큰 요청을 못 받는 외부 단편화가 생긴다. 모든 조각을 같은 크기의 프레임으로 맞추면 아무 빈 프레임이나 쓸 수 있어 이 문제가 거의 사라진다. 대신 마지막 페이지의 남는 부분처럼 받은 칸 안에서 쓰지 않는 공간, 내부 단편화를 받아들인다.

64비트 주소 공간을 한 장의 표로 만들면 너무 크다. 그래서 표를 나무처럼 여러 단계로 쪼개고 실제로 쓰는 부분만 만든다. x86-64는 48비트 가상 주소를 9비트씩 네 단계로 나누고(최신 CPU는 57비트·5단계도 지원), 표 한 장이 정확히 페이지 한 장이 되게 했다.

48비트 주소를 9비트씩 네 조각과 12비트 오프셋으로 나눠 표 네 장을 걷는 그림가상 주소 48비트L4 · 9비트L3 · 9비트L2 · 9비트L1 · 9비트오프셋 · 12비트CR3L4 표L3 표L2 표L1 표프레임표 한 장 = 2^9 = 512칸 × 8바이트 = 4 KiB(페이지 한 장). 쓰는 부분만 만든다.한 단계로 만들면 2^36칸 × 8바이트 = 512 GiB — 프로세스마다
x86-64 4단계 페이지 테이블. CR3 레지스터가 맨 위 표를 가리키고, 주소의 9비트 조각이 각 표의 칸 번호가 된다.

그런데 단계가 넷이면 메모리 접근 한 번에 표를 읽느라 메모리를 네 번 더 읽어야 한다. 이를 막는 것이 MMU 안의 작은 캐시 TLB다. 최근에 쓴 변환을 기억해 두어 적중하면 표를 읽지 않는다. 메모리 접근 100 ns, TLB 1 ns, 4단계라고 치면 평균 접근 시간은 적중률 99%일 때 약 105 ns, 90%일 때 141 ns, 0%면 501 ns다. 프로그램은 대개 가까운 주소를 반복해 쓰므로(지역성) 적중률이 매우 높다. 큰 메모리를 쓰는 데이터베이스가 2 MiB·1 GiB 거대 페이지를 쓰는 것도 TLB 한 칸이 더 넓은 범위를 덮게 하려는 것이다. 캐시와 메모리 계층 자체는 RAM 노트에 있다.

페이지 교체: 누구를 내보낼까

물리 메모리가 가득 찼는데 새 페이지가 필요하면 있던 페이지 하나를 내보내야 한다(수정된 페이지라면 디스크에 먼저 쓴다). 디스크는 RAM보다 수만~수십만 배 느리니 폴트 한 번이 비싸다. 무엇을 내보낼지가 성능을 가른다.

  • OPT(벨라디): 앞으로 가장 늦게 쓸 페이지를 내보낸다. 폴트가 가장 적지만 미래를 알아야 해서 비교 기준으로만 쓴다.
  • FIFO: 가장 먼저 들어온 페이지를 내보낸다. 자주 쓰는 페이지도 오래됐다는 이유로 쫓겨난다. 프레임을 늘렸는데 폴트가 느는 벨라디의 이상도 생긴다.
  • LRU: 가장 오래 안 쓴 페이지를 내보낸다. 가까운 과거가 가까운 미래를 닮는다는 지역성에 기대 OPT에 가깝지만, 접근할 때마다 순서를 갱신하기에는 너무 비싸다.
  • Clock(두 번째 기회): 페이지마다 하드웨어가 켜 주는 참조 비트 하나만 본다. 바늘이 돌다 비트가 1이면 0으로 지우고 한 번 봐주고, 0인 페이지를 내보낸다. LRU를 싸게 흉내 낸 것으로 실제 OS가 쓰는 방식의 뼈대다.
FIFO, LRU, Clock 세 방식이 같은 참조열에서 프레임을 채우고 내보내는 단계 그림참조701203042303FIFO폴트 0폴트 1폴트 2폴트 3폴트 4폴트 4폴트 5폴트 6폴트 7폴트 8폴트 9폴트 10폴트 10770701201201231230430420423023023LRU폴트 0폴트 1폴트 2폴트 3폴트 4폴트 4폴트 5폴트 5폴트 6폴트 7폴트 8폴트 9폴트 9770701201201203203403402432032032Clock폴트 0폴트 1폴트 2폴트 3폴트 4폴트 4폴트 5폴트 5폴트 6폴트 7폴트 7폴트 8폴트 9770701201201203203403423423420320
1 / 13

참조열 7 0 1 2 0 3 0 4 2 3 0 3을 프레임 3개로 처리한다. 색칠된 칸이 폴트(디스크에서 읽어 옴)다.

페이지 교체. 프레임이 3개뿐인데 참조한 페이지가 없으면(폴트) 하나를 내보내야 한다. 누구를 내보내느냐에 따라 폴트 수가 달라진다.

폴트가 너무 잦아 CPU가 일보다 페이지 옮기기에 시간을 다 쓰는 상태를 스래싱이라 한다. 동시에 자주 쓰는 페이지 묶음(작업 집합)이 RAM보다 크면 생긴다. 이때는 교체 방식이 아니라 메모리를 늘리거나 동시에 돌리는 일을 줄여야 한다. Linux는 메모리가 정말 바닥나면 OOM killer로 프로세스 하나를 골라 끝낸다.

동기화: 경쟁 상태, 락, 교착 상태

스레드 둘이 같은 변수를 고치면 결과가 실행 순서에 따라 달라질 수 있다. 이를 경쟁 상태(race condition)라 한다. count++는 한 줄이지만 기계어로는 "읽기 → 1 더하기 → 쓰기" 세 걸음이라, 두 스레드의 읽기가 겹치면 한쪽의 갱신이 사라진다. 이런 코드 구간을 임계 구역이라 하고, 한 번에 한 스레드만 들어가게 해야 한다.

count++를 두 스레드가 할 때 차례로 하면 2, 읽기가 겹치면 1이 되는 두 실행 순서차례로 실행A: count 읽기 → 레지스터 0A: 레지스터+1 쓰기 → count 1B: count 읽기 → 레지스터 1B: 레지스터+1 쓰기 → count 2결과 count = 2 (맞음)읽기가 겹침A: count 읽기 → 레지스터 0B: count 읽기 → 레지스터 0A: 레지스터+1 쓰기 → count 1B: 레지스터+1 쓰기 → count 1결과 count = 1 (갱신 하나 잃음)
count = 0에서 두 스레드가 count++를 한 번씩 한다. 오른쪽처럼 B가 A의 쓰기 전에 읽으면 둘 다 1을 써서 결과가 1이 된다. 드물게만 일어나 재현이 어려운 버그가 된다.
  • 원자적 명령: CPU가 "비교하고 바꾸기"(CAS)나 "더하고 가져오기" 같은 읽기·쓰기를 쪼갤 수 없는 한 명령으로 제공한다. 모든 락의 바탕이다.
  • 뮤텍스(락): 임계 구역 앞에서 잠그고 나올 때 푼다. 이미 잠겨 있으면 기다린다. 잠깐이면 돌면서 기다리고(스핀락), 길어지면 커널에 재워 달라고 한다(Linux의 futex).
  • 세마포어: 0 이상의 정수 하나다. wait는 값을 1 줄이되 0이면 기다리고, signal은 1 늘리며 기다리는 이를 깨운다. 값을 N으로 두면 "동시에 N개까지"를 표현한다(연결 풀 등).
  • 조건 변수: 락을 쥔 채 "큐가 비지 않을 때까지" 같은 조건을 기다린다. 생산자–소비자 문제의 표준 도구다.

락은 새 문제를 낳는다. 교착 상태(deadlock)는 서로 상대가 쥔 것을 기다리며 아무도 못 나아가는 상태다. 네 조건이 모두 성립할 때만 생긴다: ① 상호 배제(자원을 한 번에 하나만 씀), ② 점유 대기(쥔 채로 다른 것을 기다림), ③ 비선점(남의 락을 빼앗을 수 없음), ④ 순환 대기. 하나만 깨면 된다. 실무에서 가장 흔한 처방은 락에 순서를 정해 늘 같은 순서로 잡아 ④를 없애는 것이다.

스레드 두 개와 락 두 개가 쥠과 기다림 화살표로 원을 이루는 교착 상태락 A락 B스레드 1스레드 2쥐고 있음기다림쥐고 있음기다림순환 대기락을 늘 같은 순서(A → B)로 잡으면 원이 생기지 않는다
교착 상태. 스레드 1은 A를 쥐고 B를, 스레드 2는 B를 쥐고 A를 기다린다. 둘 다 A → B 순서로 잡았다면 원이 생기지 않는다.

교착 말고도 락이 낳는 문제가 둘 더 있다. 우선순위 스케줄링에서 낮은 우선순위 일이 늘 밀려 영영 차례를 못 받는 기아(starvation)는 오래 기다린 일의 우선순위를 올려 주는 노화(aging)로 막는다. 위 MLFQ가 주기적으로 모두를 맨 위로 올리는 것도 같은 처방이다. 낮은 우선순위 일이 쥔 락을 높은 우선순위 일이 기다리는 사이 중간 우선순위 일이 끼어들어 높은 일이 한없이 늦어지는 우선순위 역전도 있다. 락을 쥔 쪽의 우선순위를 잠시 올려 주는 우선순위 상속으로 푸는데, 실시간 시스템에서 특히 중요해 임베디드 노트에서 단계 그림으로 다룬다.

프로세스 간 통신(IPC)

프로세스는 주소 공간이 따로라 변수 하나를 함께 볼 수 없다. 데이터를 주고받으려면 커널이 마련한 길을 쓴다. 이것이 프로세스 간 통신(IPC)이다.

방법원리볼 점
파이프한쪽 끝에 쓰면 다른 끝에서 읽는 커널 안의 한 방향 버퍼. 셸의 ls | grep이 이것이다Linux 기본 용량은 64 KiB(16페이지)다. 가득 차면 쓰는 쪽이, 비면 읽는 쪽이 기다린다. 파일 시스템에 이름을 단 것이 FIFO(이름 있는 파이프)다
소켓네트워크와 같은 API로 양방향 바이트 흐름이나 메시지를 주고받는다같은 컴퓨터 안이면 유닉스 도메인 소켓, 다른 컴퓨터면 TCP·UDP. 가장 범용이다
메시지 큐커널이 메시지 단위로 줄을 세워 전한다바이트 흐름이 아니라 경계가 있는 메시지가 필요할 때
공유 메모리같은 물리 페이지를 두 프로세스의 주소 공간에 함께 매핑한다복사가 없어 가장 빠르지만, 접근 순서는 세마포어 같은 동기화로 직접 지켜야 한다
시그널커널이 프로세스에 보내는 작은 번호 하나의 알림(Ctrl+C의 SIGINT, 종료 요청 SIGTERM, 자식이 끝났다는 SIGCHLD)데이터는 거의 못 싣는다. SIGKILL·SIGSTOP은 잡거나 막거나 무시할 수 없다

내용은 pipe(7), shm_overview(7), signal(7)에 있다. 컴퓨터 여러 대가 메시지로 협력하는 이야기는 분산 시스템 노트로 이어진다.

파일 시스템: inode와 저널링

디스크는 번호 붙은 블록의 긴 줄일 뿐이다. 파일 시스템은 그 위에 이름·폴더·권한이 있는 파일을 만든다. 유닉스 계열(ext4 등)의 중심은 inode다. 파일 하나에 inode 하나가 있고, 크기·권한·소유자·시각과 데이터가 든 블록의 위치를 담는다. 정작 파일 이름은 inode에 없고 디렉터리에 있다. 디렉터리는 "이름 → inode 번호" 표를 담은 특별한 파일이다. 그래서 한 inode를 여러 이름이 가리키는 하드 링크가 가능하고, 같은 파일 시스템 안의 이동(mv)은 데이터를 옮기지 않고 표의 한 줄만 바꾼다.

디렉터리 항목 notes.txt가 inode 42를 가리키고, inode가 데이터 블록 세 개를 가리키는 그림디렉터리 /docsnotes.txt → 42photo.jpg → 57todo.md → 63inode 42종류·권한·소유자크기 9 KiB수정 시각블록 → 812블록 → 813블록 → 1907블록 812블록 813블록 1907데이터 (4 KiB씩)이름은 inode가 아니라디렉터리에 있다 —하드 링크는 같은 inode를가리키는 이름 하나 더
파일 열기 = 경로를 따라 디렉터리 표에서 inode 번호를 찾고, inode에서 블록 위치를 읽는 것. ext4는 블록 번호를 하나씩 적는 대신 연속 구간(extent)으로 적는다.

파일 하나를 늘리는 일도 디스크에는 여러 번의 쓰기다(데이터 블록, inode의 크기, 빈 블록 지도). 중간에 전원이 나가면 지도와 inode가 서로 어긋난다. 예전에는 부팅 때 fsck로 디스크 전체를 훑어 고쳤고 몇 시간이 걸리기도 했다. 저널링은 데이터베이스의 선행 기록(WAL)과 같은 발상으로, 바꿀 내용을 저널이라는 별도 영역에 먼저 쓰고 "완료" 표시를 남긴 뒤 제자리에 쓴다. 복구할 때는 저널만 보면 된다. ext4는 기본으로 메타데이터만 저널에 쓴다(ordered 모드). Btrfs·ZFS·APFS는 제자리에 덮어쓰지 않고 새 자리에 쓴 뒤 가리키는 곳을 바꾸는 copy-on-write로 같은 일을 한다. 디스크 쪽(SSD·HDD) 이야기는 저장장치 › 종류에 있다.

저널 기록, 커밋, 제자리 쓰기, 저널 비움의 네 단계와 각 구간에서 전원이 나갔을 때의 복구① 저널에 기록바뀔 블록 사본② 커밋 블록"완료" 표시③ 제자리 쓰기체크포인트④ 저널 비움자리 재사용저널 영역본 영역 · 저널⚡①~② 사이에 꺼지면커밋 없음 → 버림⚡②~④ 사이에 꺼지면저널을 다시 재생fsck 전체검사 불필요
저널링의 네 단계. 커밋 블록이 쓰였는지 여부가 갈림길이다. 커밋 전이면 그 변경은 없던 일이 되고, 커밋 뒤면 저널을 다시 재생해 끝낸다.

입출력: 인터럽트와 DMA

장치는 CPU보다 훨씬 느리다. 키보드는 사람 속도, 디스크는 마이크로초~밀리초다. CPU가 장치의 상태 레지스터를 계속 읽으며 기다리는 폴링은 단순하지만 그동안 다른 일을 못 한다. 인터럽트는 장치가 준비되면 CPU에 신호를 걸어, CPU가 하던 일을 잠시 멈추고 커널의 인터럽트 처리기를 실행하게 한다. 처리기는 짧게 급한 일만 하고, 나머지는 나중에 돌 작업으로 미룬다(Linux의 softirq·workqueue).

큰 덩어리를 옮길 때는 바이트마다 인터럽트를 받는 것도 낭비다. DMA는 장치(또는 DMA 엔진)가 RAM에 직접 읽고 쓰게 하고, 다 끝나면 인터럽트를 한 번만 건다. NVMe SSD·네트워크 카드·GPU가 모두 이렇게 데이터를 옮긴다. 반대로 아주 빠른 장치에서는 인터럽트의 전환 비용이 더 커서, 고성능 네트워크·저장장치 경로는 일부러 폴링으로 돌아가기도 한다(Linux NAPI, io_uring의 폴링 모드). 마이크로컨트롤러에서 같은 개념을 직접 다루는 모습은 임베디드 노트에 있다.

폴링, 인터럽트, DMA에서 CPU 시간이 어디에 쓰이는지 비교한 세 줄 시간 축폴링CPU가 계속 묻는다인터럽트조각마다 불려 감DMA끝나면 한 번 알림준비됐나? 준비됐나? … (CPU가 다른 일을 못 함)다른 일빨간 칸: 인터럽트 처리기 (문맥 저장·복원 포함)다른 일 — 그동안 DMA 엔진이 장치 ↔ RAM을 직접 옮긴다완료 인터럽트 한 번
같은 데이터를 받는 세 방식에서 CPU 시간이 어디에 쓰이는지. 폴링은 기다리는 데, 인터럽트는 잦은 처리기에, DMA는 거의 전부를 다른 일에 쓴다.

프로그램 쪽에서 보면 입출력을 부르는 방식은 두 축으로 나뉜다. 블로킹은 데이터가 올 때까지 호출한 스레드가 잠든다(빈 파이프에서 read하면 쓰는 쪽이 쓸 때까지 기다린다). 논블로킹은 준비가 안 됐으면 기다리지 않고 바로 "아직"(EAGAIN)을 돌려준다. 다른 축은 동기와 비동기로, 결과를 호출한 쪽이 직접 가져오느냐, 일이 끝난 뒤 완료 알림·콜백으로 받느냐다. epoll은 소켓 수천 개 가운데 "지금 읽을 수 있는 것"을 알려 주고 읽기는 프로그램이 논블로킹으로 직접 하는 방식이고, io_uring은 요청을 맡겨 두고 끝난 결과를 받는 쪽에 가깝다. 이 위에 이벤트 루프와 async·await를 얹어 스레드 하나로 연결 수천 개를 다루는 방법은 병렬 프로그래밍 노트의 "동시성과 병렬은 다르다" 절에 있다.

부팅: 전원에서 로그인까지

전원을 켜면 CPU는 메인보드 플래시에 든 펌웨어(요즘은 UEFI)부터 실행한다. 펌웨어는 메모리·장치를 점검하고 초기화한 뒤, 디스크의 EFI 시스템 파티션(ESP)에서 부트로더를 찾는다. 보안 부팅(Secure Boot)이 켜져 있으면 서명을 확인한 것만 실행한다. 부트로더(GRUB, systemd-boot, Windows Boot Manager)는 커널과 initramfs(초기 RAM 파일 시스템)를 메모리에 올린다. 커널은 CPU·메모리 관리를 켜고 드라이버를 올린 뒤, initramfs의 도움으로 진짜 루트 파일 시스템을 마운트한다. 마지막으로 첫 사용자 프로세스 init(PID 1, 대부분의 배포판에서 systemd)을 실행하고, init이 서비스와 로그인 화면을 띄운다. 메인보드 펌웨어 쪽 이야기는 메인보드 노트에 있다.

전원에서 로그인까지 여섯 단계로 이어지는 부팅 순서전원·POST하드웨어 점검UEFI 펌웨어ESP에서 부트로더 찾기부트로더커널·initramfs 적재커널드라이버·루트 FS 마운트init (PID 1)systemd가 서비스 시작로그인·셸사용자 프로그램 실행
Linux PC의 부팅 순서. 커널(색칠)이 사용자 프로세스를 하나도 만들기 전까지는 펌웨어와 부트로더가 하드웨어를 직접 다룬다.

흔한 오해

  • "스레드를 많이 만들수록 빠르다" — 코어보다 많은 스레드는 번갈아 돌 뿐이라, 계산 위주의 일에서는 문맥 교환과 캐시 다시 데우기 비용만 는다. 기다림이 많은 일은 스레드 수보다 비동기 입출력이 낫다.
  • "좀비 프로세스는 kill -9로 지운다" — 이미 죽은 프로세스라 시그널이 소용없다. 부모가 wait()로 거두거나 부모가 끝나 init이 거둬야 사라진다.
  • "끝낼 때는 처음부터 kill -9" — SIGKILL은 잡을 수 없어 프로그램이 파일을 닫고 임시 파일을 지울 기회가 없다. 먼저 SIGTERM으로 정리할 시간을 준다.
  • "64비트 OS라 가상 메모리가 RAM을 대신한다" — 주소 공간이 넓을 뿐, 작업 집합이 RAM을 넘으면 스왑으로 밀려 스래싱이 된다(위 페이지 교체 절).

핵심 자료

컨테이너가 쓰는 커널 기능(namespace·cgroup)은 컨테이너 노트, CPU 쪽 하드웨어는 CPU 노트에 있다.

자료종류요지링크
Operating Systems: Three Easy Pieces (OSTEP)무료 교재가상화·동시성·영속성 세 덩어리로 운영체제를 설명한다. 이 노트의 흐름도 대체로 이 책의 구성을 따랐다.OSTEP
MIT 6.1810 Operating System Engineering (xv6)강의작은 유닉스형 커널 xv6의 코드를 직접 고치며 시스템 콜·페이지 테이블·트랩·락을 구현해 본다.MIT PDOS
CFS SchedulerLinux 문서가상 실행 시간(vruntime)이 가장 적은 태스크를 고르는 2.6.23 이후의 기본 스케줄러 설명.kernel.org
EEVDF SchedulerLinux 문서6.6부터 CFS를 대신한 스케줄러. 받을 몫과 받은 몫의 차이(lag)와 가상 마감 시각으로 고른다.kernel.org
Concepts overview (메모리 관리)Linux 문서가상 메모리, 페이지, 거대 페이지, 페이지 캐시, 스왑, OOM killer를 한 쪽에 정리했다.kernel.org
syscalls(2)매뉴얼Linux 시스템 콜 전체 목록과 어느 커널 버전에서 생겼는지.man7.org