본문 바로가기
공부(~2016)/멀티쓰레드

[멀티쓰레드패턴] #1. Single Threaded Execution 패턴

by soy; 2015. 1. 3.

이 내용은 "Java 언어로 배우는 디자인 패턴 입문 - 멀티쓰레드편" 책의 Chapter 01 부분을 정리한 것이다.


Single Threaded Execution 패턴 (=Critical Section)

- 좁은 다리를 한 번에 한 명만 건널 수 있는 것처럼, 어떤 영역에 한 번에 한 개의 쓰레드만이 처리를 실행할 수 있도록 제한을 둔 패턴.

- Single Threaded Execution 패턴은 Critical Section 이라고도 불리는데, Single Treaded Execution은 실행하는 쓰레드(다리를 건너는 사람)에 초점을 맞춘 이름이고 Critical Section은 실행 영역 (한 명만 건널 수 있는 다리)에 주목한 이름이다.


참고사항

- 멀티쓰레드 프로그램에서, 오류를 검출하기 위한 체크메소드를 만들고 이를 통해 오류가 검출이 되면 thread-safety 하지 않다는 사실을 알 수 있다. 하지만 체크메소드에서 아무것도 검출되지 않았다고 해서 안전을 장담할 수 있는 것은 아니다. 체크 횟수가 불충분하거나 타이밍이 맞지 않으면 체크메소드를 통해서도 오류가 검출되지 않을 가능성이 있기 때문이다. 즉, 일반적으로 동작 테스트로 멀티쓰레드 프로그램의 안전성을 증명할 수 없다.

- 또한, 오류 검출 후 디버그를 표시하는 코드 자체가 thread-safety가 아니라면 잘못된 디버그 내용을 표시할 수 있다.



Single Threaded Execution 패턴의 구현 방법

여러개의 쓰레드에 의해 접근이 되는 공유자원(Shared Resource) 클래스의 메소드는 보통 다음 두 종류로 분류된다.

- safeMethod : 여러개의 쓰레드에서 동시에 호출해도 아무런 문제가 없는 메소드. 별다른 조치를 취할 필요 없음

- unsafeMethod : 여러개의 쓰레드에서 동시에 호출하면 안전하지 않기 때문에(인스턴스의 상태가 모순에 빠질 수 있기 때문에) 보호(guard)가 필요한 메소드.


여러개의 쓰레드가 공유하고 있는 인스턴스를 변경하면 인스턴스의 안전성을 보장할 수 없다. 따라서 인스턴스의 상태가 불안정해지는 범위를 정한 다음, 그 범위를 크리티컬 섹션으로 만든다. 그리고 크리티컬 섹션은 한 개의 쓰레드만이 실행하도록 guard 한다.


자바에서는 unsafeMethod / 크리티컬 섹션을 동시에 한 개의 쓰레드만 접근할 수 있도록, synchronized 키워드를 이용하여 보호한다. 즉, 한 쓰레드가 크리티컬 섹션을 실행하고 있는 동안에는 크리티컬 섹션에 들어가려고 했던 다른 쓰레드는 블록되어 대기하게 됨으로써 동시에 한 개의 쓰레드만 unsafeMethod/크리티컬 섹션을 실행함을 보장한다.


public class Gate {
    private int counter = 0;
    private String name = "Nobody";
    private String address = "Nowhere";
 
    // unsafeMethod 이므로 synchronized 선언
    public synchronized void pass(String name, String address) {
        this.counter++;
        this.name = name;
        this.address = address;
        check();
    }
 
    // unsafeMethod 이므로 synchronized 선언
    public synchronized String toString() {
        return "No." + counter + ": " + name + ", " + address;
    }
 
    // 오류를 검출하기 위한 체크 메소드. 
    // 이 메소드는 synchronized 메소드에서만 호출되고, private 메소드 이므로 synchronized 선언이 불필요함.
    private void check() {
        if (name.charAt(0) != address.charAt(0)) {
            System.out.println("***** BROKEN ***** " + toString());
        }
    }
 
}



어떤 경우에 사용하는가

1) 멀티쓰레드

- 싱글쓰레드 프로그램에서는 이 패턴을 사용할 필요가 없다. 즉 synchronized 메소드가 필요가 없다. 어차피 쓰레드가 한 개 뿐이므로 synchronized 메소드를 사용해도 안전성에는 문제가 없다. 단 synchronized 메소드를 호출하는 데에는 보통의 메소드를 호출하는 것보다 시간이 더 걸리기 때문에 성능이 저하된다.


2) 여러개의 쓰레드가 접근할 때

