10회차. 멀티쓰레드 프로그래밍

KIMA·2023년 2월 5일
0
post-thumbnail

목표

자바의 멀티쓰레드 프로그래밍에 대해 학습하기

학습할 것

프로세스와 쓰레드

프로세스

: 실행 중인 프로그램

  • 프로그램(크롬)을 여러개 실행하면 각각 프로세스가 된다.
    프로세스

  • 구성

    • 프로그램을 수행하는 데 필요한 데이터와 메모리 등의 자원
    • 하나 이상의 쓰레드
  • 대부분의 OS(윈도우, 유닉스 등)는 멀티태스킹을 지원하기 때문에 여러 개의 프로세스가 동시에 실행될 수 있다.

쓰레드

: 프로세스라는 작업 공간에서 작업을 처리하는 일꾼

  • 한 프로세스에는 하나 이상의 쓰레드가 존재하고, 동시에 작업을 수행한다.
    • CPU의 코어가 한 번에 단 하나의 작업만 수행할 수 있으므로, 동시에 처리되는 작업의 개수는 코어의 개수와 일치한다.
      • 따라서 쓰레드 수가 코어의 개수보다 많다고 하면, 각 코어가 아주 짧은 시간 동안 여러 작업을 번갈아가면서 수행함으로써 여러 작업들이 모두 동시에 수행되는 것처럼 보이게 한다.
      • 따라서 프로세스의 성능이 단순히 쓰레드의 개수에 비례하는 것은 아니며, 하나의 쓰레드를 가진 프로세스 보다 두 개의 쓰레드를 가진 프로세스가 오히려 더 낮은 성능을 보일 수도 있다.
  • 각각의 쓰레드는 작업을 수행할 때, 프로세스의 자원을 공유한다.
    • 단, 쓰레드간의 독립적인 작업을 위해 쓰레드를 생성하고 실행할 때마다 호출 스택(call stack)을 새로 생성한다.
  • 둘 이상의 쓰레드를 가진 프로세스를 '멀티쓰레드 프로세스'라고 한다.

멀티쓰레드 프로그램

멀티 쓰레딩의 핵심은 동시에 작업을 수행하는 것이다.
따라서 멀티쓰레드 프로그램은 메신저로 채팅하면서 파일을 다운로드 받거나 음성대화를 나눌 수 있다.

장점

  • 첫째, 자원을 효율적으로 사용할 수 있다.
    • 만약 싱글쓰레드로 서버 프로그램을 작성한다면, 사용자의 요청마다 새로운 프로세스를 생성해야한다.
      • 프로세스를 생성하는 것은 쓰레드를 생성하는 것에 비해 더 많은 시간과 메모리 공간이 필요하다.
        • 프로세스는 쓰레드와 달리 자원을 공유하지 않기 때문이다.
  • 둘째, 사용자에 대한 응답성이 향상된다.
    • 작업을 동시에 처리하므로 응답 속도가 빠르다.
  • 셋째, 작업이 분리되어 코드가 간결해진다.

단점

  • 여러 쓰레드가 같은 프로세스 내에서 자원을 공유하면서 작업을 하기 때문에 동기화(synchronization), 교착상태(deadlock)와 같은 문제들이 발생할 수 있다.
    • 동기화와 교착상태 문제는 뒤에서 살펴보도록 하자.

쓰레드 구현

쓰레드를 구현하는 방법은 두가지가 존재한다.

첫째, Thread 클래스 상속

Thread 클래스는 Runnable 인터페이스를 구현하였다.
Thread 클래스를 상속받은 클래스를 생성하여 run()을 오버라이딩한다.

  • Thread 클래스

    public class Thread implements Runnable {
      private Runnable r;
    
      public Thread(Runnable r) {
        this.r = r;
      }
    
      @Override
      public void run() {
        if(r! = null) {
          r.run();
        }
      }
    }
  • Thread 클래스를 상속받은 클래스 생성

    class MyThread extends Thread {
      @Override
      public void run() { // 실행할 작업을 적는다.
        for (int i = 0; i < 5; i++) {
          // 스레드의 이름을 출력한다.
          // 부모인 Thread 클래스의 getName()를 호출한다.
          System.out.println(getName());
        }
      }
    }
  • 생성한 클래스의 인스턴스 실행

    class Main {
      public static void main(String[] args) {
        MyThread myThread = new MyThread();
        myThread.start(); // 쓰레드를 실행시킨다.
      }
    }
    Thread-0
    Thread-0
    Thread-0
    Thread-0
    Thread-0

