Thread
thread는 하나의 실행 단위(execution unit)다.
자바는 객체지향언어이므로 자바의 thread는 객체로 구현된다.
자바의 강력한 기능 중 하나로 멀티스레딩(multithreading) 기능이 있다.
Multithreading
프로그램 내부 다수의 작업을 동시에 실행시키는 기능.
자바에서 동시에 수행될 수 있는 각 task는 Runnable interface의 인스턴스다.
즉, thrad는 본질적으로 작업 수행을 용이하게 하는 객체다.
// Custom task class
// 반드시 Runnable 인스턴스를 구현해야 한다.
public class TaskClass implements Runnable {
...
// 생성자
public TaskClass(...) {
...
}
// Runnable 내부의 run method 구현
public void run() {
// 시스템에게 이 스레드를 어떻게 실행시켜야하는지 알려줌
...
}
...
}
public class Client {
...
public void someMethod() {
...
// TaskClass 생성
TaskClass task = new TaskClass(...);
// thread 생성
Thread thread = new Thread(task);
// thread 시작
thread.start()
...
}
...
}
Runnable 인터페이스는 오직 run method만을 가지고 있다.
우리는 이 run method에 thread가 어떻게 실행되어야 할지에 대한 정보를 구현해야 한다.
JVM은 task의 run() 메소드를 호출함으로써 task를 실행한다.
다음 예제 코드를 살펴보자.
public class Main {
public static void main(String[] args) {
// Create tasks
Runnable printA = new PrintChar('a', 100);
Runnable printB = new PrintChar('b', 100);
Runnable print100 = new PrintNum(100);
// Create threads
Thread thread1 = new Thread(printA);
Thread thread2 = new Thread(printB);
Thread thread3 = new Thread(print100);
thread1.start();
thread2.start();
thread3.start();
}
}
class PrintChar implements Runnable {
private char charToPrint;
private int times;
public PrintChar(char c, int t) {
charToPrint = c;
times = t;
}
@Override
public void run() {
for (int i=0; i<times; i++) System.out.print(charToPrint);
}
}
class PrintNum implements Runnable {
private int numToPrint;
public PrintNum(int n) {
numToPrint = n;
}
@Override
public void run() {
for (int i=1; i<=numToPrint; i++) System.out.print(i);
}
}
thread1->thread2->thread3 순서로 실행될 것이라 생각하겠지만(나는 그랬다..) 놀랍게도 실행 순서는 무작위다.
bbbbbbb123aaaaaaaaaaaaaaaaaaaaaaa456789101112131415161718bb1920212223242526272829303132333435363738394041424344aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa45464748495051bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb525354555657aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa5859606162636465666768697071bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb72737475767778798081828384858687888990919293949596979899100
왜 무작위로 실행하나요?
CPU 실행 시간은 OS의 scheduler가 하드웨어 요인까지 복합적으로 고려하여 분배한다.
즉, JVM이 실행 순서를 보장하지 않고, 예측할 수 없다.
Thread Class
+--------------------+
| <<interface>> |
| java.lang.Runnable |
+--------------------+
^
|
+----------------------------+
| java.lang.Thread |
+----------------------------+
| +Thread() |
| +Thread(task: Runnable) |
| +start(): void |
| +isAlive(): boolean |
| +setPriority(p: int): void |
| +join(): void |
| +sleep(millis: long): void |
| +yield(): void |
| +interrupt(): void |
+----------------------------+
Thread 클래스는 Runnable을 구현하기 때문에 thread를 상속해 run 메소드를 구현하는 또 다른 클래스를 정의할 수도 있다. 그리고 그 thread 클래스의 start 메소드를 호출하여 thread를 시작시킬 수 있다.
커스텀 스레드 예제
public class CustomThread extends Thread {
...
public CustomThread(...) {
...
}
public void run() {
...
}
}
public class Client {
...
public void someMethod() {
...
CustomThread thread1 = new CustomThread(...);
thread1.start();
...
CustomThread thread2 = new CustomThread(...);
thread2.start();
}
...
}
yield()
yield() 메소드를 사용하면 실행 시간 중 남은 시간을 다른 thread에 양보(yield)한다.
public void run() {
for (int i=1; i<=numToPrint; i++) {
System.out.print(i);
Thread.yield();
// print100이 출력을 수행할 때 마다
// 다른 thread를 위해 작업을 멈춘다.
}
}
단, yield 메소드는 os에 따라 무시될 수도 있다.
따라서 실행 순서를 보장하지 않기 때문에 yield()를 의도적인 동기화 용도로 사용하는 것은 권장되지 않는다.
sleep()
sleep() 메소드는 thread를 특정 시간 동안 잠들게 해 다른 thread가 실행될 수 있도록 허용한다.
public void run() {
try {
for (int i=1; i<=numToPrint; i++) {
System.out.print(i);
// i가 50 이상이 되면 print100
// thread가 잠시(1ms) 멈추고 다른 thread를 실행시킨다.
if (i>=50) Thread.sleep(1);
}
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
Thread와 try-catch
권장
public void run() {
try {
while (...) {
...
Thread.sleep(1000);
}
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
sleep 도중 interrupt() 호출 시 즉시 InterruptException이 발생하면서 루프 탈출.
주의 필요
public void run() {
while (...) {
try {
...
Thread.sleep(1000);
}
catch (InterruptedException ex) {
ex.printStackTrace();
}
}
}
interrupt가 발생하더라도 catch 블록 실행 후 다시 반복.
외부의 종료 요청이 무시됨.
특수 목적 thread(예: daemon)에서 사용.
join()
join() 메소드를 사용하면 다른 thread가 종료되는 것을 강제로 기다리게 할 수 있다.
public void run() {
Thread thread4 = new Thread(new PrintChar('c', 40));
thread4.start();
try {
for (int i=1; i<=numToPrint; i++) {
System.out.print(i);
if (i==50) thread4.join();
}
} catch (InterruptedException ex) {
ex.printStackTrace();
}
}
print100 ------> thread4.join +--------->
|
thread4 ------------------------> thread4 finish
Round-Robing scheduling
OS는 항상 지금 실행 가능한 thread를 높은 우선순위로 두고 고른다. 낮은 우선순위를 가진 thread들은 더 높은 우선순위의 thread가 실행 중이지 않을 때만 실행될 수 있다.
만약 모든 runnable thread가 같은 우선순위를 가진다면, 각 thread들은 순환 queue 안에서 동일한 비율의 CPU 실행시간을 할당 받는다. 이를 Round-Robing scheduling이라고 한다.
thread3.setPriority(Thread.MAX_PRIORITY);
위와 같은 코드를 추가하면 thread3(print100)은 항상 처음으로 종료된다.
이런 우선순위값들은 Java의 버전이 업데이트되면서 바뀔 수 있으므로 Thread class의 상수를 사용하는 것을 권장한다.
만약 항상 더 높은 우선순위를 가진 thread가 실행 중이거나 같은 우선순위를 가진 thread가 yield를 절대 하지 않을 경우 실행될 기회를 얻지 못하는 thread가 발생할 수 있다. 이런 현상을 contention(여러 thread가 공유 리소스를 두고 경쟁) 또는 starvation(특정 스레드가 영원히 실행 기회를 얻지 못함)이라고 한다.
contention을 피하기 위해 높은 우선순위를 가진 thread는 반드시 주기적으로 sleep 또는 yield를 호출하여 본인보다 낮거나 같은 우선순위를 가진 thread가 실행될 수 있도록 해야 한다.
생각해둘 점
run()과 start()의 차이
thread.start();
새로운 콜 스택(Call stack)을 생성하고 OS scheduler에 등록하여 병렬 실행한다.
task.run();
일반적인 메소드 호출로 새로운 thread가 생기지 않는다.
즉, single thread처럼 작동한다.
Thread 상속과 Runnable 구현의 차이 + Runnable 구현을 권장하는 이유
public class CustomThread extends Thread {
...
public CustomThread(...) {
...
}
public void run() {
...
}
...
}
이렇게 해도 thread를 만들 수는 있으나, Java는 다중 상속을 지원하지 않기 때문에 더 이상 CustomThread는 다른 클래스를 상속받을 수 없다.
따라서 아래와 같은 방식을 권장한다.
public class TaskClass implements Runnable {
...
public TaskClass(...) {
...
}
public void run() {
...
}
...
}
Thread Life-Cycle
New ------> Runnable ----> Running
^ |
| v
Terminated <----+------ Blocked/Waiting
<참고자료>
탁성우, "플랫폼기반프로그래밍", 부산대학교