프로세스와 쓰레드

정순동·2024년 1월 3일
0

자바기초

목록 보기
71/89

프로세스(process)는 간단히 말해 '실행 중인 프로그램'이다. 프로그램을 실행하면 OS로부터 실행에 필요한 자원을 할당받아 프로세스가 된다.

프로세스는 프로그램을 수행하는 데 필요한 데이터와 메모리 등의 자원 그리고 쓰레드로 구성돼 있으며, 프로세스의 자원을 이용해서 실제로 작업을 수행하는 것이 바로 쓰레드이다. 이 때, 둘 이상의 쓰레드를 가진 프로세스를 '멀티쓰레드 프로세스'라고 한다.

이전 서버는 CGI로 작성됐는데 이는 멀티쓰레드를 지원하지 않아 여러 요청이 들어오는 만큼 프로세스를 생성했어야 했다. 인터넷에 대한 수요가 폭발적으로 증가하던 95~00년대에 멀티쓰레드를 지원하는 자바서블릿에 대한 개발수요가 증가하면서 자바는 유명해지는 계기가 되었다.

멀티쓰레딩의 장단점

대부분의 프로그램은 멀티쓰레드로 작성돼 있으나, 멀티쓰레딩이 꼭 장정만 있는것은 아니다.

멀티쓰레딩의 장점

  • CPU의 사용률을 향상시킨다.
  • 자원을 보다 효율적으로 사용할 수 있다.
  • 사용자에 대한 응답성이 향상된다.
  • 작업이 분리돼 코드가 간결해진다.

DOS와 같은 OS는 한 번에 한 가지 작업만 할 수 있다. 따라서 한개의 명령이 끝나야 다른 명령을 처리할 수 있다. 하지만 윈도우와 같은 멀티태스킹이 가능한 OS는 동시에 어러 작업을 수행할 수 있다.

이렇게 멀티 쓰레딩을 사용하면 사용자의 요청마다 새로운 프로세스를 생성할 필요가 없기 때문에 많은 수의 사용자 요청에 유리하다.

쓰레드를 가벼운 프로세스, 즉 경량 프로세스(LWP, light-weight process)라고 부르기도 한다.

멀티쓰레딩의 단점

  • 동기화(synchronization)에 주의해야 한다.
  • 교착상태(dead-lock)가 발생하지 안도록 주의해야 한다.
  • 각 쓰레드가 효율적으로 고르게 실행될 수 있도록 해야한다.

멀티쓰레딩은 자원을 공유하므로 공유하면서 일어날 수 있는 일들을 고려해야 하는게 단점이라고 볼 수 있다. 프로그래밍을 할 때 고려해야 할 점이 많아진다는 것이다.

쓰레드의 구현과 실행

쓰레드를 구현하는 방법은 Thread클래스를 상속받거나 Runnable 인터페이스를 구현하는 방법, 총 두 가지가 있다. 어느 쪽을 선택해도 별 차이는 없다만 Thread를 상속받으면 다른 클래스를 상속받을 수 없기 때문에, 보통은 Runnable 인터페이스를 구현하는 방법을 사용한다.