둘째, Runnable 인터페이스 구현

Runnable 인터페이스를 구현한 클래스의 인스턴스를 생성한 다음, 이 인스턴스를 Thread 클래스의 생성자의 매개변수로 제공하여 Thread 클래스의 인스턴스를 생성한다.
Thread 클래스에서 상속을 통해, run()을 오버라이딩하지 않고도 외부로 run()을 제공받는다.

  • Thread 클래스

    public class Thread implements Runnable {
      private Runnable r;
    
      public Thread(Runnable r) {
        this.r = r;
      }
    
      @Override
      public void run() {
        if(r! = null) {
          r.run();
        }
      }
    }
  • Runnable 인터페이스를 구현한 클래스

    class MyThread implements Runnable {
      @Override
      public void run() { // 실행할 작업을 적는다.
        for (int i = 0; i < 5; i++) {
          // 스레드의 이름을 출력한다.
          // Thread를 상속받지 않아 Thread의 static 메소드인 Thread.currentThread()로 현재 실행중인 Thread의 참조를 얻어와 getName()을 호출한다.
          System.out.println(Thread.currentThread().getName());
        }
      }
    }
  • Thread 클래스의 생성자로 Runnable 인터페이스를 구현한 클래스를 인자로 전달하여 생성된 Thread 클래스의 인스턴스 실행

    class Main {
      public static void main(String[] args) { 
        Runnable r = new MyThread();
        Thread myThread = new Thread(r); // 생성자 Thread(Runnable target)
        myThread.start(); // 쓰레드를 실행시킨다.
      }
    }
    Thread-0
    Thread-0
    Thread-0
    Thread-0
    Thread-0

어떤 방법으로 쓰레드를 구현해야 할까?

첫번째 방법처럼 Thread 클래스를 상속받으면 다른 클래스를 상속받을 수 없다.
따라서 다른 클래스를 확장할 필요가 있을 때에는 Runnable 인터페이스를 구현하고, 그렇지 않은 경우에는 Thread 클래스를 사용하는 것이 편하다.

쓰레드 실행

  • 방법 : start()를 호출한다.

  • 쓰레드가 실행되는 과정

    1. main 메소드에서 start()를 호출한다.
      `start()`를 호출

    2. 쓰레드가 작업을 실행하는데 필요한 호출 스택(call stack)을 생성한다.
      호출 스택(call stack)을 생성

    3. run()을 호출하여 생성된 호출 스택에 run()이 첫 번째로 올라가게 한다.
      `run()`을 호출

    4. 이때 두 쓰레드를 동시에 실행할만큼의 자원이 없다면, 바로 run()이 실행되지 않고 호출 스택이 2개(Main 쓰레드의 호출스택, 새로운 쓰레드의 호출스택)이므로 스케줄러가 정한 순서에 따라서 번갈아 가면서 실행된다.
      번갈아 가면서 실행

    5. 작업을 마친 쓰레드(run() 수행이 종료된 쓰레드)는 호출스택이 모두 비워지면서 해당 쓰레드가 사용하던 호출스택이 사라진다.
      작업을 마친 쓰레드

  • 쓰레드가 두 개 이상 실행중일 때, 두 쓰레드는 자원이 부족하지 않다면 동시에 수행되므로 다음의 결과가 발생할 수 있다.

    class MyThread1 extends Thread {
      @Override
      public void run() {
        for (int i = 0; i < 5; i++) {
          // 스레드의 이름을 출력한다.
          System.out.println(getName());
        }
      }
    }
    
    class MyThread2 extends Thread {
      @Override
      public void run() {
        for (int i = 0; i < 5; i++) {
          // 스레드의 이름을 출력한다.
          System.out.println(getName());
        }
      }
    }
    class Main {
      public static void main(String[] args) {
        MyThread1 myThread1 = new MyThread1();
        MyThread2 myThread2 = new MyThread2();
        myThread1.start();
        myThread2.start();
      }
    }
    Thread-0
    Thread-0
    Thread-1
    Thread-1
    Thread-1
    Thread-1
    Thread-1
    Thread-0
    Thread-0
    Thread-0
  • 한 번 실행이 종료된 쓰레드는 다시 실행할 수 없다.

    • 따라서 하나의 쓰레드에 대해 start()를 두 번 이상 호출하면, 실행시에 IllegalThreadStateException이 발생한다.

