http://dlucky.tistory.com/203



제1절 쓰레드의 개념

1.1 쓰레드란

    쓰레드(thread)는 사전적으로 "실" 혹은 "(구슬 등을) 실에 꿰다"는 의미를 갖고 있는데, 컴퓨터에서 프로그램을 수행할 때에도 각각의 명령어를 실에 꿰듯이 순차적으로 수행하기 때문에 '쓰레드'라는 용어를 사용하게 된 것으로 생각된다. 아직까지 쓰레드라는 용어에 대한 적합한 번역이 없기에 여기서도 그대로 쓰레드라는 용어를 사용하고자 한다.

    프로세스(process)는 "컴퓨터에서 실행되고 있는 프로그램"을 말하는 것으로, 쓰레드를 프로세스와 동일한 의미로 사용하는 경우가 많이 있으나 다음과 같은 약간의 차이를 갖고 있다. 즉, 프로세스는 완전한 하나의 프로그램이 실행되는 것으로서, 예를 들어 fork()를 통해 생성되는 프로세스는 원래의 프로세스와 똑같은 변수와 코드 등 여러 가지 면에서 똑같은 복사본이다. 따라서 부모와 똑같은 자식을 생성하기 위해서는 많은 자원(resoure)이 요구된다. 그러나, 쓰레드는 프로세스와는 달리 부모 프로세스 전체의 복사본을 만들지 않고, 필요한 코드 만을 생성하여 동시에 수행한다. 또한, 부모 쓰레드가 갖고 있는 모든 데이터를 완전하게 접근할 수 있기 때문에, 훨씬 더 효율적이다.

    대부분의 프로그램은 시작에서부터 끝에 이르기까지 모든 명령어들이 순차적으로 수행되면서 하나의 프로세스를 생성하기 때문에 단일 쓰레드 프로그램이라고 한다. 그러나, 프로그램에 따라서는 여러 가지의 함수가 서로 독립적으로 수행되면서 여러개의 프로세스를 생성하기 때문에 다중 쓰레드 프로그램이라고 한다. 다중 쓰레드 프로그램의 예로는 채팅 프로그램을 들 수 있을 것이다. 채팅 프로그램에서는 사용자가 키보드를 통해 글을 쓰는 동안에도 상대방이 글을 썼는지를 지속적으로 감시한다. 즉, 사용자의 입력을 기다리는 프로세스와 상대방이 쓴 글을 화면에 출력해주는 두 개의 프로세스를 갖기 때문에 다중 쓰레드 프로그램이 된다.

