본문 바로가기

카테고리 없음

스레드

애플리케이션을 실행하면 운영체제로부터 실행에 필요한 메모리를 할당받아 실행하게 되는데, 이것을 프로세스라고 합니다. 그리고 프로세스 내부에서 코드의 실행 흐름을 스레드(thread)라고 합니다. 

 

1. 멀티 스레드

하나의 애플리케이션은 멀티 프로세스를 만들기도 합니다. 예를 들어 메모장 애플리케이션을 2개 실행했다면 2개의 메모장 프로세스가 생성된 것입니다. 운영체제는 두 가지 이상의 작업을 동시에 처리하는 멀티 태스킹을 할 수 있도록 CPU 및 메모리 자원을 프로세스마다 적절히 할당해주고, 병렬로 실행시킵니다.

 

멀티 태스킹은 꼭 멀티 프로세스를 뜻하는 것은 아닙니다. 한 프로세스 내에서 멀티 태스킹을 할 수 있도록 만들어진 애플리케이션도 있습니다. 미디어 플레이어는 동영상 재생과 음악 재생이라는 두 가지 작업을 동시에 처리하고, 메신저는 채팅 기능을 제공하면서 동시에 파일 전송 기능을 수행하기도 합니다. 어떻게 하나의 프로세스가 두 가지 이상의 작업을 처리할 수 있을까요? 그 비밀은 멀티 스레드에 있습니다. 

 

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

 

멀티 프로세스는 운영체제에서 할망받은 자신의 메모리를 가지고 실행하기 때문에 각 프로세스는 서로 독립적입니다. 하지만 멀티 스레드는 하나의 프로세스 내부에 생성되기 때문에 하나의 스레드가 예외를 발생시키면 프로세스 자체가 종료될 수 있어 다른 스레드에 영향을 미치게 됩니다. 때문에 멀티 스레드에서는 예외처리가 정말 중요합니다. 

 

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

 

2. 메인 스레드

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

 

메인 스레드는 필요에 따라 작업 스레드들을 만들어서 병렬로 코드를 실행할 수 있습니다. 즉 멀티 스레드를 생성해서 멀티 태스킹을 수행합니다. 

 

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

 

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

자바에서는 작업 스레드도 객체로 생성되기 때문에 클래스가 필요합니다. java.lang.Thread 클래스를 직접 객체화해서 생성해도 되지만, Thread 클래스를 상속해서 하위 클래스를 만들어 생성할 수도 있습니다. 

 

3-1. Thread 클래스로부터 직접 생성

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

Thread thread = new Thread(Runnable target);

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

다음과 같이 Runnable 구현 클래스를 작성합니다. 

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

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

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

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

Thread thread = new Thread( new Runnable() {
    public void run() {
        스레드가 실행할 코드;
    }
});

작업 스레드는 생성되는 즉시 실행되는 것이 아니라, start() 메소드를 다음과 같이 호출해야만 비로소 실행됩니다. 

thread.start();

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

 

0.5초 주기로 beep 음을 발생시키면서 동시에 출력하는 작업이 있다고 가정해봅시다. 비프음 발생과 출력은 서로 다른 작업이므로 메인 스레드가 동시에 두 가지 작업을 처리할 수 없습니다. 만약 다음과 같이 작성했다면 메인 스레드는 비프음을 모두 발생시킨 다음, 출력을 시작합니다.

비프음을 발생시키며 동시에 출력하기 위해서는 두 작업 중 하나를 메인 스레드가 아닌 다른 스레드에서 실행해야 합니다. 출력은 메인 스레드가 담당하고 비프음은 작업 스레드가 맡도록 수정해 보겠습니다. 

 

다음은 Runnable 익명 구현 객체를 이용한 코드입니다. 

 

 

3-2. Thread 하위 클래스로부터 생성

작업 스레드가 실행할 작업을 Runnable로 만들지 않고, Thread의 하위 클래스로 작업 스레드를 정의하면서 작업 내용을 포함시킬 수도 있습니다. 

 

