- 커널과 드라이버처럼 성능과 동시성이 중요한 코드에서도 메모리 해제는 단순한
free()호출로 끝나지 않으며, 락 없는 공유 기법이 필요해질 수 있음 - RCU(Read, Copy, Update) 는 자주 읽히고 드물게 바뀌는 데이터를 복사한 뒤 포인터를 원자적으로 교체해, 읽기 경로를 막지 않는 방식임
- 이전 버전을 즉시
delete하면 아직 읽는 스레드에서 use-after-free가 발생할 수 있어, RCU는 읽기 구간을 추적하고 안전한 시점까지 해제를 늦춤 - RCU는 Linux에서 수만 번 쓰이고 Folly C++ 라이브러리와 Rust의
crossbeam-epoch에도 있으며, 사용 여부에 따라 나중에 정리된다는 점에서 GC와 같은 형태를 가짐 - 수동 메모리 관리가 항상 더 빠르고 예측 가능하다는 이분법은 약하며,
free(), 참조 카운팅, OS 메모리 동작도 비용과 불확실성을 갖기 때문에 현대 GC도 시스템 프로그래밍의 도구가 될 수 있음
커널 코드가 RCU 같은 기법을 쓰는 이유
- 운영체제는 매일 실행되는 프로그램 중에서도 성능 민감도가 높은 축에 속함
- OS가 빨라지면 사용자가 더 많은 계산을 할 수 있으므로, 커널과 드라이버 개발자는 코드 최적화에 많은 노력을 들임
- 운영체제는 사용자 공간의 프로세스와 스레드뿐 아니라 커널 자체의 여러 스레드, 하드웨어 인터럽트 핸들러까지 다뤄야 함
- 대기 시간이 늘어나면 사용자의 시간을 빼앗기 때문에, 커널 코드에서는 락 없이 스레드 간 데이터를 공유하는 여러 기법이 등장함
RCU의 기본 동작
- RCU(Read, Copy, Update) 는 매우 자주 읽히지만 드물게 쓰이는 데이터에 맞는 방식임
- 현재 연결된 USB 장치 집합처럼 거의 바뀌지 않지만 바뀔 수는 있는 데이터가 예가 됨
- 변경은 원자적으로 일어나야 하며, 이미 읽고 있는 독자를 막지 않아야 함
- 작성자는 공유 상태를 다음 순서로 갱신함
- 기존 데이터를 포인터에서 읽음
- 기존 데이터를 복사하고 필요한 변경을 적용해 새 버전을 만듦
- 포인터를 원자적으로 갱신해 새 버전을 가리키게 함
- 독자는 공유 포인터를 읽기만 하므로, 읽기 경로는 단순하고 대기 없이 동작함
- 이 방식은 사용하기 쉽고 wait-free이지만, 이전 버전을 정리하지 않으면 메모리 누수가 발생함
즉시 해제할 수 없는 이전 버전
- 포인터를 새 버전으로 바꾼 직후 이전 버전을 바로
delete하면 use-after-free 위험이 생김 - 락 없이 동작하기 때문에 작성자는 아직 이전 버전을 읽고 있는 독자가 있는지 알 수 없음
- 독자는
rcu_read_lock()과rcu_read_unlock()으로 읽기 측 임계 구역을 표시할 수 있음- 독자는 여전히 블록되지 않음
- 작성자는 해당 독자들이 빠져나갈 때까지 이전 데이터를 제거하지 않음
rcu_synchronize()는 모든 독자가 사라질 때까지 기다리는 것이 아니라, 이전 버전을 볼 가능성이 있는 이전 독자가 끝날 때까지만 기다리면 됨- 새 포인터를 본 독자는 새 버전을 사용하므로 이전 버전의 수명과 무관함
지연 해제와 GC의 형태
- 작성자가 갱신 함수 안에서 기다리지 않아도, 이전 데이터가 언젠가 안전하게 해제되면 코드는 올바르게 동작함
rcu_defer(old)같은 방식은 현재 독자가 임계 구역을 벗어난 뒤 아무 때나old를 해제할 수 있게 함- 전용 스레드가 오래된 미참조 버전을 주기적으로 정리하는 형태는 세대별 GC와 닮아 있음
- RCU는 사고 실험이 아니라 실제로 널리 쓰이는 기법임
- Linux는 RCU를 수만 번 사용함
- Facebook의 Folly C++ library에 RCU가 제공됨
- Rust에서는
crossbeam-epoch라는 이름으로 쓰이며, 인기 있는 동시성 라이브러리의 기반이 됨
- RCU가 “진짜 GC”인지 따지는 분류 논쟁보다 중요한 점은, 메모리가 사용 중인지 여부에 따라 나중에 정리되는 구조가 GC와 같다는 데 있음
수동 해제의 숨은 비용
- GC가 수동 메모리 관리보다 본질적으로 덜 효율적이라는 통념은 구현 세부를 보면 약해짐
-
free()는 공짜가 아님- 범용 메모리 할당자는 커널에서 받은 페이지, 크기별 버킷 분할, 사용 중인 버킷 같은 내부 전역 상태를 관리해야 함
- 여러 스레드가 할당자 상태를 잠그려 하면서 경합이 생길 수 있음
- jemalloc처럼 스레드 로컬 풀을 두더라도 이를 동기화하기 위한 추가 코드가 필요함
-
RAII와 lifetime도 할당자 비용을 없애지는 못함
- Rust의 lifetime이나 C++의 RAII는 메모리 해제 자동화와 정확성에는 도움을 주지만, 할당자 내부 구조의 복잡성을 없애지는 않음
- 많은 시나리오에서는
shared_ptr나Arc로 돌아가야 함 - 참조 카운트라는 추가 메타데이터가 필요하고, 이 값이 코어와 캐시 사이를 오가며 비용을 만들 수 있음
- 라이브니스 그래프의 순환을 누수시킬 수도 있음
-
GC가 제공할 수 있는 최적화도 있음
- 이동식 세대별 GC는 힙을 주기적으로 다시 압축함
- 할당은 포인터 증가에 가까워져 높은 처리량을 낼 수 있음
- 순차 할당의 지역성이 좋아져 캐시 성능에도 도움이 됨
메모리 관리 제어에 대한 착각
- GC에 반대하는 많은 개발자는 소프트 실시간 시스템을 만들고 있음
- 비디오 게임의 FPS나 스트리밍 코덱의 압축 성능처럼 최대한 빠른 동작을 원함
- 하지만 가끔 1밀리초가 더 걸린다고 시스템이 깨지거나 사람이 죽는 하드 지연 요구는 아님
- 프로그래머가 메모리 관리가 언제 일어나는지 결정할 수 있다는 믿음은 단순하지 않음
- 운영체제는 하드웨어 상호작용을 추상화함
- Linux는 기본적으로 메모리 요청 시 거의 아무것도 하지 않고, 실제로 사용하려 할 때 메모리를 내줄 수 있음
madvise(), 메모리 매핑 I/O, 파일 시스템 캐시가 섞이면 “무엇이 언제 할당됐는가”에 대한 단순한 답이 없음- 나쁜 날에는 단순한 포인터 접근이 디스크 I/O로 이어질 수도 있음
- 프로그래머가 메모리 관리를 위해 멈추기 좋은 시점을 항상 안다는 믿음도 제한적임
- 비디오 게임 로딩 화면처럼 명확한 경우도 있음
- 많은 소프트웨어에서는 더 중요한 작업으로 바쁘지 않을 때가 유일한 답임
shared_ptr와Arc를 가진 개별 코드는 자신이 마지막 소유자가 되어 정리를 떠맡을지 미리 알 수 없음
free()호출이 곧 메모리를 OS에 돌려준다는 믿음도 항상 맞지 않음- 메모리는 페이지 단위로 OS에서 할당됨
- 할당자는 프로그램 종료 전까지 페이지를 붙잡고 재사용하려는 경우가 많음
- OS는 스왑을 통해 페이지를 회수할 수도 있음
GC를 시스템 프로그래밍 도구로 볼 수 있는 이유
- 모든 소프트웨어가 GC의 혜택을 받는 것은 아님
- 하지만 2024년에 가까운 시점에도 시스템 프로그래머 사이에서 GC 논의는 잘못된 이분법과 공포·불확실성·의심에 묻히기 쉬움
- GC를 쓰는 언어가 수동 메모리 관리 언어보다 “명백히” 느리다는 생각은 사실이 아니라 이데올로기에 가깝다고 봄
- 생명이 걸린 시스템을 만드는 팀에서도, 거의 모든 줄에서 할당하는 GC 언어로 서브 마이크로초 지연을 제공한 사례가 있음
- 시스템의 일부가 반드시
n클록 사이클 안에 실행돼야 한다면, 그 특정 부분만 비-GC 코드나 하드웨어로 분리할 수 있음 - GC는 만능 해결책이 아니지만, 두려워하지 않고 사용할 수 있는 도구 상자 속 도구 중 하나임