코딩관계론

Thread Safety하게 만들자 본문

개발/Java

Thread Safety하게 만들자

개발자_티모 2024. 6. 9. 18:54
반응형

Thread Safety

여러 스레드가 한 함수를 동시에 호출해도 올바른 정답을 돌려준다면 thread-safe하다고 이야기합니다. 객체지향 패러다임에서는 객체를 기본 단위로 하여 상호작용하는 것입니다. Thread-safe하게 구현하기 위해서 지역변수만 사용하라, final값을 사용하라 등의 지침이 있지만 이는 우리가 알고 있는 객체의 모습이라고 보기 힘듭니다. 따라서 멤버 변수에서 어떻게 thread-safe를 구현하는지에 대해 집중하겠습니다.

 

Race condition

Race condition (경쟁 조건)은 다수의 스레드가 공유 자원에 접근하여 예상치 못한 결과를 초래할 수 있는 상황을 가리킵니다. 주로 다음과 같은 상황에서 발생할 수 있습니다:경쟁조건이랑 다수의 쓰레드가 공유자원에 접근해 예상치 못한 값을 반환할 때, 경쟁조건이 성립한다라고 말할 수 있습니다. 이 경우에 thread-safety하지 않다라고 말할 수 있게되고, 이를 해결하는 것이 thread-safety하게 만드는 방법이 되는 것이죠. 

 

그럼 경쟁조건은 어떤 연산을 수행할 때 발생할까요?

바로 Read-Modify-Write 연산을 수행할 때 입니다. 다음과 같은 코드가 있다고 생각해봅시다.

public class StaticClass {
    static int count = 0;

    public void increment(){
        count++;
    }

    public void decrement(){
        count--;
    }
}

public void testCustomConfiguration() {
        StaticClass s = new StaticClass();
        Thread t1 = new Thread(() -> {
            s.increment();
        });

        t1.start();

        Thread t2 = new Thread(() -> {
            s.increment();
        });

        t2.start();

        assertEquals(2, StaticClass.count);
}

 

test를 수행하면 다음과 같은 결과를 확인할 수 있습니다. 우리의 예상으로는 2가되어야 하는데 1이 됐습니다.

즉 읽기 후 쓰기연산이 제대로 동기화 되지 않은 상황이죠

org.opentest4j.AssertionFailedError: expected: <2> but was: <1>

 

 

만약 read or write 연산만 수행한다고 가정하면 경쟁조건이 성립할까요? 정답은 아니요입니다.

왜냐하면 read연산의 경우 변수를 수정하는 것이 아니기 때문에 일관성이 유지됩니다. 또한 write연산은 원자성이 보장되는 작업입니다. 따라서 경쟁조건이 아니라 실행순서에 따라 값이 달라지는 것이기 때문에 경쟁조건이 성립하지 않습니다.

synchronized 

먼저, "synchronized" 키워드는 다수의 스레드가 메서드 또는 코드 블록을 동시에 실행하지 못하도록 하는 Java의 동기화 메커니즘입니다. 이것은 "모니터"라는 개념을 기반으로 합니다. 모니터는 임계 영역(critical section)에 대한 접근을 조절하는 데 사용됩니다. "synchronized" 키워드를 사용하면 해당 메서드 또는 코드 블록이 모니터링됩니다

 

synchronized 동작방식

아래 코드가 있다고 생각했을 때 이  Counter클래스를 객체화하게 되면 Heap에는 아래의 그림처럼 메모리가 구성됩니다.

public class App {
    public static void main(String[] args) throws Exception {
        ThreadSafe ts = new ThreadSafe();

        // 새로운 스레드 생성 및 람다식을 사용하여 Runnable 구현
        Thread t1 = new Thread(() -> {
            ts.method1();
        });

        Thread t2 = new Thread(() -> {
            ts.method2();
        });

        t1.start();
        t2.start();
    }
}


public class ThreadSafe {
    public int count = 0;

    public synchronized void method1() {
        System.out.println(Thread.currentThread().getName() + ": method1 시작");
        try {
            Thread.sleep(3000); // 일부 작업을 시뮬레이션
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + ": method1 끝");
    }

