본문 바로가기
TIL/Java | Spring Boot

[Java] 스터디 7주차_쓰레드 (Thread)

by yeon_zoo 2022. 7. 2.

 

쓰레드와 관련한 내용은 지난 학기 운영체제를 수강하면서 배우게 되었는데, 중간고사 이후로 조금 까먹기도 했고 쓰레드 관련 프로그래밍은 해본 적이 없어서 이번 기회를 통해서 정리할 수 있었다. 

1. 프로세스와 쓰레드

  • 프로세스 : 실행 중인 프로그램 (프로그램 수행에 필요한 데이터나 메모리 등의 자원, 쓰레드로 구성되어 있음)
  • 쓰레드 : 프로세스의 자원을 이용하여 실제로 작업을 수행하는 단위

모든 프로세스에는 최소 하나 이상의 쓰레드가 존재하며 둘 이상의 쓰레드가 존재하는 것을 멀티 쓰레드 프로세스라고 한다. 

쓰레드가 작업을 수행하는데 개별적인 메모리 공간(호출스택)을 필요로 하기 때문에 프로세스의 메모리 한계에 따라 생성할 수 있는 쓰레드의 수가 결정된다. (실제로는 메모리의 한계가 올 정도로 많은 쓰레드를 생성할 일은 없음.)

*쓰레드 = 가벼운 프로세스, 경량 프로세스라고 부르기도 한다. 

 

[멀티태스킹과 멀티쓰레딩]

멀티태스킹 : 여러 프로세스가 동시에 실행되는 것.

멀티쓰레딩 : 하나의 프로세스 내에서 여러 쓰레드가 동시에 작업을 수행. 각 코어가 아주 짧은 시간 동안 여러 작업을 번갈아 가며 수행하여 여러 작업들이 동시에 수행되는 것처럼 보이게 한다. 따라서 하나의 쓰레드를 가진 프로세스보다 두 개의 쓰레드를 가진 프로세스 성능이 더 낮을 수 있다.

 

[멀티쓰레딩의 장단점]

장점

  • CPU의 사용률을 향상시킨다.
  • 자원을 보다 효율적으로 사용할 수 있다.
  • 사용자에 대한 응답성이 향상된다.
  • 작업이 분리되어 코드가 간결해진다.

여러 사용자에게 서비스를 해주는 서버 프로그램의 경우 멀티쓰레드로 작성하는 것이 필수적이어서 하나의 서버 프로세스가 여러 개의 쓰레드를 생성해서 쓰레드와 사용자의 요청이 1:1로 처리되도록 프로그래밍해야 한다. 

단점

  • 같은 프로세스 내의 다른 쓰레드들과 자원을 공유해야 하기 때문에 동기화, 교착상태에 대한 고민이 필요하다.

2. 쓰레드의 구현과 실행

[쓰레드를 구현하는 방법 두 가지]

  • Thread클래스 상속받기 : 다른 클래스 상속 불가 (다중 상속이 불가하기 때문에)
  • Runnable 인터페이스를 구현 : 일반적으로 사용. 재사용성이 높고 코드의 일관성을 유지할 수 있어서 객체 지향적인 방법임.

Runnable 인터페이스는 run()만 정의되어 있다. 

public class ThreadEx1 {
    public static void main(String[] args) {
        ThreadEx1_1 t1 = new ThreadEx1_1();

        Runnable r = new ThreadEx1_2(); // Runnable을 구현한 클래스의 인스턴스를 생성
        Thread t2 = new Thread(r);  // 생성자 Thread (Runnable target)

        t1.start();
        t2.start();
    }
}

class ThreadEx1_1 extends Thread {
    public void run() {
        for(int i = 0; i <5; i++){
            System.out.println(getName()); //조상인 Thread getName 호출
        }
    }
}

class ThreadEx1_2 implements Runnable {
    @Override
    public void run() {
        for(int i = 0; i <5; i++){
            // Thread.currentThread(); 현재 실행중인 Thread를 반환한다.
            System.out.println(Thread.currentThread().getName());
        }
    }
}

