Skip to content

Latest commit

 

History

History
656 lines (449 loc) · 23.5 KB

File metadata and controls

656 lines (449 loc) · 23.5 KB

Question

Java에서 동시성 문제를 제어하는 방법들에 대해서 설명해주세요.

프로세스와 스레드

JVM → 자바 프로세스 → 프로세스 내부 스레드 수행

프로세스: 실행 중인 프로그램(OS로부터 리소스를 할당받는 작업의 단위)

스크린샷 2023-08-03 오전 8 15 39

각 프로세스는 독립된 메모리 영역을 할당받고, 최소 하나 이상의 스레드를 가짐

별도의 주소 공간에서 실행되고, 변수나 자료구조 등에 접근 불가함

스크린샷 2023-08-03 오전 8 19 27

자바의 멀티 스레드 환경

자바 → 멀티 스레드 환경

한 프로세스 내에서 멀티태스킹이 가능함 (두 가지 이상의 작업을 동시에 처리하는 것)

멀티 스레드 환경은 공유하는 영역이 많아서 단일 스레드 방식보다 작업 전환(Context Switching) 오버헤드가 작아, 메모리 리소스가 상대적으로 적다.

하지만 공유 자원으로 인한 단점도 존재한다! → 동시성(Concurrency) 문제

동시성 vs 병렬성

스크린샷 2023-08-03 오전 8 04 12

Case 1: 동시성을 만족하지만 병렬성을 만족하지 않는다

Case 2: 둘 다 만족한다

  • 동시성 - 동시에 실행되는 것처럼 보이는 것
    • 두 개 이상의 task를 수행할 때, 각각의 task는 다른 task의 수행 시점에 상관없이 수행이 가능하다는 것 → N개의 task 실행 시간이 타임라인에서 겹칠 수 있음
    • 싱글 코어에서 멀티 스레드를 동작시키기 위한 방식으로, 멀티 태스킹을 위해 여러 게의 스레드가 번갈아가면서 실행되는 성질
    • 멀티 스레드 환경이기에 동시성을 만족시킬 수 있는 것이지, 둘 사이의 연관 관계가 있는 것은 아니다. 싱글 스레드 환경에서도 동시성을 만족할 수 있다
    • 코틀린의 ‘코루틴’도 루틴이라는 단위(함수)로 서로 협력이 가능하며, 동시성을 지원하고 비동기 처리를 도와줄 수 있다
  • 병렬성 - 실제로 동시에 실행되는 것
    • 멀티 코어에서 멀티 스레드를 동작시키는 방식으로, 하나 이상의 스레드를 포함하는 각각의 코어들이 동시에 실행되는 성질
    • 물리적인 시간에 동시에 수행됨
    • 네트워크 상 여러 컴퓨터에게 분산 작업을 요청하는 분산 컴퓨팅을 예시로 들 수 있음

⇒ 동시성은 병렬성을 가지기 위한 필요조건이지만, 충분조건은 아니다.

동시성 문제란?

동일한 데이터에 두 개 이상의 스레드, 혹은 세션에서 가변 데이터를 동시에 제어할 때 나타나는 문제로, 하나의 세션이 데이터를 수정 중일 때 다른 세션에서 수정 전의 데이터를 조회해 로직을 처리함으로서 데이터의 정합성이 깨지는 문제를 말함

예시: 재고 시스템

멀티 스레드 환경에서 가변 데이터에 접근하는 상황

스크린샷 2023-08-03 오후 2 43 08

우리가 예상한 상황은 위의 상황이겠지만,

스크린샷 2023-08-03 오후 2 48 10

이처럼 같은 데이터를 동시에 변경하려 할 때 작업 중 하나가 누락될 수 있다

이를 **레이스 컨디션(Race Condition)**이라 한다

Atomic (원자성)

자바의 연산자 중 ++ 연산자를 예로 들어 보면,

i++; 이 실행될 때 CPU가 수행하는 instruction은

  1. i을 메모리로붙어 읽어 캐시에 옮겨옴
  2. 캐시 값에 1을 더함
  3. 더한 값을 다시 캐시에 넣음
  4. 캐시에 값을 메모리로 반영함

이런 과정이 수행된다. 이렇게 CPU가 수행하는 과정 하나하나를 원자 단위의 연산이라고 한다.

