개발자는 기록이 답이다

멀티스레드 (1) - 멀티 스레드란? 본문

언어/Java

멀티스레드 (1) - 멀티 스레드란?

slow-walker 2023. 12. 5. 13:44

1. 멀티스레드 개념

 

프로세스와 스레드

 

운영체제에서는 실행중인 하나의 애플리케이션을 프로세스라고 부른다.

사용자가 애플리케이션을 실행하면 운영체제로부터 실행에 필요한 메모리를 할당받아 애플리케이션의 코드를 실행하는데 이것이 프로세스다. 하나의 애플리케이션은 다중 프로세스를 만들기도 하는데, 예를 들어 Chrome브라우저를 2개 실행했다면 2개의 Chrome프로세스가 생성된 것이다.

 

멀티태스킹은 2가지 이상의 작업을 동시에 처리하는 것을 말하는데, 운영체제는 멀티 태스킹을 할 수 있도록 CPU 및 메모리 자원을 프로세스마다 적절히 할당해주고, 병렬로 실행시킨다. 예를 들어 워드로 문서 작업을 하면서 동시에 윈도우 미디어 플레이러로 음악을 들을 수 있다. 멀티 태스킹은 꼭 멀티 프로세스를 뜻하지는 않는다. 한 프로세스 내에서 멀티 태스킹을 할 수 있도록 만들어진 애플리케이션도 있다.

 

대표적인 것이 미디어 플레이어와 메신저이다. 미디어 플레이어는 동영상 재생과 음악 재생이라는 두 작업을 동시에 처리하고, 메세지는 채팅 기능을 제공하면서 동시에 파일 전송 기능을 수행하기도 한다.

 

어떻게 하나의 프로세스가 두 가지 이상의 작업을 처리할 수 있을까? 그 비밀은 멀티 스레드에 있다.

 

스레드는 사전적 의미로 한 가닥의 실이라는 뜻인데, 한 가지 작업을 실행하기 위해 순차적으로 실행할 코드를 실처럼 이어 놓았다고 해서 유래된 이름이다. 하나의 스레드는 하나의 코드 실행 흐름이기 때문에 한 프로세스 내에 스레드가 2 개라면 코드 실행 흐름이 생긴다는 의미이다.

 

멀티 프로세스가 애플리케이션 단위의 멀티 태스킹이라면 멀티 스레드는 애플리케이션 내부에서의 멀티 태스킹이라고 볼 수 있다.

 

멀티 프로세스들은 운영체제에서 할당받은 자신의 메모리를 가지고 실행하기 때문에 서로 독립적이다. 따라서 하나의 프로세스에서 오류가 발생해도 다른 프로세스에게 영향을 미치지 않는다. 하지만 멀티 스레드는 하나의 프로세스 내부에 생성되기 때문에 하나의 스레드가 예외를 발생시키면 프로세스 자체가 종료 될 수 있어 다른 스레드에게 영향을 미치게 된다.

 

예를 들어, 멀티 프로세스인 워드와 엑셀을 동시에 사용하던 도중, 워드에 오류가 생겨 먹통이 되더라도 엑셀은 여전히 사용 가능하다.

그러나 멀티 스레드로 동작하는 메신저의 경우 파일을 전송하는 스레드에서 예외가 발생되면 메신저 프로세스 자체가 종료되기 때문에 채팅 스레드도 같이 종료된다. 그렇기 때문에 멀티 스레드에서는 예외 처리에 만전을 가해야 한다.

 

멀티스레드는 다양한 곳에서 사용된다. 대용량 데이터의 처리 시간을 줄이기 위해 데이터를 분할해서 병렬로 처리하는 곳에서 사용되기도 하고, UI를 가지고 애플리케이션에서 네트워크 통신을 하기 위해 사용되기도 한다. 또한 다수 클라이언트의 요청을 처리하는 서버를 개발할 때에도 사용된다. 멀티 스레드는 애플리케이션을 개발하는데 꼭 필요한 기능이기 때문에 반드시 이해하고 활용할 수 있도록 해야한다.

 

 

메인 스레드

 

