JAVA - 10 : 멀티쓰레드 프로그래밍

Seok-Hyun Lee·2021년 7월 16일
1

JAVA

목록 보기
10/21
post-thumbnail

10 멀티쓰레드 프로그래밍

10.1 Thread 클래스와 Runnable 인터페이스

10.1.1 프로세스와 쓰레드

프로세스는 윈도우에서 작업관리자를 사용하면서 흔히 봤을 용어인데 쓰레드는 낯선 사람들이 많을 것이다. 하지만 쓰레드에 대한 개념을 얘기하려면 프로세스를 먼저 얘기하지 않을 수 없다. 아래는 프로세스에 대한 간단한 정리이다.

  • 실행 중인 프로그램은 하나 이상의 프로세스로 구성된다.
  • 프로세스는 시스템으로부터 자원을 할당받은 컴퓨터 프로그램의 단위(개체)이다.
  • Code, Data, Stack, Heap 의 메모리 영역을 가지고 있다.
  • 프로세스의 메모리는 서로 독립적이다.

말이 어려워보이지만 요약하자면 우리가 흔히 작업 관리자에서 보는 "실행 중 프로그램"은 해당 프로그램을 위한 작업을 수행중인 프로세스들의 집합이라 생각하면 된다.

그리고 쓰레드는 "경량 프로세스"라 불리며 아래와 같은 특성을 가지고 있다.

  • 프로세스 내 작업 단위이자 가장 작은 실행 단위
  • 프로세스는 1 개 이상의 쓰레드로 구성될 수 있다.
  • 프로세스의 Code, Data, Heap을 공유, Stack만 따로 할당
  • 같은 프로세스의 쓰레드끼리는 Heap 영역을 공유

10.1.2 JVM 의 Runtime Data Area 와 쓰레드

위의 그림은 JVM의 Runtime Data Area을 나타낸 것이다. 그리고 아래는 일반적인 프로세스와 쓰레드의 모습을 도식화한 것이다.

위의 그림을 놓고 비교하면 매우 비슷하다는 것을 알 수 있다. 여기서 중요한 점은 자바에서는 JVM이 OS의 역할을 대신한다. 그래서 JVM에서는 각각의 자바 프로그램을 개별 프로세스로 관리하기 때문에 우리가 작성하는 코드는 모두 쓰레드 단위로 작업을 수행한다.

즉, 자바 어플리케이션에서는 기본적으로 쓰레드를 활용한다. 그리고 동시성을 강화하기 위해 자바는 쓰레드를 추가적으로 만들 수 있게 하며, 여기에는 Thread 클래스와 Runnable 인터페이스가 존재한다.

동시성이란, 여러 프로그램이 CPU를 효율적으로 공유하여 응답시간을 낮춤으로써 사용자가 동시에 사용하도록 만드는 것

10.1.2 Thread 클래스 & Runnable 인터페이스

기본적으로 자바의 쓰레드를 만드는 방법은 Thread 클래스 또는 Runnable 인터페이스를 사용하여 만들 수 있고 각각의 예시는 아래와 같다.

class PrimeThread extends Thread {
    long minPrime;
    PrimeThread(long minPrime) {
        this.minPrime = minPrime;
    }

    public void run() {
        // compute primes larger than minPrime
         . . .
    }
}

Thread 클래스는 여러 메서드를 가지고 있지만 run() 메소드만 재정의해도 아래와 같이 쓰레드를 만들어서 사용할 수 있다.

PrimeRun p = new PrimeRun(143);
p.start();

하지만 Runnable 인터페이스는 Thread 클래스와 사용 방법이 약간 다르다.

class PrimeRun implements Runnable {
    long minPrime;
    PrimeRun(long minPrime) {
         this.minPrime = minPrime;
    }

    public void run() {
         // compute primes larger than minPrime
          . . .
    }
}

Runnable 인터페이스는 추상 메서드를 run() 만 가지고 있어서 이를 용도에 맞게 구현해주기만 하면 사용할 수 있다. 하지만 쓰레드를 만드는 방법은 아래와 같다.