원자 단위의 연산이 수행 중엔 다른 스레드에 의해 제어되는 CPU 개입이 있을 수 없다.

이 특성은 멀티 스레드 상황에서 스레드 간 공유 메모리 이슈(동시성 문제)를 발생시킬 수 있다.

자바에서 동시성 문제를 제어하는 방법

keyPoint: 가변 데이터에 대한 순차적 접근

Synchronized

멀티 스레드 환경에서 스레드 간 데이터 동기화를 위해 자바에서 제공하는 키워드

현재 데이터를 사용하는 스레드를 제외하고 나머지 스레드들의 데이터 접근을 막하 순차적으로 데이터에 접근할 수 있도록 해줌

@Transactional
public synchronized void decrease(final Long id, final Long quantity) {
    Stock stock = stockRepository.findById(id).orElseThrow();
    stock.decrease(quantity);
    stockRepository.saveAndFlush(stock);
}

동작 원리

  • Synchronized method

클래스 인스턴스 단위로 lock을 걺

이는 인스턴스에 접근 행위 자체에 대한 lock이 아님

  • 예시 코드

    public class Main {
    
        public static void main(String[] args) throws InterruptedException {
            A a = new A();
            Thread thread1 = new Thread(() -> {
                a.run("thread1");
            });
    
            Thread thread2 = new Thread(() -> {
                a.print("thread2");
            });
    
            thread1.start();
            Thread.sleep(500);
            thread2.start();
        }
    }
    public class A {
    
        public void print(String name){
            System.out.println(name + " hello");
        }
    
        public synchronized void run(String name) {
            System.out.println(name + " lock");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(name + " unlock");
        }
    }
    // 출력
    thread1 lock
    thread2 hello
    thread1 unlock

→ synchronized 키워드가 붙지 않은 print()메소드를 호출했을 때, 중간에 실행되기 때문

print 메소드에 synchronized 키워드를 붙이면, 위 예시와는 다르게 스레드 1의 락이 해제되고 나서야 호출됨 (동기화 발생)

  • static synchronized method

인스턴스가 아닌 클래스 단위로 lock을 걺

  • 예시 코드

    public class Main {
    
        public static void main(String[] args) throws InterruptedException {
            A a1 = new A();
            A a2 = new A();
            Thread thread1 = new Thread(() -> {
                a1.run("thread1");
            });
    
            Thread thread2 = new Thread(() -> {
                a2.run("thread2");
            });
    
            thread1.start();
            thread2.start();
        }
    }
    public class A {
    
        public void print(String name){
            System.out.println(name + " hello");
        }
    
        public synchronized void run(String name) {
            System.out.println(name + " lock");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(name + " unlock");
        }
    }
    // 출력 결과
    thread1 lock
    thread1 unlock
    thread2 lock
    thread2 unlock

위 예시를 보면 다른 인스턴스에서 접근해도 lock이 발생한 것을 알 수 있다.

하지만, synchronized 키워드만 사용한 인스턴스 단위 Lock과는 공유되지 않는다.

  • synchronized block

인스턴스의 block 단위로 lock을 걺

  • 예시 코드 - block에 this를 명시

    public class A {
    
        public void run(String name) {
    
            // 전처리 로직 ....
    
            // 동기화 시작
            synchronized (this){
                System.out.println(name + " lock");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(name + " unlock");
            }
            // 동기화 해제
    
            // 후처리 로직 ...
        }
    }

동기화 전후 로직을 섞을 수 있고, block 에 진입할 때만 lock을 걸고 빠져나오므로 효율적인 사용이 가능함

위처럼 block에 this를 명시할 경우, method에 synchronized를 붙인 것처럼 인스턴스 단위로 lock이 걸림

  • 예시 코드 - block에 특정 자원을 명시

    public class Main {
    
        public static void main(String[] args) {
            A a = new A();
            Thread thread1 = new Thread(() -> {
                a.run("thread1");
            });
    
            Thread thread2 = new Thread(() -> {
                a.run("thread2");
            });
    
            Thread thread3 = new Thread(() -> {
                a.print("자원 B와 관련 없는 thread3");
            });
    
            thread1.start();
            thread2.start();
            thread3.start();
        }
    }
    
    public class A {
    
        B b = new B();
    
        public void run(String name) {
            synchronized (b){
                System.out.println(name + " lock");
                b.run();
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(name + " unlock");
            }
        }
    
        public synchronized void print(String name) {
            System.out.println(name + " hello");
        }
    }
    
    // 출력 결과
    thread1 lock
    자원 B와 관련 없는 thread3 hello
    B lock
    B unlock
    thread1 unlock
    thread2 lock
    B lock
    B unlock
    thread2 unlock