이 둘은 인스턴스 생성 방법이 다르다. Runnable 인터페이스 구현의 경우, Runnable 인터페이스를 구현한 클래스의 인스턴스를 생성한 다음, 이 인스턴스를 Thread클래스의 생성자의 매개변수로 제공해야 한다. 또한 Runnable을 구현할 경우 Thread 클래스의 static 메서드인 currentThread()를 호출해 쓰레드에 대한 참조를 얻어 와야만 호출이 가능하다. 

 

[쓰레드의 실행 - start()]

t1.start(); //쓰레드 t1을 실행시킨다.
t2.start(); //쓰레드 t2을 실행시킨다.

하나의 쓰레드에 대해 start()가 한 번만 호출될 수 있다. 

 

3. start()와 run() 

run()을 호출하는 것은 생성된 쓰레드를 실행시키는 것이 아니라 클래스에 선언된 메서드를 호출하는 것. start()는 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택을 생성한 다음에 run()을 호출해서 생성된 호출스택에 run()이 첫 번째로 올라가도록 한다. 

 

*호출스택 : 가장 위에 있는 메서드가 현재 실행 중인 메서드이고 나머지 메서드는 대기상태에 있음. (단, 쓰레드가 둘 이상일 때는 호출스택의 최상위에 있는 메서드일지라도 대기상태에 있을 수 있다.)

 

스케줄러는 실행 대기 중인 쓰레드들의 우선순위를 고려해 실행순서와 실행시간을 결정하고 각 쓰레드들은 작성된 스케줄에 따라 자신의 순서가 되면 지정된 시간동안 작업을 수행한다. 이 때 주어진 시간동안 작업을 마치지 못한 쓰레드는 자신의 차례가 다시 돌아올 때까지 대기상태로 있게 되며 작업을 마친 쓰레드는 호출스택이 모두 비워지면서 이 쓰레드가 사용하던 호출스택은 사라진다. 

 

main쓰레드 : main 메서드의 작업을 수행하는 쓰레드. 프로그램을 실행할 때 기본적으로 생성하는 쓰레드이다. 해당 쓰레드 내에서 main메서드를 호출해서 작업이 수행되도록 하는 것.

 

실행 중인 사용자 쓰레드가 하나도 없을 때 프로그램은 종료된다. 

 

4. 싱글쓰레드와 멀티쓰레드

싱글 코어에서 단순히 CPU만을 사용하는 계산 작업이라면 멀티 쓰레드보다 싱글쓰레드로 프로그래밍 하는 것이 더 효율적이다. 이는 멀티 쓰레딩의 경우 문맥 교환(context switching) 시간이 걸리기 때문이다. 

 

[두 개의 쓰레드를 사용함에도 시간이 더 오래 걸리는 이유]

  • 쓰레드 간의 작업 전환 시간이 소요됨 (context switching)
  • 한 쓰레드가 화면에 출력하고 있는 동안 다른 쓰레드는 출력이 끝나기를 기다려야 하는데 이 때 발생하는 대기시간

실행할 때마다 다른 결과를 얻을 수 있는데 실행중인 프로세스가 OS의 프로세스 스케줄러의 영향을 받기 떄문이다. JVM의 스케줄러에 의해서 어떤 쓰레드가 얼마동안 실행될 것인지 결정되는 것과 같이 프로세스도 프로세스 스케줄러에 의해서 실행순서와 실행시간이 결정되기 때문에 매 순간 상황에 따라 프로세스에게 할당되는 실행 시간이 일정하지 않고 쓰레드에게 할당되는 시간 역시 일정하지 않다. 자바가 OS 독립적이라고 하지만 실제로는 OS 종속적인 부분이 몇 가지 있는데 쓰레드도 그 중의 하나이다. 

 

두 쓰레드가 서로 다른 자원을 사용하는 작업의 경우 멀티쓰레드 프로세스가 더 효율적이다. (자원 공유하고 있지 않을 때)

 

5. 쓰레드의 우선순위

쓰레드는 우선순위라는 속성(멤버변수)를 가지고 있다. 이 우선순위(작업의 중요도)에 따라 쓰레드가 얻는 실행시간이 달라진다. 시각적인 부분이나 사용자에게 빠르게 반응해야 하는 작업을 하는 쓰레드의 우선순위는 다른 작업을 수행하는 쓰레드에 비해 높아야 한다. 

 

