본문 바로가기
C.W.K.
Stream
Lesson 04 of 05 · published

스냅숏 하나, 읽는 쪽은 여럿

~13 min · copy-on-write, undo, immutability, documentation, swift

Level 0빈 캔버스
0 XP0/37 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete

기본 동작 하나, 부르는 곳은 여럿

타일을 얼릴 수 있게 되면 레이어 전체 스냅숏은 더 이상 비싼 물건이 아냐. 참조 목록일 뿐이야. TileStore.frozenTiles()가 바로 그 목록이야. 살아 있는 타일을 전부 얼려서 키 순서로 돌려줘. 주석 말로는 "a consistent picture of the store that any thread may read while painting goes on." 이걸 부르는 곳을 세어보면 앱의 얼마나 많은 부분이 조용히 여기 기대고 있는지 보여.

  • 저장과 내보내기. ArtworkArchive가 모든 레이어의 타일과 모든 마스크의 물감을 이렇게 가져가(앞 레슨).
  • 레이어 복제. "The copy shares the original's tiles, and its mask's, frozen, until either side paints over one." 레이어를 복제해도 픽셀은 하나도 복사 안 돼.
  • 편집 세션. Transform, Adjustments, Liquify, Text는 세션이 시작될 때의 레이어를 쥐고 있어. 그래서 미리보기는 늘 원본에서 출발하고, 확정은 한 단계로 기록돼.
  • 획이 시작될 때의 레이어가 필요한 획. 복제 획과 두 획을 합치는 듀얼 브러시 획은 자기 전용 저장소에 커버리지를 칠하고, 그걸 원본 위에 합쳐. 복제는 원본에서 오프셋만큼 떨어진 픽셀을 떠다 깔고, 듀얼 브러시는 두 커버리지로 모양을 잡은 자기 색을 원본 픽셀 위에 깔아(지우개면 그 모양만큼 원본에서 덜어내고).
  • 복사하기·잘라내기 후 붙여넣기도 같은 언 그림으로 원본 레이어를 읽어.

실행 취소의 두 반쪽은 둘 다 공유야

픽셀 실행 취소 단계는 .tiles(layerID, before, after)야. 이전 상태는 타일마다 처음 쓸 때 얼었어(두 번째 레슨). 이후 상태는 snapshot(of:)에서 나와(첫 번째 코드 블록). 이건 살아 있는 버퍼 자체를 얼려. 그래서 획 직후엔 그 단계의 이후 상태가 살아 있는 타일 그 자체야. 같은 객체. 실행 취소와 다시 실행은 restore를 부르고, restore는 스냅숏의 언 버퍼를 복사 없이 setTile로 저장소에 그대로 돌려줘. 그러니까 실행 취소 뒤에도 레이어의 살아 있는 타일과 기록의 이전 상태는 또 같은 객체야.

기록 예산이 이전 상태만 세는 이유가 이거야. 단계가 실행 취소 스택에 있는 동안, 이후 상태는 살아 있는 타일이거나 다음 단계의 이전 상태야. 이미 다른 누가 값을 치르고 있는 메모리지. 다만 다시 실행 쪽엔 구멍이 있어. 실행 취소를 하면 레이어는 다시 이전 상태를 쥐고, 되돌린 단계의 이후 상태는 다시 실행 항목 말고는 아무도 안 쥐고 있을 수 있어. 획이 새로 만든 타일이 제일 뚜렷한 경우야. 이전 상태가 nil이라 항목은 64바이트로 세지는데, 실제로는 32 KiB 버퍼를 붙잡고 있어. 다음 새 단계가 다시 실행 스택을 비울 때까지 예산은 그만큼 덜 세. 나머지를 바이트 예산으로 바꾸는 건 6트랙이야.

소식을 못 들은 독스트링

이제 두 번째 코드 블록을 읽어봐. 지금 History 클래스에 달려 있는 주석이야.

"History owns frozen tile copies; applying a step copies again, so the stacks never alias live layer tiles."