Runnable 인터페이스를 구현하는 방법은 재사용성(reusability)이 높고, 코드의 일관성(consistency)을 유지할 수 있기 때문에 보다 객체지향적인 방법이다.

  1. Thread클래스를 상속
	class MyThread extends Thread {
    	public void run() { //작업내용 }	// Thread클래스의 run()을 오버라이딩
    }
  1. Runnable인터페이스를 구현
	class MyThread2 implements Runnable {
    	public void run() { //작업내용 } 	// Runnable인터페이스의 run()을 구현
    }

Runnable은 아래처럼 run()만 존재하는 간단한 인터페이스이다.

	public interface Runnable {
    	public abstract void run();
    }

이렇게 만든 MyThread들은 아래와 같이 실행한다.

  1. Thread를 상속 받은 MyThread
	MyThread t1 = new MyThread(); // 쓰레드의 생성
    t1.start(); // 쓰레드의 실행
  1. Runnable을 구현 한 MyThread2
	Runnable r = new MyThread2();
    Thread t2 = new Thread(r); // Thread(Runnable r)
    // Thread t2 = new Thread(new MyThread2());
    t2.start();

쓰레드의 실행 - start()

쓰레드를 생성했다고 해서 자동으로 실행되는 것은 아니다. start()를 호출해야만 쓰레드가 실행된다.

사실 start()가 호출됐다고 해서 바로 실행되는 것이 아니라. 실행대기 상태에 올라가 있다가 자신의 차례가 되면 실행된다. 대기중인 쓰레드가 없다면 곧바로 실행상태가 될 것이다.

쓰레드의 실행순서는 OS의 스케쥴러가 작성한 스케쥴에 의해 결정된다.

예제

간단한 예제이다 for문을 너무 적게 반복하면 동시에 실행되는 모습을 보기도전에 작업이 끝나기에 500번씩 반복시켰다. 출력 해 보면 쓰레드0이 출력됐다가도 쓰레드1이 출력되는 모습을 볼 수 있는데, 이는 쓰레드0과 1이 동시에 작업중이라는 것을 의미한다.

public class ThreadExample {
    public static void main(String[] args) {
        ThreadEx1_1 t1 = new ThreadEx1_1();

        Runnable r = new ThreadEx1_2();
        Thread t2 = new Thread(r);

        t1.start();
        t2.start();
    }
}

class ThreadEx1_1 extends Thread {
    public void run() {
        for(int i = 0; i < 500; i++) {
            System.out.println(getName()); // 조상인 Thread의 getName()을 호출
        }
    }
}

class ThreadEx1_2 implements Runnable {
    public void run() {
        for(int i = 0; i < 500; i++) {
            // Thread.currentThread() - 현재 실행 중인 Thread를 반환한다.
            System.out.println((Thread.currentThread().getName()));
        }
    }
}

이 때 t1,t2 둘 중 하나는 언제 실행될 지 모른다. OS스케쥴러가 가지고 있는 스케쥴링 로직에 의해서 대기명단에 있는 t1,t2중 먼저 실행되는게 다르기 때문이다.

Java가 아무리 JVM을 활용한 OS에 독립적인 언어라고 할 지라도, 이렇게 쓰레드처럼 몇 가지는 OS에 종속적일수 밖에 없다.

	static Thread currentThread() // 현재 실행중인 쓰레드의 참조를 반환한다.
    String getName() // 쓰레드의 이름을 반환한다.

한 가지 더 알아야 할것은 한 번 실행이 종료된 쓰레드는 다시 실행할 수 없다는 것이다. 즉, 하나의 쓰레드에 대해 start()가 한 번만 호출될 수 있다는 뜻이다.

그래서 만약 쓰레드의 작업을 한 번 더 실행해야 한다면 아래와 가이 새로운 쓰레드를 생성해서 다시 start()를 호출해야 한다. 이미 실행했던 쓰레드를 다시 start()한다면, 'IllegalThreadStateException'이 발생한다.

	ThreadEx1_1 t1 = new ThreadEx1_1();
    t1.start();
    
    t1 = new ThreadEx1_1(); // 다시 생성
    t1.strat(); // Ok.
    t1.start(); // Error!

main쓰레드

main메서드의 작업을 수행하는 것도 쓰레드이며, 이를 main쓰레드라고 한다. java에서 싱글쓰레딩으로 프로그래밍을하면 이 main쓰레드의 Call stack에 모든 호출들이 한 개씩 올라갔다 처리 되는 형식으로 진행된다.

만약 멀티쓰레딩을 사용해, main -> start() -(새로운 쓰레드 생성)-> run() 의 과정을 수행한다 했을 때, run()의 수행시간이 길다면 main쓰레드가 종료됐음에도 run쓰레드가 수행중이라 프로그램이 종료되지 않는 것을 확인할 수 있다.

만약 새로운 호출스택과 쓰레드를 만드는 start()를 실행하지 않고 바로 t1.run()을 실행하면 이는 main쓰레드 호출스택에 t1.run()을 쌓아 수행하는 싱글 쓰레딩 프로그래밍일 뿐이다.

실행 중인 사용자 쓰레드가 하나도 없을 때 프로그램은 종료된다.

싱글쓰레드와 멀티쓰레드

싱글코어

싱글코어에서는 하나의 쓰레드로 두 개의 작업을 수행하는 경우와 두 개의 쓰레드로 두 개의 작업을 수행하는 경우, 하나의 쓰레드로 두 개의 작업을 수행하는 경우가 보통 더 나은 성능을 보여준다. 그 이유는 쓰레드간의 작업 전환(context switching)에 시간이 걸리기 때문인데, 작업 전환을 할 떄는 현재 진행 중인 작업의 상태, 예를 들면 다음에 실행해야할 위치(Program Counter)등의 정보를 저장하고 읽어 오는 시간이 소요된다. 참고로 쓰레드의 스위칭에 비해 프로세스의 스위칭이 더 많은 정보를 저장해야하므로 더 많은 시간이 소요된다.

프로세스 또는 쓰레드 간의 작업 전환을 '컨텍스트 스위칭(context switching)'이라 한다.

그래서 싱글 코어에서 단순히 CPU만을 사용하는 계산작업이라면 멀티쓰레딩을 사용할 이유가 1도없다. 싱글 코어에서 멀티쓰레딩을 사용하는 이유는 카톡에서 파일을 보내는중에 메시지를 보내는 등의 작업을 수행하기 위함이지 속도를 개선하는데 사용하진 않는다.

멀티코어

과연 멀티코어에서는 멀티쓰레드는 싱글코어 싱글쓰레드보다 계산+출력이 빠를까? 정답은 X 이다. 그 이유는 멀티 코어로 두 개의 쓰레드를 실행하여 계산을 빨리했더라도 출력이라는 하나의 자원을 공유하기 때문인데, A코어의 쓰레드 작업이 끝나 출력을하려고하고, B코어의 쓰레드 작업이 끝나 출력을하려고 하더라도 동시에 출력은 불가능하기 때문에 어느 한쪽이 기다려야한다. 따라서 이 때는 계산을 이어나갈수도 출력을 할 수도 없는 쓰레드가 생기게 되고 이는 실행속도를 약간 늦추게 되는 요인이 된다.

OS에 따라 다를수 있음

JVM이 아무리 OS에 독립적이라고 해도 쓰레드만큼은 OS에 종속적이고, JVM/OS에 따라 쓰레드 스케쥴러의 구현 방법이 다를 가능성이 있기에 다른 종류의 OS에서도 충분히 테스트를 진행하고 멀티코어, 싱글코어, 싱글쓰레드, 멀티쓰레드를 사용해야 한다.

쓰레드의 I/O블락킹(blocking)

두 쓰레드가 서로 다른 자원을 사용하는 작업의 경우에는 싱글쓰레드 프로세스보다 멀티쓰레드 프로세스가 더 효율적이다. 예를 들면 사용자로부터 데이터를 입력받는 작업, 네트워크로 파일을 주고받는 작업, 프린터로 파일을 출력하는 작업과 같이 외부 기기와의 입출력을 필요로 하는 경우가 이에 해당한다.

만약 A작업과 B작업을 같은 쓰레드에서 실행하는데, A작업에 사용자의 입력을 기다려야 하고, B작업에서는 사용자의 입력을 기다릴 로직이 없다면? A - B순서로 실행된다면 당연히 B는 실행되지 않고 긴 시간을 사용자 입력에 허비해야 한다.

이 때, 멀티 쓰레드로 같은 작업을 나누어서 실행한다면 A작업중 막히더라도 B작업은 수행을 모두 끝내 놓았을것이다.

쓰레드가 입출력(I/O)처리를 위해 기다리는 것을 I/O블락킹이라고 한다.

쓰레드의 우선순위

쓰레드는 우선순위(priority)라는 속성(멤버변수)을 가지고 있는데, 이 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라진다. 쓰레드가 수행하는 작업에 중요도에 따라 쓰레드의 우선순위를 서로 다르게 지정하여 특정 쓰레드가 더 많은 작업시간을 갖도록 할 수 있다.

예를들어 카톡의 경우 파일다운로드를 처리하는 쓰레드보다 채팅 내용을 전송하는 쓰레드의 우선순위가 더 높아야 사용자가 채팅하는데 불편함이 없을 것이다. 대신 파일 다운로드의 작업처리 시간은 더 길어지겠지만.

이처럼 시각적인 부분등의 쓰레드 우선순위는 다른 작업의 쓰레드 우선순위보다 높아야 한다.

쓰레드 우선순위 정하기

쓰레드 우선순위와 관련된 메서드, 상수는 아래와 같다.

	void setPriority(int newPriority) // 쓰레드의 우선순위를 지정한 값으로 바꿈
    int getPriority() // 쓰레드의 우선순위를 반환.
    
    public static final int MAX_PRIORITY = 10; // 최대 우선순위
    public static final int MIN_PRIORITY = 1;  // 최소 우선순위
    public static final int NORM_PRIORITY = 5; // 기본 우선순위

쓰레드의 우선순위는 쓰레드를 생성한 쓰레드로부터 상속받는다는 점을 유의하자.
main메서드는 기본 우선순위인 5를 가지고 있기에 따로 지정하지 않는 이상 main에서 생성되는 모든 쓰레드는 기본 우선순위를 가지고 생성된다.

예제

public class ThreadPriorityExample {
    public static void main(String[] args) {
        ThreadEx6_1 t61 = new ThreadEx6_1();
        ThreadEx6_2 t62 = new ThreadEx6_2();

        t61.setPriority(10);
        t62.setPriority(1);

        System.out.println("Priority of t61(-) = " + t61.getPriority());
        System.out.println("Priority of t62(ㅣ) = " + t62.getPriority());
        t61.start();
        t62.start();
    }
}

class ThreadEx6_1 extends Thread {
    public void run() {
        for(int i = 0; i < 300; i++) {
            System.out.print("─");
            for(int x = 0; x < 10000000; x++);
        }
    }
}

class ThreadEx6_2 extends Thread {
    public void run() {
        for(int i = 0; i < 300; i++) {
            System.out.print("│");
            for(int x = 0; x < 10000000; x++);
        }
    }
}

쓰레드는 OS의 스케쥴러에 의해 조정되므로 우선순위는 그저 희망사항에 불가하다.
원하는 대로 나오지는 않을 것이다.

윈도우는 32단계로 스케쥴러가 우선순위를 정하고, JVM은 10단계로 설정할 수 있다. 단계에 맞춰 윈도우에 우서순위를 요청한다고 한다.
참고로 윈도우는 마우스 포인터에 굉장히 높은 우선순위를 두고 있다고한다.

위 코드를 멀티코어로 실행했을 땐 진짜 차이가 거어어어의없다.(윈도우기준)

쓰레드 그룹(thread group)

쓰레드 그룹이란? 서로 관련된 쓰레드를 그룹으로 묶어서 다루기 위한 것이다.

모든 쓰레드는 반드시 하나의 쓰레드 그룹에 포함돼 있어야 한다.

또한 폴더 안에 폴더를 생성하듯, 쓰레드 그룹에 다른 쓰레드 그룹을 포함 시킬 수 있다.
쓰레드 그룹은 본래 보안상의 이유로 도입된 개념으로, 자신이 속한 쓰레드 그룹이나 하위 쓰레드 그룹은 변경할 수 있지만 다른 쓰레드 그룹의 쓰레드를 변경할 수는 없다.

쓰레드는 아래와 같이 쓰레드 그룹을 지정하기 위한 생성자가 별도로 존재한다.

	Thread(ThreadGruop group, String name)
    Thread(ThreadGruop group, Runnable target)
    Thread(ThreadGruop group, Runnable target, String name)
    Thread(ThreadGruop group, Runnable target, String name, long stackSize)

위 생성자를 이용해 쓰레드 그룹을 지정하지 않으면, 기본적으로 자신을 생성한 쓰레드와 같은 쓰레드 그룹에 속하게 된다.

JVM은, 자바 어플리케이션이 실행되면 main과 system이라는 쓰레드 그룹을 만들고 JVM운영에 필요한 쓰레드들을 생성해서 이 쓰레드 그룹에 포함시킨다.

	ThreadGroup getThreadGroup() // 쓰레드 자신이 속한 쓰레드 그룹을 반환한다.
    
    void uncaughtException(Thread t, Throwable e) 
    // 처리되지 않은 예외에 의해 쓰레드 그룹의 쓰레드가 실행 종료됐을 때,
    // JVM에 의해 이 메서드가 자동적으로 호출된다.

데몬 쓰레드(daemon thread)

데몬 쓰레드란? 다른 일반 쓰레드의 작업을 돕는 보조적인 역할을 수행하는 쓰레드이다. 일반 쓰레드가 모두 종료되면 자동적으로 종료되며 데몬 쓰레드의 예로는 가비지 컬렉터, 워드프로세서의 자동저장, 화면자동갱신 등이 있다.

데몬 쓰레드는 무한루프와 조건문을 이용해서 실행 후 대기하고 있다가 특정 조건이 만족되면 작업을 수행하고 다시 대기하도록 작성한다.

	public void run() {
    	while(true) {
        	try {
            	Thread.sleep(3 * 1000); // 3초마다
            } catch(InterruptedException e) {}
            
            // autoSave의 값이 true이면 autoSave()를 호출한다.
            if(autoSave) autoSave();
        }
    }

일반적인 쓰레드와 작성, 실행방법이 같으나 쓰레드를 생성한 다음 실행하기 전에 setDaemon(true)를 호출하기만 하면 된다. 그리고 데몬 쓰레드가 생성한 쓰레드는 자동적으로 데몬 쓰레드가 된다.

start()를 이용해 쓰레드를 생성하고 나서는 setDaemon()을 사용할 수 없다.

	boolean isDaemon() // 쓰레드가 데몬 쓰레드인지 확인한다.
    				   // 데몬 쓰레드라면 true를 반환한다.
    void setDaemon(boolean on) // 쓰레드를 데몬 쓰레드로 또는 사용자 쓰레드로 변경한다.
    						   // 매개변수 on의 값을 true로 지정시 데몬 쓰레드가 된다.

데몬쓰레드 예제

public class DaemonThreadExample implements Runnable{
    static boolean autoSave = false;

    public static void main(String[] args) {
        Thread t = new Thread(new DaemonThreadExample());
        t.setDaemon(true);
        t.start();

        for(int i = 1; i <= 10; i++) {
            try{
                Thread.sleep(1000);
            } catch (InterruptedException e) {}
            System.out.println(i);

            if(i==5) autoSave = true;
        }
    }

    public void run() {
        while(true) {
            try {
                Thread.sleep(3 * 1000);
            } catch (InterruptedException e) {}

            if(autoSave) autoSave();
        }
    }

    public void autoSave() {
        System.out.println("작업파일이 자동 저장됐습니다.");
    }
}

처음 5초동안은 자동저장하지 않고 5초 이후에 매 3초마다 자동저장하는 코드이다.

run()안의 코드가 무한루프이어서 사용자 쓰레드일 경우에는 프로그램이 종료되지 않겠지만, 데몬쓰레드로 만들면 무한루프가 있어도 일반쓰레드가 사라지면 같이 종료되기에 상관없다.

쓰레드의 상태

  1. 쓰레드를 생성하고 start()를 호출하면 바로 실행되는 것이 아니라 실행대기열에 저장돼 자신의 차례가 될 때까지 기다린다. 실행대기열은 큐(queue)와 같은 구조로 먼저 실행대기열에 들어온 쓰레드가 먼저 실행된다.

  2. 실행대기상태에 있다가 자신의 차례가 되면 실행상태가 된다.

  3. 주어진 실행시간이 다되거나 yield()를 만나면 다시 실행대기상태가 되고 다음 차례의 쓰레드가 실행상태가 된다.

  4. 실행 중에 suspend(), sleep(), wait(), join(), I/O block에 의해 일시정지상태가 될 수 있다. 예로, 사용자의 입력을 기다리는 경우 일시정지 상태에 있다가 사용자가 입력을 마치면 다시 실행대기상태가 된다.

  5. 지정된 일시정지시간이 다 되거나(time-out), notify(), resume(), interrupt()가 호출되면 일시정지 상태를 벗어나 다시 실행 대기열에 저장되어 자신의 차례를 기다린다.

  6. 실행을 모두 마치거나 stop()이 호출되면 쓰레드는 소멸된다.

0개의 댓글