영권's
10주차 과제: 멀티쓰레드 프로그래밍 본문
목표
자바의 멀티쓰레드 프로그래밍에 대해 학습하세요.
학습할 것 (필수)
- Thread 클래스와 Runnable 인터페이스
- 쓰레드의 상태
- 쓰레드의 우선순위
- Main 쓰레드
- 동기화
- 데드락
프로세스와 쓰레드
프로세스란 간단히 말해서 '실행중인 프로그램'이다. 프로그램을 실행하면 OS로부터 실행에 필요한 자원(메모리)을 할당받아 프로세스가 된다.
프로세스는 프로그램을 수행하는 데 필요한 데이터와 메모리등의 자원 그리고 스레드로 구성 되어 있으며 프로세스의 자원을 이용해서 실제로 작업을 수행하는 것이 바로 쓰레드이다.
그래서 모든 프로세스는 최소 하나 이상의 쓰레드가 존재하며, 둘 이상의 쓰레드를 가진 프로세스를 '멀티쓰레드 프로세스'라고 한다.
하나의 프로세스가 가질 수 있는 쓰레드의 개구는 제한되어 있지 않으나 쓰레드가 작업을 수행하는데 개별적인 메모리 공간(호출스택)을 필요로 하기 때문에 프로세스의 메모리 한계에 따라 생성할 수 있는 쓰레드의 수가 결정된다.
멀티태스킹과 멀티쓰레딩
멀티쓰레딩은 하나의 프로세스 내에서 여러 쓰레드가 동시에 작업을 수행하는 것이다. CPU의 코어가 한번에 하나의 작업만 수행할 수 있으므로, 실제로 동시에 처리되는 작업의 개수는 코어의 개수와 일치한다.
그러나 쓰레드의 수는 코어의 수보다 많을 수 있고 각 코어가 아주 짧은 시간 동안 여러 작업을 번갈아 가며 수행하므로써 여러 작업들이 모두 동시에 수행되는 것처럼 보이게 한다.
그래서 프로세스의 성능이 단순히 쓰레드의 개수에 비례하는 것은 아니며 하나의 쓰레드를 가진 프로세스보다 두 개의 쓰레드를 가진 프로세스가 오히려 더 낮은 성능을 보일 수도 있다.
멀티쓰레딩의 장단점
멀티쓰레딩의 장점
- CPU의 사용률을 향상시킨다.
- 자원을 보다 효율적으로 사용할 수 있다.
- 사용자에 대한 응답성이 향상된다.
- 작업이 분리되어 코드가 간결해진다.
멀티쓰레딩의 단점
멀티쓰레딩은 여러 쓰레드가 같은 프로세스 내에서 자원을 공유하면서 작업을 하기 때문에 발생할 수 있는 동기화(synchronization), 교착상태(deadlock)와 같은 문제들을 고려해서 신중히 프로그래밍 해야한다.
Thread 클래스와 Runnable 인터페이스
Runnable 인터페이스
Runnable 인터페이스는 함수형 인터페이스(@FuntionalInterface)로 run() 추상메소드 하나만이 존재합니다.
구현하는 클래스에서 run() 메소드를 구현하는걸로 쓰레드에게 작업할 내용을 설정할 수 있습니다.
아래는 Java 8 버전의 Runnable 인터페이스 입니다.
함수형 인터페이스로 run() 추상메소드 하나만 가지고 있는것을 확인할 수 있습니다.
Thread 클래스
필드
실제 Thread 클래스안에는 많은 필드가 존재하지만 public 접근 제어자인 필드는 단 3개만 존재합니다.
모두 쓰레드의 우선 순위에 대한 상수 필드인데 3가지는 다음과 같습니다.
- public final static int MIN_PRIORITY = 1
쓰레드가 가질 수 있는 우선 순위의 최소값입니다. - public final static int NORM_PRIORITY = 5
쓰레드가 가지는 기본 우선 순위 값입니다. - public final static int MAX_PRIORITY = 10
쓰레드가 가질 수 있는 우선 순위의 최대값입니다.
생성자
생성자를 살펴보기 앞서 Thread 생성자에서 인자들이 가지는 의미를 먼저 정리하고 넘어가겠습니다.
- String gname : 쓰레드를 (이름을 지정하지 않고)생성할 때 자동으로 생성되는 이름입니다 자동으로 생성되는 이름은 "Thread-" + n 의 형식을 가집니다.(여기서 n은 정수입니다)
- String name : 쓰레드 생성자에 인자로 주는 새로운 쓰레드의 이름을 의미합니다.
- Runnable target : target은 쓰레드가 시작될 때 run() 메소드가 호출될 객체입니다.
- ThreadGroup group : group은 생성할 쓰레드를 설정할 쓰레드 그룹입니다. group 값이 null 이면서 보안 관리자(security manager)가 존재한다면 그룹은SecurityManager.getThreadGroup() 에 의해서 결정됩니다. 보안 관리자가 없거나, SecurityManager.getThreadGroup() 이 null을 반환한다면 현재 쓰레드의 그룹으로 설정됩니다.
- long stackSize : 새로운 쓰레드의 스택 사이즈를 의미합니다. 0이면 이 인자는 없는것과 같습니다.
- stackSize는 가상 머신이 스레드의 스택에 할당 할 주소 공간의 대략적인 바이트 수입니다.
- public Thread()
기본 생성자로 Thread(null, null, gname)과 같습니다.
(group = null, target = null, gname) 을 의미 - public Thread(Runnable target)
이 생성자는 Thread(null, target, gname) 과 같습니다. - public Thread(ThreadGroup group, Runnable target)
이 생성자는 Thread(group, target, gname)과 같습니다. - public Thread(String name)
이 생성자는 Thread(null, null, name)과 같습니다. - public Thread(ThreadGroup group, String name)
이 생성자는 Thread(group, null, name)과 같습니다. - public Thread(Runnable target, String name)
이 생성자는 Thread(null, target, name)과 같습니다. - public Thread(ThreadGroup group, Runnable target, String name)
새로 생성 되는 쓰레드는 자신을 생성하는 쓰레드(현재 실행중인 쓰레드)와 같은 우선순위를 가집니다.
우선순위는 setPriority 메소드를 이용해서 변경 가능합니다.
새로 생성 되는 쓰레드는 자신을 생성하는 쓰레드가 데몬 쓰레드로 마크된 경우에만 데몬 쓰레드로 마크됩니다.
setDaemon 메소드를 사용해서 쓰레드가 데몬 쓰레드인지 여부를 변경할 수 있습니다. - public Thread(ThreadGroup group, Runnable target, String name, long stackSize)
stackSize인자로 받는것을 빼면 Thread(ThreadGroup group, Runnable target, String name) 생성자와 같습니다.
stackSize 값을 0으로 하면 Thread(ThreadGroup group, Runnable target, String name)와 똑같이 동작합니다.
이 생성자의 동작은 플랫폼에 따라 다르기 때문에 사용시 극도의 주의를 기울여야합니다.
주어진 계산을 수행하는 데 필요한 스택 크기는 JRE 구현마다 다를 수 있습니다.
이러한 변화를 고려하여 스택 크기 매개 변수를 신중하게 조정해야 할 수 있으며 응용 프로그램이 실행될 각 JRE구현에 대해 조정을 반복해야 할 수 있습니다.
쓰레드의 구현과 실행
쓰레드를 구현하는 방법은 Thread클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법, 두가지가 있다.
package me.study.week10;
public class MyThread implements Runnable {
@Override
public void run() {
System.out.println("Runnable 인터페이스 구현");
}
}
package me.study.week10;
public class MyThread extends Thread {
@Override
public void run() {
super.run();
System.out.println("Thread 상속");
}
}
Thread 클래스를 상속받은 경우와 Runnable 인터페이스를 구현한 경우의 인스턴스 생성 방법이 다르다.
Runnable 인터페이스를 구현한 경우, Runnable 인터페이스를 구현한 클래스의 인스턴스를 생성한 다음, 이 인스턴스를 Thread클래스의 생성자의 매개변수로 제공해야 한다.
package me.study.week10;
public class MyThread extends Thread {
public static void main(String[] args) {
MyThread myThread = new MyThread();
Runnable runnable = new RunnableImplement();
Thread thread = new Thread(runnable);
thread.start();
myThread.start();
}
@Override
public void run() {
super.run();
System.out.println("Thread 이름 : " + getName());
}
}
class RunnableImplement implements Runnable {
@Override
public void run() {
System.out.println("Runnable 구현 이름 : " + Thread.currentThread().getName());
}
}
Thread 클래스를 상속받으면 자손 클래스에서 조상인 Thread 클래스의 메서드를 직접 호출 할 수 있지만, Runnable을 구현하면 Thread 클래스의 static 메서드인 currentThread()를 호출하여 쓰레드에 대한 참조를 얻어 와야만 호출이 가능하다.
때문에 MyThread 에서의 run 메서드는 바로 getName()을 통해 쓰레드 이름을 호출하면 되지만 RunnableImplement 에서의 run 메서드 에서는 Thread 클래스의 getName()을 호출하려면, Thread.currentThread().getName() 와 같이 해야한다.
쓰레드의 실행 - start()
스레드를 생성했다고 해서 자동으로 실행되는 것은 아니다. start()를 호출해야만 쓰레드가 실행된다.
start()가 호출되었다고 해서 바로 실행되는 것이 아니라, 일단 실행대기 상태에 있다가 자신의 차례가 되어야 실행된다.
물론 실행대기중인 쓰레드가 하나도 없으면 곧바로 실행상태가 된다.
한가지 더 알아야 할것은 한 번 실행이 종료된 스레드는 다시 실행할 수 없다는 것이다.
즉, 하나의 쓰레드에 대해 start()가 한번만 실행될 수 있다는 뜻이다.
ThreadEx1_1 t1 = new ThreadEx1_1();
t1.start();
t1.start();
만약 위 코드와 같이 실행하면 IllegalThreadStateException이 발생한다.
start()와 run()
쓰레드를 실행 시킬 때 run()이 아닌 start()를 호출하는 것에 대해서 다소 의문이 들었을 것이다.
이제 start()와 run()의 차이와 쓰레드가 실행된느 과정에 대해서 자세히 살펴보면 main메서드에서 run()을 호출하는 것은 생성된 쓰레드를 실행시키는 것이 아니라 단순히 클래스에 선언된 메서드를 호출하는 것일 뿐이다.
반면 start()는 새로운 쓰레드가 작업을 실행하는데 필요한 호출 스택을 생성한 다음에 run()을 호출해서, 생성된 호출스택에 run()이 첫번째로 올라가게 한다.
모든 쓰레드는 독립적인 작업을 수행하기 위해 자신만의 호출스택을 필요로 하기 때문에, 새로운 쓰레드를 생성하고 실행시킬 때마다 새로운 호출스택이 생성되고 쓰레드가 종료되면 작업에 사용된 호출스택은 소멸된다.
호출스택에서는 가장 위에 있는 메서드가 현재 실행중인 메서드이고 나머지 메서드들은 대기상태에 있는것이다.
그러나 위의 그림에서와 같이 스레드가 둘 이상일때는 호출스택의 최상위에 있는 메서드라 할지라도 대기상태에 있을 수 있다.
스케줄러는 실행대기중인 쓰레드들의 우선순위를 고려하여 실행순서와 실행시간을 결정하고, 각 쓰레드들은 작성된 스케줄에 따라 자신의 순서가 되면 지정된 시간동안 작업을 수행한다.
이 때 주어진 시간동안 작업을 마치지 못한 쓰레드는 다시 자신의 차례가 돌아올때까지 대기상태로 있게 되며, 작업을 마친 쓰레드, 즉 run() 의 수행이 종료된 쓰레드는 호출 스택이 모두 비워지면서 이 쓰레드가 사용하던 호출스택은 사라진다.
main 쓰레드
메인 메서드의 작업을 수행하는 것도 쓰레드이며, 이를 main쓰레드 라고 한다.
프로그램이 실행되기 위해서는 작업을 수행하는 일꾼인 쓰레드가 최소한 하나는 필요하다고 하였는데 프로그램을 실행하면 기본적으로 하나의 쓰레드를 생성하고 그 쓰레드가 main 메서드를 호출해서 작업이 수행되도록 하는것이다.
지금까지는 main 메서드가 수행을 마치면 프로그램이 종료 되었으나, main 메서드가 수행을 마쳤다하더라도 다른 쓰레드가 아직 작업을 마치지 않은 상태라면 프로그램이 종료되지 않는다.
실행 중인 사용자 쓰레드가 하나도 없을때 프로그램은 종료된다.
package me.study.week10;
public class MyThread extends Thread {
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.start();
}
@Override
public void run() {
super.run();
throwException();
}
public void throwException(){
try {
throw new Exception();
} catch (Exception e) {
e.printStackTrace();
}
}
}
새로 생성한 쓰레드에서 예외를 발생시키고 printStackTrace()를 이용해서 호출스택을 출력했을때 호출스택의 첫 번째 메서드가 main메서드가 아닌 run()인것을 확인할 수 있다.
한 쓰레드가 예외가 발생해서 종료되어도 다른 쓰레드의 실행에는 영향을 미치지 않고 main쓰레드의 호출스택이 없는 이유는 main 쓰레드가 종료 되었기 때문이다.
package me.study.week10;
public class MyThread extends Thread {
public static void main(String[] args) {
MyThread myThread = new MyThread();
myThread.run();
}
@Override
public void run() {
super.run();
throwException();
}
public void throwException(){
try {
throw new Exception();
} catch (Exception e) {
e.printStackTrace();
}
}
}
이전 예제와 달리 쓰레드가 새로 생성되지 않았다. 그저 MyThread클래스의 run()이 호출되었을 뿐이다.
위 그림은 main 쓰레드의 호출스택이며, main 메서드가 포함되어 있다.
싱글쓰레드와 멀티쓰레드
두 개의 작업을 하나의 쓰레드로 처리하는 경우와 두개의 쓰레드로 처리한느 경우를 가정하면, 하나의 쓰레드로 두 작업을 처리하는 경우는 한 작업을 마친 후에 다른 작업을 시작하지만, 두개의 쓰레드로 작업 하는 경우에는 짧은 시간동안 2개의 쓰레드가 번갈아 가면서 작업을 수행해서 동시에 작업이 처리되는 것과 같이 느끼게 한다.
위 그림에서 보면 두개의 작업을 수행한 시간이 두 그래프가 거의 같으며 오히려 두개의 쓰레드로 작업한 시간이 싱글쓰레드로 작업한 시간보다 더 걸리게 되는데 그 이유는 쓰레드간의 작업 전환(context switching)에 시간이 걸리기 때문이다.
작업 전환을 할 때는 현재 진행중인 작업의 상태, 예를 들면 다음에 실행해야할 위치(PC,프로그램 카운터) 등의 정보를 저장하고 읽어 오는 시간이 소요된다. 그래서 단순히 CPU만을 사용하는 계산작업이라면 오히려 멀티쓰레드보다 싱글쓰레드로 프로그래밍하는 것이 더 효율적이다.
public class ThreadExample {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 300; i++) {
System.out.printf("%s",new String("-"));
}
System.out.println("소요시간1 : " + (System.currentTimeMillis()-startTime) );
for (int i = 0; i < 300; i++) {
System.out.printf("%s",new String("|"));
}
System.out.println("소요시간2 : " + (System.currentTimeMillis()-startTime) );
}
}
public class ThreadExample2 {
static long startTime = System.currentTimeMillis();
public static void main(String[] args) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 300; i++) {
System.out.printf("%s",new String("-"));
}
System.out.println("소요시간2 : " + (System.currentTimeMillis() - ThreadExample2.startTime));
}
});
thread.start();
for (int i = 0; i < 300; i++) {
System.out.printf("%s",new String("|"));
}
System.out.println("소요시간1 : " + (System.currentTimeMillis() - ThreadExample2.startTime));
}
}
실제 같은 작업을 수행할때 두개의 쓰레드일때가 하나의 쓰레드에서 처리할 때 보다 시간이 더 걸린것을 볼 수 있다.
두개의 스레드에서 작업한느데도 더 많은 시간이 걸린 이유는 두가지이다.
- 하나는 두 쓰레드가 번갈아가면서 작업을 처리하기 때문에 쓰레드간의 작업전환시간이 소요되기 때문이고, 나머지 하나는 한 쓰레드가 화면에 출력하고 있는 동안 다른 쓰레드는 출력이 끝나기를 기다려야 하는데 이때 발생하는 대기시간 때문이다.
싱글 코어와 멀티 코어일때를 비교해 놓았는데, 싱글 코어인 경우 멀티쓰레드라도 하나의 코어가 번갈아가면서 수행하는 것이므로 두 작업이 절대 겹치지 않는다. 그러나 멀티코어 에서는 멀티쓰레드로 두 작업을 수행하면, 동시에 두 쓰레드가 수행될 수 있으므로 a와b가 겹치는 부분이 발생한다. 그래서 화면(console)이라는 자원을 놓고 두 쓰레드가 경쟁하게 되는 것이다.
위의 실행 결과는 실행 때마다 다른 결과를 얻을 수 있는데 그 이유는 실행중인 예제 프로그램(프로세스)이 OS의 프로세스 스케줄러의 영향을 받기 때문이다.
프로세스 스케줄러에 의해서 실행 순서와 실행시간이 결정되기 때문에 순간 상황에 따라 프로세스에게 할당되는 실행시간이 일정하지 않고 쓰레드에게 할당되는 시간 역시 일정하지 않게 된다. 그래서 이러한 불확실성을 가지고 있다는 것을 염두에 두어야 한다.
자바가 OS(플랫폼)에 독립적이라고 하지만 실제로는 OS 종속적인 부분이 몇가지 있는데 쓰레드도 그 중 하나이다.
두 쓰레드가 서로 다른 자원을 사용하는 작업의 경우에는 싱글쓰레드 프로세스보다 멀티쓰레드 프로세스가 더 효율적이다.
예를 들면 사용자로부터 데이터를 입력받는 작업, 네트워크로 파일을 주고받는 작업, 프린터로 파일을 출력하는 작업과 같이 외부기기와의 입출력을 필요로 하는 경우가 이에 해당된다.
첫 번째 그래프처럼 사용자가 입력을 마칠때까지 아무일도 하지 않고 기다리기만 해야한다면 두 개의 쓰레드로 처리하면 사용자의 입력을 기다리는 동안 다른 쓰레드가 작업을 처리할 수 있기 때문에 보다 효율적인 CPU의 사용이 가능하다.
-
쓰레드의 우선순위
멀티 스레드는 동시성(Concurrency) 또는 병렬성(Parallelism)으로 실행되기 때문에 이 용어들에 대해 정확히 이해하는 것이 좋다.
동시성은 멀티 작업을 하나의 코어에서 멀티 스레드가 번갈아가며 실행하는 성질을 말하고, 병렬성은 멀티 작업을 위해 멀티 코어에서 개별 스레드를 동시에 실행하는 성질을 말한다. 싱글 코어 CPU를 이용한 멀티 스레드 작업은 병렬적으로 실하는 것처럼 보이지만 사실 번갈아가며 실행하는 동시성 작업이다. 번갈아 실행하는 것이 워낙 빠르다보니 병렬성으로 보일 뿐이다.
스레드의 개수가 코어의 수보다 많을 경우, 스레드를 어떤 순서에 의해 동시성으로 실행할 것인가를 결정해야 하는데, 이것을 쓰레드 스케줄링이라고 한다. 쓰레드 스케줄링에 의해 쓰레드들은 아주 짧은 시간에 번갈아가면서 그들의 run() 메서드를 조금씩 실행한다.
자바의 스레드 스케줄링은 우선순위(priority) 방식과 순환 할당(Round-Robin) 방식을 사용한다.
우선 순위 방식은 우선순위가 높은 스레드가 실행 상태를 더 많이 가지도록 스케줄링하는 것을 말한다. 순환할당 방식은 시간 할당량을 정해서 하나의 스레드를 정해진 시간만큼 실행하고 다시 다른 스레드를 실행하는 방식을 말한다.
스레드 우선순위 방식은 스레드 객체에 우선 순위 번호를 부여할 수 있기 때문에 코드로 제어할 수 있다. 하지만 순환 할당 방식은 자바 가상 기계에 의해서 정해지기 때문에 코드로 제어할 수 없다.
우선 순위 방식에서 우선순위는 1에서 10까지 부여되는데, 1이 가장 우선순위가 낮고, 10이 가장 높다.
우선 순위를 부여하지 않으면 기본적으로 모든 스레드들은 5의 우선순위를 할당 받는다.
만약 우선순위를 변경하고 싶다면 Thread 클래스가 제공하는 setPriority() 메서드를 이용하면 된다.
thread.setPriority(우선순위);
thread.setPriority(Thread.MAX_PRIORITY);
thread.setPriority(Thread.NORM_PRIORITY);
thread.setPriority(Thread.MIN_PRIORITY);
우선 순위의 매개 값으로 1~10까지의 값을 직접 주어도 되지만, 코드의 가독성을 높이기 위해 Thread 클래스의 상수를 사용 할 수도 있다.
MAX_PRIORITY는 10, NORM_PRIORITY는 5, MIN_PRIORITY은 1의 값을 가지고 있다.
- setPriority(int newPriority) : 쓰레드의 우선순위를 지정한 값으로 변경합니다.
- getPriority() : 쓰레드의 우선순위를 반환합니다.
setPriority() 메소드는 쓰레드를 시작하기 전에만 우선순위를 변경할 수 있습니다.
쓰레드가 수행하는 작업의 중요도에 따라 쓰레드의 우선순위를 다르게 지정하여 특정 쓰레드가 더 많은 작업시간을 갖도록 할 수 있습니다.
쓰레드 그룹
쓰레드 그룹은 서로 관련된 쓰레드를 그룹을 다루기 위한 것으로, 폴더를 생성해서 관련된 파일들을 함께 넣어서 관리하는 것처럼 쓰레드 그룹을 생성해서 쓰레드를 그룹으로 묶어서 관리할 수 있다.
또한 폴더 안에 폴더를 생성할 수 있듯이 쓰레드 그룹에 다른 쓰레드 그룹을 포함 시킬 수 있다. 쓰레드 그룹은 보안상의 이유로 도입된 개념으로, 자신이 속한 쓰레드 그룹이나 하위 쓰레드 그룹을 변경할 수 있지만 다른 쓰레드 그룹의 쓰레드를 변경할 수는 없다.
- ThreadGroup(String name) : 지정된 이름의 새로운 쓰레드 그룹 생성
- ThreadGroup(ThreadGroup parent, String name) : 지정된 쓰레드 그룹에 포함되는 새로운 쓰레드 그룹 생성
- int activeCount() : 쓰레드 그룹에 포함된 활성 상태에 있는 쓰레드 수 반환
- int activeGroupCount() : 쓰레드 그룹에 포함된 활성 상태에 있는 쓰레드 그룹의 수 반환
- void destroy() : 쓰레드 그룹과 하위 쓰레드 그룹까지 모두 삭제한다. 단 쓰레드 그룹이나 하위 쓰레드 그룹이 비어있어야한다.
그 외) docs.oracle.com/javase/8/docs/api/index.html
쓰레드를 쓰레드 그룹에 포함시키려면 Thread의 생성자를 이용해야한다.
- Thread(ThreadGroup group, String name)
- Thread(ThreadGroup group, Runnable target)
- Thread(ThreadGroup group, Runnable target, String name)
- Thread(ThreadGroup group, Runnable target, String name, long stackSize)
모든 쓰레드는 반드시 쓰레드 그룹에 포함되어 있어야 하기 떄문에, 위와 같이 쓰레드 그룹을 지정한느 생성자를 사용하지 않은 쓰레든느 기본적으로 자신을 생성한 쓰레드와 같은 쓰레드 그룹에 속하게 된다.
자바 어플리케이션이 실행되면, JVM은 main과 system이라는 쓰레드 그룹을 만들고 메서드를 수행하는 main이라는 이름의 쓰레드는 main쓰레드 그룹에 속하고, 가비지컬렉션을 수행하는 Finalizer쓰레드는 system쓰레드 그룹에 속한다.
우리가 생성한느 모든 쓰레드 그룹은 main 쓰레드 그룹의 하위 쓰레드 그룹이 되며, 쓰레드 그룹을 지정하지 않고 생성한 쓰레드는 자동적으로 main쓰레드 그룹에 속하게 된다.
package me.study.week14;
public class ThreadGroupTest {
public static void main(String[] args) {
ThreadGroup main = Thread.currentThread().getThreadGroup();
ThreadGroup grp1 = new ThreadGroup("group1");
ThreadGroup grp2 = new ThreadGroup("group2");
ThreadGroup subGrp1= new ThreadGroup(grp1,"SubGroup1");
grp1.setMaxPriority(3); // 쓰레드 그룹 grp1의 최대 우선순위를 3으로 변경.
Runnable r = new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
};
new Thread(grp1,r,"thread1").start();
new Thread(grp2,r,"thread2").start();
new Thread(subGrp1,r,"SubGroup1").start();
System.out.println(">> List of ThreadGroup : " + main.getName() + ", Active ThreadGroup : " + main.activeGroupCount() + ", " +
"Active Thread : " + main.activeCount());
main.list(); // 쓰레드 그룹에 속한 쓰레드와 하위 쓰레드 그룹에 대한 정보를 출력
}
}
결과를 보면 새로 생성한 모든 쓰레드 그룹은 main 쓰레드 그룹의 하위에 존재한다는 것과 setMaxPriority()는 쓰레드가 쓰레드 그룹에 추가되기 이전에 호출 되어야 하며, 쓰레드 그룹 grp1의 최대 우선순위를 3으로 했기 떄문에, 후에 여기에 속하게 된 쓰레드 그룹과 쓰레드가 영향을 받은것을 알 수 있다.
그리고 참조 변수 없이 쓰레들르 생성해서 바로 실행시켰는데, 그렇다고 해서 이 쓰레드가 가비지 컬렉터의 제거 대상이 되지는 않는다. 이 쓰레드의 참조가 ThreadGroup에 저장되어있기 떄문이다.
데몬 쓰레드(daemon thread)
데몬 쓰레드는 다른 일반 쓰레드의 작업을 돕는 보조적인 역할을 수행하는 쓰레드이다.
일반 쓰레드가 모두 종료되면 데몬 쓰레든느 강제적으로 자동 종료되는데, 그 이유는 데몬스레드는 일반 쓰레드의 보조역할을 수행하므로 일반 쓰레드가 모두 종료되고 나면 데몬 쓰레드의 존재의 의미가 없기 때문이다.
이 점을 제외하면 일반 쓰레드와 데몬쓰레드는 다르지 않다. 데몬 쓰레드의 대표적인 예로는 가비지 컬렉터가 있다.
데몬 스레드는 무한루프와 조건문을 이용해서 실행 후 대기하고 있다가 특정 조건이 만족되면 작업을 수행하고 다시 대기하도록 작성한다.
데몬 스레드는 일반 쓰레드의 작성방법과 실행방법이 같으며 다만 쓰레드를 생성한 다음 실행하기 전에 setDaemon(true)를 호출하기만 하면 된다. 그리고 데몬 쓰레드가 생성한 쓰레드는 자동적으로 데몬 쓰레드가 된다.
쓰레드의 실행제어
쓰레드 프로그래밍이 어려운 이유는 동기화와 스케줄링 떄문이다. 효율적인 멀티쓰레드 프로그램을 만들기 위해서는 보다 정교한 스케줄링을 통해 프로세스에게 주어진 자원과 시간을 여러 쓰레드가 낭비없이 잘 사용하도록 프로그래밍 해야한다.
쓰레드의 스케줄링을 잘하기 위해서는 쓰레드의 상태와 관련 메서드를 잘 알아야 하는데, 먼저 쓰레드의 스케줄링과 관련된 메서드이다.
쓰레드의 상태
다음 그림은 쓰레드의 생성부터 소멸까지의 모든 과정을 그린것이다.
① : 쓰레드를 생성하고 start()를 호출하면 바로 실행되는 것이 아니라 실행대기열에 저장되어 자신의 차례가 될 때까지 기다려야 한다. 실행 대기열은 큐(queue)와 같은 구조로 먼저 실행대기열에 들어온 쓰레드가 먼저 실행된다.
② : 실행대기상태에 있다가 자신의 차례가 되면 실행상태가 된다.
③ : 주어진 실행시간이 다 되거나 yield()를 만나면 다시 실행대기상태가 되고 다음 차례의 쓰레드가 실행상태가 된다.
④ : 실행중에 suspend(), sleep(), wait(), join() I/O block에 의해 일시정지 상태가 될 수 있다. I/O Block은 입출력작업에서 발생하는 지연상태를 말한다. 사용자의 입력을 기다리는 경우를 예로 들 수 있는데, 이런 경우 일시정지 상태에 있다가 사용자가 입력을 마치면 다시 실행대기 상태가 된다.
⑤ : 지정된 일시정지시간이 다되거나(time-out), notify(), resume(), interrupt()가 호출되면서 일시정지상태를 벗어나 다시 실행대기열에 저장되어 자신의 차례를 기다리게 된다.
⑥ : 실행을 모두 마치거나 stop()이 호출되면 쓰레드는 소멸된다.
p.s) 설명을 위해 1~6까지 번호를 붙였으나 번호의 순서대로 쓰레드가 수행되는 것은 아니다.
sleep(long lillis)
sleep() 은 지정된 시간동안 쓰레드를 멈추게 한다.
sleep에 의해 일시정지 상태가 된 쓰레드는 지정된 시간이 다 되거나 interrupt()가 호출되면, interruptedException이 발생되어 잠에서 깨어나 실행대기 상태가 된다.
그래서 sleep()을 호출할 때는 항상 try-catch문으로 예외를 처리해줘야 한다.
매번 예외처리를 하는 것이 번거러울때는 try-catch문까지 포함하는 새로운 메서드를 만들기도 한다,
void delay(long millis){
try{
Thread.sleep(millis);
}catch(interruptException e){}
}
interrupt()와 interrupted() - 쓰레드의 작업을 취소한다.
진행중인 쓰레드의 작업이 끝나기 전에 취소해야할 때가 있다. 예를 들어 큰파일을 다운받을 때 시간이 너무 오래걸리면 중간에 다운로드를 포기하고 취소할 수 있어야 한다.
interrupt는 쓰레드에게 작업을 멈추라고 요청한다. 단지 멈추라고 요청만 하는 것일 뿐 쓰레드를 강제로 종료시키지는 못한다. interrupt는 그저 쓰레드의 interrupted상태(인스턴스 변수)를 바꾸는 것일 뿐이다.
그리고 interrupted()는 쓰레드에 대해 interrupt()가 호출 되었는지 알려준다. interrupt()가 호출 되지 않았다면 false, 호출 되었다면, true를 반환한다.
isinterrupt()도 쓰레드의 interrupt()가 호출되었는지 확인하는데 사용할 수 있지만, interrupted()와 달리 isInterrupted()는 쓰레드의 interrupted상태를 false로 초기화 하지 않는다.
suspend(),resume(),stop()
suspend()는 sleep()처럼 쓰레드를 멈추게 한다. suspend()에 의해 정지된 쓰레드는 resume()을 호출해야 다시 실행대기 상태가 된다.
stop은 호출되는 즉시 쓰레드가 종료된다.
suspend(),resume(),stop() 는 쓰레드의 실행을 제어하는 가장 손쉬운 방법이지만, suspend()와 stop()이 교착상태를 일으키기 쉽기 때문에 사용이 권장되지 않으며 이 메서드 들은 모두 'deprecated' 되어 있다.
yield() - 다른 쓰레드에게 양보한다.
yield()는 쓰레드 자신에게 주어진 실행시간을 다음 차례의 쓰레드에게 양보한다.
예를 들어 스케줄러에 의해 1초의 실행시간을 할당 받은 쓰레드가 0.5초의 시간도안 작업한 상태에서 yield() 와 interrupt()를 적절히 사용하면, 프로그램의 응답성을 높이고 보다 효율적인 실행이 가능하게 할 수 있다.
동기화
공유 객체를 사용할 때의 주의할 점
싱글 스레드 프로그램에서는 한 개의 스레드가 객체를 독차지해서 사용하면 되지만, 멀티 스레드 프로그램에서는 스레드들이 객체를 공유해서 작업해야 하는 경우가 있다. 이 경우 스레드a를 사용하던 객체가 스레드b 에 의해 상태가 변경 될 수 있기 때문에 스레드a 가 의도했던 것과는 다른 결과를 산출할 수도 있다.
이러한 일이 발생하는 것을 방지하기 위해서 한 쓰레드가 특정 작업을 끝마치지 전까지 다른 쓰레드에 의해 방해받지 않도록 하는 것이 필요하다.
그래서 도입된 개념이 바로 '임계 영역(critical section)'과 '잠금(락,lock)' 이다.
임계 영역이란 서로 다른 둘 이상의 프로세스가 하나의 데이터 영역을 공유하고 있을 때 이 영역을 임계영역이라고 한다.
공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정해놓고, 공유 데이터(객체)가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게 한다.
그리고 해당 쓰레드가 임계 영역 내의 모든 코드를 수행하고 벗어나서 lock을 반납해야만 다른 쓰레드가 반납된 lock을 획득하여 임계 영역의 코드를 수행할 수 있게된다.
이처럼 한 쓰레드가 진행중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는것을 '쓰레드의 동기화(synchronization)'이라고 한다.
자바에서는 synchronized 블록을 이용해서 쓰레드의 동기화를 지원했지만, JDK1.5부터는 'java.util.concurrent.locks'와 'java.util.concurrent.atomic' 패키지를 통해서 다양한 방식으로 동기화를 구현할 수 있도록 지원하고 있다.
synchronized를 이용한 동기화
가장 간단한 동기화 방법인 synchronized 키워드를 이용한 동기화에 대해서 알아보자.
이 키워드는 임계 영역을 설정하는데 사용되며 두 가지 방식이 있다.
- 메서드 전체를 임계 영역으로 지정
public synchronized void calcSum(){} // 임계 영역
첫번째 방법은 메서드 앞에 synchronized를 붙이는 것인데, synchronized를 붙이면 메서드 전체가 임계 영역으로 설정된다. 쓰레드는 synchronized메서드가 호출된 시점부터 해당 메서드가 포함된 lock을 얻어 작업을 수행하다가 메서드가 종료되면 lock을 반환한다.
- 특정한 영역을 임계 영역으로 지정
synchronized(객체의 참조변수){} // 임계 영역
두번째 방법은 메서드 내의 코드 일부를 블럭{}으로 감싸고 블럭 앞에 'synchronized(참조변수)'를 붙이는 것인데, 이때 참조변수는 락을 걸고자 하는 객체는 참조하는 것이어야 한다. 이 블록을 synchronized블록이라고 부르며, 이 블록의 영역 안으로 들어가면서부터 쓰레드는 지정된 객체의 lock을 얻게 되고, 이 블록을 벗어나면 lock을 반납한다.
두 방법 모두 lock의 획득과 반납이 모두 자동적으로 이루어지므로 우리가 해야 할 일은 그저 임계 영역만 설정해주는 것이다.
모든 객체는 lock을 하나씩 가지고 있으며, 해당 객체의 lock을 가지고 있는 쓰레드만 임계 영역의 코드를 수행할 수 있다. 그리고 다른 쓰레드들은 lock을 얻을 때까지 기다리게 된다.
임계 영역은 멀티쓰레드 프로그램의 성능을 좌우하기 때문에 가능하면 메서드 전체에 락을 거는 것보다 synchronized블록으로 임계 영역을 최소화해서 보다 효율적인 프로그램이 되도록 노력해야 한다.
synchronized를 이용한 동기화는 지정된 영역의 코드를 한번에 하나의 쓰레드가 수행하는 것을 보장한다.
wait()과 notify()
synchronized로 동기화해서 공유 데이터를 보호하는 것 까지는 좋은데, 특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않도록 하는 것도 중요하다. 만일 한 스레드가 락을 보유한 채로 오랜 시간을 보유한다면, 다른 쓰레드들은 모두 해당 객체의 락을 기다리느라 다른 작업들도 원활히 진행되지 않을 것이다.
이러한 상황을 개선하기 위해 고안된 것이 바로 wait()과 notify() 이다.
동기화된 임계 영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, 일단 wait()을 호출하여 쓰레드가 락을 반납하고 기다리게 한다.
그러면 다른 스레드가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게 된다. 나중에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서, 작업을 중단했던 쓰레드가 다시 락을 얻어 작업을 진행할 수 있게 한다.
이는 마치 빵을 사려고 줄을 서 있는것과 비슷한데, 자신의 차례가 되었는데도 자신이 원하는 빵이 나오지 않았으면 다음사람에게 순서를 양보하고 기다리다가 자신이 원하는 빵이 나오면 통보를 받고 빵을 사가는 것이다.
차이가 있다면, 오래 기다린 쓰레드가 락을 얻는다는 보장이 없다는 것이다. wait()이 호출되면, 실행 중이던 쓰레드는 해당 객체의 대기실(waiting pool)에서 통지를 기다리낟.
notify()가 호출되면, 해당 객체의 대기실에 있던 모든 쓰래드 중에서 임의의 쓰레드만 통지를 받는다.
notifyAll()은 기다리고 있는 모든 쓰레드에게 통보를 하지만, 그래도 lock을 얻지 못하면 다시 lock을 기다리는 신세가 된다.
wait() 과 notify()는 특정 객체에 대한 것이므로 Object 클래스에 정의되어있다.
- void wait()
- Causes the current thread to wait until another thread invokes the notify() method or the notifyAll() method for this object.
- 다른 스레드가 이 객체에 대해 notify () 메서드 또는 notifyAll () 메서드를 호출 할 때까지 현재 스레드가 대기하도록합니다.
- void wait(long timeout)
- Causes the current thread to wait until either another thread invokes the notify() method or the notifyAll() method for this object, or a specified amount of time has elapsed.
- 다른 스레드가 이 객체에 대해 notify () 메서드 또는 notifyAll () 메서드를 호출하거나 지정된 시간이 경과 할 때까지 현재 스레드가 대기하도록합니다.
- void wait(long timeout, int nanos)
- Causes the current thread to wait until another thread invokes the notify() method or the notifyAll() method for this object, or some other thread interrupts the current thread, or a certain amount of real time has elapsed.
- 다른 스레드가 이 객체에 대해 notify () 메서드 또는 notifyAll () 메서드를 호출하거나 다른 스레드가 현재 스레드를 인터럽트하거나 특정 시간이 경과 할 때까지 현재 스레드가 대기하도록합니다.
- void notify()
- Wakes up a single thread that is waiting on this object's monitor.
- 이 개체의 모니터에서 대기중인 단일 스레드를 깨 웁니다.
- void notifyAll()
- Wakes up all threads that are waiting on this object's monitor.
- 이 개체의 모니터에서 대기중인 모든 스레드를 깨 웁니다.
wait()은 notify() 또는 notifyAll()이 호출될 때 까지 기다리지만, 매개변수가 있는 wait()은 지정된 시간동안만 기다린다.
즉 지정된 시간이 지난 후에 자동으로 notify()가 호출되는 것과 같다.
그리고 waiting pool은 객체마다 존재하는 것이므로 notifyAll()이 호출된다고 해서 모든 객체의 waiting pool에 있는 쓰레드가 꺠워지는 것은 아니다. notifyAll()이 호출된 객체의 waiting pool에 대기중인 쓰레드만 해당된다.
기아 현상과 경쟁 상태
여러 쓰레드가 공유하고 있는 상황에서 한 객체의 lock을 가지기 위해 waiting pool에서 대기하는데 운이 나쁘면 오랫동안 기다리게 된다. 이것을 '기아(starvation) 현상'이라고 한다.
이 현상을 막으려면, notify() 대신 notifyAll()을 사용해야 한다. 일단 모든 쓰레드에게 통지를 하면, 결국 lock을 얻어 작업을 진행할 수 있다.
notifyAll()로 작업을 원하는 쓰레드의 기아현상은 막을 수 있지만 불필요한 쓰레드까지 통지를 받아서 불필요하게 원하는 쓰레드와 lock을 얻기 위해 경쟁하게 된다. 이처럼 여러 쓰레드가 lock을 얻기 위해 서로 경쟁하는 것을 '경쟁 상태(race condition)' 이라고 하는데, 이 경쟁 상태를 개선하기 위해서는 notify하고 싶은 쓰레드와 그렇지 않은 쓰레드를 구별해서 통지하는 것이 필요하다. Lock과 Condition을 이용한 동기화를 통해 선별적인 통지가 가능하다.
Lock과 Condition을 이용한 동기화
동기화를 할 수 있는 방법은 synchronized 블럭 외에도 "java.util.concurrent.locks"패키지가 제공하는 lock클래스들을 이용하는 방법이 있다.
synchronized 블럭은 동기화를 하면 자동적으로 lock 잠기고 풀리기 때문에 편리하다. 심지어 synchronized블럭 내에서 예외가 발생해도 lock은 자동적으로 풀린다. 그러나 같은 메서드 내에서만 lock을 걸 수 있는 제약이 풀편하기도 하다. 그럴때 이 lock 클래스를 사용한다.
lock 클래스 종류
- ReentrantLock : 재진입이 가능한 lock , 가장 일반적인 배타 lock
- ReentrantReadWriteLock : 읽기에는 공유적이고, 쓰기에는 배타적인 lock
- StrampedLock : ReentrantReadWriteLock에 낙관적인 lock 기능을 추가
ReentrantLock 은 가장 일반적인 lock이다. 'reentrant(재진입할 수 있는)'이라는 단어가 앞에 붙은 이유는 wait() & notify()에서 본 것처럼, 특정 조건에서 lock을 풀고 나중에 다시 lock을 얻고 임계영역으로 들어와서 이후의 작업을 수행할 수 있기 떄문이다. 지금까지 lock과 일치한다.
ReentrantReadWriteLock은 이름에서 알 수 있듯이, 읽기을 위한 lock과 쓰기를 위한 lock을 제공한다. ReentrantLock은 배타적인 lock이라서 무조건 lock이 있어야만 임계 영역의 코드를 수행할 수 있지만, ReentrantReadWriteLock은 읽기 lock이 걸려있으면, 다른 쓰레드가 읽기 lock을 중복해서 걸고 읽기를 수행할 수 있다. 읽기는 내용을 변경하지 않으므로 동시에 여러 쓰레드가 읽어도 문제가 되지 않는다. 그러나 읽기 lock이 걸린 상태에서 쓰기 lock을 거는 것은 허용되지 않은다. 반대의 경우도 마찬가지다. 읽기를 할때는 읽기 lock을 걸고, 쓰기 할 때는 쓰기lock을 거는 것일뿐 lock을 거는 방법은 같다.
StampedLock은 lock을 걸거나 해지할 때 "스탬프(long 타입의 정수값)"을 사용하며, 읽기와 쓰기를 위한 lock외에 '낙관적 읽기 lock(optimistic reading lock)'이 추가 된 것이다.
읽기 lock이 걸려 있으면, 쓰기 lock을 얻기 위해서는 읽기 lock이 풀릴 때까지 기다려야 하는데 비해 '낙관적 읽기 lock'은 쓰기 lock에 의해 바로 풀린다. 그래서 낙관적 읽기에 실패하면, 읽기 lock을 얻어서 다시 읽어 와야 한다.
무조건 읽기 lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 lock을 거는 것이다.
ReentrantLock 과 Condition
wait과 notify()는 쓰레드를 구분해서 통지하지 못한다는 단점이 있었다.
Conditiondms 이 문제점을 해결하기 위한 것이다.
wait과 notify로 쓰레드의 종류를 구분하지 않고, 공유 객체의 waiting pool에 같이 넣는 대신, A쓰레드를 위한 Condition과 B쓰레드를 위한 Condition을 각각 만들어서 각각의 waiting pool에서 따로 기다리도록 하면 문제는 해결된다.
Condition은 이미 생성된 lock으로 부터 newCondition()을 호출해서 생성한다.
private ReentrantLock lock = new ReentrantLock(); // lock을 생성
// lock으로 condition을 생성
private Condition forCook = lock.newCondition();
private Condition forCust = lock.newCondition();
// 자바의 정석3판 782p
위의 코드에서 두 개의 Condition을 생성했는데, 하나는 요리사 쓰레드를 위한 것이고 다른 하나는 손님 쓰레드를 위한 것이다. 그 다음엔 wait()과 notify() 대신 Condition의 await()과 signal()로 쓰레드의 종류를 구분해서 사용하면 된다.
쓰레드의 종류에 따라 구분하여 통지할 수 있게되어 '기아 현상'이나 '경쟁 상태'가 확실히 개선 되었다.
하지만 여전히 특정 쓰레드를 선택할 수 없기 떄문에 같은 종류의 쓰레드 간의 '기아 현상'이나 '경쟁 상태'가 발생할 가능성은 남아 있다.
volatile
- 사실 우리는 메인 메모리에 항상 직접 접근하여 연산을 하는것이 아니라 성능상의 이익을 얻기 위해 cpu 캐시에 저장된 값으로 연산을 진행한다. 그래서 멀티쓰레드 환경에서는 특정 쓰레드에서 값을 변경하여도 cpu캐시에서만 값이 변경되어, 메인 메모리에는 반영이 되지 않아 다른 쓰레드에서 문제가 발생할 수 있다. 이 때, volatile 키워드를 사용하면 해당 값을 cpu캐시에 갱신하지 않고 직접 메인 메모리에 갱신하게된다.
volatile boolean suspended = false;
위와 같이 변수 앞에 volatile을 붙이면 , 코어가 변수의 값을 읽어 올 때 캐시가 아닌 메모리에서 읽어오기 때문에 캐시와 메모리 간의 불일치가 해결된다.
변수에 volatile을 붙이는 대신에 synchronized블럭을 사용해도 같은 효과를 얻을 수 있다. 쓰레드가 synchronized 블럭으로 들어갈때와 나올 때, 캐시와 메모리간의 동기화가 이루어지기 떄문에 값의 불일치가 해소되기 때문이다.
문제점
- 역시 아직 성능상의 문제가 있다.
- 코드 가독성이 떨어진다.
- 1.4 이하의 버전에서는 적용할 수 없다.
volatile로 long과 double을 원자화
JVM은 데이터를 4BYTE 단위로 처리하기 때문에, int와 int보다 작은 타입들은 한 번에 읽거나 쓰는 것이 가능하다.
즉, 단 하나의 명령어로 읽거나 쓰거나 가능하다는 뜻이다. 하나의 명령어는 더 이상 나눌 수 없는 최소의 작업단위이므로, 작업의 중간에 다른 쓰레드가 끼어들 틈이 없다.
그러나 크기가 8byte인 long과 double타입의 변수는 하나의 명령어로 값을 읽거나 쓸수 없기 때문에 변수의 값을 읽는 과정에 다른 쓰레드가 끼어들 여지가 있다.
다른 쓰레드가 끼어들지 못하게 하려고 변수를 읽고 쓰는 동안 synchronized블럭으로 감쌀 수 있지만, 간단한 방법으로 변수를 선언할 때 volatile을 붙이는 것이다.
volatile long shareVal; // long 타입의 변수 (8btye)를 원자화
volatile double shareVal; // double 타입의 변수(8byte)를 원자화
volatile은 해당 변수에 대한 읽거나 쓰거나 원자화 된다.
원자화라는 것은 작업을 더 이상 나눌 수 없게 한다는 의미인데, synchronized블록도 일종의 원자화 라고 할 수 있다.
즉, synchronized 블럭은 여러 문장을 원자화 함으로써 쓰레드의 동기활르 구현한 것이라고 보면 된다.
volatile은 변수의 읽거나 쓰거나 원자화 할 뿐, 동기화 하는 것은 아니라는 점을 주의 해야 한다.
volatile long balance; // 인스턴스 변수 balance를 원자화 한다.
synchronized int getBalance(){ // balance의 값을 반환한다.
return balance;
}
synchronized void withdraw(int money){ // balance의 값을 변경
if(balance >= money){
balance -= money;
}
}
// 자바의 정석3판 787p
인스턴스 변수 balance를 volatile로 원자화 했으니까, 이 값을 읽어서 반환하는 메서드 getBalance()를 동기화할 필요가 없다고 생각할 수 있다. 그러나 getBalance()를 synchronized로 동기화 하지 않으면, withdraw()가 호출되어 객체에 lock을 걸고 작업을 수행하는 중인데도 getBalance()가 호출되는 것이 간으해진다. 출금이 진행 중일 때는 기다렸다가 출금이 끝난 후에 잔고를 조회할 수 있도록 하려면 getBalnce에 synchronized 를 붙여서 동기화 해야한다.
fork & join
10년 전까지만 해도 CPU의 속도는 매년 거의 2배씩 빠르게 향상되어왔습니다.
그러나 이제 그 한계에 도달하여 속도 보다는 코어의 개수를 늘려서 CPU의 성능을 향상시키는 방향으로 발전해가고 있습니다. 이런 하드웨어의 변화에 맞춰 프로그래밍도 멀티 코어를 잘 활용할 수 있는 멀티 쓰레드 프로그래밍이 점점 더 중요해지고 있습니다. 하지만 멀티 쓰레드 프로그래밍은 쉽지 않습니다.
JDK 1.7부터 'fork & join 프레임워크' 가 추가 되어, 하나의 작업을 작은 단위로 쪼개서 여러 쓰레드가 동시에 처리하는 것을 쉽게 만들어 줍니다.
수행할 작업에 따라 아래의 두 클래스 중에서 하나를 상속받아 구현하면 됩니다.
- RecursiveAction 반환값이 없는 작업을 구현할 때 사용
- RecursiveTask 반환값이 있는 작업을 구현할 때 사용
두 클래스 모두 compute()라는 추상 메서드를 가지고 있는데, 우리는 상속을 받아 compute() 라는 추상 메소드에 작업할 내용으로 재정의 하면 됩니다.
class SumTask extends RecursiveTask<Long> {
long from, to;
SumTask(long from, long to) {
this.from = from;
this.to = to;
}
public Long compute() {
long size = to - from + 1;
if (size <= 5) // 더할 숫자가 5개 이하면
return sum(); // 숫자의 합을 반환
long half = (from + to) / 2;
// 범위를 반으로 나눠서 두개의 작업을 생성
SumTask leftSum = new SumTask(from, half);
SumTask rightSum = new SumTask(half+1, to);
leftSum.fork();
return rightSum.compute() + leftSum.join();
}
long sum() {
long tmp = 0L;
for (long i = from; i <= to; i++) {
tmp += i;
}
return tmp;
}
}
쓰레드를 시작할 때 start() 메소드를 호출하듯이 쓰레드 풀을 생성해 invoke() 메소드를 호출해서 작업을 시작합니다.
- ForkJoinPool pool = new ForkJoinPool(); // 쓰레드 풀 생성
- SumTask task = new SumTask(from,to); // 수행할 작업을 생성
- Long result = pool.invoke(task); // invoke()를 호출해서 작업을 시작
ForkJoinPool은 fork&join 프레임워크에서 제공하는 쓰레드 풀(thread pool)로, 지정된 수의 쓰레드를 생성해서 미리 만들어 놓고 반복해서 재사용할 수 있게 한다.
또한, 쓰레드를 반복해서 생성하지 않아도 된다는 장점과 너무 많은 쓰레드가 생성되어 성능이 저하되는 것을 막아준다는 장점이 있다.
쓰레드 풀은 쓰레드가 수행해야하는 작업이 담긴 큐를 제공하며, 각 쓰레드는 자신의 작업 큐에 담긴 작업을 순서대로 처리한다.
쓰레드 풀은 기본적으로 코어의 개수와 동일한 개수의 쓰레드를 생성한다.
Compute()의 구현
compute()를 구현할 때는 수행할 작업 외에도 작업을 어떻게 나눌 것인가에 대해서도 구현해야합니다.
public Long compute() {
long size = to - from + 1;
if (size <= 5) { // 더할 숫자가 5개 이하면
return sum(); // 숫자의 합을 반환. sum()은 from부터 to까지의 수를 더해서 반환
}
// 범위를 반으로 나눠서 두 개의 작업을 생성
long half = (from + to) / 2;
// 절반을 기준으로 나눠 left, right 로 작업의 범위를 반으로 나눠서 새로운 작업으로 생성합니다.
SumTask leftSum = new SumTask(from, half); // 시작부터 절반지점 까지
SumTask rightSum = new SumTask(half+1, to); // 절반지점부터 끝까지
leftSum.fork(); // 작업(leftSum)을 작업 큐에 넣습니다.
return rightSum.compute() + leftSum.join();
}
compute()의 구조를 보면 일반적인 재귀 호출 메소드와 같습니다.
아래 그림은 위의 코드를 그림으로 표현한 것입니다. 그림에서는 size가 2가 될때까지 작업을 나눕니다. (코드에서는 5가 될 때까지 작업을 나눕니다.)
간단하게 말하면 compute()는 작업을 반으로 나눕니다. fork()는 작업 큐에 작업을 담습니다.
또한 fork()의 호출로 작업 큐에 담긴 작업 역시 compute()로 인해 반으로 목표한 작은 size까지 작업을 나눕니다.
이러한 작업을 반복하다 보면 여러개의 작은 단위로 작업을 나눌 수 있습니다.
아래 그림은 compute() 메소드와 fork() 메소드로 인해 작업풀에 담긴 작업이 쓰레드 풀(thread pool)의 빈 쓰레드가 작업을 가져와서 작업을 수행하는 것을 나타낸 그림입니다. 이렇게 빈 쓰레드가 작은 단위의 작업을 가져와서 작업을 수행하는 것을 작업 훔쳐오기(work stealing)라고 하며, 이 과정은 모두 쓰레드 풀에 의해 자동으로 이루어 집니다.
이런 과정을 통해 한 쓰레드에 작업이 몰리지 않고 여러 쓰레드가 골고루 작업을 나누어 처리하게 됩니다.
물론 작업의 크기가 충분히 작게 나눠져야 여러 쓰레드에게 작업을 골고루 나눠줄 수 있습니다.
fork() 와 join()
fork()는 작업을 쓰레드의 작업큐에 넣는 것이고, join()은 작업의 결과를 반환합니다.
fork()와 join()의 차이점
fork() : 해당 작업을 쓰레드 풀의 작업큐에 넣습니다. 비동기 메소드(asynchronous method)
join() : 해당 작업의 수행이 끝날 때까지 기다렸다가, 수행이 끝나면 그 결과를 반환합니다.동기 메소드(synchronous method)
비동기 메서드는 일반적인 메소드와 달리 메소드를 호출만 하고 결과를 기다리지 않습니다.
-> 내부적으로 다른 쓰레드에게 작업을 수행하도록 지시만 하고 결과를 기다리지 않고 돌아오는 것입니다.
그래서 fork()를 호출하면 기다리지 않고 다음 줄의 명령을 실행합니다. 위의 코드에서는 바로 return 문으로 넘어갑니다.
"return rightSum.compute() + leftSum.join();" 이 return 문에서 compute()가 재귀호출 될 때, join()은 호출되지 않습니다. compute()로 더이상 작업을 나눌 수 없게 됐을 때 join()의 결과를 기다렸다가 더해서 결과를 반환합니다.
즉, 재귀 호출된 모든 compute()가 모두 종료될 때, 최종 결과를 얻습니다.
데드락(DeadLock)
교착상태(데드락, deadlock)은 두 개 이상의 작업이 서로 상대방의 작업이 끝나기를 기다리고 있어서 아무것도 완료되지 못하는 상태를 말한다.
교착상태의 조건
데드락이 발생하기 위해서는 다음 네가지의 조건을 모두 만족시켜야한다.
- 상호배제(Mutual exclusion)Permalink
자원을 한 번에 한 프로세스만이 사용하는 경우. - 점유대기(Hold and wait)Permalink
최소한 하나의 자원을 점유하고 있으면서 다른 프로세스에 할당되어 사용하고 있는 자원을 추가로 점유하기 위해 대기하는 프로세스가 있는 경우. - 비선점(No preemption)Permalink
다른 프로세스에 할당된 자원은 사용이 끝날 때까지 강제로 빼앗을 수 없는 경우. - 순환대기(Circular wait)Permalink
각 프로세스는 순환적으로 다음 프로세스가 요구하는 자원을 가지고 있는 경우.
위 조건 중에서 한 가지라도 만족하지 않으면 교착 상태는 발생하지 않는다. 이중 순환대기 조건은 점유대기 조건과 비선점 조건을 만족해야 성립하는 조건이므로, 위 4가지 조건은 서로 완전히 독립적인 것은 아니다.
교착상태는 예방, 회피, 무시 세 가지 방법으로 관리할 수 있다.
예방
- 상호배제 조건의 제거
- 교착 상태는 두 개 이상의 프로세스가 공유가능한 자원을 사용할 때 발생하는 것이므로 공유 불가능한, 즉 상호 배제 조건을 제거하면 교착 상태를 해결할 수 있다.
- 점유와 대기 조건의 제거
- 한 프로세스에 수행되기 전에 모든 자원을 할당시키고 나서 점유하지 않을 때에는 다른 프로세스가 자원을 요구하도록 하는 방법이다. 자원 과다 사용으로 인한 효율성, 프로세스가 요구하는 자원을 파악하는 데에 대한 비용, 자원에 대한 내용을 저장 및 복원하기 위한 비용, 기아 상태, 무한대기 등의 문제점이 있다.
- 비선점 조건의 제거
- 비선점 프로세스에 대해 선점 가능한 프로토콜을 만들어 준다.
- 환형 대기 조건의 제거
- 자원 유형에 따라 순서를 매긴다.
이 해결 방법들은 자원 사용의 효율성이 떨어지고 비용이 많이 드는 문제점이 있다.
회피
자원이 어떻게 요청될지에 대한 추가정보를 제공하도록 요구하는 것으로 시스템에 circular wait가 발생하지 않도록 자원 할당 상태를 검사한다.
교착 상태 회피 알고리즘은 크게 두가지가 있다.
- 자원 할당 그래프 알고리즘 (Resource Allocation Graph Algorithm)
- 은행원 알고리즘 (Banker’s algorithm)
무시
예방과 회피방법을 활용하면 성능 상 이슈가 발생하는데, 데드락 발생에 대한 상황을 고려하는 것에 대한 비용이 낮다면 별다른 조치를 하지 않을 수도 있다고 한다.
다음 코드는 오라클에서 제공하는 데드락의 예제이다.
public class Deadlock {
static class Friend {
private final String name;
public Friend(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
public synchronized void bow(Friend bower) {
System.out.format("%s: %s"
+ " has bowed to me!%n",
this.name, bower.getName());
bower.bowBack(this);
}
public synchronized void bowBack(Friend bower) {
System.out.format("%s: %s"
+ " has bowed back to me!%n",
this.name, bower.getName());
}
}
public static void main(String[] args) {
final Friend alphonse =
new Friend("Alphonse");
final Friend gaston =
new Friend("Gaston");
new Thread(new Runnable() {
public void run() { alphonse.bow(gaston); }
}).start();
new Thread(new Runnable() {
public void run() { gaston.bow(alphonse); }
}).start();
}
}
출처 :
도서) 이것이 자바다.
자바의 정석
docs.oracle.com/javase/8/docs/api/index.html
yadon079.github.io/2021/java%20study%20halle/week-10
'스터디 > 백기선 라이브 스터디(자바)' 카테고리의 다른 글
12주차 과제: 애노테이션 (0) | 2021.03.05 |
---|---|
11주차 과제: Enum (0) | 2021.03.05 |
9주차 과제: 예외 처리 (0) | 2021.02.23 |
8주자 과제: 인터페이스 (0) | 2021.02.22 |
7주차 과제: 패키지 (0) | 2021.02.19 |