C++26: Trivial infinite loops are no longer undefined behaviour
C++26: 자명한 무한 루프는 더 이상 미정의 동작이 아니다
C++에서 while (true); 같은 부수 효과 없는 무한 루프는 지금까지 미정의 동작(UB)이었고, Clang은 이를 최적화로 제거해 루프 뒤의 코드가 실행되는 일까지 벌어졌습니다. C++26의 P2809R3은 '자명한 무한 루프(trivial infinite loop)'라는 좁은 범주를 정의해 이를 유효한 동작으로 바꾸고, 루프 본문을 std::this_thread::yield() 호출로 대체해 forward progress를 보장합니다. C에서는 이미 C11부터 유효했던 동작이라 C와 C++의 불필요한 분기도 해소됩니다.
- 주제
본문
int main() { while (true); } 라는 프로그램은 잘 정의되어 있을까요? C++26 이전에는 아니었습니다. 부수 효과 없는 while (true); 루프는 미정의 동작이었고, 컴파일러는 루프가 종료된다고 가정할 수 있었습니다. 실제로 Clang은 루프를 통째로 제거한 뒤 main이 넘어가 링커가 배치한 unreachable() 같은 함수가 실행되어 "Hello world!"가 출력되는 사례가 있었습니다. 이는 컴파일러 버그가 아니라 UB의 정당한 활용이었습니다.
Forward progress guarantee와 C와의 분기
이 문제의 뿌리는 C++11에서 스레딩 지원과 함께 도입된 forward progress guarantee입니다. 표준([intro.progress])은 모든 스레드가 결국 다음 중 하나를 한다고 구현이 가정할 수 있게 했습니다: 종료, 라이브러리 I/O 호출, volatile 접근, 동기화·원자 연산. while (true);는 이 중 아무것도 하지 않으므로 그런 루프에 영원히 머무는 실행은 UB가 됐고, 옵티마이저는 실행이 거기서 멈추지 않는다고 가정해 루프를 제거하고 경로를 unreachable로 표시할 수 있었습니다. 흥미로운 점은 C는 이를 제대로 처리했다는 것입니다. C++11과 C11 모두 forward progress 규칙을 도입했지만, C에는 '제어식이 상수 표현식인 루프는 종료한다고 가정할 수 없다'는 규칙이 하나 더 있어 while (1);은 C11부터 유효했습니다. C++는 이 추가 규칙을 채택하지 않아 같은 코드가 C에서는 유효하고 C++에서는 UB인 불필요한 분기가 생겼습니다.
왜 while (true);를 쓰는가 — 임베디드 halt 패턴
이 패턴은 임베디드와 커널 코드에서 흔한 halt-on-error 관용구입니다. 치명적 오류가 발생했는데 돌아갈 운영체제가 없으면 그냥 멈추는 것입니다. 그런데 이것이 C++에서는 UB였고, 결과는 이론적이지 않습니다. 옵티마이저가 루프를 제거하면 실행은 링커가 그 뒤에 배치한 코드로 넘어가고, 임베디드 시스템에서는 치명적 오류 핸들러가 장치를 실제로 멈추지 못해 하드웨어가 손상된 상태로 계속 동작합니다. 보안이 중요한 코드에서는 실질적인 취약점이 됩니다.
P2809R3가 바꾸는 것
C++26은 C의 규칙을 그대로 복사하지 않았습니다. C 방식은 검토됐지만 기각됐는데, 제어식이 상수 표현식인 훨씬 넓은 범주의 루프를 보호하게 되어 유용한 최적화를 방해할 수 있기 때문입니다. 대신 P2809R3은 의도적으로 좁은 '자명한 무한 루프' 범주를 정의했습니다. 조건은 두 가지입니다. 첫째, 본문이 문자 그대로 비어 있는 trivially empty iteration statement여야 합니다(; 또는 {}). 본문에 의미 없는 식 문장 하나라도 있으면 해당하지 않습니다. 둘째, 제어식이 true로 평가되는 상수 표현식이어야 하며, 조건 없는 for 루프는 true가 암시됩니다. 두 조건이 충족되면 루프 본문이 std::this_thread::yield() 호출로 대체되어 그동안 없던 forward progress 의미를 갖게 됩니다. while (true);, for (;;);, do {} while (true);, constexpr 변수를 쓴 루프는 해당하지만, 본문에 문장이 있거나, break 조건이 있거나, 제어식이 비상수인 경우는 해당하지 않습니다. forward progress guarantee 자체도 갱신되어 '자명한 무한 루프의 실행을 계속하는 것'이 스레드가 결국 할 수 있는 일 중 하나가 됐습니다. 또한 이 제안은 defect report로도 채택되어 구현체가 이전 C++ 모드에도 적용할 수 있으므로, 최신 컴파일러에서는 C++20 모드에서도 예전 동작이 재현되지 않을 수 있습니다. 다만 freestanding 구현에서는 yield() 대체가 일어나는지 여부가 implementation-defined인데, 베어메탈에서 의도한 halt 루프가 협조적 yield로 바뀌는 것을 막기 위한 장치입니다.
Lobsters 반응
- @gepardo — 드디어요 :) 이 C++ UB가 LLVM 때문에 rustc의 버그로까지 이어졌던 게 문득 생각나네요.
- @fanf — P2809에서 C++가 C의 규칙을 '그냥' 채택할 수 없는 이유에 대한 논의가 있길 바랐는데 찾지 못했습니다. forward progress guarantee가 저에게는 좀 이상하게 느껴집니다. 프로그램이 실행되는 인프라(락 프리미티브, 스케줄러 등)의 속성으로서의 가치는 이해하지만, 그 보장을 필요로 하거나 원하지 않을 수도 있는 프로그램에까지 요구사항을 확장해야 하는 이유는 모르겠습니다.
- @jlarocco — C++를 실제로 좋아하는 사람으로서, 가끔은 힘들다는 걸 인정해야겠네요.
- @muvlon — 글쎄요, 모든 걸 고려하면 개선인지 확실하지 않습니다. 무한 루프에서 UB를 없앤 건 맞지만 '자명한' 경우에만이고, 그 '자명한'의 정의가 좀 자명하지 않습니다. 더 나쁜 건 C와 다른 정의를 썼다는 겁니다(C에서는 괜찮지만 C++26에서도 계속 UB인 루프의 예를 논문에서 보여줄 정도로요). 그래서 기술적으로 UB는 줄었지만 경계는 더 들쭉날쭉해졌고 이를 피하기 위한 인지 부하는 더 커졌습니다. 다소 과도하게 제한적이더라도 기억하고 따르기 쉬운 규칙이 나을 수 있습니다.
- @fanf — 시그널 핸들러와 함께 루프 돌리기. 이 수상 경력의 걸작이 관용구가 아니라니, 얼마나 건방진 소리입니까! (1987년 IOCCC 수상작 wall을 링크하며)
원문: sandordargo.com / 번역·요약: Trawling