본문 바로가기

Web Sever 개발과 CS 기초/자바

동시성 문제에 대한 이해와 Java의 Lock 객체 종류

개요 목적

자바 스레드 기능을 사용하면, 여러 처리를 동시에 진행할 수 있는 장점이 있다.

하지만, 공유된 자원을 동시에 접근했을 때, 동시성 문제가 발생 할 수 있다.

이 문제를 해결하는 방법이 동기화이다.

자바 제공하는 동기화 방법은 lock 객체를 사용하는 것이다.

이번 글에서는 lock 객체에 대한 이해와 종류에 대해서 알아본다.

lock 객체를 잘 이해하기 위해, 관련 용어에 대한 설명을 먼저 한다. 

동시성 문제, 동기화, Synchronized블럭, lock객체 이해

쓰레드는 하나의 프로세스의 공통된 자원을 사용해, 쓰레드간 빠른 전환이 가능하다.

하지만 공통된 자원을 쓰는데 주의점이 있다.

예를 들어 int num = 0; 이라는 공통된 자원에 +1을 기능하는 4개의 쓰레드가 동시에 실행된다.

그러면, 값을 더해서 num에 저장하는 과정이 겹치게 되어, 원하는 결과가 나오지 않게 된다.

(왜냐하면, 너무 동시에 +1 작업이 진행되기 때문에 +1이 생략된 스레드가 발생한다.)

이렇게 여러 요청이 동시에 동일한 자원(data)에 접근하고 수정하여 생기는 문제를 동시성 문제라고 한다.

 

그래서 필요한 것이 임계영역(Critical Section)이다.

공유 자원을 연산하는 곳에 임계영역을 걸어둔다.

임계 영역에 먼저 들어온 A 쓰레드가 임계영역에 대한 lock흭득한다.

임계영역에서 연산을 끝낸 A쓰레드가 락을 반납해야(unlock) 뒤에 대기하던 B쓰레드 락을 얻고 임계영역에 들어올 수 있다.

→ 이렇게 임계영역(Critical Section)을 한 쓰레드가 쓰고 있으면, 다른 쓰레드가 접근하지 못하게 막는 것을 동기화라고 한다.

임계영역을 만드는 방법으로는 synchronized블럭lock객체를 이용하는 방법이 있다.

 

현재 자바에서는 사용의 유연성이 좋은 lock객체를 프로젝트를 진행한다. 

그 중 가장 많이 사용되는 ReentrantLock과 그 외 lock 객체에 대해서 알아보자. 

ReentrantLock 

ReentrantLock과 Condition으로 효율적인 스레드 환경을 구성한다.

ReentrtantLock과 Condition으로 기아상태와 경쟁 상태를 방지할 수 있다.

  • 기아 상태(호출 받지 못해서 block영역에 오랫동안 갇힌 경우)
  • 경쟁 상태(lock을 얻기 위해, 심한 경쟁을 해야 하는 경우)
class Table {
    String[] dishNames ={ "donut", "donut", "burger"};
    final int MAX_FOOD = 6;
    private ArrayList<String> dishes = new ArrayList<>();

    private ReentrantLock lock = new ReentrantLock();
    private Condition forCook = lock.newCondition();
    private Condition forCust = lock.newCondition();
    public void add(String dish) {
        lock.lock();
        try {
            while (dishes.size() >= MAX_FOOD) {
                String name = Thread.currentThread().getName();
                System.out.println(name + "is waiting");
                try {
                    forCook.await();
                    System.out.println("대기수" + lock.getQueueLength());
                    Thread.sleep(500);
                }catch (InterruptedException e) {}
            }
            dishes.add(dish);
            forCust.signal();
            System.out.println("Dishes: " + dishes.toString());
        }finally {
            lock.unlock();
        }
    }
}

<코드 설명>

cook과 customer의 대기 영역을 완전히 나누어서,

음식이 채워지면 cutmer 대기 영역을 불러와 실행을 준비한다.

condition으로 대기열을 나누지 않을 경우,

음식이 채워지면, cook과 customer가 같은 대기열이 있기 때문에,

cook이 불러와 질 수 있다. 그래서 lock을 얻기 위한 경쟁도 더 심해진다.

 

반면에 Sysnchronized 블럭 사용한다면,

