[whiteship] 10주차 - 멀티쓰레드 프로그래밍

노력을 즐겼던 사람·2021년 1월 24일
0
post-thumbnail

학습할 것 (필수)

  • Thread 클래스와 Runnable 인터페이스
  • 쓰레드의 상태
  • 쓰레드의 우선순위
  • Main 쓰레드
  • 동기화
  • 데드락

사전지식

프로세스

우리가 작성한 자바 프로그램이 실행이 되어서 RAM에 올라가면 프로세스가 된다.
즉, 우리가 실행하고 있는 크롬 브라우저, 디스코드, 인텔리제이 등이 하나의 프로세스가 된다.

쓰레드

프로세스를 구성하는 요소 중 하나이다. 쓰레드는 프로세스를 구성하는 또 다른 요소인 데이터, 메모리 등의 자원을 활영해서 우리가 명령하는 작업들을 수행해준다.

디스코드의 음성 대화 등등의 동작들을 쓰레드가 수행해준다고 생각하면 된다.

모든 프로세스는 최소한 하나 이상의 프로세스로 이루어져 있으며 여러개의 쓰레드를 활용하는 프로세스를 멀티 쓰레드 프로세스라고 부른다.

Thread 구현하기

두 가지 방법으로 쓰레드를 구현할 수 있다.

  • Thread class 상속받기
  • Runnalbe interface 구현하기

상속을 통해서 구현하면 다른 클래스를 상속받지 못하기 때문에 Runnalbe을 구현하는 방식을 많이 사용한다.

반면, Runnablerun() 메소드만 구현할 수 있기 때문에 더 많은 메소드를 오버라이딩하기 위해서는 Thread를 상속받아야 한다.

Thread class 상속받기

쓰레드를 구현하기 위해서 Thread를 상속받아야 한다.

class MyExtendThread extends Thread {
    @Override
    public void run() {
      for (int i=0; i<5; i++)
        System.out.prinlnt(getName());
    }
}

Threadrun 메소드를 오버라이딩 해야한다.

Runnable interface 구현하기

Runnable 을 구현하여 Thread를 사용하자

class MyRunnalbeThread implements Runnalbe {
  @Override
  public void run() {
    for (int i=0; i<5; i++) 
      System.out.println(Thread.currentThread().getName());
  }
}

마찬가지로 run()을 오버라이딩하는데 Thread의 정보를 알 수 없으니 static methodThread.currentThread()를 호출해서 실행중인 스레드를 리턴받을 수 있다.

쓰레드 생성하기

public static void main(String args[]) {
  MyExtendThread t1 = new MyExtendThread();
  
  Runnable r = new MyRunnalbeThread();
  Thread t2 = new Thread(r);
  
  // start() 메소드는 run() 메소드를 호출해준다.
  t1.start();
  t2.start();
}

Runnable을 구현한 경우에는 Runnable 타입의 MyRunnableThread 인스턴스를 생성한 후 Thread의 생성자에 매개변수로 넘겨준다.

start() 메소드는 한번만 호출될 수 있다.
다시 실행하고 싶다면 새로운 인스턴스를 생성하여 start()를 호출해야 한다.

start() / run()

간단하게 이야기하면 두 메소드의 차이점은 다음과 같다.

  • start(): 쓰레드를 실행
  • run(): 클래스의 멤버 메소드를 호출

조금 더 자세히 이야기 하자면 JVM의 Call Stack에서 차이가 난다.

Call Stack이 여러개가 되면 스케쥴러의 알고리즘에 따라서 번갈아가며 작업이 수행된다.
Call Stack에 쌓인 모든 작업을 마무리하면 Call Stack은 사라진다.

그림에서 알 수 있지만 새로운 Call Stack에는 run() 메소드만 존재한다.
따라서 위에서 작성한 MyExtendThread의 코드를 다음과 같이 수정해서 Call Stack에 run()만 존재하는지 직접 확인해보자