block에 자원(b)을 명시할 경우, 위처럼 자원 b와는 관련이 없는 thread3 과 thread 1, 2가 서로 lock을 공유하지 않는 것을 알 수 있다. 물론 thread 1, 2는 B를 block한 lock으로, 서로 lock을 공유하게 되어 동기화가 보장된다.

  • 예시 코드 - block에 클래스를 명시

    public class Main {
    
        public static void main(String[] args) {
            A a = new A();
            Thread thread1 = new Thread(() -> {
                a.run("thread1");
            });
    
            Thread thread2 = new Thread(() -> {
                a.run("thread2");
            });
    
            thread1.start();
            thread2.start();
        }
    }
    public class A {
    
        B b = new B();
    
        public void run(String name) {
            synchronized (B.class){
                System.out.println(name + " lock");
                b.run();
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println(name + " unlock");
            }
        }
    
        public synchronized void print(String name) {
            System.out.println(name + " hello");
        }
    }
    public class B extends Thread {
    
        @Override
        public synchronized void run() {
            System.out.println("B lock");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("B unlock");
        }
    }
    // 출력 결과
    thread1 lock
    B lock
    B unlock
    thread1 unlock
    thread2 lock
    B lock
    B unlock
    thread2 unlock

block의 인자로 class가 들어와도, lock을 공유하는 것을 알 수 있다.

  • static synchronized block

이때는 this 같이 현재 객체를 가리키는 표현은 사용할 수 없음

정적 인스턴스 or class만 사용할 수 있음

클래스 단위로 lock을 공유한다는 점은 같다.

  • 사용하기 적합한 상황

여러 쓰레드가 값을 write하는 상황엔 변수의 원자성이 보장됨

  • 문제점

    하나의 스레드가 특정 조건이 만족될 때까지 루프를 돌 경우 빠져나오지 못할 수 있는데, 이 때는 동기화의 효율을 높이기 위해 wait, notify를 사용함

    wait을 호출하면 객체의 lock이 풀리고 루프를 도는 스레드를 해당 객체의 wating pool에 넣는다. 그리고 조건을 만족시키는 로직에서 작업 후 notify를 호출해 대기중인 스레드 중 하나를 깨운다. (일반적으로 notifyAll을 사용해 전체를 깨움)

    하지만 이렇게 되면 wait, notify의 구체적인 대상을 확인하기 어렵고 메서드 내에서만 lock을 걸 수 있다는 제약이 생기는데, 이를 해결하기 위해 Lock, Condition이 나왔다.

    Synchronized는 블록 구조를 사용해 알아서 lock을 회수해주는 반면 Lock은 lock, unlock 메서드로 시작, 끝을 명시하므로 임계 영역을 여러 메서드에서 나눠 작성할 수 있다.

https://backtony.github.io/java/2022-05-04-java-50/

volatile

이 키워드를 사용하지 않는 변수는 task가 수행되는 동한 성능 향상을 위해 main memory에서 읽은 값을 CPU cache에 저장하게 됨

멀티 스레드 상황에선, 하나의 스레드가 변수 값을 읽어올 때 각각의 CPU cache의 변수 값이 다르므로 변수 값 불일치 문제가 발생함

volatile keyword: Java 변수를 main memory에 저장하는 것을 명시함

CPU 메모리 영역에 캐싱 된 값이 아니라 항상 최신의 값을 가지도록 메인 메모리 영역에서 값을 참조하도록 할 수 있다. -> 동일 시점에 모든 스레드가 동일한 값을 가지도록 동기화한다.

public volatile long count = 0;

  • 사용에 적합한 상황

멀티 스레드 환경에서 하나의 스레드만 read & write 하고 나머지 스레드가 read만 하는 상황에서 가장 최신의 값을 보장함

  • 문제

    ++연산과 같이 원자성이 보장되지 않는 연산의 경우 동시성 문제 발생

Atomic class

