본문 바로가기
프로그래밍 놀이터/안드로이드, Java

[Java] 병렬 프로그래밍 - 스레드 안전성

by 돼지왕 왕돼지 2012. 6. 19.
반응형




02. 스레드 안전성


- 병렬 프로그램이 단순하게 스레드와 락(lock)만 신경 써서 될 일이 아니다. 스레드와 락은 그저 목적을 위한 도구일 뿐. 스레드에 안전한 코드를 작성하는 것은 근본적으로 상태, 특히 "공유되고 변경할 수 있는 상태에 대한 접근을 관리" 하는 것이다. 객체의 상태는 인스턴스나 static 변수 같은 상태 변수에 저장된 객체의 데이터이다. 객체의 상태에는 다른 객체의 필드에 대한 의존성이 포함될 수도 있다.

- 공유됐다는 것은 여러 스레드가 특정 변수에  접근 할 수 있다는 뜻이고, 변경할 수 있다(mutable)는 것은 해당 변수 값이 변경될 수 있다는 뜻. 스레드 안전성이라는 것은 실제로는 데이터에 제어 없이 동시접근하는 걸 막는다는 의미이다. 

- 객체가 스레드에 안전해야 하느냐는 해당 객체에 여러 스레드가 접근할지의 여부에 달렸다. 객체가 어떻게 사용되는가의 문제지 그 객체가 뭘 하느냐와는 무관하다. 객체를 스레드에 안전하게 만들려면 동기화를 통해 변경할 수 있는 상태에 접근하는 과정을 조율해야 한다.

- 스레드가 하나 이상 상태 변수에 접근하고 그 중 하나라도 변수에 값을 쓰면, 해당 변수에 접근할 때 관련된 모든 스레드가 동기화를 통해 조율해야 한다. 자바에서 동기화를 위한 기본 수단은 synchronized 키워드로서 배타적인 락을 통해 보호 기능을 제공한다. 하지만, volatile 변수, 명시적 락, 단일 연산 변수(atomic variable)을 사용하는 경우에도 '동기화'라는 용어를 사용한다.

- 공유된 상태에 대한 접근을 동기화해야 한다는 원칙에 '특별한' 경우의 예외가 있다고 생각하고 싶겠지만, 그런 예외는 없다.

만약 여러 스레드가 변경할 수 있는 하나의 상태 변수를 적절한 동기화 없이 접근하면 그 프로그램은 잘못된 것이다. 이렇게 잘못된 프로그램을 고치는 데는 세 가지 방법이 있다.

1. 해당 상태 변수를 스레드 간에 공유하지 않거나
2. 해당 상태 변수를 변경할 수 없도록 만들거나
3. 해당 상태 변수에 접근할 땐 언제나 동기화를 사용한다. 


- 스레드 안전성을 확보하기 위해 나중에 클래스를 고치는 것보다는 애당초 스레드에 안전하게 설계하는 편이 훨씬 쉽다. 프로그램의 규모가 커지면 특정 변수를 여러 스레드에서 접근하는지 파악하는 일조차 간단치 않을 수 있다.

- 캡슐화나 데이터 은닉(data hiding) 같은 기법이 스레드에 안전한 클래스를 작성하는 데도 도움이 될 수 있다. 프로그램 상태를 잘 캡슐화할수록 프로그램을 스레드에 안전하게 만들기 쉽고 유지 보수 팀에서도 역시 해당 프로그램이 계속해서 스레드에 안전하도록 유지하기 쉽다.

스레드 안전한 클래스를 설계할 땐, 바람직한 객체 지향 기법이 왕도다. 캡슐화와 불변 객체를 잘 활용하고, 불변 조건을 명확하게 기술해야 한다.

 
- 항상 코드를 올바르게 작성하는 일이 먼저이고, 그 다음 필요한 만큼 성능을 개선해야 한다. 또 최적화는 성능 측정을 해본 이후에 요구 사항에 미달될 때만 하는 편이 좋고, 실제와 동일한 상황을 구현해 성능을 측정하고, 예상되는 수치가 목표 수치와 차이가 있을 때만 적용해야 한다.




2.1. 스레드 안전성이란?


여러 스레드가 클래스에 접근할 때, 실행 환경이 해당 스레드들의 실행을 어떻게 스케줄하든 어디에 끼워 넣든, 호출하는 쪽에서 추가적인 동기화나 다른 조율 없이도 정확하게 동작하면 해당 클래스는 스레드 안전하다고 할 수 있다.


스레드 안전한 클래스는 클라이언트 쪽에서 별도로 동기화할 필요가 없도록 동기화 기능도 캡슐화한다.




2.1.1 예제 : 상태 없는 서블릿.


상태 없는 객체는 항상 스레드 안전하다.






2.2. 단일 연산.


2.2.1. 경쟁 조건


