You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
각 스레드는 spinLock.lock()으로 락을 얻은 뒤 비즈니스 로직을 실행하고, finally에서 spinLock.unlock()으로 락을 반드시 반납한다.
finally를 사용하는 구조는 락을 사용할 때 자주 쓰는 패턴이며, 예외가 발생해도 락 반납이 보장되도록 작성한다.
20:24:15 [ Thread-1] 락 획득 시도
20:24:15 [ Thread-2] 락 획득 시도
20:24:15 [ Thread-2] 락 획득 완료
20:24:15 [ Thread-2] 비즈니스 로직 실행
20:24:15 [ Thread-1] 락 획득 완료
20:24:15 [ Thread-1] 비즈니스 로직 실행
20:24:15 [ Thread-2] 락 반납 완료
20:24:15 [ Thread-1] 락 반납 완료
실행 결과를 보면 두 스레드 모두 동시에 락을 획득한 것을 확인할 수 있다.
두 스레드 모두 동시에 접근할 수 있는 공유변수에 임계영역이 설정되어있지 않아 동시에 락 사용 여부를 확인한 후, 동시에 락의 값 변경이 이루어진다.
락 획득이 “원자적(atomic)으로 한 번에” 이루어지지 않는다.
즉, if (!lock)의 조건 확인과 lock = true의 변경이 하나의 연산으로 묶여 있지 않아서, 두 스레드가 같은 순간에 lock == false라고 판단할 수 있다.
두 스레드가 모두 “내가 락을 얻었다”라고 생각하고 임계 구역에 동시에 들어가는 상황이 만들어진다.
lock() 메서드는 compareAndSet(false, true)가 성공할 때까지 반복 실행한다.
unlock() 메서드는 lock.set(false)로 락 상태를 풀어 다음 스레드가 획득할 수 있도록 만든다.
락을 얻기 위한 핵심 동작이 단일 원자 연산으로 수행되도록 구성되어 있으며, 이는 스핀락에서 가장 중요한 조건 중 하나이다.
1) 락 획득 과정에서 원자성이 필요한 이유
락 획득은 아래의 두 연산이 분리되어 실행되면 경쟁 조건이 생길 수 있으므로, 반드시 하나의 원자적 연산처럼 동작해야 한다.
스레드는 먼저 락이 사용 중인지 확인하려고 lock 값이 false인지 검사한다.
락이 비어 있다고 판단되면, 즉시 lock 값을 true로 바꿔서 “내가 락을 가져갔다”는 상태를 만들어야 한다.
그런데 이 두 단계가 분리되어 있으면, 두 스레드가 동시에 false를 확인한 뒤 동시에 true로 바꾸려는 상황이 발생할 수 있다.
따라서 이 확인과 변경을 하나로 묶은 연산이 필요하고, 그 역할을 lock.compareAndSet(false, true)가 수행한다.
즉, 사용자가 적은 것처럼 위의 연산은 lock.compareAndSet(false, true);라는 CAS 연산으로 대체할 수 있으며, 이때 “기대값이 false일 때만 true로 바꿔라”라는 규칙이 깨지지 않고 보장된다.
2) 실행로그
21:11:26 [ Thread-1] 락 획득 시도
21:11:26 [ Thread-2] 락 획득 시도
21:11:26 [ Thread-1] 락 획득 완료
21:11:26 [ Thread-2] 락 획득 실패 - 스핀 대기
21:11:26 [ Thread-1] 비즈니스 로직 실행
21:11:26 [ Thread-2] 락 획득 실패 - 스핀 대기
21:11:26 [ Thread-1] 락 반납 완료
21:11:26 [ Thread-2] 락 획득 완료
21:11:26 [ Thread-2] 비즈니스 로직 실행
21:11:26 [ Thread-2] 락 반납 완료
실행 결과를 보면 한 시점에 하나의 스레드만 락을 획득하고 임계 영역(비즈니스 로직)을 수행한다.
Thread-2는 Thread-1이 락을 반납할 때까지 반복문에서 계속 실패 로그를 출력하며 대기한다.
Thread-1이 unlock()으로 false를 세팅하면, Thread-2의 CAS가 성공하면서 락을 획득하고 다음 임계 영역을 수행한다.
따라서 사용자가 정리한 것처럼 정상적으로 상호 배제가 이루어지는 것을 로그로 확인할 수 있다.
2. 동기화락 vs CAS락
1) 동기화락
락을 획득하지 못하면 스레드 상태가 BLOCKED, WAITING 등으로 바뀌며, 이후 락이 풀릴 때 대기 스레드를 깨우는 과정이 필요해 내부 동작이 복잡해질 수 있다.
다만 동기화 락은 대기 중인 스레드가 반복문을 돌지 않으므로 CPU 자원을 계속 소모하지 않는다는 장점이 있다.
2) CAS 락
별도의 “락 객체”가 대기열을 관리하는 방식이 아니라, 단지 값 변경을 경쟁하면서 반복할 뿐이므로 대기하는 스레드도 RUNNABLE 상태에서 계속 실행되며 빠르게 반응할 수 있다.
반면 반복문이 계속 실행되므로 CPU를 계속 사용하며 대기하게 되고, 경쟁이 심하거나 임계 구역이 길어질수록 CPU 소모가 커질 수 있다.
따라서 임계 영역은 실행 시간이 길지 않고 짧게 끝나는 부분에 적용해야 CPU 연산을 덜 소모한다.
반대로 외부 요청이나 데이터베이스 결과처럼 오래 걸리는 작업을 임계 영역에 넣으면, 다른 스레드가 스핀 대기하면서 CPU를 계속 소모하는 아주 비효율적인 상황이 발생할 수 있다.
원자적 연산은 여러 스레드에서 동시에 실행해도 중간 상태가 외부에 노출되지 않으므로 안전하게 동작한다.
3. 스핀락
스핀락은 반복문을 통해 락이 해제되기를 기다리는 방식이므로, 마치 제자리에서 회전(spin)하는 것처럼 보인다는 의미에서 스핀락이라고 부른다.
스레드가 락을 획득할 때까지 반복해서 시도하며 기다리는 것을 스핀 대기 또는 바쁜 대기(busy waiting) 라고 한다.
스핀락은 임계 구역이 아주 짧은 CPU 연산으로 끝나는 경우에 효과적이며, 이때는 컨텍스트 스위칭이나 깨우기 비용보다 반복 시도 비용이 더 작아질 수 있다.
정리
1. CAS
1) 장점
CAS는 락을 먼저 걸고 시작하는 방식이 아니라, 충돌이 자주 발생하지 않을 것이라는 가정을 바탕으로 공유 변수를 안전하게 갱신하려는 방식이므로 낙관적 동기화로 이해할 수 있다.
CAS는 공유 변수를 갱신할 때 락을 사용하지 않고도 안전하게 업데이트할 수 있으며, 충돌이 적은 환경에서는 불필요한 대기나 컨텍스트 스위칭이 줄어 높은 성능을 발휘하기 쉽다.
CAS는 락 프리 방식으로 동작하므로, 락을 획득하기 위해 스레드가 블로킹 상태로 들어가 대기하는 시간이 구조적으로 존재하지 않는다.
스레드가 블로킹되지 않고 계속 실행 가능한 상태를 유지하므로, 상황에 따라 병렬 처리가 더 효율적으로 이루어질 수 있다.
2) 단점
CAS는 여러 스레드가 동일한 변수에 동시에 접근해서 갱신을 시도하는 구조이므로, 충돌이 빈번한 환경에서는 업데이트 시도가 계속 실패할 수 있다.
충돌이 발생하면 보통 반복문을 돌면서 재시도하는 형태가 되는데, 이 과정에서 스레드는 계속 실행되며 CPU 자원을 지속적으로 소모하게 된다.
따라서 경쟁이 심하거나 임계 영역이 길어지는 상황에서는 CAS 기반 방식이 오히려 성능을 악화시키는 원인이 될 수 있다.
공정성을 보장하지 못하고, 경쟁이 심할 수록 기아 현상이 발생할 수 있다.
2. 동기화 락
1) 장점
동기화 락은 한 번에 하나의 스레드만 공유 변수에 접근하도록 강제하므로, 공유 변수 갱신 과정에서 충돌이 구조적으로 발생하지 않는다.
경쟁이 발생하더라도 “한 번에 하나만 들어간다”는 규칙이 유지되기 때문에, 복잡한 상황에서도 안정적으로 동작하기 쉽다.
락을 대기하는 스레드는 보통 실행을 멈추고 대기 상태로 전환되므로, 대기 중에는 반복문을 돌지 않아 CPU를 계속 소모하지 않는다.
2) 단점
스레드가 락을 획득하기 위해 대기해야 하므로, 락 경합이 심해지면 대기 시간이 길어질 수 있다.
락을 획득하거나 반환하는 과정에서 스레드 상태 전환이 발생할 수 있고, 이로 인해 컨텍스트 스위칭 비용이 증가하면서 전체 처리량이 떨어질 수 있다.
3. 결론
일반적으로는 동기화 락을 기본 선택지로 두고, 특별한 경우에 한정해서 CAS 연산을 활용하는 접근이 안전하다.
임계 영역이 매우 짧은 CPU 작업이라면, 블로킹/깨우기 비용보다 CAS 재시도 비용이 더 작을 수 있으므로 CAS 기반 락이나 CAS 기반 구조가 유리해질 수 있다.
임계 영역이 데이터베이스 요청이나 다른 서버 호출처럼 오래 걸리는 작업이라면, 스핀 대기 형태의 CAS 방식은 대기 시간 동안 CPU를 계속 소모하는 문제가 생기므로 동기화 락이 더 적합하다.
실무 관점에서는 충돌 가능성이 크지 않은 경우가 많기 때문에, 처음부터 비관적으로 락을 강하게 거는 방식보다 CAS를 활용한 낙관적 접근이 성능상 이점이 될 수 있다는 관점도 함께 고려할 수 있다.
다만 락을 직접 구현하거나 CAS를 직접 다루기보다는, CAS 방식을 내부적으로 적용한 검증된 라이브러리를 잘 활용하는 것이 유지보수성과 안정성 측면에서 더 현실적인 선택이 된다.
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
CAS 락 구현1
1. 코드
lock변수는true가 되고, 반복문을 탈출한다.lock변수는false가 된다.volatile키워드를 사용했기 때문에 다른 스레드가 lock 값을 더 잘 “보게” 되는 가시성은 어느 정도 보장되지만, 락 획득 과정 자체가 원자적으로 처리되는 것은 아니다.spinLock.lock()으로 락을 얻은 뒤 비즈니스 로직을 실행하고,finally에서spinLock.unlock()으로 락을 반드시 반납한다.finally를 사용하는 구조는 락을 사용할 때 자주 쓰는 패턴이며, 예외가 발생해도 락 반납이 보장되도록 작성한다.if (!lock)의 조건 확인과lock = true의 변경이 하나의 연산으로 묶여 있지 않아서, 두 스레드가 같은 순간에lock == false라고 판단할 수 있다.unlock()메소드는lock = false로 값을 한 번에 기록하는 구조이다.CAS 락 구현2
1. 코드
lock()메서드는compareAndSet(false, true)가 성공할 때까지 반복 실행한다.unlock()메서드는lock.set(false)로 락 상태를 풀어 다음 스레드가 획득할 수 있도록 만든다.1) 락 획득 과정에서 원자성이 필요한 이유
lock값이false인지 검사한다.lock값을true로 바꿔서 “내가 락을 가져갔다”는 상태를 만들어야 한다.false를 확인한 뒤 동시에true로 바꾸려는 상황이 발생할 수 있다.lock.compareAndSet(false, true)가 수행한다.lock.compareAndSet(false, true);라는 CAS 연산으로 대체할 수 있으며, 이때 “기대값이 false일 때만 true로 바꿔라”라는 규칙이 깨지지 않고 보장된다.2) 실행로그
Thread-2는Thread-1이 락을 반납할 때까지 반복문에서 계속 실패 로그를 출력하며 대기한다.Thread-1이unlock()으로false를 세팅하면,Thread-2의 CAS가 성공하면서 락을 획득하고 다음 임계 영역을 수행한다.2. 동기화락 vs CAS락
1) 동기화락
BLOCKED,WAITING등으로 바뀌며, 이후 락이 풀릴 때 대기 스레드를 깨우는 과정이 필요해 내부 동작이 복잡해질 수 있다.2) CAS 락
3. 스핀락
정리
1. CAS
1) 장점
2) 단점
2. 동기화 락
1) 장점
2) 단점
3. 결론
Beta Was this translation helpful? Give feedback.
All reactions