스레드 동기화

Copyright (c) 2015-, All Rights Reserved by Kwanghoon Choi
(아래 자바 프로그래밍 강의 교재의 내용을 자유롭게 이용하되 다른 웹 사이트 등을 통한 배포를 금합니다.)

 

4. 동기화 (synchronization)

앞에서는 여러 스레드들이 서로 간섭하지 않고 독립적으로 실행되는 형태로 동작하였다.

- 여러 스레드들이 동일한 객체를 공유하지도 않고

- 각 스레드가 실행 시작하고 끝나기까지의 과정이 다른 스레드의 실행 과정과 별개로 이뤄진다.

여러 스레드들이 리소스(동일 변수, 객체 등)를 공유하고 서로 협업하는 형태로 실행되는 복잡한 시나리오를 살펴본다. 그러한 시나리오들을 다음과 같이 세 가지로 분류해보자.

1. 리소스를 단순히 공유만하는 시나리오

- 여러 스레드들을 동시에 실행하고 동일한 객체나 변수 등을 공유한다. - 공유한 리소스를 사용할 때 지켜야할 특별한 규칙은 없다.

- 예를 들어, 카운터 객체를 공유하는 두 개의 스레드를 만들어 한 스레드는 카운터를 10번 증가시키고, 다른 스레드는 카운터를 10번 감소시킨다. 카운터 증가와 감소 시키는 순서는 정해져 있지 않다. (e.g., 은행 계좌에 입금, 출금 예제)

2. 리소스를 공유하면서 일정 규칙에 따라 리소스를 사용하는 시나리오

- 정해진 순서에 따라 리소스를 사용하는 시나리오 (e.g., 생산자와 소비자 문제)

- 무작위 순서로 리소스를 사용하는 시나리오 (e.g., 철학자 식사 문제)

 

 

4.1 리소스를 공유하는 시나리오

둘 이상의 스레드가 동일한 객체를 공유하여 동시에 읽고 쓸 때 스레드 간섭(Thread Interference)에 의한 오류가 발생할 수 있다. 다음 Counter 클래스를 보자.

class Counter {
    private int c = 0;

    public void increment() {
        c++;
    }
    public void decrement() {
        c--;
    }
    public int value() {
        return c;
    }
}

나머지 코드는 다음과 같다.

package com.example.java;

public class ThreadTest {

    public static void main(String[] arg ) {
        Counter c = new Counter();

        Thread t1 = new MyThread(c);
        t1.start(); // 스레드를 하나 만들어 독립적으로 실행
        Thread t2 = new Thread( new MyImplementation(c) );
        t2.start(); // 두번째 스레드를 만들어 실행

        try {
            t1.join();
            t2.join();

        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(c.getCounter());
    }
}

class MyThread extends Thread {
    private Counter c;
    public MyThread(Counter c) {
        this.c = c;
    }
    public void run() {
        for (int i=0; i<5000000; i++) {
            c.increment();
        }
    }
}

class MyImplementation implements Runnable {
    private Counter c;
    public MyImplementation(Counter c) {
        this.c = c;
    }
    public void run() {
        for (int i=0; i<5000000; i++) {
            c.decrement();
        }
    }
}

실행한 후 최종 카운터 값이 항상 0이 아님을 확인할 수 있다. 그 이유는 카운터 객체를 공유해서 사용할 때 두 스레드 간에 간섭이 발생하기 때문이다. Counter 객체 o를 두 개의 스레드가 공유한다고 가정하자. 두 스레드에서 o.increment()와 o.decrement()를 각각 호출한 경우 스레드 간섭(thread interference)가 발생하여 두 메소드 호출 결과가 모두 보존되지 않을 수도 있다.

1. Thread 1: c를 읽기 (c의 값은 0)

2. Thread 2: c를 읽기 (c의 값은 0)

3. Thread 1: 읽은 c의 값을 1 증가 시킴 (0을 1로 증가)

4. Thread 2: 읽은 c의 값을 1 감소 시킴 (0을 -1로 감소)

5. Thread 1: 증가시킨 값을 c에 저장 (c의 값은 1)

6. Thread 2: 감소시킨 값을 c에 저장 (c의 값은 -1) 두 메소드가 서로 간섭 없이 실행되었다면 o.increment()를 실행하고 o.decrement()를 실행하거나, 또는 그 반대 순서로 실행했을 것이다.

두가지 순서 중 어떤 순서로 실행하더라도 c의 최종 값은 0이다. 두 메소드를 실행할 때 간섭이 발생한 앞의 예에서는 스레드 1에 의해 실행한 o.increment() 결과가 스레드 2에 의해 사라져버렸다. 스레드 간섭으로 예측 가능한 결과를 얻지 못한다. Java 언어에서 이러한 간섭 문제를 해결하는 동기화 방법으로 synchronized 키워드를 제공한다. Counter 클래스의 메소드들에 동기화 방법을 적용하면 다음과 같다.

public class Counter {
    private int c = 0;

