Building a green thread runtime in Rust from scratch
Rust로 그린 스레드 런타임을 처음부터 만들기
Rust로 그린 스레드 런타임을 직접 구현하며 스택 할당, ARM64 레지스터 문맥 전환, 작업 스케줄링 과정을 설명합니다. 고정 크기 스택과 워커별 큐를 쓰는 이유, `join`과 패닉 처리까지 살펴보며 스택형 태스크와 비동기 태스크의 차이도 짚습니다.
- 주제
AI 요약
글쓴이는 반복적인 CRUD 작업에서 벗어나 시스템 프로그래밍을 익히려고 Rust로 그린 스레드 런타임을 만들었습니다. 목표는 프로덕션 런타임이 아니라, unsafe 코드와 그린 스레드의 동작 원리를 이해하는 일입니다. 완성한 라이브러리 rsroutine은 1,000줄이 안 되는 소규모 프로젝트입니다.
스택과 레지스터로 스레드 만들기
그린 스레드는 운영체제 대신 언어 런타임이 만들고 관리하는 스레드입니다. 여러 그린 스레드가 소수의 OS 스레드에서 번갈아 실행되며, 런타임이 직접 실행 문맥을 바꿉니다. 스레드 문맥은 스택과 CPU 레지스터 상태로 구성됩니다. 전환할 때 현재 레지스터를 저장하고 다른 스레드의 레지스터를 불러오면 됩니다.
각 작업에는 독립된 스택이 필요합니다. 처음에는 Vec<u8>로 스택을 만들려 했지만, 벡터 메모리에는 보호 페이지(guard page)를 설정하기 어렵습니다. 할당기가 여러 객체를 같은 페이지에 둘 수 있고, 메모리 접근 권한은 페이지 단위로 바꾸기 때문입니다. 구현에서는 mmap으로 페이지를 직접 확보한 뒤 mprotect로 스택 앞쪽을 접근 불가 상태로 둡니다. 스택이 경계를 넘으면 다른 메모리를 조용히 덮어쓰는 대신 프로세스가 즉시 오류를 냅니다. 각 작업 스택은 32KiB로 고정되어 있어 깊은 재귀에는 취약합니다. OS 스레드의 기본 스택 크기 2MiB보다 작고, 자동으로 늘어나지도 않습니다. 작업이 끝나면 Drop 구현이 munmap을 호출해 메모리를 해제합니다.
문맥 전환 코드는 Apple Silicon의 ARM AAPCS64 호출 규약을 따릅니다. 함수 호출에서 값이 보존되지 않아도 되는 caller-saved 레지스터는 전환 함수 호출 과정에서 이미 사라질 수 있으므로, 스택 포인터와 링크 레지스터, callee-saved 정수·부동소수점 레지스터를 저장합니다. Context 구조체에는 스택 포인터와 x19~x30, d8~d15를 둡니다. #[repr(C)]로 필드 배치를 고정해야 어셈블리 코드가 정해진 오프셋에서 값을 읽고 쓸 수 있습니다.
swap_context는 현재 문맥을 저장한 뒤 대상 문맥을 불러오고, 대상의 링크 레지스터 주소로 분기합니다. 새 작업은 이전에 문맥 전환을 한 적이 없어 돌아갈 주소가 없습니다. 그래서 bootstrap_entry를 가짜 시작점으로 넣습니다. 작업 포인터는 보존 레지스터 x19에 두고, 시작 코드가 이를 첫 번째 인자 레지스터 x0으로 옮긴 뒤 task_entry로 분기합니다.
스케줄러와 작업 대기
가장 단순한 런타임은 두 작업이 하나의 OS 스레드에서 번갈아 실행되도록 합니다. yield_now()가 호출되면 현재 작업의 문맥을 저장하고 메인 스케줄러로 돌아갑니다. 다음에 그 작업을 실행할 때는 자체 스택에 저장한 위치부터 이어서 실행하므로 지역 변수와 반복 상태가 유지됩니다.
실제 런타임은 CPU 코어 수만큼 OS 스레드를 만들고, 각 스레드를 워커로 사용합니다. 새 작업은 모든 워커가 가져갈 수 있는 전역 큐에 들어갑니다. 한 번 실행을 시작한 작업이 양보하면 해당 워커의 로컬 큐 뒤에 놓입니다. 작업 스택에는 컴파일러가 추적하지 못하는 값이 남을 수 있습니다. 예를 들어 작업이 Rc를 스택에 둔 채 양보해도 컴파일러는 이를 Send가 아닌 작업으로 판단해 막지 못합니다. 다른 OS 스레드에서 재개하면 스레드 안전성이 깨질 수 있으므로, 시작한 작업은 같은 워커에 남습니다. 그 대가로 바쁜 워커의 작업을 한가한 워커에 넘기지 못합니다.
각 워커는 자기 문맥과 현재 작업, 로컬 큐, join 결과를 기다리는 작업 목록을 관리합니다. 스레드 로컬 저장소에 워커 포인터를 두어 yield_now()가 현재 워커를 찾습니다. 전역 큐와 워커 제어 정보는 런타임이 공유하며, 할 일이 없으면 워커의 OS 스레드를 재우고 새 작업이 들어올 때 깨웁니다.
작업 실행 결과는 양보, 대기, 완료로 나뉩니다. 양보한 작업은 로컬 큐로 돌아가고, join을 기다리는 작업은 대기 목록에 놓이며, 끝난 작업은 버려져 스택을 해제합니다. join은 일반 OS 스레드에서 호출하면 그 스레드를 기다리게 하고, 그린 스레드 안에서 호출하면 해당 작업만 대기시킵니다. 워커는 다른 작업을 계속 실행합니다. 작업 함수의 패닉은 catch_unwind로 잡아 결과에 담고, join에서 오류로 돌려줍니다. 패닉이 어셈블리와 extern "C" 경계를 넘어가면 안 되기 때문에 이 처리도 필요합니다.
비동기 작업과의 차이
Tokio 같은 비동기 런타임은 작업을 워커 사이에서 옮길 수 있습니다. 컴파일러가 .await 사이에 보관되는 값이 Send인지 검사하기 때문입니다. 스택형 그린 스레드는 컴파일러가 스택 내부를 볼 수 없으므로 같은 보장을 얻지 못합니다. 따라서 이 구현은 시작한 작업을 워커에 고정합니다. 글쓴이는 이 런타임이 Apple Silicon 전용이며, 스택 크기가 고정되어 있고, 절전·채널·비동기 I/O 기능도 없다고 설명합니다.
Reddit 반응
- @u/Modi57 — “작업의 문맥이 작업 자체를 가리키는 포인터를 저장하므로, 작업이 같은 메모리 주소에 머물도록 박싱해야 한다고 하셨습니다. 실제로 이동을 막으려면
Pin도 써야 하지 않나요? 솔직히Pin을 잘 이해하지 못했습니다.”- @u/manpacket — “
Box로 해결되지만, 매번 할당해야 합니다.Pin을 쓰면 스택에 있는 값도 고정할 수 있습니다.Unpin은 타입이 자기 참조를 포함하지 않아 고정이 필요 없을 때 제약을 풀어줍니다.” - @u/Ryhu997 — “잘 짚으셨습니다.
Box는 박스 밖으로 값을 꺼내지 않는 한 작업을 고정 주소에 둡니다. 간단한 예제에서는 그렇게 하지만, 컴파일러가 이동 금지를 강제하지는 않습니다. 전체 구현은Pin<Box<Task>>와PhantomPinned를 써서 이동 금지 조건을 명시하고 컴파일러가 확인하게 합니다. 글에서 이 부분을 지나치게 단순화한 것 같습니다.” - @u/Modi57 — “자세히 설명해주셔서 감사합니다. 핵심 개념은 이해하지만 주변 세부 사항이 어렵습니다. 제가 이해한 바로는
Unpin을 구현한 타입은 고정한 뒤에도 고정을 해제할 수 있고, 구현하지 않은 타입은 그럴 수 없습니다. 타입은 언제Unpin을 구현해야 하나요? 또pin!()매크로로는!Unpin값을 고정할 수 있는데Pin::new()로는 왜 안 되나요?Pin을 쓸 때마다 제대로 보장을 받는지 확신이 서지 않습니다.Pin은 컴파일러가 보장을 강제하는 장치일 뿐이며,Pin을 없애도 동작하는 코드는 올바르다는 점은 알겠습니다.”
- @u/manpacket — “
원문: dzania.github.io / 번역·요약: Trawling