PrimeRun p = new PrimeRun(143);
Thread td = new Thread(p);
td.start();

이처럼 쓰레드의 용도를 정의하는 방법은 같지만 다른 이유가 있다. 왜냐하면 Thread 클래스는 Runnable 인터페이스의 구현 클래스이기 때문이다. 여기서 나오는 차이는 아래와 같다.

요약하자면 Runnable 인터페이스는 다형성과 메모리를 아낄 수 있는 점에서 장점을 가지고 있고 Thread 클래스는 다양한 메서드를 재정의 할 수 있는 이점을 가지고 있다. 결론적으로, Thread 클래스와 Runnable 인터페이스는 새로이 만드려는 쓰레드의 용도에 맞게 활용하면 된다.

10.2 쓰레드의 상태

쓰레드가 자바에서 기본적인 실행 단위이고 쓰레드를 생성하는 방법도 알아봤으니 이제는 쓰레드가 어떻게 실행되는지를 알아볼 차례이다. 기본적으로 쓰레드에는 위의 그림과 같은 흐름을 가지고있다. 그리고 run() 메서드가 호출되어 쓰레드가 실행중인 상태를 제외하면 아래와 같은 상태로 나뉜다.

  • NEW : 새로운 쓰레드 생성, start() 호출 이전
  • RUNNABLE : CPU 자원 할당받은 채로 작업 실행 대기 상태
  • WAITING : 다른 쓰레드의 호출을 기다리는 일시 정지 상태
  • TIMED_WAITING : 주어진 시간 동안 일시 정지 상태
  • BLOCKED : 모니터락을 얻기 위해 다른 쓰레드들의 Lock 해지를 기다리는 일지 정지 상태
  • TERMINATED : 실행을 마친 완료 상태

10.2.1 모니터


자바에서는 위의 그림과 같이 쓰레드간 자원 공유에 의한 문제를 방지하기 위해 '모니터'란 개념을 활용해 쓰레드의 작업을 제어한다. 아래는 모니터에 대한 개념 정리이다.

  • 자바의 모든 객체는 각자의 모니터를 가지고 있다.
  • 특정 객체의 모니터에는 동시에 하나의 쓰레드만 접근 가능
  • 모니터에 Lock이 걸려 있으면 다른 쓰레드들은 접근 불가.
  • 다른 쓰레드에 의해 점유된 모니터에 쓰레드가 접근하면 Wait Set에서 대기한다.

이처럼 모니터는 쓰레드들의 생애주기에서 중요한 역할을 한다. 그리고 자바에서 쓰레드가 어떤 객체의 모니터를 점유하는 유일한 방법은 synchronized 키워드를 사용하는 것이다.

class PrimeRun implements Runnable {
    long minPrime;
    PrimeRun(long minPrime) {
         this.minPrime = minPrime;
    }

    public void run() {
        // 이 객체의 모니터를 점유
	synchronized (this) {
            ...
            ...
            notify(this); // 모니터 점유 해지를 통지, 다른 쓰레드들 실행 대기 상태 진입
        } 
    }
}

10.2.2 쓰레드 상태 활용 메서드

  • getState() : 현재 쓰레드 상태 확인하기
  • sleep(long time) : time 밀리세컨드만큼 TIME_WAITING 상태 유지
  • interrupt() : 쓰레드가 일시정지되면 예외 발생시켜 catch 문으로 이동
  • yield() : 실행 중이던 쓰레드가 실행 대기 상태의 쓰레드들 중 우선순위가 같거나 높은 다른 쓰레드와 상태 교체
  • join() : 다른 쓰레드들이 종료되면 실행(계산 결과 활용에 주로 사용)
  • wait(), notify(), notifyAll() : 공유 자원 관련 동기화에 활용
