// 에드센스

스레드는 작업의 한 단위이다. 프로세스는 독자적인 메모리를 할당받아서 서로 다른 프로세스끼리는 일반적으로 서로의 메모리 영역을 침범하지 못한다. 하지만 프로세스 내부에 있는 여러 스레드들은 서로 같은 프로세스 내부에 존재하고 있기 때문에 같은 자원을 공유하여 사용할 수 있다. 같은 자원을 공유할 수 있기 때문에 동시에 여러 가지 일을 같은 자원을 두고 수행할 수 있고, 이는 곧 병렬성의 향상으로 이어진다.

 

잠깐 동시성과 병렬성을 짚고 넘어가자

 

 

동시성 VS 병렬성

동시성 병렬성
동시에 실행되는 것 같이 보이는 것 실제로 동시에 여러 작업이 처리되는 것
싱글 코어에서 멀티 쓰레드(Multi thread)를 동작 시키는 방식 멀티 코어에서 멀티 쓰레드(Multi thread)를 동작시키는 방식
한번에 많은 것을 처리 한번에 많은 일을 처리
논리적인 개념 물리적인 개념

 

첫번째 그림은 그냥 순차적으로 실행되는 모습(싱글코어)

 

두번째 그림은 동시성으로 실행되는 모습. 실제로 작업의 흐름은 한가닥이지만 여러 작업을 번갈아가며 조금씩 수행하기에 마치 동시에 진행되는 것 처럼 보인다. 작업 전환시마다 컨텍스트 스위칭이라고 비용이 발생한다.(싱글코어)

 

세번째 그림은 병렬성 작업으로 실제로 동시에 따로 작업이 진행되는 것이다.(멀티코어)

 

 

 

쓰레드 안정이 깨지는 상황

멀티 스레드 환경에서 스레드 안전(Thread-safe)한 프로그램을 제작하기 위해서는 어떤 경우에 스레드 안전하지 않은 동작이 생기는지 먼저 만들어볼 필요가 있다. 정말 간단한 예제로, 조회수 계산 로직이 있다. 특정 글을 조회하는 순산 원래 조회수에 1을 더한 값을 저장할 것이고, 여러 사용자가 동시에 접근할 것이므로 멀티 스레드 환경에서 동작한다고 가정해 보겠다.

 

public class CountingTest {
    public static void main(String[] args) {
        Count count = new Count();
        for (int i = 0; i < 100; i++) {
            new Thread(){
                public void run(){
                    for (int j = 0; j < 100; j++) {
                        System.out.println(count.view());
                    }
                }
            }.start();
        }
    }
}
class Count {
    private int count;
    public int view() {return count++;}
    public int getCount() {return count;}
}

해당 코드를 실행시켰을 때, 100명의 사용자가 100번 조회했으므로 100 * 100, 즉 10000번의 조회수가 나올것이라 예상 할 수 있다.

하지만 실제 결과값을 보았을 때는 10000번이 아닌 그보다 더 적은 조회수가 나온다. 그 이유는 조회수를 증가시키는 로직이 두 번의 동작으로 이루어지는데 동시에 여러 스레드가 접근하여 첫 번째 동작할 때의 자원과 두 번째 동작할 때의 자원 상태가 변하기 때문이다.

 

count++는

1. count변수 값을 조회한다.

2. count변수에 조회한 값에 1을 더한 값을 저장한다.

를 수행한다. 이때 발생하는 문제가 여러 쓰레드에서 count 변수를 동시에 조회하면 발생한다.

 

 

그림처럼 쓰레드1과 2에서 동시에 count변수의 값을 조회하면 둘 다 100이라는 값에 1을 더한 값을 count변수에 다시 저장하기에 102가 나와야하지만 실제론 101이 되는 것이다.

 

 

동시성을 제어하는 방법

1. 암시적 Lock

하나의 쓰레드가 접근했을때 다른 쓰레드는 접근하지 못하도록 Lock을 거는것이다. 동시성 문제를 해결할 수 있지만 한번에 하나의 쓰레드만 접근이 가능하므로 병렬성은 매우 떨어진다. 메서드에 synchronized 키워드를 붙이면 암시적 락을 적용할 수 있다.

 

메서드 Lock

class Count {
    private int count;
    public synchronized int view() {return count++;}
}

 

변수 Lock

class Count {
    private Integer count = 0;
    public int view() {
        synchronized (this.count) {
            return count++;
        }
    }
}

 

 

2. 명시적 Lock

