Search
Duplicate
😀

10. 멀티 스레드 프로그래밍

태그
스레드 우선순위
데드락
동기화
Thread & Runnable

프로세스, 스레드, 멀티스레드

1. 프로세스(Process)

실행중인 프로그램을 뜻한다. 쉽게 확인할 수 있는 방법은 작업 관리자에 들어가 프로세스 텝을 확인하면 내 컴퓨터에 얼마나 많은 프로세스들이 있는지 확인할 수 있다.

2. 스레드 (Thread)

프로세스 내에서 실행되고 있는 흐름의 단위

3. 멀티스레드 (Multi-Thread)

멀티태스킹은 여러개의 프로세스를 동시에 실행하는 것이다. 그렇다면, 왜 굳이 멀티태스킹(멀티프로세스)로 처리하면 될 것을 다시 쓰레드까지 쪼개서 처리해야 될까?
문제는 바로 프로세스를 호출 시 발생하는 Context switch(문맥교환)에 있다. 프로세스는 호출할 때마다 문맥교환이라는 오버헤드가 발생하는데 스레드로 처리를 하면 프로세스 끼리 통신하는 비용보다 통신 비용이 적고, 문맥교환이 적게 발생하기 때문에 보다 효율적인 작업이 가능하다.
[ 멀티쓰레드의 장점 ]
CPU의 사용률을 향상시킨다.
자원을 보다 효율적으로 사용할 수 있다.
사용자에 대한 응답성이 향상된다.
작업이 분리되어 코드가 간결해진다.
[ 멀티쓰레드의 단점 ]
여러 개의 쓰레드가 같은 프로세스 내에서 자원을 공유하면서 작업하기 때문에 동기화(synchronization)교착상태(deadlock)와 같은 문제가 발생할 확률이 높다.

메인 스레드(Main Thread)

메인 쓰레드는 프로그램이 시작하면 가장 먼저 실행되는 쓰레드이며, 모든 쓰레드는 메인 쓰레드로부터 생성된다.
다른 쓰레드를 생성해서 실행하지 않으면, 메인 메서드, 즉 메인 쓰레드가 종료되는 순간 프로그램도 종료된다.
하지만 여러 쓰레드를 실행하면, 메인 쓰레드가 종료되어도 다른 쓰레드가 작업을 마칠 때까지 프로그램이 종료되지 않는다.
쓰레드는 '사용자 쓰레드(user thread)'와 '데몬 쓰레드(daemon thread)'로 구분되는데,실행 중인 사용자 쓰레드가 하나도 없을 때 프로그램이 종료된다.

데몬 스레드(Daemon Thread)

쓰레드의 종류는 일반 쓰레드와 데몬 쓰레드로 나뉜다. 데몬 쓰레드는 일반 쓰레드의 보조 역할을 수행하는 쓰레드이다. 일반 쓰레드가 종료되면 데몬 쓰레드는 강제적으로 종료된다. 주로 가비지 컬렉터, (워드 등의) 자동저장, 화면 자동갱신 등에 사용된다.
데몬 쓰레드는 일반 쓰레드가 종료되면 같이 종료되기 때문에 일반적으로 무한 루프로 구현한다. 데몬 쓰레드는 일반 쓰레드와 작성방법과 실행 방법이 같다. 단 쓰레드를 생성한 다음 setDaemon(true)를 호출하기만 하면 된다. 또 데몬 쓰레드가 생성한 쓰레드는 자동적으로 데몬 쓰레드가 된다.
boolean isDaemon() // 쓰레드가 데몬 쓰레드인지 아닌지를 반환한다. void setDaemon(boolean on) // 쓰레드를 데몬 쓰레드 혹은 사용자 쓰레드로 변경한다.
Java
복사

스레드의 생성(Thread Class, Runnable Interface)

자바에선 쓰레드를 생성하는 방법이 2가지가 있다. 하나는 Thread 클래스를 상속받아서 사용하는 것이고, 나머지 하나는 Runnable 인터페이스를 구현하는 방법이다.
Thread Class는 상속(extends)을 받아서 사용해야 하므로 다른 클래스를 상속받을 수 없다.
반면, Runnable Interface는 구현(implements)해서 사용하므로 필요하다면 다른 클래스를 상속받아서 재사용성과 코드의 일관성을 높일 수 있다.
Thread는 한번 사용하면 재사용이 불가능하지만, Runnable을 이용하여 Thread를 구현하면 재사용할 수 있다. 그렇기 떄문에 인터페이스를 구현하는 방법이 일반적이다.

Thread Class를 통한 Thread 생성