쓰레드의 상태

쓰레드의 상태

출처: 멀티쓰레드 프로그래밍

상태의미
NEW쓰레드 객체는 생성되었지만, 아직 시작되지 않은(start()가 호출되지 않은) 상태
RUNNABLE실행중인 상태
WAITING대기중인 상태
TIMED_WAITING특정 시간만큼 대기중인 상태
BLOCKED동기화 작업으로 인해 다른 쓰레드 실행이 끝날 때까지 실행 중지 상태이며, 모니터 락이 풀리기를 기다리는 상태
TERMINATED종료된 상태

쓰레드의 우선순위

쓰레드가 둘 이상일 때 스케줄러는 실행대기중인 쓰레드들의 우선순위를 고려하여 실행순서와 실행시간을 결정한다.

  • 각 쓰레드들은 작성된 스케줄러에 따라 자신의 순서가 되면 지정된 시간동안 작업을 수헹한다.
    이때, 주어진 시간동안 작업을 마치지 못한 쓰레드는 다시 자신의 차례가 돌아올 때까지 실행대기상태로 남는다.
  • 자바에선 쓰레드의 우선순위를 값으로 1부터 10까지 지정할 수 있다.
    • 값이 높을수록 높은 우선순위이다.
    • 기본값은 5이다.

Main 쓰레드

: main 메서드의 작업을 수행하는 쓰레드이다.

  • 자바 애플리케이션은 기본적으로 하나의 메인 쓰레드를 가진다.
  • 메인 쓰레드만 실행하는 것을 싱글 쓰레드 애플리케이션이라 불린다.

💡 자바 프로그램이 종료되는 시점
기존에는 main 메소드가 종료되면 자바 프로그램이 종료된다고 알고 있었을 것이다.
하지만, 실제로는 main 메소드가 종료되더라도 즉, Main 쓰레드가 종료되더라도 다른 실행중인 쓰레드가 남아있다고 한다면 해당 쓰레드가 종료될 때까지 자바 프로그램은 종료되지 않는다.

동기화(Synchronization)

여러개의 쓰레드가 한 개의 리소스를 사용할 때, 다른 쓰레드의 접근을 막는 것

  • 자바에서는 synchronized 키워드를 사용하여 메소드나 블록단위로 동기화를 수행한다.

    // 메소드 단위로 동기화 수행
    publc synchronized void foo() {...}
    
    // 블록 단위로 동기화 수행
    public void foo() {
      synchronized(obj) {
       ...
      }
    }

교착상태(Deadlock)

: 두 쓰레드가 자원을 점유한 상태에서 서로 상대편이 점유한 자원을 사용하려고 기다리느라 진행이 멈춰있는 상태

  • 아래의 4조건을 모두 만족해야 교착상태에 빠진다.
    • 상호배제 : 한 자원에 대해 하나의 쓰레드만 접근가능하다.
    • 점유와 대기 : 자원을 점유한 채로 다른 자원을 점유하기 위해 대기한다.
    • 비선점 : 다른 쓰레드가 점유한 자원을 강제로 가져올 수 없다.
    • 환형대기 : 각 쓰레드가 순환적으로 자원을 요구한다.

Reference

profile
안녕하세요.

0개의 댓글