포스트

Dart Kernel과 스냅샷

Dart Kernel과 스냅샷

들어가며

dart run의 native assets 경합 이슈가 해결된 과정을 정리한다.

Kernel

Dart 소스는 Dart VM이 바로 읽지 않는다. CFE(Common Front End)라는 컴파일러 앞단이 소스를 파싱하고 타입 검사까지 마친 중간 표현으로 바꾼다. 이것이 Kernel이고 파일로 저장한 것이 .dill이다.

1
2
소스(.dart) → [CFE] → Kernel(.dill) ─→ [VM] JIT 실행         (dart run)
                                    └→ [gen_snapshot] AOT    (dart compile exe)

Kernel이 갈림길이다. 같은 dill을 VM이 JIT로 바로 돌릴 수도 있고, AOT 컴파일러의 입력으로 쓸 수도 있다.

CFE를 부르는 도구는 여럿이다. pub과 Flutter가 쓰는 frontend_server, VM 안에 상주하는 kernel_service 전부 같은 CFE이고 출력은 같은 dill이다.

dill

dill의 내용은 타입 검사가 끝난 AST를 바이너리로 직렬화한 것이다. 명령어 열(기계어나 바이트코드)이 아니라 구조다. 예시로 return x + 1을 두 형태로 놓고 보자.

1
2
3
4
5
바이트코드 (Python .pyc)      dill
LOAD_FAST    x               Return
LOAD_CONST   1               └─ BinaryOp(+)
BINARY_OP    +                  ├─ Variable(x)
RETURN_VALUE                    └─ Literal(1)

왼쪽은 위에서 아래로 한 줄씩 따라가면 그대로 실행이 된다. 오른쪽은 “더하라”는 뜻은 있지만 무엇을 먼저 꺼내고 언제 더할지 순서가 없다. 그 순서를 정하는 게 컴파일이고, Dart VM은 실행 직전에 이 트리를 보고 기계어를 만든다.

인터프리터 vs 컴파일

간단하게 구분한다면 내 코드가 실제 CPU의 기계어로 번역되어 CPU가 직접 실행하느냐, 아니면 인터프리터라는 다른 프로그램이 내 코드를 데이터로 읽으며 대신 실행하느냐로 나눌 수 있다. (바이트코드는 실제 CPU가 아니라 가상 CPU용 명령어라 후자다.)

구현중간 파일첫 실행자주 실행되면내 코드가 기계어가 되나
Python (CPython)바이트코드 .pyc인터프리터계속 인터프리터안 됨
Java (HotSpot)바이트코드 .class인터프리터JIT 기계어자주 실행되는 부분만
JavaScript (V8)없음인터프리터JIT 기계어자주 실행되는 부분만
Dart (VM)AST .dillunoptimized JIT 기계어최적화 JIT 기계어전부, 실행 중에
Dart (AOT), C, Rust, Go기계어기계어기계어전부, 실행 전에

그래서 인터프리터와 컴파일은 언어의 속성이 아니라 구현의 속성이다. 같은 언어라도 구현(엔진, VM, 컴파일러)마다 다르다.

  • Python: CPython은 끝까지 인터프리터, PyPy는 JIT, Cython은 C로 변환해 실행 전에 기계어로 만드는 AOT
  • Java: HotSpot은 인터프리터 + JIT, GraalVM native-image는 실행 전에 통째로 기계어로 만드는 AOT
  • JavaScript: V8은 JIT, QuickJS는 인터프리터
  • Dart: dart run은 JIT, dart compile exe는 AOT

Dart의 경우 VM은 메서드가 처음 불리는 순간 최적화 없이 빨리 만든 기계어(unoptimized)로 컴파일한다. 자주 불리면 최적화된 기계어로 교체한다. 인터프리터 단계는 없다. 대신 시작 비용이 생기는데 그걸 App-JIT 스냅샷과 AOT가 보완한다.

스냅샷

Dart에서 snapshot은 실행에 필요한 상태를 파일로 저장해 둔 것이다. 메모리에 만들어 놓은 것을 바이트 그대로 파일에 써 두었다가 다음 실행에서 다시 만들지 않고 읽어서 복원한다.

이름저장하는 것들어 있는 것실행 주체
Kernel 스냅샷dill 파일AST. 기계어 없음VM (JIT)
App-JIT 스냅샷VM 힙로드된 클래스 + 한 번 실행해서 호출된 함수의 JIT 기계어VM (JIT)
AOT 스냅샷VM 힙로드된 클래스 + 모든 함수의 기계어 (미리 컴파일)dartaotruntime (AOT 전용 런타임)

힙은 메모리 영역이지만, 그 안에 있는 클래스 정보, 함수, 상수, 컴파일된 기계어는 전부 바이트라서 파일로 쓸 수 있다. 객체끼리 가리키는 주소는 다음 실행에서 달라지므로 쓸 때 “몇 번째 객체”라는 번호로 바꿔 두고 읽을 때 새 주소로 다시 잇는다.

pub이 .dart_tool/pub/bin/…snapshot에 만들어 두는 파일이 Kernel 스냅샷이다. 확장자만 .snapshot이지 내용은 dill이다. 아래에서는 이 파일을 스냅샷이라고도, Kernel이라고도 부른다.

dart run의 두 경로

인자가 파일이냐 패키지 이름이냐에 따라 Kernel을 누가 언제 만드는지가 달라진다.

1
2
3
dart run bin/foo.dart   dartdev → VM (kernel_service가 즉석 컴파일, 파일로 남는 dill 없음)
dart run foo            dartdev → pub → Kernel 스냅샷 저장 → VM이 그 파일 실행
                                        (.dart_tool/pub/bin/…)