- 공유자원이 되는 인스턴스에 복수에 쓰레드가 접근을 할 가능성이 있을 때 사용한다. 즉, 멀티쓰레드 프로그램이라고 하더라도, 모든 쓰레드가 완전히 독립적으로 동작하고 있다면 이 패턴을 사용할 필요가 없다. 

- 여러개의 쓰레드를 사용하는 프레임워크에서, 쓰레드의 독립성을 프레임워크측에서 보장해주는 경우가 있다. 이런 경우는 Single Threaded Execution 패턴으로 해야할지에 대해 신경쓰지 않아도 된다.


3) 공유자원의 상태가 변경될 가능성이 있을 때

- 공유자원이 되는 인스턴스의 상태가 변경되는 것을 막기 위하여 사용한다. 즉, 인스턴스가 만들어진 후에 상태가 변경될 가능성이 전혀 없다면 이 패턴을 사용할 필요가 없다.


4) 안전성을 확보할 필요가 있을 때

- 자바 Collection 클래스의 대부분은 thread-safety 하지 않는데, 안전성을 지킬 필요가 없는 경우에 빠르게 동작할 수 있도록 하기 위한 것이다. 즉, (당연히) 안전성 확보가 필요할 때에만 이 패턴을 사용한다.



생존성(liveness) & 데드락

Single Thread Execution에서, 다음 조건을 모두 만족할 때 데드락이 발생한다. (데드락이 발생한다=생존성을 잃는다). 조건 중 한 개만 사라져도 데드락은 피할 수 있다.

- 1) 공유자원이 여러개이다.

- 2) 쓰레드가 어떠한 공유자원의 락을 획득한 상태에서, 또 다른 공유자원의 락을 획득하기를 원한다.

- 3) 공유자원의 락을 획득하는 순서가 정해져 있지 않다. 예를 들어, 공유자원 A와 B가 있을 때, [A의 락을 획득한 후 -> B의 락을 획득]하는 것도 가능하고, [B의 락을 획득한 후 -> A의 락을 획득]하는 것 또한 가능하다면 락을 획득하는 순서가 정해져 있지 않는 것이다.



재사용성(reusability) & 상속 이상(inheritance anomaly)

공유자원이 되는 클래스를 상속 받은 서브 클래스를 만들었다고 하자. 서브클래스에서 부모클래스의 필드에 접근이 가능하고, 서브클래스에서 unsafeMethod를 synchronized 메소드로 지정하지 않았다면 서브클래스에 의해 안전성이 무너질 수 있다. 

(당연한거 아닌가..?ㅠㅠ 부모클래스가 Single Threaded Execution 패턴을 적용했다면, 서브클래스에도 Single Threaded Execution 패턴을 적용해야 안전하다는 말 같음. 그렇지 않으면 상속 이상이 생길 수 있다는..)



크리티컬 섹션의 크기 & 성능

일반적으로 Single Threaded Execution 패턴은 성능을 떨어뜨린다. 그 이유는..

1) 락을 획득하는데 시간이 걸리기 때문

- synchronized 메소드에 들어가려면 인스턴스의 락을 획득해야 하는데, 이 처리에는 시간이 걸린다. 공유자원의 갯수를 줄이면 획득해야 할 락의 개수가 줄어들므로 성능 저하를 막을 수 있다.

2) 쓰레드의 충돌(conflict)로 대기하기 때문

- 한 쓰레드가 크리티컬 섹션을 실행하고 있는 동안에는, 크리티컬 섹션에 들어가려고 했던 다른 쓰레드는 블록되어 대기한다. 이러한 상황을 쓰레드의 충돌(conflict) 이라고 한다. 충돌이 일어나면 쓰레드가 대기하는 시간만큼 전체적으로 성능이 떨어지게 된다. 즉 크리티컬 섹션의 크기를 최소한으로 줄여서 쓰레드가 충돌할 확률을 낮추고, 블록되어 대기하는 시간을 줄이면 성능의 저하를 막을 수 있다.



synchronized & Before/After 패턴

synchronized void method() {
    ... 
    process();
    ...
}

synchronized 키워드를 사용하여 락을 걸고 해제하는 것과, 


void method() {
    lock();
    ...         // 1. 여기에 return문이 있다면..? 
    process();  // 2. 호출된 메소드에서 예외가 발생하면..?
    ...
    unlock();
}

직접 lock(), unlock() 메소드를 구현하여 메소드의 첫 줄에 lock()을 호출하고 마지막줄에 unlock()을 호출하는 것의 기능은 같을까? => 같지 않음.

1, Before/After 패턴에서 lock() 호출과 unlock() 호출 사이에 return문이 있다면, 락이 해제되지 않을 수 있다.

