여기서는 락프리 데이터 구조를 설명한다. 락프리(lock-free) 란 배타락을 이용하지 않고 처리를 수행하는 데이터 구조 및 그에 대한 조작 알고리즘을 총칭한다.
왜 락프리인가?
전통적인 동시성 제어 방법인 뮤텍스나 세마포어는 여러 문제점을 가지고 있다:
성능 저하: 락 경합(lock contention)으로 인한 대기 시간
데드락: 여러 스레드가 서로의 락을 기다리는 상황
우선순위 역전: 낮은 우선순위 스레드가 높은 우선순위 스레드를 블로킹
컨텍스트 스위칭: 락 대기 중 발생하는 오버헤드
락프리 알고리즘은 이러한 문제들을 아토믹한 연산을 통해 해결한다.
락프리 스택
락프리 스택은 선두 요소에 대한 push와 pop 조작만 가진 리스트로 구성된다. 가장 대표적인 구현은 Treiber 스택이다.
참고 자료: 위키피디아-Treiber 스택
Treiber 스택
Treiber 스택은 락프리 스택 알고리즘의 하나로 여러 스레드가 동시에 접근해도 락 없이 안전하게 동작하는 스택이다.
한 문장으로 아이디어를 요약하자면 이렇다.
내가 작업을 시작했을 때의 상태가 그대로라면 내 작업을 진행한다. 누군가가 중간에 이를 바꿔 상태가 바뀌었다면 처음부터 다시 시도한다.
use std::ptr::null_mut;
use std::sync::atomic::{AtomicPtr, Ordering};
struct Node<T> {
next: AtomicPtr<Node<T>>,
data: T,
}
pub struct LockFreeStack<T> {
head: AtomicPtr<Node<T>>,
}
impl<T> LockFreeStack<T> {
pub fn new() -> Self {
LockFreeStack {
head: AtomicPtr::new(null_mut()),
}
}
pub fn push(&self, v: T) {
let node = Box::new(Node {
next: AtomicPtr::new(null_mut()),
data: v,
});
let ptr = Box::into_raw(node);
loop {
let head = self.head.load(Ordering::Acquire);
unsafe {
(*ptr).next.store(head, Ordering::Release);
}
match self.head.compare_exchange_weak(
head,
ptr,
Ordering::Release,
Ordering::Acquire
) {
Ok(_) => break,
Err(_) => continue,
}
}
}
pub fn pop(&self) -> Option<T> {
loop {
let head = self.head.load(Ordering::Acquire);
if head == null_mut() {
return None;
}
let next = unsafe { (*head).next.load(Ordering::Acquire) };
match self.head.compare_exchange_weak(
head,
next,
Ordering::Release,
Ordering::Acquire
) {
Ok(_) => {
let boxed_node = unsafe { Box::from_raw(head) };
return Some(boxed_node.data);
}
Err(_) => continue,
}
}
}
}
impl<T> Drop for LockFreeStack<T> {
fn drop(&mut self) {
let mut current = self.head.load(Ordering::Relaxed);
while current != null_mut() {
let node = unsafe { Box::from_raw(current) };
current = node.next.load(Ordering::Relaxed);
}
}
}
unsafe impl<T: Send> Send for LockFreeStack<T> {}
unsafe impl<T: Send> Sync for LockFreeStack<T> {}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::thread;
#[test]
fn test_single_thread() {
let stack = LockFreeStack::new();
stack.push(1);
stack.push(2);
stack.push(3);
assert_eq!(stack.pop(), Some(3));
assert_eq!(stack.pop(), Some(2));
assert_eq!(stack.pop(), Some(1));
assert_eq!(stack.pop(), None);
}
#[test]
fn test_concurrent_operations() {
let stack = Arc::new(LockFreeStack::new());
let num_threads = 4;
let operations_per_thread = 1000;
let mut handles = vec![];
for i in 0..num_threads {
let stack_clone = Arc::clone(&stack);
let handle = thread::spawn(move || {
for j in 0..operations_per_thread {
stack_clone.push(i * operations_per_thread + j);
stack_clone.pop();
}
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
}
}
메모리 순서
Rust의 원자적 연산에서 사용되는 메모리 순서는 다음과 같다:
Relaxed: 가장 약한 순서 보장. 단일 원자적 변수에 대한 순서만 보장
Acquire: 이 연산 이후의 모든 메모리 연산이 이 연산 이후에 발생하도록 보장
Release: 이 연산 이전의 모든 메모리 연산이 이 연산 이전에 발생하도록 보장
AcqRel: Acquire와 Release의 조합
SeqCst: 가장 강한 순서 보장. 모든 스레드에서 동일한 순서를 관찰
락프리 스택에서는 주로 Acquire/Release 쌍을 사용하여 데이터 경쟁을 방지한다.
락프리에서의 문제점
ABA 문제
락프리 스택은 대부분의 경우 문제가 없으나 특정한 조건에서 ABA 문제가 발생할 수 있다.
ABA 문제는 다음과 같은 예로 설명될 수 있는데

그림에서는 2개의 스레드가 락프리 스택에 push와 pop을 수행하고 있다.
초기 락프리 스택에는 3개의 데이터가 존재하고 각 노드의 주소를 A, B, C라고 하자.
시각 0: 스레드 1은 head의 주소 A와 A의 다음 노드 주소인 B를 기억한다.
시각 1: 스레드 2가 2번의 pop 조작을 수행한다. 그러면 노드 A와 B는 스택에서 제거되고 메모리는 비게(free) 된다.
시각 2: 스레드 2가 새로운 데이터를 push한다. 이때 프리 영역이 된 노드 A가 재사용되면 스택은 A→C라는 상태가 된다.
시각 3: 스레드 1이 pop 조작 이후 CAS 조작을 수행하면 head는 외관상 바뀌지 않지만 해제된 노드B를 이용해 업데이트를 수행하게 된다.
락프리 스택에서는 CAS 조작에 따라 head가 업데이트되지 않았음을 확인한 이후에 데이터의 push와 pop이 이루어지는데, 메모리 영역이 재사용되면 문제가 발생한다.
head가 외관상 바뀌지 않지만 의미적으로는 다른 연산이 수행되었을 수 있는 것이다. 이처럼 ABA문제는 A가 실제로는 중도에 다른 상태(B)로 바뀌었음에도 처음과 끝은 A이므로 처음과 연산 종료 후 결과를 통해서는 업데이트 여부를 제대로 파악할 수 없다는 문제이다.
ABA 문제가 발생하는 이유
ABA 문제가 발생하는 이유는 업데이트 유무가 CAS 처리에 의한 값 비교로 수행되기 때문이다. 값 비교로 작업이 수행되기 때문에 해당 메모리에 어떤 쓰기 작업이 있었는지는 검증하는 과정이 없었다.
그렇다면 문제는 간단해진다. 메모리의 쓰기 작업을 감지할 수 있도록 하면 된다. 이는 Load-Link/Store-Conditional 명령을 이용하면 구현할 수 있다.
ABA 문제 해결 방법
ABA 문제를 해결하는 방법은 여러 가지가 있는데 LL/SC (Load-Link/Store-Conditional): 하드웨어 수준에서 메모리 변경 감지하는 등의 방법이 존재한다.
멀티스레드에서 참조에 대한 문제
락프리 구조는 멀티스레드에서 데이터 삭제에 대한 문제를 일으키기도 한다. 아래 예시는 멀티스레드 참조에 관한 문제를 그림으로 보여준다.

초기 상태에서 리스트는 A→B→C로 연결되어 있다.
시각 0: 스레드 1이 선두 노드를 참조했다고 가정하자.
시각 1: 스레드 2가 pop조작을 수행하고, 선두 노드를 파기한다. 그런데 이때 스레드 1의 참조 x가 댕글링 포인터가 되어버린다.
물론 러스트의 경우 소유권 규칙에 따라 위와 같은 작동을 하는 코드가 컴파일러 선에서 막히기 때문에 실제로 작동될 일은 없으나 가비지 컬렉션(GC)가 없는 언어의 경우 문제가 될 수 있다.
락프리의 분류
락프리는 원래 뮤텍스와 같은 구조들(즉, 락을 말한다)에 의존하지 않는다는 의미로 넓게 사용되었으나 락프리의 정의를 더 좁게 가져가자면
| 분류 | 배타락 사용 | 라이브락 발생 가능 | starvation 발생 가능 | 진행 보장 |
| 배타 락프리 | X | O | O | 없음 |
| 락프리 | X | X | O | 시스템 전체 진행 보장 |
| 웨이트 프리 | X | X | X | 모든 스레드 진행 보장 |
락프리 분류 더 알아보기
배타 락프리 (Obstruction-Free)
가장 약한 진행 보장
다른 스레드가 없을 때만 진행 보장
실용적이지 않아 잘 사용되지 않음
락프리 (Lock-Free)
웨이트 프리 (Wait-Free)
가장 강한 진행 보장
모든 스레드가 유한한 단계 내에 완료
구현이 매우 어렵고 성능 오버헤드가 큼
배타 락프리, 웨이트 프리와 구분되어 부르는 락프리는 라이브락이 발생하지 않는, 락에 의존하지 않는 구조로 정의할 수 있겠다.
가장 보편적으로 락프리라 함은 배타 락을 사용하지 않는 구조, 즉 배타 락프리에 더 가깝다고 할 수 있다.