Java/Thread

Thread (3) - 스레드 간 데이터 공유의 문제점 및 해결 방법

수수한개발자 2023. 11. 7.
728x90

멀티 스레드 환경의 애플리케이션에서 스레드 간 데이터 공유를 하면 어떤 문제점이 생기고 해결하는 방법에 대해 소개해보겠습니다.

 

예제 코드

 

예를 들어 메서드를 호출할 때마다 게임 아이템을 하나씩 늘리고 줄이는 로직 있다고 가정합니다.

public class Main {

    public static void main(String[] args) throws InterruptedException {
        InventoryCounter inventoryCounter = new InventoryCounter();
        IncrementingThread incrementingThread = new IncrementingThread(inventoryCounter);
        DecrementingThread decrementingThread = new DecrementingThread(inventoryCounter);

        incrementingThread.start();
        decrementingThread.start();

        incrementingThread.join();
        decrementingThread.join();

        System.out.println("We currently have " + inventoryCounter.getItems() + " items");
    }

    private static class DecrementingThread extends Thread {
        private final InventoryCounter inventoryCounter;

        public DecrementingThread(InventoryCounter inventoryCounter) {
            this.inventoryCounter = inventoryCounter;
        }

        @Override
        public void run() {
            for(int i = 0; i < 10000; i++) {
                inventoryCounter.decrement();
            }
        }
    }

    private static class IncrementingThread extends Thread {
        private final InventoryCounter inventoryCounter;

        public IncrementingThread(InventoryCounter inventoryCounter) {
            this.inventoryCounter = inventoryCounter;
        }

        @Override
        public void run() {
            for(int i = 0; i < 10000; i++) {
                inventoryCounter.increment();
            }
        }
    }

    private static class InventoryCounter {
        private int items = 0;

        public void increment() {
            items++;
        }

        public void decrement() {
            items--;
        }

        public int getItems() {
            return items;
        }
    }
}

 

 

위의 코드에서 예상하는 바는 만 번 아이템을 더해주고 만 번 아이템을 빼줬으므로 0을 기대합니다.

하지만 안타깝게도 매번 다른 결과를 가져오게 됩니다.

 

이전 글에서 알 수 있듯이 InventoryCounter 는 객체이므로 힙 영역에 할당됩니다.

이 힙 영역에 할당된 멤버 변수 또한 힙영역으로 공유 자원입니다.

그렇기 때문에 두 스레드에서 모두 공유되는 자원이므로 액세스 가능해진다는 것이고 items++ 와 items--가

동시에 호출되고 단일 작업이 아니기 때문에 해당 작업은 원자적 작업이 아니라고 할 수 있습니다.

 

문제점

원자적 작업이란 하나 또는 여러개의 집합의 작업으로 한 번에 동시 실행된 것처럼 보이는 작업을 뜻합니다.

"all or nothing" 원칙으로 연산이 완전히 수행되거나 아니면 전혀 수행되지 않아야 함을 의미합니다.

items++;

 

위의 한줄짜리 코드가 왜 원자적 작업이 아닐까요?

위의 코드를 풀어 설명하면 3가지로 나누어 설명할 수 있습니다.

  1. 먼저 메모리에 저장된 items의 현재 값을 가져옵니다.
  2. 현재 값에 1을 더합니다.
  3. 값을 더한 결과를 items 변수에 저장합니다.

items -- 또한 같은 수행을 하게 됩니다.

 

그러면 왜 매번 다른 결과가 나올까요?

 

시나리오를 세우면 

  1. 1번 스레드가 현재 items 변숫값을 가져옵니다.  items -> 0
  2. 2번 스레드가 items 값을 가져옵니다. items -> 0
  3. 1번 스레드가 ++ 연산을 합니다. items -> 1
  4. 2번 스레드가 -- 연산을 합니다. items -> -1
  5. 2번 스레드가 items 변수에 결과를 저장합니다 items -> -1
  6. 1번 스레드가 items 변수에 결과를 저장합니다. itesm -> 1

이렇게 두 스레드가 서로의 작업에 대해서는 알지 못한 채 자기 할 일만 하기 때문에 매번 결과가 다르게 나오게 됩니다.

 

 

임계 영역 - critical section

위와 같은 문제는 빈번하게 생기는 오류입니다.

이렇게 동시 실행되지 않게 보호해야 하는 코드가 있는 영역을 임계 영역(critical section)이라고 부릅니다.

 

위의 increment() 메서드 위 아래로 임계 영역을 가지고 있다고 한다면

스레드 A가 임계 영역에 들어오게 된다면 스레드 B는 스레드 A가 나갈 때까지 액세스 할 수 없게 됩니다.

그러므로 원자성이 보장되어 집니다.

 

 

 

Synchronized

여러 개의 스레드가 코드 블록이나 전체 메서드에 액세스 할 수 없도록 설계된 락입니다.

해당 키워드를 사용하는 방법은 2가지가 있습니다.

 

첫 번째는 synchronized 키워드를 붙인 메서드를 선언해주는 방법입니다.

public synchronized void increment() {
    items++;
}

public synchronized void decrement() {
    items--;
}

 

여러 개의 스레드가 이 클래스의 동일한 객체에서 해당 메서드를 호출하려고 하면, 한 개의 스레드만 메서드 중 하나를 실행할 수 있게 됩니다.