class MyExtendThread extends Thread {
	@Override
    public void run() {
      try {
        throw new Exception();
      } catch (Exception e) {
        e.printStackTrace();
      }
    }
}

그러면 아래와 같이 run() 메소드만 존재한다는 것을 확인 할 수 있다.

쓰레드의 상태

일단 표를 참고하자

STATEDescription
NEW쓰레드가 생성은 되었지만 start()를 호출하지 않은 상태
RUNNALBE실행 중 혹은 실행 가능
BLOCKED동기화 블록에 의해서 일시정지된 상태 (lock이 풀릴 때 까지 기다리는 상태)
WAITING쓰레드의 작업이 종료되지는 않았지만 쓰레드가 실행 가능하지 않은 일시정지 상태
TIMED_WAITINGWAITING 상태 + 일시정지시간이 지정된 상태
TERMINATED쓰레드의 작업이 종료된 상태

쓰레드의 상태는 Thread의 여러가지 메소드와 스케쥴러에 의해서 변경될 수 있다. 그림으로 표현하면 이해가 쉬울 것 같아서 그림을 첨부한다.

쓰레드의 우선순위

Threadpriority라는 멤버변수를 가지고 있다. (private int priority)
이 우선순위의 값에 따라 쓰레드가 스케쥴러에게 할당받는 실행시간이 달라진다. 우선순위를 조작하여 중요한 쓰레드에게 더 많은 실행시간을 할당할 수 있다.

쓰레드가 가질 수 있는 우선순위 값의 범위는 1~10이며 숫자가 높을수록 우선순위가 높다.
main() 메소드의 우선순위는 5로 설정된다.
우선순위는 getPriority()를 호출해서 확인할 수 있고 setPriority()를 호출해서 설정할 수 있다.

Main 쓰레드

psvm을 실행시켜주는 쓰레드이다.
우선순위는 5로 설정된다.

동기화

쓰레드들은 소속된 프로세스의 자원들을 공유하기 때문에 동기화가 반드시 필요하다.

동기화가 없다면 쓰레드의 작업 순서를 예측할 수 없기 때문에 변수가 원치 않은 값으로 변경될 수 있다.

synchronized 를 이용한 동기화

synchronized 영역은 임계구역(critical section) 이라고도 하며 짧을 수록 좋다.

문법은 아래와 같다.

synchronized (expression) {
  statements
}

expression에는 배열 혹은 객체를 넣어야한다.
statements에는 손상이 우려되는 코드를 넣어야한다.

statement block을 실행하기 전에 자바 인터프리터는 expression에 작성한 객체나 배열에 lock을 건다.
이 lock은 statement block이 끝날 때 까지 지속된다. 물론 끝나면 다시 해제한다.(release)
expression에 lock이 걸려있을 때는 다른 스레드에서 lock을 걸 수 없다.
먼저 lock을 걸고 작업 중인 쓰레드가 있다면 작업이 모두 끝날 때 까지 다른 쓰레드에서 데이터를 변경할 수 없게 만든다 대부분 뒤 늦게 접근한 쓰레드는 일시정지 상태에 빠진다.

syncronized는 메소드에도 선언할 수 있다. 메소드에 선언되면 당연히 메소드의 모든 동작이 syncronized에 의해 다뤄진다는 것을 나타낸다.

synchronized instance method를 실행할 때 자바는 class instance에 lock을 건다. 이는 아래와 같다고 볼 수 있다

public synchronized void sync() {
  // synchronized가 선언된 메소드의 모든 동작
}

synchronized method를 이미 다른 쓰레드가 호출해서 작업중이라면 다른 쓰레드는 사용할 수 없다.

동기화 실패 예시

Runnalbe 구현 Thread 예시

public class MyRunnableThread implements Runnalbe {
	int instance = 0; // Thread 간 공유됨
    
    @Override
    public void run() {
    	int local = 0; // Thread 간 공유되지 않음
        String name = Thread.currentThread().getName();
        
        while (i < 3) {
          System.out.println(name + " Local i:" + ++local);
          System.out.println(name + " Instance i:" + ++instance);
          System.out.println();
        }
    }
}