[쓰레드의 우선순위 지정하기]

void setPrirority(int newPriority); //쓰레드의 우선순위를 지정한 값으로 변경한다
int getPriority();	//쓰레드의 우선순위를 반환

public static final int MAX_PRIORITY = 10; //최대 우선순위
public static final int MIN_PRIORITY = 10; //최소 우선순위
public static final int NORM_PRIORITY = 10; //보통 우선순위

싱글코어에서는 우선순위가 높은 것이 더 오랜 시간 수행된다는 확실한 결과가 있지만, 멀티코어에서는 쓰레드의 우선순위에 따른 차이가 거의 없을 수 있다. 멀티 코어라도 OS마다 다른 방식으로 스케줄링 하기 때문에 어떤 OS에서 실행하느냐에 따라 다른 결과를 얻을 수 있다. 자바는 쓰레드가 우선순위에 따라 어떻게 다르게 처리되어야 하는지에 대해 강제하지 않으므로 쓰레드 우선순위와 관련된 구현이 JVM마다 다를 수 있다. 쓰레드에 우선순위를 부여하는 대신 작업에 우선순위를 두어 PriorityQueue에 저장해 놓고 우선순위가 높은 작업이 먼저 처리되도록 하는 것이 나을 수 있다. 

 

6. 쓰레드 그룹

쓰레드 그룹 : 서로 관련된 쓰레드를 그룹으로 다루기 위한 것. 쓰레드 그룹 안에 쓰레드 그룹을 포함시킬 수도 있다. 보안 상의 이유로 도입된 개념이며, 자신이 속한 쓰레드 그룹이나 하위 쓰레드 그룹은 변경할 수 있지만 다른 쓰레드 그룹의 쓰레드를 변경할 수는 없다. 

 

[쓰레드 그룹의 특징]

  • 모든 쓰레드는 반드시 쓰레드 그룹에 포함되어 있어야 하기 때문에 쓰레드 그룹을 지정하는 생성자를 사용하지 않은 쓰레드는 기본적으로 자신을 생성한 쓰레드와 같은 쓰레드 그룹에 속함. 
  • 자바 어플리케이션이 실행되면 JVM은 main과 system이라는 쓰레드 그룹을 만들고 JVM 운영에 필요한 쓰레드들을 생성해서 이 쓰레드 그룹에 포함시킴 (main 메서드 실행하는 main 쓰레드는 main 쓰레드 그룹에, 가비지컬렉션을 수행하는 Finalizer쓰레드는 system쓰레드 그룹에 속함) 
  • 우리가 생성하는 모든 쓰레드 그룹은 main쓰레드 그룹의 하위로, 지정하지 않는 경우는 main쓰레드 그룹에 속하게 됨.

 

7. 데몬 쓰레드 (Daemon thread)

  • 데몬 쓰레드 : 일반 쓰레드의 작업을 돕는 보조적인 역할으 수행하는 쓰레드. 일반 쓰레드가 모두 종료되면 데몬 쓰레드는 강제적으로 자동 종료된다. 
  • 예 : 가비지 컬렉터, 워드프로세서의 자동 저장, 화면 자동 갱신 등
  • 무한루프와 조건문을 이용해서 실행 후 대기하고 있다가 특정 조건이 만족되면 작업을 수행하고 다시 대기하도록 작성한다. 
  • 일반 쓰레드의 작성 방법과 실행 방법이 같으며 쓰레드를 생성한 다음 실행하기 전에 setDaemon(true)를 호출하기만 하면 된다.
  • 데몬 쓰레드가 생성한 쓰레드는 자동적으로 데몬 쓰레드가 된다. 
  • setDaemon(true) 메서드는 반드시 start()를 호출하기 전에 실행되어야 한다. (IllegalThreadStateException이 발생 가능)
public class ThreadEx02 implements Runnable{
    static boolean autoSave = false;

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(new ThreadEx02());
        t.setDaemon(true);
        t.start();

