개발자는 기록이 답이다

멀티스레드 (2) - 동기화, 스레드 상태 및 제어 본문

언어/Java

멀티스레드 (2) - 동기화, 스레드 상태 및 제어

slow-walker 2023. 12. 6. 11:40

1. 동기화 메소드와 동기화 블록

 

공유 객체를 사용할 때의 주의할 점

 

싱글 스레드 프로그램에서는 한 개의 스레드가 객체를 독차지해서 사용하면 되지만, 멀티 스레드 프로그램에서는 스레드들이 객체를 공유해서 작업해야 하는 경우가 있다. 이 경우, 스레드 A를 사용하던 객체가 스레드 B에 의해 상태가 변경될 수 있기 때문에 스레드 A가 의도했던 것과는 다른 결과를 산출할 수도 있다. 이는 마치 여러 사람이 계산기를 함께 나눠 쓰는 상황과 같아서 사람 A가 계산기로 작업을 하다가 계산 결과를 메모리에 저장한 뒤 잠시 자리를 비웠을 때 사람 B가 계산기를 만져서 앞 사람이 메모리에 저장한 값을 다른 값으로 변경하는 것과 같다. 그런 다음 사람 A가 돌아와 계산기에 저장된 값을 이용해서 작업을 진행한다면 결국 사람 A는 엉터리 값을 이용하게 된다.

 

 

User1 스레드가 Calculator객체의 memory필드에 100을 먼저 저장하고 2초간 일시 정지 상태가 된다. 그동안에 User2 스레드가 memory필드값을 50으로 변경한다. 2초가 지나 User1 스레드가 다시 실행 상태가 되어 memory 필드의 값을 출력하면 User2가 저장한 50이 나온다.

package org.example.chapter14.synchronization;

//메인 스레드가 실행하는 코드
public class MainThreadExample {
    public static void main(String[] args) {
        Calculator calculator = new Calculator();

        User1 user1 = new User1(); // 스레드 설정
        user1.setCalculator(calculator);// 공유 객체 설정
        user1.start();// 스레드 시작

        User2 user2 = new User2();
        user2.setCalculator(calculator);
        user2.start();
    }
}

package org.example.chapter14.synchronization;

//공유 객체
public class Calculator {
    private int memory;

    public int getMemory() {
        return memory;
    }
    // 계산기 메모리에 값을 저장하는 메모리
    public void setMemory(int memory) {
        this.memory = memory;// 매개값을 memory필드에 저장
        try {
            Thread.sleep(2000); // 스레드를 2초간 일시정지
        } catch (InterruptedException e) {}
                            //스레드 이름                                //메모리 값
        System.out.println(Thread.currentThread().getName() + ": " + this.memory);
    }
}
package org.example.chapter14.synchronization;

public class User1 extends  Thread {
    private Calculator calculator;

    public void setCalculator(Calculator calculator) {
        this.setName("CalculatorUser1");// 스레드 이름을 CalculatorUser1로 설정
        this.calculator = calculator;// 공유 객체인 Calculator를 필드에 저장
    }

    public void run() {
        calculator.setMemory(100);// 공유 객체인 Calculator의 메모리에 100을 저장
    }
}
package org.example.chapter14.synchronization;

public class User2 extends  Thread {
    private Calculator calculator;

    public void setCalculator(Calculator calculator) {
        this.setName("CalculatorUser2");// 스레드 이름을 CalculatorUser12로 설정
        this.calculator = calculator;// 공유 객체인 Calculator를 필드에 저장
    }

    public void run() {
        calculator.setMemory(50);// 공유 객체인 Calculator의 메모리에 50을 저장
    }
}
package org.example.chapter14.synchronization;

public class User2 extends  Thread {
    private Calculator calculator;

    public void setCalculator(Calculator calculator) {
        this.setName("CalculatorUser2");// 스레드 이름을 CalculatorUser12로 설정
        this.calculator = calculator;// 공유 객체인 Calculator를 필드에 저장
    }