지금 코드에선 이 문장의 모든 절이 틀렸어. 기록이 쥔 건 사본이 아니라 공유된 언 버퍼야. 단계를 적용해도 복사 안 해. restore는 공유해. 그리고 스택은 일부러, 늘, 살아 있는 레이어 타일을 같이 가리켜. 쓸 당시엔 참인 문장이었어. 6479f1f 전에는 snapshot도 restore도 copy()를 불렀거든. 그 커밋이 몇십 줄 위 byteCount 주석은 공유를 설명하게 고쳐 썼는데, 이건 그대로 남겨뒀어. 코드를 가르쳐. 설계는 공유와 쓸 때 복사고, 클래스 주석은 낡았어.

이런 낡은 주석은 없느니만 못해. 이유도 구체적이야. 이건 사소한 디테일이 아니라 안전 성질을 설명해. 그것도 나중에 고칠 사람이 기록 버퍼를 건드려도 되는지 알아보려고 딱 들여다볼 곳에서. 이 주석을 믿는 사람은 실행 취소 스택에서 닿은 버퍼는 기록만의 것이라고 판단하고 거기에 쓸 수도 있어. 그때 주석은 버그의 알리바이가 되는 거지.

공유를 안전하게 만드는 것

규칙 둘이 전부를 떠받쳐. 둘 다 타일에서 지켜져.

  1. 얼면 불변. 언 버퍼는 절대 안 써. fill에는 precondition이 있고, 그 밖의 모든 쓰기는 writableTile을 지나야 한다고 프로젝트 규칙이 정해둬.
  2. 쓸 때 복사. 저장소는 언 버퍼에 절대 안 쓰고, 먼저 사본으로 바꿔 끼워. 그래서 붙잡은 쪽은 모두 자기가 가져간 픽셀을 그대로 봐.

세 번째 규칙이 앞의 둘을 스레드 너머까지 지켜줘. 얼리거나 쓰는 건 그 그림의 스레드만 하고, 버퍼는 남한테 넘기기 전에 얼려. 그다음엔 어느 스레드든 읽어도 돼. 컴파일한 스케치(세 번째 코드 블록)는 등장인물 전원을 타일 하나에 통과시켜. 획 하나, 그 실행 취소 단계, 복제 레이어, 실행 취소, 그리고 그 뒤의 새 댑. 다들 자기 값을 지키고, 살아 있는 저장소는 딱 두 번 복사했어.

코드에서 정직한 맞바꿈도 하나 보여. frozen 플래그는 한번 서면 안 내려가. 다른 쪽이 다 놓아줘도 isFrozen을 다시 false로 돌리는 코드는 어디에도 없어. 흔한 Swift 쓸 때 복사 패턴은 isKnownUniquelyReferenced를 물어보고, 아무도 안 쥐고 있으면 복사를 건너뛰어. Ariandel은 안 물어봐. 대가는 가끔 굳이 안 해도 될 복사야. 예를 들면 그 타일을 쥐고 있던 단계를 기록이 잘라낸 뒤의 첫 획. 얻는 건 틀릴 참조 카운트가 아예 없는 규칙이고. 쓸데없는 복사 한 번은 32 KiB고, 틀린 픽셀 하나는 아빠의 그림 한 장이야.

공유가 안전한 건 공유한 걸 아무도 쓸 수 없을 때, 딱 그때뿐이야. 넘길 때 얼리고, 쓰기 전에 복사해. 그러면 실행 취소 스택부터 백그라운드 저장까지, 읽는 쪽은 전부 포인터 하나 값으로 발밑에서 안 움직이는 그림을 받아.

Code

실행 취소의 이후 상태는 살아 있는 타일을 공유하고, restore도 공유로 돌려줌 (AriandelEngine/Tiles.swift, 원문 그대로)·swift
    /// Current state of the given tiles, the after-half of an undo step. The
    /// snapshot shares the live buffers (frozen); the store copies on its next write.
    public func snapshot(of keys: some Sequence<TileKey>) -> TileSnapshot {
        var snapshot: TileSnapshot = [:]
        for key in keys {
            snapshot.updateValue(tiles[key]?.freeze(), forKey: key)
        }
        return snapshot
    }

    /// Writes a snapshot back (undo or redo). The store shares the snapshot's
    /// frozen buffers, so the snapshot never changes.
    public func restore(_ snapshot: TileSnapshot) {
        for (key, tile) in snapshot {
            setTile(tile?.freeze(), at: key)
        }
    }