- 경쟁 조건은 상대적인 시점이나 또는 JVM이 여러 스레드를 교차해서 실행하는 상황에 따라 계산의 정확성이 달라질 때 나타난다. 타이밍이 딱 맞았을 때만 정답을 얻는 경우를 말한다. 가장 일반적인 경쟁 조건 형태는 점검 후 행동(check-then-act) 형태의 구문이다. 대부분 경쟁 조건은 이런 관찰 결과의 무효화로 특징 지어진다. 즉 잠재적으로 유효하지 않은 관찰 결과로 결정을 내리거나 계산을 하는 것이다. 이런 류의 경쟁 조건을 점검 후 행동이라고 한다.


2.2.2. 예제 : 늦은 초기화 시 경쟁 조건


- 늦은 초기화(lazy initialization)는 특정 객체가 실제 필요할 때까지 초기화를 미루고 동시에 단 한번만 초기화 되도록 하기 위한 것이다. 보통 늦은 초기화에서는 null check 를 하는데, 여러 thread 에서 동시 접근할 경우, single tone 이 유지되지 못하고, 각기 다른 thread 가 각기 다른 instance 를 return 으로 받을 수 있다.

- 대부분 병렬 처리 오류가 그렇듯, 경쟁 조건 때문에 프로그램에 오류가 항상 발생하지는 않으며, 운 나쁘게 타이밍이 꼬일 때만 문제가 발생한다.  
 

2.2.3. 복합 동작

 

작업 A를 실행 중인 스레드 관점에서 다른 스레드가 작업 B를 실행할 때 작업 B가 모두 수행됐거나 또는 전혀 수행되지 않은 두 가지 상태로만 파악된다면 작업 A의 눈으로 볼 때 작업 B는 단일 연산이다. 단일 연산 작업은 자신을 포함해 같은 상태를 다루는 모든 작업이 단일 연산인 작업을 지칭한다.


- java.util.concurrent.atomic 패키지에는 단일 연산 변수( atomic variable )클래스가 준비돼 있다. 

가능하면 클래스 상태를 관리하기 위해 AtomicLong처럼 스레드에 안전하게 이미 만들어져 있는 객체를 사용하는 편이 좋다. 스레드 안전하지 않은 상태 변수를 선언해두고 사용하는 것보다 이미 스레드 안전하게 만들어진 클래스가 가질 수 있는 가능한 상태의 변화를 파악하는 편이 훨씬 쉽고, 스레드 안전성을 더 쉽게 유지하고 검증할 수 있다.





2.3. 락


- 여러 개의 변수가 하나의 불변조건을 구성하고 있다면, 이 변수들은 서로 독립적이지 않다. 따라서 변수 하나를 갱신할 땐, 다른 변수도 동일한 단일 연산 작업 내에서 함께 변경해야 한다.

상태를 일관성 있게 유지하려면 관련 있는 변수들을 하나의 단일 연산으로 갱신해야 한다.




2.3.1. 암묵적인 락

 
- 자바에는 단일 연산 특성을 보장하기 위해 synchronized 라는 구문으로 사용할 수 있는 락을 제공한다. 메소드 선언 부분에 synchronized 키워드를 지정하면 메소드 내부의 코드 전체를 포함하면서 메소드가 포함된 클래스의 인스턴스를 락으로 사용하는 synchronized 블록을 간략하게 표현한 것으로 볼 수 있다. (  static 으로 선언된 synchronized 메소드는 해당 class 객체를 락으로 사용한다. ).

- 모든 자바 객체는 락으로 사용할 수 있다. 이와 같이 자바에 내장된 락을 암묵적인 락( intrinsic lock ) 혹은 모니터 락 ( monitor lock ) 이라고 한다. 락은 스레드가 synchronized 블록에 들어가기 전에 자동으로 확보되며 정상적으로든 예외가 발생해서든 해당 블록을 벗어날 때 자동으로 해제된다. 해당 락으로 보호된 synchronized 블록이나 메소드에 들어가야만 암묵적인 락을 확보할 수 있다.

-  자바에서 암묵적인 락은 뮤텍스( mutexes ) 또는 상호 배제 락( mutual exclusion lock ) 으로 동작한다. 즉 한 번에 한 스레드만 특정 락을 소유할 수 있다.



2.3.2. 재진입성


- 스레드가 다른 스레드가 가진 락을 요청하면 해당 스레드는 대기 상태에 들어간다. 하지만 암묵적인 락은 재진입 가능( reentrant ) 하기 떄문에 특정 스레드가 자기가 이미 획득한 락을 다시 확보할 수 있다. 재진입성은 확보 요청 단위가 아닌 스레드 단위로 락을 얻는다는 것을 의미한다.

- 재진입성을 구현하려면 각 락마다 확보 횟수와 확보한 스레드를 연결시켜 둔다. 확보 횟수가 0이면 락은 해제된 상태이다. 스레드가 해제된 락을 확보하면 JVM이 락에 대한 소유 스레드를 기록하고 확보 횟수를 1로 지정한다. 같은 스레드가 락을 다시 얻으면 횟수를 증가시키고, 소유한 스레드가 synchronized 블록 밖으로 나가면 횟수를 감소시킨다. 이렇게 횟수가 0이 되면 해당 락은 해제된다.

- 재진입성 때문에 락의 동작을 쉽게 캡슐화할 수 있고, 객체 지향 병렬 프로그램을 개발하기가 단순해졌다. 