    public void run() {
        calculator.setMemory(50);// 공유 객체인 Calculator의 메모리에 50을 저장
    }
}

 

 

동기화 메소드 및 동기화 블록

 

스레드가 사용 중인 객체를 다른 스레드가 변경할 수 없도록 하려면 스레드 작업이 끝날 때까지 객체에 잠금을 걸어서 다른 스레드가 사용할 수 없도록 해야 한다. 멀티 스레드 프로그램에서 단 하나의 스레드만 실행할 수 있는 코드 영역을 임계영역(critical section)이라고 한다.

자바는 임계영역을 지정하기 위해 동기화(synchronized)메소드 동기화 블록을 제공한다.

 

스레드가 객체 내부의 동기화메소드 또는 블록에 들어가면 즉시 객체에 잠금을 걸어 다른 스레드가 임계 영역 코드를 실행하지 못하도록 한다. 동기화 메소드를 만드는 방법은 다음과 같이 메소드 선언에 synchronized 키워드를 붙이면 된다. synchronized키워드는 인스턴스와 정적 메소드 어디든 붙일 수 있다.

 

public synchronized void method() {
	임계 영역; // 단 하나의 스레드만 실행
}

 

동기화 메소드는 메소드 전체 내용이 임계 영역이므로 스레드가 동기화 메소드를 실행하는 즉시 객체에는 잠금이 일어나고, 스레드가 동기화 메소드를 실행 종료하면 잠금이 풀린다. 메소드 전체 대용이 아니라, 일부 내용만 임계 영역으로 만들고 싶다면 아래와 같이 동기화(synchronized)블록을 만들면 된다.

public void method() {
	//여러 스레드가 실행 가능 영역
    ...
    // 동기화 블록
    synchronized(공유객체) { // 공유 객체가 객체 자신이면 this를 넣을 수 있다.
    	임계 영역 // 단 하나의 스레드만 실행
    }
    // 여러 스레드가 실행 가능 영역
    ...
}

 

동기화 블록이 외부 코드들은 여러 스레드가 동시에 실행할 수 있지만, 동기화 블록의 내부 코드는 임계 영역이므로 한 번에 한 스레드만 실행할 수 있고 다른 스레드는 실행할 수 없다. 만약 동기화 메소드와 동기화 블록이 여러 개 있을 경우, 스레드가 이들 중 하나를 실행할 때 다른 스레드는 해당 메소드는 물론이고 다른 동기화 메소드 및 블록도 실행할 수 없다. 하지만 일반 메소드는 실행 가능하다.

 

아래 예제는 이전 예제에서 문제가 된 공유 객체인 Calculator를 수정한 것이다, Calculator의 SetMemory()메소드를 동기화 메소드로 만들어서 User1스레드가 setMemory()를 실행할 동안 User2스레드가 setMemory()메소드를 실행할 수 없도록 했다.

 

package org.example.chapter14.synchronization;

//공유 객체
public class Calculator {
    private int memory;

    public int getMemory() {
        return memory;
    }
    // 계산기 메모리에 값을 저장하는 메모리
    public synchronized void setMemory(int memory) {
        this.memory = memory;// 매개값을 memory필드에 저장
        try {
            Thread.sleep(2000); // 스레드를 2초간 일시정지
        } catch (InterruptedException e) {}
                            //스레드 이름                                //메모리 값
        System.out.println(Thread.currentThread().getName() + ": " + this.memory);
    }
}

 

MainThreadExample를 다시 실행시켜보면 User1은 100, User는 50이라는 출력값을 얻는데, 

 

User1스레드는 Calculator객체의 동기화 메소드은 setMemory()를 실행하는 순간 Calculator객체를 잠근다. 메인 스레드가 User2스레드를 실행시키지만, 동기화 메소드인 setMemory()를 실행시키지는 못하고 User1이 setMemory()를 모두 실행할 동안 대기해야 한다. User1스레드가 setMemory()메소드를 모두 실행하고 나면 User2스레드가 setMemory()를 실행한다.

 

