- 깨진 Rust 동시 카운터를 예제로, 일반 스레드 부하 테스트가 놓치는 문제를 재현 가능하고 최소화 가능한 실행 순서 제어로 드러냄
- 테스트용
AtomicU32래퍼가pause()를 삽입하고, managed thread가 원자 연산 전후에서 멈췄다가 테스트가 고른 순서로 다시 진행됨 - 단순 테스트는
100개 스레드가 각각100번 증가시켜 기대값10000대신9598같은 실패를 만들 수 있지만, 타이밍 의존이라 재현·디버깅·축소가 어려움 arbtest기반 속성 테스트는 같은 seed로 같은 인터리빙을 재현하고, 실패 사례를0: increment,1: increment,0: unpause,1: unpause까지 최소화함- 같은 구조를
exhaustigen으로 확장하면 최대5번 증가까지 모든 인터리빙을 열거할 수 있고,fetch_add수정 뒤81133개 인터리빙이 통과함
원자적이지 않은 동시 카운터
- 예제는 Rust의
AtomicU32를 쓰지만,increment()가load후store(value + 1)를 수행해 증가 연산 자체는 원자적이지 않음 Counter구조는 단순함value: AtomicU32increment()는SeqCst로 값을 읽고, 읽은 값에1을 더해 다시 저장함get()은 현재 값을SeqCst로 읽음
- 두 스레드가 같은 값을 읽은 뒤 같은 증가 결과를 저장할 수 있어 업데이트가 사라짐
일반 스레드 테스트가 부족한 이유
- 가장 단순한 검증은 여러 스레드가 같은 카운터를 반복 증가시키고 마지막 값을 확인하는 방식임
thread_count = 100increment_count = 100- 기대값은
10000
- 예시 실행은
left: 9598,right: 10000으로 실패함 - 이 방식은 스케줄링 타이밍에 크게 의존함
- 같은 실패를 결정적으로 재현하기 어려움
- 디버깅하기 어려움
- 스레드 수나 증가 횟수를 줄이면 운 좋게 통과할 수 있어 실패 사례를 최소화하기 어려움
속성 기반 테스트로 인터리빙 다루기
- 속성 기반 테스트(PBT)는 상태 머신 테스트와 잘 맞음
- 임의 입력을 생성하기 쉬움
- 동시 실행 결과가 순차 실행 모델과 같아야 한다는 속성을 둘 수 있음
- 실패 입력을 최소화하려는 요구와도 맞음
- 난점은 실제 OS 스레드를 원하는 시점에 한 단계씩 진행시키기 어렵다는 데 있음
- 해결책은 각 반복에서 임의의 스레드를 골라 한 단계 진행시키는 구조임
- 한 스레드의
load와store사이에 다른 스레드를 끼워 넣을 수 있어야 함 - 이를 위해 스레드를 직접 제어하는 managed thread API를 구성함
- 한 스레드의
테스트용 AtomicU32와 pause 삽입
- 테스트 빌드에서는
std::sync::atomic::AtomicU32대신 자체managed_thread::AtomicU32를 사용함#[cfg(test)] use managed_thread::AtomicU32#[cfg(not(test))] use std::sync::atomic::AtomicU32
- 래퍼
AtomicU32는load()와store()전후에pause()를 호출함load:pause()→ 실제load→pause()store:pause()→ 실제store→pause()
- 이 삽입점 덕분에 테스트가 원자 연산 주변에서 스레드를 멈추고 다시 진행시키며 실행 순서를 제어할 수 있음
managed thread API의 형태
- 테스트는
std::thread::scope안에서 두 개의 managed thread를 생성함- scoped thread를 사용하므로 스택 지역 데이터를 빌릴 수 있음
spawn(scope, &counter)처럼 카운터 참조를 상태로 전달함
- managed thread는 처음부터 특정
main함수를 실행하지 않고, 제어 스레드가submit()으로 보낸 클로저를 실행함t.submit(|c| c.increment())- 스레드는 자신의 상태
T에 대해 클로저를 실행함
- 테스트 루프는 엔트로피가 남아 있는 동안 각 스레드에 대해 무작위로 행동함
- 스레드가 멈춰 있으면
unpause() - 멈춰 있지 않으면
submit()으로increment()를 실행함 - 순차 모델
counter_model도 같은 횟수만큼 증가시킴
- 스레드가 멈춰 있으면
- 마지막에는 모든 스레드를
join()하고counter_model과 실제counter.get()을 비교함
pause와 unpause 구현
pause()는 테스트 대상CounterAPI를 바꾸지 않기 위해thread_local!로 현재 managed thread의 컨텍스트를 찾음- 컨텍스트는
Arc<SharedContext>로 공유됨 SharedContext는Mutex<State>와Condvar를 가짐
- 컨텍스트는
- 상태는
Ready,Running,Paused로 나뉨Ready: 다음 클로저를 기다리는 상태Running: managed thread가 실행 중인 상태Paused:pause()지점에서 멈춘 상태
- managed thread가
pause()에 도달하면 상태를Running에서Paused로 바꾸고 condition variable로 제어 스레드에 알림 unpause()는 상태를Paused에서Running으로 바꾸고 managed thread를 깨운 뒤, 다시Running이 아닐 때까지 기다림- 제어 스레드와 managed thread가 동시에 계속 실행되는 상황을 막음
- 어느 시점에도 둘 중 하나만 실행되도록 만들어 비결정성을 줄임
실패 재현과 최소화
arbtest실행은 깨진 카운터에서 실패를 찾음- 예시 실패는 모델 값
4, 실제 값3 - 실패 seed는
0x4fd7ddff00000020
- 예시 실패는 모델 값
- 같은 seed를 지정하면 같은 인터리빙을 다시 얻을 수 있어 실패 재현이 쉬워짐
.minimize()를 사용하면 실패 사례가 더 짧은 실행으로 줄어듦- 최종 최소 사례 seed는
0x9c2a13a600000001 - 최소 trace는 네 단계임
0: increment1: increment0: unpause1: unpause
- 최종 최소 사례 seed는
- 이 최소 사례에서는 기대값이
2인데 실제 값이1이 되어load/store기반 증가의 결함이 드러남
모든 인터리빙 열거로 확장
- 같은 구조를 무작위 인터리빙 대신 열거 기반으로 바꿀 수 있음
exhaustigen을 사용해 최대5번 증가까지 모든 인터리빙을 탐색하는 테스트를 작성함- 테스트는 더미 반복을 피하고, 항상 스레드를
unpause하거나increment를 제출하도록 구성됨
- 테스트는 더미 반복을 피하고, 항상 스레드를
- 깨진 구현은 같은 버그를 찾음
- 예시 실패는
left: 2,right: 1
- 예시 실패는
Counter::increment()를fetch_add(1, SeqCst)로 고치면 테스트가 통과함AtomicU32래퍼에도fetch_add()전후pause()를 추가함- 실행 결과는
all 81133 interleavings are fine! - 실행 시간은
real 8.65s, CPU는8.16s, RSS는63.91mb
약한 메모리 모델과 모델 체킹으로의 확장
- 현재 장난감 구현의
AtomicU32는 실제 atomic에 위임함 - 확장 아이디어는 각 atomic마다 쓰인 값들의 집합을 유지하고, 읽기 시 약한 메모리 모델과 일관되는 임의의 값을 반환하는 방식임
- 인터리빙 탐색도 무작위보다 더 똑똑하게 만들 수 있음
- 모델 체킹 접근으로 의미 있게 다른 인터리빙을 모두 고려했는지 확인할 수 있음
- Generate All The Things 방식처럼 작은 범위의 모든 인터리빙을 열거할 수 있음
shrinking 없이 최소화가 가능한 이유
- 사용한
arbtest는 익숙한 PRNG 인터페이스처럼 보이지만 유한 PRNG를 사용함- 랜덤 값을 계속 요청하면 어느 시점에
Err(OutOfEntropy)를 반환함 - 그래서 테스트 코드에
?와while !rng.is_empty()가 등장함
- 랜덤 값을 계속 요청하면 어느 시점에
- 테스트가 엔트로피를 다 쓰면 짧게 종료되므로, 사용 가능한 엔트로피를 줄이면 테스트 실행도 짧아짐
- 내부 구현은 개념적으로
&mut &[u8]에 가까움- 랜덤 숫자를 요청할 때마다 바이트 슬라이스가 줄어듦
- 초기 슬라이스가 짧을수록 더 단순한 테스트가 됨
- 이 방식 덕분에 별도의 shrinking 로직을 직접 구현하지 않아도 실패 사례가 짧아질 수 있음
- 예제 소스 코드는 properly-concurrent에 있음