2, lock() 호출과 unlock() 호출 사이에 호출된 메소드에서 예외가 발생하면, 락이 해제되지 않을 수 있다.

이에 반해 synchronized 메소드/블록은 return을 하든지 예외가 발생하든지 상관없이 확실하게 락을 해제해준다.


void method() {
    lock();
    try{
    ...
    } finally {
        unlock();
    }
}

이렇게 finally를 이용하여 unlock()을 해준다면 return을 하든지 예외가 발생하든지 상관 없이 락을 해제시킬 수 있다.

- try 블록에서 어떤 상황이 발생하더라도 finally 블록이 확실하게 실행된다. 이는 자바 언어의 규칙이다.

- 이러한 finally 사용법은 Before/After 패턴을 구현하는 방법 중 하나이다.



이 synchronized는 무엇을 지키고 있는 것일까

- 소스코드를 읽다가 synchronized를 발견하면 '이 synchronized는 무엇을 지키고 있는 것일까' 생각하라. 보통은 해당 인스턴스의 특정 필드들을 지키고 있을 것이다.

- 무엇을 지키고 있는지 확인한 다음은 '다른 곳에서도 잘 지키고 있는가' 생각하라. 지키고 있는 필드가 여러 클래스/메소드에 걸쳐 사용되고 있을 때, 한 쪽만 synchronized로 지키고 다른 쪽은 그렇지 않다면 그 필드는 결국 보호받지 못한다.

- 여러개의 쓰레드가 공유하고 있는 필드에 접근하는 메소드는 모두 synchronized로 보호할 필요가 있다.

- 지켜야 하는 필드들을 각각 보호할지, 통합해서 보호할지 잘 생각해야 한다. (어떤 단위로 지켜야 할까?)



이 synchronized는 어떤 락을 사용해서 지키고 있는가

synchronized한 인스턴스를 실행하는 쓰레드는 this의 락을 획득해야 한다. 어떤 인스턴스의 락을 획득할 수 있는 것은 딱 한 개의 쓰레드 뿐이다. 

한편, 인스턴스가 다르면 락도 다르다. 락은 인스턴스마다 독립적으로 존재한다. 어떤 인스턴스의 synchronized 메소드가 실행중이라고 해서, 다른 인스턴스의 synchronized 메소드를 실행할 수 없는 것은 아니다.

synchronized 블록에서는 어떤 인스턴스의 락을 사용할지를 명시적으로 지정한다. 이 때 인스턴스 지정을 잘못 하면 절대 안된다..



최소 단위의 조작

1. synchronized 메소드/블럭은 atomic하게 취급된다. 즉, 하나의 최소 단위로 취급되므로 분할이 불가능하다.

2. 자바 언어에서는 최소 단위의 조작이 처음부터 정의되어 있다.

- char, int 등의 primitive type의 대입이나 참조는 최소 단위이다.

- 객체 등 reference type의 대입이나 참조 또한 최소 단위이다. 애초부터 최소 단위이기 때문에 synchronized를 붙이지 않아도 분할되는 일이 절대 없다.

- 단, long과 double의 대입이나 참조는 최소 단위가 아니다. 따라서 long이나 double 필드를 여러 쓰레드가 공유할 경우, 그 필드에 대한 조작은 Single Threaded Execution 패턴 (synchronized) 을 사용하여 이뤄져야 한다. 또는 synchronized에 넣지 않고 volatile 키워드를 사용하여 그 필드를 사용하는 방법도 있다. volatile 키워드를 붙이면 그 필드의 조작은 최소 단위가 된다. (그 외에도 다른 역할이 더 있다.)



계수 세마포어

Single Threaded Execution은 크리티컬 섹션을 '단 한 개의 쓰레드'만 실행하는 패턴이었다. 이것을 일반화하여 어떤 영역을 '최대 N개의 쓰레드'까지 실행할 수 있도록 할 때 사용하는 것이 계수 세마포어이다. 계수 세마포어는 capacity를 제어한다.

- 사용할 수 있는 리소스의 갯수가 최대 N개로 제한되어 있는데, N개보다 많은 수의 쓰레드가 그 리소스를 필요로 한다면? 이때 계수 세마포어를 사용한다.


java.util.concurrent 패키지에서는 계수 세마포어를 나타내는 Semaphore 클래스를 제공한다.

- Semaphore 클래스의 생성자는 리소스의 수를 인자로 받는다.