결국 User1스레드가 Calculator객체를 사용할 동안 User2스레드는 Calculator객체를 사용하지 못하므로 User1스레드는 안전하게 방해받지 않고 Calculator객체를 사용할 수 있게 된다.

 

아래처럼 동기화 메소드가 아닌 동기화 블록으로도 만들 수 있다.

    public void setMemory(int memory) {
        synchronized (this) {// 공유 객체인 Calculator의 참조(잠금 대상)
            this.memory = memory;// 매개값을 memory필드에 저장
            try {
                Thread.sleep(2000); // 스레드를 2초간 일시정지
            } catch (InterruptedException e) {}
            //스레드 이름                                //메모리 값
            System.out.println(Thread.currentThread().getName() + ": " + this.memory);
        }
    }

 

스레드가 동기화 블록으로 들어가면 this(Calculator 객체)를 잠그고, 동기화 블록을 실행한다. 동기화 블록을 모두 실행할 때까지 다른 스레드들은 this(Calculator 객체)의 모든 동기화 메소드 또는 동기화 블록을 실행할 수 없게 된다.

 

2. 스레드 상태

 

스레드 객체를 생성하고, start()메소드를 호출하면 곧바로 스레드가 실행되는 것처럼 보이지만 사실은 실행 대기 상태가 된다. 실행 대기 상태란 아직 스케줄링이 되지 않아서 실행을 기다리고 있는 상태를 말한다. 실행 대기 상태에 있는 스레드 중에서 스레드 스케줄링으로 선택된 스레드가 비로서 CPU를 점유하고 run()메소드를 실행한다. 이때를 실행(Running)상태라고 한다. 실행 상태의 스레드는 run()메소드를 모두 실행하기 전에 스레드 스케줄링에 의해 다시 실행 대기 상태로 돌아갈 수 있다. 그리고 실행 대기 상태에 있는 다른 스레드가 선택되어 실행 상태가 된다. 이렇게 스레드는 실행 대기 상태와 실행 상태를 번갈아가면서 자신의 run()메소드를 조금씩 실행한다. 실행 상태에서 run()메소드가 종료되면, 더이상 실행할 코드가 없기 때문에 스레드의 실행은 멈추게 된다. 이 상태를 종료 상태라고 한다.

 

 

경우에 따라서 스레드는 실행 상태에서 실행 대기 상태로 가지 않을 수도 있다. 실행 상태에서 일시 정지 상태로 가기도 하는데, 일시 정지 상태는 스레드가 실행할 수 없는 상태이다. 일시 정지 상태는 WAITING, TIMED_WAITING, BLOCKED가 있는데, 일시 정지 상태가 되는 이유는 나중에 설명하도록 하고, 스레드가 다시 실행 상태로 가기 위해서는 일시 정지 상태에서 실행 대기 상태로 가야 한다는 점만 알아두자

 

이러한 스레드 상태를 코드에서 확인할 수 있도록 하기 위해 자바 5부터 Thread클래스에 getState()메소드가 추가 되었다. getState()메소드는 다음 표처럼 스레드 상태에 따라서 Thread, State열거 상수를 리턴한다.

 

아래는 스레드 상태를 출력하는 StatePrintThread 클래스이다. 생성자 매개값으로 받은 타겟 스레드의 상태를 0.5초 주기로 출력한다.

package org.example.chapter14.status;

//타겟 스레드의 상태를 출력하는 스레드
public class StatePrintThread extends Thread{
    private Thread targetThread;

    public StatePrintThread(Thread targetThread) {//상태를 조사할 스레드
        this.targetThread = targetThread;
    }