    public synchronized void increment() {
        c++;
    }
    public synchronized void decrement() {
        c--;
    }
    public synchronized int value() {
        return c;
    }
}

Java 언어에서 모든 객체는 lock을 포함하고 있다. 스레드가 어떤 객체의 필드를 접근하려면 이 객체의 lock의 소유권을 먼저 획득해야 한다. lock의 소유권을 가지고 있는 동안은 다른 스레드는 동일한 lock을 소유할 수 없다. 스레드가 객체를 사용한 다음 lock의 소유권을 해제하면 비로서 다른 스레드가 이 객체를 사용할 수 있도록 한다. 만일 lock의 소유권이 해제되지 않은 상황에서 어떤 스레드가 이 lock을 소유하려고 시도하면 lock이 해제될 때까지 그 스레드는 블럭되어 실행을 멈춘다.

SynchronizedCounter 클래스로부터 객체 so를 생성했다고 가정하자. 두 스레드에서 so.increment()와 so.decrement()를 각각 동시에 호출했다고 가정하면 먼저 실행된 스레드의 메소드를 먼저 실행하고 리턴하면 다음 메소드가 실행된다. 즉 두 메소드는 서로 겹치지 않고(atomically) 실행된다. 참고로 synchronized 키워드를 메소드에 붙이지 않고 { }의 블럭에 붙일 수 있다.

이 키워드를 사용하는 아래 두 가지 예는 동일한 의미를 갖는 코드이다.

public synchronized void m() {
    // code
}

public void m() {
    synchronized (this) {
        // code
    }
}

 

4.2 정해진 순서에 따라 공유 리소스를 사용하는 시나리오

카운터 객체를 공유하는 스레드 프로그램의 예에서 이 객체의 increment 메소드와 decrement 메소드를 실행할 때 한 시점에는 반드시 하나의 스레드가 실행하도록 보장하기 위해 syncrhonized 키워드를 활용함을 알 수 있었다. 그런데 이 예에서는 두 스레드 중 카운터 객체의 lock을 먼저 받으면 카운팅 기능을 할 수 있었다. 하지만 어떤 시나리오에서는 lock을 먼저 받았다고 해서 일을 바로 진행할 수 없는 경우도 발생할 수 있다. 예를 들어, 카운터 값이 항상 0이상을 유지하는 조건을 유지하면서 카운터 값을 증가 또는 감소시키려면 앞서 설명한 프로그램에서 처럼 synchronized 메소드를 사용하는 것만으로는 충분하지 않다.

- 앞의 카운터 예제 프로그램에서 실행 중 어느 시점에 decrement를 담당하는 스레드가 상대적으로 더 빈번하게 실행되었다고 가정하면 그때 카운터 값은 음수가 될 것이다. 이러한 조건을 만족하는 카운터 프로그램을 작성하기 위해 Object 클래스의 wait와 notify 메소드를 살펴본다. Object 클래스는 내부적으로 모두 lock을 가지고 있고, 이 lock과 연관된 wait와 notifiy (notifyAll) 메소드를 제공한다. 예를 들어, 스레드 A가 counter.wait()를 호출하면 이 counter 객체의 lock을 다른 스레드가 사용할 수 있도록 양보하고 A는 잠시 정지 상태가 된다. 양보한 lock을 획득한 다른 스레드 B는 counter 객체를 사용한 다음 counter.notify() (또는 counter.notifyAll())을 호출해서 이 counter 객체의 소유권을 양보했던 스레드 A를 깨워 다시 실행시킨다.

package com.example.java;

public class CounterTest {
    public static void main(String[] args) {
        Counter c = new Counter();
        Thread t1 = new MyThread( c );
        Thread t2 = new Thread( new MyImplementation( c ) );
        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("The counter value: " + c.value());
    }
}

class Counter {
    int c = 0;
    public synchronized void increment() {
        c = c + 1;
        this.notify();
    }

    public synchronized void decrement() {
        if ( c <= 0 ) { 
            try { 
                this.wait(); 
            } catch (InterruptedException e1) { 
                e1.printStackTrace(); 
            } 
        } 
        c = c - 1; 
    } 