위의 두 키워드만으로는 동시성 문제를 깔끔하기 해결할 수 없기에, 비원자적 연산에서도 동기화를 가능하게 하기 위한 클래스들이 있다

  • java.util.concurrent.AtomicLong
public class AtomicLong extends Number implements java.io.Serializable {
	
    private volatile long value; // volatile 키워드가 적용되어 있다.
	
    public final long incrementAndGet() { // value 값을 실제로 증가시키는 메서드
        return U.getAndAddLong(this, VALUE, 1L) + 1L;
    }	
}
  • 성능 비교

    // Blocking 방식
    
    private static long startTime = System.currentTimeMillis();
    private static int maxCnt = 1000;
    private static long count = 0;
    
    @Test
    void threadNotSafe() throws Exception {
        for (int i = 0; i < maxCnt; i++) {
            new Thread(this::plus).start();
        }
    
        Thread.sleep(2000); // 모든 스레드가 종료될때 까지 잠깐 대기
        Assertions.assertThat(count).isEqualTo(maxCnt);
    }
    
    public synchronized void plus() {
        if (++count == maxCnt) {
            System.out.println(System.currentTimeMillis() - startTime);
        }
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
        }
    }
    
    // 출력 결과
    1336
    
    // Non-Blocking 방식
    
    private static long startTime = System.currentTimeMillis();
    private static int maxCnt = 1000;
    private static AtomicLong count2 = new AtomicLong();
    
    @Test
    void threadNotSafe2() throws Exception {
        for (int i = 0; i < maxCnt; i++) {
            new Thread(this::plus2).start();
        }
    
        Thread.sleep(2000); // 모든 스레드가 종료될때 까지 잠깐 대기
        Assertions.assertThat(count2.get()).isEqualTo(maxCnt);
    }
    
    public void plus2() {
        if (count2.incrementAndGet() == maxCnt) {
            System.out.println(System.currentTimeMillis() - startTime);
        }
        try {
            Thread.sleep(1);
        } catch (InterruptedException e) {
        }
    }
    
    // 출력 결과
    165

Synchronized 키워드를 통한 Blocking 방식보다는 AtomicLong class를 통한 Non-blocking 방식이 효율적으로 성능을 낼 수 있다.

https://devwithpug.github.io/java/java-thread-safe/#2-volatile

Database Lock (MySQL)

Lock에는 공유락과 베타락 두 종류가 있다

여기에서 사용하는 lock은 데이터를 변경하고자 할 때 사용하는 Exclusive Lock임

  • Pessimistic Lock

    데이터에 lock을 걸어서 정합성을 맞추는 방법

    베타lock을 걸게 되면 다른 트랜잭션에서는 lock이 해제되기 전에 데이터를 가져갈 수 없음.

    자원 요청에 따른 동시성 문제가 발생할 것이라고 예상하고 락을 걸었으므로 비관적(Pessimistic) 락이라고 함

    한 서버가 DB 데이터를 가져올 때 Lock을 걸면, 다른 서버에서는 한 서버의 작업이 끝나 lock이 풀릴 때까지 데이터에 접근하지 못함

    스크린샷 2023-08-04 오후 9 31 05
    public interface StockRepository extends JpaRepository<Stock, Long> {
    
        @Lock(value = LockModeType.PESSIMISTIC_WRITE)
        @Query("select s from Stock s where s.id = :id")
        Stock findByWithPessimisticLock(final Long id);
    }

    단점

    • 데이터 자체에 별도로 락을 걸으므로 동시성이 떨어져 성능 저하가 발생할 수 있음
    • 읽기 작업이 많은 DB에는 손해가 더 큼
    • 서로 자원이 필요할 때, 데드락 가능성
  • Optimistic Lock

    실제로 Lock을 이용하지 않고 버전을 이용함으로써 정합성을 맞추는 방법

    데이터를 읽은 후 update를 수행할 때 현재 읽은 버전이 맞는지 확인하여 업데이트함

    데이터에 락을 걸어서 선점하지 않고, 동시성 문제가 발생하면 그 때 처리하기 때문에 낙관적(Optimistic) 락이라고 함

    Entity
    @Getter
    @NoArgsConstructor
    public class Stock {
    
        @Id
        @GeneratedValue(strategy = GenerationType.AUTO)
        private Long id;
    
        private Long productId;
    
        private Long quantity;
    
    	//버전 칼럼 추가
        @Version
        private Long version;
    
        //로직 생략
    
    }
    public interface StockRepository extends JpaRepository<Stock, Long> {
    
        @Lock(value = LockModeType.PESSIMISTIC_WRITE)
        @Query("select s from Stock s where s.id = :id")
        Stock findByWithPessimisticLock(final Long id);
    
    	//Optimistic Lock
        @Lock(value = LockModeType.OPTIMISTIC)
        @Query("select s from Stock s where s.id = :id")
        Stock findByWithOptimisticLock(final Long id);
    
    }

    버전을 확인하며 데이터를 처리함

    단점

    • 업데이트가 실패했을 때 재시도 로직을 개발자가 직접 작성해야함
    • 충돌이 빈번하게 일어나게 되면 롤백 처리를 해주어야하므로 성능이 떨어짐
  • Named Lock

    이름을 가진 metadata locking

    row 혹은 table 단위로 락을 거는 Pessimistic Lock 방식과는 다르게, Named Lock은 metadata 단위로 락을 건다

    스크린샷 2023-08-04 오후 9 40 34

    Stock에 락을 걸지 않고, 별도의 공간에 락을 건다

    public interface LockRepository extends JpaRepository<Stock, Long> {
    
        @Query(value = "select get_lock(:key, 3000)", nativeQuery = true)
        void getLock(String key);
    
        @Query(value = "select release_lock(:key, key)", nativeQuery = true)
        void releaseLock(String key);
    }

    장점

    • 분산 락을 구현할 때 사용할 수 있음

    단점

    • 트랜잭션 종료 시 락 해제와 세션관리에 주의해야 함

