본문 바로가기

쓰기 정책·보조기억장치

캐시에 쓴 값을 아래 계층에 곧바로 전하는 write-through와 내보낼 때 전하는 write-back, 쓰기 실패 때 블록을 가져올지 정하는 할당 정책을 시뮬레이션으로 비교합니다. 파일이 저장장치에 실제로 기록되는 시점과, HDD·SSD·광디스크·테이프의 접근 방식을 다룹니다.

캐시에서 어떤 값을 7에서 9로 고쳤는데, 주기억장치에는 아직 7이 남아 있을 수 있습니다. 읽기만 하는 동안에는 캐시가 아래층의 복사본일 뿐이지만, 쓰기가 시작되면 캐시와 아래층의 값이 달라질 수 있기 때문입니다. 그래서 캐시에는 읽기 규칙뿐 아니라 고친 값을 언제 아래층에 전할지 정하는 쓰기 정책이 필요합니다. 이 글에서는 캐시 주소·매핑·교체의 캐시에 쓰기를 더하고, 계층의 맨 아래인 보조기억장치를 살펴봅니다.

쓰기 적중: 언제 아래층에 전할까

write-through는 캐시에 쓸 때마다 같은 값을 아래층에도 함께 씁니다. 캐시와 아래층의 값이 늘 같아서 단순하지만, 같은 자리를 여러 번 고치면 그때마다 아래층에 쓰기가 일어나 느립니다. 실제 CPU는 쓰기 버퍼(write buffer)에 쓸 값을 모아 두고 CPU는 기다리지 않게 하므로, 캐시에 쓴 순간과 아래층에 실제로 쓰기가 끝난 순간은 다를 수 있습니다.

write-back은 캐시만 고치고, 그 라인이 고쳐졌다는 표시로 더티 비트(dirty bit)를 1로 둡니다. 아래층에는 그 라인이 교체되어 캐시에서 나갈 때 한 번만 씁니다. 같은 자리를 여러 번 고쳐도 아래층 쓰기는 한 번이라 효율적이지만, 그동안 아래층의 값은 옛 값입니다. 더티 비트가 0인 라인은 아래층과 값이 같으므로 내보낼 때 다시 쓰지 않아도 됩니다. write-back이 고친 데이터를 버린다는 뜻이 아니라, 아래층에 쓰는 시점을 늦춘다는 뜻입니다.

쓰기 실패: 블록을 가져올까

쓰려는 주소가 캐시에 없을 때는 블록을 가져올지도 정해야 합니다. write-allocate는 블록을 먼저 캐시로 가져온 뒤 캐시에서 고치고, no-write-allocate는 캐시는 그대로 두고 아래층에만 씁니다. 이것은 앞의 write-through, write-back과 따로 정하는 선택이라, 네 가지 조합이 모두 가능합니다. 다만 고친 값을 캐시에 오래 두는 write-back은 write-allocate와, 어차피 아래층에 바로 쓰는 write-through는 no-write-allocate와 함께 쓰는 경우가 많습니다.

라인이 하나뿐인 캐시에서 A를 읽고, A를 6으로, 다시 7로 고친 뒤, B를 읽는 과정을 두 정책으로 비교해 보겠습니다. 쓰기 실패 때는 두 정책 모두 블록을 가져온다고 하겠습니다.

def simulate(events, policy):
    memory = {"A": 5, "B": 8}
    line = None
    writes = 0
    for op, name, value in events:
        if line is None or line[0] != name:
            if line is not None and line[2]:
                memory[line[0]] = line[1]
                writes += 1
            line = [name, memory[name], False]
        if op == "write":
            line[1] = value
            if policy == "write-through":
                memory[name] = value
                writes += 1
            else:
                line[2] = True
        print(f"{op:5} {name}: 캐시 {line[0]}={line[1]} 더티={int(line[2])} 메모리 A={memory['A']}")
    return writes


events = [("read", "A", None), ("write", "A", 6), ("write", "A", 7), ("read", "B", None)]
for policy in ("write-through", "write-back"):
    print(policy)
    print(f"메모리 쓰기 {simulate(events, policy)}번")
write-through
read  A: 캐시 A=5 더티=0 메모리 A=5
write A: 캐시 A=6 더티=0 메모리 A=6
write A: 캐시 A=7 더티=0 메모리 A=7
read  B: 캐시 B=8 더티=0 메모리 A=7
메모리 쓰기 2번
write-back
read  A: 캐시 A=5 더티=0 메모리 A=5
write A: 캐시 A=6 더티=1 메모리 A=5
write A: 캐시 A=7 더티=1 메모리 A=5
read  B: 캐시 B=8 더티=0 메모리 A=7
메모리 쓰기 1번