누가 notify를 얻을지 알 수 없다.

public synchronized void add(String dish){ // synchronized를 추가
        while(dishes.size() >= MAX_FOOD){
            String name = Thread.currentThread().getName();
            System.out.println(name+" is waiting");
            try{
                wait(); 
                Thread.sleep(500);
            }catch (InterruptedException e){}
        }
        dishes.add(dish);
        System.out.println("make " +dish);
        notifyAll(); 
        System.out.println("Dishes : " + dishes.toString());
    }

 

synchronized과 다른 ReentrantLock이 가지는 차이점

  • 공정성 생성자를 지원한다.

ReentrantLock lock = new ReentrantLock(true);

생성자에 true를 넣을 경우, 공정성을 지원한다.

공정성이란, 가장 오래기다린 쓰레드가 락을 획득하게 해준다.

  • 다양한 메소드를 통해서 스레드 상황을 파악할 수 있다.

ex) getQueueLength()  - 현재 lock을 얻으려 대기하는 스레드와 그 수를 알 수 있다.

Semaphore

Semaphore를 사용하면 동시에 접근할 수 있는 스레드 수를 제한할 수 있다..

<Semaphore 사용 예시>

acquire()메소드로 제한된 자원에 접근을 허용하고, 접근 가능 수를 -1한다

접근 가능 수가 0이면 다른 스레드가 나올 때가지 기다려야 한다.

release()를 통해 자원에서 스레드가 빠져나오면, 접근 가능 수 +1이 된다.

그러면 다른 스레드 중 하나가 자원에 접근 할 수 있다.

import java.util.concurrent.Semaphore;

public class Semaphore1 {
    
    public static void main(String[] args) {
        SomeResource resource = new SomeResource(3);
        for (int i = 0; i < 10; i++) {
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    resource.use();
                }
            });
            t.start();
        }
    }
}

class SomeResource {
    private final Semaphore semaphore;
    private final int maxThread;
    
    SomeResource(int maxThread) {
        this.maxThread = maxThread;
        this.semaphore = new Semaphore(maxThread);
    }
    
    public void use() {
        try {
            semaphore.acquire();
            System.out.println("[" + Thread.currentThread().getName() + "]"
                + semaphore.toString() + " 사용중");
            Thread.sleep((long) (Math.random() * 10000));
            System.out.println("[" + Thread.currentThread().getName() + "] 종료");
            semaphore.release(); // Thread 가 semaphore에게 종료를 알림
        } catch (InterruptedException e) {

        }
    }
}

<실행결과>

[Thread-2]java.util.concurrent.Semaphore@14d3f560[Permits = 1] 사용중
[Thread-0]java.util.concurrent.Semaphore@14d3f560[Permits = 0] 사용중
[Thread-1]java.util.concurrent.Semaphore@14d3f560[Permits = 1] 사용중
[Thread-2] 종료
[Thread-9]java.util.concurrent.Semaphore@14d3f560[Permits = 0] 사용중
[Thread-0] 종료
[Thread-5]java.util.concurrent.Semaphore@14d3f560[Permits = 0] 사용중
[Thread-1] 종료
[Thread-3]java.util.concurrent.Semaphore@14d3f560[Permits = 0] 사용중
[Thread-5] 종료
[Thread-6]java.util.concurrent.Semaphore@14d3f560[Permits = 0] 사용중
[Thread-9] 종료
[Thread-8]java.util.concurrent.Semaphore@14d3f560[Permits = 0] 사용중
[Thread-3] 종료
[Thread-7]java.util.concurrent.Semaphore@14d3f560[Permits = 0] 사용중
[Thread-8] 종료
[Thread-4]java.util.concurrent.Semaphore@14d3f560[Permits = 0] 사용중
[Thread-4] 종료
[Thread-6] 종료
[Thread-7] 종료

thread2가 종료된 후에야 thread9가 접근하는 것을 볼 수 있다.


Reference

https://javarevisited.blogspot.com/2013/03/reentrantlock-example-in-java-synchronized-difference-vs-lock.html#ixzz4lSKXKi6n

https://loosie.tistory.com/543?category=964815

https://www.geeksforgeeks.org/reentrantreadwritelock-class-in-java/

https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=seban21&logNo=70154615066

https://javaplant.tistory.com/30