병렬 프로그래밍이란?
공유되고 변경할 수 있는 상태에 대한 접근 관리를 하는 것이다.
공유 : 여러 스레드가 특정 변수에 접근 할 수 있고 변경 할 수 있다는 의미
이러한 접근 관리에는 스레드 안정성이 필요하다.
스레드 안정성
- 데이터 제어 없이 동시접근을 막음을 의미
- 객체가 스레드에 안전해야 하느냐는 해당 객체에 여러 스레드의 접근여부에 달렸다.
- 스레드가 하나 이상의 상태 변수에 접근하고 그 중 하나 이상의 변수에 값을 쓰면 , 해당 변수에 접근 할 때 관련된 모든 스레드가 동기화
조율 대상
JAVA의 동기화 수단
- synchronized 키워드
- volatile, 명시적인 lock, 단일 연산 변수(atomic variable)
! 여러 스레드가 변경 할 수 있는 상태 변수를 적절한 동기화 없이 접근한다면 문제가 발생한다.
해결방법
- 해당 상태 변수를 스레드간 공유 금지시킨다
- 해당 상태 변수를 변경 불가로 설정한다
- 해당 상태 변수에 언제나 동기화를 적용한다
좋은 해결 방법
- 객체지향에 입각하여 설계
- 캡슐화와 불변객체 활용
- 불변조건을 명확히 기술
해당 문제에 대해 쉽게 생각해보자.
a b c 스레드와 int 형 변수 i 가 있다.
a 스레드
i++
b스레드
i = i * i
c스레드
i = i / i
각 스레드를 동시에 3번씩 반복 시킬때 값은 어떻게 될까?
운이 좋다면 동일한 결과를 얻을 수 있겠지만 다른 결과 및 에러를 뱉어내는 것을 볼 수 있을 것이다.
abc 스레드가 동시접근 할 때 i가 10이라고 생각해보자.
a스레드가 접근할 때 i는 10이다.
i++를 적용하면 i는 11이 된다. 하지만 a스레드가 i라는 변수를 가져왔지만 i++ 적용하기 전에
b스레드에서 i라는 변수에 접근하면 어떻게 될까?
i=10 이라는 변경되기 전의 i 값을 가져오고 i = 100이라는 결과를 낼 것 이다.
설계 시 스레드 별 계산 결과가 합산 된 i를 생각했지만 스레드 안정성을 만족시키지 못하면 올바르지 않은 결과를 내고 데드락에 걸릴 수도 있다.
이것을 위해 원자 단위 연산을 생각해야 한다.
원자성( Atomicity )
@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
private long count = 0;
public long getCount() {
return count;
}
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
++count;
encodeIntoResponse(resp, factors);
}
}
위의 코드는 스레드 안정성을 충족하지 못한 상태이다.
여기서 ++count는 단일 연산이 아니다.
아래와 같은 로직을 거치며,
++count;
004134A5 mov eax, dword ptr [count]
004134A8 add eax, 1
004134AB mov dword ptr [count], eax
쉽게 표현하자면 이런 로직으로 실행된다.
경쟁 상태 (Race condition)
둘 이상의 조작이나 입력 등이 결과에 영향을 줄 수 있는 상태를 의미한다.
여러 스레드를 교차하는 시점에서,
i++의 연산 중 다른 스레드가 메모리에 결과값이 반영되지 않은 i를 가져간다면 경쟁 상태 문제가 발생한다.
또 다른 예제로,
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
싱글톤 생성 예제이다.
null 체크 과정에서 여러 스레드가 동시 접근한다면 싱글톤을 유지하지 못하고 각기 다른 스레드에서 인스턴스가 반환 될 수 있다.
@ThreadSafe
public class CountingFactorizer implements Servlet {
private final AtomicLong count = new AtomicLong(0);
public long getCount() {
return count.get();
}
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = factor(i);
count.incrementAndGet();
encodeIntoResponse(resp, factors);
}
}
가능하면 클래스 상태를 관리하기 위해 AtomicLong처럼 스레드에 안전하게 이미 만들어져 있는 객체를 사용하는 편이 좋다.
AtomicLong 이란?
Thread-safe로 구현되어 멀티쓰레드에서 synchronized 없이 사용할 수 있고
또한 synchronized 보다 적은 비용으로 동시성을 보장할 수 있다.
스레드에 안전하지 않은 상태 변수를 선언해두고 사용하는 것 보다 이미 스레드에 안전하게 만들어진 클래스의 상태 변화를 파악하는 것이
훨씬 쉽고 , 스레드 안정성을 유지하기에 좋다.
그런데 이런 Thread-safe 한 변수가 여러 개 있고, 그 변수들이 관련되어 있다면 어떻게 할까?
Loking
- 여러 개의 변수가 하나의 불변 조건을 구성하고 있다면, 이 변수들은 서로 독립적이지 않음.
- 변수 하나를 갱신할 땐, 다른 변수도 동일한 단일 연산 작업 내에서 함께 변경해야 함.
- 상태를 일관성 있게 유지하려면 관련 있는 변수들을 하나의 단일 연산으로 갱신해야 함.
@NotThreadSafe
public class UnsafeCachingFactorizer implements Servlet {
private final AtomicReference<BigInteger> lastNumber = new AtomicReference<BigInteger>();
private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber.get()))
encodeIntoResponse(resp, lastFactors.get());
else {
BigInteger[] factors = factor(i);
lastNumber.set(i);
lastFactors.set(factors);
encodeIntoResponse(resp, factors);
}
}
}
! AtomicReference 사용법
https://codechacha.com/ko/java-atomic-reference/
위의 코드에선 타이밍이 좋지 않다면, lastNumber와 lastFactor가 동시에 갱신하지 못한다.
이럴 때 Intrinsic Locks (Monitor) 를 사용 할 수 있다.
• Synchronized : 자바에는 단일 연산 특성을 보장하기 위한 락
메소드 선언 부분에 synchronized 키워드를 지정하면 메소드 내부의 코드 전체를 포함하면서 메소드가 포함된 클래스의 인스턴스를 락으로 사용하는 synchronized 블록을 간략하게 표현한 것으로 볼 수 있음
• static 으로 선언된 synchronized 메소드는 해당 class 객체를 락으로 사용
• 락을 사용 할 수 있는 객체 : 모든 자바 객체 • 획득과 해제
• 스레드가 synchronized 블록에 들어가기 전에 자동으로 확보
• 정상적으로든 예외가 발생해서든 해당 블록을 벗어날 때 자동으로 해제
• 해당 락으로 보호된 synchronized 블록이나 메소드에 들어가야만 암묵적인 락 확보
• 동작 방식 : 뮤텍스(mutexes) 또는 상호 배제 락(mutual exclusion lock)
@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
@GuardedBy("this")
private BigInteger lastNumber;
@GuardedBy("this")
private BigInteger[] lastFactors;
public synchronized void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber))
encodeIntoResponse(resp, lastFactors);
else {
BigInteger[] factors = factor(i);
lastNumber = i;
lastFactors = factors;
encodeIntoResponse(resp, factors);
}
}
}
위의 코드 대로라면 안전하기야 하겠지…만 여러 클라이언트가 동시에 사용할 수 없어서 성능 문제가 발생한다.
재진입성(Reentrancy)
스레드가 다른 스레드가 가진 락을 요청하면 대기 상태에 들어간다.
컴퓨터 프로그램에서 재진입성을 가진다 라는 것은, 병렬로 안전하게 실행이 가능하다는 의미이다.
즉 재진입이 가능하다는 것은 언제나 동일한 실행결과를 가져다 준다는 것을 의미하고 다음의 조건을 만족해야 한다.
- 정적 (전역) 변수를 가지고 있지 않아야 한다.
- 호출자가 호출 시 제공한 매개변수만으로 동작해야 한다.
- 싱글턴 객체의 잠금에 의존하지 않아야 한다.
- 다른 비-재진입 함수를 호출하지 않아야 한다.
public class Widget {
public synchronized void doSomething() {
...
}
}
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
System.out.println(toString() + ": calling doSomething");
super.doSomething();
}
}
위와 같은 코드가 있고, 암묵적인 락이 재진입이 불가능 했다면 데드락에 빠졌을 것이다.
쉽게 설명해보자.
1. A , B 두개의 스레드가 있다. A 스레드가 먼저 LoggingWidget 메서드에 접근하여 락을 확보한다.
2. A 스레드가 super.doSomething 으로 부모 메서드를 호출하고, 기존의 락을 반환하고 부모메서드인 Widget의 락을 확보한다.
3.그리고 이 타이밍에 B스레드가 LoggingWidget의 락을 확보한다.
4. A스레드가 다시 기존 메서드로 돌아가기 위해 LoggingWidget의 락을 확보하기 위해 대기한다.
하지만 이미 부모와 자식 메서드를 두개의 스레드가 모두 각각 락을 확보하고 있기 때문에 데드락이 발생한다.
그렇기에 병렬 프로그래밍에선 재진입성을 확보해야 하고, 재진입이 가능하다는 전제하에 프로그래밍을 해야 한다.
Lock
락 이란 자신이 보호하는 코드 경로에 여러 스레드가 순차적으로 접근하도록 하는 것이다.
특정 변수에 대한 접근을 조율하기 위해서는
- 해당 변수에 접근하는 모든 부분 동기화
- 해당 변수에 접근하는 모든 곳에서 반드시 같은 락을 사용
! 공유 변수에 값을 쓸 때만 동기화 하면 된다? x
- 여러 스레드에서 접근 할 수 있고 변경 가능한 모든 변수를 대상으로 해당 변수에 접근할 때는 항상 동일한 락을 먼저 확보한 상태여야 한다.
락은 대기표와 같다.
(모든 부분 ) 과 (모든 곳) 에서 락을 걸어야 하는 이유는 같은 로직에서 먼저 선언됐지만 늦게 처리되는 메서드의 변수가 처음 정의된 값과 다른 경우가 있을 수 있기 때문이다.
적절한 캡슐화를 통해 하나의 트랜잭션 내에서 하나의 락으로 묶어야 한다.
락 활용의 예
- 먼저 모든 변경 가능한 변수를 객체 안에 캡슐화
- 해당 객체의 암묵적인 락을 사용해 캡슐화한 변수에 접근하는 모든 코드 경로를 동기화
- 여러 스레드가 동시에 접근하는 상태에서 내부 변수를 보호
- 특정 변수가 락으로 보호되면, 한 번에 한 스레드만 해당 변수에 접근 할 수 있다는 점이 보장된다.
불변 조건에 대한 보호
- 여러 상태 변수에 대한 불변 조건이 있으면 각 변수는 모두 같은 락으로 보호
- 여러 변수에 대한 불변조건이 있으면 해당 변수들은 모두 같은 락으로 보호
모든 메서드를 동기화하면 활동성이나 성능에 문제가 발생한다.
안전성과 성능
단순하고 큰 단위로 동기화하면 안전성을 확보할 수는 있겠지만 자원소모가 너무 심해져 병렬 프로그래밍의 의미가 없어진다.
@ThreadSafe
public class SynchronizedFactorizer implements Servlet {
@GuardedBy("this")
private BigInteger lastNumber;
@GuardedBy("this")
private BigInteger[] lastFactors;
public synchronized void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
if (i.equals(lastNumber))
encodeIntoResponse(resp, lastFactors);
else {
BigInteger[] factors = factor(i);
lastNumber = i;
lastFactors = factors;
encodeIntoResponse(resp, factors);
}
}
}
위의 코드처럼 메서드 자체에 동기화를 건다면..
이렇게 하나의 메서드가 끝나야 다른 메서드가 호출되는 결과가 만들어진다.
@ThreadSafe
public class CachedFactorizer implements Servlet {
@GuardedBy("this")
private BigInteger lastNumber;
@GuardedBy("this")
private BigInteger[] lastFactors;
@GuardedBy("this")
private long hits;
@GuardedBy("this")
private long cacheHits;
public synchronized long getHits() {
return hits;
}
public synchronized double getCacheHitRatio() {
return (double) cacheHits / (double) hits;
}
}
public void service(ServletRequest req, ServletResponse resp) {
BigInteger i = extractFromRequest(req);
BigInteger[] factors = null;
synchronized (this) {
++hits;
if (i.equals(lastNumber)) {
++cacheHits;
factors = lastFactors.clone();
}
}
if (factors == null) {
factors = factor(i);
synchronized (this) {
lastNumber = i;
lastFactors = factors.clone();
}
}
encodeIntoResponse(resp, factors);
}
}
이렇게 적절히 synchronized ( 동기화 ) 블록의 범위를 줄이면 스레드 안전성 유지, 동시성 향상을 기대할 수 있으므로 적절한 크기
유지가 중요하다.
블록의 크기가 너무 작을 때
- 단일 연산으로 처리해야 하는 작업을 여러 개의 동기화 블록으로 나눈 행위
- 서로 다른 두가지 동기화 수단을 사용해 봐야 성능이나 안전성 측면의 이점이 없고 코드를 읽는 사람에게 혼동을 주는 등 코드를 의미없이 복잡하게 만든다.
- 락을 획득하는 작업도 어느정도의 자원을 소모하기 때문에 성능 저하의 우려가 있다.
java에서는 synchronized 키워드 등을 사용, 병렬 프로그래밍을 간단하게 구현 할 수 있도록 만들었지만,
사실 동기화나 락을 구현하는 것 자체도 어렵다...
메모리 할당을 직접 해주는 C 등에서 동기화를 직접 구현하는 부분을 찾아본다면 락을 획득하는 작업 또한 왜 자원을 소모하는지 알 수 밖에 없다.
블록의 크기가 너무 클 때
- 안전성,단순성,성능 등의 서로 상충하는 설계 원칙 사이에 적절한 타협이 이뤄지지 않는다
- 그러나 안전성은 절대 타협해서는 안된다.
!락 사용시 주의사항
- 동기화 블록 안의 코드가 무엇을 하는지, 수행하는 데 얼마나 걸릴지 파악하는 것이 중요하다.
- 계산량이 많은 작업을 하거나 잠재적으로 대기 상태에 들어 갈 수 있는 작업을 하느라 락을 오래 잡고 있으면 활동성이나 성능 문제를 야기한다.
- 락을 가능한 잡지 말아야 하는 작업 -> 빨리 끝나지 않을 수 있는 작업
- 복잡하고 오래걸리는 계산, 네트웍, 사용자 입출력 등..
락을 사용하여 동기화를 구현 할 때, 락으로 보호돼 있다는 사실 등을 @GuardedBy 와 같은 어노테이션으로 표시하곤 한다.
공유 상태에서 안전하게 접근할 수 있도록 락 규칙이나 동기화 정책을 만들고 그것을 일정하게 따르는 것은 개발자의 몫인데
여러 사람이 공유하는 프로그램은 만든다고 끝이 아니다.
후에 프로그램을 고도화 하거나, 유지 보수 하거나 , 커스터마이징 하거나 다른 사람들이 알 수 있게 명확히 표시해야 한다.
복잡한 병렬 프로그래밍에서 동기화 되어있는 부분을 잘 못 건드려서 락이 꼬여버린다면 정말 머리아픈 상황이 일어날 수 있겠다...
쉽게 생각해보면 현업에서의 협업 뿐만 아니라 작은 스터디를 할 때도 정해놓는 파일, 폴더 네이밍 규칙을 정하는 이유와 같다.
다음 포스팅은 구현을 해보려고 한다.
'JAVA > JAVA 병렬프로그래밍' 카테고리의 다른 글
JAVA 병렬 프로그래밍 [5] - 화장실 스케쥴링 (1) | 2022.06.06 |
---|---|
JAVA 병렬 프로그래밍 [4] - Sharing Objects ( 객체 공유 ) (0) | 2022.06.05 |
JAVA 병렬 프로그래밍 [3] - 멀티 스레드로 1-100까지의 합 구하기 (0) | 2022.06.05 |
JAVA 병렬 프로그래밍 [1] (0) | 2022.04.16 |