class SyncExmp {
    // 메서드 A, 모니터 점유
    public synchronized void methodA() {
        System.out.println("쓰레드 A 실행");
        notify(); // 쓰레드 B, 일시정지->실행대기
        try {
            wait();//쓰레드 A, 실행->일시정지
        } catch (Exception e) {
        }
    }

    // 메서드 B, 모니터 점유
    public synchronized void methodB() {
        System.out.println("쓰레드 B 실행");
        notify(); //쓰레드 A, 일시정지->실행대기
        try {
            wait();//쓰레드 B, 실행->일시정지
        } catch (Exception e) {
        }
    }
}

//쓰레드 A, 메서드 A 만 실행
class TdA extends Thread{
    private SyncExmp sync;

    public ThreadA(SyncExmp sync) {
        this.sync = sync;
    }

    @Override
    public void run() {
        for(int i =0; i<3; i++) {
            sync.methodA();
        }
    }
}

//쓰레드 B, 메서드 B 만 실행
class TdB extends Thread{
    private SyncExmp sync;

    public ThreadB(SyncExmp sync) {
        this.sync = sync;
    }

    @Override
    public void run() {
        for(int i =0; i<3; i++) {
            sync.methodB();
        }
    }
}

//main 스레드
public class Main {
    public static void main(String[] args) {
        SyncExmp sync = new SyncExmp(); //공유되는 자원(객체) 생성
        
        //TdA와 TdB 생성
        TdA A = new TdA(sync);
        TB B = new TdB(sync);

        // 쓰레드간 공유 자원 활용
        A.start();
        B.start();

    }
}

위의 결과는 아래와 같이 공유되는 자원을 기준으로 아래와 같이 한번씩 번갈아가면서 순차적으로 나온다.

쓰레드 A 실행
쓰레드 B 실행
쓰레드 A 실행
쓰레드 B 실행
쓰레드 A 실행
쓰레드 B 실행

지금까지 모니터와 쓰레드 상태에 관한 간단한 소개와 예제 정도의 글만을 작성하였다. 모니터와 쓰레드 상테애 관한 글은 깊게 들어가면 내용이 방대해지기 때문에 현재 포스트에서는 여기까지만 다루고 다른 글에서 이어나가도록 하겠다.

자바 동시/병렬 프로그래밍 글 보러가기

10.3 쓰레드의 우선순위

이제 자바의 쓰레드의 생애 주기 상에서 다양한 상태를 가지고 있다는 것을 알게 되었다. 그러면 실제로 쓰레드 실행을 위한 우선 순위는 어떻게 정해지는지를 확인해보자

  • void setPriority(int newPriority): 쓰레드의 우선순위를 지정한 값으로 변경
  • int getPriority(): 쓰레드의 우선순위를 반환한다.
  • public static final int MAX_PRIORITY = 10 , 최대 우선 순위
  • public static final int MIN_PRIORITY = 1 ,최소 우선 순위
  • public static final int NORM_PRIORITY = 5 , 보통 우선 순위

쓰레드 우선순위는 1 ~ 10 범위 내의 숫자를 상대적으로 비교하여 높은 숫자부터 쓰레드 작업이 우선시된다. 주의할 점은 해당 숫자는 오로지 실행 순서에만 활용되고 숫자가 크다고 해서 자원을 많이 받는것이 아니라 자원에 대한 사용 빈도가 높아진다(독점X). 그리고 main() 쓰레드는 기본적으로 5의 우선순위를 가지고 있다. 아래는 우선 순위를 설정하는 예시이다.

class ThreadA extends Thread{...}
class ThreadB extends Thread{...}

class ThreadPriority {
    public static void main(String args[]) {
        ThreadA td1 = new ThreadA();
        ThreadB td2 = new ThreadB();
        
        td1.setPriority(2); // defalut 우선순위 5
        td2.setPriority(7);
        
        // main이 실행된 후 td2가 td1보다 자주 실행
        td1.start();
        td2.start();
    }
}

10.4 Main 쓰레드

10.5 동기화