모든 자바 애플리케이션은 메인 스레드(main thread)가 main()메소드를 실행하면서 시작된다. 메인 스레드는 main()의 첫 코드부터 아래로 순차적으로 실행하고, main()메소드의 마지막 코드를 실행하거나 return문을 만나면 실행이 종료된다.

 

메인 스레드는 필요에 따라 작업 스레드들을 만들어서 병렬로 코드를 실행할 수 있다 즉 멀티 스레드를 생성해서 멀티 태스킹을 수행한다. 다음 그림에서 우측의 멀티 스레드 애플리케이션을 보면 메인 스레드가 작업 스레드1을 생성하고 실행한 다음, 곧이어 작업 스레드2를 생성하고 실행한다.

싱글 스레드 애플리케이션에서는 메인 스레드가 종료하면 프로세스도 종료된다, 하지만 멀티 스레드 애플리케이션에서는 실행 중인 스레드가 하나라도 있다면, 프로세스는 종료되지 않는다. 메인 스레드가 작업 스레드보다 먼저 종료되더라도 작업 스레드가 계속 실행 중이라면 프로세는 종료되지 않는다.

 

2. 작업 스레드 생성과 실행

 

멀티 스레드로 실행하는 애플리케이션을 개발하려면 먼저 몇 개의 작업을 병렬로 실행할지 결정하고 각 작업별로 스레드를 생성해야한다.

 

어떤 자바 애플리케이션이건 메인 스레드는 반드시 존재하기 때문에 메인 작업 이외에 추가적인 병렬 작업의 수만큼 스레드를 생성하면 된다. 자바에서는 작업 스레드도 객체로 생성되기 때문에 클래스가 필요하다. java.lang.Thread 클래스를 직접 객체화해서 생성해도 되지만, Thread를 상속해서 하위 클래스를 만들어 생성할 수도 있다.

 

Thread클래스로부터 직접 생성

 

 java.lang.Thread 클래스로부터 작업 스레드 객체를 직접 생성하려면 다음과 같이 Runnable을 매개값으로 갖는 생성자를 호출해야한다.

 

Thread thread = new Thread(Runnable target);

Runnable은 작업 스레드가 실행할 수 있는 코드를 가지고 있는 객체라고 해서 붙여진 이름이다. Runnable은 인터페이스 타입이기 때문에 구현 객체를 만들어 대입해야 한다. Runnable에는 run()메소드 하나가 정의되어 있는데, 구현 클래스는 run()을 재정의해서 작업 스레드가 실행할 코드를 작성해야 한다.

class Task implements Runnable {
	public void run() {
    	스레드가 실행할 코드;
    }
}

 

Runnable은 작업 내용을 가지고 있는 객체이지 실제 스레드는 아니다. Runnable 구현 객체를 생성한 후, 이것을 매개값으로 해서 Thread 생성자를 호출하면 비로소 작업 스레드가 생성된다.

Runnable task = new Task();
Thread thread = new Thread(task);

 

코드를 좀 더 절약하기 위해 Thread 생성자를 호출할 때 Runnable 익명 객체를 매개값으로 사용할 수 있다. 오히려 아래 방식이 더 많이 사용된다.

 

Runnable 인터페이스는 run()메소드 하나만 정의되어 있기 때문에 함수적 인터페이스이다, 따라서 아래와 같이 람다식을 매개값으로 사용할 수도 있다 가장 간단한 방법이지만, 자바 8부터 지원되기 때문에 자바 7이전 버전에서는 사용할 수 없다.

 

작업 스레드는 생성되는 즉시 실행되는게 아니라, start()메소드를 호출해야 실행된다.

thread.start();

 

start()메소드가 호출되면, 작업 스레드는 매개값으로 받은 Runnable의 run()메소드를 실행하면서 자신의 작업을 처리한다.

 

0.5초 주기로 비프(beep)음을 발생시키면서 동시에 프린팅하는 작업이 있다고 가정해보자. 비프음 발생과 프린팅은 서로 다른 작업이므로 메인 스레드가 동시에 두 가지 작업을 처리할 수 없다. 만약 다음과 같이 작성했다면 메인 스레드는 비프음을 모두 발생하 다음, 프린팅을 시작한다.