(1)    Thread 클래스를 상속 받아서 내가 시킬 일을 해줄 클래스를 하나 만든다.
(2)    위에서 만든 클래스에 run() 메소드 안에 시킬 일을 정의해 준다.
(3)    메인스레드에서 위에서 만든 클래스의 start() 메소드를 호출한다.
// java.lang.Thread 클래스를 상속받아 사용자 정의 Thread 클래스를 생성할 수 있다. class Hamburger extends Thread { // 이 클래스에서 상위 클래스인 Thread의 run() 메소드를 재정의를 해야 Thread의 실행부분을 작성할 수 있다. @Override public void run() { super.run(); System.out.println("Hamburger 나왔습니다."); } } public class ThreadExample{ public static void main(String[] args) { Hamburger hamburger = new Hamburger(); // Thread 객체를 생성한 후 hamburger.start(); // start() 메소드를 호출하면 thread가 실행된다. 여기서 start() 메소드는 쓰레드 객체의 run() 메소드를 호출한다. } }
Java
복사

Runnable interface를 통한 Thread 생성

Runnable은 오로지 run() 메서드만 구현되어 있는 함수형 인터페이스이다.
(1)    우선 Runnable인터페이스를 구현하는 클래스를 하나 만든다.
(2)    위에서 만든 클래스의 run() 메소드에 시킬 일을 정의한다.
(3)    위에서 만든 클래스를 생성하고, Thread를 생성할 때 인자로 넣어서 생성한다.
(4)    Thread의 start() 메소드를 호출하여 새로운 스레드가 만들어져서 실행되도록 한다.
// Runnable 인터페이스를 implements 키워드를 이용하여 구현하면 된다. class Hamburger implements Runnable { @Override public void run() { System.out.println("Hamburger 나왔습니다."); } } public class MultiThreadExample{ public static void main(String[] args) { // Runnable 인터페이스를 구현해 Thread를 만들 경우 // 객체 생성을 Thread 클래스 타입으로 만들어 줘야 한다. // run() 메소드의 트리거 역할을 하는 start()는 Thread 클래스에 정의되어있기 때문에 반드시 객체 생성시 Thread 타입으로 만들어야 한다. Thread hamburger = new Thread(new Hamburger()); hamburger.start(); } }
Java
복사

Start(), run()

쓰레드를 실행하기 위해서는 start 메서드를 통해 해당 쓰레드를 호출해야 한다. start 메서드는 쓰레드가 작업을 실행할 호출 스택을 만들고 그 안에 run 메서드를 올려주는 역할을 한다.
위 예제에서 start를 호출하지 않고 run을 호출하면, 새로운 호출 스택이 생성되지 않기 때문에, 그냥 한 메서드 안에서 코드를 실행하는 것과 같다.

스레드의 상태

스레드 객체를 생성하고, start() 메소드를 호출하면 스레드는 실행 대기 상태가 된다. 실행 대기 상태란 아직 스케줄링이 되지 않아서 실행을 기다리고 있는 상태를 말한다.
실행 대기 상태에 있는 스레드 중에서 스레드 스케줄링으로 선택된 스레드가 비로서 CPU를 점유하고 run() 메소드를 실행한다. 이때를 실행(Running) 상태라고 한다.
실행 상태의 스레드는 run() 메소드를 모두 실행하기 전에 스레드 스케줄링에 의해 다시 실행 대기 상태로 돌아갈 수 있다. 그리고 실행 대기 상태에 있는 다른 스레드가 선택되어 실행 상태가 된다. 이렇게 스레드는 실행 대기 상태와 실행 상태를 번갈아가며 자신의 run() 메소드를 조금씩 실행한다.
실행 상태에서 run() 메소드가 종료되면, 더 이상 실행할 코드가 없기 때문에 스레드의 실행은 멈춘다. 이 상태를 종료 상태라고 한다.
쓰레드 클래스에 있는 getState() 메소드를 사용하면 Thread.State 열거 상수를 리턴한다.

스레드의 상태 제어

실행 중인 스레드의 상태를 변경하는 것을 스레드 상태 제어라고 한다. 아래 그림은 상태 변화를 가져오는 메소드의 종류를 보여준다. 취소선(-)을 가진 메소드는 스레드의 안전성을 해치어 더 이상 사용하지 않도록 권장되는 Deprecated 메소드들이다.
아래 그림에서 wait(), notify(), notifyAll은 Object 클래스의 메소드이고, 그 이외의 메소드는 모두 Thread 클래스의 메소드들이다.

주어진 시간동안 일시 정지 - sleep()

