Thread를 공부하던 중에 Lock이라는 요소가 중요하다. 이 부분에 대해 더 깊게 공부하고자 정리해보고자 한다.

Lock이란?

동시에 여러 쓰레드가 같은 자원에 접근하는 것을 제한하여 데이터의 일관성과 무결성을 유지하는데 사용되는 도구이다. java에서 java.util.concurrent.locks에서 여러 타입의 Lock을 제공한다. 동기화의 더 유연한 형태를 제공하는 장점이 있다.

Lock 사용방법

  1. Lock 획득: lock() 메서드를 호출하여 Lock을 획득한다.
  2. Critical section: Lock을 획득한 후, 공유 자원에 접근하는 코드를 실행
  3. Lock 해제: unlock() 메서드를 호출하여 Lock을 해제한다. 이 부분은 반드시 finally 블록 안에 수행되어야만 함

vs Synchronized

  • 유연성: Lock는 tryLock() 메서드를 제공함으로써 Lock을 얻을 수 없을 떄 다른 작업을 수행할 수 있는 유연성을 제공.
  • 재진입 가능: ReentrantLock은 이름에서 알 수 있듯이, 재진입이 가능

ReentrantLock

  • Lock 인터페이스를 구현한 클래스로 재진입이 가능한 Lock이다.
  • 한 쓰레드가 이미 Lock을 보유하고 있는 경우, 해당 쓰레드는 Lock을 다시 획득 할 수 있음.
  1. 필요한 경우

    • 재귀 함수: 재귀 함수에서 Lock을 획득하고 해제하는 경우, 재진입 가능하지 않다면 같은 쓰레드 내에서도 Lock을 다시 획득할 수 없게 되므로 DeadLock이 발생할 수있다.
    • 복잡한 메서드 호출 구조: 하나의 큰 연산을 여러 메서드로 분할하여 구현하는 경우, 공유 자원을 접근해야 하는데, 재진입을 할수 없다면 DeadLock과 같은 문제가 발생할 수 있다.
    • 확장성: 코드의 확장성을 향상시킬 수 있다. 메서드나 블록이 이미 Lock을 획득한 상태에서도 안전한 다른 메서드나 코드 볼록을 호출할 수 있다.
  2. 소스코드

    public class ThreadTest {
        private final Lock lock = new ReentrantLock();
        private int count = 0;
        public int getCount() {
            return count;
        }
    
        public void increment() {
            lock.lock();  // Lock을 획득
            try {
                count++; // critical section
            } finally {
                lock.unlock();  // Lock을 해제
            }
        }
        @Test
        public void test(){
            ThreadTest example = new ThreadTest();
    
            Thread t1 = new Thread(() -> {
                for (int i = 0; i < 10000; i++) {
                    example.increment();
                }
            });
    
            Thread t2 = new Thread(() -> {
                for (int i = 0; i < 10000; i++) {
                    example.increment();
                }
            });
    
            t1.start();
            t2.start();
    
            try {
                t1.join();
                t2.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    
            System.out.println("Count: " + example.getCount());  // Should print 2000
        }
    }
  3. 출력

    • Lock을 설정했을 경우
      Count: 20000
    • Lock을 설정하지 않았을 경우
      Count: 13705 // 값이 계속 바뀐다

    ReentrantReadWriteLock

    • Write Lock, Read Lock을 분리하여 관리한다. 여러 쓰레드가 동시에 Read를 수행할 수 있지만, Write 작업은 한 번에 하나의 쓰레드만 수행가능하다.
    1. 필요한 경우

      • Read 작업이 Write 작업보다 빈번히 발생하고, Read 작업 간에는 동시성을 허용해야 하는 경우
    2. 소스코드

      public class ThreadTest {
          class ReadWriteLockExample {
              private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
              private int data;
      
              public void write(int newData) {
                  rwLock.writeLock().lock();
                  try {
                      data = newData;
                  } finally {
                      rwLock.writeLock().unlock();
                  }
              }
      
              public int read() {
                  rwLock.readLock().lock();
                  try {
                      return data;
                  } finally {
                      rwLock.readLock().unlock();
                  }
              }
          }
      
          @Test
          public void test() {
              ReadWriteLockExample example = new ReadWriteLockExample();
      
              // Write
              Thread thread1 = new Thread(() -> example.write(42));
              thread1.start();
              try {
                  thread1.join();
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
      
              // Read
              Thread thread2 = new Thread(() -> {
                  int value = example.read();
                  System.out.println("Read Value: " + value);
              });
              thread2.start();
          }
      }
    3. 출력

      Read Value: 42

StampedLock

  • Read와 Write Lock을 제공하는 동시에, 더 가벼운 “Optimistic Lock”모드를 지원한다.
  • Write또는 Write Lock 모드에서 서로 변경 가능하다.
    • Upgrade: Read Lock → Write Lock
    • Downgrade: Write Lock → Read Lock
  1. 필요한 경우

    • Write작업이 많고, 데이터의 변경 빈도가 낮은 상황에서 높은 동시성을 유지해야 하는 경우
    • Non-Blocking 알고리즘과 함께 사용되는 경우
  2. 소스코드

    public class ThreadTest {
        class StampedLockExample {
            private final StampedLock stampedLock = new StampedLock();
            private int balance;
    
            public void deposit(int amount) {
                long stamp = stampedLock.writeLock();
                try {
                    balance += amount;
                } finally {
                    stampedLock.unlockWrite(stamp);
                }
            }
    
            public int getBalance() {
                long stamp = stampedLock.tryOptimisticRead();
                int b = balance;
                if (!stampedLock.validate(stamp)) {
                    stamp = stampedLock.readLock();
                    try {
                        b = balance;
                    } finally {
                        stampedLock.unlockRead(stamp);
                    }
                }
                return b;
            }
        }
    
        @Test
        public void test() {
            StampedLockExample example = new StampedLockExample();
    
            // Deposit
            Thread thread1 = new Thread(() -> example.deposit(100));
            thread1.start();
            try {
                thread1.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
    
            // Read Balance
            Thread thread2 = new Thread(() -> {
                int balance = example.getBalance();
                System.out.println("Balance: " + balance);
            });
            thread2.start();
        }
    }
  3. 출력

    Balance: 100

Semaphore

  • 동시에 접근할 수 있는 쓰레드의 최대 허용 수를 제한한다.
  1. 필요한 경우

    • 동시 접근 수를 제한해야 하는 경우(N개)
  2. 소스코드

    public class ThreadTest {
        @Test
        public void accessResource() {
            int numberOfPermits = 2; // 동시에 접근할 수 있는 허용 횟수
    
            Semaphore semaphore = new Semaphore(numberOfPermits);
    
            Runnable task = () -> {
                try {
                    semaphore.acquire(); // 세마포어로부터 허가를 얻음
                    System.out.println(Thread.currentThread().getName() + " acquired the semaphore.");
                    // 공유 자원에 대한 작업 수행
                    Thread.sleep(2000); // 잠시 대기
                    System.out.println(Thread.currentThread().getName() + " released the semaphore.");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    semaphore.release(); // 세마포어를 해제하여 다른 스레드가 접근할 수 있도록 함
                }
            };
    
            // 여러 스레드 생성 및 실행
            Thread thread1 = new Thread(task, "Thread 1");
            Thread thread2 = new Thread(task, "Thread 2");
            Thread thread3 = new Thread(task, "Thread 3");
    
            thread1.start();
            thread2.start();
            thread3.start();
        }
    }
  3. 출력

    Thread 1 acquired the semaphore.
    Thread 2 acquired the semaphore.

Optimistic Locking vs Pessimistic Locking

앞서 언급한 StampedLock 언급한 Optimistic Locking에 대해서 설명하고자 한다. 그런 김에 상반된 개념인 Pessismistic Locking에 대해도 알아보고자 한다.

  1. Optimistic Locking (낙관적 락)

    • 동시에 여러 쓰레드나 프로세스가 자원에 접근하는 것을 허용하며, 충돌이 발생하는 것을 예상하지 않음
    • 자원을 읽을 때는 Lock을 걸지 않거나 매우 짧게 사용하고, 자원을 변경하기 전에 해당 자원의 상태를 확인. → version number 또는 Timestamp 등과 같은 메타데이터로 확인
    • 변경 작업 쓰레드가 완료 된 후, 다른 쓰레드가 해당 데이터를 읽을 때 충돌이 발생하는 지 감지한다. → 충돌이 발생한다면 해결하거나 Rollback을 하여 일관성 유지
  2. Pessimistic Locking (비관적 락)

    • 데이터를 변경할 때 즉시 Lock을 획득
    • 데이터의 일관성과 무결성을 보장
    • 주로 RDBMS에 사용된다. → 트랜잭션에서 Lock을 사용함으로써 데이터 일관성을 유지
    Optimistic LockingPessimistic Locking
    Lock 획득 시기데이터 변경 시 미리 Lock 을 획득데이터 변경 시 미리 Lock 을 획득
    동시성높음(다른 사용자와 동시 접근)낮음(다른 사용자가 대기)
    Lock 유지 시간없음Lock 기간
    충돌 감지 방법데이터 변경 시 version 충돌 감지데이터 변경 시
    Lock 해제 방법데이터 변경 시 version 증가데이터 변경 후
    장점Lock없이 동시 접근 가능, 충돌 감지Lock 획득 시 데이터 안전 보장
    단점version 충돌 발생 가능성성능 저하, 대기 시간 발생
    사용 분야트래픽이 많고 Lock 충돌 가능성 낮은 시스템금융, 예약 시스템 등 안정성 중시

DeadLock

Lock에서 빠질 수 없는 개념인 DeadLock이라는 요소를 알아보고자 한다.

  • 동시성 프로그래밍에서 발생하는 상황 중 하나로, 다수의 프로세스 또는 쓰레드가 상대방의 작업이 끝나기를 기다리며 진행하지 못하는 상태
  1. 충족 조건
    • 상호 배제(Mutual Exclusion): 자원은 한 번에 하나의 프로세스 또는 쓰레드만 사용할 수 있어야 함
    • 점유 대기(Hold & Wait): 프로세스나 쓰레드는 이미 보유한 자원을 가진 채 다른 자원을 얻기 위해 대기
    • 비선점(No Preemption): 다른 프로세스나 쓰레드가 이미 보유한 자원을 강제로 뺐을 수 없음
    • 순환 대기(Circular Wait): 여러 프로세스 또는 쓰레드 간에 자원을 순환적으로 대기하고 있는 상태
  2. 예방 및 해결
    • 예방
      • 상호배제 제거: 여러 프로세스가 공유 자원 사용
      • 점유 대기 부정: 프로세스 실행 전 모든 자원을 할당
      • 우선순위 설정: 자원을 점유 중인 프로세스가 다른 자원을 요구할 떄 가진 자원을 반납
      • 순환대기 부정: 자원에 고유 번호를 할당한 후에 순서대로 자원을 요청
    • 회피
      • 은행원 알고리즘(Banker's Algorithm)
        • 은행에서 모든 고객의 요구가 충족되도록 현금을 할당하는 것에서 유래
        • Maximum Demand(최대 요구량): 각 자원 유형의 최대 가용 가능 자원 수를 미리 알고 있어야 함
        • 프로세스가 자원을 요청할 때, 시스템은 해당 자원을 할당하고도 DeadLock이 발생하지 않는 지 확인
        • 요청한 자원을 프로세스에게 할당할 수 없다면 대기 상태로 전환
        • 다른 프로세스가 자원을 반납하면, 대기 중인 프로세스에게 해당 자원을 할당할 수 있게 됨
        • 장점; DeadLock를 방지하면서도 자원을 최대한 효율적으로 사용
        • 단점: Maximum Demand를 미리 알고 있어야 하므로 실제 상황에서 적용하기 어려운 경우도 있음
    • Detection & Recovery
      • Detection : DeadLock이 발생하면 이를 탐지하고 복구하기 위한 알고리즘을 사용→ 탐지 된 DeadLock상태에서 프로세스를 종료하거나 자원을 해제하는 방법을 적용
      • Recovery: 탐지 된 DeadLock 상태에서 프로세스를 종료하거나 자원을 해제하는 방법을 적용

Philosopher's Dining Problem

DeadLock을 이해하고 해결하기 위한 예제

  • 다섯 명의 철학자가 원형 식탁에 앉아서 밥을 먹으며, 그 사이에는 다섯 개의 포크가 놓여있는 상황을 가정한다. 스파게티와 같은 면 요리이기 때문에 두 개의 포크가 필요하다. 각 철학자는 다음과 같은 행동을 반복
    • 생각하기: 철학자는 자신의 머리를 생각으로 가득 채운다.
    • 배고픈 상태: 철학자가 배고플 때, 그는 왼쪽 포크와 오른쪽 포크를 동시에 집으려고 시도한다.
    • 먹기: 양손에 포크를 모두 들고 있을 경우, 철학자는 밥을 먹는다.
    • 포만 상태: 먹은 후에 포크를 내려놓고 포만 상태가 된다.
  • 문제점
    • 모든 철학자가 왼쪾 포크를 집으려고 시도하면 더이상 어떤 철학자도 오른쪽 포크를 집을 수 없으므로 DeadLock이 발생한다.
  • 해결방법
    • 각 철학자와 젓가락에 고유한 아이디를 할당
    • 포크를 나타내는 세마포어 또는 뮤텍스를 사용하여 포크를 획득하고 해제하는 과정을 동기화
    • 철학자들이 순서를 일관되게 따르도록 메커니즘을 구현

결론

지금까지 Lock의 기본적인 개념부터 Java에서 사용하는 Lock관련 라이브러리들을 정리해봤다. 또 사용되는 개념인 낙관적 락 & 비관적 락, 발생할만한 문제인 DeadLock에 대해서 정리해보았다.


References

profile
I'm not only a web developer.

0개의 댓글