        for (int i=1; i<= 10; i++){
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {}
            System.out.println(i);

            if (i == 5)
                autoSave = true;
        }
    }

    public void run(){
        while(true) {
            try {
                Thread.sleep(3 * 1000);
            } catch (InterruptedException e) {}
            if (autoSave){
                autoSave();
            }
        }
    }
    public void autoSave(){
        System.out.println("작업파일이 자동저장되었습니다.");
    }
}

실행 결과

만일 이 쓰레드를 데몬 쓰레드로 설정하지 않았다면 이 프로그램은 강제종료하지 않는 한 영원히 종료되지 않을 것이다. 

 

 

8. 쓰레드의 실행제어

효율적인 멀티쓰레드 프로그램을 만들기 위해서는 보다 정교한 스케줄링을 통해 프로세스에게 주어진 자원과 시간을 여러 쓰레드가 낭비없이 잘 사용하도록 프로그래밍 해야 한다. 

  • sleep(long millis) : 일정시간동안 쓰레드를 멈추게 한다.
  • interrupt()와 interrupted() : 쓰레드의 작업을 취소한다. 멈추라고 요청하지만 강제할 수는 없다. 일시정지 상태(WAITING)에 있을 때 해당 쓰레드에 대해 interrupt()를 호출하면 실행대기 상태(RUNNABLE)로 바뀐다. 
  • suspend() : sleep()처럼 쓰레드를 멈추게 한다. 정지된 쓰레드는 resume()를 호출해야 다시 실행대기 상태가 된다. 
  • stop() : 호출 즉시 쓰레드가 종료된다.  suspend()와 stop()은 교착상태를 일으키기 쉽게 작성되어 있으므로 사용을 권장하지 않는다.
  • yield() : 자신에게 주어진 실행시간을 다음 차례의 쓰레드에게 양보한다. 
  • join() : 자신이 하던 작업을 잠시 멈추고 다른 쓰레드가 지정된 시간동안 작업을 수행하도록 기다린다.

 

9. 쓰레드의 동기화

동기화 : 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것

 

9.1 synchronized를 이용한 동기화

synchronized 키워드는 임계 영역을 설정하는 데 사용된다 

 1. 메서드 전체를 임계 영역으로 지정 
public synchronized void calcSum() {
	...
}​

2. 특정한 영역을 임계 영역으로 지정
synchronized(객체의 참조변수) {
	...
}​

[메서드 전체를 임계 영역으로 지정하는 방법]

  • 메서드 앞에 synchronized를 붙인다
  • synchronized 메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 lock을 얻어 작업을 수행하다가 메서드가 종료되면 lock을 반환한다. 

 

[특정 영역을 임계 영역으로 지정하는 방법]

  • 메서드 내의 코드 일부를 블럭으로 감싸고 블럭 앞에 'synchronized(참조변수)'를 붙인다.
  • 이 블럭을 synchronized블럭이라고 부른다. 이 블럭의 영역 안으로 들어가면서부터 쓰레드는 지정된 객체의 lock을 얻게 되고, 블럭을 벗어나면 lock을 반납한다.
  • 참조변수는 락을 걸고자하는 객체를 참조하는 것이어야 한다. 

 

우리가 해야 할 일은 임계 영역을 설정해주는 것 뿐이다. (lock 획득과 반납은 모두 자동적으로 이루어짐. ) 임계 영역은 멀티 쓰레드 프로그램의 성능을 좌우하기 때문에 가능하면 메서드 전체에 lock을 거는 것보다 synchronized블럭으로 임계 영역을 최소화해서 보다 효율적인 프로그램이 되도록 노력해야 한다. 

 

9.2 wait()과 notify()

synchronized로 공유 데이터를 보호하는 것도 중요하지만, 특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않도록 하는 것도 중요하다. 이를 위해 고안된 것이 바로 wait()와 notify() 이다. 

  • wait() : 동기화된 임계영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면 일단 wait()을 호출하여 쓰레드가 락을 반납하고 기다리게 한다. 
  • notify() : wait()을 호출하여 반납된 락을 다른 쓰레드가 얻어서 사용하다가 나중에 기존 쓰레드가 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서 작업을 중단했던 쓰레드가 다시 락을 얻어 작업 진행할 수 있도록 한다. 

기아현상과 경쟁 상태

 

댓글