특이한 점은 Thread A가 increment() 메서드를 호출하여 임계 영역에 들어가게 되면 Thread B는 increment, decrement 두 개의 메서드 모두 호출할 수 없게 됩니다.

synchronized는 객체마다 각각 동기화가 됩니다.

각 동기화된 메서드들을 각각의 방에 대한 문이라고 생각하고 하나의 문이 잠기면 다른 문들도 잠기게 됩니다.

 

 

두 번째는 임계 영역이라고 생각되는 코드의 블록을 정의하고 synchronized 키워드를 이용해 전체 메서드를 동기화하지 않으면서 그 영역에 대해서만 액세스 하는 방법입니다.

 

public void increment() {
    synchronized (this) {
        items++;
    }
}

public synchronized void decrement() {
    synchronized (this) {
        items--;
    }
}

 

위와 같은 방법이 더 유연성을 높여줍니다.

Thread A가 incremnet()에 접근하게 될 때 Thread B는 decrement()에 접근하여 처리할 수 있게 됩니다.

또 다른 장점은 해당 블록 부분만 동기화한다는 부분입니다.

 

 

Volatile

기본적으로 자바에서 객체의 주소값을 참조하는 (reference) 경우는 단일 연산을 통해 안정하게 연산할 수 있습니다.

이게 중요한 이유는 래퍼런스를 가져오거나 배열, 문자열 등 객체에 설정하는 작업을 getter, setter가 원자적으로 수행하게 되어서 동기화시킬 필요가 없습니다.

 

자바에서는 Primitive, Reference 두 가지의 타입이 있는데

원시형 타입은 정수, 실수 등 실제 데이터 값을 저장하는 타입이고 레퍼런스 타입은 객체의 주소값(메모리 주소)을 저장하는 타입입니다.

 

여기서 long과 double을 제외한 모든 할당도 원시적 작업에 해당됩니다.

롱과 더블은 길이가 64비트 이기 때문에 자바가 보장을 해주지 않습니다.

64비트 컴퓨터인 경우라고 하더라도 CPU가 두 개 연산을 통해 하위 32비트, 상위 32비트에 쓰게 되어 완료할 가능성이 큽니다.

 

Java는 이에 대해 해결책으로 Volatile이라는 키워드를 제공합니다.

이 키워드를 통해 long과 double을 선언하면 해당 변수에 읽고 쓰는 작업이 thread-safe 한 원자성을 가집니다.

 

public class Main {
    public static void main(String[] args) {
        DataRace dataRace = new DataRace();
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1_000_000; i++) {
                dataRace.increment();
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1_000_000; i++) {
                dataRace.checkForDataRace();
            }
        });

        thread1.start();
        thread2.start();
    }

    public static class DataRace {
        int x = 0;
        int y = 0;

        public void increment() {
            x++;
            y++;
        }

        public void checkForDataRace() {
            if (y > x) {
                System.out.println("This should not be possible!");
            }
        }
    }
}

 

위의 코드에서 y는 항상 x보다 클 수가 없습니다.

예를 들면 Thread2가 y > x 를 체크할 때 Thread1이 increment 함수를 호출하기전이라면 

x == y 로 똑같기 때문에 조건문에 걸리지 않고

만약 동시에 호출을 했더라도 x == y 이거나 x가 더 큰 경우로 나오기 때문에 if문을 탈수가 없습니다.

하지만 위의 코드를 실행하면 이상하게 출력문이 나옵니다. 왜그럴까요?

 

이것이 바로 데이터 경쟁이라고 하는데요.

종종 컴파일러와 CPU가 성능 최적화와 하드웨어 활용을 위해 비순차적으로 명령을 처리하는 경우가 있다고 합니다.

컴파일러와 CPU가 이렇게 처리하지 않으면 프로그램 처리 속도는 매우 늦어집니다.

 

public void someFuntion() {
    x = 1;
    y = x + 2;
    z = y + 10;
}

 

하지만 위의 코드 같은 경우는 비순차적으로 실행되지 않습니다.

각 코드가 이전 코드에 의존하고 있기 때문입니다.

 

public void increment1() {
	x++;
	y++;
}

public void increment2() {
	x++;
	y++;
}

 

 

하지만 위의 코드같이 의존성이 없는 x++, y++ 같은 코드는 해당 메서드들의 논리가 동일하므로 두 메서드가 같다고 판단합니다.

싱글 스레드에서는 문제가 없지만 멀티 스레드라면 다른 코어에서 실행되는 스레드를 인지하지 못하고 동일 변수를 읽고

특정 처리 순서에 의존하게 됩니다.

 

예제 코드에서 변수에 volatile 키워드를 붙여 해결 할 수 있습니다.

이 키워드는 메인 메모리에 직접 쓰기 때문에 비용이 많이 듭니다.

그리고 위의 예제에서는 스레드1은 쓰기만 하고 스레드2는 읽기만하기 때문에 데이터 경쟁을 피할 수 있었습니다.

결국에 각각의 스레드가 쓰기를 하게 된다면 예상치 못한 결과를 가져올 수 있기때문에 읽기와 쓰기가 나눠진 스레드에서 사용해야합니다.

 

728x90

댓글