    public void run() {
        while(true) {
            Thread.State state = targetThread.getState();// 스레드 상태 열기
            System.out.println("타겟 스레드 상태: " + state);

            if (state == Thread.State.NEW) { //객체 생성 상태일 경우 실행 대기 상태로 만듬
                targetThread.start();
            }

            if (state == Thread.State.TERMINATED) {// 종료상태일 경우 while문을 종료
                break;
            }

            try {
                //0.5초간 일시 정지
                Thread.sleep(500);
            } catch (Exception e) {}
        }
    }
}

 

아래는 타겟 스레드 클래스이다. 3라인에서 10억번 루핑을 돌게 해서 RUNNABLE 상태를 유지하고 7라인에서 sleep() 메소드를 호출해서 1.5초간 TIMED_WAITING상태를 유지한다. 그리고 10라인에서는 다시 10억번 루핑을 돌게 해서 RUNNABLE상태를 유지한다.

package org.example.chapter14.status;

//타겟 스레드
public class TargetThread extends Thread {
    public void run() {
        for (long i = 0; i < 1000000000; i++) {}

        try {
            //1.5초간 일시 정지
            Thread.sleep(1500);
        } catch (Exception e) {}

        for (long i = 0; i < 1000000000; i++) {}
    }
}

 

TargetThread가 객체로 생성되면 NEW상태를 가지고, run()메소드가 종료되면 TERMINATED상태가 되므로 결국 다음과 같은 상태로변한다.

 

다음은 StatePrintThread를 생성해서 매개값으로 전달받은 TargetThread의 상태를 출력하는 실행 클래스이다.

package org.example.chapter14.status;

// 실행 클래스
public class ThreadStateExample {
    public static void main(String[] args) {
        StatePrintThread statePrintThread = new StatePrintThread(new TargetThread());
        statePrintThread.start();
    }
}

 

3. 스레드 상태 제어

 

사용자는 미디어 플레이어에서 동영상을 보다가 일시 정지시킬수도 있고, 종료시킬 수도 있다. 일시 정지는 조금 후 다시 동영상을 보겠다는 의미이므로 미디어 플레이어는 동영상 스레드를 일시 정지 상태로 만들어야 한다. 그리고 종료는 더 이상 동영상을 보지 않겠다는 의미이므로 미디어 플레이어는 스레드를 종료 상태로 만들어야 한다. 이와 같이 실행 중인 스레드의 상태를 변경하는 것을 스레드 상태 제어라고 한다.

 

멀티 스레드 프로그램을 만들기 위해서는 정교한 스레드 상태 제어가 필요한데, 상태 제어가 잘못되면 프로그램은 불안정해져서 먹통이 되거나 다운된다. 멀티 스레드 프로그래밍이 어렵다고 하는 이유가 바로 여기에 있다. 스레드는 잘 사용하면 약이 되지만, 잘못 사용하면 치명적인 프로그램 버그가 되기 때문에 스레드를 정확하게 제어한느 방법을 잘 알고 있어야 한다. 스레드 제어를 제대로 하기 위해서는 스레드의 상태 변화를 가져오는 메소드들을 파악하고 있어야 한다. 

 

위 그림에서 취소선을 가진 메소드는 스레드의 안전성을 해친다고 하여 더 이상 사용되지 않도록 권장된 Deprecated메소드이다.

 

 

 

위 표에서 wait(), notify(), notifyAll()은 Object클래스의 메소드이고, 그 이외의 메소드는 모두 Thread 클래스의 메소드들이다. wait(), notify(), notifyAll()메소드의 사용 방법은 스레드의 동기화에서 자세히 설명하기로 하고, 이번에는 Thread클래스의 메소드들만 살펴볼 것이다.

 

주어진 시간동안 일시정지 sleep()

 

실행 중인 스레드를 일정 시간 멈추게 하고 싶다면 Thread 클래스의 정적 메소드인 sleep()을 사용하면 된다. 다음과 같이 Thread.sleep()메소드를 호출한 스레드는 주어진 시간동안 일시 정지 상태가 되고, 다시 실행 대기 상태로 돌아간다.