synchronized 키워드 없이 명시적으로 ReentrantLock을 사용하는 Lock을 명시적 Lock이라고한다. 해당 Lock의 범위를 메서드 내부에서 한정하기 어렵거나, 동시에 여러 Lock을 사용하고 싶을 때 사용한다. 

 

명시적 Lock을 사용한 예제

public class CountingTest {
    public static void main(String[] args) {
        Count count = new Count();
        for (int i = 0; i < 100; i++) {
            new Thread(){
                public void run(){
                    for (int j = 0; j < 1000; j++) {
                        count.getLock().lock();
                        System.out.println(count.view());
                        count.getLock().unlock();
                    }
                }
            }.start();
        }
    }
}
class Count {
    private int count = 0;
    private Lock lock = new ReentrantLock();
    public int view() {
            return count++;
    }
    public Lock getLock(){
        return lock;
    };
}

 

 

3. 자원의 가시성을 책임지는 volatile

 

여러 쓰레드가 하나의 자원에 동시에 읽기/쓰기를 진행할때 항상 메모리에 접근하지 않는다. 성능 향상을 위해 CPU 캐시를 참조하여 값을 조회하는데 이 값과 메인 메모리의 값이 항상 일치하는지 보장할 수 없다. 즉, 변수를 조회하여 값을 읽었는데 실제 값과 다를 수 있다는 말이다. 실제 자원의 값(메인 메모리 값)을 볼 수 있는 개념을 자원의 가시성이라고 부르는에 이 자원의 가시성을 확보하지 못한것이다.

 

 

"멀티쓰레드 환경에서 쓰레드가 변수를 읽어올 때,

CPU 캐시에 저장된 값이 다르기 때문에 변수 값 불일치 문제가 발생"

 

 

public class SharedObject {
    public int counter = 0;
}

Thread-1은 counter값을 증가시키고 있지만 CPU Cache에만 반영되어있고 실제로 Main Memory에는 반영이 되지 않는 상태. 그렇기 때문에 Thread-2는 count값을 계속 읽어오지만 0을 가져오는 문제가 발생. 

 

 

volatile은 이러한 CPU 캐시 사용을 막는다. 해당 변수에 volatile 키워드를 붙여주면 해당 변수는 캐시에 저장되는 대상에서 제외된다. 매 번 메모리에 접근해서 실제 값을 읽어오도록 설정해서 캐시 사용으로 인한 데이터 불일치를 막는다. 실제 메모리에 저장된 값을 조회하고 이를 통해 자원의 가시성을 확보할 수 있다.

 

public class SharedObject {
    public volatile int counter = 0;
}

 

volatile은 자원의 가시성을 확보해주지만 동시성 이슈를 해결하기에는 그리 충분하지 않다. 공유 자원에 read&write를 할 때는 동기화를 통해 해당 연산이 원자성을 이루도록 설정해주어야 동시성 이슈를 해결할 수 있다.

 

 

volatile이 효과적인 경우는 하나의 스레드가 wtite를 하고 다른 하나의 스레드가 read만 할 경우다. 이 경우 read만 하는 스레드는 CPU 캐시를 사용하고 다른 스레드가 write한 값을 즉각적으로 확인하지 못한다. volatile은 이런 경우 해당 자원에 가시성을 확보하게 해 줌으로써 동시성 이슈 해결에 도움을 줄 수 있다.

 

 

 

4. 쓰레드 안전한 객체 사용

Concurrunt 패키지를 통해 쓰레드 안전한 구조를 챙길 수 있다.

class Count {
    private AtomicInteger count = new AtomicInteger(0);
    public int view() {
            return count.getAndIncrement();
    }
}

 

5. 불변 객체

String 객체처럼 한번 만들면 그 상태가 변하지 않는 객체를 불변객체라고 한다. 불변 객체는 락을 걸 필요가 없다. 내부적인 상태가 변하지 않으니 여러 스레드에서 동시에 참조해도 동시성 이슈가 발생하지 않는다는 장점이 있다. 즉, 불변 객체는 언제라도 스레드 안전.

 

불변 객체는 객체의 상태를 변화시킬 수 있는 부분을 모두 제거해야한다. setter를 만들지 말고 final로 선언하면 된다.

 

 

 

참고:

'Java' 카테고리의 다른 글

[Java] 제네릭(Generic)  (0) 2021.07.11
[Java] Garbage Collection  (4) 2021.07.01
[Java] Static 키워드  (0) 2021.06.28
[Java] JVM 구조와 동작원리  (0) 2021.06.27
[Java] Thread/MultiThread 3 - 쓰레드풀  (0) 2021.06.19

+ Recent posts