Rust 바이너리에서 main 이전에 일어나는 일과, 링커 섹션 및 생성자를 활용해 등록, 의존성 주입, 가변 데이터 초기화를 구현하는 방법을 깊이 있게 살펴본다.
공개 사항
🧠 이 글은 100% 사람이 작성했습니다. Claude는 피드백과 링커 심볼 다이어그램 보조에 사용되었고, Cursor는 피드백과 예제가 컴파일 가능함을 확인하는 데 사용되었습니다.
이 글의 작성자는 main 이전의 삶이라는 주제에 깊은 관심이 있습니다. 그는 ctor 크레이트의 작성자이며, 아래 예제에서 사용할 linktime 프로젝트의 제작자이기도 합니다.
모든 Rust 바이너리에는 공통점이 하나 있습니다: fn main(). C 세계에서 왔다면 int main(argc, argv)가 더 익숙할 수도 있습니다. 일부 플랫폼은 이것을 조금 더 감춰 놓기도 하지만, 내부적으로 모든 바이너리에는 진입점이 있습니다.
우리는 main _이전_에 무슨 일이 일어나는지, 그리고 այնտեղ에서 어떤 흥미로운 일을 할 수 있는지 이야기해 보겠습니다. 추가로, 오늘날 Rust 생태계에서 아직 널리 쓰이지 않는 가변 데이터에 대한 새로운 기법들 도 보여드릴 것입니다.
이 글은 Rust 소스가 Rust 바이너리가 되는 방식의 기술적 세부 사항을 깊이 파고듭니다. 독자에게 다음과 같은 배경지식이 있으면 도움이 될 수 있습니다.
대부분의 개발자에게 익숙하지 않을 수 있는 부분은 main 함수에 어떻게 도달하느냐입니다. 모든 언어의 내부에는 런타임이 있습니다. C에도 있습니다. 아마 libc로 알고 있을 C 런타임 말입니다. Rust 역시 자체 런타임을 갖고 있습니다. Rust 표준 라이브러리입니다. 그리고 대부분의 실행 코드 런타임에서 C가 공용어이기 때문에 1, Rust는 C의 런타임 위에 자기 런타임을 구축하여, 사실상 C를 감싸는 더 높은 수준의 추상화를 세웁니다.
런타임은 정의가 약간 모호합니다. 디스크에 존재하는 실행 코드이기도 하고, 컴파일 시점에 사용되는 컴파일 가능한 헤더와 라이브러리이기도 합니다. 하지만 런타임의 목적은 언제나 같습니다. 개발자가 작성한 코드를 플랫폼의 운영체제와 통합하는 것입니다.
당신이 main으로 선언한 함수가 시작되기 전에 일어나는 처리 생태계 전체가 있습니다. C는 이 시간을 이용해 메모리 할당, 파일 접근, 스레드 로컬 저장소와 기타 C 런타임 서비스를 구성합니다. Rust도 이 시간을 이용해 자체 언어와 런타임의 일부를 구성합니다. 구체적으로 Rust에는 패닉과 언와인딩을 처리하는 인프라가 있습니다. Rust는 또 C 스타일 프로그램 인수 2를 자체 std::env::args 인터페이스로 변환해야 합니다. 이 모든 장치는 Rust 컴파일러 프로젝트에서 볼 수 있습니다.
런타임은 이 pre-main 단계를 활용하는데, 그 이유는 이것이 (1) 사용자 코드보다 먼저 실행되고, (2) 단일 스레드이며 매우 일관되고 예측 가능한 순서의 환경을 보장하기 때문입니다. 이 특성 덕분에 신뢰할 수 있고 결정적인 초기화가 가능합니다.
이 환경을 활용하지 않는다면, 매우 유용한 부트스트래핑 단계를 놓치고 있는 셈입니다. 이 글의 뒤쪽에서 우리는 main 이전의 삶을 활용해 유용한 프리미티브를 어떻게 만들 수 있는지 보게 될 것입니다.
바이너리는 운영체제의 로더 3가 제어를 넘기면서 시작됩니다. 로더는 바이너리를 메모리에 적재하고 환경을 설정하는 OS의 일부입니다. 런타임은 로더가 넘긴 제어를 받아들이는 역할을 합니다. 모든 OS에는 이 제어를 받아들이는 플랫폼별 훅이 있습니다. 어느 정도는 이것이 진짜 main입니다. Linux에서는 진입점이 ELF 헤더의 e_entry 필드에 저장되며, 기본적으로 링커는 _start라는 심볼의 주소를 այնտեղ에 배치합니다. 비슷한 훅이 Windows에도 존재하며, _WinMainCRTStartup이라는 함수에서 실행 파일을 시작합니다. 이 시점에서 C 런타임은 스스로를 구성할 기회를 얻고, 모든 런타임은 초기화 함수를 통해 이를 수행합니다.
런타임의 초기 형태에서는 부트스트래핑이 정적인 함수 호출 트리였습니다. 파일 I/O 초기화, 할당자 초기화 같은 식이었습니다. 런타임이 더 복잡해지면서 이 함수 호출 트리도 더 복잡해졌고, 바이너리 크기는 필요할 수도 있고 아닐 수도 있는 더 많은 C 런타임 기능을 흡수하며 커졌습니다.
시간이 흐르면서 링커는 사용되지 않는 코드를 아예 바이너리를 디스크에 쓰기 전에 버릴 수 있게 되었고, 그 안에는 사용되지 않는 C 런타임 부분도 포함되었습니다. 그리고 그와 함께 정적인 초기화 호출 트리를 대체할 수단이 필요해졌습니다.
초기화 코드를 선언하는 가장 대중적인 방법 4은 GCC의 __attribute__((constructor))에서 나왔습니다. 이 방식은 초기화 함수 목록을 디스크의 바이너리 안에 연속된 덩어리로 배치하는 것이었습니다. C 런타임이 시작되면 이 함수들을 하나씩 순회하며 호출할 수 있었고, 그 결과 C 런타임의 여러 부분이 서브시스템을 강하게 결합하지 않고도 초기화를 요청할 수 있게 되었습니다. 또한 링커는 사용되지 않는 서브시스템과 초기화 코드를 함께 제거할 수 있었습니다.
결국 생성자 순서 지정의 필요성이 충분히 중요해져서, 생성자에 우선순위를 부여하고 특정 순서로 실행할 수 있게 되었습니다. 덕분에 런타임은 서브시스템들을 서로 앞뒤 관계에 맞게 초기화할 수 있었습니다. 예를 들어 메모리 할당(malloc) 서브시스템은 버퍼링된 파일 I/O에 필요할 수 있습니다.
대부분의 플랫폼에서는 5 우선순위 처리를 위해 링커가 동원되었습니다. 각 플랫폼은 데이터가 섹션에 기록되는 순서를 우선순위화하는 방식을 갖게 되었고, 그 결과 C 런타임은 잘 정렬된 함수 포인터 목록 6을 얻을 수 있었습니다.
우리는 Rust에서 #[unsafe(link_section = "...")] 속성을 사용해 직접 이 예제를 손으로 만들 수도 있습니다(Rust Playground에서 시도해 보세요).
/// Linux example: the modern glibc runtime uses `.init_array` to hold function
/// pointers, and a numeric suffix allows them to be ordered. Note that priorities
/// less than or equal to 100 are reserved for the runtime itself, so any code that
/// wants to use the C runtime must use a priority of 101 or higher.
// On Linux, `.init_array` holds _function pointers_, not functions.
// We can convert a function to a function pointer with one of the below
// blocks which is equivalent to this:
//
// #[used] // <-- without this, Rust might decide the init function is unused and remove it
// #[unsafe(link_section = ".init_array.NNNNN")] // <-- the section where we place the function pointer
// static INIT_ARRAY_FN_PTR: extern "C" fn()
// = function; // <-- the function pointer data: we assign the function to it
//
// extern "C" fn function() { ... } // <-- the function itself
#[used]
#[unsafe(link_section = ".init_array.101")]
static INIT_FN_FIRST: extern "C" fn() = const {
extern "C" fn init() {
println!("Initializing (first!)");
}
init
};
#[used]
#[unsafe(link_section = ".init_array.201")]
static INIT_FN_SECOND: extern "C" fn() = const {
extern "C" fn init() {
println!("Initializing (second!)");
}
init
};
fn main() {
println!("Main!")
}
이 글의 예제는 Linux와 여러 BSD에서 동작하지만, 크로스 플랫폼 예제로 설계된 것은 아닙니다. 예를 들어 macOS에는 start와 stop 심볼이 있지만 이름이 다릅니다 7. Windows는 start와 stop 심볼을 지원하지 않지만, 섹션을 정렬하는 규칙 집합이 있어서 사실상 같은 효과를 냅니다.
플랫폼별 차이가 매우 크기 때문에, 우리는 ctor와 link-section 크레이트(linktime 프로젝트의 일부)를 도입하여 플랫폼별 차이를 추상화하고 링커 작업의 전반적인 복잡성을 숨기겠습니다.
훌륭한 inventory와 linkme 역시 같은 원리 위에 만들어진 매우 인기 있는 크레이트 두 개이지만, 이 글의 예제에는 덜 적합하게 만드는 제한점 8이 있습니다.
더 알고 싶다면 link-section 크레이트에는 플랫폼별 동작에 대한 자세한 보고서가 들어 있습니다.
ctor 크레이트는 생성자를 크로스 플랫폼 방식으로 등록하는 데 필요한 모든 상용구를 처리하도록 설계되었습니다. 이를 이용하면 위의 예제를 다음처럼 단순화할 수 있습니다.
use ctor::ctor;
#[ctor(unsafe, priority = 101)]
fn init1() {
println!("Initializing (first)!");
}
#[ctor(unsafe, priority = 201)]
fn init2() {
println!("Initializing (second)!");
}
fn main() {
println!("Main!")
}
어느 예제에서도 초기화 함수를 명시적으로 호출하지 않았다는 점에 주목하세요. 링커가 그것들을 정리해 두었고, C 런타임이 우리 대신 호출했습니다.
생성자가 링크되는 과정은 신비한 것이 아닙니다. 사실 컴파일러는 바이너리 안에서 데이터나 코드의 위치를 직접 이름 붙일 수 있게 해 줍니다. 대부분의 플랫폼에서는 이 위치를 “섹션”이라고 부릅니다. 그리고 당연히, 위에서 보았듯이 Rust 역시 이를 허용합니다. 우리가 보게 될 문제는 이런 조직화 기능을 실제로 활용하는 것입니다.
링커는 오래전부터 C가 어떤 형태의 바이너리든 목표로 삼을 수 있게 해 준 핵심 요소였습니다. 대부분의 링커는 개발자가 링커 스크립트를 제공할 수 있게 합니다. 이것은 소스 코드 옆에 존재하는 텍스트 파일이며, 소스는 오브젝트 파일로 컴파일된 뒤 링커 스크립트의 지시에 따라 조립됩니다. 링커 스크립트를 사용하면 하나의 C 파일이 Linux 실행 파일이 될 수도 있고, 하드 드라이브의 부트 섹터에 들어가는 원시 어셈블리 블록이 될 수도 있습니다.
링커 스크립트는 또한 가상 심볼을 정의할 수 있게 합니다. 즉, 어떤 소스 파일에도 존재하지 않지만 C 코드가 적재된 바이너리의 실제 데이터에 대한 포인터에 접근하는 데 사용할 수 있는 심볼입니다.
링커 스크립트는 복잡한 주제이므로 이 글의 범위를 벗어나지만, 실제 세계의 예제를 쉽게 찾아볼 수 있습니다.
// Adapted from https://wiki.osdev.org/Linker_Scripts
SECTIONS
{
.text.start (_KERNEL_BASE_) : {
startup.o( .text )
}
.text : ALIGN(CONSTANT(MAXPAGESIZE)) {
_TEXT_START_ = .;
*(.text)
_TEXT_END_ = .;
}
.data : ALIGN(CONSTANT(MAXPAGESIZE)) {
_DATA_START_ = .;
*(.data)
_DATA_END_ = .;
}
}
위 예제에서는 가상 심볼 _TEXT_START_와 _TEXT_END_가 각각 .text 섹션의 시작과 끝을 가리키도록 명시적으로 정의됩니다. _TEXT_START_ = .;에서 마침표는 특별한 문법으로, 대략 바이너리의 현재 출력 주소를 가리키는 위치 카운터를 뜻합니다.
이것은 처음 접하는 대부분의 개발자를 헷갈리게 하지만, 링커는 시작과 끝 심볼의 주소를 설정하는 것 이지, 같은 이름의 static이 배치된 위치를 정하는 것이지, 포인터인 심볼의 값을 설정하는 것 이 아닙니다. 다시 말해 시작과 정지 심볼은 *const Type가 아닙니다. 시작과 정지 심볼 자체는 아무 데이터도 갖고 있지 않고, 오직 그 주소만 사용됩니다. 섹션은 시작 심볼(포함)과 정지 심볼(제외) 사이 의 데이터 범위입니다.
| Section | Static | Value | Linker symbol(s) | |
|---|---|---|---|---|
my_numbers | _DATA_1 | 11 | ⎫ ⎬ ⎭ | _DATA_1, _start_my_numbers |
_DATA_2 | 22 | _DATA_2 | ||
_DATA_3 | 33 | _DATA_3 | ||
_DATA_4 | 44 | _DATA_4 | ||
(past the end) | ↤ | _stop_my_numbers |
모든 섹션에 대해 시작과 끝 심볼을 지정하는 것은 링커 스크립트에서 복잡하고 지루할 수 있으므로, 많은 링커는 9 결국 실행 파일의 모든 섹션 경계를 자동으로 정의하는 기능을 갖게 되었습니다. 예를 들어 GNU 툴체인에서는 MY_SECTION이라는 이름의 섹션에 대해 __start_MY_SECTION과 __stop_MY_SECTION 심볼이 자동으로 정의됩니다. macOS도 비슷한 패턴을 갖고 있어서 각 섹션마다 section$start와 section$end 심볼을 합성합니다.
GNU 링커에서는 링커 스크립트에 명시적으로 정의되지 않은 섹션을 “고아 섹션”이라고 부릅니다 10. 여기서 중요한 점 하나는, 섹션 이름이 C 심볼 이름과 호환될 때에만, 그리고 그럴 때에만 링커가 자동으로 해당 섹션에 _start 및 _stop 접두 심볼을 정의한다는 것입니다. 아래 예제에서 사용한 our_strings라는 섹션 이름은 동작하지만, our.strings나 .our_strings를 골랐다면 동작하지 않았을 것입니다.
아래 예제에서 시작과 정지 심볼이 MaybeUninit<()>인 이유를 보게 될 것입니다. 경계 심볼은 데이터를 담고 있지 않고, 그 주소만이 중요합니다.
이들을 위한 이상적인 Rust 타입은 “불투명 외부 타입”일 것입니다. 이것은 extern_types 기능으로 구현될 예정입니다. 현재 Stable Rust에서는 이것이 구현되어 있지 않으므로 MaybeUninit를 대신 사용합니다. 이것은 컴파일러에게 해당 데이터가 초기화되지 않았으며, 일반적으로 참조를 통해 읽기에 안전하지 않다는 뜻을 전달합니다. 하지만 &raw const 포인터를 static 항목에 취하는 것은 언제나 유효하므로, 값을 읽지 않고도 그 주소를 안전하게 취득할 수 있습니다.
use std::mem::MaybeUninit;
#[used]
#[unsafe(link_section = "our_strings")]
static FIRST_STRING: &'static str = "Hello, ";
#[used]
#[unsafe(link_section = "our_strings")]
static SECOND_STRING: &'static str = "world!";
// Note: these are not pointers. Instead, the linker has placed
// the boundary symbols STATIC_STRING_START and STATIC_STRING_END at
// the start and end of the section!
unsafe extern "C" {
#[link_name = "__start_our_strings"]
static STATIC_STRING_START: MaybeUninit<()>;
#[link_name = "__stop_our_strings"]
static STATIC_STRING_END: MaybeUninit<()>;
}
fn main() {
let strings: &'static [&'static str] = unsafe {
// SAFETY: get the addresses of the start and end symbols without
// reading them.
let start = &raw const STATIC_STRING_START as *const &'static str;
let end = &raw const STATIC_STRING_END as *const &'static str;
std::slice::from_raw_parts(start, end.offset_from(start) as usize)
};
// "Hello, world!"
println!("String: {}", strings.join(""));
}
link-section 크레이트는 이런 링커 섹션의 세부 사항을 추상화하고, 이를 표준 슬라이스 연산을 모두 사용할 수 있는 전통적인 Rust 슬라이스로 변환하도록 설계되었습니다. 이를 사용하면 위 예제를 다음처럼 단순화할 수 있습니다.
use link_section::{in_section, section};
#[section(typed)]
static OUR_STRINGS: link_section::TypedSection<&'static str>;
#[in_section(OUR_STRINGS)]
static FIRST_STRING: &'static str = "Hello, ";
#[in_section(OUR_STRINGS)]
static SECOND_STRING: &'static str = "world!";
fn main() {
println!("String: {}", OUR_STRINGS.join(""));
}
이 예제들에서는 하나의 크레이트 안의 하나의 모듈에서 링크 섹션에 항목을 제출하고 있지만, 그것은 요구사항이 아닙니다. 사실 링크 섹션의 힘은 바이너리에 코드를 제공하는 어떤 크레이트에서든 링크 섹션에 항목을 제출할 수 있다는 데 있습니다. 링커는 최종 바이너리를 쓰기 직전에 그것들을 모두 모아 줍니다.
이제 우리가 만들려는 등록 패턴은 다른 이름의 의존성 주입입니다. 이것은 잘 알려진 패턴입니다. Dagger나 Spring 같은 프레임워크는 등록 데이터의 소비자 가 그 데이터의 제공자 와 결합되지 않아야 한다는 같은 원리 위에 세워져 있습니다. 제공자 는 정의된 위치에서 데이터를 등록하고, 소비자 는 단순히 레지스트리를 읽습니다.
링커 섹션과 전통적인 DI 사이에서 다소 다른 점은, DI에서는 프레임워크가 시작 시점에 모듈 그래프를 순회하거나 적재된 클래스를 스캔해서 제공자와 소비자 위치를 모두 발견해야 하는 경우가 많다는 것입니다. 링크 섹션에서는 이 마법이 바이너리를 쓸 때 처리됩니다. 제공자 데이터를 모두 모아서 소비자가 아주 쉽게 사용할 수 있게 만드는 것은 링커입니다.
아래 예제는 link_section::section을 사용해 CLI 하위 명령을 등록하며, 이 패턴의 한 사례입니다. Turbopack 같은 더 복잡한 프로젝트는 문자열 풀 상수를 등록하기 위해 이 패턴을 사용하고, 직렬화/역직렬화 및 turbotask 증분 컴파일 함수에 쓰이는 등록 장치에도 같은 방식이 사용됩니다. 가상의 웹서버는 이 패턴을 이용해 빌드 시점에 자동 수집되는 라우트와 미들웨어를 등록할 수 있을 것입니다. 핵심 메커니즘은 같습니다. 기여자들은 의존성 트리의 어떤 크레이트에서든 공용 등록 시스템에 데이터를 넣고, 소비자는 그것이 어디에서 제공되었는지 몰라도 수집된 데이터를 읽습니다.
main 이전에 작업한다는 점의 한 가지 장점은 환경이 잘 통제된다는 것입니다. 우리가 직접 시작하지 않는 한 어떤 스레드도 실행되지 않습니다. 이는 많은 경우 락과 다른 동기화 프리미티브의 복잡성을 피할 수 있다는 뜻이며, 데이터 수명주기의 쓰기 가능 단계와 불변 단계를 main 이전과 이후로 명확하게 나눌 수 있다는 뜻이기도 합니다. 그리고 그 덕분에 실행 중인 프로그램에서의 데이터 접근은 락을 획득하고 해제할 필요를 피하면서 더 단순하고 더 효율적이 될 수 있습니다.
먼저 하위 명령, const 생성 함수, 그리고 그것들을 수집할 #[section]을 정의하겠습니다.
use std::collections::VecDeque;
use std::path::Path;
use link_section::{in_section, section};
struct CliSubcommand {
is_default: bool,
name: &'static str,
description: &'static str,
f: fn(&Path, &[String]),
}
impl CliSubcommand {
const fn new(name: &'static str,
description: &'static str,
f: fn(&Path, &[String])) -> Self {
Self { is_default: false, name, description, f }
}
const fn new_default(name: &'static str,
description: &'static str,
f: fn(&Path, &[String])) -> Self {
Self { is_default: true, name, description, f }
}
}
#[section(typed)]
static CLI_SUBCOMMANDS: link_section::TypedSection<CliSubcommand>;
이제 하위 명령을 등록하겠습니다. 이것들은 코드의 어디에든 있을 수 있습니다.
mod list {
#[in_section(CLI_SUBCOMMANDS)]
static CLI_SUBCOMMAND_LIST: CliSubcommand =
CliSubcommand::new("list", "List all items", |_exe, _args| {
println!("Listing all items");
});
}
mod add {
#[in_section(CLI_SUBCOMMANDS)]
static CLI_SUBCOMMAND_ADD: CliSubcommand =
CliSubcommand::new("add", "Add a new item", |_exe, _args| {
println!("Adding a new item");
});
}
mod help {
#[in_section(CLI_SUBCOMMANDS)]
static CLI_SUBCOMMAND_HELP: CliSubcommand =
CliSubcommand::new_default("help", "Show help", |exe, _args| {
println!("Usage: {} <subcommand> [options]", exe.display());
println!();
println!("Subcommands:");
for subcommand in CLI_SUBCOMMANDS {
println!(" {}: {}", subcommand.name, subcommand.description);
}
});
}
그런 다음 main 함수에서는 등록된 하위 명령이 무엇인지, 어디에 있는지 전혀 몰라도 동적으로 디스패치할 수 있습니다. CLI_SUBCOMMANDS 섹션 정의만 볼 수 있으면 됩니다.
fn main() {
let mut args: VecDeque<String> = std::env::args().collect();
let exe = args.pop_front().expect("No executable name provided");
let exe = Path::new(&exe);
let subcommand_name = args.pop_front().unwrap_or_default();
let rest: Vec<String> = args.into();
// Try to find the subcommand by name
for cmd in CLI_SUBCOMMANDS {
if cmd.name == subcommand_name {
(cmd.f)(exe, &rest);
return;
}
}
// If no subcommand was found, fall back to the default subcommand
for cmd in CLI_SUBCOMMANDS {
if cmd.is_default {
(cmd.f)(exe, &rest);
return;
}
}
}
위 코드를 실행하면 예상대로 동작합니다.
$ ./cli
Usage: ./cli <subcommand> [options]
Subcommands:
list: List all items
add: Add a new item
help: Show help
$ ./cli list
Listing all items
이 절에서는 조금 더 고급 주제를 다룹니다. Rust Atomics and Locks에 익숙하거나, 적어도 Rust 동시성 기초에 관한 첫 장 정도는 읽어 보셨다면 도움이 됩니다.
위 예제는 링크된 데이터가 불변이라고 가정합니다. 하지만 링크 기반 데이터 조직의 힘은 그 절반에 불과합니다. 전역 정적 데이터의 가변성은 표준 Rust에서 잘 알려진 해법들이 있는 흔한 문제입니다. 예를 들어 Rust의 내부 가변성 도구인 뮤텍스나 원자적 타입을 사용할 수 있습니다. 하지만 각각은 어느 정도 런타임 비용을 동반합니다. 경합이 없다면 비싸지는 않지만, 반드시 공짜인 것은 아닙니다 11.
그렇다면 런타임 데이터 접근 비용을 최소화하고 싶다면 어떨까요? 불변 데이터는 간단합니다. Rust는 기본적으로 불변 데이터에 대한 안전한 동시 접근을 허용합니다 12. 하지만 가변 데이터에 대해서 Rust는 엄격한 요구사항을 둡니다. 데이터를 안전하게 변경하려면 두 가지가 필요합니다. (1) 변경은 스레드 안전한 방식으로 이루어져야 하고, (2) 가변 참조가 존재하는 동안 그 데이터에 대한 참조가 둘 이상 존재해서는 안 됩니다.
이 글의 처음에서 main 이전의 삶은 스레드가 실행되지 않기 때문에 부트스트랩하기 좋은 장소라고 말했습니다. 그리고 데이터가 현재 단 하나의 스레드에서만 접근 가능하다면, (1)의 해결책은 아주 단순합니다. 우리는 원자적으로 아무것도 할 필요가 없습니다. 그 데이터에 대한 모든 변경이 그 데이터에 대한 모든 읽기보다 “먼저 일어나기만” 하면 됩니다. 단일 스레드 환경에서는 “먼저 일어남”이 자동입니다 13. 이것은 main 이전에 링크 섹션의 데이터를 변경하고, main 이후에는 어떤 스레드에서든 락 없이 안전하게 접근할 수 있음을 의미합니다.
(2)에 대한 해결도 비슷합니다. main 이전에만 가변 참조를 취하고, 그리고 오직 가변 참조만 취한다면, 가변 참조가 존재할 때 데이터에 대한 참조가 둘 이상 존재하는 일은 결코 없습니다.
pre-main 환경은 락이나 다른 동기화 프리미티브를 쓰지 않고도 (1)과 (2)를 모두 만족합니다.
또 하나 아주 조심해야 할 링커 섹션 관련 함정이 있습니다. 섹션의 모든 항목을 담은 슬라이스는 그 섹션 안에 사는 정적 항목에 대한 별칭 입니다. 별칭 규칙은 슬라이스와 정적 항목 모두에 적용되며, 슬라이스를 통해 안전하게 변경하려면 정적 항목이 UnsafeCell 안에 배치되도록 반드시 해야 합니다 14. Rust는 다른 방식으로 정적 항목을 수정하는 것을 허용하지 않습니다. UnsafeCell로 감싸지지 않은 정적 항목에 대해서는 LLVM이 그 데이터를 캐시하거나 재정렬하거나, 혹은 다른 가정을 해도 된다고 판단할 수 있습니다. UnsafeCell 자체는 Sync가 아니므로, 그 위에 자신만의 래퍼 타입을 추가해야 합니다.
아래 예제에서는 이제 경계 심볼에 MaybeUninit<SyncUnsafeCell<...>>를, 항목들에는 SyncUnsafeCell<...>를 사용하고 있다는 점에 주목하세요.
우리가 슬라이스를 정렬할 계획이므로, 데이터가 읽기 전용 메모리에 들어가지 않게 하려면 Rust에게 슬라이스 항목이 불변이 아니라고 알려야 합니다. UnsafeCell을 포함하는 타입을 사용하면, 즉 Rust가 내부 가변성을 나타내는 의미적 신호로 사용하는 타입을 쓰면, Rust 컴파일러는 그 데이터를 쓰기 가능한 바이너리 영역에 배치해야 함을 알게 됩니다.
일부 플랫폼에서는 특히 Windows에서, 데이터 항목에 이것을 생략하면 슬라이스를 정렬하려 할 때 세그멘테이션 오류가 발생합니다. 다른 플랫폼에서는 예를 들어 AIX에서, 섹션의 가변성이 섹션 식별자의 일부이므로 경계 심볼의 가변성도 섹션의 가변성과 일치해야 합니다.
이제 이런 작업을 다른 방식으로 한다면 어떨지 예제를 따라가 보겠습니다. 우리는 전적으로 링크 시점에 정의된 문자열 인터닝 풀을 만들 것이고, 여기에 한 가지 조건을 추가하겠습니다. 필요할 경우 값 기준 이진 검색으로 문자열을 빠르게 인터닝할 수 있도록 런타임에 인터닝된 문자열 슬라이스를 정렬할 수 있어야 합니다(Rust Playground에서 시도해 보세요).
use std::cell::UnsafeCell;
use std::mem::MaybeUninit;
#[cfg(debug_assertions)]
use std::sync::atomic::{AtomicBool, Ordering};
/// Nightly Rust offers a built-in `SyncUnsafeCell`. This is a minimal
/// reimplementation of that:
/// <https://doc.rust-lang.org/std/cell/struct.SyncUnsafeCell.html>
#[repr(transparent)]
struct SyncUnsafeCell<T: ?Sized>(UnsafeCell<T>);
// SAFETY: safety burden of UnsafeCell is placed entirely on the user
unsafe impl<T: ?Sized + Sync> Sync for SyncUnsafeCell<T> {}
macro_rules! intern_string {
($name:ident, $string:literal) => {
#[allow(unused)]
const $name: &'static str = const {
// This is not a common pattern, but it's entirely valid
// to nest static items inside of const blocks.
// You can think of this as a way to hide the symbols
// in a completely anonymous namespace.
const VALUE: &str = $string;
// Safety note: this static must _never_ be used. This is
// purely a submission to the linker and _any_ access to it
// may be UB.
#[used]
#[unsafe(link_section = "our_strings")]
static ITEM: SyncUnsafeCell<&'static str> =
SyncUnsafeCell(UnsafeCell::new(VALUE));
VALUE
};
};
}
intern_string!(WORLD, "world");
intern_string!(EXCLAMATION, "!");
intern_string!(HELLO, "hello");
intern_string!(FROM, "from");
intern_string!(RUST, "Rust");
unsafe extern "C" {
#[link_name = "__start_our_strings"]
static STATIC_STRING_START: MaybeUninit<SyncUnsafeCell<()>>;
#[link_name = "__stop_our_strings"]
static STATIC_STRING_END: MaybeUninit<SyncUnsafeCell<()>>;
}
/// Debug check to make sure the slice is sorted once and only once. This _could_
/// be enabled in release mode without any major performance impact, but we have enough
/// guarantees in place. Note that atomic access _does_ establish some memory ordering
/// guarantees, but the soundness guarantees are upheld with or without this atomic check.
#[cfg(debug_assertions)]
static SLICE_IS_SORTED: AtomicBool = AtomicBool::new(false);
// Implementation note: this function must not be called before `SORT_STRINGS_CTOR` has
// run.
fn interned_strings() -> &'static [&'static str] {
// We use Acquire/Release pairing as a double-initialization check
#[cfg(debug_assertions)]
debug_assert!(SLICE_IS_SORTED.load(Ordering::Acquire), "Oh no! Slice was not sorted!");
// SAFETY: we are calling this after main and we can guarantee that no
// mutable reference is still alive. Since we know that no other code
// is running before main, and that `SORT_STRINGS_CTOR` will run before main,
// we can guarantee creating these slices is safe as 1) the sort "happens-before"
// any access and 2) the mutable reference has been closed before any read-reference
// access (satisfying aliasing XOR mutability requirement).
let strings: &'static [&'static str] = unsafe {
let start = &raw const STATIC_STRING_START as *const &'static str;
let end = &raw const STATIC_STRING_END as *const &'static str;
std::slice::from_raw_parts(start, end.offset_from(start) as usize)
};
strings
}
// Implementation note: this function assumes the slice has been sorted. See
// the guarantee above on `interned_strings` for reasoning.
fn maybe_intern_string(s: impl AsRef<str>) -> Option<&'static str> {
let s = s.as_ref();
let strings = interned_strings();
strings.binary_search(&s).ok().map(|index| strings[index])
}
// SAFETY: We use the reserved `.init_array.0` priority because we do not
// access any C runtime functions (sort_unstable does not allocate) and we
// want to run before all other code. `.init_array.101` would work in our
// case, but this prevents other early-init code from accidentally running
// in the wrong order. `SLICE_IS_SORTED` is a debug check to make sure that
// doesn't happen. Note that all early-init code is tagged with `unsafe` so
// it always needs to be aware of safety guarantees of all APIs it touches.
#[used]
#[unsafe(link_section = ".init_array.0")]
static SORT_STRINGS_CTOR: extern "C" fn() = const {
extern "C" fn sort_strings() {
// We use Acquire/Release pairing as a double-initialization check
#[cfg(debug_assertions)]
debug_assert!(!SLICE_IS_SORTED.load(Ordering::Acquire), "Oh no! Sorted twice?!?");
// SAFETY: we are calling this before main and we can guarantee that no
// reference from `interned_strings` exists yet because we know no other
// threads will be running, and we're not calling `interned_strings` yet.
let strings: &mut [&'static str] = unsafe {
// SAFETY: the bounds markers are not mutable, but we can safely
// cast them to mutable pointers because we know the data behind
// them is stored within `UnsafeCell` which is Rust's way of
// giving us interior mutability.
let start = &raw const STATIC_STRING_START as *mut &'static str;
let end = &raw const STATIC_STRING_END as *mut &'static str;
std::slice::from_raw_parts_mut(start, end.offset_from(start) as usize)
};
strings.sort_unstable();
#[cfg(debug_assertions)]
SLICE_IS_SORTED.store(true, Ordering::Release);
}
sort_strings
};
fn main() {
for (i, s) in interned_strings().iter().enumerate() {
println!("[{i}]: {s}");
}
println!("{}, {}{}", HELLO, WORLD, EXCLAMATION);
println!(
"{}, {}{}",
maybe_intern_string("hello").unwrap(),
maybe_intern_string("world").unwrap(),
maybe_intern_string("!").unwrap()
);
}
위 예제는 꽤 묵직합니다. 친절한 주석까지 더해져 더 두꺼워졌기도 합니다. 하지만 ctor와 link-section 같은 크레이트가 얼마나 많은 상용구를 줄여 주는지 보여 주는 좋은 예입니다.
이 크레이트들을 사용한 동등한 구현은 TypedMutableSection과 ctor를 이용해 main 이전에 항목이 정렬되도록 만들 수 있습니다. TypedMutableSection의 요구사항은 항목이 const여야 한다는 점에 주목하세요. 이유는 가변 섹션이 위 수동 구현 예제와 비슷한 스타일의 코드를 사용하기 때문입니다.
//! String interning pool using `ctor` and `link-section`.
use ctor::ctor;
use link_section::{in_section, section};
#[section(mutable)]
static INTERNED_STRINGS: link_section::TypedMutableSection<&'static str>;
#[in_section(INTERNED_STRINGS)]
const WORLD: &'static str = "world";
#[in_section(INTERNED_STRINGS)]
const EXCLAMATION: &'static str = "!";
#[in_section(INTERNED_STRINGS)]
const HELLO: &'static str = "hello";
#[in_section(INTERNED_STRINGS)]
const FROM: &'static str = "from";
#[in_section(INTERNED_STRINGS)]
const RUST: &'static str = "Rust";
#[ctor(unsafe)]
fn sort_strings() {
let strings: &mut [&'static str] = unsafe { INTERNED_STRINGS.as_mut_slice() };
strings.sort_unstable();
}
fn maybe_intern_string(s: impl AsRef<str>) -> Option<&'static str> {
let s = s.as_ref();
let strings = INTERNED_STRINGS.as_slice();
strings.binary_search(&s).ok().map(|index| strings[index])
}
fn main() {
for (i, s) in INTERNED_STRINGS.iter().enumerate() {
println!("[{i}]: {s}");
}
println!("{}, {}{}", HELLO, WORLD, EXCLAMATION);
println!(
"{}, {}{}",
maybe_intern_string("hello").unwrap(),
maybe_intern_string("world").unwrap(),
maybe_intern_string("!").unwrap()
);
}
물론 이 특정 예제가 링크 섹션 없이는 불가능한 것은 아닙니다. 이 글에서 논의한 패턴을 통해 우리가 얻는 것은 세 가지입니다. (1) 태그된 항목을 보장된 방식으로 집계할 수 있고, 모든 데이터가 미리 할당되어 메모리상에 연속적으로 놓인다는 점, (2) 코드의 어디에든 등록을 분산할 수 있다는 점, 그리고 (3) 섹션 안의 항목 수를 확실하게 알 수 있다는 점입니다.
위 세 가지 장점에서 파생되는 큰 이점 하나는, 링크 섹션이 할당을 전혀 필요로 하지 않는다 는 것입니다. 이것을 링크 섹션 없이 다시 작성한다면 HashMap, Vec 또는 다른 자료구조를 할당해야 하고, 항목을 수집하면서 여러 번 크기를 늘릴 수도 있습니다. 런타임이 되기 전까지 실제 항목 수를 모르기 때문입니다.
두 번째 큰 이점은 제어의 역전 입니다. 전통적인 “수집” 방식의 의존성 그래프는 아래와 같습니다. 공용 타입이 의존성 그래프 깊숙이 중첩되어 있고, 여러 모듈이 그 공용 타입 모듈에 의존하며, 다시 수집기 모듈이 모든 모듈에 의존해서 그들의 타입을 수집합니다.
변화가 커 보이지 않을 수는 있지만, 실제 영향은 큽니다. 이제 수집기는 어디에나 있을 수 있으며, 어떤 모듈이 데이터를 제공하는지 신경 쓸 필요가 없어집니다.
그리고 물론 우리는 슬라이스에만 제한되지 않습니다. scattered-collect 크레이트에는 링크 시점 지원을 갖춘 많은 자료구조의 유사체를 찾을 수 있습니다.
Scattered*Slice: 슬라이스를 제공하는 다양한 Vec 유사 구조체들(선택적으로 정렬도 제공).ScatteredMap/ScatteredSet: 최소한의 pre-main 초기화와 함께 해시된 키-값 조회를 제공하는 HashMap/HashSet 유사체.링크 시점 계산은 재미있고 강력하지만, 항상 올바른 도구는 아닙니다. 종종 링크 시점이 아닌 동등한 방법이 있습니다. 데이터를 제공하려는 각 크레이트를 볼 수 있는 크레이트에서 수동으로 데이터를 수집하는 것입니다. 이것은 때로 불편할 수 있습니다. 기여자들이 핵심 크레이트에 있는 하나의 상향식 기여 지점을 보는 대신, 많은 크레이트 참조를 가진 “수집기” 크레이트가 모두를 모아야 하기 때문입니다.
죽은 코드 제거는 어려워집니다. link-section 크레이트와 linkme의 동등 기능은 둘 다 모든 항목에 #[used]를 붙이므로, 링커는 사용되지 않는 데이터를 제거할 수 없습니다. 링크 시점 수집과 죽은 코드 제거를 잘 함께 작동시키는 방법을 찾는 것은 복잡한 문제이며 이 글의 범위를 벗어납니다. 인터닝된 문자열 원자 같은 작은 데이터 조각이라면 문제가 아닐 수 있지만, 프로그램이 원시 JSON/JavaScript 덩어리나 방대한 데이터 구조 같은 더 큰 데이터를 인터닝하려 한다면, 식별하기 어려운 죽은 코드가 많이 쌓일 수 있습니다.
pre-main 생성자 함수는 제한이 있습니다. 패닉할 수 없고 15, Rust는 모든 표준 라이브러리 함수가 사용 가능하다고 보장하지 않으며, 같은 우선순위 레벨 안에서 초기화 함수가 호출되는 순서는 보장되지 않고 플랫폼 의존성이 매우 큽니다. 신중하게 계획하면 이런 제한을 우회할 수 있을지 모르지만, main 이전의 삶은 미묘하고 디버그하기 어려운 이유로 올바르지 않을 수도 있습니다.
현재 시점에서 Miri는 모든 pre-main 생성자와 link-section 구성을 완전히 지원하지 않습니다. Miri는 pre-main 실행에 대해 매우 기초적인 관점만 갖고 있고, 링크 섹션은 전혀 모델링하지 않습니다. 시간이 지나면 나아질 수 있겠지만, 이 글을 쓰는 시점 기준으로는 정의되지 않은 동작을 검사하기 위한 권장 방법은 LLVM sanitizer들(ASan, TSan 등)입니다.
제어의 역전 패턴 역시 비용이 있습니다. 링크 섹션에 데이터를 제공하는 모든 위치를 감사하기가 더 어려워질 수 있습니다.
실제로는 널리 배포되고 많이 사용되는 많은 Rust 프로그램이 이미 pre-main 기능에 의존하고 있습니다. ctor, link-section, inventory, linkme 크레이트는 오늘날 많은 하위 크레이트에서 사용되고 있습니다.
간단히, WASM에 대하여
위 예제들은 꽤 중요한 플랫폼 하나를 생략했는데, 그럴 만한 이유가 있습니다. WASM은 오래전의 불편한 선택 때문에 현재 링크 섹션을 네이티브하게 지원하지 않습니다(51088와 52353에 자세한 내용이 있습니다). #[link_section] 어노테이션이 항목을 진짜 코드 섹션에 배치하도록 허용하는 대신, 항목들은 WASM 커스텀 섹션 에 배치되는데, 이는 WASM 코드 자체에서는 접근할 수 없습니다.
linktime 크레이트들은 WASM을 지원 하며, WASM 바이너리에서도 이런 접근이 동작하도록 만드는 에뮬레이션 우회책을 갖고 있습니다. 하지만 이 글의 작성자는 가까운 미래에 적절한 WASM 지원을 어떻게 추가할 수 있을지 제안해 보고 싶어 합니다.
main 이전에도 많은 일을 할 수 있고, 특정한 경우에는 그 이점이 매우 큽니다. 그것은 매우 잘 정렬되고, 매우 잘 통제되는 환경이며, 락, 원자적 연산, 기타 동기화 프리미티브 없이도 많은 작업을 더 자신 있게 수행할 수 있게 해 줍니다. 링크 섹션은 어색한 크레이트 의존 순서 없이도 바이너리 전체에 걸쳐 관련 데이터를 임의로 집계하고 함께 배치할 수 있게 해 줍니다. 많은 경우 할당을 완전히 피할 수도 있는데, 이것은 할당자의 최악의 죄 중 하나인 단편화로 이어지는 잦은 할당 재조정을 피하는 데 도움이 됩니다.
더 읽고 싶다면 이 글에서 다룬 여러 크레이트를 확인해 보세요.
ctor: main 이전에 실행되는 모듈 초기화 함수dtor: 이 글에서는 다루지 않았지만 ctor의 종료 시 대응물link-section: 가변성 지원을 포함한, 링커가 관리하는 타입 지정 섹션(슬라이스) 및 비타입 지정 섹션scattered-collect: 링커가 관리하는 더 고수준의 컬렉션: 슬라이스, 정렬된 슬라이스, 맵사랑하는 아내 Mia, Benjamin Woodruff, Luke Sandberg, 그리고 @ssokolow에게 피드백과 리뷰에 대해 감사드립니다. 그들의 도움이 없었다면 이 글은 가능하지 않았을 것입니다.
Go는 주목할 만한 예외입니다. 일부 플랫폼에서는 C 런타임을 피하고, 필요한 플랫폼에서만 libc를 ABI 안정성 경계로 사용합니다. 예를 들어 Apple의 libSystem.dylib와 OpenBSD의 libc가 그렇습니다.↩
Windows에서는 이것들이 DOS 스타일 인수이며, 이는 다시 CP/M 스타일 인수에서 유래했습니다.↩
로더가 실행되기 전에는 프로그램은 그저 디스크 위의 몇 바이트일 뿐이며, 로더는 커널 자체이거나 Linux의 ld.so 같은 사용자 공간 시스템 구성요소일 수 있고, 그 바이트들을 메모리에 매핑한 뒤 제어를 넘깁니다.↩
가장 대중적인 방법이라는 말은… 겸손한 작성자의 의견입니다.↩
macOS는 이것을 지원하지 않습니다. C 런타임이 자체 초기화를 수행한 뒤, 링커가 본 순서대로 모든 사용자 생성자 함수를 실행합니다.↩
AIX는 생성자 함수에 특별한 심볼 명명 규칙을 갖고 있습니다. sinit 접두사 뒤에 16진 우선순위 값이 따라옵니다.↩
이것은 글의 뒤에서 다시 다루겠지만, macOS는 __start_와 __stop_ 심볼 대신 각 섹션마다 section$start와 section$end 심볼을 합성합니다.↩
linkme는 분산 슬라이스를 만들지만 현재 WASM을 지원하지 않고, 섹션 정렬에 필요한 가변 섹션 데이터를 지원하지 않습니다. inventory는 WASM을 지원하지만, 섹션 안의 각 항목마다 ctor와 유사한 함수가 필요합니다.↩
Windows 링커는 이 기능을 지원하지 않지만, 대신 사실상 같은 역할을 하는 심볼 전체 정렬 순서를 정의합니다.↩
고아 섹션은 배치 알고리즘이 복잡합니다.↩
예를 들어 원자적 값은 항상 다시 읽어야 하며, 이것은 오늘날 꽤 많은 CPU 캐시를 사용할 수 있지만, 분명 무한하지는 않습니다.↩
Sync이기만 하다면 가능합니다. Sync는 스레드 사이에서 데이터에 대한 참조를 공유해도 안전하다는 뜻입니다.↩
이것은 복잡한 주제이며, 더 배우기 위한 최고의 자료는 Rust Atomics and Locks입니다. 새 스레드를 시작한다는 것은 모든 이전 쓰기가 새 스레드의 어떤 작업보다도 “먼저 일어난다”는 뜻이지만, 그 증명은 독자에게 맡기겠습니다. 아니면 미래의 글에서 다룰지도 모르겠습니다.↩
가변 정적 변수에 대한 참조를 취하는 것조차 Rust 2024에서는 기본적으로 금지됩니다!↩
좀 더 정확히 말하면 패닉할 수는 있지만 패닉해서는 안 됩니다. 실제로는 이중 패닉이 발생합니다. thread '<unnamed>' panicked at ...: pre-main panic!가 출력되고, 바로 이어서 thread '<unnamed>' panicked at ...: panic in a function that cannot unwind가 출력됩니다.↩