1.2 쓰레드의 생성 및 수행

    (1) 스레드의 생성과 종료

    쓰레드를 생성하는 방법에는 두 가지가 있는데, 첫째로는 Thread 클래스로 부터 상속을 받아서 만들수도 있다. 자바는 다중상속을 허용하지 않기 때문에 Thread 클래스를 상속받으면 다른 클래스를 상속하여 수행할 수가 없다. 따라서 이 방법은 독립적으로 수행되는 쓰레드를 생성할 경우에 주로 사용된다. 둘째로는 둘째로는 Runnable 인터페이스를 구현(implements)해서 쓰레드를 생성하는 것으로 GUI 프로그래밍에서와 같이 이미 다른 클래스를 상속하여 그 특성을 유지한 프로그램을 작성할 필요가 있을 때 주로 사용된다.

    쓰레드를 실행하기 위한 전제조건으로 run() 메소드가 필요한데, 여기에 쓰레드가 수행할 작업의 내용을 기술한다. 이와 같이 쓰레드를 이용하는 클래스를 선언하여 객체를 생성하면, 해당 객체에대해 start() 메소드를 호출하여 쓰레드를 실행시킨다. 쓰레드를 종료하기 위해서는 stop() 메소드를 호출한다.

    다음은 Thread 클래스를 상속하여 생성한 클래스로부터 2개의 객체를 생성하여 실행시키는 예제 프로그램이다. 이 프로그램은 완전히 독립적인 두 개의 프로세스를 생성하여 실행하기 때문에, 실행결과로부터 count 변수도 개별적으로 소유하여 수행하고 있음을 볼 수 있다.

    [예제 - Thread 상속의 예]

      class HelloThread extends Thread {
         private int count=0;  // 카운트 변수
         public HelloThread() { // 생성자
            System.out.println(getName() + " Created.");
         }

         // 쓰레드 start()시 수행되는 메소드
         public void run() {
             while (count < 3) {
                System.out.println(getName() + " Count = " + count++);
                try { sleep(500); } // 0.5초간 sleep
                catch (InterruptedException e) {}
             }
             System.out.println(getName() + " Stopped!");
         }

         // 두 개의 쓰레드를 생성하여 실행시킴
         public static void main(String args[]) {
            HelloThread thr_a = new HelloThread();
            HelloThread thr_b = new HelloThread();
            thr_a.start();
            thr_b.start();
         }
      }

     

    위의 프로그램은 Thread 클래스를 상속받아서 작성되었는데, 이를 Runnable 인터페이스를 구현하도록 프로그램을 수정해 보면 다음과 같다. 여기서 Runnable 인터페이스는 run() 메소드만을 갖고 있기 때문에 run() 메소드만 구현하면 된다.

    [예제 - Runnable 구현의 예]

      class HelloThread implements Runnable {
         int count=0;  // 카운트 변수
         String  threadName = Thread.currentThread().getName();
         public HelloThread() { // 생성자
            System.out.println(threadName + " Created.");
         }

         // 쓰레드 start()시 수행되는 메소드
         public void run() {
             while (count < 3) {
                System.out.println(threadName + " Count = " + count++);
                try { Thread.currentThread().sleep(500); } // 0.5초간 sleep
                catch (InterruptedException e) {}
             }
             System.out.println(threadName + " Stopped!");
         }
      }

      public class ExampleThread { 
         // 두 개의 쓰레드를 생성하여 실행시킴
         public static void main(String args[]) {
            Thread thr_a = new Thread(new HelloThread());
            Thread thr_b = new Thread(new HelloThread());
            thr_a.start();
            thr_b.start();
         }
      }

     

    (2) 쓰레드의 상태 전이

    컴퓨터에서 하나의 프로세스(쓰레드)가 생성되면 이것은 대기상태에 있거나 혹은 CPU에서 처리 되는 등의 상태(status)를 거쳐서 결국은 종료(Dead) 상태에 이르게 되는 데, 이를 쓰레드의 상태전이라고 한다. 쓰레드의 상태가 전이되는 과정을 그림으로 그리면 다음과 같으며, 이를 상태전이도라고 한다.

    먼저 각 상태에 대해 간략히 설명하면 다음과 같다.

    • Newborn은 쓰레드가 생성자(constructor)를 통해서 생성되어 처음으로 나타나는 상태이다.
    • Runnable은 CPU의 디스패티(dispatch) 큐(queue)에 등록되어 실행가능한 상태로서 우선순위에 따라 CPU에서 작업되기를 기다리는 상태이다.
    • Running은 운영체제에 의해 큐로부터 꺼내어져서 CPU에서 실행되는 상태이다.
    • Blocked은 일시적으로 쓰레드의 수행이 중단된 상태로서 특정조건이 되면 다시 실행상태로 이전된다.
    • Dead는 쓰레드의 수행을 완전히 종료한 상태로서, start() 메소드 등으로 재수행할 수 없다.

    다음으로 각 상태의 전이를 일으키는 쓰레드 메소드들에 대해 살펴보면 다음과 같다.

    • start()는 생성자에 의해 생성된 쓰레드를 실행대기상태로 전이시킨다.
    • yield()는 CPU에서 실행중인 쓰레드를 대기상태로 보내어 다른 쓰레드를 수행할 수 있도록 한다.
    • sleep()는 1 mSec 단위로 매개변수를 지정함으로써 일정 시간동안 쓰레드의 수행을 중지하는 것으로서, 특정한 주기를 갖고 쓰레드를 실행시킬 필요가 있는 시계 프로그램 등에서 주로 사용된다.
    • suspend()는 블록을 시킨다는 점에서는 sleep()와 동일하지만, 시간을 단위로 블록시키는 것이 아니라, suspend() 메소드로 블록을 시킨 후에는 resume() 메소드를 이용하여 대기상태로 전이시킨다는 차이가 있다. 그러나, 이들 메소드는 Dead-Lock을 유발할 가능성이 있기 때문에 stop() 메소드와 함께 JDK1.2이후 버전에서는 deprecated 경고를 발행하여 사용을 자제하도록 하고 있다.
    • wait()는 블록을 시킨 후 notify() 메소드를 통해 실행대기상태로 전이하기 때문에 suspend()/resume() 메소드와 유사하지만, synchronized 메소드에서만 사용할 수 있다는 차이점이 있다.
    • stop()은 쓰레드의 모든 작업을 종료시킨다.

     