2.4. 락으로 상태 보호하기.

 
- 락은 자신이 보호하는 코드 경로에 여러 스레드가 순차적으로 접근하도록 한다.

- 특정 변수에 대한 접근을 조율하기 위해 동기화할 때는 해당 변수에 접근하는 모든 부분을 동기화해야 한다. 또한 변수에 대한 접근을 조율하기 위해 락을 사용할 땐 해당 변수에 접근하는 모든 곳에서 반드시 같은 락을 사용해야 한다.
 
- 흔한 실수 중 하나는 공유 변수에 값을 쓸 때만 동기화가 필요하다고 생각하기 쉽다는 점인데, 당연하지만 잘못된 생각이다.

여러 스레드에서 접근할 수 있고 변경 가능한 모든 변수를 대상으로 해당 변수에 접근할 때는 항상 동일한 락을 먼저 확보한 상태여야 한다. 이 경우 해당 변수는 확보된 락에 의해 보호된다고 말한다.

 
- 락으로 보호돼 있다는 사실은 @GuardedBy 어노테이션으로 표시한다.

- 특정 객체의 락을 얻는다고 해도 다른 스레드가 해당 객체에 접근하는 걸 막을 순 없다. 락을 얻으면 단지 다른 스레드가 동일한 락을 얻지 못하게 할 수 있을 뿐이다.

- 공유 상태에 안전하게 접근할 수 있도록 락 규칙( locking protocol )이나 동기화 정책을 만들고 프로그램 내에서 규칙과 정책을 일관성 있게 따르는 건 순전히 개발자에게 달렸다.

모든 변경할 수 있는 공유 변수는 정확하게 단 하나의 락으로 보호해야 한다. 유지 보수하는 사람이 알 수 있게 어느 락으로 보호하고 있는지를 명확하게 표시하라.


- 락을 활용함에 있어 일반적인 사용 예는 먼저 모든 변경 가능한 변수를 객체 안에 캡슐화하고, 해당 객체의 암묵적인 락을 사용해 캡슐화한 변수에 접근하는 모든 코드경로를 동기화함으로써 여러 스레드가 동시에 접근하는 상태에서 내부 변수를 보호하는 방법이다.

- 특정 변수가 락으로 보호되면, 즉 해당 변수에 항상 락을 확보한 상태에서 접근하도록 하면, 한 번에 한 스레드만 해당 변수에 접근할 수 있다는 점을 보장할 수 있다. 클래스에 여러 상태 변수에 대한 불변조건이 있으면 불변조건에 관련된 각 변수는 모두 같은 락으로 보호돼야 한다는 추가 요구사항이 따라 붙는다.

여러 변수에 대한 불변조건이 있으면 해당 변수들은 모두 같은 락으로 보호해야 한다.


- 모든 메소드를 동기화하면 활동성이나 성능에 문제가 생길 수도 있다.




2.5. 활동성과 성능


- 단순하고 큰 단위로 동기화에 접근하면 안전성을 확보할 순 있지만 치러야 할 대가가 너무 크다.

-  synchronized 블록의 범위를 줄이면 스레드 안전성을 유지하면서 쉽게 동시성을 향상시킬 수 있다. 이 때 synchronized 블록의 범위를 너무 작게 줄이지 않도록 조심해야 한다. 다시 말해 단일 연산으로 처리해야 하는 작업을 여러 개의 synchronized 블록으로 나누진 말아야 한다.

- 서로 다른 두 가지 동기화 수단을 사용해 봐야 혼동을 줄 뿐 성능이나 안전성 측면의 이점이 없다.

- 락을 얻고 놓는 작업만으로도 어느 정도의 부하가 따른다. 따라서 단일 연산 구조에 문제가 생기지 않는다 해도 synchronized 블록을 "너무 잘게" 쪼개는 일은 바람직하지 않다.

- synchronized 블록의 크기를 적정하게 유지하려면 안전성( 절대 타협할 수 없다. ), 단순성, 성능 등의 서로 상충하는 설계 원칙 사이에 적절한 타협이 필요할 수 있다. 때론 단순성과 성능이 서로 상충되는데, 일반적으로는 적절한 타협점을 찾을 수 있다.

종종 단순성과 성능이 서로 상충할 때가 있다. 동기화 정책을 구현할 때는 성능을 위해 조급하게 단순성( 잠재적으로 안전성을 훼손하면서 )을 희생하고픈 유혹을 버려야 한다.

 
- 락을 사용할 땐 블록 안의 코드가 무엇을 하는지, 수행하는 데 얼마나 걸릴지를 파악해야 한다. 계산량이 많은 작업을 하거나 잠재적으로 대기 상태에 들어 갈 수 있는 작업을 하느라 락을 오래 잡고 있으면 활동성이나 성능 문제를 야기할 수 있다.

복잡하고 오래 걸리는 계산 작업, 네트웍 작업, 사용자 입출력 작업과 같이 빨리 끝나지 않을 수 있는 작업을 하는 부분에서는 가능한 한 락을 잡지 말아라.

 
 
도움이 되셨다면 손가락 꾸욱~




 
반응형

댓글