본문 바로가기
Language/Java

[Java] 멀티쓰레드 동기화 - synchronized

by jaee_ 2021. 9. 11.

쓰레드의 동기화

씽글쓰레드의 경우에는 하나의 프로세스 내에서 하나의 쓰레드만 작업하기 때문에 프로세스의 자원에 대한 문제가 딱히 없다. 하지만 멀티쓰레드의 경우 여러개의 쓰레드가 하나의 프로세스 내의 자원을 공유하기 때문에 서로의 작업이 프로세스 내에서 공유하는 공유자원에  영향을 끼칠 수 있다. 예를들어, A 쓰레드가 공유자원을 가져다 작업을 하던 도중 B 쓰레드에게 제어권이 넘어가게 될 경우 다시 A 쓰레드가 제어권을 가지고 작업을 마무리 했을 때 원하는 결과가 도출되지 않을 가능성이 있다. 

 

 이러한 일이 발생하지 않도록 하기 위해서는 A 쓰레드가 작업이 끝나기 전까지 다른 쓰레드에게 제어권이 넘어가지 않도록 하는 것이 필요하다. 그래서 도입된 개념이 '임계영역'과 '잠금(lock)'이다.

 

'임계영역'이란 프로세스간에 공유자원에 접근하는데에 있어 문제가 발생하지 않도록 한번에 하나의 프로세스만 이용하게끔 보장해줘야 하는 영역이다. '락(lock)'이란 하나의 쓰레드나 프로세스가 자원을 사용하고 있는 동안에 잠금을 하여 접근을 못하게 하는 방식이다.

 

공유 데이터를 사용하는 코드영역을 임계영역으로 지정하고, 공유 데이터가 가지고 있는 잠금(lock)을 획득한 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게끔 하고, 잠금(lock)을 획득했던 쓰레드가 영역 내에서 작업이 끝나면 영역을 벗어나 잠금(lock)을 다시 반납한다. 그리고 다른 쓰레드가 반납된 잠금(lock)을 획득하여 임계영역에서 코드를 수행하게 된다. 

 

이처럼 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것쓰레드의 동기화(synchronization)이라고 한다.

 

자바에서는 쓰레드의 동기화를 위해 synchronized 블럭, java.util.concurrent.locks, java.util.concurrent.automic 패키지를 지원하고 있다. 이 게시글에서는  synchronized 블럭을 이용한 동기화와 volatile키워드 , Automic 클래스에 대해서 살펴볼 것이다. 

 


 

synchronized를 이용한 동기화 방법

우선 synchronized를 이용한 동기화 방법부터 알아보자. 말 그대로 synchronized 키워드를 사용하여 임계영역을 설정하면 된다. 사용방법은 메소드에 선언하여 메소드 전체를 임계영역으로 지정하는 방법특정한 영역에만 선언하여 특정 영역만 임계영역으로 지정하는 방법이 있다. 

    // 1. 메소드에 선언하여 메소드 전체를 임계영역으로 지정하는 방법
    public synchronized void calcSum() {

    }

    // 2. 특정영역에 선언하여 특정영역만 임계영역으로 지정하는 방법
    public void calc() {
        synchronized (참조변수){ //임계영역 
        }
    }

위의 코드와 같이 선언할 수 있다. 자세한 예제는 아래를 통해서 살펴보도록 하자. 우선 synchronized 이용하지 않은 예제이다. 

import study.week06.Account.RunnableSample2;

// synchronized를 사용하지 않은 예제
public class SynchronizedSample {

    public static void main(String[] args) {
        Runnable run = new RunnableSample2();
        new Thread(run).start();
        new Thread(run).start();
    }

class Account {

    private int balance = 1000;

    public int getBalance() {
        return balance;
    }

    public void withdraw(int money) {
        if (balance >= money) { // (1)
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            balance -= money;
        }
    }

    static class RunnableSample2 implements Runnable {

        Account account = new Account();

        @Override
        public void run() {
            while (account.getBalance() > 0) {
                // 100,200,300 중의 한 값을 임의로 선택하여 출금
                int money = (int) (Math.random() * 3 + 1) * 100;
                account.withdraw(money);
                System.out.println("잔액 : "+ account.getBalance());
            }
        }
    }
}


//결과 
잔액 : 900
잔액 : 900
잔액 : 300
잔액 : 300
잔액 : -100
잔액 : -100

Account(은행계좌)에서 balance(잔고)를 확인하고 출금하려는 금액이 잔고보다 클 경우에 금액을 출금하는 예제이다. 하지만 결과를 보면 -100값이 나온것을 볼 수 있다. 이런 결과가 나오는 이유는 한 쓰레드가 if문(1)을 통과하고나서 다른 쓰레드가 출금을 진행했기 때문에 나오는 결과이다.

 

이해를 돕기위해 추가해서 말하자면, A쓰레드와 B쓰레드가 작업중일 때 하나의 계좌(Account)라는 공유자원을 가지고 돈계산을 하고 있다. A 쓰레드는 if문을 통과해서 출금을 진행하는 동안 B쓰레드는 if문 검사를 받는 중이다. B쓰레드가 if문을 검사할 땐 계좌에 출금할 돈이 충분했지만 출금을 진행하려고 하는 동안 A쓰레드에서 이미 출금을 진행해버렸다. 그렇기 때문에 결과가 음수값이 나오게 되는 것이다. 

 

그럼 위의 예제를 synchronized를 사용하여 lock을 걸어서 위와같은 문제를 해결해보자. 