1.3 쓰레드의 우선순위

    여러 개의 쓰레드를 구현하는 자바 프로그램에서는 각 쓰레드들이 빠르게 전환되면서 실행이 되어 쓰레드들이 동시에 실행되는 것처럼 보인다. 그러나 CPU는 대기중인 쓰레드들을 정해진 우선순이(priority)규칙에 따라 한 순간에 하나의 쓰레드만을 선택하여 수행한다.

    자바에서도 각 쓰레드에 1에서 10까지의 우선순위를 부여함으로써 쓰레드의 실행 순서를 변경할 수 있다. 여기서, 1이 가장 낮은 우선 순위이고 10이 가장 높은 우선 순위로서, MIN_PRIORITY(숫자 1에 해당), NOM_PRIORITY(숫자 5에 해당), MAX_PRIORITY(숫자 10에 해당) 등의 상수를 사용할 수도 있다. 쓰레드에 우선순위를 지정하지 않으면 우선순위는 NOM_PRIORITY(5)이다. 쓰레드의 우선순위를 변경하려면, Thread 클래스의 setPriority() 메소드를 사용하며, 특정 스레드의 우선 순위를 알아보려면 Thread 클래스의 getPriority() 메소드를 사용한다.

    다음은 위 프로그램을 약간 수정한 것으로, 두 개의 쓰레드에 각각 다른 우선 순위를 부여한 후에 수행하도록 하였다.

    [예제 - Thread 우선순위의 예]

      class ThreadPriority extends Thread {
             private int count=0;  // 카운트 변수

             // 생성자 -> 쓰레드의 이름 지정
             public ThreadPriority (String s)  {
                 super(s);
             }

             // 쓰레드 start()시 수행되는 메소드
             public void run() {
                 while (count < 5) {
                    count++;
                    System.out.print(getName() + " → ");
                    try { sleep(10); } // 0.01초간 sleep
                    catch (InterruptedException e) {}
                 }
             }
           
             // 두 개의 쓰레드를 생성하여 실행시킴
             public static void main(String args[]) {
                ThreadPriority thr_a = new ThreadPriority("MIN");
                thr_a.setPriority(Thread.MIN_PRIORITY); // 최저우선순위 지정
                ThreadPriority thr_b = new ThreadPriority("MAX");
                thr_b.setPriority(Thread.MAX_PRIORITY); // 최고우선순위 지정
                thr_a.start();
                thr_b.start();
             }
          }

     

    프로그램의 실행결과를 살펴보면 MAX가 나중에 start()되었지만, 전반적으로 앞부분에서 실행되고 있음을 볼 수 있다. 또한, 두 번의 실행순서가 서로 다름을 볼 수 있는데 이는 CPU가 처리하는 다른 작업들에 영향을 받았기 때문이다. 즉, 각 쓰레드는 0.01초간의 휴식기간을 갖는데, MAX 쓰레드가 수행된 후 휴식기간중에 MIN 이 도착하면 MIN이 수행되지만, MIN이 실행되기 바로 직전에라도 0.01초가 지나서 MAX가 도착하면 우선순위가 높아서 MAX가 다시 수행된다.

     

