Process란 실행중인 프로그램 자체를 생각하면 된다. Eclipse같은 IDE, 지금 사용중인 Browser, 문서 편집을 위한 Word 등이 모두 Process다. Process는 프로그램 실행에 필요한 모든 데이터, 메모리 등 자원과 thread로 구성된다.
그럼 Multi Process란 뭘까? 이는 동시에 여러 개의 프로세스를 실행시키는 것이다. 음악을 들으면서 Eclipse로 작업 하는 것이 대표적인 Multi Process 환경이다. 우리가 사용하는 OS들은 대부분 Multi Process를 지원한다.
이때 실행되는 Process들은 각자 고유의 자원, 데이터, 스레드 들로 구성되며 이들은 서로 공유하지 않는다.
Thread
Thread는 프로그램 실행의 최소 단위로 모든 Process들은 최소 하나 이상의 Thread로 구성된다. 언제나 있는 기본 Thread를 Main Thread라고 하고 둘 이상의 Thread로 구성된 Process를 Multi Thread Process라고 한다.
Multi Thread는 하나의 Process 내에서의 동작을 의미한다. 메신저를 쓸때 채팅을 하면서 동시에 파일을 다운로드 받는것, 워드 작업을 하는 과정에 자동 저장이 일어나는 것들이 Multi Thread에 의한 동작이다.
동시성(Concurrent)와 병렬성(Parallel)
여기서 "동시에" 라는 용어가 나오는데 이 용어에 대한 정확한 정의가 필요하다. Thread 프로그래밍을 하면서 자주 접하는 용어인 동시성과 병렬성이 잘못 사용되는 경우가 있는데, 확실히 정리하고 넘어가자.
동시성(Concurrent): 많은 클라이언트를 동시에 처리하는 것이 목표다. 동시성은 여러 작업이 동시에 실행되는 것처럼 보이지만 실제로는 아주 짧은 시간 단위로 번갈아 가며 실행된다. 이를 Context Switching이라고 한다. 예를 들어, 한 사람이 여러 사람의 전화를 번갈아 가며 받는 경우가 바로 동시성에 해당한다. Thread 클래스를 이용해서 처리한다.
병렬성(Parallel): 여러 작업을 실제로 동시에 실행하는 것이 목표다. 이를 통해 개별 클라이언트의 응답성이 향상된다. 병렬성은 실제로 여러 작업을 동시에 실행하기 위해서 Multi Core CPU를 사용한다. 예를 들어, 여러 사람이 각각 다른 전화를 동시에 받는 경우를 의미한다. ForkJoinPool이나 ExecutorService를 통해 처리하며 parallelStream이 이해 해당한다.
멀티 스레드의 장/단점
장점
1. CPU의 사용률 향상: 멀티 스레드를 사용하면 CPU가 한가할만 하면 그 틈에 다른 작업을 계속 처리하게 한다. 따라서 CPU의 사용률을 향상시킬 수 있다. 일반적으로 순차 작업에서는 CPU가 대기 상태에 있는 시간이 많아 자원을 충분히 활용하지 못하는 경우가 많다. 하지만 멀티 스레드는 동시성을 가지므로 대기 시간을 최소화하고 CPU를 좀 더 열일 할 수 있게 한다.
2. 작업의 분리로 사용자 응답성 개선: 멀티스레드는 여러 작업을 동시에 처리할 수 있기 때문에 사용자 인터페이스가 멈추지 않고 부드럽게 동작할 수 있게 한다. 또는 아주 큰 파일을 다운로드 받는작업의 경우도 마찬가지이다. 다운로드 작업과 다른 작업을 스레드로 분리하지 않는다면 다운로드가 다 끝날 때까지 다른 작업은 진행하지 못한다.
3. 자원의 공유를 통해 효율적으로 활용: 스레드는 하나의 heap 공간을 공유한다. 따라서 공유데이터를 사용하면 메모리 사용량을 줄일 수 있고 데이터 접근 속도도 향상된다.
단점
1. Context Switching에 따른 비용 발생: 여러 스레드가 동시에 실행되는 것 처럼 보이려면 CPU는 스레드간 컨텍스트 스위칭을 자주 수행해야 하는데 이 과정에서 성능 저하가 발생할 수 있다.
2. 스레드 제어의 어려움: 멀티 스레드 프로그래밍은 코드의 복잡성을 크게 증가시킨다. 스레드 간의 동기화, 상태 관리, 데드락 방지 등 고려해야할 요소가 많아지고 당연히 디버깅과 유지보수가 어려워진다.
스레드의 생성와 실행
스레드의 생성
스레드는 Runnable interface를 사용하거나 Thread를 상속받아서 사용할 수 있다.
먼저 Runnable interface를 사용하는 방법을 살펴보자. Runnable은 @FunctionalInterface로 run() 메서드를 재정의 한 후 Thread 의 생성자 파라미터로 공급해주면 된다.
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for(int i=0; i<30; i++) {
System.out.print("-");
}
}
});
Thread t2 = new Thread(()->{
System.out.println("Hello");
});
또다른 방법은 Thread를 상속받아서 사용하는 방법이다. Thread가 어차피 Runnable을 구현하고 있기 때문에 그냥 run 메서드만 재정의해주면 된다.
class MyThread extends Thread{
public void run() {
for(int i=0; i<30; i++) {
System.out.print("@");
}
}
}
Thread t3 = new MyThread();
Runnable을 구현하는 경우 code가 복잡해질 수 있지만 Thread를 상속받는 경우는 더 이상 다른 클래스를 상속받을 수 없기 때문에 확장성에 문제가 생길 수 있다.
스레드 실행
스레드를 실행시키는 방법은 살짝 의외이다. 우리가 앞서 재정의한 메서드는 분명 run() 이지만.
t1.start();
t3.start();
System.out.println("main is over");
호출한 코드는 run()이 아니라 start이다. 그리고 또 충격적인 것은 출력 결과이다. 분명 t1과 t2를 순서대로 실행 시키고 나서 'main is over'를 출력했지만 실행된 코드를 살펴보면 머리가 혼란스럽다.
main is over
--------@@------------------@@@@@@----@@@@@@@@@@@@@@@@@@@@@@
main is over를 맨 마지막에 출력했으나 맨 처음 출력됨, 프로그램이 종료된 후 다른 내용이 출력됨
-와 @가 섞여서 나타남
실행할 때마다 출력 결과가 달라짐
start()와 run()의 관계
일단 스레드 동작 방식에 대한 궁금증을 뒤로하고 start()와 run()에 대해서 좀 더 살펴보자.
start(): start()는 run()이 호출 돌 수 있도록 준비만 하는 것으로 실제 스레드를 동작시키는 것은 아니다.
run(): 스레드에서 하고싶은 작업을 정리한 것으로 준비된 스레드가 동작하면서 실행하는 메서드이다.
즉 start()가 되야 run()을 호출될 수 있고 run()이 동작해야 어떤 작업이 가능한데 우리는 run()을 호출한 적이 없다. 도대체 run()은 누가 , 언제 호출하는 것일까? 정답은 JVM이 OS의 스레드 스케줄러에 의해 호출한다.
멀티 스테드와 메모리
자바에서 메모리 구조를 이야기할 때 크게 스택과 힙으로 나누는데 스택은 스레드 스텍이라고 부른다. 즉 스레드 별로 하나씩 스택이 생기고 힙은 하나를 공유한다.
다음 그림의 좌측은 main 스레드와 t1 스레드가 어떻게 동작하는지를 보여준다. 스레드가 생성되면 기존 스레드와 별도의 스택에서 새 살림을 차리게 된다. 프로세스를 구성하고 있는 모든 스레드가 종료되어야 프로세스가 종료되게 되는데 main 스레드가 먼저 종료되고 t1 스레드가 나중에 종료될 수도 있다. 그래서 이전 예에서 'main is over'가 출력된 후에 '-' 또는 '@'가 출력된 것이다.
만약 thread의 run()을 직접 호출해버리면 어떻게 될까? 그것은 스레드로써 동작시키는 것이 아니라 단순한 메서드의 실행이 된다.