package org.example.chapter14;

import java.awt.*;

// 메인 스레드만 이용한 경우
public class BeepPrintExample1 {
    public static void main(String[] args) {
        Toolkit toolkit = Toolkit.getDefaultToolkit(); // ToolKit 객체얻기
        for (int i = 0; i < 5; i++) {
            toolkit.beep(); // 비프음 발생
            try {
                Thread.sleep(500); // 0.5초간 일시정지
            } catch (Exception e) {}
        }


        for (int i = 0; i < 5; i++) {
            System.out.println("띵");
            try {
                Thread.sleep(500); // 0.5초간 일시정지
            } catch (Exception e) {}
        }
    }
}

 

비프음을 발생시키면서 동시에 프린팅을 하려면 두 작업 중 하나를 메인 스레드가 아닌 다른 스레드에서 실행시켜야 한다.  프린팅은 메인 스레드가 담당하고 비프음을 들려주는 것은 작업 스레드가 담당하도록 수정해보자. 우선 작업을 정의하는 Runnable 구현 클래스를 작성한다.

package org.example.chapter14;

import java.awt.*;

// 비프음을 들려주는 작업정의
public class BeepTask implements Runnable{
    @Override
    public void run() { 
        // 스레드 실행 내용
        Toolkit toolkit = Toolkit.getDefaultToolkit();
        for (int i = 0; i < 5; i++) {
            toolkit.beep(); // 비프음 발생
            try {
                Thread.sleep(500);
            } catch (Exception e) {}
        }
    }
}

 

BeepPrintExample1.java에서 비프음을 발생하는 코드를 아래와 같이 작업 스레드 생성 및 실행 코드로 변경한다.

package org.example.chapter14;

// 메인스레드와 작업스레드가 동시에 실행
public class BeepPrintExample2 {
    // 메인스레드
    public static void main(String[] args) {
        BeepTask beepTask = new BeepTask();
        Thread thread = new Thread(beepTask);
        thread.start(); // BeepRunnable

        for (int i = 0; i < 5; i++) {
            System.out.println("띵");
            try {
                Thread.sleep(500); // 0.5초간 일시정지
            } catch (Exception e) {}
        }
    }
}

 

3라인에서 BeepTask객체를 생성하고, 이것을 매개값으로 해서 4라인에서 작업 스레드를 생성한다. 5라인에서 작업 스레드의 start()메소드를 호출하면 작업 스레드에 의해 BeepTask객체의 run()메소드가 실행되어 비프음이 발생한다. 그와 동시에 메인 스레드는 7라인의 for문을 실행시켜 0.5초 간격으로 "띵"을 프린팅한다. 다음은 3~4라인을 대체하여 작업 스레드를 만들 수 있는 또 다른 2가지 방법이다.

 

 

Thread 하위 클래스로부터 생성

 

작업 스레드가 실행할 작업을 Runnable로 만들지 않고, Thread의 하위 클래스로 작업 스레드를 정의하면서 작업 내용을 포함시킬 수도 있다. 아래는 작업 스레드 클래스를 정의하는 방법인데, Thread 클래스를 상속한 후 run메소드를 재정의(Overriding)해서 스레드가 실행할 코드를 작성하면 된다. 작성 스레드 클래스로부터 작업 스레드 객체를 생성하는 방법은 일반적인 객체를 생성하는 방법과 동일하다

 

코드를 좀더 절약하기 위해 아래 처럼 Thread 익명 객체로 작업 스레드 객체를 생성할수도 있다.

 

이렇게 생성된 작업 스레드 객체에서 start()메소드를 호출하면 작업 스레드는 자신의 run()메소드를 실행하게 된다.

 

아래 BeepThread클래스는 이전 예제를 수정해서 Runnable을 생성하지 않고 Thread의 하위 클래스로 작업을 정의한 것이다.

package org.example.chapter14;

import java.awt.*;