line은 캐시의 유일한 라인으로 이름, 값, 더티 비트를 담습니다. 찾는 이름이 라인에 없으면 실패이므로, 라인이 더티이면 먼저 메모리에 써서 내보내고 새 블록을 가져옵니다. write-through는 쓸 때마다 메모리도 고쳐 메모리 A가 6, 7로 바로 따라가고, 메모리 쓰기가 두 번 일어납니다. write-back은 두 번 고치는 동안 메모리 A가 5에 머물다가, B를 읽으려고 A의 라인을 내보내는 순간 7이 한 번 쓰입니다. 그 사이에 다른 장치가 메모리의 A를 읽었다면 옛 값 5를 보았을 것인데, 이 문제는 공유 메모리와 일관성에서 다시 다룹니다.

파일이 저장되었다는 것

CPU 캐시와 주기억장치 사이의 쓰기 정책은 파일이 저장장치에 기록되는 것과는 다른 문제입니다. 프로그램이 파일에 쓴 내용은 먼저 운영체제가 주기억장치에 둔 버퍼에 모이고, 운영체제가 적당한 때에 저장장치로 보내며, 저장장치 안에도 자체 버퍼가 있습니다. 그래서 프로그램이 쓰기를 마쳤다고 해서 그 순간 전원이 꺼져도 내용이 남는다고 말할 수는 없습니다. 데이터베이스처럼 기록이 확실해야 하는 프로그램은 운영체제에 버퍼를 저장장치까지 비우도록 따로 요청하는데, 운영체제의 이 버퍼인 페이지 캐시와 그 요청인 fsync는 운영체제 교재의 파일 시스템 실무에서 다룹니다. 이 글의 모델은 정전 같은 상황에서 파일이 남는지까지는 다루지 않습니다.

더 깊이: 저장장치 안의 write-back 캐시

저장장치 안의 버퍼도 CPU 캐시처럼 write-back으로 동작할 수 있습니다. Linux의 쓰기 캐시 제어 문서에 따르면 특히 소비자용 저장장치 가운데 휘발성 write-back 캐시를 가진 것이 많고, 이런 장치는 데이터가 비휘발성 매체에 실제로 닿기 전에 입출력이 끝났다고 운영체제에 알립니다. 그래서 운영체제는 fsync나 sync, 언마운트처럼 데이터를 확실히 남겨야 하는 때에 장치의 캐시까지 비우게 해야 합니다.

Linux 블록 계층은 이를 요청의 표시 두 가지로 처리합니다. REQ_PREFLUSH가 붙은 요청은 그 요청을 시작하기 전에, 앞서 끝났다고 알린 쓰기들이 모두 비휘발성 매체에 있게 합니다. REQ_FUA가 붙은 쓰기는 데이터가 실제로 기록된 뒤에야 끝났다고 알립니다. 휘발성 쓰기 캐시가 없는 장치라면 블록 계층이 두 표시를 떼어 내므로 아무 효과가 없습니다.

장치에 write-back 캐시가 켜져 있는지는 /sys/block/<디스크>/queue/write_cache가 "write back"인지 "write through"인지로 볼 수 있습니다. 블록 장치의 sysfs 파일을 설명한 커널 문서는 이 파일에 쓰면 커널이 장치를 보는 방식만 바뀌고 장치의 상태는 그대로이므로, "write through"로 바꾸면 커널이 보내던 캐시 비우기까지 사라져 안전하지 않을 수 있다고 경고합니다.

저장장치는 접근 방식이 다르다

하드 디스크(hard disk drive, HDD)는 회전하는 자기 원판에 데이터를 기록합니다. 원하는 위치를 읽으려면 헤드가 해당 트랙으로 움직이는 탐색 시간(seek time), 원하는 섹터가 헤드 아래로 돌아오기를 기다리는 회전 지연(rotational latency), 데이터를 읽어 보내는 전송 시간(transfer time)이 걸립니다. 평균 탐색 4 ms, 평균 회전 지연 3 ms, 전송 1 ms라면 한 번의 접근에 약 8 ms가 듭니다. 이웃한 데이터를 이어서 읽는 순차 접근은 탐색과 회전 지연을 처음 한 번만 치르므로 훨씬 빠릅니다.

SSD(solid state drive)는 플래시 메모리를 쓰므로 움직이는 부품이 없어 임의 접근도 빠릅니다. 하지만 플래시는 덮어쓸 수 없고, 쓰기는 페이지 단위로, 지우기는 그보다 큰 블록 단위로 해야 하며, 블록마다 지울 수 있는 횟수에도 한계가 있습니다. 그래서 SSD 안의 FTL(flash translation layer)이 프로그램이 보는 논리 주소를 플래시의 실제 위치에 연결하고, 고친 데이터를 다른 빈 위치에 쓴 뒤 연결만 바꾸며, 쓰기가 한곳에 몰리지 않도록 고르게 나눕니다. 같은 논리 주소에 다시 써도 실제로는 다른 곳에 기록될 수 있습니다.