try {
	Thread.sleep(1000);
} catch (InterruptedException e) {
	// interrupt() 메소드가 호출되면 실행
}

 

매개값에는 얼마동안 일시 정지 상태로 있을 것인지, 밀리세컨드(1/1000) 단위로 시간을 주면 된다. 위와 같이 1000이라는 값을 주면 스레드는 1초가 경과할 동안 일시 정지 상태로 있게 된다. 일시 정지 상태에서 주어진 시간이 되기 전에 interrupt()메소드가 호출되면 InterruptedException이 발생하기 때문에 예외 처리가 필요하다. 

 

아래 예제는 3초주기로 Bepp음을 10번 발생시킨다.

package org.example.chapter14.control;

import java.awt.*;

// 3초 주기로 10번 비프음 발생
public class SleepExample {

    public static void main(String[] args) {
        Toolkit toolkit = Toolkit.getDefaultToolkit();
        for (int i = 0; i < 10; i++) {
            toolkit.beep();
            try {
                Thread.sleep(3000); // 3초 동안 메인 스레드를 일시 정지 상태를 만듬
            } catch (InterruptedException e) {
                
            }
        }
    }
}

 

8~11라인은 메인 스레드를 3초동안 일시 정지 상태로 보내고, 3초가 지나면 다시 실행 준비 상태로 돌아오도록 했다. 만약 비프 소리가 나지 않으면, 사운드 카드가 제대로 설치되었는지, 스피커가 켜져있는지 확인해보기 바란다. 스피커가 없다면 비프소리를 들을 수 없다.

 

다른 스레드에게 실행 양보 yield()

 

스레드가 처리하는 작업은 반복적인 실행을 위해 for문이나 while문을 포함하는 경우가 많다. 가끔은 이 반복문들이 무의미한 반복을 하는 경우가 있다.

public void run() {
	while(true) {
    	if(work) {
        	System.out.println("ThreadA 작업 내용");
        }
    }
}

 

스레드가 시작 되어 run() 메소드를 실행하면 While(true){}블록을 무한 반복 실행한다. 만약 work의 값이 False라면 그릐고 work의 값이 Fasle에서 true로 변경된 시점이 불명확하다면, while문은 어떠한 실행문도 실행하지 않고 무의미한 반복을 한다. 이것보다는 다른 스레드에게 실행을 양보하고 자신은 실행 대기 상태로 가는 것이 전체 프로그램 성능에 도움이 된다. 이런 기능을 위해서 스레드는 yield()메소드를 제공하고 있다. yield()메소드를 호출한 스레드는 실행 대기 상태로 돌아가고 동일한 우선순위 또는 높은 우선순위를 갖는 다른 스레드가 실행 기회를 가질 수 있도록 해준다.

 

아래처럼 의미없는 반복을 줄이기 위해 yield()메소드를 호출해서 다른 스레드에게 실행 기회를 주도록 수정한다.

public void run() {
	while(true) {
    	if(work) {
        	System.out.println("ThreadA 작업 내용");
        } else {
        	Thread.yield();
        }
    }
}

 

 

아래 예제에서는 처음 실행 후 3초 동안은 ThreadA와 ThreadB가 번갈아가며 실행된다 3초 뒤에 메인 스레드가 ThreadA의 work필드를 false로 변경함으로써 ThreadA는 yield()메소드를 호출한다. 따라서 이후 3초 동안에는 ThreadB가 더많은 실행 기회를 얻게 된다. 메인 스레드는 3초 뒤에 다시 ThreadA의 work필드를 ture로 변경해서 ThreadA와 ThreadB가 번갈아가며 실행하도록 한다. 마지막으로 메인 스레드는 3초뒤에 ThreadA와 ThreadB의 stop필드를 true로 변경해서 두 스레드가 반복 작업을 중지하고 종료하도록 한다.