2P by neo 2달전 | favorite | 댓글 1개

동시성 데이터 구조의 적절한 테스트

하나, 둘, 셋, 둘

  • Rust 라이브러리인 loom을 사용하여 락-프리 데이터 구조를 철저히 테스트할 수 있음
  • 간단한 동시성 카운터 예제 코드 제공
  • 코드의 버그는 증가 연산이 원자적이지 않다는 것임
use std::sync::atomic::{AtomicU32, Ordering::SeqCst};

#[derive(Default)]
pub struct Counter {
    value: AtomicU32,
}

impl Counter {
    pub fn increment(&self) {
        let value = self.value.load(SeqCst);
        self.value.store(value + 1, SeqCst);
    }

    pub fn get(&self) -> u32 {
        self.value.load(SeqCst)
    }
}

간단한 테스트

  • 여러 스레드에서 같은 카운터를 반복적으로 증가시키고 결과를 확인하는 테스트
  • 테스트는 성공적으로 실패하지만, 타이밍에 의존하여 재현이 어려움
#[test]
fn threaded_test() {
    let counter = Counter::default();
    let thread_count = 100;
    let increment_count = 100;

    std::thread::scope(|scope| {
        for _ in 0..thread_count {
            scope.spawn(|| {
                for _ in 0..increment_count {
                    counter.increment()
                }
            });
        }
    });

    assert_eq!(counter.get(), thread_count * increment_count);
}

속성 기반 테스트 (PBT)

  • 상태 기계를 테스트하는 데 적합한 속성 기반 테스트 적용 시도
  • 스레드를 수동으로 단계별로 실행할 수 있다면, 다른 스레드의 load&store 사이에 끼워 넣기 쉬움
#[test]
fn state_machine_test() {
    arbtest::arbtest(|rng| {
        let mut state: i32 = 0;
        let step_count: usize = rng.int_in_range(0..=100)?;

        for _ in 0..step_count {
            match *rng.choose(&["inc", "dec"])? {
                "inc" => state += 1,
                "dec" => state -= 1,
                _ => unreachable!(),
            }
        }
        Ok(())
    });
}

간단한 계측

  • 스레드가 원자적 연산 사이에서 "일시 정지"할 수 있도록 하는 방법
pub fn increment(&self) {
    pause();
    let value = self.value.load(SeqCst);
    pause();
    self.value.store(value + 1, SeqCst);
    pause();
}

fn pause() {
    // ¯\_(ツ)_/¯
}

관리된 스레드 API

  • API 디자인의 한 규칙은 단일 사용자부터 시작하여 API의 느낌을 이해한 후 실제 구현을 진행하는 것
  • 관리된 스레드를 사용하여 속성 기반 테스트 작성
let counter = Counter::default();
let t1 = managed_thread::spawn(&counter);
let t2 = managed_thread::spawn(&counter);

while !rng.is_empty() {
    let coin_flip: bool = rng.arbitrary()?;
    if t1.is_paused() {
        if coin_flip {
            t1.unpause();
        }
    } else if t2.is_paused() {
        if coin_flip {
            t2.unpause();
        }
    }
}

관리된 스레드 구현

  • 제어 스레드와 관리된 스레드 간의 통신 필요
  • 상태를 보호하는 뮤텍스와 조건 변수를 사용하여 구현
struct SharedContext {
    state: Mutex<State>,
    cv: Condvar,
}

#[derive(PartialEq, Eq, Default)]
enum State {
    #[default]
    Running,
    Paused,
}

impl SharedContext {
    fn pause(&self) {
        let mut guard = self.state.lock().unwrap();
        assert_eq!(*guard, State::Running);
        *guard = State::Paused;
        self.cv.notify_all();
        guard = self.cv.wait_while(guard, |state| *state == State::Paused).unwrap();
        assert_eq!(*guard, State::Running);
    }
}

전체 코드 통합

  • 관리된 스레드와 테스트 코드 통합