// 비프음을 들려주는 스레드
public class BeepThread extends Thread {
    @Override
    public void run() {
        // 스레드 실행 내용
        Toolkit toolkit = Toolkit.getDefaultToolkit();
        for (int i = 0; i < 5; i++) {
            toolkit.beep(); // 비프음 발생
            try {
                Thread.sleep(500);
            } catch (Exception e) {}
        }
    }
}

 

BeepThread 클래스를 이용해서 작업 스레드 객체를 생성하고 실행한다.

package org.example.chapter14;

// 메인스레드와 작업스레드가 동시에 실행
public class BeepPrintExample3 {
    // 메인스레드
    public static void main(String[] args) {
        BeepTask beepTask = new BeepTask();
        Thread thread1 = new Thread(beepTask);
        thread1.start(); // BeepThread

        for (int i = 0; i < 5; i++) {
            System.out.println("띵");
            try {
                Thread.sleep(500); // 0.5초간 일시정지
            } catch (Exception e) {
            }
        }
    }
}

 

3라인에서 BeepThread 객체를 생성하고 4라인에서 start()메소드를 호출하여 BeepThread가 run()메소드를 실행하도록 했다. 그와 동시에 메인 스레드는 6라인의 for문을 실행시켜 0.5초 간격으로 "띵"을 프린팅한다. 다음은 3라인을 대체하여 작업 스레드를 만들 수 있는 또 다른 방법을 보여준다.

 

스레드의 이름

 

스레드는 자신의 이름을 가지고 있다. 스레드의 이름이 큰 역할을 하는 것은 아니지만, 디버깅할 때 어던 스레드가 어떤 작업을 하는지 조사할 목적으로 가끔 사용된다. 메인 스레드는 "main"이라는 이름을 가지고 있고, 우리가 직접 생성한 스레드는 자동적으로 "Thread-n"이라는 이름으로 설정된다. n은 스레드 번호를 말한다. Thread-n 대신 다른 이름으로 설정하고 싶다면 Thread 클래래스의 setName()메소드로 변경하면 된다.

thread.setName("스레드 이름");

 

반대로 스레드 이름을 알고 싶을 경우에는 getName() 메소드를 호출하면 된다.

thread.getName();

 

 setName()과  getName() 은 Thread의 인스턴스 메소드이므로 스레드 객체의 참조가 필요하다. 만약 스레드 객체으 ㅣ참조가 가지고 있지 않다면, Thread의 정적 메소드인 currentThread()로 코드를 실행하는 현재 스레드의 참조를 얻을 수 있다.

 

아래 예제는 메인 스레드의 참조를 얻어 스레드 이름을 콘솔에 출력하고, 새로 생성한 스레드의 이름을 setName() 메소드로 설정한 후, getName() 메소드로 읽어오도록 했다.

 

package org.example.chapter14;

public class ThreadNameExample {
    public static void main(String[] args) {
        Thread mainThread = Thread.currentThread(); // 이 코드를 실행하는 스레드 객체 얻기
        System.out.println("프로그램 시작 스레드 이름: " + mainThread.getName());

        ThreadA threadA = new ThreadA();
        System.out.println("작업 스레드 이름: " + threadA.getName());
        threadA.start();// ThreadA 시작

        ThreadB threadB = new ThreadB();
        System.out.println("작업 스레드 이름: " + threadB.getName());
        threadB.start();// ThreadB 시작
    }
}
package org.example.chapter14;

public class ThreadA extends Thread {
    public ThreadA() {
        setName("ThreadA"); // 스레드 이름 설정
    }

    // ThreadA 실행내용
    public void run() {
        for (int i = 0; i < 2; i++) {
            System.out.println(getName() + "가 출력한 내용");
        }
    }
}
package org.example.chapter14;

public class ThreadB extends Thread{
    // ThreadB 실행내용
    public void run() {
        for (int i = 0; i < 2; i++) {
            System.out.println(getName() + "가 출력한 내용");
        }
    }
}

 

스레드 우선순위

 

  • 동시성 : 멀티작업을 위해 하나의 코어에서 멀티 스레드가 번갈아가며 실행
  • 병렬성 : 멀티 코어에서 개별 스레드를 동시에 실행하는 성질

 

