2. 스레드의 안전성
도입
2-0-1. 스레드 안전(Thread Safety)
- 멀티 스레드 프로그래밍에서 어떤 함수나 변수, 혹은 객체가 여러 스레드로부터 동시에 접근이 이루어져도 프로그램의 실행에는 문제가 없음을 말한다.
ex) 하나의 함수가 한 스레드로부터 호출되어 실행 중일 때, 다른 스레드가 그 함수를 호출하여 동시에 함께 실행되더라도 각 스레드에서의 함수의 수행결과가 올바르게 출력되는 것.
- 병렬 프로그램에서 단순히 스레드와 락(Lock)만 신경 써서 해결되지 않는다. 스레드와 락은 병렬 프로그램을 올바르게 잘 사용하기 위한 도구일 뿐이다.
2-0-2. 스레드에 안전한 코드를 작성하는 방법
- 공유되고 변경할 수 있는 스레드의 상태에 대한 접근을 관리하는 것
2-0-3. 객체의 상태
- 인스턴스(instance)나 static 변수 같은 상태 변수에 저장된 객체 데이터
- 스레드가 하나 이상 상태 변수에 접근하고 그 중 하나라도 변수에 값을 쓰면 변수에 접근할 때 관련된 모든 스레드가 동기화를 통해 조율해야 함.
2-0-4. 자바의 기본 동기화 수단
- synchronized 키워드(배타적인 락을 통해 보호 기능을 제공)
- but, volatile 변수, 명시적 락, 단일 연산 변수(atomic variable)을 사용하는 경우에도 동기화라는 용어를 사용함.
* 여러 스레드가 변경 할 수 있는 하나의 상태 변수를 적절한 동기화 없이 접근하면 그 프로그램은 잘못된 것으로 이것을 고칠수 있는 세 가지 방법
1) 해당 상태 변수를 스레드 간에 공유하지 않음
2) 해당 상태 변수를 변경 할 수 없도록 만듬
3) 해당 상태 변수에 접근할 땐 언제나 동기화를 사용
※※ 병렬 프로그램을 설계할 때 스레드에 안전하게 설계하는 것이 중요!!
2-0-5. 스레드에 안전한 클래스를 작성하는 기법
- 데이터 은닉(Data Hiding)
- 특정 변수에 접근하는 코드가 적을수록 -> 적절한 동기화가 사용됐는지 확인하기 쉽고, 특정 변수에 접근여부를 판단하기 쉬움.
- 캡슐화(Encapsulation) : 반드시 캡슐화(Encapsulation)를 해야하는 것은 아님. but, 프로그램 상태를 잘 캡슐화(Encapsulation)할수록 -> 만들고자하는 프로그램을 스레드에 안전하게 만들기 쉽고, 유지보수를 하는데 있어서 스레드에 안전하도록 유지하기 쉬움.
※※ 스레드에 안전한 클래스를 설계할 때 바람직한 객체 지향 기법을 이용하는 것이 좋다.
※※ 캡슐화(Encapsulation)와 불변 객체를 잘 활용하고, 불변 조건을 명확하게 기술해야한다.
본론
2.1 스레드의 안정성이란?
정의
- 프로그램에서 원치 않는 스래드간에 상호 작용 없이 호출할 수 있는 것
- 호출하는 측에서 다른 작업을 하지 않고도 여러 스레드에서 동시에 호출할 수 있는 것
정확성 = 클래스의 명세에 부합하다는 뜻.
※※ 여러 스레드가 클래스에 접근할 때, 실행환경이 해당 스레드들의 실행을 어떻게 스케줄하든 어디에 끼워 넣든, 호출하는 쪽에서 추가적인 동기화나 다른 조율 없이도 정확하게 동작하면 해당 클래스는 스레드에 안전하다고 말한다.
** 스레드에 안전한 클래스는 클라이언트 쪽에서 별도로 동기화할 필요가 없도록 동기화 기능도 캡슐화 한다.
Ex)2.1 상태없는 서블릿.
@ThreadSafe
public class StatelessFactorizer extends GenericServlet implements Servlet {
@Override
public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
encodeIntoResponse(res, factors);
}
void encodeIntoResponse(ServletResponse res, BigInteger[] factors) {
}
BigInteger extractFromRequest(ServletRequest req) {
return new BigInteger("7");
}
BigInteger[] factor(BigInteger i) {
return new BigInteger[] { i };
}
}
선언한 변수가 없고 다른 클래스의 변수를 참조하지 않는다. 특정 계산을 위한 일시적인 상태는 스레드의 스택에 저장되는 지역변수에만 저장되고, 실행하는 해당 스레드에서만 접근할 수 있다. 그래서 다른 스레드의 결과에 영향을 줄수 없다. 이는 두 스레드가 상태를 공유하지 않기 때문에 사실상 서로 다른 인스턴스에 접근하는 것과 같다. 상태없는 객체에 접근하는 스레드가 어떤 일을 하든 다른 스레드가 수행하는 동작의 정확성에 영향을 끼칠 수 없기 때문에 상태 없는 객체는 항상 스레드에 안전하다.
※※ 상태없는 객체는 항상 스레드에 안전하다.
2.2 단일 연산
Q. 상태 없는 객체에 상태를 하나 추가하면?
접속카운트를 추가한 코드 count 변수
public class UnsafeCountingFactorizer extends GenericServlet implements Servlet {
private long count = 0;
public long getCount() {
return count;
}
@Override
public void service(ServletRequest req, ServletResponse res) throws ServletException, IOException {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
++count;
encodeIntoResponse(res, factors);
}
void encodeIntoResponse(ServletResponse res, BigInteger[] factors) {
}
BigInteger extractFromRequest(ServletRequest req) {
return new BigInteger("7");
}
BigInteger[] factor(BigInteger i) {
return new BigInteger[] { i };
}
}
- 단일 스레드에서는 잘 작동하겠지만 스레드에는 안전하지 않다. 이유는 변경한 값을 잃어버리는 경우가 생길 수 있다.
값을 증가시키는 ++count는 한 줄 짜리 간단한 코드라 단일 작업처럼 보이지만 실제로는 단일연산이 아니다. 즉, 나눌 수 없는 최소 단위의 작업으로 실행되는 게 아니라는 것이다.
두 개 이상의 스레드에서는 동기화를 하지 않았기 때문에 접속 카운터의 값이 엉망으로 기록될 수 있다.
2.2.1 경쟁조건(race condition)
정의
- 두 개 이상의 프로세스가 공통 자원을 병행적으로 읽거나 쓸 때, 공용 데이터에 대한 접근이 어떤 순서에 따라 이루어졌는지에 따라 그 실행 결과가 달라지는 상황
* 피하는 방법
- Mutual Exclusion과 Critical Section이 있다.
1) Mutual Exclusion(상호배재)
- 두 개 이상의 프로세스가 공용 데이터를 동시에 접근하는 것을 막아주는 것. 즉 한 프로세스가 공용 데이터를 사용하고 있으면 다른 프로세스가 그 자원을 사용하지 못하도록 막거나, 다른 프로세스가 이미 공용 자원을 사용하고 있으면 이 프로세스가 그 자원을 사용하지 못하도록 한다.
2) Critical Section(임계구역)
- 프로세스 입장에서 어떤 부분은 자신만의 데이터를 가지고 열심히 계산만 하는 부분이 있고, 공용 데이터에 값을 저장하거나 읽어오는 부분이 있다. 공용데이터에 접근하는 부분을 특별히 Critical region 또는 Critical Section이라고 부른다. 즉 프로세스란 측면에서 보았을 때 어떤 두 프로세스가 동시에 Critical Section(공용데이터에 접근하는 부분)에 들어가지 않도록 하는 것이다.
- 데이터 경쟁(data race) : 공유된 final이 아닌 필드에 대한 접근을 동기화로 보호하지 않았을 때 발생
2.2.2 늦은 초기화 시 경쟁조건
public class LazyInitRace {
private LazyInitRace instance = null;
public LazyInitRace getInstance() {
if (instance == null) {
instance = new LazyInitRace();
}
return instance;
}
}
Lazy initialization은 특정 객체가 실제 필요할 때까지 초기화를 미루고 동시에 단 한번반 초기화 되도록 하기 위한 것.
위 코드는 경쟁조건 때문에 제대로 동작하지 않을 가능성이 있다.
2.2.3 복합동작
단일연산 : 스레드의 안전성을 보장하기 위해(늦은 초기화 같은) 점검 후 행동과(증가 작업 같은) 읽고 수정하고 쓰기 등의 작업
복합동작 : 점검 후 행동과 읽고 수정하고 쓰기 같은 일련의 동작
- 스레드에 안전하기 위해서는 전체가 단일 연산으로 실행돼야 하는 일련의 동작을 지징함
2.3 락(Lock)
- 상태를 일관성 있게 유지하려면 관련 있는 변수들을 하나의 단일 연산으로 갱신해야 한다.
2.3.1 암묵적인 락
- synchronized 구문은 락으로 사용될 객체의 참조 값과 해당 락으로 보호하려는 코드 블록으로 구성
Synchronized (lock) {
// lock으로 보호된 공유 상태에 접근하거나 해당 상태를 수정
}
- 자바에 내장된 락을 암묵적인 락(intrinsic lock) or 모니터 락(monitor lock)이라고 한다.
- 자바의 암묵적 락은 뮤텍스(mutex) 또는 mutual exclusion lock(상호배제 락)으로 동작한다.
즉, 한번에 한 스레드만 특정 락을 소유할 수 있다.
2.3.2 재진입성
- 스레드가 다른 스레드에 가진 락을 요청하면 해당 스레드는 대기상태에 들어간다.
- 암묵적인 락은 재진입 가능하기 때문에 특정 스레드는 자기가 이미 획득한 락을 다시 확보할 수 있다.
- 재진입성은 확보 요청 단위가 아닌 스레드 단위로 락을 얻는다는 것을 의미
- 재진입성을 구현하려면 각 락마다 확보 횟수와 확보한 스레드를 연결시켜둠
2.4 락(Lock)으로 상태 보호하기
- 경쟁조건을 피할려면 접속카운터를 증가시키거나(읽고 수정하고 쓰기) 늦게 초기화하는(확인 후행동) 경우 하나의 고용된 상태에 대한 복합동작을 단일 연산으로 만들어야한다.
** 여러 스레드에서 접근할 수 있고 변경 가능한 모든 변수를 대상으로 해당 변수에 접근할 때는 항상 동일한 락을 먼저 확보한 상태여야 한다. 이 경우 해당 변수는 확보된 락에 의해 보호된다고 말한다.
** 변경할 수 있는 모든 공유변수는 정확히 단 하나의 락으로 보호해야 한다. 유지보수하는 사람이 알 수 있게 어떤 락으로 보호하고 있는지 명확하게 표시해야한다.
- 락으로 보호해야하는 경우 : 모든 데이터를 락으로 보호하는 것이 아니고, 변경 가능한 데이터를 여러 스레드에서 접근해 사용하는 경우에만 해당한다.
** 여러 변수에 대한 불변조건(Invariant)이 있으면 해당 변수들은 모두 같은 락으로 보호해야 한다.
** 모든 메서드를 동기화하면 활동성이나 성능에 문제가 생길수도 있다.
2.5 활동성과 성능
** 서비스 메서드에 동기화를 걸면 안정성을 확보할 수 있지만 치러야 할 대가가 너무 크다.
이유
Synchronized 키워드를 지정하고 service 메서드는 한번에 한 스레드만 실행할 수 있다. 이는 동시에 여러 요청을 처리할 수 있게 설계된 서블릿 프레임워크의 의도와 배치되고 요청이 많아졌을 경우 느린 속도 때문에 사용자 불만이 높아질 것이다.
오랫동안 service를 작업하고 있다면 다른 클라이언트는 현재 요청이 완료될 때까지 마냥 기다려야 한다. 캐시돼있는 데이터를 빨리처리할 수 있는데도 요청이 오래걸리는 이전 작업들 때문에 처리가 늦어짐.
- synchronized 블록의 범위를 줄이면
스레드의 안전성을 유지하면서 쉽게 동시성을 향상 시킬수 있다. 이때 synchronized 블록의 범위를 너무 작게 줄이지 않도록 조심해야한다. 즉 단일 연산으로 처리해야 하는 작업을 여러 개의 synchronized 블록으로 나누지 말아야한다는 것이다.
** synchronized 블록은 최대한 빨리 뽑아내는 것이 중요.
- 락을 얻고 해제 하는 작업만으로도 어느정도 부하가 따른다. 따라서 단일 연산 구조에 문제가 생기지 않는다 해도 synchronized 블록을 “너무 잘게” 쪼개는 일은 바람직하지 않다.
결론은 너무 잘게 쪼게는 것도 좋은 것이 아니고, 길게 유지하는 것도 좋지 않다는 것이다.
적당히 적절하게 유지하는 것이 중요.(balance)
- synchronized 블록의 크기를 적정하게 유지하려면 안정성(절대 타협할 수 없다), 단순성, 성능 등이 서로 상충하는 설계 원칙 사이에 적절한 타협이 필요 할 수 있다.
** 종종 단순성과 성능이 서로 상충할 때가 있다. 동기화 정책을 구현할 때는 성능을 위해 조급하게 단순성(잠재적으로 안정성을 훼손하면서)을 희생하려는 유혹을 버려야 한다.
- 락(Lock)을 사용할 때 블록 안의 코드가 무엇을 하는지, 수행하는데 얼마나 걸리는지 파악해야한다. 계산량이 많은 작업을 하거나 잠재적으로 대기 상태에 들어 갈 수 있는 작업을 하느라 락을 오래 잡고 있으면 활동성이나 성능 문제를 발생할 수 있다.
** 복잡하고 오래 걸리는 계산 작업, 네트워크 작업, 사용자 입출력 작업과 같이 빨리 끝나지 않을 수 있는 작업을 하는 부분에서는 가능하면 락(Lock)을 잡지말자.
'Language > Java' 카테고리의 다른 글
규칙 2. 생성자 인자가 많을 때는 Builder 패턴 적용을 고려하라 (0) | 2017.01.19 |
---|---|
규칙 1. 생성자대신 정적 팩터리 메서드를 사용할 수 없는지 생각해 보라 (0) | 2017.01.19 |
String 보다는 StringBuilder, StringBuffer를 사용해야하는 이유. (0) | 2016.10.07 |
[링크]셧다운 후크를 사용한 어플리케이션의 안전한 종료 처리 (0) | 2016.08.11 |
Java static 키워드 (0) | 2016.08.11 |