public class WorkerThread extends Thread {
    @Override 
    public void run() {
        스레드가 실행할 코드;
    }
}
Thread thread = new WorkerThread();

코드를 좀 더 절약하기 위해 다음과 같이 Thread 익명 객체로 작업 스레드 객체를 생성할 수도 있습니다. 

Thread thread = new Thread() {
    public void run() {
        스레드가 실행할 코드;
    }
};

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

 

4. 스레드의 이름

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

 

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

반대로 스레드 이름을 알고 싶을 땐 getName() 메소드를 호출합니다. 

 

setName()과 getName()은  Thread 클래스의 인스턴스 메소드이므로 스레드 객체의 참조가 필요합니다. 만약 스레드 객체의 참조를 가지고 있지 않다면, Thread 클래스의 정적 메소드인 currentThread()를 이용해서 현재 스레드의 참조를 얻을 수 있습니다. 

Thread thread = Thread.currentThread();

 

5. 공유 객체를 사용할 때 주의할 점

멀티 스레드 프로그램에서는 스레드들이 객체를 공유해서 작업해야 하는 경우가 있습니다. 이 경우 주의해야 할 점에 대해 살펴보겠습니다. 

 

스레드 A가 사용했던 객체를 스레드 B가 상태를 변경할 수 있기 때문에 스레드 A가 의도했던 것과는 다른 결과를 산출할 수도 있습니다.

 

다음 예제를 통해 공유 객체를 사용할 때의 문제점을 파악할 수 있습니다. 

결과:

User2: 50

User1: 50

 

5-1. 동기화 메소드

동기화: 작업들 사이의 수행 시기를 맞추는 것. 사건이 동시에 일어나거나, 일정한 간격을 두고 일어나도록 시간의 간격을 조정하는 것을 이른다.

스레드가 사용 중인 객체를 다른 스레드가 변경할 수 없게 하려면 스레드 작업이 끝날 때까지 객체에 잠금을 걸어서 다른 스레드가 사용할 수 없도록 해야 합니다. 

 

멀티 스레드 프로그램에서 단 하나의 스레드만 실행할 수 있는 코드 영역을 임계 영역(critical section)이라고 합니다. 자바는 임계 영역을 지정하기 위해 동기화(synchronized 동시에 발생하게[움직이게] 하다) 메소드를 제공합니다. 스레드가 객체 내부의 동기화 메소드를 실행하면 즉시 객체에 잠금을 걸어 다른 스레드가 동기화 메소드를 실행하지 못하도록 합니다. 

 

동기화 메소드를 만들려면 다음과 같이 메소드 선언에 synchronized 키워드를 붙이면 되는데, 인스턴스와 정적 메소드 어디든 붙일 수 있습니다. 

public synchronized void method() {
    임계 영역; // 단 하나의 스레드만 실행
}

동기화 메소드는 메소드 전체 내용이 임계 영역이므로 스레드가 동기화 메소드를 실행하는 즉시 객체에는 잠금이 일어나고, 스레드가 동기화 메소드를 실행 종료하면 잠금이 풀립니다. 

 

만약 동기화 메소드가 여러개 있을 경우, 스레드가 이들 중 하나를 실행할 때 다른 스레드는 해당 메소드는 물론이고 다른 동기화 메소드도 실행할 수 없습니다(?). 하지만 이때 다른 스레드에서 일반 메소드는 실행이 가능합니다. 

 

동기화 메소드를 사용하여 이전 예제를 수정하면 다음과 같습니다. 

package sec06.exam06;


public class Calculator {
    private int memory;

    public synchronized void setMemory(int memory) {
        this.memory = memory;
        try {
            Thread.sleep(2000);
        } catch(InterruptedException e) {}
        System.out.println(Thread.currentThread().getName() + ": " + this.memory);
    }
}

결과:

User1: 100

User2: 50

 

User1 스레드는 Calculator 객체의 동기화 메소드인 setMemory(int memory)를 실행하는 순간 Calculator 객체를 잠금 처리합니다.User2는 Use1이 setMemory(int memory)를 모두 실행할 동안 대기해야 합니다.