Thread.sleep() 메소드를 호출한 스레드는 주어진 시간 동안 일시 정지 상태가 되고, 다시 실행 대기 상태로 돌아간다.

다른 스레드에게 실행 양보 - yield()

스레드가 처리하는 작업은 반복적인 실행을 위해 for문, while문을 포함하는 경우가 많다. 이때 무의미한 반복을 하는 경우도 있다.
public void run(){ while(true){ if(work){ System.out.println(ThreadA 작업 내용”); } } }
Java
복사
위 코드는 스레드가 시작되어 run() 메소드를 실행하면 while 블록이 무한 반복된다. 만약 work 값이 false이고, 해당 값이 false에서 true로 변경되는 시점이 불명확하면, while문은 어떠한 실행문도 실행하지 않고 무의미한 반복을 한다.
이것보다는 yield() 메소드를 사용하여 다른 스레드에게 실행을 양보하고 자신은 실행 대기 상태로 가는 것이 프로그램 성능에 도움이 된다. yield() 메소드를 호출한 스레드는 실행 대기 상태로 돌아가고 동일한 우선순위 또는 높은 우선순위를 갖는 다른 스레드가 실행 기회를 가질 수 있다.
public class ThreadA extends Thread{ public boolean stop = false; //종료 플래그 public boolean work = true; //작업 진행 여부 플래그 public void run(){ while(!stop){ //stop이 true가 되면 while 종료 if(work){ System.out.println("ThreadA 작업 내용"); }else{ Thread.yield(); //work가 false가 되면 다른 스레드에게 실행 양보 } } } }
Java
복사

다른 스레드의 종료를 기다림 - join()

다른 스레드가 종료될 때까지 기다렸다가 스레드를 실행할 경우 join() 메소드를 사용한다.
예를 들어 ThreadA가 ThreadB의 join() 메소드를 호출하면 ThreadA는 ThreadB가 종료할 때까지 일시 정지 상태가 된다. ThreadB의 run() 메소드가 종료되면 비로소 ThreadA는 일시 정지에서 풀려 다음 코드를 실행한다.

스레드 간 협업 - wait(), notify(), notifyAll()

두 개의 스레드를 교대로 번갈아가며 실행해야 할 경우 자신의 작업이 끝나면 상대방 스레드를 일시 정지 상태에서 풀어주고, 자신은 일시 정지 상태로 만들어야한다. 이 방법의 핵심은 공유 객체에 있다.
공유 객체는 두 스레드가 작업할 내용을 각각 동기화 메소드로 구분해 놓는다. 한 스레드가 작업을 완료하면 notify() 메소드를 호출해서 일시 정지 상태에 있는 다른 스레드를 실행 대기 상태로 만들고, 자신은 두 번 작업을 하지 않도록 wait() 메소드를 호출하여 일시 정지 상태로 만든다.
만약 wait() 대신 wait(long timeout)이나, wait(long timeout int nanos)를 사용하면 notify()를 호출하지 않아도 지정된 시간이 지나면 스레드가 자동적으로 실행 대기 상태가 된다.
notify() 메소드와 동일한 역할을 하는 notifyAll() 메소드도 존재한다. notify()는 wait()에 의해 일시 정지된 스레드 중 한 개를 실행 대기 상태로 만들고, notifyAll 메소드는 wait()에 의해 일시 정지된 모든 스레드들을 실행 대기 상태로 만든다.
이 메소드들은 Thread 클래스가 아닌 Object 클래스에 선언된 메소드이므로 모든 공유 객체에서 호출이 가능하다. 주의할 점은 이 메소드들은 동기화 메소드 또는 동기화 블록 내에서만 사용할 수 있다.

스레드의 안전한 종료 - stop 플래그, interrupt()

Thread 클래스를 스레드를 즉시 종료시키기 위해 stop() 메소드를 제공하지만, 스레드가 사용 중이던 자원(파일, 네트워크 연결)을 불안전한 상태로 남기는 문제가 있어 dreprecated 되었다.
이러한 문제를 해결하고 스레드를 즉시 종료시키는 방법으로 stop 플래그를 이용하는 방법, interrupt() 메소드를 이용하는이 있다.
stop 플래그를 이용한 스레드 종료
스레드는 run() 메소드가 끝나면 자동적으로 종료되므로, run() 메소드가 정상적으로 종료되도록 유도하는 것이 최선의 방법이다.
interrupt() 메소드를 이용한 스레드 종료
interrupt() 메소드는 스레드가 일시 정지 상태에 있을 때 InterruptedException 예외를 발생시키는 역할을 한다. 이것을 이용하면 run() 메소드를 정상 종료시킬 수 있다.
다음과 같이 ThreadA가 ThreadB의 interrupt() 메소드를 실행하면, ThreadB가 sleep() 메소드로 일시 정지 상태가 될 때 ThreadB에서 InterruptedException()이 발생하여 예외 처리(catch) 블록으로 이동한다.

스레드의 우선 순위

멀티 스레드는 동시성(Cuncurrency) 또는 병렬성(Parallelism)으로 실행된다.
동시성은 멀티 작업을 위해 하나의 코어에서 멀티 스레드가 번갈아가며 실행하는 설정이다.
병렬성은 멀티 작업을 위해 멀티 코어에서 개별 스레드를 동시에 실행하는 성질이다.
스레드의 개수가 코어의 수보다 많은 경우, 스레드를 어떤 순서에 의해 동시성으로 실행할 것인가 결정해야 한다. 이것을 스레드 스케줄링이라고 한다. 스레드 스케줄링에 의해 스레드들은 아주 짧은 시간 번갈아가며 본인들의 run() 메소드를 조금씩 실행한다.
자바의 스레드 스케줄링은 우선순위(Priority) 방식과 순환 할당(Round-Robin) 방식을 사용한다.
우선순위 방식은 우선순위가 높은 스레드가 실행 상태를 더 많이 가지도록 스케줄링하는 것이다.  순환 할당 방식은 시간 할당량(Time Slice)을 정해서 하나의 스레드를 정해진 시간만큼 실행하고 다시 다른 스레드를 실행하는 방식을 말한다.
스레드 우선순위 방식은 스레드 객체에 우선순위 번호를 부여할 수 있기 때문에 개발자가 코드로 제어할 수 있다. 하지만 순환 할당 방식은 JVM에서 정해지므로 코드로 제어할 수 없다.
우선순위는 1에서 10까지 부여된다. 1이 가장 우선순위가 낮고, 10이 가장 높다. 우선순위를 부여하지 않으면 모든 스레드는 5의 우선순위를 할당받는다. 우선순위를 변경하고 싶다면 Thread 클래스가 제공하는 setPriority() 메소드를 사용한다. 이때 우선순위 매개값으로 1~10까지 값을 직접 주어도 되지만 ,코드 가독성을 높이기 위해 Thread 클래스의 상수를 사용할 수도 있다.
thread.setPriority(Thread.MAX_PRIORITY); // 10의 우선순위 thread.setPriority(Thread.NORM_PRIORITY); // 5의 우선순위 thread.setPriority(Thread.MIN_PRIORITY); // 1의 우선순위
Java
복사

스레드의 동기화

멀티 스레드 프로그램에서는 스레드들이 객체를 공유해서 작업해야 하는 경우가 있다. 이 경우 스레드 A를 사용하던 객체가 스레드 B에 의해 상태가 변경될 수 있기 때문에 스레드 A가 의도했던 것과 다른 결과를 산출할 수 있다.
스레드가 사용 중인 객체를 다른 스레드가 변경할 수 없도록 하려면, 스레드 작업이 끝날 때까지 객체에 잠금을 걸어 다른 스레드가 사용할 수 없도록 해야 한다.
멀티 스레드 프로그램에서 단 하나의 스레드만 실행할 수 있는 영역을 임계 영역(critical section)이라 한다. 자바는 임계 영역을 지정하기 위해 동기화(synchronized) 메소드와 동기화 블록을 제공한다. 스레드가 객체 내부의 동기화 메소드 또는 블록에 들어가면 즉시 객체에 잠금을 걸어 다른 스레드가 임계 영역 코드를 실행하지 못하도록 한다.
// synchronized 키워드를 사용한 동기화 메소드 public synchronized void method(){ //임계 영역, 단 하나의 스레드만 실행 } // 동기화 블록 public void method(){ //여러 스레드가 실행 가능 영역 synchronized(공유객체){ //공유 객체가 자신이면 this를 넣을 수 있다. //임계 영역, 단 하나의 스레드만 실행 } //여러 스레드가 실행 가능 영역 }
Java
복사

데드락

프로세스가 자원을 얻지 못해서 다음 처리를 하지 못하는 상태, 교착상태 라고도 부른다
프로세스는 자원을 요청하고 얻지 못하면 대기상태(Waiting)으로 들어가는데 자원을 얻지 못해 영원히 waiting 상태로 대기하고 실행하지 못할 때 DeadLock이 발생했다고 한다.
시스템적으로 한정된 자원을 여러 곳에서 사용하려고 할 때 발생한다.
데드락 이론은 OS의 데드락과 동일하다. 기존에 작성한 블로그의 글을 첨부함