1.1정보는 비트와 컨텍스트로 이루어진다
컴퓨터 안에 들어 있는 모든 것 — 사진, 음악, 코드, 메일, 게임 세이브 — 은 결국 0과 1의 나열이다.
하드디스크에 들어 있는 비트와 RAM에 들어 있는 비트, 네트워크 선을 흐르는 비트, 화면을 그리는 비트는 본질적으로 같은 종류의 신호다.
이 사실이 너무 당연해서 잊기 쉬운데, 같은 비트열이 어떻게 해석되느냐가 그 비트가 의미하는 바를 결정한다는 점은 강조해도 지나치지 않다.
예를 들어 메모리에 01001000 01101001이라는 16비트가 들어 있다고 하자.
이걸 ASCII 텍스트로 읽으면 "Hi"이고, 부호 없는 16비트 정수로 읽으면 18537이며,
x86 명령어의 일부로 읽으면 또 다른 의미가 된다. 비트 자체에는 자기 정체성이 없다.
오직 컨텍스트(context)가 정체성을 부여한다.
예시를 위해 ASCII 표의 일부를 보자:
| 문자 | 10진 | 16진 | 2진 |
H | 72 | 0x48 | 01001000 |
i | 105 | 0x69 | 01101001 |
0 | 48 | 0x30 | 00110000 |
A | 65 | 0x41 | 01000001 |
\n (개행) | 10 | 0x0A | 00001010 |
그래서 우리가 즐겨 쓰는 hello.c 같은 소스 파일도, 디스크에 저장될 때는 한 글자씩 ASCII 코드로 변환된 비트의 행렬에 불과하다.
이런 식으로 오로지 ASCII 문자만으로 구성된 파일을 텍스트 파일이라 부르고, 그 외의 모든 파일을 바이너리 파일이라 부른다.
그림 1.1 · hello.c 가 디스크에 저장된 모습 (앞부분만)#include <stdio.h>
↓ 한 글자씩 ASCII로
35 105 110 99 108 117 100 101 32 60 115 116 100 105 111 46
'#' 'i' 'n' 'c' 'l' 'u' 'd' 'e' ' ' '<' 's' 't' 'd' 'i' 'o' '.'
메모
유니코드(UTF-8) 시대에는 한 글자가 1바이트가 아닐 수 있다. "안녕"은 UTF-8로 6바이트(EC 95 88 EB 85 95).
하지만 본질은 같다 — 결국은 비트열이고, 그걸 "한국어 문자"로 읽으려면 약속(인코딩)이 있어야 한다.
같은 비트열을 다르게 해석하면 다른 결과가 나오는 현상은 다음 장(2장 정수/부동소수점)에서 매우 중요해진다.
0xC1900000을 부호 있는 32비트 정수로 읽으면 음수, 부동소수점으로 읽으면 −18.0이다. 자료형은 본질적으로 "이 비트를 어떻게 읽을지에 대한 약속"이다.
연습 1.1
메모리에 16진수로 0x6F 0x6B 두 바이트가 있다. 이를 (a) ASCII 문자열, (b) 16비트 부호 없는 리틀엔디언 정수로 각각 해석하면 무엇인가?
1.2프로그램은 다른 프로그램에 의해 다양한 형태로 번역된다
고수준 언어로 쓴 코드를 컴퓨터가 직접 실행할 수는 없다. CPU가 진짜로 이해하는 건 0과 1로 된 기계어(machine code)뿐이다.
그래서 우리에겐 통역사가 필요하다 — 그게 바로 컴파일러 시스템이다. C 소스 한 줄이 실행 가능한 바이너리가 되기까지는 네 단계의 번역이 일어난다.
크게 보면 다음과 같다:
그림 1.2 · 컴파일 파이프라인hello.c → [전처리기 cpp] → hello.i (전처리된 소스)
hello.i → [컴파일러 cc1] → hello.s (어셈블리)
hello.s → [어셈블러 as] → hello.o (재배치 가능 오브젝트)
hello.o → [링커 ld] → hello (실행 파일)
↑ ↑
사람이 읽는 영역 CPU가 읽는 영역
- 전처리(Preprocessing) —
#include, #define, #ifdef 같은 지시어를 처리한다. stdio.h의 내용물이 그대로 펼쳐져 들어가는 단계.
- 컴파일(Compilation) — 전처리된 C 소스를 사람이 그래도 읽을 수는 있는 어셈블리 텍스트로 번역한다. 이때 최적화가 일어난다.
- 어셈블(Assembly) — 어셈블리어 한 줄 한 줄을 그에 대응하는 이진 명령어로 번역한다. 결과물은 ELF/Mach-O 같은 형식의 오브젝트 파일.
- 링킹(Linking) — 우리 코드가 부른
printf 같은 외부 함수를 표준 라이브러리에서 찾아 합쳐, 마침내 운영체제가 적재할 수 있는 실행 파일을 만든다.
실습으로 이 단계를 분리해 볼 수 있다. gcc는 친절하게 옵션을 제공해 준다:
# 전처리만: hello.i 생성
gcc -E hello.c -o hello.i
# 어셈블리까지: hello.s 생성
gcc -S hello.c -o hello.s
# 어셈블까지: hello.o 생성 (오브젝트 파일)
gcc -c hello.c -o hello.o
# 링킹까지: 실행 파일 생성
gcc hello.c -o hello
흥미로운 사실 하나. hello.o는 그 자체로는 실행할 수 없다. 안에 들어 있는 printf 호출이 "어디로 가야 하는지" 모르기 때문이다.
링커가 표준 C 라이브러리(libc) 안의 printf를 찾아 그 주소를 채워 넣어줘야 비로소 살아 움직이는 프로그램이 된다.
이 "주소 채워 넣기"의 디테일은 7장 링킹에서 본격적으로 다룬다.
꿀팁
gcc -v hello.c -o hello 를 실행해 보면 위의 모든 단계가 어떤 도구로 어떻게 호출되는지 콘솔에 줄줄이 찍힌다. 한번쯤 보고 가자.
1.3컴파일 시스템을 이해하면 얻는 이득
"그냥 gcc hello.c 치면 되는데 왜 그 내부까지 알아야 하나?"라는 질문이 나올 수 있다. 답은 셋이다.
첫째, 성능 최적화. 같은 코드를 어떻게 쓰느냐에 따라 컴파일러가 만들어내는 어셈블리가 달라지고, 결과적으로 실행 속도가 몇 배씩 차이 난다.
예를 들어 switch는 if-else 사슬보다 점프 테이블로 컴파일되어 빠를 때가 많다.
포인터 별칭(aliasing)이 있으면 컴파일러는 안전을 위해 최적화를 포기한다. 이런 상호작용을 모르면 "왜 이 함수가 이렇게 느리지?" 하고 머리만 긁게 된다.
둘째, 링크 에러 디버깅. undefined reference to 'foo'나 multiple definition 같은 메시지를 본 적 있을 것이다.
이런 오류는 링커가 심볼 테이블을 어떻게 다루는지 알아야 진단할 수 있다. extern, static의 의미, 헤더 가드, 강한 심볼/약한 심볼 — 7장에서 이 모든 게 풀린다.
셋째, 보안. 버퍼 오버플로 공격, 스택 스매싱, 리턴 지향 프로그래밍(ROP)은 모두 "실행 파일이 메모리에 어떻게 배치되고 함수 호출이 어떻게 일어나는지"를 정확히 아는 사람이 만들고 막을 수 있다.
이 시각은 3장(어셈블리)과 9장(가상 메모리)에서 자라난다.
주의
최적화 옵션 -O2가 켜져 있을 때 디버거로 단계별 실행을 해 보면, 소스 코드의 줄 순서와 어셈블리 실행 순서가 일치하지 않을 때가 있다.
컴파일러가 명령어를 재배치했기 때문이다. "버그가 디버그 빌드에서만 사라진다" 같은 미스터리는 종종 이런 데서 온다.
1.4프로세서는 메모리에 저장된 명령을 읽고 해석한다
실행 파일이 만들어졌다. 이제 셸에서 ./hello를 친다고 하자. 글자가 화면에 찍히기까지, 하드웨어와 OS가 어떻게 협력하는지 본다.
1.4.1시스템의 하드웨어 구성
현대 PC를 단순화하면 다섯 덩어리다 — CPU, 메인 메모리, I/O 브리지, 그리고 두 종류의 버스(시스템 버스와 메모리 버스), 그리고 디스플레이/디스크/키보드 같은 I/O 장치들.
그림 1.3 · 단순화된 PC의 하드웨어 구성도 ┌────────────────────────────────┐
│ CPU │
│ ┌──────┐ ┌────────────────┐ │
│ │ PC │ │ 레지스터 파일 │ │
│ └──────┘ └────────────────┘ │
│ ┌────────────────────────────┐ │
│ │ ALU (산술 논리 장치) │ │
│ └────────────────────────────┘ │
└─────────────┬──────────────────┘
│ 시스템 버스
┌────────────┴────────────┐
│ I/O 브리지 │
└────┬───────────────┬────┘
│ 메모리 버스 │ I/O 버스
┌────┴────┐ ┌───┴───┬─────────┬─────────┐
│ 메인 메모리│ │ USB │ 디스크 │ 그래픽 │
│ (DRAM) │ │ (키보드)│ │ 어댑터 │
└─────────┘ └───────┴─────────┴─────────┘
핵심 부품들의 역할은 이렇다:
- CPU — 시키는 일을 하는 일꾼. 매 클럭마다 메모리에서 명령어 하나를 가져와 실행한다.
- PC(Program Counter) — 다음에 실행할 명령어의 주소를 담은 64비트 레지스터. 사실상 "지금 어디 책 읽는 중인지" 표시하는 책갈피.
- 레지스터 파일(register file) — CPU 내부의 초고속 작업대.
%rax, %rbx 같은 십수 개의 64비트 칸으로 구성되어 있고, 한 클럭에 읽고 쓸 수 있다.
- ALU(Arithmetic Logic Unit) — 더하기, 빼기, 비교, 비트 연산 같은 진짜 계산을 하는 회로.
- 메인 메모리(DRAM) — 명령어와 데이터가 잠시 머무는 큰 작업장. 전원이 꺼지면 사라진다(휘발성).
- 버스(bus) — 데이터가 흐르는 전선 묶음. 보통 한 번에 워드(word) 단위로 옮긴다 — 64비트 시스템이라면 한 번에 8바이트.
1.4.2hello 프로그램 실행하기
실행 시점에 일어나는 일을 시간 순서로 따라가 보자.
-
셸에
./hello라고 친다. 키보드의 신호가 USB 컨트롤러를 거쳐 메인 메모리로 들어가고, 셸 프로그램이 그걸 한 글자씩 읽어서 화면에 그린다(에코).
-
엔터를 누르면 셸은 사용자가 명령어 입력을 끝냈다는 걸 알고,
hello라는 실행 파일을 디스크에서 메모리로 복사한다.
이때 DMA(Direct Memory Access) 라는 기술 덕에 CPU를 거치지 않고 디스크 컨트롤러가 직접 메모리에 데이터를 채운다 — CPU는 그 시간에 다른 일을 할 수 있다.
-
로딩이 끝나면 CPU는
hello 프로그램의 첫 명령어를 가져와 실행하기 시작한다. 그 안에는 printf를 호출하는 명령들이 있고,
printf는 결국 OS의 write 시스템 콜을 부른다.
-
write는 "hello, world\n"이라는 문자열을 메모리에서 화면 출력 장치로 흘려보내고, 그래픽 어댑터가 픽셀을 점화한다.
이 순간만 봐도 — 디스크에서 메모리로의 복사, CPU가 명령어를 한 번에 한 줄씩 가져오는 방식, 화면으로 데이터가 흘러가는 길 —
하드웨어 안에서 끊임없이 데이터가 복사되고 있음이 보인다. 그리고 복사는 비싸다. 이걸 줄이려는 노력이 다음 절의 주제다.
1.5캐시가 중요하다
문제는 속도 차이다. CPU는 매우 빠르고, DRAM은 그에 비해 매우 느리다.
대략적인 사이클 수로 직관을 잡아 보자(현대 데스크톱 기준의 어림값이고, 시스템마다 다르다):
| 저장소 | 접근 시간 (클럭) | 비유 |
| 레지스터 | 0 ~ 1 | 책상 위의 펜 |
| L1 캐시 (SRAM) | 약 4 | 책상 서랍 |
| L2 캐시 (SRAM) | 약 10 | 책장 한 칸 |
| L3 캐시 (SRAM) | 약 40 | 옆방 책장 |
| 메인 메모리 (DRAM) | 약 200 | 도서관 |
| SSD | 수만 | 다른 도시 도서관 |
| HDD | 수백만 | 해외 도서관 |
매번 DRAM까지 갔다 오면 CPU는 200 사이클 동안 손가락만 빨고 있어야 한다.
그래서 자주 쓰는 데이터를 CPU 가까이에 베껴 두는 작은 SRAM 메모리, 즉 캐시(cache)를 쓴다.
캐시가 효과적인 이유는 프로그램이 보이는 두 가지 패턴 — 지역성(locality) — 덕분이다.
같은 데이터를 곧 다시 쓰는 시간 지역성, 그리고 가까운 주소를 곧 쓰는 공간 지역성. 잘 짠 코드는 자연히 지역성이 높고, 캐시 히트율이 높다.
메모
행렬을 행 방향으로 순회하는 코드(for i: for j: A[i][j])와 열 방향으로 순회하는 코드(for j: for i: A[i][j])는 결과는 같지만,
캐시 친화도 차이로 실행 시간이 10배 이상 벌어질 수 있다. 6장에서 이 현상을 직접 측정한다.
연습 1.2
한 번의 DRAM 접근이 200 사이클이고 한 번의 L1 캐시 접근이 4 사이클이다. 100만 번의 메모리 접근에서 95%가 L1에서 처리된다면, 평균 사이클 수는?
1.6저장 장치는 계층을 이룬다
캐시 아이디어를 일반화하면 다음과 같은 그림이 나온다 — 위로 올라갈수록 빠르고 작고 비싸며, 아래로 내려갈수록 느리고 크고 싸다.
각 층은 바로 아래 층에 대한 캐시 역할을 한다.
그림 1.4 · 메모리 계층 (상단이 빠르고 작음) ▲ 빠름·작음·비쌈
│
┌──────┴──────┐
│ 레지스터 │ L0 — 수백 바이트
└──────┬──────┘
┌────────┴────────┐
│ L1 캐시 │ ~ 수십 KB
└────────┬────────┘
┌──────────┴──────────┐
│ L2 캐시 │ ~ 수백 KB
└──────────┬──────────┘
┌────────────┴────────────┐
│ L3 캐시 │ ~ 수십 MB
└────────────┬────────────┘
┌──────────────┴──────────────┐
│ 메인 메모리 (DRAM) │ ~ 수십 GB
└──────────────┬──────────────┘
┌────────────────┴────────────────┐
│ 로컬 보조 저장 (SSD/HDD) │ ~ TB
└────────────────┬────────────────┘
┌──────────────────┴──────────────────┐
│ 원격 저장 (네트워크 파일/클라우드) │ 거의 무한
└─────────────────────────────────────┘
│
▼ 느림·큼·쌈
이 계층 구조는 한 가지 위대한 통찰을 담고 있다. 각 층이 자기 바로 아래 층의 작은 부분을 흉내 낸다는 것.
L1은 L2의 일부를, L2는 L3의 일부를, 결국 보조 저장은 거의 모든 데이터를 들고 있고, 더 빠른 층은 그중 "지금 자주 쓰는 부분"만 담아 둔다.
프로그래머 입장에서는 마치 거대하고도 빠른 단일 메모리가 있는 것처럼 보인다 — 이게 가능한 이유가 바로 지역성이다.
1.7운영체제가 하드웨어를 관리한다
지금까지 프로그램이 직접 하드웨어와 대화하는 것처럼 말했지만, 실제로는 그렇지 않다.
프로그램과 하드웨어 사이에는 운영체제(OS)라는 두꺼운 층이 있다. OS가 하는 일은 두 가지로 요약된다.
- 응용 프로그램이 하드웨어를 직접 건드리지 못하게 막는다. 그래야 시스템이 안전하고 안정적으로 돌아간다.
- 하드웨어를 추상화해 통일된 인터페이스를 제공한다. 응용은 디스크 모델이나 NIC 칩셋 같은 잡다한 차이를 신경 쓰지 않아도 된다.
OS가 제공하는 핵심 추상은 네 가지 — 프로세스, 스레드, 가상 메모리, 파일.
1.7.1프로세스
프로세스(process)는 "실행 중인 프로그램"의 OS 추상이다. 각 프로세스는 자기만의 CPU, 자기만의 메모리, 자기만의 I/O 장치를 가진 듯한 환상을 누린다.
실제로는 한 CPU 위에서 OS가 빠르게 프로세스들을 갈아 끼우고 있을 뿐이다 — 이걸 컨텍스트 스위치(context switch)라 한다.
그림 1.5 · 두 프로세스 사이의 컨텍스트 스위치시간 →
프로세스 A ─━━━━━━─────────────━━━━━━───── 실행
프로세스 B ──────━━━━━━━━━━━━━━─────━━━━━━ 실행
↑ ↑
저장 A의 상태 → 저장 B의 상태 →
로드 B의 상태 로드 A의 상태
컨텍스트 스위치 때 OS는 떠나는 프로세스의 레지스터, PC, 스택 포인터 등을 잘 챙겨 두고, 들어오는 프로세스의 그것을 복원한다.
이 메커니즘 덕에 사용자는 한 번에 수십 개의 프로그램을 띄워도 다 동시에 실행되고 있는 것처럼 느낀다.
1.7.2스레드
현대 시스템에서 프로세스는 더 작은 실행 단위인 스레드(thread) 여러 개로 구성될 수 있다.
한 프로세스 안의 스레드들은 메모리 공간(코드/데이터/힙)을 공유하지만, 각자 자기 스택과 레지스터 상태를 갖는다.
왜 굳이 스레드를? 프로세스끼리는 메모리가 분리되어 있어 데이터를 주고받으려면 IPC(파이프, 소켓 등)가 필요하지만,
스레드끼리는 그냥 같은 변수를 읽고 쓰면 끝난다. 가볍고 빠른 협력이 가능하다. 다만 그 대가로 — 같은 데이터에 동시에 접근하면서 생기는 경쟁 조건(race condition)이라는 두통거리가 따라온다. 12장의 주제다.
1.7.3가상 메모리
가상 메모리(virtual memory)는 OS가 제공하는 가장 우아한 환상 중 하나다.
모든 프로세스는 마치 자기가 컴퓨터의 모든 메모리(64비트 시스템이라면 이론상 16엑사바이트)를 통째로 쓰고 있는 듯한 주소 공간을 갖는다.
실제로는 여러 프로세스가 물리 메모리를 잘게 쪼개 나눠 쓰고 있을 뿐이지만, 이 환상이 너무 잘 유지되어 응용 프로그래머는 거의 신경 쓸 필요가 없다.
그림 1.6 · 리눅스 프로세스의 가상 주소 공간 레이아웃 (64비트, 위가 높은 주소)높은 주소 ┌───────────────────────────┐ 0x7fff_ffff_ffff
│ 커널 영역 (사용자 접근 ✗) │
├───────────────────────────┤
│ 사용자 스택 ↓ │ 함수 호출 시 자라남
├───────────────────────────┤
│ ⋮ │
│ 공유 라이브러리 │ libc.so 등
│ ⋮ │
├───────────────────────────┤
│ 힙 (heap) ↑ │ malloc 영역, 위로 자람
├───────────────────────────┤
│ 읽기/쓰기 데이터 (.data) │ 초기화된 전역 변수
├───────────────────────────┤
│ 읽기 전용 (.text) │ 프로그램 코드
낮은 주소 └───────────────────────────┘ 0x0000_0000_0000
이 환상을 떠받치는 메커니즘이 페이지 테이블(page table)이다. CPU와 OS가 협력해 모든 가상 주소를 물리 주소로 번역한다.
속도를 위해 그 번역 결과를 캐시해 두는 게 TLB(Translation Lookaside Buffer)다. 자세한 건 9장에서.
1.7.4파일
파일(file)은 디스크, 네트워크, 키보드, 화면 같은 잡다한 I/O 장치를 단일한 추상으로 통일한 결과다.
유닉스 철학에서는 "모든 것이 파일이다(everything is a file)"라는 슬로건이 있을 정도로, 디스크 위 데이터든 USB 입력이든 일단 파일 디스크립터(file descriptor) 라는 작은 정수로 다룬다.
그러면 응용 프로그램은 디스크가 SSD인지 HDD인지, USB 키보드인지 PS/2 키보드인지 일일이 알 필요 없이 read/write만 부르면 된다.
이 추상의 우아함은 10장 시스템 수준 입출력에서 다시 본다.
꿀팁
echo "hi" > /dev/tty 를 한번 쳐 보자. 터미널 자체가 파일처럼 다뤄지는 걸 직접 볼 수 있다.
1.8시스템은 네트워크로 다른 시스템과 통신한다
지금까지 한 대의 컴퓨터 안에서 일어나는 일을 봤다. 하지만 우리가 매일 쓰는 거의 모든 의미 있는 작업은 여러 대의 컴퓨터가 협력해 이뤄진다.
브라우저로 사이트를 열고, 카톡을 보내고, 깃에 푸시하는 모든 행위는 사실 네트워크 너머의 컴퓨터에 부탁을 보내는 일이다.
OS의 시각으로 보면, 네트워크는 그저 또 하나의 I/O 장치다. 응용은 소켓(socket) 이라는 파일 비슷한 개체에 데이터를 write하면 NIC를 거쳐 인터넷으로 흘러간다. 반대로 받을 때는 read로 읽는다.
그림 1.7 · 클라이언트-서버 통신 흐름┌──────────────┐ ┌──────────────┐
│ 클라이언트 │ 1. 요청 패킷 송신 │ 서버 │
│ (내 PC) │ ───────────────────────→ │ (원격지) │
│ │ │ │
│ │ 4. 응답 패킷 수신 │ │
│ │ ←─────────────────────── │ │
└──────────────┘ └──────────────┘
↑ ↓
2. 처리 3. 결과 작성
예를 들어 ssh로 원격 서버에 접속해 ./hello를 실행하면, 키보드 입력은 SSH 클라이언트 → 인터넷 → 원격 서버의 셸로 흐르고,
실행 결과는 정반대 방향으로 흘러 우리 화면에 찍힌다. 한 컴퓨터의 입력이 다른 컴퓨터의 출력이 되는 셈이다. 11장에서 직접 소켓 코드를 짜 본다.
1.9중요한 주제들
책 전체에 반복해서 등장하는 굵직한 주제 셋을 미리 짚는다.
1.9.1암달의 법칙
시스템의 한 부분만 빨라졌을 때 전체가 얼마나 빨라지는가? 직관적으로 "그 부분이 차지하던 비중만큼"이다.
이걸 정량화한 것이 암달의 법칙(Amdahl's Law)이다.
어떤 시스템에서 전체 실행 시간 중 비율 α가 어떤 부분에서 소비된다고 하자. 그 부분만 k배 빠르게 만들었을 때, 전체 속도 향상 S는:
1
S = ─────────────────
(1 - α) + α / k
예를 들어 어떤 프로그램의 60%(α=0.6)가 디스크 I/O에서 소비되고, 그 부분을 SSD로 바꿔 5배 빨라졌다(k=5)면:
S = 1 / ((1 - 0.6) + 0.6 / 5)
= 1 / (0.4 + 0.12)
= 1 / 0.52
≈ 1.92 (즉 약 1.92배 빨라짐)
더 강한 직관: 만약 그 부분을 무한히 빠르게(k→∞) 만든다 해도, 전체는 1/(1−α) = 2.5배밖에 못 빨라진다.
남은 40%가 발목을 잡는다. 그래서 최적화는 항상 가장 큰 비중을 차지하는 부분부터 손대야 한다 — 그 격언의 수학적 근거가 바로 이 식이다.
주의
"이 함수만 어셈블리로 새로 짰는데 왜 전체는 안 빨라지지?" — 그 함수가 전체의 5%를 차지했다면, 무한 가속해도 전체는 5.3%밖에 못 빨라진다. 프로파일러부터 돌리자.
연습 1.3
α=0.8, k=4 일 때 전체 속도 향상 S 는 얼마인가? 같은 α 에서 k 가 무한대로 가면 S 는?
1.9.2동시성과 병렬성
두 단어가 자주 헷갈린다. 동시성(concurrency)은 "여러 일이 동시에 진행 중인 듯이 보이는 것"이고, 병렬성(parallelism)은 "여러 일이 진짜로 동시에 처리되는 것"이다.
비유하자면 한 명의 요리사가 여러 냄비를 번갈아 보는 게 동시성, 요리사 여럿이 각자 한 냄비씩 맡는 게 병렬성.
현대 시스템은 세 층위에서 병렬성을 활용한다 — 스레드 수준(여러 코어), 명령어 수준(파이프라이닝, 슈퍼스칼라), 그리고 데이터 수준(SIMD).
이 세 층은 각각 4장(프로세서 구조), 5장(최적화), 12장(병행 프로그래밍)에서 다룬다.
1.9.3추상화의 중요성
이 책 전체를 관통하는 정신은 추상화(abstraction)다. 함수는 명령어 묶음을 단일 호출로 추상화한 것이고,
파일은 모든 I/O를 통일된 인터페이스로 추상화한 것이며, 가상 메모리는 물리 메모리를 프로세스별 환상으로 추상화한 것이고, 프로세스는 CPU를 추상화한 것이다.
좋은 추상은 사용자에게 덜 알아도 되게 해 준다. 그러나 새는 추상(leaky abstraction)도 있다 —
평소엔 잘 가려져 있다가, 성능 문제나 오류가 발생하면 갑자기 아래층의 디테일이 튀어나온다. 그래서 응용 프로그래머도 결국 한 층 아래까지는 알아둬야 한다. 이 책의 존재 이유다.
1.10요약
한 줄짜리 hello 프로그램을 따라 컴퓨터 시스템 전체를 한 번 훑었다. 정리하면:
- 모든 정보는 비트열이며, 의미는 컨텍스트가 부여한다.
- 고수준 코드는 전처리·컴파일·어셈블·링크의 4단계 번역을 거쳐 실행 파일이 된다.
- 프로세서는 PC가 가리키는 메모리 위치에서 명령어를 한 줄씩 가져와 ALU와 레지스터로 처리한다.
- 메모리는 레지스터·캐시·DRAM·SSD/HDD·원격 저장의 계층을 이루며, 위층은 아래층의 캐시 역할을 한다.
- OS는 프로세스/스레드/가상 메모리/파일이라는 네 가지 추상으로 하드웨어를 가린다.
- 네트워크는 단지 또 하나의 I/O이고, 시스템들이 협력해 큰 일을 한다.
- 암달의 법칙은 "큰 비중부터 최적화하라"는 격언의 정량적 근거다.
- 동시성·병렬성·추상화는 책 전반에 반복해서 등장할 키워드다.
지도는 일단 펼쳤다. 다음 장부터는 한 영역씩 줌인해 들어간다. 가장 먼저, 모든 것의 시작인 비트와 숫자의 세계로.
메모
이 장의 모든 주제는 뒤 장에서 적어도 한 번씩 다시 등장한다. 지금 모르는 게 많아도 괜찮다 — 일단 지도에 핀을 꽂아 두고 가자.