1.4 동기화 (synchronized)

    다중 사용자 프로그래밍 혹은 다중 쓰레드 프로그램에서 어떤 프로그램은 순차적으로 수행하지 않고 병렬적으로 처리할 경우에는 계산결과에 문제가 발생할 수 있다. 예를 들어, 은행에 10만원이 남아 있는데, 다음과 같이 두 개의 쓰레드가 중첩(overlap)되어 수행된다면 그 결과는 예상하지 못한 결과를 낳게 될 것이다.

    시점

    쓰레드 A

    쓰레드 B

    T1:

    - 현재잔고 → 10만원

     

    T2:

    - 현금인출 → 10만원 - 5만원

    - 현재잔고 → 10만원

    T3:

    - 잔고기록 → 5만원

    - 현금인출 → 10만원 - 3만원

    T4:

     

    - 잔고기록 → 7만원

    위에서 각 쓰레드는 정상적으로 수행되어 쓰레드A는 현금잔고 10만원에서 5만원을 인출한 후 잔고로서 5만원으로 계산하였으며, 쓰레드B도 같은 과정을 통해 잔고를 7만원으로 계산하였다. 따라서 최초의 현금잔고 10만원으로부터 각각 5만원과 3만원 (합계 8만원)을 인출한 후에 최종적으로 7만원이 남는 문제가 발생하게 되었다.

    이러한 문제는 데이터베이스의 다중사용자(Multi-User)문제로서 동일한 데이터를 서로 다른 두 개의 쓰레드가 동시에 갱신(update)하는 과정에서 발생하는 것으로서, 갱신의 상실(Lost of Update)라고 한다. 이러한 문제를 제거하기 위해서는 하나의 쓰레드가 끝날 때까지 락(lock)을 걸어서 다른 쓰레드가 동일한 데이터를 갱신하지 못하도록 함으로써 해결할 수 있는데, 자바에서는 이를 동기화(synchronized)라는 문장을 이용해서 처리한다.

    어떤 데이터를 공유하는 쓰레드 내부의 데이터를 동기화하기 위해서는 synchronized 키워드를 사용한다. 이 synchronized 키워드를 사용하는 방법은 두 가지가 있는데 첫번째는 메소드 선언부에서 하는 방법과 두 번째는 동기화 객체를 사용하는 코드 블록 부분만을 synchronized로 선언하는 방법이다. 첫 번째 방법은 메소드 내에서 처리할 내용이 많을 경우에는 스레드의 효율이 떨어지기 때문에, 메소드 내에서 처리할 내용이 많을 경우에는 두 번째 방법에서와 같이 동기화 객체를 사용하는 코드 블록 부분에만 synchronized로 선언한다.

      public class SyncTest {
         int ShareNumber = 0;
         // 메소드 전체를 sychronized로 선언
         public synchronized void increaseNumber() {
            ShareNumber++;
         }
      }

      public class SyncTest {
         int ShareNumber = 0;
         public void increaseNumber() {
            // 순수하게 공유문제가 발생하는 부분만을 synchronized로 선언
            synchronized (this) {
               ShareNumber++
            }
         }
      }

    위에서 SyncTest 클래스는 synchronized 키워드가 붙어있는 메소드를 갖고 있기 때문에, 자바시스템은 SyncTest 클래스로부터 생성되는 모든 객체마다 락(lock)을 결합시킨다. 이후 어떤 객체의 동기화된 메소드가 호출되면 그 메소드를 호출한 쓰레드는 그 객체에 결합된 락을 잠근다. 따라서, 이 락이 풀리기 전까지는 동기화된 메소드 뿐만 아니라 이 객체를 호출하는 것 자체가 금지된다.

    다음은 여러개의 쓰레드가 하나의 객체를 이용하여 작업을 수행할 때 부적합한 계산결과가 발행할 수 있음을 보여주는 예로서, A, B 두 개의 쓰레드가 각각 1~10과 1~5까지의 합을 구한다. 따라서, A 쓰레드의 합은 50, B 쓰레드의 합은 15가 되어야 한다.

    [예제 - 동일한 객체에 접근하는 다중 Thread의 예]

      // 공유 데이터 클래스
      class Summing {
         private int sum;
         public void sumTo(int num) {
            sum = 0;
            for (int i=1; i<=num; i++) {
               sum = sum + i;
               System.out.println("쓰레드 " +Thread.currentThread().getName() + "의 1~" + i + "까지 합은 " + sum );
               try {
                    Thread.sleep((int)(Math.random()*100));
               } catch(InterruptedException e) { }
             }
        }

         public int getSum() { return sum; }
      }

      // 쓰레드 클래스
      class SyncThread extends Thread {
         private Summing sum;
         private int num;
         public SyncThread(String s, Summing sum, int num) {
            super(s);
            this.sum = sum;
            this.num = num;
            System.out.println("쓰레드 " + getName() + "가 시작됨.");
        }

        public void run() {
            sum.sumTo(num);
            System.out.println("쓰레드 " + getName() + "가 종료됨.");
        }
      }

      public class SyncExample {
        public static void main(String agrs[]){
            Summing sum = new Summing(); // 공유데이터 객체 생성
            SyncThread a = new SyncThread("A", sum, 10);  // 쓰레드 A 생성
            SyncThread b = new SyncThread("B", sum, 5);   // 쓰레드 B 생성
            a.start();
            b.start();
        }
      }

    이 프로그램에서는 쓰레드 A와 쓰레드 B가 교차하여 수행할 수 있도록 run() 메소드에 Thread.sleep() 메소드를 삽입하였다. 위의 프로그램을 수행하면 다음과 같은 결과를 얻게 되는데, 프로그램을 수행할 때마다 서로 다른 결과가 나올 수 있다.

      쓰레드 A가 시작됨.
      쓰레드 B가 시작됨.
      쓰레드 A의 1~1까지 합은 1
      쓰레드 B의 1~1까지 합은 1
      쓰레드 A의 1~2까지 합은 3
      쓰레드 A의 1~3까지 합은 6
      쓰레드 B의 1~2까지 합은 8
      쓰레드 B의 1~3까지 합은 11
      쓰레드 A의 1~4까지 합은 15
      쓰레드 A의 1~5까지 합은 20
      쓰레드 B의 1~4까지 합은 24
      쓰레드 B의 1~5까지 합은 29
      쓰레드 A의 1~6까지 합은 35
      쓰레드 A의 1~7까지 합은 42
      쓰레드 A의 1~8까지 합은 50
      쓰레드 B가 종료됨.
      쓰레드 A의 1~9까지 합은 59
      쓰레드 A의 1~10까지 합은 69
      쓰레드 A가 종료됨. 

    위의 결과는 1~5 의 합은 29, 1~10의 합은 69로 계산되었는데, 이는 쓰레드 A와 쓰레드 B의 계산이 혼합되었기 때문에 나타난 결과이다. 따라서, 다음과 같이 run() 메소드를 수정하면 한번에 하나씩만 수행되기 때문에 정상적인 결과가 출력된다. 물론, 이럴 경우 이 프로그램은 쓰레드 프로그램으로서는 큰 의미를 갖지 못하지만, synchronized의 개념을 설명하기 위해 도입했을 뿐이다.

         public void synchronized sumTo(int num) {
            // 내용은 동일함. 
        }

    위에서는 Summing 클래스내의 메소드에 동기화를 설정하였기 때문에, 특정 쓰레드가 이 메소드에서 작업을 수행하면 다른 쓰레드는 작업이 끝날 때까지 객체에 접근할 수 없다. 따라서, 다음의 실행결과에서 보는 바와 같이 쓰레드 A와 B가 각각 생성된 후에 A의 계산이 끝난 후 B가 시작되었다. 또한, 다음의 결과에서 보는 바와 같이 "쓰레드A가 종료"되었다는 메시지가 B의 계산이 시작된 이후에 나타나고 있는데, 이부분은 동기화와 관계없이 개별적으로 수행될 수 있기 때문이다.

      쓰레드 A가 시작됨.
      쓰레드 B가 시작됨.
      쓰레드 A의 1~1까지 합은 1
      쓰레드 A의 1~2까지 합은 3
      쓰레드 A의 1~3까지 합은 6
      쓰레드 A의 1~4까지 합은 10
      쓰레드 A의 1~5까지 합은 15
      쓰레드 A의 1~6까지 합은 21
      쓰레드 A의 1~7까지 합은 28
      쓰레드 A의 1~8까지 합은 36
      쓰레드 A의 1~9까지 합은 45
      쓰레드 A의 1~10까지 합은 55
      쓰레드 B의 1~1까지 합은 1
      쓰레드 A가 종료됨.
      쓰레드 B의 1~2까지 합은 3
      쓰레드 B의 1~3까지 합은 6
      쓰레드 B의 1~4까지 합은 10
      쓰레드 B의 1~5까지 합은 15
      쓰레드 B가 종료됨.