공유되는 자원에 여러 쓰레드가 접근하여 발생할 수 있는 문제를 방지하고자하는 동기화의 목적으로 모니터와 Lock, Synchronized에 대한 얘기를 앞에서 다루긴 했지만 동기화는 작업의 절차가 중요한 곳에서도 중요한 개념이다. 이에 대해서는 자바 동시/병렬 프로그래밍만을 다룬 포스트에서 깊게 다루도록 하겠다.

10.6 데드락(Deadlock)


데드락은 우리나라말로 "교착상태"를 의미한다. 이는 위의 그림을 통해 얘기하자면 "서로 오도 가도 못하는 상황"을 의미한다. 쓰레드를 활용할 때는 이렇게 둘 이상의 쓰레드가 서로 교착되어 아무런 작업이 실행되지 못하는 상태인 데드락을 주의해야 한다. 그럼 쓰레드는 어떤 경우에 교착 상태에 빠지는지 알아보자.

class SyncExmp {
    // 메서드 A, 모니터 점유
    public synchronized void methodA() {
        System.out.println("쓰레드 A 실행");
        notify(); // 쓰레드 B, 일시정지->실행대기
        try {
            System.out.println("쓰레드 A 일시 정지");
            wait();//쓰레드 A, 실행->일시정지
        } catch (Exception e) {
        }
    }

    // 메서드 B, 모니터 점유
    public synchronized void methodB() {
        System.out.println("쓰레드 B 실행");
        notify(); //쓰레드 A, 일시정지->실행대기
        try {
            System.out.println("쓰레드 B 일시 정지");
            wait();//쓰레드 B, 실행->일시정지
        } catch (Exception e) {
        }
    }
}

//쓰레드 A, 메서드 A 만 실행
class TdA extends Thread{
    private SyncExmp sync;

    public ThreadA(SyncExmp sync) {
        this.sync = sync;
    }

    @Override
    public void run() {
        for(int i =0; i<2; i++) {
            sync.methodA();
        }
    }
}

//쓰레드 B, 메서드 B 만 실행
class TdB extends Thread{
    private SyncExmp sync;

    public ThreadB(SyncExmp sync) {
        this.sync = sync;
    }

    @Override
    public void run() {
        for(int i =0; i<2; i++) {
            sync.methodB();
        }
    }
}

//main 스레드
public class Main {
    public static void main(String[] args) {
        SyncExmp sync = new SyncExmp(); //공유되는 자원(객체) 생성
        
        //TdA와 TdB 생성
        TdA A = new TdA(sync);
        TB B = new TdB(sync);

        // 쓰레드간 공유 자원 활용
        A.start();
        B.start();

    }
}

위의 코드는 앞에서 wait(),notify()에 대해 설명할 때 활용했던 코드에서 wait()을 실행하기 전에 각 쓰레드가 어떤 상태로 변경되는지를 확인하기 위해 상태를 출력해주는 코드만 추가한 소스 코드이다. 이를 실행하면 아래와 같은 결과가 나온다.

쓰레드 A 실행
쓰레드 A 일시 정지
쓰레드 B 실행
쓰레드 B 일시 정지
쓰레드 A 실행
쓰레드 A 일시 정지
쓰레드 B 실행
쓰레드 B 일시 정지

하지만 원래대로라면 어떤 쓰레드가 wait()이 되어 일시 정지가 된 상태에서 다른 쓰레드의 notify()에 의해 다시 실행대기 상태로 진입하지 못하게 되면 두 쓰레드는 서로가 일시정지에 걸리게 되고 두 쓰레드는 무한 대기 상태에 빠지게 된다. 아래의 예시는 각 메서드에서 notify()를 주석처리한 후의 결과이다.

쓰레드 A 실행
쓰레드 A 일시 정지
쓰레드 B 실행
쓰레드 B 일시 정지

각각 한번 씩 실행한 후에 출력은 더 이상 일어나지 않고 있지만 프로그램은 계속 돌고 있다.

profile
Arch-ITech

0개의 댓글