프로세스(process)란 간단히 말해서 ‘실행 중인 프로그램(program)’이다. 프로그램을 실행하면 OS로부터 실행에 필요한 자원(메모리)를 할당받아 프로세스가 된다.
현재 우리가 사용하는 OS들(윈도우, 리눅스, 맥OS 등등..)은 모두 멀티태스킹을 지원한다. 멀티태스킹을 지원한다는 것은 여러 개의 프로세스를 동시에 실행할 수 있다는 것이다. 내가 블로그에 글을 쓰면서, 동시에 유튜브로 음악을 듣고, 인텔리제이를 실행할 수 있는 것은 모두 OS가 멀티태스킹을 지원하기 때문이다.
프로세스는 프로그램을 수행하는 데 필요한 데이터와 메모리 등의 자원(resources)과 쓰레드로 구성되어 있다. 프로세스의 자원을 이용해서 실제 작업을 수행하는 것이 바로 쓰레드이다.
하나의 프로세스는 하나 이상의 쓰레드를 가지며, 둘 이상의 쓰레드를 가진 프로세스를 '멀티쓰레드 프로세스(multi-threaded process)'라고 한다. 우리가 카카오톡이나 슬랙을 사용할 때, 상대가 전송한 파일을 다운로드하면서 동시에 채팅을 할 수 있는 것은 해당 프로그램이 멀티쓰레드로 작성되어 있기 때문이다.
멀티쓰레딩은 하나의 프로세스 내에서 여러 쓰레드가 동시에 작업을 수행하는 것이다.
CPU의 코어(core)가 한 번에 단 하나의 작업만 수행할 수 있으므로, 실제로 동시에 처리되는 작업의 개수는 코어의 개수와 일치한다. 그러나 처리해야 하는 쓰레드의 수는 항상 코어의 수보다 많기 때문에 각 코어가 짧은 시간 동안 여러 작업을 번갈아 가며 수행함으로써 여러 작업들이 모두 동시에 수행되는 것처럼 보이게 한다.
프로세스의 성능이 단순히 쓰레드의 개수에 비례하는 것은 아니며, 하나의 쓰레드를 가진 프로세스 보다 두 개의 쓰레드를 가진 프로세스가 오히려 더 낮은 성능을 보일 수도 있다.
장점
단점
Java에서의 Thread api 주석을 확인하면 다음과 같이 설명되어 있다.
클래스를 Thread의 자식 클래스로 선언하는 방법이다.
자식 클래스는 실행 메소드(run 메소드)를 재정의 해야한다.
그 다음 클래스의 인스턴스를 할당하고 실행할 수 있다.
public class ThreadDemo {
public static void main(String[] args) {
// 상속으로 구현
ThreadByInheritance thread1 = new ThreadByInheritance();
thread1.start();
}
}
class ThreadByInheritance extends Thread {
@Override
public void run() {
for (int i = 0; i < 100; i++) {
System.out.print(0);
}
}
}
클래스를 Runnable 인터페이스를 구현하는 클래스로 선언하는 방법이다.
해당 클래스는 run() 메소드를 구현한 뒤 hread를 만들 때 인수로 전달하고 시작할 수 있다.
public class ThreadDemo {
public static void main(String[] args) {
//인터페이스로 구현
Runnable r = new ThreadByImplement();
Thread thread = new Thread(r); //생성자: Thread(Runnable target)
// 아래 코드로 축약 가능
// Thread thread2 = new Thread(new ThreadByImplement());
thread.start();
}
}
class ThreadByImplement implements Runnable {
@Override
public void run() {
for (int i = 0; i < 500; i++) {
System.out.print(1);
}
}
}
Thread 클래스가 다른 클래스를 확장할 필요가 있을 경우에는 Runnable 인터페이스를 구현하면 되며, 그렇지 않은 경우는 Thread 클래스를 사용하는 것이 좋다.
스레드는 다음과 같은 상태중 하나를 가진다.
new
runnable
blocked
waiting
timed-waiting
terminated
쓰레드를 실행하기 위해서는 start 메서드를 통해 해당 쓰레드를 호출해야 한다. start 메서드는 쓰레드가 작업을 실행할 호출 스택을 만들고 그 안에 run 메서드를 올려주는 역할을 한다.
start를 호출하지 않고 run을 호출하면, 새로운 호출 스택이 생성되지 않기 때문에, 그냥 한 메서드 안에서 코드를 실행하는 것과 같다.
쓰레드는 우선순위(priority)라는 멤버 변수를 갖고 있다.
각 쓰레드별로 우선순위를 다르게 설정해줌으로써 어떤 쓰레드에 더 많은 작업 시간을 부여할 것인가를 설정해줄 수 있다.
우선순위는 1 ~ 10 사이의 값을 지정해줄 수 있으며 기본값으로 5가 설정되어 있고 높을 수록 우선순위가 높다.
public class Thread implements Runnable {
void setPriority(int newPriority) // 쓰레드의 우선순위를 지정한 값으로 변경한다.
int getPriority() // 쓰레드의 우선순위를 반환한다.
public static final int MIN_PRIORITY = 1; // 최소 우선순위
public static final int NORM_PRIORITY = 5; // 보통 우선순위
public static final int MAX_PRIORITY = 10; // 최대 우선순위
}
setPriority 메서드는 쓰레드를 실행하기 전에만 호출할 수 있다.
그런데 주의할 점은 이것이 반드시 보장되는 것은 아니다. 쓰레드의 작업 할당은 OS의 스케쥴링 정책과 JVM의 구현에 따라 다르기 때문에 코드에서 우선순위를 지정하는 것은 단지 희망사항을 전달하는 것일 뿐, 실제 작업은 내가 설정한 우선 순위와 다르게 진행될 수 있다.
public static void main(String args[]){
System.out.println("Hello World!")
}
Java는 main 메소드를 통해서 실행하게 된다. main 쓰레드는 프로그램이 시작하면 가장 먼저 실행되는 쓰레드이며, 모든 쓰레드는 main 쓰레드로부터 생성된다.
이를 싱글 스레드라고도 하는데 메인 쓰레드가 종료 되면, 프로세스 자체도 종료된다. 이런 메인 쓰레드 구조에서 작업 쓰레드를 여러개 생성하여, 멀티 쓰레드를 구성 할 수 있다.
쓰레드는 '사용자 쓰레드(user thread)'와 '데몬 쓰레드(daemon thread)'로 구분되는데, 실행 중인 사용자 쓰레드가 하나도 없을 때 프로그램이 종료된다.
데몬 쓰레드는 main 쓰레드를 보조하는 쓰레드를 이야기 한다. 보조를 하는 역할 이기 때문에, 메인쓰레드가 종료되면 데몬 쓰레드도 강제적으로 종료된다.
public static void main(String[] args) {
Thread th = new ThreadExample();
th.setDaemon(true); // 데몬쓰레드들로 만들기.
th.start();
}
여러 개의 쓰레드가 한 개의 리소스를 사용하려고 할 때 사용 하려는 쓰레드를 제외한 나머지들을 접근하지 못하게 막는 것이다.
자바에서 동기화 하는 방법은 3가지로 분류된다
public class CommonCalculate {
private int amount;
public CommonCalculate() {
amount=0;
}
public synchronized void plus(int value) {
amount += value;
}
public void minus(int value) {
synchronized (this){
amount -= value;
}
}
}
위와 같이 method 수준, 코드 레벨 수준에서 동기화 작업이 가능하다.
public class AtomicTypeSample {
public static void main(String[] args) {
AtomicLong atomicLong = new AtomicLong();
AtomicLong atomicLong1 = new AtomicLong(123);
long expectedValue = 123;
long newValue = 234;
System.out.println(atomicLong.compareAndSet(expectedValue,newValue));
atomicLong1.set(234);
System.out.println(atomicLong1.compareAndSet(234,newValue));
System.out.println(atomicLong1.compareAndSet(expectedValue,newValue));
System.out.println(atomicLong.get());
System.out.println(atomicLong1.get());
}
}
>>>false
>>>true
>>>false
>>>0
>>>234
volatile keyword 는 Java 변수를 Main Memory에 저장하겠다라는 것을 명시하는것이다.
매번 변수의 값을 Read할 때마다 CPU cache에 저장된 값이 아닌 Main Memory에서 읽는다. 또한 변수의 값을 Write할 때마다 Main Memory 에 까지 작성한다.
volatile 변수를 사용하고 있지 않는 MultiThread 애플리케이션은 작업을 수행하는 동안 성능 향상을 위해서 Main Memory에서 읽은 변수를 CPU Cache에 저장하게 된다
만약 Multi Thread환경에서 Thread가 변수 값을 읽어올 때 각각의 CPU Cache에 저장된 값이 다르기 때문에 변수 값 불일치 문제가 발생하게 된다.
ex)
public class SharedObject {
public volatile int counter = 0;
}
Multi Thread 환경에서 하나의 Thread만 read & write하고 나머지 Thread 가 read하는 상황에서 가장 최신의 값을 보장한다
volatile는 변수의 read와 write 를 Main Memory 에서 진행하게 된다
CPU Cache 보다 Main Memory가 비용이 더 크기 때문에 변수 값 일치를 보장해야 하는 경우에 volatile 을 사용하는 것이 좋다
멀티 쓰레드가 실행될 때 이 두가지 중 하나로 실행된다.
이것은 CPU의 코어의 수와도 연관이 있는데, 하나의 코에서 여러 쓰레드가 실행되는 것을 "동시성", 멀티 코어를 사용할 때 코어별로 개별 쓰레드가 실행되는 것을 "병렬성"이라고 한다.
만약 코어의 수가 쓰레드의 수보다 많다면, 병렬성으로 쓰레드를 실행하면 되는데, 코어의 수보다 쓰레드의 수가 더 많을 경우 "동시성"을 고려하지 않을 수 없다.
2개 이상의 프로세스가 다른 프로세스의 작업이 끝나기만 기다리며 작업을 더 이상 진행하지 못하는 상태를 교착 상태(dead lock)라고 한다.
교착상태가 발생하기 위해서는 아래의 4가지 조건을 만족해야 한다.
비선점
점유와 대기
원형 대기
교착 상태 예방
교착 상태 회피
교착 상태 검출과 회복
교착 상태를 검출한 후 이를 회복시키는 것은 결론적으로 교착 상태를 해결하는 현실적인 접근 방법이다.
JDK 1.7부터 'fork & join 프레임워크' 가 추가 되어, 하나의 작업을 작은 단위로 쪼개서 여러 쓰레드가 동시에 처리하는 것을 쉽게 만들어 준다.
수행할 작업에 따라 아래의 두 클래스 중에서 하나를 상속받아 구현한다.
RecursiveAction 반환값이 없는 작업을 구현할 때 사용
RecursiveTask 반환값이 있는 작업을 구현할 때 사용
위의 두 클래스를 상속받아 compute() 라는 추상 메소드에 작업할 내용으로 재정의 하면 된다.
ex) 1부터 n까지의 합을 계산한 결과를 반환
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;
}
}
invoke() 메소드를 호출해서 작업을 시작
ForkJoinPool pool = new ForkJoinPool(); // 쓰레드 풀을 생성
SumTask task = new SumTask(from, to); // 수행할 작업을 생성
Long result = pool.invoke(task); // invoke() 를 호출해서 작업을 시작
fork&join프레임워크에서 제공하는 쓰레드 풀(thread pool)이다.
장점
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()는 작업을 반으로 나누고 fork()는 작업 큐에 작업을 담는다.
위는 compute() 메소드와 fork() 메소드로 인해 작업풀에 담긴 작업이 쓰레드 풀(thread pool)의 빈 쓰레드가 작업을 가져와서 작업을 수행하는 것을 나타낸 그림이다.
빈 쓰레드가 작은 단위의 작업을 가져와서 작업을 수행하는 것을 작업 훔쳐오기(work stealing)라고 하며, 이 과정은 모두 쓰레드 풀에 의해 자동으로 이루어 진다.
이런 과정을 통해 한 쓰레드에 작업이 몰리지 않고 여러 쓰레드가 골고루 작업을 나누어 처리하게 된다.
물론 작업의 크기가 충분히 작게 나눠져야 여러 쓰레드에게 작업을 골고루 나눠줄 수 있다.
fork() : 해당 작업을 쓰레드 풀의 작업큐에 넣는다. 비동기 메소드(asynchronous method)
join() : 해당 작업의 수행이 끝날 때까지 기다렸다가, 수행이 끝나면 그 결과를 반환한다.동기 메소드(synchronous method)
return rightSum.compute() + leftSum.join();
이 return 문에서 compute()가 재귀호출 될 때, join()은 호출되지 않는다. compute()로 더이상 작업을 나눌 수 없게 됐을 때 join()의 결과를 기다렸다가 더해서 결과를 반환한다.
즉, 재귀 호출된 모든 compute()가 모두 종료될 때, 최종 결과를 얻는다.
참조