    // 1.메소드에 선언하여 메소드 전체를 임계영역으로 지정
    public synchronized void withdraw(int money) {
        if (balance >= money) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            balance -= money;
        }
    }
    
    // 2. 특정영역에 선언하여 특정영역만 임계영역으로 지정
    public void withdraw(int money) {
        synchronized (this){
            if (balance >= money) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                balance -= money;
            }
        }
    }
    
// 결과
잔액 : 900
잔액 : 700
잔액 : 400
잔액 : 300
잔액 : 100
잔액 : 0

위의 코드의 withdraw 메소드만 수정해주었다 . 여러번 실행해도 결과는 음수값이 나오지 않는 것을 확인 할 수 있다. 이렇게 메소드 또는 특정영역에 synchronized를 선언해주어 임계영역을 지정하고 lock을 획득한 쓰레드가 작업이 끝나기 전까지 다른 쓰레드가 접근하지 못하도록 하면 동기화를 할 수 있다.

 

또한, 위의 코드에서 중요한 것이 있는데 바로 Account 클래스 내에 있는 balance라는 변수가 private이고 setter메소드가 없으며 getBalance() 메소드를 통해서만 접근이 가능하다. 만약 이 값이 public이며 setter메소드가 있다면 외부에서 접근이 가능하므로 동기화를 해도 값이 변경되는 것을 막을 방법이 없다. 그러므로 동기화를 위해선 불변객체(변하지 않는 객체)로 선언해주는 것도 중요하다. 

 

메소드에 직접적으로 synchronized를 선언하는 것도 동기화를 할 수 있는 방법이지만 임계영역은 멀티쓰레드 프로그램의 성능을 좌우하기 때문에 가능하면 메소드 전체에  lock을 거는 것보다 synchronized 블럭을 이용하여 임계영역을 최소화하여 프로그래밍을 하는 것이 좋다. 

 


volatile

이전에 concurrenctHashMap에 대해서 글을 쓴 적이 있는데 그때 처음 접했던 단어이다. 간단하게 다시 정리해보자. 

 

멀티코어 프로세서에서는 코어마다 캐시를 가지고 있다. 코어는 메모리에서 읽어온 값을 캐시에 저장하고 캐시에서 값을 읽어서 작업한다. 다시 같은 값을 읽어올 때는 먼저 캐시에 있는지 확인하고 없을때에만 메모리에서 읽어온다. 그렇기 때문에 멀티쓰레드 환경에서 메모리에 있는 공유자원을 사용하려해도 캐시때문에 원하는 값이 나오지 않을 가능성이 있다. 

 

자바에서의 volatile 키워드는 위의 문제점을 해결해줄 수 있는 멀티쓰레드 환경에서 동기화를 해주는 키워드이다. 변수 앞에 volatile을 붙이면 코어가 변수의 값을 읽어올 때 캐시값을 읽어오는 것이 아니라 메모리에 있는 값을 읽어오기 때문에 캐시와 메모리 간의 값 불일치하는 문제를 해결할 수 있다. 

위의 그림은 CPU마다 캐시영역을 가지고 있고 쓰레드에서 값을 읽을때마다 메인메모리가 아닌 캐시에 저장된 값을 읽어오는 것을 보여주는 그림이다. 

이 그림은 메인메모리에 공유자원인 공유자원인 counter 변수가 존재하고 CPU1과 CPU2안에 있는Thread가 모두 counter변수에 접근한다. Thread1에서 counter를 증가시켰지만 메인메모리에 값이 써지는 것이 아닌 캐시에 값이 저장이 된다. 이렇게 연산한 CPU1의 counter 값이 언제 메인메모리로 쓰여질 지 알 수 없다. 이러한 문제를 가시성 문제라고 한다. 

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

위처럼 변수의 앞에 volatile 키워드를 선언해주면 가시성 문제를 해결할 수 있다.

 

하지만, volatile 키워드를 사용해주었다고 해서 모든 동기화가 해결되는 것은 아니다. 아래의 그림을 보자. 

Thread 1에서 counter값을 0으로 읽고 값을 +1하여 증가시키는 연산을 진행한다. 그리고 Thread1의 값이 메인메모리에 쓰여지기 전에 Thread2에서 counter의 값을 가지고 +1하여 증가시키는 연산을 진행한다고 하자. 어떤 결과가 나올 것 같은가? 두개의 쓰레드 모두 +1의 연산을 했으니 당연히 2가 나와야 하지만 두개의 쓰레드가 0의 값을 가지고 연산을 했으니 값은 1이 나올 것이다. 

 

즉, volatile은 멀티쓰레드 환경에서 오직 한 개의 쓰레드에서 쓰기 & 읽기 작업을 할 때, 그리고 다른 쓰레드들은 읽기 작업만 할 때 안정성을 보장한다. 위의 예제처럼 멀티쓰레드 환경에서 동시에 여러 쓰레드가 읽기 & 쓰기 작업을 할 때는 synchronized 키워드를 사용하여 값의 원자성을 보장해주어야 한다. 

 

또한, volatile은 메인메모리에 직접 값을 read/write  하므로 CPU Cache에서 접근하는 것보다 많은 비용이 발생한다는 점을 고려하여 사용해야 한다. 

 


Atomic

java.concurrent.atomic 패키지를 보면 원자적 연산을 수행 할 수 있는 클래스들이 있다.
Atomic 클래스들은 Compare and Swap(CAS) 기반으로 되어 있어 스레드에 안전하다. Compare and Swap(CAS)란 변수의 값을 변경하기 전에 기존의 값이 내가 예상한 값인지 확인하여 같은 경우에만 값을 할당하는 알고리즘 기법이다. (값을 할당하기 전 한 번 더 검사함) 


참고

https://thswave.github.io/java/2015/03/08/java-volatile.html

https://icarus8050.tistory.com/121

 

 

댓글