Redis

Redis를 사용해 분산 락을 구현함으로써 데이터 정합성 문제를 해결함

분산 락이란?

→ Race Condition에서 하나의 공유 자원에 접근할 때 데이터의 결함이 발생하지 않도록 원자성을 보장하는 기법

락 획득 방식에 따라 두 가지 사용 방법이 있음

  • Lettuce

    공식적으로 분산 락 기능을 제공하지 않아 직접 구현해서 사용해야함

    락 획득 방식: Spin Lock

    → 락을 획득하지 못한 경우 redis에 계속해서 요청을 보내는 방식

    스크린샷 2023-08-04 오후 9 58 11

    redis에 부하가 생길 수 있음

    이를 막기 위해 락 획득을 재시도하는 시간(term)을 길게 설정하게 되면, 락을 획득할 수 있음에도 불구하고 무조건 설정된 시간을 기다려야 하는 비효율적인 경우가 발생할 수 있음

  • Redission

    락 획득 방식: Pub / Sub (발행 / 구독) 방식

    락이 해제될 때마다 구독 중인 클라이언트에게 ‘락 획득 가능’이라는 알림을 보내기 때문에 클라이언트 측에서는 락 획득에 실패할 경우 redis에 락 획득 요청을 하는 과정이 사라지므로 계속된 요청으로 인한 부하가 발생하지 않게 됨

용어 정리

  • Context Switching: 스케줄러가 기존 실행 프로세스를 우선순위로 인해 미루고 새 프로세스로 교체해야 할 때, 프로세스 상태 값을 교체하는 작업. 프로그램 카운터와 스택 포인터 등으로 인해 스위칭이 가능함
  • 스케쥴러: 어떤 프로세스에게 자원을 할당할지 순서와 방법을 결정하는 운영체제 커널의 모듈
  • 오버헤드: 사용된 시간과 메모리의 양. 성능을 결정하는 중요한 요소
  • 프로그램 카운터(PC): 마이크로프로세서 내부에 있는 레지스터 중 하나로서, 다음에 실행될 명령어의 주소를 가지고 있어 실행할 기계어 코드의 위치를 지정하는 주소 레지스터
  • 스택 포인터(SP): CPU안에서 스택에 데이터가 채워진 마지막 위치를 가리키는 레지스터
  • 커널: 운영체제 중 항상 필요한 부분만을 전원이 켜짐과 동시에 메모리에 올려놓고 그렇지 않은 부분은 필요할 때 메모리에 올려서 사용하게 되는데, 이때 메모리에 상주하는 운영체제의 부분