TECH 으로 돌아가기
TECH HACKER NEWS 오늘 7분 읽기 40 READS

뮤텍스가 병목일 때 — 모던 C++로 락프리 큐를 바닥부터 만들어보기

뮤텍스가 병목일 때 — 모던 C++로 락프리 큐를 바닥부터 만들어보기
SOURCE IMAGE · HACKER NEWS
뮤텍스가 병목일 때 — 모던 C++로 락프리 큐를 바닥부터 만들어보기

멀티스레드 프로그램에서 스레드끼리 데이터를 주고받는 가장 기본적인 방법은 큐에 뮤텍스(mutex)를 채우는 거예요. 뮤텍스가 뭐냐면, 화장실 열쇠 같은 거예요. 열쇠를 가진 스레드만 큐를 만질 수 있고, 나머지는 문 앞에서 기다리는 거죠. 대부분의 애플리케이션에서는 이걸로 충분한데요. 게임 엔진, 오디오 처리, 초단타 트레이딩처럼 마이크로초를 다투는 분야로 가면 이 열쇠 자체가 병목이 돼요. 그래서 등장하는 게 락프리(lock-free) 자료구조고, 이걸 모던 C++로 바닥부터 만들어보는 과정을 다룬 글이 나와서 핵심을 정리해봤어요.

뮤텍스는 뭐가 문제일까

열쇠를 기다리는 스레드는 운영체제가 재웠다가 깨우는데, 이 컨텍스트 스위칭 비용이 마이크로초 단위로 커요. 경쟁이 심하면 스레드들이 열쇠 앞에 줄을 서는 락 컨보이가 생기고, 낮은 우선순위 스레드가 열쇠를 쥔 채 밀려나서 높은 우선순위 스레드까지 막아버리는 우선순위 역전도 일어나요. 특히 오디오 스레드처럼 몇 밀리초 안에 무조건 일이 끝나야 하는 곳에서 락을 기다리다 데드라인을 놓치면 소리가 뚝뚝 끊기죠. 그래서 이 동네에는 “오디오 스레드에서는 절대 락을 잡지 마라”는 격언이 있을 정도예요.

락프리가 뭐냐면

락 없이, CPU가 제공하는 원자적 연산만으로 스레드들이 안전하게 협업하는 방식이에요. 핵심 도구는 CAS(compare-and-swap)인데, “이 메모리 값이 아직 A면 B로 바꿔줘, 다른 스레드가 먼저 바꿨으면 실패를 알려줘”를 CPU가 중간에 끼어들 틈 없이 한 번에 처리해주는 명령이에요. 실패하면 다시 읽고 다시 시도하는 루프를 돌죠. 누가 실패하더라도 전체적으로는 누군가 항상 전진하고 있다는 보장, 이게 락프리의 정의예요.

가장 실용적인 형태: SPSC 링 버퍼

락프리 큐의 입문이자 실무에서 가장 많이 쓰이는 형태는 생산자 하나, 소비자 하나만 있는 SPSC(single-producer single-consumer) 큐예요. 고정 크기 배열을 원형으로 돌려쓰는 링 버퍼에, 쓰는 위치(head)와 읽는 위치(tail) 인덱스를 두는 구조인데요. 묘미는 head는 생산자만 쓰고 tail은 소비자만 쓴다는 점이에요. 각 변수를 고치는 스레드가 하나뿐이니 CAS조차 필요 없고, 원자적인 load와 store만으로 충분하거든요.

대신 메모리 오더링이라는 개념이 등장해요. 컴파일러와 CPU는 성능을 위해 명령 순서를 몰래 바꾸는데, “데이터를 배열에 다 써넣은 다음에 head를 올린다”는 순서가 뒤집히면 소비자가 쓰다 만 데이터를 읽는 참사가 나요. 그래서 C++의 std::atomic에 memory_order_release(이 변수를 쓰기 전에 내가 한 일은 전부 완료됐음을 보장)와 memory_order_acquire(이 변수를 읽고 나면 상대가 한 일이 전부 보임을 보장)를 지정해서 울타리를 쳐요. 기본값인 seq_cst는 가장 안전하지만 더 비싸서, 어디까지 느슨하게 풀어도 되는지 판단하는 게 실력이죠.

성능의 복병: 폴스 셰어링

여기까지 만들어도 성능이 이상하게 안 나올 수 있는데, 범인은 폴스 셰어링(false sharing)인 경우가 많아요. CPU는 메모리를 64바이트짜리 캐시라인 단위로 다루는데, head와 tail이 같은 캐시라인에 나란히 있으면 생산자가 head를 갱신할 때마다 소비자 코어의 캐시가 통째로 무효화돼요. 서로 다른 변수를 만지는데도 같은 줄에 산다는 이유로 캐시를 계속 뺏고 뺏기는 거죠. 해법은 의외로 간단해서, alignas(64)로 두 변수를 서로 다른 캐시라인에 떨어뜨려 놓으면 돼요. 이 한 줄로 처리량이 몇 배 뛰는 경우도 있어요.

실무에서는 어떻게 할까

생산자나 소비자가 여럿인 MPMC 큐로 가면 난이도가 급상승해요. 값을 확인하는 사이에 다른 스레드가 A를 B로 바꿨다가 다시 A로 되돌려서 CAS가 변화를 눈치채지 못하는 ABA 문제 같은 함정이 기다리거든요. 그래서 실무에서는 직접 만들기보다 moodycamel::ConcurrentQueue, Boost.Lockfree, 페이스북 folly의 MPMCQueue처럼 검증된 구현을 쓰는 게 정석이에요. 자바 진영의 LMAX Disruptor도 같은 계열의 유명한 사례고요. 그래도 한 번 직접 만들어보는 경험은 값져요. 원자성, 메모리 오더링, 캐시 동작은 언어를 가리지 않아서, 나중에 Go의 채널이나 Rust의 crossbeam을 쓸 때도 내부에서 무슨 일이 벌어지는지 보이게 되거든요.

정리하면, 락프리 큐는 “락을 없앤다”기보다 “CPU의 원자적 연산과 메모리 모델을 정확히 이해하고 활용한다”에 가까운 기술이에요. 여러분은 실무에서 락 경쟁 때문에 성능 문제를 겪어본 적 있나요? 있다면 어떤 방법으로 푸셨는지 궁금하네요.


🔗 출처: Hacker News

SOURCE · HACKER NEWS
원문 전체 보기 → https://blog.jaysmito.dev/blog/04-fast-lockfree-queues/
SHARE
처리 중...