Kangrejos 2025에서 소개된 Rust의 다가오는 언어 기능과 Rust for Linux가 그 발전에 끼친 영향, 그리고 커널 개발에 핵심적인 필드 프로젝션, 제자리 초기화, 임의의 self 타입의 현황과 과제를 살펴본다.
LWN.net에 오신 것을 환영합니다
다음 구독자 전용 콘텐츠는 한 LWN 구독자에 의해 공개되었습니다. 수천 명의 구독자들이 리눅스 및 자유 소프트웨어 커뮤니티의 최고의 뉴스를 얻기 위해 LWN에 의존합니다. 이 글을 즐기셨다면 LWN 구독을 고려해 주십시오. 방문해 주셔서 감사합니다!
Rust for Linux 프로젝트는 Rust에 큰 도움이 되었다고 Rust 언어 설계 팀 공동 리드 중 한 명인 Tyler Mandry가 말했다. 그는 Kangrejos 2025에서 다가오는 Rust 언어 기능을 소개하고, 그 발전을 추진하는 데 기여한 Rust for Linux 개발자들에게 감사의 뜻을 전하는 발표를 했다. 이어서 Benno Lossin과 Xiangfei Ding이 커널 개발에 가장 중요한 세 가지 언어 기능, 즉 필드 프로젝션(field projections), 제자리 초기화(in-place initialization), 임의의 self 타입(arbitrary self types)에 대해 더 자세히 설명했다.
Mandry에 따르면, 많은 이들이 Rust에서 새로운 언어 기능의 개발이 꽤 느리다고 말하곤 한다. 부분적으로는 잘못된 설계를 언어에 못 박지 않기 위해 Rust 언어 팀이 기울이는 신중함 때문이기도 하다. 하지만 더 큰 이유는 “관심의 정렬(alignment in attention)”이다. Rust 프로젝트는 자원봉사자 중심으로 움직이기 때문에, 특정 기능이나 관련 기능군을 밀어붙이는 데 집중하는 사람이 없으면 일들이 표류한다. Rust for Linux 프로젝트는 바로 이 지점을 해결하는 데 매우 도움이 되었다고 Mandry는 설명했다. 많은 사람들이 흥분하며 관심을 갖는 주제이고, 리눅스 커널이 필요로 하는 소수의 구체적인 사안에 노력을 집중시키기 때문이다.
Mandry는 이어서 다가오는 언어 기능들을 숨 가쁘게 훑었다. 여기에 알려진 크기 정보가 없는 타입, 참조 카운팅 개선, const와 같은 종류의 사용자 정의 함수 한정자 등이 포함되었다. 그는 발표 말미에, 그중 어떤 것이 Rust for Linux에 가장 중요한지, 그리고 모인 커널 개발자들이 이를 어떻게 우선순위화할지를 물었다. 뒤에서 다룰 세 기능 외에도, Lossin은 트레이트 정의 안에서 컴파일 타임에 평가될 수 있는 함수(라스트에서는 const 함수라고 부른다)를 작성할 수 있는 능력이 확실히 필요하다고 말했다. Danilo Krummrich는 특수화(specialization)를 요청했는데, 이는 곧바로 Lossin의 “오, 안 돼!”라는 반응을 이끌어냈다. 이 기능은 거의 10년 동안 Rust의 타입 시스템에 문제를 일으켜 온 역사가 있기 때문이다. 특수화는 하나의 트레이트에 대해 겹치는 두 구현이 존재하도록 허용하고, 컴파일러가 더 구체적인 쪽을 고르게 하는 기능이다. Matthew Maurer는 정수 오버플로 시 컴파일러의 동작을 제어할 수 있는 기능을 요청했다.
결국 Miguel Ojeda는 Mandry에게, 우선순위는 Rust for Linux가 현재 사용 중인 불안정 언어 기능을 안정화하는 데 두고, 그 다음으로는 프로젝트의 코드 구조를 바꿀 수 있는 언어 기능들, 그리고 그 밖의 모든 것들이라고 말했다. 이어진 두 발표에서는 이러한 핵심 언어 기능 몇 가지의 현재 상태와 향후 계획을 훨씬 더 자세히 다뤘다.
필드 프로젝션이란 구조체에 대한 포인터를 받아 그 구조체의 필드에 대한 포인터로 바꾸는 개념을 말한다. Rust에는 이미 내장 참조 및 포인터 타입에 대해 이 기능이 있지만, 사용자 정의 스마트 포인터 타입에는 항상 적용되지 않는다. Rust for Linux 개발자들은 신뢰할 수 없는 데이터, 참조 카운팅, 외부 락, 기타 커널 특유의 복잡성을 처리하기 위해 사용자 정의 스마트 포인터를 사용하고자 하므로, 모든 포인터 타입에 동일한 문법으로 필드 프로젝션을 허용하는 일반적인 언어 기능이 있으면 이득을 볼 수 있다. Lossin은 Kangrejos 2022부터 진행해 온 이 문제에 관한 자신의 작업을 소개했다. 지금까지 “많은 진전”이 있었지만, 아직은 몇 가지 세부를 남겨둔 설계 단계에 머물러 있다.
내장된 필드 프로젝션은 모두 같은 종류의 타입 시그니처를 가진다고 Lossin은 설명했다. 예를 들어, 객체에 대한 참조를 받아 그 필드에 대한 참조로 바꾸는 코드와, 객체에 대한 원시 포인터를 받아 그 필드에 대한 원시 포인터로 바꾸는 코드는 모양은 다르지만 유사한 시그니처를 갖는다:
fn project_reference(r: &MyStruct) -> &Field {
&r.field
}
unsafe fn project_pointer(r: *mut MyStruct) -> *mut Field {
unsafe { &raw mut (*r).field }
}
// 이에 해당하는 C 코드는 대략 다음과 같다:
struct field *project(struct my *r) {
return &(r->field);
}
이 예시는 비교적 최근에 도입된 raw borrow 문법을 사용한다.
Pin 타입은 여기에 약간의 복잡성을 더한다. Rust 컴파일러는 기본적으로 성능상의 이유로 구조체를 자유롭게 이동할 수 있다. 하지만 구조체가 C 쪽에서 참조되고 있을 때는 이 방식이 통하지 않으므로, Pin 타입을 사용해 이동되면 안 되는 구조체를 표시한다. ~~Pin<MyStruct>~~Pin<&mut MyStruct> [Lossin의 정정: Pin은 항상 구조체 자체가 아니라 포인터 타입을 감싼다]을(를) 프로젝션하면, 그 필드 또한 이동되어서는 안 되는 타입인지 여부에 따라 Pin<&mut Field> 또는 평범한 &mut Field가 나올 수 있다. 따라서 가장 일반적인 형태의 필드 프로젝션 연산 시그니처는 대략 다음과 같다고 Lossin은 말했다:
Container<'a, Struct> -> Output<'a, Field>
즉, 어떤 수명 'a 동안 유효해야 하는 구조체를 감싸는 포인터 타입이 주어지면, 필드를 프로젝션한 결과로 같은 수명 동안 유효한 (아마도 다른) 출력 포인터 타입이 그 구조체의 필드를 감싸게 된다. Lossin은 이어서, 이를 지원하면 커널의 Rust 바인딩에서 RCU(read-copy-update)를 완전히 구현하는 일이 훨씬 쉬워지는 예시를 들었다.
RCU 메커니즘은 리더를 동시 실행 중인 라이터로부터 보호하지만, 라이터끼리는 보호하지 않는다고 그는 설명했다. 따라서 커널에서는 어떤 데이터는 뮤텍스로 보호하되, 그중 자주 접근되는 필드는 RCU로 보호하는 패턴이 흔하다. 이렇게 하면 리더는 값싼 RCU 락에 의존하고, 라이터는 뮤텍스로 서로 동기화한다. 이 인터페이스를 Rust로 옮기는 데에는 문제가 있다. Rust에서는 먼저 잠그지 않으면 Mutex 내부의 내용에 접근할 수 없기 때문이다. 따라서 이 패턴을 곧이곧대로 옮기면, Rust 측의 리더도 RCU 필드를 읽기 위해 뮤텍스를 잠가야 하므로 성능상 받아들일 수 없는 손해가 생긴다.
하지만 언어 차원의 일반화된 필드 프로젝션이 있으면, Rust for Linux 개발자들은 락을 잡지 않고도 &Mutex<MyStruct>를 &Rcu<Field>로 프로젝션하도록 허용하는 바인딩을 작성할 수 있다. 드라이버 코드에서는 RCU로 보호된 필드를 읽는 시도가 C에서와 마찬가지로 일반적인 접근처럼 보일 것이다. 동시에 컴파일러는 RCU로 보호되지 않은 다른 데이터에는 뮤텍스를 잡지 않고 손대지 않도록 여전히 검사한다.
Lossin은 마지막으로, 이 기능에 대한 트래킹 이슈를 계속 지켜보고 피드백을 달아 달라고 요청했다. Daniel Almeida가 메인라인 커널 밖에서 이 기능을 테스트하는 것이 정말 도움이 되는지 물었고, Ojeda는 그렇다고 답했다. 그렇게 해야 Rust 팀에 가서 이 기능의 안정화를 설득하기가 더 쉬워지기 때문이다. Rust for Linux 프로젝트는 새로운 불안정 기능 사용을 지양하고(그리고 Debian stable에 패키징된 버전과 같거나 더 오래된 Rust 버전으로 컴파일하려고) 있기 때문에, 이 기능이 완성되어 2027년으로 예상되는 Debian 14에 들어가야 커널 코드에서 널리 사용할 수 있다.
Andreas Hindborg는 “이거 어제 받을 수 없나요?”라고 말해 웃음을 자아냈다. 커널의 Rust 바인딩에는 이미 다양한 불변식을 인코딩한 수많은 사용자 정의 포인터가 있다. 이 기능은 커널 코드에서 사용 가능해지는 즉시, 드라이버 코드에서 그 사용성을 상당히 높여줄 것이다.
이어 Ding은 사용자 정의 포인터를 위한 또 다른 인체공학적 언어 기능인 임의의 self 타입에 관해 업데이트를 전했다. Rust에서 어떤 타입의 메서드는 첫 번째 인자로 그 타입의 객체 자체나 그에 대한 참조를 받을 수 있다. 이런 메서드는 일반적인 Type::function() 문법 대신 .method() 문법으로 호출할 수 있다. 하지만 커널 Rust 코드에 스마트 포인터가 늘어나면서, 프로그래머는 자주 평범한 참조를 갖고 있지 않다. 종종 그 대신 Pin, Arc, 또는 다른 스마트 포인터 타입을 갖고 있다.
Ding이 작업해 온 임의의 self 타입 제안은, 프로그래머가 일반 참조 대신 스마트 포인터를 받는 메서드를 작성할 수 있게 해준다:
impl MyStruct {
fn method(self: Pin<&mut MyStruct>) {}
}
불행히도 이를 컴파일러에 추가하는 일은 간단하지 않았다. 사용자 정의 스마트 포인터를 가능하게 하는 근간인 Rust의 Deref 트레이트와의 상호작용이 구현을 복잡하게 만든다. 메서드 매칭을 탐색하는 도중에는 모든 타입 정보가 가용하지 않기 때문이다. 현재 사용자가 Pin<&mut MyStruct>를 가지고 그 위에서 메서드를 호출하면, 컴파일러는 먼저 Pin에 매칭되는 메서드를 찾는다. 찾지 못하면 타입을 한 번 역참조해 &mut MyStruct를 만든다. 그 타입에서 매칭을 찾고, 마지막으로 한 번 더 역참조해 MyStruct를 만든다. 이 타입에서야 비로소 매칭되는 메서드를 찾거나, 그렇지 않으면 컴파일러가 타입 오류를 내게 된다.
이 절차가 MyStruct에 연결된 함수들을 검사하기 시작할 즈음이면, 임의의 self 타입 구현에 필요한 래핑 타입들에 관한 정보는 이미 버려진 상태다. Ding은 이 문제를 바로잡기 위해 시도했다가 폐기한 접근들을 잠시 설명한 뒤, 현재 접근법에 집중했다. 그는 임시로 Receiver라고 부르는 또 다른 트레이트를 추가했다. 이는 임의의 self 타입과 함께 사용할 수 있는 타입을 표시하는 데 쓰인다. 이렇게 하면 컴파일러는 Deref 구현 체인을 따르기 전에 Receiver 구현 체인을 먼저 따라가 볼 수 있다. 이는 곧 포인터 타입이 임의의 self 타입으로 사용되려면 명시적으로 선택(opt-in)해야 함을 의미하지만, Ding은 이를 단점으로 보지 않았다. 포인터 타입의 작성자가 새 기능을 언제 지원할지 결정하게 함으로써, 우발적인 하위 호환성 문제를 도입할 우려를 크게 줄일 수 있기 때문이다. 커널 측에는 큰 장벽도 아니다. Rust 개발자들이 필요한 사례를 만날 때마다 Receiver 구현을 추가해 나가면 되기 때문이다.
Ojeda는 임의의 self 타입 기능을 마무리하는 데 얼마나 걸릴지, 특히 1년 안에 준비될 수 있는지 물었다. Ding은 언어 팀의 지원이 있다면 1년도 가능하다고 동의했다. 그는 코드를 제출하기 전에, 컴파일러 변경이 공개된 Rust 라이브러리를 깨뜨리지 않는지 확인하는 데 Rust 커뮤니티가 사용하는 도구인 Crater를 자신의 변경에 대해 돌려보고 싶다고 했다. Ojeda는 Crater 실행 중 일부 패키지를 컴파일할 때 메모리 요구 사항 때문에 겪었던 어려움을 언급하며, 이를 위해 큰 빌드 머신을 확보하는 데 도움을 주겠다고 제안했다.
Ding이 다루고자 했던 또 다른 주제는 제자리 초기화 작업이었다. 앞서 언급된 다른 새 언어 기능과 마찬가지로, 이 기능은 새로운 사용 사례를 가능케 하기보다는 흔한 커널 코드를 더 깔끔하게 만든다. 현재 커널의 Rust 코드는 초기화 이후 위치가 고정되도록(Pin으로 감싸서) 구조체를 생성할 때 pin_init!() 매크로를 사용한다.
pin_init!() 자체에는 아무 문제가 없다. “우리는 pin_init!()를 사랑합니다! 이것을 언어 기능으로 만들고 싶습니다.” 제자리 초기화를 위한 언어 기능을 채택하면 커널 밖의 몇몇 날카로운 모서리도 다듬을 수 있다. 큰 Future 값을 힙에 만들 때의 인체공학을 개선하고, 일부 트레이트를 dyn 호환으로 만들 수도 있다. 이 언어 기능의 정확한 설계는 아직 유동적이다. Ding은 가능한 세 가지 제안을 소개했다.
가장 단순한 방안은 Alice Ryhl과 Lossin이 제안한 것으로, 구조체 초기화 표현식 앞에 새 키워드 init을 붙여, 컴파일러가 커널의 PinInit 트레이트 구현을 자동으로 생성하도록 하는 것이다. 이는 언어에 대한 변경이 비교적 최소한이라는 장점이 있지만, 현재 형태의 PinInit 트레이트 사용을 고정하게 된다.
또 다른 해결책은 Taylor Cramer가 제안한 것으로, 언어에 새로운 종류의 참조를 도입하는 것이다. Rust의 기존 참조는 읽기만 가능한(&) 또는 읽기/쓰기가 가능한(&mut) 두 종류가 있다. 이 제안은 세 번째 종류인 &out을 추가하는데, 이는 쓰기만 가능하고 읽기는 불가능하다. &out 참조를 사용하는 유일한 방법은 값에 쓰기를 하거나, 프로젝션을 사용해 여러 필드에 대한 &out 참조들로 분해하는 것이다. 이 체계에서는 제자리 초기화가, 먼저 힙에 공간을 할당한 뒤 &out 참조를 반환하는 형태가 된다. 호출 측 코드는 이를 마음대로 채워 넣을 수 있으며, 필요하다면 하위 부분을 다른 함수에 넘길 수도 있다. 컴파일러는 모든 &out 참조가 사용되었음을 추적한 다음에야, 해당 힙 할당에 대한 일반 &mut 참조를 얻을 수 있도록 허용한다.
다만 이 제안은 Ryhl과 Lossin의 접근법보다 다듬어지지 않았다. Ding은 이후에, 자신과 Mandry, 그리고 다른 컴파일러 기여자들이 그날 발표 사이사이에 이 방식이 Rust 컴파일러 내부와 어떻게 상호작용할지 파악하려고 실제로 작업 중이었다고 전했다. 컨퍼런스가 끝날 무렵에는 대략적인 구현 방법을 떠올렸으므로, 곧 out 포인터 제안의 더 자세한 버전이 나올지도 모른다.
마지막 설계안은 C++에서 영감을 얻은 것으로, 새로운 값을 생성한 뒤 즉시 힙으로 이동시키는 코드가 처음부터 힙에서 생성되도록 보장하는 최적화 형태다. Ding은 이 최종 제안의 세부에 대해서는 확신이 덜했으며, 차라리 PinInit 제안과 out 참조 제안을 모두 구현해 보고, 실제로 어떤 접근이 더 잘 작동하는지 보는 것이 최선일 수 있다고 제안했다.
어떤 접근이 최종적으로 선택되든, 언어 개선을 견인하는 Rust for Linux 프로젝트에 대한 Mandry의 주장은 분명해 보인다. 이 기능들이 아직 초기 단계에 있긴 하지만, 이를 채택하면 사용자 정의 스마트 포인터와 관련된 코드를 커널 안팎에서 크게 단순화할 수 있다.
Update: 이 글에 소개된 발표 이후, 필드 프로젝션 작업에 업데이트가 있었다. Lossin은 LWN에, 이제 모든 구조체의 모든 필드가 구조적으로 고정된 것으로 간주되므로, Pin을 프로젝션하면 항상 Pin<&mut Field> 또는 이와 유사한 값이 나오게 되었다고 알려왔다.