동시성 문제는 프로세스 수준과 DB 수준 모두에서 검토해야함. 이 노트에서는 프로세스 수준에서 보겠음.
잠금(lock) 이용한 접근 제어
- 잠금을 사용하면 공유 자원에 접근하는 스레드를 하나로 제한 가능.
- 일반적 흐름은 다음과 같다.
-
- 잠금 획득
-
- 공유 자원에 접근(임계 영역 접근 가능)
-
- 잠금 해제
-
동시 접근 제어를 위한 구성 요소
자바의 RentrnatLock은 한 번에 1개 스레드만 잠금을 구할 수 있다. 즉, 한 번에 한 스레드만 공유 자원에 접근 가능.
잠금(뮤텍스) 외에도 동시 접근 제어를 위해 세마포어와 읽기 쓰기 잠금이 있는데 차례로 알아보자.
세마포어
세마포어는 동시에 실행할 수 있는 스레드 수를 제한한다. 자원에 대한 접근을 일정 수준으로 제한하고 싶을 때 세마포어를 쓸 수 있다. 예를 들면, 외부 서비스에 대한 동시 요청을 최대 5개로 제한하고 싶을 때 세마포어를 사용할 수 있다.
세마포어는 허용 가능한 숫자를 이용해 생성한다. 이 숫자를 자바 세마포어 구현체는 퍼밋(permit)이라 표현하며, Go 의 세마포어 구현체는 weight라 표현한다.
세마포어에는 이진(binary) 세마포어와 계수(counting) 세마포어가 있다. 이진 세마포어는 뮤텍스랑 마찬가지고 카운팅 세마포어는 지정 수만큰 동시 접근이 가능한 것.
세마포어를 사용하는 전형적인 순서
- 세마포어에서 퍼밋 획득(허용 가능 숫자 1 감소)
- 코드 실행
- 세마포어에서 퍼밋 반환(허용 가능 숫자 1 증가)
세마포어에서 퍼밋을 구하고 반환하는 연산을 각각 P연산(또는 wait 연산), V연산(or signal 연산)이라함
실제 자바 코드 예시
import java.util.concurrent,Semaphore;
public class MyClient{
private Semaphore semaphore = new Semaphore(5);
public String getData(){
try{
} catch(InterruptedException e){
throw new RuntimeException(e);
}
try {
String data = ...//외부 연동 코드
return data;
} finnally {
semaphore.release(); //퍼밋 반환
}
}
}
읽기 쓰기 잠금
public void addUserSession(UserSession session){
lock.lock();
try{
sessions.put(session.getSessionId(), session);
}finally{
lock.unlock();
}
}
public UserSession getUserSession(String sessionId){
lock.lock();
try{
return sessions.get(sessionId);//한 번에 한 스레드만 읽기 가능
}finally{
lock.unlock();
}
}
위와 같이 되면 오직 한 스레드만 put or get 이 가능하게된것. 잠금을 사용하면 데이터를 변경하지 않더라도 동시에 읽기가 안 된다. 한 번에 1개 스레드만 읽기 기능을 실행할 수 있기 때문이다. 한 번에 한 스레드만 읽기가 가능하므로 쓰기 빈도 대비 읽기 빈도가 높을 때는 읽기 성능이 떨어지는 문제가 발생할 수 있다.
읽기 쓰기 잠금 쓰면 이런 성능상 단점 없애면서 잠금을 통해 데이터 동시 접근 문제를 없앨 수 있다. 읽기 쓰기 잠금은 다음 특징 가진다.
- 쓰기 잠금은 한 번에 한 스레드만 구할 수 있다
- 읽기 잠금은 한 번에 여러 스레드가 구살 수 있다
- 한 스레드가 쓰기 잠금 획득시 이게 해제될 때까지 읽기 잠금을 구할 수 없다.
- 읽기 잠금을 획득한 모든 스레드가 읽기 잠금을 해제할 때까지 쓰기 잠금을 구할 수 없다. 위 특징에 따라 읽기 쓰기 잠금 사용하면 쓰기 동안 읽기를 할 수 없고 읽기 동안 쓰기를 할 수 없다. 또한 동시에 여러 스레드가 읽기를 실행할 수 있다. 따라서 읽기 쓰기 잠금을 사용하면 잠금을 사용했을때 발생하는 읽기 성능 문제를 완화할 수 있다.
읽기 쓰기 잠금 이용한 동시 접근 제어 코드
public class UserSeesionsRW {
private ReadWriterLock lock = new ReentrantReadWriteLock();
private Lock writeLock = lock.writeLock();
private Lock readLock = lock.readLock();
private Map<String, UserSession> sessions = new HashMap<>();
public void addUserSession(UserSession session){
writeLock.lock();
try{
sessions.put(session.getSessionId(), session);
}finally{
writeLock.unlock();
}
}
public UserSession getUserSession(String sessionId){
readLock.lock();
try{
return sessions.get(sessionId);
}finally{
readLock.unlock();
}
}
}
언제 사용하면 좋은가요?
ReadWriteLock은 읽기 작업이 쓰기 작업보다 훨씬 빈번하게 일어나는 경우에 성능상 큰 이점을 가집니다. 일반적인 synchronized나 ReentrantLock은 읽기 작업조차도 한 번에 하나의 스레드만 접근을 허용하기 때문에, 여러 스레드가 동시에 읽을 수 있는 ReadWriteLock이 훨씬 높은 동시성(Concurrency)을 제공합니다.
|현재 상태|다른 스레드가 읽기 잠금(Read Lock)을 요청|다른 스레드가 쓰기 잠금(Write Lock)을 요청| |아무도 잠금을 안 가짐|성공 (O)|성공 (O)| |한 개 이상의 스레드가 읽기 잠금을 가짐|성공 (O)|대기 (X)| |한 스레드가 쓰기 잠금을 가짐|대기 (X)|대기 (X)|
원자적 타입
잠금을 쓰면 카운트 증가에 대한 동시성 문제 간단히 해결 가능. 하지만 CPU 효율이 떨어지는 단점(CPU가 노니까 락 대기하면)
AtomicInteger의 경우 내부적으로 CAS(Compare And Swap) 연산을 사용함. 이를 통해 스레드 멈추지 않고 다중 스레드 환경에서 안전하게 값 변경 가능.
내부 구현은 잠금 쓰는것보다 복잡하지만 사용하는 개발자 입장에서는 락쓰는것보다 간단히 동시성 문제 해결 가능.
동시성 지원 컬렉션
java에서 일반적인 컬렉션 클래스인 HashMap, HashSet을 여러 스레드가 변경할 경우 데이터가 깨질 가능성이 존재한다. 이 문제를 해결하는 방법중 하나는 동기화된 컬렉션을 사용하는것.
java의 Collections Class는 동기화된 컬렉션을 생성하는 메서드를 제공. 한번에 한 스레드만 접근하도록 락을 써줌.
Map<String, String> map = new HashMap<>();
//동기화된 컬렉션 개체 생성
Map<String, String> syncMap = Collections.synchronizedMap(map);
Java23 이전 기준 가상스레드 사용시 주의점
Java23 이전 기준 가상스레드 사용한다면 동기화 컬렉션 객체로 변환해주는 메서드 사용하면 안됨. 내부적으로 synchronized 사용해서 동시 접근을 동기화 하기 때문. 자바 23 또는 이전 버전 기준으로 가상 스레드는 아직 synchronized 지원않해서 가상 스레드 환경에서 Collections.synchronized()로 생성한 동기화 컬렉션 사용하면 성능에 문제 생길 가능성이 있음.
또다른 방법으로 동시성 자체를 지원하는 컬렉션 타입을 쓰는 것. 예를 들어 HashMap 대시 ConcurrentHashMap을 쓰는 것.
ConcurrentHashMap은 데이터 변경시 잠금 범위를 최소화한다. 따라서 키의 해시 분포가 고르고 동시 수정이 많다면, 동기화된 맵 사용하는것보다 더 나은 성능 제공
불변(Immutable) 값
동시성 문제 피하기 위한 방법 중 하나는 불변 값을 사용하는 것. 함수형 프로그래밍을 학습하면 초반에 불변에 대해 배우는데 말그대로 변하지 않는 값 의미한다. 데이터 변경이 필요할 경우 기존 값 수정이 아닌 새로 생성한다. 자바의 CopyOnWriteArrayList 같은거
synchronized와 ReentrantLock
자바의 synchronized와 ReentrantLock 키워드를 말하는 것.
- synchronized
- 이거 사용하면 더 간단히 스레드의 동시 접근 제어 가능
- 코드 블록이 끝나면 자동으로 잠금을 풀어주기에
unlock()같은 메서드 호출 불필요
- ReentrantLock
- 이거는 synchronized에 없는기능을 제공.
- 바로, 잠금 획득 대기 시간을 지정 가능한 것.
- 또한 자바 21에 추가도니 가상 스레드가 ReentrantLock만 지원하고 synchronized는 자바24 부터 지원함.