AriandelEngine/History.swift에 지금도 남아 있는 낡은 클래스 주석 (원문 그대로)·swift
/// Undo and redo stacks, bounded by bytes and by count.
///
/// History owns frozen tile copies; applying a step copies again, so the
/// stacks never alias live layer tiles.
public final class History {
최소 스케치: 기록, 복제, 살아 있는 저장소가 타일 하나를 공유함 (Swift 6 모드 swiftc로 컴파일해서 실행함)·swift
// A minimal sketch of one frozen buffer with many readers: undo history,
// a duplicate layer and the live store all hold the same object, and the
// store's copy-on-write keeps each of them from seeing another's writes.
final class Buffer {
    var value: Int
    private(set) var isFrozen = false
    init(_ value: Int) { self.value = value }
    @discardableResult func freeze() -> Buffer { isFrozen = true; return self }
}

final class Store {
    private(set) var tiles: [Int: Buffer] = [:]
    private(set) var copies = 0

    func writableTile(at key: Int) -> Buffer {
        if let tile = tiles[key] {
            guard tile.isFrozen else { return tile }
            copies += 1
            let copy = Buffer(tile.value)
            tiles[key] = copy
            return copy
        }
        let tile = Buffer(0)
        tiles[key] = tile
        return tile
    }

    func setTile(_ tile: Buffer?, at key: Int) { tiles[key] = tile }  // takes it as it is: frozen stays shared
    func snapshot(_ key: Int) -> Buffer? { tiles[key]?.freeze() }
    func restore(_ tile: Buffer?, at key: Int) { setTile(tile?.freeze(), at: key) }  // shares, never copies
}

let layer = Store()
layer.writableTile(at: 0).value = 10                 // the canvas before the stroke
let before = layer.snapshot(0)                       // history's before-state (frozen, shared)
layer.writableTile(at: 0).value = 20                 // the stroke: copies once, writes the copy
let after = layer.snapshot(0)                        // history's after-state IS the live tile

let duplicate = Store()                              // Duplicate Layer: share every tile, copy none
duplicate.setTile(layer.snapshot(0), at: 0)
print("duplicate shares the live tile:", duplicate.tiles[0]! === layer.tiles[0]!)

layer.restore(before, at: 0)                         // undo: the store now holds history's buffer
print("after undo, live tile is history's before-state:", layer.tiles[0]! === before!)

layer.writableTile(at: 0).value = 99                 // paint after the undo
print("history before =", before!.value, " history after =", after!.value,
      " duplicate =", duplicate.tiles[0]!.value, " live =", layer.tiles[0]!.value)
print("copies made by the live store:", layer.copies)

// Output (swiftc -swift-version 6):
// duplicate shares the live tile: true
// after undo, live tile is history's before-state: true
// history before = 10  history after = 20  duplicate = 20  live = 99
// copies made by the live store: 2

External links

Exercise

스케치의 restore를 낡은 주석이 말하는 대로 복사하게 바꿔봐. 중간에 칠하지 말고 실행 취소와 다시 실행을 몇 번 연달아 하고, 저장소의 복사 횟수를 공유 버전과 비교해. 그다음 restore를 되돌리고, writableTile이 얼린 표시를 무시하게 바꿔(isFrozen 가드부터 return copy까지를 return tile 한 줄로), 다시 돌려서 붙잡은 쪽이 각각 뭘 출력하는지 적어.
Hint
restore에서 복사하면 실행 취소와 다시 실행 때마다 값을 치러. 공유하면 그 뒤에 칠할 때만, 그것도 타일마다 한 번 치러. 옛 설계가 틀린 게 아니라 비쌌을 뿐인 이유야. 얼린 표시를 무시하면 모두가 99를 출력하고 저장소는 복사 0번이라고 보고해. 기록이 이전 상태로 쥐고 있던 버퍼에 획이 바로 썼고, 나머지는 줄줄이 따라간 거야. 복사 0번과 망가진 기록은 같은 버그를 양쪽에서 본 모습이야. (가드 줄만 지우면 반대가 돼. 이미 있는 타일에 쓸 때마다 복사하니까 낭비지만 맞긴 맞고, 스케치는 같은 값에 복사 2번을 출력해.)

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인 — 댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.