Spinlocks Considered Harmful (2020)
스핀락은 해롭습니다 (2020)
스핀락은 잠금이 풀릴 때까지 CPU를 점유하며 기다리므로, 잠금 보유 스레드가 선점되면 대기 스레드까지 CPU를 낭비하고 우선순위 역전을 일으킬 수 있습니다. 글은 Rust의 no_std 환경에서도 스핀락을 무조건 대안으로 삼지 말고, 운영체제 뮤텍스나 초기화 구조를 검토하라고 제안합니다.
- 주제
AI 요약
Rust의 once_cell을 관리하는 저자는 no_std 지원 요청에 따라 표준 라이브러리의 뮤텍스를 스핀락으로 바꾸는 관행을 문제 삼습니다. 글에서는 스핀락의 대기 방식이 운영체제 스케줄러와 상호작용할 때 생기는 문제를 설명하고, 실제 Rust 크레이트를 이용해 우선순위 역전을 재현합니다.
스핀락과 운영체제 뮤텍스
스핀락은 compare_and_swap을 반복해 잠금을 얻고, 잠금을 풀 때는 원자적 저장을 수행합니다. 대기 스레드는 사용자 공간에서 계속 실행되며, 운영체제 입장에서는 계산 작업을 수행하는 스레드와 다르지 않습니다. 반면 std::sync::Mutex나 parking_lot::Mutex 같은 뮤텍스는 대기 시간이 길어지면 시스템 호출로 현재 스레드를 CPU에서 내려놓습니다. 운영체제는 잠금을 기다리는 스레드를 큐에 넣고, 잠금이 풀리면 대기 스레드를 깨웁니다. 실제 뮤텍스도 시스템 호출 비용을 줄이려고 먼저 짧게 스핀하지만, 계속 기다려야 하면 결국 운영체제에 맡깁니다.
선점과 우선순위 역전
짧은 임계 구역이라도 스레드는 언제든 선점될 수 있습니다. 잠금을 쥔 스레드가 선점되면 다른 스레드는 잠금이 풀릴 때까지 스핀합니다. 이때 대기 스레드도 CPU를 쓰는 작업으로 보이므로, 잠금을 쥔 스레드가 다시 실행될 기회를 얻기 전에 대기 스레드들이 CPU 할당량을 소진할 수 있습니다.
우선순위가 다른 스레드가 섞이면 상황은 더 나빠집니다. 낮은 우선순위 스레드가 잠금을 쥔 상태에서 선점되고, 높은 우선순위 스레드들이 그 잠금을 기다리면 운영체제는 높은 우선순위 스레드를 계속 실행합니다. 코어 수보다 대기 중인 높은 우선순위 스레드가 많으면 낮은 우선순위 스레드가 임계 구역을 마치지 못해 교착에 가까운 상태가 이어질 수 있습니다.
no_std와 인터럽트
no_std가 곧 운영체제가 없는 환경을 뜻하지는 않습니다. 일반 사용자 공간 프로그램도 Rust 런타임 전체를 끌어오지 않으려고 no_std 크레이트를 사용할 수 있습니다. 따라서 운영체제의 선점과 우선순위 역전 문제는 여전히 생깁니다.
베어메탈이나 커널처럼 운영체제가 없는 환경에서는 인터럽트가 문제가 됩니다. 일반 코드가 임계 구역에 있는 동안 주변 장치 인터럽트가 발생하고, 인터럽트 처리기도 같은 스핀락을 얻으려 하면 교착 상태가 보장됩니다. 인터럽트 처리기가 잠금을 풀어 줄 일반 코드를 기다리는 동안, 그 코드는 인터럽트가 끝나기를 기다리기 때문입니다.
getrandom으로 재현한 사례
저자는 getrandom 크레이트를 사례로 듭니다. 이 크레이트는 /dev/random 파일 디스크립터를 LazyUsize에 저장하며, 초기화 과정에서 스핀락을 사용합니다. 저자는 스레드 하나에 낮은 우선순위를 주고 나머지 스레드에는 높은 우선순위를 설정한 뒤, 초기화가 느려지도록 환경을 구성했습니다. 또한 getrandom이 시스템 호출 경로 대신 /dev/random 경로를 택하도록 syscall과 libc 함수를 덮어쓰는 시스템 프로그래밍 기법을 사용했습니다.
실행은 2분이 지나도 끝나지 않아 저자가 직접 종료했습니다. 반면 getrandom의 동기화 방식을 std::sync::Once로 바꾸면 약 0.5초 만에 끝났습니다. Once가 운영체제의 대기 기능을 사용하므로, 운영체제가 대기 중인 높은 우선순위 스레드를 실행에서 제외하고 낮은 우선순위 스레드가 초기화를 마치도록 스케줄링했기 때문입니다.
대안
작은 임계 구역이라 빠를 것이라는 이유만으로 스핀락을 선택하지 말고 std나 parking_lot의 뮤텍스를 사용하라고 권합니다. 이 뮤텍스는 잠금이 곧 풀릴 때는 짧게 스핀하고, 오래 기다릴 때는 운영체제에 대기를 맡깁니다.
일회성 초기화에는 스핀락 대신 사용자에게 상태 저장을 맡기거나, std::sync::Once 같은 초기화 프리미티브를 활용하는 방법도 제안합니다. 상태가 usize에 들어가고 초기화 함수가 멱등적이며 빠르다면 경합을 허용하는 초기화 방식도 고려할 수 있다고 설명하지만, 글의 편집 메모에서는 초기화 함수가 코어 수만큼만 호출된다는 주장을 정정합니다. 라이브러리가 동기화 프리미티브를 직접 제공하지 않고 사용자에게 선택하게 하는 방법도 있습니다. 단일 스레드만 사용하는 환경에서는 잠금 대신, 실제 블로킹이 발생하면 패닉을 내는 방식도 대안으로 듭니다.
Lobsters 반응
- @nrposner — 이번 주에만 우선순위 역전에 관한 글을 두 번째로 읽었습니다. 비가 오면 계속 퍼붓는 법이지요. https://bcantrill.dtrace.org/2005/06/14/opensolaris-sewer-tour
원문: matklad.github.io / 번역·요약: Trawling