멀티 스레드의 개념
멀티 스레드(Multi Thread) 개념
운영체제에서는 실행 중인 하나의 애플리케이션을 프로세스라고 부릅니다. 사용자가 애플리케이션을 실행하면 운영체제로부터 실행에 필요한 메모리를 할당받아 애플리케이션의 코드를 실행하는데 이것이 프로세스 입니다.
예를들어, Chrome 브라우저를 두 개 실행했다면 두 개의 Chrome 프로세스가 생성된 것 입니다.
기본적으로 멀티 태스킹은 두 가지 이상의 작업을 동시에 처리하는 것을 말합니다. 운영체제는 멀티 태스킹을 할 수 있도록 CPU 및 메모리 자원을 프로세스마다 적절히 할당해주고, 병렬로 실행시킵니다. 중요한건 멀티 태스킹은 꼭 멀티 프로세스를 뜻하지 않습니다. 대표적인 것이 미디어 플레이어와 메신저 입니다. 미디어 플레이는 동영상 재생과 음악 재생이라는 두 작업을 동시에 처리하고, 메신저는 채팅 기능을 제공하면서 동시에 파일 전송기능을 수행하기도 합니다. 이렇게 하나의 프로세스가 두 가지 이상의 작업을 처리하기 위해서는 멀티 스레드를 이해해야 합니다.
스레드는 사전적 의미로 한 가닥의 실이라는 뜻입니다. 한 가지 작업을 실행하기 위해 순차적으로 실행할 코드를 실처럼 이어 놓았다고 해서 유래된 이름입니다. 하나의 스레드는 하나의 코드 실행 흐름이기 때문에 한 프로세스 내에 스레드가 두 개라면 두 개의 코드 실행흐름이 생긴다는 의미입니다.
멀티 프로세스가 애플리케이션 단위의 멀티 태스킹이라면 멀티 스레드는 애플리케이션 내부에서 멀티 태스킹이라고 볼 수 있습니다.
멀티 프로세스들은 운영체제에서 할당받은 자신의 메모리를 가지고 실행하기 때문에 서로 독립적입니다. 따라서 하나의 프로세스에서 오류가 발생해도 다른 프로세스에게 영향을 미치지 않습니다. 하지만 멀티 스레드는 하나의 프로세스 내부에 생성되기 때문에 하나의 스레드가 예외를 발생시키면 프로세스 자체가 종료될 수 있어 다른 스레드에게도 영향을 미칩니다. 그렇기 때문에 멀티 스레드에서는 예외 처리에 만전을 기해야 합니다.
멀티 스레드는 다양한 곳에 사용되지만, 대표적으로 대용량 데이터의 처리 시간을 줄이기 위해 데이터를 분할해서 병렬로 처리하는 곳에서 사용되기도 하고, UI를 가지고 있는 애플리케이션에서 네트워크 통신을 하기 위해 사용되기도 합니다. 또 다수 클라이언트의 요청을 처리하는 서버를 개발할 때에도 사용됩니다. 멀티 스레드는 애플리케잏션을 개발하는데 꼭 필요한 기능이기 때문에 반드시 이해하고 활용할 수 있도록 합니다.
메인 스레드
모든 자바 애플리케이션은 메인 스레드가 main() 메소드를 실행하면서 시작됩니다. 메인 스레드는 main() 메소드의 첫 코드부터 아래로 순차적으로 실행하고, main() 메소드의 마지막 코드를 실행하거나 return 문을 만나면 실행이 종료됩니다.
public static void main(String[] args){
// 코드의 실행 흐름 -> 스레드
String data = null;
if(...){
}
while(...){
}
System.out.println("...");
}
메인 스레드는 필요에 따라 작업 스레드들을 만들어서 병렬로 코드를 실행할 수 있습니다. 즉, 멀티 스레드를 생성해서 멀티 태스킹을 수행합니다. 싱글 스레드 애플리케이션에서는 메인 스레드가 종료하면 프로세스도 종료합니다. 하지만 멀티 스레드 애플리케이션에서는 실행 중인 스레드가 하나라도 있다면, 프로세스는 종료되지 않습니다. 메인 스레드가 작업 스레드보다 먼저 종료되더라도 작업 스레드가 계속 실행 중이라면 프로세스는 종료되지 않습니다.
작업 스레드 생성과 실행
멀티 스레드로 실행하는 애플리케이션을 개발하려면 먼저 몇 개의 작업을 병렬로 실행할지 결정하고 각 작업별로 스레드를 생성해야 합니다.
어떤 자바 애플리케이션이건 메인 스레드는 반드시 존재하기 때문에 메인 작업 이외에 추가적인 병렬 작업의 수만큼 스레드를 생성합니다. java.lang.Thread 클래스로부터 작업 스레드 객체를 직접 생성하려면 아래코드 같이 Runnable을 매개값으로 갖는 생성자를 호출해야 합니다.
Thread thread = new Thread(Runnable target);
Runnable은 작업 스레드가 실행할 수 있는 코드를 가지고 있는 객체입니다. Runnable은 인터페이스 타입이기 때문에 구현 객체를 만들어 대입해야 합니다. Ruunable에는 run() 메소드 하나가 정의되어 있는데, 구현 클래스는 run()을 재정의해서 작업 스레드가 실행할 코드를 작성해야 합니다.
class Task implements Runnable{
@Override
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(){
스레드가 실행할 코드;
}
});
Runnable 인터페이스는 run() 메소드가 하나만 정의되어 있기 때문에 함수적 인터페이스 입니다. 따라서 아래와 같이 람다식을 매개값으로 사용할 수 있습니다.
참고로 람다식은 자바 8부터 지원되기 때문에 자바 7 이전 버전에서는 사용할 수 없습니다.
Thread thread = new Thread(() -> {
스레드가 실행할 코드;
});
작업 스레드는 생성되는 즉시 실행되는 것이 아니라, start() 메소드를 다음과 같이 호출해야만 비로소 실행됩니다.
thread.start();
start() 메소드가 호출되면, 작업 스레드는 매개값으로 받은 Runnable의 run() 메소드를 실행하면서 자신의 작업을 처리합니다.
0.5초 주기로 비프(beep)음을 발생시키면서 동시에 프린팅하는 작업이 있다고 가정해보겠습니다. 비프음 발생과 프린팅은 서로 다른 작업이므로 메인 스레드가 동시에 두가지 작업을 처리할 수 없습니다. 만약 다음과 같이 작성했다면 메인 스레드는 비프음을 모두 발생한 다음, 프린팅을 시작합니다.
import java.awt.*;
public class BeepPrintExample1 {
public static void main(String[] args) {
Toolkit toolkit = Toolkit.getDefaultToolkit();
for (int i = 0; i < 5; i++) {
toolkit.beep();
try{ Thread.sleep(500);}catch (Exception e){}
}
for (int i = 0; i < 5; i++) {
System.out.println("띵");
try{ Thread.sleep(500);}catch (Exception e){}
}
}
}
이제 프린팅을 메인 스레드가 담당하고, 비프음을 들려주는 것은 작업 스레드가 담당하도록 수정해보겠습니다. 우선 작업을 정의하는 Runnable 구현 클래스를 다음과 같이 작성합니다.
비프음을 들려주는 작업 스레드 정의
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){}
}
}
}
메인 스레드와 작업 스레드가 동시에 실행되도록 코드 수정
public class BeepPrintExample1 {
public static void main(String[] args) {
BeepTask beepTask = new BeepTask();
Thread thread = new Thread(beepTask);
thread.start();
for (int i = 0; i < 5; i++) {
System.out.println("띵");
try{ Thread.sleep(500);}catch (Exception e){}
}
}
}
---------------------------------------------------------
// 람다식 이용
public class BeepPrintExample1 {
public static void main(String[] args) {
BeepTask beepTask = new BeepTask();
Thread thread = new Thread(() -> {
Toolkit toolkit = Toolkit.getDefaultToolkit();
for(int i = 0; i < 5; i++){
toolkit.beep();
try{ Thread.sleep(500); }catch(Exception e){}
}
});
thread.start();
for (int i = 0; i < 5; i++) {
System.out.println("띵");
try{ Thread.sleep(500);}catch (Exception e){}
}
}
}
Thread 하위 클래스부터 생성
작업 스레드가 실행할 작업은 Runnable로 만들지 않고, Thread의 하위 클래스로 작업 스레드를 정의하면서 작업 내용을 포함시킬 수도 있습니다. Thread 클래스를 상속한 후 run 메소드를 재정의해서 스레드가 실행할 코드를 작성하면 됩니다.
public class WorkerThread extends Thread{
@Override
public void run(){
// 스레드가 실행할 코드
}
}
Thread thread = new WorkerThread();
코드를 절약하기 위해 아래와 같이 Thread 익명 객체로 작업 스레드 객체를 생성할 수도 있습니다.
Thread thread = new Thread(){
public void run(){
// 스레드가 실행할 코드
}
};
스레드의 이름
스레드는 자신의 이름을 가지고 있습니다. 스레드의 이름 자체가 큰 역할을 하는 것은 아니지만, 디버깅할 때 어떤 스레드가 어떤 작업을 하는지 조사할 목적으로 가끔 사용됩니다. 메인 스레드는 "main"이라는 이름을 가지고 있고, 우리가 직접 생성한 스레드는 자동적으로 "Thread-n"이라는 이름으로 설정됩니다. n은 스레드 번호를 의미합니다. Thread-n 대신 다른 이름으로 변경하고 싶다면 Thread 클래스의 setName() 메소드로 변경하면 됩니다.
thread.setName("스레드 이름");
반대로 스레드 이름을 알고 싶을 경우에는 getName() 메소드를 호출하면 됩니다.
thread.getName();
setName()과 getName()은 Thread의 인스턴스 메소드이므로 스레드의 객체의 참조가 필요합니다. 만약 스레드 객체의 참조를 가지고 있지 않다면, Thread의 정적 메소드인 currentThread()로 코드를 실행하는 현재 스레드의 참조를 얻을 수 있습니다.
thread.currentThread();
// 메인 스레드 이름 출력 및 UserThread 생성 및 시작
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();
ThreadB threadB = new ThreadB();
System.out.println("작업 스레드 이름: "+ threadB.getName());
threadB.start();
}
}
// ThreadA 클래스
public class ThreadA extends Thread{
public ThreadA() {
setName("ThreadA");
}
// 스레드 이름 얻기
@Override
public void run() {
for (int i = 0; i < 2; i++) {
System.out.println(getName() + "가 출력한 내용");
}
}
}
// ThreadB 클래스
public class ThreadB extends Thread{
public ThreadB() {
setName("ThreadB");
}
// 스레드 이름 얻기
@Override
public void run() {
for (int i = 0; i < 2; i++) {
System.out.println(getName() + "가 출력한 내용");
}
}
}
스레드 우선 순위
멀티 스레드는 동시성(Concurrency) 또는 병렬성(Parallelism)으로 실행되기 때문에 이 용어들에 대해 정확히 이해하는 것이 좋습니다. 동시성은 멀티 작업을 위해 하나의 코어에서 멀티 스레드가 번갈아가며 실행하는 성질을 말하고, 병렬성은 멀티 작업을 위해 멀티 코어에서 개별 스레드를 동시에 실행하는 성질을 말합니다. 싱글 코어 CPU를 이용한 멀티 스레드 작업은 병렬적으로 실행되는 것 처럼 보이지만, 사실은 번갈아가며 실행하는 동시성 작업입니다. 번갈아 실행하는 것이 워낙 빠르다보니 병렬성으로 보일 뿐입니다.
스레드의 개수가 코어의 수보다 많을 경우, 스레드를 어떤 순서에 의해 동시성으로 실행할 것인가를 결정해야 하는데 이것을 스레드 스케줄링이라고 합니다. 스레드 스케줄링에 의해 스레드들은 아주 짧은 시간에 번갈아가며 그들의 run() 메소드를 조금씩 실행합니다.
자바의 스레드 스케줄링은 우선순위(Priority) 방식과 순환 할당(Round-Robin) 방식을 사용합니다. 우선순위 방식은 우선순위가 높은 스레드가 실행 상태를 더 많이 가지도록 스케줄링하는 것을 말합니다. 순한 할당 방식은 시간 할당량을 정해서 하나의 스레드를 정해진 시간만큼 실행하고 다시 다른 스레드를 실행하는 방식을 말합니다.
스레드 우선순위 방식은 스레드 객체에 우선순위 번호를 부여할 수 있기 때문에 개발자가 코드로 제어할 수 있습니다. 하지만 순환 할당 방식은 자바 가상 머신에 의해서 정해지기 때문에 코드로 제어할 수 없습니다.
우선순위 방식에서 우선순위는 1에서부터 10까지 부여되는데 1이 가장 우선순위가 낮고, 10이 가장 높습니다. 우선순위를 부여하지 않으면 모든 스레드들은 기본적으로 5의 우선순위를 할당받습니다. 우선순위를 변경하고 싶다면 Thread 클래스가 제공하는 setPriority() 메소드를 이용하면 됩니다.
thread.setPriority();
우선순위의 매개값으로 1 ~ 10까지의 값을 직접 주어도 되지만, 코드의 가독성을 높이기 위해 Thread 클래스의 상수를 사용할 수도 있습니다.
thread.setPriority(Thread.MAX_PRIORITY);
thread.setPriority(Thread.NORM_PRIORITY);
thread.setPriority(Thread.MIN_PRIORITY);
MAX_PRIORITY는 10, NORM_PRIORITY은 5, MIN_PRIORITY은 각각 1의 값을 가지고 있습니다. 다른 스레드에 비해 실행 기회를 더 많이 가지려면 MAX_PRIORITY로 우선순위를 높게 설정하면 됩니다. 동일한 계산 작업을 하는 스레드들이 있고, 싱글 코어에서 동시성으로 실행할 경우, 우선 순위가 높은 스레드가 실행 기회를 더 많이 가지기 때문에 우선순위가 낮은 스레드보다 계산 작업을 빨리 끝냅니다. 쿼드 코어일 경우 4개의 스레드가 병렬성으로 실행될 수 있기 때문에 4개 이하의 스레드를 실행할 경우에는 우선순위 방식이 크게 영향을 미치지 못합니다. 최소한 5개 이상의 스레드가 실행되어야 우선순위의 영향을 받습니다. 아래 코드는 10개의 스레드를 생성하고 20억 번의 루핑을 누가 더 빨리 끝내는가를 테스트하는 예제입니다. Thread 1 ~ 9는 우선순위를 가장 낮게 주었고, Thread10은 우선순위를 가장 높게 주었다. 결과는 Thread10의 계산 작업이 가장 빨리 끝난다.
public class CalcThread extends Thread {
public CalcThread(String name) {
setName(name);
}
@Override
public void run() {
for (int i = 0; i < 2000000000; i++) {
}
System.out.println(getName());
}
}
public class PriorityExample {
public static void main(String[] args) {
for (int i = 1; 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();
}
}
}
실행결과
사실 몇번을 돌려봤지만 스레드10을 제외한 나머지 스레드 1~9에 우선순위를 1로 주어도 무조건 스레드10이 계산을 가장 먼저 끝나지 않습니다. 아무래도 위에서 언급한것 처럼 현재 저의 PC가 옥타코어이고 스레드 생성 수를 10개로 해서 그런지 스레드 각각의 실행속도가 너무 빨라서 우선순위가 작업의 속도에는 영향을 미치는지는 정확하게 알수가 없는것 같습니다.... 스레드 생성 수를 5개로 제한하고 다시 돌려보니 그래도 1 ~ 2번째로 작업이 빨리 끝나는거 같습니다.
동기화 메소드와 동기화 블록
공유 객체를 사용할 때의 주의할 점
싱글 스레드 프로그램에서는 한 개의 스레드가 객체를 독차지해서 사용하면 되지만, 멀티 스레드 프로그램에서는 스레드들이 객체를 공유해서 작업해야 하는 경우가 있습니다. 이 경우 스레드 A를 사용하던 객체가 스레드 B에 의해 상태가 변경될 수 있기 때문에 스레드 A가 의도했던 것과는 다른 결과를 산출할 수도 있습니다. 이는 마치 여러사람이 계산기를 함께 나눠 쓰는 상황과 같아서 사람 A가 계산기로 작업을 하다가 계산 결과를 메모리에 저장한 뒤 잠시 자리를 비웠을 때 사람 B가 계산기를 만져서 앞 사람이 메모리에 저장한 값을 다른 값으로 변경하는 것과 같습니다. 그런 다음 사람 A가 들어와 계산기에 저장된 값을 이용해서 작업을 진행한다면 결국 사람 A는 엉터리 값을 이용하게 됩니다.
public class MainThreadExample {
public static void main(String[] args) {
Calculator calculator = new Calculator();
// User1 스레드 생성
User1 user1 = new User1();
// 공유 객체 설정
user1.setCalculator(calculator);
user1.start();
// User2 스레드 생성
User2 user2 = new User2();
// 공유 객체 설정
user2.setCalculator(calculator);
user2.start();
}
}
// 공유 객체
public class Calculator {
private int memory;
public int getMemory() {
return memory;
}
public void setMemory(int memory) {
this.memory = memory;
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
}
System.out.println(Thread.currentThread().getName() + ": " + this.memory);
}
}
// User1 스레드
public class User1 extends Thread {
private Calculator calculator;
public void setCalculator(Calculator calculator){
setName("User1");
this.calculator = calculator;
}
@Override
public void run() {
calculator.setMemory(100);
}
}
// User2 스레드
public class User2 extends Thread{
private Calculator calculator;
public void setCalculator(Calculator calculator) {
setName("User2");
this.calculator = calculator;
}
@Override
public void run() {
calculator.setMemory(50);
}
}
동기화 메소드 및 동기화 블록
스레드가 사용중인 객체를 다른 스레드가 변경할 수 없도록 하려면 스레드 작업이 끝날 때까지 객체에 잠금을 걸어서 다른 스레드가 사용할 수 없도록 해야합니다. 멀티 스레드 프로그램에서 단 하나의 스레드만 실행할 수 있는 영역을 임계영역이라고 합니다. 자바는 임계영역을 지정하기 위해 동기화(synchronized) 메소드와 동기화 블록을 제공합니다. 스레드가 객체 내부의 동기화 메소드 또는 블록에 들어가면 즉시 객체에 잠금을 걸어 다른 스레드가 임계 영역 코드를 실행하지 못하도록 합니다. 동기화 메소드를 만드는 방법은 아래와 같이 메소드 선언에 synchronized 키워드를 붙이면 됩니다. synchronized 키워드는 인스턴스와 정적 메소드 어디든 붙일 수 있습니다.
public synchronized void method(){
임계 영역; // 단 하나의 스레드만 실행
}
동기화 메소드는 메소드 전체 내용이 임계 영역이므로 스레드가 동기화 메소드를 실행하는 즉시 객체에는 잠금이 일어납니다. 스레드가 동기화 메소드를 실행 종료하면 잠금이 풀립니다. 메소드 전체 내용이 아니라, 일부 내용만 임계 영역으로 만들고 싶다면 다음과 같이 동기화 블록을 만들면 됩니다.
public void method(){
//여러 스레드가 실행 가능 영역
...
synchronized(공유 객체){
임계 영역 // 단 하나의 스레드만 실행
}
// 여러 스레드가 실행 가능 영역
...
}
동기화 블록의 외부 코드들은 여러 스레드가 동시에 실행할 수 있지만, 동기화 블록의 내부 코드는 임계 영역이므로 한 번에 한 스레드만 실행할 수 있고 다른 스레드는 실행할 수 없습니다. 만약 메소드 동기화 블록이 여러개 있을 경우, 스레드가 이들 중 하나를 실행할 때 다른 스레드는 해당 메소드는 물론이고 다른 동기화 메소드 및 블록도 실행할 수 없습니다.하지만 일반 메소드는 실행이 가능합니다.
이제 예제 코드를 수정해 보겠습니다. 문제가 된 공유 객체 Calculator를 수정한 것입니다. Calculator의 setMemory() 메소드를 동기화 메소드로 만들어서 User1 스레드가 setMemory()를 실행할 동안 User2 스레드가 setMemory() 메소드를 실행할 수 없도록 했습니다.
// 동기화 메소드로 수정된 공유 객체
public class Calculator {
private int memory;
public int getMemory() {
return 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이라는 출력값을 얻습니다.
아래처럼 동기화 블록으로도 만들 수 있습니다.
public void setMemory(int memory) {
synchronized(this){
this.memory = memory;
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
}
System.out.println(Thread.currentThread().getName() + ": " + this.memory);
}
}
스레드가 동기화 블록으로 들어가면 this(Calculator 객체)를 잠그고, 동기화 블록을 실행합니다. 동기화 블록을 모두 실행할 때까지 다른 스레드들은 this(Calculator 객체)의 모든 동기화 메소드 또는 동기화 블록을 실행할 수 없게 됩니다.