    public synchronized void method2() {
        System.out.println(Thread.currentThread().getName() + ": method2 시작");
        try {
            Thread.sleep(3000); // 일부 작업을 시뮬레이션
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + ": method2 끝");
    }
}

 

heap 모습

 

그럼 아래의 그림처럼 하나의 Counter 객체에 여러 개의 Thread가 Sync increment함수를 호출하게 되면 다음과 같은 순서로 실행됩니다.

    1. 첫 번째 스레드가 increment 함수를 호출합니다.
    2. 해당 스레드는 Counter 객체의 모니터를 획득합니다.
    3. 모니터를 획득한 스레드는 increment 함수를 실행하고 count 값을 증가시킵니다.
    4. 다른 스레드들은 해당 메서드를 호출하려고 할 때 모니터를 획득하려고 시도하지만, 이미 다른 스레드가 모니터를 보유 중이므로 대기 상태에 있게 됩니다.
    5. 첫 번째 스레드가 increment 함수를 완료하고 모니터를 반납하면, 대기 중인 스레드 중 하나가 모니터를 획득하게 되고, 순차적으로 실행됩니다.
    6. 이 과정이 반복되면서 동기화된 실행이 이루어집니다.

 

synchronized의 객체 단위의 락이란?

객체단위로 락을 걸면 synchronized함수를 호출하면 해당 객체의 일반함수도 호출하지 못하는 것으로 생각할 수도 있는데 아니다. 일반함수도 동시에 호출할 수 있다.

 

위의 코드에서 method2함수에 synchronized키워드를 제거한다면 다음과 같은 출력을 확인할 수 있다. 즉 thread1에서 synchronized method1을 실행하고 있지만 thread2에 method2로 접근해서 함수를 실행할 수 있다.

Thread-0: method1 시작
Thread-1: method2 시작

 

하지만 synchronized함수는 객체간에 동기화가 됨으로 동시에 접근이 불가능하다. 따라서 이 부분을 객체단위의 락이라고 표현한다.

synchronized의 치명적인 단점 Dead lock

데드락(Deadlock)은 두 개 이상의 스레드가 서로의 락을 기다리며 무한 대기 상태에 빠지는 상황을 의미합니다. 이를 방지하기 위해 코드 작성 시 주의가 필요합니다. 예를 들어, 두 개의 락 객체를 사용하여 데드락이 발생할 수 있는 상황을 시뮬레이션한 코드는 다음과 같습니다:

public class ThreadSafe {
    public int count = 0;
    public Object obj = new Object();
    public Object obj2 = new Object();