#[test]
fn test_counter() {
    arbtest::arbtest(|rng| {
        eprintln!("begin trace");
        let counter = Counter::default();
        let mut counter_model: u32 = 0;

        std::thread::scope(|scope| {
            let t1 = managed_thread::spawn(scope, &counter);
            let t2 = managed_thread::spawn(scope, &counter);
            let mut threads = [t1, t2];

            while !rng.is_empty() {
                for (tid, t) in threads.iter_mut().enumerate() {
                    if rng.arbitrary()? {
                        if t.is_paused() {
                            eprintln!("{tid}: unpause");
                            t.unpause()
                        } else {
                            eprintln!("{tid}: increment");
                            t.submit(|c| c.increment());
                            counter_model += 1;
                        }
                    }
                }
            }

            for t in threads {
                t.join();
            }
            assert_eq!(counter_model, counter.get());

            Ok(())
        })
    });
}

GN⁺의 정리

  • 이 글은 동시성 데이터 구조를 테스트하는 방법을 설명함
  • Rust의 loom 라이브러리를 사용하여 원자적이지 않은 연산을 테스트하는 방법을 탐구함
  • 관리된 스레드를 사용하여 동시성 문제를 재현 가능하고 디버깅 가능한 방식으로 테스트함
  • 이 글은 동시성 프로그래밍에 관심이 있는 개발자에게 유용할 것임
  • 비슷한 기능을 가진 프로젝트로는 Java의 JCStress가 있음
Hacker News 의견
  • Rust로 Temper라는 라이브러리를 개발 중이며, Rust 메모리 모델의 복잡한 부분을 다루기 위해 많은 노력이 필요함

    • C++/Rust 메모리 모델의 가장 큰 테스트 케이스 모음을 포함하고 있음
    • Loom은 더 완전한 라이브러리로, 뮤텍스와 큐 같은 고수준 구조를 철저히 테스트할 수 있게 해줌
    • Foundation DB 테스트 강연에서 영감을 받았으며, WebAssembly가 이 분야에서 중요한 역할을 할 것이라고 믿음
  • Rust에서 공유 메모리 원자 스냅샷을 구현했으며, 자동화된 테스트를 매우 중요하게 생각함

    • 처음에는 Loom을 사용했지만 나중에 Shuttle로 전환함
    • Shuttle은 무작위화된 접근 방식을 사용하며, 버그를 찾는 확률적 보장을 제공함
    • Shuttle은 더 빠르고 복잡한 테스트 시나리오에 잘 확장됨
    • 실패한 테스트를 재현할 수 있는 기능이 매우 중요함
  • 이 접근 방식의 단점은 테스트 코드에 맞게 코드 자체를 수정해야 한다는 점임

    • 두 스레드를 실행하고 ptrace로 단일 스텝을 통해 명령어 실행을 무작위로 섞는 방법으로도 가능할 것임
  • JetBrains의 Lincheck은 Kotlin/Java 세계에서 좋은 라이브러리임

    • 선언적이며 선형화 결과를 출력하는 방식이 마음에 듦
  • C++용 "Loom" 같은 라이브러리가 있는지 궁금함

    • 잠금 없는 데이터 구조를 테스트하고 싶음
  • 이 접근 방식은 소프트 전진 보장에 한계가 있을 수 있음

    • cmpxchg 루프에서 실제 하드웨어와 스케줄러에서는 중단될 가능성이 매우 낮음
    • 그러나 이 테스트 접근 방식에서는 작업 수와 일시 중지 횟수에 따라 전진 확률이 달라짐
  • 실질적인 지식이 필요하며, 실제 스레드를 생성해야 함

    • 비동기 런타임을 사용할 수 있는지 궁금함
  • ptrace를 사용해 스레드를 단일 스텝으로 실행하여 명령어 수준에서 다른 인터리빙을 만들 수 있음

    • 블랙박스 테스트 대안이 있는지 궁금함
  • Loom을 사용하려면 조건부 컴파일을 사용해야 하며, 이는 다소 침입적임

    • 다른 언어가 자체 스케줄러를 사용하는 데 더 나은지 궁금함
  • Python에서 동일한 작업을 수행하는 방법을 알고 싶음

    • 이와 같은 테스트를 허용하는 스레드 클래스를 만들 수 있는지 궁금함