싱글코어 CPU를 이용한 멀티 스레드 작업은 병렬적으로 실행되는것 처럼 보이지만, 사실은 번갈아가며 실행하는 동시성 작업이다. 번갈아 실행하는 것이 워낙 빠르다보니 병렬성으로 보일 뿐이다.

 

 

스레드의 개수가 코어의 수보다 많을 경우, 스레드를 어떤 순서에 의해 동시성으로 실행할 것인가를 결정해야 하는데, 이것을 스레드 스케줄링이라고 한다. 스레드 스케줄링에 의해 스레드들은 아주 짧은 시간에 번갈아가면서 그들의 run()메소드를 조금씩 실행한다

 

자바의 스레드 스케줄링은 우선순위(Priority)방식순환 할당(Round-Robin)방식을 사용한다. 우선순위 방식은 우선순위가 높은 스레드가 실행 상태를 더 많이 가지도록 스케줄링하는 것을 말한다. 순환 할당 방식은 시간 할당량(Time Slice) 을 정해서 하나의 스레드를 정해진 시간만큼 다시 다른 스레드를 실행하는 방식을 말한다.

  • 스레드 우선순위 방식은 스레드 객체에 우선 순위 번호를 부여할 수 있기 때문에 개발자가 코드로 제어할 수 있다.
  • 순환 할당 방식은 자바 가상 기계에 의해서 정해지기 때문에 코드로 제어 할 수 없다. 

우선순위 방식에서 우선순위는 1에서부터 10까지 부여되는데, 1이 가장 우선순위가 낮고, 10이 가장 높다. 우선순위를 부여하지 않으면 모든 스레드들은 기본적으로 5의 우선순위를 할당받는다. 만약 우선순위를 변경하고 싶다면 Thread클래스가 제공하는 setPriority() 메소드를 이용하면 된다.

 

thread.setPriority(우선순위);

 

우선순위 매개값으로 1~10까지의 값을 직접 주어도 되지만, 코드의 가독성(이해도)을 높이기 위해 Thread클래스의 상수를 사용할 수도 있다.

thread.setPriority(Thread.MAX_PRIORITY); //10
thread.setPriority(Thread.NORM_PRIORITY); //5
thread.setPriority(Thread.MIN_PRIORITY); //1

 

다른 스레드들에 비해 실행 기회를 더 많이 가지려면 우선순위를 높게 설정하면된다. 동일한 계산 작업을 하는 스레드들이 잇고, 싱글 코어에서 동시성으로 실행할 경우, 우선순위가 높은 스레드가 실행 기회를 더 많이 가지기 때문에 우선순위가 낮은 스레드보다 계산 작업을 빨리 끝낸다. 쿼드 코어일 경우에는 4개의 스레드가 병렬성으로 실행될 수 있기 때문에 4개 이하의 스레드를 실행할 경우 우선순위 방식이 크게 영향을 미치지 못한다.최소한 5개 이상의 스레드가 실행되어야 우선순위의 영향을 받는다.

 

 다음은 스레드 10개를 생성하고 20억번의 루핑을 누가 더 빨리 끝내는가 테스트한 예제이다. Thread1~9는 우선순위를 가장 낮게 주었고, Thread10은 우선순위가 가장 높게 주었더니 Thread10 의 계산작업이 가장 빨리 끝난다.

package org.example.chapter14.priority;

public class CalcThread extends Thread {
    public CalcThread(String name) {
        setName(name); // 스레드 이름 설정
    }

    // ThreadA 실행내용
    public void run() {
        for (int i = 0; i < 2000000000; i++) {
        }
        System.out.println(getName());
    }
}
package org.example.chapter14.priority;

public class PriorityExample {
    public static void main(String[] args) {
        for (int i = 0; i <= 10 ; i++) {
            Thread thread = new CalcThread("thread" + i);
            if (i != 10) {
                thread.setPriority(Thread.MIN_PRIORITY);
            } else {
                thread.setPriority(Thread.MAX_PRIORITY);
            }
            thread.start();
        }
    }
}