    public void method1() {
        synchronized(obj){
            try {
                Thread.sleep(3000); // 일부 작업을 시뮬레이션
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized(obj2){

                System.out.println(Thread.currentThread().getName() + ": method1 시작");
                System.out.println(Thread.currentThread().getName() + ": method1 끝");
            }
        }
    }

    public void method2() {
        synchronized(obj2){
            try {
                Thread.sleep(3000); // 일부 작업을 시뮬레이션
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized(obj){

                System.out.println(Thread.currentThread().getName() + ": method2 시작");
                System.out.println(Thread.currentThread().getName() + ": method2 끝");
            }
        }
    }
}

 

이러한 Deadlock 상황을 해결하기 위해선 LOCK 순서를 일치시켜야 합니다 method2에서 obj2를 먼저 lock하는게 아닌 obj를 먼저 lock하게 되면 상황을 해결할 수 있습니다.

ReentrantLock

ReentrantLock은 암묵적인 모니터 락인 synchronized 메서드 및 문장을 사용하여 액세스되는 것과 동일한 기본 동작 및 의미를 가지고 있습니다. 하지만 ReentrantLock은 synchronized에 비해서 더 많은 기능을 제공하는데 그 중하나가 공정성입니다.

 

ReentrantLock의 생성자를 보시면 fail이라는 변수를 받고 있는데, 이를 true로 설정하면 쓰레드가 기아 상태로 빠지는 것을 막을 수 있습니다. synchronized의 경우 이 기능이 없기 때문에 쓰레드가 기아상태가 될 수도 있습니다.

  • Constructor SummaryConstructorsConstructor and Description 
    ReentrantLock()
    Creates an instance of ReentrantLock.
    ReentrantLock(boolean fair)
    Creates an instance of ReentrantLock with the given fairness policy.

하지만 이 설정을 수행한다면 전체 처리량이 낮을 수 있지만, 락을 얻는 데 걸리는 시간의 분산이 작아지고 기아 현상이 보장됩니다. 그러나 락의 공정성은 스레드 스케줄링의 공정성을 보장하지 않음에 유의해야 합니다. 

ReentrantLock 장단점은?

장점:

  1. 기아현상을 해결합니다. ReentrantLock은 공정한 기다림(Fairness) 정책을 사용할 수 있으므로, 대기 중인 스레드가 오랫동안 블록되지 않고 공정하게 잠금을 획득할 수 있습니다.
  2. 더 높은 유연성을 제공합니다. ReentrantLock은 tryLock()와 같은 추가 메서드를 통해 더 세밀한 제어가 가능합니다.
  3. 인터럽트를 지원합니다. lockInterruptibly() 메서드를 사용하여 다른 스레드가 인터럽트를 발생시킬 수 있습니다.

단점:

  1. 공정성을 보장하지 않습니다. 공정한 기다림(Fairness) 정책을 사용할 때, 더 오랜 시간이 소요될 수 있으며 이는 처리량을 저하시킬 수 있습니다. 기아현상을 해결하려다보니 공정성을 보장하지 못하는 특성이 있습니다.
  2. 코드가 복잡해질 수 있습니다. ReentrantLock을 사용하면 명시적으로 잠금을 획득하고 해제해야 하므로 코드가 더 복잡해질 수 있습니다.
  3. 자바의 내장된 동기화 메커니즘보다 성능이 떨어질 수 있습니다. 하지만 이는 일반적으로 실제 애플리케이션에서는 큰 문제가 되지 않습니다.

Thread local

Thread Local은 각 스레드에게 독립적인 저장 공간을 제공하여 스레드 간 변수의 충돌을 방지합니다. 이것은 스레드로부터 캡슐화된 데이터를 저장하고 검색하기 위한 간단한 방법을 제공합니다

 

인증을 예시로 들면 굉장히 쉽다. 우리가 인증을 성공하면 is_auth라는 변수가 true로 변경된다는 코드가 있다고 가정해보자.

package com.example.javatest;

public class Auth {
    private boolean is_auth;

    public boolean login(String name, String password){
        if (is_auth){
            return true;
        }
        if (name.equals("test1234") && password.equals("test1234")){
            is_auth=true;
            return is_auth;
        }
        
        return is_auth;
    }

    public void verify(String name){
        System.out.println(name + "의 인증 결과는? " + this.is_auth);

    }

}

 

메인에서는 아래와 같이 호출한다.  출력 결과는 다음과 같다 "thread2의 인증 결과는? true"

public class JavaTestApplication {
    public static void main(String[] args) {
        Auth auth = new Auth();

        new Thread(() -> {
            auth.login("test1234", "test1234");
        }).start();

        new Thread(() -> {
            auth.login("test1234", "test12345");
            auth.verify();
        }).start();
    }
}

 

실행과정을 확인해보면 

  1. 먼저 실행한 thread가 인증에 성공했기 때문에 is_auth라는 변수는 true가 된다.
  2. 후에 실행한 변수는 is_auth가 true이기 때문에 login을 검증하지 않고 통과된다.

 

만약 thread local변수를 선언해서 is_auth를 가져올 수 있도록 하면 어떻게 될까? 짐작가시다 싶이 이젠 로그인이 불가능해질 것이다.

즉 thread에서 is_auth를 get했을 때 해당 쓰레드의 로그인의 성공 유무에 따라 true, false를 반환하게 될 것이다.

package com.example.javatest;

public class Auth {
    private static  final ThreadLocal<Boolean> threadlocal = new ThreadLocal<Boolean>();

    public boolean login(String name, String password){
        Boolean is_auth = threadlocal.get();

        if (is_auth == null){
            is_auth = false;
        }

        if (is_auth){
            System.out.println("이미 인증에 성공했음: " + name);
            return true;
        }

        if (name.equals("test1234") && password.equals("test1234")){
            System.out.println("인증에 성공했음: " + name);
            threadlocal.set(true);
            return true;
        }

        return false;
    }

    public void verify(String name){
        Boolean is_auth = threadlocal.get();
        if (is_auth == null){
            is_auth = false;
        }
        System.out.println(name + "의 인증 결과는? " + is_auth);
    }
}

 

 

그림을 설명하자면 thread1에서는 login을 성공하여 thread local에 is_auth를 true로 저장할 수 있었다.

 

하지만 thread2에서는 login을 하지 않고 증명을 요구했기 때문에 is_auth는 false가 된다.

 

Thread local 장단점은?

장점:

  1. 각 스레드에 대해 독립적인 값을 유지합니다. 이로써 동기화 문제가 발생하지 않으며, 데드락이나 기아현상과 같은 문제를 방지할 수 있습니다.
  2. 스레드 간에 공유되지 않는 데이터를 효율적으로 관리할 수 있습니다. 예를 들어, 각각의 스레드에서 사용하는 사용자 세션 정보, 트랜잭션 상태 등을 저장하는 데 사용할 수 있습니다.
  3. 스레드 안전한(Safe)한 방식으로 데이터를 공유할 수 있습니다. 여러 스레드에서 동시에 접근하더라도 각 스레드는 자신만의 값에 접근하므로 별도의 동기화가 필요하지 않습니다.

단점:

  1. 메모리 누수(Memory Leak)가 발생할 수 있습니다. ThreadLocal 변수에 대해 사용이 종료되지 않으면 해당 변수가 차지하는 메모리는 계속 유지됩니다. 이러한 상황이 장기간 지속되면 시스템 전체의 메모리 사용량에 영향을 미칠 수 있습니다.
  2. 가비지 컬렉션의 오버헤드가 발생할 수 있습니다. ThreadLocal 변수가 사용 종료되고 해당 스레드가 종료되더라도, 가비지 컬렉터가 이를 인식하고 정리하는 데 시간이 소요될 수 있습니다.

주의사항

하지만 주의할 점은 있는데 thread를 생성하고 소멸하는 것은 자원이 많이 소모되는 일이기 때문에 기존 쓰레드를 재활용해서 사용할 수가 있다. 그 방법 중 하나가 쓰레드 풀을 생성하고 미리 생성된 쓰레드 풀을 주는 것이다.

 

하지만 이 방법으로 했을 때는 기존 thread를 재활용하는 것임으로 인증정보가 남게된다. 아래의 코드를 실행하면 auth가 true로 나오는 것을 확인할 수 있다.

package com.example.javatest;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class JavaTestApplication {

    public static void main(String[] args) {
        int poolSize = 1;
        ExecutorService executorService = Executors.newFixedThreadPool(poolSize);
        Auth auth = new Auth();

        for (int i = 1; i <= 2; i++) {
            if (i == 1) {
                executorService.submit(() -> {
                    auth.login("test1234", "test1234");
                });
            }else{
                executorService.submit(() -> {
                    auth.verify("thread2");
                });
            }
        }
    }

}

 

따라서 remove함수를 호출해서 인증정보를 지워야 한다.

   //Auth class의 함수
   public void remove(){
        threadlocal.remove();
    }
반응형

'개발 > Java' 카테고리의 다른 글

Error vs Exception  (0) 2024.06.18
Heap 메모리의 청소부 - Garbage Collection  (0) 2024.06.09
static, final 어디까지 알아보고 왔는가?  (0) 2024.06.09
JVM 메모리 구조  (0) 2024.06.08
자바 어떻게 실행되는가?  (0) 2024.06.02