- Semaphore 클래스의 acquire 메소드는 리소스를 확보하는 메소드이다. 확보 가능한 리소스가 있다면 쓰레드가 acquire 메소드로부터 곧바로 돌아오게 되고, 세마포어 내부에서는 리소스의 수가 한 개 감소한다. 확보 가능한 리소스가 없는 경우, 쓰레드는 acquire 메소드 내부에서 블록하며 대기한다.

- Semaphore 클래스의 release 메소드는 리소스를 해제하는 메소드이다. 세마포어 내부에서 리소스의 갯수가 한 개 늘어난다. 또한 acquire 메소드 안에서 대기중인 쓰레드가 있다면, 그 중 한 개가 깨어나 acquire 메소드로부터 돌아올 수 있다.

- Semaphore 클래스의 availablePermits 메소드는 현재 남아있는 리소스의 갯수를 반환한다.


import java.util.Random;
import java.util.concurrent.Semaphore;
 
// 갯수 제한이 있는 리소스
class BoundedResource {
    private final Semaphore semaphore; // 세마포어 변수 선언
    private final int permits;
    private final static Random random = new Random(314159);
 
    // 생성자 (인자로 받는 permits은 리소스의 갯수)
    public BoundedResource(int permits) {
        this.semaphore = new Semaphore(permits);
        this.permits = permits;
    }
 
    // 리소스를 사용함
    public void use() throws InterruptedException {
        semaphore.acquire(); // 리소스를 획득한다.
        try {
            doUse();
        } finally {       // 어떠한 경우에도 release()가 실행될 수 있도록 finally 이용
            semaphore.release();
        }
    }
 
    // 리소스를 실제로 사용하는 메소드
    protected void doUse() throws InterruptedException {
        System.out.println("BEGIN: used = " + (permits - semaphore.availablePermits()));
        Thread.sleep(random.nextInt(500));
        System.out.println("END:   used = " + (permits - semaphore.availablePermits()));
    }
}
 
// 리소스를 사용하는 쓰레드 클래스
class UserThread extends Thread {
    private final static Random random = new Random(26535);
    private final BoundedResource resource;
 
    public UserThread(BoundedResource resource) {
        this.resource = resource;
    }
 
    public void run() {
        try {
            while (true) {
                resource.use();
                Thread.sleep(random.nextInt(3000));
            }
        } catch (InterruptedException e) {
        }
    }
}
 
public class Main {
    public static void main(String[] args) {
        // 3개의 리소스를 준비한다
        BoundedResource resource = new BoundedResource(3);
 
        // 10개의 쓰레드를 생성 및 기동한다.
        for (int i = 0; i < 10; i++) {
            new UserThread(resource).start();
        }
    }
}


관련 패턴

Guarded Suspension 패턴

- SIngle Threaded Execution 패턴에서는 쓰레드가 블록되어 대기상태에 들어가는 조건은 '다른 쓰레드가 guard되어 있는 unsafeMethod를 실행하고 있는 중인가 아닌가' 이다. 

- Guarded Suspension 패턴에서는 쓰레드가 블록되어 대기상태에 들어가는 조건이 '객체의 상태가 적절한가 아닌가' 이다. 또한, 이 패턴을 구성할 때 객체의 상태를 체크하는 부분에서 Single Threaded Execution 패턴이 사용된다.


Read-Write Lock 패턴

- Single Threaded Execution 패턴에서는 한 개의 쓰레드가 guard되어 있는 unsafeMethod를 실행하는 도중이라면, 그 메소드를 실행하려고 했던 다른 쓰레드들은 모두 대기하게 된다.

- Read-Write Lock 패턴에서는 여러개의 쓰레드가 read 메소드를 동시에 실행할 수 있다. write 메소드를 실행하고자 하는 쓰레드만 대기한다. 또한, 이 패턴을 구성할 때 쓰레드의 종류나 갯수를 체크하는 부분에서 Single Threaded Execution 패턴이 사용된다.


Immutable 패턴

- Single Threaded Execution 패턴에서는 unsafeMethod를 한 개의 쓰레드에서만 실행하도록 guard 해야 한다.

- Immutable 패턴에서는 객체의 상태가 변화하지 않는다. 따라서 어떠한 메소드도 guard할 필요가 없다. 다시 말해 Immutable 패턴에서는 모든 메소드가 safeMethod이다.


Thread-Specific Storage 패턴

- SIngle Threaded Execution 패턴에서는 여러개의 쓰레드가 공유자원 인스턴스에 접근한다. 따라서 unsafeMethod에 guard가 필요하다.

- Thread- Specific Storage 패턴에서는 쓰레드마다 고유 영역이 확보되어 있고, 그 고유 영역에는 단 한 개의 쓰레드에서만 접근할 수 있기 때문에 메소드 guard가 필요 없다.

댓글