이 내용은 "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가 필요 없다.
댓글