멀티스레드와 동기화를 한 줄기로: 스레드, 락, 락프리, 메모리 순서, 거짓 공유
목차
멀티스레드에서 생기는 문제는 대부분 스레드들이 데이터를 공유하는 순간부터 시작합니다. 이 글은 그 순간부터 치르는 비용을 순서대로 따라갑니다. 뼈대는 메이퇀 기술 블로그의 글 “基本功 | 一文讲清多线程和多线程同步”를 따랐고 예제는 Java로 옮겼으며 수치는 제 기계에서 직접 쟀습니다.
먼저 결론입니다.
- 스레드는 실행 흐름 하나입니다. 리눅스 커널은 프로세스와 스레드를 모두 태스크로 다룹니다.
- 동기화가 필요한 자리는 세 가지 모양으로 나타납니다. 쓰는 도중의 값이 읽히는 자리, 읽고 고치고 쓰는 사이에 끼어드는 자리, 확인하고 행동하는 사이에 끼어드는 자리입니다.
- 락은 한 번에 하나만 지나가게 하고 조건 변수는 신호가 올 때까지 스레드를 재웁니다.
- 락프리는 한 스레드가 멈춰도 다른 스레드가 나아간다는 성질의 이름입니다. CAS로 만든 스핀락에는 이 성질이 없습니다.
- 코드에 쓴 순서와 메모리에 반영되는 순서는 다를 수 있습니다. 일반 필드로 잰 3,600만 회 중 787만 회에서 두 스레드가 둘 다 상대의 옛 값을 읽었고
volatile을 붙이자 0회였습니다. - 서로 다른 변수라도 같은 64바이트 안에 있으면 변수 하나를 같이 쓰는 것만큼 느렸습니다. 같은 64바이트 블록이면 약 730ms, 블록이 다르면 약 97ms였습니다.
- 가장 싼 동기화는 스레드마다 사본을 두는 것입니다. 스레드 4개가 카운터를 올릴 때
AtomicLong은 2,386ms,LongAdder는 126ms였습니다.
깊게 들어가는 내용은 같은 시리즈의 글에 이미 있어서 이 글은 줄기만 잡고 그쪽으로 잇습니다.
| 이 글의 장 | 더 깊게 다룬 글 |
|---|---|
| 1. 스레드 | 스프링 MVC는 왜 스레드를 최대 200개까지 사용할까, 유저 스레드: 커널 없이 갈아타기 |
| 3. 락 | 세마포어와 뮤텍스, 락 메커니즘의 모든 것, 멀티코어로 가다: SMP와 락 |
| 4. 조건 변수, 5. 락프리, 8. 스레드마다 사본 두기 | Java 동시성 컬렉션과 유틸리티 |
1. 스레드는 실행 흐름 하나입니다
스레드는 차례로 실행되는 명령의 흐름 하나와 그 흐름에 딸린 상태입니다. 호출 스택, 레지스터 값, 스레드 전용 데이터가 그 상태입니다. 같은 프로세스의 스레드들은 주소 공간과 열린 파일을 함께 써서 전역 변수와 힙의 객체가 모든 스레드에게 보입니다.
코드에는 할 일이 적혀 있고 그 일을 실제로 하는 것은 코어입니다. 메이퇀 글은 앞쪽을 논리 스레드, 뒤쪽을 하드웨어 스레드로 나눠 부릅니다. 어느 스레드를 어느 코어에서 언제 돌릴지는 운영체제의 스케줄러가 정합니다. 코어가 하나뿐이어도 스케줄러가 시간을 짧게 잘라 번갈아 실행하므로 멀티스레드 프로그램은 돕니다.
리눅스 커널에는 프로세스와 스레드의 구분이 없습니다. Linus Torvalds는 1996년 메일에서 이렇게 적었습니다.
There is NO reason to think that “threads” and “processes” are separate entities.
Torvalds가 든 단위는 실행 컨텍스트입니다. CPU 상태, 페이지 매핑, 권한, 열린 파일과 시그널 핸들러가 한 컨텍스트의 상태이고 리눅스는 이것을 태스크라고 부릅니다. 태스크끼리 상태의 일부를 공유할 수 있고 주소 공간까지 공유하는 태스크들을 우리가 스레드라고 부릅니다.
Java의 플랫폼 스레드는 이 커널 스레드에 대개 1대1로 대응합니다. JDK 21의 가상 스레드는 Java 런타임이 스케줄하는 사용자 모드 스레드입니다.
스레드가 앞으로 나아가지 못하는 경우는 셋입니다.
- 락을 얻지 못해 잠듭니다. 스케줄러가 그 코어에 다른 스레드를 올립니다.
- 데이터가 오지 않은 소켓을 읽을 때처럼 블로킹 호출 안에서 기다립니다.
- 코어를 쥔 채 같은 확인을 되풀이합니다. 실행은 되고 있지만 일은 나아가지 않습니다.
스레드를 늘릴 때 치르는 컨텍스트 스위칭 비용과 풀 크기는 스프링 MVC는 왜 스레드를 최대 200개까지 사용할까에서 다뤘습니다.
2. 동기화가 필요한 자리는 세 가지 모양입니다
동기화가 필요한 자리는 세 가지 모양으로 나타나고 메이퇀 글이 든 예제 여섯 개도 이 셋으로 묶입니다. 메이퇀 글은 이 자리를 알아보는 일이 동기화 수단을 익히는 일보다 중요하다고 적습니다.
쓰는 도중의 값이 읽힙니다. 여러 필드가 함께 바뀌어야 뜻이 맞는 객체가 있습니다. 한 스레드가 필드 하나를 고친 직후에 다른 스레드가 읽으면 반만 바뀐 객체를 봅니다.
class Range { int low, high; // 항상 low <= high 여야 한다 void set(int l, int h) { low = l; high = h; } boolean contains(int v) { return low <= v && v <= high; }}set(10, 20)이 low만 쓴 순간에 contains가 돌면 옛 high와 새 low를 함께 읽습니다.
읽고 고치고 쓰는 사이에 끼어듭니다. count++는 읽기, 더하기, 쓰기 세 단계입니다. 두 스레드가 같은 값을 읽고 각자 1을 더해 쓰면 증가는 한 번만 남습니다.
확인하고 행동하는 사이에 끼어듭니다. 값을 읽고 그 값에 따라 다음 동작을 하는 코드에서 생깁니다.
if (!map.containsKey(key)) { // 확인 map.put(key, load(key)); // 행동}두 스레드가 모두 “없다”를 확인한 뒤 둘 다 넣습니다. 맵 자체가 스레드에 안전해도 이 두 줄 사이는 안전하지 않습니다. putIfAbsent나 computeIfAbsent처럼 확인과 행동을 한 동작으로 묶은 메서드를 써야 합니다.
메이퇀 글이 질문으로만 남긴 예제 두 개에는 여기서 답을 붙입니다.
- 정수 하나를 여러 스레드가 그냥 읽고 써도 되는지. Java에서
int와 참조의 읽기와 쓰기는 원자적이어서 반만 쓰인 값은 읽히지 않습니다.volatile이 아닌long과double은 다릅니다. Java 언어 명세는 이 쓰기를 32비트 쓰기 두 번으로 취급한다고 적습니다. 값이 찢어지지 않는 것과 다른 스레드가 쓴 값이 언제 보이는지는 별개의 문제입니다. - 한 스레드만 쓰고 한 스레드만 읽는 링 버퍼에 락이 필요한지. 쓰는 쪽은
in만, 읽는 쪽은out만 고치므로 락 없이 성립합니다. 리눅스 커널의 kfifo가 이 구조이고 소스의 주석에 “with only one concurrent reader and one concurrent writer, you don’t need extra locking”이라고 적혀 있습니다. 다만 데이터를 먼저 쓰고 인덱스를 나중에 옮기는 순서가 다른 코어에도 그 순서로 보여야 합니다. kfifo는 인덱스를 옮기기 직전에 쓰기 배리어를 둡니다. 값이 언제 보이는지와 배리어는 6장에서 함께 다룹니다.
세 모양 모두에서 락이 지켜야 하는 대상은 코드가 건드리는 데이터입니다.
3. 락은 한 번에 하나만 지나가게 합니다
락은 공유 데이터를 한 번에 한 스레드만 지나가게 합니다. 메이퇀 글은 이 제약을 고속도로 검문소에 견줍니다. 차선이 여럿이어도 검문소가 한 칸이면 차는 한 대씩 지나갑니다.
| 수단 | 얻지 못하면 | 맞는 자리 |
|---|---|---|
뮤텍스 (synchronized, ReentrantLock) | 잠들어 코어를 내놓음 | 대부분의 경우 |
읽기-쓰기 락 (ReentrantReadWriteLock) | 쓰기가 있으면 잠듦. 읽기끼리는 함께 들어감 | 읽기가 쓰기보다 훨씬 많은 데이터 |
| 스핀락 | 코어를 쥔 채 다시 시도 | 락을 쥐는 시간이 스레드를 재웠다 깨우는 시간보다 짧은 커널 코드 |
락은 약속입니다. 모든 스레드가 “잠금, 사용, 해제”의 순서를 지켜야 성립하고 한 스레드라도 락 없이 같은 데이터를 건드리면 컴파일은 되고 결과가 틀립니다.
스핀락은 사용자 공간 코드에는 권하지 않습니다. 락을 쥔 스레드가 시간 할당이 끝나 코어에서 내려가면 기다리는 스레드들은 그동안 코어만 태웁니다. 리눅스 커널의 뮤텍스는 돌기와 재우기를 섞습니다. 커널 문서는 락을 쥔 쪽이 지금 실행 중이면 곧 풀 가능성이 크다고 보고 먼저 잠깐 돌다가 그래도 얻지 못하면 재운다고 설명합니다.
데드락의 전형은 둘입니다. 스레드 1이 A를 쥐고 B를 기다리는데 스레드 2가 B를 쥐고 A를 기다리는 경우와 재진입을 지원하지 않는 락을 쥔 스레드가 같은 락을 다시 요청하는 경우입니다. 앞의 경우는 모든 스레드가 같은 순서로 락을 잡게 하거나 tryLock으로 실패하면 쥔 것을 내려놓게 해서 풉니다.
뮤텍스와 세마포어의 차이, 식사하는 철학자, 우선순위 역전은 세마포어와 뮤텍스에서 다뤘습니다. 하드웨어 명령부터 DB 락과 분산 락까지는 락 메커니즘의 모든 것에 있습니다.
4. 조건 변수는 신호가 올 때까지 스레드를 재웁니다
락은 스레드들이 동시에 들어가지 못하게 막습니다. 어떤 일이 끝날 때까지 기다리게 하는 것은 다른 문제입니다. 큐에서 메시지를 꺼내 처리하는 작업 스레드를 락만으로 쓰면 이렇게 됩니다.
while (true) { Message m; synchronized (queue) { m = queue.poll(); } if (m != null) process(m); // 없으면 곧바로 다시 확인한다}메시지가 없어도 스레드는 쉬지 않고 큐를 확인합니다. 확인 사이에 잠깐 재우면 얼마나 재울지가 문제가 되는데 길면 메시지 처리가 늦고 짧으면 코어를 헛되이 씁니다. 메이퇀 글의 비유로는 택배가 왔는지 보러 계속 계단을 오르내리는 것입니다. 도착하면 전화를 받는 편이 낫습니다.
조건 변수가 그 전화입니다.
lock.lock();try { while (queue.isEmpty()) notEmpty.await(); // 락을 놓고 잠든다 return queue.poll();} finally { lock.unlock();}await는 락을 놓는 것과 잠드는 것을 한 동작으로 합니다. 깨어날 때는 락을 다시 잡은 뒤에 돌아옵니다. 넣는 쪽은 락을 잡고 큐에 넣은 뒤 notEmpty.signal()을 부릅니다.
조건을 while로 확인하는 이유는 둘입니다. 깨어난 스레드가 락을 다시 잡기 전에 다른 소비자가 먼저 꺼내 갔을 수 있습니다. 또 Java 문서는 알림 없이 깨어나는 가짜 깨움을 허용하며 항상 반복문 안에서 기다리라고 적습니다.
실제 코드에서는 BlockingQueue가 이 구조를 감싸 두어서 직접 쓸 일이 드뭅니다. 구현체별 설명은 Java 동시성 컬렉션에 있습니다.
5. 락프리는 한 스레드가 멈춰도 나머지가 나아간다는 성질입니다
락을 쥔 스레드가 멈추면 그 락을 기다리는 스레드도 모두 멈춥니다. 락프리는 이 약점이 없는 알고리즘에 붙는 이름입니다. 위키백과의 정의는 이렇습니다.
An algorithm is lock-free if, when the program threads are run for a sufficiently long time, at least one of the threads makes progress.
락프리인지는 한 스레드를 아무 지점에서나 세워도 다른 스레드가 계속 나아가는지로 가립니다. 그래서 synchronized를 쓰지 않았다고 락프리가 되지는 않습니다. CAS를 반복해 만든 스핀락은 락 객체가 없어도 쥔 스레드가 멈추면 나머지가 모두 헛돕니다.
락프리 코드의 재료는 CAS입니다. “지금 값이 내가 읽은 그 값이면 새 값으로 바꾼다”를 CPU가 한 명령으로 해 줍니다.
int old, next;do { old = counter.get(); next = old + 1;} while (!counter.compareAndSet(old, next)); // 그 사이 바뀌었으면 다시읽기와 CAS 사이에 다른 스레드가 값을 바꾸면 CAS가 실패하고 이 스레드는 다시 읽습니다. 어느 스레드가 중간에 멈춰도 다른 스레드의 CAS는 성공합니다. 한 스레드가 계속 지기만 할 수는 있습니다. 락프리는 전체의 전진을 보장하고 각 스레드의 전진까지는 보장하지 않습니다.
락프리도 경합이 심하면 재시도가 늘고 같은 캐시 라인을 코어들이 서로 빼앗는 비용을 치릅니다. 이 비용은 7장과 8장에서 쟀습니다. CAS의 하드웨어 구현과 ABA 문제는 락 메커니즘의 모든 것에, Atomic 클래스들은 Java 동시성 컬렉션에 있습니다.
6. 코드에 쓴 순서와 메모리에 반영되는 순서는 다릅니다
스레드 하나만 보면 코드는 쓴 순서대로 실행된 것처럼 보입니다. 다른 코어에서 보면 그 순서가 달라질 수 있습니다. 순서가 달라지는 원인은 컴파일러와 CPU입니다. 컴파일러는 결과가 같은 범위에서 명령 순서를 바꾸고 Java에서는 JIT 컴파일러가 이 일을 합니다. CPU는 쓰기를 곧바로 캐시에 반영하지 않습니다.
쓰기는 스토어 버퍼에 먼저 들어갑니다
코어가 어떤 변수에 쓰려는데 그 캐시 라인이 자기 캐시에 없으면 라인을 가져올 때까지 기다려야 합니다. Paul McKenney의 글은 이 멈춤을 없애려고 코어와 캐시 사이에 스토어 버퍼를 두었다고 설명합니다. 코어는 쓰기를 버퍼에 적어 두고 다음 명령으로 넘어갑니다. 라인이 도착하면 버퍼의 값이 캐시로 옮겨집니다. 자기가 쓴 값은 자기 버퍼에서 바로 읽으므로 그 코어 혼자서는 순서가 어긋나 보이지 않습니다. 다른 코어의 버퍼는 보이지 않습니다.
받는 쪽에도 같은 종류의 지연이 있습니다. 다른 코어가 “그 라인을 무효로 하라”는 메시지를 보내면 받은 코어는 메시지를 무효화 큐에 넣고 곧바로 확인 응답을 보냅니다. 실제 무효화는 응답을 보낸 뒤로 미루므로 그 사이에는 낡은 값을 읽을 수 있습니다.
메모리 배리어는 이 두 지연에 선을 긋는 명령입니다. 리눅스 커널 문서의 정의로는 쓰기 배리어가 배리어 앞의 쓰기를 뒤의 쓰기보다 먼저 일어난 것으로 보이게 하고 읽기 배리어가 읽기의 순서를 똑같이 보장합니다.
어디까지 뒤바뀌는지는 아키텍처마다 다릅니다
x86에서는 “쓰기 뒤에 온 읽기가 그 쓰기보다 먼저 일어난 것처럼 보이는 것” 하나만 허용됩니다. 쓰기끼리의 순서는 지켜지므로 x86에서는 리눅스의 smp_wmb()가 CPU 명령을 내지 않습니다. ARM과 POWER는 읽기와 쓰기의 네 조합이 모두 뒤바뀔 수 있습니다. McKenney의 글에 CPU별로 허용되는 재배열을 정리한 표가 있습니다.
메이퇀 글의 예제는 한 코어가 a = 1; b = 2;를 실행했는데 다른 코어가 b의 새 값은 보고 a의 새 값은 보지 못하는 경우입니다. 쓰기끼리의 순서가 뒤바뀌는 경우라서 ARM에서는 일어날 수 있고 x86에서는 하드웨어만으로는 일어나지 않습니다. 원문은 아키텍처를 밝히지 않았습니다.
일반 필드에서는 두 스레드가 둘 다 0을 읽었습니다
x86에서도 허용되는 그 한 가지는 어느 기계에서나 잴 수 있습니다. 이 글의 측정은 ARM 기계에서 했습니다. 스레드 1은 x에 1을 쓰고 y를 읽습니다. 스레드 2는 y에 1을 쓰고 x를 읽습니다.
// 스레드 1 // 스레드 2p.x = 1; p.y = 1;p.r1 = p.y; p.r2 = p.x;네 줄을 어떤 순서로 끼워 넣어도 r1과 r2가 둘 다 0일 수는 없습니다. 먼저 쓴 쪽이 있으면 다른 쪽은 1을 읽어야 합니다. 객체 200만 개를 두 스레드가 같은 순서로 훑으며 이 동작을 하게 하고 18번 되풀이했습니다.
| 필드 | (0, 0) | (0, 1) | (1, 0) | (1, 1) | 합 |
|---|---|---|---|---|---|
일반 int | 7,867,312 | 12,287,702 | 15,842,202 | 2,784 | 36,000,000 |
volatile int | 0 | 19,929,144 | 16,070,763 | 93 | 36,000,000 |
일반 필드에서는 불가능해야 할 (0, 0)이 787만 회 나왔고 volatile에서는 0회입니다. (1, 1)이 93회 있으므로 volatile 실행에서도 두 스레드가 같은 객체에서 실제로 겹쳤습니다. 0회는 겹친 상태에서 나온 값입니다.
이 실험은 (0, 0)이 나온다는 결과만 확인했고 원인이 스토어 버퍼인지 JIT의 재배열인지는 가르지 못합니다. Java 메모리 모델은 일반 필드에서 둘 다 허용합니다.
Java에서는 volatile과 락이 선을 긋습니다
Java 코드에는 배리어를 직접 쓰지 않습니다. 언어 명세가 보장을 정해 두었습니다.
volatile변수에 쓴 것은 그 뒤에 그 변수를 읽는 모든 스레드에게 보입니다.- 모니터를 푸는 동작은 그 뒤에 같은 모니터를 잡는 동작보다 먼저입니다. 그래서
synchronized안에서 쓴 값은 다음에 같은 락을 잡은 스레드에게 보입니다. Thread.start()를 부르기 전에 쓴 값은 시작된 스레드에게 보입니다.
2장의 첫 질문에서 남은 답은 여러 스레드가 읽는 플래그나 설정 값을 volatile로 선언하는 것입니다. 둘째 질문의 링 버퍼를 Java로 쓴다면 in과 out이 volatile이어야 데이터를 쓴 뒤 인덱스를 옮긴 순서가 읽는 쪽에 그대로 보입니다.
7. 다른 변수라도 같은 64바이트에 있으면 같이 느려집니다
캐시는 라인 단위로 움직입니다. 코어가 어떤 변수에 쓰려면 그 변수가 든 라인 전체를 혼자 가져야 하고 다른 코어가 가진 사본은 무효가 됩니다. 두 스레드가 서로 다른 변수를 고쳐도 두 변수가 한 라인에 있으면 코어들은 쓸 때마다 그 라인을 서로 빼앗습니다. 논리로는 공유하지 않는데 하드웨어에서는 공유하고 있어서 거짓 공유라고 부릅니다.
간섭하는 단위는 이 기계에서 64바이트였습니다
두 스레드가 각자 자기 long 하나를 2천만 번 원자적으로 증가시키게 하고 두 값의 위치만 바꿔 가며 쟀습니다. 메모리는 힙 밖에 잡아 128바이트 경계에 맞췄습니다.
| 두 값의 위치 (바이트) | 같은 64바이트 블록 | 같은 128바이트 블록 | 중앙값 | 최소 ~ 최대 |
|---|---|---|---|---|
| 0, 8 | 예 | 예 | 749.0ms | 699.3 ~ 810.0 |
| 0, 56 | 예 | 예 | 750.5ms | 679.8 ~ 859.3 |
| 64, 120 | 예 | 예 | 710.6ms | 674.3 ~ 764.8 |
| 0, 64 | 아니오 | 예 | 97.4ms | 97.0 ~ 113.5 |
| 56, 64 | 아니오 | 예 | 118.2ms | 97.1 ~ 202.7 |
| 0, 120 | 아니오 | 예 | 110.1ms | 97.3 ~ 155.0 |
| 0, 128 | 아니오 | 아니오 | 97.6ms | 97.3 ~ 97.9 |
| 0, 1024 | 아니오 | 아니오 | 97.5ms | 96.8 ~ 97.8 |
같은 64바이트 블록이면 710~750ms이고 블록이 다르면 97~118ms입니다. 코드는 같고 두 값 사이의 거리만 다른데 약 7.5배입니다. 8바이트 떨어진 값과 56바이트 떨어진 값이 같은 시간이므로 같은 블록인지만 상관이 있고 가까운 정도는 상관이 없습니다.
측정하면서 따로 적어 둘 것이 둘 있습니다.
- macOS의
sysctl hw.cachelinesize는 이 기계의 캐시 라인을 128바이트라고 알려 줍니다. 그런데 같은 128바이트 블록 안이라도 64바이트 경계를 넘으면 느려지지 않았습니다. 이 실험에서 간섭한 단위는 64바이트입니다. 두 값이 다른 이유는 확인하지 못했습니다. - 힙 안의 배열로 먼저 쟀을 때는 같은 위치의 결과가 실행마다 100ms와 800ms를 오갔습니다. 가비지 컬렉터가 배열을 옮기면 블록 경계를 기준으로 한 위치가 달라지기 때문입니다. 그래서 주소가 바뀌지 않는 힙 밖 메모리로 다시 쟀습니다. 거짓 공유를 잴 때는 주소가 고정됐는지부터 확인해야 합니다.
메이퇀 글의 예제를 옮겼더니 원자 변수 경합의 몫이 더 컸습니다
메이퇀 글의 예제에서는 16MB 배열을 두 스레드가 채웁니다. 글은 원자 변수에서 다음 위치를 하나씩 받아 가면 3.4초, 16칸씩 받아 가면 0.06초였다고 적고 그 차이를 거짓 공유로 설명합니다. 원자 변수 호출이 줄어서 빨라진 것은 아닌지 확인하는 대조도 붙였는데 16칸 판의 안쪽 반복에 경합하는 원자 변수 하나를 더했더니 0.33초였다고 합니다.
같은 구조를 Java로 옮겨 15번씩 쟀습니다.
| 한 번에 받아 가는 양 | 중앙값 | 최소 ~ 최대 |
|---|---|---|
| 1칸 (8바이트) | 96.83ms | 83.00 ~ 127.13 |
| 8칸 (64바이트) | 14.93ms | 9.97 ~ 24.80 |
| 16칸 (128바이트) | 5.56ms | 3.91 ~ 11.64 |
| 32칸 (256바이트) | 3.84ms | 3.04 ~ 7.62 |
| 16칸 + 칸마다 경합하는 원자 변수 하나 | 67.91ms | 60.30 ~ 80.20 |
한 칸씩 받아 가는 쪽이 16칸씩 받아 가는 쪽보다 17배 느려 방향은 원문과 같습니다. 원자 변수 경합이 차지하는 비중은 원문과 다릅니다. 원문의 대조에서는 원자 변수 경합이 느린 쪽 시간의 10분의 1이었는데 여기서는 67.91ms로 96.83ms의 70%입니다. 이 기계와 JVM에서는 느려진 시간의 큰 몫이 배열의 거짓 공유보다 위치를 나눠 주는 원자 변수 하나를 두 코어가 다툰 데서 나왔습니다.
원자 변수 하나를 두 스레드가 올리는 것도 한 캐시 라인을 두 코어가 번갈아 독점하는 일이어서 거짓 공유와 같은 현상입니다. AtomicLong 하나를 두 스레드가 2천만 번씩 올리는 데 걸린 682ms는 앞 절의 표에서 같은 블록의 서로 다른 두 값을 올릴 때 걸린 749ms와 비슷합니다. 하드웨어에서는 진짜 공유와 거짓 공유의 비용이 같습니다.
Java에서는 이미 떼어 놓은 클래스를 씁니다
JDK에는 필드를 다른 라인으로 떼어 놓는 @Contended가 있습니다. 이 기계의 JDK 21에서 기본 패딩은 128바이트이고 기본 설정에서는 JDK 내부 클래스에만 적용됩니다. 애플리케이션 코드에서는 이미 그렇게 만든 클래스를 쓰는 편이 가장 쉽습니다. LongAdder가 내부에 두는 칸에 이 애너테이션이 붙어 있습니다.
8. 가장 싼 동기화는 스레드마다 사본을 두는 것입니다
메이퇀 글은 끝에서 락 없이 스레드 안전을 얻는 방법을 표로 정리하고 “The fastest synchronization of all is the kind that never takes place.”라는 문장으로 맺습니다. 표의 방법 가운데 여럿이 사본을 두어 공유를 줄이는 방법입니다. Java에서 대응하는 수단은 이렇습니다.
| 원문의 방법 | Java에서 |
|---|---|
| 원자적 대입 | int와 참조의 읽기와 쓰기. 다른 스레드에 보이는 시점까지 필요하면 volatile |
| 원자 변수, CAS | AtomicInteger, AtomicLong, AtomicReference의 compareAndSet |
| 이중 버퍼 | 불변 객체를 새로 만들고 volatile 참조를 바꿔 끼움. CopyOnWriteArrayList |
| 스레드 전용 데이터 | ThreadLocal, 메서드 안의 지역 변수 |
| CPU별 변수 | LongAdder. 스레드들이 여러 칸에 나눠 더하고 읽을 때 합침 |
| RCU | 가장 가까운 것은 위의 참조 바꿔 끼우기. 옛 판은 가비지 컬렉터가 거둠 |
마지막 두 줄의 대응은 제 해석입니다. 원리가 같은 것을 골랐고 구현은 다릅니다.
공유를 줄인 효과를 카운터로 쟀습니다. 스레드마다 2천만 번씩 올립니다.
| 스레드 수 | AtomicLong | LongAdder |
|---|---|---|
| 1 | 102.5ms | 128.2ms |
| 2 | 682.0ms | 126.7ms |
| 4 | 2,386.1ms | 126.3ms |
AtomicLong은 스레드가 늘수록 느려져 스레드 4개에서는 일이 4배인데 시간은 23배입니다. LongAdder는 스레드 수와 상관없이 126ms 근처입니다. 스레드가 하나일 때는 102.5ms 대 128.2ms로 AtomicLong이 조금 빠릅니다. LongAdder의 sum()은 여러 칸을 더한 값이라 더하는 도중의 정확한 순간값이 필요한 곳에는 맞지 않습니다. LongAdder는 통계처럼 쓰기가 많고 읽기가 드문 카운터에 맞습니다.
측정 조건과 한계
- 기계는 Apple M2 Pro(성능 코어 8, 효율 코어 4), macOS 26.3.1, OpenJDK 21.0.9입니다. x86에서는 재지 않았습니다. 6장에서 적은 대로 x86은 허용하는 재배열이 더 적습니다.
- 스레드를 코어에 고정하지 못했습니다. 스레드가 성능 코어와 효율 코어 중 어디에서 돌았는지 모릅니다. 측정 동안 다른 작업이 함께 돌았고 부하 평균은 4~6이었습니다. 그래서 여러 번 재어 중앙값과 최소, 최대를 함께 적었습니다.
- JMH를 쓰지 않았습니다. 실험마다 워밍업을 2~10차례 돌린 뒤
System.nanoTime으로 쟀습니다. 몇 배 차이는 믿을 수 있지만 10% 안쪽의 차이는 이 방법으로 가릴 수 없습니다. - 거짓 공유 측정은 두 스레드, 원자적 증가 한 가지 동작입니다. 읽기가 섞이거나 스레드가 많을 때의 값은 재지 않았습니다.
- 6장의 실험은 (0, 0)이 나오는 것을 보였을 뿐 원인을 가르지 못합니다.
sysctl이 알려 주는 128바이트와 측정한 64바이트가 다른 이유는 확인하지 못했습니다.
참고
- 뼈대로 삼은 글: 基本功 | 一文讲清多线程和多线程同步 (美团技术团队, 2024-07-19)
- 스토어 버퍼, 무효화 큐, 아키텍처별 재배열 표: Paul E. McKenney, Memory Barriers: a Hardware View for Software Hackers
- 배리어의 종류와 정의: Linux kernel documentation, memory-barriers
- happens-before,
long과double의 비원자적 취급, 가짜 깨움: Java Language Specification 17장 Threads and Locks - 조건 변수와 반복문 안에서 기다리기: java.util.concurrent.locks.Condition
- 플랫폼 스레드와 가상 스레드: java.lang.Thread
- 프로세스와 스레드는 같은 것이라는 메일: Linus Torvalds, Re: proc fs and shared pids (1996-08-06)
- 뮤텍스가 먼저 잠깐 도는 이유: Linux kernel documentation, Generic Mutex Subsystem
- 락 없는 단일 생산자 단일 소비자 큐: Linux kernel, include/linux/kfifo.h
- 락프리의 정의: Wikipedia, Non-blocking algorithm
- 거짓 공유와
@Contended: JEP 142: Reduce Cache Contention on Specified Fields LongAdder: java.util.concurrent.atomic.LongAdder
댓글
댓글 수정/삭제는 GitHub Discussions에서 가능합니다.