Paul McKenney가 Kangrejos 2025에서 C/C++의 수명 종료 포인터 재핑과 포인터 출처 문제, 그리고 Rust(특히 unsafe 코드)로의 파급효과를 논의했다. LIFO 스택, 천사적 출처 제안, 컴파일러 최적화 상충점, Rust의 노출된 출처 모델과의 상호작용, 그리고 댓글 토론에서 제기된 다양한 사례를 다룬다.
수명 종료 포인터 재핑 극복의 진전 [LWN.net]
사용자: 비밀번호: | |
LWN.net에 오신 것을 환영합니다
다음의 구독자 전용 콘텐츠는 한 LWN 구독자에 의해 여러분께 공개되었습니다. 수천 명의 구독자가 리눅스와 자유 소프트웨어 커뮤니티의 최고 뉴스를 위해 LWN에 의존합니다. 이 글을 즐기셨다면 LWN 구독을 고려해 주세요. LWN.net을 방문해 주셔서 감사합니다!
작성: Daroc Alden
2025년 10월 7일
Paul McKenney는 지난해 발표에 이어, Kangrejos 2025에서 수명 종료 포인터 재핑(lifetime-end pointer zapping) 문제에 관한 원격 발표를 했다. 다중 스레드 코드의 일부 일반적인 패턴은 기술적으로는 정의되지 않은 동작이며, 이를 바로잡기 위해 C와 C++ 명세에 변경이 필요하다는 내용이다. 이러한 변경은 커널의 Rust 바인딩처럼 unsafe Rust를 사용하는 코드에도 영향을 줄 수 있다. 문제 해결의 진전은 더디지만, McKenney는 해결책이 눈앞에 있다고 믿고 있다.
그는 먼저 C나 C++에서 연결 리스트로 원자적 LIFO 스택을 작성하는 가장 단순한 방식이 정의되지 않은 동작을 초래할 수 있음을 지적했다. 구체적으로는, 비트 패턴은 유효하지만 출처(provenance)가 무효한 포인터가 만들어질 수 있다. 한 스레드가 항목 A를 스택에 푸시하려 한다고 상상해 보자. 그 스레드는 현재 스택의 꼭대기 항목 B에 대한 포인터를 읽어 A의 next 필드에 저장한 뒤, 원자적 비교-교환(compare-and-swap) 명령을 사용해 꼭대기 포인터가 여전히 B를 가리키는 경우에만 A에 대한 포인터를 새로운 꼭대기로 저장한다.
여기까지는 문제가 없다. 이제 두 번째 스레드가 동시에 스택에서 항목을 팝(pop)하여 해제하고, 새 항목 C를 할당한 다음, 그것을 다시 스택에 푸시한다고 하자. 새 할당의 주소가 다르면, 첫 번째 스레드의 비교-교환은 실패하여 재시도를 해야 함을 알게 된다. 하지만 메모리 할당자가 방금 해제된 적절한 크기의 메모리를 보고, 그 동일한 메모리를 새 항목에 다시 돌려준다면 어떻게 될까? 이 경우, 첫 번째 스레드의 비교-교환은 성공한다. B에 대한 포인터와 C에 대한 포인터가 비트 단위로 동일하기 때문이다. 실제 CPU 입장에서는 아무 문제가 없다. 하지만 C의 추상 기계에 따르면, C에 대한 포인터와 B에 대한 포인터는 비트는 같아도 출처 정보가 다르다. 이는 해제된 B를 가리키는 포인터(댕글링 포인터)를 "좀비 포인터"로 만들며, 이를 이용하는 모든 행위는 정의되지 않은 동작이다. 현재 컴파일러들은 이 특정한 정의되지 않은 동작을 적극적으로 이용하지는 않지만, McKenney는 미리 해결책에 합의하여 선제적으로 대응하고자 한다.
중요한 점은, 이렇게 동작하는 모든 코드를 바꾸는 것은 사실상 불가능하다는 것이다. 거의 모든 비자명한 다중 스레드 프로젝트 전반에 수많은 오픈코드된 원자적 LIFO 스택이나 동등한 코드 조각이 존재하기 때문이다, 라고 McKenney는 말했다. 이 종류의 스택의 첫 구현은 알려지지 않았지만, 아마 1960년대로 거슬러 올라갈 것이다. 1970년대에도 이 기법을 널리 알려진 것으로 언급하는 구현이 최소한 하나 있었다.
작년에 Davis Herring의 "천사적 출처(angelic provenance)" 제안은 도움이 될 것처럼 보였으나, C++ 위원회는 천사적 출처가 "매우 중요한 최적화" 일부를 무효화할 수 있는 코드 사례들을 찾아냈다. Herring의 제안은 새 규칙을 추가해, 정수를 포인터로 캐스팅할 때(또는 McKenney의 별도 제안이 채택될 경우 원자적타입에서 포인터를 로드할 때), 컴파일러가 프로그램에 정의되지 않은 동작을 일으키지 않도록 할 수 있는 출처 선택지가 하나라도 있다면, 반드시 그 선택을 하도록 요구한다.
기술적으로는, 천사적 선택과 객체 생성 사이에 happens-before 관계가 없어야 한다는 요구사항이다. 즉, 객체가 별도 스레드에서 생성되고 그 생성이 선택 이후에 일어날 수도 있지만, 스레드가 동기화되어 있지 않아 컴파일러가 그렇게 될 것이라고 증명할 수 없는 경우에도, 컴파일러는 그 객체의 출처를 고려해야 한다는 뜻이다.
수정된 규칙은 해당 연산 시점에 이미 존재하는 객체들의 출처만을 컴파일러가 고려하도록 요구한다(자세한 내용은 사이드바 참조). 이는 컴파일러 작성자가 구현하기 더 단순하고, 실제로 할당되기 전에 객체의 포인터를 얻는 문제를 피한다. McKenney의 추가 제안은, 원자적 타입을 통한 모든 로드를 정수에서 포인터로 변환된 것처럼 취급해, 천사적 출처 규칙을 적용하게 함으로써 원자적 LIFO 스택을 구현하는 가장 단순한 방법이 정의된 동작이 되도록 하자는 것이다 … 거의.
C와 C++에서는 무효 포인터로 수행하는 어떤 연산이든 합리적인 결과를 낸다는 보장이 없다. 그래서 위의 제안들이 있으면 LIFO 스택을 위한 원자 변수에서 포인터 값을 읽는 행위 자체는 문제가 되지 않지만, 애초에 그 포인터 값을 쓰는(write) 행위가 최적화로 제거될 수 있다. 따라서 기존 LIFO 스택이 제대로 동작하려면 마지막으로 필요한 조각은, 무효 포인터를 쓸 때에도, 출처 정보가 더는 사용 불가능하더라도 포인터의 실제 비트가 대상에 기록되도록 요구하는 규칙이다.
모두 합치면, 이 모든 변경을 위원회를 통과시키는 데 필요한 작업은 "그리 많지 않다". "현재의 제안들에 대해 과거만큼 속앓이가 크지 않다."
Ryhl은 Rust에서 유사한 제안을 구현할 때 논의되었던 질문을 제기했다 — 새 규칙이 천사적 선택을 "악마적(demonic)" 선택과 서로 재정렬되지 못하게 만들지 않겠느냐는 것이다. 구체적으로, 메모리 할당 같은 특정 연산에서는, 컴파일러가 그 연산이 최적화 대상 프로그램에 가장 불리하게 진행된다고 가정할 수 있다. 따라서 그런 경우에 프로그램이 잘못되었다면, 전반적으로 잘못된 것이다. Rust에는 C와 같은 정의되지 않은 동작 규칙이 있지는 않지만, unsafe Rust를 작성하는 프로그래머는 여전히 다양한 불변식을 지켜야 하므로, 이 문제에 부딪힐 수 있다. 그녀는 문제를 보여주는 Ralf Jung의 예제 Rust 코드를 공유했다:
fn nondet() -> bool {
let b = Box::new(0);
ptr::from_ref(&*b).to_addr() % 3 == 0
}
let a1 = 0; let a2 = 1;
let a1addr = ptr::from_ref(&a1).to_exposed_addr();
let a2addr = ptr::from_ref(&a2).to_exposed_addr();
let b: bool = nondet(); // demonic non-det choice
let x = ptr::from_exposed_addr(a1addr); // picks provenance of a1 or a2
let y = if b { x } else { x.with_addr(a2addr) };
let _val = *y;
이 예제에서 nondet()은 악마적 선택이다(새로 할당된 힙 항목은 어디에나 위치할 수 있으므로, 함수가 잘못될 수 있는지 판단하는 목적상 컴파일러는 정의되지 않은 동작을 유발하는 위치를 가정할 수 있다). 그리고 ptr::from_exposed_addr()는 정수→포인터 변환 주변의 제안된 규칙에 따르면 천사적 선택이 된다. "exposed" 함수와 비-exposed 변형 간의 차이는 Rust의 실험적 노출된 출처 모델의 산물이다 — 정수를 포인터로 캐스팅할 때는 오직 "노출된(exposed)" 출처만이 포인터의 출처가 될 수 있다. 함께 고려하면, 컴파일러는 b에 대한 자신의 가정에 비추어 프로그램을 유효하게 만드는 x의 출처(a1addr와 a2addr 중)를 선택해야 한다. Ryhl의 요점은, 현재는 위의 nondet() 호출보다 x의 대입을 앞당겨 재정렬하는 것이 허용된다는 것이다. x가 그 값에 직접적으로 의존하지 않기 때문이다. 그러나 제안된 규칙에서는 그 최적화가 무효가 된다. 그렇게 재정렬하면 악마적 선택이 항상 천사적 선택의 답을 무효화하는 값을 고를 수 있고, 그러면 프로그램이 깨지기 때문이다.
보수적인 해결책은, 컴파일러가 천사적 선택과 악마적 선택을 서로 상대적으로 재정렬하지 못하도록 요구하는 것이다. 그러나 이는 변수 대입을 루프 밖으로 이동하는 등의 국소 최적화를 방해할 수 있다. 그런 요구가 실제 성능과 파급효과에 어떤 영향을 미칠지는 아직 알려져 있지 않다.
McKenney는 처음에는 예제가 그 자체로 잘못되었다고 생각했는데, Gary Guo가 Rust에서는 연관된 출처의 메모리 밖을 가리키는 주소를 가진 포인터를 소유할 수 있지만, 역참조만 하지 않으면 된다고 설명해 주기 전까지는 그랬다. 이는 더 많은 객체 간에 유효한 포인터 연산을 허용하기 때문에 도움이 된다. C나 C++에서는 그렇지 않으므로 McKenney는 그 문제를 고려하지 않았던 것이다. 그러나 설명을 듣고 나서, 그는 포인터를 이런 방식으로 사용할 수 있도록 허용한 선택에 매우 열광적이었다.
그는 임의의 포인터 산술을 허용한 Rust 커뮤니티를 칭찬했다. 이는 그가 C++ 위원회를 통해 추진하는 것을 미루고 있던 작업이다. "그건 아직 생각하지 않았습니다. C++에서는 공룡 한 마리씩만 싸우고 싶었거든요." 그는 그 예제가 추가 문제를 야기한다는 점을 인정했으며, 흥미로운 미래 과제가 될 것이라고 언급했다. 바라건대, 내년 Kangrejos까지 만족스러운 답을 내놓을 수 있기를. 현재 제안이 C와 C++ 위원회를 통과한다면, 두 언어가 언젠가 포인터 산술에 관한 규칙을 완화하려 할 때 유사한 문제에 부딪힐 가능성이 크다.
댓글을 게시하려면
2025년 10월 7일 17:09 UTC (화) 게시자: NYKevin (구독자, #129325) [링크] (12개 응답)
글에 제시된 예시를 읽어보면, 다음과 같은 규칙을 명세할 수 있을 것처럼 보입니다:
포인터 형식의 glvalue(Rust 용어로는 장소 식)에 대한 비교-교환 연산이 성공할 때마다, 비교 대상 값(즉, compare/swap의 "old value" 인자)의 출처와 일치하는 어떤 포인터든 부수 효과로 다음 출처 중 하나로 변경되어야 한다:
비교 대상 값("old value" 인자)의 출처.
덮어쓴 값(glvalue 피연산자)의 출처.
위의 선택지 중 오직 하나만이 프로그램의 정의된 동작을 보장한다면, 컴파일러는 그 선택을 해야 한다. 필요하다면 동일한 비교-교환 연산으로부터 서로 다른 포인터가 서로 다른 출처를 상속받을 수 있다. 어느 쪽을 택해도 프로그램이 정의된 동작을 갖지 못한다면, 그 동작은 정의되지 않는다.
더 넓고 일반적인 경우가 분명 있을 텐데 제가 이해를 못하고 있는 듯합니다. 그렇지 않다면 이렇게 크게 논의될 리가 없겠지요. 하지만, 비교-교환이나 유사한 원자적 연산을 중심으로 하지 않는 다중 스레드 출처 문제가 어떻게 발생하는지 상상하기가 어렵습니다. 광범위한 락을 쓰면 출처는 스레드 간에 잘 이동해야 하고, 광범위한 락을 쓰지 않으면 데이터 레이스 UB를 피하기 위해 어차피 원자적 연산이 필수이니까요.
(분명한 반론을 미리 막자면: 제가 이해하기로, 이는 "천사적" 출처와 같지 않습니다. 그 규칙은 CAS 연산에 관련된 두 특정 포인터 값만이 아니라, 프로그램 어디에서나 출처를 끌어오도록/강제하도록 합니다.)
2025년 10월 7일 18:34 UTC (화) 게시자: daroc (편집자, #160859) [링크] (2개 응답)
음. 좋은 질문이네요!
제가 전문가가 아님을 전제로, 비교-교환이 아닌 기존의 원자적(완화된) 쓰기/읽기로도 스레드 간 동기화가 가능합니다. 사실, hazard pointer 구현과 관련한 다음 시나리오를 상상해 봅시다:
스레드 1이 연결 리스트의 꼭대기에서 A에 대한 포인터를 읽고, 즉시 선점됩니다. 스레드 2가 리스트에서 팝하고 A를 해제한 뒤, 같은 위치에 B를 할당하고 B를 푸시합니다. 이제 스레드 1이 들고 있는 포인터는 잘못된 출처를 가진 ‘좀비’입니다.
이제 스레드 1은 그 좀비 포인터를 자신의 hazard-pointer 리스트에 기록해, 다른 스레드들이 그 포인터의 피참조 대상을 해제하지 말라고 지시합니다. 그런 다음 연결 리스트 꼭대기를 완화된 읽기로 읽고, 리스트가 변하지 않았는지 확인합니다.
이제 스레드 1은 자신이 유효한 포인터를 가지고 있고 A가 해제되지 않는다고 생각합니다. 하지만 실제로는 잘못된 출처를 가진 B에 대한 포인터입니다. 다른 스레드들은 스레드 1의 hazard 테이블에 B에 대한 포인터가 있으므로 B를 해제하지 않을 것이고, 따라서 스레드 1이 정말로 B를 조작해도 안전합니다. 그러나 컴파일러가 출처가 잘못되었음을 알아차린다면, 이를 정의되지 않은 동작이라고 할 수 있습니다. 이 과정에서 비교-교환 연산은 한 번도 등장하지 않았지만, (제 생각엔) 스레드 1이 정확히 그 시점에 선점되지 않았다면 전체 시나리오는 합법이었을 겁니다.
제가 어떤 세부사항을 놓쳤을 수도 있습니다 — 출처와 동시성은 까다롭거든요 — 하지만 아마도 그런 더 복잡한 시나리오를 처리하려면 완전한 천사적 출처가 필요할 것 같습니다.
2025년 10월 12일 15:48 UTC (일) 게시자: alison (구독자, #63752) [링크] (1개 응답)
스레드 1이 연결 리스트의 꼭대기에서 A에 대한 포인터를 읽고, 즉시 선점됩니다. 스레드 2가 리스트에서 팝하고 A를 해제한 뒤, 같은 위치에 B를 할당하고 B를 푸시합니다.
그건 전형적인 ABA 문제죠. 늘 드는 예로, 빨간불에 멈춰 있던 운전자가 대기하는 동안 휴대폰으로 LWN을 읽기 시작합니다. 잠시 후 운전자는 "앗, 초록불을 놓쳤나?" 하고 생각합니다. 신호는 빨간불이므로, 놓치지 않았다고 결론내립니다.
이 논의를 읽으며, 런타임에 새로 생성하지 않고 미리 할당된 풀에서 버퍼를 제공하는 일반적으로 바람직한 관행이 ABA 문제의 가능성을 높이지 않을까 하는 생각이 들었습니다. 답은 그렇다일 것입니다.
2025년 10월 13일 10:35 UTC (월) 게시자: chris_se (구독자, #99706) [링크]
이 논의를 읽으며, 런타임에 새로 생성하지 않고 미리 할당된 풀에서 버퍼를 제공하는 일반적으로 바람직한 관행이 ABA 문제의 가능성을 높이지 않을까 하는 생각이 들었습니다. 답은 그렇다일 것입니다.
글쎄요, 구체적인 사항에 따라 다를 겁니다. 내부적으로 많은 메모리 할당기도 같은 크기의 객체를 위해 메모리 풀을 사용하는 것을 좋아하므로, 이는 메모리 할당기의 세부 구현이나 여러분의 풀 기반 알고리즘의 세부사항에 크게 의존합니다.
재미있게도, 이 특정 ABA 문제는 "전통적인" ABA 문제가 아니라 출처에 관한 문제일 뿐이므로, 미리 생성된 객체(단순히 메모리를 미리 할당하는 것이 아니라 미리 ‘객체’를 만들어 두는 것)에서 가져온다면 정의되지 않은 동작 문제가 해결됩니다. 컴파일러 개발자들이 생각하는 의미에서 그 객체는 결코 파괴되지 않기 때문이죠. 이는 RCU, hazard 포인터, 이중워드 CAS 같은 방법이 실제 문제 해결에 필요했던 다른 ABA 문제들과는 다릅니다. 반면, 이 스택 예시는 포인터의 출처에 관한 ABA 문제만 겪을 뿐이고, 현재 컴파일러가 생성하는 어셈블리 코드는 지금은 잘 동작하며, 이는 다른 유형의 ABA 문제에서는 그렇지 않습니다.
2025년 10월 7일 19:06 UTC (화) 게시자: comex (구독자, #71521) [링크] (8개 응답)
제가 이해하기로, 이는 "천사적" 출처와 같지 않습니다
"천사적"이란 추상 기계가 정의된 동작을 결과로 하는 선택지를 반드시 고르도록 해야 한다는 뜻입니다. 그러니 당신의 제안은 천사적 출처의 한 형태이지만, 기사에서 "천사적 출처" 텍스트와 함께 링크된 제안서 P2434R4보다는 약하다는 점은 맞습니다.
추상적으로, 당신의 제안이 어려운 이유는, 작동 의미론에서 포인터 값은 주소와 출처 그 자체이기 때문입니다. 기존 값의 출처가 바뀐다는 것은, 주소가 바뀐다는 것만큼이나 말이 되지 않습니다. 변수는 한 포인터 값에서 다른 포인터 값으로 바뀔 수 있지만, 이는 변이를 필요로 합니다.
당신이 반문할 수 있죠: C의 전통적인 수명 종료 포인터 재핑 의미론은 기존 포인터 값, 심지어 주소까지 바꾸게 하니까요! 하지만 그런 의미론은 문제가 있으며 실제로 충실히 구현되지는 않습니다.
또 반문할 수 있죠: 작동 의미론이 뭐가 그리 중요하냐, 그건 그냥 모델일 뿐이라고. 맞습니다. 하지만 그 모델은 포인터 출처 관련 알려진 잘못된 최적화를 막고 이론적 기반을 더 견고히 하려는 느리고도 진행 중인 노력 속에서, 예컨대 LLVM IR 의미론의 기반이 되어가고 있습니다. 예를 들어 https://bugs.llvm.org/show_bug.cgi?id=34548, LLVM이 inttoptr(ptrtoint(x))를 잘못해서 x로 최적화하는 문제에 대한 버그 리포트를 보세요. 8년이 지난 지금도 여전히 이슈입니다.
구체적인 예를 들어보겠습니다. 다음 코드가 있다고 합시다:
struct Foo { virtual void bar(); };
extern void do_something_with(Foo *);
void test(Foo *foo) {
foo->bar();
do_something_with(foo);
foo->bar();
}
순진하게는, 두 번의 가상 호출 각각에서 vtable을 로드하고, 그다음 vtable에서 함수 포인터를 로드해야 합니다. 하지만 Clang은 -fstrict-vtable-pointers로 컴파일하면 test를 최적화해서 함수 포인터를 한 번만 로드한 뒤, 두 번째 호출에도 재사용합니다. 객체의 수명 동안 vtable이 바뀌지 않는다는 사실 때문입니다.
하지만 do_something_with가 foo를 해제하고, 같은 주소에 우연히 새 객체를 만든다면 어떨까요? 새 객체는 다른 vtable을 가질 수 있습니다. 이 최적화가 여전히 유효한 유일한 이유는, foo 포인터를 사용해 그 새 객체에 접근하는 것이 foo의 출처가 새 객체와 호환되지 않기 때문에 정의되지 않은 동작이기 때문입니다. 컴파일러는 do_something_with의 본문을 볼 수 없으므로, 객체를 해제하고 재할당하려는 모든 시도가 필연적으로 호환되지 않는 출처를 낳을 것이라는 사실에 의존해야 합니다.
그러나 당신의 규칙 아래에서는, do_something_with가 비교-교환을 수행해 출처를 바꿀 수 있고, 업데이트된 출처가 포인터의 모든 복사본, 즉 foo까지 전파될 수 있습니다. 그러면 그 최적화는 더 이상 유효하지 않습니다. 아니면 그 최적화를 유지하려면, 업데이트된 출처가 왜 foo까지 전파되지 않는지에 대한 규칙을 정의해야 하고, 이는 다시 인라이닝이나 그 밖의 것들에 문제를 야기할 수 있습니다.
공정하게 말하면, 이 vtable 최적화의 가치는 불확실해 보입니다. 지금은 Clang이 플래그 뒤에 숨겨두는 이유가 실제 코드베이스를 깨뜨리기 때문이라고 상상할 수 있죠. 아마도 다른 최적화를 포함한 더 좋은 예가 있을 겁니다. 제가 떠올리는 다른 아이디어들은 보통 덜 현실적인 코드를 수반하네요.
(사족: 해제 후 같은 포인터 위치에 다른 객체를 placement new로 구성하는 경우도 있습니다. 그렇게 하면 C++17에서 새로 도입된 std::launder를 포인터에 호출해야 합니다. 이것도 일종의 출처 문제지만, Rust에는 등가물이 없어서 제가 덜 익숙합니다.)
2025년 10월 7일 23:28 UTC (화) 게시자: NYKevin (구독자, #129325) [링크] (1개 응답)
변수는 한 포인터 값에서 다른 포인터 값으로 바뀔 수 있지만, 이는 변이를 필요로 합니다.
바로 그게 문제죠: 원래의 패턴이 동작하려면, 우리는 기능적으로 동등한 두 가지 중 하나를 해야 합니다:
CAS 시점에 우리가 읽은 포인터 및 그로부터 파생된 모든 포인터의 출처를 변이한다.
CAS가 포인터의 출처를 소급적으로 결정한다고 말한다.
저는 이 둘 사이의 구분을 유지할 요점을 못 느끼겠습니다만, 어떤 컴파일러 엔지니어들은 거리에서의 섬뜩한 작용(원격 변이)보다 개념상의 타임머신을 더 쉽게 받아들일지도 모르지요. 어느 쪽이든, CAS 이후가 되어야 포인터의 최종 출처를 알 수 있습니다.
그러나 당신의 규칙 아래에서는,
do_something_with가 비교-교환을 수행해 출처를 바꿀 수 있고, 업데이트된 출처가 포인터의 모든 복사본, 즉foo까지 전파될 수 있습니다. 그러면 그 최적화는 더 이상 유효하지 않습니다. 아니면 그 최적화를 유지하려면, 업데이트된 출처가 왜foo까지 전파되지 않는지에 대한 규칙을 정의해야 하고, 이는 다시 인라이닝이나 그 밖의 것들에 문제를 야기할 수 있습니다.
이것을 성사시키는 방법은 CAS와 그 이전(원자적) 읽기 사이에 관계를 도입해서, 해당 읽기에서 파생된 포인터의 출처만을 변경하도록 하는 것입니다(혹은 마법 같은 시간여행으로, CAS가 성공하기 전이지만 그 순간부터 올바른 출처를 부여). 그러면 당신의 예는 문제가 되지 않습니다. foo 포인터는 do_something_with() 내부에서 벌어지는 장난과는 무관하게 파생되지 않았기 때문입니다.
또 다른 문제는, 포인터가 원자적으로 여러 번 덮어써질 수 있다는 점인데, 그렇다면 사실 N개의 구별되는 출처를 고려해야 합니다. 하지만 대부분의 경우, 첫 번째나 마지막 출처만이 실제로 동작할 가능성이 크므로, 아마 이건 불필요할지도요.
해제 후 같은 포인터 위치에 다른 객체를 placement new로 구성하는 경우도 있습니다.
맞습니다만, 말씀하신 대로 이는 이미 std::launder로 보호(혹은 보호되어야)되고 있습니다.
2025년 10월 8일 0:21 UTC (수) 게시자: PaulMcKenney (✭ supporter ✭, #9624) [링크]
네, 과거로 되돌아가 관련된 모든 포인터의 출처를 전파하는 방식은 호의적으로 받아들여지지 않을 겁니다. ;-)
몇 해 전, 우리는 일종의 출처 장벽(provenance barrier)을 추진했는데, 이는 그 시점에서 포인터와 그로부터 도달 가능한 모든 포인터를 세탁(launder)하는 효과를 가졌을 것입니다. 이 제안은 어느 정도 긍정적인 관심을 받았지만, 결국 거절되었습니다. 더 이른 제안들도 많았고, 여기에서 찾아볼 수 있습니다: https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2021/.... 그리고 이 주제에 관심을 가져 주셔서 모두들 감사합니다!
2025년 10월 8일 6:28 UTC (수) 게시자: mb (구독자, #50428) [링크] (4개 응답)
하지만
do_something_with가foo를 해제하고, 같은 주소에 우연히 새 객체를 만든다면 어떨까요?
궁금한 게 있습니다:
이것은 Rust에서는 UB이고 따라서 문제가 안 되는 건가요? 자기 자신에 대한 참조가 살아 있으므로 해제가 수행되지 않는 것이 보장되기 때문입니다.
2025년 10월 8일 12:55 UTC (수) 게시자: daroc (편집자, #160859) [링크] (3개 응답)
Rust에서는 함수가 매개변수의 소유권을 가져가면 호출한 함수가 그 후에 사용할 수 없습니다. 반대로 함수가 매개변수를 참조로 받는다면, 그 객체를 해제할 수 없습니다. 그래서 네, 이 문제는 컴파일 타임에 차단됩니다.
… 다만, 타입에 트레이트 객체를 저장하는 가변 내부 셀(mutable interior cell)이 있다면 예외입니다. 그 경우 함수가 트레이트 객체를 다른 vtable을 가진 것으로 교체할 수 있고, 호출하는 코드는 vtable 포인터를 다시 로드해야 합니다. 하지만 컴파일러는 관련된 타입을 검사해 그런 가능성이 있는지 알 수 있습니다.
2025년 10월 8일 18:19 UTC (수) 게시자: NYKevin (구독자, #129325) [링크] (2개 응답)
… 다만, 타입에 트레이트 객체를 저장하는 가변 내부 셀(mutable interior cell)이 있다면 예외입니다. 그 경우 함수가 트레이트 객체를 다른 vtable을 가진 것으로 교체할 수 있고, 호출하는 코드는 vtable 포인터를 다시 로드해야 합니다. 하지만 컴파일러는 관련된 타입을 검사해 그런 가능성이 있는지 알 수 있습니다.
그건 통하지 않는 것 같습니다.
&dyn Trait와 그로부터 파생된 모든 것(예: &UnsafeCell<dyn Trait>)은 뚱뚱한(fat) 포인터입니다. 객체를 가리키는 포인터 하나와, 정적으로 할당된 vtable을 가리키는 별도의 포인터 하나를 가집니다. 이 vtable은 타입마다 존재하고 인스턴스마다 존재하지 않으므로, 이를 덮어쓸 수 없고, 다른 vtable로 바꾸는 것은 포인터의 변이로 간주되며, 피참조 대상의 변이가 아닙니다.
또 다른, 더 평범한 문제로, 두 개의 서로 다른 트레이트 객체(같은 트레이트 바운드를 갖더라도)는 반드시 같은 크기가 아닙니다. 따라서 이들을 서로 바꾸는 것이 아예 불가능할 수도 있습니다(더 큰 객체는 더 작은 객체의 할당에 들어맞지 않습니다).
물론 UnsafeCell<&dyn Trait>이 있다면, 피참조 대상 대신 포인터를 변이시킬 수 있습니다. 하지만 이는 트레이트 객체에 국한된 이야기가 아니며(그리고 포인터가 여러분 모르게 변이될 수 있다면, 이런 최적화를 적용할 수 없다는 점은 꽤 자명합니다).
2025년 10월 8일 18:34 UTC (수) 게시자: daroc (편집자, #160859) [링크] (1개 응답)
맞습니다. 죄송해요, 제가 불명확했네요. 저는 이런 타입을 가정했습니다:
Arc<Mutex<&dyn Foo>>
… 그러면 Mutex를 잠그고 그 위에서 .foo_method()를 호출할 때, Deref 구현이 &dyn Foo로부터 vtable 포인터를 가져와야 합니다. 그다음 뮤텍스 가드를 다른 함수에 넘기거나, 잠금을 풀었다 다시 잡은 뒤 .foo_method()를 다시 호출한다면, 컴파일러는 캐시된 값을 재사용하는 대신 구조체 내부에서 vtable 포인터를 다시 가져와야 합니다.
아마요. 제법 확신은 있지만, 언급된 C++ 예와 유사하다고 생각할 뿐, 제가 뭔가 뉘앙스를 놓칠 수도 있습니다.
2025년 10월 8일 21:33 UTC (수) 게시자: NYKevin (구독자, #129325) [링크]
그 말이 맞습니다. 제가 알기론 그렇습니다. 하지만 핵심은, 당신이 묘사한 것은 전혀 다른 트레이트 객체를 가리키도록 포인터를 변이하는 것이며, 반면 C++의 placement new는 포인터에는 손대지 않고 피참조 대상을 파괴한 다음 같은 자리에 새로 구성한다는 효과를 낸다는 점입니다.
다른 방식으로 생각하면, 단순화를 위해 Mutex(와 const 정합성)를 무시하면, Arc<&dyn Foo>는 도덕적으로 std::shared_ptr<Foo*>와 동등합니다(Foo는 vtable을 가짐). 흔히 쓰이는 std::shared_ptr<Foo>와 혼동하면 안 됩니다. 당신이 묘사한 변이는 전혀 다른 Foo 인스턴스를 가리키는 완전히 다른 Foo로 Foo를 바꾸는 것과 동등합니다. 물론 올바르게 하려면 Mutex가 필요하지만, 이는 단지 한 층의 간접참조를 더해줄 뿐 본질을 바꾸지는 않습니다.
아마 Arc<Mutex<dyn Foo>>라면 어떨까 궁금할 수 있습니다. 이는 물론 완전히 합법적으로 구성 가능한 타입입니다. 그렇게 하면 &mut dyn Foo까지는 얻을 수 있습니다. 그러나 그 이후로는 더 나아갈 수 없습니다. 이를 다른 Foo로 바꾸려면 std::mem::swap() 같은 것을 호출해야 할 텐데, 그럴 수 없습니다. dyn Foo는 크기가 정해져 있지 않고, std::mem::swap()에는 암시적인 T: Sized 바운드가 있으므로 컴파일러가 호출을 거부할 것입니다. 이를 수행하는 다른(안전한) 함수나 메서드들도 유사한 바운드를 갖거나(보통 암시적), T를 값으로 받습니다(이는 크기가 정해지지 않은 타입을 받을 수 없죠). 새 Foo가 기존 Foo의 할당에 들어맞을 만큼 충분히 작은지 보장할 합리적인 방법이 없기 때문입니다.
2025년 10월 8일 12:42 UTC (수) 게시자: PaulMcKenney (✭ supporter ✭, #9624) [링크]
이 구체 예시에 답하자면:
struct Foo { virtual void bar(); };
extern void do_something_with(Foo *);
void test(Foo *foo) {
foo->bar();
do_something_with(foo);
foo->bar();
}
여기서 "foo"로의 포인터 변환은 do_something_with()에서의 잠재적 free()보다 먼저 일어나야 합니다. 이는 Davis Herring의 제안된 의미론 아래에서, 컴파일러가 "foo"가 전반에 걸쳐 같은 객체를 가리킨다고 가정할 권리가 있음을 의미합니다. 따라서 vtable 포인터 최적화는 이 제안된 의미론 아래에서도 허용됩니다.
2025년 10월 8일 15:01 UTC (수) 게시자: tialaramex (구독자, #21167) [링크] (3개 응답)
Aria의 기능은 더 이상 실험이 아닙니다. Rust는 Aria가 제안했던 출처 규칙(수정본 포함)을 채택했습니다.
특히 무서운 포인터 별칭(aliasing) 문제가 있다면 조율할 세부사항이 있습니다. Rust는 현재 그런 경우에 무슨 일이 일어나는지 말해주지 않아서, 유일하게 안전한 선택지는 포인터 코드에서 무서운 별칭을 금지하는 것일 수 있습니다. 언젠가는 이 부분이 충분히 못 박혀서, 정확히 무엇이 허용되지 않는지를 명세할 겁니다(여전히 당신이 하려는 걸 못하게 될 수 있지만, 그건 경우에 따라 다르겠지요). 하지만 전반적인 출처는 오늘날의 Rust에서 받아들여진 API입니다.
그래서 "실험적" 출처에 관한 링크의 끝에는 "실험적"이라고 쓰여 있지 않고, 그 페이지가 다루는 API들은 Rust 1.84(1월 릴리스)의 평범한 안정 API입니다. 네, 링크된 페이지에는 "실험적"이라는 단어가 나오긴 합니다. Rust는 여전히 새 실험적 기능을 추가(때로 제거)하고, 초기에는 그 실험을 통해 제공되었으니까요. 하지만 여기서 논의하는 작업에는 이제 실험적이라는 라벨이 붙어 있지 않습니다.
2025년 10월 10일 19:15 UTC (금) 게시자: PaulMcKenney (✭ supporter ✭, #9624) [링크] (2개 응답)
먼저, 포인터 출처를 다루는 Rust의 최근 상황을 어느 정도 알려주셔서 감사합니다!
우리가 우려하는 표준적인 코드는 다음과 같습니다. 이는 C++이지만, 포인터 재핑 관련 작업 문서 세 가지의 제안된 변경을 가정합니다:
https://godbolt.org/z/35GocdG6s
제가 보기엔, Rust 변형은 push() 함수의 .compare_exchange_weak() 직전에 포인터의 출처를 노출(expose)하기 위해 expose_provenance()를 사용할 수 있을 것 같습니다. push() 함수가 ->next 포인터에 직접 접근할 수 있다면, 성공적인 .compare_exchange_weak() 이후 그 포인터에 with_exposed_provenance()를 적용할 수도 있을 겁니다. 적어도 그렇게 해도 포인터의 실제 메모리 비트를 변경하지 않는다는 가정하에서요.
다만, 예제에서 ->next 포인터에 직접 접근하지 않는 이유는, 사람들이 포인터 접근에 디버깅을 적용하길 원하기 때문입니다. 이 경우 ->next 포인터에 with_exposed_provenance()를 적용할 방법을 저는 떠올리지 못하겠습니다. 아마 제 상상력의 부족 때문일 수 있겠지요. 특정 포인터 접근에 훅이나 트레이스포인트를 두는 방식일까요?
하지만 더 나은 방법이 있을까요?
2025년 10월 13일 0:33 UTC (월) 게시자: tialaramex (구독자, #21167) [링크] (1개 응답)
안녕하세요 Paul.
저는 여기서의 Rust 의미론 전문가가 아니고, C++ 의미론은 더더욱 전문과는 거리가 멉니다. 다만 "포인터 재핑" 제안서들과 이 분야의 많은 문서를 읽었고, 이해했다고 믿고 싶은 사람입니다.
스스로 비전문가라고 자임하는 만큼, 제가 완전히 바보 같을 수도 있지만, 노출(exposure) API는 포인터가 아닌 주소(정수 값)에 관한 것이라고 알고 있습니다. 그런데 C++ 코드의 것들은 모두 포인터죠. 그래서 우리는 애초에 노출된 출처 API를 원하지 않는 것 아닐까요?
제가 이해하기로(물론 틀렸다면 지적해 주세요) C++ 코드의 출처 문제는 스택에 Jigglypuff 하나만 있는 상황에서, 1단계에서 그 Jigglypuff에 대한 포인터를 얻고, 2단계 중간에 스레드가 잠들고, 자는 동안 다른 스레드가 스택을 팝해 Jigglypuff를 얻고, Jigglypuff를 파괴하고, 우연히 방금 파괴된 Jigglypuff와 동일한 주소로 Pikachu를 만든 경우입니다. 3단계에서는 우리가 성공하는 것이 불가피해 보입니다 — 그런데 우리는 파괴된 Jigglypuff에 대한 포인터를 사용한 것 같지요. 이게 어떻게 괜찮고, 왜 괜찮은지, 부정적인 결과는 무엇인지요?
"그다음 적용할 수 있다"는 설명은 경쟁 상태(레이스)가 있는 것처럼 보입니다. 다른 스레드가(우리가 Jigglypuff를 가리킨다고 믿었던 포인터를 보고, 역참조하면 아마 Pikachu를 볼 겁니다) 우리가 그런 변경을 적용하기 전에 그 포인터를 볼 수 있죠. 그게 괜찮다면, 왜 그런 변경을 적용하는 건가요?
아마도 낮은 수준으로 내리는 과정(lowering)과, Rust의 엄격한 API로 이를 처리하는 게 왜 문제인지(혹은 어쩌면 문제가 아닌지)를 봐야 할 것 같습니다.
2025년 10월 13일 13:44 UTC (월) 게시자: PaulMcKenney (✭ supporter ✭, #9624) [링크]
포켓몬! 그거 참 추억을 소환하네요. ;-)
우리는 동시 LIFO 스택을 다루고 있고, 여기서 "동시"란 (그 외 여러 의미 중) 레이스를 우아하게 처리해야 함을 뜻합니다. 무엇이 push()되든, pop_all()을 수행하는 누구든 그것을 거의 어떤 순서로든 받아들일 준비가 되어 있어야 합니다. 그러니 이게 포켓몬 스택이라면, pop_all() 호출자는 Jigglypuff뿐 아니라 Pikachu(혹은 Blastoise, Squirtle, …)도 받아들일 준비를 해야 합니다. 하지만 ewok, gremlin 같은 다른 것들을 팝할 걱정은 할 필요가 없습니다. 다시 말하지만, push()되는 건 오직 포켓몬이어야 하니까요. 그리고 실제로 이게 포켓몬의 스택이라면, 포켓몬 외에는 아무 것도 push()되어서는 안 됩니다. 우리 앞의 질문은, 언어 구현이 이것을 우아하게 다루게 하려면 어떻게 해야 하느냐입니다.
그리고 한 가지 우려는, 언어 구현이 push() 시점에 스택 꼭대기가 실제로 Jigglypuff임을 알아채고, 어떤 방식으로든 그 요소와 Jigglypuff 특화 상태를 새 블록(이제 Pikachu)이 pop되는 시점까지 계속 결부시키는 것입니다. 말씀하신 대로, 이는 나쁩니다.
한 가지 희망은, 노출(exposure) API와 유사한 무언가가 LIFO 스택이 ->next 포인터의 출처가 미지수라고 표시할 수 있게 해주는 것입니다. 실제 출처는 성공적인 compare_exchange_weak() 연산까지는 변경될 수 있음을 유념하면서요. C와 C++에서는 이 재출처화(reprovenancing)가 자동으로 이뤄져야 합니다(그래서 천사적 출처를 포함하는 제안들이 있는 것이고요). 하지만 Rust는 거의 모든 Rust 코드 작성자가 아직 살아있고, 자신이 동시 LIFO 스택을 코딩했는지 기억할 가능성이 매우 높다는 이점을 가집니다. 그래서 이론적으로 Rust에서 가능한 한 가지는 기존의 모든 LIFO 스택 코드를 변경해, (예컨대) 출처를 제거한 포인터를 set_next()에 전달하는 것입니다. 현실적으로, 이것이 Rust 언어 또는 그 모델링 도구(Miri 같은 것들 — 슬프게도 이 경우는 가능성이 낮아 보입니다)에 통하는지는 모르겠습니다. 또 다른 가능성은, "old" 포인터의 출처를 재설정하는 특별한 compare_exchange_weak() 연산이지만, 이는 ->next 포인터가 LIFO 스택에 노출되어 있는 스택에만 통하고, set_next()와 get_next()를 사용하는 스택에는 통하지 않습니다.
그리고 마지막으로 하신 말씀에 관해, 저 역시 이것이 Rust에서는 어떻게든 문제가 아니기를 바랍니다. 하지만 희망은 영원하고, 단지 희망만으로는 올바른 동시 코드를 일관되게 만들어낼 수 없습니다. ;-)
2025년 10월 8일 15:41 UTC (수) 게시자: tialaramex (구독자, #21167) [링크]
글에 실린 Rust 코드 스니펫은 2023년 당시 제안되던 기능에 관한 논의에서 나온 것이어서 실제로는 컴파일되지 않습니다. 그래서 실제 Rust의 제공 API로 실제 컴파일되도록 손을 봤습니다. 제 번역으로 인한 실수는 전적으로 제 책임이며, 통찰은 Ralf의(아니면 아마 Mario의) 것입니다.
LWN은 긴 코드를 붙여넣기엔 그리 좋지 않아서, Compiler Explorer 링크를 첨부합니다:
https://rust.godbolt.org/z/1WqW5MhqP
눈에 띄는 점은, 원래 스니펫에는 "unsafe"라는 단어가 한 번도 나오지 않지만, 실제로는 한 번 필요하다는 것입니다. 구체적으로 포인터를 역참조할 때 필요합니다. 그리고 이것이 잘못됐다면(저도 진짜 확신은 없습니다), Rust는 바로 거기서 잘못을 가리킵니다. 이 코드는 (안전하지 않게) 사실 유효하지 않은 포인터를 역참조했기 때문에 잘못이며, unsafe 키워드는 우리가 그것이 유효함을 보장할 책임이 있음을 의미합니다.
Copyright © 2025, Eklektix, Inc.
댓글과 공개 게시물의 저작권은 작성자에게 있습니다.
Linux는 Linus Torvalds의 등록 상표입니다.