[JAVA] Thread란?

업데이트:



✅ Thread란 ?

프로그램을 실행하면 OS로 부터 실행에 필요한 자원(메모리)을 할당받아 프로세스가 된다. 프로세스는 프로그램을 수행하는데 필요한 데이터와 메모리 등의 자원 그리고 쓰레드로 구성되어 있으며 프로세스의 자원을 이용해 실제로 작업을 수행하는 것이 바로 쓰레드이다. 모든 프로세스에는 최소한 하나 이상의 쓰레드가 존재하고, 두 개 이상의 쓰레드를 가지는 프로세스를 멀티쓰레드 프로세스(multi-thread process) 라고한다.



✅ Thread 구현과 실행

자바에서 쓰레드를 구현하는 방법은 두가지가 있다.

  1. Runnable 인터페이스를 구현하는 방법
  2. Thread 클래스를 상속받는 방법

어느쪽을 선택해도 차이는 없지만 Thread를 상속받으면 다른 클래스를 상속받을 수 없기때문에, Runnable 인터페이스를 구현하는 방법이 일반적이다.

1. Thread 클래스 상속

class MyThread extends Thread {
	public void run() {
		/* 작업 내용 */
	}
}
2. Runnable인터페이스 구현

class Mythread implements Runnable {
	public void run() {
		/* 작업 내용 */
	}
}

위와같은 형태로 Thread를 구현할 수 있다. 어떤 방식을 사용하든 run() 메소드의 몸통부분을 구현함으로써 쓰레드를 만들면 된다.





📌 Thread 구현과 실행 예제

public class Ex10_ThreadEx1 {  
	public static void main(String[] args) {  
		Ex1 ex1 = new Ex1();
		
		Runnable r = new Ex2();
		Thread t2 = new Thread(r);

		ex1.start();
		ex2.start();  
	}  
}  
  
class Ex1 extends Thread{  
	public void run(){  
		for(int i=0;i<5;i++) {
			System.out.println(getName());
		}
	}  
}

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

Thread를 상속받은 경우에는 Thread의 자손 클래스의 인스턴스를 생성해주기만 하면 구현할 수 있다.

Runnable 인터페이스를 구현한 경우에는 Runnable을 구현한 클래스의 인스턴스를 생성한 다음 이 인스턴스를 Thread클래스 생성자의 매개변수로 제공해야 한다.



📌 Thread의 실행 - start( )

쓰레드를 생성했다고 해서 쓰레드가 실행되는 것은 아니다. start()를 호출해야만 쓰레드가 실행된다.

Thread t1 = new Thread();
Thread t2 = new Thread();

t1.start();
t2.start();

하지만 start()를 호출했다고 해서 바로 실행되는 것이 아니라, 실행대기 상태인 waiting status를 가지고 있다가 자신의 차례가 되면 실행된다. 이러한 쓰레드의 실행 순서는 OS의 스케쥴러에 의해 결정되는데 OS마다 스케쥴러 정책이 다르고, 쓰레드의 상태마다 또 정책이 다르기 때문에 JAVA 개발단계에서 스케쥴러에 대한 처리는 할 수 없고 OS에 맡겨야만 한다.

한가지 더 알아둬야 할 것은 실행이 한번 종료된 쓰레드는 다시 start()를 통해서 실행 할 수 없다. 즉 하나의 쓰레드에 대해 start()가 한번만 호출 될 수 있다. 때문에 쓰레드의 작업을 한번더 수행하기 위해서는 인스턴스를 다시 생성하고 start()를 사용해야만 한다. 만일 같은 인스턴스에 대해 두번의 start() 를 실행하면 IllegalThreadStateException 이 발생한다.

IllegalThreadStateException 이 발생하는 이유에 대해 살짝 알아보면, start() 메서드를 호출하게 되면 메서드의 실행 상태를 나타내는 내부 플래그가 ‘true’로 설정되어 해당 스레드를 다시 시작할 수 없다는것을 나타내게 된다. 그래서 같은 인스턴스에 대해 다시 start() 메소드를 실행하면 이미 내부 플래그가 true로 설정되어 있기 때문에 오류가 발생하게 되는 것이다.