    public int getCounter() { 
        return c; 
    } 
} 

이와 유사한 방식으로 동작하는 프로그램들 중 대표적인 예가 생산자 소비자 협업 프로그램이다. 생산자 소비자 협업 프로그램은 생산자가 만든 물건을 소비자가 받아 소비하는 과정을 흉내낸다. 이때 생산자와 소비자를 담당하는 두 스레드를 만들고 물건을 서로 주고 받기 위해 버퍼 리소스를 공유한다.

두 스레드를 동시에 실행했을 때 우연히 소비자가 공유된 버퍼 리소스를 먼저 차지하게되었다라고 가정하자. 설사 소비자가 먼저 버퍼 리소스를 사용할 수 있게 된 상황이라도 생산자가 물건을 만들어내기 전에는 그 물건을 가져갈 수 없다. 이때 소비자 스레드가 계속해서 버퍼 리소스를 가지고 있으면 생산자 스레드가 물건을 생산하더라도 버퍼에 저장할 수 없으므로, 소비자 스레드는 버퍼 리소스를 생산자스레드가 사용할 수 있도록 양보하고 물건이 생산되기를 기다려야 한다. 카운터 예제 프로그램과 생산자 소비자 프로그램을 비교하자면, 위 카운터 예제에서는 increment는 항상 실행 가능하나 decrement는 현재 카운터 값이 0 이상인 경우에만 실행 가능하다. 생산자 소비자 프로그램에서는 생산자가 물건을 생산했다고 하더라도 버퍼에 있는 물건을 소비자가 아직 소비하지 않았다면 그 버퍼가 빌때까지 생산자는 기다려야 한다. 또한 앞에서 이미 설명한 바와 같이 소비자도 생산자가 만든 물건이 버퍼에 있지 않으면 그 버퍼가 찰 때까지 기다려야 한다.

[문제] 위의 카운터 프로그램은 카운터가 감소하는 하한값을 0으로 두었다. 이것은 생산자 소비자 프로그램에서 생산자가 특별한 제한없이 많은 수의 물건을 생산할 수 있다고 가정한 경우에 해당한다. 생산자가 소비자가 소비했는지 여부에 따라 제한을 두어 생산하는 원래 생산자 소비자 프로그램과 같이, 카운터가 증가하는 상한값을 하한값과 함께 고려하는 경우 inc() 메소드도 dec() 메소드와 같은 구조를 갖도록 수정해야 한다. 상한값 10, 하한값 0 사이에 카운터 값을 유지하도록 카운터 프로그램을 수정하시오.

4.3 무작위 순서로 공유 리소스를 사용하는 시나리오

리소스 선점 상황에 따라 각 스레드들이 오도가도 못하는 교착 상태(deadlock)빠질 수 있는 시나리오를 고려하자. 식사하는 철학자 문제를 간략화해서 설명한다.

- 두 사람(스레드)가 저녁 식사를 한다.

- 두 사람 모두 각자에게 주어진 식사(초기 counter값)를 모두 마치는 것을 목표로 한다.

- 불행히도 젓가락(공유 리소스)이 한 세트 밖에 없어서 두 사람이 이를 공유해야 한다.

- 한 사람이 젓가락 두 짝을 집으면 식사를 한 번(counter를 1 감소) 할 수 있다. 각 사람이 젓가락을 집는 전략으로 두 가지가 있다.

- 한 사람이 lock1 -> lock2 순서대로 잡으려 하고 다른 사람은 lock2 -> lock1 순서대로 잡는다.

- 한 사람이 lock1 -> lock2 순서대로 잡으려 하고 다른 사람도 lock1 -> lock2 순서대로 잡는다.

 

아래 프로그램에서 첫번째 전략은 main 메소드의 다음 코드로 구현한다.

Thread p1 = new ThreadPhilosopher("A", lock1, lock2);
Thread p2 = new ThreadPhilosopher("B", lock2, lock1);

두번째 시도는 위 두 문장을 아래의 문장으로 바꾸면 된다.

Thread p1 = new ThreadPhilosopher("A", lock1, lock2);
Thread p2 = new ThreadPhilosopher("B", lock1, lock2);

각각의 시도의 결과는 어떠할까? 그리고 왜 그러한 상황이 벌어지게 되었는지 살펴보자.

package com.example.java;

public class ThreadPhilosopherTest {
    public static void main(String[] args) throws InterruptedException {
        String lock1 = "lock1";
        String lock2 = "lock2";

        Thread p1 = new ThreadPhilosopher("A", lock1, lock2);
        Thread p2 = new ThreadPhilosopher("B", lock2, lock1);
        p1.start();
        p2.start();

        p1.join();
        p2.join();

        System.out.println("Finished...");
    }
}

class ThreadPhilosopher extends Thread {
    String name;
    int counter = 100000;
    String left;
    String right;

    ThreadPhilosopher(String name, String left, String right) {
        this.name = name;
        this.left = left;
        this.right = right;
    }
    public void run () {
        while ( counter > 0 ) {
            synchronized (left) {
                System.out.println(name + " got " + left);
                synchrnozie (right) {
                     System.out.println(name + " got " + right);
                     counter--;
                }
            } 
            System.out.println(name + " released both");
        }
    }
}

[문제] 위의 프로그램은 철학자 2명만 고려했다. 그 수를 5명으로 늘려 철학자 식사 문제를 푸는 프로그램으로 수정하시오.

[끝]

Leave a Reply

Your email address will not be published. Required fields are marked *