위 코드의 결과는 아래와 같다

공유되는 instance는 총 6번 실행되어 6의 값을 가진다.

Thread 상속 Thread 예시

class MyExtend Thread extends Thread {
  Data d; // 공유됨
  
  public MyExtendThread(Data d) {
    this.d = d;
  }
  
  @Override
  public void run() {
    int local = 0; // 공유되지 않음
    
    while (local < 3) {
      System.out.println(getName() + " Local i: " + ++local);
      System.out.println(getName() + " Instance i: " + ++d.instance);
      System.out.println();
    }
  }
}

...
...

public static void main(String[] args) {
  Data d = new Data();
  MyExtendThread t1 = new MyExtendTread(d);
  MyExtendThread t2 = new MyExtendTread(d);
  t1.start();
  t2.start();
} 

마찬가지로 결과는 아래와 같다.

반면에 Thread를 상속 받는 경우에 아래와 같은 코드는 인스턴스 변수더라도 쓰레드 간 공유를 하지 않는다.

class MyExtend Thread extends Thread {
  int instance = 0; // 공유되지 않음
  
  @Override
  public void run() {
    int local = 0; // 공유되지 않음
    
    while (local < 3) {
      System.out.println(getName() + " Local i: " + ++local);
      System.out.println(getName() + " Instance i: " + ++d.instance);
      System.out.println();
    }
  }
}

...
...

public static void main(String[] args) {
  MyExtendThread t1 = new MyExtendTread();
  MyExtendThread t2 = new MyExtendTread();
  t1.start();
  t2.start();
} 

지역 변수는 각 쓰레드의 스택 내에 생성되므로 쓰레드간 공유가 되지 않는다!
모든 인스턴스 메소드에는 참조변수 thissuper가 숨겨져 있기 때문에 인스턴스 변수에 접근할 수 있다!

동기화는 위에서 언급한 것 처럼 synchronized를 활용해서 쉽게 구현할 수 있다.

데드락

만약 다른 쓰레드가 이미 작업중인 synchronized 영역에 접근을 시도한다면 대기 상태에 빠지게 되고 우리가 작성한 코드에 논리적인 오류가 있다면 모든 쓰레드가 대기 상태에 빠지는 데드락 상태에 빠질 수 있다.

stop(), suspend(), resume() 메소드는 데드락을 일으킬 가능성이 높기 때문에 deprecated 되었다. 없다고 생각하자

데드락에 빠지지 않도록 wait(), notify(),제어문, 변수를 활용해서 쓰레드를 제어하자

wait(), notify(), notifuAll() 특징

  • Object 클래스에 정의가 되어 있다.
  • synchronized 블록안에서만 사용이 가능하다

wait()

wait(), wait(long), wait(long, int) 의 형태로 오버로딩이 되어 있다.
또한 InterruptedExceptionthrows하기 때문에 예외처리가 반드시 필요하다.

wait() 메소드가 호출되면 해당 스레드는 waiting pool에서 자신이 걸어놓은 lock을 모두 풀고 대기한다.

waiting pool은 객체마다 공유되지 않는다.

매개변수가 있는 wait() 메소드를 호출하면 notify 메소드로 깨우지 않아도 매개변수에 맞는 시간이 흐른 후 자동으로 쓰레드가 실행된다.

notify(), notifyAll()

wait() 중인 쓰레드를 다른 쓰레드에서 notify()를 통해 깨워줘야 한다.
또, notifyAll()을 사용하면 waiting pool에 있는 모든 쓰레드가 깨어나는데 동기화에 의해서 하나의 쓰레드를 제외한 나머지 쓰레드는 다시 대기 상태에 빠지게 된다.
그러므로 notifyAll()을 사용해서 우선순위 등을 고려하는 JVM의 스케쥴러에게 쓰레드 할당을 위임하자

profile
노력하는 자는 즐기는 자를 이길 수 없다 를 알면서도 게으름에 지는 중

0개의 댓글