그러면 yield() 메서드를 실행한다음 다시 start메서드를 실행하면 괜찮지 않을까 해서 관련 자료를 찾아보니 yield() 를 호출하더라도 쓰레드의 실행상태를 초기화하는것이 아닌 쓰레드의 실행을 잠시 중단하고 다른 쓰레드에게 스케줄링 순서를 양보하는 것이기 때문에 여전히 IllegalThreadStateException이 발생한다고 한다.



📌 Thread의 실행 - run( )

이제는 쓰레드를 실행하기 위해 start()메서드를 사용해야 한다는것을 알고있다. 그런데 왜 run() 메서드를 직접 실행하지 않고 start()를 통해 쓰레드를 실행하는 걸까?

main메서드에서 run()을 호출하는 것은 쓰레드를 실행시키는 것이 아니라 단순히 메서드를 호출하는 것일 뿐이다.

반면에 start()는 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택(call stack)을 생성한 다음 run()을 호출해서 생성된 호출 스택에 run()메서드가 첫번째로 올라가게 한다.

모든 쓰레드는 독립적인 작업을 수행하기 위해 자신만의 호출스택을 필요로 하는데, start() 를 통해 작업을 수행할때마다 새로운 호출스택을 생성하고 쓰레드가 종료되면 생성된 호출스택을 소멸되게 하여 쓰레드의 실행이 가능하게끔 한다.


  1. main() 메서드에서 쓰레드의 start()를 호출한다.
  2. start()는 쓰레드와 호출스택을 생성한다.
  3. 생성된 호출스택에 run() 메서드가 호출되어 작업을 수행한다.
  4. main()run() 두개의 쓰레드는 OS의 스케줄링에 의해 번갈아 실행된다.

참고로 우리가 항상 사용하는 main() 메소드도 하나의 쓰레드이다!





✅ 싱글쓰레드와 멀티쓰레드

싱글쓰레드는 한번에 한가지 작업만 수행할 수 있지만 쓰레드를 여러개 사용하는 멀티쓰레드는 한번에 여러가지 작업을 수행할 수 있다. 우리가 보기에 멀티쓰레드를 사용하면 동시에 여러작업을 수행하는 것처럼 보인다(실제로는 동시아님). 이러한 멀티쓰레드는 많은 장점이 존재한다.

  • CPU의 사용률 향상
  • 효율적인 자원의 사용
  • 사용자에 대한 응답성 향상
  • 코드 간결

우리가 메신저로 채팅을 하면서 파일을 다운로드 받거나 음성대화를 나눌 수 있는 것도 대표적인 멀티쓰레드를 활용한 예이다. (그래서 멀티쓰레드를 처음배우면 꼭 채팅프로그램 만들어보는 이유이다)

그러면 무조건 멀티쓰레드를 사용해서 여러작업을 동시에 수행하는게(실제로는 동시아님) 더 효율적이지 않을까 생각할 수 있지만, 뭐든 득이 있으면 실이 있는법! 항상 그렇지만은 않다. 멀티쓰레드, 즉 여러개의 쓰레드를 사용하면 같은 프로세스 내에서 자원을 공유하며 작업을 하기 때문에 동기화(Synchronized), 교착상태(Deadlock) 같은 문제들을 고려하여 프로그래밍 해야한다. 이부분은 뒤에 좀더 자세하게 설명하겠지만 아무튼 무조건적으로 멀티쓰레드가 효율적이다 라는건 절대 아니다!



📌 싱글쓰레드와 멀티쓰레드 비교 ( 싱글코어 )

