락 경합
고친 사람 github-actions[bot]
락 경합은 여러 실행 흐름이 같은 락을 동시에 잡으려 해서 서로를 기다리게 되는 고장입니다. 기다리는 동안 그 흐름들은 아무 일도 못 합니다. 일손을 늘려도 잠긴 구간은 한 번에 하나씩만 지나가므로 처리량이 그만큼 안 늘어납니다. 값이 깨지지도 오류가 나지도 않고 기다림만 늘어납니다.
쉽고 빠른 이해
같은 락(잠금)을 여럿이 동시에 잡으려 해서 줄이 생기는 고장입니다. 주문이 몰릴 때 재고 한 줄을 고치려는 요청들이 차례를 기다리는 것이 그런 경우입니다.
락은 한 번에 한 흐름만 들여보냅니다. 락을 쥐고 있는 시간이 길거나 그 락을 찾는 흐름이 많으면 기다리는 시간이 금세 붑니다.
이렇게 터집니다:
- 여러 흐름이 같은 락 하나를 잡으려 합니다
- 락을 쥐고 하는 일이 길거나 아주 잦습니다
- 흐름을 늘릴수록 기다리는 쪽만 늘어납니다
줄이려면 나누는 값을 치릅니다. 락을 여러 개로 쪼개면 조각 여럿에 걸친 일이 까다로워집니다. 락 없이 고치는 방법은 다시 시도하는 처리를 손으로 짜야 합니다.
상세
이 절은 경합이 무엇과 무엇 사이에서 생기는지부터 가릅니다. 이어서 터지는 조건 셋, 기다림이 처리량을 깎는 모양, 못 잡은 흐름이 기다리는 두 방법, 줄이는 손질 넷, 닮은 고장과 가르는 선, 알아채는 지표를 봅니다.
경합이 생기는 곳
실행 흐름은 명령을 차례차례 밟아 나가는 단위 하나를 말합니다. 한 프로그램 안에서 따로 도는 흐름이 스레드입니다. 저마다 메모리를 따로 쥔 흐름은 프로세스입니다.
락은 한 번에 한 흐름만 들여보내는 장치입니다. 락이 지키는 코드 구간, 그러니까 한 번에 한 흐름만 지나야 하는 구간을 임계 구역이라고 합니다.
락 경합은 그 임계 구역 앞에 둘 이상이 동시에 닿아 서로 기다리게 되는 고장입니다. 주문 여럿이 같은 재고 수량 한 줄을 동시에 줄이려는 장면이 그렇습니다. 「경합」은 다툰다는 뜻입니다. 영어로는 lock contention 이라고 적습니다.
락을 쓴다고 다 경합은 아닙니다. 한 흐름이 잡고 놓는 동안 아무도 그 락을 찾지 않으면 기다림은 없습니다. 잡으려는 때가 겹쳐야 경합입니다.
터지는 조건
셋이 함께 맞을 때 기다림이 눈에 띄게 커집니다. 하나라도 빠지면 락이 있어도 흐름은 거의 안 멈춥니다.
| 조건 | 무슨 뜻인가 |
|---|---|
| 같은 락 | 흐름 둘 이상이 락 하나를 두고 만난다 |
| 겹치는 때 | 한 흐름이 쥐고 있는 동안 다른 흐름이 잡으러 온다 |
| 붙잡는 길이 | 쥐고 있는 시간이 길다. 짧아도 아주 잦으면 같다 |
셋째 조건이 손댈 곳을 알려 줍니다. 겹칠 확률은 쥐고 있는 시간을 따라 오르내립니다. 그래서 쥐는 구간을 줄이면 흐름 수를 그대로 두고도 경합이 줄어듭니다.
조건은 이 정도로 좁혀야 재현이 됩니다. 「부하가 높을 때 느려진다」는 조건이 아닙니다. 「스레드 여덟이 같은 객체를 잠그고 그 안에서 데이터베이스를 한 번 왕복하면, 여덟째 스레드는 앞의 일곱이 끝날 때까지 기다린다」가 조건입니다.
기다림이 처리량을 깎는 모양
아래 그림은 흐름 넷이 같은 락을 두고 만난 모양입니다.
flowchart TD
A["흐름 A · 락을 쥠"] --> C["임계 구역 · 한 번에 하나"]
subgraph W["기다리는 흐름"]
B["흐름 B"]
D["흐름 C"]
E["흐름 D"]
end
W -.->|락이 놓일 때까지 선다| C
C --> R["재고 수량 한 줄"]
그림에서 볼 것은 임계 구역으로 들어가는 실선이 하나뿐이라는 점입니다. 흐름을 넷으로 늘려도 그 구간을 지나는 속도는 그대로입니다. 늘어난 셋은 일을 하는 대신 줄에 섭니다.
기다리는 동안 그 흐름들이 받은 CPU(Central Processing Unit, 중앙 처리 장치) 시간은 계산에 안 쓰입니다. 흐름을 늘릴수록 이렇게 버려지는 시간이 늘어납니다.
잠들었다 깨는 흐름이면 문맥 교환에 값이 더 붙습니다. 문맥 교환은 돌던 흐름을 내리고 다른 흐름을 올리는 일입니다. 그래서 경합이 심해지면 처리량이 평평해지다가 꺾이기도 합니다.
전체 일 가운데 임계 구역을 지나야 하는 몫이 클수록 장비를 늘려 얻는 것에 천장이 생깁니다. 그 천장을 다루는 이야기가 암달의 법칙입니다.
못 잡은 흐름이 기다리는 두 방법
락을 못 잡은 흐름은 둘 중 하나를 합니다. 놓였는지 되물으며 도는 것과, 잠들었다가 놓일 때 깨는 것입니다. 어느 쪽이든 그 흐름의 일은 멈춰 있습니다.
돌며 기다리는 락이 스핀락입니다. 깨어나는 절차가 없어 놓이는 순간 바로 들어갑니다. 대신 기다리는 내내 CPU를 쥐고 있습니다.
잠들었다 깨는 락은 뮤텍스 계열입니다. 기다리는 동안 CPU를 내놓지만, 재우고 깨우는 데 문맥 교환 값이 듭니다.
| 기다리는 방법 | 기다리는 동안 CPU | 값이 싼 때 |
|---|---|---|
| 돌며 되묻기 | 쥐고 있다 | 임계 구역이 아주 짧다 |
| 잠들었다 깨기 | 내놓는다 | 기다림이 길어질 수 있다 |
표가 말하는 것은 경합이 심할수록 돌며 기다리는 쪽이 비싸진다는 것입니다. 짧을 줄 알고 고른 방법이 길어진 기다림을 만나면 CPU만 태웁니다.
락 구간을 줄이기
첫째 손질은 락을 쥔 채 무엇을 하고 있나를 보는 것입니다. 아래는 캐시를 채우면서 락 안에서 데이터베이스를 왕복하는 꼴입니다.
synchronized (cache) {
var row = db.find(id); // 락 쥔 채 왕복
cache.put(id, row);
}
왕복은 임계 구역 밖에서 해도 캐시에 들어가는 값이 갈리지 않습니다. 밖으로 빼면 락을 쥐는 구간이 캐시에 넣는 일 하나로 줄어듭니다.
var row = db.find(id); // 락 밖에서 왕복
synchronized (cache) {
cache.put(id, row); // 쥐는 구간은 이것뿐
}
고친 뒤에는 둘이 같은 값을 잇달아 넣는 일이 생깁니다. 나중에 넣은 쪽이 남습니다. 같은 값이면 상관없습니다. 먼저 읽은 값을 바탕으로 더하거나 빼는 계산이라면 그 계산은 락 안에 남겨야 합니다.
락을 쪼개거나 없애기
둘째 손질은 락 하나를 여럿으로 가르는 것입니다. 지키는 데이터를 조각으로 나누고 조각마다 락을 하나씩 둡니다. 서로 다른 조각을 만지는 흐름은 이제 안 만납니다.
flowchart TD
subgraph ONE["락 하나로 지킬 때"]
L0["락"] --> M0["데이터 전체"]
end
subgraph MANY["락을 쪼갠 뒤"]
L1["락 1"] --> M1["조각 1"]
L2["락 2"] --> M2["조각 2"]
L3["락 3"] --> M3["조각 3"]
end
위는 어떤 키를 만지든 같은 락에서 만나는 모양입니다. 아래는 키가 속한 조각이 어느 락을 잡을지 정합니다. 조각이 많을수록 겹칠 확률이 줍니다. 대신 조각 여럿에 걸친 일은 락을 여럿 잡습니다. 그래서 서로 쥔 것을 기다리다 아무도 못 나아가는 데드락을 조심해야 합니다. 데이터를 나눠 담는 샤딩이 저장소에서 같은 일을 합니다.
셋째 손질은 공유를 없애는 것입니다. 흐름마다 자기 몫을 따로 두고 끝에 한 번만 합치면 그사이에는 다툴 것이 없습니다. 흐름마다 따로 갖는 스레드 로컬 변수가 그 방법입니다.
넷째 손질은 락 없이 고치는 것입니다. CAS(Compare-And-Swap, 비교 후 교체)는 「지금 값이 내가 읽은 값과 같을 때만 새 값으로 바꿔라」를 한 명령으로 처리합니다. 기다림은 사라지지만 실패하면 다시 읽고 다시 시도합니다. 다툼이 잦으면 기다림이 재시도로 모습만 바꿉니다.
닮은 고장과 가르는 선
이름이 나란히 불리는 동시성 고장들과는 「락이 무엇을 하고 있나」와 「무엇이 어긋나나」로 갈립니다.
| 고장 | 락 | 어긋나는 것 |
|---|---|---|
| 락 경합 | 제 일을 하고 있다 | 결과는 맞고 기다림이 는다 |
| 경쟁 상태 | 없거나 잘못 걸렸다 | 결과가 실행 순서에 따라 갈린다 |
| 데드락 | 서로 쥔 것을 기다린다 | 얽힌 흐름이 다 못 나아간다 |
| 기아 | 제 일을 하고 있다 | 한 흐름만 끝내 차례를 못 받는다 |
| 거짓 공유 | 없어도 난다 | CPU가 메모리를 덩어리로 실어 둔다. 서로 다른 값이 같은 덩어리에 담겨 그 덩어리를 빼앗는다 |
경합은 락이 제구실을 하고 있다는 증거이기도 합니다. 값은 안 깨집니다. 경쟁 상태는 반대로 락이 없어서 값이 깨지는 고장이라, 그걸 막으려 락을 걸면 경합이 생깁니다. 이 둘은 맞바꾸는 짝입니다.
데드락은 경합이 심해진 끝이 아니라 다른 고장입니다. 경합은 쥔 쪽이 놓으면 언젠가 풀립니다. 데드락은 놓아 줄 쪽이 서로를 기다려 저절로 안 풀립니다. 기아는 전체가 잘 도는 동안 한 흐름만 계속 밀리는 것입니다. 평균은 멀쩡한 채로 그 흐름만 못 끝냅니다.
경합을 드러내는 지표
제일 곧은 신호는 흐름 수와 처리량이 같이 안 움직이는 것입니다. 스레드를 늘려도 처리량이 평평하거나 꺾이면 한 번에 하나만 지나는 구간이 어딘가 있다는 뜻입니다.
락마다 기다린 시간의 합과 기다린 흐름 수는 프로파일러가 보여 줍니다. 프로파일러는 프로그램이 어디서 시간을 쓰는지 재는 도구입니다. 데이터베이스는 어느 요청이 어느 락을 기다리는지 자체 통계로 알려 줍니다.
가장 느린 쪽 몇 %의 응답 시간인 꼬리 지연이 평균보다 먼저 움직입니다. 대부분의 요청은 안 기다리고 지나가기 때문입니다. 줄 끝에서 오래 서는 것은 몇몇뿐입니다. 지금 도는 스레드가 각각 어디서 멈춰 있는지 찍어 보면 같은 락 이름 아래 여럿이 멈춰 선 모습으로도 보입니다.
이 신호는 자원이 다 찬 것과 갈라 봐야 합니다. CPU와 디스크가 한가한 채로 응답만 늘어지면 병목이 락 쪽에 있다는 뜻입니다.
관련 항목
락 경합이 생기는 실행 구조
동시성 · 병렬성 · 스레드 · 프로세스 · 코루틴 · 임계 구역 · 공유 자원 · 문맥 교환 · 스케줄러
락 경합이 나는 잠금 장치
락 · 뮤텍스 · 세마포어 · 스핀락 · 읽기-쓰기 락 · 낙관적 잠금 · 비관적 잠금 · 분산 락 · 행 잠금 · 테이블 잠금
락 경합과 나란히 나는 동시성 고장
데드락 · 라이브락 · 기아 · 경쟁 상태 · 우선순위 역전 · 락 호위 · 썬더링 허드 · 거짓 공유 · 재시도 폭풍
락 경합을 줄이는 수단
락 쪼개기 · 락 스트라이핑 · 샤딩 · 스레드 로컬 · 불변 객체 · 락 프리 · CAS · 원자적 연산 · 읽기 복사 갱신 · 배치 처리
락 경합을 드러내는 지표
처리량 · 꼬리 지연 · 응답 시간 · 대기 시간 · 큐 길이 · 포화 · 병목
락 경합이 잦은 공유 자원 도구
커넥션 풀 · 스레드 풀 · 캐싱 · 작업 큐 · 시퀀스 · 데이터베이스
락 경합이 정하는 확장의 천장
다른 이름: lock contention · 잠금 경합 · 락 컨텐션