광디스크는 빛으로 데이터를 읽고 쓰며, CD, DVD, Blu-ray는 기록 방식과 용량이 다릅니다. 자기 테이프는 앞에서부터 차례로 읽어야 해서 임의 접근은 아주 느리지만, 용량당 값이 싸고 오래 보관하기 좋아 백업과 보관용으로 지금도 쓰입니다. 모든 저장장치는 비휘발성이지만 영원하지는 않으며, 매체마다 보관 수명과 손상될 가능성이 다르므로 중요한 데이터는 여러 곳에 나누어 둡니다.

정리

  • write-through는 쓸 때마다 아래층에도 쓰고, write-back은 더티 비트를 두었다가 라인을 내보낼 때 한 번 씁니다.
  • 쓰기 실패 때 write-allocate는 블록을 가져와 고치고, no-write-allocate는 아래층에만 쓰며, 이 선택은 쓰기 적중 정책과 따로 정합니다.
  • write-back은 아래층 쓰기를 줄이지만, 그동안 아래층에는 옛 값이 남아 있습니다.
  • 캐시 쓰기 정책과 파일이 저장장치에 기록되는 것은 다른 문제이며, 운영체제와 장치의 버퍼를 거칩니다.
  • HDD는 탐색, 회전 지연, 전송 시간이 들고, SSD는 움직이는 부품이 없지만 FTL이 지우기 단위와 수명을 관리합니다.

연습 문제

1. 언제 아래층에 쓰나

아래층에 A = 5, B = 8이 있고, 라인이 하나인 빈 캐시가 write-back과 write-allocate로 동작합니다. A 읽기, A에 6 쓰기, B 읽기를 차례로 할 때, 단계마다 캐시의 내용, 더티 비트, 아래층의 A를 쓰세요. write-through라면 무엇이 달라지나요?

해설 보기

A를 읽은 뒤 캐시는 A = 5, 더티 0이고 아래층 A도 5입니다. A에 6을 쓰면 캐시는 A = 6, 더티 1이지만 아래층 A는 아직 5입니다. B를 읽으려면 유일한 라인의 A를 내보내야 하는데 더티이므로 6을 아래층에 쓰고, 캐시는 B = 8, 더티 0, 아래층 A는 6이 됩니다. write-through라면 A에 6을 쓰는 순간 아래층 A도 6이 되고, B를 읽을 때는 더티가 아니므로 다시 쓰지 않습니다.

2. 하드 디스크의 평균 접근 시간

7200 rpm으로 도는 하드 디스크의 평균 탐색 시간이 9 ms이고 전송 시간이 0.1 ms입니다. 평균 회전 지연과 한 번의 임의 접근에 드는 평균 시간을 구하세요.

해설 보기

7200 rpm은 1분에 7200바퀴이므로 한 바퀴에 60 / 7200초, 곧 약 8.33 ms가 걸립니다. 원하는 섹터는 평균적으로 반 바퀴를 기다리면 오므로 평균 회전 지연은 약 4.17 ms입니다. 그래서 평균 접근 시간은 9 + 4.17 + 0.1, 약 13.3 ms입니다. 같은 시간 동안 CPU는 수천만 개의 명령어를 실행할 수 있으므로, 디스크의 임의 접근이 얼마나 비싼지 알 수 있습니다.

3. 쓰기 실패에서 블록을 가져오지 않으면

write-through와 no-write-allocate를 쓰는 캐시에 A만 들어 있을 때, 캐시에 없는 B에 9를 쓰면 캐시와 아래층은 어떻게 되나요? 곧이어 B를 읽으면 어떻게 되나요?

해설 보기

no-write-allocate이므로 B의 블록을 가져오지 않고 아래층의 B만 9로 고치며, 캐시에는 여전히 A만 있습니다. 곧이어 B를 읽으면 캐시에 없으므로 실패하고, 이번에는 읽기 실패라서 아래층에서 B = 9를 가져옵니다. 쓴 직후에 같은 데이터를 다시 읽는 일이 많은 프로그램이라면 write-allocate가 이런 실패를 줄여 줍니다. 반대로 한 번 쓰고 다시 읽지 않는 데이터가 많다면, 블록을 가져오지 않아 캐시를 아낄 수 있습니다.

쓰기 정책이 헷갈리면 "쓰기 적중일 때 아래층에 언제 쓰는가"와 "쓰기 실패일 때 블록을 가져오는가"의 두 질문을 따로 적어 보세요. 다음 글 메모리 칩의 폭·뱅크·선택 신호에서는 칩 여러 개로 주기억장치를 꾸미는 방법을 다룹니다.