위 그림은 싱글코어 환경에서 싱글 쓰레드와 멀티쓰레드의 작업을 비교한 그림이다. 첫번째 그림은 싱글쓰레드에서 두개의 작업을 처리하는 경우이다. A작업을 마친후에 B작업을 수행하는것을 볼 수 있다. 두번째 그림은 멀티쓰레드(2개)로 두개의 작업을 처리하는 경우이다. 두개의 쓰레드로 작업 하는 경우에는 짧은 시간동안 th1, th2 두개의 쓰레드가 번갈아가며 작업하고 있기때문에 두 작업이 동시에 처리되는 것처럼 느끼게 한다.

그런데 위 그림을 보면 싱글쓰레드나 멀티쓰레드나 두 작업이 처리되는 시간은 거의 동일하다. 오히려 멀티쓰레드로 처리한 작업시간이 더 오래걸리게 된다. 그 이유는 멀티쓰레드 작업시 쓰레드 간 작업 전환(Context Switching)에 걸리는 시간이 멀티쓰레드로 작업함으로써 단축되는 시간보다 더 크기 때문이다.

이러한 이유로 멀티쓰레드 작업이 항상 더 좋은것은 아님을 설명할 수 있다. 싱글코어 환경에서 단순히 CPU만을 사용하는 계산작업 이라면 오히려 멀티쓰레드보다 싱글쓰레드 환경이 더 효율적일 수 있다.



📌 멀티쓰레드 예제

싱글쓰레드는 우리가 main()에서 맨날 실행하는 코드가 전부 싱글쓰레드 이므로 예제는 생략하고 멀티쓰레드의 예제만 살펴보도록 하겠다.

public class Ex12_MultiThread {  
  
	static long startTime = 0;  
  
	public static void main(String[] args) {  
		Thread1 th1 = new Thread1();  
		th1.start();  
		startTime = System.currentTimeMillis();  
  
		for(int i=0;i<300;i++) System.out.printf("%s", new String("-"));  
  
		System.out.println("소요시간 1 : "+(System.currentTimeMillis()-Ex12_MultiThread.startTime));  
	}  
}  
  
class Thread1 extends Thread {  
	public void run(){  
		for(int i=0;i<300;i++){  
			System.out.printf("%s", new String("|"));  
		}  
		System.out.print("소요시간2 : " + (System.currentTimeMillis() - Ex12_MultiThread.startTime));  
	}  
}
[결과]
-----------------------------------------------
-----------------------------------------------
-----------------------||||||||||||||||||||||||
|||||||||||||||||||||||||||||||||||||||||||||||
|||||||||||||||||||||||||||||||||||||||||||||||
|||||||||||||||||||||||||||||||||||||||||||||||
|||||||||||||||||||||||||||||||||||||||||||||||
||||||||||||||||||||||||||---------------------
-----------------------------------------------
-----------------------------------------------
---||||||||||||||||||||||||||||||||||||--------
-------------------||||||||||||||||||||||||||소요
시간2 : 35---------------------------------소요시
 1 : 36

위와 같은 예제에서 싱글쓰레드인 경우를 보이진 않았지만 실제로 비교를 해보면 싱글쓰레드로 실행한 작업시간이 더 짧다. 앞서 언급했다시피 멀티쓰레드를 사용할 때 쓰레드 간의 작업전환시간 + 화면에 출력하기위한 대기시간이 소요되기 때문에 멀티쓰레드가 더 오래걸리는 것이다.





✅ 쓰레드의 우선순위

쓰레드는 우선순위(Priority)라는 속성을 가지고 있는데, 이 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라진다. 쓰레드가 수행하는 작업의 중요도에 따라 쓰레드의 우선순위를 달리하여 특정 쓰레드가 더 많은 작업시간을 갖게하는 것이다.

예를들어 파일전송기능이 있는 메신저의 경우, 파일다운로드를 처리하는 쓰레드보다 메세지 전송을 처리하는 쓰레드의 우선순위를 더 높게하여 메세지를 보내면서도 파일 다운로드를 할 수 있게끔 만들어야 한다.

void setPriority(int newPriority)  // 쓰레드의 우선순위 지정
void getPriority()                 // 쓰레드의 우선순위 반환