패키지 이름일 때 pub을 거치는 이유는 이름을 실제 파일 경로로 바꿔야 하기 때문이다. foo가 어떤 패키지고 디스크 어디에 있는지, 진입점이 무엇인지는 pub이 안다. 그리고 패키지 이름으로 실행하는 건 대개 소스가 안 바뀌는 도구라서 pub은 어차피 해석한 김에 컴파일 결과를 프로젝트 안 고정 경로에 저장해 둔다.

native assets 매핑은 Kernel에 들어간다

이슈의 시작은 package:webcrypto가 @Native로 선언한 C 함수를 VM이 찾는 순간이었다. 빌드 훅으로 네이티브 라이브러리를 제공하는 패키지는 @Native 심볼이 어느 라이브러리에 있는지를 native_assets.yaml이라는 매핑으로 알려준다. 이 매핑은 실행 시점에 다시 읽히는 게 아니라 컴파일 때 Kernel 안에 들어간다. 컴파일러가 yaml을 읽어 vm:ffi:native-assets라는 합성 라이브러리(소스 파일이 없는, 어노테이션만 달린 라이브러리)를 만들어 dill 끝에 붙인다. VM은 @Native 함수가 처음 호출될 때 이 라이브러리의 매핑을 보고 심볼을 해석한다.

1
2
3
4
vm:ffi:native-assets
@pragma('vm:ffi:native-assets', {
  'package:foo/foo.dart': ['absolute', 'C:\...\Temp\dart_native_assets_XXXX\lib\foo.dll']
})

즉 DLL의 절대경로가 Kernel 안에 상수로 들어간다. 이 동작이 이슈가 됐다.

이슈 해결 과정

처음 경합이 있던 건 DLL 파일이었다. 경로가 .dart_tool/lib/ 아래로 고정이라 모든 dart run이 그 파일 하나를 지우고 다시 복사했다. 그래서 남이 로드 중인 DLL을 지우다 실패하거나 반쯤 복사된 DLL을 여는 문제가 났다.

1차 수정은 DLL을 실행마다 다른 temp 디렉터리에 두고 그 실행이 끝날 때 지우는 것이었다. 이걸로 DLL은 독립됐지만 그 DLL 경로를 담는 스냅샷이 또 경합하고 있었다. pub이 캐시하는 스냅샷이다. 캐시 경로는 진입점 스크립트당 하나라서 모든 dart run이 같은 위치에 스냅샷을 덮어쓰고 읽었다.

1
2
3
4
5
6
7
8
dart run A → Kernel_A (temp_A 경로 포함) → .dart_tool/pub/bin/foo/foo.dart-<sdk>.snapshot
dart run B → Kernel_B (temp_B 경로 포함) → .dart_tool/pub/bin/foo/foo.dart-<sdk>.snapshot  (같은 파일)

1. A가 Kernel_A를 파일에 쓴다
2. B가 Kernel_B로 덮어쓴다
3. A가 파일을 실행한다 → Kernel_B가 실행됨, DLL은 자기 것이 아닌 temp_B에서 찾음
4. B가 끝나면서 temp_B를 지운다
5. A가 temp_B의 DLL을 열려다 실패한다

스냅샷에서 실행마다 달라지는 건 DLL 경로가 적힌 매핑뿐이고 코드는 같다. dill 포맷은 컴포넌트 여러 개를 이어붙이는 것을 허용한다. VM은 파일을 읽다가 컴포넌트가 끝나면 다음 컴포넌트를 계속 읽는다. 그러니 매핑만 따로 떼어 내 뒤에 붙이면 된다.

1
2
3
4
5
6
7
source (이전)
  소스 + native_assets.yaml → 한 번에 컴파일 → dill(코드+매핑)

concatenation (개선)
  소스 → 코드 dill                 실행마다 같음, 캐시 가능
  native_assets.yaml → 매핑 dill   작음, 실행마다 새로
  두 파일 바이트 이어붙임           → VM이 둘 다 읽음

원리는 두 파일을 readAsBytes 해서 [...a, ...b]로 쓰는 것과 같다. dartdev의 실제 구현은 이렇다.

1
2
3
4
5
1. pub이 코드만 담긴 Kernel 스냅샷을 .dart_tool/pub/bin/…에 캐시
2. frontend_server --native-assets-only --native-assets=<yaml>
       --output-dill=<temp>/native_assets.dill      매핑만 담긴 dill
3. <temp>/concatenated.dill ← 1의 스트림 + 2의 스트림   (addStream)
4. VM이 concatenated.dill 실행, 종료 시 temp 디렉터리째 삭제
1
2
3
4
<temp>/
├─ lib/foo.dll           훅 결과. dartdev가 복사
├─ native_assets.dill    매핑만. frontend_server --native-assets-only 출력
└─ concatenated.dill     pub 스냅샷 + native_assets.dill. VM이 실행하는 파일

concatenation은 2026-09-09에 main에 머지됐다(CL 542880).

Dart 3.14.0-248.0.dev로 재현 레포(litert_crypto, repro/dll-race 브랜치)에서 검증했다. 이 레포는 Flutter asset transformer가 asset 11개를 변환하는 구조라, asset마다 dart run litert_crypto가 하나씩 떠서 dart run 11개가 동시에 돈다. 11개 모두 정상 종료했다.

이 기사는 저작권자의 CC BY-NC 4.0 라이센스를 따릅니다.