위와 같은 코드로 우선순위를 지정할 수 있고 1~10범위를 가지며 숫자가 높을수록 높은 우선순위를 의미한다. 또한 main() 메소드를 수행하는 쓰레드는 우선순위가 5로 자동 지정된다.

위의 그림은 WINDOWS의 싱글코어 환경에서 쓰레드의 우선순위가 같은 경우와 A가 더 높은 경우 두가지 실행시간에 대한 그림이다. 우선순위가 같은 경우 A,B 두개의 쓰레드가 거의 동일한 실행시간을 가지지만, A의 우선수위가 높은 경우에는 A에 더 많은 실행시간이 먼저 부여된것을 볼 수 있다. 때문에 A가 먼저 작업이 끝나고 B가 이후에 끝나는 모습이 보인다.

그러나! 똑같은 실험을 멀티코어 환경에서 진행해보면 실행결과는 왼쪽 그림과 똑같다. 우선순위를 다르게하여 여러번 실험을 해봐도 결과는 똑같다. 멀티코어 환경에서는 두 쓰레드의 우선순위를 달리하여도 실행시간에 대한 차이가 거의 아예 없다. 한마디로 “멀티코어 환경에서는 쓰레드에 높은 우선순위를 준다 할지라도 더 많은 실행시간과 실행기회를 갖지 못할 수 있다” 라는 뜻이다.



📌 쓰레드 우선순위는 사실 실행 시간을 보장하지 않는다..!

사실 대부분의 JAVA 기본책이나 블로그 글을 보면 위와 같은 내용이 대부분이고 쓰레드의 우선순위 부여가 실행시간을 보장해주진 않는다 정도로 마무리 되는데 좀 구체적인 이유가 궁금해서 찾아봤다.

자바에서 쓰레드에 우선순위를 부여하면 해당 쓰레드가 실행되는 우선순위는 높아지지만, 보장된 실행시간을 의미하지는 않는다. 쓰레드의 우선순위는 단지 스케줄러에게 어떤 쓰레드를 우선적으로 실행시킬지 알려주는 힌트일 뿐이다.

실제로 OS와 하드웨어에 따라 쓰레드 스케줄링 정책이 달라지고, 우선순위에만 의존해서 쓰레드 실행시간을 보장할 수는 없다. 예를들어 OS에서 Round Robin 스케줄링을 사용하면, 모든 쓰레드에 동등한 실행 시간을 할당하게 되어 우선순위가 높은 쓰레드가 먼저 실행되는 것을 보장할 수 없다.

또한 다른 우선순위를 가진 쓰레드가 경쟁 상태(race condition)에 놓일 경우에도 우선순위가 높은 쓰레드가 우선적으로 실행되지 않을 수도 있다.

이처럼 쓰레드 우선순위는 쓰레드 스케줄링에 영향을 줄 수는 있지만, 실행시간을 보장해주지는 않는다. 쓰레드 간의 상호작용과 공유 자원 접근을 관리하고, 실행시간을 조절하기 위해서는 동기화 메커니즘과 다른 쓰레드 제어 기법을 사용해야 한다.





✅ 데몬 쓰레드(daemon thread)

데몬쓰레드는 다른 일반쓰레드의 작업을 돕는 보조적인 역할을 수행하는 쓰레드이다. 데몬쓰레드는 일반쓰레드의 보조 역할이기 때문에 일반쓰레드가 종료되면 자동으로 데몬쓰레드 역시 종료된다. 무한루프와 조건문을 이용해서 실행 후 대기하다가 특정 조건이 만족되면 작업을 수행하고 다시 대기하도록 작성한다.

boolean isDaemon()	// 데몬쓰레드인지 확인
void setDaemon(boolean on)	// 쓰레드를 데몬쓰레드로 변경

setDaemon(boolean on)은 반드시 start()를 호출하기 전에 실행되어야 한다. 그렇지 않으면 IllegalThreadStateException이 발생한다.

댓글남기기